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.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +65 -6
  4. package/engine/go.mod +1 -1
  5. package/engine/internal/gate/gate.go +154 -46
  6. package/engine/internal/gate/gate_test.go +290 -2
  7. package/engine/internal/gate/readiness_binding.go +66 -100
  8. package/engine/internal/gate/readiness_binding_test.go +126 -3
  9. package/engine/internal/lib/resolve.go +6 -6
  10. package/engine/internal/state/cmd/workflowmanifest/main.go +32 -3
  11. package/engine/internal/state/feature.go +1 -100
  12. package/engine/internal/state/observation.go +584 -0
  13. package/engine/internal/state/observation_open_other.go +9 -0
  14. package/engine/internal/state/observation_open_unix.go +12 -0
  15. package/engine/internal/state/observation_test.go +960 -0
  16. package/engine/internal/state/observation_unix_test.go +68 -0
  17. package/engine/internal/state/schema.go +60 -160
  18. package/engine/internal/state/state_test.go +489 -101
  19. package/engine/internal/state/status.go +67 -48
  20. package/engine/tests/adr_0004_required_by_phase_test.go +16 -13
  21. package/engine/tests/adr_0011_define_plan_test.go +14 -12
  22. package/engine/tests/gate_test.go +403 -28
  23. package/engine/tests/workspace_observation_migration_test.go +1065 -0
  24. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  25. package/pack/.claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  26. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  27. package/pack/.claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  28. package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  29. package/pack/.claude/skills/rite-autocomplete/SKILL.md +98 -146
  30. package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  31. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +96 -134
  32. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  33. package/pack/.claude/skills/rite-build/SKILL.md +51 -79
  34. package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -17
  35. package/pack/.claude/skills/rite-plan/SKILL.md +17 -17
  36. package/pack/.claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  37. package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  38. package/pack/.claude/skills/rite-prove/SKILL.md +95 -153
  39. package/pack/.claude/skills/rite-vet/SKILL.md +121 -174
  40. package/pack/.claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  41. package/pack/.claude/skills/rite-vet/reference/artifacts.md +10 -6
  42. package/pack/.claude/skills/rite-vet/reference/depth.md +10 -3
  43. package/pack/.claude/skills/rite-vet/reference/review-axes.md +12 -4
  44. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  45. package/pack/generated/claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  46. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  47. package/pack/generated/claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  48. package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  49. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +98 -146
  50. package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  51. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +96 -134
  52. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  53. package/pack/generated/claude/skills/rite-build/SKILL.md +51 -79
  54. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -17
  55. package/pack/generated/claude/skills/rite-plan/SKILL.md +17 -17
  56. package/pack/generated/claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  57. package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  58. package/pack/generated/claude/skills/rite-prove/SKILL.md +95 -153
  59. package/pack/generated/claude/skills/rite-vet/SKILL.md +121 -174
  60. package/pack/generated/claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  61. package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +10 -6
  62. package/pack/generated/claude/skills/rite-vet/reference/depth.md +10 -3
  63. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +12 -4
  64. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -10
  65. package/pack/generated/codex/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  66. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  67. package/pack/generated/codex/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  68. package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  69. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +98 -146
  70. package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  71. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +96 -134
  72. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  73. package/pack/generated/codex/skills/rite-build/SKILL.md +51 -79
  74. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -17
  75. package/pack/generated/codex/skills/rite-plan/SKILL.md +17 -17
  76. package/pack/generated/codex/skills/rite-plan/reference/anti-patterns.md +11 -1
  77. package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +10 -3
  78. package/pack/generated/codex/skills/rite-prove/SKILL.md +95 -153
  79. package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -174
  80. package/pack/generated/codex/skills/rite-vet/reference/anti-patterns.md +14 -2
  81. package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +10 -6
  82. package/pack/generated/codex/skills/rite-vet/reference/depth.md +10 -3
  83. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +12 -4
  84. 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
+ }