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
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
22
|
-
readinessBindingDomain
|
|
23
|
-
readinessBindingVersion
|
|
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
|
|
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
|
-
|
|
43
|
+
observation, err := state.ObserveWorkspace(root, slug)
|
|
50
44
|
if err != nil {
|
|
51
|
-
return "",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
if
|
|
93
|
-
return
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
return expected, err
|
|
116
|
+
if diagnostic, hasDiagnostic := fact.Diagnostic(); hasDiagnostic {
|
|
117
|
+
return expected, readinessDiagnosticError(diagnostic)
|
|
98
118
|
}
|
|
99
|
-
|
|
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(
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
148
|
+
return errors.New(prefix + repair)
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
func
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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) {
|