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.
- package/CHANGELOG.md +12 -494
- package/README.md +4 -4
- package/docs/cli.md +22 -7
- package/docs/command-map.md +1 -1
- package/docs/engine/commands.md +24 -9
- package/docs/engine/workspace-schema.md +5 -5
- package/engine/commands.go +1 -1
- package/engine/internal/gate/gate.go +9 -0
- package/engine/internal/gate/gate_test.go +110 -0
- package/engine/internal/gate/readiness_binding.go +9 -0
- package/engine/internal/gate/readiness_binding_test.go +2 -2
- package/engine/internal/lib/observe_summary.go +22 -22
- package/engine/internal/lib/open_visual.go +254 -0
- package/engine/internal/lib/open_visual_test.go +280 -0
- package/engine/internal/lib/taskgraph.go +4 -143
- package/engine/internal/lib/taskgraph_test.go +203 -0
- package/engine/internal/lib/visual_outline.go +163 -0
- package/engine/internal/lib/visual_outline_test.go +161 -0
- package/engine/internal/state/taskgraph.go +213 -0
- package/engine/internal/testutil/testutil.go +3 -0
- package/engine/main.go +6 -2
- package/engine/root_routing_test.go +49 -14
- package/engine/testdata/visual/open-visual-smoke.html +675 -0
- package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
- package/engine/tests/adr_0027_readiness_binding_test.go +1 -1
- package/engine/tests/gate_test.go +4 -2
- package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/.claude/skills/rite-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
- package/package.json +1 -1
- 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
|
|
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
|
|
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
|
-
|
|
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 {
|