forgedock 1.29.0 → 1.30.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.
Files changed (50) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +102 -38
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -85,6 +85,7 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
85
85
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
86
86
  "decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
87
87
  Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
88
+ A validate or repro command must not read a $TMPDIR file it does not create itself: each run gets a fresh TMPDIR.
88
89
  A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when there is none. Then stop.
89
90
 
90
91
  ${BLOCK_RULES}`;
@@ -93,7 +94,7 @@ ${BLOCK_RULES}`;
93
94
  /** Builder call 2, same session: implement the frozen contract. */
94
95
  export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
95
96
  const remote = mode === "ci" || mode === "hybrid";
96
- return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
97
+ return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, run the repo's formatter on the files you changed, and commit. Do not push or touch GitHub.
97
98
 
98
99
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
99
100
 
@@ -147,6 +148,39 @@ export function hotspotBuildRule(facts) {
147
148
  return `\n\nDon't grow a file over ${facts.file_lines} lines by more than ${facts.growth_lines} lines. Put new code next to it.`;
148
149
  }
149
150
 
151
+ /** #4322: tests that enumerate the siblings of a module the issue names (bin/lean/enumerating.mjs), for the plan; "" when none. */
152
+ export function enumeratingPlanNote(list) {
153
+ if (!Array.isArray(list) || !list.length) return "";
154
+ return `\n\nEnumerating tests (found by the engine): these tests list every member of a module or directory this issue names, so a new export, table, job, action, namespace or file there must join their list or CI goes red. List each one you will touch under Affected Files in ${INVESTIGATION_FILE}, and add a validate command that runs only those tests.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line} (${e.why})`).join("\n")}`;
155
+ }
156
+
157
+ const NOT_MEMBER = `or, when the symbol is not a member of that list, record {"symbol":"<name>","test":"<test path>","why":"<why it is not a member>"} in ${RESULT_FILE} "not_members"`;
158
+
159
+ /** #4322: the builder rule citing the plan's enumerating tests; "" when none. */
160
+ export function enumeratingBuildRule(list) {
161
+ if (!Array.isArray(list) || !list.length) return "";
162
+ return `\n\nEnumerating tests: add every new exported symbol, table, job, action, namespace or file to each test listed below whose list enumerates its siblings, one test-list edit per new symbol, ${NOT_MEMBER}.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line}`).join("\n")}`;
163
+ }
164
+
165
+ /** #4322: after implement, new symbols that tests enumerating their siblings do not name. */
166
+ export function missingEntriesPrompt(missing, mode = "local") {
167
+ return `These tests enumerate the siblings of symbols or files this change adds, but do not name them (CI will fail on the stale list):\n${missing.slice(0, 30).map((m) => `- ${m.symbol} (${m.module}) -> ${m.test}:${m.line}`).join("\n")}\n\nFor each, add the symbol to that test's list, ${NOT_MEMBER}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
168
+ }
169
+
170
+ const RENUMBER_RULE = "Renumber the lane's files, never the base's: rename each with git mv to its new number, keep the SQL (or other migration source) byte-identical, regenerate the snapshot and journal with the repo's own migration generator (never hand- or text-merge the journal), keep journal entries strictly ordered, and update every reference to the old name.";
171
+
172
+ /** #4322: lane-added sequence-numbered files whose number the base already uses, at hand-off or after a base-sync. */
173
+ export function sequencePrompt(collisions, lane, mode = "local") {
174
+ const rows = collisions.slice(0, 30).map((c) => `- ${c.lane} -> number ${c.next}${c.base.length ? ` (base has ${c.base.join(", ")})` : " (moves to keep the order)"}`).join("\n");
175
+ return `origin/${lane} already uses sequence numbers this change adds:\n${rows}\n\nFirst bring the branch up to date with \`git merge --no-edit origin/${lane}\` if it is not (already fetched; never rebase or force). ${RENUMBER_RULE} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
176
+ }
177
+
178
+ /** #4322: the base-sync conflict addition when a conflicted file is a numbered migration or a journal; "" otherwise. */
179
+ export function sequenceSyncNote(files) {
180
+ const hit = (files ?? []).some((f) => /(?:^|\/)\d{3,}[_-][^/]*$|(?:^|\/)_journal\.json$/.test(String(f)));
181
+ return hit ? ` A numbered migration or its journal conflicts: do not text-merge it. ${RENUMBER_RULE}` : "";
182
+ }
183
+
150
184
  export function contractCorrectionPrompt(problems) {
151
185
  return `Your ${CONTRACT_FILE} is not valid:\n${problems.slice(0, 20).map((p) => `- ${p}`).join("\n")}\n\nFix the contract (planning only, no code, no commit) and stop. Do not push or touch GitHub.`;
152
186
  }
@@ -207,7 +241,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
207
241
 
208
242
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
209
243
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
210
- - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with repo-relative path:line evidence (e.g. src/app/route.ts:12, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change.
244
+ - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change.
211
245
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
212
246
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
213
247
  - regressions: concrete breakages only, each with path:line.
@@ -257,7 +291,7 @@ export function reviewerPrompt({ issue, comments = [], contract, result, files =
257
291
  * for, with the exact problems and the reviewer's previous reply; no diff is re-sent (the worktree still holds the change).
258
292
  */
259
293
  export function reviewerRepairPrompt({ missing = [], problems = [], rows = null }) {
260
- return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is repo-relative path:line.`;
294
+ return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is a backtick-quoted repo-relative path:line copied verbatim (e.g. \`apps/web/app/(console)/layout.tsx:12\`); for an evidence path reported as not existing, give the full repo-relative path of that file from the changed files or the repository.`;
261
295
  }
262
296
 
263
297
  /** Pre-filter (#4123): one read-only, single-turn check of an issue a cheap signal flagged as possibly already fixed. */
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
10
10
  import { RECOVER_HOW } from "./lean.mjs";
11
+ import { recoveryEvidence } from "./render.mjs";
11
12
  import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
12
13
  import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
13
14
  // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
@@ -17,7 +18,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
17
18
  // runOne: `script[n]` is a list of results, one per launch of issue n (the last repeats); ctx is recorded.
18
19
  const batch = (script, bodies = {}, extra = {}) => {
19
20
  const calls = [], decisions = [];
20
- const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: 1 },
21
+ const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: extra.maxAttempts ?? 1 },
21
22
  readBody: async (n) => bodies[n] ?? "",
22
23
  runOne: async (n, _lane, ctx = {}) => {
23
24
  calls.push({ n, recover: ctx.recover ?? null });
@@ -37,7 +38,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
37
38
  const r = byIssue(rows);
38
39
  assert.equal(r[1].outcome, "merged"); assert.equal(r[1].recovered, "ci.red");
39
40
  assert.equal(r[2].outcome, "merged", "the dependent was not skipped");
40
- assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1 }]);
41
+ assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1, final: true }]);
41
42
  assert.ok(calls.findIndex((c) => c.n === 2) > calls.findLastIndex((c) => c.n === 1), "the dependent ran after the recovered merge");
42
43
  const d = decisions.find((x) => x.issue === 1);
43
44
  assert.match(d.what, /recovery lane for ci\.red/); assert.match(d.why, /Required check/); assert.ok(d.alternative);
@@ -138,7 +139,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
138
139
  const ctxs = [];
139
140
  const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 1 }, readBody: async () => "",
140
141
  runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return NH("ci.red"); } });
141
- assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1 }]);
142
+ assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1, final: true }]);
142
143
  assert.equal(rows[0].outcome, "needs-human"); assert.equal(rows[0].recovery.code, "ci.red");
143
144
  assert.deepEqual(state.recovering, {});
144
145
  });
@@ -160,7 +161,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
160
161
  await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "base.conflict", "--recover-attempt", "1"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
161
162
  leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
162
163
  } finally { console.log = l; }
163
- assert.deepEqual(got, [{ code: "base.conflict", attempt: 1 }]);
164
+ assert.deepEqual(got, [{ code: "base.conflict", attempt: 1, final: true }]);
164
165
  // The batch: a ci.red lane is relaunched in-process with recover, the decision is recorded and the report lists it.
165
166
  const leanIo = {
166
167
  gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
@@ -174,7 +175,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
174
175
  io: { gh: async () => "o/r\n" }, leanIo, sched: null, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: (d) => decisions.push(d),
175
176
  leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return o.recover ? { terminalReason: "merged", pr: 55 } : { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
176
177
  } finally { console.log = l; }
177
- assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1 }]);
178
+ assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1, final: true }]);
178
179
  assert.equal(res.ok, true);
179
180
  assert.ok(decisions.some((d) => d.issue === 5 && /recovery lane for ci\.red/.test(d.what)));
180
181
  assert.ok(logs.includes("recovered: #5 (ci.red)"), logs.join("\n"));
@@ -189,6 +190,130 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
189
190
  assert.match(step3, /one question list[\s\S]*once, at the end of the batch/);
190
191
  assert.match(spec, /recovered: #A \(ci\.red\) #B \(base\.conflict\)/);
191
192
  });
193
+
194
+ it("#4302: with maxAttempts 2 only the last attempt is final; a non-final attempt is not relabelled while its successor runs", async () => {
195
+ const relabels = [];
196
+ const conf = { maxAttempts: 2, conflicting: async () => true, relabelNeedsHuman: async (x) => { relabels.push(x); } };
197
+ const pend = { terminalReason: "ci-pending", pr: 9, detail: "CI still running" };
198
+ const seen = [];
199
+ const { rows, calls } = await batch({ 1: [NH("base.conflict"), pend, pend] }, {}, { ...conf, onUpdate: (r) => seen.push([r.outcome, relabels.length]) });
200
+ assert.deepEqual(calls.map((c) => c.recover), [null, { code: "base.conflict", attempt: 1, final: false }, { code: "base.conflict", attempt: 2, final: true }]);
201
+ assert.equal(rows[0].outcome, "ci-pending");
202
+ assert.ok(seen.filter(([o]) => o === "recovering").every(([, k]) => k === 0), "no relabel between attempts");
203
+ assert.deepEqual(relabels, [], "the final lane relabels ci-pending itself; the runner does not");
204
+ });
205
+
206
+ it("#4302: a non-final attempt that ends needs-human/ci-pending without a successor is relabelled by the runner, once", async () => {
207
+ const relabels = [];
208
+ const rl = { maxAttempts: 2, relabelNeedsHuman: async (x) => { relabels.push(x); } };
209
+ // ci-pending on a PR that does not conflict is not recoverable: no attempt 2.
210
+ const a = await batch({ 1: [NH("ci.red"), { terminalReason: "ci-pending", pr: 9, detail: "CI still running" }] }, {}, { ...rl, conflicting: async () => false });
211
+ assert.equal(a.calls.length, 2); assert.equal(a.rows[0].outcome, "ci-pending");
212
+ assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `ci-pending`/);
213
+ // needs-human with a human code is never recovered: no attempt 2.
214
+ relabels.length = 0;
215
+ const b = await batch({ 1: [NH("ci.red"), NH("builder.blocked", "needs credentials")] }, {}, rl);
216
+ assert.equal(b.calls.length, 2); assert.equal(b.rows[0].outcome, "needs-human");
217
+ assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /ended `needs-human` \(`builder\.blocked`\)/);
218
+ // maxAttempts 1: the only attempt is final, so the lane relabels and the runner does not (unchanged).
219
+ relabels.length = 0;
220
+ const c = await batch({ 1: [NH("ci.red"), NH("builder.blocked")] }, {}, { ...rl, maxAttempts: 1 });
221
+ assert.deepEqual(c.calls.map((x) => x.recover), [null, { code: "ci.red", attempt: 1, final: true }]);
222
+ assert.deepEqual(relabels, []);
223
+ });
224
+
225
+ describe("#4315: a recovery queued when the batch stops is relabelled needs-human with the full reason", () => {
226
+ const LONG = `ci.red: ${"x".repeat(400)} END-OF-REASON`;
227
+ // issue 1 ends ci.red twice (original lane + non-final attempt 1/2); `stop` ends the batch before attempt 2 launches
228
+ const stopped = async (stop) => {
229
+ const relabels = [], calls = [];
230
+ let tick; const secondRun = new Promise((r) => { tick = r; });
231
+ const rows = await orchestrate({ issues: stop.issues ?? [1], parallel: 2, recovery: { maxAttempts: 2 }, readBody: async () => "",
232
+ relabelNeedsHuman: async (x) => { relabels.push(x); },
233
+ runOne: async (n, _l, ctx = {}) => {
234
+ calls.push({ n, recover: ctx.recover ?? null });
235
+ if (n === 2) { await secondRun; return { terminalReason: "engine-error", detail: "boom" }; }
236
+ if (calls.filter((c) => c.n === 1).length === 2) tick();
237
+ await new Promise((r) => setImmediate(r));
238
+ return NH("ci.red", LONG);
239
+ }, ...stop.opts(calls) });
240
+ return { rows, relabels, calls: calls.filter((c) => c.n === 1) };
241
+ };
242
+ const check = ({ rows, relabels, calls }, cause) => {
243
+ assert.deepEqual(calls.map((c) => c.recover), [null, { code: "ci.red", attempt: 1, final: false }], "attempt 2 never launched");
244
+ assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
245
+ assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `needs-human` \(`ci\.red`\)/);
246
+ assert.ok(relabels[0].evidence.includes(LONG), "full reason, not 300 chars");
247
+ assert.match(relabels[0].evidence, /Recovery attempt 2\/2 for `ci\.red` was queued, but the batch stopped \(/);
248
+ assert.match(relabels[0].evidence, cause);
249
+ const r1 = byIssue(rows)[1];
250
+ assert.equal(r1.outcome, "held"); assert.equal(r1.recovery.code, "ci.red"); assert.equal(r1.recovery.attempts, 2);
251
+ assert.match(formatOutcomeLists(rows).join("\n"), /unrecovered: #1/);
252
+ };
253
+
254
+ it("breaker", async () => {
255
+ check(await stopped({ issues: [1, 2], opts: () => ({ breaker: { errors: 1, window_min: 30 } }) }), /circuit breaker/);
256
+ });
257
+ it("execution budget (on_budget stop)", async () => {
258
+ check(await stopped({ opts: (calls) => ({ remote: { max: 3, isRemote: () => true, admit: () => (calls.length >= 2 ? { ok: false, reason: "cap $5 reached" } : { ok: true }) } }) }),
259
+ /execution budget: cap \$5 reached/);
260
+ });
261
+ it("drift hold", async () => {
262
+ check(await stopped({ opts: (calls) => ({ drift: { holds: () => (calls.length >= 2 ? "packages/ui" : null) } }) }), /drift: packages\/ui flagged/);
263
+ });
264
+
265
+ it("a resume of a lost run that does not relaunch it relabels once; a second resume does not repeat it", async () => {
266
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
267
+ recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", result_code: "ci.red", prev_attempt: 1 } },
268
+ breaker: { at: 1, detail: "circuit breaker: 3 engine-errors within 30 min; admissions stopped" } };
269
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
270
+ const relabels = [], ran = [];
271
+ const go = () => orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
272
+ relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async (n) => { ran.push(n); return { terminalReason: "merged" }; } });
273
+ const rows = await go();
274
+ assert.deepEqual(ran, []); assert.equal(rows[0].outcome, "held");
275
+ assert.equal(relabels.length, 1); assert.ok(relabels[0].evidence.includes(LONG));
276
+ assert.match(relabels[0].evidence, /attempt 1\) did not merge.*[\s\S]*circuit breaker/);
277
+ assert.deepEqual(state.relabelled, [1]);
278
+ await go();
279
+ assert.equal(relabels.length, 1, "relabelled once across resumes");
280
+ });
281
+
282
+ it("a resume that relaunches the lost recovery lane does not relabel; the lane gets ctx.recover", async () => {
283
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
284
+ recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", prev_attempt: 1 } }, relabelled: [1] };
285
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
286
+ const relabels = [], ctxs = [];
287
+ const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
288
+ relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return { terminalReason: "merged" }; } });
289
+ assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 2, final: true }]);
290
+ assert.equal(rows[0].outcome, "merged"); assert.deepEqual(relabels, []);
291
+ assert.equal(state.relabelled, undefined);
292
+ });
293
+
294
+ it("a store from before #4315 (no detail, no relabelled) still resumes and relabels with the code", async () => {
295
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [1], results: {}, running: {}, recoveries: { 1: 1 }, recovering: { 1: { code: "ci.red", attempt: 1 } },
296
+ breaker: { at: 1, detail: "circuit breaker: tripped" } };
297
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
298
+ const relabels = [];
299
+ const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "",
300
+ relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async () => ({ terminalReason: "merged" }) });
301
+ assert.equal(rows[0].outcome, "held"); assert.equal(rows[0].recovery.detail, "ci.red");
302
+ assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /The lane did not merge: it ended `needs-human`/);
303
+ });
304
+ });
305
+
306
+ it("#4302: --recover-nonfinal reaches runCore as final: false; without it a recovery lane is final", async () => {
307
+ const got = [];
308
+ const l = console.log; console.log = () => {};
309
+ try {
310
+ for (const extra of [["--recover-nonfinal"], []]) {
311
+ await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "ci.red", "--recover-attempt", "1", ...extra], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
312
+ leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
313
+ }
314
+ } finally { console.log = l; }
315
+ assert.deepEqual(got, [{ code: "ci.red", attempt: 1, final: false }, { code: "ci.red", attempt: 1, final: true }]);
316
+ });
192
317
  });
193
318
 
194
319
  describe("#4298 staging-review follow-ups: recovery", () => {
@@ -197,7 +322,7 @@ describe("#4298 staging-review follow-ups: recovery", () => {
197
322
  for (const c of RECOVERABLE_CODES) assert.ok(String(RECOVER_HOW[c]).length > 20, c);
198
323
  });
199
324
 
200
- it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt}", async () => {
325
+ it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt, final}", async () => {
201
326
  let t = 0; const seen = [];
202
327
  const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 0.5 }, { terminalReason: "merged", pr: 9, cost_usd: 0.25 }],
203
328
  2: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 2 }] };
@@ -207,7 +332,7 @@ describe("#4298 staging-review follow-ups: recovery", () => {
207
332
  const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
208
333
  assert.equal(r[1].outcome, "merged"); assert.equal(r[1].cost_usd, 0.75); assert.equal(r[1].minutes, 6);
209
334
  assert.equal(r[2].outcome, "needs-human"); assert.equal(r[2].cost_usd, 3); assert.equal(r[2].minutes, 2);
210
- assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1 }, { code: "ci.red", attempt: 1 }]);
335
+ assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1, final: true }, { code: "ci.red", attempt: 1, final: true }]);
211
336
  });
212
337
 
213
338
  it("formatOutcomeLists caps a detail at OUTCOME_LINE_MAX", () => {
@@ -228,3 +353,142 @@ describe("#4298 staging-review follow-ups: recovery", () => {
228
353
  assert.match(spec, /resumes every daemon-hosted batch that has no report yet/);
229
354
  });
230
355
  });
356
+
357
+ describe("#4304 staging-review follow-ups: recovery", () => {
358
+ it("a recovery row counts the pre-filter cost once; a single pass still includes it", async () => {
359
+ const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 1 }],
360
+ 2: [{ terminalReason: "merged", pr: 4, cost_usd: 1 }] };
361
+ const k = { 1: 0, 2: 0 };
362
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, recovery: { maxAttempts: 1 }, readBody: async () => "", now: () => 0,
363
+ prefilter: async () => ({ closed: false, cost_usd: 0.1 }), runOne: async (n) => script[n][k[n]++] });
364
+ const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
365
+ assert.equal(k[1], 2, "issue 1 ran a recovery lane");
366
+ assert.equal(r[1].cost_usd, 2.1);
367
+ assert.equal(r[2].cost_usd, 1.1);
368
+ });
369
+
370
+ const runBatchWith = async (enginePath) => {
371
+ const leanIo = {
372
+ gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
373
+ git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
374
+ };
375
+ const runs = [], logs = [], l = console.log;
376
+ console.log = (x) => logs.push(String(x));
377
+ try {
378
+ await orchestrateFromCli(["5", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("core-rec-"), batchRoot: tmpDir("core-rec-batch-"), allowTmpfs: true, emitTelemetry: async () => {},
379
+ io: { gh: async () => "o/r\n" }, leanIo, sched: null, enginePath, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: () => {},
380
+ relabelNeedsHuman: async () => {},
381
+ leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
382
+ } finally { console.log = l; }
383
+ return { runs, logs };
384
+ };
385
+
386
+ it("engine.path full turns batch recovery off (run-issue rejects --recover there); core keeps it", async () => {
387
+ const full = await runBatchWith("full");
388
+ assert.deepEqual(full.runs, [null]);
389
+ assert.ok(full.logs.some((x) => /recovery: off \(engine\.path is full/.test(x)), full.logs.join("\n"));
390
+ const core = await runBatchWith("core");
391
+ assert.deepEqual(core.runs, [null, { code: "ci.red", attempt: 1, final: true }]);
392
+ });
393
+ });
394
+
395
+ describe("#4316 one shared recovery evidence text", () => {
396
+ it("lane, settle and batch-exit relabels render the same evidence shape", () => {
397
+ const long = `${"x".repeat(320)}\nsecond line`;
398
+ assert.equal(recoveryEvidence({ code: "ci.red", attempt: 1, outcome: "needs-human", resultCode: "ci.red", detail: " red check\nmore " }),
399
+ "Recovery pass for `ci.red` (attempt 1) did not merge: it ended `needs-human` (`ci.red`): red check. The issue stays `needs-human`.");
400
+ assert.equal(recoveryEvidence({ code: "ci.red", attempt: 2, outcome: "ci-pending", detail: "" }),
401
+ "Recovery pass for `ci.red` (attempt 2) did not merge: it ended `ci-pending`. The issue stays `needs-human`.");
402
+ assert.equal(recoveryEvidence({ code: "c", attempt: 1, outcome: "o", detail: long }).includes(`: ${"x".repeat(300)}. The issue`), true, "first line, 300 chars");
403
+ const queued = recoveryEvidence({ code: "base.conflict", attempt: 0, outcome: undefined, resultCode: "base.conflict", detail: long, full: true, queued: 1, cause: "breaker", recoveryMax: 2 });
404
+ assert.equal(queued, `The lane did not merge: it ended \`needs-human\` (\`base.conflict\`): ${long}\n\n`
405
+ + "Recovery attempt 1/2 for `base.conflict` was queued, but the batch stopped (breaker) before it launched. The issue stays `needs-human`.");
406
+ assert.match(recoveryEvidence({ code: "c", attempt: 1, outcome: "o", queued: 2, cause: "batch stopped" }), /^Recovery pass for `c` \(attempt 1\) did not merge: it ended `o`\n\nRecovery attempt 2\/2 /);
407
+ });
408
+ it("/orchestrate spec names the pre-filter's flaky exceptions and recovery being off on engine.path full", () => {
409
+ const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
410
+ assert.match(spec, /unless it is flaky \(wording or `flaky` label\), was reopened after a pre-filter close, or\s+the workflow failed and passed at the same SHA/);
411
+ assert.match(spec, /recovery is off when `engine\.path: full`/);
412
+ });
413
+ it("orchestrate.mjs and lean.mjs build no copy of their own", () => {
414
+ for (const f of ["orchestrate.mjs", "lean.mjs"]) {
415
+ const src = readFileSync(new URL(`./${f}`, import.meta.url), "utf8");
416
+ assert.equal((src.match(/did not merge/g) ?? []).length, 0, f);
417
+ }
418
+ });
419
+ });
420
+
421
+ describe("#4318: the batch-exit relabel survives a throw and a signal; relabel results; full non-final reason", () => {
422
+ const LONG = `ci.red: line one ${"x".repeat(400)}\nsecond line END-OF-REASON`;
423
+ const NH = (code, detail) => ({ terminalReason: "needs-human", code, detail });
424
+ // issue 1 ends ci.red; attempt 1/2 is queued, and `onRecovering` runs when the runner reports it
425
+ const queued = (opts) => {
426
+ const relabels = [], ran = [];
427
+ const p = orchestrate({ issues: [1], parallel: 1, recovery: { maxAttempts: 2 }, readBody: async () => "",
428
+ relabelNeedsHuman: opts.relabel ?? (async (x) => { relabels.push(x); }),
429
+ runOne: async (n, _l, ctx = {}) => { ran.push(ctx.recover ?? null); await new Promise((r) => setImmediate(r)); return NH("ci.red", LONG); },
430
+ ...opts.extra(relabels) });
431
+ return { p, relabels, ran };
432
+ };
433
+
434
+ it("an exception out of the loop relabels the queued recovery once, then propagates", async () => {
435
+ let boom = false;
436
+ const { p, relabels, ran } = queued({ extra: () => ({
437
+ now: () => { if (boom) throw new Error("clock broke"); return 1000; },
438
+ onUpdate: (r) => { if (r.outcome === "recovering") boom = true; } }) });
439
+ await assert.rejects(p, /clock broke/);
440
+ assert.deepEqual(ran, [null], "attempt 1 never launched");
441
+ assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
442
+ assert.ok(relabels[0].evidence.includes(LONG), "full persisted reason");
443
+ assert.match(relabels[0].evidence, /the batch stopped \(batch failed: clock broke\)/);
444
+ });
445
+
446
+ it("onExit hands the caller the relabel: a signal relabels with its cause, once; the normal exit does not repeat it", async () => {
447
+ let exitFn = null;
448
+ const { p, relabels } = queued({ extra: () => ({
449
+ onExit: (fn) => { exitFn = fn; },
450
+ onUpdate: async (r) => { if (r.outcome === "recovering" && exitFn) { const f = exitFn; exitFn = null; await f("interrupted by SIGINT"); await f("again"); } } }) });
451
+ await p;
452
+ assert.equal(relabels.length, 1, "idempotent");
453
+ assert.match(relabels[0].evidence, /the batch stopped \(interrupted by SIGINT\)/);
454
+ });
455
+
456
+ it("a non-final attempt's relabel carries the full multi-line reason, not 300 chars", async () => {
457
+ const relabels = [];
458
+ await orchestrate({ issues: [1], parallel: 1, recovery: { maxAttempts: 2 }, readBody: async () => "", relabelNeedsHuman: async (x) => { relabels.push(x); },
459
+ runOne: async (_n, _l, ctx = {}) => (ctx.recover ? NH("builder.blocked", LONG) : NH("ci.red", "red")) });
460
+ assert.equal(relabels.length, 1);
461
+ assert.ok(relabels[0].evidence.includes(LONG));
462
+ assert.ok(relabels[0].evidence.includes("END-OF-REASON"));
463
+ });
464
+
465
+ describe("relabel results on the batch-exit path", () => {
466
+ const lost = (relabel) => {
467
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
468
+ recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", result_code: "ci.red", prev_attempt: 1 } },
469
+ breaker: { at: 1, detail: "circuit breaker: tripped" } };
470
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
471
+ const calls = [];
472
+ const go = () => orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
473
+ relabelNeedsHuman: async (x) => { calls.push(x); return relabel(); }, runOne: async () => ({ terminalReason: "merged" }) });
474
+ return { go, calls, state: () => state };
475
+ };
476
+ it("a failed label add (false / \"failed\" / a throw) is not recorded: the next resume retries it", async () => {
477
+ for (const relabel of [() => false, () => "failed", () => { throw new Error("gh down"); }]) {
478
+ const b = lost(relabel);
479
+ await b.go();
480
+ assert.equal(b.state().relabelled, undefined);
481
+ await b.go();
482
+ assert.equal(b.calls.length, 2, "retried on resume");
483
+ }
484
+ });
485
+ it("\"skipped\" (closed or leased issue) and true are recorded: no retry", async () => {
486
+ for (const relabel of [() => "skipped", () => true]) {
487
+ const b = lost(relabel);
488
+ await b.go(); await b.go();
489
+ assert.deepEqual(b.state().relabelled, [1]);
490
+ assert.equal(b.calls.length, 1);
491
+ }
492
+ });
493
+ });
494
+ });
@@ -50,6 +50,19 @@ export function capComment(body, max = MAX_COMMENT_CHARS) {
50
50
  const note = "\n\n_[truncated: comment exceeded the size cap]_";
51
51
  return s.slice(0, max - note.length) + note;
52
52
  }
53
+ /**
54
+ * #4297/#4316: the one evidence text for a recovery that did not merge (lane, settle and batch-exit relabel). `attempt` 0 is
55
+ * the first lane; the detail is its first line capped at 300 chars unless `full`. `queued` (the attempt that never launched)
56
+ * adds why the batch stopped before it.
57
+ */
58
+ export function recoveryEvidence({ code, attempt, outcome, resultCode, detail, full = false, queued = 0, cause = "", recoveryMax = 0 }) {
59
+ const how = attempt ? `Recovery pass for \`${code}\` (attempt ${attempt}) did not merge` : "The lane did not merge";
60
+ const d = String(detail ?? "").trim();
61
+ const shown = full ? d : d.split("\n")[0].slice(0, 300);
62
+ const head = `${how}: it ended \`${outcome ?? "needs-human"}\`${resultCode ? ` (\`${resultCode}\`)` : ""}${shown ? `: ${shown}` : ""}`;
63
+ return queued ? `${head}\n\nRecovery attempt ${queued}/${recoveryMax || queued} for \`${code}\` was queued, but the batch stopped (${cause}) before it launched. The issue stays \`needs-human\`.`
64
+ : `${head}. The issue stays \`needs-human\`.`;
65
+ }
53
66
  const usable = (md) => sanitizeMd(md).replace(/\s+/g, " ").length >= 20;
54
67
 
55
68
  /** Text of the `### heading` section (up to the next heading), or "". */
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Sequence-numbered artifacts at hand-off (#4322): migrations named `<number>_<name>` (0007_usage.sql, 0007_x.py,
5
+ * meta/0007_snapshot.json) that the lane added with a number the base already uses. Union merge (#3969) cannot help: a
6
+ * duplicate number is a semantic collision and a drizzle-style journal is structured JSON. The engine only detects and
7
+ * verifies; the builder renumbers with the repo's own migration generator. Deterministic (git only), never throws.
8
+ */
9
+ import { basename, dirname, posix } from "node:path";
10
+
11
+ const NUM_RE = /^(\d{3,})[_-]/;
12
+ const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build)\//;
13
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
14
+ const numOf = (p) => NUM_RE.exec(basename(p))?.[1] ?? null;
15
+ const inDir = (dir) => (dir === "." ? [] : [`${dir}/`]);
16
+ const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
17
+
18
+ /** Lane-added numbered files (base...HEAD), with their blob ids. */
19
+ async function laneNumbered(git, base) {
20
+ const g = out(git);
21
+ const added = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...HEAD`])).filter((f) => numOf(f) && !SKIP_RE.test(f));
22
+ const res = [];
23
+ for (const path of added) res.push({ path, blob: (await g(["rev-parse", `HEAD:${path}`])).trim() });
24
+ return res;
25
+ }
26
+
27
+ /**
28
+ * Collisions: in each directory where a lane-added numbered file shares its number with a different base file (another
29
+ * name, or the same path with other content), every lane-added numbered file there must move, in order, above the base's
30
+ * highest number. @returns {Promise<Array<{dir, number, lane, base:string[], next}>>} (base [] = moves only to keep order)
31
+ */
32
+ export async function sequenceCollisions({ git, base }) {
33
+ try {
34
+ const g = out(git);
35
+ const lane = await laneNumbered(git, base);
36
+ const res = [];
37
+ for (const dir of [...new Set(lane.map((f) => dirname(f.path)))]) {
38
+ const mine = lane.filter((f) => dirname(f.path) === dir).sort((a, b) => Number(numOf(a.path)) - Number(numOf(b.path)) || a.path.localeCompare(b.path));
39
+ const theirs = [];
40
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", base, ...inDir(dir)]))) {
41
+ const m = /^\d+\s+blob\s+(\S+)\t(.+)$/.exec(l);
42
+ if (m && dirname(m[2]) === dir && numOf(m[2])) theirs.push({ path: m[2], blob: m[1] });
43
+ }
44
+ const clash = (f) => theirs.filter((t) => numOf(t.path) === numOf(f.path) && (t.path !== f.path || t.blob !== f.blob)).map((t) => t.path);
45
+ if (!mine.some((f) => clash(f).length)) continue;
46
+ const width = Math.max(...mine.map((f) => numOf(f.path).length));
47
+ let next = Math.max(0, ...theirs.map((t) => Number(numOf(t.path))));
48
+ for (const f of mine) res.push({ dir, number: numOf(f.path), lane: f.path, base: clash(f), next: String(++next).padStart(width, "0") });
49
+ }
50
+ return res;
51
+ } catch { return []; }
52
+ }
53
+
54
+ /**
55
+ * Drizzle-style journal checks for `dirs` at `ref` (`<dir>/meta/_journal.json`, {entries:[{idx, tag}]}): duplicate idx
56
+ * or tag, idx or tag number not strictly increasing, a numbered .sql file without a tag. No journal: [].
57
+ */
58
+ export async function journalProblems({ git, ref = "HEAD", dirs = [] }) {
59
+ const g = out(git), problems = [];
60
+ for (const dir of [...new Set(dirs)]) {
61
+ const jp = posix.join(dir, "meta", "_journal.json");
62
+ const text = await g(["show", `${ref}:${jp}`]);
63
+ if (!text.trim()) continue;
64
+ let entries;
65
+ try { entries = JSON.parse(text).entries; } catch { problems.push(`${jp}: not valid JSON`); continue; }
66
+ if (!Array.isArray(entries)) { problems.push(`${jp}: no entries array`); continue; }
67
+ const tags = new Set();
68
+ for (let i = 0; i < entries.length; i++) {
69
+ const e = entries[i] ?? {}, prev = entries[i - 1];
70
+ if (tags.has(e.tag)) problems.push(`${jp}: duplicate tag ${e.tag}`);
71
+ tags.add(e.tag);
72
+ if (prev && !(Number(e.idx) > Number(prev.idx))) problems.push(`${jp}: idx ${e.idx} (${e.tag}) not after ${prev.idx} (${prev.tag})`);
73
+ const n = numOf(String(e.tag ?? "")), pn = prev ? numOf(String(prev.tag ?? "")) : null;
74
+ if (n && pn && !(Number(n) > Number(pn))) problems.push(`${jp}: ${e.tag} not numbered after ${prev.tag}`);
75
+ }
76
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", "--name-only", ref, ...inDir(dir)]))) {
77
+ if (dirname(l) === dir && l.endsWith(".sql") && numOf(l) && !tags.has(basename(l, ".sql"))) problems.push(`${jp}: no entry for ${basename(l)}`);
78
+ }
79
+ }
80
+ return problems;
81
+ }
82
+
83
+ /** Lane-added numbered files with their blobs, recorded before a renumber so verifyRenumber can prove the SQL kept. */
84
+ export const snapshotLane = ({ git, base }) => laneNumbered(git, base).catch(() => []);
85
+
86
+ /**
87
+ * After the builder renumbered: no collision remains, every recorded non-JSON file (the SQL; snapshots are regenerated)
88
+ * still exists byte-identical in its directory, and the journals are strictly ordered. @returns {Promise<{ok, problems}>}
89
+ */
90
+ export async function verifyRenumber({ git, base, before = [] }) {
91
+ try {
92
+ const problems = [];
93
+ const left = await sequenceCollisions({ git, base });
94
+ for (const c of left.filter((x) => x.base.length)) problems.push(`${c.lane} still shares number ${c.number} with ${c.base.join(", ")}`);
95
+ const now = await laneNumbered(git, base);
96
+ for (const b of before.filter((f) => !f.path.endsWith(".json"))) {
97
+ if (!now.some((f) => dirname(f.path) === dirname(b.path) && f.blob === b.blob)) problems.push(`${b.path}: content changed or file gone after renumbering (keep it byte-identical, rename only)`);
98
+ }
99
+ problems.push(...await journalProblems({ git, dirs: [...new Set([...before, ...now].map((f) => dirname(f.path)))] }));
100
+ return { ok: !problems.length, problems };
101
+ } catch (e) { return { ok: false, problems: [String(e?.message ?? e)] }; }
102
+ }