forgedock 1.29.0 → 1.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +102 -38
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -17,7 +17,7 @@ import { readLog } from "../engine/runlog.mjs";
17
17
  import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir, makeLeanIo } from "./effects.mjs";
18
18
  import { retrying, PUSH_TRANSIENT_RE } from "./retry.mjs";
19
19
  import { checkAdmission, hostParallelism } from "./admission.mjs";
20
- import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
20
+ import { OUTCOME_CODES, classifyNeedsHuman } from "../engine/outcome-codes.mjs";
21
21
  import * as refactorMod from "./refactor.mjs";
22
22
  import { readAdmission, readConformanceMode, readReviewIncomplete, readEnvConfig, readContextPack } from "../engine-cli.mjs";
23
23
  import { prepareToolchain } from "./toolchain.mjs";
@@ -319,6 +319,18 @@ describe("core work-on", () => {
319
319
  assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("issue_coverage is missing")));
320
320
  });
321
321
 
322
+ it("#4327 a review incomplete only by unresolved evidence paths gets one more targeted re-ask, then merges", async () => {
323
+ const lost = () => conf({ requirements: [{ id: "R1", status: "implemented", evidence: "/layout.tsx:3", validated_by: "V1" }] });
324
+ const w = world({ reviews: [lost(), lost(), ok()] });
325
+ assert.equal((await run(w)).terminalReason, "merged");
326
+ const rv = reviewers(w);
327
+ assert.equal(rv.length, 3); assert.equal(builders(w).length, 2, "never charged to the builder");
328
+ assert.match(rv[2][4], /answer ONLY for these ids or sections: R1/); assert.match(rv[2][4], /evidence path \/layout\.tsx does not exist/);
329
+ const w2 = world({ reviews: [lost(), lost(), lost(), ok()] });
330
+ const r2 = await run(w2);
331
+ assert.equal(r2.code, "review.incomplete"); assert.equal(reviewers(w2).length, 3, "at most three reviewer runs"); assert.equal(w2.merges.length, 0);
332
+ });
333
+
322
334
  it("a CONFORMS verdict whose rows fail is not conforming (the engine computes the verdict)", async () => {
323
335
  const w = world({ reviews: [bad("half done", "CONFORMS"), ok()] });
324
336
  assert.equal((await run(w)).terminalReason, "merged");
@@ -450,6 +462,83 @@ describe("core work-on", () => {
450
462
  noNeedsHuman(w);
451
463
  });
452
464
 
465
+ const splitBlock = { status: "blocked", summary: "epic", block: { kind: "split-required", subissues: [{ title: "part A", body: "do A" }, { title: "part B", body: "do B", depends_on: [0] }] } };
466
+ it("#4337 without decomposition.close_parent a split keeps the parent open (opt-in, default false)", async () => {
467
+ const w = depWorld({ changed: [], result: splitBlock }, {});
468
+ assert.equal((await run(w)).terminalReason, "decomposed");
469
+ assert.equal(w.closed.length, 0);
470
+ assert.ok(w.labels.includes("decomposed"));
471
+ });
472
+
473
+ it("#4337 with decomposition.close_parent a split closes the parent not planned after its FORGE:DECOMPOSED trail and label, listing every sub-issue", async () => {
474
+ const w = depWorld({ changed: [], result: splitBlock }, {});
475
+ const r = await run(w, { closeParent: true });
476
+ assert.equal(r.terminalReason, "decomposed");
477
+ assert.equal(w.closed.length, 1);
478
+ const c = w.closed[0];
479
+ assert.equal(c[c.indexOf("--reason") + 1], "not planned");
480
+ const note = c[c.indexOf("--comment") + 1];
481
+ assert.match(note, /#201/); assert.match(note, /#202/);
482
+ assert.ok(w.labels.includes("decomposed"));
483
+ assert.ok(!w.edits.some((e) => e.includes("--remove-label") && e.join(" ").includes("workflow:decomposed")), "the decomposed label is kept");
484
+ const seq = w.seq.filter((x) => x === "label:decomposed" || x === "close");
485
+ assert.deepEqual(seq, ["label:decomposed", "close"]);
486
+ assert.ok(w.comments.findIndex((x) => x.includes("FORGE:DECOMPOSED")) >= 0);
487
+ });
488
+
489
+ it("#4337 no close when the FORGE:DECOMPOSED comment or the decomposed label could not be written (fail closed)", async () => {
490
+ const w = depWorld({ changed: [], result: splitBlock }, {});
491
+ const post = w.io.gh;
492
+ w.io.gh = async (a) => (a[0] === "issue" && a[1] === "comment" && a[a.indexOf("--body") + 1].includes("FORGE:DECOMPOSED") ? Promise.reject(new Error("HTTP 422")) : post(a));
493
+ assert.equal((await run(w, { closeParent: true })).terminalReason, "decomposed");
494
+ assert.equal(w.closed.length, 0);
495
+ const w2 = depWorld({ changed: [], result: splitBlock }, {});
496
+ const tl = w2.io.transitionLabel;
497
+ w2.io.transitionLabel = async (n, st) => { if (st === "decomposed") throw new Error("label failed"); return tl(n, st); };
498
+ assert.equal((await run(w2, { closeParent: true })).terminalReason, "decomposed");
499
+ assert.equal(w2.closed.length, 0);
500
+ });
501
+
502
+ const trail = { body: "<!-- FORGE:DECOMPOSED -->\n## Split into sub-issues\n- #31 part A\n- #32 part B\n", author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
503
+ const revoke = { body: "<!-- FORGE:DECOMPOSED:REVOKED -->\nre-plan", author_association: "OWNER", user: { type: "User", login: "op" } };
504
+ it("#4337 a dependency block on a CLOSED decomposed parent resolves to its open sub-issues; no usable trail keeps the parent", async () => {
505
+ const parent = { state: "CLOSED", labels: [{ name: "workflow:decomposed" }] };
506
+ const block = { status: "blocked", summary: "needs #3", block: { kind: "dependency", issues: [3] } };
507
+ const w = depWorld({ changed: [], issueComments: [trail], result: block }, { 3: parent, 31: { state: "CLOSED", labels: [] }, 32: { state: "OPEN", labels: [] } });
508
+ const r = await run(w);
509
+ assert.equal(r.terminalReason, "dependency-open");
510
+ assert.deepEqual(r.dependencies, [32]);
511
+ for (const comments of [[], [trail, revoke]]) {
512
+ const w2 = depWorld({ changed: [], issueComments: comments, result: block }, { 3: parent });
513
+ const r2 = await run(w2);
514
+ assert.equal(r2.terminalReason, "dependency-open", JSON.stringify(comments));
515
+ assert.deepEqual(r2.dependencies, [3]);
516
+ }
517
+ const w3 = depWorld({ changed: [], issueComments: [trail], result: block }, { 3: parent, 31: { state: "CLOSED", labels: [] }, 32: { state: "CLOSED", labels: [] } });
518
+ assert.notEqual((await run(w3)).terminalReason, "dependency-open", "satisfied once every sub-issue is closed");
519
+ });
520
+
521
+ it("#4337 a run on a CLOSED decomposed tracker ends decomposed with its sub-issues; a trusted revoke reopens it and the run proceeds", async () => {
522
+ const w = world({ issueStates: { 7: "CLOSED" }, labels: [{ name: "workflow:decomposed" }], issueComments: [trail] });
523
+ const r = await run(w);
524
+ assert.equal(r.terminalReason, "decomposed");
525
+ assert.deepEqual(r.subissues, [31, 32]);
526
+ assert.equal(builders(w).length, 0);
527
+ assert.equal(w.patches.length, 0);
528
+ const w2 = world({ issueStates: { 7: "CLOSED" }, labels: [{ name: "workflow:decomposed" }], issueComments: [] });
529
+ assert.deepEqual((await run(w2)).subissues, [], "no trail: no sub-issues, so the batch skips its dependents (fail closed)");
530
+ const w3 = world({ issueStates: { 7: "CLOSED" } });
531
+ assert.equal((await run(w3)).terminalReason, "invalid", "a plain closed issue is still already closed");
532
+ const w4 = world({ issueStates: { 7: "CLOSED" }, labels: [{ name: "workflow:decomposed" }], issueComments: [trail, revoke] });
533
+ const gh = w4.io.gh;
534
+ w4.io.gh = async (a) => (a[0] === "api" && a.length === 2 && /issues\/7$/.test(a[1]) ? JSON.stringify({ state: "closed", labels: [{ name: "workflow:decomposed" }] }) : gh(a));
535
+ const r4 = await run(w4);
536
+ assert.equal(w4.patches.length, 1);
537
+ assert.ok(w4.patches[0].includes("state=open"));
538
+ assert.ok(builders(w4).length > 0, "the reopened issue is worked on");
539
+ assert.notEqual(r4.terminalReason, "decomposed");
540
+ });
541
+
453
542
  it("#4124 a prod-data (non-human) or untyped block gets one corrective resume; a builder that then proceeds merges", async () => {
454
543
  const w = world({ result: { status: "blocked", summary: "needs live wire capture and prod baselines", block: { kind: "human", why: "prod-data" } },
455
544
  resumeResult: { status: "fixed", summary: "captured via read-only monitoring", evidence: [], tests: [] } });
@@ -787,6 +876,24 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
787
876
  assert.equal((await run(plain, ends["ci-pending"][1])).terminalReason, "ci-pending");
788
877
  assert.equal(add(plain), 0, "no recovery: a ci-pending run is not labelled");
789
878
  });
879
+
880
+ it("#4302: a non-final recovery attempt (recover.final false) never relabels needs-human; final true or absent relabels once", async () => {
881
+ const add = (w) => w.edits.filter((e) => e.join(" ") === "issue edit 7 -R o/r --add-label needs-human").length;
882
+ const nh = (w) => w.comments.filter((c) => c.startsWith("<!-- FORGE:NEEDS_HUMAN -->")).length;
883
+ const ends = {
884
+ "ci-pending": [{ waits: Array(500).fill({ rc: 2, text: "" }) }, { ciMaxMinutes: 10, mergeWait: "all" }],
885
+ "needs-human": [{ waits: [{ rc: 1, text: "lint failed" }] }, { mergeWait: "all" }],
886
+ };
887
+ for (const [reason, [wo, ro]] of Object.entries(ends)) {
888
+ for (const [final, want] of [[false, 0], [true, 1], [undefined, 1]]) {
889
+ const w = world(wo);
890
+ const r = await run(w, { recover: { code: "ci.red", attempt: 1, ...(final === undefined ? {} : { final }) }, ...ro });
891
+ assert.equal(r.terminalReason, reason);
892
+ assert.equal(add(w), want, `${reason} final=${final}: label`); assert.equal(nh(w), want, `${reason} final=${final}: comment`);
893
+ if (reason === "needs-human") assert.equal(r.recovery, true);
894
+ }
895
+ }
896
+ });
790
897
  });
791
898
 
792
899
  describe("devdocs freshness gate (the repo's own scripts/check-devdocs-freshness.sh)", () => {
@@ -1008,6 +1115,60 @@ describe("conformance shadow mode (the default)", () => {
1008
1115
  assert.match(nhBody(w), /validate V1 \(`node --test test\/foo\.test\.js`\) failed \(exit 1\)/);
1009
1116
  });
1010
1117
 
1118
+ it("#4323 a validate the host cannot run (missing module) ends env.tool_missing naming it: no builder resume, no reviewer", async () => {
1119
+ const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
1120
+ for (const conformance of ["shadow", "enforce"]) {
1121
+ const w = world({ validation: v }), ran = [];
1122
+ w.io.runCommand = async (a) => { ran.push(a); return { rc: 1, output: "ModuleNotFoundError: No module named 'yaml'" }; };
1123
+ const r = await run(w, { conformance });
1124
+ assert.equal(r.code, OUTCOME_CODES.ENV_TOOL_MISSING, conformance); assert.equal(w.merges.length, 0);
1125
+ assert.equal(builders(w).length, 2, "plan + implement only"); assert.equal(reviewers(w).length, 0);
1126
+ assert.match(nhBody(w), /Environment error \(not a conformance failure\): the host is missing yaml/);
1127
+ assert.deepEqual(ran.map((a) => [a.command, a.cwd]), [["node --test test/foo.test.js", WT]], "only the failed contract validate is rerun, in the worktree");
1128
+ assert.ok(ran[0].env && typeof ran[0].env === "object", "the rerun gets the lane env");
1129
+ }
1130
+ });
1131
+ it("#4323 a rerun that fails for a repo reason stays conformance.unimplemented; one that passes merges", async () => {
1132
+ const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
1133
+ const w = world({ validation: v, resumeValidation: v });
1134
+ w.io.runCommand = async () => ({ rc: 1, output: "Error: Cannot find module './src/foo.js'" });
1135
+ assert.equal((await runShadow(w)).code, "conformance.unimplemented"); assert.equal(builders(w).length, 3);
1136
+ const p = world({ validation: v });
1137
+ p.io.runCommand = async () => ({ rc: 0, output: "" });
1138
+ assert.equal((await runShadow(p)).terminalReason, "merged"); assert.equal(builders(p).length, 2);
1139
+ });
1140
+
1141
+ it("#4328 freeze-time preflight: a validate needing a module the host lacks gets the one contract correction, before any build", async () => {
1142
+ const pyC = { ...CONTRACT, validate: [{ id: "V1", command: `python3 -c "import yaml"` }] }, uvC = { ...CONTRACT, validate: [{ id: "V1", command: `uv run python -c "import yaml"` }] };
1143
+ const probe = (w) => { const ran = []; w.io.probeCommand = async (a) => { ran.push(a); return { rc: /^'python3' .*'yaml'/.test(a.command) ? 3 : 0, output: "" }; }; return ran; };
1144
+ const dir = tmpDir("preflight-");
1145
+ const w = world({ contract: pyC, fixedContract: uvC, validation: [{ id: "V1", command: uvC.validate[0].command, exit: 0 }] }), ran = probe(w);
1146
+ assert.equal((await run(w, { dir })).terminalReason, "merged");
1147
+ assert.equal(builders(w).length, 3, "plan + one contract correction + implement");
1148
+ assert.match(builders(w)[1][4], /V1: needs Python module 'yaml' \(for python3\), which this host lacks[\s\S]*repo's own toolchain/);
1149
+ assert.ok(ran.every((a) => a.cwd === WT && a.env && typeof a.env === "object"), "probes run in the worktree with the lane env");
1150
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "VALIDATE_PREFLIGHT").map((e) => [e.corrected, e.misses.map((m) => m.tool)]), [[true, []]]);
1151
+ // still missing after the correction: advisory only, the lane goes on (the post-build rerun is the gate)
1152
+ const dir2 = tmpDir("preflight-");
1153
+ const s = world({ contract: pyC, fixedContract: pyC, validation: [{ id: "V1", command: pyC.validate[0].command, exit: 0 }] }); probe(s);
1154
+ assert.equal((await run(s, { dir: dir2 })).terminalReason, "merged"); assert.equal(builders(s).length, 3);
1155
+ assert.deepEqual(readLog(dir2, 7).filter((e) => e.event === "VALIDATE_PREFLIGHT").map((e) => [e.corrected, e.misses.map((m) => m.tool)]), [[false, ["yaml"]]]);
1156
+ assert.ok(traj(s).some((t) => /Validate preflight: the host lacks yaml after the contract correction/.test(t)));
1157
+ // every tool present: no correction, no event
1158
+ const dir3 = tmpDir("preflight-");
1159
+ const ok = world({ contract: uvC, validation: [{ id: "V1", command: uvC.validate[0].command, exit: 0 }] }); const ran3 = probe(ok);
1160
+ assert.equal((await run(ok, { dir: dir3 })).terminalReason, "merged"); assert.equal(builders(ok).length, 2);
1161
+ assert.deepEqual(ran3.map((a) => a.command), ["command -v -- 'uv' >/dev/null 2>&1"]);
1162
+ assert.equal(readLog(dir3, 7).filter((e) => e.event === "VALIDATE_PREFLIGHT").length, 0);
1163
+ });
1164
+ it("#4328 an invalid contract and a preflight miss share one correction; still invalid is contract.invalid as before", async () => {
1165
+ const bad = { ...CONTRACT, requirements: [{ id: "R1", quote: "make foo nicer", disposition: "deliver" }], validate: [{ id: "V1", command: "jq . a.json" }] };
1166
+ const w = world({ contract: bad, fixedContract: bad });
1167
+ w.io.probeCommand = async () => ({ rc: 1, output: "" });
1168
+ assert.equal((await run(w)).code, "contract.invalid"); assert.equal(builders(w).length, 2, "plan + one correction");
1169
+ assert.match(builders(w)[1][4], /R1: quote is not verbatim[\s\S]*V1: needs command 'jq'/);
1170
+ });
1171
+
1011
1172
  it("a failed validate fetches the lane and the resume says how to catch up with a moved base (merge, never rebase)", async () => {
1012
1173
  const w = world({ validation: [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }], resumeValidation: [{ id: "V1", command: "node --test test/foo.test.js", exit: 0 }] });
1013
1174
  assert.equal((await runShadow(w)).terminalReason, "merged");
@@ -1306,17 +1467,35 @@ describe("bug lanes freeze a red-green repro_test (#4271)", () => {
1306
1467
  assert.equal((await run(w)).terminalReason, "merged");
1307
1468
  assert.match(builders(w)[0][4], /This issue is a bug\. When it is testable, .*"repro_test":\{"path"/);
1308
1469
  assert.match(builders(w)[0][4], /"repro_test":"none","repro_test_reason"/);
1309
- assert.deepEqual(w.rgCalls, [{ cwd: "/wt/lean-7", base: "origin/staging", command: RT.command }]);
1470
+ assert.deepEqual(w.rgCalls.map(({ env, ...x }) => x), [{ cwd: "/wt/lean-7", base: "origin/staging", command: RT.command }]);
1310
1471
  assert.match(w.comments.find((c) => c.includes("FORGE:CONTRACT")), /"repro_test"/, "repro_test is frozen in the contract JSON");
1311
1472
  assert.ok(traj(w)[0].includes("repro_test test/foo.test.js foo handles null: BASE=FAILED HEAD=RAN"));
1312
1473
  assert.equal(builders(w).length, 2); assert.equal(reviewers(w).length, 1);
1313
1474
  const t = rworld([RED_GREEN], { labels: [{ name: "type:bug" }] });
1314
1475
  assert.equal((await run(t)).terminalReason, "merged"); assert.equal(t.rgCalls.length, 1, "type:bug counts as a bug");
1315
1476
  });
1477
+ it("#4325 the red-green check, declared checks, validate reruns and env.setup run with the builder's lane env", async () => {
1478
+ const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
1479
+ const w = rworld([RED_GREEN], { validation: v }), ran = [], setups = [];
1480
+ w.io.runTmp = { prune: async () => {}, make: async (_i, id) => `/home/x/.forge/tmp/runs/${id}`, remove: async () => {} };
1481
+ w.io.prepareToolchain = async () => ({ env: { PATH: "/rt/toolchain/bin:/usr/bin" }, summary: "node 22" });
1482
+ w.io.deps = { env: () => ({ UV_CACHE_DIR: "/c/uv", TMPDIR: "/should-not-win" }), prepare: async () => [] };
1483
+ w.io.runSetup = async (a) => { setups.push(a); return { exit: 0, stdout: "", stderr: "" }; };
1484
+ w.io.runCommand = async (a) => { ran.push(a); return { rc: 0, output: "" }; };
1485
+ const prev = process.env.FORGE_HOST_PARALLELISM; process.env.FORGE_HOST_PARALLELISM = "3";
1486
+ try { assert.equal((await run(w, { envSetup: "make setup", precheck: { commands: ["npm run lint"] } })).terminalReason, "merged"); }
1487
+ finally { if (prev === undefined) delete process.env.FORGE_HOST_PARALLELISM; else process.env.FORGE_HOST_PARALLELISM = prev; }
1488
+ const lane = { UV_CACHE_DIR: "/c/uv", TMPDIR: w.envs[0].TMPDIR, FORGE_HOST_PARALLELISM: "3", PATH: "/rt/toolchain/bin:/usr/bin" };
1489
+ assert.match(lane.TMPDIR, /\/runs\/7-/, "the builder ran with the run TMPDIR");
1490
+ assert.deepEqual(w.rgCalls.map((x) => x.env), [lane], "red-green gets the lane env, not the daemon's");
1491
+ assert.deepEqual(ran.map((a) => [a.command, a.env]), [["npm run lint", lane], ["node --test test/foo.test.js", lane]]);
1492
+ assert.deepEqual(setups.map((a) => a.env), [lane]);
1493
+ assert.equal(w.envs[0].CLAUDE_CODE_TMPDIR, lane.TMPDIR); assert.equal(w.envs[0].UV_CACHE_DIR, "/c/uv"); assert.match(w.envs[0].FORGE_DOCKER_LABELS ?? "", /lean-7/);
1494
+ });
1316
1495
 
1317
1496
  it("a repro test that is not red on base and green on head fails the precheck in enforce and in shadow: one in-session resume, then needs-human", async () => {
1318
1497
  for (const conformance of ["enforce", "shadow"]) {
1319
- for (const rg of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, null]) {
1498
+ for (const rg of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }]) {
1320
1499
  const w = rworld([rg]);
1321
1500
  const r = await run(w, { conformance });
1322
1501
  assert.equal(r.code, "conformance.unimplemented", `${conformance} ${JSON.stringify(rg)}`); assert.equal(w.merges.length, 0);
@@ -1330,6 +1509,27 @@ describe("bug lanes freeze a red-green repro_test (#4271)", () => {
1330
1509
  assert.equal((await run(fixed, { conformance: "shadow" })).terminalReason, "merged"); assert.equal(fixed.rgCalls.length, 2);
1331
1510
  });
1332
1511
 
1512
+ it("a repro test the harness cannot run on head (or no harness result) is env.repro_unrunnable: recoverable, no resume, no reviewer (#4334)", async () => {
1513
+ for (const conformance of ["enforce", "shadow"]) {
1514
+ for (const rg of [{ base: "SKIPPED", head: "UNRUNNABLE" }, null]) {
1515
+ const w = rworld([rg]);
1516
+ const r = await run(w, { conformance });
1517
+ assert.equal(r.code, "env.repro_unrunnable", `${conformance} ${JSON.stringify(rg)}`); assert.equal(r.terminalReason, "needs-human");
1518
+ assert.equal(classifyNeedsHuman(r), "recoverable");
1519
+ assert.equal(w.merges.length, 0); assert.equal(builders(w).length, 2, "plan + implement, no resume"); assert.equal(reviewers(w).length, 0);
1520
+ assert.match(nh(w), /the repro harness could not run the repro test on head; no builder resume was spent/);
1521
+ }
1522
+ }
1523
+ });
1524
+
1525
+ it("the repro harness runs with the lane env: toolchain PATH and the per-run TMPDIR (#4334)", async () => {
1526
+ const w = rworld([RED_GREEN]);
1527
+ w.io.runTmp = { prune: async () => {}, path: (id) => `/rt/${id}`, make: async (_i, id) => `/rt/${id}`, remove: async () => true };
1528
+ w.io.prepareToolchain = async () => ({ pins: [{}], env: { PATH: "/shim/bin:/host/bin" }, summary: "pnpm@10.4.1" });
1529
+ assert.equal((await run(w)).terminalReason, "merged");
1530
+ assert.equal(w.rgCalls[0].env.PATH, "/shim/bin:/host/bin"); assert.match(w.rgCalls[0].env.TMPDIR, /^\/rt\//);
1531
+ });
1532
+
1333
1533
  it("a non-testable bug records repro_test none with a reason and is not blocked; a missing repro_test gets the one contract correction", async () => {
1334
1534
  const none = { ...CONTRACT, repro_test: "none", repro_test_reason: "spec-only change, nothing executable" };
1335
1535
  const w = rworld([RED_GREEN], { contract: none });
@@ -1783,6 +1983,21 @@ describe("orchestrate", () => {
1783
1983
  assert.ok(log.indexOf("end 12") < log.indexOf("start 2"));
1784
1984
  });
1785
1985
 
1986
+ it("#4337 a CLOSED decomposed tracker in the batch gates its dependents on its sub-issues like resolveBlock; no trail skips them", async () => {
1987
+ const trail = { body: "<!-- FORGE:DECOMPOSED -->\n- #31 part A\n- #32 part B\n", author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
1988
+ const tracker = async (comments) => run(world({ issueStates: { 7: "CLOSED" }, labels: [{ name: "workflow:decomposed" }], issueComments: comments }));
1989
+ const lane = await tracker([trail]);
1990
+ const log = [];
1991
+ const rows = await orchestrate({ issues: [7, 2], parallel: 3, readBody: async (n) => (n === 2 ? "**Depends on**: #7" : ""),
1992
+ runOne: async (n) => { log.push(`start ${n}`); await new Promise((r) => setImmediate(r)); log.push(`end ${n}`); return n === 7 ? lane : { terminalReason: "merged" }; } });
1993
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[7, "decomposed"], [2, "merged"], [31, "merged"], [32, "merged"]]);
1994
+ assert.ok(log.indexOf("end 31") < log.indexOf("start 2") && log.indexOf("end 32") < log.indexOf("start 2"), log.join(","));
1995
+ const failClosed = await tracker([]);
1996
+ const rows2 = await orchestrate({ issues: [7, 2], parallel: 3, readBody: async (n) => (n === 2 ? "**Depends on**: #7" : ""),
1997
+ runOne: async (n) => (n === 7 ? failClosed : { terminalReason: "merged" }) });
1998
+ assert.equal(rows2.find((r) => r.issue === 2).outcome, "skipped");
1999
+ });
2000
+
1786
2001
  it("a dependency that closed as invalid does not block", async () => {
1787
2002
  const h = harness({ 2: "**Depends on**: #1" }, { 1: "invalid" });
1788
2003
  const rows = await orchestrate({ issues: [1, 2], parallel: 2, ...h });
@@ -2613,6 +2828,25 @@ describe("batch robustness (#4112-#4115)", () => {
2613
2828
  assert.equal(r.heavyQueueMax, 4);
2614
2829
  });
2615
2830
 
2831
+ it("#4331 pre-PR checks: scoped by the test map, concurrent up to FORGE_HOST_PARALLELISM under one heavy slot, timed", async () => {
2832
+ const w = heavyWorld(); w.changed = ["commands/foo.md"];
2833
+ w.baseFiles = { "origin/staging:.forge/test-map.json": JSON.stringify({ version: 1, entries: [
2834
+ { globs: ["commands/**"], command: "bash scripts/check-a.sh", cwd: "." }, { globs: ["bin/**"], command: "bash scripts/check-b.sh", cwd: "." }] }) };
2835
+ const ran = []; let live = 0, peak = 0;
2836
+ w.io.runCommand = async ({ command }) => { ran.push(command); peak = Math.max(peak, ++live); await new Promise((r) => setImmediate(r)); w.clock += 1000; live--; return { rc: 0, output: "" }; };
2837
+ const prev = process.env.FORGE_HOST_PARALLELISM; process.env.FORGE_HOST_PARALLELISM = "2";
2838
+ const dir = tmpDir("core-");
2839
+ let r;
2840
+ try { r = await run(w, { dir, precheck: { commands: ["bash scripts/check-a.sh", "bash scripts/check-b.sh", "make lint", "make fmt"] } }); }
2841
+ finally { if (prev === undefined) delete process.env.FORGE_HOST_PARALLELISM; else process.env.FORGE_HOST_PARALLELISM = prev; }
2842
+ assert.deepEqual(ran, ["bash scripts/check-a.sh", "make lint", "make fmt"], "mapped check-b misses commands/foo.md; unmapped ones run");
2843
+ assert.equal(peak, 2);
2844
+ assert.equal(w.acq.filter((a) => a.phase === "precheck").length, 1, "one heavy slot for the round");
2845
+ const [pre] = readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS");
2846
+ assert.equal(pre.parallelism, 2); assert.ok(pre.ms > 0); assert.ok(pre.results.every((x) => Number.isFinite(x.ms)));
2847
+ assert.ok(r.precheckMs > 0); assert.equal(r.precheckDeferred, 0);
2848
+ });
2849
+
2616
2850
  it("#4169 without io.heavy (or heavy.enabled false) nothing is gated and the runner env is unchanged", async () => {
2617
2851
  const w = world();
2618
2852
  const r = await run(w);
@@ -3172,7 +3406,7 @@ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds fa
3172
3406
  writeFileSync(`${d}/forge.yaml`, "engine:\n conformance: shadow\n");
3173
3407
  assert.equal(readVerify(d), undefined);
3174
3408
  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");
3175
- assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 60, checkRun: false });
3409
+ assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 60, precheckTotalSeconds: 600, checkRun: false });
3176
3410
  });
3177
3411
  });
3178
3412
 
@@ -3511,7 +3745,7 @@ describe("local-mode CI fix rounds at merge and declared pre-PR check commands (
3511
3745
  assert.ok(w.seq.indexOf("builder-resume", w.seq.indexOf("check:1")) < w.seq.indexOf("pr"), "the fix runs before the PR is opened");
3512
3746
  const log = readLog(dir, 7);
3513
3747
  const pre = log.filter((e) => e.event === "PRECHECK_COMMANDS");
3514
- assert.deepEqual(pre.map((e) => e.results), [[{ command: "bash scripts/check-test-tmp-cleanup.sh", rc: 1 }], [{ command: "bash scripts/check-test-tmp-cleanup.sh", rc: 0 }]]);
3748
+ assert.deepEqual(pre.map((e) => e.results), [[{ command: "bash scripts/check-test-tmp-cleanup.sh", rc: 1, ms: 0 }], [{ command: "bash scripts/check-test-tmp-cleanup.sh", rc: 0, ms: 0 }]]);
3515
3749
  assert.ok(log.findIndex((e) => e.event === "PRECHECK_COMMANDS") < log.findIndex((e) => e.event === "PR_OPENED"));
3516
3750
  });
3517
3751
 
@@ -3534,6 +3768,16 @@ describe("local-mode CI fix rounds at merge and declared pre-PR check commands (
3534
3768
  assert.equal(builders(w).filter((b) => /declared check commands failed/.test(b[4])).length, 1);
3535
3769
  assert.match(traj(w)[0], /builder made no new commit for them/);
3536
3770
  });
3771
+
3772
+ it("#4318/#4331 pre-PR: a round has a total budget; a command gets what is left, the rest are deferred to CI, not a failure", async () => {
3773
+ const w = world(); const ran = [];
3774
+ w.io.runCommand = async ({ command, timeoutMs }) => { ran.push([command, timeoutMs]); w.clock += 60000; return { rc: 0, output: "" }; };
3775
+ const r = await run(w, { precheck: { commands: ["make a", "make b", "make c"] }, verify: { precheckTotalSeconds: 100 } });
3776
+ assert.equal(r.terminalReason, "merged");
3777
+ assert.deepEqual(ran, [["make a", 100000], ["make b", 40000]]);
3778
+ assert.equal(builders(w).filter((b) => /declared check commands failed/.test(b[4])).length, 0, "skipped is not failing");
3779
+ assert.match(traj(w)[0], /deferred-to-ci: pre-PR check budget 100 s reached; left to CI: `make c`/);
3780
+ });
3537
3781
  });
3538
3782
 
3539
3783
  describe("test map and repo facts caches (#4272)", () => {
@@ -3547,10 +3791,20 @@ describe("test map and repo facts caches (#4272)", () => {
3547
3791
  { globs: ["**/*.py"], command: "uv run pytest", cwd: ".", source: "pyproject.toml" }, { globs: ["test/**"], command: "npm test", cwd: "pkg", source: "pkg/package.json" }]);
3548
3792
  const dir = tmpDir("core-");
3549
3793
  assert.equal((await run(w, { dir, precheck: { commands: ["make lint"] } })).terminalReason, "merged");
3550
- assert.deepEqual(ran, ["npm run lint", "cd pkg && npm test"]);
3794
+ // #4315: the map adds to learned.test_commands, never replaces them
3795
+ assert.deepEqual(ran, ["make lint", "npm run lint", "cd pkg && npm test"]);
3551
3796
  const pre = readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS");
3552
- assert.deepEqual(pre.map((e) => [e.source, e.results.map((r) => r.command)]), [["test-map", ["npm run lint", "cd pkg && npm test"]]]);
3553
- assert.match(traj(w)[0], /Pre-PR checks selected from \.forge\/test-map\.json: 2 of 3 entries/);
3797
+ assert.deepEqual(pre.map((e) => [e.source, e.results.map((r) => r.command)]), [["test-map+learned", ["make lint", "npm run lint", "cd pkg && npm test"]]]);
3798
+ assert.match(traj(w)[0], /Pre-PR checks selected from \.forge\/test-map\.json: 2 of 3 entries, plus 1 learned/);
3799
+ });
3800
+
3801
+ it("#4315 a partial test map still runs the learned commands it does not cover, each once", async () => {
3802
+ const w = world(); const ran = recorder(w);
3803
+ w.baseFiles = { [MAPF]: map([{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["**/*.go"], command: "go test ./...", cwd: "." }]) };
3804
+ const dir = tmpDir("core-");
3805
+ assert.equal((await run(w, { dir, precheck: { commands: ["npm test", "make lint"] } })).terminalReason, "merged");
3806
+ assert.deepEqual(ran, ["npm test", "make lint"]);
3807
+ assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS").map((e) => Object.keys(e).includes("source")), [false]);
3554
3808
  });
3555
3809
 
3556
3810
  it("the main checkout's test map is used when the base ref has none; a map alone enables the step", async () => {
@@ -3589,7 +3843,7 @@ describe("test map and repo facts caches (#4272)", () => {
3589
3843
  assert.equal((await run(w, { dir, precheck: { commands: ["true", "make lint"] } })).terminalReason, "merged");
3590
3844
  assert.deepEqual(ran, ["true", "make lint"]);
3591
3845
  assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "PRECHECK_COMMANDS").map(({ round, results, ...rest }) => [round, results, Object.keys(rest).filter((k) => k === "source")]),
3592
- [[0, [{ command: "true", rc: 0 }, { command: "make lint", rc: 0 }], []]]);
3846
+ [[0, [{ command: "true", rc: 0, ms: 0 }, { command: "make lint", rc: 0, ms: 0 }], []]]);
3593
3847
  const [plan, impl] = builders(w);
3594
3848
  assert.equal(plan[4].startsWith(builderPlanPrompt(7)), true); assert.equal(impl[4].startsWith(builderImplementPrompt("local")), true);
3595
3849
  for (const b of builders(w)) assert.doesNotMatch(b[4], /Repo facts/);
@@ -4095,3 +4349,76 @@ describe("review trigger (#4273): shadow logs and always reviews, enforce skips
4095
4349
  assert.equal(at("engine:\n path: core\n"), undefined);
4096
4350
  });
4097
4351
  });
4352
+
4353
+ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
4354
+ const LIST = [{ module: "src/foo.js", test: "test/registry.test.js", line: 4, why: "literal list of 3 sibling export names" }];
4355
+ const MISS = [{ symbol: "fooNew", module: "src/foo.js", test: "test/registry.test.js", line: 4 }];
4356
+ const COLL = [{ dir: "db", number: "0007", lane: "db/0007_usage.sql", base: ["db/0007_billing.sql"], next: "0008" }];
4357
+ const CONFLICTING = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" }, OKSTATE = { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" };
4358
+ /** `seq`: sequenceCheck results per call; `verify`: sequenceVerify result; `missing`: enumeratingMissing results per call. */
4359
+ const hworld = ({ list = [], missing = [[]], seq = [[]], verify = { ok: true, problems: [] }, ...o } = {}) => {
4360
+ const w = world(o); w.enumCalls = []; w.seqCalls = 0;
4361
+ const m = [...missing], s = [...seq];
4362
+ w.io.enumeratingTests = async (x) => { w.enumCalls.push(x); return list; };
4363
+ w.io.enumeratingMissing = async () => (m.length > 1 ? m.shift() : m[0]);
4364
+ w.io.sequenceCheck = async () => { w.seqCalls++; return s.length > 1 ? s.shift() : s[0]; };
4365
+ w.io.sequenceSnapshot = async () => [{ path: "db/0007_usage.sql", blob: "b1" }];
4366
+ w.io.sequenceVerify = async (x) => { w.verifyArgs = x; return verify; };
4367
+ return w;
4368
+ };
4369
+
4370
+ it("no enumerating tests and no collisions: no new plan step, no extra builder call (prompts unchanged)", async () => {
4371
+ const w = hworld();
4372
+ assert.equal((await run(w)).terminalReason, "merged");
4373
+ const b = builders(w);
4374
+ assert.equal(b.length, 2);
4375
+ assert.ok(!/Enumerating tests/.test(b[0][4]) && !/Enumerating tests/.test(b[1][4]));
4376
+ assert.equal(w.enumCalls[0].cwd, WT);
4377
+ assert.equal(w.seqCalls, 1, "checked once at hand-off");
4378
+ });
4379
+
4380
+ it("the plan lists the enumerating tests and the implement rule cites them", async () => {
4381
+ const w = hworld({ list: LIST });
4382
+ assert.equal((await run(w)).terminalReason, "merged");
4383
+ const [plan, impl] = builders(w);
4384
+ assert.match(plan[4], /Enumerating tests \(found by the engine\)[\s\S]*src\/foo\.js -> test\/registry\.test\.js:4/);
4385
+ assert.match(impl[4], /one test-list edit per new symbol[\s\S]*not_members[\s\S]*test\/registry\.test\.js:4/);
4386
+ });
4387
+
4388
+ it("a new symbol missing from an enumerating test: one builder resume; still missing is an anomaly, not needs-human", async () => {
4389
+ let w = hworld({ missing: [MISS, []] });
4390
+ assert.equal((await run(w)).terminalReason, "merged");
4391
+ assert.equal(builders(w).length, 3);
4392
+ assert.match(builders(w)[2][4], /fooNew \(src\/foo\.js\) -> test\/registry\.test\.js:4/);
4393
+ w = hworld({ missing: [MISS] });
4394
+ assert.equal((await run(w)).terminalReason, "merged");
4395
+ assert.match(traj(w).at(-1), /Enumerating test\(s\) still miss new symbol\(s\): fooNew → test\/registry\.test\.js/);
4396
+ w = hworld({ missing: [MISS], result: { status: "fixed", summary: "s", evidence: [], tests: [], not_members: [{ symbol: "fooNew", test: "test/registry.test.js", why: "internal helper" }] } });
4397
+ assert.equal((await run(w)).terminalReason, "merged");
4398
+ assert.equal(builders(w).length, 2, "a justified not-member needs no resume");
4399
+ });
4400
+
4401
+ it("a sequence number the lane base already uses is renumbered at hand-off, before the PR; still colliding is base.conflict", async () => {
4402
+ let w = hworld({ seq: [COLL, []] });
4403
+ assert.equal((await run(w)).terminalReason, "merged");
4404
+ const b = builders(w);
4405
+ assert.equal(b.length, 3);
4406
+ assert.match(b[2][4], /origin\/staging already uses sequence numbers[\s\S]*db\/0007_usage\.sql -> number 0008 \(base has db\/0007_billing\.sql\)[\s\S]*git merge --no-edit origin\/staging/);
4407
+ assert.ok(w.seq.indexOf("pr") > w.seq.lastIndexOf("builder-resume"));
4408
+ assert.deepEqual(w.verifyArgs.before, [{ path: "db/0007_usage.sql", blob: "b1" }]);
4409
+ w = hworld({ seq: [COLL], verify: { ok: false, problems: ["db/0007_usage.sql still shares number 0007 with db/0007_billing.sql"] } });
4410
+ const r = await run(w);
4411
+ assert.equal(r.code, OUTCOME_CODES.BASE_CONFLICT);
4412
+ assert.equal(w.prs.length, 0);
4413
+ });
4414
+
4415
+ it("base-sync: a clean text merge that brings a taken number is renumbered and re-reviewed; a conflicted journal gets the no-text-merge rule", async () => {
4416
+ let w = hworld({ seq: [[], COLL, []], prView: [CONFLICTING, OKSTATE], mergeResults: ["clean"], reviews: [ok(), ok()] });
4417
+ assert.equal((await run(w)).terminalReason, "merged");
4418
+ assert.equal(reviewers(w).length, 2, "re-review after the renumber");
4419
+ assert.match(w.comments.find((c) => c.includes("<!-- FORGE:BASE_SYNC -->")), /clean merge[\s\S]*builder renumbered sequence-numbered files/);
4420
+ w = hworld({ prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], conflictFiles: ["db/meta/_journal.json"], reviews: [ok(), ok()] });
4421
+ assert.equal((await run(w)).terminalReason, "merged");
4422
+ assert.match(builders(w)[2][4], /conflicts in: db\/meta\/_journal\.json[\s\S]*do not text-merge it\. Renumber the lane's files/);
4423
+ });
4424
+ });