devrites 4.1.0 → 4.3.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 +13 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +69 -6
- package/docs/orchestration.md +6 -3
- 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/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/resolve.go +6 -6
- package/engine/internal/lib/skilltrust.go +196 -0
- package/engine/internal/lib/taskgraph.go +162 -0
- package/engine/internal/lib/taskgraph_test.go +150 -0
- package/engine/internal/lib/workspace_read.go +29 -0
- package/engine/internal/parallel/cli.go +467 -0
- package/engine/internal/parallel/git.go +181 -0
- package/engine/internal/parallel/lease.go +244 -0
- package/engine/internal/parallel/ops.go +416 -0
- package/engine/internal/parallel/parallel_test.go +242 -0
- package/engine/internal/parallel/pathdisjoint.go +129 -0
- package/engine/internal/parallel/pathdisjoint_test.go +97 -0
- 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 +97 -53
- package/engine/main.go +72 -3
- package/engine/root_routing_test.go +1 -1
- 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/skill-authoring.md +22 -0
- 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 +46 -81
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/.claude/skills/rite-build/reference/output.md +2 -0
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +9 -22
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
- 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/dependency-graph.md +3 -0
- 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/skill-authoring.md +22 -0
- 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 +46 -81
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +9 -22
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
- 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/dependency-graph.md +3 -0
- 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/skill-authoring.md +22 -0
- 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 +46 -81
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +9 -22
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
- 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/dependency-graph.md +3 -0
- 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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func gitOk(t *testing.T, dir string, args ...string) string {
|
|
12
|
+
t.Helper()
|
|
13
|
+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
14
|
+
cmd.Env = append(os.Environ(),
|
|
15
|
+
"GIT_CONFIG_NOSYSTEM=1",
|
|
16
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
17
|
+
"GIT_AUTHOR_NAME=DevRites Test",
|
|
18
|
+
"GIT_AUTHOR_EMAIL=devrites@example.invalid",
|
|
19
|
+
"GIT_COMMITTER_NAME=DevRites Test",
|
|
20
|
+
"GIT_COMMITTER_EMAIL=devrites@example.invalid",
|
|
21
|
+
)
|
|
22
|
+
out, err := cmd.CombinedOutput()
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
25
|
+
}
|
|
26
|
+
return strings.TrimSpace(string(out))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func setupRepo(t *testing.T) (repo, base string) {
|
|
30
|
+
t.Helper()
|
|
31
|
+
repo = t.TempDir()
|
|
32
|
+
gitOk(t, repo, "init")
|
|
33
|
+
gitOk(t, repo, "checkout", "-b", "main")
|
|
34
|
+
if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
if err := os.WriteFile(filepath.Join(repo, "src", "a.go"), []byte("package main\n"), 0o644); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
if err := os.WriteFile(filepath.Join(repo, "src", "b.go"), []byte("package main\n"), 0o644); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("base\n"), 0o644); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
gitOk(t, repo, "add", ".")
|
|
47
|
+
gitOk(t, repo, "commit", "-m", "base")
|
|
48
|
+
base = gitOk(t, repo, "rev-parse", "HEAD")
|
|
49
|
+
return repo, base
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestLeaseRoundTrip(t *testing.T) {
|
|
53
|
+
t.Parallel()
|
|
54
|
+
dir := t.TempDir()
|
|
55
|
+
path := filepath.Join(dir, "parallel-lease.md")
|
|
56
|
+
lease := &Lease{
|
|
57
|
+
BatchID: "batch1",
|
|
58
|
+
CreatedAt: "2026-08-23T00:00:00Z",
|
|
59
|
+
BaseSHA: "abcdef1",
|
|
60
|
+
N: 2,
|
|
61
|
+
Status: StatusRunning,
|
|
62
|
+
ControlPIDOrSession: "test",
|
|
63
|
+
Slices: []LeaseSlice{
|
|
64
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}, WorktreePath: "/tmp/a", Branch: "devrites/parallel/x/batch1/slice-a", WrightStatus: WrightPending},
|
|
65
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}, WorktreePath: "/tmp/b", Branch: "devrites/parallel/x/batch1/slice-b", WrightStatus: WrightPending},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
if err := WriteLease(path, lease); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
got, err := ReadLease(path)
|
|
72
|
+
if err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
if got.BatchID != "batch1" || got.Status != StatusRunning || len(got.Slices) != 2 {
|
|
76
|
+
t.Fatalf("unexpected lease: %#v", got)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestCreateAbortCleanup(t *testing.T) {
|
|
81
|
+
repo, base := setupRepo(t)
|
|
82
|
+
slug := "demo-feature"
|
|
83
|
+
batch := "batch1"
|
|
84
|
+
lease, err := Create(CreateOpts{
|
|
85
|
+
Root: repo,
|
|
86
|
+
Slug: slug,
|
|
87
|
+
BatchID: batch,
|
|
88
|
+
BaseSHA: base,
|
|
89
|
+
Slices: []SlicePaths{
|
|
90
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
91
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
if err != nil {
|
|
95
|
+
t.Fatal(err)
|
|
96
|
+
}
|
|
97
|
+
if lease.Status != StatusRunning || lease.N != 2 {
|
|
98
|
+
t.Fatalf("lease=%#v", lease)
|
|
99
|
+
}
|
|
100
|
+
wtA := lease.Slices[0].WorktreePath
|
|
101
|
+
wtB := lease.Slices[1].WorktreePath
|
|
102
|
+
for _, wt := range []string{wtA, wtB} {
|
|
103
|
+
if _, err := os.Stat(wt); err != nil {
|
|
104
|
+
t.Fatalf("missing worktree %s: %v", wt, err)
|
|
105
|
+
}
|
|
106
|
+
if head := gitOk(t, wt, "rev-parse", "HEAD"); head != base {
|
|
107
|
+
t.Fatalf("worktree head %s want %s", head, base)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
111
|
+
t.Fatalf("control moved after create: %s", head)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Drift control tip, then abort should restore base.
|
|
115
|
+
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
gitOk(t, repo, "add", "README.md")
|
|
119
|
+
gitOk(t, repo, "commit", "-m", "drift")
|
|
120
|
+
if _, err := Abort(repo, slug, true); err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
124
|
+
t.Fatalf("abort left control at %s want %s", head, base)
|
|
125
|
+
}
|
|
126
|
+
leasePath, _ := LeasePath(repo, slug)
|
|
127
|
+
lease, err = ReadLease(leasePath)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
if lease.Status != StatusAborted {
|
|
132
|
+
t.Fatalf("status=%s", lease.Status)
|
|
133
|
+
}
|
|
134
|
+
if _, err := os.Stat(wtA); err != nil {
|
|
135
|
+
t.Fatalf("abort should preserve worktree: %v", err)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if err := Cleanup(repo, slug, true); err != nil {
|
|
139
|
+
t.Fatal(err)
|
|
140
|
+
}
|
|
141
|
+
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
142
|
+
t.Fatalf("cleanup should remove worktree A")
|
|
143
|
+
}
|
|
144
|
+
if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
|
|
145
|
+
t.Fatalf("cleanup should clear lease")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestCreateRefusesOverlap(t *testing.T) {
|
|
150
|
+
repo, base := setupRepo(t)
|
|
151
|
+
_, err := Create(CreateOpts{
|
|
152
|
+
Root: repo,
|
|
153
|
+
Slug: "demo",
|
|
154
|
+
BatchID: "batch1",
|
|
155
|
+
BaseSHA: base,
|
|
156
|
+
Slices: []SlicePaths{
|
|
157
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
158
|
+
{ID: "slice-b", Paths: []string{"src/a.go"}},
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
if err == nil || !strings.Contains(err.Error(), "overlap") {
|
|
162
|
+
t.Fatalf("expected overlap refusal, got %v", err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestIntegrateDivergentSiblings(t *testing.T) {
|
|
167
|
+
repo, base := setupRepo(t)
|
|
168
|
+
slug := "demo-feature"
|
|
169
|
+
batch := "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
|
+
wtA := lease.Slices[0].WorktreePath
|
|
184
|
+
wtB := lease.Slices[1].WorktreePath
|
|
185
|
+
|
|
186
|
+
if err := os.WriteFile(filepath.Join(wtA, "src", "a.go"), []byte("package main\n\nfunc A() {}\n"), 0o644); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
gitOk(t, wtA, "add", "src/a.go")
|
|
190
|
+
gitOk(t, wtA, "commit", "-m", "slice-a green")
|
|
191
|
+
tcA := gitOk(t, wtA, "rev-parse", "HEAD")
|
|
192
|
+
|
|
193
|
+
if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc B() {}\n"), 0o644); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
gitOk(t, wtB, "add", "src/b.go")
|
|
197
|
+
gitOk(t, wtB, "commit", "-m", "slice-b green")
|
|
198
|
+
tcB := gitOk(t, wtB, "rev-parse", "HEAD")
|
|
199
|
+
|
|
200
|
+
if _, err := RecordGreen(repo, slug, "slice-a", tcA); err != nil {
|
|
201
|
+
t.Fatal(err)
|
|
202
|
+
}
|
|
203
|
+
if _, err := RecordGreen(repo, slug, "slice-b", tcB); err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
|
|
208
|
+
if err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
if lease.Status != StatusComplete {
|
|
212
|
+
t.Fatalf("status=%s", lease.Status)
|
|
213
|
+
}
|
|
214
|
+
if tip == "" || tip == base {
|
|
215
|
+
t.Fatalf("expected advanced tip, got %s", tip)
|
|
216
|
+
}
|
|
217
|
+
head := gitOk(t, repo, "rev-parse", "HEAD")
|
|
218
|
+
if head != tip {
|
|
219
|
+
t.Fatalf("control head %s want tip %s", head, tip)
|
|
220
|
+
}
|
|
221
|
+
a := mustRead(t, filepath.Join(repo, "src", "a.go"))
|
|
222
|
+
b := mustRead(t, filepath.Join(repo, "src", "b.go"))
|
|
223
|
+
if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
|
|
224
|
+
t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if err := Cleanup(repo, slug, false); err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
231
|
+
t.Fatalf("cleanup should remove workers")
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func mustRead(t *testing.T, path string) string {
|
|
236
|
+
t.Helper()
|
|
237
|
+
b, err := os.ReadFile(path)
|
|
238
|
+
if err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
return string(b)
|
|
242
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Package parallel implements deterministic parallel-worktree control ops:
|
|
2
|
+
// path-disjoint checks, advisory leases, and git worktree create/integrate/cleanup.
|
|
3
|
+
package parallel
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"regexp"
|
|
11
|
+
"sort"
|
|
12
|
+
"strings"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
var winAbsRE = regexp.MustCompile(`(?i)^[A-Za-z]:[/\\]`)
|
|
16
|
+
|
|
17
|
+
// SlicePaths is one path-disjoint eligibility unit.
|
|
18
|
+
type SlicePaths struct {
|
|
19
|
+
ID string `json:"id"`
|
|
20
|
+
Paths []string `json:"paths"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NormalizePath mirrors scripts/check-path-disjoint.py: slash-normalize, reject
|
|
24
|
+
// empty/absolute/.. paths, and drop empty/"." segments.
|
|
25
|
+
func NormalizePath(raw string) (string, error) {
|
|
26
|
+
path := strings.TrimSpace(strings.ReplaceAll(raw, `\`, "/"))
|
|
27
|
+
if path == "" {
|
|
28
|
+
return "", fmt.Errorf("empty path is not allowed")
|
|
29
|
+
}
|
|
30
|
+
if strings.HasPrefix(path, "/") || winAbsRE.MatchString(path) {
|
|
31
|
+
return "", fmt.Errorf("path must be project-relative, not absolute: %q", raw)
|
|
32
|
+
}
|
|
33
|
+
parts := make([]string, 0, strings.Count(path, "/")+1)
|
|
34
|
+
for part := range strings.SplitSeq(path, "/") {
|
|
35
|
+
if part == "" || part == "." {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if part == ".." {
|
|
39
|
+
return "", fmt.Errorf("path must not contain '..': %q", raw)
|
|
40
|
+
}
|
|
41
|
+
parts = append(parts, part)
|
|
42
|
+
}
|
|
43
|
+
if len(parts) == 0 {
|
|
44
|
+
return "", fmt.Errorf("empty path is not allowed")
|
|
45
|
+
}
|
|
46
|
+
return strings.Join(parts, "/"), nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func validateSlicePaths(paths []string, label, root string) ([]string, error) {
|
|
50
|
+
if paths == nil {
|
|
51
|
+
return nil, fmt.Errorf("%s: paths must be a list", label)
|
|
52
|
+
}
|
|
53
|
+
normalized := make([]string, 0, len(paths))
|
|
54
|
+
seen := make(map[string]struct{}, len(paths))
|
|
55
|
+
for _, raw := range paths {
|
|
56
|
+
path, err := NormalizePath(raw)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return nil, fmt.Errorf("%s: %w", label, err)
|
|
59
|
+
}
|
|
60
|
+
// Parallel eligibility must reject workspace control metadata so slices
|
|
61
|
+
// cannot claim control-tree / SSOT files (docs: ".devrites/**").
|
|
62
|
+
if path == ".devrites" || strings.HasPrefix(path, ".devrites/") {
|
|
63
|
+
return nil, fmt.Errorf("%s: path must not include .devrites: %q", label, path)
|
|
64
|
+
}
|
|
65
|
+
if _, ok := seen[path]; ok {
|
|
66
|
+
return nil, fmt.Errorf("%s: duplicate path %q", label, path)
|
|
67
|
+
}
|
|
68
|
+
seen[path] = struct{}{}
|
|
69
|
+
normalized = append(normalized, path)
|
|
70
|
+
if root != "" {
|
|
71
|
+
full := filepath.Join(root, filepath.FromSlash(path))
|
|
72
|
+
if info, err := os.Lstat(full); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
73
|
+
return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return normalized, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// CheckPathDisjoint returns slice ids when every pair of path sets is disjoint.
|
|
81
|
+
func CheckPathDisjoint(slices []SlicePaths, root string) ([]string, error) {
|
|
82
|
+
if len(slices) < 2 {
|
|
83
|
+
return nil, fmt.Errorf("need at least two slices to check path-disjoint eligibility")
|
|
84
|
+
}
|
|
85
|
+
owners := make(map[string][]string)
|
|
86
|
+
ids := make([]string, 0, len(slices))
|
|
87
|
+
for index, item := range slices {
|
|
88
|
+
label := fmt.Sprintf("slice %d", index)
|
|
89
|
+
if item.ID != "" {
|
|
90
|
+
label = fmt.Sprintf("slice %q", item.ID)
|
|
91
|
+
ids = append(ids, item.ID)
|
|
92
|
+
} else {
|
|
93
|
+
ids = append(ids, fmt.Sprintf("%d", index))
|
|
94
|
+
}
|
|
95
|
+
paths, err := validateSlicePaths(item.Paths, label, root)
|
|
96
|
+
if err != nil {
|
|
97
|
+
return nil, err
|
|
98
|
+
}
|
|
99
|
+
for _, path := range paths {
|
|
100
|
+
owners[path] = append(owners[path], label)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
var overlaps []string
|
|
104
|
+
for path, labels := range owners {
|
|
105
|
+
if len(labels) > 1 {
|
|
106
|
+
overlaps = append(overlaps, fmt.Sprintf("%q shared by %s", path, strings.Join(labels, ", ")))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if len(overlaps) > 0 {
|
|
110
|
+
sort.Strings(overlaps)
|
|
111
|
+
return nil, fmt.Errorf("path sets overlap: %s", strings.Join(overlaps, "; "))
|
|
112
|
+
}
|
|
113
|
+
return ids, nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ParseSlicesJSON accepts {"slices":[...]} or a top-level slices array.
|
|
117
|
+
func ParseSlicesJSON(data []byte) ([]SlicePaths, error) {
|
|
118
|
+
var asObject struct {
|
|
119
|
+
Slices []SlicePaths `json:"slices"`
|
|
120
|
+
}
|
|
121
|
+
if err := json.Unmarshal(data, &asObject); err == nil && asObject.Slices != nil {
|
|
122
|
+
return asObject.Slices, nil
|
|
123
|
+
}
|
|
124
|
+
var asList []SlicePaths
|
|
125
|
+
if err := json.Unmarshal(data, &asList); err == nil {
|
|
126
|
+
return asList, nil
|
|
127
|
+
}
|
|
128
|
+
return nil, fmt.Errorf(`input must be {"slices": [...]} or a top-level slices array`)
|
|
129
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestNormalizePathRejectsDirty(t *testing.T) {
|
|
11
|
+
t.Parallel()
|
|
12
|
+
cases := []string{"", " ", "/", "/abs", `C:\windows`, `..`, "a/../b", "../x"}
|
|
13
|
+
for _, raw := range cases {
|
|
14
|
+
if _, err := NormalizePath(raw); err == nil {
|
|
15
|
+
t.Fatalf("NormalizePath(%q) should fail", raw)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestNormalizePathAcceptsRelative(t *testing.T) {
|
|
21
|
+
t.Parallel()
|
|
22
|
+
got, err := NormalizePath(`src\foo.go`)
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatal(err)
|
|
25
|
+
}
|
|
26
|
+
if got != "src/foo.go" {
|
|
27
|
+
t.Fatalf("got %q", got)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestCheckPathDisjointOverlap(t *testing.T) {
|
|
32
|
+
t.Parallel()
|
|
33
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
34
|
+
{ID: "a", Paths: []string{"src/a.go"}},
|
|
35
|
+
{ID: "b", Paths: []string{"src/a.go"}},
|
|
36
|
+
}, "")
|
|
37
|
+
if err == nil || !strings.Contains(err.Error(), "overlap") {
|
|
38
|
+
t.Fatalf("expected overlap error, got %v", err)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestCheckPathDisjointOK(t *testing.T) {
|
|
43
|
+
t.Parallel()
|
|
44
|
+
ids, err := CheckPathDisjoint([]SlicePaths{
|
|
45
|
+
{ID: "a", Paths: []string{"src/a.go"}},
|
|
46
|
+
{ID: "b", Paths: []string{"src/b.go"}},
|
|
47
|
+
}, "")
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
if len(ids) != 2 {
|
|
52
|
+
t.Fatalf("ids=%v", ids)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestCheckPathDisjointRejectsDevritesPaths(t *testing.T) {
|
|
57
|
+
t.Parallel()
|
|
58
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
59
|
+
{ID: "a", Paths: []string{".devrites/work/state.md"}},
|
|
60
|
+
{ID: "b", Paths: []string{"src/b.go"}},
|
|
61
|
+
}, "")
|
|
62
|
+
if err == nil || !strings.Contains(err.Error(), ".devrites") {
|
|
63
|
+
t.Fatalf("expected .devrites rejection error, got %v", err)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestCheckPathDisjointSymlinkRoot(t *testing.T) {
|
|
68
|
+
t.Parallel()
|
|
69
|
+
dir := t.TempDir()
|
|
70
|
+
target := filepath.Join(dir, "real.go")
|
|
71
|
+
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
link := filepath.Join(dir, "link.go")
|
|
75
|
+
if err := os.Symlink(target, link); err != nil {
|
|
76
|
+
t.Skip("symlinks unavailable")
|
|
77
|
+
}
|
|
78
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
79
|
+
{ID: "a", Paths: []string{"link.go"}},
|
|
80
|
+
{ID: "b", Paths: []string{"other.go"}},
|
|
81
|
+
}, dir)
|
|
82
|
+
if err == nil || !strings.Contains(err.Error(), "symlink") {
|
|
83
|
+
t.Fatalf("expected symlink error, got %v", err)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func TestParseSlicesJSONShapes(t *testing.T) {
|
|
88
|
+
t.Parallel()
|
|
89
|
+
a, err := ParseSlicesJSON([]byte(`{"slices":[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]}`))
|
|
90
|
+
if err != nil || len(a) != 2 {
|
|
91
|
+
t.Fatalf("object shape: %v %#v", err, a)
|
|
92
|
+
}
|
|
93
|
+
b, err := ParseSlicesJSON([]byte(`[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]`))
|
|
94
|
+
if err != nil || len(b) != 2 {
|
|
95
|
+
t.Fatalf("array shape: %v %#v", err, b)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Command workflowmanifest writes the deterministic cross-language derivative
|
|
2
|
-
// of state.
|
|
2
|
+
// of state.PhasePolicies. The typed Go registry remains the authority.
|
|
3
3
|
package main
|
|
4
4
|
|
|
5
5
|
import (
|
|
@@ -12,6 +12,35 @@ import (
|
|
|
12
12
|
"github.com/devrites/devrites/internal/state"
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
+
type workflowPhase struct {
|
|
16
|
+
ID state.Phase `json:"id"`
|
|
17
|
+
ResumeVerb string `json:"resumeVerb,omitempty"`
|
|
18
|
+
TransitionRight string `json:"transitionRight"`
|
|
19
|
+
RequiredSections []state.Section `json:"requiredSections,omitempty"`
|
|
20
|
+
WorkspaceRequired []state.ArtifactPath `json:"workspaceRequired"`
|
|
21
|
+
ProofRequired bool `json:"proofRequired,omitempty"`
|
|
22
|
+
BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
|
|
23
|
+
Shippable bool `json:"shippable,omitempty"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func workflowPhases() []workflowPhase {
|
|
27
|
+
policies := state.PhasePolicies()
|
|
28
|
+
phases := make([]workflowPhase, len(policies))
|
|
29
|
+
for i, policy := range policies {
|
|
30
|
+
phases[i] = workflowPhase{
|
|
31
|
+
ID: policy.Target,
|
|
32
|
+
ResumeVerb: policy.ResumeVerb,
|
|
33
|
+
TransitionRight: policy.TransitionRight,
|
|
34
|
+
RequiredSections: policy.RequiredSections,
|
|
35
|
+
WorkspaceRequired: policy.RequiredArtifacts,
|
|
36
|
+
ProofRequired: policy.ProofRequired,
|
|
37
|
+
BlocksOpenQuestions: policy.BlocksOpenQuestions,
|
|
38
|
+
Shippable: policy.Shippable,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return phases
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
func main() {
|
|
16
45
|
outPath := flag.String("out", "workflow_manifest.json", "output path")
|
|
17
46
|
check := flag.Bool("check", false, "fail when the output is stale")
|
|
@@ -21,13 +50,13 @@ func main() {
|
|
|
21
50
|
GeneratedBy string `json:"generatedBy"`
|
|
22
51
|
SchemaVersion int `json:"schemaVersion"`
|
|
23
52
|
AuthorityPolicy state.AuthorityPolicy `json:"authorityPolicy"`
|
|
24
|
-
Phases []
|
|
53
|
+
Phases []workflowPhase `json:"phases"`
|
|
25
54
|
CursorKeyAliases []state.CursorKeyAlias `json:"cursorKeyAliases"`
|
|
26
55
|
}{
|
|
27
56
|
GeneratedBy: "go generate ./internal/state; DO NOT EDIT",
|
|
28
57
|
SchemaVersion: state.SchemaVersion,
|
|
29
58
|
AuthorityPolicy: state.WorkflowAuthorityPolicy(),
|
|
30
|
-
Phases:
|
|
59
|
+
Phases: workflowPhases(),
|
|
31
60
|
CursorKeyAliases: state.CursorKeyAliases(),
|
|
32
61
|
}
|
|
33
62
|
data, err := json.MarshalIndent(document, "", " ")
|
|
@@ -9,7 +9,6 @@ import (
|
|
|
9
9
|
"strings"
|
|
10
10
|
|
|
11
11
|
"github.com/devrites/devrites/internal/devritespaths"
|
|
12
|
-
"github.com/devrites/devrites/internal/markdowntext"
|
|
13
12
|
"github.com/devrites/devrites/internal/rootfacts"
|
|
14
13
|
)
|
|
15
14
|
|
|
@@ -24,15 +23,6 @@ func ResolveRoot(override string) (string, error) {
|
|
|
24
23
|
return facts.PhysicalRoot, nil
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
// Feature is a loaded per-feature workspace: its declared phase plus which
|
|
28
|
-
// completeness sections currently have real content.
|
|
29
|
-
type Feature struct {
|
|
30
|
-
Slug string
|
|
31
|
-
Phase Phase
|
|
32
|
-
Present map[Section]bool
|
|
33
|
-
PresentFiles map[string]bool
|
|
34
|
-
}
|
|
35
|
-
|
|
36
26
|
// featureDir is the canonical per-feature state directory under root.
|
|
37
27
|
func featureDir(root, slug string) string {
|
|
38
28
|
return devritespaths.FeatureDir(root, slug)
|
|
@@ -71,74 +61,6 @@ func regularFileExists(path string) bool {
|
|
|
71
61
|
return err == nil && info.Mode().IsRegular()
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
// LoadFeature reads a canonical feature workspace. state.md is the sole
|
|
75
|
-
// lifecycle authority; optional README.md metadata is not runtime state.
|
|
76
|
-
func LoadFeature(root, slug string) (*Feature, error) {
|
|
77
|
-
dir := featureDir(root, slug)
|
|
78
|
-
if !regularFileExists(filepath.Join(dir, LedgerFile)) {
|
|
79
|
-
return nil, fmt.Errorf("feature %q not found", slug)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
phase, ledgerDeclared, err := declaredPhaseFromLedger(filepath.Join(dir, LedgerFile))
|
|
83
|
-
if err != nil {
|
|
84
|
-
return nil, fmt.Errorf("feature %q: %w", slug, err)
|
|
85
|
-
}
|
|
86
|
-
if !ledgerDeclared {
|
|
87
|
-
return nil, fmt.Errorf("feature %q: no phase in %s ledger", slug, LedgerFile)
|
|
88
|
-
}
|
|
89
|
-
if !KnownPhase(phase) {
|
|
90
|
-
return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
present := make(map[Section]bool, len(Sections))
|
|
94
|
-
for _, section := range Sections {
|
|
95
|
-
present[section] = sectionPresentAny(dir, section)
|
|
96
|
-
}
|
|
97
|
-
presentFiles := make(map[string]bool)
|
|
98
|
-
for _, name := range WorkspaceFiles() {
|
|
99
|
-
presentFiles[name] = sectionPresent(filepath.Join(dir, name))
|
|
100
|
-
}
|
|
101
|
-
return &Feature{
|
|
102
|
-
Slug: slug,
|
|
103
|
-
Phase: phase,
|
|
104
|
-
Present: present,
|
|
105
|
-
PresentFiles: presentFiles,
|
|
106
|
-
}, nil
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
func sectionPresentAny(dir string, s Section) bool {
|
|
110
|
-
for _, name := range sectionFiles[s] {
|
|
111
|
-
if sectionPresent(filepath.Join(dir, name)) {
|
|
112
|
-
return true
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return false
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
func declaredPhaseFromLedger(path string) (Phase, bool, error) {
|
|
119
|
-
raw, err := os.ReadFile(path)
|
|
120
|
-
if err != nil {
|
|
121
|
-
if errors.Is(err, os.ErrNotExist) {
|
|
122
|
-
return "", false, nil
|
|
123
|
-
}
|
|
124
|
-
return "", false, fmt.Errorf("read %s: %w", LedgerFile, err)
|
|
125
|
-
}
|
|
126
|
-
structural, err := markdowntext.Structural(raw)
|
|
127
|
-
if err != nil {
|
|
128
|
-
return "", false, fmt.Errorf("read %s: %w", LedgerFile, err)
|
|
129
|
-
}
|
|
130
|
-
lines := strings.Split(string(structural), "\n")
|
|
131
|
-
value, ok := CursorField(lines, CursorPhase)
|
|
132
|
-
if !ok {
|
|
133
|
-
return "", false, nil
|
|
134
|
-
}
|
|
135
|
-
word := firstPhaseWord(value)
|
|
136
|
-
if phase, known := PhaseForName(word); known {
|
|
137
|
-
return phase, true, nil
|
|
138
|
-
}
|
|
139
|
-
return Phase(word), true, nil
|
|
140
|
-
}
|
|
141
|
-
|
|
142
64
|
func firstPhaseWord(value string) string {
|
|
143
65
|
word := strings.ToLower(strings.TrimSpace(value))
|
|
144
66
|
if i := strings.IndexAny(word, " "); i > 0 {
|
|
@@ -153,27 +75,6 @@ func blankOrHash(trimmed string) bool {
|
|
|
153
75
|
return trimmed == "" || strings.HasPrefix(trimmed, "#")
|
|
154
76
|
}
|
|
155
77
|
|
|
156
|
-
// sectionPresent reports whether a section file has real content: it exists
|
|
157
|
-
// and, after removing a leading YAML frontmatter block, ATX (`#`) headings, and
|
|
158
|
-
// whitespace, some content remains. A stub that is only a heading counts as
|
|
159
|
-
// empty, so scaffolding a file never fakes completeness.
|
|
160
|
-
func sectionPresent(path string) bool {
|
|
161
|
-
raw, err := os.ReadFile(path)
|
|
162
|
-
if err != nil {
|
|
163
|
-
return false
|
|
164
|
-
}
|
|
165
|
-
if _, err := markdowntext.Structural(raw); err != nil {
|
|
166
|
-
return false
|
|
167
|
-
}
|
|
168
|
-
body := stripFrontmatter(raw)
|
|
169
|
-
for _, line := range strings.Split(string(body), "\n") {
|
|
170
|
-
if !blankOrHash(strings.TrimSpace(line)) {
|
|
171
|
-
return true
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return false
|
|
175
|
-
}
|
|
176
|
-
|
|
177
78
|
// stripFrontmatter removes a well-formed leading frontmatter block when
|
|
178
79
|
// determining whether an artifact contains substantive body content. The
|
|
179
80
|
// closing fence must be a line that is exactly `---`.
|
|
@@ -191,7 +92,7 @@ func stripFrontmatter(raw []byte) []byte {
|
|
|
191
92
|
}
|
|
192
93
|
}
|
|
193
94
|
if end < 0 {
|
|
194
|
-
return raw
|
|
95
|
+
return raw
|
|
195
96
|
}
|
|
196
97
|
return []byte(strings.Join(lines[end+1:], "\n"))
|
|
197
98
|
}
|