forgedock 1.29.1 → 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 (38) 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 +94 -36
  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 +17 -12
  9. package/bin/lean/context.test.mjs +36 -5
  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 +189 -43
  14. package/bin/lean/lean.test.mjs +305 -6
  15. package/bin/lean/orchestrate.mjs +104 -85
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +21 -4
  19. package/bin/lean/prefilter.test.mjs +24 -1
  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 +75 -0
  24. package/bin/lean/sequence.mjs +102 -0
  25. package/bin/lean/sequence.test.mjs +112 -0
  26. package/bin/lean/testmap.mjs +43 -6
  27. package/bin/lean/testmap.test.mjs +38 -1
  28. package/bin/lean/verify.mjs +7 -2
  29. package/bin/lean/verify.test.mjs +8 -2
  30. package/commands/orchestrate.md +3 -1
  31. package/docs/CONFIG.md +8 -1
  32. package/docs/OUTCOME-CODES.md +6 -4
  33. package/docs/RELEASE-NOTES.md +10 -0
  34. package/package.json +1 -1
  35. package/scripts/acceptance-red-green.sh +13 -4
  36. package/scripts/acceptance-red-green.test.sh +14 -0
  37. package/scripts/test-map.sh +63 -17
  38. package/scripts/test-map.test.sh +55 -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 }));
@@ -111,25 +111,30 @@ export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}) =>
111
111
  ...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
112
112
  ...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
113
113
 
114
- /** Max referenced issues whose state is read for the cache stamp (one REST call each). */
115
- export const LINK_STAMP_MAX = 20;
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
116
  /**
117
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. Timeline cross-refs are
119
- * left to the gather. An unreadable issue counts as `n:?`; never throws; '' when the body references nothing.
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.
120
122
  */
121
123
  export async function linkStateStamp({ gh, repo, issue, body }) {
122
124
  const parent = parentOf(body);
123
125
  const nums = [...new Set([...(parent ? [parent] : []), ...parseDependsOn(stripComments(body), issue), ...bodyLinks(body, repo, issue)])]
124
- .filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(0, LINK_STAMP_MAX);
126
+ .filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(-LINK_STAMP_MAX);
125
127
  if (!nums.length) return "";
126
- const states = [];
127
- for (const n of nums) {
128
- let st = "?";
129
- try { st = String(await gh(["api", `repos/${repo}/issues/${n}`, "--jq", ".state"])).trim().toUpperCase() || "?"; } catch { /* unreadable: n:? */ }
130
- states.push(`${n}:${st}`);
131
- }
132
- return hash12(states.join(","));
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(","));
133
138
  }
134
139
 
135
140
  // ---- gather (I/O) ----
@@ -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, linkStateStamp, 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]" } };
@@ -221,20 +221,51 @@ describe("#4298 context-pack cache and shared names", () => {
221
221
  assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 2 }));
222
222
  });
223
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
+
224
236
  it("#4316: linked-issue states are in the key; a state change recompiles; no links keeps the old key", async () => {
225
237
  const states = { 7: "open", 8: "open", 9: "closed" };
238
+ let rel = 2;
226
239
  const calls = [];
227
- const gh = async (args) => { calls.push(args); const n = Number(/issues\/(\d+)$/.exec(args[1])?.[1]); if (n === 9 && states.fail9) throw new Error("HTTP 502"); return `${states[n]}\n`; };
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]));
228
250
  const body = "**Parent**: #7\nDepends on #8\nSee #9 and #50 (self).";
229
251
  const s1 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
230
252
  assert.match(s1, /^[0-9a-f]{12}$/);
231
- assert.deepEqual(calls.map((a) => a[1]), ["repos/o/r/issues/7", "repos/o/r/issues/8", "repos/o/r/issues/9"], "parent, depends-on and links once each, never self");
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"));
232
257
  assert.equal(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s1, "stable");
233
258
  states[8] = "closed";
234
259
  const s2 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
235
260
  assert.notEqual(s2, s1, "a linked issue closing changes the stamp");
236
- states.fail9 = true;
237
- assert.match(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), /^[0-9a-f]{12}$/, "an unreadable link never throws");
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;
238
269
  assert.equal(await linkStateStamp({ gh: async () => { throw new Error("x"); }, repo: "o/r", issue: 50, body: "no links here" }), "");
239
270
  const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
240
271
  assert.equal(k({ scope: "s", attempts: 0, links: "" }), k({ scope: "s", attempts: 0 }), "no links: the pre-#4316 key");
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { exportNames, enumeratingTests, missingEntries, unjustified } from "./enumerating.mjs";
10
+ import { enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, builderPlanPrompt } from "./prompts.mjs";
11
+
12
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
13
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
14
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
15
+ function repo(files) {
16
+ const d = tmpDir("enum-");
17
+ sh(d, "init", "-q", "-b", "main");
18
+ put(d, files);
19
+ sh(d, "add", "-A"); sh(d, "commit", "-qm", "base");
20
+ return d;
21
+ }
22
+
23
+ const CORE = "export async function getUser() {}\nexport const listUsers = () => [];\nexport function deleteUser() {}\nexport { helperA as renamedHelper, type Foo } from './x';\n";
24
+ const FILES = {
25
+ "packages/core/package.json": JSON.stringify({ name: "@acme/core" }),
26
+ "packages/core/src/index.ts": CORE,
27
+ "packages/core/test/registry.test.ts": "import * as core from '../src/index';\nconst SERVICE_TESTS = { getUser: 1, listUsers: 1 };\nconst NOT_A_SERVICE = ['deleteUser', 'renamedHelper'];\nexpect(Object.keys(core).filter((k) => !(k in SERVICE_TESTS) && !NOT_A_SERVICE.includes(k))).toEqual([]);\n",
28
+ "packages/web/test/pkg.test.ts": "import * as c from '@acme/core';\nit('all', () => expect(Object.keys(c).length).toBe(4));\n",
29
+ "packages/core/test/unrelated.test.ts": "import { getUser } from '../src/index';\nit('x', () => getUser());\n",
30
+ "locales/en/common.json": "{}", "locales/en/auth.json": "{}", "locales/en/billing.json": "{}",
31
+ "tests/catalog.test.ts": "const NAMESPACES = ['common', 'auth', 'billing'];\n",
32
+ };
33
+
34
+ describe("enumerating tests (#4322)", () => {
35
+ it("reads JS/TS and Python exports", () => {
36
+ assert.deepEqual(exportNames(CORE, "a.ts").sort(), ["deleteUser", "getUser", "listUsers", "renamedHelper"]);
37
+ assert.deepEqual(exportNames("__all__ = ['a', \"bb\"]\ndef c(): pass", "m.py"), ["a", "bb"]);
38
+ assert.deepEqual(exportNames("def run():\n pass\nclass Job: pass\ndef _private(): pass", "m.py"), ["run", "Job"]);
39
+ });
40
+
41
+ it("plan: lists every test enumerating the exports of a module the issue names (literal list, Object.keys of a relative or package import)", async () => {
42
+ const d = repo(FILES);
43
+ const list = await enumeratingTests({ git: gitIn(d), cwd: d, paths: ["packages/core/src/index.ts"] });
44
+ const tests = list.filter((e) => e.module === "packages/core/src/index.ts").map((e) => e.test).sort();
45
+ assert.deepEqual(tests, ["packages/core/test/registry.test.ts", "packages/web/test/pkg.test.ts"]);
46
+ assert.ok(!list.some((e) => e.test.endsWith("unrelated.test.ts")), "a test importing one export does not enumerate them");
47
+ assert.match(list.find((e) => e.test.endsWith("registry.test.ts")).why, /literal list of [34] sibling export names/);
48
+ assert.match(list.find((e) => e.test.endsWith("pkg.test.ts")).why, /^Object\.keys\(c\)$/);
49
+ const files = await enumeratingTests({ git: gitIn(d), cwd: d, paths: ["locales/en"] });
50
+ assert.deepEqual(files.map((e) => [e.module, e.test, e.line]), [["locales/en", "tests/catalog.test.ts", 1]]);
51
+ });
52
+
53
+ it("plan note and build rule cite the list; no enumerating tests means no new plan step (prompt unchanged)", async () => {
54
+ const d = repo(FILES);
55
+ assert.deepEqual(await enumeratingTests({ git: gitIn(d), cwd: d, paths: ["README.md", "packages/core/package.json"] }), []);
56
+ assert.equal(enumeratingPlanNote([]), ""); assert.equal(enumeratingPlanNote(null), "");
57
+ assert.equal(enumeratingBuildRule([]), "");
58
+ const list = [{ module: "packages/core/src/index.ts", test: "packages/core/test/registry.test.ts", line: 2, why: "literal list of 4 sibling export names" }];
59
+ assert.match(enumeratingPlanNote(list), /Enumerating tests[\s\S]*Affected Files[\s\S]*validate command[\s\S]*- packages\/core\/src\/index\.ts -> packages\/core\/test\/registry\.test\.ts:2/);
60
+ const rule = enumeratingBuildRule(list);
61
+ assert.match(rule, /one test-list edit per new symbol/);
62
+ assert.match(rule, /"not_members"/);
63
+ assert.match(rule, /registry\.test\.ts:2/);
64
+ assert.ok(!builderPlanPrompt(7).includes("Enumerating tests"));
65
+ });
66
+
67
+ it("after implement: a new export missing from an enumerating test is reported; a listed one or a justified not-member is not", async () => {
68
+ const d = repo(FILES);
69
+ sh(d, "checkout", "-qb", "lane");
70
+ put(d, { "packages/core/src/index.ts": `${CORE}export function getProfile() {}\nexport function updateProfile() {}\n` });
71
+ put(d, { "packages/core/test/registry.test.ts": FILES["packages/core/test/registry.test.ts"].replace("listUsers: 1", "listUsers: 1, updateProfile: 1") });
72
+ put(d, { "locales/en/profile.json": "{}" });
73
+ sh(d, "add", "-A"); sh(d, "commit", "-qm", "lane");
74
+ const miss = await missingEntries({ git: gitIn(d), cwd: d, base: "main" });
75
+ const keys = miss.map((m) => `${m.symbol} -> ${m.test}`).sort();
76
+ assert.deepEqual(keys, [
77
+ "getProfile -> packages/core/test/registry.test.ts",
78
+ "getProfile -> packages/web/test/pkg.test.ts",
79
+ "profile -> tests/catalog.test.ts",
80
+ "updateProfile -> packages/web/test/pkg.test.ts",
81
+ ]);
82
+ const left = unjustified(miss, [{ symbol: "getProfile", test: "packages/web/test/pkg.test.ts", why: "count test, not a list" }, { symbol: "profile", test: "tests/catalog.test.ts", why: "" }]);
83
+ assert.equal(left.length, 3, "a not-member needs a non-empty why");
84
+ assert.match(missingEntriesPrompt(miss), /getProfile \(packages\/core\/src\/index\.ts\) -> packages\/core\/test\/registry\.test\.ts:\d+[\s\S]*not_members/);
85
+ });
86
+
87
+ it("a lane that adds no enumerable symbol gets nothing; a broken git never throws", async () => {
88
+ const d = repo(FILES);
89
+ sh(d, "checkout", "-qb", "lane");
90
+ put(d, { "packages/core/src/index.ts": `${CORE}// comment only\n` });
91
+ sh(d, "add", "-A"); sh(d, "commit", "-qm", "lane");
92
+ assert.deepEqual(await missingEntries({ git: gitIn(d), cwd: d, base: "main" }), []);
93
+ const bad = async () => { throw new Error("boom"); };
94
+ assert.deepEqual(await missingEntries({ git: bad, cwd: d, base: "main" }), []);
95
+ assert.deepEqual(await enumeratingTests({ git: bad, cwd: d, paths: ["x"] }), []);
96
+ });
97
+ });