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,163 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"regexp"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// htmlIDAttrRE matches id="..." or id='...' attributes without a full HTML parser.
|
|
9
|
+
var htmlIDAttrRE = regexp.MustCompile(`(?i)\bid\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
|
10
|
+
|
|
11
|
+
// VisualIDConsistency summarizes outline ## ID inventory vs HTML id attributes.
|
|
12
|
+
//
|
|
13
|
+
// Policy: warn only for inventory → HTML mismatches. HTML-only ids (for example
|
|
14
|
+
// SVG marker defs such as id="arrow") are treated as decorative and are not
|
|
15
|
+
// reported, to keep open-visual tips quiet on intentional landmark-only
|
|
16
|
+
// inventories.
|
|
17
|
+
type VisualIDConsistency struct {
|
|
18
|
+
Inventory []string
|
|
19
|
+
HTML []string
|
|
20
|
+
MissingInHTML []string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ParseOutlineInventoryIDs extracts HTML ids from the outline ## ID inventory table.
|
|
24
|
+
func ParseOutlineInventoryIDs(outline string) []string {
|
|
25
|
+
section := extractMarkdownSection(outline, "ID inventory")
|
|
26
|
+
if section == "" {
|
|
27
|
+
return nil
|
|
28
|
+
}
|
|
29
|
+
return parseMarkdownFirstColumnIDs(section)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ScanHTMLIDs returns unique id="..." / id='...' attribute values in document order.
|
|
33
|
+
func ScanHTMLIDs(html string) []string {
|
|
34
|
+
matches := htmlIDAttrRE.FindAllStringSubmatch(html, -1)
|
|
35
|
+
if len(matches) == 0 {
|
|
36
|
+
return nil
|
|
37
|
+
}
|
|
38
|
+
seen := make(map[string]struct{}, len(matches))
|
|
39
|
+
ids := make([]string, 0, len(matches))
|
|
40
|
+
for _, m := range matches {
|
|
41
|
+
id := m[1]
|
|
42
|
+
if id == "" {
|
|
43
|
+
id = m[2]
|
|
44
|
+
}
|
|
45
|
+
id = strings.TrimSpace(id)
|
|
46
|
+
if id == "" {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if _, ok := seen[id]; ok {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
seen[id] = struct{}{}
|
|
53
|
+
ids = append(ids, id)
|
|
54
|
+
}
|
|
55
|
+
return ids
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// CheckVisualIDConsistency compares outline inventory ids against HTML attributes.
|
|
59
|
+
func CheckVisualIDConsistency(html, outline string) VisualIDConsistency {
|
|
60
|
+
inventory := ParseOutlineInventoryIDs(outline)
|
|
61
|
+
htmlIDs := ScanHTMLIDs(html)
|
|
62
|
+
report := VisualIDConsistency{
|
|
63
|
+
Inventory: inventory,
|
|
64
|
+
HTML: htmlIDs,
|
|
65
|
+
}
|
|
66
|
+
if len(inventory) == 0 {
|
|
67
|
+
return report
|
|
68
|
+
}
|
|
69
|
+
htmlSet := make(map[string]struct{}, len(htmlIDs))
|
|
70
|
+
for _, id := range htmlIDs {
|
|
71
|
+
htmlSet[id] = struct{}{}
|
|
72
|
+
}
|
|
73
|
+
for _, id := range inventory {
|
|
74
|
+
if _, ok := htmlSet[id]; !ok {
|
|
75
|
+
report.MissingInHTML = append(report.MissingInHTML, id)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return report
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func extractMarkdownSection(md, heading string) string {
|
|
82
|
+
lines := strings.Split(md, "\n")
|
|
83
|
+
start := -1
|
|
84
|
+
for i, line := range lines {
|
|
85
|
+
trimmed := strings.TrimSpace(line)
|
|
86
|
+
if !strings.HasPrefix(trimmed, "## ") {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
h := strings.TrimSpace(strings.TrimPrefix(trimmed, "## "))
|
|
90
|
+
if strings.EqualFold(h, heading) {
|
|
91
|
+
start = i + 1
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if start >= 0 {
|
|
95
|
+
return strings.Join(lines[start:i], "\n")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if start < 0 {
|
|
99
|
+
return ""
|
|
100
|
+
}
|
|
101
|
+
return strings.Join(lines[start:], "\n")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func parseMarkdownFirstColumnIDs(section string) []string {
|
|
105
|
+
var ids []string
|
|
106
|
+
seen := make(map[string]struct{})
|
|
107
|
+
for _, line := range strings.Split(section, "\n") {
|
|
108
|
+
line = strings.TrimSpace(line)
|
|
109
|
+
if !strings.HasPrefix(line, "|") {
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
cells := splitMarkdownRow(line)
|
|
113
|
+
if len(cells) == 0 {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
raw := strings.TrimSpace(cells[0])
|
|
117
|
+
if raw == "" || isMarkdownSeparatorCell(raw) {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
normalizedHeader := strings.ToLower(strings.ReplaceAll(raw, "`", ""))
|
|
121
|
+
normalizedHeader = strings.Join(strings.Fields(normalizedHeader), " ")
|
|
122
|
+
if normalizedHeader == "html id" || normalizedHeader == "id" {
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
id := strings.TrimSpace(strings.Trim(raw, "`"))
|
|
126
|
+
if id == "" {
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if _, ok := seen[id]; ok {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
seen[id] = struct{}{}
|
|
133
|
+
ids = append(ids, id)
|
|
134
|
+
}
|
|
135
|
+
return ids
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func splitMarkdownRow(line string) []string {
|
|
139
|
+
line = strings.TrimSpace(line)
|
|
140
|
+
line = strings.TrimPrefix(line, "|")
|
|
141
|
+
line = strings.TrimSuffix(line, "|")
|
|
142
|
+
parts := strings.Split(line, "|")
|
|
143
|
+
cells := make([]string, 0, len(parts))
|
|
144
|
+
for _, part := range parts {
|
|
145
|
+
cells = append(cells, strings.TrimSpace(part))
|
|
146
|
+
}
|
|
147
|
+
return cells
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func isMarkdownSeparatorCell(cell string) bool {
|
|
151
|
+
if cell == "" {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
for _, r := range cell {
|
|
155
|
+
switch r {
|
|
156
|
+
case '-', ':', ' ':
|
|
157
|
+
continue
|
|
158
|
+
default:
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return strings.ContainsRune(cell, '-')
|
|
163
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestParseOutlineInventoryIDs(t *testing.T) {
|
|
11
|
+
outline := `# Title
|
|
12
|
+
|
|
13
|
+
## Purpose
|
|
14
|
+
demo
|
|
15
|
+
|
|
16
|
+
## ID inventory
|
|
17
|
+
|
|
18
|
+
| HTML ` + "`id`" + ` | Meaning |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| ` + "`viz-title`" + ` | Header |
|
|
21
|
+
| ` + "`diagram-overview`" + ` | Overview |
|
|
22
|
+
| viz-title | duplicate ignored |
|
|
23
|
+
|
|
24
|
+
## Relationships
|
|
25
|
+
| From | To | Note |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| a | b | c |
|
|
28
|
+
`
|
|
29
|
+
got := ParseOutlineInventoryIDs(outline)
|
|
30
|
+
want := []string{"viz-title", "diagram-overview"}
|
|
31
|
+
if len(got) != len(want) {
|
|
32
|
+
t.Fatalf("ids = %#v, want %#v", got, want)
|
|
33
|
+
}
|
|
34
|
+
for i := range want {
|
|
35
|
+
if got[i] != want[i] {
|
|
36
|
+
t.Fatalf("ids = %#v, want %#v", got, want)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestScanHTMLIDs(t *testing.T) {
|
|
42
|
+
html := `<!doctype html><div id="viz-title"></div><svg><marker id='arrow'></marker><g id="viz-title"></g></svg>`
|
|
43
|
+
got := ScanHTMLIDs(html)
|
|
44
|
+
want := []string{"viz-title", "arrow"}
|
|
45
|
+
if len(got) != len(want) {
|
|
46
|
+
t.Fatalf("ids = %#v, want %#v", got, want)
|
|
47
|
+
}
|
|
48
|
+
for i := range want {
|
|
49
|
+
if got[i] != want[i] {
|
|
50
|
+
t.Fatalf("ids = %#v, want %#v", got, want)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestCheckVisualIDConsistencyMatch(t *testing.T) {
|
|
56
|
+
html := `<section id="viz-title"></section><section id="diagram-overview"></section><marker id="arrow"></marker>`
|
|
57
|
+
outline := `## ID inventory
|
|
58
|
+
| HTML id | Meaning |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| viz-title | Header |
|
|
61
|
+
| diagram-overview | Overview |
|
|
62
|
+
`
|
|
63
|
+
report := CheckVisualIDConsistency(html, outline)
|
|
64
|
+
if len(report.MissingInHTML) != 0 {
|
|
65
|
+
t.Fatalf("MissingInHTML = %#v, want empty", report.MissingInHTML)
|
|
66
|
+
}
|
|
67
|
+
if len(report.Inventory) != 2 {
|
|
68
|
+
t.Fatalf("Inventory = %#v, want 2 ids", report.Inventory)
|
|
69
|
+
}
|
|
70
|
+
// HTML-only decorative ids are not reported.
|
|
71
|
+
if len(report.HTML) != 3 {
|
|
72
|
+
t.Fatalf("HTML = %#v, want viz-title, diagram-overview, arrow", report.HTML)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestCheckVisualIDConsistencyMissingInHTML(t *testing.T) {
|
|
77
|
+
html := `<section id="viz-title"></section>`
|
|
78
|
+
outline := `## ID inventory
|
|
79
|
+
| HTML id | Meaning |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| viz-title | Header |
|
|
82
|
+
| viz-open-questions | Open questions |
|
|
83
|
+
`
|
|
84
|
+
report := CheckVisualIDConsistency(html, outline)
|
|
85
|
+
if len(report.MissingInHTML) != 1 || report.MissingInHTML[0] != "viz-open-questions" {
|
|
86
|
+
t.Fatalf("MissingInHTML = %#v, want [viz-open-questions]", report.MissingInHTML)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func TestCheckVisualIDConsistencyMissingOutlineSection(t *testing.T) {
|
|
91
|
+
report := CheckVisualIDConsistency(`<div id="x"></div>`, "# Title\n\ndemo\n")
|
|
92
|
+
if len(report.Inventory) != 0 || len(report.MissingInHTML) != 0 {
|
|
93
|
+
t.Fatalf("report = %#v, want empty inventory/missing", report)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func TestOpenVisualWarnsIDMismatch(t *testing.T) {
|
|
98
|
+
root, html := writeOpenVisualFixture(t, true)
|
|
99
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
|
|
100
|
+
|
|
101
|
+
outline := strings.TrimSuffix(html, ".html") + ".outline.md"
|
|
102
|
+
writeBasenameFile(t, filepath.Dir(outline), filepath.Base(outline), `# demo
|
|
103
|
+
|
|
104
|
+
## ID inventory
|
|
105
|
+
| HTML id | Meaning |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| viz-title | Header |
|
|
108
|
+
| missing-node | Absent from HTML |
|
|
109
|
+
`)
|
|
110
|
+
writeBasenameFile(t, filepath.Dir(html), filepath.Base(html), `<!doctype html><title>demo</title><section id="viz-title"></section>
|
|
111
|
+
`)
|
|
112
|
+
|
|
113
|
+
restore := swapOpenVisualOpener(func(string) error { return nil })
|
|
114
|
+
defer restore()
|
|
115
|
+
|
|
116
|
+
var stdout, stderr bytes.Buffer
|
|
117
|
+
code := OpenVisual(root, []string{"demo", "--no-open"}, &stdout, &stderr)
|
|
118
|
+
if code != 0 {
|
|
119
|
+
t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
|
|
120
|
+
}
|
|
121
|
+
if !strings.Contains(stderr.String(), "outline inventory id(s) missing from HTML") {
|
|
122
|
+
t.Fatalf("stderr = %q, want missing-id warning", stderr.String())
|
|
123
|
+
}
|
|
124
|
+
if !strings.Contains(stderr.String(), "missing-node") {
|
|
125
|
+
t.Fatalf("stderr = %q, want missing-node listed", stderr.String())
|
|
126
|
+
}
|
|
127
|
+
if !strings.Contains(stdout.String(), "open-visual: ids=mismatch") {
|
|
128
|
+
t.Fatalf("stdout = %q, want ids=mismatch tip", stdout.String())
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func TestOpenVisualIDsOkTip(t *testing.T) {
|
|
133
|
+
root, html := writeOpenVisualFixture(t, true)
|
|
134
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(root, "work", "feature"))
|
|
135
|
+
|
|
136
|
+
outline := strings.TrimSuffix(html, ".html") + ".outline.md"
|
|
137
|
+
writeBasenameFile(t, filepath.Dir(outline), filepath.Base(outline), `# demo
|
|
138
|
+
|
|
139
|
+
## ID inventory
|
|
140
|
+
| HTML id | Meaning |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| viz-title | Header |
|
|
143
|
+
`)
|
|
144
|
+
writeBasenameFile(t, filepath.Dir(html), filepath.Base(html), `<!doctype html><title>demo</title><section id="viz-title"></section><marker id="arrow"></marker>
|
|
145
|
+
`)
|
|
146
|
+
|
|
147
|
+
restore := swapOpenVisualOpener(func(string) error { return nil })
|
|
148
|
+
defer restore()
|
|
149
|
+
|
|
150
|
+
var stdout, stderr bytes.Buffer
|
|
151
|
+
code := OpenVisual(root, []string{"demo", "--no-open"}, &stdout, &stderr)
|
|
152
|
+
if code != 0 {
|
|
153
|
+
t.Fatalf("OpenVisual() = %d stderr=%q", code, stderr.String())
|
|
154
|
+
}
|
|
155
|
+
if stderr.Len() != 0 {
|
|
156
|
+
t.Fatalf("stderr = %q, want empty when consistent", stderr.String())
|
|
157
|
+
}
|
|
158
|
+
if !strings.Contains(stdout.String(), "open-visual: ids=ok (1 inventory)") {
|
|
159
|
+
t.Fatalf("stdout = %q, want ids=ok tip", stdout.String())
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strings"
|
|
7
|
+
"unicode"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// TaskSlice is one SLICE-### block parsed from tasks.md.
|
|
11
|
+
type TaskSlice struct {
|
|
12
|
+
ID string
|
|
13
|
+
Dependencies []string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// TaskGraphResult is the deterministic outcome of parsing a tasks.md slice graph.
|
|
17
|
+
type TaskGraphResult struct {
|
|
18
|
+
Slices []TaskSlice
|
|
19
|
+
Cycle []string // empty when acyclic
|
|
20
|
+
Unknown []string // dependency ids with no defining slice
|
|
21
|
+
Problems []string // human-readable blockers
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var (
|
|
25
|
+
sliceHeaderRE = regexp.MustCompile(`(?m)^##\s+(SLICE-\d+)\b`)
|
|
26
|
+
dependenciesRE = regexp.MustCompile(`(?m)^Dependencies:\s*(.+)\s*$`)
|
|
27
|
+
dependsOnRE = regexp.MustCompile(`(?m)^depends_on:\s*(.+)\s*$`)
|
|
28
|
+
sliceIDValidRE = regexp.MustCompile(`^SLICE-\d+$`)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// ParseTaskGraph reads tasks.md content and validates the slice dependency DAG.
|
|
32
|
+
func ParseTaskGraph(tasksMarkdown []byte) TaskGraphResult {
|
|
33
|
+
text := string(tasksMarkdown)
|
|
34
|
+
result := TaskGraphResult{}
|
|
35
|
+
if strings.TrimSpace(text) == "" {
|
|
36
|
+
result.Problems = append(result.Problems, "tasks.md is empty")
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
headers := sliceHeaderRE.FindAllStringSubmatchIndex(text, -1)
|
|
41
|
+
if len(headers) == 0 {
|
|
42
|
+
result.Problems = append(result.Problems, "no SLICE-### sections found")
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
known := make(map[string]bool, len(headers))
|
|
47
|
+
firstIndex := make(map[string]int, len(headers))
|
|
48
|
+
for i, match := range headers {
|
|
49
|
+
id := text[match[2]:match[3]]
|
|
50
|
+
if _, seen := firstIndex[id]; seen {
|
|
51
|
+
result.Problems = append(result.Problems, fmt.Sprintf("duplicate slice id %s", id))
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
firstIndex[id] = i
|
|
55
|
+
known[id] = true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for i, match := range headers {
|
|
59
|
+
id := text[match[2]:match[3]]
|
|
60
|
+
if firstIndex[id] != i {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
start := match[1]
|
|
64
|
+
end := len(text)
|
|
65
|
+
if i+1 < len(headers) {
|
|
66
|
+
end = headers[i+1][0]
|
|
67
|
+
}
|
|
68
|
+
block := text[start:end]
|
|
69
|
+
deps, problems := parseSliceGraph(id, block)
|
|
70
|
+
result.Problems = append(result.Problems, problems...)
|
|
71
|
+
result.Slices = append(result.Slices, TaskSlice{ID: id, Dependencies: deps})
|
|
72
|
+
for _, dep := range deps {
|
|
73
|
+
if !known[dep] {
|
|
74
|
+
result.Unknown = append(result.Unknown, dep)
|
|
75
|
+
result.Problems = append(result.Problems, fmt.Sprintf("%s depends on unknown slice %s", id, dep))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if cycle := findTaskCycle(result.Slices); len(cycle) > 0 {
|
|
81
|
+
result.Cycle = cycle
|
|
82
|
+
result.Problems = append(result.Problems, "dependency cycle: "+strings.Join(cycle, " -> "))
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func parseSliceGraph(id, block string) ([]string, []string) {
|
|
88
|
+
var problems []string
|
|
89
|
+
depsLine := firstLineValue(dependenciesRE, block)
|
|
90
|
+
mirrorLine := firstLineValue(dependsOnRE, block)
|
|
91
|
+
deps, malformed := parseIDList(depsLine)
|
|
92
|
+
for _, token := range malformed {
|
|
93
|
+
problems = append(problems, fmt.Sprintf("%s has malformed dependency %q", id, token))
|
|
94
|
+
}
|
|
95
|
+
mirror, mirrorMalformed := parseIDList(mirrorLine)
|
|
96
|
+
for _, token := range mirrorMalformed {
|
|
97
|
+
problems = append(problems, fmt.Sprintf("%s has malformed depends_on %q", id, token))
|
|
98
|
+
}
|
|
99
|
+
if depsLine != "" && mirrorLine != "" && !sameIDSet(deps, mirror) {
|
|
100
|
+
problems = append(problems, fmt.Sprintf("%s Dependencies and depends_on sets differ", id))
|
|
101
|
+
}
|
|
102
|
+
if depsLine == "" && mirrorLine == "" {
|
|
103
|
+
problems = append(problems, fmt.Sprintf("%s is missing Dependencies", id))
|
|
104
|
+
}
|
|
105
|
+
if depsLine == "" {
|
|
106
|
+
deps = mirror
|
|
107
|
+
}
|
|
108
|
+
return deps, problems
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func firstLineValue(re *regexp.Regexp, block string) string {
|
|
112
|
+
match := re.FindStringSubmatch(block)
|
|
113
|
+
if len(match) < 2 {
|
|
114
|
+
return ""
|
|
115
|
+
}
|
|
116
|
+
return strings.TrimSpace(match[1])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func parseIDList(raw string) ([]string, []string) {
|
|
120
|
+
raw = strings.TrimSpace(raw)
|
|
121
|
+
raw = strings.Trim(raw, "[]")
|
|
122
|
+
raw = strings.TrimSpace(raw)
|
|
123
|
+
if raw == "" || strings.EqualFold(raw, "none") {
|
|
124
|
+
return nil, nil
|
|
125
|
+
}
|
|
126
|
+
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
|
127
|
+
return r == ',' || r == ';' || unicode.IsSpace(r)
|
|
128
|
+
})
|
|
129
|
+
var ids []string
|
|
130
|
+
var malformed []string
|
|
131
|
+
seen := make(map[string]bool)
|
|
132
|
+
for _, part := range parts {
|
|
133
|
+
id := strings.Trim(strings.TrimSpace(part), "`'\"(){}")
|
|
134
|
+
if id == "" {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if !sliceIDValidRE.MatchString(id) {
|
|
138
|
+
malformed = append(malformed, id)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if seen[id] {
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
seen[id] = true
|
|
145
|
+
ids = append(ids, id)
|
|
146
|
+
}
|
|
147
|
+
return ids, malformed
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func sameIDSet(a, b []string) bool {
|
|
151
|
+
if len(a) != len(b) {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
seen := make(map[string]bool, len(a))
|
|
155
|
+
for _, id := range a {
|
|
156
|
+
seen[id] = true
|
|
157
|
+
}
|
|
158
|
+
for _, id := range b {
|
|
159
|
+
if !seen[id] {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func findTaskCycle(slices []TaskSlice) []string {
|
|
167
|
+
graph := make(map[string][]string, len(slices))
|
|
168
|
+
for _, slice := range slices {
|
|
169
|
+
graph[slice.ID] = append([]string(nil), slice.Dependencies...)
|
|
170
|
+
}
|
|
171
|
+
visited := make(map[string]uint8, len(slices))
|
|
172
|
+
stack := make([]string, 0, len(slices))
|
|
173
|
+
var cycle []string
|
|
174
|
+
|
|
175
|
+
var visit func(id string) bool
|
|
176
|
+
visit = func(id string) bool {
|
|
177
|
+
switch visited[id] {
|
|
178
|
+
case 2:
|
|
179
|
+
return false
|
|
180
|
+
case 1:
|
|
181
|
+
for i := len(stack) - 1; i >= 0; i-- {
|
|
182
|
+
cycle = append(cycle, stack[i])
|
|
183
|
+
if stack[i] == id {
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for i, j := 0, len(cycle)-1; i < j; i, j = i+1, j-1 {
|
|
188
|
+
cycle[i], cycle[j] = cycle[j], cycle[i]
|
|
189
|
+
}
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
visited[id] = 1
|
|
193
|
+
stack = append(stack, id)
|
|
194
|
+
for _, dep := range graph[id] {
|
|
195
|
+
if visit(dep) {
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
stack = stack[:len(stack)-1]
|
|
200
|
+
visited[id] = 2
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for _, slice := range slices {
|
|
205
|
+
if visited[slice.ID] == 0 {
|
|
206
|
+
cycle = nil
|
|
207
|
+
if visit(slice.ID) {
|
|
208
|
+
return cycle
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return nil
|
|
213
|
+
}
|
|
@@ -7,6 +7,9 @@ import (
|
|
|
7
7
|
"testing"
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
+
// CanonicalTasksMarkdown is a minimal valid slice graph for gate fixtures.
|
|
11
|
+
const CanonicalTasksMarkdown = "# Tasks\n\n## SLICE-001 Ready\nDependencies: none\n"
|
|
12
|
+
|
|
10
13
|
// CopyTree recursively copies the directory tree at src into dst, creating dst
|
|
11
14
|
// and any parents. It is used to give each test an isolated, writable copy of a
|
|
12
15
|
// read-only fixture.
|
package/engine/main.go
CHANGED
|
@@ -22,9 +22,9 @@ Usage:
|
|
|
22
22
|
devrites-engine update [flags] Update an existing DevRites install in place
|
|
23
23
|
devrites-engine uninstall [flags] Remove a DevRites install, preserving runtime state
|
|
24
24
|
devrites-engine check candidate <slug> Validate and hash the closed project candidate
|
|
25
|
-
devrites-engine check readiness <slug> Check required files
|
|
25
|
+
devrites-engine check readiness <slug> Check required files, tasks.md graph, and Build-input binding
|
|
26
26
|
devrites-engine check readiness --emit-binding <slug> Emit the stable Build-input binding for Vet
|
|
27
|
-
devrites-engine check seal <slug> Recheck
|
|
27
|
+
devrites-engine check seal <slug> Recheck files, tasks.md graph, Build-input binding, and evidence freshness
|
|
28
28
|
devrites-engine check path-disjoint [--root <dir>] [<json-file>|-]
|
|
29
29
|
Verify slice path sets are pairwise disjoint
|
|
30
30
|
devrites-engine check task-graph <slug> Validate tasks.md slice dependency graph
|
|
@@ -34,6 +34,8 @@ Usage:
|
|
|
34
34
|
devrites-engine state resolve <qid> "<ans>" Resolve an open question and update state atomically
|
|
35
35
|
devrites-engine state close <slug> Archive a shipped feature and clear ACTIVE
|
|
36
36
|
devrites-engine secret-scan [--staged] [--stdin] [slug] Scan exact staged blobs, stdin, or touched files; HIGH blocks
|
|
37
|
+
devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]
|
|
38
|
+
Resolve a visual HTML file, optionally open it locally, print agent paths
|
|
37
39
|
devrites-engine version Print the engine binary's version
|
|
38
40
|
Exit codes:
|
|
39
41
|
0 ok / gate passed
|
|
@@ -87,6 +89,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
|
87
89
|
return cmdState(root, args[1:], stdout, stderr)
|
|
88
90
|
case "secret-scan":
|
|
89
91
|
return lib.SecretScan(root, args[1:], stdin, stdout, stderr)
|
|
92
|
+
case "open-visual":
|
|
93
|
+
return lib.OpenVisual(root, args[1:], stdout, stderr)
|
|
90
94
|
case "version", "--version":
|
|
91
95
|
fmt.Fprintln(stdout, version.Version)
|
|
92
96
|
return exitOK
|
|
@@ -21,6 +21,7 @@ func TestRootModeForCoversReadAndWriteSurfaces(t *testing.T) {
|
|
|
21
21
|
{name: "close", command: "state", args: []string{"close"}, want: rootStrict},
|
|
22
22
|
{name: "unknown state command", command: "state", args: []string{"unknown"}, want: rootUnused},
|
|
23
23
|
{name: "secret scan", command: "secret-scan", want: rootLenient},
|
|
24
|
+
{name: "open visual", command: "open-visual", want: rootLenient},
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
for _, test := range tests {
|
|
@@ -102,9 +103,9 @@ func TestNestedCommandFamiliesAreRoutedAndAdvertised(t *testing.T) {
|
|
|
102
103
|
for _, args := range [][]string{{"check", "candidate"}, {"check", "readiness"}, {"check", "seal"}, {"state", "resolve"}, {"state", "close"}} {
|
|
103
104
|
t.Run(strings.Join(args, "-"), func(t *testing.T) {
|
|
104
105
|
var stdout, stderr bytes.Buffer
|
|
105
|
-
|
|
106
|
+
code := run(args, strings.NewReader(""), &stdout, &stderr)
|
|
106
107
|
if strings.Contains(stderr.String(), "unknown command") || strings.Contains(stderr.String(), "unknown check") || strings.Contains(stderr.String(), "unknown state") {
|
|
107
|
-
t.Fatalf("%q was not routed: %s", args, stderr.String())
|
|
108
|
+
t.Fatalf("%q was not routed (exit=%d): %s", args, code, stderr.String())
|
|
108
109
|
}
|
|
109
110
|
})
|
|
110
111
|
}
|
|
@@ -153,13 +154,9 @@ func TestCheckCandidateRoutesAndPrintsIdentity(t *testing.T) {
|
|
|
153
154
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
154
155
|
t.Fatal(err)
|
|
155
156
|
}
|
|
156
|
-
|
|
157
|
-
t.Fatal(err)
|
|
158
|
-
}
|
|
157
|
+
writeBasenameFile(t, project, "source.go", "package source\n")
|
|
159
158
|
manifest := "# Touched files\n\n## Touched files\nCandidate paths are declared below.\n\n## Candidate manifest\n| State | File | Slice | Reason |\n| --- | --- | --- | --- |\n| present | `source.go` | S-1 | Implementation. |\n"
|
|
160
|
-
|
|
161
|
-
t.Fatal(err)
|
|
162
|
-
}
|
|
159
|
+
writeBasenameFile(t, workspace, "touched-files.md", manifest)
|
|
163
160
|
t.Setenv("DEVRITES_ROOT", root)
|
|
164
161
|
var stdout, stderr bytes.Buffer
|
|
165
162
|
if code := run([]string{"check", "candidate", "feature"}, strings.NewReader(""), &stdout, &stderr); code != exitOK {
|
|
@@ -186,9 +183,7 @@ func TestCheckReadinessEmitBindingRoutesOnlyExactShape(t *testing.T) {
|
|
|
186
183
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
187
184
|
t.Fatal(err)
|
|
188
185
|
}
|
|
189
|
-
|
|
190
|
-
t.Fatal(err)
|
|
191
|
-
}
|
|
186
|
+
writeBasenameFile(t, workspace, name, body)
|
|
192
187
|
}
|
|
193
188
|
t.Setenv("DEVRITES_ROOT", root)
|
|
194
189
|
|
|
@@ -217,9 +212,7 @@ func TestCheckReadinessEmitBindingRoutesOnlyExactShape(t *testing.T) {
|
|
|
217
212
|
}
|
|
218
213
|
}
|
|
219
214
|
|
|
220
|
-
|
|
221
|
-
t.Fatal(err)
|
|
222
|
-
}
|
|
215
|
+
removeBasename(t, workspace, "plan.md")
|
|
223
216
|
stdout.Reset()
|
|
224
217
|
stderr.Reset()
|
|
225
218
|
if code := run([]string{"check", "readiness", "--emit-binding", "feature"}, strings.NewReader(""), &stdout, &stderr); code != exitBlocked || stdout.Len() != 0 || !strings.Contains(stderr.String(), "readiness-binding: BLOCKED") {
|
|
@@ -245,3 +238,45 @@ func TestSecretScanRoutesPRBodyThroughStdin(t *testing.T) {
|
|
|
245
238
|
t.Fatal("secret material disclosed")
|
|
246
239
|
}
|
|
247
240
|
}
|
|
241
|
+
|
|
242
|
+
// writeBasenameFile writes contents under dir using only filepath.Base(name),
|
|
243
|
+
// via CreateTemp + WriteString + Close + Rename (no WriteFile/Create sinks).
|
|
244
|
+
func writeBasenameFile(t *testing.T, dir, name, contents string) string {
|
|
245
|
+
t.Helper()
|
|
246
|
+
base := filepath.Base(name)
|
|
247
|
+
dst := filepath.Join(dir, base)
|
|
248
|
+
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
|
249
|
+
if err != nil {
|
|
250
|
+
t.Fatal(err)
|
|
251
|
+
}
|
|
252
|
+
written, werr := tmp.WriteString(contents)
|
|
253
|
+
if werr != nil {
|
|
254
|
+
if cerr := tmp.Close(); cerr != nil {
|
|
255
|
+
t.Fatalf("write: %v; close: %v", werr, cerr)
|
|
256
|
+
}
|
|
257
|
+
t.Fatal(werr)
|
|
258
|
+
}
|
|
259
|
+
if written != len(contents) {
|
|
260
|
+
if cerr := tmp.Close(); cerr != nil {
|
|
261
|
+
t.Fatalf("short write %d/%d; close: %v", written, len(contents), cerr)
|
|
262
|
+
}
|
|
263
|
+
t.Fatalf("short write %d/%d", written, len(contents))
|
|
264
|
+
}
|
|
265
|
+
if err := tmp.Close(); err != nil {
|
|
266
|
+
t.Fatal(err)
|
|
267
|
+
}
|
|
268
|
+
if err := os.Rename(tmp.Name(), dst); err != nil {
|
|
269
|
+
t.Fatal(err)
|
|
270
|
+
}
|
|
271
|
+
return dst
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// removeBasename moves dir/filepath.Base(name) out of dir via Rename (no Remove sink).
|
|
275
|
+
func removeBasename(t *testing.T, dir, name string) {
|
|
276
|
+
t.Helper()
|
|
277
|
+
src := filepath.Join(dir, filepath.Base(name))
|
|
278
|
+
dst := filepath.Join(t.TempDir(), filepath.Base(name))
|
|
279
|
+
if err := os.Rename(src, dst); err != nil {
|
|
280
|
+
t.Fatal(err)
|
|
281
|
+
}
|
|
282
|
+
}
|