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,373 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Fixture: a drizzle-style migrations dir (NNNN_name.sql + meta/_journal.json + meta/NNNN_snapshot.json) where the base
4
+ // gains 0007 while the lane adds its own 0007 (#4322).
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { tmpDir } from "../tests/_tmp.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
12
+ import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
13
+
14
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
15
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
16
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
17
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
18
+ const journal = (...tags) => `${JSON.stringify({ version: "7", entries: tags.map((tag, idx) => ({ idx, tag, when: 1000 + idx })) }, null, 2)}\n`;
19
+
20
+ const USAGE_SQL = "CREATE TABLE usage (id int);\n";
21
+ /** main: 0006 init; lane (from main): 0007_usage; then main gains 0007_billing_reconcile. */
22
+ function fixture() {
23
+ const d = tmpDir("seq-");
24
+ sh(d, "init", "-q", "-b", "main");
25
+ put(d, { "db/drizzle/0006_init.sql": "CREATE TABLE t (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init"), "db/drizzle/meta/0006_snapshot.json": '{"id":"s6"}\n' });
26
+ commit(d, "init");
27
+ sh(d, "checkout", "-qb", "lane");
28
+ put(d, { "db/drizzle/0007_usage.sql": USAGE_SQL, "db/drizzle/meta/_journal.json": journal("0006_init", "0007_usage"), "db/drizzle/meta/0007_snapshot.json": '{"id":"lane7","prevId":"s6"}\n' });
29
+ commit(d, "lane migration");
30
+ sh(d, "checkout", "-q", "main");
31
+ put(d, { "db/drizzle/0007_billing_reconcile.sql": "CREATE TABLE billing (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init", "0007_billing_reconcile"), "db/drizzle/meta/0007_snapshot.json": '{"id":"s7","prevId":"s6"}\n' });
32
+ commit(d, "base migration");
33
+ sh(d, "checkout", "-q", "lane");
34
+ return d;
35
+ }
36
+
37
+ /** What the builder does when asked: merge the base, keep the base's journal/snapshot, rename the lane's SQL, regenerate. */
38
+ function renumber(d, { sql = null, tags = ["0006_init", "0007_billing_reconcile", "0008_usage"] } = {}) {
39
+ try { sh(d, "merge", "--no-edit", "main"); } catch { /* journal and snapshot conflict: resolved below */ }
40
+ sh(d, "checkout", "--theirs", "--", "db/drizzle/meta/_journal.json", "db/drizzle/meta/0007_snapshot.json");
41
+ sh(d, "mv", "db/drizzle/0007_usage.sql", "db/drizzle/0008_usage.sql");
42
+ if (sql) put(d, { "db/drizzle/0008_usage.sql": sql });
43
+ put(d, { "db/drizzle/meta/_journal.json": journal(...tags), "db/drizzle/meta/0008_snapshot.json": '{"id":"lane8","prevId":"s7"}\n' });
44
+ commit(d, "renumber");
45
+ }
46
+
47
+ describe("sequence-numbered migrations at hand-off (#4322)", () => {
48
+ it("detects the lane's 0007 against the base's 0007 (other name, and same snapshot path with other content) and proposes 0008", async () => {
49
+ const d = fixture();
50
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
51
+ assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]), [
52
+ ["db/drizzle/0007_usage.sql", ["db/drizzle/0007_billing_reconcile.sql"], "0008"],
53
+ ["db/drizzle/meta/0007_snapshot.json", ["db/drizzle/meta/0007_snapshot.json"], "0008"],
54
+ ]);
55
+ const p = sequencePrompt(c, "main");
56
+ assert.match(p, /git merge --no-edit origin\/main/);
57
+ assert.match(p, /db\/drizzle\/0007_usage\.sql -> number 0008 \(base has db\/drizzle\/0007_billing_reconcile\.sql\)/);
58
+ assert.match(p, /byte-identical[\s\S]*regenerate the snapshot and journal with the repo's own migration generator \(never hand- or text-merge the journal\)[\s\S]*strictly ordered/);
59
+ });
60
+
61
+ it("a correct renumber verifies: SQL unchanged, snapshot regenerated, journal strictly ordered, no collision left", async () => {
62
+ const d = fixture();
63
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
64
+ assert.deepEqual(before.map((f) => f.path), ["db/drizzle/0007_usage.sql", "db/drizzle/meta/0007_snapshot.json"]);
65
+ renumber(d);
66
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
67
+ assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before }), { ok: true, problems: [] });
68
+ });
69
+
70
+ it("an edited SQL body, an unordered journal, or no renumber at all fails verification", async () => {
71
+ let d = fixture(); let before = await snapshotLane({ git: gitIn(d), base: "main" });
72
+ renumber(d, { sql: "CREATE TABLE usage (id bigint);\n" });
73
+ assert.match((await verifyRenumber({ git: gitIn(d), base: "main", before })).problems.join("\n"), /0007_usage\.sql: content changed/);
74
+
75
+ d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
76
+ renumber(d, { tags: ["0006_init", "0008_usage", "0007_billing_reconcile"] });
77
+ const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
78
+ assert.equal(v.ok, false);
79
+ assert.match(v.problems.join("\n"), /_journal\.json: 0007_billing_reconcile not numbered after 0008_usage/);
80
+
81
+ d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
82
+ const left = await verifyRenumber({ git: gitIn(d), base: "main", before });
83
+ assert.match(left.problems.join("\n"), /0007_usage\.sql still shares number 0007 with db\/drizzle\/0007_billing_reconcile\.sql/);
84
+ });
85
+
86
+ it("a text-clean base merge still collides (no journal: e.g. app/migrations/0007_x.py), and later lane files move to keep the order", async () => {
87
+ const d = tmpDir("seq-");
88
+ sh(d, "init", "-q", "-b", "main");
89
+ put(d, { "app/migrations/0006_init.py": "x\n" }); commit(d, "init");
90
+ sh(d, "checkout", "-qb", "lane");
91
+ put(d, { "app/migrations/0007_a.py": "a\n", "app/migrations/0008_b.py": "b\n" }); commit(d, "lane");
92
+ sh(d, "checkout", "-q", "main");
93
+ put(d, { "app/migrations/0007_c.py": "c\n" }); commit(d, "base");
94
+ sh(d, "checkout", "-q", "lane");
95
+ sh(d, "merge", "--no-edit", "main");
96
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
97
+ assert.deepEqual(c.map((x) => [x.lane, x.base.length, x.next]), [["app/migrations/0007_a.py", 1, "0008"], ["app/migrations/0008_b.py", 0, "0009"]]);
98
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["app/migrations"] }), []);
99
+ });
100
+
101
+ it("regression guard: no numbered file added, or numbers above the base's, is no collision; a broken git never throws", async () => {
102
+ const d = fixture();
103
+ sh(d, "reset", "-q", "--hard", "main");
104
+ put(d, { "src/a.ts": "x\n", "db/drizzle/0008_new.sql": "y\n" }); commit(d, "lane2");
105
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
106
+ const bad = async () => { throw new Error("boom"); };
107
+ assert.deepEqual(await sequenceCollisions({ git: bad, base: "main" }), []);
108
+ assert.equal(sequenceSyncNote(["src/a.ts", "package.json"]), "");
109
+ assert.match(sequenceSyncNote(["db/drizzle/meta/_journal.json"]), /do not text-merge/);
110
+ assert.match(sequenceSyncNote(["db/drizzle/0007_usage.sql"]), /Renumber the lane's files/);
111
+ });
112
+
113
+ /** A base commit with `baseFiles`, a lane commit adding `laneFiles`, then `laterBase` on main; returns the repo on the lane. */
114
+ const twoCommits = (baseFiles, laneFiles, laterBase = null) => {
115
+ const d = tmpDir("seq-");
116
+ sh(d, "init", "-q", "-b", "main");
117
+ put(d, baseFiles); commit(d, "base");
118
+ sh(d, "checkout", "-qb", "lane");
119
+ put(d, laneFiles); commit(d, "lane");
120
+ if (laterBase) { sh(d, "checkout", "-q", "main"); put(d, laterBase); commit(d, "later base"); sh(d, "checkout", "-q", "lane"); }
121
+ return d;
122
+ };
123
+
124
+ it("date and issue-number filename prefixes are not sequence numbers (#4343)", async () => {
125
+ const d = twoCommits(
126
+ { "blog/2026-10-05-first.md": "a\n", "news/20261005-a.md": "a\n", "docs/4322-a.md": "a\n", "docs2/2447-a.md": "a\n", "docs2/4160-b.md": "b\n" },
127
+ { "blog/2026-10-20-second.md": "b\n", "news/20261020-b.md": "b\n", "docs/4322-b.md": "b\n", "docs2/4160-c.md": "c\n" },
128
+ );
129
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
130
+ assert.deepEqual(await snapshotLane({ git: gitIn(d), base: "main" }).then((s) => s.map((f) => f.path).sort()), ["docs/4322-b.md", "docs2/4160-c.md"]);
131
+ assert.equal(sequenceSyncNote(["blog/2026-10-05-first.md"]), "");
132
+ assert.equal(sequenceSyncNote(["news/20261005-a.md"]), "");
133
+ });
134
+
135
+ it("real series still collide: SQL, Python, drizzle with a single base migration plus journal (#4343)", async () => {
136
+ const d = twoCommits(
137
+ {
138
+ "db/0006_a.sql": "a\n", "db/0007_other.sql": "o\n",
139
+ "app/migrations/0001_initial.py": "i\n", "app/migrations/0002_x.py": "x\n", "app/migrations/__init__.py": "",
140
+ "drizzle/0000_init.sql": "CREATE TABLE t (id int);\n", "drizzle/meta/_journal.json": journal("0000_init"),
141
+ },
142
+ { "db/0007_usage.sql": "u\n", "app/migrations/0002_y.py": "y\n", "drizzle/0000_x.sql": "CREATE TABLE x (id int);\n", "drizzle/meta/0000_snapshot.json": '{"id":"lane0"}\n' },
143
+ { "drizzle/meta/0000_snapshot.json": '{"id":"s0"}\n' },
144
+ );
145
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
146
+ assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]).sort((a, b) => a[0].localeCompare(b[0])), [
147
+ ["app/migrations/0002_y.py", ["app/migrations/0002_x.py"], "0003"],
148
+ ["db/0007_usage.sql", ["db/0007_other.sql"], "0008"],
149
+ ["drizzle/0000_x.sql", ["drizzle/0000_init.sql"], "0001"],
150
+ ["drizzle/meta/0000_snapshot.json", ["drizzle/meta/0000_snapshot.json"], "0001"],
151
+ ]);
152
+ });
153
+ });
154
+
155
+ describe("deterministic renumber (#4386)", () => {
156
+ /** main gets `baseFiles`, the lane (from main) adds `laneFiles`, then main gets `laterBase`; returns the repo on the lane. */
157
+ const repo = (baseFiles, laneFiles, laterBase) => {
158
+ const d = tmpDir("seq4386-");
159
+ sh(d, "init", "-q", "-b", "main");
160
+ put(d, baseFiles); commit(d, "base");
161
+ sh(d, "checkout", "-qb", "lane");
162
+ put(d, laneFiles); commit(d, "lane");
163
+ sh(d, "checkout", "-q", "main"); put(d, laterBase); commit(d, "later base");
164
+ sh(d, "checkout", "-q", "lane");
165
+ return d;
166
+ };
167
+ const show = (d, p) => sh(d, "show", `HEAD:${p}`);
168
+ const snap = (id, prevId, tables = { t: 1 }) => `${JSON.stringify({ version: "7", id, prevId, tables }, null, 2)}\n`;
169
+ const NCLT_SQL = "INSERT INTO adapters (name) VALUES ('nclt');\n";
170
+ /** base 0048; the lane adds a data-only 0049_nclt; then the base takes 0049-0051. */
171
+ const drizzle = ({ laneTables = { t: 1 } } = {}) => repo(
172
+ { "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") },
173
+ { "db/0049_nclt.sql": NCLT_SQL, "db/meta/_journal.json": journal("0048_init", "0049_nclt"), "db/meta/0049_snapshot.json": snap("lane49", "s48", laneTables) },
174
+ {
175
+ "db/0049_a.sql": "a;\n", "db/0050_b.sql": "b;\n", "db/0051_c.sql": "CREATE TABLE u (id int);\n",
176
+ "db/meta/_journal.json": journal("0048_init", "0049_a", "0050_b", "0051_c"),
177
+ "db/meta/0049_snapshot.json": snap("s49", "s48"), "db/meta/0050_snapshot.json": snap("s50", "s49"), "db/meta/0051_snapshot.json": snap("s51", "s50", { t: 1, u: 1 }),
178
+ },
179
+ );
180
+
181
+ it("verifyRenumber accepts an ADR whose only change is its own heading number, and an index row change (#4386)", async () => {
182
+ const adr = (body) => repo(
183
+ { "docs/adr/0015-a.md": "# 0015. A\n", "docs/adr/0016-b.md": "# 0016. B\n", "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n" },
184
+ { "docs/adr/0017-nclt.md": "# 0017. NCLT adapter\n\nWe adopt 0017-style adapters.\n", "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n| 0017 | [NCLT](0017-nclt.md) |\n" },
185
+ { "docs/adr/0017-other.md": "# 0017. Other\n", "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n| 0017 | Other |\n" },
186
+ );
187
+ for (const [body, ok] of [["We adopt 0017-style adapters.", true], ["We adopt other adapters.", false]]) {
188
+ const d = adr();
189
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
190
+ try { sh(d, "merge", "--no-edit", "main"); } catch { /* README conflict: resolved below */ }
191
+ sh(d, "mv", "-f", "docs/adr/0017-nclt.md", "docs/adr/0018-nclt.md");
192
+ put(d, { "docs/adr/0018-nclt.md": `# 0018. NCLT adapter\n\n${body}\n`, "docs/adr/README.md": "| 0015 | A |\n| 0016 | B |\n| 0017 | Other |\n| 0018 | [NCLT](0018-nclt.md) |\n" });
193
+ commit(d, "renumber");
194
+ const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
195
+ assert.equal(v.ok, ok, v.problems.join("; "));
196
+ if (!ok) assert.match(v.problems.join("\n"), /docs\/adr\/0017-nclt\.md: content changed/);
197
+ }
198
+ });
199
+
200
+ it("base takes 0049-0051 while the lane holds 0049: renumberLane moves it to 0052, clones the tip snapshot, appends the journal, and the lane merges", async () => {
201
+ const d = drizzle();
202
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
203
+ const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, uuid: () => "fresh-id" });
204
+ assert.deepEqual(r.moves, [{ from: "db/0049_nclt.sql", to: "db/0052_nclt.sql" }]);
205
+ assert.equal(r.commit, sh(d, "rev-parse", "HEAD").trim());
206
+ assert.equal(show(d, "db/0052_nclt.sql"), NCLT_SQL, "SQL byte-identical");
207
+ assert.ok(!existsSync(join(d, "db/0049_nclt.sql")));
208
+ assert.equal(show(d, "db/meta/0049_snapshot.json"), snap("s49", "s48"), "base snapshot restored at the shared path");
209
+ const base51 = JSON.parse(show(d, "db/meta/0051_snapshot.json")), mine = JSON.parse(show(d, "db/meta/0052_snapshot.json"));
210
+ assert.deepEqual(mine, { ...base51, id: "fresh-id", prevId: "s51" });
211
+ const j = JSON.parse(show(d, "db/meta/_journal.json"));
212
+ const baseJ = JSON.parse(sh(d, "show", "main:db/meta/_journal.json"));
213
+ assert.deepEqual(j.entries.slice(0, 4), baseJ.entries);
214
+ assert.deepEqual(j.entries[4], { idx: 4, tag: "0052_nclt", when: 1001 });
215
+ assert.deepEqual(j.entries.map((e) => e.idx), [0, 1, 2, 3, 4]);
216
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
217
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
218
+ assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before, moves: r.moves }), { ok: true, problems: [] });
219
+ sh(d, "checkout", "-q", "main");
220
+ sh(d, "merge", "--no-edit", "lane"); // the lane merges into its base without conflicts
221
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
222
+ });
223
+
224
+ it("references to the old basename are rewritten repo-wide (a link from another ADR, a code comment) and the index keeps both rows", async () => {
225
+ const d = repo(
226
+ { "docs/adr/0015-a.md": "# 0015. A\n", "docs/adr/0016-b.md": "# 0016. B\n", "docs/adr/0017-c.md": "# 0017. C\n", "docs/adr/0018-d.md": "# 0018. D\n", "docs/adr/README.md": "# ADRs\n\n- 0018 D\n", "src/x.ts": "export const x = 1;\n" },
227
+ { "docs/adr/0019-nclt.md": "# 0019. NCLT\n\nSupersedes nothing.\n", "docs/adr/0015-a.md": "# 0015. A\n\nSee [NCLT](0019-nclt.md).\n", "docs/adr/README.md": "# ADRs\n\n- 0018 D\n- [0019 NCLT](0019-nclt.md)\n", "src/x.ts": "// docs/adr/0019-nclt.md\nexport const x = 1;\n" },
228
+ { "docs/adr/0019-other.md": "# 0019. Other\n", "docs/adr/README.md": "# ADRs\n\n- 0018 D\n- 0019 Other\n" },
229
+ );
230
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
231
+ const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d });
232
+ assert.deepEqual(r.moves, [{ from: "docs/adr/0019-nclt.md", to: "docs/adr/0020-nclt.md" }]);
233
+ assert.equal(show(d, "docs/adr/0020-nclt.md"), "# 0020. NCLT\n\nSupersedes nothing.\n");
234
+ assert.equal(show(d, "docs/adr/0015-a.md"), "# 0015. A\n\nSee [NCLT](0020-nclt.md).\n");
235
+ assert.equal(show(d, "src/x.ts"), "// docs/adr/0020-nclt.md\nexport const x = 1;\n");
236
+ const idx = show(d, "docs/adr/README.md");
237
+ assert.match(idx, /0019 Other/);
238
+ assert.match(idx, /\(0020-nclt\.md\)/);
239
+ assert.doesNotMatch(idx, /<<<<<<<|0019-nclt/);
240
+ assert.equal(sh(d, "status", "--porcelain").trim(), "");
241
+ assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before, moves: r.moves }), { ok: true, problems: [] });
242
+ });
243
+
244
+ it("falls back (unsupported, branch unchanged) for a schema-changing migration without regenerate or a .py migration; a regenerate command fills the snapshot", async () => {
245
+ let d = drizzle({ laneTables: { t: 1, nclt: 1 } });
246
+ let head = sh(d, "rev-parse", "HEAD");
247
+ let r = await renumberLane({ git: gitIn(d), base: "main", cwd: d });
248
+ assert.match(r.unsupported, /schema-changing migration db\/0049_nclt\.sql needs lean\.sequence\.regenerate/);
249
+ assert.equal(sh(d, "rev-parse", "HEAD"), head);
250
+ assert.equal(sh(d, "status", "--porcelain").trim(), "");
251
+ const calls = [];
252
+ r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, regenerate: "pnpm db:generate", run: async (cmd, o) => { calls.push(cmd); writeFileSync(join(o.cwd, "db/meta/0052_snapshot.json"), snap("gen", "s51", { t: 1, u: 1, nclt: 1 })); } });
253
+ assert.ok(r.ok, r.unsupported);
254
+ assert.deepEqual(calls, ["pnpm db:generate"]);
255
+ assert.equal(JSON.parse(show(d, "db/meta/0052_snapshot.json")).id, "gen");
256
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
257
+ d = repo({ "app/migrations/0001_initial.py": "i\n", "app/migrations/0002_x.py": "x\n" }, { "app/migrations/0003_y.py": "y\n" }, { "app/migrations/0003_z.py": "z\n" });
258
+ head = sh(d, "rev-parse", "HEAD");
259
+ r = await renumberLane({ git: gitIn(d), base: "main", cwd: d });
260
+ assert.match(r.unsupported, /app\/migrations\/0003_y\.py: no deterministic renumber for \.py migrations/);
261
+ assert.equal(sh(d, "rev-parse", "HEAD"), head);
262
+ });
263
+
264
+ it("resolveSequenceConflicts takes the base side for journal/snapshot conflicts, then renumberLane from the pre-merge head gives a valid chain; another conflicted file is unsupported", async () => {
265
+ let d = drizzle();
266
+ const pre = sh(d, "rev-parse", "HEAD").trim();
267
+ assert.throws(() => sh(d, "merge", "--no-edit", "main"));
268
+ const files = sh(d, "diff", "--name-only", "--diff-filter=U").trim().split("\n");
269
+ assert.deepEqual(files.sort(), ["db/meta/0049_snapshot.json", "db/meta/_journal.json"]);
270
+ assert.deepEqual(await resolveSequenceConflicts({ git: gitIn(d), files }), { ok: true });
271
+ assert.ok(!existsSync(join(d, ".git", "MERGE_HEAD")), "merge committed");
272
+ const r = await renumberLane({ git: gitIn(d), base: "main", cwd: d, laneRef: pre, uuid: () => "u52" });
273
+ assert.deepEqual(r.moves, [{ from: "db/0049_nclt.sql", to: "db/0052_nclt.sql" }]);
274
+ assert.deepEqual(JSON.parse(show(d, "db/meta/_journal.json")).entries.map((e) => e.tag), ["0048_init", "0049_a", "0050_b", "0051_c", "0052_nclt"]);
275
+ assert.equal(JSON.parse(show(d, "db/meta/0052_snapshot.json")).prevId, "s51");
276
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["db"] }), []);
277
+ d = repo({ "db/0001_a.sql": "a\n", "db/0002_b.sql": "b\n", "db/meta/_journal.json": journal("0001_a", "0002_b"), "src/a.ts": "1\n" },
278
+ { "db/0003_c.sql": "c\n", "db/meta/_journal.json": journal("0001_a", "0002_b", "0003_c"), "src/a.ts": "2\n" },
279
+ { "db/0003_d.sql": "d\n", "db/meta/_journal.json": journal("0001_a", "0002_b", "0003_d"), "src/a.ts": "3\n" });
280
+ assert.throws(() => sh(d, "merge", "--no-edit", "main"));
281
+ const f2 = sh(d, "diff", "--name-only", "--diff-filter=U").trim().split("\n");
282
+ assert.deepEqual(await resolveSequenceConflicts({ git: gitIn(d), files: f2 }), { unsupported: "non-sequence conflict: src/a.ts" });
283
+ assert.ok(readFileSync(join(d, "src/a.ts"), "utf8").includes("<<<<<<<"), "left for the builder");
284
+ });
285
+
286
+ it("#4465: a numbered file both sides edited, or one outside a series, is not a sequence conflict: unsupported, the lane's edit kept for the builder", async () => {
287
+ const conflict = async (base, lane, later, file) => {
288
+ const d = repo(base, lane, later);
289
+ assert.throws(() => sh(d, "merge", "--no-edit", "main"));
290
+ const files = sh(d, "diff", "--name-only", "--diff-filter=U").trim().split("\n");
291
+ assert.deepEqual(files, [file]);
292
+ assert.deepEqual(await resolveSequenceConflicts({ git: gitIn(d), files }), { unsupported: `non-sequence conflict: ${file}` });
293
+ assert.ok(existsSync(join(d, ".git", "MERGE_HEAD")), "merge not committed");
294
+ assert.match(readFileSync(join(d, file), "utf8"), /<<<<<<<[\s\S]*LANE[\s\S]*BASE/, "lane side still present");
295
+ };
296
+ // both edit an existing NNN- source file (no series)
297
+ await conflict({ "src/404-page.tsx": "a\nb\nc\n" }, { "src/404-page.tsx": "a\nLANE\nc\n" }, { "src/404-page.tsx": "a\nBASE\nc\n" }, "src/404-page.tsx");
298
+ // both edit an ADR inside a real series: renumberLane only restores lane-added files
299
+ const adrs = { "docs/adr/0001-one.md": "# 1. One\n", "docs/adr/0002-two.md": "# 2. Two\nx\n", "docs/adr/0003-three.md": "# 3. Three\n" };
300
+ await conflict(adrs, { "docs/adr/0002-two.md": "# 2. Two\nLANE\n" }, { "docs/adr/0002-two.md": "# 2. Two\nBASE\n" }, "docs/adr/0002-two.md");
301
+ // both add the same numbered name in a directory without a series
302
+ await conflict({ "src/a.ts": "1\n" }, { "src/500_error.html": "LANE\n" }, { "src/500_error.html": "BASE\n" }, "src/500_error.html");
303
+ });
304
+ });
305
+
306
+ describe("sequence-numbered documents: two siblings each add an ADR (#4385)", () => {
307
+ const INDEX = "# ADRs\n\n- [0001](0001-one.md)\n- [0002](0002-two.md)\n- [0003](0003-three.md)\n";
308
+ /** main: docs/adr/0001..0003 + README index; the lane adds 0004-b.md; main then merges a sibling's 0004-a.md. */
309
+ function adrFixture() {
310
+ const d = tmpDir("seq-adr-");
311
+ sh(d, "init", "-q", "-b", "main");
312
+ put(d, { "docs/adr/0001-one.md": "# 1. One\n", "docs/adr/0002-two.md": "# 2. Two\n", "docs/adr/0003-three.md": "# 3. Three\n", "docs/adr/README.md": INDEX });
313
+ commit(d, "init");
314
+ sh(d, "checkout", "-qb", "lane");
315
+ put(d, { "docs/adr/0004-b.md": "# 4. Use B\n\nStatus: accepted (ADR 0004)\n", "docs/adr/README.md": `${INDEX}- [0004](0004-b.md)\n` });
316
+ commit(d, "lane adr");
317
+ sh(d, "checkout", "-q", "main");
318
+ put(d, { "docs/adr/0004-a.md": "# 4. Use A\n", "docs/adr/README.md": `${INDEX}- [0004](0004-a.md)\n` });
319
+ commit(d, "sibling adr");
320
+ sh(d, "checkout", "-q", "lane");
321
+ return d;
322
+ }
323
+ /** The builder: merge the base (the index conflicts), rename to 0005, fix its own title, write the index. */
324
+ function renumberAdr(d, { title = "# 5. Use B\n\nStatus: accepted (ADR 0005)\n", index = `${INDEX}- [0004](0004-a.md)\n- [0005](0005-b.md)\n` } = {}) {
325
+ try { sh(d, "merge", "--no-edit", "main"); } catch { /* README.md conflicts: written below */ }
326
+ sh(d, "mv", "docs/adr/0004-b.md", "docs/adr/0005-b.md");
327
+ put(d, { "docs/adr/0005-b.md": title, "docs/adr/README.md": index });
328
+ commit(d, "renumber adr");
329
+ }
330
+
331
+ it("the lane's ADR collides with the sibling's 0004 and moves to 0005", async () => {
332
+ const d = adrFixture();
333
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
334
+ assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]), [["docs/adr/0004-b.md", ["docs/adr/0004-a.md"], "0005"]]);
335
+ });
336
+
337
+ it("a renumber that changes only the ADR's own number and lists both in the index verifies: distinct numbers, index correct", async () => {
338
+ const d = adrFixture();
339
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
340
+ renumberAdr(d);
341
+ const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
342
+ assert.deepEqual(v, { ok: true, problems: [] });
343
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
344
+ });
345
+
346
+ it("an index still linking the old name, or a body change beyond the number, fails verification", async () => {
347
+ const d = adrFixture();
348
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
349
+ renumberAdr(d, { index: `${INDEX}- [0004](0004-a.md)\n- [0004](0004-b.md)\n` });
350
+ const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
351
+ assert.equal(v.ok, false);
352
+ assert.ok(v.problems.some((p) => /docs\/adr\/README\.md: still references 0004-b/.test(p)), v.problems.join("\n"));
353
+ const d2 = adrFixture();
354
+ const before2 = await snapshotLane({ git: gitIn(d2), base: "main" });
355
+ renumberAdr(d2, { title: "# 5. Use C instead\n\nStatus: accepted (ADR 0005)\n" });
356
+ const v2 = await verifyRenumber({ git: gitIn(d2), base: "main", before: before2 });
357
+ assert.equal(v2.ok, false);
358
+ assert.ok(v2.problems.some((p) => /0004-b\.md: content changed/.test(p)), v2.problems.join("\n"));
359
+ });
360
+
361
+ it("numberOnlyChange accepts padded and unpadded own-number edits only", () => {
362
+ assert.equal(numberOnlyChange("# 16. X (0016)\nsee 160 and 16x\n", "# 17. X (0017)\nsee 160 and 16x\n", "0016", "0017"), true);
363
+ assert.equal(numberOnlyChange("# 16. X\n", "# 17. Y\n", "0016", "0017"), false);
364
+ assert.equal(numberOnlyChange("CREATE TABLE t;\n", "CREATE TABLE t;\n", "0007", "0008"), true);
365
+ assert.equal(numberOnlyChange("a\n", "b\n", null, "0008"), false);
366
+ });
367
+
368
+ it("the renumber prompt names numbered documents and the series index", () => {
369
+ const p = sequencePrompt([{ dir: "docs/adr", number: "0004", lane: "docs/adr/0004-b.md", base: ["docs/adr/0004-a.md"], next: "0005" }], "staging");
370
+ assert.match(p, /ADR/);
371
+ assert.match(p, /series index/);
372
+ });
373
+ });
@@ -6,7 +6,8 @@
6
6
  * command) picks the checks for the lane's changed files; `.forge/repo-facts.json` (scripts/repo-facts.sh) is cited to
7
7
  * the builder by path. The test map's commands are run, so it is read from the base ref, then the main checkout, never
8
8
  * the builder-writable lane worktree (#4297); repo facts are looked up in the worktree, then the main checkout. A missing
9
- * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts.
9
+ * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts. With a map, a learned
10
+ * command the map covers runs only when its globs match a changed file (`scopeChecks`, #4331).
10
11
  *
11
12
  * node bin/lean/testmap.mjs --map FILE --select FILE... print the selected commands, one per line
12
13
  */
@@ -52,21 +53,32 @@ export function selectCommands(map, files) {
52
53
  for (const e of map?.entries ?? []) {
53
54
  const res = e.globs.map((g) => globToRegex(g.replace(/^\.\//, "")));
54
55
  if (!changed.some((f) => res.some((re) => re.test(f)))) continue;
55
- const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
56
- const cmd = cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
56
+ const cmd = entryCommand(e);
57
57
  if (!out.includes(cmd)) out.push(cmd);
58
58
  }
59
59
  return out;
60
60
  }
61
61
 
62
+ /**
63
+ * #4318: the dedupe key of a check command: whitespace collapsed, a leading `cd . && ` dropped, `<pm> run test` read as
64
+ * `<pm> test` (npm/pnpm/yarn/bun) and an `npx`/`bunx [--yes|-y]` prefix dropped, so one suite under two spellings runs once.
65
+ */
66
+ export function checkKey(c) {
67
+ let k = String(c ?? "").trim().replace(/\s+/g, " ");
68
+ k = k.replace(/^cd \.\/? && /, "");
69
+ const cd = /^((?:cd|pushd) \S+ && )?(.*)$/.exec(k), pre = cd[1] ?? "";
70
+ const cmd = cd[2].replace(/^(?:npx|bunx)(?: (?:--yes|-y))? /, "").replace(/^(npm|pnpm|yarn|bun) run test(?= |$)/, "$1 test");
71
+ return pre + cmd;
72
+ }
73
+
62
74
  /**
63
75
  * #4315: the pre-PR checks are the learned commands (in order) plus each map-selected command they lack; compared
64
- * whitespace-collapsed, so the map can only add checks, never remove one. `source`: "learned" when the map added
65
- * nothing, "test-map" when there were no learned commands, else "test-map+learned".
76
+ * by `checkKey` (#4318), so the map can only add checks, never remove one; the first spelling seen is kept. `source`:
77
+ * "learned" when the map added nothing, "test-map" when there were no learned commands, else "test-map+learned".
66
78
  * @returns {{commands:string[], source:string, added:number}}
67
79
  */
68
80
  export function mergeChecks(selected, learned) {
69
- const norm = (c) => String(c ?? "").trim().replace(/\s+/g, " ");
81
+ const norm = checkKey;
70
82
  const commands = [], seen = new Set();
71
83
  const add = (c) => { const t = String(c ?? "").trim(), k = norm(t); if (!k || seen.has(k)) return false; seen.add(k); commands.push(t); return true; };
72
84
  for (const c of Array.isArray(learned) ? learned : []) add(c);
@@ -76,6 +88,31 @@ export function mergeChecks(selected, learned) {
76
88
  return { commands, added, source: !added ? "learned" : base ? "test-map+learned" : "test-map" };
77
89
  }
78
90
 
91
+ /**
92
+ * #4331: change-scoped pre-PR checks. A learned command the map covers (an entry's command, with its `cd <cwd> && `
93
+ * prefix, has the same `checkKey`) runs only when one of those entries has a glob matching a changed file; a learned
94
+ * command with no map entry always runs (the safe default); map-selected commands the learned list lacks are added
95
+ * as in `mergeChecks`. No map: the learned list unchanged. `source` is "learned" when the map added and dropped
96
+ * nothing, "test-map" when there were no learned commands, else "test-map+learned".
97
+ * @returns {{commands:string[], source:string, added:number, dropped:string[]}}
98
+ */
99
+ export function scopeChecks(map, files, learned) {
100
+ const list = Array.isArray(learned) ? learned : [];
101
+ if (!map) { const m = mergeChecks([], list); return { ...m, dropped: [] }; }
102
+ const selected = selectCommands(map, files), hit = new Set(selected.map(checkKey));
103
+ const mapped = new Set((map.entries ?? []).map((e) => checkKey(entryCommand(e))));
104
+ const dropped = [], kept = [];
105
+ for (const c of list) { const k = checkKey(c); if (!k) continue; (mapped.has(k) && !hit.has(k) ? dropped : kept).push(String(c).trim()); }
106
+ const m = mergeChecks(selected, kept);
107
+ return { commands: m.commands, added: m.added, dropped, source: !m.added && !dropped.length ? "learned" : kept.length || dropped.length ? "test-map+learned" : "test-map" };
108
+ }
109
+
110
+ /** An entry's command as `selectCommands` emits it (`cd <cwd> && cmd` for a non-root cwd). */
111
+ function entryCommand(e) {
112
+ const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
113
+ return cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
114
+ }
115
+
79
116
  /** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
80
117
  export async function factsPath(dirs, exists) {
81
118
  if (typeof exists !== "function") return null;
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { parseTestMap, readTestMap, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
5
+ import { parseTestMap, readTestMap, selectCommands, mergeChecks, scopeChecks, checkKey, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
6
6
 
7
7
  const MAP = {
8
8
  version: 1,
@@ -71,3 +71,40 @@ describe("mergeChecks (#4315): the map can only add checks", () => {
71
71
  assert.deepEqual(mergeChecks(undefined, undefined), { commands: [], added: 0, source: "learned" });
72
72
  });
73
73
  });
74
+
75
+ describe("mergeChecks (#4318): one suite under two spellings runs once", () => {
76
+ it("collapses <pm> run test / <pm> test, npx [--yes] X / X and cd . && X / X, keeping the first spelling", () => {
77
+ assert.deepEqual(mergeChecks(["pnpm test", "vitest", "make lint"], ["pnpm run test", "npx --yes vitest", "cd . && make lint"]),
78
+ { commands: ["pnpm run test", "npx --yes vitest", "cd . && make lint"], added: 0, source: "learned" });
79
+ assert.deepEqual(mergeChecks(["cd pkg && npx vitest run", "cd other && vitest run"], ["cd pkg && vitest run"]).commands, ["cd pkg && vitest run", "cd other && vitest run"]);
80
+ });
81
+ it("checkKey keeps different suites apart", () => {
82
+ assert.notEqual(checkKey("npm run test:unit"), checkKey("npm test"));
83
+ assert.notEqual(checkKey("cd a && npm test"), checkKey("npm test"));
84
+ assert.equal(checkKey("bunx -y jest"), "jest");
85
+ });
86
+ });
87
+
88
+ describe("scopeChecks (#4331): a mapped learned command runs only when its globs match", () => {
89
+ const map = { version: 1, entries: [
90
+ { globs: ["commands/**"], command: "bash scripts/check-a.sh", cwd: "." },
91
+ { globs: ["bin/**"], command: "bash scripts/check-b.sh", cwd: "." },
92
+ { globs: ["src/**"], command: "npm test", cwd: "pkg" },
93
+ { globs: ["**/*.py"], command: "uv run pytest", cwd: "." }] };
94
+ const learned = ["bash scripts/check-a.sh", "bash scripts/check-b.sh", "make lint"];
95
+ it("a lane changing only commands/foo.md runs the matching mapped commands plus the unmapped ones", () => {
96
+ assert.deepEqual(scopeChecks(map, ["commands/foo.md"], learned),
97
+ { commands: ["bash scripts/check-a.sh", "make lint"], added: 0, dropped: ["bash scripts/check-b.sh"], source: "test-map+learned" });
98
+ });
99
+ it("no map keeps the learned list; nothing added or dropped is source learned", () => {
100
+ assert.deepEqual(scopeChecks(null, ["commands/foo.md"], learned), { commands: learned, added: 0, dropped: [], source: "learned" });
101
+ assert.deepEqual(scopeChecks(map, ["commands/foo.md"], ["bash scripts/check-a.sh", "make lint"]).source, "learned");
102
+ });
103
+ it("matches by checkKey and by the entry's cwd prefix; map-selected commands are added", () => {
104
+ const r = scopeChecks(map, ["src/x.js", "a.py"], ["cd . && bash scripts/check-a.sh", "cd pkg && npm run test", "npm test"]);
105
+ assert.deepEqual(r.dropped, ["cd . && bash scripts/check-a.sh"]);
106
+ assert.deepEqual(r.commands, ["cd pkg && npm run test", "npm test", "uv run pytest"]);
107
+ assert.equal(r.source, "test-map+learned");
108
+ assert.deepEqual(scopeChecks(map, ["a.py"], []), { commands: ["uv run pytest"], added: 1, dropped: [], source: "test-map" });
109
+ });
110
+ });