Skip to content

Commit d87ee3e

Browse files
committed
feat: enforce validation gates on phase/plan → resolved transition
Add internal/gate package that enforces delivery_completeness + gates checks for phases and plan_coverage for plans when transitioning to resolved status. Blocked transitions exit with code 2 and return EntityUpdateGateResponse JSON. --force flag bypasses the gate (requires --reason). Warnings are emitted to stderr and force=true is recorded in entity history. New files: - internal/gate/gate.go (Enforce entry point) - internal/gate/policy.go (transition → check mapping) - internal/gate/report.go (Report type) - internal/gate/gate_test.go (13 subtests) Modified: - internal/cli/entity.go (gate wiring + --force flag) - internal/cli/entity_test.go (5 integration tests) - internal/jsoncontract/responses.go (EntityUpdateGateResponse type)
1 parent c7a6d51 commit d87ee3e

7 files changed

Lines changed: 863 additions & 1 deletion

File tree

internal/cli/entity.go

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/spf13/cobra"
10+
"github.com/tyeongkim/spec-graph/internal/gate"
1011
"github.com/tyeongkim/spec-graph/internal/index"
1112
"github.com/tyeongkim/spec-graph/internal/jsoncontract"
1213
"github.com/tyeongkim/spec-graph/internal/model"
@@ -186,6 +187,8 @@ var entityUpdateCmd = &cobra.Command{
186187
handleError(cmd, err)
187188
}
188189

190+
var oldStatus model.EntityStatus
191+
189192
if cmd.Flags().Changed("title") {
190193
v, _ := cmd.Flags().GetString("title")
191194
ef.Title = v
@@ -195,6 +198,7 @@ var entityUpdateCmd = &cobra.Command{
195198
ef.Description = v
196199
}
197200
if cmd.Flags().Changed("status") {
201+
oldStatus = ef.Status
198202
v, _ := cmd.Flags().GetString("status")
199203
schema := spectoml.DefaultSchema()
200204
if err := schema.ValidateEntity(ef.ID, string(ef.Type), v); err != nil {
@@ -233,6 +237,87 @@ var entityUpdateCmd = &cobra.Command{
233237
ef.Metadata = meta
234238
}
235239

240+
// Gate enforcement
241+
forceFlag, _ := cmd.Flags().GetBool("force")
242+
if cmd.Flags().Changed("status") {
243+
target := gate.Target{
244+
EntityID: id,
245+
EntityType: ef.Type,
246+
FromStatus: oldStatus,
247+
ToStatus: ef.Status,
248+
}
249+
if policy := gate.LookupPolicy(target); policy != nil {
250+
efAdapter := &indexValidateEntityFetcher{idx: queryIndex}
251+
rfAdapter := &validateRelationAdapter{fetcher: &indexRelationFetcher{idx: queryIndex}}
252+
253+
report, err := gate.Enforce(target, rfAdapter, efAdapter)
254+
if err != nil {
255+
handleError(cmd, fmt.Errorf("gate enforce: %w", err))
256+
}
257+
258+
if report.Blocked {
259+
if forceFlag {
260+
reason, _ := cmd.Flags().GetString("reason")
261+
if reason == "" {
262+
handleError(cmd, &model.ErrInvalidInput{Message: "--force requires --reason"})
263+
}
264+
if len(report.Warnings) > 0 || len(report.BlockingIssues) > 0 {
265+
allIssues := append(report.BlockingIssues, report.Warnings...)
266+
warningOutput := make([]jsoncontract.ValidateIssue, len(allIssues))
267+
for i, issue := range allIssues {
268+
warningOutput[i] = jsoncontract.ValidateIssue{
269+
Check: issue.Check,
270+
Severity: string(issue.Severity),
271+
Entity: issue.Entity,
272+
Message: issue.Message,
273+
}
274+
}
275+
warningJSON, _ := json.Marshal(warningOutput)
276+
fmt.Fprintf(os.Stderr, "%s\n", warningJSON)
277+
}
278+
} else {
279+
issues := make([]jsoncontract.ValidateIssue, len(report.BlockingIssues))
280+
for i, issue := range report.BlockingIssues {
281+
issues[i] = jsoncontract.ValidateIssue{
282+
Check: issue.Check,
283+
Severity: string(issue.Severity),
284+
Entity: issue.Entity,
285+
Message: issue.Message,
286+
}
287+
}
288+
warnings := make([]jsoncontract.ValidateIssue, len(report.Warnings))
289+
for i, issue := range report.Warnings {
290+
warnings[i] = jsoncontract.ValidateIssue{
291+
Check: issue.Check,
292+
Severity: string(issue.Severity),
293+
Entity: issue.Entity,
294+
Message: issue.Message,
295+
}
296+
}
297+
bySeverity := make(map[string]int)
298+
for _, issue := range report.BlockingIssues {
299+
bySeverity[string(issue.Severity)]++
300+
}
301+
response := jsoncontract.EntityUpdateGateResponse{
302+
Blocked: true,
303+
EntityID: report.EntityID,
304+
EntityType: string(report.EntityType),
305+
FromStatus: string(report.FromStatus),
306+
ToStatus: string(report.ToStatus),
307+
Issues: issues,
308+
Warnings: warnings,
309+
Summary: jsoncontract.ValidateSummary{
310+
TotalIssues: len(report.BlockingIssues),
311+
BySeverity: bySeverity,
312+
},
313+
}
314+
writeJSON(cmd, response)
315+
os.Exit(2)
316+
}
317+
}
318+
}
319+
}
320+
236321
ef.UpdatedAt = time.Now()
237322

238323
if err := tomlStore.WriteEntity(ef); err != nil {
@@ -243,11 +328,16 @@ var entityUpdateCmd = &cobra.Command{
243328
actor, _ := cmd.Flags().GetString("actor")
244329
source, _ := cmd.Flags().GetString("source")
245330

331+
detail := source
332+
if forceFlag {
333+
detail = "force=true; " + detail
334+
}
335+
246336
if err := tomlStore.AppendHistory(id, spectoml.HistoryEntry{
247337
Action: model.ActionUpdate,
248338
Reason: reason,
249339
Actor: actor,
250-
Detail: source,
340+
Detail: detail,
251341
Timestamp: time.Now(),
252342
}); err != nil {
253343
handleError(cmd, fmt.Errorf("append history: %w", err))
@@ -469,6 +559,7 @@ func init() {
469559
entityUpdateCmd.Flags().String("reason", "", "reason for update")
470560
entityUpdateCmd.Flags().String("actor", "", "actor performing the change")
471561
entityUpdateCmd.Flags().String("source", "", "source of the change")
562+
entityUpdateCmd.Flags().Bool("force", false, "bypass gate checks (requires --reason)")
472563

473564
entityDeprecateCmd.Flags().String("reason", "", "reason for deprecation")
474565
entityDeprecateCmd.Flags().String("actor", "", "actor performing the change")

internal/cli/entity_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,3 +595,174 @@ func TestEntityFullLifecycle(t *testing.T) {
595595
t.Fatalf("expected exit 1 after delete, got %d", r.exitCode)
596596
}
597597
}
598+
599+
func setupGatedPhaseWithUnresolvedQuestion(t *testing.T, dir, dbFile string) {
600+
t.Helper()
601+
cmds := [][]string{
602+
{"--db", dbFile, "entity", "add", "--type", "phase", "--id", "PHS-001", "--title", "Phase 1", "--status", "active"},
603+
{"--db", dbFile, "entity", "add", "--type", "question", "--id", "QST-001", "--title", "Unresolved question", "--status", "active"},
604+
{"--db", dbFile, "relation", "add", "--from", "PHS-001", "--to", "QST-001", "--type", "covers"},
605+
}
606+
for _, args := range cmds {
607+
r := runCLI(t, dir, args...)
608+
if r.exitCode != 0 {
609+
t.Fatalf("setup failed (%v): exit=%d stderr=%s", args, r.exitCode, r.stderr)
610+
}
611+
}
612+
}
613+
614+
func TestGateBlocksPhaseResolution(t *testing.T) {
615+
dbFile := initTestProject(t)
616+
dir := t.TempDir()
617+
setupGatedPhaseWithUnresolvedQuestion(t, dir, dbFile)
618+
619+
r := runCLI(t, dir, "--db", dbFile, "entity", "update", "PHS-001", "--status", "resolved")
620+
if r.exitCode != 2 {
621+
t.Fatalf("expected exit 2 (gate blocked), got %d; stdout=%s stderr=%s", r.exitCode, r.stdout, r.stderr)
622+
}
623+
624+
var resp jsoncontract.EntityUpdateGateResponse
625+
if err := json.Unmarshal([]byte(r.stdout), &resp); err != nil {
626+
t.Fatalf("unmarshal gate response: %v\nraw: %s", err, r.stdout)
627+
}
628+
if !resp.Blocked {
629+
t.Error("expected blocked=true")
630+
}
631+
if resp.EntityID != "PHS-001" {
632+
t.Errorf("entity_id = %q; want PHS-001", resp.EntityID)
633+
}
634+
if resp.EntityType != "phase" {
635+
t.Errorf("entity_type = %q; want phase", resp.EntityType)
636+
}
637+
if resp.FromStatus != "active" {
638+
t.Errorf("from_status = %q; want active", resp.FromStatus)
639+
}
640+
if resp.ToStatus != "resolved" {
641+
t.Errorf("to_status = %q; want resolved", resp.ToStatus)
642+
}
643+
if len(resp.Issues) == 0 {
644+
t.Fatal("expected at least one blocking issue")
645+
}
646+
foundGatesIssue := false
647+
for _, iss := range resp.Issues {
648+
if iss.Check == "gates" && iss.Entity == "QST-001" {
649+
foundGatesIssue = true
650+
break
651+
}
652+
}
653+
if !foundGatesIssue {
654+
t.Errorf("expected gates issue for QST-001; got issues: %+v", resp.Issues)
655+
}
656+
if resp.Summary.TotalIssues < 1 {
657+
t.Errorf("summary.total_issues = %d; want >= 1", resp.Summary.TotalIssues)
658+
}
659+
}
660+
661+
func TestGateForceBypassWithReason(t *testing.T) {
662+
dbFile := initTestProject(t)
663+
dir := t.TempDir()
664+
setupGatedPhaseWithUnresolvedQuestion(t, dir, dbFile)
665+
666+
r := runCLI(t, dir, "--db", dbFile, "entity", "update", "PHS-001",
667+
"--status", "resolved", "--force", "--reason", "override for testing")
668+
if r.exitCode != 0 {
669+
t.Fatalf("expected exit 0 (force bypass), got %d; stdout=%s stderr=%s", r.exitCode, r.stdout, r.stderr)
670+
}
671+
672+
var resp jsoncontract.EntityResponse
673+
if err := json.Unmarshal([]byte(r.stdout), &resp); err != nil {
674+
t.Fatalf("unmarshal entity response: %v\nraw: %s", err, r.stdout)
675+
}
676+
if resp.Entity.ID != "PHS-001" {
677+
t.Errorf("entity.id = %q; want PHS-001", resp.Entity.ID)
678+
}
679+
if resp.Entity.Status != model.EntityStatusResolved {
680+
t.Errorf("entity.status = %q; want resolved", resp.Entity.Status)
681+
}
682+
683+
if r.stderr == "" {
684+
t.Fatal("expected warnings on stderr")
685+
}
686+
var warnings []jsoncontract.ValidateIssue
687+
if err := json.Unmarshal([]byte(r.stderr), &warnings); err != nil {
688+
t.Fatalf("unmarshal stderr warnings: %v\nraw: %s", err, r.stderr)
689+
}
690+
if len(warnings) == 0 {
691+
t.Error("expected at least one warning in stderr")
692+
}
693+
}
694+
695+
func TestGateForceWithoutReasonFails(t *testing.T) {
696+
dbFile := initTestProject(t)
697+
dir := t.TempDir()
698+
setupGatedPhaseWithUnresolvedQuestion(t, dir, dbFile)
699+
700+
r := runCLI(t, dir, "--db", dbFile, "entity", "update", "PHS-001",
701+
"--status", "resolved", "--force")
702+
if r.exitCode != 3 {
703+
t.Fatalf("expected exit 3 (INVALID_INPUT), got %d; stdout=%s stderr=%s", r.exitCode, r.stdout, r.stderr)
704+
}
705+
706+
var errResp jsoncontract.ErrorResponse
707+
if err := json.Unmarshal([]byte(r.stderr), &errResp); err != nil {
708+
t.Fatalf("unmarshal error response: %v\nraw: %s", err, r.stderr)
709+
}
710+
if errResp.Error.Code != "INVALID_INPUT" {
711+
t.Errorf("error.code = %q; want INVALID_INPUT", errResp.Error.Code)
712+
}
713+
}
714+
715+
func TestGateNotAppliedForNonResolvedTransition(t *testing.T) {
716+
dbFile := initTestProject(t)
717+
dir := t.TempDir()
718+
719+
setupGatedPhaseWithUnresolvedQuestion(t, dir, dbFile)
720+
721+
r := runCLI(t, dir, "--db", dbFile, "entity", "update", "PHS-001", "--status", "draft")
722+
if r.exitCode != 0 {
723+
t.Fatalf("expected exit 0 for non-gated transition, got %d; stdout=%s stderr=%s", r.exitCode, r.stdout, r.stderr)
724+
}
725+
726+
var resp jsoncontract.EntityResponse
727+
if err := json.Unmarshal([]byte(r.stdout), &resp); err != nil {
728+
t.Fatalf("unmarshal: %v\nraw: %s", err, r.stdout)
729+
}
730+
if resp.Entity.Status != model.EntityStatusDraft {
731+
t.Errorf("status = %q; want draft", resp.Entity.Status)
732+
}
733+
}
734+
735+
func TestGatePassesWhenNoIssues(t *testing.T) {
736+
dbFile := initTestProject(t)
737+
dir := t.TempDir()
738+
739+
cmds := [][]string{
740+
{"--db", dbFile, "entity", "add", "--type", "phase", "--id", "PHS-001", "--title", "Phase 1", "--status", "active"},
741+
{"--db", dbFile, "entity", "add", "--type", "question", "--id", "QST-001", "--title", "Resolved question", "--status", "active"},
742+
{"--db", dbFile, "entity", "add", "--type", "decision", "--id", "DEC-001", "--title", "Answer", "--status", "active"},
743+
{"--db", dbFile, "relation", "add", "--from", "PHS-001", "--to", "QST-001", "--type", "covers"},
744+
{"--db", dbFile, "relation", "add", "--from", "DEC-001", "--to", "QST-001", "--type", "answers"},
745+
}
746+
for _, args := range cmds {
747+
r := runCLI(t, dir, args...)
748+
if r.exitCode != 0 {
749+
t.Fatalf("setup failed (%v): exit=%d stderr=%s", args, r.exitCode, r.stderr)
750+
}
751+
}
752+
753+
r := runCLI(t, dir, "--db", dbFile, "entity", "update", "PHS-001", "--status", "resolved")
754+
if r.exitCode != 0 {
755+
t.Fatalf("expected exit 0 (gate passes), got %d; stdout=%s stderr=%s", r.exitCode, r.stdout, r.stderr)
756+
}
757+
758+
var resp jsoncontract.EntityResponse
759+
if err := json.Unmarshal([]byte(r.stdout), &resp); err != nil {
760+
t.Fatalf("unmarshal: %v\nraw: %s", err, r.stdout)
761+
}
762+
if resp.Entity.ID != "PHS-001" {
763+
t.Errorf("entity.id = %q; want PHS-001", resp.Entity.ID)
764+
}
765+
if resp.Entity.Status != model.EntityStatusResolved {
766+
t.Errorf("entity.status = %q; want resolved", resp.Entity.Status)
767+
}
768+
}

0 commit comments

Comments
 (0)