devrites 4.1.0 → 4.2.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/engine/commands.md +65 -6
- package/engine/go.mod +1 -1
- package/engine/internal/gate/gate.go +154 -46
- package/engine/internal/gate/gate_test.go +290 -2
- package/engine/internal/gate/readiness_binding.go +66 -100
- package/engine/internal/gate/readiness_binding_test.go +126 -3
- package/engine/internal/lib/resolve.go +6 -6
- package/engine/internal/state/cmd/workflowmanifest/main.go +32 -3
- package/engine/internal/state/feature.go +1 -100
- package/engine/internal/state/observation.go +584 -0
- package/engine/internal/state/observation_open_other.go +9 -0
- package/engine/internal/state/observation_open_unix.go +12 -0
- package/engine/internal/state/observation_test.go +960 -0
- package/engine/internal/state/observation_unix_test.go +68 -0
- package/engine/internal/state/schema.go +60 -160
- package/engine/internal/state/state_test.go +489 -101
- package/engine/internal/state/status.go +67 -48
- package/engine/tests/adr_0004_required_by_phase_test.go +16 -13
- package/engine/tests/adr_0011_define_plan_test.go +14 -12
- package/engine/tests/gate_test.go +403 -28
- package/engine/tests/workspace_observation_migration_test.go +1065 -0
- package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -10
- package/pack/.claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
- package/pack/.claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
- package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +98 -146
- package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +96 -134
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
- package/pack/.claude/skills/rite-build/SKILL.md +51 -79
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -17
- package/pack/.claude/skills/rite-plan/SKILL.md +17 -17
- package/pack/.claude/skills/rite-plan/reference/anti-patterns.md +11 -1
- package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
- package/pack/.claude/skills/rite-prove/SKILL.md +95 -153
- package/pack/.claude/skills/rite-vet/SKILL.md +121 -174
- package/pack/.claude/skills/rite-vet/reference/anti-patterns.md +14 -2
- package/pack/.claude/skills/rite-vet/reference/artifacts.md +10 -6
- package/pack/.claude/skills/rite-vet/reference/depth.md +10 -3
- package/pack/.claude/skills/rite-vet/reference/review-axes.md +12 -4
- package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -10
- package/pack/generated/claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
- package/pack/generated/claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
- package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +98 -146
- package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +96 -134
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
- package/pack/generated/claude/skills/rite-build/SKILL.md +51 -79
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -17
- package/pack/generated/claude/skills/rite-plan/SKILL.md +17 -17
- package/pack/generated/claude/skills/rite-plan/reference/anti-patterns.md +11 -1
- package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
- package/pack/generated/claude/skills/rite-prove/SKILL.md +95 -153
- package/pack/generated/claude/skills/rite-vet/SKILL.md +121 -174
- package/pack/generated/claude/skills/rite-vet/reference/anti-patterns.md +14 -2
- package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +10 -6
- package/pack/generated/claude/skills/rite-vet/reference/depth.md +10 -3
- package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +12 -4
- package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -10
- package/pack/generated/codex/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
- package/pack/generated/codex/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
- package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +98 -146
- package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +11 -5
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +96 -134
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
- package/pack/generated/codex/skills/rite-build/SKILL.md +51 -79
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -17
- package/pack/generated/codex/skills/rite-plan/SKILL.md +17 -17
- package/pack/generated/codex/skills/rite-plan/reference/anti-patterns.md +11 -1
- package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +10 -3
- package/pack/generated/codex/skills/rite-prove/SKILL.md +95 -153
- package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -174
- package/pack/generated/codex/skills/rite-vet/reference/anti-patterns.md +14 -2
- package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +10 -6
- package/pack/generated/codex/skills/rite-vet/reference/depth.md +10 -3
- package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +12 -4
- package/package.json +1 -1
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"io/fs"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"slices"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestWorkspaceObservationInventoryCanonicalArtifactsOnce(t *testing.T) {
|
|
14
|
+
root, slug := newObservationWorkspace(t)
|
|
15
|
+
wantPaths := []ArtifactPath{
|
|
16
|
+
"state.md",
|
|
17
|
+
"brief.md",
|
|
18
|
+
"spec.md",
|
|
19
|
+
"decisions.md",
|
|
20
|
+
"assumptions.md",
|
|
21
|
+
"questions.md",
|
|
22
|
+
"decision-coverage.md",
|
|
23
|
+
"architecture.md",
|
|
24
|
+
"plan.md",
|
|
25
|
+
"tasks.md",
|
|
26
|
+
"traceability.md",
|
|
27
|
+
"eng-review.md",
|
|
28
|
+
"test-plan.md",
|
|
29
|
+
"evidence.md",
|
|
30
|
+
"touched-files.md",
|
|
31
|
+
"review.md",
|
|
32
|
+
"seal.md",
|
|
33
|
+
"strategy.md",
|
|
34
|
+
"design-brief.md",
|
|
35
|
+
"ai-spec.md",
|
|
36
|
+
".devrites/principles.md",
|
|
37
|
+
}
|
|
38
|
+
for _, path := range wantPaths {
|
|
39
|
+
body := []byte("# Artifact\n\nsubstantive content\n")
|
|
40
|
+
switch path {
|
|
41
|
+
case LedgerFile:
|
|
42
|
+
body = []byte("- Phase: build\n")
|
|
43
|
+
case ".devrites/principles.md":
|
|
44
|
+
body = []byte("root principles\n")
|
|
45
|
+
}
|
|
46
|
+
writeObservationArtifact(t, root, slug, path, body)
|
|
47
|
+
}
|
|
48
|
+
writeFile(t, filepath.Join(root, "work", slug, "principles.md"), []byte("workspace principles decoy\n"))
|
|
49
|
+
writeFile(t, filepath.Join(root, "work", slug, ".devrites", "principles.md"), []byte("workspace .devrites principles decoy\n"))
|
|
50
|
+
physicalRoot, err := filepath.EvalSymlinks(root)
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(physicalRoot, "work", slug))
|
|
55
|
+
|
|
56
|
+
opens := make(map[ArtifactPath]int)
|
|
57
|
+
reads := make(map[ArtifactPath]int)
|
|
58
|
+
observation, err := observeWorkspace(physicalRoot, slug, func(stage observationStage, path ArtifactPath) error {
|
|
59
|
+
switch stage {
|
|
60
|
+
case observationBeforeOpen:
|
|
61
|
+
opens[path]++
|
|
62
|
+
case observationBeforeRead:
|
|
63
|
+
reads[path]++
|
|
64
|
+
}
|
|
65
|
+
return nil
|
|
66
|
+
})
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
if observation.Slug() != slug {
|
|
71
|
+
t.Fatalf("Slug() = %q, want %q", observation.Slug(), slug)
|
|
72
|
+
}
|
|
73
|
+
facts := observation.Facts()
|
|
74
|
+
gotPaths := make([]ArtifactPath, len(facts))
|
|
75
|
+
for i, fact := range facts {
|
|
76
|
+
gotPaths[i] = fact.Path()
|
|
77
|
+
}
|
|
78
|
+
if !slices.Equal(gotPaths, wantPaths) {
|
|
79
|
+
t.Fatalf("Facts paths = %v, want %v", gotPaths, wantPaths)
|
|
80
|
+
}
|
|
81
|
+
for _, path := range wantPaths {
|
|
82
|
+
if opens[path] != 1 || reads[path] != 1 {
|
|
83
|
+
t.Errorf("%s opened/read = %d/%d, want 1/1", path, opens[path], reads[path])
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
principles, ok := observation.Fact(".devrites/principles.md")
|
|
87
|
+
if !ok || string(principles.Bytes()) != "root principles\n" {
|
|
88
|
+
t.Fatalf("root principles fact = (%q, %v), want selected-root principles", principles.Bytes(), ok)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestWorkspaceObservationPathAcceptsCanonicalRelativeOverride(t *testing.T) {
|
|
93
|
+
root, slug := newObservationWorkspace(t)
|
|
94
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
95
|
+
physicalRoot, err := filepath.EvalSymlinks(root)
|
|
96
|
+
if err != nil {
|
|
97
|
+
t.Fatal(err)
|
|
98
|
+
}
|
|
99
|
+
t.Setenv("DEVRITES_WORKSPACE", filepath.Join(filepath.Base(physicalRoot), "work", slug))
|
|
100
|
+
|
|
101
|
+
observation, err := ObserveWorkspace(physicalRoot, slug)
|
|
102
|
+
if err != nil {
|
|
103
|
+
t.Fatal(err)
|
|
104
|
+
}
|
|
105
|
+
if observation.Slug() != slug {
|
|
106
|
+
t.Fatalf("Slug() = %q, want %q", observation.Slug(), slug)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestWorkspaceObservationPathRejectsWorkspaceOverrideWhitespace(t *testing.T) {
|
|
111
|
+
root, slug := newObservationWorkspace(t)
|
|
112
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
113
|
+
physicalRoot, err := filepath.EvalSymlinks(root)
|
|
114
|
+
if err != nil {
|
|
115
|
+
t.Fatal(err)
|
|
116
|
+
}
|
|
117
|
+
canonical := filepath.Join(physicalRoot, "work", slug)
|
|
118
|
+
tests := []struct {
|
|
119
|
+
name string
|
|
120
|
+
override string
|
|
121
|
+
}{
|
|
122
|
+
{name: "leading", override: " " + canonical},
|
|
123
|
+
{name: "trailing", override: canonical + " "},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for _, test := range tests {
|
|
127
|
+
t.Run(test.name, func(t *testing.T) {
|
|
128
|
+
t.Setenv("DEVRITES_WORKSPACE", test.override)
|
|
129
|
+
observation, err := ObserveWorkspace(physicalRoot, slug)
|
|
130
|
+
if observation != nil {
|
|
131
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
132
|
+
}
|
|
133
|
+
assertObservationFailure(t, err, ObservationWorkspaceInvalid, "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry")
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestWorkspaceObservationPathRejectsLeadingParentRelativeOverride(t *testing.T) {
|
|
139
|
+
root, slug := newObservationWorkspace(t)
|
|
140
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
141
|
+
physicalRoot, err := filepath.EvalSymlinks(root)
|
|
142
|
+
if err != nil {
|
|
143
|
+
t.Fatal(err)
|
|
144
|
+
}
|
|
145
|
+
rootParent := filepath.Dir(physicalRoot)
|
|
146
|
+
override := filepath.Join("..", filepath.Base(rootParent), filepath.Base(physicalRoot), "work", slug)
|
|
147
|
+
if filepath.IsAbs(override) || !strings.HasPrefix(override, ".."+string(filepath.Separator)) {
|
|
148
|
+
t.Fatalf("override = %q, want leading-parent relative path", override)
|
|
149
|
+
}
|
|
150
|
+
if filepath.Clean(override) != override {
|
|
151
|
+
t.Fatalf("filepath.Clean(%q) changed fixture", override)
|
|
152
|
+
}
|
|
153
|
+
if filepath.IsLocal(override) {
|
|
154
|
+
t.Fatalf("filepath.IsLocal(%q) = true, want false", override)
|
|
155
|
+
}
|
|
156
|
+
want := filepath.Join(physicalRoot, "work", slug)
|
|
157
|
+
if resolved := filepath.Join(rootParent, override); resolved != want {
|
|
158
|
+
t.Fatalf("filepath.Join(%q, %q) = %q, want %q", rootParent, override, resolved, want)
|
|
159
|
+
}
|
|
160
|
+
t.Setenv("DEVRITES_WORKSPACE", override)
|
|
161
|
+
|
|
162
|
+
observation, err := ObserveWorkspace(physicalRoot, slug)
|
|
163
|
+
if observation != nil {
|
|
164
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
165
|
+
}
|
|
166
|
+
assertObservationFailure(t, err, ObservationWorkspaceInvalid, "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func TestWorkspaceObservationPathRejectsWorkspaceOverrideAliases(t *testing.T) {
|
|
170
|
+
aliases := []struct {
|
|
171
|
+
name string
|
|
172
|
+
path func(root string) string
|
|
173
|
+
}{
|
|
174
|
+
{
|
|
175
|
+
name: "in-root symlink alias",
|
|
176
|
+
path: func(root string) string {
|
|
177
|
+
return filepath.Join(root, "work", "alias")
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "outside-root symlink alias",
|
|
182
|
+
path: func(string) string {
|
|
183
|
+
return filepath.Join(t.TempDir(), "alias")
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
overrides := []struct {
|
|
188
|
+
name string
|
|
189
|
+
path func(t *testing.T, root, slug, alias string) string
|
|
190
|
+
}{
|
|
191
|
+
{
|
|
192
|
+
name: "direct absolute",
|
|
193
|
+
path: func(_ *testing.T, _, _, alias string) string {
|
|
194
|
+
return alias
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "wrapped relative",
|
|
199
|
+
path: func(t *testing.T, root, slug, alias string) string {
|
|
200
|
+
aliasFromRootParent, err := filepath.Rel(filepath.Dir(root), alias)
|
|
201
|
+
if err != nil {
|
|
202
|
+
t.Fatal(err)
|
|
203
|
+
}
|
|
204
|
+
targetFromAliasParent, err := filepath.Rel(filepath.Dir(alias), filepath.Join(root, "work", slug))
|
|
205
|
+
if err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
separator := string(filepath.Separator)
|
|
209
|
+
return aliasFromRootParent + separator + ".." + separator + targetFromAliasParent
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "wrapped absolute",
|
|
214
|
+
path: func(t *testing.T, root, slug, alias string) string {
|
|
215
|
+
targetFromAliasParent, err := filepath.Rel(filepath.Dir(alias), filepath.Join(root, "work", slug))
|
|
216
|
+
if err != nil {
|
|
217
|
+
t.Fatal(err)
|
|
218
|
+
}
|
|
219
|
+
separator := string(filepath.Separator)
|
|
220
|
+
return alias + separator + ".." + separator + targetFromAliasParent
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for _, aliasCase := range aliases {
|
|
226
|
+
for _, overrideCase := range overrides {
|
|
227
|
+
t.Run(aliasCase.name+"/"+overrideCase.name, func(t *testing.T) {
|
|
228
|
+
root, slug := newObservationWorkspace(t)
|
|
229
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
230
|
+
physicalRoot, err := filepath.EvalSymlinks(root)
|
|
231
|
+
if err != nil {
|
|
232
|
+
t.Fatal(err)
|
|
233
|
+
}
|
|
234
|
+
alias := aliasCase.path(physicalRoot)
|
|
235
|
+
if err := os.Symlink(filepath.Join(physicalRoot, "work", slug), alias); err != nil {
|
|
236
|
+
t.Fatal(err)
|
|
237
|
+
}
|
|
238
|
+
aliasInfo, err := os.Lstat(alias)
|
|
239
|
+
if err != nil {
|
|
240
|
+
t.Fatal(err)
|
|
241
|
+
}
|
|
242
|
+
if aliasInfo.Mode()&os.ModeSymlink == 0 {
|
|
243
|
+
t.Fatalf("alias mode = %v, want symlink", aliasInfo.Mode())
|
|
244
|
+
}
|
|
245
|
+
t.Setenv("DEVRITES_WORKSPACE", overrideCase.path(t, physicalRoot, slug, alias))
|
|
246
|
+
|
|
247
|
+
observation, err := ObserveWorkspace(physicalRoot, slug)
|
|
248
|
+
if observation != nil {
|
|
249
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
250
|
+
}
|
|
251
|
+
assertObservationFailure(t, err, ObservationWorkspaceInvalid, "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry")
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func TestWorkspaceObservationSymlinkClassifiesRootPrinciplesFinalLink(t *testing.T) {
|
|
258
|
+
root, slug := newObservationWorkspace(t)
|
|
259
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
260
|
+
target := filepath.Join(t.TempDir(), "principles.md")
|
|
261
|
+
writeFile(t, target, []byte("outside principles\n"))
|
|
262
|
+
if err := os.Symlink(target, filepath.Join(root, "principles.md")); err != nil {
|
|
263
|
+
t.Fatal(err)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
267
|
+
if err != nil {
|
|
268
|
+
t.Fatal(err)
|
|
269
|
+
}
|
|
270
|
+
assertArtifactFact(t, observation, ".devrites/principles.md", ArtifactUnsafe, DiagnosticFinalSymlink, nil)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func TestWorkspaceObservationStateClassifiesCodesAndRetention(t *testing.T) {
|
|
274
|
+
root, slug := newObservationWorkspace(t)
|
|
275
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
276
|
+
writeObservationArtifact(t, root, slug, "spec.md", []byte("# Empty\n\n"))
|
|
277
|
+
malformed := []byte("hostile-secret\xff\n")
|
|
278
|
+
writeObservationArtifact(t, root, slug, "decisions.md", malformed)
|
|
279
|
+
|
|
280
|
+
target := filepath.Join(t.TempDir(), "outside.md")
|
|
281
|
+
if err := os.WriteFile(target, []byte("outside\n"), 0o644); err != nil {
|
|
282
|
+
t.Fatal(err)
|
|
283
|
+
}
|
|
284
|
+
if err := os.Symlink(target, observationArtifactPath(root, slug, "assumptions.md")); err != nil {
|
|
285
|
+
t.Fatal(err)
|
|
286
|
+
}
|
|
287
|
+
if err := os.Mkdir(observationArtifactPath(root, slug, "questions.md"), 0o755); err != nil {
|
|
288
|
+
t.Fatal(err)
|
|
289
|
+
}
|
|
290
|
+
writeObservationArtifact(t, root, slug, "decision-coverage.md", sizedMarkdown("coverage\n", (1<<20)+1))
|
|
291
|
+
writeObservationArtifact(t, root, slug, "architecture.md", []byte("architecture\n"))
|
|
292
|
+
writeObservationArtifact(t, root, slug, "plan.md", []byte("plan\n"))
|
|
293
|
+
writeObservationArtifact(t, root, slug, "traceability.md", []byte{})
|
|
294
|
+
present := []byte("# Tasks\n\n- [ ] retained\n")
|
|
295
|
+
writeObservationArtifact(t, root, slug, "tasks.md", present)
|
|
296
|
+
|
|
297
|
+
observation, err := observeWorkspace(root, slug, func(stage observationStage, path ArtifactPath) error {
|
|
298
|
+
if stage == observationBeforeOpen && path == "architecture.md" {
|
|
299
|
+
return fs.ErrPermission
|
|
300
|
+
}
|
|
301
|
+
if stage == observationBeforeRead && path == "plan.md" {
|
|
302
|
+
return errors.New("hostile read failure at /physical/path")
|
|
303
|
+
}
|
|
304
|
+
return nil
|
|
305
|
+
})
|
|
306
|
+
if err != nil {
|
|
307
|
+
t.Fatal(err)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
assertArtifactFact(t, observation, "brief.md", ArtifactAbsent, "", nil)
|
|
311
|
+
assertArtifactFact(t, observation, "spec.md", ArtifactEmpty, "", []byte("# Empty\n\n"))
|
|
312
|
+
assertArtifactFact(t, observation, "decisions.md", ArtifactMalformed, DiagnosticMalformedMarkdown, malformed)
|
|
313
|
+
assertArtifactFact(t, observation, "assumptions.md", ArtifactUnsafe, DiagnosticFinalSymlink, nil)
|
|
314
|
+
assertArtifactFact(t, observation, "questions.md", ArtifactUnsafe, DiagnosticNonRegular, nil)
|
|
315
|
+
assertArtifactFact(t, observation, "decision-coverage.md", ArtifactUnsafe, DiagnosticFileTooLarge, nil)
|
|
316
|
+
assertArtifactFact(t, observation, "architecture.md", ArtifactUnreadable, DiagnosticPermissionDenied, nil)
|
|
317
|
+
assertArtifactFact(t, observation, "plan.md", ArtifactUnreadable, DiagnosticReadFailure, nil)
|
|
318
|
+
assertArtifactFact(t, observation, "tasks.md", ArtifactPresent, "", present)
|
|
319
|
+
assertArtifactFact(t, observation, "traceability.md", ArtifactEmpty, "", []byte{})
|
|
320
|
+
zeroByteFact, _ := observation.Fact("traceability.md")
|
|
321
|
+
if zeroByteFact.Bytes() == nil {
|
|
322
|
+
t.Fatal("zero-byte empty artifact did not retain a copied empty byte slice")
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func TestWorkspaceObservationSymlinkClassifiesParent(t *testing.T) {
|
|
327
|
+
base := t.TempDir()
|
|
328
|
+
target := t.TempDir()
|
|
329
|
+
if err := os.WriteFile(filepath.Join(target, "spec.md"), []byte("content\n"), 0o644); err != nil {
|
|
330
|
+
t.Fatal(err)
|
|
331
|
+
}
|
|
332
|
+
if err := os.Symlink(target, filepath.Join(base, "nested")); err != nil {
|
|
333
|
+
t.Fatal(err)
|
|
334
|
+
}
|
|
335
|
+
root, err := os.OpenRoot(base)
|
|
336
|
+
if err != nil {
|
|
337
|
+
t.Fatal(err)
|
|
338
|
+
}
|
|
339
|
+
defer root.Close()
|
|
340
|
+
|
|
341
|
+
fact, _, err := observeArtifact(artifactLocation{
|
|
342
|
+
path: ArtifactPath("spec.md"),
|
|
343
|
+
root: root,
|
|
344
|
+
name: "nested/spec.md",
|
|
345
|
+
}, nil)
|
|
346
|
+
if err != nil {
|
|
347
|
+
t.Fatal(err)
|
|
348
|
+
}
|
|
349
|
+
if fact.State() != ArtifactUnsafe {
|
|
350
|
+
t.Fatalf("State() = %q, want %q", fact.State(), ArtifactUnsafe)
|
|
351
|
+
}
|
|
352
|
+
diagnostic, ok := fact.Diagnostic()
|
|
353
|
+
if !ok || diagnostic.Code != DiagnosticParentSymlink {
|
|
354
|
+
t.Fatalf("Diagnostic() = (%+v, %v), want parent symlink", diagnostic, ok)
|
|
355
|
+
}
|
|
356
|
+
if fact.Bytes() != nil {
|
|
357
|
+
t.Fatalf("Bytes() = %q, want nil", fact.Bytes())
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func TestWorkspaceObservationDefensiveCopies(t *testing.T) {
|
|
362
|
+
root, slug := newObservationWorkspace(t)
|
|
363
|
+
body := []byte("- Phase: build\n")
|
|
364
|
+
writeObservationArtifact(t, root, slug, LedgerFile, body)
|
|
365
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
366
|
+
if err != nil {
|
|
367
|
+
t.Fatal(err)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
facts := observation.Facts()
|
|
371
|
+
facts[0].path = "mutated.md"
|
|
372
|
+
facts[0].state = ArtifactUnsafe
|
|
373
|
+
facts[0].bytes[0] = 'X'
|
|
374
|
+
facts[0] = ArtifactFact{}
|
|
375
|
+
|
|
376
|
+
fact, ok := observation.Fact(LedgerFile)
|
|
377
|
+
if !ok {
|
|
378
|
+
t.Fatal("Fact(state.md) returned ok=false")
|
|
379
|
+
}
|
|
380
|
+
returned := fact.Bytes()
|
|
381
|
+
returned[0] = 'Y'
|
|
382
|
+
fresh, ok := observation.Fact(LedgerFile)
|
|
383
|
+
if !ok || fresh.Path() != LedgerFile || fresh.State() != ArtifactPresent || !slices.Equal(fresh.Bytes(), body) {
|
|
384
|
+
t.Fatalf("fresh fact = (%q, %q, %q, %v), want retained state fact", fresh.Path(), fresh.State(), fresh.Bytes(), ok)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func TestWorkspaceObservationUnknownLookupHasNoSideEffects(t *testing.T) {
|
|
389
|
+
root, slug := newObservationWorkspace(t)
|
|
390
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
391
|
+
callbacks := 0
|
|
392
|
+
observation, err := observeWorkspace(root, slug, func(observationStage, ArtifactPath) error {
|
|
393
|
+
callbacks++
|
|
394
|
+
return nil
|
|
395
|
+
})
|
|
396
|
+
if err != nil {
|
|
397
|
+
t.Fatal(err)
|
|
398
|
+
}
|
|
399
|
+
beforeCallbacks := callbacks
|
|
400
|
+
beforeFacts := observation.Facts()
|
|
401
|
+
|
|
402
|
+
fact, ok := observation.Fact("not-in-inventory.md")
|
|
403
|
+
if ok || fact.path != "" || fact.state != "" || fact.code != "" || fact.bytes != nil {
|
|
404
|
+
t.Fatalf("Fact(uninventoried) = (%+v, %v), want zero/false", fact, ok)
|
|
405
|
+
}
|
|
406
|
+
if fact.Bytes() != nil {
|
|
407
|
+
t.Fatalf("zero fact Bytes() = %q, want nil", fact.Bytes())
|
|
408
|
+
}
|
|
409
|
+
if diagnostic, diagnosticOK := fact.Diagnostic(); diagnosticOK || diagnostic != (ArtifactDiagnostic{}) {
|
|
410
|
+
t.Fatalf("zero fact Diagnostic() = (%+v, %v), want zero/false", diagnostic, diagnosticOK)
|
|
411
|
+
}
|
|
412
|
+
if callbacks != beforeCallbacks || !slices.EqualFunc(observation.Facts(), beforeFacts, equalArtifactFact) {
|
|
413
|
+
t.Fatal("uninventoried lookup performed I/O or mutated the observation")
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func TestWorkspaceObservationStateMissingPreservesCallerOrderAndSelectedDiagnostics(t *testing.T) {
|
|
418
|
+
root, slug := newObservationWorkspace(t)
|
|
419
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
420
|
+
writeObservationArtifact(t, root, slug, "brief.md", []byte("# Heading\n"))
|
|
421
|
+
writeObservationArtifact(t, root, slug, "spec.md", []byte("bad\x00markdown"))
|
|
422
|
+
writeObservationArtifact(t, root, slug, "plan.md", []byte("real\n"))
|
|
423
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
424
|
+
if err != nil {
|
|
425
|
+
t.Fatal(err)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
required := []ArtifactPath{"plan.md", "spec.md", "questions.md", "brief.md"}
|
|
429
|
+
missing, diagnostics := observation.Missing(required)
|
|
430
|
+
wantMissing := []ArtifactPath{"spec.md", "questions.md", "brief.md"}
|
|
431
|
+
if !slices.Equal(missing, wantMissing) {
|
|
432
|
+
t.Fatalf("Missing() paths = %v, want %v", missing, wantMissing)
|
|
433
|
+
}
|
|
434
|
+
wantDiagnostics := []ArtifactDiagnostic{{Path: "spec.md", State: ArtifactMalformed, Code: DiagnosticMalformedMarkdown}}
|
|
435
|
+
if !slices.Equal(diagnostics, wantDiagnostics) {
|
|
436
|
+
t.Fatalf("Missing() diagnostics = %+v, want %+v", diagnostics, wantDiagnostics)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
func TestWorkspaceObservationDisclosureRejectsInvalidWorkspace(t *testing.T) {
|
|
441
|
+
root := filepath.Join(t.TempDir(), "hostile-root-secret", ".devrites")
|
|
442
|
+
observation, err := ObserveWorkspace(root, "missing")
|
|
443
|
+
if observation != nil {
|
|
444
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
445
|
+
}
|
|
446
|
+
assertObservationFailure(t, err, ObservationWorkspaceInvalid, "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry")
|
|
447
|
+
if strings.Contains(err.Error(), root) || strings.Contains(err.Error(), "hostile-root-secret") {
|
|
448
|
+
t.Fatalf("error disclosed physical input: %v", err)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
func TestWorkspaceObservationRejectsCanonicalWorkspaceSymlinkWithoutDisclosure(t *testing.T) {
|
|
453
|
+
for _, test := range []struct {
|
|
454
|
+
name string
|
|
455
|
+
target func(t *testing.T, root string) string
|
|
456
|
+
}{
|
|
457
|
+
{
|
|
458
|
+
name: "inside root",
|
|
459
|
+
target: func(t *testing.T, root string) string {
|
|
460
|
+
t.Helper()
|
|
461
|
+
return filepath.Join(root, "retained-target-secret")
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "outside root",
|
|
466
|
+
target: func(t *testing.T, _ string) string {
|
|
467
|
+
t.Helper()
|
|
468
|
+
return filepath.Join(t.TempDir(), "retained-target-secret")
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
} {
|
|
472
|
+
t.Run(test.name, func(t *testing.T) {
|
|
473
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
474
|
+
slug := "linked"
|
|
475
|
+
target := test.target(t, root)
|
|
476
|
+
writeFile(t, filepath.Join(target, LedgerFile), []byte("- Phase: build\ntarget-content-secret\n"))
|
|
477
|
+
if err := os.MkdirAll(filepath.Join(root, "work"), 0o755); err != nil {
|
|
478
|
+
t.Fatal(err)
|
|
479
|
+
}
|
|
480
|
+
workspace := filepath.Join(root, "work", slug)
|
|
481
|
+
if err := os.Symlink(target, workspace); err != nil {
|
|
482
|
+
t.Fatal(err)
|
|
483
|
+
}
|
|
484
|
+
t.Setenv("DEVRITES_WORKSPACE", "")
|
|
485
|
+
|
|
486
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
487
|
+
if observation != nil {
|
|
488
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
489
|
+
}
|
|
490
|
+
assertObservationFailure(t, err, ObservationWorkspaceInvalid, "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry")
|
|
491
|
+
if strings.Contains(err.Error(), target) || strings.Contains(err.Error(), "retained-target-secret") || strings.Contains(err.Error(), "target-content-secret") {
|
|
492
|
+
t.Fatalf("error disclosed target path or content: %v", err)
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
func TestWorkspaceObservationRootRejectsIdentityChanges(t *testing.T) {
|
|
499
|
+
for _, tc := range []struct {
|
|
500
|
+
name string
|
|
501
|
+
mutate func(t *testing.T, root, slug string)
|
|
502
|
+
}{
|
|
503
|
+
{
|
|
504
|
+
name: "root replaced",
|
|
505
|
+
mutate: func(t *testing.T, root, slug string) {
|
|
506
|
+
t.Helper()
|
|
507
|
+
if err := os.Rename(root, root+"-moved"); err != nil {
|
|
508
|
+
t.Fatal(err)
|
|
509
|
+
}
|
|
510
|
+
// Replace with a regular file so OpenRoot fails closed on every
|
|
511
|
+
// platform, including Windows directory file-ID reuse across
|
|
512
|
+
// rename/recreate of an empty directory at the same path.
|
|
513
|
+
if err := os.WriteFile(root, []byte("replaced-root\n"), 0o644); err != nil {
|
|
514
|
+
t.Fatal(err)
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: "workspace replaced",
|
|
520
|
+
mutate: func(t *testing.T, root, slug string) {
|
|
521
|
+
t.Helper()
|
|
522
|
+
workspace := filepath.Join(root, "work", slug)
|
|
523
|
+
if err := os.Rename(workspace, workspace+"-moved"); err != nil {
|
|
524
|
+
t.Fatal(err)
|
|
525
|
+
}
|
|
526
|
+
if err := os.WriteFile(workspace, []byte("replaced-workspace\n"), 0o644); err != nil {
|
|
527
|
+
t.Fatal(err)
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
} {
|
|
532
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
533
|
+
root, slug := newObservationWorkspace(t)
|
|
534
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
535
|
+
calls := 0
|
|
536
|
+
observation, err := observeWorkspace(root, slug, func(stage observationStage, _ ArtifactPath) error {
|
|
537
|
+
if stage == observationAfterResolve {
|
|
538
|
+
calls++
|
|
539
|
+
tc.mutate(t, root, slug)
|
|
540
|
+
}
|
|
541
|
+
return nil
|
|
542
|
+
})
|
|
543
|
+
if calls != 1 {
|
|
544
|
+
t.Fatalf("after-resolve calls = %d, want 1", calls)
|
|
545
|
+
}
|
|
546
|
+
if observation != nil {
|
|
547
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
548
|
+
}
|
|
549
|
+
assertObservationFailure(t, err, ObservationWorkspaceInvalid, "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry")
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
func TestWorkspaceObservationLimitsPerFile(t *testing.T) {
|
|
555
|
+
for _, tc := range []struct {
|
|
556
|
+
name string
|
|
557
|
+
size int
|
|
558
|
+
wantState ArtifactState
|
|
559
|
+
wantCode DiagnosticCode
|
|
560
|
+
wantBytes int
|
|
561
|
+
}{
|
|
562
|
+
{name: "exactly one MiB", size: 1 << 20, wantState: ArtifactPresent, wantBytes: 1 << 20},
|
|
563
|
+
{name: "one MiB plus one", size: (1 << 20) + 1, wantState: ArtifactUnsafe, wantCode: DiagnosticFileTooLarge},
|
|
564
|
+
} {
|
|
565
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
566
|
+
root, slug := newObservationWorkspace(t)
|
|
567
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
568
|
+
writeObservationArtifact(t, root, slug, "ai-spec.md", sizedMarkdown("optional input\n", tc.size))
|
|
569
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
570
|
+
if err != nil {
|
|
571
|
+
t.Fatal(err)
|
|
572
|
+
}
|
|
573
|
+
fact, ok := observation.Fact("ai-spec.md")
|
|
574
|
+
if !ok || fact.State() != tc.wantState || len(fact.Bytes()) != tc.wantBytes {
|
|
575
|
+
t.Fatalf("ai-spec fact = (%q, %d bytes, %v), want (%q, %d bytes)", fact.State(), len(fact.Bytes()), ok, tc.wantState, tc.wantBytes)
|
|
576
|
+
}
|
|
577
|
+
diagnostic, diagnosticOK := fact.Diagnostic()
|
|
578
|
+
if tc.wantCode == "" {
|
|
579
|
+
if diagnosticOK {
|
|
580
|
+
t.Fatalf("Diagnostic() = %+v, want none", diagnostic)
|
|
581
|
+
}
|
|
582
|
+
} else if !diagnosticOK || diagnostic.Code != tc.wantCode {
|
|
583
|
+
t.Fatalf("Diagnostic() = (%+v, %v), want %q", diagnostic, diagnosticOK, tc.wantCode)
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
func TestWorkspaceObservationLimitsAggregateIncludingLaterOptionalFacts(t *testing.T) {
|
|
590
|
+
for _, tc := range []struct {
|
|
591
|
+
name string
|
|
592
|
+
overflow bool
|
|
593
|
+
}{
|
|
594
|
+
{name: "exactly eight MiB"},
|
|
595
|
+
{name: "eight MiB plus one in later optional fact", overflow: true},
|
|
596
|
+
} {
|
|
597
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
598
|
+
root, slug := newObservationWorkspace(t)
|
|
599
|
+
paths := []ArtifactPath{LedgerFile, "brief.md", "spec.md", "decisions.md", "assumptions.md", "questions.md", "decision-coverage.md", "strategy.md"}
|
|
600
|
+
for _, path := range paths {
|
|
601
|
+
prefix := "retained content\n"
|
|
602
|
+
if path == LedgerFile {
|
|
603
|
+
prefix = "- Phase: build\n"
|
|
604
|
+
}
|
|
605
|
+
writeObservationArtifact(t, root, slug, path, sizedMarkdown(prefix, 1<<20))
|
|
606
|
+
}
|
|
607
|
+
if tc.overflow {
|
|
608
|
+
writeObservationArtifact(t, root, slug, "ai-spec.md", []byte("x"))
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
612
|
+
if !tc.overflow {
|
|
613
|
+
if err != nil || observation == nil {
|
|
614
|
+
t.Fatalf("ObserveWorkspace() = (%+v, %v), want observation", observation, err)
|
|
615
|
+
}
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if observation != nil {
|
|
619
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
620
|
+
}
|
|
621
|
+
assertObservationFailure(t, err, ObservationAggregateTooLarge, "workspace observation: aggregate_too_large: retained content exceeds the 8 MiB aggregate limit; reduce retained Markdown below 8 MiB, then retry")
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
func TestWorkspaceObservationLimitsCountEmptyAndMalformedBytes(t *testing.T) {
|
|
627
|
+
root, slug := newObservationWorkspace(t)
|
|
628
|
+
presentPaths := []ArtifactPath{LedgerFile, "brief.md", "decisions.md", "assumptions.md", "questions.md", "decision-coverage.md", "strategy.md"}
|
|
629
|
+
for _, path := range presentPaths {
|
|
630
|
+
prefix := "retained content\n"
|
|
631
|
+
if path == LedgerFile {
|
|
632
|
+
prefix = "- Phase: build\n"
|
|
633
|
+
}
|
|
634
|
+
writeObservationArtifact(t, root, slug, path, sizedMarkdown(prefix, 1<<20))
|
|
635
|
+
}
|
|
636
|
+
writeObservationArtifact(t, root, slug, "design-brief.md", bytesOf(' ', 1<<20))
|
|
637
|
+
writeObservationArtifact(t, root, slug, "ai-spec.md", []byte{0xff})
|
|
638
|
+
|
|
639
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
640
|
+
if observation != nil {
|
|
641
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
642
|
+
}
|
|
643
|
+
assertObservationFailure(t, err, ObservationAggregateTooLarge, "workspace observation: aggregate_too_large: retained content exceeds the 8 MiB aggregate limit; reduce retained Markdown below 8 MiB, then retry")
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
func TestWorkspaceObservationConcurrentChangeOutranksCallbackErrorsAfterDetectedMutation(t *testing.T) {
|
|
647
|
+
sentinel := errors.New("sentinel callback failure")
|
|
648
|
+
for _, stage := range []observationStage{
|
|
649
|
+
observationAfterInspect,
|
|
650
|
+
observationBeforeOpen,
|
|
651
|
+
observationBeforeRead,
|
|
652
|
+
observationAfterRead,
|
|
653
|
+
} {
|
|
654
|
+
t.Run(observationStageName(stage), func(t *testing.T) {
|
|
655
|
+
root, slug := newObservationWorkspace(t)
|
|
656
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
657
|
+
writeObservationArtifact(t, root, slug, "brief.md", []byte("before\n"))
|
|
658
|
+
artifactPath := observationArtifactPath(root, slug, "brief.md")
|
|
659
|
+
calls := make(map[observationStage]int)
|
|
660
|
+
|
|
661
|
+
observation, err := observeWorkspace(root, slug, func(current observationStage, artifact ArtifactPath) error {
|
|
662
|
+
if artifact != "brief.md" {
|
|
663
|
+
return nil
|
|
664
|
+
}
|
|
665
|
+
calls[current]++
|
|
666
|
+
if current != stage {
|
|
667
|
+
return nil
|
|
668
|
+
}
|
|
669
|
+
writeFile(t, artifactPath, []byte("detectably larger content\n"))
|
|
670
|
+
return sentinel
|
|
671
|
+
})
|
|
672
|
+
if observation != nil {
|
|
673
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
674
|
+
}
|
|
675
|
+
assertObservationFailure(t, err, ObservationConcurrentChange, "workspace observation: concurrent_change: workspace changed during acquisition; retry")
|
|
676
|
+
assertSingleObservationAttempt(t, calls, stage)
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
func TestWorkspaceObservationConcurrentChangeRejectsDetectedArtifactChangesWithoutRetry(t *testing.T) {
|
|
682
|
+
for _, tc := range []struct {
|
|
683
|
+
name string
|
|
684
|
+
stage observationStage
|
|
685
|
+
setup func(t *testing.T, path string)
|
|
686
|
+
mutate func(t *testing.T, path string)
|
|
687
|
+
}{
|
|
688
|
+
{
|
|
689
|
+
name: "appearance",
|
|
690
|
+
stage: observationAfterInspect,
|
|
691
|
+
setup: func(*testing.T, string) {},
|
|
692
|
+
mutate: func(t *testing.T, path string) { writeFile(t, path, []byte("appeared\n")) },
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
name: "disappearance",
|
|
696
|
+
stage: observationBeforeOpen,
|
|
697
|
+
setup: func(t *testing.T, path string) { writeFile(t, path, []byte("before\n")) },
|
|
698
|
+
mutate: func(t *testing.T, path string) {
|
|
699
|
+
if err := os.Remove(path); err != nil {
|
|
700
|
+
t.Fatal(err)
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: "identity replacement",
|
|
706
|
+
stage: observationBeforeOpen,
|
|
707
|
+
setup: func(t *testing.T, path string) { writeFile(t, path, []byte("before\n")) },
|
|
708
|
+
mutate: func(t *testing.T, path string) {
|
|
709
|
+
if err := os.Rename(path, path+".old"); err != nil {
|
|
710
|
+
t.Fatal(err)
|
|
711
|
+
}
|
|
712
|
+
writeFile(t, path, []byte("after!\n"))
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: "type change",
|
|
717
|
+
stage: observationBeforeOpen,
|
|
718
|
+
setup: func(t *testing.T, path string) { writeFile(t, path, []byte("before\n")) },
|
|
719
|
+
mutate: func(t *testing.T, path string) {
|
|
720
|
+
if err := os.Remove(path); err != nil {
|
|
721
|
+
t.Fatal(err)
|
|
722
|
+
}
|
|
723
|
+
if err := os.Mkdir(path, 0o755); err != nil {
|
|
724
|
+
t.Fatal(err)
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
name: "size change",
|
|
730
|
+
stage: observationBeforeOpen,
|
|
731
|
+
setup: func(t *testing.T, path string) { writeFile(t, path, []byte("before\n")) },
|
|
732
|
+
mutate: func(t *testing.T, path string) { writeFile(t, path, []byte("larger content\n")) },
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: "replacement after read",
|
|
736
|
+
stage: observationAfterRead,
|
|
737
|
+
setup: func(t *testing.T, path string) { writeFile(t, path, []byte("before\n")) },
|
|
738
|
+
mutate: func(t *testing.T, path string) {
|
|
739
|
+
if err := os.Rename(path, path+".old"); err != nil {
|
|
740
|
+
t.Fatal(err)
|
|
741
|
+
}
|
|
742
|
+
writeFile(t, path, []byte("after!\n"))
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
} {
|
|
746
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
747
|
+
root, slug := newObservationWorkspace(t)
|
|
748
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
749
|
+
artifactPath := observationArtifactPath(root, slug, "brief.md")
|
|
750
|
+
tc.setup(t, artifactPath)
|
|
751
|
+
stageCalls := make(map[observationStage]int)
|
|
752
|
+
mutated := false
|
|
753
|
+
observation, err := observeWorkspace(root, slug, func(stage observationStage, path ArtifactPath) error {
|
|
754
|
+
if path != "brief.md" {
|
|
755
|
+
return nil
|
|
756
|
+
}
|
|
757
|
+
stageCalls[stage]++
|
|
758
|
+
if !mutated && stage == tc.stage {
|
|
759
|
+
mutated = true
|
|
760
|
+
tc.mutate(t, artifactPath)
|
|
761
|
+
}
|
|
762
|
+
return nil
|
|
763
|
+
})
|
|
764
|
+
if !mutated {
|
|
765
|
+
t.Fatal("target artifact was not mutated")
|
|
766
|
+
}
|
|
767
|
+
assertSingleObservationAttempt(t, stageCalls, tc.stage)
|
|
768
|
+
if observation != nil {
|
|
769
|
+
t.Fatalf("observation = %+v, want nil", observation)
|
|
770
|
+
}
|
|
771
|
+
assertObservationFailure(t, err, ObservationConcurrentChange, "workspace observation: concurrent_change: workspace changed during acquisition; retry")
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
func TestWorkspaceObservationConcurrentChangeDoesNotClaimSameSizeOrTransientDetection(t *testing.T) {
|
|
777
|
+
t.Run("same identity and size", func(t *testing.T) {
|
|
778
|
+
root, slug := newObservationWorkspace(t)
|
|
779
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
780
|
+
before := []byte("before\n")
|
|
781
|
+
after := []byte("after!\n")
|
|
782
|
+
writeObservationArtifact(t, root, slug, "brief.md", before)
|
|
783
|
+
artifactPath := observationArtifactPath(root, slug, "brief.md")
|
|
784
|
+
observation, err := observeWorkspace(root, slug, func(stage observationStage, artifact ArtifactPath) error {
|
|
785
|
+
if stage == observationAfterRead && artifact == "brief.md" {
|
|
786
|
+
writeFile(t, artifactPath, after)
|
|
787
|
+
}
|
|
788
|
+
return nil
|
|
789
|
+
})
|
|
790
|
+
if err != nil {
|
|
791
|
+
if observation != nil {
|
|
792
|
+
t.Fatalf("observation = %+v with error %v, want nil", observation, err)
|
|
793
|
+
}
|
|
794
|
+
assertObservationFailure(t, err, ObservationConcurrentChange, "workspace observation: concurrent_change: workspace changed during acquisition; retry")
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
fact, ok := observation.Fact("brief.md")
|
|
798
|
+
if !ok || fact.State() != ArtifactPresent || (!slices.Equal(fact.Bytes(), before) && !slices.Equal(fact.Bytes(), after)) {
|
|
799
|
+
t.Fatalf("successful same-size acquisition returned incoherent fact: state=%q bytes=%q ok=%v", fact.State(), fact.Bytes(), ok)
|
|
800
|
+
}
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
t.Run("transient appearance between probes", func(t *testing.T) {
|
|
804
|
+
root, slug := newObservationWorkspace(t)
|
|
805
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
806
|
+
artifactPath := observationArtifactPath(root, slug, "brief.md")
|
|
807
|
+
observation, err := observeWorkspace(root, slug, func(stage observationStage, artifact ArtifactPath) error {
|
|
808
|
+
if stage == observationAfterInspect && artifact == "brief.md" {
|
|
809
|
+
writeFile(t, artifactPath, []byte("transient\n"))
|
|
810
|
+
if removeErr := os.Remove(artifactPath); removeErr != nil {
|
|
811
|
+
t.Fatal(removeErr)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return nil
|
|
815
|
+
})
|
|
816
|
+
if err != nil {
|
|
817
|
+
if observation != nil {
|
|
818
|
+
t.Fatalf("observation = %+v with error %v, want nil", observation, err)
|
|
819
|
+
}
|
|
820
|
+
assertObservationFailure(t, err, ObservationConcurrentChange, "workspace observation: concurrent_change: workspace changed during acquisition; retry")
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
fact, ok := observation.Fact("brief.md")
|
|
824
|
+
if !ok || fact.State() != ArtifactAbsent || fact.Bytes() != nil {
|
|
825
|
+
t.Fatalf("successful transient acquisition returned incoherent fact: state=%q bytes=%q ok=%v", fact.State(), fact.Bytes(), ok)
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
func TestWorkspaceObservationConcurrentChangeDoesNotApplyAfterReturn(t *testing.T) {
|
|
831
|
+
root, slug := newObservationWorkspace(t)
|
|
832
|
+
writeObservationArtifact(t, root, slug, LedgerFile, []byte("- Phase: build\n"))
|
|
833
|
+
before := []byte("retained before return\n")
|
|
834
|
+
writeObservationArtifact(t, root, slug, "brief.md", before)
|
|
835
|
+
|
|
836
|
+
observation, err := ObserveWorkspace(root, slug)
|
|
837
|
+
if err != nil {
|
|
838
|
+
t.Fatal(err)
|
|
839
|
+
}
|
|
840
|
+
writeObservationArtifact(t, root, slug, "brief.md", []byte("changed after return\n"))
|
|
841
|
+
|
|
842
|
+
fact, ok := observation.Fact("brief.md")
|
|
843
|
+
if !ok || fact.State() != ArtifactPresent || !slices.Equal(fact.Bytes(), before) {
|
|
844
|
+
t.Fatalf("post-return mutation changed retained fact: state=%q bytes=%q ok=%v", fact.State(), fact.Bytes(), ok)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
func assertSingleObservationAttempt(t *testing.T, calls map[observationStage]int, trigger observationStage) {
|
|
849
|
+
t.Helper()
|
|
850
|
+
if calls[trigger] != 1 {
|
|
851
|
+
t.Fatalf("triggering stage %s calls = %d, want 1", observationStageName(trigger), calls[trigger])
|
|
852
|
+
}
|
|
853
|
+
for stage, count := range calls {
|
|
854
|
+
if count > 1 {
|
|
855
|
+
t.Fatalf("stage %s calls = %d, want at most 1", observationStageName(stage), count)
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
func observationStageName(stage observationStage) string {
|
|
861
|
+
switch stage {
|
|
862
|
+
case observationAfterResolve:
|
|
863
|
+
return "after_resolve"
|
|
864
|
+
case observationAfterInspect:
|
|
865
|
+
return "after_inspect"
|
|
866
|
+
case observationBeforeOpen:
|
|
867
|
+
return "before_open"
|
|
868
|
+
case observationBeforeRead:
|
|
869
|
+
return "before_read"
|
|
870
|
+
case observationAfterRead:
|
|
871
|
+
return "after_read"
|
|
872
|
+
case observationBeforeFinalSweep:
|
|
873
|
+
return "before_final_sweep"
|
|
874
|
+
default:
|
|
875
|
+
return "unknown"
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
func assertArtifactFact(t *testing.T, observation *WorkspaceObservation, path ArtifactPath, state ArtifactState, code DiagnosticCode, wantBytes []byte) {
|
|
880
|
+
t.Helper()
|
|
881
|
+
fact, ok := observation.Fact(path)
|
|
882
|
+
if !ok {
|
|
883
|
+
t.Fatalf("Fact(%q) returned ok=false", path)
|
|
884
|
+
}
|
|
885
|
+
if fact.Path() != path || fact.State() != state || !slices.Equal(fact.Bytes(), wantBytes) {
|
|
886
|
+
t.Fatalf("Fact(%q) = (path %q, state %q, bytes %q), want (%q, %q, %q)", path, fact.Path(), fact.State(), fact.Bytes(), path, state, wantBytes)
|
|
887
|
+
}
|
|
888
|
+
diagnostic, diagnosticOK := fact.Diagnostic()
|
|
889
|
+
if code == "" {
|
|
890
|
+
if diagnosticOK || diagnostic != (ArtifactDiagnostic{}) {
|
|
891
|
+
t.Fatalf("Fact(%q).Diagnostic() = (%+v, %v), want zero/false", path, diagnostic, diagnosticOK)
|
|
892
|
+
}
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
wantDiagnostic := ArtifactDiagnostic{Path: path, State: state, Code: code}
|
|
896
|
+
if !diagnosticOK || diagnostic != wantDiagnostic {
|
|
897
|
+
t.Fatalf("Fact(%q).Diagnostic() = (%+v, %v), want (%+v, true)", path, diagnostic, diagnosticOK, wantDiagnostic)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
func assertObservationFailure(t *testing.T, err error, want ObservationFailure, wantMessage string) {
|
|
902
|
+
t.Helper()
|
|
903
|
+
if err == nil || err.Error() != wantMessage {
|
|
904
|
+
t.Fatalf("error = %v, want %q", err, wantMessage)
|
|
905
|
+
}
|
|
906
|
+
var failure ObservationFailure
|
|
907
|
+
if !errors.As(err, &failure) || failure != want {
|
|
908
|
+
t.Fatalf("errors.As(%v) = %q, want %q", err, failure, want)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
func equalArtifactFact(left, right ArtifactFact) bool {
|
|
913
|
+
return left.path == right.path && left.state == right.state && left.code == right.code && slices.Equal(left.bytes, right.bytes)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
func newObservationWorkspace(t *testing.T) (string, string) {
|
|
917
|
+
t.Helper()
|
|
918
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
919
|
+
slug := "observed"
|
|
920
|
+
if err := os.MkdirAll(filepath.Join(root, "work", slug), 0o755); err != nil {
|
|
921
|
+
t.Fatal(err)
|
|
922
|
+
}
|
|
923
|
+
return root, slug
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
func writeObservationArtifact(t *testing.T, root, slug string, path ArtifactPath, body []byte) {
|
|
927
|
+
t.Helper()
|
|
928
|
+
writeFile(t, observationArtifactPath(root, slug, path), body)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
func observationArtifactPath(root, slug string, path ArtifactPath) string {
|
|
932
|
+
if path == ".devrites/principles.md" {
|
|
933
|
+
return filepath.Join(root, "principles.md")
|
|
934
|
+
}
|
|
935
|
+
return filepath.Join(root, "work", slug, filepath.FromSlash(string(path)))
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
func writeFile(t *testing.T, path string, body []byte) {
|
|
939
|
+
t.Helper()
|
|
940
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
941
|
+
t.Fatal(err)
|
|
942
|
+
}
|
|
943
|
+
if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
944
|
+
t.Fatal(err)
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
func sizedMarkdown(prefix string, size int) []byte {
|
|
949
|
+
body := bytesOf('x', size)
|
|
950
|
+
copy(body, prefix)
|
|
951
|
+
return body
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
func bytesOf(value byte, size int) []byte {
|
|
955
|
+
body := make([]byte, size)
|
|
956
|
+
for i := range body {
|
|
957
|
+
body[i] = value
|
|
958
|
+
}
|
|
959
|
+
return body
|
|
960
|
+
}
|