devrites 4.2.0 → 4.4.0
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 +6 -0
- package/README.md +1 -1
- package/docs/cli.md +15 -0
- package/docs/engine/commands.md +20 -2
- package/docs/orchestration.md +6 -3
- package/engine/commands.go +1 -1
- package/engine/internal/lib/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/open_visual.go +254 -0
- package/engine/internal/lib/open_visual_test.go +280 -0
- package/engine/internal/lib/skilltrust.go +196 -0
- package/engine/internal/lib/taskgraph.go +162 -0
- package/engine/internal/lib/taskgraph_test.go +150 -0
- package/engine/internal/lib/visual_outline.go +163 -0
- package/engine/internal/lib/visual_outline_test.go +161 -0
- package/engine/internal/lib/workspace_read.go +29 -0
- package/engine/internal/parallel/cli.go +467 -0
- package/engine/internal/parallel/git.go +181 -0
- package/engine/internal/parallel/lease.go +244 -0
- package/engine/internal/parallel/ops.go +416 -0
- package/engine/internal/parallel/parallel_test.go +242 -0
- package/engine/internal/parallel/pathdisjoint.go +129 -0
- package/engine/internal/parallel/pathdisjoint_test.go +97 -0
- package/engine/internal/state/status.go +41 -16
- package/engine/main.go +76 -3
- package/engine/root_routing_test.go +50 -15
- package/engine/testdata/visual/open-visual-smoke.html +675 -0
- package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
- package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -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-build/SKILL.md +29 -36
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/.claude/skills/rite-build/reference/output.md +2 -0
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/.claude/skills/rite-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -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 +3 -0
- 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/standards/skill-authoring.md +22 -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-build/SKILL.md +29 -36
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -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 +3 -0
- 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/standards/skill-authoring.md +22 -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-build/SKILL.md +29 -36
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -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 +3 -0
- 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
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"regexp"
|
|
9
|
+
"strings"
|
|
10
|
+
"unicode"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// SkillTrustFinding is one deterministic trust scan result.
|
|
14
|
+
type SkillTrustFinding struct {
|
|
15
|
+
Severity string // HIGH or MEDIUM
|
|
16
|
+
Rule string
|
|
17
|
+
Line int
|
|
18
|
+
Detail string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// SkillTrustResult summarizes a skill-trust scan.
|
|
22
|
+
type SkillTrustResult struct {
|
|
23
|
+
Path string
|
|
24
|
+
Findings []SkillTrustFinding
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var skillTrustRules = []struct {
|
|
28
|
+
severity string
|
|
29
|
+
rule string
|
|
30
|
+
re *regexp.Regexp
|
|
31
|
+
detail string
|
|
32
|
+
}{
|
|
33
|
+
{
|
|
34
|
+
severity: "HIGH",
|
|
35
|
+
rule: "prompt_injection_disregard",
|
|
36
|
+
re: regexp.MustCompile(`(?i)\b(disregard|ignore)\b.{0,40}\b(prior|previous|above|system|instruction|rule|constraint|safety)\b`),
|
|
37
|
+
detail: "instruction attempts to override prior constraints",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
severity: "HIGH",
|
|
41
|
+
rule: "credential_exfil_curl_env",
|
|
42
|
+
re: regexp.MustCompile(`(?i)\bcurl\b.{0,120}\$(?:ENV|HOME|[A-Z_][A-Z0-9_]*)`),
|
|
43
|
+
detail: "curl command references environment variables (possible exfiltration)",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
severity: "HIGH",
|
|
47
|
+
rule: "sensitive_path_reference",
|
|
48
|
+
re: regexp.MustCompile(`(?i)(~/.ssh|~/.aws|/etc/passwd|id_rsa|\.pem\b|api[_-]?key\s*[:=])`),
|
|
49
|
+
detail: "references sensitive filesystem or credential material",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
severity: "MEDIUM",
|
|
53
|
+
rule: "hidden_remote_fetch",
|
|
54
|
+
re: regexp.MustCompile(`(?i)\b(curl|wget|fetch)\b.{0,80}\b(http|https)://`),
|
|
55
|
+
detail: "network fetch instruction in skill prose",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ScanSkillTrust reads one Markdown skill/agent file and returns structural trust findings.
|
|
60
|
+
func ScanSkillTrust(path string) (SkillTrustResult, error) {
|
|
61
|
+
info, err := os.Stat(path)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return SkillTrustResult{}, err
|
|
64
|
+
}
|
|
65
|
+
if info.IsDir() {
|
|
66
|
+
return SkillTrustResult{}, fmt.Errorf("skill-trust: %q is a directory", path)
|
|
67
|
+
}
|
|
68
|
+
if info.Size() > 1<<20 {
|
|
69
|
+
return SkillTrustResult{}, fmt.Errorf("skill-trust: %q exceeds 1 MiB limit", path)
|
|
70
|
+
}
|
|
71
|
+
file, err := os.Open(path)
|
|
72
|
+
if err != nil {
|
|
73
|
+
return SkillTrustResult{}, err
|
|
74
|
+
}
|
|
75
|
+
defer file.Close()
|
|
76
|
+
raw, err := io.ReadAll(io.LimitReader(file, 1<<20+1))
|
|
77
|
+
if err != nil {
|
|
78
|
+
return SkillTrustResult{}, err
|
|
79
|
+
}
|
|
80
|
+
if len(raw) > 1<<20 {
|
|
81
|
+
return SkillTrustResult{}, fmt.Errorf("skill-trust: %q exceeds 1 MiB limit", path)
|
|
82
|
+
}
|
|
83
|
+
return SkillTrustResult{Path: filepath.Clean(path), Findings: analyzeSkillTrustContent(string(raw))}, nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func analyzeSkillTrustContent(text string) []SkillTrustFinding {
|
|
87
|
+
var findings []SkillTrustFinding
|
|
88
|
+
lines := strings.Split(text, "\n")
|
|
89
|
+
for i, line := range lines {
|
|
90
|
+
lineNum := i + 1
|
|
91
|
+
for _, r := range line {
|
|
92
|
+
if isSuspiciousUnicode(r) {
|
|
93
|
+
findings = append(findings, SkillTrustFinding{
|
|
94
|
+
Severity: "HIGH",
|
|
95
|
+
Rule: "suspicious_unicode",
|
|
96
|
+
Line: lineNum,
|
|
97
|
+
Detail: fmt.Sprintf("suspicious Unicode U+%04X", r),
|
|
98
|
+
})
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for _, rule := range skillTrustRules {
|
|
103
|
+
if rule.re.MatchString(line) {
|
|
104
|
+
findings = append(findings, SkillTrustFinding{
|
|
105
|
+
Severity: rule.severity,
|
|
106
|
+
Rule: rule.rule,
|
|
107
|
+
Line: lineNum,
|
|
108
|
+
Detail: rule.detail,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
findings = appendMissingNameFinding(text, findings)
|
|
114
|
+
return dedupeFindings(findings)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func looksLikeSkillFile(text string) bool {
|
|
118
|
+
lower := strings.ToLower(text)
|
|
119
|
+
return strings.Contains(lower, "user-invocable:") || strings.Contains(lower, "# /rite-") || strings.Contains(lower, "disable-model-invocation:")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func appendMissingNameFinding(text string, findings []SkillTrustFinding) []SkillTrustFinding {
|
|
123
|
+
if strings.Contains(text, "---") && !strings.Contains(text, "\nname:") && looksLikeSkillFile(text) {
|
|
124
|
+
findings = append(findings, SkillTrustFinding{
|
|
125
|
+
Severity: "MEDIUM",
|
|
126
|
+
Rule: "missing_name_frontmatter",
|
|
127
|
+
Line: 1,
|
|
128
|
+
Detail: "skill-like Markdown lacks name frontmatter",
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return findings
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func isSuspiciousUnicode(r rune) bool {
|
|
135
|
+
switch r {
|
|
136
|
+
case '\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060':
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
if r >= '\u202A' && r <= '\u202E' {
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
if r >= '\u2066' && r <= '\u2069' {
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func dedupeFindings(in []SkillTrustFinding) []SkillTrustFinding {
|
|
149
|
+
if len(in) == 0 {
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
seen := make(map[string]bool, len(in))
|
|
153
|
+
out := make([]SkillTrustFinding, 0, len(in))
|
|
154
|
+
for _, finding := range in {
|
|
155
|
+
key := finding.Rule + "|" + fmt.Sprint(finding.Line) + "|" + finding.Detail
|
|
156
|
+
if seen[key] {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
seen[key] = true
|
|
160
|
+
out = append(out, finding)
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// SkillTrustBlocks returns true when findings contain any HIGH severity item.
|
|
166
|
+
func SkillTrustBlocks(findings []SkillTrustFinding) bool {
|
|
167
|
+
for _, finding := range findings {
|
|
168
|
+
if finding.Severity == "HIGH" {
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// FormatSkillTrust writes a human-readable report.
|
|
176
|
+
func FormatSkillTrust(result SkillTrustResult) string {
|
|
177
|
+
if len(result.Findings) == 0 {
|
|
178
|
+
return fmt.Sprintf("skill-trust: PASS (%s)\n", result.Path)
|
|
179
|
+
}
|
|
180
|
+
var b strings.Builder
|
|
181
|
+
fmt.Fprintf(&b, "skill-trust: FINDINGS (%s)\n", result.Path)
|
|
182
|
+
for _, finding := range result.Findings {
|
|
183
|
+
fmt.Fprintf(&b, "%s %s line=%d %s\n", finding.Severity, finding.Rule, finding.Line, finding.Detail)
|
|
184
|
+
}
|
|
185
|
+
return b.String()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ContainsOnlyASCII returns false when non-ASCII control or bidi characters appear.
|
|
189
|
+
func ContainsOnlyASCII(text string) bool {
|
|
190
|
+
for _, r := range text {
|
|
191
|
+
if r > unicode.MaxASCII && isSuspiciousUnicode(r) {
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return true
|
|
196
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// TaskSlice is one SLICE-### block parsed from tasks.md.
|
|
10
|
+
type TaskSlice struct {
|
|
11
|
+
ID string
|
|
12
|
+
Dependencies []string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// TaskGraphResult is the deterministic outcome of parsing a tasks.md slice graph.
|
|
16
|
+
type TaskGraphResult struct {
|
|
17
|
+
Slices []TaskSlice
|
|
18
|
+
Cycle []string // empty when acyclic
|
|
19
|
+
Unknown []string // dependency ids with no defining slice
|
|
20
|
+
Problems []string // human-readable blockers
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var (
|
|
24
|
+
sliceHeaderRE = regexp.MustCompile(`(?m)^##\s+(SLICE-\d+)\b`)
|
|
25
|
+
dependenciesRE = regexp.MustCompile(`(?m)^Dependencies:\s*(.+)\s*$`)
|
|
26
|
+
sliceIDValidRE = regexp.MustCompile(`^SLICE-\d+$`)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// ParseTaskGraph reads tasks.md content and validates the slice dependency DAG.
|
|
30
|
+
func ParseTaskGraph(tasksMarkdown []byte) TaskGraphResult {
|
|
31
|
+
text := string(tasksMarkdown)
|
|
32
|
+
result := TaskGraphResult{}
|
|
33
|
+
if strings.TrimSpace(text) == "" {
|
|
34
|
+
result.Problems = append(result.Problems, "tasks.md is empty")
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
headers := sliceHeaderRE.FindAllStringSubmatchIndex(text, -1)
|
|
39
|
+
if len(headers) == 0 {
|
|
40
|
+
result.Problems = append(result.Problems, "no SLICE-### sections found")
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
known := make(map[string]bool)
|
|
45
|
+
for _, match := range headers {
|
|
46
|
+
id := text[match[2]:match[3]]
|
|
47
|
+
known[id] = true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for i, match := range headers {
|
|
51
|
+
id := text[match[2]:match[3]]
|
|
52
|
+
start := match[1]
|
|
53
|
+
end := len(text)
|
|
54
|
+
if i+1 < len(headers) {
|
|
55
|
+
end = headers[i+1][0]
|
|
56
|
+
}
|
|
57
|
+
block := text[start:end]
|
|
58
|
+
deps := parseSliceDependencies(block)
|
|
59
|
+
result.Slices = append(result.Slices, TaskSlice{ID: id, Dependencies: deps})
|
|
60
|
+
for _, dep := range deps {
|
|
61
|
+
if !known[dep] {
|
|
62
|
+
result.Unknown = append(result.Unknown, dep)
|
|
63
|
+
result.Problems = append(result.Problems, fmt.Sprintf("%s depends on unknown slice %s", id, dep))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if cycle := findTaskCycle(result.Slices); len(cycle) > 0 {
|
|
69
|
+
result.Cycle = cycle
|
|
70
|
+
result.Problems = append(result.Problems, "dependency cycle: "+strings.Join(cycle, " -> "))
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func parseSliceDependencies(block string) []string {
|
|
76
|
+
match := dependenciesRE.FindStringSubmatch(block)
|
|
77
|
+
if len(match) < 2 {
|
|
78
|
+
return nil
|
|
79
|
+
}
|
|
80
|
+
raw := strings.TrimSpace(match[1])
|
|
81
|
+
if raw == "" || strings.EqualFold(raw, "none") {
|
|
82
|
+
return nil
|
|
83
|
+
}
|
|
84
|
+
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
|
85
|
+
return r == ',' || r == ';'
|
|
86
|
+
})
|
|
87
|
+
var deps []string
|
|
88
|
+
seen := make(map[string]bool)
|
|
89
|
+
for _, part := range parts {
|
|
90
|
+
id := strings.TrimSpace(part)
|
|
91
|
+
if id == "" {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if !sliceIDValidRE.MatchString(id) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if seen[id] {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
seen[id] = true
|
|
101
|
+
deps = append(deps, id)
|
|
102
|
+
}
|
|
103
|
+
return deps
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func findTaskCycle(slices []TaskSlice) []string {
|
|
107
|
+
graph := make(map[string][]string, len(slices))
|
|
108
|
+
for _, slice := range slices {
|
|
109
|
+
graph[slice.ID] = append([]string(nil), slice.Dependencies...)
|
|
110
|
+
}
|
|
111
|
+
visited := make(map[string]uint8, len(slices))
|
|
112
|
+
stack := make([]string, 0, len(slices))
|
|
113
|
+
var cycle []string
|
|
114
|
+
|
|
115
|
+
var visit func(id string) bool
|
|
116
|
+
visit = func(id string) bool {
|
|
117
|
+
switch visited[id] {
|
|
118
|
+
case 2:
|
|
119
|
+
return false
|
|
120
|
+
case 1:
|
|
121
|
+
for i := len(stack) - 1; i >= 0; i-- {
|
|
122
|
+
cycle = append(cycle, stack[i])
|
|
123
|
+
if stack[i] == id {
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for i, j := 0, len(cycle)-1; i < j; i, j = i+1, j-1 {
|
|
128
|
+
cycle[i], cycle[j] = cycle[j], cycle[i]
|
|
129
|
+
}
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
visited[id] = 1
|
|
133
|
+
stack = append(stack, id)
|
|
134
|
+
for _, dep := range graph[id] {
|
|
135
|
+
if visit(dep) {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
stack = stack[:len(stack)-1]
|
|
140
|
+
visited[id] = 2
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for _, slice := range slices {
|
|
145
|
+
if visited[slice.ID] == 0 {
|
|
146
|
+
cycle = nil
|
|
147
|
+
if visit(slice.ID) {
|
|
148
|
+
return cycle
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return nil
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// CheckTaskGraph validates tasks.md for a feature workspace slug.
|
|
156
|
+
func CheckTaskGraph(root, slug string) (TaskGraphResult, error) {
|
|
157
|
+
raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
|
|
158
|
+
if err != nil {
|
|
159
|
+
return TaskGraphResult{}, err
|
|
160
|
+
}
|
|
161
|
+
return ParseTaskGraph(raw), nil
|
|
162
|
+
}
|