devrites 4.3.0 → 4.4.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +12 -494
  2. package/README.md +4 -4
  3. package/docs/cli.md +22 -7
  4. package/docs/command-map.md +1 -1
  5. package/docs/engine/commands.md +24 -9
  6. package/docs/engine/workspace-schema.md +5 -5
  7. package/engine/commands.go +1 -1
  8. package/engine/internal/gate/gate.go +9 -0
  9. package/engine/internal/gate/gate_test.go +110 -0
  10. package/engine/internal/gate/readiness_binding.go +9 -0
  11. package/engine/internal/gate/readiness_binding_test.go +2 -2
  12. package/engine/internal/lib/observe_summary.go +22 -22
  13. package/engine/internal/lib/open_visual.go +254 -0
  14. package/engine/internal/lib/open_visual_test.go +280 -0
  15. package/engine/internal/lib/taskgraph.go +4 -143
  16. package/engine/internal/lib/taskgraph_test.go +203 -0
  17. package/engine/internal/lib/visual_outline.go +163 -0
  18. package/engine/internal/lib/visual_outline_test.go +161 -0
  19. package/engine/internal/state/taskgraph.go +213 -0
  20. package/engine/internal/testutil/testutil.go +3 -0
  21. package/engine/main.go +6 -2
  22. package/engine/root_routing_test.go +49 -14
  23. package/engine/testdata/visual/open-visual-smoke.html +675 -0
  24. package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
  25. package/engine/tests/adr_0027_readiness_binding_test.go +1 -1
  26. package/engine/tests/gate_test.go +4 -2
  27. package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
  28. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  29. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  30. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  31. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  32. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  33. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  34. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  35. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  36. package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  37. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  38. package/pack/.claude/skills/rite-define/SKILL.md +10 -1
  39. package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
  40. package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
  41. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +4 -1
  42. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
  43. package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
  44. package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
  45. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  46. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  47. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  48. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  49. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  50. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  51. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  52. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  53. package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  54. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  55. package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
  56. package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
  57. package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
  58. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +4 -1
  59. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
  60. package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
  61. package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
  62. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
  63. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
  64. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
  65. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
  66. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
  67. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
  68. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
  69. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
  70. package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
  71. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
  72. package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
  73. package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
  74. package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
  75. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +4 -1
  76. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
  77. package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
  78. package/package.json +1 -1
  79. package/scripts/install-lib.sh +1 -1
@@ -0,0 +1,280 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync/atomic"
10
+ "testing"
11
+ )
12
+
13
+ func TestOpenVisualResolvesNameUnderWorkspace(t *testing.T) {
14
+ root, html := writeOpenVisualFixture(t, true)
15
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
16
+
17
+ var opens atomic.Int32
18
+ restore := swapOpenVisualOpener(func(path string) error {
19
+ opens.Add(1)
20
+ if path != html {
21
+ t.Fatalf("opened %q, want %q", path, html)
22
+ }
23
+ return nil
24
+ })
25
+ defer restore()
26
+
27
+ var stdout, stderr bytes.Buffer
28
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
29
+ if code != 0 {
30
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
31
+ }
32
+ if opens.Load() != 1 {
33
+ t.Fatalf("opener calls = %d, want 1", opens.Load())
34
+ }
35
+ out := stdout.String()
36
+ if !strings.Contains(out, "open-visual: html="+html) {
37
+ t.Fatalf("stdout missing html tip:\n%s", out)
38
+ }
39
+ if !strings.Contains(out, "open-visual: outline="+strings.TrimSuffix(html, ".html")+".outline.md") {
40
+ t.Fatalf("stdout missing outline tip:\n%s", out)
41
+ }
42
+ if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
43
+ t.Fatalf("stdout missing playbook tip:\n%s", out)
44
+ }
45
+ if stderr.Len() != 0 {
46
+ t.Fatalf("stderr = %q, want empty", stderr.String())
47
+ }
48
+ }
49
+
50
+ func TestOpenVisualAbsolutePathAndNoOpen(t *testing.T) {
51
+ root, html := writeOpenVisualFixture(t, true)
52
+ if root == "" {
53
+ t.Fatal("empty fixture root")
54
+ }
55
+
56
+ var opens atomic.Int32
57
+ restore := swapOpenVisualOpener(func(string) error {
58
+ opens.Add(1)
59
+ return nil
60
+ })
61
+ defer restore()
62
+
63
+ var stdout, stderr bytes.Buffer
64
+ code := OpenVisual("", []string{html, "--no-open"}, &stdout, &stderr)
65
+ if code != 0 {
66
+ t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
67
+ }
68
+ if opens.Load() != 0 {
69
+ t.Fatalf("opener calls = %d, want 0 with --no-open", opens.Load())
70
+ }
71
+ if !strings.Contains(stdout.String(), "open-visual: html="+html) {
72
+ t.Fatalf("stdout = %q", stdout.String())
73
+ }
74
+ }
75
+
76
+ func TestOpenVisualWarnsMissingOutline(t *testing.T) {
77
+ root, html := writeOpenVisualFixture(t, false)
78
+ t.Setenv("DEVRITES_ROOT", root)
79
+
80
+ restore := swapOpenVisualOpener(func(string) error { return nil })
81
+ defer restore()
82
+
83
+ var stdout, stderr bytes.Buffer
84
+ code := OpenVisual(root, []string{"demo", "--slug", "feature", "--no-open"}, &stdout, &stderr)
85
+ if code != 0 {
86
+ t.Fatalf("OpenVisual() = %d", code)
87
+ }
88
+ if !strings.Contains(stderr.String(), "warning: missing outline companion") {
89
+ t.Fatalf("stderr = %q, want missing-outline warning", stderr.String())
90
+ }
91
+ if !strings.Contains(stdout.String(), "open-visual: outline=(missing)") {
92
+ t.Fatalf("stdout = %q, want missing outline tip", stdout.String())
93
+ }
94
+ if !strings.Contains(stdout.String(), "open-visual: html="+html) {
95
+ t.Fatalf("stdout = %q, want html tip for %s", stdout.String(), html)
96
+ }
97
+ }
98
+
99
+ func TestOpenVisualUsageErrors(t *testing.T) {
100
+ tests := []struct {
101
+ name string
102
+ args []string
103
+ want string
104
+ }{
105
+ {name: "missing operand", args: nil, want: "path or visual name required"},
106
+ {name: "unknown flag", args: []string{"demo", "--poll"}, want: `unknown flag "--poll"`},
107
+ {name: "slug without value", args: []string{"demo", "--slug"}, want: "--slug requires a value"},
108
+ }
109
+ for _, test := range tests {
110
+ t.Run(test.name, func(t *testing.T) {
111
+ var stdout, stderr bytes.Buffer
112
+ code := OpenVisual("", test.args, &stdout, &stderr)
113
+ if code != 2 {
114
+ t.Fatalf("code = %d, want 2", code)
115
+ }
116
+ if stdout.Len() != 0 {
117
+ t.Fatalf("stdout = %q", stdout.String())
118
+ }
119
+ if !strings.Contains(stderr.String(), test.want) {
120
+ t.Fatalf("stderr = %q, want %q", stderr.String(), test.want)
121
+ }
122
+ })
123
+ }
124
+ }
125
+
126
+ func TestOpenVisualRejectsNonHTML(t *testing.T) {
127
+ dir := t.TempDir()
128
+ path := writeBasenameFile(t, dir, "notes.md", "# hi\n")
129
+ restore := swapOpenVisualOpener(func(string) error {
130
+ t.Fatal("opener must not run for non-html")
131
+ return nil
132
+ })
133
+ defer restore()
134
+
135
+ var stdout, stderr bytes.Buffer
136
+ code := OpenVisual("", []string{path, "--no-open"}, &stdout, &stderr)
137
+ if code != 2 || !strings.Contains(stderr.String(), "require a .html file") {
138
+ t.Fatalf("code=%d stderr=%q", code, stderr.String())
139
+ }
140
+ }
141
+
142
+ func TestOpenVisualRefusesNameModeSymlinkEscape(t *testing.T) {
143
+ root, html := writeOpenVisualFixture(t, true)
144
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
145
+
146
+ outside := writeBasenameFile(t, t.TempDir(), "escape.html", "<!doctype html><title>escape</title>\n")
147
+ removeBasename(t, filepath.Dir(html), filepath.Base(html))
148
+ if err := os.Symlink(outside, html); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+
152
+ restore := swapOpenVisualOpener(func(string) error {
153
+ t.Fatal("opener must not run for escaped symlink")
154
+ return nil
155
+ })
156
+ defer restore()
157
+
158
+ var stdout, stderr bytes.Buffer
159
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
160
+ if code != 2 {
161
+ t.Fatalf("code = %d, want 2", code)
162
+ }
163
+ if !strings.Contains(stderr.String(), "escapes workspace visual/") {
164
+ t.Fatalf("stderr = %q, want symlink escape refusal", stderr.String())
165
+ }
166
+ if stdout.Len() != 0 {
167
+ t.Fatalf("stdout = %q, want empty on refuse", stdout.String())
168
+ }
169
+ }
170
+
171
+ func TestOpenVisualPrintsTipsWhenOpenerFails(t *testing.T) {
172
+ root, html := writeOpenVisualFixture(t, true)
173
+ t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
174
+
175
+ restore := swapOpenVisualOpener(func(string) error {
176
+ return errors.New("xdg-open: not found")
177
+ })
178
+ defer restore()
179
+
180
+ var stdout, stderr bytes.Buffer
181
+ code := OpenVisual(root, []string{"demo"}, &stdout, &stderr)
182
+ if code != 0 {
183
+ t.Fatalf("OpenVisual() = %d, want 0 (warn-and-continue)", code)
184
+ }
185
+ out := stdout.String()
186
+ if !strings.Contains(out, "open-visual: html="+html) {
187
+ t.Fatalf("stdout missing html tip:\n%s", out)
188
+ }
189
+ if !strings.Contains(out, "open-visual: outline=") {
190
+ t.Fatalf("stdout missing outline tip:\n%s", out)
191
+ }
192
+ if !strings.Contains(out, "open-visual: playbooks="+openVisualPlaybookHint) {
193
+ t.Fatalf("stdout missing playbook tip:\n%s", out)
194
+ }
195
+ if !strings.Contains(stderr.String(), "warning: failed to open browser") {
196
+ t.Fatalf("stderr = %q, want opener warning", stderr.String())
197
+ }
198
+ }
199
+
200
+ func TestIsOpenVisualPathOperandLeadingDotName(t *testing.T) {
201
+ if isOpenVisualPathOperand(".draft") {
202
+ t.Fatal(".draft should be a visual name, not a path operand")
203
+ }
204
+ if !isOpenVisualPathOperand("./demo.html") {
205
+ t.Fatal("./demo.html should be a path operand")
206
+ }
207
+ if !isOpenVisualPathOperand("../other.html") {
208
+ t.Fatal("../other.html should be a path operand")
209
+ }
210
+ }
211
+
212
+ func writeOpenVisualFixture(t *testing.T, withOutline bool) (root, html string) {
213
+ t.Helper()
214
+ project := t.TempDir()
215
+ root = filepath.Join(project, ".devrites")
216
+ visual := filepath.Join(root, "work", "feature", "visual")
217
+ if err := os.MkdirAll(visual, 0o755); err != nil {
218
+ t.Fatal(err)
219
+ }
220
+ html = writeBasenameFile(t, visual, "demo.html", "<!doctype html><title>demo</title>\n")
221
+ if withOutline {
222
+ writeBasenameFile(t, visual, "demo.outline.md", "# Title\n\ndemo\n")
223
+ }
224
+ writeBasenameFile(t, filepath.Join(root, "work", "feature"), "state.md", "# state\n")
225
+ if resolved, err := filepath.EvalSymlinks(html); err == nil {
226
+ html = resolved
227
+ }
228
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
229
+ root = resolved
230
+ }
231
+ return root, html
232
+ }
233
+
234
+ // writeBasenameFile writes contents under dir using only filepath.Base(name),
235
+ // via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
236
+ func writeBasenameFile(t *testing.T, dir, name, contents string) string {
237
+ t.Helper()
238
+ base := filepath.Base(name)
239
+ dst := filepath.Join(dir, base)
240
+ tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ written, werr := tmp.WriteString(contents)
245
+ if werr != nil {
246
+ if cerr := tmp.Close(); cerr != nil {
247
+ t.Fatalf("write: %v; close: %v", werr, cerr)
248
+ }
249
+ t.Fatal(werr)
250
+ }
251
+ if written != len(contents) {
252
+ if cerr := tmp.Close(); cerr != nil {
253
+ t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
254
+ }
255
+ t.Fatalf("short write %d/%d", written, len(contents))
256
+ }
257
+ if err := tmp.Close(); err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if err := os.Rename(tmp.Name(), dst); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ return dst
264
+ }
265
+
266
+ // removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
267
+ func removeBasename(t *testing.T, dir, name string) {
268
+ t.Helper()
269
+ src := filepath.Join(dir, filepath.Base(name))
270
+ dst := filepath.Join(t.TempDir(), filepath.Base(name))
271
+ if err := os.Rename(src, dst); err != nil {
272
+ t.Fatal(err)
273
+ }
274
+ }
275
+
276
+ func swapOpenVisualOpener(fn func(string) error) func() {
277
+ prev := openVisualOpener
278
+ openVisualOpener = fn
279
+ return func() { openVisualOpener = prev }
280
+ }
@@ -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 {