forgedock 1.34.0 → 1.36.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 (61) hide show
  1. package/README.md +78 -324
  2. package/bin/engine/events.mjs +152 -0
  3. package/bin/engine/events.test.mjs +128 -0
  4. package/bin/engine/telemetry-seam.mjs +4 -3
  5. package/bin/engine-cli.mjs +181 -26
  6. package/bin/experiment.mjs +55 -0
  7. package/bin/forgedock.mjs +19 -1
  8. package/bin/hooks/reread-nudge.mjs +44 -5
  9. package/bin/lean/batch.mjs +66 -3
  10. package/bin/lean/batch.test.mjs +176 -1
  11. package/bin/lean/budget.mjs +14 -3
  12. package/bin/lean/budget.test.mjs +11 -1
  13. package/bin/lean/conflictres.mjs +93 -0
  14. package/bin/lean/conflictres.test.mjs +93 -0
  15. package/bin/lean/conformance.mjs +30 -9
  16. package/bin/lean/conformance.test.mjs +51 -0
  17. package/bin/lean/effects.mjs +14 -3
  18. package/bin/lean/execution.mjs +2 -0
  19. package/bin/lean/i18n.mjs +65 -0
  20. package/bin/lean/i18n.test.mjs +47 -0
  21. package/bin/lean/lane.mjs +16 -0
  22. package/bin/lean/lean.mjs +147 -36
  23. package/bin/lean/lean.test.mjs +319 -8
  24. package/bin/lean/orchestrate.mjs +130 -13
  25. package/bin/lean/postdeploy-check.mjs +149 -0
  26. package/bin/lean/postdeploy-check.test.mjs +146 -0
  27. package/bin/lean/postdeploy-monitor.mjs +87 -0
  28. package/bin/lean/postdeploy-monitor.test.mjs +121 -0
  29. package/bin/lean/precheck.mjs +58 -8
  30. package/bin/lean/precheck.test.mjs +77 -3
  31. package/bin/lean/prefilter.mjs +29 -4
  32. package/bin/lean/prefilter.test.mjs +13 -5
  33. package/bin/lean/probe.mjs +2 -1
  34. package/bin/lean/prompts.mjs +36 -6
  35. package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
  36. package/bin/lean/sandbox-remote.mjs +7 -0
  37. package/bin/lean/schedd.mjs +34 -5
  38. package/bin/lean/schedd.test.mjs +84 -0
  39. package/bin/lean/sequence.mjs +26 -0
  40. package/bin/lean/sequence.test.mjs +41 -2
  41. package/bin/lean/toolstate.mjs +77 -0
  42. package/bin/lean/toolstate.test.mjs +83 -0
  43. package/bin/lean/verify.mjs +18 -2
  44. package/bin/lean/verify.test.mjs +29 -3
  45. package/bin/stats/aggregate.mjs +1 -1
  46. package/bin/stats/experiment.mjs +44 -0
  47. package/bin/stats/github-source.mjs +2 -1
  48. package/bin/stats/index.mjs +42 -2
  49. package/bin/stats/release.mjs +75 -0
  50. package/bin/stats/release.test.mjs +68 -0
  51. package/commands/orchestrate.md +8 -3
  52. package/docs/CONFIG.md +61 -2
  53. package/docs/EVENTS.md +68 -0
  54. package/docs/STATS.md +31 -0
  55. package/docs/TELEMETRY.md +3 -1
  56. package/docs/site/install.md +89 -0
  57. package/package.json +1 -1
  58. package/scripts/bench-lightlane.mjs +2 -1
  59. package/scripts/eval-gate-scorecard.mjs +7 -0
  60. package/scripts/file-deferred-followups.sh +1 -0
  61. package/scripts/file-deferred-followups.test.sh +8 -0
@@ -118,10 +118,14 @@ function world(o = {}) {
118
118
  if (a[0] === "write-tree") return w.salvageTrees.length ? w.salvageTrees.shift() : "";
119
119
  if (a[0] === "rev-parse" && a[1] === "HEAD^{tree}") return "0".repeat(40);
120
120
  if (a.includes("commit-tree")) return "c".repeat(40);
121
+ if (a[0] === "diff" && a.includes("--diff-filter=A")) return (w.added ?? []).join("\0"); // #4590: files the branch added vs the base
122
+ if (a[0] === "rm" && a[1] === "--cached" && w.added) w.added = w.added.filter((f) => !a.includes(f));
121
123
  if (a[0] === "diff" && a.includes("--diff-filter=U")) return w.conflictFiles.join("\n");
122
124
  if (a[0] === "diff" && a[1] === "--check") return "";
123
125
  if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
124
126
  if (a[0] === "merge" && a[1] === "--abort") { w.mergeInProgress = false; return ""; }
127
+ if (a[0] === "rev-parse" && a.length === 2 && (o.shipped ?? []).includes(String(a[1]).replace(/^origin\//, ""))) return `tip-${a[1]}`; // #4623
128
+ if (a[0] === "rev-list" && a.includes("--merges")) { const l = String(a.at(-1)).slice(1).replace(/^origin\//, ""); return (o.shipped ?? []).includes(l) ? `m1 s1 tip-origin/${l}\n` : ""; }
125
129
  if (a[0] === "rev-parse" && a.length === 2 && /^origin\//.test(String(a[1]))) return w.baseHeads.length > 1 ? w.baseHeads.shift() : w.baseHeads[0]; // #4386: the base head moves only when a test says so
126
130
  if (a[0] === "rev-parse") return w.head;
127
131
  if (a[0] === "diff" && a[1] === "--name-only" && !a.some((x) => String(x).includes("..."))) return w.changed.filter((f) => !(o.equalToBase ?? []).includes(f)).join("\n");
@@ -129,6 +133,7 @@ function world(o = {}) {
129
133
  if (a[0] === "diff" && a[1] === "--numstat") return w.numstat ?? "";
130
134
  if (a[0] === "ls-files") return [...new Set(["src/foo.js", "src/bar.js", "test/foo.test.js", ...w.changed])].join("\n");
131
135
  if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
136
+ if (a[0] === "merge-base" && a[1] === "--is-ancestor") { if ([...(o.shipped ?? []), ...(o.unusedAncestors ?? [])].includes(String(a[2]).replace(/^origin\//, ""))) return ""; throw new Error("not an ancestor"); } // #4593
132
137
  if (a[0] === "ls-remote" && String(a.at(-1)).startsWith("refs/heads/")) { const b = a.at(-1).slice("refs/heads/".length); return (o.branchesOnOrigin ?? ["staging", "main"]).includes(b) ? `abc\trefs/heads/${b}\n` : ""; }
133
138
  if (a[0] === "ls-remote") return w.remote;
134
139
  if (a[0] === "show" && w.baseFiles) { if (!(a[1] in w.baseFiles)) throw new Error(`fatal: path not in ${a[1]}`); return w.baseFiles[a[1]]; }
@@ -791,8 +796,9 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
791
796
 
792
797
  for (const d of ["defer", "post-deploy"]) {
793
798
  it(`a contracted ${d} is filed only after the reviewer accepts it, then linked in the PR and on close`, async () => {
794
- const k = two(d, { why: d === "defer" ? "separate module" : "only measurable after deploy" });
799
+ const k = two(d, { why: d === "defer" ? "separate module" : "only measurable after deploy", ...(d === "post-deploy" ? { check: "login-smoke" } : {}) });
795
800
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, { ...k, requirements: [k.requirements[0], { ...k.requirements[1], tracked: "#900" }] })] });
801
+ if (d === "post-deploy") w.baseFiles = { "origin/staging:.forge/post-deploy-checks.json": JSON.stringify({ "login-smoke": "true" }) };
796
802
  const r = await run(w);
797
803
  assert.equal(r.terminalReason, "merged");
798
804
  assert.equal(w.followups.length, 1); assert.equal(w.followups[0].parent, 7);
@@ -1618,9 +1624,9 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
1618
1624
  assert.equal(hotspotBuildRule(facts([], { file_lines: 1000, growth_lines: 50 })).trim(), "Don't grow a file over 1000 lines by more than 50 lines. Put new code next to it.");
1619
1625
  assert.equal(hotspotBuildRule(null), ""); assert.equal(hotspotBuildRule(facts([], { enabled: false })), "");
1620
1626
  const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
1621
- // 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths.
1622
- assert.ok(approxTokens(plan) <= 1850, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1623
- assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1500);
1627
+ // 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line).
1628
+ assert.ok(approxTokens(plan) <= 1900, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1629
+ assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1600); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each
1624
1630
  });
1625
1631
 
1626
1632
  it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
@@ -2038,7 +2044,7 @@ describe("comment templates", () => {
2038
2044
  it("plan and implement prompts carry the stage templates and the contract schema within budget", () => {
2039
2045
  const plan = builderPlanPrompt(7), impl = builderImplementPrompt();
2040
2046
  assert.ok(approxTokens(plan) <= 1800, `plan prompt is ${approxTokens(plan)} tokens`);
2041
- assert.ok(approxTokens(impl) <= 1500, `implement prompt is ${approxTokens(impl)} tokens`);
2047
+ assert.ok(approxTokens(impl) <= 1550, `implement prompt is ${approxTokens(impl)} tokens`); // 1550: #4596 i18n translate rule
2042
2048
  for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
2043
2049
  "investigation.md", "contract.json", "PLANNING ONLY", '"requirements"', '"deliverables"', '"preserve"', '"validate"', "verbatim", "defer", "post-deploy"]) assert.ok(plan.includes(h), h);
2044
2050
  for (const h of ["### Approach", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes", "implementation.md", "result.json",
@@ -2071,6 +2077,83 @@ describe("merge_wait policy", () => {
2071
2077
  const w2 = world(); w2.io.requiredChecks = async () => ["build"]; w2.io.checkStates = async () => states({ build: "pending" });
2072
2078
  assert.equal((await run(w2, { ciMaxMinutes: 5 })).terminalReason, "ci-pending");
2073
2079
  });
2080
+ describe("#4624 merge-time rerun / wait / flaky branch of mergeFixRound", () => {
2081
+ const RUN = (n, attempt) => `https://github.com/o/r/actions/runs/${n}/job/${attempt}`;
2082
+ const NO_FIX = { ...parseVerify({}), maxRounds: 0 };
2083
+ const WAIT = 20000;
2084
+ const waits = (w) => { const n = { n: 0 }; const s = w.io.sleep; w.io.sleep = async (ms) => { if (ms === WAIT) n.n++; return s(ms); }; return n; };
2085
+ const fixPrompts = (w) => { const p = []; const inner = w.io.runner; w.io.runner = async (c) => { if (/CI failed on the pushed branch/.test(c.prompt)) p.push(c.prompt); return inner(c); }; return p; };
2086
+ const events = (dir, name) => readLog(dir, 7).filter((e) => e.event === name);
2087
+ /** `build` (run 77) is red; `urlAfter` is its URL once the rerun was triggered, `stateAfter` its state then. */
2088
+ const redBuild = (stateAfter, urlAfter) => {
2089
+ const w = world(); w.reran = [];
2090
+ w.io.requiredChecks = async () => ["build"];
2091
+ w.io.rerunFailedJobs = async (id) => { w.reran.push(id); };
2092
+ w.io.checkStates = async () => (w.reran.length ? [{ name: "build", state: stateAfter, url: urlAfter }] : [{ name: "build", state: "failure", url: RUN(77, 1) }]);
2093
+ w.io.failedLogs = async () => "boom";
2094
+ return w;
2095
+ };
2096
+
2097
+ it("a failed required check is rerun once, waited on while its URL is unchanged, and merges when the new attempt is green without a fix round", async () => {
2098
+ const w = redBuild("success", RUN(77, 2)); const p = fixPrompts(w), dir = tmpDir("core-");
2099
+ // the rerun was triggered, but the check still reports the old attempt for two more polls
2100
+ const states = w.io.checkStates; let stale = 2;
2101
+ w.io.checkStates = async () => (w.reran.length && stale-- > 0 ? [{ name: "build", state: "failure", url: RUN(77, 1) }] : states());
2102
+ const n = waits(w);
2103
+ const r = await run(w, { dir });
2104
+ assert.equal(r.terminalReason, "merged"); assert.deepEqual(w.reran, ["77"]); assert.equal(p.length, 0);
2105
+ assert.equal(n.n, 3, "one wait after the rerun, then two while the URL is unchanged");
2106
+ assert.deepEqual(events(dir, "CI_FLAKE_RERUN").map((e) => [e.trigger, e.result, e.runs]), [["merge", "rerun-started", ["77"]]]);
2107
+ assert.equal(events(dir, "CI_FIX_ROUND").length, 0);
2108
+ });
2109
+
2110
+ it("a rerun whose check URL never changes is waited on 20 times, then falls through to the fix round; the head is rerun only once", async () => {
2111
+ const w = redBuild("failure", RUN(77, 1)); const n = waits(w), dir = tmpDir("core-");
2112
+ const r = await run(w, { dir, verify: NO_FIX });
2113
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, OUTCOME_CODES.CI_RED); assert.equal(w.merges.length, 0);
2114
+ assert.equal(n.n, 21, "the wait after the rerun plus 20 bounded waits");
2115
+ assert.deepEqual(w.reran, ["77"]);
2116
+ assert.equal(events(dir, "CI_FLAKE_RERUN").length, 1);
2117
+ });
2118
+
2119
+ it("a rerun that reports a new attempt still red goes to the fix round and reaches the builder", async () => {
2120
+ const w = redBuild("failure", RUN(77, 2)); const p = fixPrompts(w), n = waits(w), dir = tmpDir("core-");
2121
+ await run(w, { dir, verify: { ...parseVerify({}), maxRounds: 1 } });
2122
+ assert.ok(n.n >= 1); assert.equal(p.length, 1); assert.equal(w.reran[0], "77");
2123
+ assert.deepEqual(events(dir, "CI_FIX_ROUND").map((e) => [e.trigger, e.jobs]), [["merge", ["build"]]]);
2124
+ });
2125
+
2126
+ it("a rerun that cannot start is logged rerun-failed and the lane does not wait", async () => {
2127
+ const w = redBuild("failure", RUN(77, 1)); const n = waits(w), dir = tmpDir("core-");
2128
+ w.io.rerunFailedJobs = async () => { throw new Error("HTTP 403"); };
2129
+ const r = await run(w, { dir, verify: NO_FIX });
2130
+ assert.equal(r.code, OUTCOME_CODES.CI_RED); assert.equal(n.n, 0);
2131
+ assert.deepEqual(events(dir, "CI_FLAKE_RERUN").map((e) => e.result), ["rerun-failed"]);
2132
+ });
2133
+
2134
+ it("verify.flaky: a required check still red after the rerun is reported and ends needs-human; nothing merged, builder not resumed", async () => {
2135
+ const w = redBuild("failure", RUN(77, 2)); const p = fixPrompts(w), dir = tmpDir("core-");
2136
+ const r = await run(w, { dir, verify: { ...parseVerify({}), flaky: ["build"] } });
2137
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, OUTCOME_CODES.CI_RED);
2138
+ assert.equal(w.merges.length, 0); assert.equal(p.length, 0); assert.deepEqual(w.reran, ["77"]);
2139
+ assert.deepEqual(events(dir, "CI_FLAKY_REPORTED").map((e) => [e.trigger, e.jobs]), [["merge", ["build"]]]);
2140
+ assert.ok(JSON.stringify(w.comments).includes("still failed on PR #"), "needs-human message names the rerun");
2141
+ });
2142
+
2143
+ it("verify.flaky with a mix: only the non-flaky required check reaches the builder", async () => {
2144
+ const w = world(); w.reran = [];
2145
+ w.io.requiredChecks = async () => ["build", "lint"];
2146
+ w.io.rerunFailedJobs = async (id) => { w.reran.push(id); };
2147
+ w.io.checkStates = async () => [{ name: "build", state: "failure", url: RUN(77, w.reran.length + 1) }, { name: "lint", state: "failure", url: RUN(78, w.reran.length + 1) }];
2148
+ w.io.failedLogs = async () => "boom";
2149
+ const dir = tmpDir("core-");
2150
+ await run(w, { dir, verify: { ...parseVerify({}), flaky: ["build"], maxRounds: 1 } });
2151
+ assert.deepEqual(w.reran.slice(0, 2).sort(), ["77", "78"], "both red runs rerun on the first head");
2152
+ assert.deepEqual(events(dir, "CI_FLAKY_REPORTED")[0].jobs, ["build"]);
2153
+ assert.deepEqual(events(dir, "CI_FIX_ROUND").map((e) => e.jobs), [["lint"]]);
2154
+ });
2155
+ });
2156
+
2074
2157
  it("all waits for every check (waitCiGreen); none merges immediately", async () => {
2075
2158
  const w = world({ waits: [{ rc: 2, text: "" }] }); let req = 0; w.io.requiredChecks = async () => { req++; return []; };
2076
2159
  assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "merged");
@@ -2103,6 +2186,9 @@ describe("prompts", () => {
2103
2186
  const p = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, comments: [{ body: "please also cover bar" }, { body: "<!-- FORGE:STARTED -->\nx" }], contract: CONTRACT, result: { status: "fixed" }, files: ["src/foo.js"], diff: "x".repeat(2000) });
2104
2187
  for (const t of ["<issue_body>", "<issue_comments>\nplease also cover bar\n</issue_comments>", "<contract>", '"R1"', "<builder_result>", "<changed_files>\nsrc/foo.js", "<diff>"]) assert.ok(p.includes(t), t);
2105
2188
  assert.doesNotMatch(p, /FORGE:STARTED/);
2189
+ const am = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, contract: { ...CONTRACT, requirements: [{ ...CONTRACT.requirements[0], amendment: { text: "reveal once", why: "hash-only token" } }] }, diff: "" });
2190
+ assert.ok(am.includes("reveal once") && am.includes("hash-only token"));
2191
+ assert.match(REVIEWER_SYSTEM, /amendment\.text/);
2106
2192
  assert.ok(approxTokens(REVIEWER_SYSTEM) + approxTokens(reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, contract: CONTRACT, diff: "" })) <= 1800);
2107
2193
  });
2108
2194
 
@@ -2243,6 +2329,51 @@ describe("orchestrate", () => {
2243
2329
  assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.detail]), [[9, "needs-human", "ci.red"], [1, "skipped", "dependency #9 needs-human"]]);
2244
2330
  });
2245
2331
 
2332
+ it("#4595 a dependent skipped for a needs-human blocker is re-admitted when the blocker merges meanwhile; its dependents follow", async () => {
2333
+ const log = [];
2334
+ let merged = false;
2335
+ const rows = await orchestrate({ issues: [9, 1, 2], parallel: 1,
2336
+ readBody: async (n) => ({ 1: "**Depends on**: #9", 2: "**Depends on**: #1" })[n] ?? "",
2337
+ dispatchCheck: async (n) => (n === 9 && merged ? { outcome: "merged", pr: 90, detail: "PR #90 already merged" } : null),
2338
+ runOne: async (n) => { log.push(n); if (n === 9) { merged = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 100 + n }; } });
2339
+ assert.deepEqual(log, [9, 1, 2]);
2340
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "merged"], [1, "merged"], [2, "merged"]]);
2341
+ assert.equal(rows[0].pr, 90);
2342
+ });
2343
+
2344
+ it("#4595 an out-of-set parked blocker that closes re-admits the skipped issue", async () => {
2345
+ const log = [];
2346
+ let closed = false;
2347
+ const rows = await orchestrate({ issues: [1], parallel: 1, readBody: async () => "",
2348
+ issueLabels: async () => ["needs-human"],
2349
+ dispatchCheck: async (n) => (n === 9 && closed ? { outcome: "invalid", detail: "issue closed" } : null),
2350
+ runOne: async (n) => { log.push(n); if (log.length === 1) { closed = true; return { terminalReason: "dependency-open", dependencies: [9] }; } return { terminalReason: "merged" }; } });
2351
+ assert.deepEqual(log, [1, 1]);
2352
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
2353
+ });
2354
+
2355
+ it("#4614 an out-of-set blocker that dispatchCheck reports merged but the lane still sees open re-admits once, then stays skipped", async () => {
2356
+ const log = [];
2357
+ let checks = 0;
2358
+ const rows = await orchestrate({ issues: [20], parallel: 1, readBody: async () => "",
2359
+ issueLabels: async () => ["needs-human"],
2360
+ dispatchCheck: async (n) => { checks++; return n === 10 ? { outcome: "merged", pr: 77, detail: "PR #77 already merged" } : null; },
2361
+ runOne: async (n) => { log.push(n); if (log.length > 5) throw new Error("re-admission loop"); return { terminalReason: "dependency-open", dependencies: [10] }; } });
2362
+ assert.deepEqual(log, [20, 20], "one re-admission, no loop");
2363
+ assert.ok(checks <= 3, `bounded dispatch checks (${checks})`);
2364
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[20, "skipped"]]);
2365
+ });
2366
+
2367
+ it("#4595 an unreadable or still-open blocker leaves the dependent skipped; no dispatchCheck changes nothing", async () => {
2368
+ for (const dispatchCheck of [async () => { throw new Error("gh down"); }, async () => null, undefined]) {
2369
+ const log = [];
2370
+ const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""), ...(dispatchCheck ? { dispatchCheck } : {}),
2371
+ runOne: async (n) => { log.push(n); return { terminalReason: "needs-human", detail: "ci.red" }; } });
2372
+ assert.deepEqual(log, [9]);
2373
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "needs-human"], [1, "skipped"]]);
2374
+ }
2375
+ });
2376
+
2246
2377
  it("#4124 a repeated dependency-open stops after the requeue cap", async () => {
2247
2378
  const h = harness({}, { 1: "dependency-open" });
2248
2379
  const clock = { t: 0 }, starts = [];
@@ -3724,6 +3855,34 @@ describe("lane resolution in core work-on (#4125)", () => {
3724
3855
  });
3725
3856
  });
3726
3857
 
3858
+ describe("shipped lane follows the open PR base (#4593)", () => {
3859
+ const pr = (base) => [{ number: 77, headRefOid: "aaaa1111aaaa1111", baseRefName: base }];
3860
+ const go = (o) => run(world({ milestone: "Old", branchesOnOrigin: ["staging", "milestone/old", "milestone/new"], remote: "abc\trefs/heads/feat/issue-7-lean", ...o }), { lane: undefined });
3861
+ it("proceeds on the PR base when the issue's lane is shipped", async () => {
3862
+ const w = world({ milestone: "Old", branchesOnOrigin: ["staging", "milestone/old", "milestone/new"], shipped: ["milestone/old"], prList: pr("milestone/new"), remote: "abc\trefs/heads/feat/issue-7-lean" });
3863
+ const r = await run(w, { lane: undefined });
3864
+ assert.notEqual(r.terminalReason, "needs-human");
3865
+ assert.equal(r.terminalReason, "merged");
3866
+ assert.ok(w.calls.some((c) => c[0] === "git" && c.includes("origin/milestone/new..refs/heads/feat/issue-7-lean")), "work is compared against the PR base");
3867
+ });
3868
+ it("unused lane at the staging tip is not shipped (#4623)", async () => {
3869
+ // ancestor of staging but no merge commit took it: the hand-retargeted PR must stop, not be followed
3870
+ const w = world({ milestone: "Old", branchesOnOrigin: ["staging", "milestone/old", "milestone/new"], unusedAncestors: ["milestone/old"], prList: pr("milestone/new"), remote: "abc\trefs/heads/feat/issue-7-lean" });
3871
+ const r = await run(w, { lane: undefined });
3872
+ assert.deepEqual([r.terminalReason, r.code], ["needs-human", "lane.mismatch"]);
3873
+ });
3874
+ it("stays needs-human when the lane is not shipped, the base is shipped, missing, or the default branch", async () => {
3875
+ for (const [o, code] of [
3876
+ [{ prList: pr("milestone/new") }, "lane.mismatch"],
3877
+ [{ shipped: ["milestone/old", "milestone/new"], prList: pr("milestone/new") }, "lane.mismatch"],
3878
+ [{ shipped: ["milestone/old"], prList: pr("milestone/gone") }, "lane.mismatch"],
3879
+ [{ shipped: ["milestone/old"], prList: pr("main") }, "lane.default_branch"]]) {
3880
+ const r = await go(o);
3881
+ assert.deepEqual([r.terminalReason, r.code], ["needs-human", code], JSON.stringify(o));
3882
+ }
3883
+ });
3884
+ });
3885
+
3727
3886
  describe("lane entry points (#4125)", () => {
3728
3887
  const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
3729
3888
  const deps = () => ({ dir: tmpDir("core-cli-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async (a) => (a.includes("defaultBranchRef") ? "main\n" : "o/r\n") },
@@ -3876,6 +4035,17 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
3876
4035
  const ciValidation = [{ id: "V1", command: "node --test test/foo.test.js", ci: true }];
3877
4036
  const events = (dir) => readLog(dir, 7).filter((e) => e.event === "CI_VERIFY");
3878
4037
 
4038
+ it("untrack commit before CI verify: a tool cache the builder committed does not move HEAD under the CI lookup (#4621)", async () => {
4039
+ const w = withCi(world({ validation: ciValidation })); w.added = ["src/foo.js", "apps/worker/.eslintcache"];
4040
+ const inner = w.io.git;
4041
+ w.io.git = async (a, o) => { const out = await inner(a, o); if (a[0] === "commit" && a.some((x) => /untrack lane tool state/.test(x))) w.head = `dddd${w.calls.length}`.padEnd(16, "0"); return out; };
4042
+ const dir = tmpDir("core-ci-");
4043
+ const r = await run(w, { verify: CI, dir });
4044
+ assert.equal(r.terminalReason, "merged");
4045
+ assert.deepEqual(events(dir).map((e) => e.result), ["pass"], "CI found the run for the pushed tip");
4046
+ assert.ok(!builders(w).some((b) => /CI (is )?unavailable|cannot run/i.test(String(b[4]))), "no ci_unavailable builder round");
4047
+ });
4048
+
3879
4049
  it("green CI: the builder is told not to run test suites, the branch is pushed and verified before review, validate left to CI passes enforce", async () => {
3880
4050
  const w = withCi(world({ validation: ciValidation }));
3881
4051
  const dir = tmpDir("core-ci-");
@@ -3964,7 +4134,7 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
3964
4134
  assert.equal(readVerify(d), undefined);
3965
4135
  writeFileSync(`${d}/forge.yaml`, "verify:\n mode: ci\n workflow: verify.yml\n skip_paths: \"*.md,docs/**\"\n max_rounds: 3\n precheck_timeout_seconds: 60\n e2e_label: full-ci\n e2e_checks: \"e2e (1),e2e (2)\"\n");
3966
4136
  const e2e = { mode: "auto", label: "full-ci", workflow: null, pathsInput: null, checks: ["e2e (1)", "e2e (2)"], paths: ["e2e/**", "**/e2e/**", "*.e2e.*", "*.e2e-spec.*", "cypress/**", "playwright/**"], blocking: "changed" };
3967
- assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 60, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e });
4137
+ assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 60, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e });
3968
4138
  });
3969
4139
 
3970
4140
  describe("#4387 a no-commit CI fix round reruns the failed jobs once", () => {
@@ -4003,6 +4173,31 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
4003
4173
  assert.deepEqual(w.reran, ["77"]); assert.equal(w.merges.length, 0);
4004
4174
  assert.deepEqual(flakeEvents(dir).map((e) => e.result), ["fail"]);
4005
4175
  });
4176
+
4177
+ /** #4586: count the builder CI-fix prompts of a world. */
4178
+ const fixPrompts = (w) => { const n = { n: 0 }; const inner = w.io.runner; w.io.runner = async (c) => { if (/CI failed on the pushed branch/.test(c.prompt)) n.n++; return inner(c); }; return n; };
4179
+
4180
+ it("#4586 a flake that goes green on the rerun spends no fix round and never resumes the builder", async () => {
4181
+ const w = flaky("success"), n = fixPrompts(w), dir = tmpDir("core-ci-");
4182
+ const r = await run(w, { verify: PUSH, dir });
4183
+ assert.equal(r.terminalReason, "merged"); assert.equal(n.n, 0); assert.deepEqual(w.reran, ["77"]);
4184
+ });
4185
+
4186
+ it("#4586 a failure that repeats on the same head after the rerun counts one round and reaches the builder", async () => {
4187
+ const w = flaky("failure"), n = fixPrompts(w), dir = tmpDir("core-ci-");
4188
+ const r = await run(w, { verify: PUSH, dir });
4189
+ assert.equal(r.code, "ci.red"); assert.ok(n.n >= 1);
4190
+ assert.equal(readLog(dir, 7).filter((e) => e.event === "CI_FIX_ROUND").length, 1, "one counted round");
4191
+ assert.deepEqual(w.reran, ["77"], "one rerun per head, none after the round");
4192
+ });
4193
+
4194
+ it("#4586 verify.flaky: a listed failing job is reported, never reruns, never feeds the builder, and the lane merges", async () => {
4195
+ const w = flaky("failure"), n = fixPrompts(w), dir = tmpDir("core-ci-");
4196
+ w.io.checkStates = async () => [{ name: "test", state: "success", url: RUN, head: w.head }];
4197
+ const r = await run(w, { verify: { ...PUSH, flaky: ["test"] }, dir });
4198
+ assert.equal(r.terminalReason, "merged"); assert.equal(n.n, 0); assert.deepEqual(w.reran, []);
4199
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "CI_FLAKY_REPORTED").map((e) => e.jobs), [["test"]]);
4200
+ });
4006
4201
  });
4007
4202
  });
4008
4203
 
@@ -4392,6 +4587,25 @@ describe("local-mode CI fix rounds at merge and declared pre-PR check commands (
4392
4587
  assert.ok(log.findIndex((e) => e.event === "PRECHECK_COMMANDS") < log.findIndex((e) => e.event === "PR_OPENED"));
4393
4588
  });
4394
4589
 
4590
+ it("#4585 verify.invariants run in the pre-PR round even with no learned commands, and the prompts carry the checklist", async () => {
4591
+ const w = world(); const ran = [];
4592
+ w.io.runCommand = async ({ command }) => { ran.push(command); return { rc: 0, output: "" }; };
4593
+ const r = await run(w, { verify: { invariants: ["bash scripts/inv.sh"] } });
4594
+ assert.equal(r.terminalReason, "merged");
4595
+ assert.deepEqual(ran, ["bash scripts/inv.sh"]);
4596
+ const b = builders(w);
4597
+ assert.match(b[0][4], /Repo-wide invariants[\s\S]*bash scripts\/inv\.sh/); assert.match(b[1][4], /Invariants checklist[\s\S]*bash scripts\/inv\.sh/);
4598
+ const w2 = world(); let n = 0; w2.io.runCommand = async () => { n++; return { rc: 0, output: "" }; };
4599
+ await run(w2); assert.equal(n, 0, "no invariants: nothing runs");
4600
+ });
4601
+
4602
+ it("#4585 a failing invariant resumes the builder like a failed check command", async () => {
4603
+ const w = world(); let n = 0;
4604
+ w.io.runCommand = async () => (n++ ? { rc: 0, output: "" } : { rc: 1, output: "registry missing table x" });
4605
+ await run(w, { verify: { invariants: ["bash scripts/inv.sh"] } });
4606
+ assert.ok(n >= 2, "the invariant is rerun after the builder fix round");
4607
+ });
4608
+
4395
4609
  it("pre-PR: a passing set adds no builder turn; no commands configured runs nothing", async () => {
4396
4610
  const w = world(); let n = 0;
4397
4611
  w.io.runCommand = async () => { n++; return { rc: 0, output: "" }; };
@@ -4430,7 +4644,7 @@ describe("#4364 in-session forgedock check: plan hand-off and self-check record"
4430
4644
  it("writes check-plan.json beside the contract and the implement prompt asks for forgedock check", async () => {
4431
4645
  const w = world(); w.io.runCommand = async () => ({ rc: 0, output: "" });
4432
4646
  assert.equal((await run(w, { precheck: { commands: [" make lint ", ""], maxMinutes: 5 }, verify: { mode: "hybrid" } })).terminalReason, "merged");
4433
- assert.deepEqual(JSON.parse(w.files[`${WT}/.forge-lean/check-plan.json`]), { v: 1, base: "origin/staging", mode: "hybrid", learned: ["make lint"], maxMinutes: 5, precheckTotalSeconds: 600, precheckTimeoutSeconds: 300 });
4647
+ assert.deepEqual(JSON.parse(w.files[`${WT}/.forge-lean/check-plan.json`]), { v: 1, base: "origin/staging", mode: "hybrid", learned: ["make lint"], invariants: [], maxMinutes: 5, precheckTotalSeconds: 600, precheckTimeoutSeconds: 300 });
4434
4648
  assert.match(builders(w)[1][4], /forgedock\.mjs check` in the worktree[\s\S]*report the problem as blocked/);
4435
4649
  });
4436
4650
 
@@ -5072,6 +5286,17 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5072
5286
  assert.equal(w.dupArgs.cwd, WT);
5073
5287
  });
5074
5288
 
5289
+ it("#4596 English copies in a locale catalog resume the builder once to translate; a leftover is an anomaly", async () => {
5290
+ const w = hworld(); const c = [[{ path: "messages/ta.json", key: "calendarView.day", value: "Day view", source: "messages/en.json" }], []]; w.cpCalls = 0;
5291
+ w.io.englishCopies = async (x) => { w.cpCalls++; w.cpArgs = x; return c.length > 1 ? c.shift() : c[0]; };
5292
+ assert.equal((await run(w)).terminalReason, "merged");
5293
+ const b = builders(w);
5294
+ assert.equal(b.length, 3);
5295
+ assert.match(b[2][4], /messages\/ta\.json: calendarView\.day = "Day view" \(same as messages\/en\.json\)[\s\S]*Translate each/);
5296
+ assert.ok(w.seq.indexOf("pr") > w.seq.lastIndexOf("builder-resume"), "fixed before the PR");
5297
+ assert.equal(w.cpArgs.cwd, WT);
5298
+ });
5299
+
5075
5300
  it("#4406 a duplicate that remains after the round is an anomaly; verify.duplicate_exports false skips the check", async () => {
5076
5301
  let w = hworld(); w.io.duplicateExports = async () => DUP;
5077
5302
  assert.equal((await run(w)).terminalReason, "merged");
@@ -5082,6 +5307,23 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5082
5307
  assert.equal(builders(w).length, 2);
5083
5308
  });
5084
5309
 
5310
+ const HAND = ["db/2026_x.sql: not produced by the migration generator (no journal entry added for it)"];
5311
+ it("#4591 a hand-named migration resumes the builder once with the generator rule; a remainder is an anomaly; clean costs no round", async () => {
5312
+ const w = hworld(); const d = [HAND, []];
5313
+ w.io.migrationGenerator = async (x) => { w.genArgs = x; return d.length > 1 ? d.shift() : d[0]; };
5314
+ assert.equal((await run(w)).terminalReason, "merged");
5315
+ const b = builders(w);
5316
+ assert.equal(b.length, 3);
5317
+ assert.match(b[2][4], /db\/2026_x\.sql[\s\S]*Never name or number migration files by hand/);
5318
+ assert.equal(w.genArgs.cwd, WT);
5319
+ const w2 = hworld(); w2.io.migrationGenerator = async () => HAND;
5320
+ assert.equal((await run(w2)).terminalReason, "merged");
5321
+ assert.match(traj(w2)[0], /Hand-named migration\(s\) remain \(hand-off\)/);
5322
+ const w3 = hworld(); w3.io.migrationGenerator = async () => [];
5323
+ await run(w3);
5324
+ assert.equal(builders(w3).length, 2);
5325
+ });
5326
+
5085
5327
  it("no enumerating tests and no collisions: no new plan step, no extra builder call (prompts unchanged)", async () => {
5086
5328
  const w = hworld();
5087
5329
  assert.equal((await run(w)).terminalReason, "merged");
@@ -5184,6 +5426,31 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5184
5426
  assert.match(w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->")), /deterministic: base side taken[\s\S]*renumbered deterministically[\s\S]*not required \(deterministic renumber \+ base merge\)/);
5185
5427
  });
5186
5428
 
5429
+ it("#4588: base-sync conflicts the engine resolves (union / JSON map / generated) need no builder, re-review or sequence resolver", async () => {
5430
+ const w = dworld({ seq: [[], []], prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["src/index.ts", "i18n/status.json"] }), asked = [];
5431
+ w.io.conflictResolve = async (x) => { asked.push(x); w.head = "eeee0000eeee0000"; w.conflictFiles = []; w.mergeInProgress = false; return { resolved: x.files, remaining: [] }; };
5432
+ assert.equal((await run(w, { baseMerge: { unionGlobs: ["src/index.ts"], jsonGlobs: ["i18n/status.json"], generatedGlobs: [], generatedCommand: "" } })).terminalReason, "merged");
5433
+ assert.deepEqual(asked[0].files, ["src/index.ts", "i18n/status.json"]);
5434
+ assert.equal(asked[0].cfg.jsonGlobs[0], "i18n/status.json");
5435
+ assert.equal(w.resolveArgs, undefined, "sequence resolver not asked");
5436
+ assert.equal(builders(w).length, 2, "plan + implement only");
5437
+ assert.equal(reviewers(w).length, 1, "no re-review");
5438
+ assert.match(w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->")), /deterministic: 2 union \/ JSON-map \/ generated file/);
5439
+ });
5440
+
5441
+ it("#4588: files the engine cannot resolve go to the sequence resolver, and the builder when that fails; without config nothing changes", async () => {
5442
+ const w = dworld({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["src/index.ts", "db/meta/_journal.json"], resolve: { unsupported: "x" } });
5443
+ w.io.conflictResolve = async () => ({ resolved: ["src/index.ts"], remaining: ["db/meta/_journal.json"] });
5444
+ assert.equal((await run(w, { baseMerge: { unionGlobs: ["src/index.ts"] } })).terminalReason, "merged");
5445
+ assert.deepEqual(w.resolveArgs.files, ["db/meta/_journal.json"]);
5446
+ assert.ok(builders(w).length >= 3, "the builder finishes the merge");
5447
+ const n = dworld({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["db/meta/_journal.json"] });
5448
+ let called = false; n.io.conflictResolve = async () => { called = true; return { resolved: [], remaining: [] }; };
5449
+ await run(n);
5450
+ assert.equal(called, false, "no baseMerge config: resolver not consulted");
5451
+ assert.deepEqual(n.resolveArgs.files, ["db/meta/_journal.json"]);
5452
+ });
5453
+
5187
5454
  it("a schema-changing migration without lean.sequence.regenerate falls back to the builder renumber and the trajectory records why", async () => {
5188
5455
  const w = dworld({ seq: [COLL, []], det: [{ unsupported: "schema-changing migration db/0007_usage.sql needs lean.sequence.regenerate (its snapshot cannot be cloned)" }] });
5189
5456
  assert.equal((await run(w, { sequenceRegenerate: null })).terminalReason, "merged");
@@ -5679,6 +5946,17 @@ describe("e2e merge gate (#4403)", () => {
5679
5946
  assert.equal(r2.terminalReason, "needs-human"); assert.equal(w2.merges.length, 0);
5680
5947
  });
5681
5948
 
5949
+ it("#4624 verify.flaky: a flaky-listed own e2e failure is reported as an anomaly, never reaches the builder, and the lane merges; a non-listed one still does", async () => {
5950
+ const w = withE2e(world({ changed: SPECS }), ["failure"]);
5951
+ const fixes = []; const inner = w.io.runner; w.io.runner = async (c) => { if (/CI failed on the pushed branch/.test(c.prompt)) fixes.push(c.prompt); return inner(c); };
5952
+ const r = await go(w, { verify: { ...cfg(), flaky: ["e2e"] } });
5953
+ assert.equal(r.terminalReason, "merged"); assert.equal(fixes.length, 0);
5954
+ assert.ok(traj(w).some((t) => /Known-flaky e2e failure\(s\) reported, not blocking \(verify\.flaky\): e2e/.test(t)));
5955
+ const w2 = withE2e(world({ changed: SPECS }), ["failure"]);
5956
+ const r2 = await go(w2, { verify: { ...cfg(), flaky: ["other"], maxRounds: 0 } });
5957
+ assert.equal(r2.code, OUTCOME_CODES.CI_RED); assert.equal(w2.merges.length, 0);
5958
+ });
5959
+
5682
5960
  it("verify.e2e: never (or no label/workflow configured) keeps today's behaviour: no label, no e2e wait, no event", async () => {
5683
5961
  for (const verify of [cfg({ e2e: "never" }), parseVerify({})]) {
5684
5962
  const w = withE2e(world({ changed: SPECS }), ["failure"]);
@@ -6015,7 +6293,7 @@ describe("#4395 lane profile", () => {
6015
6293
  assert.match(p, /Issue independent read-only calls \(reads, greps, git log\/blame, gh\) in the same turn or one shell call\./);
6016
6294
  assert.match(p, /Read each file's ranges once .* and keep notes; don't re-read it\./);
6017
6295
  }
6018
- assert.ok(approxTokens(builderImplementPrompt()) <= 1500 && approxTokens(builderPlanPrompt(7)) <= 1800);
6296
+ assert.ok(approxTokens(builderImplementPrompt()) <= 1550 && approxTokens(builderPlanPrompt(7)) <= 1800);
6019
6297
  });
6020
6298
 
6021
6299
  it("runCliBackendAsync reports the first stream turn's context as baselineTokens; makeLeanRunner drops it on resume", async () => {
@@ -6222,3 +6500,36 @@ describe("#4450 light lane: one builder session, derived contract, upgrade to th
6222
6500
  assert.equal(events(dir, "LIGHT_DONE").length + events(dir, "LIGHT_UPGRADE").length, 0);
6223
6501
  });
6224
6502
  });
6503
+
6504
+ describe("tool state never rides the lane branch (#4590)", () => {
6505
+ const untracks = (w) => w.calls.filter((c) => c[0] === "git" && c[1] === "rm" && c[2] === "--cached");
6506
+ const subjects = (w) => w.calls.filter((c) => c[0] === "git" && c[1] === "commit" && c.some((x) => /untrack lane tool state/.test(x)));
6507
+
6508
+ it("a branch that added .forge-lean/ files and tool caches is untracked and committed before the PR push", async () => {
6509
+ const dir = tmpDir("toolstate-");
6510
+ const w = world({}); w.added = ["src/foo.js", ".forge-lean/result.json", "apps/worker/.eslintcache", "packages/a/tsconfig.tsbuildinfo"];
6511
+ assert.equal((await run(w, { dir })).terminalReason, "merged");
6512
+ const rm = untracks(w);
6513
+ assert.ok(rm.length >= 1, "git rm --cached ran");
6514
+ for (const f of [".forge-lean/result.json", "apps/worker/.eslintcache", "packages/a/tsconfig.tsbuildinfo"]) assert.ok(rm[0].includes(f), f);
6515
+ assert.ok(!rm[0].includes("src/foo.js"), "product files stay");
6516
+ assert.equal(subjects(w).length, 1, "one untrack commit; the next push finds the branch clean");
6517
+ const firstPush = w.calls.findIndex((c) => c[0] === "git" && c[1] === "push");
6518
+ assert.ok(w.calls.findIndex((c) => c === subjects(w)[0]) < firstPush, "committed before the push");
6519
+ assert.ok(readLog(dir, 7).some((e) => e.event === "TOOLSTATE_UNTRACKED"));
6520
+ });
6521
+
6522
+ it("a clean branch makes no untrack commit", async () => {
6523
+ const w = world({}); w.added = ["src/foo.js"];
6524
+ assert.equal((await run(w)).terminalReason, "merged");
6525
+ assert.equal(untracks(w).length, 0);
6526
+ assert.equal(subjects(w).length, 0);
6527
+ });
6528
+ });
6529
+
6530
+ describe("block rules: a check timeout is not a block (#4589)", () => {
6531
+ it("tells the builder rc 124 is host load, never a human block", async () => {
6532
+ const { blockCorrectionPrompt } = await import("./prompts.mjs");
6533
+ assert.match(blockCorrectionPrompt("x"), /times out \(rc 124\) is host load, never a block/);
6534
+ });
6535
+ });