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.
- package/bin/engine/outcome-codes.mjs +4 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine-cli.mjs +94 -36
- 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 +17 -12
- package/bin/lean/context.test.mjs +36 -5
- 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 +189 -43
- package/bin/lean/lean.test.mjs +305 -6
- package/bin/lean/orchestrate.mjs +104 -85
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +21 -4
- package/bin/lean/prefilter.test.mjs +24 -1
- 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 +75 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/orchestrate.md +3 -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/test-map.sh +63 -17
- 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: "
|
|
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
|
@@ -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
|
|
115
|
-
export const LINK_STAMP_MAX =
|
|
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.
|
|
119
|
-
*
|
|
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(
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
237
|
-
assert.
|
|
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");
|
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
|
+
}
|
|
@@ -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
|
+
});
|