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.
- package/CHANGELOG.md +19 -494
- package/README.md +4 -4
- package/docs/cli.md +7 -7
- package/docs/command-map.md +1 -1
- package/docs/engine/commands.md +8 -7
- package/docs/engine/workspace-schema.md +5 -5
- 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/taskgraph.go +4 -143
- package/engine/internal/lib/taskgraph_test.go +203 -0
- package/engine/internal/state/taskgraph.go +213 -0
- package/engine/internal/testutil/testutil.go +3 -0
- package/engine/main.go +2 -2
- package/engine/tests/adr_0027_readiness_binding_test.go +1 -1
- package/engine/tests/gate_test.go +4 -2
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/pack/generated/codex/skills/rite/SKILL.md +1 -2
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +4 -1
- package/package.json +1 -1
- package/scripts/codex-generate.sh +2 -0
package/docs/engine/commands.md
CHANGED
|
@@ -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
|
|
19
|
-
| `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
170
|
-
required;
|
|
171
|
-
- `check seal`: final file completeness and open-human-gate checks,
|
|
172
|
-
readiness-binding recheck, then exact
|
|
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:
|
|
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":
|
|
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
|
|
13
|
-
Phase string
|
|
14
|
-
Status string
|
|
15
|
-
NextAction string
|
|
16
|
-
MissingSections []string
|
|
17
|
-
MissingFiles []string
|
|
18
|
-
PrinciplesPresent bool
|
|
19
|
-
TaskGraph *
|
|
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 = &
|
|
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
|
|
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 {
|