forgedock 1.31.0 → 1.32.1

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.
@@ -0,0 +1,219 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Light-lane execution (#4450, parent #4442). An eligible issue (light.mjs, mode on) runs ONE builder session: failing
5
+ * test first, then the fix, then the targeted tests. No plan session, no pre-frozen contract: the contract record is
6
+ * derived afterwards from the diff and the test (deriveLightContract) and checked by the same validateContract as a
7
+ * planned one. The deterministic gates are kept (targeted tests, red/green repro, the API-shape detector of #4449);
8
+ * any upgrade trigger hands the lane to the full plan -> freeze -> implement path, with the light work kept on the
9
+ * branch and carried over as a digest (#4400). runLightStep takes every effect as a callback, so lean.mjs only wires it.
10
+ */
11
+ import { classifyPath } from "./routing.mjs";
12
+ import { upgradeTriggers } from "./light.mjs";
13
+ import { inIssue } from "./conformance.mjs";
14
+ import { runChecks } from "./precheck.mjs";
15
+ import { LEAN_DIR, RESULT_FILE, IMPLEMENTATION_FILE, FORGEDOCK_CLI, hostRules } from "./prompts.mjs";
16
+
17
+ const UPGRADE_NOTE_MAX = 10;
18
+ const DETAIL_MAX = 200;
19
+ const one = (s, max = DETAIL_MAX) => {
20
+ const t = String(s ?? "").replace(/<!--|--!?>/g, " ").replace(/\s+/g, " ").trim().replace(/</g, "&lt;");
21
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
22
+ };
23
+
24
+ /** The one builder session of a light lane: failing test, fix, targeted tests, result.json with what the contract is derived from. */
25
+ export function lightBuildPrompt(n, opts = {}) {
26
+ return `Resolve issue #${n} (read it with gh issue view ${n} --comments) in ONE session; this is a light lane for a small, well-specified issue. There is no plan step and no frozen contract: the engine derives the contract from your diff and your test.
27
+
28
+ ${hostRules(opts)}
29
+
30
+ 1. Write the failing regression test first and run it: it must fail on the current code for the reason the issue describes.
31
+ 2. Make the smallest fix that makes it pass. Keep the public API shape unchanged (signatures, sync/async, generator/function, exported names); the engine checks it.
32
+ 3. Run the targeted tests (the new test plus the existing tests of the files you changed), run the repo's formatter on the files you changed, and commit. Do not push or touch GitHub.
33
+ 4. Run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes.
34
+
35
+ When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
36
+ ### Approach
37
+ ### Changes (file: what)
38
+ ### Verification (each test command, its result, and whether the new test fails without the fix)
39
+
40
+ Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirement":"<one sentence of the issue, verbatim, that this fix delivers>","repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"},"tests":[{"command":"<targeted test command>","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}]}
41
+ If the issue is invalid, already fixed, blocked, or bigger than a small fix (more than a couple of files, a design decision), do not grind: write {"status":"upgrade","summary":"<why>"} and stop. The engine then runs the full lane, keeping what you committed.`;
42
+ }
43
+
44
+ /** "-" (binary) and junk count 0. */
45
+ const count = (v) => (/^\d+$/.test(String(v ?? "")) ? Number(v) : 0);
46
+ /** A numstat path, rename forms resolved to the new path: `a => b`, `dir/{a => b}/f`. */
47
+ const newPath = (p) => {
48
+ const s = String(p ?? "").trim();
49
+ const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(s);
50
+ if (brace) return `${brace[1]}${brace[3]}${brace[4]}`.replace(/\/\/+/g, "/");
51
+ const arrow = s.indexOf(" => ");
52
+ return arrow >= 0 ? s.slice(arrow + 4) : s;
53
+ };
54
+ /** `git diff --numstat` output -> {files:[{path, added, deleted}]} for upgradeTriggers; the engine's own dir is skipped. */
55
+ export function parseNumstat(out) {
56
+ const files = [];
57
+ for (const line of String(out ?? "").split("\n")) {
58
+ const m = /^(\S+)\t(\S+)\t(.+)$/.exec(line.trim() ? line : "");
59
+ if (!m) continue;
60
+ const path = newPath(m[3]);
61
+ if (!path || path === LEAN_DIR || path.startsWith(`${LEAN_DIR}/`)) continue;
62
+ files.push({ path, added: count(m[1]), deleted: count(m[2]) });
63
+ }
64
+ return { files };
65
+ }
66
+
67
+ /** detectApiShape output (apishape.mjs) -> upgradeTriggers apiShape strings; a missing or unreadable result is `unknown` (#4449). */
68
+ export function apiShapeReasons(r) {
69
+ if (!r || typeof r !== "object" || !["none", "changed", "unknown"].includes(r.status)) return ["api shape: no detector result (unknown)"];
70
+ const out = [];
71
+ for (const c of Array.isArray(r.changes) ? r.changes : []) out.push(one(`${c?.file ?? "?"}: ${c?.symbol ?? "?"} ${c?.change ?? "changed"}`));
72
+ for (const u of Array.isArray(r.unknown) ? r.unknown : []) out.push(one(`${u?.file ?? "?"}: unknown${u?.lang ? ` (${u.lang})` : ""}${u?.reason ? `, ${u.reason}` : ""}`));
73
+ if (r.status !== "none" && !out.length) out.push(`api shape: ${r.status}`);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The contract record of a light lane, from the diff and the builder's test (pure). requirement: the builder's verbatim
79
+ * issue sentence, else the issue title; deliverables: the changed non-test files; validate: the repro command plus each
80
+ * targeted test command; repro_test: the builder's, else "none" with a reason. validateContract judges it like any other.
81
+ */
82
+ export function deriveLightContract({ title = "", issueText = "", result = null, files = [] } = {}) {
83
+ const r = result && typeof result === "object" ? result : {};
84
+ const said = typeof r.requirement === "string" ? r.requirement.trim() : "";
85
+ const quote = said && inIssue(said, issueText) ? said : String(title ?? "").trim();
86
+ const paths = [...new Set((Array.isArray(files) ? files : []).map((f) => String(f ?? "").trim()).filter((f) => f && f !== LEAN_DIR && !f.startsWith(`${LEAN_DIR}/`)))];
87
+ const code = paths.filter((f) => classifyPath(f) !== "test");
88
+ const t = r.repro_test;
89
+ const repro = t && typeof t === "object" && !Array.isArray(t) && String(t.path ?? "").trim() && String(t.command ?? "").trim()
90
+ ? { path: String(t.path).trim(), id: String(t.id ?? "").trim(), command: String(t.command).trim() } : null;
91
+ const commands = [...new Set([repro?.command, ...(Array.isArray(r.tests) ? r.tests : []).map((x) => (x && typeof x === "object" ? String(x.command ?? "").trim() : ""))].filter(Boolean))];
92
+ return {
93
+ requirements: [{ id: "R1", quote, disposition: "deliver" }],
94
+ deliverables: code.map((path, i) => ({ id: `D${i + 1}`, path, change: "light-lane fix (derived from the diff)", requirements: ["R1"] })),
95
+ validate: commands.map((command, i) => ({ id: `V${i + 1}`, command })),
96
+ ...(repro ? { repro_test: repro } : { repro_test: "none", repro_test_reason: "the light-lane builder named no repro test" }),
97
+ };
98
+ }
99
+
100
+ /** The full lane's prompt addition after an upgrade: why the light lane stopped (bounded, one line per reason). */
101
+ export function upgradeNote(reasons) {
102
+ const rs = (Array.isArray(reasons) ? reasons : []).filter((x) => x && x.code);
103
+ if (!rs.length) return "";
104
+ const lines = rs.slice(0, UPGRADE_NOTE_MAX).map((x) => `- ${one(x.code, 40)}: ${one(x.detail)}`);
105
+ if (rs.length > UPGRADE_NOTE_MAX) lines.push(`- … ${rs.length - UPGRADE_NOTE_MAX} more`);
106
+ return `\n\nLight-lane upgrade: a single light builder session already worked on this issue and its commits are on the branch. The engine moved the issue to the full lane because:\n${lines.join("\n")}\nKeep the committed work that fits the issue; plan and fix what these reasons show.`;
107
+ }
108
+
109
+ /**
110
+ * #4451: the light targeted tests, concurrently through precheck.mjs runChecks (one heavy slot per running command, the
111
+ * worker cap `parallelism` divided among them) with no round budget: every command runs, none is deferred to CI.
112
+ * run({command, workers}) -> {rc, output}. Returns [{command, rc, output}] in input order.
113
+ */
114
+ export async function runLightTests({ commands, parallelism = 1, run, acquire, now = () => Date.now() }) {
115
+ // the per-command limit stays far below the budget, so runChecks never sees a budget cut: a timeout (rc 124) stays a failure
116
+ const { results } = await runChecks({ commands, parallelism, budgetMs: Number.MAX_SAFE_INTEGER, perCommandMs: 2 ** 40, now, acquire, run: ({ command, workers }) => run({ command, workers }) });
117
+ return results.map(({ command, rc, output }) => ({ command, rc, output }));
118
+ }
119
+
120
+ /**
121
+ * One light lane step. ctx callbacks (all async unless noted): build() runs the one builder session; readResult();
122
+ * checkpoint() commits uncommitted work; changedFiles(); numstat() -> `git diff --numstat` text; runTests(commands) ->
123
+ * [{command, rc, output}] or null (no runner); fixTests(failed) resumes the builder once; reproOf(contract) -> {base, head}
124
+ * or null; apiShape() -> detectApiShape output or null; validate(contract) -> {ok, problems} (sync). Plus title, issueText,
125
+ * riskPaths, cfg. Returns {upgrade:false, contract, files, repro, testFailures} or {upgrade:true, reasons, files, ...}, plus the
126
+ * gate inputs of the FORGE:LITE receipt (#4452): diff (parseNumstat), apiShape (apiShapeReasons), testCommands, testRuns.
127
+ * Never throws: an internal error is the upgrade reason light_error.
128
+ */
129
+ export async function runLightStep(ctx) {
130
+ const up = (reasons, extra = {}) => ({ upgrade: true, reasons, contract: null, files: [], repro: null, testFailures: 0, diff: null, apiShape: null, testCommands: [], testRuns: null, ...extra });
131
+ try {
132
+ await ctx.build();
133
+ const result = await ctx.readResult();
134
+ await ctx.checkpoint();
135
+ const files = await ctx.changedFiles();
136
+ if (!files.length) return up([{ code: "no_commit", detail: `the light builder made no commit (status ${one(result?.status ?? "none", 40)})` }]);
137
+ if (result?.status !== "fixed") return up([{ code: "not_fixed", detail: `light builder status ${one(result?.status ?? "none", 40)}${result?.summary ? `: ${one(result.summary)}` : ""}` }], { files });
138
+ const contract = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result, files });
139
+ const vc = ctx.validate(contract);
140
+ if (!vc?.ok) return up([{ code: "contract_invalid", detail: `derived contract: ${one((vc?.problems ?? [])[0] ?? "invalid")}` }], { files });
141
+
142
+ let testFailures = 0;
143
+ const commands = contract.validate.map((v) => v.command);
144
+ let runs = await ctx.runTests(commands);
145
+ let failed = (runs ?? []).filter((x) => x?.rc !== 0);
146
+ if (failed.length) {
147
+ testFailures = 1;
148
+ await ctx.fixTests(failed);
149
+ runs = await ctx.runTests(commands);
150
+ failed = (runs ?? []).filter((x) => x?.rc !== 0);
151
+ if (failed.length) testFailures = 2;
152
+ }
153
+ const now = await ctx.changedFiles();
154
+ // #4451: the three deterministic gates are independent, so they run concurrently
155
+ const [repro, numstat, shape] = await Promise.all([ctx.reproOf(contract), ctx.numstat(), ctx.apiShape()]);
156
+ const diff = parseNumstat(numstat), apiShape = apiShapeReasons(shape);
157
+ const t = upgradeTriggers({ diff, repro, testFailures, riskPaths: ctx.riskPaths ?? [], apiShape, cfg: ctx.cfg ?? {} });
158
+ const latest = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result: (await ctx.readResult()) ?? result, files: now });
159
+ return { upgrade: t.upgrade, reasons: t.reasons, contract: t.upgrade ? null : (ctx.validate(latest)?.ok ? latest : contract), files: now, repro, testFailures,
160
+ diff, apiShape, testCommands: commands, testRuns: runs == null ? null : (runs ?? []).length };
161
+ } catch (e) {
162
+ return up([{ code: "light_error", detail: one(e?.message ?? e) }]);
163
+ }
164
+ }
165
+
166
+ const usd = (x) => (typeof x === "number" && Number.isFinite(x) ? `$${x.toFixed(4)}` : "n/a");
167
+ const RECEIPT_ROWS_MAX = 10;
168
+ const bullets = (xs) => {
169
+ const lines = xs.slice(0, RECEIPT_ROWS_MAX).map((x) => ` - ${x}`);
170
+ if (xs.length > RECEIPT_ROWS_MAX) lines.push(` - … ${xs.length - RECEIPT_ROWS_MAX} more`);
171
+ return lines;
172
+ };
173
+
174
+ /**
175
+ * The FORGE:LITE receipt of a light lane (#4452, parent #4442), without the marker (pure): outcome, diff stat, red/green
176
+ * repro, gates (targeted tests, API shape, derived contract), cost and eligibility, plus the upgrade reasons when the lane
177
+ * moved to the full pipeline. `costUsd` is the lane's true cost so far: the sum of #4407 per-session deltas, never a
178
+ * running session total. `lr` is runLightStep's return; `eligibility` is lightEligibility's.
179
+ */
180
+ export function renderLiteReceipt({ lr = {}, eligibility = null, costUsd = null, contractHash = null } = {}) {
181
+ const r = lr && typeof lr === "object" ? lr : {};
182
+ const upgraded = !!r.upgrade;
183
+ const rows = Array.isArray(r.diff?.files) ? r.diff.files : null;
184
+ const nonTest = rows ? rows.filter((f) => classifyPath(String(f.path ?? "")) !== "test") : [];
185
+ const add = (xs) => xs.reduce((n, f) => n + (Number(f.added) || 0), 0), del = (xs) => xs.reduce((n, f) => n + (Number(f.deleted) || 0), 0);
186
+ const diff = rows
187
+ ? `${rows.length} file(s), +${add(rows)}/-${del(rows)} (non-test: ${nonTest.length} file(s), ${add(nonTest) + del(nonTest)} line(s))`
188
+ : `n/a${Array.isArray(r.files) && r.files.length ? ` (${r.files.length} changed file(s))` : ""}`;
189
+ const rp = r.repro && typeof r.repro === "object" ? r.repro : null;
190
+ const repro = rp ? `base ${one(rp.base || "?", 40)} → head ${one(rp.head || "?", 40)}${rp.base === "FAILED" ? " (red on base)" : " (not red on base)"}` : "n/a";
191
+ const cmds = Array.isArray(r.testCommands) ? r.testCommands : [];
192
+ const fails = Number(r.testFailures) || 0;
193
+ const tests = r.testRuns == null ? (cmds.length ? `${cmds.length} command(s), no runner` : "not run")
194
+ : `${cmds.length} command(s), ${fails ? (fails >= 2 ? "failed twice" : "failed once, passed after one fix") : "passed"}`;
195
+ const api = Array.isArray(r.apiShape) ? (r.apiShape.length ? `${r.apiShape.length} change(s)/unknown` : "unchanged") : "not checked";
196
+ const contract = upgraded ? "not derived (upgraded)" : contractHash ? `derived, valid (\`${one(contractHash, 20)}\`)` : "derived, valid";
197
+ const e = eligibility && typeof eligibility === "object" ? eligibility : {};
198
+ const eReasons = (Array.isArray(e.reasons) ? e.reasons : []).filter((x) => x && x.code).map((x) => `${one(x.code, 40)}: ${one(x.detail)}`);
199
+ const lines = [
200
+ `## Light lane ${upgraded ? "upgraded to the full lane" : "receipt"}`,
201
+ `**Outcome:** ${upgraded ? "upgraded" : "light"}`,
202
+ `**Diff:** ${diff}`,
203
+ `**Red/green:** ${repro}`,
204
+ "**Gates:**",
205
+ ` - targeted tests: ${tests}`,
206
+ ...bullets(cmds.map((c) => `\`${one(c, 120)}\``)).map((x) => ` ${x}`),
207
+ ` - API shape: ${api}`,
208
+ ...(Array.isArray(r.apiShape) ? bullets(r.apiShape.map((x) => one(x))).map((x) => ` ${x}`) : []),
209
+ ` - contract: ${contract}`,
210
+ `**Cost:** ${usd(costUsd)} (true per-session cost, #4407 deltas)`,
211
+ `**Eligibility:** ${one(e.verdict ?? "unknown", 20)} (mode ${one(e.mode ?? "?", 10)})${e.verdict === "eligible" && !eReasons.length ? ": repro or expected behaviour, predicted files within limits, size S, no risk path" : ""}`,
212
+ ...bullets(eReasons),
213
+ ];
214
+ if (upgraded) {
215
+ const ups = (Array.isArray(r.reasons) ? r.reasons : []).filter((x) => x && x.code).map((x) => `${one(x.code, 40)}: ${one(x.detail)}`);
216
+ lines.push("**Upgrade reasons:**", ...bullets(ups.length ? ups : ["unknown"]));
217
+ }
218
+ return lines.join("\n");
219
+ }
@@ -0,0 +1,237 @@
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 { deriveLightContract, runLightStep, runLightTests, parseNumstat, apiShapeReasons, upgradeNote, lightBuildPrompt, renderLiteReceipt } from "./lightlane.mjs";
6
+ import { validateContract } from "./conformance.mjs";
7
+
8
+ const TITLE = "foo crashes on null input";
9
+ const ISSUE = `${TITLE}\n\nfoo crashes on null input in src/foo.js.\n\n- foo must handle a null argument without throwing.\n- Existing callers of foo keep working.`;
10
+ const RT = { path: "test/foo.test.js", id: "foo handles null", command: "node --test test/foo.test.js" };
11
+ const RESULT = { status: "fixed", summary: "guard null", requirement: "foo must handle a null argument without throwing.", repro_test: RT, tests: [{ command: "node --test test/foo.test.js", result: "pass" }, { command: "node --test test/bar.test.js", result: "pass" }] };
12
+ const FILES = ["src/foo.js", "test/foo.test.js"];
13
+ const validate = (c) => validateContract(c, ISSUE, { repro: true });
14
+
15
+ describe("deriveLightContract (#4450)", () => {
16
+ it("quotes the builder's verbatim sentence, delivers the changed non-test files, validates the repro and the targeted tests", () => {
17
+ const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: RESULT, files: [...FILES, ".forge-lean/result.json"] });
18
+ assert.deepEqual(c.requirements, [{ id: "R1", quote: "foo must handle a null argument without throwing.", disposition: "deliver" }]);
19
+ assert.deepEqual(c.deliverables.map((d) => d.path), ["src/foo.js"]);
20
+ assert.deepEqual(c.validate, [{ id: "V1", command: "node --test test/foo.test.js" }, { id: "V2", command: "node --test test/bar.test.js" }]);
21
+ assert.deepEqual(c.repro_test, RT);
22
+ assert.equal(validate(c).ok, true, validate(c).problems.join("; "));
23
+ });
24
+ it("a sentence that is not in the issue falls back to the title; no repro test is repro_test none with a reason", () => {
25
+ const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: { ...RESULT, requirement: "make foo better in every way possible", repro_test: undefined }, files: FILES });
26
+ assert.equal(c.requirements[0].quote, TITLE);
27
+ assert.equal(c.repro_test, "none");
28
+ assert.ok(c.repro_test_reason);
29
+ assert.deepEqual(c.validate.map((v) => v.command), ["node --test test/foo.test.js", "node --test test/bar.test.js"]);
30
+ });
31
+ it("a test-only diff has no deliverable, so the derived contract is invalid", () => {
32
+ const c = deriveLightContract({ title: TITLE, issueText: ISSUE, result: RESULT, files: ["test/foo.test.js"] });
33
+ assert.equal(validate(c).ok, false);
34
+ });
35
+ });
36
+
37
+ describe("parseNumstat / apiShapeReasons / upgradeNote", () => {
38
+ it("numstat rows, binary as 0, renames to the new path, the engine dir skipped", () => {
39
+ assert.deepEqual(parseNumstat("3\t1\tsrc/foo.js\n-\t-\timg.png\n2\t0\tsrc/{a.js => b.js}\n1\t1\told.js => new.js\n4\t0\t.forge-lean/result.json\n").files, [
40
+ { path: "src/foo.js", added: 3, deleted: 1 }, { path: "img.png", added: 0, deleted: 0 }, { path: "src/b.js", added: 2, deleted: 0 }, { path: "new.js", added: 1, deleted: 1 }]);
41
+ });
42
+ it("none is no reason; changes and unknown files are reasons; a missing result is unknown", () => {
43
+ assert.deepEqual(apiShapeReasons({ status: "none", changes: [], unknown: [] }), []);
44
+ assert.deepEqual(apiShapeReasons({ status: "changed", changes: [{ file: "a.py", symbol: "ichunked", change: "generator-function" }], unknown: [] }), ["a.py: ichunked generator-function"]);
45
+ assert.deepEqual(apiShapeReasons({ status: "unknown", changes: [], unknown: [{ file: "x.go", lang: "go", reason: "unsupported-language" }] }), ["x.go: unknown (go), unsupported-language"]);
46
+ assert.equal(apiShapeReasons(null).length, 1);
47
+ });
48
+ it("the upgrade note lists the reasons, escapes markup and is empty without reasons", () => {
49
+ assert.equal(upgradeNote([]), "");
50
+ const n = upgradeNote([{ code: "diff_lines", detail: "90 non-test lines (max 40) <!-- x -->" }]);
51
+ assert.match(n, /Light-lane upgrade/);
52
+ assert.match(n, /- diff_lines: 90 non-test lines \(max 40\)/);
53
+ assert.ok(!n.includes("<!--"));
54
+ });
55
+ it("the light prompt asks for test first, fix, targeted tests and the derivation fields", () => {
56
+ const p = lightBuildPrompt(7);
57
+ assert.match(p, /failing regression test first/);
58
+ assert.match(p, /"requirement":/);
59
+ assert.match(p, /"repro_test":/);
60
+ assert.match(p, /"status":"upgrade"/);
61
+ assert.match(p, /Host rules/);
62
+ });
63
+ });
64
+
65
+ /** A light step whose effects are fakes; `o` overrides any of them. */
66
+ function step(o = {}) {
67
+ const calls = { build: 0, fix: 0, tests: 0, checkpoint: 0 };
68
+ const ctx = {
69
+ title: TITLE, issueText: ISSUE, riskPaths: [], cfg: { mode: "on" }, validate,
70
+ build: async () => { calls.build++; },
71
+ readResult: async () => o.result ?? RESULT,
72
+ checkpoint: async () => { calls.checkpoint++; },
73
+ changedFiles: async () => o.files ?? FILES,
74
+ numstat: async () => o.numstat ?? "5\t1\tsrc/foo.js\n20\t0\ttest/foo.test.js\n",
75
+ runTests: async (cmds) => { calls.tests++; const rc = (o.testRcs ?? [])[calls.tests - 1] ?? 0; return cmds.map((command) => ({ command, rc, output: rc ? "not ok 1" : "" })); },
76
+ fixTests: async () => { calls.fix++; },
77
+ reproOf: async () => o.repro ?? { base: "FAILED", head: "RAN" },
78
+ apiShape: async () => (o.api === undefined ? { status: "none", changes: [], unknown: [] } : o.api),
79
+ ...(o.ctx ?? {}),
80
+ };
81
+ return { ctx, calls };
82
+ }
83
+ const codes = (r) => r.reasons.map((x) => x.code);
84
+
85
+ describe("runLightStep (#4450)", () => {
86
+ it("nothing triggers: one build, the derived contract, no fix round", async () => {
87
+ const { ctx, calls } = step();
88
+ const r = await runLightStep(ctx);
89
+ assert.equal(r.upgrade, false, JSON.stringify(r.reasons));
90
+ assert.equal(calls.build, 1); assert.equal(calls.fix, 0); assert.equal(calls.checkpoint, 1);
91
+ assert.deepEqual(r.contract.deliverables.map((d) => d.path), ["src/foo.js"]);
92
+ });
93
+ const cases = [
94
+ ["diff_lines", { numstat: "60\t0\tsrc/foo.js\n" }],
95
+ ["diff_files", { numstat: "1\t0\tsrc/a.js\n1\t0\tsrc/b.js\n1\t0\tsrc/c.js\n", files: ["src/a.js", "src/b.js", "src/c.js", "test/foo.test.js"] }],
96
+ ["repro_not_red", { repro: { base: "PASSED", head: "RAN" } }],
97
+ ["tests_failed_twice", { testRcs: [1, 1] }],
98
+ ["risk_path", { numstat: "1\t0\tsrc/auth/login.js\n", files: ["src/auth/login.js", "test/foo.test.js"] }],
99
+ ["api_shape", { api: { status: "changed", changes: [{ file: "src/foo.js", symbol: "foo", change: "signature" }], unknown: [] } }],
100
+ ["api_shape", { api: null }],
101
+ ["not_fixed", { result: { status: "upgrade", summary: "too big" } }],
102
+ ["no_commit", { files: [] }],
103
+ ["contract_invalid", { files: ["test/foo.test.js"], numstat: "3\t0\ttest/foo.test.js\n" }],
104
+ ["light_error", { ctx: { numstat: async () => { throw new Error("git broke"); } } }],
105
+ ];
106
+ for (const [code, o] of cases) {
107
+ it(`upgrades on ${code}${o.api === null ? " (no detector result)" : ""}`, async () => {
108
+ const r = await runLightStep(step(o).ctx);
109
+ assert.equal(r.upgrade, true);
110
+ assert.ok(codes(r).includes(code), codes(r).join(","));
111
+ assert.equal(r.contract, null);
112
+ });
113
+ }
114
+ it("one failed test run gets one fix round and stays light when the rerun passes", async () => {
115
+ const { ctx, calls } = step({ testRcs: [1, 0] });
116
+ const r = await runLightStep(ctx);
117
+ assert.equal(r.upgrade, false); assert.equal(calls.fix, 1); assert.equal(r.testFailures, 1);
118
+ });
119
+ });
120
+
121
+ describe("#4451 light prechecks run concurrently, capped by host parallelism", () => {
122
+ /** fake commands that each wait for an explicit release; records start/end order and the max in flight */
123
+ const fake = () => {
124
+ const log = [], gates = new Map();
125
+ let live = 0, max = 0;
126
+ const run = ({ command }) => new Promise((resolve) => {
127
+ live++; max = Math.max(max, live); log.push(`start ${command}`);
128
+ gates.set(command, () => { live--; log.push(`end ${command}`); resolve({ rc: command === "b" ? 1 : 0, output: command }); });
129
+ });
130
+ const tick = () => new Promise((r) => setImmediate(r));
131
+ return { run, log, gates, tick, max: () => max };
132
+ };
133
+ it("parallelism >= count: every command starts before the first finishes; results in input order", async () => {
134
+ const f = fake();
135
+ const p = runLightTests({ commands: ["a", "b", "c"], parallelism: 4, run: f.run });
136
+ await f.tick();
137
+ assert.deepEqual(f.log, ["start a", "start b", "start c"]);
138
+ for (const c of ["c", "a", "b"]) { f.gates.get(c)(); await f.tick(); }
139
+ const r = await p;
140
+ assert.deepEqual(r, [{ command: "a", rc: 0, output: "a" }, { command: "b", rc: 1, output: "b" }, { command: "c", rc: 0, output: "c" }]);
141
+ assert.equal(f.max(), 3);
142
+ });
143
+ it("parallelism 2 of 4: never more than 2 in flight; the next starts only when a slot frees", async () => {
144
+ const f = fake();
145
+ const p = runLightTests({ commands: ["a", "b", "c", "d"], parallelism: 2, run: f.run });
146
+ await f.tick();
147
+ assert.deepEqual(f.log, ["start a", "start b"]);
148
+ f.gates.get("b")(); await f.tick();
149
+ assert.deepEqual(f.log.slice(2), ["end b", "start c"]);
150
+ for (const c of ["a", "c"]) { f.gates.get(c)(); await f.tick(); }
151
+ f.gates.get("d")();
152
+ assert.equal((await p).length, 4);
153
+ assert.equal(f.max(), 2);
154
+ });
155
+ it("parallelism 1 runs them one after another; a timeout (rc 124) stays a failure, never deferred", async () => {
156
+ const order = [];
157
+ let t = 0; // a clock that advances between commands (a wall clock does too, in a slow run)
158
+ const r = await runLightTests({ commands: ["x", "y"], parallelism: 1, now: () => (t += 1000), run: async ({ command }) => { order.push(command); return { rc: command === "y" ? 124 : 0, output: "" }; } });
159
+ assert.deepEqual(order, ["x", "y"]);
160
+ assert.deepEqual(r.map((x) => x.rc), [0, 124]);
161
+ });
162
+ it("each held command takes one acquire slot and releases it", async () => {
163
+ let held = 0, peak = 0;
164
+ const acquire = async () => { held++; peak = Math.max(peak, held); return { release: () => { held--; } }; };
165
+ await runLightTests({ commands: ["a", "b", "c"], parallelism: 2, acquire, run: async () => ({ rc: 0, output: "" }) });
166
+ assert.equal(held, 0); assert.ok(peak <= 2);
167
+ });
168
+ it("runLightStep starts the repro, numstat and API-shape gates before any of them resolves", async () => {
169
+ const started = [], pending = [];
170
+ const later = (name, v) => () => new Promise((res) => { started.push(name); pending.push(() => res(v)); });
171
+ const { ctx } = step({ ctx: { reproOf: later("repro", { base: "FAILED", head: "RAN" }), numstat: later("numstat", "5\t1\tsrc/foo.js\n"), apiShape: later("api", { status: "none", changes: [], unknown: [] }) } });
172
+ const p = runLightStep(ctx);
173
+ for (let i = 0; i < 20 && started.length < 3; i++) await new Promise((r) => setImmediate(r));
174
+ assert.deepEqual([...started].sort(), ["api", "numstat", "repro"], "all three in flight together");
175
+ pending.forEach((f) => f());
176
+ assert.equal((await p).upgrade, false);
177
+ });
178
+ });
179
+
180
+ describe("renderLiteReceipt (#4452)", () => {
181
+ const ELIG = { mode: "on", verdict: "eligible", eligible: true, light: true, reasons: [] };
182
+
183
+ it("renders diff stat, red/green result, gates, true cost and eligibility reason for a light lane", async () => {
184
+ const r = await runLightStep(step().ctx);
185
+ const body = renderLiteReceipt({ lr: r, eligibility: ELIG, costUsd: 0.4321, contractHash: "c0ffee12" });
186
+ assert.match(body, /^## Light lane receipt/);
187
+ assert.match(body, /\*\*Outcome:\*\* light/);
188
+ assert.match(body, /\*\*Diff:\*\* 2 file\(s\), \+25\/-1 \(non-test: 1 file\(s\), 6 line\(s\)\)/);
189
+ assert.match(body, /\*\*Red\/green:\*\* base FAILED → head RAN \(red on base\)/);
190
+ assert.match(body, /\*\*Gates:\*\*\n - targeted tests: 2 command\(s\), passed/);
191
+ assert.match(body, /`node --test test\/foo\.test\.js`/);
192
+ assert.match(body, /API shape: unchanged/);
193
+ assert.match(body, /contract: derived, valid \(`c0ffee12`\)/);
194
+ assert.match(body, /\*\*Cost:\*\* \$0\.4321 \(true per-session cost, #4407 deltas\)/);
195
+ assert.match(body, /\*\*Eligibility:\*\* eligible \(mode on\): repro or expected behaviour/);
196
+ assert.ok(!/Upgrade reasons/.test(body));
197
+ assert.ok(!/<!--|undefined|NaN/.test(body), body);
198
+ });
199
+
200
+ it("an upgraded lane lists the gates that fired and the upgrade reasons; free text is sanitised", async () => {
201
+ const api = { status: "changed", changes: [{ file: "src/foo.js", symbol: "foo", change: "sync-async" }], unknown: [] };
202
+ const r = await runLightStep(step({ api, testRcs: [1, 1], repro: { base: "PASSED", head: "RAN" } }).ctx);
203
+ assert.equal(r.upgrade, true);
204
+ const elig = { mode: "on", verdict: "eligible", reasons: [{ code: "no_repro", detail: "x <!-- y --> <b>" }] };
205
+ const body = renderLiteReceipt({ lr: r, eligibility: elig, costUsd: 1.5 });
206
+ assert.match(body, /^## Light lane upgraded to the full lane/);
207
+ assert.match(body, /\*\*Outcome:\*\* upgraded/);
208
+ assert.match(body, /base PASSED → head RAN \(not red on base\)/);
209
+ assert.match(body, /targeted tests: 2 command\(s\), failed twice/);
210
+ assert.match(body, /API shape: 1 change\(s\)\/unknown\n {4}- src\/foo\.js: foo sync-async/);
211
+ assert.match(body, /contract: not derived \(upgraded\)/);
212
+ assert.match(body, /\*\*Upgrade reasons:\*\*[\s\S]*- api_shape: [\s\S]*- repro_not_red: [\s\S]*- tests_failed_twice: /);
213
+ assert.match(body, /- no_repro: x y &lt;b>/);
214
+ assert.ok(!/<!--|--!?>/.test(body));
215
+ });
216
+
217
+ it("an early upgrade (no commit) renders n/a, never undefined", async () => {
218
+ const r = await runLightStep(step({ files: [] }).ctx);
219
+ const body = renderLiteReceipt({ lr: r, eligibility: null, costUsd: null });
220
+ assert.match(body, /\*\*Diff:\*\* n\/a/);
221
+ assert.match(body, /\*\*Red\/green:\*\* n\/a/);
222
+ assert.match(body, /targeted tests: not run/);
223
+ assert.match(body, /API shape: not checked/);
224
+ assert.match(body, /\*\*Cost:\*\* n\/a/);
225
+ assert.match(body, /- no_commit: /);
226
+ assert.ok(!/undefined|NaN/.test(body), body);
227
+ });
228
+
229
+ it("runLightStep returns the receipt inputs alongside its #4450 fields", async () => {
230
+ const r = await runLightStep(step().ctx);
231
+ assert.deepEqual(r.diff, { files: [{ path: "src/foo.js", added: 5, deleted: 1 }, { path: "test/foo.test.js", added: 20, deleted: 0 }] });
232
+ assert.deepEqual(r.apiShape, []);
233
+ assert.deepEqual(r.testCommands, ["node --test test/foo.test.js", "node --test test/bar.test.js"]);
234
+ assert.equal(r.testRuns, 2);
235
+ for (const k of ["upgrade", "reasons", "contract", "files", "repro", "testFailures"]) assert.ok(k in r, k);
236
+ });
237
+ });