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.
- package/bin/engine/outcome-codes.mjs +4 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine-cli.mjs +102 -38
- package/bin/engine.mjs +3 -1
- package/bin/lean/batch.test.mjs +62 -1
- package/bin/lean/conformance.mjs +129 -8
- package/bin/lean/conformance.test.mjs +77 -3
- package/bin/lean/context.mjs +32 -4
- package/bin/lean/context.test.mjs +71 -12
- package/bin/lean/effects.mjs +20 -6
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/lean.mjs +207 -56
- package/bin/lean/lean.test.mjs +336 -9
- package/bin/lean/orchestrate.mjs +132 -83
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +96 -14
- package/bin/lean/prefilter.test.mjs +111 -2
- package/bin/lean/preflight.mjs +133 -0
- package/bin/lean/preflight.test.mjs +61 -0
- package/bin/lean/prompts.mjs +37 -3
- package/bin/lean/recovery.test.mjs +271 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +57 -3
- package/bin/lean/testmap.test.mjs +53 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +7 -4
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +8 -1
- package/docs/OUTCOME-CODES.md +6 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +186 -34
- package/scripts/test-map.test.sh +250 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- 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: "
|
|
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, /
|
|
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 }));
|
package/bin/lean/context.mjs
CHANGED
|
@@ -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
|
|
106
|
-
* `
|
|
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?.
|
|
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
|
|
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({
|
|
219
|
-
assert.
|
|
220
|
-
assert.notEqual(k({
|
|
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({
|
|
228
|
-
assert.equal((await run({
|
|
229
|
-
await run({
|
|
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({
|
|
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({
|
|
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: {
|
|
243
|
-
const file = `/runs/${contextPackCacheKey(50, "abc", cfg, {
|
|
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());
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -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
|
-
/** #
|
|
310
|
-
|
|
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
|
+
}
|