devrites 4.4.0 → 4.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,13 +12,13 @@ workflow strategy.
12
12
  | `update [flags]` | Refresh an existing managed installation. |
13
13
  | `uninstall [flags]` | Remove managed artifacts while preserving runtime workspace state. |
14
14
  | `check candidate <slug>` | Validate the strict manifest and compute the content-bound project-candidate identity. |
15
- | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
15
+ | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, the `tasks.md` slice graph when that artifact is required, and the current stable Build-input binding when applicable. |
16
16
  | `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
17
- | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
18
- | `check path-disjoint` with optional `--root DIR` and optional `JSON-FILE` or `-` | Verify slice path sets are pairwise disjoint. |
19
- | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and unknown dependencies. |
17
+ | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the `tasks.md` slice graph, the stable Build-input binding, and exact candidate bindings. |
18
+ | `check path-disjoint [--root <dir>] [<json-file> | -]` | Verify slice path sets are pairwise disjoint. |
19
+ | `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles, unknown deps, malformed tokens, duplicate IDs, missing `Dependencies`, and `depends_on` mismatch. |
20
20
  | `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
21
- | `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. |
21
+ | `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. `task_graph.ok` is true iff `task_graph.problems` is empty; `problems` lists cycle, unknown-dep, malformed-token, duplicate-id, and missing-`Dependencies` blockers. |
22
22
  | `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
23
23
  | `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
24
24
  | `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
@@ -36,8 +36,9 @@ not to the engine command namespace.
36
36
  The candidate gate validates and hashes path/state/type/mode/content identity;
37
37
  it does not infer scope from Git. The readiness gate checks target-Phase
38
38
  structure and applies open-question blocking only when that target is Clarify
39
- or later, plus the exact stable Build-input binding after Vet. The seal gate
40
- always targets Phase `seal`, repeats that binding, and checks exact candidate
39
+ or later, plus the exact `tasks.md` slice graph when `tasks.md` is required,
40
+ plus the exact stable Build-input binding after Vet. The seal gate
41
+ always targets Phase `seal`, repeats that graph and binding, and checks exact candidate
41
42
  bindings in evidence, optional browser evidence, review, and seal. None judges
42
43
  the meaning of `CLEAR`/`READY` prose,
43
44
  parses reviewer narratives, infers acceptance coverage, counts assertions,
@@ -166,11 +166,11 @@ The engine provides only:
166
166
  - `check candidate`: strict candidate-manifest validation and content-bound
167
167
  identity;
168
168
  - `check readiness`: phase-relative file completeness, open-human-gate check,
169
- and the stable vetted Build-input binding whenever `eng-review.md` is
170
- required;
171
- - `check seal`: final file completeness and open-human-gate checks, then the
172
- readiness-binding recheck, then exact candidate bindings after that aggregate
173
- gate passes;
169
+ the `tasks.md` slice graph when that artifact is required, and the stable
170
+ vetted Build-input binding whenever `eng-review.md` is required;
171
+ - `check seal`: final file completeness and open-human-gate checks, the
172
+ `tasks.md` slice graph, then the readiness-binding recheck, then exact
173
+ candidate bindings after that aggregate gate passes;
174
174
  - atomic `state resolve` answer/drop/batch and transactional `state close`;
175
175
  - secret scanning, version reporting, and local install lifecycle primitives.
176
176
 
@@ -94,6 +94,15 @@ func checkObservation(kind Kind, observation *state.WorkspaceObservation) (*Resu
94
94
  blocked = true
95
95
  }
96
96
  }
97
+ if len(missingFiles) == 0 && phaseRequiresTasks(policy) {
98
+ if fact, ok := observation.Fact("tasks.md"); ok && fact.State() == state.ArtifactPresent {
99
+ graph := state.ParseTaskGraph(fact.Bytes())
100
+ for _, problem := range graph.Problems {
101
+ stateProblems = append(stateProblems, "task-graph: "+problem)
102
+ blocked = true
103
+ }
104
+ }
105
+ }
97
106
  if len(missingFiles) == 0 && phaseRequiresReadinessBinding(policy) {
98
107
  expected, bindingErr := verifyReadinessBinding(observation)
99
108
  if bindingErr != nil {
@@ -12,6 +12,114 @@ import (
12
12
  "github.com/devrites/devrites/internal/testutil"
13
13
  )
14
14
 
15
+ func TestCheckBlocksCyclicTaskGraphWhenTasksAreRequired(t *testing.T) {
16
+ root := t.TempDir()
17
+ workspace := writeReadinessFixture(t, root, "cyclic", "build")
18
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
19
+
20
+ ## SLICE-001 A
21
+ Dependencies: SLICE-002
22
+
23
+ ## SLICE-002 B
24
+ Dependencies: SLICE-001
25
+ `)
26
+ binding := mustReadinessBinding(t, root, "cyclic")
27
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
28
+
29
+ res, err := Check(Readiness, root, "cyclic")
30
+ if err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ if !res.Blocked || res.ReasonID != reason.GateReadinessMissing {
34
+ t.Fatalf("blocked=%v reason=%q, want blocked missing", res.Blocked, res.ReasonID)
35
+ }
36
+ joined := strings.Join(res.StateProblems, "\n")
37
+ if !strings.Contains(joined, "task-graph: dependency cycle:") {
38
+ t.Fatalf("StateProblems=%q", joined)
39
+ }
40
+ if !strings.Contains(res.Render(), "result: blocked (state invariant)") {
41
+ t.Fatalf("Render()=\n%s", res.Render())
42
+ }
43
+ }
44
+
45
+ func TestCheckBlocksCyclicTaskGraphAtSeal(t *testing.T) {
46
+ root := t.TempDir()
47
+ writeCompleteGateFeature(t, root, "cyclic-seal", state.PhaseSeal, state.PhaseSeal, "none\n")
48
+ workspace := filepath.Join(root, "work", "cyclic-seal")
49
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
50
+
51
+ ## SLICE-001 A
52
+ Dependencies: SLICE-002
53
+
54
+ ## SLICE-002 B
55
+ Dependencies: SLICE-001
56
+ `)
57
+ binding := mustReadinessBinding(t, root, "cyclic-seal")
58
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
59
+
60
+ res, err := Check(Seal, root, "cyclic-seal")
61
+ if err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ if !res.Blocked || res.ReasonID != reason.GateSealMissing {
65
+ t.Fatalf("blocked=%v reason=%q, want blocked %s", res.Blocked, res.ReasonID, reason.GateSealMissing)
66
+ }
67
+ joined := strings.Join(res.StateProblems, "\n")
68
+ if !strings.Contains(joined, "task-graph: dependency cycle:") {
69
+ t.Fatalf("StateProblems=%q", joined)
70
+ }
71
+ if !strings.Contains(res.Render(), "result: blocked (state invariant)") {
72
+ t.Fatalf("Render()=\n%s", res.Render())
73
+ }
74
+ }
75
+
76
+ func TestCheckBlocksMalformedTaskGraphInsteadOfDroppingTokens(t *testing.T) {
77
+ root := t.TempDir()
78
+ workspace := writeReadinessFixture(t, root, "malformed", "define")
79
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
80
+
81
+ ## SLICE-001 Ready
82
+ Dependencies: none
83
+
84
+ ## SLICE-002 Next
85
+ Dependencies: SLICE-001 and later
86
+ `)
87
+
88
+ res, err := Check(Readiness, root, "malformed")
89
+ if err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ if !res.Blocked {
93
+ t.Fatal("expected malformed dependency to block readiness")
94
+ }
95
+ joined := strings.Join(res.StateProblems, "\n")
96
+ if !strings.Contains(joined, `malformed dependency "and"`) || !strings.Contains(joined, `malformed dependency "later"`) {
97
+ t.Fatalf("StateProblems=%q", joined)
98
+ }
99
+ }
100
+
101
+ func TestCheckBlocksMissingDependenciesInsteadOfTreatingSliceAsIndependent(t *testing.T) {
102
+ root := t.TempDir()
103
+ workspace := writeReadinessFixture(t, root, "nodeps", "define")
104
+ testutil.WriteFile(t, filepath.Join(workspace, "tasks.md"), `# Tasks
105
+
106
+ ## SLICE-001 Ready
107
+ Goal: looks complete without an ordering field
108
+ `)
109
+
110
+ res, err := Check(Readiness, root, "nodeps")
111
+ if err != nil {
112
+ t.Fatal(err)
113
+ }
114
+ if !res.Blocked {
115
+ t.Fatal("expected missing Dependencies to block readiness")
116
+ }
117
+ joined := strings.Join(res.StateProblems, "\n")
118
+ if !strings.Contains(joined, "SLICE-001 is missing Dependencies") {
119
+ t.Fatalf("StateProblems=%q", joined)
120
+ }
121
+ }
122
+
15
123
  func TestCheckAndRenderReadiness(t *testing.T) {
16
124
  root := t.TempDir()
17
125
  writeFeature(t, root, "alpha", map[string]string{
@@ -505,6 +613,8 @@ func writeCompleteGateFeature(t *testing.T, root, slug string, current, required
505
613
  case "questions.md":
506
614
  questionsRequired = true
507
615
  content = questions
616
+ case "tasks.md":
617
+ content = testutil.CanonicalTasksMarkdown
508
618
  }
509
619
  testutil.WriteFile(t, filepath.Join(root, "work", slug, name), content)
510
620
  }
@@ -148,6 +148,15 @@ func readinessDiagnosticError(diagnostic state.ArtifactDiagnostic) error {
148
148
  return errors.New(prefix + repair)
149
149
  }
150
150
 
151
+ func phaseRequiresTasks(policy state.PhasePolicy) bool {
152
+ for _, artifact := range policy.RequiredArtifacts {
153
+ if artifact == "tasks.md" {
154
+ return true
155
+ }
156
+ }
157
+ return false
158
+ }
159
+
151
160
  func phaseRequiresReadinessBinding(policy state.PhasePolicy) bool {
152
161
  for _, artifact := range policy.RequiredArtifacts {
153
162
  if artifact == "eng-review.md" {
@@ -91,7 +91,7 @@ func TestReadinessBindingBindsOnlyStableBuildInputs(t *testing.T) {
91
91
  func TestReadinessBindingGoldenDigest(t *testing.T) {
92
92
  root := t.TempDir()
93
93
  writeReadinessFixture(t, root, "golden", "build")
94
- const want = "Readiness inputs SHA-256: d84b9050bd8db742c6a379a966bd7457cde04a69d6811130817729776d976ebe"
94
+ const want = "Readiness inputs SHA-256: c4a073e85373f5fd9f9302c61b6772e766e4fa2a3da2ccc77bad23756c9f412d"
95
95
  if got := mustReadinessBinding(t, root, "golden"); got != want {
96
96
  t.Fatalf("ReadinessBinding()=%q, want %q", got, want)
97
97
  }
@@ -374,7 +374,7 @@ func writeReadinessFixture(t *testing.T, root, slug, phase string) string {
374
374
  "decision-coverage.md": "# Decision coverage\n\nCLEAR\n",
375
375
  "architecture.md": "# Architecture\n\nReady.\n",
376
376
  "plan.md": "# Plan\n\nReady.\n",
377
- "tasks.md": "# Tasks\n\nReady.\n",
377
+ "tasks.md": testutil.CanonicalTasksMarkdown,
378
378
  "traceability.md": "# Traceability\n\nReady.\n",
379
379
  "eng-review.md": "# Engineering review\n\nREADY\n",
380
380
  "test-plan.md": "# Test plan\n\nReady.\n",
@@ -7,21 +7,25 @@ import (
7
7
  "github.com/devrites/devrites/internal/state"
8
8
  )
9
9
 
10
+ // ObserveTaskGraph is the slice-graph subset of ObserveSummary.
11
+ type ObserveTaskGraph struct {
12
+ SliceCount int `json:"slice_count"`
13
+ Cycle []string `json:"cycle,omitempty"`
14
+ Unknown []string `json:"unknown_dependencies,omitempty"`
15
+ Problems []string `json:"problems,omitempty"`
16
+ OK bool `json:"ok"`
17
+ }
18
+
10
19
  // ObserveSummary is a sanitized, machine-readable workspace snapshot.
11
20
  type ObserveSummary struct {
12
- Slug string `json:"slug"`
13
- Phase string `json:"phase,omitempty"`
14
- Status string `json:"status,omitempty"`
15
- NextAction string `json:"next_action,omitempty"`
16
- MissingSections []string `json:"missing_sections,omitempty"`
17
- MissingFiles []string `json:"missing_files,omitempty"`
18
- PrinciplesPresent bool `json:"principles_present"`
19
- TaskGraph *struct {
20
- SliceCount int `json:"slice_count"`
21
- Cycle []string `json:"cycle,omitempty"`
22
- Unknown []string `json:"unknown_dependencies,omitempty"`
23
- OK bool `json:"ok"`
24
- } `json:"task_graph,omitempty"`
21
+ Slug string `json:"slug"`
22
+ Phase string `json:"phase,omitempty"`
23
+ Status string `json:"status,omitempty"`
24
+ NextAction string `json:"next_action,omitempty"`
25
+ MissingSections []string `json:"missing_sections,omitempty"`
26
+ MissingFiles []string `json:"missing_files,omitempty"`
27
+ PrinciplesPresent bool `json:"principles_present"`
28
+ TaskGraph *ObserveTaskGraph `json:"task_graph,omitempty"`
25
29
  }
26
30
 
27
31
  // ObserveSummaryFor builds a summary for one feature slug.
@@ -40,16 +44,12 @@ func ObserveSummaryFor(root, slug string) (ObserveSummary, error) {
40
44
  MissingSections: missingSectionNames(report.Missing),
41
45
  }
42
46
 
43
- if graph, graphErr := CheckTaskGraph(root, slug); graphErr == nil && len(graph.Slices) > 0 {
44
- summary.TaskGraph = &struct {
45
- SliceCount int `json:"slice_count"`
46
- Cycle []string `json:"cycle,omitempty"`
47
- Unknown []string `json:"unknown_dependencies,omitempty"`
48
- OK bool `json:"ok"`
49
- }{
47
+ if graph, graphErr := CheckTaskGraph(root, slug); graphErr == nil && (len(graph.Slices) > 0 || len(graph.Problems) > 0) {
48
+ summary.TaskGraph = &ObserveTaskGraph{
50
49
  SliceCount: len(graph.Slices),
51
- Cycle: graph.Cycle,
52
- Unknown: graph.Unknown,
50
+ Cycle: append([]string(nil), graph.Cycle...),
51
+ Unknown: append([]string(nil), graph.Unknown...),
52
+ Problems: append([]string(nil), graph.Problems...),
53
53
  OK: len(graph.Problems) == 0,
54
54
  }
55
55
  }
@@ -1,155 +1,16 @@
1
1
  package lib
2
2
 
3
- import (
4
- "fmt"
5
- "regexp"
6
- "strings"
7
- )
3
+ import "github.com/devrites/devrites/internal/state"
8
4
 
9
5
  // TaskSlice is one SLICE-### block parsed from tasks.md.
10
- type TaskSlice struct {
11
- ID string
12
- Dependencies []string
13
- }
6
+ type TaskSlice = state.TaskSlice
14
7
 
15
8
  // TaskGraphResult is the deterministic outcome of parsing a tasks.md slice graph.
16
- type TaskGraphResult struct {
17
- Slices []TaskSlice
18
- Cycle []string // empty when acyclic
19
- Unknown []string // dependency ids with no defining slice
20
- Problems []string // human-readable blockers
21
- }
22
-
23
- var (
24
- sliceHeaderRE = regexp.MustCompile(`(?m)^##\s+(SLICE-\d+)\b`)
25
- dependenciesRE = regexp.MustCompile(`(?m)^Dependencies:\s*(.+)\s*$`)
26
- sliceIDValidRE = regexp.MustCompile(`^SLICE-\d+$`)
27
- )
9
+ type TaskGraphResult = state.TaskGraphResult
28
10
 
29
11
  // ParseTaskGraph reads tasks.md content and validates the slice dependency DAG.
30
12
  func ParseTaskGraph(tasksMarkdown []byte) TaskGraphResult {
31
- text := string(tasksMarkdown)
32
- result := TaskGraphResult{}
33
- if strings.TrimSpace(text) == "" {
34
- result.Problems = append(result.Problems, "tasks.md is empty")
35
- return result
36
- }
37
-
38
- headers := sliceHeaderRE.FindAllStringSubmatchIndex(text, -1)
39
- if len(headers) == 0 {
40
- result.Problems = append(result.Problems, "no SLICE-### sections found")
41
- return result
42
- }
43
-
44
- known := make(map[string]bool)
45
- for _, match := range headers {
46
- id := text[match[2]:match[3]]
47
- known[id] = true
48
- }
49
-
50
- for i, match := range headers {
51
- id := text[match[2]:match[3]]
52
- start := match[1]
53
- end := len(text)
54
- if i+1 < len(headers) {
55
- end = headers[i+1][0]
56
- }
57
- block := text[start:end]
58
- deps := parseSliceDependencies(block)
59
- result.Slices = append(result.Slices, TaskSlice{ID: id, Dependencies: deps})
60
- for _, dep := range deps {
61
- if !known[dep] {
62
- result.Unknown = append(result.Unknown, dep)
63
- result.Problems = append(result.Problems, fmt.Sprintf("%s depends on unknown slice %s", id, dep))
64
- }
65
- }
66
- }
67
-
68
- if cycle := findTaskCycle(result.Slices); len(cycle) > 0 {
69
- result.Cycle = cycle
70
- result.Problems = append(result.Problems, "dependency cycle: "+strings.Join(cycle, " -> "))
71
- }
72
- return result
73
- }
74
-
75
- func parseSliceDependencies(block string) []string {
76
- match := dependenciesRE.FindStringSubmatch(block)
77
- if len(match) < 2 {
78
- return nil
79
- }
80
- raw := strings.TrimSpace(match[1])
81
- if raw == "" || strings.EqualFold(raw, "none") {
82
- return nil
83
- }
84
- parts := strings.FieldsFunc(raw, func(r rune) bool {
85
- return r == ',' || r == ';'
86
- })
87
- var deps []string
88
- seen := make(map[string]bool)
89
- for _, part := range parts {
90
- id := strings.TrimSpace(part)
91
- if id == "" {
92
- continue
93
- }
94
- if !sliceIDValidRE.MatchString(id) {
95
- continue
96
- }
97
- if seen[id] {
98
- continue
99
- }
100
- seen[id] = true
101
- deps = append(deps, id)
102
- }
103
- return deps
104
- }
105
-
106
- func findTaskCycle(slices []TaskSlice) []string {
107
- graph := make(map[string][]string, len(slices))
108
- for _, slice := range slices {
109
- graph[slice.ID] = append([]string(nil), slice.Dependencies...)
110
- }
111
- visited := make(map[string]uint8, len(slices))
112
- stack := make([]string, 0, len(slices))
113
- var cycle []string
114
-
115
- var visit func(id string) bool
116
- visit = func(id string) bool {
117
- switch visited[id] {
118
- case 2:
119
- return false
120
- case 1:
121
- for i := len(stack) - 1; i >= 0; i-- {
122
- cycle = append(cycle, stack[i])
123
- if stack[i] == id {
124
- break
125
- }
126
- }
127
- for i, j := 0, len(cycle)-1; i < j; i, j = i+1, j-1 {
128
- cycle[i], cycle[j] = cycle[j], cycle[i]
129
- }
130
- return true
131
- }
132
- visited[id] = 1
133
- stack = append(stack, id)
134
- for _, dep := range graph[id] {
135
- if visit(dep) {
136
- return true
137
- }
138
- }
139
- stack = stack[:len(stack)-1]
140
- visited[id] = 2
141
- return false
142
- }
143
-
144
- for _, slice := range slices {
145
- if visited[slice.ID] == 0 {
146
- cycle = nil
147
- if visit(slice.ID) {
148
- return cycle
149
- }
150
- }
151
- }
152
- return nil
13
+ return state.ParseTaskGraph(tasksMarkdown)
153
14
  }
154
15
 
155
16
  // CheckTaskGraph validates tasks.md for a feature workspace slug.
@@ -1,6 +1,7 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "os"
5
6
  "path/filepath"
6
7
  "strings"
@@ -52,6 +53,127 @@ Dependencies: SLICE-999
52
53
  }
53
54
  }
54
55
 
56
+ func TestParseTaskGraphRejectsMalformedDependencyTokens(t *testing.T) {
57
+ tasks := `# Tasks
58
+
59
+ ## SLICE-001 A
60
+ Dependencies: none
61
+
62
+ ## SLICE-002 B
63
+ Dependencies: SLICE-001 and slice-003
64
+ `
65
+ graph := ParseTaskGraph([]byte(tasks))
66
+ if len(graph.Problems) == 0 {
67
+ t.Fatal("expected malformed dependency problem")
68
+ }
69
+ joined := strings.Join(graph.Problems, "\n")
70
+ if !strings.Contains(joined, `malformed dependency "and"`) || !strings.Contains(joined, `malformed dependency "slice-003"`) {
71
+ t.Fatalf("problems=%v", graph.Problems)
72
+ }
73
+ }
74
+
75
+ func TestParseTaskGraphAcceptsWhitespaceSeparatedDependencies(t *testing.T) {
76
+ tasks := `# Tasks
77
+
78
+ ## SLICE-001 A
79
+ Dependencies: none
80
+
81
+ ## SLICE-002 B
82
+ Dependencies: none
83
+
84
+ ## SLICE-003 C
85
+ Dependencies: SLICE-001 SLICE-002
86
+ `
87
+ graph := ParseTaskGraph([]byte(tasks))
88
+ if len(graph.Problems) != 0 {
89
+ t.Fatalf("problems=%v", graph.Problems)
90
+ }
91
+ if len(graph.Slices) != 3 || len(graph.Slices[2].Dependencies) != 2 {
92
+ t.Fatalf("slices=%+v", graph.Slices)
93
+ }
94
+ }
95
+
96
+ func TestParseTaskGraphRejectsDuplicateSliceIDs(t *testing.T) {
97
+ tasks := `# Tasks
98
+
99
+ ## SLICE-001 A
100
+ Dependencies: none
101
+
102
+ ## SLICE-002 B
103
+ Dependencies: SLICE-001
104
+
105
+ ## SLICE-001 Duplicate
106
+ Dependencies: none
107
+ `
108
+ graph := ParseTaskGraph([]byte(tasks))
109
+ joined := strings.Join(graph.Problems, "\n")
110
+ if !strings.Contains(joined, "duplicate slice id SLICE-001") {
111
+ t.Fatalf("problems=%v", graph.Problems)
112
+ }
113
+ if len(graph.Slices) != 2 {
114
+ t.Fatalf("slices=%d, want first-occurrence only", len(graph.Slices))
115
+ }
116
+ }
117
+
118
+ func TestParseTaskGraphRejectsDependsOnMismatch(t *testing.T) {
119
+ tasks := `# Tasks
120
+
121
+ ## SLICE-001 A
122
+ Dependencies: none
123
+
124
+ ## SLICE-002 B
125
+ Dependencies: SLICE-001
126
+ depends_on: []
127
+ `
128
+ graph := ParseTaskGraph([]byte(tasks))
129
+ joined := strings.Join(graph.Problems, "\n")
130
+ if !strings.Contains(joined, "SLICE-002 Dependencies and depends_on sets differ") {
131
+ t.Fatalf("problems=%v", graph.Problems)
132
+ }
133
+ }
134
+
135
+ func TestParseTaskGraphRejectsMissingDependencies(t *testing.T) {
136
+ tasks := `# Tasks
137
+
138
+ ## SLICE-001 A
139
+ Goal: silent independent slice
140
+ `
141
+ graph := ParseTaskGraph([]byte(tasks))
142
+ joined := strings.Join(graph.Problems, "\n")
143
+ if !strings.Contains(joined, "SLICE-001 is missing Dependencies") {
144
+ t.Fatalf("problems=%v", graph.Problems)
145
+ }
146
+ }
147
+
148
+ func TestParseTaskGraphAllowsDependsOnWithoutDependenciesLine(t *testing.T) {
149
+ tasks := `# Tasks
150
+
151
+ ## SLICE-001 A
152
+ depends_on: []
153
+ `
154
+ graph := ParseTaskGraph([]byte(tasks))
155
+ if len(graph.Problems) != 0 {
156
+ t.Fatalf("problems=%v", graph.Problems)
157
+ }
158
+ }
159
+
160
+ func TestParseTaskGraphAllowsMatchingDependsOnMirror(t *testing.T) {
161
+ tasks := `# Tasks
162
+
163
+ ## SLICE-001 A
164
+ Dependencies: none
165
+ depends_on: []
166
+
167
+ ## SLICE-002 B
168
+ Dependencies: SLICE-001
169
+ depends_on: [SLICE-001]
170
+ `
171
+ graph := ParseTaskGraph([]byte(tasks))
172
+ if len(graph.Problems) != 0 {
173
+ t.Fatalf("problems=%v", graph.Problems)
174
+ }
175
+ }
176
+
55
177
  func TestCheckTaskGraphWorkspace(t *testing.T) {
56
178
  root := filepath.Join(t.TempDir(), ".devrites")
57
179
  workspace := filepath.Join(root, "work", "feature")
@@ -126,6 +248,87 @@ func TestObserveSummaryForGolden(t *testing.T) {
126
248
  if summary.Phase == "" {
127
249
  t.Fatalf("summary=%+v", summary)
128
250
  }
251
+ if summary.TaskGraph == nil || !summary.TaskGraph.OK || len(summary.TaskGraph.Problems) != 0 {
252
+ t.Fatalf("task_graph=%+v, want ok with no problems", summary.TaskGraph)
253
+ }
254
+ }
255
+
256
+ func TestObserveSummaryExposesTaskGraphProblems(t *testing.T) {
257
+ root := filepath.Join(t.TempDir(), ".devrites")
258
+ workspace := filepath.Join(root, "work", "blocked-graph")
259
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
260
+ t.Fatal(err)
261
+ }
262
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | define |\n"), 0o644); err != nil {
263
+ t.Fatal(err)
264
+ }
265
+ body := `# Tasks
266
+
267
+ ## SLICE-001 Ready
268
+ Dependencies: none
269
+
270
+ ## SLICE-002 Next
271
+ Dependencies: SLICE-001 and later
272
+ `
273
+ if err := os.WriteFile(filepath.Join(workspace, "tasks.md"), []byte(body), 0o644); err != nil {
274
+ t.Fatal(err)
275
+ }
276
+
277
+ summary, err := ObserveSummaryFor(root, "blocked-graph")
278
+ if err != nil {
279
+ t.Fatal(err)
280
+ }
281
+ if summary.TaskGraph == nil {
282
+ t.Fatal("expected task_graph")
283
+ }
284
+ if summary.TaskGraph.OK {
285
+ t.Fatal("expected task_graph.ok=false")
286
+ }
287
+ joined := strings.Join(summary.TaskGraph.Problems, "\n")
288
+ if !strings.Contains(joined, `malformed dependency "and"`) || !strings.Contains(joined, `malformed dependency "later"`) {
289
+ t.Fatalf("problems=%v", summary.TaskGraph.Problems)
290
+ }
291
+ if len(summary.TaskGraph.Cycle) != 0 {
292
+ t.Fatalf("cycle=%v, want empty for a malformed-token failure", summary.TaskGraph.Cycle)
293
+ }
294
+
295
+ raw, err := json.Marshal(summary.TaskGraph)
296
+ if err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ encoded := string(raw)
300
+ if !strings.Contains(encoded, `"ok":false`) || !strings.Contains(encoded, `"problems"`) {
301
+ t.Fatalf("json=%s", encoded)
302
+ }
303
+ }
304
+
305
+ func TestObserveSummaryExposesProblemsWhenNoSliceHeaders(t *testing.T) {
306
+ root := filepath.Join(t.TempDir(), ".devrites")
307
+ workspace := filepath.Join(root, "work", "bullet-list")
308
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | define |\n"), 0o644); err != nil {
312
+ t.Fatal(err)
313
+ }
314
+ if err := os.WriteFile(filepath.Join(workspace, "tasks.md"), []byte("# Tasks\n\n- do the work\n"), 0o644); err != nil {
315
+ t.Fatal(err)
316
+ }
317
+
318
+ summary, err := ObserveSummaryFor(root, "bullet-list")
319
+ if err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ if summary.TaskGraph == nil || summary.TaskGraph.OK {
323
+ t.Fatalf("task_graph=%+v, want problems with ok=false", summary.TaskGraph)
324
+ }
325
+ if summary.TaskGraph.SliceCount != 0 {
326
+ t.Fatalf("slice_count=%d", summary.TaskGraph.SliceCount)
327
+ }
328
+ joined := strings.Join(summary.TaskGraph.Problems, "\n")
329
+ if !strings.Contains(joined, "no SLICE-### sections found") {
330
+ t.Fatalf("problems=%v", summary.TaskGraph.Problems)
331
+ }
129
332
  }
130
333
 
131
334
  func copyDir(src, dst string) error {