forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Builder self-review (#4374): the builder answers the shared `REVIEW_RUBRIC` (prompts.mjs) in result.json
5
+ * `self_review: [{question, answer, evidence}]` inside its build session. The engine only reads and logs it: a missing
6
+ * self-review is SELF_REVIEW_MISSING, and the first reviewer judgement is compared with it (SELF_REVIEW_AGREED /
7
+ * SELF_REVIEW_DISAGREED). Never a failure, never a runner call. Pure: no I/O.
8
+ */
9
+
10
+ const text = (v) => (v === null || v === undefined ? "" : String(v)).trim();
11
+
12
+ /**
13
+ * The self-review of a result: `present` with its items and the questions not answered yes, `missing` when a fixed result
14
+ * has no non-empty `self_review` array, `invalid` when an entry lacks a question or an answer, or null for a result
15
+ * that is not `fixed` (nothing to self-review).
16
+ */
17
+ export function readSelfReview(result) {
18
+ if (!result || typeof result !== "object" || result.status !== "fixed") return null;
19
+ const raw = result.self_review;
20
+ if (!Array.isArray(raw) || !raw.length) return { state: "missing", items: [], no: [] };
21
+ const items = raw.map((x) => ({ question: text(x?.question), answer: text(x?.answer), evidence: text(x?.evidence) }));
22
+ if (items.some((x) => !x.question || !x.answer)) return { state: "invalid", items, no: [] };
23
+ return { state: "present", items, no: items.filter((x) => x.answer.toLowerCase() !== "yes").map((x) => x.question) };
24
+ }
25
+
26
+ /**
27
+ * Compare a present self-review with a reviewer decision (`decide()` state). Null when either side has no judgement
28
+ * (self-review not present, reviewer reply incomplete, unparseable or skipped). Agreement: all yes and the reviewer
29
+ * conforms, or any no and the reviewer finds it nonconforming.
30
+ */
31
+ export function compareSelfReview(self, decision) {
32
+ if (self?.state !== "present") return null;
33
+ const reviewer = decision?.state;
34
+ if (reviewer !== "conforms" && reviewer !== "nonconforming") return null;
35
+ const allYes = self.no.length === 0;
36
+ return { agreed: allYes === (reviewer === "conforms"), reviewer, self_no: self.no };
37
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { readSelfReview, compareSelfReview } from "./selfreview.mjs";
6
+
7
+ const item = (question, answer = "yes", evidence = "src/a.py:3") => ({ question, answer, evidence });
8
+
9
+ describe("readSelfReview (#4374)", () => {
10
+ it("only a fixed result is self-reviewed", () => {
11
+ for (const r of [null, undefined, "x", { status: "blocked" }, { status: "invalid", self_review: [item("1")] }]) assert.equal(readSelfReview(r), null);
12
+ });
13
+
14
+ it("a fixed result without a non-empty self_review array is missing", () => {
15
+ for (const self_review of [undefined, null, [], "yes", {}]) assert.deepEqual(readSelfReview({ status: "fixed", self_review }), { state: "missing", items: [], no: [] });
16
+ });
17
+
18
+ it("an entry without a question or an answer is invalid", () => {
19
+ assert.equal(readSelfReview({ status: "fixed", self_review: [item("1"), { question: "2" }] }).state, "invalid");
20
+ assert.equal(readSelfReview({ status: "fixed", self_review: [null] }).state, "invalid");
21
+ });
22
+
23
+ it("present: items normalized, the questions not answered yes listed", () => {
24
+ const sr = readSelfReview({ status: "fixed", self_review: [item(" 1 ", "YES"), item("2", "no", 7), item(3, "partly", null)] });
25
+ assert.equal(sr.state, "present");
26
+ assert.deepEqual(sr.items, [item("1", "YES"), item("2", "no", "7"), { question: "3", answer: "partly", evidence: "" }]);
27
+ assert.deepEqual(sr.no, ["2", "3"]);
28
+ });
29
+ });
30
+
31
+ describe("compareSelfReview (#4374)", () => {
32
+ const yes = readSelfReview({ status: "fixed", self_review: [item("1"), item("2")] });
33
+ const no = readSelfReview({ status: "fixed", self_review: [item("1"), item("2", "no")] });
34
+
35
+ it("all yes agrees with conforms and disagrees with nonconforming", () => {
36
+ assert.deepEqual(compareSelfReview(yes, { state: "conforms" }), { agreed: true, reviewer: "conforms", self_no: [] });
37
+ assert.deepEqual(compareSelfReview(yes, { state: "nonconforming" }), { agreed: false, reviewer: "nonconforming", self_no: [] });
38
+ });
39
+
40
+ it("a no agrees with nonconforming and disagrees with conforms", () => {
41
+ assert.deepEqual(compareSelfReview(no, { state: "nonconforming" }), { agreed: true, reviewer: "nonconforming", self_no: ["2"] });
42
+ assert.deepEqual(compareSelfReview(no, { state: "conforms" }), { agreed: false, reviewer: "conforms", self_no: ["2"] });
43
+ });
44
+
45
+ it("no comparison without a judgement on either side", () => {
46
+ for (const s of ["review.incomplete", "review.unparseable", "review.inconsistent", "environment", undefined]) assert.equal(compareSelfReview(yes, { state: s }), null, String(s));
47
+ assert.equal(compareSelfReview(null, { state: "conforms" }), null);
48
+ assert.equal(compareSelfReview({ state: "missing", items: [], no: [] }, { state: "conforms" }), null);
49
+ assert.equal(compareSelfReview(yes, null), null);
50
+ });
51
+ });
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Host sequence lease (#4386): one lane at a time per (repo, lane branch, sequence dir) in the window from the
5
+ * merge-gate renumber of its numbered files to its merge, so parallel lanes stop overtaking each other's numbers.
6
+ * Each key is a one-slot `heavy.mjs` semaphore in its own registry dir (`schedDir()/seq-lease/<key>/heavy.json`):
7
+ * file-locked, FIFO, dead holders dropped by pid. The heavy-step registry and its env caps are never touched.
8
+ * Fail-open: a wait over `maxWaitS` (or an unreadable registry) proceeds without the lease; CI and base-sync stay the gate.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { join } from "node:path";
12
+ import { acquireHeavy, openHeavy } from "./heavy.mjs";
13
+ import { schedDir } from "./sched.mjs";
14
+
15
+ /** The lease key of one sequence dir of one lane branch of one repo. */
16
+ export const sequenceLeaseKey = (repo, lane, dir) => createHash("sha1").update(`${repo}\0${lane}\0${dir}`).digest("hex").slice(0, 16);
17
+
18
+ const NO_MEM = Object.freeze({ cores: () => 2, memAvailableMb: () => null });
19
+
20
+ /**
21
+ * `acquire({repo, lane, dirs, issue, maxWaitS})` takes every dir's lease in sorted order (no lock-order deadlock).
22
+ * @returns {{acquire: (e:object) => Promise<{waitMs:number, timedOut:boolean, release:Function}>}}
23
+ */
24
+ export function makeSequenceLeaseIo({ dir = join(schedDir(), "seq-lease"), open = openHeavy, acquire = acquireHeavy, pollMs } = {}) {
25
+ return {
26
+ async acquire({ repo, lane, dirs = [], issue = null, maxWaitS = 1800 } = {}) {
27
+ const held = [];
28
+ let waitMs = 0, timedOut = false;
29
+ for (const d of [...new Set(dirs)].sort()) {
30
+ const h = open(join(dir, sequenceLeaseKey(repo, lane, d)), { cfg: { max: 1, max_wait_s: maxWaitS }, env: {}, host: NO_MEM });
31
+ const g = await acquire(h, { issue, phase: "sequence" }, ...(pollMs ? [{ pollMs }] : []));
32
+ waitMs += g.waitMs ?? 0;
33
+ if (g.timedOut) timedOut = true;
34
+ held.push(g);
35
+ }
36
+ let released = false;
37
+ return { waitMs, timedOut, release: () => { if (released) return; released = true; for (const g of held.reverse()) { try { g.release(); } catch { /* fail-open */ } } } };
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Host sequence lease (#4386): one numbered-file lane per (repo, lane, dir) in the renumber→CI→merge window.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { tmpDir } from "../tests/_tmp.mjs";
10
+ import { makeSequenceLeaseIo, sequenceLeaseKey } from "./seqlease.mjs";
11
+ import { sequenceCollisions, journalProblems, renumberLane } from "./sequence.mjs";
12
+
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ const lease = () => makeSequenceLeaseIo({ dir: tmpDir("seqlease-"), pollMs: 20 });
15
+
16
+ describe("sequence lease (#4386)", () => {
17
+ it("a second acquire on the same (repo, lane, dir) waits for the release; other dirs, lanes and repos do not; release is idempotent", async () => {
18
+ const io = lease(), order = [];
19
+ const a = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 30 });
20
+ const other = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["docs/adr"], maxWaitS: 30 });
21
+ const otherLane = await io.acquire({ repo: "o/r", lane: "milestone/x", dirs: ["db"], maxWaitS: 30 });
22
+ assert.equal(other.timedOut, false);
23
+ assert.equal(otherLane.timedOut, false);
24
+ const b = io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 30 }).then((g) => { order.push("b"); return g; });
25
+ await sleep(150);
26
+ order.push("a-release");
27
+ a.release(); a.release();
28
+ const gb = await b;
29
+ assert.deepEqual(order, ["a-release", "b"]);
30
+ assert.ok(gb.waitMs >= 100);
31
+ gb.release(); other.release(); otherLane.release();
32
+ assert.notEqual(sequenceLeaseKey("o/r", "staging", "db"), sequenceLeaseKey("o/r", "staging", "db2"));
33
+ });
34
+
35
+ it("a wait over maxWaitS proceeds without the lease (fail-open)", async () => {
36
+ const io = lease();
37
+ const a = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 30 });
38
+ const b = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 0.1 });
39
+ assert.equal(b.timedOut, true);
40
+ b.release(); a.release();
41
+ });
42
+ });
43
+
44
+ const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
45
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
46
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
47
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
48
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
49
+ const journal = (...tags) => `${JSON.stringify({ version: "7", entries: tags.map((tag, idx) => ({ idx, tag, when: 1000 + idx })) }, null, 2)}\n`;
50
+ const snap = (id, prevId) => `${JSON.stringify({ version: "7", id, prevId, tables: { t: 1 } }, null, 2)}\n`;
51
+
52
+ describe("two lanes adding migrations to the same lane branch (#4386) [integration]", () => {
53
+ it("the second renumbers at the merge gate under the lease and merges; neither ends base.conflict", async () => {
54
+ const root = tmpDir("seqlanes-"), origin = join(root, "origin.git"), seed = join(root, "seed");
55
+ sh(root, "init", "-q", "--bare", "-b", "staging", origin);
56
+ sh(root, "clone", "-q", origin, seed);
57
+ sh(seed, "checkout", "-qb", "staging");
58
+ put(seed, { "db/0048_init.sql": "CREATE TABLE t (id int);\n", "db/meta/_journal.json": journal("0048_init"), "db/meta/0048_snapshot.json": snap("s48", "s47") });
59
+ commit(seed, "init");
60
+ sh(seed, "push", "-q", "origin", "staging");
61
+ const lanes = ["a", "b"].map((n) => {
62
+ const d = join(root, `lane-${n}`);
63
+ sh(root, "clone", "-q", "-b", "staging", origin, d);
64
+ sh(d, "checkout", "-qb", `feat/${n}`);
65
+ put(d, { [`db/0049_${n}.sql`]: `INSERT INTO t VALUES ('${n}');\n`, "db/meta/_journal.json": journal("0048_init", `0049_${n}`), "db/meta/0049_snapshot.json": snap(`lane-${n}`, "s48") });
66
+ commit(d, `lane ${n}`);
67
+ return d;
68
+ });
69
+ const io = lease(), outcomes = [];
70
+ let inWindow = 0, maxInWindow = 0;
71
+ /** The merge gate of one lane: lease, re-fetch, late renumber when the number is taken, "CI", merge, release. */
72
+ const gate = async (d) => {
73
+ const l = await io.acquire({ repo: "o/r", lane: "staging", dirs: ["db"], maxWaitS: 60 });
74
+ inWindow++; maxInWindow = Math.max(maxInWindow, inWindow);
75
+ try {
76
+ sh(d, "fetch", "-q", "origin", "staging");
77
+ const base = "origin/staging";
78
+ const c = await sequenceCollisions({ git: gitIn(d), base });
79
+ let how = "no collision";
80
+ if (c.length) {
81
+ const r = await renumberLane({ git: gitIn(d), base, cwd: d });
82
+ assert.ok(r.ok, r.unsupported);
83
+ how = `renumbered ${r.moves.map((m) => m.to).join(", ")}`;
84
+ }
85
+ await sleep(100); // required checks
86
+ sh(d, "push", "-q", "origin", "HEAD:staging"); // the merge: rejected if the base moved meanwhile
87
+ outcomes.push({ d, how, outcome: "merged" });
88
+ } catch (e) { outcomes.push({ d, outcome: "base.conflict", error: String(e?.message ?? e) }); }
89
+ finally { inWindow--; l.release(); }
90
+ };
91
+ await Promise.all(lanes.map(gate));
92
+ assert.deepEqual(outcomes.map((o) => o.outcome), ["merged", "merged"], JSON.stringify(outcomes));
93
+ assert.equal(maxInWindow, 1, "one numbered-file PR in the window at a time");
94
+ assert.equal(outcomes[0].how, "no collision");
95
+ assert.match(outcomes[1].how, /renumbered db\/0050_[ab]\.sql/);
96
+ sh(seed, "pull", "-q", "origin", "staging");
97
+ const tags = JSON.parse(sh(seed, "show", "HEAD:db/meta/_journal.json")).entries.map((e) => e.tag);
98
+ assert.deepEqual(tags.map((t) => t.slice(0, 4)), ["0048", "0049", "0050"]);
99
+ assert.deepEqual(await journalProblems({ git: gitIn(seed), dirs: ["db"] }), []);
100
+ });
101
+ });
@@ -0,0 +1,385 @@
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
+ * #4386: the engine renumbers itself first (`renumberLane`: git mv, heading number, references, journal append, data-only
9
+ * snapshot clone or `lean.sequence.regenerate`), resolves base-sync conflicts that touch only sequence files
10
+ * (`resolveSequenceConflicts`), and hands the builder only what it returns `unsupported` for.
11
+ * #4343: a leading calendar date (YYYY-MM-DD, YYYYMMDD) is not a number, and a directory only counts when it holds a
12
+ * series on the base: a migration journal (`<dir>/meta/_journal.json`, `<dir>/_journal.json`), or >=2 distinct numbers
13
+ * of the lane file's width that fill at least half of their min..max range (sparse issue-numbered docs are no series).
14
+ */
15
+ import { execFile } from "node:child_process";
16
+ import { randomUUID } from "node:crypto";
17
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { basename, dirname, join, posix } from "node:path";
20
+ import { promisify } from "node:util";
21
+
22
+ const NUM_RE = /^(\d{3,})[_-]/;
23
+ const DATE_RE = /^(?:19|20)\d\d(-?)(?:0[1-9]|1[0-2])\1(?:0[1-9]|[12]\d|3[01])[_-]/;
24
+ const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build)\//;
25
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
26
+ /** The sequence number a file name starts with, or null (none, or a leading date). */
27
+ export const seqNumber = (name) => { const b = basename(String(name ?? "")); return DATE_RE.test(b) ? null : NUM_RE.exec(b)?.[1] ?? null; };
28
+ const numOf = seqNumber;
29
+ const inDir = (dir) => (dir === "." ? [] : [`${dir}/`]);
30
+ const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
31
+
32
+ /** Lane-added numbered files (base...HEAD), with their blob ids. */
33
+ async function laneNumbered(git, base, ref = "HEAD") {
34
+ const g = out(git);
35
+ const added = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...${ref}`])).filter((f) => numOf(f) && !SKIP_RE.test(f));
36
+ const res = [];
37
+ for (const path of added) res.push({ path, blob: (await g(["rev-parse", `${ref}:${path}`])).trim() });
38
+ return res;
39
+ }
40
+
41
+ /**
42
+ * #4343: whether `dir` holds a series at `ref`: a migration journal (`<dir>/_journal.json`, `<dir>/meta/_journal.json`),
43
+ * or >=2 distinct numbers of the given width that fill at least half of their min..max range. @returns {(width) => boolean}
44
+ */
45
+ async function seriesDir(g, ref, dir) {
46
+ const listed = lines(await g(["-c", "core.quotepath=off", "ls-tree", "-r", "--name-only", ref, ...inDir(dir)]));
47
+ const journal = listed.some((p) => p === posix.join(dir, "_journal.json") || p === posix.join(dir, "meta", "_journal.json"));
48
+ const nums = listed.filter((p) => dirname(p) === dir).map(numOf).filter(Boolean);
49
+ return (w) => {
50
+ if (journal) return true;
51
+ const ns = [...new Set(nums.filter((n) => n.length === w).map(Number))];
52
+ return ns.length >= 2 && ns.length * 2 >= Math.max(...ns) - Math.min(...ns) + 1;
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Collisions: in each directory where a lane-added numbered file shares its number with a different base file (another
58
+ * name, or the same path with other content), every lane-added numbered file there must move, in order, above the base's
59
+ * highest number. @returns {Promise<Array<{dir, number, lane, base:string[], next}>>} (base [] = moves only to keep order)
60
+ */
61
+ export async function sequenceCollisions({ git, base, ref = "HEAD" }) {
62
+ try {
63
+ const g = out(git);
64
+ const lane = await laneNumbered(git, base, ref);
65
+ const res = [];
66
+ for (const dir of [...new Set(lane.map((f) => dirname(f.path)))]) {
67
+ const series = await seriesDir(g, base, dir);
68
+ const mine = lane.filter((f) => dirname(f.path) === dir && series(numOf(f.path).length)).sort((a, b) => Number(numOf(a.path)) - Number(numOf(b.path)) || a.path.localeCompare(b.path));
69
+ if (!mine.length) continue;
70
+ const theirs = [];
71
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", base, ...inDir(dir)]))) {
72
+ const m = /^\d+\s+blob\s+(\S+)\t(.+)$/.exec(l);
73
+ if (m && dirname(m[2]) === dir && numOf(m[2])) theirs.push({ path: m[2], blob: m[1] });
74
+ }
75
+ const clash = (f) => theirs.filter((t) => numOf(t.path) === numOf(f.path) && (t.path !== f.path || t.blob !== f.blob)).map((t) => t.path);
76
+ if (!mine.some((f) => clash(f).length)) continue;
77
+ const width = Math.max(...mine.map((f) => numOf(f.path).length));
78
+ let next = Math.max(0, ...theirs.map((t) => Number(numOf(t.path))));
79
+ for (const f of mine) res.push({ dir, number: numOf(f.path), lane: f.path, base: clash(f), next: String(++next).padStart(width, "0") });
80
+ }
81
+ return res;
82
+ } catch { return []; }
83
+ }
84
+
85
+ /**
86
+ * Drizzle-style journal checks for `dirs` at `ref` (`<dir>/meta/_journal.json`, {entries:[{idx, tag}]}): duplicate idx
87
+ * or tag, idx or tag number not strictly increasing, a numbered .sql file without a tag. No journal: [].
88
+ */
89
+ export async function journalProblems({ git, ref = "HEAD", dirs = [] }) {
90
+ const g = out(git), problems = [];
91
+ for (const dir of [...new Set(dirs)]) {
92
+ const jp = posix.join(dir, "meta", "_journal.json");
93
+ const text = await g(["show", `${ref}:${jp}`]);
94
+ if (!text.trim()) continue;
95
+ let entries;
96
+ try { entries = JSON.parse(text).entries; } catch { problems.push(`${jp}: not valid JSON`); continue; }
97
+ if (!Array.isArray(entries)) { problems.push(`${jp}: no entries array`); continue; }
98
+ const tags = new Set();
99
+ for (let i = 0; i < entries.length; i++) {
100
+ const e = entries[i] ?? {}, prev = entries[i - 1];
101
+ if (tags.has(e.tag)) problems.push(`${jp}: duplicate tag ${e.tag}`);
102
+ tags.add(e.tag);
103
+ if (prev && !(Number(e.idx) > Number(prev.idx))) problems.push(`${jp}: idx ${e.idx} (${e.tag}) not after ${prev.idx} (${prev.tag})`);
104
+ const n = numOf(String(e.tag ?? "")), pn = prev ? numOf(String(prev.tag ?? "")) : null;
105
+ if (n && pn && !(Number(n) > Number(pn))) problems.push(`${jp}: ${e.tag} not numbered after ${prev.tag}`);
106
+ }
107
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", "--name-only", ref, ...inDir(dir)]))) {
108
+ if (dirname(l) === dir && l.endsWith(".sql") && numOf(l) && !tags.has(basename(l, ".sql"))) problems.push(`${jp}: no entry for ${basename(l)}`);
109
+ }
110
+ }
111
+ return problems;
112
+ }
113
+
114
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ /**
116
+ * #4385: `now` is `was` with, at most, its own number `from` replaced by `to` (zero-padded or not, word-bounded), as a
117
+ * renumbered ADR changes its title (`# 16. X` -> `# 17. X`). Any other byte differs: false.
118
+ */
119
+ export function numberOnlyChange(was, now, from, to) {
120
+ if (!from || !to) return false;
121
+ const parts = String(was).split(new RegExp(`\\b(0*${Number(from)})\\b`));
122
+ const alt = (tok) => `(?:${[...new Set([tok, to, String(Number(to))])].map(esc).join("|")})`;
123
+ return new RegExp(`^${parts.map((p, i) => (i % 2 ? alt(p) : esc(p))).join("")}$`).test(String(now));
124
+ }
125
+
126
+ /** Lane-added numbered files with their blobs, recorded before a renumber so verifyRenumber can prove the SQL kept. */
127
+ export const snapshotLane = ({ git, base }) => laneNumbered(git, base).catch(() => []);
128
+
129
+ const stemOf = (p) => basename(p).replace(/\.[^.]*$/, "");
130
+ const word = (x) => new RegExp(`(?<![\\w-])${esc(x)}(?![\\w-])`, "g");
131
+ /** Every move's old stem → new stem, and the file's own number token → its new number (#4386). */
132
+ function normalise(text, path, moves) {
133
+ let t = String(text);
134
+ for (const m of moves) t = t.replace(word(stemOf(m.from)), stemOf(m.to));
135
+ const own = moves.find((m) => m.from === path);
136
+ return own && /\.mdx?$/i.test(own.to) ? retitle(t, numOf(own.from), numOf(own.to)) : t;
137
+ }
138
+ /** A numbered document's own number in its first heading (else its first non-empty line): 0017 → 0018, or 17 → 18. */
139
+ function retitle(text, oldNum, newNum) {
140
+ const ls = String(text).split("\n");
141
+ let i = ls.findIndex((l) => /^#{1,6}\s/.test(l));
142
+ if (i < 0) i = ls.findIndex((l) => l.trim());
143
+ if (i < 0) return String(text);
144
+ const pad = new RegExp(`\\b${oldNum}\\b`), bare = new RegExp(`\\b${Number(oldNum)}\\b`);
145
+ ls[i] = pad.test(ls[i]) ? ls[i].replace(pad, newNum) : ls[i].replace(bare, String(Number(newNum)));
146
+ return ls.join("\n");
147
+ }
148
+
149
+ /**
150
+ * After a renumber (engine or builder): no collision remains, every recorded non-JSON file (the SQL, a numbered ADR;
151
+ * snapshots are regenerated) still exists in its directory byte-identical, or renamed and changed only in its own
152
+ * number (#4385), or (#4386) identical once each move's old stem and the file's own heading number are mapped to the new
153
+ * ones; no tracked file still names a renamed file's old stem (index, links), and the journals are strictly ordered.
154
+ * `moves` ([{from,to}]) defaults to the pairing of recorded and current lane files by the name after the number.
155
+ * @returns {Promise<{ok, problems}>}
156
+ */
157
+ export async function verifyRenumber({ git, base, before = [], moves = null }) {
158
+ try {
159
+ const g = out(git), problems = [];
160
+ const left = await sequenceCollisions({ git, base });
161
+ for (const c of left.filter((x) => x.base.length)) problems.push(`${c.lane} still shares number ${c.number} with ${c.base.join(", ")}`);
162
+ const now = await laneNumbered(git, base);
163
+ const rest = (p) => basename(p).slice(numOf(p).length);
164
+ const mv = moves?.length ? moves : before.flatMap((b) => {
165
+ const to = now.find((f) => dirname(f.path) === dirname(b.path) && f.path !== b.path && rest(f.path) === rest(b.path) && f.blob !== b.blob);
166
+ return to && !now.some((f) => f.path === b.path) ? [{ from: b.path, to: to.path }] : [];
167
+ });
168
+ for (const b of before.filter((f) => !f.path.endsWith(".json"))) {
169
+ const peers = now.filter((f) => dirname(f.path) === dirname(b.path));
170
+ if (peers.some((f) => f.blob === b.blob)) continue;
171
+ const was = await g(["cat-file", "-p", b.blob]), mapped = normalise(was, b.path, mv);
172
+ let kept = false;
173
+ for (const f of peers) {
174
+ if (kept || f.path === b.path) continue;
175
+ const text = await g(["cat-file", "-p", f.blob]);
176
+ kept = numberOnlyChange(was, text, numOf(b.path), numOf(f.path)) || (rest(f.path) === rest(b.path) && text === mapped);
177
+ }
178
+ if (!kept) problems.push(`${b.path}: content changed or file gone after renumbering (keep it byte-identical, rename only)`);
179
+ }
180
+ // #4385: a renamed file's old name must not survive in an index or link (docs/adr/README.md listing 0016-b.md).
181
+ for (const b of before.filter((f) => !f.path.endsWith(".json") && !now.some((x) => x.path === f.path))) {
182
+ const stem = basename(b.path).replace(/\.[^.]*$/, "");
183
+ for (const hit of lines(await g(["-c", "core.quotepath=off", "grep", "-l", "-w", "-F", stem, "HEAD", "--"]))) problems.push(`${hit.replace(/^HEAD:/, "")}: still references ${stem} after renumbering (update the index/links)`);
184
+ }
185
+ problems.push(...await journalProblems({ git, dirs: [...new Set([...before, ...now].map((f) => dirname(f.path)))] }));
186
+ return { ok: !problems.length, problems };
187
+ } catch (e) { return { ok: false, problems: [String(e?.message ?? e)] }; }
188
+ }
189
+
190
+ const JOURNAL_RE = /(?:^|\/)_journal\.json$/, SNAPSHOT_RE = /(?:^|\/)\d{3,}_snapshot\.json$/, INDEX_RE = /(?:^|\/)(?:README|index)\.mdx?$/i;
191
+ const SUPPORTED_RE = /\.(?:sql|md|mdx)$/i;
192
+
193
+ /**
194
+ * #4386: resolve a base merge in progress whose conflicts are all sequence files: numbered files, journals and
195
+ * snapshots take the base side (`--theirs`; the lane's originals come back through renumberLane from the pre-merge head;
196
+ * a journal keeps the base entries and appends the lane's, so a lane whose number did not collide keeps its entry),
197
+ * and an index file beside numbered files (README.md, index.md) keeps both sides (union). Commits the merge.
198
+ * #4465: a numbered file (or snapshot) only counts when renumberLane can restore the lane's side: an add/add conflict
199
+ * (no merge-base stage, so the lane added it) in a directory that holds a series on the base being merged (MERGE_HEAD).
200
+ * Any other conflicted file: {unsupported}, and the merge is left as is for the builder. Never throws.
201
+ * @returns {Promise<{ok:true}|{unsupported:string}>}
202
+ */
203
+ export async function resolveSequenceConflicts({ git, files = [] }) {
204
+ try {
205
+ const g = out(git);
206
+ if (!files.length) return { unsupported: "no conflicted files" };
207
+ const tracked = lines(await g(["-c", "core.quotepath=off", "ls-files"]));
208
+ const indexFile = (f) => INDEX_RE.test(f) && tracked.some((p) => dirname(p) === dirname(f) && numOf(p));
209
+ const stage1 = new Set(lines(await g(["-c", "core.quotepath=off", "ls-files", "-u"])).map((l) => /^\d+ \S+ (\d)\t(.+)$/.exec(l)).filter((m) => m?.[1] === "1").map((m) => m[2]));
210
+ const series = new Map();
211
+ const laneAdded = async (f) => {
212
+ if (stage1.has(f)) return false; // both sides changed an existing file: the base side would drop the lane's edit
213
+ if (!series.has(dirname(f))) series.set(dirname(f), await seriesDir(g, "MERGE_HEAD", dirname(f)));
214
+ return series.get(dirname(f))(numOf(f).length);
215
+ };
216
+ const other = [];
217
+ for (const f of files) {
218
+ if (JOURNAL_RE.test(f) || (indexFile(f) && !numOf(f))) continue;
219
+ if (!(numOf(f) && (await laneAdded(f)))) other.push(f);
220
+ }
221
+ if (other.length) return { unsupported: `non-sequence conflict: ${other.join(", ")}` };
222
+ for (const f of files) {
223
+ if (indexFile(f) && !numOf(f)) {
224
+ const tmp = mkdtempSync(join(tmpdir(), "seq-union-"));
225
+ try {
226
+ const b = join(tmp, "base"), t = join(tmp, "theirs");
227
+ writeFileSync(b, await g(["show", `:1:${f}`]));
228
+ writeFileSync(t, await g(["show", `:3:${f}`]));
229
+ await git(["checkout", "--ours", "--", f]);
230
+ await git(["merge-file", "--union", f, b, t]);
231
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
232
+ await git(["add", "--", f]);
233
+ } else if (JOURNAL_RE.test(f)) { // base entries, then the lane's (renumberLane retags them); idx consecutive
234
+ const parse = async (n) => { try { return JSON.parse(await g(["show", `:${n}:${f}`])); } catch { return null; } };
235
+ const ours = await parse(2), theirs = await parse(3);
236
+ if (!Array.isArray(theirs?.entries) || !Array.isArray(ours?.entries ?? [])) return { unsupported: `${f}: journal is not valid JSON on both sides` };
237
+ const tags = new Set(theirs.entries.map((e) => e?.tag));
238
+ let idx = Math.max(-1, ...theirs.entries.map((e) => Number(e?.idx)).filter(Number.isFinite));
239
+ const add = (ours?.entries ?? []).filter((e) => !tags.has(e?.tag)).map((e) => ({ ...e, idx: ++idx }));
240
+ await git(["checkout", "--theirs", "--", f]);
241
+ writeFileSync(join(String(await g(["rev-parse", "--show-toplevel"])).trim(), f), `${JSON.stringify({ ...theirs, entries: [...theirs.entries, ...add] }, null, 2)}\n`);
242
+ await git(["add", "--", f]);
243
+ } else {
244
+ try { await git(["checkout", "--theirs", "--", f]); await git(["add", "--", f]); }
245
+ catch { await git(["rm", "-q", "-f", "--", f]); } // the base deleted it
246
+ }
247
+ }
248
+ await git(["commit", "--no-edit"]);
249
+ return { ok: true };
250
+ } catch (e) { return { unsupported: `sequence conflict resolution failed: ${String(e?.message ?? e).split("\n")[0]}` }; }
251
+ }
252
+
253
+ const sh = promisify(execFile);
254
+ const defaultRun = (cmd, { cwd }) => sh("sh", ["-c", cmd], { cwd, timeout: 600000, maxBuffer: 50 * 1024 * 1024 });
255
+ const json = async (g, spec) => { const t = await g(["show", spec]); if (!t.trim()) return null; try { return JSON.parse(t); } catch { return null; } };
256
+ const strip = (o) => { const { id, prevId, ...r } = o ?? {}; return JSON.stringify(r); };
257
+ const writeJson = (p, o) => writeFileSync(p, `${JSON.stringify(o, null, 2)}\n`);
258
+
259
+ /**
260
+ * #4386: renumber the lane's sequence-numbered files deterministically, the mechanical fix a human does by hand:
261
+ * 1. bring the branch up to date with the base (a sequence-only conflict takes the base side, else unsupported);
262
+ * 2. write each colliding lane file (`.sql`, Markdown; read from `laneRef`, the pre-merge lane head) at max(base)+1…,
263
+ * and restore or remove the old path; a Markdown file's own number in its first heading follows;
264
+ * 3. per drizzle-style journal: the base journal plus the lane's entries, retagged, with consecutive idx;
265
+ * 4. per moved SQL with a snapshot: data-only (equal to its lane predecessor but id/prevId) clones the tip snapshot with
266
+ * a fresh id and prevId = tip id; otherwise `regenerate` runs (none configured: unsupported);
267
+ * 5. every tracked file naming an old stem (links, comments) is rewritten to the new one (SQL bodies stay untouched);
268
+ * then one commit. Any other migration language, a failed generator or an error: {unsupported}, the branch reset to
269
+ * where it started. Never throws.
270
+ * @returns {Promise<{ok:true, moves:Array<{from,to}>, commit:string|null}|{unsupported:string}>}
271
+ */
272
+ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerate = null, run = null, uuid = randomUUID }) {
273
+ const g = out(git);
274
+ const start = (await g(["rev-parse", "HEAD"])).trim();
275
+ const written = [];
276
+ const fail = async (why) => {
277
+ try { await git(["merge", "--abort"]); } catch { /* no merge in progress */ }
278
+ try { await git(["reset", "-q", "--hard", start]); } catch { /* best effort */ }
279
+ const keep = new Set(lines(await g(["ls-files"])));
280
+ for (const p of written) if (!keep.has(p)) rmSync(join(cwd, p), { force: true });
281
+ return { unsupported: why };
282
+ };
283
+ try {
284
+ const lane = (await g(["rev-parse", laneRef])).trim() || start;
285
+ const collisions = (await sequenceCollisions({ git, base, ref: lane })).filter((c) => !SNAPSHOT_RE.test(c.lane));
286
+ if (!collisions.length) return { ok: true, moves: [], commit: null };
287
+ const odd = collisions.find((c) => !SUPPORTED_RE.test(c.lane));
288
+ if (odd) return { unsupported: `${odd.lane}: no deterministic renumber for .${basename(odd.lane).split(".").pop()} migrations` };
289
+ const moves = collisions.map((c) => ({ from: c.lane, to: posix.join(c.dir, c.next + basename(c.lane).slice(c.number.length)), dir: c.dir, oldNum: c.number, newNum: c.next }));
290
+ // Plan the journals and snapshots before touching anything, so an unsupported migration leaves the branch as it was.
291
+ const baseList = lines(await g(["-c", "core.quotepath=off", "ls-tree", "-r", "--name-only", base]));
292
+ const plans = [];
293
+ let needRegen = false;
294
+ for (const dir of [...new Set(moves.map((m) => m.dir))]) {
295
+ const jp = [posix.join(dir, "meta", "_journal.json"), posix.join(dir, "_journal.json")].find((p) => baseList.includes(p));
296
+ if (!jp) continue;
297
+ const meta = dirname(jp), mine = moves.filter((m) => m.dir === dir && /\.sql$/i.test(m.from));
298
+ const snaps = [];
299
+ for (const m of mine) {
300
+ const laneSnap = await json(g, `${lane}:${posix.join(meta, `${m.oldNum}_snapshot.json`)}`);
301
+ if (!laneSnap) continue;
302
+ const laneMeta = lines(await g(["-c", "core.quotepath=off", "ls-tree", "--name-only", lane, `${meta}/`])).filter((p) => SNAPSHOT_RE.test(p) && Number(numOf(p)) < Number(m.oldNum));
303
+ const pred = laneMeta.sort((a, b) => Number(numOf(a)) - Number(numOf(b))).at(-1);
304
+ const dataOnly = Boolean(pred) && strip(await json(g, `${lane}:${pred}`)) === strip(laneSnap);
305
+ if (!dataOnly && !regenerate) return { unsupported: `schema-changing migration ${m.from} needs lean.sequence.regenerate (its snapshot cannot be cloned)` };
306
+ if (!dataOnly) needRegen = true;
307
+ snaps.push({ m, dataOnly });
308
+ }
309
+ plans.push({ dir, jp, meta, snaps });
310
+ }
311
+ // 1. up to date with the base
312
+ if (!(await git(["merge-base", "--is-ancestor", base, "HEAD"]).then(() => true, () => false))) {
313
+ try { await git(["merge", "--no-edit", base]); } catch {
314
+ const files = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=U"]));
315
+ const r = await resolveSequenceConflicts({ git, files });
316
+ if (!r.ok) return await fail(r.unsupported);
317
+ }
318
+ }
319
+ const head = lines(await g(["-c", "core.quotepath=off", "ls-files"]));
320
+ const touched = new Set();
321
+ // 2. the files, highest number first so a lane file never lands on another lane file not yet moved
322
+ for (const m of [...moves].sort((a, b) => Number(b.oldNum) - Number(a.oldNum))) {
323
+ let text = await g(["cat-file", "blob", `${lane}:${m.from}`]);
324
+ if (/\.mdx?$/i.test(m.to)) text = retitle(text, m.oldNum, m.newNum);
325
+ mkdirSync(dirname(join(cwd, m.to)), { recursive: true });
326
+ writeFileSync(join(cwd, m.to), text);
327
+ written.push(m.to);
328
+ touched.add(m.to);
329
+ if (head.includes(m.from)) {
330
+ if (baseList.includes(m.from)) await git(["checkout", base, "--", m.from]);
331
+ else await git(["rm", "-q", "-f", "--", m.from]);
332
+ }
333
+ }
334
+ const stem = new Map(moves.map((m) => [stemOf(m.from), stemOf(m.to)]));
335
+ // 3. + 4. journals and snapshots
336
+ for (const p of plans) {
337
+ const bj = (await json(g, `${base}:${p.jp}`)) ?? { entries: [] }, lj = (await json(g, `${lane}:${p.jp}`)) ?? { entries: [] };
338
+ const baseEntries = Array.isArray(bj.entries) ? bj.entries : [];
339
+ const tags = new Set(baseEntries.map((e) => e?.tag));
340
+ let idx = Math.max(-1, ...baseEntries.map((e) => Number(e?.idx)).filter(Number.isFinite));
341
+ const add = (Array.isArray(lj.entries) ? lj.entries : []).filter((e) => !tags.has(e?.tag)).map((e) => ({ ...e, idx: ++idx, tag: stem.get(e.tag) ?? e.tag }));
342
+ writeJson(join(cwd, p.jp), { ...bj, entries: [...baseEntries, ...add] });
343
+ touched.add(p.jp);
344
+ const baseSnaps = baseList.filter((x) => dirname(x) === p.meta && SNAPSHOT_RE.test(x)).sort((a, b) => Number(numOf(a)) - Number(numOf(b)));
345
+ let tip = baseSnaps.length ? await json(g, `${base}:${baseSnaps.at(-1)}`) : null;
346
+ for (const { m, dataOnly } of p.snaps.sort((a, b) => Number(a.m.newNum) - Number(b.m.newNum))) {
347
+ const oldSnap = posix.join(p.meta, `${m.oldNum}_snapshot.json`), newSnap = posix.join(p.meta, `${m.newNum}_snapshot.json`);
348
+ if (head.includes(oldSnap)) {
349
+ if (baseList.includes(oldSnap)) await git(["checkout", base, "--", oldSnap]);
350
+ else await git(["rm", "-q", "-f", "--", oldSnap]);
351
+ }
352
+ if (!dataOnly) continue;
353
+ if (!tip) return await fail(`${m.from}: the base has no snapshot to clone`);
354
+ const next = { ...tip, id: uuid(), prevId: tip.id };
355
+ writeJson(join(cwd, newSnap), next);
356
+ written.push(newSnap);
357
+ touched.add(newSnap);
358
+ tip = next;
359
+ }
360
+ }
361
+ if (needRegen) {
362
+ try { await (run ?? defaultRun)(regenerate, { cwd }); } catch (e) { return await fail(`lean.sequence.regenerate failed: ${String(e?.stderr || e?.message || e).trim().split("\n")[0]}`); }
363
+ for (const p of plans) {
364
+ touched.add(p.meta);
365
+ for (const { m, dataOnly } of p.snaps) {
366
+ const want = posix.join(p.meta, `${m.newNum}_snapshot.json`);
367
+ if (!dataOnly) { try { readFileSync(join(cwd, want)); } catch { return await fail(`lean.sequence.regenerate did not write ${want}`); } }
368
+ }
369
+ }
370
+ }
371
+ // 5. references to the old names, repo-wide (moved files included; SQL bodies and the lane dir stay as they are)
372
+ await git(["add", "-A", "--", ...touched]);
373
+ for (const [from, to] of stem) {
374
+ for (const f of lines(await g(["-c", "core.quotepath=off", "grep", "-l", "-I", "-F", "-e", from, "--"]))) {
375
+ if (/\.sql$/i.test(f) || f.startsWith(".forge-lean/") || JOURNAL_RE.test(f)) continue;
376
+ const p = join(cwd, f), t = readFileSync(p, "utf8"), u = t.replace(word(from), to);
377
+ if (u !== t) { writeFileSync(p, u); touched.add(f); }
378
+ }
379
+ }
380
+ await git(["add", "-A", "--", ...touched]);
381
+ const msg = `chore(sequence): renumber ${moves.map((m) => `${basename(m.from)} → ${basename(m.to)}`).join(", ")} (deterministic, #4386)`;
382
+ await git(["commit", "-q", "-m", msg]);
383
+ return { ok: true, moves: moves.map(({ from, to }) => ({ from, to })), commit: (await g(["rev-parse", "HEAD"])).trim() || null };
384
+ } catch (e) { return await fail(`deterministic renumber failed: ${String(e?.message ?? e).split("\n")[0]}`); }
385
+ }