devrites 4.5.0 → 4.7.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 +12 -0
- package/NOTICE.md +0 -13
- package/README.md +4 -3
- package/docs/adr/0029-v5-workspace-schema-and-native-migration.md +62 -0
- package/docs/adr/README.md +3 -2
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/command-map.md +2 -2
- package/docs/engine/commands.md +9 -0
- package/docs/engine/state-schema.md +2 -2
- package/docs/engine/workspace-schema.md +9 -3
- package/docs/usage.md +1 -1
- package/engine/commands.go +23 -0
- package/engine/internal/fsutil/copy_test.go +193 -0
- package/engine/internal/gate/gate_test.go +7 -7
- package/engine/internal/gate/readiness_binding_test.go +8 -2
- package/engine/internal/install/apply.go +314 -0
- package/engine/internal/install/binary.go +255 -0
- package/engine/internal/install/install.go +0 -1596
- package/engine/internal/install/manifest.go +109 -0
- package/engine/internal/install/merge.go +468 -0
- package/engine/internal/install/preflight.go +234 -0
- package/engine/internal/install/uninstall.go +119 -0
- package/engine/internal/install/update.go +184 -0
- package/engine/internal/lib/candidate_test.go +3 -0
- package/engine/internal/lib/closeout.go +4 -0
- package/engine/internal/lib/closeout_test.go +3 -3
- package/engine/internal/lib/migrate.go +219 -0
- package/engine/internal/lib/migrate_test.go +154 -0
- package/engine/internal/lib/resolve.go +3 -0
- package/engine/internal/lib/resolve_remediation_test.go +1 -1
- package/engine/internal/lib/secretscan_test.go +1 -0
- package/engine/internal/lib/taskgraph_test.go +2 -2
- package/engine/internal/parallel/ops.go +67 -16
- package/engine/internal/parallel/ops_test.go +211 -0
- package/engine/internal/state/cmd/workflowmanifest/main.go +24 -13
- package/engine/internal/state/cmd/workflowmanifest/main_test.go +96 -0
- package/engine/internal/state/cursor.go +74 -0
- package/engine/internal/state/schema.go +3 -2
- package/engine/internal/state/state_test.go +2 -2
- package/engine/internal/state/workflow_manifest.json +1 -1
- package/engine/internal/state/workspaceschema.go +58 -0
- package/engine/internal/state/workspaceschema_test.go +67 -0
- package/engine/main.go +7 -31
- package/engine/root_routing_test.go +10 -2
- package/engine/testdata/fixtures/basic/devrites-root/work/auth-tokens/state.md +1 -0
- package/engine/testdata/fixtures/basic/devrites-root/work/search-ranking/state.md +1 -0
- package/engine/testdata/golden/TestParityResolve/answer/state.md.golden +1 -0
- package/engine/testdata/golden/TestParityResolve/drop/state.md.golden +1 -0
- package/engine/tests/parity_closeout_test.go +3 -0
- package/engine/tests/parity_resolve_test.go +1 -0
- package/pack/.claude/agents/devrites-code-reviewer.md +8 -0
- package/pack/.claude/agents/devrites-devex-reviewer.md +7 -0
- package/pack/.claude/agents/devrites-doubt-reviewer.md +7 -0
- package/pack/.claude/agents/devrites-evidence-scout.md +4 -0
- package/pack/.claude/agents/devrites-frontend-reviewer.md +8 -0
- package/pack/.claude/agents/devrites-performance-reviewer.md +7 -0
- package/pack/.claude/agents/devrites-plan-reviewer.md +8 -0
- package/pack/.claude/agents/devrites-retrospector.md +2 -0
- package/pack/.claude/agents/devrites-security-auditor.md +7 -0
- package/pack/.claude/agents/devrites-simplifier-reviewer.md +7 -0
- package/pack/.claude/agents/devrites-spec-reviewer.md +7 -0
- package/pack/.claude/agents/devrites-strategy-reviewer.md +7 -0
- package/pack/.claude/agents/devrites-test-analyst.md +7 -0
- package/pack/.claude/skills/devrites-frontend-craft/SKILL.md +4 -7
- package/pack/.claude/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
- package/pack/.claude/skills/devrites-lib/reference/intent-map.md +7 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
- package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +9 -7
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +1 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +6 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/coding-style.md +4 -3
- package/pack/.claude/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/core.md +3 -6
- package/pack/.claude/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/documentation.md +6 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/elicitation.md +5 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/error-handling.md +14 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
- package/pack/.claude/skills/devrites-lib/reference/standards/patterns.md +16 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/performance.md +4 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/security.md +10 -2
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
- package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +2 -2
- package/pack/.claude/skills/devrites-lib/reference/standards/tooling.md +1 -1
- package/pack/.claude/skills/rite-define/reference/plan-template.md +5 -2
- package/pack/.claude/skills/rite-learn/SKILL.md +6 -0
- package/pack/.claude/skills/rite-polish/SKILL.md +4 -0
- package/pack/.claude/skills/rite-polish/reference/anti-ai-slop.md +12 -53
- package/pack/.claude/skills/rite-pr-feedback/SKILL.md +7 -2
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -4
- package/pack/.claude/skills/rite-status/SKILL.md +2 -0
- package/pack/generated/claude/agents/devrites-code-reviewer.md +8 -0
- package/pack/generated/claude/agents/devrites-devex-reviewer.md +7 -0
- package/pack/generated/claude/agents/devrites-doubt-reviewer.md +7 -0
- package/pack/generated/claude/agents/devrites-evidence-scout.md +4 -0
- package/pack/generated/claude/agents/devrites-frontend-reviewer.md +8 -0
- package/pack/generated/claude/agents/devrites-performance-reviewer.md +7 -0
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +8 -0
- package/pack/generated/claude/agents/devrites-retrospector.md +2 -0
- package/pack/generated/claude/agents/devrites-security-auditor.md +7 -0
- package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +7 -0
- package/pack/generated/claude/agents/devrites-spec-reviewer.md +7 -0
- package/pack/generated/claude/agents/devrites-strategy-reviewer.md +7 -0
- package/pack/generated/claude/agents/devrites-test-analyst.md +7 -0
- package/pack/generated/claude/skills/devrites-frontend-craft/SKILL.md +4 -7
- package/pack/generated/claude/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
- package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +7 -0
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
- package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +9 -7
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +1 -1
- package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +6 -1
- package/pack/generated/claude/skills/devrites-lib/reference/standards/coding-style.md +4 -3
- package/pack/generated/claude/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +3 -6
- package/pack/generated/claude/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/documentation.md +6 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
- package/pack/generated/claude/skills/devrites-lib/reference/standards/elicitation.md +5 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/error-handling.md +14 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
- package/pack/generated/claude/skills/devrites-lib/reference/standards/patterns.md +16 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/performance.md +4 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +10 -2
- package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
- package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +2 -2
- package/pack/generated/claude/skills/devrites-lib/reference/standards/tooling.md +1 -1
- package/pack/generated/claude/skills/rite-define/reference/plan-template.md +5 -2
- package/pack/generated/claude/skills/rite-learn/SKILL.md +6 -0
- package/pack/generated/claude/skills/rite-polish/SKILL.md +4 -0
- package/pack/generated/claude/skills/rite-polish/reference/anti-ai-slop.md +12 -53
- package/pack/generated/claude/skills/rite-pr-feedback/SKILL.md +7 -2
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -4
- package/pack/generated/claude/skills/rite-status/SKILL.md +2 -0
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +8 -0
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +7 -0
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +7 -0
- package/pack/generated/codex/agents/devrites-evidence-scout.toml +4 -0
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +8 -0
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +7 -0
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +8 -0
- package/pack/generated/codex/agents/devrites-retrospector.toml +2 -0
- package/pack/generated/codex/agents/devrites-security-auditor.toml +7 -0
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +7 -0
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +7 -0
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +7 -0
- package/pack/generated/codex/agents/devrites-test-analyst.toml +7 -0
- package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +4 -7
- package/pack/generated/codex/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
- package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +7 -0
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +9 -7
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +6 -1
- package/pack/generated/codex/skills/devrites-lib/reference/standards/coding-style.md +4 -3
- package/pack/generated/codex/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +3 -6
- package/pack/generated/codex/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/documentation.md +6 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
- package/pack/generated/codex/skills/devrites-lib/reference/standards/elicitation.md +5 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/error-handling.md +14 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
- package/pack/generated/codex/skills/devrites-lib/reference/standards/patterns.md +16 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/performance.md +4 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +10 -2
- package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
- package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +2 -2
- package/pack/generated/codex/skills/devrites-lib/reference/standards/tooling.md +1 -1
- package/pack/generated/codex/skills/rite-define/reference/plan-template.md +5 -2
- package/pack/generated/codex/skills/rite-learn/SKILL.md +6 -0
- package/pack/generated/codex/skills/rite-polish/SKILL.md +4 -0
- package/pack/generated/codex/skills/rite-polish/reference/anti-ai-slop.md +12 -53
- package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +7 -2
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -4
- package/pack/generated/codex/skills/rite-status/SKILL.md +2 -0
- package/package.json +1 -1
- package/docs/markdown-instruction-upgrade-2026-08-27.md +0 -127
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// commitIn makes a one-file green commit inside a worker worktree and returns
|
|
13
|
+
// its transfer commit SHA.
|
|
14
|
+
func commitIn(t *testing.T, wt, file, marker string) string {
|
|
15
|
+
t.Helper()
|
|
16
|
+
body := "package main\n\nfunc " + marker + "() {}\n"
|
|
17
|
+
if err := os.WriteFile(filepath.Join(wt, file), []byte(body), 0o644); err != nil {
|
|
18
|
+
t.Fatal(err)
|
|
19
|
+
}
|
|
20
|
+
gitOk(t, wt, "add", file)
|
|
21
|
+
gitOk(t, wt, "commit", "-m", "slice "+marker)
|
|
22
|
+
return gitOk(t, wt, "rev-parse", "HEAD")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// greenBatch creates a running lease with two recorded-green slices.
|
|
26
|
+
func greenBatch(t *testing.T, repo, base, slug, batch string) *Lease {
|
|
27
|
+
t.Helper()
|
|
28
|
+
lease, err := Create(CreateOpts{
|
|
29
|
+
Root: repo,
|
|
30
|
+
Slug: slug,
|
|
31
|
+
BatchID: batch,
|
|
32
|
+
BaseSHA: base,
|
|
33
|
+
Slices: []SlicePaths{
|
|
34
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
35
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
tcA := commitIn(t, lease.Slices[0].WorktreePath, "src/a.go", "A")
|
|
42
|
+
tcB := commitIn(t, lease.Slices[1].WorktreePath, "src/b.go", "B")
|
|
43
|
+
if _, err := RecordGreen(repo, slug, "slice-a", tcA); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
if _, err := RecordGreen(repo, slug, "slice-b", tcB); err != nil {
|
|
47
|
+
t.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
return lease
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type seamRestore struct {
|
|
53
|
+
origLease func(string, *Lease) error
|
|
54
|
+
origRemove func(string, string) error
|
|
55
|
+
origWarn func(string, ...any)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func swapSeams(t *testing.T) {
|
|
59
|
+
t.Helper()
|
|
60
|
+
s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origWarn: warnf}
|
|
61
|
+
t.Cleanup(func() { writeLease, removeWorktree, warnf = s.origLease, s.origRemove, s.origWarn })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// TestIntegrateFailedTransitionSurvivesTransientWriteFailure proves the
|
|
65
|
+
// integrate-failed lease transition is retried once when the first write
|
|
66
|
+
// fails, so the on-disk lease honestly records integrate-failed instead of a
|
|
67
|
+
// stale running status.
|
|
68
|
+
func TestIntegrateFailedTransitionSurvivesTransientWriteFailure(t *testing.T) {
|
|
69
|
+
repo, base := setupRepo(t)
|
|
70
|
+
slug, batch := "demo-feature", "batch1"
|
|
71
|
+
greenBatch(t, repo, base, slug, batch)
|
|
72
|
+
leasePath, err := LeasePath(repo, slug)
|
|
73
|
+
if err != nil {
|
|
74
|
+
t.Fatal(err)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Corrupt a transfer commit so Integrate fails mid-flight via fail().
|
|
78
|
+
corrupted, err := ReadLease(leasePath)
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
corrupted.Slices[0].TransferCommit = strings.Repeat("deadbeef", 5)
|
|
83
|
+
if err := WriteLease(leasePath, corrupted); err != nil {
|
|
84
|
+
t.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
swapSeams(t)
|
|
88
|
+
calls := 0
|
|
89
|
+
writeLease = func(path string, l *Lease) error {
|
|
90
|
+
calls++
|
|
91
|
+
if calls == 1 {
|
|
92
|
+
return errors.New("transient lease write failure")
|
|
93
|
+
}
|
|
94
|
+
// delegates to the real WriteLease captured by swapSeams
|
|
95
|
+
return origWriteLease()(path, l)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_, got, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
|
|
99
|
+
if err == nil {
|
|
100
|
+
t.Fatal("expected integrate failure from corrupted transfer commit")
|
|
101
|
+
}
|
|
102
|
+
if calls != 2 {
|
|
103
|
+
t.Fatalf("lease write calls=%d want 2 (initial + one retry)", calls)
|
|
104
|
+
}
|
|
105
|
+
if got == nil || got.Status != StatusIntegrateFailed {
|
|
106
|
+
t.Fatalf("returned lease status=%v want integrate-failed", got)
|
|
107
|
+
}
|
|
108
|
+
onDisk, err := ReadLease(leasePath)
|
|
109
|
+
if err != nil {
|
|
110
|
+
t.Fatal(err)
|
|
111
|
+
}
|
|
112
|
+
if onDisk.Status != StatusIntegrateFailed {
|
|
113
|
+
t.Fatalf("on-disk lease status=%s want integrate-failed", onDisk.Status)
|
|
114
|
+
}
|
|
115
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
116
|
+
t.Fatalf("control head %s want base %s", head, base)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// origWriteLease returns the real WriteLease bypassing any test override.
|
|
121
|
+
func origWriteLease() func(string, *Lease) error {
|
|
122
|
+
return WriteLease
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// TestIntegrateSurfacesStuckStagingWorktree proves a staging-worktree removal
|
|
126
|
+
// failure on the integrate success path is propagated (fail closed, status
|
|
127
|
+
// integrate-failed, control untouched) instead of silently compounding into
|
|
128
|
+
// un-cleanable branch state.
|
|
129
|
+
func TestIntegrateSurfacesStuckStagingWorktree(t *testing.T) {
|
|
130
|
+
repo, base := setupRepo(t)
|
|
131
|
+
slug, batch := "demo-feature", "batch1"
|
|
132
|
+
greenBatch(t, repo, base, slug, batch)
|
|
133
|
+
leasePath, err := LeasePath(repo, slug)
|
|
134
|
+
if err != nil {
|
|
135
|
+
t.Fatal(err)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
swapSeams(t)
|
|
139
|
+
removeWorktree = func(string, string) error {
|
|
140
|
+
return errors.New("simulated locked staging worktree")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_, got, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
|
|
144
|
+
if err == nil {
|
|
145
|
+
t.Fatal("expected integrate to surface the stuck staging worktree")
|
|
146
|
+
}
|
|
147
|
+
if !strings.Contains(err.Error(), "staging worktree") {
|
|
148
|
+
t.Fatalf("error should name the staging worktree, got %v", err)
|
|
149
|
+
}
|
|
150
|
+
if got == nil || got.Status != StatusIntegrateFailed {
|
|
151
|
+
t.Fatalf("returned lease status=%v want integrate-failed", got)
|
|
152
|
+
}
|
|
153
|
+
onDisk, err := ReadLease(leasePath)
|
|
154
|
+
if err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
if onDisk.Status != StatusIntegrateFailed {
|
|
158
|
+
t.Fatalf("on-disk lease status=%s want integrate-failed", onDisk.Status)
|
|
159
|
+
}
|
|
160
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
161
|
+
t.Fatalf("control head %s want base %s (merge must not apply when staging cleanup fails)", head, base)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// TestCleanupWarnsOnFailedBranchCleanup proves Cleanup reports branch cleanup
|
|
166
|
+
// failures instead of erasing the lease while orphans remain.
|
|
167
|
+
func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
|
|
168
|
+
repo, base := setupRepo(t)
|
|
169
|
+
slug, batch := "demo-feature", "batch1"
|
|
170
|
+
lease, err := Create(CreateOpts{
|
|
171
|
+
Root: repo,
|
|
172
|
+
Slug: slug,
|
|
173
|
+
BatchID: batch,
|
|
174
|
+
BaseSHA: base,
|
|
175
|
+
Slices: []SlicePaths{
|
|
176
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
177
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
if err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
branchA := lease.Slices[0].Branch
|
|
184
|
+
|
|
185
|
+
swapSeams(t)
|
|
186
|
+
// Removal "fails" without removing; Cleanup's own RemoveAll then deletes
|
|
187
|
+
// the worktree directory while the stale git registration still lists the
|
|
188
|
+
// branch as checked out, so deleteBranch refuses and must be reported.
|
|
189
|
+
removeWorktree = func(string, string) error {
|
|
190
|
+
return errors.New("simulated stuck worker worktree")
|
|
191
|
+
}
|
|
192
|
+
var warnings []string
|
|
193
|
+
warnf = func(format string, args ...any) {
|
|
194
|
+
warnings = append(warnings, fmt.Sprintf(format, args...))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if err := Cleanup(repo, slug, true); err != nil {
|
|
198
|
+
t.Fatalf("cleanup continues after reporting failures: %v", err)
|
|
199
|
+
}
|
|
200
|
+
joined := strings.Join(warnings, "\n")
|
|
201
|
+
if !strings.Contains(joined, branchA) {
|
|
202
|
+
t.Fatalf("warnings should name the stuck branch %s, got:\n%s", branchA, joined)
|
|
203
|
+
}
|
|
204
|
+
leasePath, err := LeasePath(repo, slug)
|
|
205
|
+
if err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
|
|
209
|
+
t.Fatalf("cleanup should still clear the lease, got %v", err)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"encoding/json"
|
|
8
8
|
"flag"
|
|
9
9
|
"fmt"
|
|
10
|
+
"io"
|
|
10
11
|
"os"
|
|
11
12
|
|
|
12
13
|
"github.com/devrites/devrites/internal/state"
|
|
@@ -42,9 +43,19 @@ func workflowPhases() []workflowPhase {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
func main() {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
os.Exit(run(os.Args[1:], os.Stderr))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// run is the testable entry point. It returns the process exit code.
|
|
50
|
+
func run(args []string, stderr io.Writer) int {
|
|
51
|
+
flags := flag.NewFlagSet("workflowmanifest", flag.ContinueOnError)
|
|
52
|
+
flags.SetOutput(io.Discard)
|
|
53
|
+
outPath := flags.String("out", "workflow_manifest.json", "output path")
|
|
54
|
+
check := flags.Bool("check", false, "fail when the output is stale")
|
|
55
|
+
if err := flags.Parse(args); err != nil {
|
|
56
|
+
fmt.Fprintln(stderr, err)
|
|
57
|
+
return 2
|
|
58
|
+
}
|
|
48
59
|
|
|
49
60
|
document := struct {
|
|
50
61
|
GeneratedBy string `json:"generatedBy"`
|
|
@@ -61,25 +72,25 @@ func main() {
|
|
|
61
72
|
}
|
|
62
73
|
data, err := json.MarshalIndent(document, "", " ")
|
|
63
74
|
if err != nil {
|
|
64
|
-
|
|
75
|
+
fmt.Fprintln(stderr, err)
|
|
76
|
+
return 1
|
|
65
77
|
}
|
|
66
78
|
data = append(data, '\n')
|
|
67
79
|
if *check {
|
|
68
80
|
current, err := os.ReadFile(*outPath)
|
|
69
81
|
if err != nil {
|
|
70
|
-
|
|
82
|
+
fmt.Fprintln(stderr, err)
|
|
83
|
+
return 1
|
|
71
84
|
}
|
|
72
85
|
if !bytes.Equal(current, data) {
|
|
73
|
-
|
|
86
|
+
fmt.Fprintf(stderr, "%s is stale; run go generate ./internal/state\n", *outPath)
|
|
87
|
+
return 1
|
|
74
88
|
}
|
|
75
|
-
return
|
|
89
|
+
return 0
|
|
76
90
|
}
|
|
77
91
|
if err := os.WriteFile(*outPath, data, 0o644); err != nil {
|
|
78
|
-
|
|
92
|
+
fmt.Fprintln(stderr, err)
|
|
93
|
+
return 1
|
|
79
94
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
func fatal(err error) {
|
|
83
|
-
fmt.Fprintln(os.Stderr, err)
|
|
84
|
-
os.Exit(1)
|
|
95
|
+
return 0
|
|
85
96
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/devrites/devrites/internal/state"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestRunWritesDeterministicManifest(t *testing.T) {
|
|
15
|
+
dir := t.TempDir()
|
|
16
|
+
out := filepath.Join(dir, "workflow_manifest.json")
|
|
17
|
+
|
|
18
|
+
if code := run([]string{"--out", out}, os.Stderr); code != 0 {
|
|
19
|
+
t.Fatalf("run exit=%d", code)
|
|
20
|
+
}
|
|
21
|
+
first, err := os.ReadFile(out)
|
|
22
|
+
if err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
var document struct {
|
|
26
|
+
GeneratedBy string `json:"generatedBy"`
|
|
27
|
+
SchemaVersion int `json:"schemaVersion"`
|
|
28
|
+
Phases []struct {
|
|
29
|
+
ID state.Phase `json:"id"`
|
|
30
|
+
TransitionRight string `json:"transitionRight"`
|
|
31
|
+
} `json:"phases"`
|
|
32
|
+
}
|
|
33
|
+
if err := json.Unmarshal(first, &document); err != nil {
|
|
34
|
+
t.Fatalf("manifest is not valid JSON: %v", err)
|
|
35
|
+
}
|
|
36
|
+
if document.SchemaVersion != state.SchemaVersion {
|
|
37
|
+
t.Fatalf("schemaVersion=%d want %d", document.SchemaVersion, state.SchemaVersion)
|
|
38
|
+
}
|
|
39
|
+
if !strings.HasPrefix(document.GeneratedBy, "go generate ./internal/state") {
|
|
40
|
+
t.Fatalf("generatedBy %q", document.GeneratedBy)
|
|
41
|
+
}
|
|
42
|
+
if len(document.Phases) == 0 {
|
|
43
|
+
t.Fatal("manifest must list phases")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Deterministic: a second run produces identical bytes.
|
|
47
|
+
if code := run([]string{"--out", out}, os.Stderr); code != 0 {
|
|
48
|
+
t.Fatalf("second run exit=%d", code)
|
|
49
|
+
}
|
|
50
|
+
second, err := os.ReadFile(out)
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
if !bytes.Equal(first, second) {
|
|
55
|
+
t.Fatal("manifest output is not deterministic")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func TestRunCheckPassesOnCurrentAndFailsOnStale(t *testing.T) {
|
|
60
|
+
dir := t.TempDir()
|
|
61
|
+
out := filepath.Join(dir, "workflow_manifest.json")
|
|
62
|
+
|
|
63
|
+
if code := run([]string{"--out", out}, os.Stderr); code != 0 {
|
|
64
|
+
t.Fatalf("generate exit=%d", code)
|
|
65
|
+
}
|
|
66
|
+
if code := run([]string{"--out", out, "--check"}, os.Stderr); code != 0 {
|
|
67
|
+
t.Fatalf("check on current manifest exit=%d", code)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if err := os.WriteFile(out, []byte("{\"stale\": true}\n"), 0o644); err != nil {
|
|
71
|
+
t.Fatal(err)
|
|
72
|
+
}
|
|
73
|
+
stderr := &strings.Builder{}
|
|
74
|
+
if code := run([]string{"--out", out, "--check"}, stderr); code != 1 {
|
|
75
|
+
t.Fatalf("check on stale manifest exit=%d want 1", code)
|
|
76
|
+
}
|
|
77
|
+
if !strings.Contains(stderr.String(), "is stale") {
|
|
78
|
+
t.Fatalf("stderr should name the stale manifest, got %q", stderr.String())
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestRunCheckFailsOnMissingManifest(t *testing.T) {
|
|
83
|
+
dir := t.TempDir()
|
|
84
|
+
missing := filepath.Join(dir, "absent.json")
|
|
85
|
+
stderr := &strings.Builder{}
|
|
86
|
+
if code := run([]string{"--out", missing, "--check"}, stderr); code != 1 {
|
|
87
|
+
t.Fatalf("check on missing manifest exit=%d want 1", code)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestRunRejectsUnknownFlags(t *testing.T) {
|
|
92
|
+
stderr := &strings.Builder{}
|
|
93
|
+
if code := run([]string{"--bogus"}, stderr); code != 2 {
|
|
94
|
+
t.Fatalf("unknown flag exit=%d want 2", code)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -20,6 +20,7 @@ const (
|
|
|
20
20
|
CursorAFKSlicesRemaining = "afk_slices_remaining"
|
|
21
21
|
CursorReturnPhase = "return_phase"
|
|
22
22
|
CursorReturnNextAction = "return_next_action"
|
|
23
|
+
CursorSchema = "schema"
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
var cursorKeyAliases = map[string]string{
|
|
@@ -168,6 +169,79 @@ func DeleteCursorField(lines []string, key string) []string {
|
|
|
168
169
|
return out
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
// CursorForm reports the presentation of the state.md cursor: "table" when a
|
|
173
|
+
// canonical table row exists, "legacy" when only bullet fields exist, and
|
|
174
|
+
// "none" when neither is present.
|
|
175
|
+
func CursorForm(lines []string) string {
|
|
176
|
+
form := "none"
|
|
177
|
+
for _, line := range lines {
|
|
178
|
+
_, _, kind, ok := parseCursorLine(line)
|
|
179
|
+
if !ok {
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if kind == cursorLineTable {
|
|
183
|
+
return "table"
|
|
184
|
+
}
|
|
185
|
+
if form == "none" {
|
|
186
|
+
form = "legacy"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return form
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ConvertCursorToTable rewrites legacy bullet cursor fields into canonical
|
|
193
|
+
// table rows in place. A bullet whose key resolves to a canonical cursor key
|
|
194
|
+
// is a cursor field wherever it appears — the same rule CursorField uses to
|
|
195
|
+
// read — so such a bullet is converted even inside prose sections. Prose
|
|
196
|
+
// without a canonical cursor key is preserved, and a value carrying a raw pipe
|
|
197
|
+
// keeps its bullet form because a table cell cannot represent it. It reports
|
|
198
|
+
// whether any line changed.
|
|
199
|
+
func ConvertCursorToTable(lines []string) ([]string, bool) {
|
|
200
|
+
changed := false
|
|
201
|
+
out := append([]string(nil), lines...)
|
|
202
|
+
for i, line := range out {
|
|
203
|
+
key, value, kind, ok := parseCursorLine(line)
|
|
204
|
+
if !ok || kind != cursorLineLegacy {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if !isMigratableCursorKey(key) {
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if strings.Contains(value, "|") {
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
spelling, ok := canonicalCursorSpelling(key)
|
|
214
|
+
if !ok {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
|
218
|
+
out[i] = indent + "| " + spelling + " | " + value + " |"
|
|
219
|
+
changed = true
|
|
220
|
+
}
|
|
221
|
+
return out, changed
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func isMigratableCursorKey(key string) bool {
|
|
225
|
+
_, ok := canonicalCursorSpelling(key)
|
|
226
|
+
return ok
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// canonicalCursorSpelling maps a cursor key (including aliases) to the
|
|
230
|
+
// canonical table-row spelling. Matching is normalization-based, so
|
|
231
|
+
// "Next step" resolves to the "next_action" spelling.
|
|
232
|
+
func canonicalCursorSpelling(key string) (string, bool) {
|
|
233
|
+
normalized := normalizeCursorKey(key)
|
|
234
|
+
canonicals := []string{CursorPhase, CursorStatus, CursorNextAction, CursorQuestionID,
|
|
235
|
+
CursorActiveSlice, CursorAFKSlicesRemaining, CursorReturnPhase,
|
|
236
|
+
CursorReturnNextAction, CursorSchema}
|
|
237
|
+
for _, canonical := range canonicals {
|
|
238
|
+
if normalized == normalizeCursorKey(canonical) {
|
|
239
|
+
return canonical, true
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return "", false
|
|
243
|
+
}
|
|
244
|
+
|
|
171
245
|
func structuralCursorLines(lines []string) ([]string, bool) {
|
|
172
246
|
if len(lines) == 0 {
|
|
173
247
|
return nil, true
|
|
@@ -2,8 +2,9 @@ package state
|
|
|
2
2
|
|
|
3
3
|
//go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
|
|
4
4
|
|
|
5
|
-
// SchemaVersion versions the persisted workflow/state manifest
|
|
6
|
-
|
|
5
|
+
// SchemaVersion versions the persisted workflow/state manifest and the
|
|
6
|
+
// workspace contract the engine accepts.
|
|
7
|
+
const SchemaVersion = 3
|
|
7
8
|
|
|
8
9
|
const EvidenceFile = "evidence.md"
|
|
9
10
|
|
|
@@ -703,7 +703,7 @@ func TestStatusRejectsCorruptStateMarkdownWithoutContentDisclosure(t *testing.T)
|
|
|
703
703
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
704
704
|
t.Fatal(err)
|
|
705
705
|
}
|
|
706
|
-
if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte("| phase | build |\x00\n"), 0o644); err != nil {
|
|
706
|
+
if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte("| phase | build |\x00\n| schema | 3 |\n"), 0o644); err != nil {
|
|
707
707
|
t.Fatal(err)
|
|
708
708
|
}
|
|
709
709
|
want := `compute status: feature "corrupt": state.md is malformed (malformed_markdown); repair state.md and retry`
|
|
@@ -720,7 +720,7 @@ func TestOnlyCanonicalWorkLayoutIsDiscovered(t *testing.T) {
|
|
|
720
720
|
if err := os.MkdirAll(legacy, 0o755); err != nil {
|
|
721
721
|
t.Fatal(err)
|
|
722
722
|
}
|
|
723
|
-
if err := os.WriteFile(filepath.Join(legacy, "state.md"), []byte("- Phase: spec\n"), 0o644); err != nil {
|
|
723
|
+
if err := os.WriteFile(filepath.Join(legacy, "state.md"), []byte("- Phase: spec\n- Schema: 3\n"), 0o644); err != nil {
|
|
724
724
|
t.Fatal(err)
|
|
725
725
|
}
|
|
726
726
|
if _, err := Status(root, "alias"); err == nil {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"generatedBy": "go generate ./internal/state; DO NOT EDIT",
|
|
3
|
-
"schemaVersion":
|
|
3
|
+
"schemaVersion": 3,
|
|
4
4
|
"authorityPolicy": {
|
|
5
5
|
"principlesTrust": "Project principles may become project policy only after explicit provenance and validation; arbitrary project-local Markdown is never inherently trusted executable instruction.",
|
|
6
6
|
"trackedState": [
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// ErrWorkspaceSchemaRefused identifies a workspace whose declared schema is not
|
|
13
|
+
// the current engine contract; callers map it to the blocked exit code.
|
|
14
|
+
var ErrWorkspaceSchemaRefused = errors.New("workspace schema version refused")
|
|
15
|
+
|
|
16
|
+
// workspaceSchemaKey is the state.md cursor row carrying the workspace schema
|
|
17
|
+
// version. Workspaces written before the v5 engine carry no row and resolve to
|
|
18
|
+
// schema 2, the last pre-v5 contract.
|
|
19
|
+
const workspaceSchemaKey = CursorSchema
|
|
20
|
+
|
|
21
|
+
// WorkspaceSchema reports the schema version declared by the feature's
|
|
22
|
+
// state.md cursor. A missing row means the pre-v5 (schema 2) contract.
|
|
23
|
+
func WorkspaceSchema(root, slug string) (int, error) {
|
|
24
|
+
raw, err := os.ReadFile(filepath.Join(featureDir(root, slug), LedgerFile))
|
|
25
|
+
if err != nil {
|
|
26
|
+
return 0, fmt.Errorf("read workspace schema for %s: %w", slug, err)
|
|
27
|
+
}
|
|
28
|
+
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
|
|
29
|
+
return workspaceSchemaRow(lines)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// RequireWorkspaceSchema refuses feature workspaces whose schema is not the
|
|
33
|
+
// current one, naming the exact recovery path for each direction.
|
|
34
|
+
func RequireWorkspaceSchema(root, slug string) error {
|
|
35
|
+
version, err := WorkspaceSchema(root, slug)
|
|
36
|
+
if err != nil {
|
|
37
|
+
return err
|
|
38
|
+
}
|
|
39
|
+
if version < SchemaVersion {
|
|
40
|
+
return fmt.Errorf("%w: workspace %s predates the v5 workspace schema (schema %d, engine requires %d); run devrites-engine migrate %s", ErrWorkspaceSchemaRefused, slug, version, SchemaVersion, slug)
|
|
41
|
+
}
|
|
42
|
+
if version > SchemaVersion {
|
|
43
|
+
return fmt.Errorf("%w: workspace %s declares schema %d, newer than this engine's %d; upgrade devrites", ErrWorkspaceSchemaRefused, slug, version, SchemaVersion)
|
|
44
|
+
}
|
|
45
|
+
return nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func workspaceSchemaRow(lines []string) (int, error) {
|
|
49
|
+
value, ok := CursorField(lines, workspaceSchemaKey)
|
|
50
|
+
if !ok {
|
|
51
|
+
return 2, nil
|
|
52
|
+
}
|
|
53
|
+
version, err := strconv.Atoi(strings.TrimSpace(value))
|
|
54
|
+
if err != nil || version <= 0 {
|
|
55
|
+
return 0, fmt.Errorf("state.md schema row is invalid (value %q); expected the workspace schema version", value)
|
|
56
|
+
}
|
|
57
|
+
return version, nil
|
|
58
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func writeSchemaWorkspace(t *testing.T, stateBody string) (root, slug string) {
|
|
11
|
+
t.Helper()
|
|
12
|
+
root = t.TempDir()
|
|
13
|
+
slug = "demo"
|
|
14
|
+
if err := os.MkdirAll(featureDir(root, slug), 0o755); err != nil {
|
|
15
|
+
t.Fatal(err)
|
|
16
|
+
}
|
|
17
|
+
if err := os.WriteFile(filepath.Join(featureDir(root, slug), LedgerFile), []byte(stateBody), 0o644); err != nil {
|
|
18
|
+
t.Fatal(err)
|
|
19
|
+
}
|
|
20
|
+
return root, slug
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestWorkspaceSchemaDefaultsToPreV5WhenRowIsAbsent(t *testing.T) {
|
|
24
|
+
root, slug := writeSchemaWorkspace(t, "- Phase: build\n- Status: running\n")
|
|
25
|
+
version, err := WorkspaceSchema(root, slug)
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
if version != 2 {
|
|
30
|
+
t.Fatalf("version=%d want 2 (pre-v5 default)", version)
|
|
31
|
+
}
|
|
32
|
+
if err := RequireWorkspaceSchema(root, slug); err == nil {
|
|
33
|
+
t.Fatal("pre-v5 workspace must be refused")
|
|
34
|
+
} else if !strings.Contains(err.Error(), "devrites-engine migrate demo") {
|
|
35
|
+
t.Fatalf("refusal must name migrate, got %v", err)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestWorkspaceSchemaAcceptsCurrentAndRefusesNewer(t *testing.T) {
|
|
40
|
+
root, slug := writeSchemaWorkspace(t, "| phase | build |\n| schema | 3 |\n")
|
|
41
|
+
if err := RequireWorkspaceSchema(root, slug); err != nil {
|
|
42
|
+
t.Fatalf("current schema must pass, got %v", err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
root, slug = writeSchemaWorkspace(t, "| phase | build |\n| schema | 4 |\n")
|
|
46
|
+
err := RequireWorkspaceSchema(root, slug)
|
|
47
|
+
if err == nil {
|
|
48
|
+
t.Fatal("newer schema must be refused")
|
|
49
|
+
}
|
|
50
|
+
if !strings.Contains(err.Error(), "upgrade devrites") {
|
|
51
|
+
t.Fatalf("newer-schema refusal must name the upgrade path, got %v", err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestWorkspaceSchemaRefusesMalformedRow(t *testing.T) {
|
|
56
|
+
root, slug := writeSchemaWorkspace(t, "| phase | build |\n| schema | banana |\n")
|
|
57
|
+
if err := RequireWorkspaceSchema(root, slug); err == nil {
|
|
58
|
+
t.Fatal("malformed schema row must be refused")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func TestWorkspaceSchemaMissingLedgerIsRefused(t *testing.T) {
|
|
63
|
+
root := t.TempDir()
|
|
64
|
+
if err := RequireWorkspaceSchema(root, "absent"); err == nil {
|
|
65
|
+
t.Fatal("missing workspace must be refused")
|
|
66
|
+
}
|
|
67
|
+
}
|