devrites 3.0.3 → 3.0.5
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 +31 -0
- package/README.md +55 -43
- package/SECURITY.md +72 -58
- package/docs/adr/0001-go-engine-as-control-plane.md +43 -0
- package/docs/adr/0002-dual-host-harness.md +34 -0
- package/docs/adr/0003-gate-model-hitl-pause.md +38 -0
- package/docs/adr/0004-state-schema-phases-sections.md +6 -3
- package/docs/adr/0005-hooks-as-engine-subcommands.md +39 -0
- package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +50 -0
- package/docs/adr/0007-canonical-live-workspace-filenames.md +36 -0
- package/docs/adr/0008-sanctioned-engine-network-boundary.md +33 -0
- package/docs/adr/README.md +58 -0
- package/docs/agents/issue-driven-rites.md +5 -5
- package/docs/architecture.md +61 -45
- package/docs/capability-surface-selection.md +3 -1
- package/docs/cli.md +9 -2
- package/docs/command-map.md +6 -2
- package/docs/engine/agent-contract.md +5 -3
- package/docs/engine/commands.md +27 -14
- package/docs/engine/state-schema.md +14 -9
- package/docs/engine/workspace-schema.md +5 -4
- package/docs/flow.md +18 -7
- package/docs/porting-to-a-new-harness.md +6 -2
- package/docs/release.md +10 -9
- package/docs/skills.md +1 -1
- package/docs/templates/CRITIC_REVIEW_PACKET_TEMPLATE.md +35 -0
- package/docs/usage.md +26 -16
- package/engine/internal/lib/packageexistence.go +729 -52
- package/engine/internal/lib/packageexistence_test.go +343 -0
- package/engine/internal/lib/util.go +1 -1
- package/engine/internal/migrate/migrate.go +16 -11
- package/engine/internal/migrate/migrate_test.go +26 -4
- package/engine/internal/state/feature.go +28 -14
- package/engine/internal/state/schema.go +20 -8
- package/engine/internal/state/state_test.go +6 -6
- package/engine/tests/budget_test.go +2 -2
- package/engine/tests/meta_test.go +3 -3
- package/engine/tests/migrate_cli_test.go +24 -22
- package/pack/.claude/skills/devrites-lib/SKILL.md +7 -9
- package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +11 -3
- package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +2 -3
- package/pack/.claude/skills/rite/SKILL.md +6 -7
- package/pack/.claude/skills/rite/reference/menu.md +5 -5
- package/pack/.claude/skills/rite-adopt/SKILL.md +2 -3
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +7 -9
- package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +11 -3
- package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +2 -3
- package/pack/generated/claude/skills/rite/SKILL.md +6 -7
- package/pack/generated/claude/skills/rite/reference/menu.md +5 -5
- package/pack/generated/claude/skills/rite-adopt/SKILL.md +2 -3
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +7 -9
- package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +11 -3
- package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +2 -3
- package/pack/generated/codex/skills/rite/SKILL.md +6 -7
- package/pack/generated/codex/skills/rite/reference/menu.md +5 -5
- package/pack/generated/codex/skills/rite-adopt/SKILL.md +2 -3
- package/package.json +2 -1
- package/scripts/skills-inventory.mjs +18 -0
- package/scripts/validate-workflow-security.py +3 -8
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPackageExistenceJavaScriptWorkspaces(t *testing.T) {
|
|
13
|
+
tests := []struct {
|
|
14
|
+
name string
|
|
15
|
+
sourcePath string
|
|
16
|
+
workingDir string
|
|
17
|
+
importLine string
|
|
18
|
+
files map[string]string
|
|
19
|
+
wantCode int
|
|
20
|
+
wantFinding string
|
|
21
|
+
}{
|
|
22
|
+
{
|
|
23
|
+
name: "nested workspace",
|
|
24
|
+
sourcePath: "frontend/src/example.ts",
|
|
25
|
+
workingDir: "frontend",
|
|
26
|
+
importLine: `import { Dialog } from "@ark-ui/svelte/dialog";`,
|
|
27
|
+
files: map[string]string{
|
|
28
|
+
"frontend/package.json": `{"dependencies":{"@ark-ui/svelte":"^5.22.1"}}`,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "scoped package subpath",
|
|
33
|
+
sourcePath: "app/src/example.ts",
|
|
34
|
+
importLine: `import value from "@scope/package/subpath";`,
|
|
35
|
+
files: map[string]string{
|
|
36
|
+
"app/package.json": `{"devDependencies":{"@scope/package":"1.0.0"}}`,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "wildcard alias",
|
|
41
|
+
sourcePath: "frontend/src/example.ts",
|
|
42
|
+
importLine: `import Button from "$lib/components/button";`,
|
|
43
|
+
files: map[string]string{
|
|
44
|
+
"frontend/package.json": `{}`,
|
|
45
|
+
"frontend/tsconfig.json": "{\n // SvelteKit generates the inherited aliases.\n \"extends\": \"./.svelte-kit/tsconfig.json\"\n}",
|
|
46
|
+
"frontend/.svelte-kit/tsconfig.json": `{"compilerOptions":{"paths":{"$lib/*":["../src/lib/*"]}}}`,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "exact alias",
|
|
51
|
+
sourcePath: "frontend/src/example.ts",
|
|
52
|
+
importLine: `import library from "$lib";`,
|
|
53
|
+
files: map[string]string{
|
|
54
|
+
"frontend/package.json": `{}`,
|
|
55
|
+
"frontend/tsconfig.json": `{"compilerOptions":{"paths":{"$lib":["./src/lib"]}}}`,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "package shaped alias",
|
|
60
|
+
sourcePath: "frontend/src/example.svelte",
|
|
61
|
+
importLine: `import { css } from "styled-system/css";`,
|
|
62
|
+
files: map[string]string{
|
|
63
|
+
"frontend/package.json": `{}`,
|
|
64
|
+
"frontend/jsconfig.json": `{"compilerOptions":{"paths":{"styled-system/*":["./styled-system/*"]}}}`,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "undeclared dependency",
|
|
69
|
+
sourcePath: "frontend/src/example.ts",
|
|
70
|
+
importLine: `import missing from "definitely-missing-package";`,
|
|
71
|
+
files: map[string]string{"frontend/package.json": `{}`},
|
|
72
|
+
wantCode: 3,
|
|
73
|
+
wantFinding: "definitely-missing-package",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "sibling isolation",
|
|
77
|
+
sourcePath: "workspace-a/src/example.ts",
|
|
78
|
+
importLine: `import sibling from "sibling-only-package";`,
|
|
79
|
+
files: map[string]string{
|
|
80
|
+
"workspace-a/package.json": `{}`,
|
|
81
|
+
"workspace-b/package.json": `{"dependencies":{"sibling-only-package":"1.0.0"}}`,
|
|
82
|
+
},
|
|
83
|
+
wantCode: 3,
|
|
84
|
+
wantFinding: "sibling-only-package",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "malformed config does not allow alias",
|
|
88
|
+
sourcePath: "frontend/src/example.ts",
|
|
89
|
+
importLine: `import { css } from "styled-system/css";`,
|
|
90
|
+
files: map[string]string{
|
|
91
|
+
"frontend/package.json": `{}`,
|
|
92
|
+
"frontend/tsconfig.json": `{"compilerOptions":{"paths":{"styled-system/*":["./styled-system/*"]}}`,
|
|
93
|
+
},
|
|
94
|
+
wantCode: 3,
|
|
95
|
+
wantFinding: "styled-system",
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for _, test := range tests {
|
|
100
|
+
t.Run(test.name, func(t *testing.T) {
|
|
101
|
+
repo := t.TempDir()
|
|
102
|
+
writePackageExistenceFile(t, repo, "Cargo.toml", "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n")
|
|
103
|
+
writePackageExistenceFile(t, repo, test.sourcePath, "// baseline\n")
|
|
104
|
+
for path, content := range test.files {
|
|
105
|
+
writePackageExistenceFile(t, repo, path, content+"\n")
|
|
106
|
+
}
|
|
107
|
+
gitPackageExistence(t, repo, "init", "-q")
|
|
108
|
+
gitPackageExistence(t, repo, "config", "user.email", "test@example.com")
|
|
109
|
+
gitPackageExistence(t, repo, "config", "user.name", "Test")
|
|
110
|
+
gitPackageExistence(t, repo, "add", ".")
|
|
111
|
+
gitPackageExistence(t, repo, "commit", "-qm", "baseline")
|
|
112
|
+
if err := os.MkdirAll(featureDir(repo, "feat"), 0o755); err != nil {
|
|
113
|
+
t.Fatal(err)
|
|
114
|
+
}
|
|
115
|
+
writePackageExistenceFile(t, repo, test.sourcePath, "// baseline\n"+test.importLine+"\n")
|
|
116
|
+
t.Chdir(filepath.Join(repo, test.workingDir))
|
|
117
|
+
|
|
118
|
+
var stdout, stderr bytes.Buffer
|
|
119
|
+
gotCode := PackageExistence(repo, []string{"feat"}, &stdout, &stderr)
|
|
120
|
+
if gotCode != test.wantCode {
|
|
121
|
+
t.Fatalf("code = %d, want %d\nstdout: %s\nstderr: %s", gotCode, test.wantCode, stdout.String(), stderr.String())
|
|
122
|
+
}
|
|
123
|
+
if test.wantFinding != "" && !strings.Contains(stderr.String(), test.wantFinding) {
|
|
124
|
+
t.Fatalf("stderr does not name %q: %s", test.wantFinding, stderr.String())
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestPackageExistenceNonJavaScriptWorkspaces(t *testing.T) {
|
|
131
|
+
tests := []struct {
|
|
132
|
+
name string
|
|
133
|
+
sourcePath string
|
|
134
|
+
addedSource string
|
|
135
|
+
files map[string]string
|
|
136
|
+
wantCode int
|
|
137
|
+
wantFinding string
|
|
138
|
+
}{
|
|
139
|
+
{
|
|
140
|
+
name: "go import block rejects undeclared module",
|
|
141
|
+
sourcePath: "main.go",
|
|
142
|
+
addedSource: "package main\n\nimport (\n\t\"definitely.invalid/missing\"\n)\n",
|
|
143
|
+
files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n"},
|
|
144
|
+
wantCode: 3,
|
|
145
|
+
wantFinding: "definitely.invalid/missing",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "go modules do not collide by hostname",
|
|
149
|
+
sourcePath: "main.go",
|
|
150
|
+
addedSource: "package main\n\nimport \"github.com/evil/missing\"\n",
|
|
151
|
+
files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n\nrequire github.com/good/pkg v1.0.0\n"},
|
|
152
|
+
wantCode: 3,
|
|
153
|
+
wantFinding: "github.com/evil/missing",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "nested go module declaration",
|
|
157
|
+
sourcePath: "backend/main.go",
|
|
158
|
+
addedSource: "package main\n\nimport \"github.com/good/pkg/sub\"\n",
|
|
159
|
+
files: map[string]string{
|
|
160
|
+
"Cargo.toml": "[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
|
|
161
|
+
"backend/go.mod": "module example.com/backend\n\ngo 1.26\n\nrequire github.com/good/pkg v1.0.0\n",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "go standard library subpackage",
|
|
166
|
+
sourcePath: "main.go",
|
|
167
|
+
addedSource: "package main\n\nimport \"encoding/json\"\n",
|
|
168
|
+
files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n"},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "go standard library typo remains undeclared",
|
|
172
|
+
sourcePath: "main.go",
|
|
173
|
+
addedSource: "package main\n\nimport \"encoding/josn\"\n",
|
|
174
|
+
files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n"},
|
|
175
|
+
wantCode: 3,
|
|
176
|
+
wantFinding: "encoding/josn",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "malformed go manifest does not declare import",
|
|
180
|
+
sourcePath: "main.go",
|
|
181
|
+
addedSource: "package main\n\nimport \"github.com/missing/pkg\"\n",
|
|
182
|
+
files: map[string]string{"go.mod": "module example.com/app\n\ngo 1.26\n\nrequire (\n github.com/missing/pkg v1.0.0\n"},
|
|
183
|
+
wantCode: 3,
|
|
184
|
+
wantFinding: "github.com/missing/pkg",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "rust rejects undeclared crate",
|
|
188
|
+
sourcePath: "src/main.rs",
|
|
189
|
+
addedSource: "use definitely_missing_crate::Thing;\n",
|
|
190
|
+
files: map[string]string{"Cargo.toml": "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[dependencies]\n"},
|
|
191
|
+
wantCode: 3,
|
|
192
|
+
wantFinding: "definitely_missing_crate",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "nested rust dependency and hyphen normalization",
|
|
196
|
+
sourcePath: "crates/app/src/main.rs",
|
|
197
|
+
addedSource: "use serde_json::Value;\n",
|
|
198
|
+
files: map[string]string{
|
|
199
|
+
"Cargo.toml": "[workspace]\nmembers = [\"crates/app\"]\n",
|
|
200
|
+
"crates/app/Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies.serde-json]\nversion = \"1\"\n",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "python dotted import uses top-level module",
|
|
205
|
+
sourcePath: "app.py",
|
|
206
|
+
addedSource: "from requests.sessions import Session\n",
|
|
207
|
+
files: map[string]string{"requirements.txt": "requests==2.32.0\n"},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "python standard library module",
|
|
211
|
+
sourcePath: "app.py",
|
|
212
|
+
addedSource: "import asyncio\n",
|
|
213
|
+
files: map[string]string{"requirements.txt": "requests==2.32.0\n"},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "nested python manifest declaration",
|
|
217
|
+
sourcePath: "backend/app.py",
|
|
218
|
+
addedSource: "import requests\n",
|
|
219
|
+
files: map[string]string{
|
|
220
|
+
"Cargo.toml": "[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
|
|
221
|
+
"backend/requirements.txt": "requests==2.32.0\n",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "pyproject dependency declaration",
|
|
226
|
+
sourcePath: "service/app.py",
|
|
227
|
+
addedSource: "import requests\n",
|
|
228
|
+
files: map[string]string{
|
|
229
|
+
"service/pyproject.toml": "[project]\ndependencies = [\n \"requests[socks]>=2.32\",\n]\n",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "pipfile dependency declaration",
|
|
234
|
+
sourcePath: "service/app.py",
|
|
235
|
+
addedSource: "import requests\n",
|
|
236
|
+
files: map[string]string{
|
|
237
|
+
"service/Pipfile": "[packages]\nrequests = \"*\"\n",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "malformed pyproject does not declare import",
|
|
242
|
+
sourcePath: "service/app.py",
|
|
243
|
+
addedSource: "import definitely_missing\n",
|
|
244
|
+
files: map[string]string{
|
|
245
|
+
"service/pyproject.toml": "[project]\ndependencies = [\n \"definitely_missing\"\n",
|
|
246
|
+
},
|
|
247
|
+
wantCode: 3,
|
|
248
|
+
wantFinding: "definitely_missing",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "python multiple imports reject undeclared module",
|
|
252
|
+
sourcePath: "app.py",
|
|
253
|
+
addedSource: "import requests, definitely_missing\n",
|
|
254
|
+
files: map[string]string{"requirements.txt": "requests==2.32.0\n"},
|
|
255
|
+
wantCode: 3,
|
|
256
|
+
wantFinding: "definitely_missing",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: "comments and other ecosystems do not declare python imports",
|
|
260
|
+
sourcePath: "app.py",
|
|
261
|
+
addedSource: "import definitely_missing\n",
|
|
262
|
+
files: map[string]string{
|
|
263
|
+
"Cargo.toml": "[package]\nname = \"definitely_missing\"\nversion = \"0.1.0\"\n",
|
|
264
|
+
"requirements.txt": "# definitely_missing is intentionally absent\n",
|
|
265
|
+
},
|
|
266
|
+
wantCode: 3,
|
|
267
|
+
wantFinding: "definitely_missing",
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for _, test := range tests {
|
|
272
|
+
t.Run(test.name, func(t *testing.T) {
|
|
273
|
+
repo := t.TempDir()
|
|
274
|
+
for path, content := range test.files {
|
|
275
|
+
writePackageExistenceFile(t, repo, path, content)
|
|
276
|
+
}
|
|
277
|
+
writePackageExistenceFile(t, repo, test.sourcePath, "// baseline\n")
|
|
278
|
+
gitPackageExistence(t, repo, "init", "-q")
|
|
279
|
+
gitPackageExistence(t, repo, "config", "user.email", "test@example.com")
|
|
280
|
+
gitPackageExistence(t, repo, "config", "user.name", "Test")
|
|
281
|
+
gitPackageExistence(t, repo, "add", ".")
|
|
282
|
+
gitPackageExistence(t, repo, "commit", "-qm", "baseline")
|
|
283
|
+
if err := os.MkdirAll(featureDir(repo, "feat"), 0o755); err != nil {
|
|
284
|
+
t.Fatal(err)
|
|
285
|
+
}
|
|
286
|
+
writePackageExistenceFile(t, repo, test.sourcePath, test.addedSource)
|
|
287
|
+
t.Chdir(repo)
|
|
288
|
+
|
|
289
|
+
var stdout, stderr bytes.Buffer
|
|
290
|
+
gotCode := PackageExistence(repo, []string{"feat"}, &stdout, &stderr)
|
|
291
|
+
if gotCode != test.wantCode {
|
|
292
|
+
t.Fatalf("code = %d, want %d\nstdout: %s\nstderr: %s", gotCode, test.wantCode, stdout.String(), stderr.String())
|
|
293
|
+
}
|
|
294
|
+
if test.wantFinding != "" && !strings.Contains(stderr.String(), test.wantFinding) {
|
|
295
|
+
t.Fatalf("stderr does not name %q: %s", test.wantFinding, stderr.String())
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func TestPackageExistenceGenericResolverFailsClosed(t *testing.T) {
|
|
302
|
+
repo := t.TempDir()
|
|
303
|
+
writePackageExistenceFile(t, repo, "package.json", `{"dependencies":{"definitely-missing-package":"1.0.0"}}`)
|
|
304
|
+
writePackageExistenceFile(t, repo, "src/example.custom", "// baseline\n")
|
|
305
|
+
gitPackageExistence(t, repo, "init", "-q")
|
|
306
|
+
gitPackageExistence(t, repo, "config", "user.email", "test@example.com")
|
|
307
|
+
gitPackageExistence(t, repo, "config", "user.name", "Test")
|
|
308
|
+
gitPackageExistence(t, repo, "add", ".")
|
|
309
|
+
gitPackageExistence(t, repo, "commit", "-qm", "baseline")
|
|
310
|
+
if err := os.MkdirAll(featureDir(repo, "feat"), 0o755); err != nil {
|
|
311
|
+
t.Fatal(err)
|
|
312
|
+
}
|
|
313
|
+
writePackageExistenceFile(t, repo, "src/example.custom", "import \"definitely-missing-package\"\n")
|
|
314
|
+
t.Chdir(repo)
|
|
315
|
+
|
|
316
|
+
var stdout, stderr bytes.Buffer
|
|
317
|
+
gotCode := PackageExistence(repo, []string{"feat"}, &stdout, &stderr)
|
|
318
|
+
if gotCode != 3 {
|
|
319
|
+
t.Fatalf("code = %d, want 3\nstdout: %s\nstderr: %s", gotCode, stdout.String(), stderr.String())
|
|
320
|
+
}
|
|
321
|
+
if !strings.Contains(stderr.String(), "definitely-missing-package") {
|
|
322
|
+
t.Fatalf("stderr does not name unresolved import: %s", stderr.String())
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func writePackageExistenceFile(t *testing.T, root, path, content string) {
|
|
327
|
+
t.Helper()
|
|
328
|
+
fullPath := filepath.Join(root, filepath.FromSlash(path))
|
|
329
|
+
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
|
330
|
+
t.Fatal(err)
|
|
331
|
+
}
|
|
332
|
+
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
|
|
333
|
+
t.Fatal(err)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
func gitPackageExistence(t *testing.T, root string, args ...string) {
|
|
338
|
+
t.Helper()
|
|
339
|
+
cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
|
|
340
|
+
if out, err := cmd.CombinedOutput(); err != nil {
|
|
341
|
+
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -14,7 +14,7 @@ func gitToplevel(dir string) string {
|
|
|
14
14
|
if err != nil {
|
|
15
15
|
return ""
|
|
16
16
|
}
|
|
17
|
-
return strings.TrimSpace(string(out))
|
|
17
|
+
return filepath.Clean(strings.TrimSpace(string(out)))
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// gitDiffNames lists the paths that differ, relative to gitRoot. With one ref it
|
|
@@ -90,11 +90,11 @@ func featureDirsNeedingNormalization(root string) ([]normalizationTarget, error)
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
func needsNormalizeFeature(dir string) bool {
|
|
93
|
-
if !regularFileExists(filepath.Join(dir,
|
|
93
|
+
if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
|
|
94
94
|
return true
|
|
95
95
|
}
|
|
96
|
-
return regularFileExists(filepath.Join(dir, "
|
|
97
|
-
regularFileExists(filepath.Join(dir,
|
|
96
|
+
return regularFileExists(filepath.Join(dir, "proof.md")) && !regularFileExists(filepath.Join(dir, state.EvidenceFile)) ||
|
|
97
|
+
regularFileExists(filepath.Join(dir, "status.md")) && !regularFileExists(filepath.Join(dir, state.LedgerFile))
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
func regularFileExists(path string) bool {
|
|
@@ -103,16 +103,21 @@ func regularFileExists(path string) bool {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
func normalizeFeatureDir(dir, slug string) error {
|
|
106
|
-
|
|
106
|
+
for _, alias := range state.WorkspaceMapFiles()[1:] {
|
|
107
|
+
if err := copyAliasFile(dir, alias, state.WorkspaceMapFile); err != nil {
|
|
108
|
+
return fmt.Errorf("copy %s to %s: %w", alias, state.WorkspaceMapFile, err)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
|
|
107
112
|
phase := derivePhase(filepath.Join(dir, state.LedgerFile))
|
|
108
|
-
if err := state.AtomicWrite(filepath.Join(dir,
|
|
109
|
-
return fmt.Errorf("write
|
|
113
|
+
if err := state.AtomicWrite(filepath.Join(dir, state.WorkspaceMapFile), []byte(workspaceIndex(slug, phase)), 0o644); err != nil {
|
|
114
|
+
return fmt.Errorf("write workspace index: %w", err)
|
|
110
115
|
}
|
|
111
116
|
}
|
|
112
|
-
if err := copyAliasFile(dir, "
|
|
113
|
-
return fmt.Errorf("copy
|
|
117
|
+
if err := copyAliasFile(dir, "proof.md", state.EvidenceFile); err != nil {
|
|
118
|
+
return fmt.Errorf("copy proof.md to evidence.md: %w", err)
|
|
114
119
|
}
|
|
115
|
-
return copyAliasFile(dir,
|
|
120
|
+
return copyAliasFile(dir, "status.md", state.LedgerFile)
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
func copyAliasFile(dir, alias, canonical string) error {
|
|
@@ -128,8 +133,8 @@ func copyAliasFile(dir, alias, canonical string) error {
|
|
|
128
133
|
return state.AtomicWrite(dst, data, 0o644)
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
//
|
|
132
|
-
func
|
|
136
|
+
// workspaceIndex renders the compact map added to a normalized workspace.
|
|
137
|
+
func workspaceIndex(slug string, phase state.Phase) string {
|
|
133
138
|
return fmt.Sprintf(`---
|
|
134
139
|
slug: %s
|
|
135
140
|
title: %s
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package migrate
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"os"
|
|
4
5
|
"path/filepath"
|
|
5
6
|
"strings"
|
|
6
7
|
"testing"
|
|
@@ -33,8 +34,11 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
assertFile(t, root, "work/alpha/spec.md", "spec\n")
|
|
36
|
-
assertFile(t, root, "work/alpha/
|
|
37
|
-
assertFile(t, root, "work/alpha/
|
|
37
|
+
assertFile(t, root, "work/alpha/evidence.md", "proof\n")
|
|
38
|
+
assertFile(t, root, "work/alpha/state.md", "status: done - shipped\n")
|
|
39
|
+
assertFileContains(t, root, "work/alpha/README.md", "phase: done")
|
|
40
|
+
assertMissing(t, root, "work/alpha/proof.md")
|
|
41
|
+
assertMissing(t, root, "work/alpha/status.md")
|
|
38
42
|
assertFile(t, root, "work/alpha/review.md", "review\n")
|
|
39
43
|
|
|
40
44
|
f, err := state.LoadFeature(root, "alpha")
|
|
@@ -57,8 +61,9 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
|
|
|
57
61
|
|
|
58
62
|
func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
|
|
59
63
|
root := t.TempDir()
|
|
60
|
-
testutil.WriteFile(t, filepath.Join(root, "features/beta/
|
|
61
|
-
testutil.WriteFile(t, filepath.Join(root, "features/beta/
|
|
64
|
+
testutil.WriteFile(t, filepath.Join(root, "features/beta/feature.md"), "# Beta\n")
|
|
65
|
+
testutil.WriteFile(t, filepath.Join(root, "features/beta/status.md"), "- Phase: prove\n")
|
|
66
|
+
testutil.WriteFile(t, filepath.Join(root, "features/beta/proof.md"), "evidence\n")
|
|
62
67
|
|
|
63
68
|
res, err := Run(root)
|
|
64
69
|
if err != nil {
|
|
@@ -67,6 +72,9 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
|
|
|
67
72
|
if got := strings.Join(res.Migrated, ","); got != "beta" {
|
|
68
73
|
t.Fatalf("Run normalized=%q, want beta", got)
|
|
69
74
|
}
|
|
75
|
+
assertFile(t, root, "features/beta/README.md", "# Beta\n")
|
|
76
|
+
assertFile(t, root, "features/beta/state.md", "- Phase: prove\n")
|
|
77
|
+
assertFile(t, root, "features/beta/evidence.md", "evidence\n")
|
|
70
78
|
assertFile(t, root, "features/beta/status.md", "- Phase: prove\n")
|
|
71
79
|
assertFile(t, root, "features/beta/proof.md", "evidence\n")
|
|
72
80
|
|
|
@@ -111,3 +119,17 @@ func assertFile(t *testing.T, root, rel, want string) {
|
|
|
111
119
|
t.Fatalf("%s=%q, want %q", rel, got, want)
|
|
112
120
|
}
|
|
113
121
|
}
|
|
122
|
+
|
|
123
|
+
func assertFileContains(t *testing.T, root, rel, want string) {
|
|
124
|
+
t.Helper()
|
|
125
|
+
if got := testutil.ReadFile(t, filepath.Join(root, rel)); !strings.Contains(got, want) {
|
|
126
|
+
t.Fatalf("%s=%q, want it to contain %q", rel, got, want)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func assertMissing(t *testing.T, root, rel string) {
|
|
131
|
+
t.Helper()
|
|
132
|
+
if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
|
|
133
|
+
t.Fatalf("%s exists or stat failed with %v, want missing", rel, err)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -66,8 +66,8 @@ func featureDir(root, slug string) string {
|
|
|
66
66
|
|
|
67
67
|
// ListFeatures returns the slugs of every feature under root — directories under
|
|
68
68
|
// canonical work/ plus compatibility features/ recognized as a feature — sorted.
|
|
69
|
-
// A directory is a feature if it has a
|
|
70
|
-
// ledger (state.md), so a live workspace the pack created without a
|
|
69
|
+
// A directory is a feature if it has a workspace map OR the working-state
|
|
70
|
+
// ledger (state.md), so a live workspace the pack created without a map
|
|
71
71
|
// still lists. Missing layout directories yield an empty list, not an error.
|
|
72
72
|
func ListFeatures(root string) ([]string, error) {
|
|
73
73
|
seen := map[string]bool{}
|
|
@@ -96,11 +96,18 @@ func ListFeatures(root string) ([]string, error) {
|
|
|
96
96
|
return slugs, nil
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// isFeatureDir reports whether dir
|
|
100
|
-
//
|
|
99
|
+
// isFeatureDir reports whether dir carries a workspace map or the working-state
|
|
100
|
+
// ledger. Either is a sufficient phase source for LoadFeature.
|
|
101
101
|
func isFeatureDir(dir string) bool {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
if regularFileExists(filepath.Join(dir, LedgerFile)) {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
for _, name := range workspaceMapFiles {
|
|
106
|
+
if regularFileExists(filepath.Join(dir, name)) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
func regularFileExists(path string) bool {
|
|
@@ -109,18 +116,25 @@ func regularFileExists(path string) bool {
|
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
// LoadFeature reads feature <slug> under root. The phase comes from the live
|
|
112
|
-
// working-state ledger when it declares one, otherwise from
|
|
113
|
-
// frontmatter. A feature with neither a
|
|
114
|
-
// Section content is read from the canonical section files or their
|
|
119
|
+
// working-state ledger when it declares one, otherwise from workspace-map
|
|
120
|
+
// frontmatter. A feature with neither a map nor a ledger does not exist.
|
|
121
|
+
// Section content is read from the canonical section files or their
|
|
115
122
|
// aliases (see sectionFiles).
|
|
116
123
|
func LoadFeature(root, slug string) (*Feature, error) {
|
|
117
124
|
dir := featureDir(root, slug)
|
|
118
125
|
|
|
119
|
-
manifest
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
var manifest []byte
|
|
127
|
+
for _, name := range workspaceMapFiles {
|
|
128
|
+
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
129
|
+
if err == nil {
|
|
130
|
+
manifest = data
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
if !errors.Is(err, os.ErrNotExist) {
|
|
134
|
+
return nil, fmt.Errorf("feature %q: read %s: %w", slug, name, err)
|
|
135
|
+
}
|
|
122
136
|
}
|
|
123
|
-
hasManifest :=
|
|
137
|
+
hasManifest := manifest != nil
|
|
124
138
|
hasLedger := regularFileExists(filepath.Join(dir, LedgerFile))
|
|
125
139
|
if !hasManifest && !hasLedger {
|
|
126
140
|
return nil, fmt.Errorf("feature %q not found", slug)
|
|
@@ -150,7 +164,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
|
|
|
150
164
|
phase = manifestPhase
|
|
151
165
|
}
|
|
152
166
|
if phase == "" {
|
|
153
|
-
return nil, fmt.Errorf("feature %q: no phase in
|
|
167
|
+
return nil, fmt.Errorf("feature %q: no phase in workspace-map frontmatter or %s ledger", slug, LedgerFile)
|
|
154
168
|
}
|
|
155
169
|
if !KnownPhase(phase) {
|
|
156
170
|
return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
|
|
@@ -3,11 +3,23 @@ package state
|
|
|
3
3
|
//go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
|
|
4
4
|
|
|
5
5
|
// SchemaVersion is the .devrites state-schema version this engine understands.
|
|
6
|
-
// A
|
|
6
|
+
// A workspace map may declare its own schemaVersion in frontmatter; the engine
|
|
7
7
|
// refuses a version newer than this (see LoadFeature) and otherwise reads the
|
|
8
8
|
// files, which evolve additively.
|
|
9
9
|
const SchemaVersion = 1
|
|
10
10
|
|
|
11
|
+
const (
|
|
12
|
+
WorkspaceMapFile = "README.md"
|
|
13
|
+
EvidenceFile = "evidence.md"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
var workspaceMapFiles = []string{WorkspaceMapFile, "feature.md", "index.md"}
|
|
17
|
+
|
|
18
|
+
// WorkspaceMapFiles returns the canonical workspace map followed by readable aliases.
|
|
19
|
+
func WorkspaceMapFiles() []string {
|
|
20
|
+
return append([]string(nil), workspaceMapFiles...)
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
// Section is one single-concern completeness file within a feature directory.
|
|
12
24
|
// Splitting a feature into small files (rather than one long document) keeps
|
|
13
25
|
// each file context-cheap and makes completeness self-evident.
|
|
@@ -33,23 +45,23 @@ var Sections = []Section{
|
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
// sectionFiles lists the filenames that can satisfy each section, canonical name
|
|
36
|
-
// first, then
|
|
37
|
-
//
|
|
48
|
+
// first, then supported aliases — the same mapping `devrites-engine migrate`
|
|
49
|
+
// normalizes (proof→evidence, status→state). A section
|
|
38
50
|
// counts as present if ANY of its files has real content, so the engine reads a
|
|
39
|
-
// live workspace before the pack sweep converges the filenames. The
|
|
40
|
-
//
|
|
51
|
+
// live workspace before the pack sweep converges the filenames. The workspace
|
|
52
|
+
// map is not a section; it is handled separately in LoadFeature.
|
|
41
53
|
var sectionFiles = map[Section][]string{
|
|
42
54
|
SectionSpec: {"spec.md"},
|
|
43
55
|
SectionPlan: {"plan.md"},
|
|
44
56
|
SectionDecisions: {"decisions.md"},
|
|
45
57
|
SectionTasks: {"tasks.md"},
|
|
46
|
-
SectionProof: {
|
|
47
|
-
SectionStatus: {"
|
|
58
|
+
SectionProof: {EvidenceFile, "proof.md"},
|
|
59
|
+
SectionStatus: {"state.md", "status.md"},
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
// LedgerFile is the working-state ledger the live pack writes. It carries the
|
|
51
63
|
// phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
|
|
52
|
-
// when no
|
|
64
|
+
// when no workspace map declares one, and it satisfies the status section.
|
|
53
65
|
const LedgerFile = "state.md"
|
|
54
66
|
|
|
55
67
|
// Phase is a workflow state. The order mirrors the rite-* arc.
|
|
@@ -138,9 +138,9 @@ func writeWorkSection(t *testing.T, root, slug, name, body string) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// A live workspace
|
|
142
|
-
// the state.md ledger and the proof/status
|
|
143
|
-
//
|
|
141
|
+
// A live workspace map need not carry frontmatter: the phase lives in
|
|
142
|
+
// the canonical state.md ledger and the proof/status concepts are satisfied by
|
|
143
|
+
// evidence.md/state.md. The engine must load, list, and report it anyway.
|
|
144
144
|
func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
|
|
145
145
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
146
146
|
writeSection(t, root, "live", "state.md", "- Phase: prove\n- Status: running\n")
|
|
@@ -148,7 +148,7 @@ func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
|
|
|
148
148
|
writeSection(t, root, "live", "plan.md", "# Plan\n\nApproach.\n")
|
|
149
149
|
writeSection(t, root, "live", "decisions.md", "# Decisions\n\nChose X.\n")
|
|
150
150
|
writeSection(t, root, "live", "tasks.md", "# Tasks\n\n- [x] slice 1\n")
|
|
151
|
-
writeSection(t, root, "live", "evidence.md", "# Evidence\n\nTests pass.\n")
|
|
151
|
+
writeSection(t, root, "live", "evidence.md", "# Evidence\n\nTests pass.\n")
|
|
152
152
|
|
|
153
153
|
rep, err := Status(root, "live")
|
|
154
154
|
if err != nil {
|
|
@@ -158,10 +158,10 @@ func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
|
|
|
158
158
|
t.Errorf("phase = %q, want prove (from the state.md ledger)", rep.Phase)
|
|
159
159
|
}
|
|
160
160
|
if !rep.Present[SectionProof] {
|
|
161
|
-
t.Error("proof section should be present via
|
|
161
|
+
t.Error("proof section should be present via canonical evidence.md")
|
|
162
162
|
}
|
|
163
163
|
if !rep.Present[SectionStatus] {
|
|
164
|
-
t.Error("status section should be present via
|
|
164
|
+
t.Error("status section should be present via canonical state.md")
|
|
165
165
|
}
|
|
166
166
|
if !rep.Complete() {
|
|
167
167
|
t.Errorf("prove-phase feature should be complete, missing: %v", rep.Missing)
|
|
@@ -7,8 +7,8 @@ import (
|
|
|
7
7
|
|
|
8
8
|
// TestStatusLiveWorkspace is the P2 acceptance check: `devrites-engine status <slug>`
|
|
9
9
|
// must report a canonical work/<slug> feature the live pack created without a
|
|
10
|
-
//
|
|
11
|
-
// evidence.md/state.md
|
|
10
|
+
// workspace-map frontmatter — phase from the canonical state.md ledger and proof/status
|
|
11
|
+
// completeness from canonical evidence.md/state.md files. Before schema unification this returned
|
|
12
12
|
// "feature not found".
|
|
13
13
|
func TestStatusLiveWorkspace(t *testing.T) {
|
|
14
14
|
work := t.TempDir()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
package main_test
|
|
2
2
|
|
|
3
|
-
// Cross-cutting assertions
|
|
4
|
-
//
|
|
3
|
+
// Cross-cutting assertions: network I/O stays inside the one sanctioned package,
|
|
4
|
+
// and the inline fail-open guard no-ops when the binary is absent.
|
|
5
5
|
|
|
6
6
|
import (
|
|
7
7
|
"bytes"
|
|
@@ -11,7 +11,7 @@ import (
|
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
// TestFirstPartyMakesNoNetworkCalls asserts that no first-party package imports a
|
|
14
|
-
// network client EXCEPT internal/iohooks — the
|
|
14
|
+
// network client EXCEPT internal/iohooks — the one sanctioned network surface,
|
|
15
15
|
// where the source-citation cache does conditional-HEAD revalidation. Confining
|
|
16
16
|
// network to that single, auditable package keeps the rest of the engine a
|
|
17
17
|
// network-free control plane that makes zero model calls (PRD: "zero API"). The
|