forgedock 1.30.0 → 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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -5,37 +5,68 @@
5
5
  * meta/0007_snapshot.json) that the lane added with a number the base already uses. Union merge (#3969) cannot help: a
6
6
  * duplicate number is a semantic collision and a drizzle-style journal is structured JSON. The engine only detects and
7
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).
8
14
  */
9
- import { basename, dirname, posix } from "node:path";
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";
10
21
 
11
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])[_-]/;
12
24
  const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build)\//;
13
25
  const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
14
- const numOf = (p) => NUM_RE.exec(basename(p))?.[1] ?? null;
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;
15
29
  const inDir = (dir) => (dir === "." ? [] : [`${dir}/`]);
16
30
  const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
17
31
 
18
32
  /** Lane-added numbered files (base...HEAD), with their blob ids. */
19
- async function laneNumbered(git, base) {
33
+ async function laneNumbered(git, base, ref = "HEAD") {
20
34
  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));
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));
22
36
  const res = [];
23
- for (const path of added) res.push({ path, blob: (await g(["rev-parse", `HEAD:${path}`])).trim() });
37
+ for (const path of added) res.push({ path, blob: (await g(["rev-parse", `${ref}:${path}`])).trim() });
24
38
  return res;
25
39
  }
26
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
+
27
56
  /**
28
57
  * Collisions: in each directory where a lane-added numbered file shares its number with a different base file (another
29
58
  * name, or the same path with other content), every lane-added numbered file there must move, in order, above the base's
30
59
  * highest number. @returns {Promise<Array<{dir, number, lane, base:string[], next}>>} (base [] = moves only to keep order)
31
60
  */
32
- export async function sequenceCollisions({ git, base }) {
61
+ export async function sequenceCollisions({ git, base, ref = "HEAD" }) {
33
62
  try {
34
63
  const g = out(git);
35
- const lane = await laneNumbered(git, base);
64
+ const lane = await laneNumbered(git, base, ref);
36
65
  const res = [];
37
66
  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));
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;
39
70
  const theirs = [];
40
71
  for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", base, ...inDir(dir)]))) {
41
72
  const m = /^\d+\s+blob\s+(\S+)\t(.+)$/.exec(l);
@@ -80,23 +111,275 @@ export async function journalProblems({ git, ref = "HEAD", dirs = [] }) {
80
111
  return problems;
81
112
  }
82
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
+
83
126
  /** Lane-added numbered files with their blobs, recorded before a renumber so verifyRenumber can prove the SQL kept. */
84
127
  export const snapshotLane = ({ git, base }) => laneNumbered(git, base).catch(() => []);
85
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
+
86
149
  /**
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}>}
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}>}
89
156
  */
90
- export async function verifyRenumber({ git, base, before = [] }) {
157
+ export async function verifyRenumber({ git, base, before = [], moves = null }) {
91
158
  try {
92
- const problems = [];
159
+ const g = out(git), problems = [];
93
160
  const left = await sequenceCollisions({ git, base });
94
161
  for (const c of left.filter((x) => x.base.length)) problems.push(`${c.lane} still shares number ${c.number} with ${c.base.join(", ")}`);
95
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
+ });
96
168
  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)`);
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)`);
98
184
  }
99
185
  problems.push(...await journalProblems({ git, dirs: [...new Set([...before, ...now].map((f) => dirname(f.path)))] }));
100
186
  return { ok: !problems.length, problems };
101
187
  } catch (e) { return { ok: false, problems: [String(e?.message ?? e)] }; }
102
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
+ }