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
|
@@ -5,71 +5,129 @@ package state
|
|
|
5
5
|
// (observable results), not internal structure.
|
|
6
6
|
|
|
7
7
|
import (
|
|
8
|
+
"errors"
|
|
9
|
+
"io/fs"
|
|
8
10
|
"os"
|
|
9
11
|
"os/exec"
|
|
12
|
+
"path"
|
|
10
13
|
"path/filepath"
|
|
11
14
|
"slices"
|
|
12
15
|
"strings"
|
|
13
16
|
"testing"
|
|
14
17
|
)
|
|
15
18
|
|
|
16
|
-
func
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
func expectedPhasePolicies() []PhasePolicy {
|
|
20
|
+
sectionsSpec := []Section{SectionSpec}
|
|
21
|
+
sectionsPlan := []Section{SectionSpec, SectionPlan}
|
|
22
|
+
sectionsBuild := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
|
|
23
|
+
sectionsProof := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof}
|
|
24
|
+
sectionsComplete := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus}
|
|
25
|
+
|
|
26
|
+
artifactsFrame := []ArtifactPath{"state.md"}
|
|
27
|
+
artifactsSpec := []ArtifactPath{"brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
28
|
+
artifactsClarify := append(append([]ArtifactPath(nil), artifactsSpec...), "decision-coverage.md")
|
|
29
|
+
artifactsPlan := append(append([]ArtifactPath(nil), artifactsClarify...), "architecture.md", "plan.md", "tasks.md", "traceability.md")
|
|
30
|
+
artifactsVetted := append(append([]ArtifactPath(nil), artifactsPlan...), "eng-review.md", "test-plan.md")
|
|
31
|
+
artifactsProof := append(append([]ArtifactPath(nil), artifactsVetted...), "evidence.md", "touched-files.md")
|
|
32
|
+
artifactsFinal := append(append([]ArtifactPath(nil), artifactsProof...), "review.md", "seal.md")
|
|
33
|
+
|
|
34
|
+
return []PhasePolicy{
|
|
35
|
+
{Target: PhaseFrame, ResumeVerb: "frame", TransitionRight: "Frame an unstructured request before lifecycle work.", RequiredArtifacts: artifactsFrame},
|
|
36
|
+
{Target: PhaseSpec, ResumeVerb: "spec", TransitionRight: "Author the product specification.", RequiredSections: sectionsSpec, RequiredArtifacts: artifactsSpec},
|
|
37
|
+
{Target: PhaseClarify, ResumeVerb: "clarify", TransitionRight: "Close decision coverage in the written specification.", RequiredSections: sectionsSpec, RequiredArtifacts: artifactsClarify, BlocksOpenQuestions: true},
|
|
38
|
+
{Target: PhaseTemper, ResumeVerb: "temper", TransitionRight: "Optionally challenge the clarified specification strategy.", RequiredSections: sectionsSpec, RequiredArtifacts: artifactsClarify, BlocksOpenQuestions: true},
|
|
39
|
+
{Target: PhaseDefine, ResumeVerb: "define", TransitionRight: "Author and approve the initial implementation plan.", RequiredSections: sectionsPlan, RequiredArtifacts: artifactsPlan, BlocksOpenQuestions: true},
|
|
40
|
+
{Target: PhasePlan, ResumeVerb: "vet", TransitionRight: "Hold the approved or repaired plan checkpoint for engineering review.", RequiredSections: sectionsPlan, RequiredArtifacts: artifactsPlan, BlocksOpenQuestions: true},
|
|
41
|
+
{Target: PhaseVet, ResumeVerb: "vet", TransitionRight: "Review implementation readiness before build.", RequiredSections: sectionsBuild, RequiredArtifacts: artifactsVetted, BlocksOpenQuestions: true},
|
|
42
|
+
{Target: PhaseBuild, ResumeVerb: "build", TransitionRight: "Implement the next approved vertical slice.", RequiredSections: sectionsBuild, RequiredArtifacts: artifactsVetted, BlocksOpenQuestions: true},
|
|
43
|
+
{Target: PhaseConverge, ResumeVerb: "converge", TransitionRight: "Recover unmet clarified intent into new slices.", RequiredSections: sectionsBuild, RequiredArtifacts: artifactsVetted, BlocksOpenQuestions: true},
|
|
44
|
+
{Target: PhaseProve, ResumeVerb: "prove", TransitionRight: "Produce acceptance evidence for the implementation.", RequiredSections: sectionsProof, RequiredArtifacts: artifactsProof, ProofRequired: true, BlocksOpenQuestions: true},
|
|
45
|
+
{Target: PhasePolish, ResumeVerb: "polish", TransitionRight: "Apply the bounded quality pass.", RequiredSections: sectionsProof, RequiredArtifacts: artifactsProof, ProofRequired: true, BlocksOpenQuestions: true},
|
|
46
|
+
{Target: PhaseReview, ResumeVerb: "review", TransitionRight: "Review the proven implementation.", RequiredSections: sectionsProof, RequiredArtifacts: artifactsProof, ProofRequired: true, BlocksOpenQuestions: true},
|
|
47
|
+
{Target: PhaseSeal, ResumeVerb: "seal", TransitionRight: "Decide the final GO or NO-GO.", RequiredSections: sectionsComplete, RequiredArtifacts: artifactsFinal, ProofRequired: true, BlocksOpenQuestions: true, Shippable: true},
|
|
48
|
+
{Target: PhaseShip, ResumeVerb: "ship", TransitionRight: "Perform authorized release and close-out mutations.", RequiredSections: sectionsComplete, RequiredArtifacts: artifactsFinal, ProofRequired: true, BlocksOpenQuestions: true, Shippable: true},
|
|
49
|
+
{Target: PhaseDone, TransitionRight: "Represent archived completion with no resume command.", RequiredSections: sectionsComplete, RequiredArtifacts: artifactsFinal, ProofRequired: true, BlocksOpenQuestions: true, Shippable: true},
|
|
26
50
|
}
|
|
27
|
-
|
|
28
|
-
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func assertPhasePolicyEqual(t *testing.T, label string, got, want PhasePolicy) {
|
|
54
|
+
t.Helper()
|
|
55
|
+
if got.Target != want.Target ||
|
|
56
|
+
got.ResumeVerb != want.ResumeVerb ||
|
|
57
|
+
got.TransitionRight != want.TransitionRight ||
|
|
58
|
+
!slices.Equal(got.RequiredSections, want.RequiredSections) ||
|
|
59
|
+
!slices.Equal(got.RequiredArtifacts, want.RequiredArtifacts) ||
|
|
60
|
+
got.ProofRequired != want.ProofRequired ||
|
|
61
|
+
got.BlocksOpenQuestions != want.BlocksOpenQuestions ||
|
|
62
|
+
got.Shippable != want.Shippable {
|
|
63
|
+
t.Errorf("%s = %+v, want %+v", label, got, want)
|
|
29
64
|
}
|
|
30
65
|
}
|
|
31
66
|
|
|
32
|
-
func
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
67
|
+
func policyForTest(t *testing.T, target Phase) PhasePolicy {
|
|
68
|
+
t.Helper()
|
|
69
|
+
policy, ok := PolicyFor(target)
|
|
70
|
+
if !ok {
|
|
71
|
+
t.Fatalf("PolicyFor(%q) returned unknown", target)
|
|
36
72
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
return policy
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestPhasePoliciesExposeCanonicalLifecycle(t *testing.T) {
|
|
77
|
+
want := expectedPhasePolicies()
|
|
78
|
+
got := PhasePolicies()
|
|
79
|
+
if len(got) != len(want) {
|
|
80
|
+
t.Fatalf("len(PhasePolicies()) = %d, want %d", len(got), len(want))
|
|
40
81
|
}
|
|
41
|
-
for i
|
|
42
|
-
|
|
43
|
-
|
|
82
|
+
for i := range want {
|
|
83
|
+
assertPhasePolicyEqual(t, "PhasePolicies", got[i], want[i])
|
|
84
|
+
lookedUp, ok := PolicyFor(want[i].Target)
|
|
85
|
+
if !ok {
|
|
86
|
+
t.Fatalf("PolicyFor(%q) returned unknown", want[i].Target)
|
|
44
87
|
}
|
|
88
|
+
assertPhasePolicyEqual(t, "PolicyFor", lookedUp, want[i])
|
|
45
89
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
90
|
+
for _, unknown := range []Phase{"", "planning"} {
|
|
91
|
+
policy, ok := PolicyFor(unknown)
|
|
92
|
+
if ok {
|
|
93
|
+
t.Errorf("PolicyFor(%q) = (%+v, true), want unknown lookup rejected", unknown, policy)
|
|
94
|
+
}
|
|
95
|
+
assertPhasePolicyEqual(t, "unknown PolicyFor result", policy, PhasePolicy{})
|
|
51
96
|
}
|
|
97
|
+
}
|
|
52
98
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
99
|
+
func TestPolicyForReturnsDefensiveNestedCopies(t *testing.T) {
|
|
100
|
+
want := expectedPhasePolicies()[7]
|
|
101
|
+
policy := policyForTest(t, PhaseBuild)
|
|
102
|
+
policy.RequiredSections[0] = SectionStatus
|
|
103
|
+
policy.RequiredArtifacts[0] = "mutated.md"
|
|
104
|
+
|
|
105
|
+
assertPhasePolicyEqual(t, "PolicyFor after mutation", policyForTest(t, PhaseBuild), want)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestPhasePoliciesReturnsDefensiveNestedCopies(t *testing.T) {
|
|
109
|
+
want := expectedPhasePolicies()
|
|
110
|
+
policies := PhasePolicies()
|
|
111
|
+
policies[7].Target = PhaseDone
|
|
112
|
+
policies[7].RequiredSections[0] = SectionStatus
|
|
113
|
+
policies[7].RequiredArtifacts[0] = "mutated.md"
|
|
114
|
+
|
|
115
|
+
assertPhasePolicyEqual(t, "sibling policy after mutation", policies[8], want[8])
|
|
116
|
+
fresh := PhasePolicies()
|
|
117
|
+
assertPhasePolicyEqual(t, "PhasePolicies after mutation", fresh[7], want[7])
|
|
57
118
|
}
|
|
58
119
|
|
|
59
120
|
func TestPrebuildWorkspaceRequirementsAreEnforcedByPhase(t *testing.T) {
|
|
60
121
|
requireFiles := func(phase Phase, names ...string) {
|
|
61
122
|
t.Helper()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
got := make(map[string]bool, len(definition.workspaceRequired))
|
|
67
|
-
for _, name := range definition.workspaceRequired {
|
|
68
|
-
got[name] = true
|
|
123
|
+
policy := policyForTest(t, phase)
|
|
124
|
+
got := make(map[string]bool, len(policy.RequiredArtifacts))
|
|
125
|
+
for _, artifact := range policy.RequiredArtifacts {
|
|
126
|
+
got[string(artifact)] = true
|
|
69
127
|
}
|
|
70
128
|
for _, name := range names {
|
|
71
129
|
if !got[name] {
|
|
72
|
-
t.Errorf("phase %q does not require %s: %v", phase, name,
|
|
130
|
+
t.Errorf("phase %q does not require %s: %v", phase, name, policy.RequiredArtifacts)
|
|
73
131
|
}
|
|
74
132
|
}
|
|
75
133
|
}
|
|
@@ -81,6 +139,32 @@ func TestPrebuildWorkspaceRequirementsAreEnforcedByPhase(t *testing.T) {
|
|
|
81
139
|
requireFiles(PhaseBuild, "decision-coverage.md", "eng-review.md", "test-plan.md")
|
|
82
140
|
}
|
|
83
141
|
|
|
142
|
+
func TestWorkspaceObservationIncludesEveryLifecycleArtifact(t *testing.T) {
|
|
143
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
144
|
+
writeWorkSection(t, root, "all-artifacts", "state.md", "- Phase: frame\n")
|
|
145
|
+
writeWorkSection(t, root, "all-artifacts", "seal.md", "# Seal\n\nGO\n")
|
|
146
|
+
|
|
147
|
+
observation, err := ObserveWorkspace(root, "all-artifacts")
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatal(err)
|
|
150
|
+
}
|
|
151
|
+
expected := map[ArtifactPath]bool{}
|
|
152
|
+
for _, policy := range PhasePolicies() {
|
|
153
|
+
for _, artifact := range policy.RequiredArtifacts {
|
|
154
|
+
expected[artifact] = true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for artifact := range expected {
|
|
158
|
+
if _, observed := observation.Fact(artifact); !observed {
|
|
159
|
+
t.Errorf("observation does not include lifecycle artifact %q", artifact)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
seal, ok := observation.Fact("seal.md")
|
|
163
|
+
if !ok || seal.State() != ArtifactPresent {
|
|
164
|
+
t.Errorf("later-phase seal fact = (%q, %v), want present", seal.State(), ok)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
84
168
|
func TestRuntimeCompletenessUsesWorkspaceRequiredFiles(t *testing.T) {
|
|
85
169
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
86
170
|
for name, body := range map[string]string{
|
|
@@ -110,7 +194,8 @@ func TestRuntimeCompletenessUsesWorkspaceRequiredFiles(t *testing.T) {
|
|
|
110
194
|
|
|
111
195
|
func TestRuntimeCompletenessRejectsEmptyRequiredFile(t *testing.T) {
|
|
112
196
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
113
|
-
for _,
|
|
197
|
+
for _, artifact := range policyForTest(t, PhaseClarify).RequiredArtifacts {
|
|
198
|
+
name := string(artifact)
|
|
114
199
|
body := "# " + name + "\n\nreal\n"
|
|
115
200
|
if name == "decision-coverage.md" {
|
|
116
201
|
body = ""
|
|
@@ -128,39 +213,78 @@ func TestRuntimeCompletenessRejectsEmptyRequiredFile(t *testing.T) {
|
|
|
128
213
|
}
|
|
129
214
|
}
|
|
130
215
|
|
|
131
|
-
func
|
|
216
|
+
func TestPhasePolicyInvariants(t *testing.T) {
|
|
217
|
+
policies := PhasePolicies()
|
|
132
218
|
phaseNames := map[Phase]bool{}
|
|
133
219
|
transitionRights := map[string]Phase{}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
220
|
+
sectionOrder := make(map[Section]int, len(Sections))
|
|
221
|
+
for i, section := range Sections {
|
|
222
|
+
sectionOrder[section] = i
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for i, policy := range policies {
|
|
226
|
+
if policy.Target == "" || phaseNames[policy.Target] {
|
|
227
|
+
t.Fatalf("policy %d has empty or duplicate target %q", i, policy.Target)
|
|
228
|
+
}
|
|
229
|
+
phaseNames[policy.Target] = true
|
|
230
|
+
if policy.TransitionRight == "" || transitionRights[policy.TransitionRight] != "" {
|
|
231
|
+
t.Fatalf("phase %q has empty or duplicate transition right %q", policy.Target, policy.TransitionRight)
|
|
232
|
+
}
|
|
233
|
+
transitionRights[policy.TransitionRight] = policy.Target
|
|
234
|
+
if len(policy.RequiredArtifacts) == 0 {
|
|
235
|
+
t.Fatalf("phase %q has no required artifacts", policy.Target)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
seenSections := map[Section]bool{}
|
|
239
|
+
previousSectionIndex := -1
|
|
240
|
+
for _, section := range policy.RequiredSections {
|
|
241
|
+
index, known := sectionOrder[section]
|
|
242
|
+
if !known || seenSections[section] || index <= previousSectionIndex {
|
|
243
|
+
t.Fatalf("phase %q has non-canonical required sections %v", policy.Target, policy.RequiredSections)
|
|
244
|
+
}
|
|
245
|
+
seenSections[section] = true
|
|
246
|
+
previousSectionIndex = index
|
|
247
|
+
}
|
|
248
|
+
seenArtifacts := map[ArtifactPath]bool{}
|
|
249
|
+
for _, artifact := range policy.RequiredArtifacts {
|
|
250
|
+
name := string(artifact)
|
|
251
|
+
if artifact == "" || seenArtifacts[artifact] {
|
|
252
|
+
t.Fatalf("phase %q has empty or duplicate required artifact %q", policy.Target, artifact)
|
|
253
|
+
}
|
|
254
|
+
if path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.HasPrefix(name, "../") || strings.Contains(name, `\`) {
|
|
255
|
+
t.Fatalf("phase %q has non-feature-relative required artifact %q", policy.Target, artifact)
|
|
150
256
|
}
|
|
151
|
-
if
|
|
152
|
-
t.Fatalf("phase %q
|
|
257
|
+
if artifact == ".devrites/principles.md" {
|
|
258
|
+
t.Fatalf("phase %q includes root principles in feature-relative requirements", policy.Target)
|
|
153
259
|
}
|
|
260
|
+
seenArtifacts[artifact] = true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if i > 0 {
|
|
264
|
+
for _, section := range policies[i-1].RequiredSections {
|
|
265
|
+
if !seenSections[section] {
|
|
266
|
+
t.Errorf("phase %q dropped section %q required by %q", policy.Target, section, policies[i-1].Target)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for _, artifact := range policies[i-1].RequiredArtifacts {
|
|
270
|
+
if !seenArtifacts[artifact] {
|
|
271
|
+
t.Errorf("phase %q dropped artifact %q required by %q", policy.Target, artifact, policies[i-1].Target)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if policy.Target == PhaseDone && policy.ResumeVerb != "" {
|
|
276
|
+
t.Errorf("done resume verb = %q, want empty", policy.ResumeVerb)
|
|
154
277
|
}
|
|
155
|
-
if
|
|
156
|
-
t.
|
|
278
|
+
if policy.Shippable && !policy.ProofRequired {
|
|
279
|
+
t.Errorf("shippable phase %q does not require proof", policy.Target)
|
|
157
280
|
}
|
|
158
281
|
}
|
|
159
282
|
}
|
|
160
283
|
|
|
161
284
|
func TestStatusFixtureBuildIncomplete(t *testing.T) {
|
|
162
285
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
163
|
-
for _,
|
|
286
|
+
for _, artifact := range policyForTest(t, PhaseBuild).RequiredArtifacts {
|
|
287
|
+
name := string(artifact)
|
|
164
288
|
body := "# " + name + "\n\nreal\n"
|
|
165
289
|
if name == "state.md" {
|
|
166
290
|
body = "- Phase: build\n"
|
|
@@ -180,7 +304,8 @@ func TestStatusFixtureBuildIncomplete(t *testing.T) {
|
|
|
180
304
|
|
|
181
305
|
func TestStatusFixtureSpecComplete(t *testing.T) {
|
|
182
306
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
183
|
-
for _,
|
|
307
|
+
for _, artifact := range policyForTest(t, PhaseSpec).RequiredArtifacts {
|
|
308
|
+
name := string(artifact)
|
|
184
309
|
body := "# " + name + "\n\nreal\n"
|
|
185
310
|
if name == "state.md" {
|
|
186
311
|
body = "- Phase: spec\n"
|
|
@@ -196,6 +321,259 @@ func TestStatusFixtureSpecComplete(t *testing.T) {
|
|
|
196
321
|
}
|
|
197
322
|
}
|
|
198
323
|
|
|
324
|
+
func TestStatusRenderRemainsExactForCompleteAndOrdinaryMissingArtifacts(t *testing.T) {
|
|
325
|
+
completeRoot := filepath.Join(t.TempDir(), ".devrites")
|
|
326
|
+
writeStatusRequiredArtifacts(t, completeRoot, "complete", PhaseSpec)
|
|
327
|
+
complete, err := Status(completeRoot, "complete")
|
|
328
|
+
if err != nil {
|
|
329
|
+
t.Fatal(err)
|
|
330
|
+
}
|
|
331
|
+
wantComplete := "feature: complete\n" +
|
|
332
|
+
"phase: spec\n" +
|
|
333
|
+
" spec present required\n" +
|
|
334
|
+
" plan empty\n" +
|
|
335
|
+
" decisions present\n" +
|
|
336
|
+
" tasks empty\n" +
|
|
337
|
+
" proof empty\n" +
|
|
338
|
+
" status present\n" +
|
|
339
|
+
"result: complete\n"
|
|
340
|
+
if got := complete.Render(); got != wantComplete {
|
|
341
|
+
t.Fatalf("complete Render() =\n%q\nwant\n%q", got, wantComplete)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for _, tc := range []struct {
|
|
345
|
+
name string
|
|
346
|
+
briefBody *string
|
|
347
|
+
}{
|
|
348
|
+
{name: "absent"},
|
|
349
|
+
{name: "empty", briefBody: new(string)},
|
|
350
|
+
} {
|
|
351
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
352
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
353
|
+
writeStatusRequiredArtifacts(t, root, tc.name, PhaseSpec)
|
|
354
|
+
brief := filepath.Join(root, "work", tc.name, "brief.md")
|
|
355
|
+
if tc.briefBody == nil {
|
|
356
|
+
if err := os.Remove(brief); err != nil {
|
|
357
|
+
t.Fatal(err)
|
|
358
|
+
}
|
|
359
|
+
} else if err := os.WriteFile(brief, []byte(*tc.briefBody), 0o644); err != nil {
|
|
360
|
+
t.Fatal(err)
|
|
361
|
+
}
|
|
362
|
+
report, statusErr := Status(root, tc.name)
|
|
363
|
+
if statusErr != nil {
|
|
364
|
+
t.Fatal(statusErr)
|
|
365
|
+
}
|
|
366
|
+
want := "feature: " + tc.name + "\n" +
|
|
367
|
+
"phase: spec\n" +
|
|
368
|
+
" spec present required\n" +
|
|
369
|
+
" plan empty\n" +
|
|
370
|
+
" decisions present\n" +
|
|
371
|
+
" tasks empty\n" +
|
|
372
|
+
" proof empty\n" +
|
|
373
|
+
" status present\n" +
|
|
374
|
+
"result: incomplete (missing files: brief.md)\n"
|
|
375
|
+
if got := report.Render(); got != want {
|
|
376
|
+
t.Fatalf("Render() =\n%q\nwant\n%q", got, want)
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
func TestStatusRendersSelectedDiagnosticsBeforeResultWithoutRecovery(t *testing.T) {
|
|
383
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
384
|
+
writeStatusRequiredArtifacts(t, root, "diagnostic", PhaseSpec)
|
|
385
|
+
writeWorkSection(t, root, "diagnostic", "spec.md", "hostile-secret\x00")
|
|
386
|
+
|
|
387
|
+
report, err := Status(root, "diagnostic")
|
|
388
|
+
if err != nil {
|
|
389
|
+
t.Fatal(err)
|
|
390
|
+
}
|
|
391
|
+
want := "feature: diagnostic\n" +
|
|
392
|
+
"phase: spec\n" +
|
|
393
|
+
" spec empty required\n" +
|
|
394
|
+
" plan empty\n" +
|
|
395
|
+
" decisions present\n" +
|
|
396
|
+
" tasks empty\n" +
|
|
397
|
+
" proof empty\n" +
|
|
398
|
+
" status present\n" +
|
|
399
|
+
"artifact: spec.md: malformed (malformed_markdown)\n" +
|
|
400
|
+
"result: incomplete (missing files: spec.md)\n"
|
|
401
|
+
if got := report.Render(); got != want {
|
|
402
|
+
t.Fatalf("Render() =\n%q\nwant\n%q", got, want)
|
|
403
|
+
}
|
|
404
|
+
if strings.Contains(report.Render(), "next:") || strings.Contains(report.Render(), "hostile-secret") {
|
|
405
|
+
t.Fatalf("Status disclosed content or emitted recovery:\n%s", report.Render())
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
func TestStatusOmitsUnselectedEvidenceDiagnostic(t *testing.T) {
|
|
410
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
411
|
+
writeStatusRequiredArtifacts(t, root, "unselected-evidence", PhaseSpec)
|
|
412
|
+
writeWorkSection(t, root, "unselected-evidence", EvidenceFile, "hostile-secret\x00")
|
|
413
|
+
|
|
414
|
+
report, err := Status(root, "unselected-evidence")
|
|
415
|
+
if err != nil {
|
|
416
|
+
t.Fatal(err)
|
|
417
|
+
}
|
|
418
|
+
want := "feature: unselected-evidence\n" +
|
|
419
|
+
"phase: spec\n" +
|
|
420
|
+
" spec present required\n" +
|
|
421
|
+
" plan empty\n" +
|
|
422
|
+
" decisions present\n" +
|
|
423
|
+
" tasks empty\n" +
|
|
424
|
+
" proof empty\n" +
|
|
425
|
+
" status present\n" +
|
|
426
|
+
"result: complete\n"
|
|
427
|
+
if got := report.Render(); got != want {
|
|
428
|
+
t.Fatalf("Render() =\n%q\nwant\n%q", got, want)
|
|
429
|
+
}
|
|
430
|
+
if strings.Contains(report.Render(), "artifact:") || strings.Contains(report.Render(), "next:") {
|
|
431
|
+
t.Fatalf("unselected evidence emitted diagnostic or recovery:\n%s", report.Render())
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func TestStatusReturnsExactLogicalErrorsForUnusableState(t *testing.T) {
|
|
436
|
+
for _, tc := range []struct {
|
|
437
|
+
name string
|
|
438
|
+
prepare func(t *testing.T, root, slug string)
|
|
439
|
+
callback observationCallback
|
|
440
|
+
want string
|
|
441
|
+
}{
|
|
442
|
+
{
|
|
443
|
+
name: "absent",
|
|
444
|
+
prepare: func(*testing.T, string, string) {},
|
|
445
|
+
want: `compute status: feature "broken": state.md is absent; add real content to state.md and retry`,
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: "empty",
|
|
449
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
450
|
+
writeWorkSection(t, root, slug, LedgerFile, "")
|
|
451
|
+
},
|
|
452
|
+
want: `compute status: feature "broken": state.md is empty; add real content to state.md and retry`,
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
name: "malformed",
|
|
456
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
457
|
+
writeWorkSection(t, root, slug, LedgerFile, "bad\x00state")
|
|
458
|
+
},
|
|
459
|
+
want: `compute status: feature "broken": state.md is malformed (malformed_markdown); repair state.md and retry`,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: "final symlink",
|
|
463
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
464
|
+
target := filepath.Join(t.TempDir(), "state.md")
|
|
465
|
+
if err := os.WriteFile(target, []byte("- Phase: build\n"), 0o644); err != nil {
|
|
466
|
+
t.Fatal(err)
|
|
467
|
+
}
|
|
468
|
+
if err := os.Symlink(target, filepath.Join(root, "work", slug, LedgerFile)); err != nil {
|
|
469
|
+
t.Fatal(err)
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
want: `compute status: feature "broken": state.md is unsafe (final_symlink); repair state.md and retry`,
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: "nonregular",
|
|
476
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
477
|
+
if err := os.Mkdir(filepath.Join(root, "work", slug, LedgerFile), 0o755); err != nil {
|
|
478
|
+
t.Fatal(err)
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
want: `compute status: feature "broken": state.md is unsafe (non_regular); repair state.md and retry`,
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: "too large",
|
|
485
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
486
|
+
writeFile(t, filepath.Join(root, "work", slug, LedgerFile), sizedMarkdown("- Phase: build\n", (1<<20)+1))
|
|
487
|
+
},
|
|
488
|
+
want: `compute status: feature "broken": state.md is unsafe (file_too_large); repair state.md and retry`,
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: "permission denied",
|
|
492
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
493
|
+
writeWorkSection(t, root, slug, LedgerFile, "- Phase: build\n")
|
|
494
|
+
},
|
|
495
|
+
callback: func(stage observationStage, path ArtifactPath) error {
|
|
496
|
+
if stage == observationBeforeOpen && path == LedgerFile {
|
|
497
|
+
return fs.ErrPermission
|
|
498
|
+
}
|
|
499
|
+
return nil
|
|
500
|
+
},
|
|
501
|
+
want: `compute status: feature "broken": state.md is unreadable (permission_denied); repair state.md and retry`,
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "read failure",
|
|
505
|
+
prepare: func(t *testing.T, root, slug string) {
|
|
506
|
+
writeWorkSection(t, root, slug, LedgerFile, "- Phase: build\n")
|
|
507
|
+
},
|
|
508
|
+
callback: func(stage observationStage, path ArtifactPath) error {
|
|
509
|
+
if stage == observationBeforeRead && path == LedgerFile {
|
|
510
|
+
return errors.New("hostile read failure")
|
|
511
|
+
}
|
|
512
|
+
return nil
|
|
513
|
+
},
|
|
514
|
+
want: `compute status: feature "broken": state.md is unreadable (read_failure); repair state.md and retry`,
|
|
515
|
+
},
|
|
516
|
+
} {
|
|
517
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
518
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
519
|
+
slug := "broken"
|
|
520
|
+
if err := os.MkdirAll(filepath.Join(root, "work", slug), 0o755); err != nil {
|
|
521
|
+
t.Fatal(err)
|
|
522
|
+
}
|
|
523
|
+
tc.prepare(t, root, slug)
|
|
524
|
+
report, err := statusWithCallback(root, slug, tc.callback)
|
|
525
|
+
if report != nil || err == nil || err.Error() != tc.want {
|
|
526
|
+
t.Fatalf("statusWithCallback() = (%+v, %v), want nil and %q", report, err, tc.want)
|
|
527
|
+
}
|
|
528
|
+
if strings.Contains(err.Error(), root) || strings.Contains(err.Error(), "hostile") {
|
|
529
|
+
t.Fatalf("error disclosed physical path or content: %v", err)
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
func TestStatusUsesRetainedStateWithoutConsumerReopen(t *testing.T) {
|
|
536
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
537
|
+
writeWorkSection(t, root, "retained", LedgerFile, "- Phase: build\n")
|
|
538
|
+
path := filepath.Join(root, "work", "retained", LedgerFile)
|
|
539
|
+
opens := 0
|
|
540
|
+
reads := 0
|
|
541
|
+
report, err := statusWithCallback(root, "retained", func(stage observationStage, artifact ArtifactPath) error {
|
|
542
|
+
if artifact != LedgerFile {
|
|
543
|
+
return nil
|
|
544
|
+
}
|
|
545
|
+
switch stage {
|
|
546
|
+
case observationBeforeOpen:
|
|
547
|
+
opens++
|
|
548
|
+
case observationBeforeRead:
|
|
549
|
+
reads++
|
|
550
|
+
case observationAfterRead:
|
|
551
|
+
if writeErr := os.WriteFile(path, []byte("- Phase: prove\n"), 0o644); writeErr != nil {
|
|
552
|
+
return writeErr
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return nil
|
|
556
|
+
})
|
|
557
|
+
if err != nil {
|
|
558
|
+
t.Fatal(err)
|
|
559
|
+
}
|
|
560
|
+
if report.Phase != PhaseBuild || opens != 1 || reads != 1 {
|
|
561
|
+
t.Fatalf("phase=%q opens=%d reads=%d, want retained build and 1/1", report.Phase, opens, reads)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
func TestStatusPreservesMissingAndUnknownPhaseErrors(t *testing.T) {
|
|
566
|
+
root := filepath.Join(t.TempDir(), ".devrites")
|
|
567
|
+
writeWorkSection(t, root, "missing-phase", LedgerFile, "- Status: build\n")
|
|
568
|
+
if _, err := Status(root, "missing-phase"); err == nil || err.Error() != `compute status: feature "missing-phase": no phase in state.md ledger; record phase in state.md and retry` {
|
|
569
|
+
t.Fatalf("missing-phase error = %v", err)
|
|
570
|
+
}
|
|
571
|
+
writeWorkSection(t, root, "unknown-phase", LedgerFile, "- Phase: building\n")
|
|
572
|
+
if _, err := Status(root, "unknown-phase"); err == nil || err.Error() != `compute status: feature "unknown-phase": unknown phase "building"; record a known phase in state.md and retry` {
|
|
573
|
+
t.Fatalf("unknown-phase error = %v", err)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
199
577
|
func TestCanonicalWorkspaceCompletenessUsesConcretePhaseFiles(t *testing.T) {
|
|
200
578
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
201
579
|
writeWorkSection(t, root, "live", "state.md", "| Key | Value |\n| --- | --- |\n| phase | vet |\n")
|
|
@@ -220,10 +598,10 @@ func TestCanonicalWorkspaceCompletenessUsesConcretePhaseFiles(t *testing.T) {
|
|
|
220
598
|
|
|
221
599
|
func TestFinalPhasesRequireReviewAndSealArtifacts(t *testing.T) {
|
|
222
600
|
for _, phase := range []Phase{PhaseSeal, PhaseShip, PhaseDone} {
|
|
223
|
-
required :=
|
|
224
|
-
for _, name := range []
|
|
601
|
+
required := policyForTest(t, phase).RequiredArtifacts
|
|
602
|
+
for _, name := range []ArtifactPath{"review.md", "seal.md"} {
|
|
225
603
|
if !slices.Contains(required, name) {
|
|
226
|
-
t.Errorf("
|
|
604
|
+
t.Errorf("PolicyFor(%q).RequiredArtifacts = %v, want %s", phase, required, name)
|
|
227
605
|
}
|
|
228
606
|
}
|
|
229
607
|
}
|
|
@@ -235,6 +613,17 @@ func TestStatusUnknownSlugErrors(t *testing.T) {
|
|
|
235
613
|
}
|
|
236
614
|
}
|
|
237
615
|
|
|
616
|
+
func writeStatusRequiredArtifacts(t *testing.T, root, slug string, phase Phase) {
|
|
617
|
+
t.Helper()
|
|
618
|
+
for _, artifact := range policyForTest(t, phase).RequiredArtifacts {
|
|
619
|
+
body := "# Artifact\n\nreal content\n"
|
|
620
|
+
if artifact == LedgerFile {
|
|
621
|
+
body = "- Phase: " + string(phase) + "\n"
|
|
622
|
+
}
|
|
623
|
+
writeWorkSection(t, root, slug, string(artifact), body)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
238
627
|
func writeSection(t *testing.T, root, slug, name, body string) {
|
|
239
628
|
t.Helper()
|
|
240
629
|
dir := filepath.Join(root, "work", slug)
|
|
@@ -260,7 +649,7 @@ func writeWorkSection(t *testing.T, root, slug, name, body string) {
|
|
|
260
649
|
// A live workspace map need not carry frontmatter: the phase lives in
|
|
261
650
|
// the canonical state.md ledger and the proof/status concepts are satisfied by
|
|
262
651
|
// evidence.md/state.md. The engine must load, list, and report it anyway.
|
|
263
|
-
func
|
|
652
|
+
func TestStatusFromOfficialBulletLedger(t *testing.T) {
|
|
264
653
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
265
654
|
writeSection(t, root, "live", "state.md", "- Phase: prove\n- Status: running\n")
|
|
266
655
|
writeSection(t, root, "live", "spec.md", "# Spec\n\nDo the thing.\n")
|
|
@@ -276,11 +665,12 @@ func TestLoadFeatureFromOfficialBulletLedger(t *testing.T) {
|
|
|
276
665
|
if rep.Phase != PhaseProve {
|
|
277
666
|
t.Errorf("phase = %q, want prove (from the state.md ledger)", rep.Phase)
|
|
278
667
|
}
|
|
279
|
-
|
|
280
|
-
|
|
668
|
+
rendered := rep.Render()
|
|
669
|
+
if !strings.Contains(rendered, " proof present required\n") {
|
|
670
|
+
t.Errorf("proof section should be present via canonical evidence.md:\n%s", rendered)
|
|
281
671
|
}
|
|
282
|
-
if !
|
|
283
|
-
t.
|
|
672
|
+
if !strings.Contains(rendered, " status present\n") {
|
|
673
|
+
t.Errorf("status section should be present via canonical state.md:\n%s", rendered)
|
|
284
674
|
}
|
|
285
675
|
|
|
286
676
|
slugs, err := ListFeatures(root)
|
|
@@ -307,7 +697,7 @@ func TestStateLedgerIgnoresOptionalREADMEFrontmatter(t *testing.T) {
|
|
|
307
697
|
}
|
|
308
698
|
}
|
|
309
699
|
|
|
310
|
-
func
|
|
700
|
+
func TestStatusRejectsCorruptStateMarkdownWithoutContentDisclosure(t *testing.T) {
|
|
311
701
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
312
702
|
dir := filepath.Join(root, "work", "corrupt")
|
|
313
703
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
@@ -316,8 +706,9 @@ func TestLoadFeatureRejectsCorruptStateMarkdown(t *testing.T) {
|
|
|
316
706
|
if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte("| phase | build |\x00\n"), 0o644); err != nil {
|
|
317
707
|
t.Fatal(err)
|
|
318
708
|
}
|
|
319
|
-
|
|
320
|
-
|
|
709
|
+
want := `compute status: feature "corrupt": state.md is malformed (malformed_markdown); repair state.md and retry`
|
|
710
|
+
if _, err := Status(root, "corrupt"); err == nil || err.Error() != want {
|
|
711
|
+
t.Fatalf("Status() error = %v, want %q", err, want)
|
|
321
712
|
}
|
|
322
713
|
}
|
|
323
714
|
|
|
@@ -360,7 +751,7 @@ func TestSpeculativeWorkspaceAliasesAreRejected(t *testing.T) {
|
|
|
360
751
|
t.Fatal(err)
|
|
361
752
|
}
|
|
362
753
|
|
|
363
|
-
if _, err :=
|
|
754
|
+
if _, err := Status(root, "aliases"); err == nil {
|
|
364
755
|
t.Fatal("feature.md/index.md/status.md aliases created a feature without state.md")
|
|
365
756
|
}
|
|
366
757
|
if slugs, err := ListFeatures(root); err != nil {
|
|
@@ -371,11 +762,16 @@ func TestSpeculativeWorkspaceAliasesAreRejected(t *testing.T) {
|
|
|
371
762
|
|
|
372
763
|
writeWorkSection(t, root, "aliases", "state.md", "- Phase: prove\n")
|
|
373
764
|
writeWorkSection(t, root, "aliases", "proof.md", "# Proof\n\nOld alias content.\n")
|
|
374
|
-
|
|
765
|
+
report, err := Status(root, "aliases")
|
|
766
|
+
if err != nil {
|
|
767
|
+
t.Fatal(err)
|
|
768
|
+
}
|
|
769
|
+
observation, err := ObserveWorkspace(root, "aliases")
|
|
375
770
|
if err != nil {
|
|
376
771
|
t.Fatal(err)
|
|
377
772
|
}
|
|
378
|
-
|
|
773
|
+
evidence, ok := observation.Fact(EvidenceFile)
|
|
774
|
+
if report.present[SectionProof] || !ok || evidence.State() != ArtifactAbsent {
|
|
379
775
|
t.Fatal("proof.md alias satisfied canonical evidence.md presence")
|
|
380
776
|
}
|
|
381
777
|
}
|
|
@@ -383,7 +779,7 @@ func TestSpeculativeWorkspaceAliasesAreRejected(t *testing.T) {
|
|
|
383
779
|
func TestStatusCursorCannotStandInForPhase(t *testing.T) {
|
|
384
780
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
385
781
|
writeWorkSection(t, root, "status-only", "state.md", "- Status: build\n")
|
|
386
|
-
if _, err :=
|
|
782
|
+
if _, err := Status(root, "status-only"); err == nil || !strings.Contains(err.Error(), "no phase in state.md") {
|
|
387
783
|
t.Fatalf("status-as-phase error = %v, want explicit missing phase", err)
|
|
388
784
|
}
|
|
389
785
|
}
|
|
@@ -398,33 +794,25 @@ func TestLedgerPhaseRejectsUnknownWord(t *testing.T) {
|
|
|
398
794
|
}
|
|
399
795
|
}
|
|
400
796
|
|
|
401
|
-
func
|
|
402
|
-
dir := t.TempDir()
|
|
797
|
+
func TestArtifactClassificationDistinguishesContentFromStubs(t *testing.T) {
|
|
403
798
|
cases := []struct {
|
|
404
799
|
name string
|
|
405
800
|
body string
|
|
406
|
-
want
|
|
801
|
+
want ArtifactState
|
|
407
802
|
}{
|
|
408
|
-
{"
|
|
409
|
-
{"
|
|
410
|
-
{"
|
|
411
|
-
{"
|
|
412
|
-
{"
|
|
413
|
-
{"content
|
|
414
|
-
{"
|
|
415
|
-
{"
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if c.name != "missing" {
|
|
422
|
-
if err := os.WriteFile(path, []byte(c.body), 0o644); err != nil {
|
|
423
|
-
t.Fatal(err)
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
if got := sectionPresent(path); got != c.want {
|
|
427
|
-
t.Errorf("case %d (%s): sectionPresent = %v, want %v", i, c.name, got, c.want)
|
|
803
|
+
{"empty", "", ArtifactEmpty},
|
|
804
|
+
{"heading-only stub", "# Tasks\n", ArtifactEmpty},
|
|
805
|
+
{"frontmatter-only", "---\nphase: build\n---\n", ArtifactEmpty},
|
|
806
|
+
{"real content", "# Spec\n\nDo the thing.\n", ArtifactPresent},
|
|
807
|
+
{"content after frontmatter", "---\nk: v\n---\n\nreal words\n", ArtifactPresent},
|
|
808
|
+
{"fenced content", "# Notes\n\n```\nexample\n```\n", ArtifactPresent},
|
|
809
|
+
{"NUL content", "# Notes\n\nbad\x00\n", ArtifactMalformed},
|
|
810
|
+
{"malformed UTF-8", "# Notes\n\nbad\xff\n", ArtifactMalformed},
|
|
811
|
+
}
|
|
812
|
+
for _, tc := range cases {
|
|
813
|
+
state, _ := classifyArtifact([]byte(tc.body))
|
|
814
|
+
if state != tc.want {
|
|
815
|
+
t.Errorf("%s: classifyArtifact state=%q, want %q", tc.name, state, tc.want)
|
|
428
816
|
}
|
|
429
817
|
}
|
|
430
818
|
}
|