forgedock 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -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
+ });
@@ -108,12 +108,47 @@ describe("account-scoped usage-limit holds (#4190)", () => {
108
108
  const limits = openLimits(storeFile(), { now });
109
109
  limits.set("A", t0 + 3_600_000, "batch b0 #1");
110
110
  const starts = [];
111
- const sleep = async (ms) => { limits.clear("A"); c.t += Math.min(ms, 5000); };
111
+ const sleep = async (ms) => { limits.clear("A"); c.t += ms; }; // faithful: a sleep advances the full time asked for
112
112
  await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => "A", getParallel: () => 1, pollMs: 5000,
113
113
  runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
114
114
  assert.equal(starts[0], t0 + 5000, "admitted on the poll after the clear, not at the hour-long reset");
115
115
  });
116
116
 
117
+ it("a held batch polls the host store: limits clear admits within one poll without getCancelled or getParallel (#4446)", async () => {
118
+ const { c, now } = fakeClock();
119
+ const t0 = c.t;
120
+ const file = storeFile();
121
+ const limits = openLimits(file, { now });
122
+ limits.set("A", t0 + 3_600_000, "batch b0 #1");
123
+ const sleeps = [];
124
+ // The operator clears the hold from another process (its own handle on the host file) while the batch sleeps.
125
+ const sleep = async (ms) => {
126
+ sleeps.push(ms);
127
+ if (sleeps.length === 1) await quiet(() => limitsFromCli(["clear", "A"], { limits: openLimits(file, { now }), identity: () => "A" }));
128
+ c.t += ms;
129
+ };
130
+ const starts = [];
131
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, now, sleep, readBody: async () => "", limits, identity: () => "A", pollMs: 5000,
132
+ runOne: async (n) => { starts.push([n, now()]); return { terminalReason: "merged" }; } });
133
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
134
+ assert.ok(starts.every(([, at]) => at <= t0 + 30_000), `admitted within 30s of the clear, got ${JSON.stringify(starts)}`);
135
+ assert.deepEqual(starts, [[1, t0 + 5000], [2, t0 + 5000]], "admitted on the first poll after the clear");
136
+ });
137
+
138
+ it("a held batch keeps waiting while the hold is present (#4446)", async () => {
139
+ const { c, now } = fakeClock();
140
+ const t0 = c.t;
141
+ const limits = openLimits(storeFile(), { now });
142
+ limits.set("A", t0 + 3_600_000, "batch b0 #1");
143
+ const sleeps = [];
144
+ const sleep = async (ms) => { sleeps.push(ms); c.t += ms; };
145
+ const starts = [];
146
+ await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => "A", pollMs: 5000,
147
+ runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
148
+ assert.deepEqual(starts, [t0 + 3_600_000], "admitted exactly at the reset, never earlier");
149
+ assert.ok(sleeps.length > 1 && sleeps.every((ms) => ms > 0 && ms <= 5000), "re-read the store every poll while held");
150
+ });
151
+
117
152
  it("a legacy batch-local holdUntil is ignored on resume and no longer persisted", async () => {
118
153
  const { c, now, sleep } = fakeClock();
119
154
  let state = { v: 1, order: [1], pending: [1], holdUntil: c.t + 3_600_000 };