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
@@ -3,10 +3,12 @@ package gate
3
3
  import (
4
4
  "os"
5
5
  "path/filepath"
6
+ "slices"
6
7
  "strings"
7
8
  "testing"
8
9
 
9
10
  "github.com/devrites/devrites/internal/reason"
11
+ "github.com/devrites/devrites/internal/state"
10
12
  "github.com/devrites/devrites/internal/testutil"
11
13
  )
12
14
 
@@ -109,6 +111,52 @@ func TestSealRequiresDurableReviewAndSealArtifacts(t *testing.T) {
109
111
  }
110
112
  }
111
113
 
114
+ func TestCheckReturnsMissingSectionsInSelectedPolicyOrder(t *testing.T) {
115
+ tests := []struct {
116
+ name string
117
+ kind Kind
118
+ current state.Phase
119
+ required state.Phase
120
+ emptyArtifacts []string
121
+ wantMissing []state.Section
122
+ }{
123
+ {
124
+ name: "readiness",
125
+ kind: Readiness,
126
+ current: state.PhaseBuild,
127
+ required: state.PhaseBuild,
128
+ emptyArtifacts: []string{"tasks.md", "spec.md"},
129
+ wantMissing: []state.Section{state.SectionSpec, state.SectionTasks},
130
+ },
131
+ {
132
+ name: "seal",
133
+ kind: Seal,
134
+ current: state.PhaseSpec,
135
+ required: state.PhaseSeal,
136
+ emptyArtifacts: []string{state.EvidenceFile, "tasks.md"},
137
+ wantMissing: []state.Section{state.SectionTasks, state.SectionProof},
138
+ },
139
+ }
140
+
141
+ for _, test := range tests {
142
+ t.Run(test.name, func(t *testing.T) {
143
+ root := t.TempDir()
144
+ writeCompleteGateFeature(t, root, test.name, test.current, test.required, "none\n")
145
+ for _, name := range test.emptyArtifacts {
146
+ testutil.WriteFile(t, filepath.Join(root, "work", test.name, name), "# Empty\n")
147
+ }
148
+
149
+ result, err := Check(test.kind, root, test.name)
150
+ if err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ if !slices.Equal(result.Missing, test.wantMissing) {
154
+ t.Fatalf("Missing=%v, want %v", result.Missing, test.wantMissing)
155
+ }
156
+ })
157
+ }
158
+ }
159
+
112
160
  func TestRenderIncludesReasonForPassedGate(t *testing.T) {
113
161
  result := Result{
114
162
  Kind: Seal,
@@ -149,6 +197,54 @@ func TestStateAwaitingHumanReadsCanonicalCursor(t *testing.T) {
149
197
  }
150
198
  }
151
199
 
200
+ func TestCheckAppliesOpenQuestionsByTargetPolicy(t *testing.T) {
201
+ const openQuestion = "## q-1\nstatus: open\ngate: blocking\n"
202
+ cases := []struct {
203
+ name string
204
+ kind Kind
205
+ current state.Phase
206
+ required state.Phase
207
+ wantTarget state.Phase
208
+ wantBlocked bool
209
+ wantReason reason.ID
210
+ wantInvariant string
211
+ }{
212
+ {name: "frame readiness", kind: Readiness, current: state.PhaseFrame, required: state.PhaseFrame, wantTarget: state.PhaseFrame, wantReason: reason.GateReadinessPassed},
213
+ {name: "spec readiness", kind: Readiness, current: state.PhaseSpec, required: state.PhaseSpec, wantTarget: state.PhaseSpec, wantReason: reason.GateReadinessPassed},
214
+ {name: "clarify readiness", kind: Readiness, current: state.PhaseClarify, required: state.PhaseClarify, wantTarget: state.PhaseClarify, wantBlocked: true, wantReason: reason.GateReadinessMissing, wantInvariant: "open blocking human question(s) remain in questions.md but state.md is not awaiting_human"},
215
+ {name: "seal from spec", kind: Seal, current: state.PhaseSpec, required: state.PhaseSeal, wantTarget: state.PhaseSeal, wantBlocked: true, wantReason: reason.GateSealMissing, wantInvariant: "open blocking human question(s) remain in questions.md but state.md is not awaiting_human"},
216
+ }
217
+
218
+ for _, tc := range cases {
219
+ t.Run(tc.name, func(t *testing.T) {
220
+ root := t.TempDir()
221
+ writeCompleteGateFeature(t, root, "phase-policy", tc.current, tc.required, openQuestion)
222
+ if tc.kind == Seal {
223
+ binding, err := ReadinessBinding(root, "phase-policy")
224
+ if err != nil {
225
+ t.Fatal(err)
226
+ }
227
+ testutil.AppendFile(t, filepath.Join(root, "work", "phase-policy", "eng-review.md"), "\n"+binding+"\n")
228
+ }
229
+
230
+ got, err := Check(tc.kind, root, "phase-policy")
231
+ if err != nil {
232
+ t.Fatal(err)
233
+ }
234
+ if got.Target != tc.wantTarget || got.Blocked != tc.wantBlocked || got.ReasonID != tc.wantReason {
235
+ t.Fatalf("target=%q blocked=%v reason=%q, want target=%q blocked=%v reason=%q", got.Target, got.Blocked, got.ReasonID, tc.wantTarget, tc.wantBlocked, tc.wantReason)
236
+ }
237
+ if tc.wantInvariant == "" {
238
+ if len(got.StateProblems) != 0 {
239
+ t.Fatalf("StateProblems=%v, want none", got.StateProblems)
240
+ }
241
+ } else if strings.Join(got.StateProblems, "\n") != tc.wantInvariant {
242
+ t.Fatalf("StateProblems=%q, want %q", got.StateProblems, tc.wantInvariant)
243
+ }
244
+ })
245
+ }
246
+ }
247
+
152
248
  func TestCheckBlocksOpenHumanQuestions(t *testing.T) {
153
249
  for _, tc := range []struct {
154
250
  name string
@@ -201,14 +297,18 @@ func TestCheckUsesWorkspaceOverrideForStateInvariants(t *testing.T) {
201
297
  if err := os.MkdirAll(root, 0o755); err != nil {
202
298
  t.Fatal(err)
203
299
  }
204
- override := filepath.Join(root, "work", "alpha")
300
+ physicalRoot, err := filepath.EvalSymlinks(root)
301
+ if err != nil {
302
+ t.Fatal(err)
303
+ }
304
+ override := filepath.Join(physicalRoot, "work", "alpha")
205
305
  t.Setenv("DEVRITES_WORKSPACE", override)
206
306
  testutil.WriteFile(t, filepath.Join(override, "README.md"), "---\nphase: spec\nschemaVersion: 1\n---\n")
207
307
  testutil.WriteFile(t, filepath.Join(override, "state.md"), "- Phase: build\n- Status: running\n")
208
308
  testutil.WriteFile(t, filepath.Join(override, "spec.md"), "spec\n")
209
309
  testutil.WriteFile(t, filepath.Join(override, "questions.md"), "## q-1\nstatus: open\ngate: blocking\n")
210
310
 
211
- got, err := Check(Readiness, root, "alpha")
311
+ got, err := Check(Readiness, physicalRoot, "alpha")
212
312
  if err != nil {
213
313
  t.Fatal(err)
214
314
  }
@@ -225,6 +325,194 @@ func TestOpenBlockingQuestionGates(t *testing.T) {
225
325
  }
226
326
  }
227
327
 
328
+ func TestCheckObservationUsesRetainedPhaseQuestionsReadinessAndReview(t *testing.T) {
329
+ root := t.TempDir()
330
+ workspace := writeReadinessFixture(t, root, "retained", "build")
331
+ binding := mustReadinessBinding(t, root, "retained")
332
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
333
+
334
+ observation, err := state.ObserveWorkspace(root, "retained")
335
+ if err != nil {
336
+ t.Fatal(err)
337
+ }
338
+ testutil.WriteFile(t, filepath.Join(workspace, "state.md"), "- Phase: spec\n- Status: running\n")
339
+ testutil.WriteFile(t, filepath.Join(workspace, "questions.md"), "## q-1\nstatus: open\ngate: blocking\n")
340
+ testutil.WriteFile(t, filepath.Join(workspace, "plan.md"), "# Plan\n\nChanged after observation.\n")
341
+ testutil.WriteFile(t, filepath.Join(workspace, "eng-review.md"), "# Engineering review\n\nNo binding.\n")
342
+
343
+ result, err := checkObservation(Readiness, observation)
344
+ if err != nil {
345
+ t.Fatal(err)
346
+ }
347
+ if result.Phase != state.PhaseBuild || result.Blocked || len(result.StateProblems) != 0 {
348
+ t.Fatalf("retained result phase=%q blocked=%v problems=%v", result.Phase, result.Blocked, result.StateProblems)
349
+ }
350
+ }
351
+
352
+ func TestGateDiagnosticRecoveryLines(t *testing.T) {
353
+ for _, tc := range []struct {
354
+ diagnostic state.ArtifactDiagnostic
355
+ required string
356
+ optional string
357
+ }{
358
+ {
359
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactMalformed, Code: state.DiagnosticMalformedMarkdown},
360
+ "next: repair spec.md: replace invalid Markdown with valid Markdown; required artifacts need substantive content",
361
+ "next: repair spec.md: replace invalid Markdown with valid Markdown; optional readiness input may instead be removed",
362
+ },
363
+ {
364
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactUnsafe, Code: state.DiagnosticParentSymlink},
365
+ "next: repair spec.md: replace the symlinked parent with a real directory",
366
+ "next: repair spec.md: replace the symlinked parent with a real directory; optional readiness input may instead be removed",
367
+ },
368
+ {
369
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactUnsafe, Code: state.DiagnosticFinalSymlink},
370
+ "next: repair spec.md: replace the symlink with a regular file",
371
+ "next: repair spec.md: replace the symlink with a regular file; optional readiness input may instead be removed",
372
+ },
373
+ {
374
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactUnsafe, Code: state.DiagnosticNonRegular},
375
+ "next: repair spec.md: replace the non-regular entry with a regular file",
376
+ "next: repair spec.md: replace the non-regular entry with a regular file; optional readiness input may instead be removed",
377
+ },
378
+ {
379
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactUnsafe, Code: state.DiagnosticFileTooLarge},
380
+ "next: repair spec.md: reduce the file to at most 1 MiB",
381
+ "next: repair spec.md: reduce the file to at most 1 MiB; optional readiness input may instead be removed",
382
+ },
383
+ {
384
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactUnreadable, Code: state.DiagnosticPermissionDenied},
385
+ "next: repair spec.md: grant read permission",
386
+ "next: repair spec.md: grant read permission; optional readiness input may instead be removed",
387
+ },
388
+ {
389
+ state.ArtifactDiagnostic{Path: "spec.md", State: state.ArtifactUnreadable, Code: state.DiagnosticReadFailure},
390
+ "next: repair spec.md: restore a readable regular file",
391
+ "next: repair spec.md: restore a readable regular file; optional readiness input may instead be removed",
392
+ },
393
+ } {
394
+ if got := diagnosticRecovery(tc.diagnostic, true); got != tc.required {
395
+ t.Errorf("required diagnosticRecovery(%+v)=%q, want %q", tc.diagnostic, got, tc.required)
396
+ }
397
+ if got := diagnosticRecovery(tc.diagnostic, false); got != tc.optional {
398
+ t.Errorf("optional diagnosticRecovery(%+v)=%q, want %q", tc.diagnostic, got, tc.optional)
399
+ }
400
+ }
401
+
402
+ unknown := state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnsafe, Code: "unknown"}
403
+ if got := diagnosticRecovery(unknown, true); got != "" {
404
+ t.Fatalf("required unknown diagnosticRecovery()=%q, want empty", got)
405
+ }
406
+ if got := diagnosticRecovery(unknown, false); got != "" {
407
+ t.Fatalf("optional unknown diagnosticRecovery()=%q, want empty", got)
408
+ }
409
+ }
410
+
411
+ func TestCheckRendersRequiredDiagnosticBeforeRecoveryWithoutGenericRecovery(t *testing.T) {
412
+ root := t.TempDir()
413
+ writeCompleteGateFeature(t, root, "diagnostic", state.PhaseSpec, state.PhaseSpec, "none\n")
414
+ testutil.WriteFile(t, filepath.Join(root, "work", "diagnostic", "spec.md"), "hostile-secret\x00")
415
+
416
+ result, err := Check(Readiness, root, "diagnostic")
417
+ if err != nil {
418
+ t.Fatal(err)
419
+ }
420
+ want := "gate: readiness\n" +
421
+ "feature: diagnostic\n" +
422
+ "phase: spec\n" +
423
+ "result: blocked (missing to leave \"spec\": spec.md)\n" +
424
+ "reason: DRV-GATE-READINESS-MISSING\n" +
425
+ "artifact: spec.md: malformed (malformed_markdown)\n" +
426
+ "next: repair spec.md: replace invalid Markdown with valid Markdown; required artifacts need substantive content\n" +
427
+ "retry: devrites-engine check readiness diagnostic\n"
428
+ if got := result.Render(); got != want {
429
+ t.Fatalf("Render()=\n%q\nwant\n%q", got, want)
430
+ }
431
+ if strings.Contains(result.Render(), "next: add real content") || strings.Contains(result.Render(), "hostile-secret") {
432
+ t.Fatalf("diagnostic output used generic recovery or disclosed content:\n%s", result.Render())
433
+ }
434
+ }
435
+
436
+ func TestCheckOmitsUnselectedEvidenceDiagnostic(t *testing.T) {
437
+ root := t.TempDir()
438
+ const slug = "unselected-evidence"
439
+ writeCompleteGateFeature(t, root, slug, state.PhaseSpec, state.PhaseSpec, "none\n")
440
+ workspace := filepath.Join(root, "work", slug)
441
+ if err := os.Symlink("brief.md", filepath.Join(workspace, state.EvidenceFile)); err != nil {
442
+ t.Fatalf("create unselected evidence symlink: %v", err)
443
+ }
444
+
445
+ result, err := Check(Readiness, root, slug)
446
+ if err != nil {
447
+ t.Fatal(err)
448
+ }
449
+ want := "gate: readiness\n" +
450
+ "feature: unselected-evidence\n" +
451
+ "phase: spec\n" +
452
+ "result: pass\n" +
453
+ "reason: DRV-GATE-READINESS-PASSED\n"
454
+ if got := result.Render(); got != want {
455
+ t.Fatalf("Render()=\n%q\nwant\n%q", got, want)
456
+ }
457
+ if strings.Contains(result.Render(), "artifact:") || strings.Contains(result.Render(), "next:") {
458
+ t.Fatalf("unselected evidence emitted diagnostic or recovery:\n%s", result.Render())
459
+ }
460
+ }
461
+
462
+ func TestCheckSelectsOptionalReadinessDiagnosticInBindingOrder(t *testing.T) {
463
+ root := t.TempDir()
464
+ workspace := writeReadinessFixture(t, root, "optional-diagnostic", "build")
465
+ testutil.WriteFile(t, filepath.Join(workspace, "strategy.md"), "bad\x00strategy")
466
+
467
+ result, err := Check(Readiness, root, "optional-diagnostic")
468
+ if err != nil {
469
+ t.Fatal(err)
470
+ }
471
+ if !result.Blocked || result.ReasonID != reason.GateReadinessStale || len(result.Diagnostics) != 1 {
472
+ t.Fatalf("blocked=%v reason=%q diagnostics=%+v", result.Blocked, result.ReasonID, result.Diagnostics)
473
+ }
474
+ rendered := result.Render()
475
+ ordered := []string{
476
+ "reason: DRV-GATE-READINESS-STALE\n",
477
+ "artifact: strategy.md: malformed (malformed_markdown)\n",
478
+ "next: repair strategy.md: replace invalid Markdown with valid Markdown; optional readiness input may instead be removed\n",
479
+ "invariant: readiness input strategy.md is malformed (malformed_markdown); replace invalid Markdown with valid Markdown; repair the input and rerun /rite-vet\n",
480
+ "retry: devrites-engine check readiness optional-diagnostic\n",
481
+ }
482
+ position := -1
483
+ for _, line := range ordered {
484
+ next := strings.Index(rendered, line)
485
+ if next <= position {
486
+ t.Fatalf("Render() missing ordered line %q:\n%s", line, rendered)
487
+ }
488
+ position = next
489
+ }
490
+ }
491
+
492
+ func writeCompleteGateFeature(t *testing.T, root, slug string, current, required state.Phase, questions string) {
493
+ t.Helper()
494
+ policy, ok := state.PolicyFor(required)
495
+ if !ok {
496
+ t.Fatalf("PolicyFor(%q) returned unknown", required)
497
+ }
498
+ questionsRequired := false
499
+ for _, artifact := range policy.RequiredArtifacts {
500
+ name := string(artifact)
501
+ content := "# " + name + "\n\nreal\n"
502
+ switch name {
503
+ case "state.md":
504
+ content = "- Phase: " + string(current) + "\n- Status: running\n"
505
+ case "questions.md":
506
+ questionsRequired = true
507
+ content = questions
508
+ }
509
+ testutil.WriteFile(t, filepath.Join(root, "work", slug, name), content)
510
+ }
511
+ if !questionsRequired {
512
+ testutil.WriteFile(t, filepath.Join(root, "work", slug, "questions.md"), questions)
513
+ }
514
+ }
515
+
228
516
  func writeFeature(t *testing.T, root, slug string, files map[string]string) {
229
517
  t.Helper()
230
518
  for name, content := range files {
@@ -7,26 +7,20 @@ import (
7
7
  "errors"
8
8
  "fmt"
9
9
  "hash"
10
- "io"
11
- "os"
12
- "path/filepath"
13
10
  "strings"
14
11
 
15
- "github.com/devrites/devrites/internal/devritespaths"
16
12
  "github.com/devrites/devrites/internal/markdowntext"
17
13
  "github.com/devrites/devrites/internal/state"
18
14
  )
19
15
 
20
16
  const (
21
- readinessBindingLabel = "Readiness inputs SHA-256: "
22
- readinessBindingDomain = "devrites-readiness-inputs"
23
- readinessBindingVersion = "1"
24
- maxReadinessInputBytes int64 = 1 << 20
25
- maxReadinessTotalBytes int64 = 8 << 20
17
+ readinessBindingLabel = "Readiness inputs SHA-256: "
18
+ readinessBindingDomain = "devrites-readiness-inputs"
19
+ readinessBindingVersion = "1"
26
20
  )
27
21
 
28
22
  type readinessInput struct {
29
- logical string
23
+ logical state.ArtifactPath
30
24
  required bool
31
25
  }
32
26
 
@@ -46,31 +40,24 @@ var readinessInputs = [...]readinessInput{
46
40
 
47
41
  // ReadinessBinding returns the exact line Vet records in eng-review.md.
48
42
  func ReadinessBinding(root, slug string) (string, error) {
49
- workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
43
+ observation, err := state.ObserveWorkspace(root, slug)
50
44
  if err != nil {
51
- return "", errors.New("readiness inputs: feature workspace is unavailable")
45
+ return "", err
52
46
  }
47
+ return readinessBindingFromObservation(observation)
48
+ }
53
49
 
50
+ func readinessBindingFromObservation(observation *state.WorkspaceObservation) (string, error) {
54
51
  digest := sha256.New()
55
52
  writeReadinessField(digest, readinessBindingDomain)
56
53
  writeReadinessField(digest, readinessBindingVersion)
57
54
  writeReadinessField(digest, fmt.Sprintf("%d", len(readinessInputs)))
58
- var total int64
59
55
  for _, input := range readinessInputs {
60
- path := filepath.Join(workspace, filepath.FromSlash(input.logical))
61
- if input.logical == ".devrites/principles.md" {
62
- path = filepath.Join(root, "principles.md")
63
- }
64
- content, present, err := readReadinessInput(path, input.logical, input.required)
56
+ content, present, err := retainedReadinessInput(observation, input)
65
57
  if err != nil {
66
58
  return "", err
67
59
  }
68
- total += int64(len(content))
69
- if total > maxReadinessTotalBytes {
70
- return "", fmt.Errorf("readiness input %s exceeds the 8 MiB aggregate limit", input.logical)
71
- }
72
-
73
- writeReadinessField(digest, input.logical)
60
+ writeReadinessField(digest, string(input.logical))
74
61
  if present {
75
62
  writeReadinessField(digest, "present")
76
63
  } else {
@@ -79,31 +66,64 @@ func ReadinessBinding(root, slug string) (string, error) {
79
66
  writeReadinessLength(digest, uint64(len(content)))
80
67
  _, _ = digest.Write(content)
81
68
  }
82
-
83
69
  return readinessBindingLabel + hex.EncodeToString(digest.Sum(nil)), nil
84
70
  }
85
71
 
86
- func verifyReadinessBinding(root, slug string) (string, error) {
87
- expected, err := ReadinessBinding(root, slug)
72
+ func retainedReadinessInput(observation *state.WorkspaceObservation, input readinessInput) ([]byte, bool, error) {
73
+ fact, ok := observation.Fact(input.logical)
74
+ if !ok {
75
+ return nil, false, fmt.Errorf("readiness input %s cannot be inspected", input.logical)
76
+ }
77
+ switch fact.State() {
78
+ case state.ArtifactAbsent:
79
+ if input.required {
80
+ return nil, false, fmt.Errorf("readiness input %s is missing", input.logical)
81
+ }
82
+ return nil, false, nil
83
+ case state.ArtifactEmpty, state.ArtifactPresent:
84
+ return fact.Bytes(), true, nil
85
+ case state.ArtifactMalformed, state.ArtifactUnsafe, state.ArtifactUnreadable:
86
+ diagnostic, _ := fact.Diagnostic()
87
+ return nil, false, readinessDiagnosticError(diagnostic)
88
+ default:
89
+ return nil, false, fmt.Errorf("readiness input %s cannot be inspected", input.logical)
90
+ }
91
+ }
92
+
93
+ func readinessDiagnostics(observation *state.WorkspaceObservation) []state.ArtifactDiagnostic {
94
+ var diagnostics []state.ArtifactDiagnostic
95
+ for _, input := range readinessInputs {
96
+ fact, ok := observation.Fact(input.logical)
97
+ if !ok {
98
+ continue
99
+ }
100
+ if diagnostic, hasDiagnostic := fact.Diagnostic(); hasDiagnostic {
101
+ diagnostics = append(diagnostics, diagnostic)
102
+ }
103
+ }
104
+ return diagnostics
105
+ }
106
+
107
+ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, error) {
108
+ expected, err := readinessBindingFromObservation(observation)
88
109
  if err != nil {
89
110
  return "", err
90
111
  }
91
- workspace, err := devritespaths.ExistingFeatureDirChecked(root, slug)
92
- if err != nil {
93
- return "", errors.New("readiness input eng-review.md cannot be inspected")
112
+ fact, ok := observation.Fact("eng-review.md")
113
+ if !ok || fact.State() == state.ArtifactAbsent {
114
+ return expected, errors.New("readiness input eng-review.md is missing")
94
115
  }
95
- content, _, err := readReadinessInput(filepath.Join(workspace, "eng-review.md"), "eng-review.md", true)
96
- if err != nil {
97
- return expected, err
116
+ if diagnostic, hasDiagnostic := fact.Diagnostic(); hasDiagnostic {
117
+ return expected, readinessDiagnosticError(diagnostic)
98
118
  }
99
- structural, err := markdowntext.Structural(content)
119
+ visible, err := markdowntext.Structural(fact.Bytes())
100
120
  if err != nil {
101
121
  return expected, errors.New("readiness input eng-review.md is invalid Markdown")
102
122
  }
103
123
 
104
124
  attempts := 0
105
125
  matches := 0
106
- for _, raw := range strings.Split(string(structural), "\n") {
126
+ for _, raw := range strings.Split(string(visible), "\n") {
107
127
  line := strings.TrimSuffix(raw, "\r")
108
128
  if !strings.Contains(line, readinessBindingLabel) {
109
129
  continue
@@ -119,76 +139,22 @@ func verifyReadinessBinding(root, slug string) (string, error) {
119
139
  return expected, nil
120
140
  }
121
141
 
122
- func phaseRequiresReadinessBinding(phase state.Phase) bool {
123
- for _, name := range state.RequiredWorkspaceFiles(phase) {
124
- if name == "eng-review.md" {
125
- return true
126
- }
142
+ func readinessDiagnosticError(diagnostic state.ArtifactDiagnostic) error {
143
+ prefix := fmt.Sprintf("readiness input %s is %s (%s); ", diagnostic.Path, diagnostic.State, diagnostic.Code)
144
+ repair := diagnosticRepair(diagnostic.Code)
145
+ if repair == "" {
146
+ repair = "restore a readable regular file"
127
147
  }
128
- return false
148
+ return errors.New(prefix + repair)
129
149
  }
130
150
 
131
- func readReadinessInput(path, logical string, required bool) ([]byte, bool, error) {
132
- before, err := os.Lstat(path)
133
- if errors.Is(err, os.ErrNotExist) {
134
- if required {
135
- return nil, false, fmt.Errorf("readiness input %s is missing", logical)
151
+ func phaseRequiresReadinessBinding(policy state.PhasePolicy) bool {
152
+ for _, artifact := range policy.RequiredArtifacts {
153
+ if artifact == "eng-review.md" {
154
+ return true
136
155
  }
137
- return nil, false, nil
138
- }
139
- if err != nil {
140
- return nil, false, fmt.Errorf("readiness input %s cannot be inspected", logical)
141
- }
142
- if before.Mode()&os.ModeSymlink != 0 {
143
- return nil, false, fmt.Errorf("readiness input %s is a symlink", logical)
144
- }
145
- if !before.Mode().IsRegular() {
146
- return nil, false, fmt.Errorf("readiness input %s is not a regular file", logical)
147
- }
148
- if before.Size() < 0 || before.Size() > maxReadinessInputBytes {
149
- return nil, false, fmt.Errorf("readiness input %s exceeds the 1 MiB limit", logical)
150
- }
151
-
152
- file, err := os.Open(path)
153
- if err != nil {
154
- return nil, false, fmt.Errorf("readiness input %s cannot be opened", logical)
155
- }
156
- defer file.Close()
157
- opened, err := file.Stat()
158
- if err != nil {
159
- return nil, false, fmt.Errorf("readiness input %s cannot be inspected", logical)
160
156
  }
161
- if !opened.Mode().IsRegular() || opened.Size() != before.Size() || !os.SameFile(before, opened) {
162
- return nil, false, fmt.Errorf("readiness input %s changed type while opening", logical)
163
- }
164
- content, err := io.ReadAll(io.LimitReader(file, maxReadinessInputBytes+1))
165
- if err != nil {
166
- return nil, false, fmt.Errorf("readiness input %s cannot be read", logical)
167
- }
168
- if int64(len(content)) > maxReadinessInputBytes {
169
- return nil, false, fmt.Errorf("readiness input %s exceeds the 1 MiB limit", logical)
170
- }
171
- after, err := file.Stat()
172
- if err != nil {
173
- return nil, false, fmt.Errorf("readiness input %s cannot be inspected", logical)
174
- }
175
- current, err := os.Lstat(path)
176
- if err != nil {
177
- return nil, false, fmt.Errorf("readiness input %s cannot be inspected", logical)
178
- }
179
- if current.Mode()&os.ModeSymlink != 0 ||
180
- !current.Mode().IsRegular() ||
181
- !after.Mode().IsRegular() ||
182
- after.Size() != opened.Size() ||
183
- after.Size() != int64(len(content)) ||
184
- !os.SameFile(opened, after) ||
185
- !os.SameFile(opened, current) {
186
- return nil, false, fmt.Errorf("readiness input %s changed size or type while reading", logical)
187
- }
188
- if _, err := markdowntext.Structural(content); err != nil {
189
- return nil, false, fmt.Errorf("readiness input %s is invalid Markdown", logical)
190
- }
191
- return content, true, nil
157
+ return false
192
158
  }
193
159
 
194
160
  func writeReadinessField(digest hash.Hash, value string) {