forgedock 1.29.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +102 -38
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -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 { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, explainFailures } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs } from "./conformance.mjs";
6
6
 
7
7
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
8
8
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -72,6 +72,31 @@ describe("precheck (deterministic, no reviewer)", () => {
72
72
  assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 1 }] }, changedFiles: files })), ["conformance.unimplemented"]);
73
73
  assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files })), ["conformance.unimplemented"]);
74
74
  });
75
+ it("#4323 a failed validate whose rerun could not find a host tool or module is env.tool_missing naming it, not conformance", () => {
76
+ const failed = { ...res, validation: [{ id: "V1", exit: 1 }] };
77
+ const pc = precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 1, output: "Traceback...\nModuleNotFoundError: No module named 'yaml'" } } });
78
+ assert.deepEqual(codes(pc), ["env.tool_missing"]);
79
+ assert.equal(pc.failures[0].kind, "validate_env"); assert.equal(pc.failures[0].tool, "yaml");
80
+ assert.match(pc.failures[0].detail, /could not run: the host is missing Python module 'yaml'/);
81
+ assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 127, output: "bash: line 1: pytest: command not found" } } })), ["env.tool_missing"]);
82
+ assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, ciVerified: true, validateRuns: { V1: { rc: 127, output: "bash: pytest: command not found" } } })), [], "CI-verified head: like a validate left to CI");
83
+ assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 0, output: "" } } })), [], "a passing rerun counts as passed");
84
+ assert.deepEqual(codes(precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 1, output: "AssertionError: 1 !== 2" } } })), ["conformance.unimplemented"]);
85
+ const local = precheck({ contract: C, result: failed, changedFiles: files, repoHas: repoTracks(["src/leak/__init__.py"]), validateRuns: { V1: { rc: 1, output: "ModuleNotFoundError: No module named 'leak.core'" } } });
86
+ assert.deepEqual(codes(local), ["conformance.unimplemented"], "a repo module the change did not create is a code defect");
87
+ });
88
+ it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
89
+ assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
90
+ assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
91
+ assert.deepEqual(missingTool({ rc: 1, output: "ImportError: No module named yaml.constructor" }), { tool: "yaml", kind: "python-module" });
92
+ assert.deepEqual(missingTool({ rc: 1, output: "Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'zod' imported from /x/a.mjs" }), { tool: "zod", kind: "node-module" });
93
+ assert.deepEqual(missingTool({ rc: 1, output: "Error: Cannot find module '@scope/pkg/sub'" }), { tool: "@scope/pkg", kind: "node-module" });
94
+ assert.equal(missingTool({ rc: 1, output: "Error: Cannot find module './lib/new.js'" }), null);
95
+ assert.equal(missingTool({ rc: 1, output: "Error: Cannot find module '/repo/src/x.mjs'" }), null);
96
+ assert.equal(missingTool({ rc: 127, output: "bash: ./scripts/check.sh: No such file or directory" }, { command: "./scripts/check.sh" }), null);
97
+ assert.equal(missingTool({ rc: 1, output: "ModuleNotFoundError: No module named 'app'" }, { repoHas: repoTracks(["app.py"]) }), null);
98
+ assert.equal(missingTool({ rc: 1, output: "1 test failed: expected not found" }), null, "rc != 127 needs command not found");
99
+ });
75
100
  it("a deliver requirement reported deferred without an amendment -> untracked_deferral; partial -> unimplemented", () => {
76
101
  const r = { ...res, requirements: [{ id: "R1", status: "deferred" }, { id: "R2", status: "partial" }] };
77
102
  assert.deepEqual(codes(precheck({ contract: C, result: r, changedFiles: files })), ["conformance.untracked_deferral", "conformance.unimplemented"]);
@@ -117,6 +142,30 @@ describe("conformance review: the engine computes the verdict from complete rows
117
142
  assert.equal(normalizeEvidencePath("b/route.ts", { changedFiles: ["app/a/route.ts", "app/b/route.ts"] }), "app/b/route.ts");
118
143
  assert.equal(decide(row("R1", { evidence: "scrape.js:7" }), ctx).review.requirements[0].evidence, "src/scrape.js:7");
119
144
  });
145
+ it("#4327 evidence paths with route-group, dynamic and catch-all segments are kept verbatim", () => {
146
+ const repo = ["apps/admin/app/(console)/layout.tsx", "apps/web/app/blog/[slug]/page.tsx", "apps/web/app/docs/[...slug]/page.tsx"];
147
+ const c = { ...ctx, repoFiles: repo, pathExists: (p) => repo.includes(p) };
148
+ for (const [ev, want] of [
149
+ ["`(console)/layout.tsx:12` + nav (sidebar)", "apps/admin/app/(console)/layout.tsx:12"],
150
+ ["`apps/admin/app/(console)/layout.tsx`:12", "apps/admin/app/(console)/layout.tsx:12"],
151
+ ["see apps/web/app/blog/[slug]/page.tsx:4", "apps/web/app/blog/[slug]/page.tsx:4"],
152
+ ["(apps/web/app/docs/[...slug]/page.tsx:9)", "apps/web/app/docs/[...slug]/page.tsx:9"],
153
+ ["[page](apps/web/app/docs/[...slug]/page.tsx:9)", "apps/web/app/docs/[...slug]/page.tsx:9"],
154
+ ]) {
155
+ const d = decide(row("R1", { evidence: ev }), c);
156
+ assert.equal(d.state, "conforms", ev); assert.equal(d.review.requirements[0].evidence, want, ev);
157
+ }
158
+ assert.deepEqual(evidencePathLine("(src/scrape.js:7), fixed"), { path: "src/scrape.js", line: "7" });
159
+ });
160
+ it("#4327 a missing route-group path is still missing; only path problems qualify for the extra re-ask", () => {
161
+ const c = { ...ctx, repoFiles: ["apps/admin/app/(console)/layout.tsx"], pathExists: (p) => p === "apps/admin/app/(console)/layout.tsx" };
162
+ const d = decide(row("R1", { evidence: "`apps/admin/app/(admin)/layout.tsx:12`" }), c);
163
+ assert.equal(d.state, "review.incomplete"); assert.deepEqual(d.missing, ["R1"]);
164
+ assert.match(d.failures[0].detail, /evidence path apps\/admin\/app\/\(admin\)\/layout\.tsx does not exist/);
165
+ assert.equal(evidencePathOnly(d.failures.map((f) => f.detail)), true);
166
+ assert.equal(evidencePathOnly(decide({ verdict: "PASS" }, ctx).failures.map((f) => f.detail)), false);
167
+ assert.equal(evidencePathOnly([]), false);
168
+ });
120
169
  it("#4160 a targeted re-ask merges by id: the repair rows complete the first reply", () => {
121
170
  const first = { ...good, requirements: [good.requirements[1]], preserve: [] };
122
171
  const d1 = decide(first, ctx);
@@ -176,6 +225,21 @@ describe("conformance review: the engine computes the verdict from complete rows
176
225
  assert.equal(movingBaseDiff("git diff HEAD~1"), null);
177
226
  assert.equal(movingBaseDiff(""), null);
178
227
  });
228
+ it("#4325 a validate or repro_test command reading a $TMPDIR file it does not create is rejected at freeze", () => {
229
+ const bad = validateContract({ ...C, validate: [{ id: "V4", command: "uv run --with-requirements $TMPDIR/api-req.txt pytest tests/x.py" }] }, ISSUE);
230
+ assert.equal(bad.ok, false);
231
+ assert.ok(bad.problems.some((x) => x.startsWith("V4: reads $TMPDIR/api-req.txt")), bad.problems.join("; "));
232
+ const rt = validateContract({ ...C, repro_test: { path: "t/x.py", command: "pytest -r ${TMPDIR}/req.txt t/x.py" } }, ISSUE, { repro: true });
233
+ assert.ok(rt.problems.some((x) => x.startsWith("repro_test: reads $TMPDIR/req.txt")), rt.problems.join("; "));
234
+ for (const ok of ["uv export > $TMPDIR/r.txt && uv run --with-requirements $TMPDIR/r.txt pytest", "pytest --basetemp=$TMPDIR/pt --junitxml $TMPDIR/j.xml", "mkdir -p \"$TMPDIR/d\" && cp a $TMPDIR/d/a && cat $TMPDIR/d/a", "X=$TMPDIR/cache npm test", "echo hi | tee $TMPDIR/l; grep hi $TMPDIR/l", "node --test test/leak.test.js"]) {
235
+ assert.equal(validateContract({ ...C, validate: [{ id: "V4", command: ok }] }, ISSUE).ok, true, ok);
236
+ }
237
+ });
238
+ it("#4325 tmpdirInputs lists read-before-create $TMPDIR paths once, in first-use order", () => {
239
+ assert.deepEqual(tmpdirInputs("cat ${TMPDIR:-/tmp}/a.json | jq . && cp $TMPDIR/b.txt out && cat $TMPDIR/a.json"), ["$TMPDIR/a.json", "$TMPDIR/b.txt"]);
240
+ assert.deepEqual(tmpdirInputs("cp src.txt $TMPDIR/b.txt && cat $TMPDIR/b.txt"), []);
241
+ assert.deepEqual(tmpdirInputs(""), []);
242
+ });
179
243
  it("#4160: reviewSkeleton (object form) lists every required row, scope rows pre-filled with the engine's authorization", () => {
180
244
  const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
181
245
  const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
@@ -301,12 +365,22 @@ describe("repro_test on bug lanes (#4271)", () => {
301
365
  it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
302
366
  const k = { ...C, repro_test: RT };
303
367
  assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [] });
304
- for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "UNRUNNABLE", head: "UNRUNNABLE" }]) {
368
+ for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
305
369
  const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
306
370
  assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
307
371
  assert.match(pc.failures[0].detail, new RegExp(`BASE=${repro.base} HEAD=${repro.head}`));
308
372
  }
309
- assert.match(reproFailures(k, {})[0].detail, /test\/leak\.test\.js releases on 429.*BASE=UNRUNNABLE HEAD=UNRUNNABLE/);
373
+ assert.match(reproFailures(k, { base: "SKIPPED", head: "FAILED" })[0].detail, /HEAD=FAILED \(base not run: head did not pass\)$/);
374
+ assert.doesNotMatch(reproFailures(k, { base: "RAN", head: "RAN" })[0].detail, /base not run/);
375
+ });
376
+ it("a harness UNRUNNABLE on head is env.repro_unrunnable, not conformance.unimplemented (#4334)", () => {
377
+ const k = { ...C, repro_test: RT };
378
+ for (const repro of [{ base: "SKIPPED", head: "UNRUNNABLE" }, { base: "UNRUNNABLE", head: "UNRUNNABLE" }, {}]) {
379
+ const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
380
+ assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["env.repro_unrunnable", "repro_env"]], JSON.stringify(repro));
381
+ assert.match(pc.failures[0].detail, /test\/leak\.test\.js releases on 429 \(`node --test test\/leak\.test\.js`\): the harness could not run the test on head \(environment, not the change\)/);
382
+ assert.match(pc.failures[0].detail, new RegExp(`BASE=${repro.base ?? "UNRUNNABLE"} HEAD=UNRUNNABLE$`));
383
+ }
310
384
  });
311
385
  it("off (no repro option) is today's contract check and precheck exactly", () => {
312
386
  assert.deepEqual(validateContract(C, ISSUE), validateContract(C, ISSUE, { repro: false }));
@@ -102,12 +102,40 @@ export function priorAttempts(body, comments = []) {
102
102
  }
103
103
 
104
104
  /**
105
- * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298) the issue's
106
- * `updatedAt` and prior-attempt count, so an edit or a new attempt on the same base compiles a fresh pack.
105
+ * Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
106
+ * issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
107
+ * fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
107
108
  */
108
109
  export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}) =>
109
110
  `context-${issue}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
110
- ...(stamp?.updatedAt != null || stamp?.attempts != null ? [String(stamp.updatedAt ?? ""), Number(stamp.attempts) || 0] : [])]))}.json`;
111
+ ...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
112
+ ...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
113
+
114
+ /** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
115
+ export const LINK_STAMP_MAX = 100;
116
+ /**
117
+ * #4316: the linked-issue part of the cache stamp: a hash of `n:STATE` for each issue the body references (parent, Depends
118
+ * on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base. #4318: one GraphQL call
119
+ * reads every state plus the issue's own cross-reference/connected timeline count (a new relation changes the stamp too);
120
+ * above LINK_STAMP_MAX the highest (most recent) numbers are kept. A failed call counts every number as `n:?`; never
121
+ * throws; '' (and no call) when the body references nothing.
122
+ */
123
+ export async function linkStateStamp({ gh, repo, issue, body }) {
124
+ const parent = parentOf(body);
125
+ const nums = [...new Set([...(parent ? [parent] : []), ...parseDependsOn(stripComments(body), issue), ...bodyLinks(body, repo, issue)])]
126
+ .filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(-LINK_STAMP_MAX);
127
+ if (!nums.length) return "";
128
+ const [owner, name] = String(repo ?? "").split("/");
129
+ const self = Number(issue);
130
+ const q = `query($o:String!,$n:String!){repository(owner:$o,name:$n){${Number.isSafeInteger(self) && self > 0
131
+ ? `self:issueOrPullRequest(number:${self}){... on Issue{timelineItems(itemTypes:[CROSS_REFERENCED_EVENT,CONNECTED_EVENT,DISCONNECTED_EVENT]){totalCount}}}` : ""}${
132
+ nums.map((n) => `i${n}:issueOrPullRequest(number:${n}){... on Issue{state} ... on PullRequest{state}}`).join(" ")}}}`;
133
+ let r = null;
134
+ try { r = JSON.parse(String(await gh(["api", "graphql", "-f", `query=${q}`, "-f", `o=${owner ?? ""}`, "-f", `n=${name ?? ""}`])))?.data?.repository ?? null; } catch { r = null; }
135
+ const states = nums.map((n) => `${n}:${String(r?.[`i${n}`]?.state ?? "").toUpperCase() || "?"}`);
136
+ const rel = r?.self?.timelineItems?.totalCount;
137
+ return hash12([...states, `rel:${Number.isInteger(rel) ? rel : "?"}`].join(","));
138
+ }
111
139
 
112
140
  // ---- gather (I/O) ----
113
141
 
@@ -298,7 +326,7 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
298
326
  }
299
327
 
300
328
  /**
301
- * The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp`) or gathered
329
+ * The pack for one run: read from the cache (`cacheDir`, keyed by issue + base head SHA + config + `stamp` {scope, attempts}) or gathered
302
330
  * and compiled, then cached. `gather` returns the facts; `fs` is {read, write, rename?, list?, rm?}. A gather that
303
331
  * reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
304
332
  * cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
@@ -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 { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
5
+ import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
6
6
 
7
7
  const REPO = "o/r";
8
8
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -212,26 +212,85 @@ describe("#4298 context-pack cache and shared names", () => {
212
212
  list: async () => [...store.keys()].map((k) => k.slice("/runs/".length)), rm: async (p) => { ops.push(["rm", p]); store.delete(p); } } };
213
213
  };
214
214
 
215
- it("the key changes with the issue's updatedAt and attempt count; no stamp keeps the old shape", () => {
215
+ it("#4304: the key changes with the scope hash and attempt count, not updatedAt; no stamp keeps the old shape", () => {
216
216
  const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
217
217
  assert.equal(k(), contextPackCacheKey(50, "abc", cfg));
218
- assert.equal(k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }), k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }));
219
- assert.notEqual(k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }), k({ updatedAt: "2026-10-20T11:00:00Z", attempts: 1 }));
220
- assert.notEqual(k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 1 }), k({ updatedAt: "2026-10-20T10:00:00Z", attempts: 2 }));
218
+ assert.equal(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 1 }));
219
+ assert.equal(k({ scope: "aaa111", attempts: 1, updatedAt: "2026-10-20T10:00:00Z" }), k({ scope: "aaa111", attempts: 1, updatedAt: "2026-10-20T11:00:00Z" }), "the lane's own posts do not miss the cache");
220
+ assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "bbb222", attempts: 1 }));
221
+ assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 2 }));
222
+ });
223
+
224
+ it("#4318: above LINK_STAMP_MAX the highest numbers are kept, still in one call", async () => {
225
+ const calls = [];
226
+ const gh = async (args) => { calls.push(args); return JSON.stringify({ data: { repository: {} } }); };
227
+ const body = Array.from({ length: LINK_STAMP_MAX + 5 }, (_, i) => `#${i + 1}`).join(" ");
228
+ await linkStateStamp({ gh, repo: "o/r", issue: 9999, body });
229
+ assert.equal(calls.length, 1);
230
+ const nums = [...(calls[0].find((x) => x.startsWith("query=")) ?? "").matchAll(/i(\d+):/g)].map((m) => Number(m[1]));
231
+ assert.equal(nums.length, LINK_STAMP_MAX);
232
+ assert.equal(nums[0], 6, "the lowest numbers are dropped");
233
+ assert.equal(nums.at(-1), LINK_STAMP_MAX + 5);
234
+ });
235
+
236
+ it("#4316: linked-issue states are in the key; a state change recompiles; no links keeps the old key", async () => {
237
+ const states = { 7: "open", 8: "open", 9: "closed" };
238
+ let rel = 2;
239
+ const calls = [];
240
+ // #4318: one GraphQL call; an alias per referenced number, plus the issue's own timeline relation count
241
+ const gh = async (args) => {
242
+ calls.push(args);
243
+ if (states.fail) throw new Error("HTTP 502");
244
+ const q = args.find((a) => a.startsWith("query=")) ?? "";
245
+ const repository = { self: { timelineItems: { totalCount: rel } } };
246
+ for (const m of q.matchAll(/i(\d+):issueOrPullRequest/g)) repository[`i${m[1]}`] = states[m[1]] ? { state: states[m[1]].toUpperCase() } : null;
247
+ return JSON.stringify({ data: { repository } });
248
+ };
249
+ const aliases = (a) => [...(a.find((x) => x.startsWith("query=")) ?? "").matchAll(/i(\d+):/g)].map((m) => Number(m[1]));
250
+ const body = "**Parent**: #7\nDepends on #8\nSee #9 and #50 (self).";
251
+ const s1 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
252
+ assert.match(s1, /^[0-9a-f]{12}$/);
253
+ assert.equal(calls.length, 1, "one call for every reference");
254
+ assert.deepEqual(calls[0].slice(0, 2), ["api", "graphql"]);
255
+ assert.deepEqual(aliases(calls[0]), [7, 8, 9], "parent, depends-on and links once each, never self");
256
+ assert.ok(calls[0].includes("o=o") && calls[0].includes("n=r"));
257
+ assert.equal(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s1, "stable");
258
+ states[8] = "closed";
259
+ const s2 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
260
+ assert.notEqual(s2, s1, "a linked issue closing changes the stamp");
261
+ rel = 3;
262
+ assert.notEqual(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s2, "a new timeline relation changes the stamp");
263
+ rel = 2;
264
+ states.fail = true;
265
+ const sf = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
266
+ assert.match(sf, /^[0-9a-f]{12}$/, "a failed call never throws");
267
+ assert.notEqual(sf, s2, "a failed call is n:? for every number");
268
+ delete states.fail;
269
+ assert.equal(await linkStateStamp({ gh: async () => { throw new Error("x"); }, repo: "o/r", issue: 50, body: "no links here" }), "");
270
+ const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
271
+ assert.equal(k({ scope: "s", attempts: 0, links: "" }), k({ scope: "s", attempts: 0 }), "no links: the pre-#4316 key");
272
+ assert.notEqual(k({ scope: "s", attempts: 0, links: s1 }), k({ scope: "s", attempts: 0, links: s2 }));
273
+ const { fs } = memFs();
274
+ let n = 0;
275
+ const run = (links) => contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { scope: "s", attempts: 0, links }, gather: async () => { n++; return facts(); } });
276
+ await run(s1);
277
+ assert.equal((await run(s1)).cached, true);
278
+ assert.equal((await run(s2)).cached, false, "the pack recompiles after a linked issue closed");
279
+ assert.equal(n, 2);
221
280
  });
222
281
 
223
282
  it("an issue edit recompiles; a gather with source errors is not cached", async () => {
224
283
  const { fs, store } = memFs();
225
284
  let n = 0;
226
285
  const run = (stamp, errors) => contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp, gather: async () => { n++; return facts(errors); } });
227
- await run({ updatedAt: "t1", attempts: 0 });
228
- assert.equal((await run({ updatedAt: "t1", attempts: 0 })).cached, true);
229
- await run({ updatedAt: "t2", attempts: 0 });
286
+ await run({ scope: "s1", attempts: 0 });
287
+ assert.equal((await run({ scope: "s1", attempts: 0, updatedAt: "later" })).cached, true);
288
+ await run({ scope: "s2", attempts: 0 });
230
289
  assert.equal(n, 2, "the edit recompiled");
231
290
  const before = store.size;
232
- const r = await run({ updatedAt: "t3", attempts: 0 }, ["graph: timeline HTTP 502"]);
291
+ const r = await run({ scope: "s3", attempts: 0 }, ["graph: timeline HTTP 502"]);
233
292
  assert.deepEqual([r.cached, r.errors, store.size], [false, ["graph: timeline HTTP 502"], before]);
234
- assert.equal((await run({ updatedAt: "t3", attempts: 0 }, ["graph: timeline HTTP 502"])).cached, false, "errors are retried, not served from cache");
293
+ assert.equal((await run({ scope: "s3", attempts: 0 }, ["graph: timeline HTTP 502"])).cached, false, "errors are retried, not served from cache");
235
294
  });
236
295
 
237
296
  it("writes through a temp file and a rename, then prunes the issue's other packs only", async () => {
@@ -239,8 +298,8 @@ describe("#4298 context-pack cache and shared names", () => {
239
298
  store.set("/runs/context-50-old-aaaaaaaaaaaa.json", "{}");
240
299
  store.set("/runs/context-500-x-bbbbbbbbbbbb.json", "{}");
241
300
  store.set("/runs/50-run.jsonl", "");
242
- await contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { updatedAt: "t", attempts: 0 }, gather: async () => facts() });
243
- const file = `/runs/${contextPackCacheKey(50, "abc", cfg, { updatedAt: "t", attempts: 0 })}`;
301
+ await contextPackFor({ cfg, issue: 50, headSha: "abc", cacheDir: "/runs", fs, stamp: { scope: "t", attempts: 0 }, gather: async () => facts() });
302
+ const file = `/runs/${contextPackCacheKey(50, "abc", cfg, { scope: "t", attempts: 0 })}`;
244
303
  assert.equal(ops[0][0], "write"); assert.match(ops[0][1], /\.tmp$/);
245
304
  assert.deepEqual(ops[1], ["rename", ops[0][1], file]);
246
305
  assert.deepEqual([...store.keys()].sort(), ["/runs/50-run.jsonl", "/runs/context-500-x-bbbbbbbbbbbb.json", file].sort());
@@ -15,6 +15,8 @@ import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
15
15
  import { makeDepCache } from "./depcache.mjs";
16
16
  import { makeHeavyIo } from "./heavy.mjs";
17
17
  import { gatherContextFacts } from "./context.mjs";
18
+ import { enumeratingTests, missingEntries } from "./enumerating.mjs";
19
+ import { sequenceCollisions, snapshotLane, verifyRenumber } from "./sequence.mjs";
18
20
 
19
21
  const pexec = promisify(execFile);
20
22
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -197,12 +199,12 @@ export async function requiredChecksFor({ gh, repo, lane }) {
197
199
 
198
200
  /** Combined stdout+stderr kept per declared check command (tail). */
199
201
  const CHECK_OUTPUT_CAP = 64000;
200
- /** #4240: run a forge.yaml declared check command locally (no gh, no Actions); {rc, output}, rc 124 on timeout. */
201
- export function runCheckCommand({ command, cwd, timeoutMs = 300000 }) {
202
+ /** #4240: run a forge.yaml declared check command locally (no gh, no Actions); {rc, output}, rc 124 on timeout. `env` (#4323) is merged over process.env. */
203
+ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }) {
202
204
  return new Promise((done) => {
203
205
  let out = "", timedOut = false, child;
204
206
  const add = (b) => { out += String(b); if (out.length > 2 * CHECK_OUTPUT_CAP) out = out.slice(-CHECK_OUTPUT_CAP); };
205
- try { child = spawn("bash", ["-c", String(command)], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
207
+ try { child = spawn("bash", ["-c", String(command)], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env: { ...process.env, ...env } } : {}) }); }
206
208
  catch (e) { done({ rc: 127, output: String(e?.message ?? e) }); return; }
207
209
  const kill = () => { try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } };
208
210
  const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
@@ -236,6 +238,8 @@ export function makeLeanIo({ repo }) {
236
238
  },
237
239
  /** #4240: one declared check command (`bash -c`) in `cwd`; never throws. A timeout kills the process group and gives rc 124. */
238
240
  runCommand: runCheckCommand,
241
+ /** #4328: a read-only freeze-time probe of a validate command's tool or module (separate from runCommand). */
242
+ probeCommand: runCheckCommand,
239
243
  /** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
240
244
  async dispatchWorkflow({ workflow, ref, inputs = {} }) {
241
245
  await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
@@ -306,11 +310,21 @@ export function makeLeanIo({ repo }) {
306
310
  const args = [script("hotspots.sh"), ...(base ? ["--base", base] : []), "--", ...paths];
307
311
  try { return JSON.parse((await pexec("bash", args, { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
308
312
  },
309
- /** #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure. */
310
- async redGreen({ cwd, base, command, timeoutSec = 300 }) {
313
+ /** #4322: tests enumerating the members of the given paths, and new members those tests miss (bin/lean/enumerating.mjs); [] on failure. */
314
+ enumeratingTests: ({ cwd, paths = [] }) => enumeratingTests({ git: (a) => run("git")(a, { cwd }), cwd, paths }),
315
+ enumeratingMissing: ({ cwd, base }) => missingEntries({ git: (a) => run("git")(a, { cwd }), cwd, base }),
316
+ /** #4322: sequence-numbered files the lane shares with the base (bin/lean/sequence.mjs); snapshot before, verify after a renumber. */
317
+ sequenceCheck: ({ cwd, base }) => sequenceCollisions({ git: (a) => run("git")(a, { cwd }), base }),
318
+ sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
319
+ sequenceVerify: ({ cwd, base, before }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before }),
320
+ /**
321
+ * #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure.
322
+ * `env` (#4325): the lane env (run TMPDIR, toolchain), merged over process.env, so the command sees what the builder saw.
323
+ */
324
+ async redGreen({ cwd, base, command, timeoutSec = 300, env = null }) {
311
325
  const args = [script("acceptance-red-green.sh"), "--repo-path", cwd, "--base-ref", base, "--test-cmd", command, "--timeout", String(timeoutSec)];
312
326
  try {
313
- const m = /BASE=(\w+) HEAD=(\w+)/.exec(String((await pexec("bash", args, { cwd, timeout: (timeoutSec * 2 + 60) * 1000, maxBuffer: 4 * 1024 * 1024 })).stdout ?? ""));
327
+ const m = /BASE=(\w+) HEAD=(\w+)/.exec(String((await pexec("bash", args, { cwd, timeout: (timeoutSec * 2 + 60) * 1000, maxBuffer: 4 * 1024 * 1024, ...(env ? { env: { ...process.env, ...env } } : {}) })).stdout ?? ""));
314
328
  return m ? { base: m[1], head: m[2] } : null;
315
329
  } catch { return null; }
316
330
  },
@@ -0,0 +1,189 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Enumerating tests (#4322): tests whose expected list names every member of a group (a module's exports, or the files
5
+ * of one directory), so a new member must join the list or CI goes red. Deterministic and codebase-agnostic: git + fs,
6
+ * no LLM, runs none of the repo's commands. A test enumerates a group when it names at least min(3, n) members as string
7
+ * literals or object keys, or calls Object.keys/Object.entries on an import of the module. Never throws: failures are [].
8
+ */
9
+ import fs from "node:fs";
10
+ import { basename, dirname, join, posix } from "node:path";
11
+
12
+ const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py)$/;
13
+ const TEST_RE = /(?:^|\/)(?:__tests__|tests?)\/|\.(?:test|spec)\.[^/]+$|(?:^|\/)test_[^/]+\.py$|_test\.[^/]+$/;
14
+ const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build|coverage)\//;
15
+ const MAX_TEST_BYTES = 512 * 1024;
16
+ const MAX_TESTS = 5000;
17
+ const MAX_MODULES = 20, MAX_TESTS_PER_MODULE = 10;
18
+ const MIN_NAME = 3;
19
+
20
+ export const isTestPath = (p) => TEST_RE.test(p);
21
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
23
+ const stem = (p) => basename(p).replace(/\.[^.]+$/, "").replace(/\.d$/, "");
24
+
25
+ /** Exported names of a JS/TS or Python source text. */
26
+ export function exportNames(text, path = "") {
27
+ const t = String(text ?? ""), out = new Set();
28
+ if (/\.py$/.test(path)) {
29
+ const all = /^__all__\s*=\s*[[(]([\s\S]*?)[\])]/m.exec(t);
30
+ if (all) for (const m of all[1].matchAll(/["']([A-Za-z_]\w*)["']/g)) out.add(m[1]);
31
+ else for (const m of t.matchAll(/^(?:async\s+)?(?:def|class)\s+([A-Za-z]\w*)/gm)) out.add(m[1]);
32
+ return [...out];
33
+ }
34
+ for (const m of t.matchAll(/^\s*export\s+(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|const|let|var|class|enum)\s+([A-Za-z_$][\w$]*)/gm)) out.add(m[1]);
35
+ for (const m of t.matchAll(/^\s*export\s+(?!type\b)\{([^}]*)\}/gm)) {
36
+ for (const part of m[1].split(",")) {
37
+ const p = part.trim().replace(/^type\s+/, "");
38
+ if (!p || part.trim().startsWith("type ")) continue;
39
+ const name = (/\bas\s+([A-Za-z_$][\w$]*)$/.exec(p)?.[1]) ?? /^[A-Za-z_$][\w$]*/.exec(p)?.[0];
40
+ if (name && name !== "default") out.add(name);
41
+ }
42
+ }
43
+ return [...out];
44
+ }
45
+
46
+ /** Line (1-based) of the first literal/key mention of any of `names` in `text`, and the distinct names mentioned. */
47
+ function mentions(text, names) {
48
+ const hit = new Set();
49
+ let line = 0;
50
+ for (const n of names) {
51
+ if (n.length < MIN_NAME) continue;
52
+ const re = new RegExp(`(["'\`])${esc(n)}\\1|(?:^|[{,\\s])${esc(n)}\\s*:(?!:)`, "m");
53
+ const m = re.exec(text);
54
+ if (!m) continue;
55
+ hit.add(n);
56
+ const l = text.slice(0, m.index).split("\n").length;
57
+ if (!line || l < line) line = l;
58
+ }
59
+ return { hit, line };
60
+ }
61
+
62
+ /** Local names bound to an import of `module` in a test at `testPath` (namespace, default or require). */
63
+ function importBindings(text, testPath, module, pkgs) {
64
+ const out = [];
65
+ const target = module.replace(SOURCE_RE, "");
66
+ const targets = new Set([target, ...(stem(module) === "index" ? [dirname(module)] : [])]);
67
+ const pkgNames = pkgs.filter((p) => stem(module) === "index" && [p.dir, posix.join(p.dir, "src")].includes(dirname(module))).map((p) => p.name);
68
+ const matches = (spec) => {
69
+ if (pkgNames.includes(spec)) return true;
70
+ if (!spec.startsWith(".")) return false;
71
+ const r = posix.normalize(posix.join(dirname(testPath), spec)).replace(SOURCE_RE, "");
72
+ return targets.has(r) || targets.has(r.replace(/\/index$/, ""));
73
+ };
74
+ for (const m of text.matchAll(/import\s+(?:\*\s+as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))\s+from\s+["']([^"']+)["']/g)) if (matches(m[3])) out.push(m[1] ?? m[2]);
75
+ for (const m of text.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+import|require)\(\s*["']([^"']+)["']\s*\)/g)) if (matches(m[2])) out.push(m[1]);
76
+ return out;
77
+ }
78
+
79
+ /** {path, text} of the repo's tracked test files (read from the working tree; absent/sparse/oversize files skipped). */
80
+ async function loadTests(git, cwd) {
81
+ let files;
82
+ try { files = lines(await git(["-c", "core.quotepath=off", "ls-files"])); } catch { return { tests: [], files: [], pkgs: [] }; }
83
+ const tests = [];
84
+ for (const f of files) {
85
+ if (tests.length >= MAX_TESTS) break;
86
+ if (!isTestPath(f) || SKIP_RE.test(f) || !SOURCE_RE.test(f)) continue;
87
+ try {
88
+ const p = join(cwd, f);
89
+ if (fs.statSync(p).size > MAX_TEST_BYTES) continue;
90
+ tests.push({ path: f, text: fs.readFileSync(p, "utf8") });
91
+ } catch { /* sparse or unreadable: skip */ }
92
+ }
93
+ const pkgs = [];
94
+ for (const f of files.filter((x) => /(?:^|\/)package\.json$/.test(x) && !SKIP_RE.test(x))) {
95
+ try { const n = JSON.parse(fs.readFileSync(join(cwd, f), "utf8")).name; if (typeof n === "string" && n) pkgs.push({ name: n, dir: dirname(f) }); } catch { /* skip */ }
96
+ }
97
+ return { tests, files, pkgs };
98
+ }
99
+
100
+ /** Tests enumerating one group: [{test, line, why}]. `exports` groups also match Object.keys/entries of an import of `module`. */
101
+ function enumerators(tests, { module, names, kind }, pkgs) {
102
+ const out = [];
103
+ const usable = names.filter((n) => n.length >= MIN_NAME);
104
+ if (usable.length < 2) return out;
105
+ const need = Math.min(3, usable.length);
106
+ for (const t of tests) {
107
+ if (t.path === module) continue;
108
+ const { hit, line } = mentions(t.text, usable);
109
+ if (hit.size >= need) { out.push({ test: t.path, line, why: `literal list of ${hit.size} sibling ${kind === "files" ? "file names" : "export names"}` }); continue; }
110
+ if (kind !== "exports") continue;
111
+ for (const b of importBindings(t.text, t.path, module, pkgs)) {
112
+ const m = new RegExp(`Object\\.(?:keys|entries)\\(\\s*${esc(b)}\\s*\\)`).exec(t.text);
113
+ if (m) { out.push({ test: t.path, line: t.text.slice(0, m.index).split("\n").length, why: `Object.keys(${b})` }); break; }
114
+ }
115
+ }
116
+ return out.slice(0, MAX_TESTS_PER_MODULE);
117
+ }
118
+
119
+ const fileGroup = (files, dir, exclude = new Set()) => [...new Set(files.filter((f) => dirname(f) === dir && !isTestPath(f) && !exclude.has(f) && stem(f) !== "index").map(stem))];
120
+
121
+ /**
122
+ * Plan time: tests enumerating the exports or directory siblings of each path the issue names.
123
+ * @returns {Promise<Array<{module:string, test:string, line:number, why:string}>>}
124
+ */
125
+ export async function enumeratingTests({ git, cwd, paths = [] }) {
126
+ try {
127
+ const { tests, files, pkgs } = await loadTests(git, cwd);
128
+ if (!tests.length) return [];
129
+ const tracked = new Set(files), out = [], seen = new Set();
130
+ for (const raw of [...new Set(paths.map((p) => String(p).replace(/^\.\//, "").replace(/\/$/, "")))].slice(0, MAX_MODULES)) {
131
+ const groups = [];
132
+ if (tracked.has(raw) && SOURCE_RE.test(raw) && !isTestPath(raw)) {
133
+ let text = ""; try { text = fs.readFileSync(join(cwd, raw), "utf8"); } catch { /* sparse */ }
134
+ groups.push({ module: raw, names: exportNames(text, raw), kind: "exports" });
135
+ groups.push({ module: dirname(raw), names: fileGroup(files, dirname(raw)), kind: "files" });
136
+ } else if (files.some((f) => dirname(f) === raw)) groups.push({ module: raw, names: fileGroup(files, raw), kind: "files" });
137
+ for (const g of groups) for (const e of enumerators(tests, g, pkgs)) {
138
+ const k = `${g.module}\0${e.test}`;
139
+ if (!seen.has(k)) { seen.add(k); out.push({ module: g.module, ...e }); }
140
+ }
141
+ }
142
+ return out;
143
+ } catch { return []; }
144
+ }
145
+
146
+ /**
147
+ * After implement: each new export (vs `base`) of a changed source file and each new file, paired with every test that
148
+ * enumerates its existing siblings but never names it. @returns {Promise<Array<{symbol, module, test, line}>>}
149
+ */
150
+ export async function missingEntries({ git, cwd, base }) {
151
+ try {
152
+ const changed = lines(await git(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=AM", `${base}...HEAD`]));
153
+ const added = new Set(lines(await git(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...HEAD`])));
154
+ const groups = [];
155
+ for (const f of changed) {
156
+ if (isTestPath(f) || SKIP_RE.test(f)) continue;
157
+ if (SOURCE_RE.test(f)) {
158
+ let head = "", old = "";
159
+ try { head = fs.readFileSync(join(cwd, f), "utf8"); } catch { continue; }
160
+ if (!added.has(f)) { try { old = String(await git(["show", `${base}:${f}`])); } catch { old = ""; } }
161
+ const now = exportNames(head, f), was = new Set(exportNames(old, f));
162
+ const fresh = now.filter((n) => !was.has(n) && n.length >= MIN_NAME);
163
+ if (fresh.length) groups.push({ module: f, names: now.filter((n) => was.has(n)), fresh, kind: "exports" });
164
+ }
165
+ }
166
+ if (!groups.length && !added.size) return [];
167
+ const { tests, files, pkgs } = await loadTests(git, cwd);
168
+ if (!tests.length) return [];
169
+ const newFiles = [...added].filter((f) => !isTestPath(f) && !SKIP_RE.test(f));
170
+ for (const dir of [...new Set(newFiles.map(dirname))]) {
171
+ const fresh = newFiles.filter((f) => dirname(f) === dir).map(stem).filter((s) => s.length >= MIN_NAME && s !== "index");
172
+ if (fresh.length) groups.push({ module: dir, names: fileGroup(files, dir, added), fresh, kind: "files" });
173
+ }
174
+ const out = [];
175
+ for (const g of groups.slice(0, MAX_MODULES)) {
176
+ for (const e of enumerators(tests, g, pkgs)) {
177
+ const text = tests.find((t) => t.path === e.test)?.text ?? "";
178
+ for (const s of g.fresh) if (!new RegExp(`(?<![\\w$])${esc(s)}(?![\\w$])`).test(text)) out.push({ symbol: s, module: g.module, test: e.test, line: e.line });
179
+ }
180
+ }
181
+ return out;
182
+ } catch { return []; }
183
+ }
184
+
185
+ /** Drop entries the builder justified in result.json "not_members" [{symbol, test, why}] (a non-empty why is required). */
186
+ export function unjustified(missing, notMembers) {
187
+ const ok = new Set((Array.isArray(notMembers) ? notMembers : []).filter((n) => n && String(n.why ?? "").trim()).map((n) => `${n.symbol}\0${n.test}`));
188
+ return (missing ?? []).filter((m) => !ok.has(`${m.symbol}\0${m.test}`));
189
+ }