devrites 5.2.3 → 5.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 +12 -0
- package/README.md +1 -1
- package/engine/internal/install/uninstall.go +3 -0
- package/engine/internal/lib/resolve.go +7 -0
- package/engine/internal/parallel/cli.go +62 -8
- package/engine/internal/parallel/git.go +46 -7
- package/engine/internal/parallel/lease.go +7 -0
- package/engine/internal/parallel/ops.go +256 -72
- package/engine/internal/parallel/ops_test.go +223 -6
- package/engine/internal/parallel/parallel_test.go +229 -6
- package/engine/internal/parallel/pathdisjoint.go +18 -3
- package/engine/internal/parallel/pathdisjoint_test.go +21 -0
- package/pack/.claude/agents/devrites-plan-drafter.md +9 -0
- package/pack/.claude/agents/devrites-plan-reviewer.md +4 -1
- package/pack/.claude/skills/devrites-api-interface/SKILL.md +3 -1
- package/pack/.claude/skills/devrites-doubt/SKILL.md +3 -2
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/.claude/skills/rite-build/SKILL.md +7 -3
- package/pack/.claude/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +4 -2
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/.claude/skills/rite-build/reference/spec-drift-guard.md +8 -1
- package/pack/.claude/skills/rite-plan/SKILL.md +26 -1
- package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +8 -4
- package/pack/.claude/skills/rite-review/SKILL.md +10 -6
- package/pack/.claude/skills/rite-seal/SKILL.md +3 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +19 -5
- package/pack/generated/claude/agents/devrites-plan-drafter.md +9 -0
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +4 -1
- package/pack/generated/claude/skills/devrites-api-interface/SKILL.md +3 -1
- package/pack/generated/claude/skills/devrites-doubt/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/generated/claude/skills/rite-build/SKILL.md +7 -3
- package/pack/generated/claude/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +4 -2
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/generated/claude/skills/rite-build/reference/spec-drift-guard.md +8 -1
- package/pack/generated/claude/skills/rite-plan/SKILL.md +26 -1
- package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +8 -4
- package/pack/generated/claude/skills/rite-review/SKILL.md +10 -6
- package/pack/generated/claude/skills/rite-seal/SKILL.md +3 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +19 -5
- package/pack/generated/codex/agents/devrites-plan-drafter.toml +9 -0
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +4 -1
- package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +3 -1
- package/pack/generated/codex/skills/devrites-doubt/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/generated/codex/skills/rite-build/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +4 -2
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/generated/codex/skills/rite-build/reference/spec-drift-guard.md +8 -1
- package/pack/generated/codex/skills/rite-plan/SKILL.md +26 -1
- package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +8 -4
- package/pack/generated/codex/skills/rite-review/SKILL.md +10 -6
- package/pack/generated/codex/skills/rite-seal/SKILL.md +3 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +19 -5
- package/pack/generated/omp/agents/devrites-plan-drafter.md +9 -0
- package/pack/generated/omp/agents/devrites-plan-reviewer.md +4 -1
- package/pack/generated/omp/skills/devrites-api-interface/SKILL.md +3 -1
- package/pack/generated/omp/skills/devrites-doubt/SKILL.md +3 -2
- package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/generated/omp/skills/rite-build/SKILL.md +7 -3
- package/pack/generated/omp/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +4 -2
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/generated/omp/skills/rite-build/reference/spec-drift-guard.md +8 -1
- package/pack/generated/omp/skills/rite-plan/SKILL.md +26 -1
- package/pack/generated/omp/skills/rite-plan/reference/replan-and-repair.md +8 -4
- package/pack/generated/omp/skills/rite-review/SKILL.md +10 -6
- package/pack/generated/omp/skills/rite-seal/SKILL.md +3 -1
- package/pack/generated/omp/skills/rite-vet/SKILL.md +19 -5
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"os"
|
|
7
7
|
"path/filepath"
|
|
8
8
|
"strings"
|
|
9
|
+
"sync"
|
|
9
10
|
"testing"
|
|
10
11
|
)
|
|
11
12
|
|
|
@@ -50,15 +51,18 @@ func greenBatch(t *testing.T, repo, base, slug, batch string) *Lease {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
type seamRestore struct {
|
|
53
|
-
origLease
|
|
54
|
-
origRemove
|
|
55
|
-
|
|
54
|
+
origLease func(string, *Lease) error
|
|
55
|
+
origRemove func(string, string) error
|
|
56
|
+
origSalvage func(LeaseSlice, string) (string, error)
|
|
57
|
+
origWarn func(string, ...any)
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
func swapSeams(t *testing.T) {
|
|
59
61
|
t.Helper()
|
|
60
|
-
s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origWarn: warnf}
|
|
61
|
-
t.Cleanup(func() {
|
|
62
|
+
s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origSalvage: salvage, origWarn: warnf}
|
|
63
|
+
t.Cleanup(func() {
|
|
64
|
+
writeLease, removeWorktree, salvage, warnf = s.origLease, s.origRemove, s.origSalvage, s.origWarn
|
|
65
|
+
})
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
// TestIntegrateFailedTransitionSurvivesTransientWriteFailure proves the
|
|
@@ -162,6 +166,219 @@ func TestIntegrateSurfacesStuckStagingWorktree(t *testing.T) {
|
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
// TestCleanupNeverTouchesForeignWorktreePath proves a lease whose
|
|
170
|
+
// worktree_path points outside the batch scratch layout is never salvaged
|
|
171
|
+
// into nor deleted — the lease field is advisory, not a RemoveAll license.
|
|
172
|
+
func TestCleanupNeverTouchesForeignWorktreePath(t *testing.T) {
|
|
173
|
+
repo, base := setupRepo(t)
|
|
174
|
+
slug, batch := "demo-feature", "batch1"
|
|
175
|
+
lease, err := Create(CreateOpts{
|
|
176
|
+
Root: repo,
|
|
177
|
+
Slug: slug,
|
|
178
|
+
BatchID: batch,
|
|
179
|
+
BaseSHA: base,
|
|
180
|
+
Slices: []SlicePaths{
|
|
181
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
182
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
if err != nil {
|
|
186
|
+
t.Fatal(err)
|
|
187
|
+
}
|
|
188
|
+
foreign := filepath.Join(t.TempDir(), "precious")
|
|
189
|
+
if err := os.MkdirAll(foreign, 0o755); err != nil {
|
|
190
|
+
t.Fatal(err)
|
|
191
|
+
}
|
|
192
|
+
if err := os.WriteFile(filepath.Join(foreign, "keep.txt"), []byte("x"), 0o644); err != nil {
|
|
193
|
+
t.Fatal(err)
|
|
194
|
+
}
|
|
195
|
+
lease.Slices[0].WorktreePath = foreign
|
|
196
|
+
// A forged branch outside the batch namespace must survive too.
|
|
197
|
+
gitOk(t, repo, "branch", "user/topic", base)
|
|
198
|
+
lease.Slices[0].Branch = "user/topic"
|
|
199
|
+
leasePath, err := LeasePath(repo, slug)
|
|
200
|
+
if err != nil {
|
|
201
|
+
t.Fatal(err)
|
|
202
|
+
}
|
|
203
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
var warnings []string
|
|
208
|
+
swapSeams(t)
|
|
209
|
+
warnf = func(format string, args ...any) {
|
|
210
|
+
warnings = append(warnings, fmt.Sprintf(format, args...))
|
|
211
|
+
}
|
|
212
|
+
if _, err := Cleanup(repo, slug, true); err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
if _, err := os.Stat(filepath.Join(foreign, "keep.txt")); err != nil {
|
|
216
|
+
t.Fatalf("foreign path was touched: %v", err)
|
|
217
|
+
}
|
|
218
|
+
if tip := gitOk(t, repo, "rev-parse", "user/topic"); tip != base {
|
|
219
|
+
t.Fatalf("foreign branch user/topic moved to %s, want %s", tip, base)
|
|
220
|
+
}
|
|
221
|
+
joined := strings.Join(warnings, "\n")
|
|
222
|
+
if !strings.Contains(joined, "outside") {
|
|
223
|
+
t.Fatalf("expected warnings naming foreign path and branch, got:\n%s", warnings)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// TestCleanupFailedSalvageKeepsWorktreeDir proves a salvage error keeps the
|
|
228
|
+
// worktree on disk as the only remaining copy — the batch-level scratch
|
|
229
|
+
// removal must skip kept slice dirs instead of destroying them.
|
|
230
|
+
func TestCleanupFailedSalvageKeepsWorktreeDir(t *testing.T) {
|
|
231
|
+
repo, base := setupRepo(t)
|
|
232
|
+
slug, batch := "demo-feature", "batch1"
|
|
233
|
+
if _, err := Create(CreateOpts{
|
|
234
|
+
Root: repo,
|
|
235
|
+
Slug: slug,
|
|
236
|
+
BatchID: batch,
|
|
237
|
+
BaseSHA: base,
|
|
238
|
+
Slices: []SlicePaths{
|
|
239
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
240
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
241
|
+
},
|
|
242
|
+
}); err != nil {
|
|
243
|
+
t.Fatal(err)
|
|
244
|
+
}
|
|
245
|
+
wtA := WorkerWorktreePath(repo, batch, "slice-a")
|
|
246
|
+
wtB := WorkerWorktreePath(repo, batch, "slice-b")
|
|
247
|
+
|
|
248
|
+
swapSeams(t)
|
|
249
|
+
realSalvage := salvage
|
|
250
|
+
salvage = func(sl LeaseSlice, batchID string) (string, error) {
|
|
251
|
+
if sl.ID == "slice-a" {
|
|
252
|
+
return "", errors.New("injected salvage failure")
|
|
253
|
+
}
|
|
254
|
+
return realSalvage(sl, batchID)
|
|
255
|
+
}
|
|
256
|
+
salvaged, err := Cleanup(repo, slug, true)
|
|
257
|
+
if err != nil {
|
|
258
|
+
t.Fatal(err)
|
|
259
|
+
}
|
|
260
|
+
if _, err := os.Stat(wtA); err != nil {
|
|
261
|
+
t.Fatalf("kept worktree was removed: %v", err)
|
|
262
|
+
}
|
|
263
|
+
if _, err := os.Stat(wtB); !os.IsNotExist(err) {
|
|
264
|
+
t.Fatalf("clean slice-b worktree should be removed, stat err=%v", err)
|
|
265
|
+
}
|
|
266
|
+
if _, err := git(repo, "rev-parse", "--verify", "devrites/parallel/"+slug+"/"+batch+"/slice-a"); err != nil {
|
|
267
|
+
t.Fatalf("slice-a branch should be kept: %v", err)
|
|
268
|
+
}
|
|
269
|
+
found := false
|
|
270
|
+
for _, s := range salvaged {
|
|
271
|
+
if s.SliceID == "slice-a" && s.Worktree == wtA {
|
|
272
|
+
found = true
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if !found {
|
|
276
|
+
t.Fatalf("salvage output should report kept worktree for slice-a, got %+v", salvaged)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// TestConcurrentRecordGreenKeepsEverySlice proves the feature lock serializes
|
|
281
|
+
// concurrent record-green calls so no sibling's transfer commit is lost to a
|
|
282
|
+
// read-modify-write race on the lease.
|
|
283
|
+
func TestConcurrentRecordGreenKeepsEverySlice(t *testing.T) {
|
|
284
|
+
repo, base := setupRepo(t)
|
|
285
|
+
slug, batch := "demo-feature", "batch1"
|
|
286
|
+
lease, err := Create(CreateOpts{
|
|
287
|
+
Root: repo,
|
|
288
|
+
Slug: slug,
|
|
289
|
+
BatchID: batch,
|
|
290
|
+
BaseSHA: base,
|
|
291
|
+
Slices: []SlicePaths{
|
|
292
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
293
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
if err != nil {
|
|
297
|
+
t.Fatal(err)
|
|
298
|
+
}
|
|
299
|
+
tcA := commitIn(t, lease.Slices[0].WorktreePath, "src/a.go", "A")
|
|
300
|
+
tcB := commitIn(t, lease.Slices[1].WorktreePath, "src/b.go", "B")
|
|
301
|
+
|
|
302
|
+
var wg sync.WaitGroup
|
|
303
|
+
errs := make(chan error, 2)
|
|
304
|
+
for _, item := range [][2]string{{"slice-a", tcA}, {"slice-b", tcB}} {
|
|
305
|
+
wg.Add(1)
|
|
306
|
+
go func(sliceID, commit string) {
|
|
307
|
+
defer wg.Done()
|
|
308
|
+
_, err := RecordGreen(repo, slug, sliceID, commit)
|
|
309
|
+
errs <- err
|
|
310
|
+
}(item[0], item[1])
|
|
311
|
+
}
|
|
312
|
+
wg.Wait()
|
|
313
|
+
close(errs)
|
|
314
|
+
for err := range errs {
|
|
315
|
+
if err != nil {
|
|
316
|
+
t.Fatal(err)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
leasePath, err := LeasePath(repo, slug)
|
|
321
|
+
if err != nil {
|
|
322
|
+
t.Fatal(err)
|
|
323
|
+
}
|
|
324
|
+
onDisk, err := ReadLease(leasePath)
|
|
325
|
+
if err != nil {
|
|
326
|
+
t.Fatal(err)
|
|
327
|
+
}
|
|
328
|
+
got := map[string]string{}
|
|
329
|
+
for _, sl := range onDisk.Slices {
|
|
330
|
+
got[sl.ID] = sl.TransferCommit
|
|
331
|
+
}
|
|
332
|
+
if got["slice-a"] != tcA || got["slice-b"] != tcB {
|
|
333
|
+
t.Fatalf("lost update: transfer commits %v want a=%s b=%s", got, tcA, tcB)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// TestConcurrentCreateSingleWinner proves the feature lock serializes create
|
|
338
|
+
// so two racing creates cannot both pass the lease-existence check.
|
|
339
|
+
func TestConcurrentCreateSingleWinner(t *testing.T) {
|
|
340
|
+
repo, base := setupRepo(t)
|
|
341
|
+
opts := CreateOpts{
|
|
342
|
+
Root: repo,
|
|
343
|
+
Slug: "demo-feature",
|
|
344
|
+
BatchID: "batch1",
|
|
345
|
+
BaseSHA: base,
|
|
346
|
+
Slices: []SlicePaths{
|
|
347
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
348
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
var wg sync.WaitGroup
|
|
352
|
+
errs := make(chan error, 2)
|
|
353
|
+
wins := make(chan *Lease, 2)
|
|
354
|
+
for i := 0; i < 2; i++ {
|
|
355
|
+
wg.Add(1)
|
|
356
|
+
go func() {
|
|
357
|
+
defer wg.Done()
|
|
358
|
+
lease, err := Create(opts)
|
|
359
|
+
if err != nil {
|
|
360
|
+
errs <- err
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
wins <- lease
|
|
364
|
+
}()
|
|
365
|
+
}
|
|
366
|
+
wg.Wait()
|
|
367
|
+
close(errs)
|
|
368
|
+
close(wins)
|
|
369
|
+
succeeded := 0
|
|
370
|
+
for range wins {
|
|
371
|
+
succeeded++
|
|
372
|
+
}
|
|
373
|
+
failed := 0
|
|
374
|
+
for range errs {
|
|
375
|
+
failed++
|
|
376
|
+
}
|
|
377
|
+
if succeeded != 1 || failed != 1 {
|
|
378
|
+
t.Fatalf("concurrent create: %d succeeded, %d failed; want exactly 1/1", succeeded, failed)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
165
382
|
// TestCleanupWarnsOnFailedBranchCleanup proves Cleanup reports branch cleanup
|
|
166
383
|
// failures instead of erasing the lease while orphans remain.
|
|
167
384
|
func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
|
|
@@ -194,7 +411,7 @@ func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
|
|
|
194
411
|
warnings = append(warnings, fmt.Sprintf(format, args...))
|
|
195
412
|
}
|
|
196
413
|
|
|
197
|
-
if err := Cleanup(repo, slug, true); err != nil {
|
|
414
|
+
if _, err := Cleanup(repo, slug, true); err != nil {
|
|
198
415
|
t.Fatalf("cleanup continues after reporting failures: %v", err)
|
|
199
416
|
}
|
|
200
417
|
joined := strings.Join(warnings, "\n")
|
|
@@ -112,17 +112,19 @@ func TestCreateAbortCleanup(t *testing.T) {
|
|
|
112
112
|
t.Fatalf("control moved after create: %s", head)
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
// Drift control tip
|
|
115
|
+
// Drift control tip; abort must never rewind external commits — the drift
|
|
116
|
+
// commit survives and the lease still marks aborted.
|
|
116
117
|
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
|
|
117
118
|
t.Fatal(err)
|
|
118
119
|
}
|
|
119
120
|
gitOk(t, repo, "add", "README.md")
|
|
120
121
|
gitOk(t, repo, "commit", "-m", "drift")
|
|
121
|
-
|
|
122
|
+
drift := gitOk(t, repo, "rev-parse", "HEAD")
|
|
123
|
+
if _, err := Abort(repo, slug); err != nil {
|
|
122
124
|
t.Fatal(err)
|
|
123
125
|
}
|
|
124
|
-
if head := gitOk(t, repo, "rev-parse", "HEAD"); head !=
|
|
125
|
-
t.Fatalf("abort
|
|
126
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != drift {
|
|
127
|
+
t.Fatalf("abort rewound control: head %s want drift tip %s", head, drift)
|
|
126
128
|
}
|
|
127
129
|
leasePath, _ := LeasePath(repo, slug)
|
|
128
130
|
lease, err = ReadLease(leasePath)
|
|
@@ -136,7 +138,7 @@ func TestCreateAbortCleanup(t *testing.T) {
|
|
|
136
138
|
t.Fatalf("abort should preserve worktree: %v", err)
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
if err := Cleanup(repo, slug, true); err != nil {
|
|
141
|
+
if _, err := Cleanup(repo, slug, true); err != nil {
|
|
140
142
|
t.Fatal(err)
|
|
141
143
|
}
|
|
142
144
|
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
@@ -147,6 +149,88 @@ func TestCreateAbortCleanup(t *testing.T) {
|
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
// TestCleanupSalvagesAbortedWork proves a forced cleanup of an aborted batch
|
|
153
|
+
// never destroys wright work: committed transfers keep their branches and
|
|
154
|
+
// uncommitted allowlisted changes are committed onto the slice branch before
|
|
155
|
+
// the worktree is removed.
|
|
156
|
+
func TestCleanupSalvagesAbortedWork(t *testing.T) {
|
|
157
|
+
repo, base := setupRepo(t)
|
|
158
|
+
slug, batch := "demo-feature", "batch1"
|
|
159
|
+
lease, err := Create(CreateOpts{
|
|
160
|
+
Root: repo,
|
|
161
|
+
Slug: slug,
|
|
162
|
+
BatchID: batch,
|
|
163
|
+
BaseSHA: base,
|
|
164
|
+
Slices: []SlicePaths{
|
|
165
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
166
|
+
{ID: "slice-b", Paths: []string{"src/b.go", "src/b_new.go"}},
|
|
167
|
+
{ID: "slice-c", Paths: []string{"src/c.go"}},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
if err != nil {
|
|
171
|
+
t.Fatal(err)
|
|
172
|
+
}
|
|
173
|
+
wtA := lease.Slices[0].WorktreePath
|
|
174
|
+
wtB := lease.Slices[1].WorktreePath
|
|
175
|
+
wtC := lease.Slices[2].WorktreePath
|
|
176
|
+
|
|
177
|
+
// slice-a: committed transfer (red at review — work must survive).
|
|
178
|
+
tcA := commitIn(t, wtA, "src/a.go", "A")
|
|
179
|
+
// slice-b: uncommitted WIP — modified tracked file plus a new untracked
|
|
180
|
+
// file inside the allowlist.
|
|
181
|
+
if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc Bwip() {}\n"), 0o644); err != nil {
|
|
182
|
+
t.Fatal(err)
|
|
183
|
+
}
|
|
184
|
+
if err := os.WriteFile(filepath.Join(wtB, "src", "b_new.go"), []byte("package main\n\nfunc Bnew() {}\n"), 0o644); err != nil {
|
|
185
|
+
t.Fatal(err)
|
|
186
|
+
}
|
|
187
|
+
// slice-c: untouched — nothing to salvage, branch should be deleted.
|
|
188
|
+
|
|
189
|
+
if _, err := Abort(repo, slug); err != nil {
|
|
190
|
+
t.Fatal(err)
|
|
191
|
+
}
|
|
192
|
+
salvaged, err := Cleanup(repo, slug, true)
|
|
193
|
+
if err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
bySlice := map[string]Salvage{}
|
|
198
|
+
for _, s := range salvaged {
|
|
199
|
+
bySlice[s.SliceID] = s
|
|
200
|
+
}
|
|
201
|
+
if got := bySlice["slice-a"].Commit; got != tcA {
|
|
202
|
+
t.Fatalf("slice-a salvage commit %s want transfer %s", got, tcA)
|
|
203
|
+
}
|
|
204
|
+
sb := bySlice["slice-b"]
|
|
205
|
+
if sb.Branch == "" || sb.Commit == "" || sb.Commit == base {
|
|
206
|
+
t.Fatalf("slice-b should salvage WIP onto its branch, got %+v", sb)
|
|
207
|
+
}
|
|
208
|
+
if _, ok := bySlice["slice-c"]; ok {
|
|
209
|
+
t.Fatalf("slice-c carried no work; its branch should be deleted, got %+v", bySlice["slice-c"])
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The salvaged WIP commit actually contains the uncommitted changes.
|
|
213
|
+
if names := gitOk(t, repo, "diff", "--name-only", base, sb.Commit); !strings.Contains(names, "src/b.go") || !strings.Contains(names, "src/b_new.go") {
|
|
214
|
+
t.Fatalf("salvage commit missing WIP paths: %s", names)
|
|
215
|
+
}
|
|
216
|
+
if body := gitOk(t, repo, "show", sb.Commit+":src/b_new.go"); !strings.Contains(body, "Bnew") {
|
|
217
|
+
t.Fatalf("salvaged file content missing: %q", body)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Worktrees removed, lease cleared, kept branches still resolve.
|
|
221
|
+
for _, wt := range []string{wtA, wtB, wtC} {
|
|
222
|
+
if _, err := os.Stat(wt); !os.IsNotExist(err) {
|
|
223
|
+
t.Fatalf("cleanup should remove worktree %s", wt)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
leasePath, _ := LeasePath(repo, slug)
|
|
227
|
+
if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
|
|
228
|
+
t.Fatalf("cleanup should clear lease")
|
|
229
|
+
}
|
|
230
|
+
gitOk(t, repo, "rev-parse", "--verify", "refs/heads/"+bySlice["slice-a"].Branch)
|
|
231
|
+
gitOk(t, repo, "rev-parse", "--verify", "refs/heads/"+sb.Branch)
|
|
232
|
+
}
|
|
233
|
+
|
|
150
234
|
func TestCreateAcceptsFourSlices(t *testing.T) {
|
|
151
235
|
t.Parallel()
|
|
152
236
|
repo, _ := setupRepo(t)
|
|
@@ -322,13 +406,20 @@ func TestIntegrateDivergentSiblings(t *testing.T) {
|
|
|
322
406
|
if head != tip {
|
|
323
407
|
t.Fatalf("control head %s want tip %s", head, tip)
|
|
324
408
|
}
|
|
409
|
+
if n := gitOk(t, repo, "rev-list", "--count", base+"..HEAD"); n != "1" {
|
|
410
|
+
t.Fatalf("control should gain exactly one squash commit, got %s", n)
|
|
411
|
+
}
|
|
412
|
+
subj := gitOk(t, repo, "log", "-1", "--format=%s")
|
|
413
|
+
if !strings.HasPrefix(subj, "WIP(demo-feature):") {
|
|
414
|
+
t.Fatalf("squash subject %q missing WIP(demo-feature): prefix", subj)
|
|
415
|
+
}
|
|
325
416
|
a := mustRead(t, filepath.Join(repo, "src", "a.go"))
|
|
326
417
|
b := mustRead(t, filepath.Join(repo, "src", "b.go"))
|
|
327
418
|
if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
|
|
328
419
|
t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
|
|
329
420
|
}
|
|
330
421
|
|
|
331
|
-
if err := Cleanup(repo, slug, false); err != nil {
|
|
422
|
+
if _, err := Cleanup(repo, slug, false); err != nil {
|
|
332
423
|
t.Fatal(err)
|
|
333
424
|
}
|
|
334
425
|
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
@@ -336,6 +427,138 @@ func TestIntegrateDivergentSiblings(t *testing.T) {
|
|
|
336
427
|
}
|
|
337
428
|
}
|
|
338
429
|
|
|
430
|
+
// TestIntegrateWithoutApplyKeepsLeaseRunning proves a flag-less integrate
|
|
431
|
+
// stages the squash commit on the integrate branch but leaves the lease
|
|
432
|
+
// running and control at base, so a later non-forced cleanup cannot discard
|
|
433
|
+
// the only refs holding the integrated tree.
|
|
434
|
+
func TestIntegrateWithoutApplyKeepsLeaseRunning(t *testing.T) {
|
|
435
|
+
repo, base := setupRepo(t)
|
|
436
|
+
slug, batch := "demo-feature", "batch1"
|
|
437
|
+
greenBatch(t, repo, base, slug, batch)
|
|
438
|
+
|
|
439
|
+
tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug})
|
|
440
|
+
if err != nil {
|
|
441
|
+
t.Fatal(err)
|
|
442
|
+
}
|
|
443
|
+
if tip == "" || tip == base {
|
|
444
|
+
t.Fatalf("expected staged squash tip, got %s", tip)
|
|
445
|
+
}
|
|
446
|
+
if lease.Status != StatusRunning {
|
|
447
|
+
t.Fatalf("status=%s want running without --apply-to-control", lease.Status)
|
|
448
|
+
}
|
|
449
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
450
|
+
t.Fatalf("control moved without apply: %s", head)
|
|
451
|
+
}
|
|
452
|
+
ibranch := IntegrateBranchName(slug, batch)
|
|
453
|
+
if got := gitOk(t, repo, "rev-parse", ibranch); got != tip {
|
|
454
|
+
t.Fatalf("integrate branch %s want squash tip %s", got, tip)
|
|
455
|
+
}
|
|
456
|
+
if _, err := Cleanup(repo, slug, false); err == nil {
|
|
457
|
+
t.Fatal("cleanup on running lease must refuse without --force")
|
|
458
|
+
}
|
|
459
|
+
// Forced cleanup salvages then removes; the integrate branch holds the
|
|
460
|
+
// squash tip, so it is kept and reported rather than deleted.
|
|
461
|
+
if _, err := Cleanup(repo, slug, true); err != nil {
|
|
462
|
+
t.Fatal(err)
|
|
463
|
+
}
|
|
464
|
+
if got := gitOk(t, repo, "rev-parse", ibranch); got != tip {
|
|
465
|
+
t.Fatalf("forced cleanup dropped integrate branch: %s want %s", got, tip)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// TestIntegrateFailedLeaseRepairsAndRetries proves integrate-failed is not a
|
|
470
|
+
// dead end: a repaired transfer re-records on the failed lease and a retried
|
|
471
|
+
// integrate completes into one squash commit on control.
|
|
472
|
+
func TestIntegrateFailedLeaseRepairsAndRetries(t *testing.T) {
|
|
473
|
+
repo, base := setupRepo(t)
|
|
474
|
+
slug, batch := "demo-feature", "batch1"
|
|
475
|
+
greenBatch(t, repo, base, slug, batch)
|
|
476
|
+
realTC := gitOk(t, repo, "rev-parse", WorkerBranch(slug, batch, "slice-a"))
|
|
477
|
+
|
|
478
|
+
leasePath, err := LeasePath(repo, slug)
|
|
479
|
+
if err != nil {
|
|
480
|
+
t.Fatal(err)
|
|
481
|
+
}
|
|
482
|
+
bad, err := ReadLease(leasePath)
|
|
483
|
+
if err != nil {
|
|
484
|
+
t.Fatal(err)
|
|
485
|
+
}
|
|
486
|
+
bad.Slices[0].TransferCommit = strings.Repeat("deadbeef", 5)
|
|
487
|
+
if err := WriteLease(leasePath, bad); err != nil {
|
|
488
|
+
t.Fatal(err)
|
|
489
|
+
}
|
|
490
|
+
if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err == nil {
|
|
491
|
+
t.Fatal("expected integrate failure")
|
|
492
|
+
}
|
|
493
|
+
onDisk, err := ReadLease(leasePath)
|
|
494
|
+
if err != nil {
|
|
495
|
+
t.Fatal(err)
|
|
496
|
+
}
|
|
497
|
+
if onDisk.Status != StatusIntegrateFailed {
|
|
498
|
+
t.Fatalf("status=%s want integrate-failed", onDisk.Status)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if _, err := RecordGreen(repo, slug, "slice-a", realTC); err != nil {
|
|
502
|
+
t.Fatalf("record-green on integrate-failed lease: %v", err)
|
|
503
|
+
}
|
|
504
|
+
if _, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err != nil {
|
|
505
|
+
t.Fatalf("integrate retry: %v", err)
|
|
506
|
+
} else if lease.Status != StatusComplete {
|
|
507
|
+
t.Fatalf("status=%s want complete", lease.Status)
|
|
508
|
+
}
|
|
509
|
+
if n := gitOk(t, repo, "rev-list", "--count", base+"..HEAD"); n != "1" {
|
|
510
|
+
t.Fatalf("expected one squash commit on control, got %s", n)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// TestIntegrateRefusesDirtyControlOnSlicePaths proves --apply-to-control
|
|
515
|
+
// neither absorbs nor clobbers uncommitted user work on slice paths: overlap
|
|
516
|
+
// refuses with the lease still running, while unrelated dirty files are left
|
|
517
|
+
// alone and the FF proceeds.
|
|
518
|
+
func TestIntegrateRefusesDirtyControlOnSlicePaths(t *testing.T) {
|
|
519
|
+
repo, base := setupRepo(t)
|
|
520
|
+
slug, batch := "demo-feature", "batch1"
|
|
521
|
+
greenBatch(t, repo, base, slug, batch)
|
|
522
|
+
|
|
523
|
+
if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
|
|
524
|
+
t.Fatal(err)
|
|
525
|
+
}
|
|
526
|
+
userWIP := filepath.Join(repo, "src", "a.go")
|
|
527
|
+
if err := os.WriteFile(userWIP, []byte("user wip\n"), 0o644); err != nil {
|
|
528
|
+
t.Fatal(err)
|
|
529
|
+
}
|
|
530
|
+
if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err == nil {
|
|
531
|
+
t.Fatal("expected dirty-control refusal")
|
|
532
|
+
}
|
|
533
|
+
leasePath, err := LeasePath(repo, slug)
|
|
534
|
+
if err != nil {
|
|
535
|
+
t.Fatal(err)
|
|
536
|
+
}
|
|
537
|
+
lease, err := ReadLease(leasePath)
|
|
538
|
+
if err != nil {
|
|
539
|
+
t.Fatal(err)
|
|
540
|
+
}
|
|
541
|
+
if lease.Status != StatusRunning {
|
|
542
|
+
t.Fatalf("lease status=%s want running after precondition refusal", lease.Status)
|
|
543
|
+
}
|
|
544
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
545
|
+
t.Fatalf("control moved despite refusal: %s", head)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Unrelated uncommitted work is not part of the batch and must survive.
|
|
549
|
+
gitOk(t, repo, "checkout", "--", "src/a.go")
|
|
550
|
+
keep := filepath.Join(repo, "unrelated.txt")
|
|
551
|
+
if err := os.WriteFile(keep, []byte("keep me\n"), 0o644); err != nil {
|
|
552
|
+
t.Fatal(err)
|
|
553
|
+
}
|
|
554
|
+
if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err != nil {
|
|
555
|
+
t.Fatalf("integrate with non-overlapping dirty file: %v", err)
|
|
556
|
+
}
|
|
557
|
+
if b, err := os.ReadFile(keep); err != nil || string(b) != "keep me\n" {
|
|
558
|
+
t.Fatalf("unrelated dirty file lost: err=%v body=%q", err, b)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
339
562
|
func mustRead(t *testing.T, path string) string {
|
|
340
563
|
t.Helper()
|
|
341
564
|
b, err := os.ReadFile(path)
|
|
@@ -68,15 +68,30 @@ func validateSlicePaths(paths []string, label, root string) ([]string, error) {
|
|
|
68
68
|
if path == ".devrites" || strings.HasPrefix(path, ".devrites/") {
|
|
69
69
|
return nil, fmt.Errorf("%s: path must not include .devrites: %q", label, path)
|
|
70
70
|
}
|
|
71
|
+
// Same for git internals: a transfer commit can never carry .git/**
|
|
72
|
+
// anyway, and inside a worktree .git is a file, so claiming it is
|
|
73
|
+
// pure footgun.
|
|
74
|
+
if path == ".git" || strings.HasPrefix(path, ".git/") {
|
|
75
|
+
return nil, fmt.Errorf("%s: path must not include .git: %q", label, path)
|
|
76
|
+
}
|
|
71
77
|
if _, ok := seen[path]; ok {
|
|
72
78
|
return nil, fmt.Errorf("%s: duplicate path %q", label, path)
|
|
73
79
|
}
|
|
74
80
|
seen[path] = struct{}{}
|
|
75
81
|
normalized = append(normalized, path)
|
|
76
82
|
if root != "" {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
// Check every component, not just the leaf: a symlinked directory
|
|
84
|
+
// in the chain would let a worktree write outside itself.
|
|
85
|
+
cur := root
|
|
86
|
+
for _, seg := range strings.Split(path, "/") {
|
|
87
|
+
cur = filepath.Join(cur, seg)
|
|
88
|
+
info, err := os.Lstat(cur)
|
|
89
|
+
if err != nil {
|
|
90
|
+
break // missing components are fine; git creates them
|
|
91
|
+
}
|
|
92
|
+
if info.Mode()&os.ModeSymlink != 0 {
|
|
93
|
+
return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
|
|
94
|
+
}
|
|
80
95
|
}
|
|
81
96
|
}
|
|
82
97
|
}
|
|
@@ -87,6 +87,27 @@ func TestCheckPathDisjointSymlinkRoot(t *testing.T) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// A symlinked directory inside a claimed path must also be rejected: the
|
|
91
|
+
// leaf Lstat alone misses it, and a worktree would then write outside itself.
|
|
92
|
+
func TestCheckPathDisjointSymlinkedDir(t *testing.T) {
|
|
93
|
+
t.Parallel()
|
|
94
|
+
dir := t.TempDir()
|
|
95
|
+
target := filepath.Join(dir, "outside")
|
|
96
|
+
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
97
|
+
t.Fatal(err)
|
|
98
|
+
}
|
|
99
|
+
if err := os.Symlink(target, filepath.Join(dir, "linkdir")); err != nil {
|
|
100
|
+
t.Skip("symlinks unavailable")
|
|
101
|
+
}
|
|
102
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
103
|
+
{ID: "a", Paths: []string{"linkdir/x.go"}},
|
|
104
|
+
{ID: "b", Paths: []string{"other.go"}},
|
|
105
|
+
}, dir)
|
|
106
|
+
if err == nil || !strings.Contains(err.Error(), "symlink") {
|
|
107
|
+
t.Fatalf("expected symlink error for symlinked dir, got %v", err)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
90
111
|
func TestParseSlicesJSONShapes(t *testing.T) {
|
|
91
112
|
t.Parallel()
|
|
92
113
|
a, err := ParseSlicesJSON([]byte(`{"slices":[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]}`))
|
|
@@ -60,6 +60,14 @@ principle-exception questions. List them for the root.
|
|
|
60
60
|
re-dispatches the rest. A finding blocked on human-owned information goes in
|
|
61
61
|
`blocked_findings` with the needed input named — never `remaining_findings`,
|
|
62
62
|
which is reserved for what another dispatch can complete.
|
|
63
|
+
- In `repair`, before returning, self-attack the delta: probe every clause the
|
|
64
|
+
hunks touch for the attack classes Vet's reviewers hunt — race/observation
|
|
65
|
+
windows, identity/alias escapes, durability/ordering gaps,
|
|
66
|
+
platform-capability gaps, unbounded blocking, happy-path-only coverage.
|
|
67
|
+
Close each real exposure inside the same delta or return it in
|
|
68
|
+
`self_attack_exposures` with its bound; never silently drop one. This
|
|
69
|
+
narrows the next Vet round; it never replaces independent review and never
|
|
70
|
+
self-approves the repair.
|
|
63
71
|
|
|
64
72
|
## Output format
|
|
65
73
|
|
|
@@ -77,6 +85,7 @@ technical_decisions: []
|
|
|
77
85
|
human_owned_choices: []
|
|
78
86
|
validation_commands: []
|
|
79
87
|
applicability_disposition: []
|
|
88
|
+
self_attack_exposures: []
|
|
80
89
|
```
|
|
81
90
|
|
|
82
91
|
The root validates the cross-file bundle and persists accepted content.
|
|
@@ -104,7 +104,10 @@ criterion, slice, or proof.
|
|
|
104
104
|
- `/rite-vet` may request one initial pass and one narrow recheck per correction/fingerprint.
|
|
105
105
|
- Label each finding **Critical / Important / Suggestion / Nit / FYI** and include
|
|
106
106
|
the relevant plan or task section, confidence score, and a concrete fix. Do not
|
|
107
|
-
pad the report with praise.
|
|
107
|
+
pad the report with praise. In a recheck, also mark each finding
|
|
108
|
+
`new_failure_mode` or name the already-pinned clause/fingerprint it refines —
|
|
109
|
+
the packet lists prior repairs; the mark makes alias-detection and
|
|
110
|
+
`convergence_pressure` eligibility mechanical.
|
|
108
111
|
- When a dimension has no issue, say "strong: <why>" instead of inventing a
|
|
109
112
|
finding.
|
|
110
113
|
- If a claim cannot be verified, such as blast radius without an index, say so and
|
|
@@ -51,7 +51,9 @@ Before standing the interface, run `devrites-doubt`.
|
|
|
51
51
|
## Done when
|
|
52
52
|
The contract is complete only when **every** field carries a type + optionality + unit,
|
|
53
53
|
**every** success and error status code is enumerated with its error-body shape, the
|
|
54
|
-
`devrites-doubt` verdict is accept (on reject: revise the contract and re-doubt
|
|
54
|
+
`devrites-doubt` verdict is accept (on reject: revise the contract and re-doubt under
|
|
55
|
+
`devrites-doubt`'s per-fingerprint bounds and distinct-findings ceiling — a contract
|
|
56
|
+
that resists repair becomes a blocker or human gate, not an endless revise loop), and the
|
|
55
57
|
contract + rationale are recorded in `decisions.md`. A contract that pins only the
|
|
56
58
|
happy-path shape is not done.
|
|
57
59
|
|
|
@@ -27,7 +27,7 @@ code · claiming "this is safe", "this scales", or "this matches the spec".
|
|
|
27
27
|
issues but classify **zero** as actionable, the review is too agreeable. Sharpen the
|
|
28
28
|
prompt or use a fresh reviewer. Accept a clean pass only after a genuine attempt to
|
|
29
29
|
disprove the claim.
|
|
30
|
-
- [ ] **5. STOP**: met a stop condition (only trivial findings,
|
|
30
|
+
- [ ] **5. STOP**: met a stop condition (only trivial findings, no open fingerprint has budget left, the distinct-findings ceiling, or user override). Emit a **binary gate verdict** the orchestrator must clear: **accept** (no valid-&-actionable findings remain) or **reject + the specific required changes**. On reject, the orchestrator loops the wright on those changes before the slice is accepted. Account per finding, not per round: a reject citing a genuinely new defect is progress and the loop continues; a reject re-citing a fingerprint the wright already failed on is no-progress and burns that fingerprint's budget. A finding that only refines a clause a prior cycle already pinned may close as an explicit bounded declared residual. Ceiling: three distinct actionable findings on one decision mean the decision resists repair — classify by decision ownership: human-owned product/risk uncertainty escalates; objective required changes become a technical blocker, never a retry-authorization question.
|
|
31
31
|
|
|
32
32
|
## Deletion-test lens (for "is this abstraction load-bearing?" doubts)
|
|
33
33
|
|
|
@@ -72,7 +72,8 @@ real time. Map the verdict to a `questions.md` entry instead of a synchronous pr
|
|
|
72
72
|
`questions.md` entry with `gate: blocking`, set `state.md` `Status: awaiting_human`,
|
|
73
73
|
fire the `notify:` hook, and STOP. AFK never silently accepts irreversible risk.
|
|
74
74
|
|
|
75
|
-
The
|
|
75
|
+
The same bounds apply in AFK. Once no open fingerprint has budget left or the
|
|
76
|
+
distinct-findings ceiling trips, human-owned uncertainty becomes a blocking
|
|
76
77
|
question; an unresolved objective technical finding becomes `Status: blocked` with its exact
|
|
77
78
|
required changes and `/rite-plan unblock`, regardless of AFK config.
|
|
78
79
|
|