forgedock 1.49.0 → 1.51.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 (44) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -0
  2. package/bin/engine-cli.mjs +123 -20
  3. package/bin/forgedock.mjs +31 -3
  4. package/bin/lean/batch-health.mjs +219 -0
  5. package/bin/lean/batch-health.test.mjs +156 -0
  6. package/bin/lean/batch.mjs +12 -3
  7. package/bin/lean/batch.test.mjs +304 -8
  8. package/bin/lean/conformance.mjs +79 -7
  9. package/bin/lean/conformance.test.mjs +63 -1
  10. package/bin/lean/context.mjs +8 -2
  11. package/bin/lean/context.test.mjs +11 -1
  12. package/bin/lean/credentials.mjs +18 -0
  13. package/bin/lean/execution.mjs +23 -9
  14. package/bin/lean/execution.test.mjs +93 -0
  15. package/bin/lean/fixclosereasons.mjs +52 -0
  16. package/bin/lean/fixclosereasons.test.mjs +64 -0
  17. package/bin/lean/heavy.mjs +14 -4
  18. package/bin/lean/heavy.test.mjs +55 -1
  19. package/bin/lean/lean.mjs +93 -36
  20. package/bin/lean/lean.test.mjs +143 -19
  21. package/bin/lean/live-execution.mjs +136 -0
  22. package/bin/lean/live-execution.test.mjs +83 -0
  23. package/bin/lean/orchestrate-blocked.test.mjs +47 -0
  24. package/bin/lean/orchestrate.mjs +135 -17
  25. package/bin/lean/prefilter.mjs +4 -3
  26. package/bin/lean/prefilter.test.mjs +5 -4
  27. package/bin/lean/prompts.mjs +4 -2
  28. package/bin/lean/recovery.test.mjs +5 -5
  29. package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
  30. package/bin/lean/sandbox-remote.mjs +6 -2
  31. package/bin/lean/schedd.mjs +9 -0
  32. package/bin/pool/agent.mjs +3 -1
  33. package/bin/pool/router.mjs +72 -7
  34. package/bin/review-staging.mjs +2 -0
  35. package/bin/runner.mjs +24 -0
  36. package/commands/orchestrate.md +2 -1
  37. package/commands/review-pr-staging.md +2 -2
  38. package/docs/CONFIG.md +39 -1
  39. package/docs/OUTCOME-CODES.md +2 -1
  40. package/package.json +1 -1
  41. package/pool/README.md +2 -0
  42. package/pool/image/cs-cred +8 -1
  43. package/scripts/review-staging-post.sh +70 -25
  44. package/scripts/review-staging-post.test.sh +40 -8
@@ -3,11 +3,11 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { resolveLane, makeLaneCache } from "./lane.mjs";
10
- import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER } from "./orchestrate.mjs";
10
+ import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists, CAPACITY_FALLBACK_AFTER, isTransientEngineError, transientRetryDelayMs, DEFAULT_TRANSIENT_RETRIES } from "./orchestrate.mjs";
11
11
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
12
12
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
13
13
 
@@ -265,9 +265,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
265
265
  assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), true);
266
266
  writeFileSync(join(pd, "stat"), "42 (no) de) Z 1 42 42 0 -1\n");
267
267
  assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), false);
268
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, blockedLabel: "blocked", globalParallel: null, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
269
- writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
270
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, blockedLabel: "blocked", globalParallel: 4, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
268
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, transientRetries: 4, blockedLabel: "blocked", globalParallel: null, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
269
+ writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n transient_retries: 2\n");
270
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, transientRetries: 2, blockedLabel: "blocked", globalParallel: 4, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
271
271
  });
272
272
 
273
273
  it("run-issue --result-file writes the lane result, also when the run throws", async () => {
@@ -794,7 +794,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
794
794
  const mk = (issue, prs, events = "", { body = "{}", open = "[]" } = {}) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
795
795
  if (a[0] === "issue" && a[a.length - 1] === "body") return body; // #4920: the lease lookup
796
796
  if (a[0] === "pr" && a.includes("open")) return open; // #4920: the open lane PR lookup
797
- if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
797
+ if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason,labels"]); if (issue instanceof Error) throw issue; return issue; }
798
798
  if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
799
799
  assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
800
800
  if (prs instanceof Error) throw prs;
@@ -813,6 +813,42 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
813
813
  assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
814
814
  assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
815
815
  });
816
+ it("#5152 makeDispatchCheck: a merged investigation closed as partial (NOT_PLANNED + workflow:merged) satisfies its dependents", async () => {
817
+ // the blocker's lane PR is not on feat/issue-9-lean (legacy or renamed branch): only its labels say it was delivered
818
+ let closed = false;
819
+ const gh = async (a) => {
820
+ if (a[0] === "issue" && a[2] === "9" && a.includes("state,stateReason,labels")) {
821
+ return JSON.stringify(closed ? { state: "CLOSED", stateReason: "NOT_PLANNED", labels: [{ name: "workflow:merged" }] } : { state: "OPEN", labels: [] });
822
+ }
823
+ if (a[0] === "issue" && a[a.length - 1] === "body") return "{}";
824
+ if (a[0] === "pr") return "[]";
825
+ return "";
826
+ };
827
+ const dispatchCheck = makeDispatchCheck({ repo: "o/r", gh });
828
+ closed = true;
829
+ assert.deepEqual(await dispatchCheck(9), { outcome: "merged", detail: "issue closed, workflow:merged" }, "never final: delivered scope");
830
+ closed = false;
831
+ const ran = [];
832
+ const rows = await orchestrate({ issues: [9, 1], parallel: 1, readBody: async (n) => (n === 1 ? "**Depends on**: #9" : ""), dispatchCheck,
833
+ runOne: async (n) => { ran.push(n); if (n === 9) { closed = true; return { terminalReason: "needs-human", detail: "ci.red" }; } return { terminalReason: "merged", pr: 101 }; } });
834
+ assert.ok(ran.includes(1), "the dependent runs instead of being skipped");
835
+ assert.deepEqual(rows.find((r) => r.issue === 1)?.outcome, "merged");
836
+ });
837
+ it("#5152 makeDispatchCheck: decomposed parents are satisfied once every sub-issue closed; only an unlabelled NOT_PLANNED is final", async () => {
838
+ const dec = '<!-- FORGE:DECOMPOSED -->\n## Split into sub-issues\n- #21 a\n- #22 b';
839
+ const mk = (labels, subs, { decomposed = dec } = {}) => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
840
+ if (a[0] === "issue" && a[2] === "9") return JSON.stringify({ state: "CLOSED", stateReason: "NOT_PLANNED", labels });
841
+ if (a[0] === "issue" && ["21", "22"].includes(a[2])) return JSON.stringify({ state: subs[a[2]] });
842
+ if (a[0] === "api") { assert.equal(a.find((x) => String(x).includes("/issues/9/comments")), "repos/o/r/issues/9/comments", "pinned to the batch repo"); return JSON.stringify({ body: decomposed, author_association: "NONE", user: { type: "Bot", login: "forgedock[bot]" } }); }
843
+ if (a[0] === "pr") return "[]";
844
+ return "";
845
+ } });
846
+ const d = [{ name: "workflow:decomposed" }];
847
+ assert.deepEqual(await mk(d, { 21: "CLOSED", 22: "CLOSED" })(9), { outcome: "merged", detail: "issue decomposed; all sub-issues closed" });
848
+ assert.deepEqual(await mk(d, { 21: "CLOSED", 22: "OPEN" })(9), { outcome: "invalid", detail: "issue closed: decomposed, sub-issues still open" }, "moving: not final");
849
+ assert.deepEqual(await mk(d, {}, { decomposed: "no trail" })(9), { outcome: "invalid", detail: "issue closed: decomposed, sub-issues still open" }, "no trail: not final");
850
+ assert.deepEqual(await mk([{ name: "workflow:invalid" }], {})(9), { outcome: "invalid", detail: "issue closed (not planned)", final: true }, "#4687 kept: dropped scope is final");
851
+ });
816
852
  });
817
853
 
818
854
  describe("#4504: a resume settles lanes that ended while no dispatcher ran", () => {
@@ -933,7 +969,7 @@ describe("#4550: forgedock orchestrate --requeue", () => {
933
969
  if (n === 1 && started.filter((x) => x === 1).length === 1) return { terminalReason: "needs-human", detail: "builder.blocked: product-decision" };
934
970
  return { terminalReason: "merged" };
935
971
  } });
936
- await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
972
+ await until(() => log.some((r) => r.issue === 2 && ["skipped", "held"].includes(r.outcome)));
937
973
  entries = [{ issue: 1, reason: "decided in scope", by: "operator", at: "2026-10-24T00:00:00Z" }];
938
974
  await until(() => started.filter((x) => x === 2).length === 1, 3000);
939
975
  g3.open();
@@ -1105,7 +1141,7 @@ describe("#5112: late dependents of a decomposed issue, edge re-read on --requeu
1105
1141
  if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
1106
1142
  return n === 1 ? { terminalReason: "needs-human", detail: "builder.blocked: product-decision" } : { terminalReason: "merged" };
1107
1143
  } });
1108
- await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
1144
+ await until(() => log.some((r) => r.issue === 2 && ["skipped", "held"].includes(r.outcome)));
1109
1145
  bodies[2] = ""; // the operator dropped the wrong edge
1110
1146
  entries = [{ issue: 2, reason: "edge dropped", by: "operator", at: "2026-11-05T00:00:00Z" }];
1111
1147
  await until(() => started.includes(2), 3000);
@@ -1345,3 +1381,263 @@ describe("batch plan secrets (#4787)", () => {
1345
1381
  assert.ok(fast < 10 * LAT, `first lane after ${fast} ms`);
1346
1382
  });
1347
1383
  });
1384
+
1385
+ describe("#5138: live max_remote and placement of a running batch", () => {
1386
+ const tick = () => new Promise((r) => setTimeout(r, 1));
1387
+ it("live max_remote raised from 1 to 2 admits a waiting remote lane while the first still runs", async () => {
1388
+ let m = 1, open;
1389
+ const gate = new Promise((r) => { open = r; });
1390
+ const live = new Set(), seen = [];
1391
+ const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 1, readBody: async () => "", sleep: tick,
1392
+ remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
1393
+ runOne: async (n, _ln, ctx = {}) => { live.add(n); seen.push([n, ctx.placement?.where, [...live].sort()]); if (n === 1) await gate; live.delete(n); return { terminalReason: "merged", pr: 100 + n }; } });
1394
+ await until(() => live.has(1));
1395
+ for (let i = 0; i < 20; i++) await tick();
1396
+ assert.ok(!seen.some(([n]) => n === 2), "#2 waits: max_remote 1 is taken by #1");
1397
+ m = 2;
1398
+ await until(() => seen.some(([n]) => n === 2));
1399
+ assert.deepEqual(seen.find(([n]) => n === 2), [2, "remote", [1, 2]]);
1400
+ open();
1401
+ const rows = await done;
1402
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
1403
+ });
1404
+ it("live max_remote raised from 0 to 1 admits a waiting remote lane (nothing running)", async () => {
1405
+ let m = 0;
1406
+ const seen = [];
1407
+ const done = orchestrate({ issues: [1], parallel: 2, pollMs: 1, readBody: async () => "", sleep: tick,
1408
+ remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
1409
+ runOne: async (n, _ln, ctx = {}) => { seen.push([n, ctx.placement?.where]); return { terminalReason: "merged", pr: 100 + n }; } });
1410
+ for (let i = 0; i < 20; i++) await tick();
1411
+ assert.deepEqual(seen, [], "#1 waits under max_remote 0");
1412
+ m = 1;
1413
+ const rows = await done;
1414
+ assert.deepEqual(seen, [[1, "remote"]]);
1415
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged"]);
1416
+ });
1417
+ it("live max_remote lowered from 2 to 1 stops no running lane and holds new remote admissions", async () => {
1418
+ let m = 2;
1419
+ const live = new Set(), gates = new Map(), seen = [];
1420
+ const gate = (n) => { let open; const p = new Promise((r) => { open = r; }); gates.set(n, open); return p; };
1421
+ const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 1, readBody: async () => "", sleep: tick,
1422
+ remote: { get max() { return m; }, isRemote: () => true, capacity: () => null },
1423
+ runOne: async (n) => { live.add(n); seen.push([n, [...live].sort()]); if (n !== 3) await gate(n); live.delete(n); return { terminalReason: "merged", pr: 100 + n }; } });
1424
+ await until(() => live.size === 2);
1425
+ m = 1;
1426
+ gates.get(1)();
1427
+ for (let i = 0; i < 20; i++) await tick();
1428
+ assert.ok(!seen.some(([n]) => n === 3), "#3 waits while #2 holds the one remote slot");
1429
+ assert.ok(live.has(2), "#2 keeps running");
1430
+ gates.get(2)();
1431
+ const rows = await done;
1432
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
1433
+ assert.deepEqual(seen.find(([n]) => n === 3)[1], [3]);
1434
+ });
1435
+ it("--set-max-remote / --set-placement write the batch's execution file; bad values and provider-less remote refused", async () => {
1436
+ const root = tmpDir("batch-live-exec-");
1437
+ const b = openBatch(root, "b1");
1438
+ b.writePlan({ argv: ["1"] });
1439
+ await quiet(() => orchestrateFromCli(["--set-max-remote", "b1", "3"], { batchRoot: root }));
1440
+ assert.deepEqual(b.readExecution(), { maxRemote: 3 });
1441
+ await quiet(() => orchestrateFromCli(["--set-placement", "b1", "local"], { batchRoot: root }));
1442
+ assert.deepEqual(b.readExecution(), { maxRemote: 3, mode: "local" });
1443
+ await assert.rejects(orchestrateFromCli(["--set-placement", "b1", "hybrid"], { batchRoot: root }), /no remote provider/);
1444
+ await assert.rejects(orchestrateFromCli(["--set-max-remote", "b1", "-1"], { batchRoot: root }), /non-negative integer/);
1445
+ await assert.rejects(orchestrateFromCli(["--set-placement", "b1", "cloud"], { batchRoot: root }), /placement must be one of/);
1446
+ await assert.rejects(orchestrateFromCli(["--set-max-remote", "nope", "1"], { batchRoot: root }), /no batch nope/);
1447
+ b.setExecution({ provider: true });
1448
+ await quiet(() => orchestrateFromCli(["--set-placement", "b1", "hybrid"], { batchRoot: root }));
1449
+ assert.equal(b.readExecution().mode, "hybrid");
1450
+ });
1451
+ });
1452
+
1453
+ describe("#5161 transient engine-error retries", () => {
1454
+ const CURL_502 = 'claude CLI exited with status 1. Output: curl: (22) The requested URL returned error: 502 {"duration_api_ms":0}';
1455
+ // Launch times per issue under the fake clock.
1456
+ const launches = () => { const at = new Map(); return { at, note: (n, t) => at.set(n, [...(at.get(n) ?? []), t]) }; };
1457
+ const gaps = (ts) => ts.slice(1).map((t, i) => t - ts[i]);
1458
+
1459
+ it("#5161 the classifier: curl 502, HTTP/1.1 5xx, Bad Gateway, Service Unavailable, overloaded and resets are transient; code failures are not", () => {
1460
+ for (const d of [CURL_502, "HTTP/1.1 503 Service Unavailable", "HTTP/2 504", "502 Bad Gateway", "Service Unavailable", "API overloaded", "read ECONNRESET", "sandbox launch: fetch failed"])
1461
+ assert.equal(isTransientEngineError(d), true, d);
1462
+ for (const d of ["boom", "TypeError: x is undefined", "", null, undefined]) assert.equal(isTransientEngineError(d), false, String(d));
1463
+ assert.deepEqual([1, 2, 3, 4, 5].map(transientRetryDelayMs), [30_000, 120_000, 480_000, 900_000, 900_000]);
1464
+ assert.equal(DEFAULT_TRANSIENT_RETRIES, 4);
1465
+ });
1466
+
1467
+ it("#5161 a curl 502 engine-error is retried with growing backoff and merges, not ended after one immediate relaunch", async () => {
1468
+ const { c, now, sleep } = fakeClock();
1469
+ const l = launches(), updates = [];
1470
+ let tries = 0;
1471
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, onUpdate: (r) => updates.push(r),
1472
+ runOne: async (n) => { l.note(n, c.t); if (++tries <= 3) throw new Error(CURL_502); return { terminalReason: "merged", pr: 7 }; } });
1473
+ assert.equal(rows[0].outcome, "merged");
1474
+ const g = gaps(l.at.get(1));
1475
+ assert.equal(g.length, 3);
1476
+ assert.ok(g[0] >= 30_000 && g[1] >= 120_000 && g[2] >= 480_000, `gaps ${g}`);
1477
+ assert.ok(!updates.some((u) => /relaunching once/.test(u.detail ?? "")));
1478
+ });
1479
+
1480
+ it("#5161 an always-502 issue is retried up to transientRetries with growing delays, then ends engine-error; one decided row per retry", async () => {
1481
+ const { c, now, sleep } = fakeClock();
1482
+ const l = launches(), decisions = [];
1483
+ const [row] = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, transientRetries: 2, decide: (d) => decisions.push(d),
1484
+ runOne: async (n) => { l.note(n, c.t); throw new Error(CURL_502); } });
1485
+ assert.equal(row.outcome, "engine-error");
1486
+ assert.match(row.detail, /gave up after 2 transient retries/);
1487
+ const g = gaps(l.at.get(1));
1488
+ assert.equal(g.length, 2);
1489
+ assert.ok(g[0] > 0 && g[1] > g[0], `gaps ${g}`);
1490
+ assert.equal(decisions.length, 2);
1491
+ assert.ok(decisions.every((d) => d.issue === 1 && /^transient: /.test(d.why) && /retry after transient engine-error \(\d\/2\)/.test(d.what)));
1492
+ });
1493
+
1494
+ it("#5161 a dependent stays pending while its root's transient retries are pending and runs after it merges", async () => {
1495
+ const { now, sleep } = fakeClock();
1496
+ const started = [], updates = [];
1497
+ let tries = 0;
1498
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), now, sleep, onUpdate: (r) => updates.push(r),
1499
+ runOne: async (n) => { started.push(n); if (n === 1 && ++tries <= 2) throw new Error(CURL_502); return { terminalReason: "merged", pr: 10 + n }; } });
1500
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
1501
+ assert.ok(!updates.some((u) => u.issue === 2 && u.outcome === "skipped"));
1502
+ assert.deepEqual(started, [1, 1, 1, 2]);
1503
+ });
1504
+
1505
+ it("#5161 a non-transient engine-error keeps the single immediate relaunch", async () => {
1506
+ const { c, now, sleep } = fakeClock();
1507
+ const l = launches();
1508
+ const [row] = await orchestrate({ issues: [9], readBody: async () => "", now, sleep, runOne: async (n) => { l.note(n, c.t); throw new Error("boom"); } });
1509
+ assert.equal(row.outcome, "engine-error");
1510
+ assert.deepEqual(gaps(l.at.get(9)), [0]);
1511
+ });
1512
+
1513
+ it("#5161 the breaker still counts transient engine-errors as transport errors (#4685)", async () => {
1514
+ const { now, sleep } = fakeClock();
1515
+ const saves = [];
1516
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", now, sleep, breaker: { errors: 2, window_min: 30 },
1517
+ store: { load: () => null, save: (st) => saves.push(st) }, runOne: async () => { throw new Error(CURL_502); } });
1518
+ assert.equal(saves.map((st) => st.breaker).find(Boolean)?.transport, true);
1519
+ assert.ok(rows.some((r) => r.outcome === "held"));
1520
+ });
1521
+ });
1522
+
1523
+ describe("#5167 early batch health in runBatch", () => {
1524
+ // #1 and #3 are roots, #2 depends on #1. #3 runs until `release`; the captured T+15 timer fires while it runs.
1525
+ const mk = ({ fail }) => {
1526
+ const timers = [], comments = [], notes = [], gate = { open: null };
1527
+ const released = new Promise((r) => { gate.open = r; });
1528
+ const bodies = { 1: "", 2: "Depends on #1", 3: "" };
1529
+ const leanIo = { gh: async (a) => {
1530
+ if (a[0] === "issue" && a[1] === "comment") { if (/FORGE:BATCH-HEALTH/.test(a.at(-1))) comments.push(a); return ""; }
1531
+ if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ body: bodies[a[2]] ?? "", milestone: { title: "M Two" }, comments: [], author: { login: "x" } });
1532
+ return a[0] === "repo" ? "main\n" : "[]";
1533
+ }, git: async (a) => (a[0] === "ls-remote" ? "abc\tref\n" : "") };
1534
+ const deps = { dir: tmpDir("bh-run-"), batchRoot: tmpDir("bh-batch-"), allowTmpfs: true, emitTelemetry: async () => {}, leanIo, pollMs: 5, preflight: null,
1535
+ io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ body: bodies[a[2]] ?? "", state: "OPEN" }) : "o/r\n"), git: async () => "" },
1536
+ healthConfig: { rootFailed: true, terminalShare: 0.5, stalled: true, checksMin: [15, 60], issue: 77, notifyCommand: "notify-me" },
1537
+ healthTimer: { set: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clear: () => {} },
1538
+ notify: async (cmd, payload) => { notes.push([cmd, payload]); return { ok: true }; },
1539
+ leanModule: { runCore: async (o) => {
1540
+ if (o.issue === 3) { await released; return { terminalReason: "merged", pr: 3, cost_usd: 0, wall_ms: 1 }; }
1541
+ return fail ? { terminalReason: "engine-error", detail: "lane crashed: boom", cost_usd: 0, wall_ms: 1 } : { terminalReason: "merged", pr: o.issue, cost_usd: 0, wall_ms: 1 };
1542
+ } } };
1543
+ return { deps, timers, comments, notes, gate };
1544
+ };
1545
+ const capture = async (fn) => { const lines = [], l = console.log; console.log = (...a) => lines.push(a.join(" ")); try { await fn(); } finally { console.log = l; } return lines; };
1546
+
1547
+ it("#5167 roots ending engine-error raise one BATCH UNHEALTHY at T+15 with recovery commands, on every channel", async () => {
1548
+ const { deps, timers, comments, notes, gate } = mk({ fail: true });
1549
+ let id = null;
1550
+ const lines = await capture(async () => {
1551
+ const run = orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter", "--parallel", "3"], deps);
1552
+ try {
1553
+ await until(() => { id = readdirSyncSafe(deps.batchRoot)[0]; return id && JSON.parse(readFileSync(join(deps.batchRoot, id, "state.json"), "utf8")).results?.[1]; });
1554
+ assert.deepEqual(timers.map((t) => t.ms > 14 * 60000 && t.ms <= 15 * 60000 ? 15 : t.ms > 59 * 60000 ? 60 : t.ms), [15, 60], "T+15 and T+60 armed");
1555
+ await timers[0].fn();
1556
+ await until(() => existsSync(join(deps.batchRoot, id, "health.json")) && JSON.parse(readFileSync(join(deps.batchRoot, id, "health.json"), "utf8")).alertedAt, 5000);
1557
+ await until(() => notes.length === 1, 5000);
1558
+ } finally { gate.open(); await run; }
1559
+ });
1560
+ assert.ok(lines.some((l) => /^health checks: T\+15 \d\d:\d\dZ, T\+60 \d\d:\d\dZ, and at batch end; forgedock status --batch /.test(l)), lines.join("\n"));
1561
+ const alerts = lines.filter((l) => /^BATCH UNHEALTHY /.test(l));
1562
+ assert.equal(alerts.length, 1, "alerted once (T+15), not again at batch end");
1563
+ assert.match(alerts[0], /\(T\+15\)/);
1564
+ assert.match(alerts[0], /cause: root-failed: root #1 engine-error/);
1565
+ assert.match(alerts[0], /recovery:\n {4}forgedock orchestrate --requeue \S+ 1 2 --reason/);
1566
+ assert.match(alerts[0], new RegExp(`forgedock orchestrate --cancel ${id}`));
1567
+ assert.equal(comments.length, 1);
1568
+ assert.deepEqual(comments[0].slice(0, 5), ["issue", "comment", "77", "-R", "o/r"]);
1569
+ assert.equal(notes[0][0], "notify-me");
1570
+ assert.equal(notes[0][1].batch, id);
1571
+ const h = JSON.parse(readFileSync(join(deps.batchRoot, id, "health.json"), "utf8"));
1572
+ assert.deepEqual(Object.keys(h.checks).sort(), ["T+15", "end"]);
1573
+ });
1574
+
1575
+ it("#5167 a healthy batch emits no alert", async () => {
1576
+ const { deps, timers, comments, notes, gate } = mk({ fail: false });
1577
+ gate.open();
1578
+ const lines = await capture(() => orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter"], deps));
1579
+ for (const t of timers) await t.fn(); // a late timer after the end still finds the batch healthy
1580
+ assert.ok(!lines.some((l) => /BATCH UNHEALTHY/.test(l)), lines.join("\n"));
1581
+ assert.equal(comments.length + notes.length, 0);
1582
+ const [id] = readdirSyncSafe(deps.batchRoot);
1583
+ const h = JSON.parse(readFileSync(join(deps.batchRoot, id, "health.json"), "utf8"));
1584
+ assert.equal(h.last.healthy, true);
1585
+ assert.equal(h.alertedAt, undefined);
1586
+ });
1587
+ });
1588
+
1589
+
1590
+ describe("#5168: --requeue accepts engine-error; a claude login failure is held, not an engine-error", () => {
1591
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
1592
+ it("#5168 --requeue accepts a final engine-error outcome and resets its engine-error count", async () => {
1593
+ const b = openBatch(tmpDir("batch-requeue-5168-"), "b1");
1594
+ b.writePlan({ argv: ["1"], repo: "o/r" });
1595
+ b.store.save({ order: [1, 2], results: { 1: { outcome: "engine-error" }, 2: { outcome: "merged" } } });
1596
+ const r = b.requeue([1, 2], { reason: "login fixed", by: "operator" });
1597
+ assert.deepEqual(r.requeued, [1]);
1598
+ assert.deepEqual(r.refused, [{ issue: 2, reason: "not terminal" }]);
1599
+
1600
+ // The runner applies it: the issue returns to pending with a fresh relaunch (its engine-error count reset).
1601
+ let entries = [];
1602
+ const started = [], g2 = gate(), log = [];
1603
+ const done = orchestrate({ issues: [1, 2], parallel: 2, pollMs: 5, readBody: async () => "", getRequeued: () => entries, onUpdate: (u) => log.push(u),
1604
+ runOne: async (n) => {
1605
+ started.push(n);
1606
+ if (n === 2) { await g2.p; return { terminalReason: "merged" }; }
1607
+ return started.filter((x) => x === 1).length <= 3 ? { terminalReason: "engine-error", detail: "Failed to authenticate" } : { terminalReason: "merged" };
1608
+ } });
1609
+ await until(() => log.some((u) => u.issue === 1 && u.outcome === "engine-error" && !/relaunching/.test(u.detail ?? "")));
1610
+ entries = [{ issue: 1, reason: "login fixed", by: "operator", at: "2026-11-07T00:00:00Z" }];
1611
+ try { await until(() => started.filter((x) => x === 1).length >= 4, 3000); } catch { /* asserted below */ }
1612
+ g2.open();
1613
+ const rows = await done;
1614
+ assert.equal(started.filter((x) => x === 1).length, 4, "two runs before the requeue, then a fresh run and its relaunch");
1615
+ assert.equal(rows.find((x) => x.issue === 1).outcome, "merged");
1616
+ assert.equal(rows.find((x) => x.issue === 1).requeued.prior[0].outcome, "engine-error");
1617
+ });
1618
+
1619
+ it("#5168 deferPolicy holds backend.auth like a usage limit: never capped, never counted", () => {
1620
+ assert.deepEqual(deferPolicy({ code: "backend.auth", retryInSeconds: 900 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 900_000, hold: true });
1621
+ assert.equal(deferPolicy({ code: "backend.auth" }, { now: 0 }).hold, true);
1622
+ });
1623
+
1624
+ it("#5168 a backend.auth lane stays pending under the account hold: no relaunch used, no breaker, no engine-error", async () => {
1625
+ const { now, sleep, c } = fakeClock();
1626
+ const t0 = c.t, updates = [], starts = {};
1627
+ let calls = 0;
1628
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, now, sleep, readBody: async () => "", breaker: { errors: 1, window_min: 30 }, maxDeferrals: 1,
1629
+ onUpdate: (u) => updates.push(u),
1630
+ runOne: async (n) => {
1631
+ (starts[n] ??= []).push(now());
1632
+ if (n === 1 && ++calls <= 3) return { terminalReason: "deferred", code: "backend.auth", retryInSeconds: 900, backendId: "default", detail: "claude CLI authentication failed" };
1633
+ return { terminalReason: "merged", pr: 5 };
1634
+ } });
1635
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
1636
+ assert.equal(starts[1].length, 4, "deferred past maxDeferrals: the hold is uncapped");
1637
+ assert.ok(starts[2][0] >= t0 + 900_000, "the held account admits nothing until the hold ends");
1638
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
1639
+ assert.ok(updates.some((u) => u.outcome === "paused" && /paused \(backend\.auth\)/.test(u.detail ?? "")));
1640
+ });
1641
+ });
1642
+
1643
+ function readdirSyncSafe(d) { try { return readdirSync(d).filter((n) => !n.endsWith(".json")); } catch { return []; } }
@@ -73,6 +73,32 @@ export function inIssue(quote, issueText) {
73
73
  if (q.length < 3) return false;
74
74
  return String(issueText ?? "").split("\n").some((l) => norm(l.replace(ITEM_PREFIX_RE, "")) === q);
75
75
  }
76
+ /** #5164: the issue's list or checklist item lines (item marker stripped), or every non-empty line when it has none. */
77
+ export function criterionLines(issueText) {
78
+ const lines = String(noComments(String(issueText ?? ""))).split("\n");
79
+ const items = lines.filter((l) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(l)).map((l) => l.replace(ITEM_PREFIX_RE, "").trim()).filter((l) => norm(l));
80
+ return items.length ? items : lines.map((l) => l.trim()).filter((l) => norm(l));
81
+ }
82
+ /** #5164: norm() plus what a reviewer retyping a criterion slips on: `#N` reference digits, trailing punctuation, a plural `s`. */
83
+ const loose = (s) => norm(String(s ?? "").replace(/#\d+/g, "#")).replace(/[\s.,;:!?)\]]+$/, "").replace(/\b([a-z]{3,})s\b/g, "$1");
84
+ const MATCH_MIN = 0.8;
85
+ /**
86
+ * #5164: the issue's exact criterion line a near-verbatim quote stands for, or null. After loose normalisation the quote is a
87
+ * prefix covering at least 80% of exactly one line, or else at least 80% of its words are in exactly one line (no ties).
88
+ */
89
+ export function matchCriterion(quote, issueText) {
90
+ const q = loose(quote);
91
+ if (q.length < MIN_QUOTE_CHARS) return null;
92
+ const lines = criterionLines(issueText).map((raw) => ({ raw, l: loose(raw) }));
93
+ const prefix = lines.filter(({ l }) => l.startsWith(q) && q.length >= MATCH_MIN * l.length);
94
+ if (prefix.length === 1) return prefix[0].raw;
95
+ if (prefix.length > 1) return null;
96
+ const qw = [...new Set(q.match(WORD_RE) ?? [])];
97
+ if (qw.length < 3) return null;
98
+ const hits = lines.filter(({ l }) => { const lw = new Set(l.match(WORD_RE) ?? []); return qw.filter((w) => lw.has(w)).length / qw.length >= MATCH_MIN; });
99
+ return hits.length === 1 ? hits[0].raw : null;
100
+ }
101
+ const MISQUOTE_PREFIX = "issue_coverage quote is not verbatim from the issue";
76
102
  // Scan-based (not a one-pass regex), so nested or unclosed markers cannot survive into the scope hash.
77
103
  const stripHtmlComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
78
104
  /**
@@ -268,7 +294,7 @@ const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does no
268
294
 
269
295
  const E2E_TERM = String.raw`(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)`;
270
296
  const E2E_WORD_RE = new RegExp(String.raw`\b${E2E_TERM}\b`, "i");
271
- const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit(?:[- ]tests?)?|vitest|jest|component tests?|route tests?)`;
297
+ const MOCKED_TERM = String.raw`(?:mock\w*|stub\w*|fake\w*|unit(?:[- ]?test\w*)?|(?:use[sd]?|using|via|by)\s+units|vitest|jest|component tests?|route tests?)`;
272
298
  const MOCKED_RE = new RegExp(String.raw`\b${MOCKED_TERM}\b`, "i");
273
299
  // #5116: a negated mock ("do not mock", "no stubs") or a fake clock is not a substitute for the e2e check.
274
300
  const NEG_MOCK_RE = /\b(?:do(?:es)? not|don't|never|no|not|without)\s+(?:use\s+|any\s+)?(?:mock\w*|stub\w*|fake\w*)/gi;
@@ -277,7 +303,10 @@ const NEG_DROP_RE = /\b(?:do(?:es)? not|don't|never|not)\s+(?:be\s+|been\s+|bein
277
303
  const FAKE_CLOCK_RE = /\bfake[- ](?:timers?|clocks?)\b/gi;
278
304
  // A weakener governs the e2e term only within a few words and the same clause (no . or ; between them).
279
305
  const GAP = String.raw`(?:[\s,]+\w+){0,5}?[\s,]+`;
280
- const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:no|not|without|never|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP}${E2E_TERM}\b`, "i");
306
+ // #5145/#5157: only the determiner "no" ("no new fixtures needed for e2e") stops at a preposition, since it negates a noun. not/without/never
307
+ // negate the verb that governs e2e ("not need to run e2e", "not with e2e") and "no need/reason/point/requirement" negates the action; all keep the wide GAP.
308
+ const NEG_GAP = String.raw`(?:[\s,]+(?!(?:for|of|in|on|at|by|with|from|via)\b)\w+){0,5}?[\s,]+`;
309
+ const DROP_BEFORE_RE = new RegExp(String.raw`\b(?:no${NEG_GAP}|(?:not|without|never|no\s+(?:need|reason|point|requirement)|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|swap\w*)${GAP})${E2E_TERM}\b`, "i");
281
310
  const DROP_AFTER_RE = new RegExp(String.raw`\b${E2E_TERM}${GAP}(?:replaced|dropped|skipped|swapped|downgraded|unnecessary|(?:not|no longer)\s+(?:needed|required|necessary|run\w*|used|executed))\b`, "i");
282
311
  const INSTEAD_OF_RE = new RegExp(String.raw`\b${MOCKED_TERM}[^.;]{0,60}?\b(?:instead of|rather than|in lieu of)\b${GAP}${E2E_TERM}\b`, "i");
283
312
  const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
@@ -1000,6 +1029,13 @@ const MISSING_PATH_RE = /^[^:]+: evidence path .+ does not exist$/;
1000
1029
  export function evidencePathOnly(problems = []) {
1001
1030
  return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)));
1002
1031
  }
1032
+ /**
1033
+ * #5164: true when every problem of an incomplete review is a reviewer slip a targeted re-ask can fix: an evidence path that
1034
+ * did not resolve (#4327) or an issue_coverage misquote. A real coverage gap is never one, so it keeps the two-run limit.
1035
+ */
1036
+ export function reaskable(problems = []) {
1037
+ return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)) || String(p).startsWith(MISQUOTE_PREFIX));
1038
+ }
1003
1039
  const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
1004
1040
 
1005
1041
  /**
@@ -1007,7 +1043,7 @@ const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !igno
1007
1043
  * contract's own authorization ("none" when unauthorized, still scope creep), a preserve row per id as "unverified"
1008
1044
  * (still preserve_violated), and implemented evidence rewritten to `path:line` with the repo path. Returns a new object.
1009
1045
  */
1010
- export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = [], ignore = [] }) {
1046
+ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = [], ignore = [], issueText = null }) {
1011
1047
  if (!rv || typeof rv !== "object") return rv;
1012
1048
  const out = { ...rv };
1013
1049
  const scope = Array.isArray(rv.scope) ? [...rv.scope] : [];
@@ -1016,6 +1052,14 @@ export function fillEngineRows(rv, { contract, changedFiles = [], repoFiles = []
1016
1052
  const pres = Array.isArray(rv.preserve) ? [...rv.preserve] : [];
1017
1053
  for (const c of contract.preserve ?? []) if (!pres.some((x) => x?.id === c.id)) pres.push({ id: c.id, status: "unverified", note: "no row from the reviewer", filled: true });
1018
1054
  out.preserve = pres;
1055
+ // #5164: a near-verbatim coverage quote is replaced by the issue's exact criterion line; the original stays as `requoted`
1056
+ if (Array.isArray(rv.issue_coverage) && issueText != null) {
1057
+ out.issue_coverage = rv.issue_coverage.map((c) => {
1058
+ if (!c || typeof c !== "object" || typeof c.quote !== "string" || inIssue(c.quote, issueText)) return c;
1059
+ const line = matchCriterion(c.quote, issueText);
1060
+ return line ? { ...c, quote: line, requoted: c.quote } : c;
1061
+ });
1062
+ }
1019
1063
  if (Array.isArray(rv.requirements)) {
1020
1064
  out.requirements = rv.requirements.map((r) => {
1021
1065
  if (r?.status !== "implemented" && r?.instrumented !== true) return r;
@@ -1036,7 +1080,10 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
1036
1080
  if (!cov?.length) miss("issue_coverage", "issue_coverage is missing or empty");
1037
1081
  for (const c of cov ?? []) {
1038
1082
  if (!COVER_STATUS.includes(c?.status)) miss("issue_coverage", `issue_coverage status invalid: ${c?.status}`);
1039
- if (!inIssue(c?.quote, issueText)) miss("issue_coverage", `issue_coverage quote is not verbatim from the issue: "${String(c?.quote ?? "").slice(0, 60)}"`);
1083
+ if (!inIssue(c?.quote, issueText)) {
1084
+ const near = nearestSpan(c?.quote, criterionLines(issueText).join("\n"));
1085
+ miss("issue_coverage", `${MISQUOTE_PREFIX}: "${String(c?.quote ?? "").slice(0, 60)}"${near ? `; nearest criterion line: "${near}"` : ""}`);
1086
+ }
1040
1087
  }
1041
1088
  const reqs = arr("requirements");
1042
1089
  if (!reqs) p.push("requirements rows are missing");
@@ -1283,12 +1330,37 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
1283
1330
  });
1284
1331
  }
1285
1332
 
1286
- /** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
1333
+ /**
1334
+ * #5141: the FORGE:FOLLOWUP_PENDING note for follow-ups the lane could not file before or after the merge. Each row keeps the
1335
+ * requirement text and the `FORGE:DEFERRED_FROM: #<n>` marker the follow-up script looks up, so a later pass files it once.
1336
+ */
1337
+ export function renderPendingFollowups(rows, pr) {
1338
+ const flat = (s) => String(s ?? "").replace(/[\t\r\n]+/g, " ").trim();
1339
+ return [`## Follow-up pending`, `Merged by PR #${pr}, but the tracking issue could not be filed, so this issue stays open until it is. Still to file (idempotent by the marker):`, "",
1340
+ ...rows.map((r) => `- ${flat(r.id)} (${flat(r.disposition)}): ${flat(r.quote)} | why: ${flat(r.why)} | \`${r.marker}\``)].join("\n");
1341
+ }
1342
+
1343
+ /**
1344
+ * #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}; unmatched items omitted. Both sides get every
1345
+ * identity rule of scripts/file-deferred-followups.sh: trimmed (#4388), backticks stripped (#4984) and repeated leading
1346
+ * `R<n>: R<m>:` prefixes collapsed to the first (#5141: a quote starting with its own id looped on followup.unavailable).
1347
+ * An item still unmatched takes the one echoed row whose identity starts with `<item.id>:`; never when several do.
1348
+ */
1287
1349
  export function followupMap(out, items) {
1350
+ const norm = (k) => {
1351
+ let s = String(k).replace(/`/g, "").trim(), m;
1352
+ while ((m = /^(R\d+:)\s*R\d+:\s*(.*)$/s.exec(s))) s = `${m[1]} ${m[2]}`;
1353
+ return s;
1354
+ };
1288
1355
  const echoed = new Map();
1289
- const norm = (k) => String(k).replace(/`/g, "").trim(); // #4984: the script strips backticks from the label it echoes
1290
1356
  for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(norm(k), Number(n)); }
1291
- return Object.fromEntries(items.filter((i) => echoed.has(norm(i.key))).map((i) => [i.key, echoed.get(norm(i.key))]));
1357
+ const byId = (id) => { const hits = [...echoed].filter(([k]) => k.startsWith(`${id}:`)); return hits.length === 1 ? hits[0][1] : undefined; };
1358
+ const res = {};
1359
+ for (const i of items) {
1360
+ const n = echoed.get(norm(i.key)) ?? (i.id ? byId(String(i.id)) : undefined);
1361
+ if (n !== undefined) res[i.key] = n;
1362
+ }
1363
+ return res;
1292
1364
  }
1293
1365
 
1294
1366
  /**
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { describe, it } from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
6
+ import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, reaskable, matchCriterion, criterionLines, fillEngineRows, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, renderPendingFollowups, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
7
7
 
8
8
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
9
9
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -494,6 +494,38 @@ describe("conformance review: the engine computes the verdict from complete rows
494
494
  it("incomplete: a coverage quote paraphrased instead of verbatim", () => {
495
495
  assert.equal(decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx).state, "review.incomplete");
496
496
  });
497
+ it("#5164 a near-verbatim coverage misquote is requoted to the criterion line and the review completes", () => {
498
+ const issue = "## Acceptance Criteria\n- [ ] Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.\n- [ ] The scrape route must release the spend reservation on 429.";
499
+ const k = { ...C, requirements: [{ id: "R1", quote: "The scrape route must release the spend reservation on 429.", disposition: "deliver" }, { id: "R2", quote: Q2, disposition: "deliver" }] };
500
+ const quote = "Every manifest step kind from #38829 compiles to engine node";
501
+ const rv = { ...good, issue_coverage: [{ quote, contract_ids: ["R1"], status: "covered" }, { quote: "The scrape route must release the spend reservation on 429.", status: "covered" }] };
502
+ const d = decide(rv, { ...ctx, contract: k, issueText: `${issue}\n${ISSUE}` });
503
+ assert.equal(d.state, "conforms");
504
+ assert.equal(d.review.issue_coverage[0].quote, "Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.");
505
+ assert.equal(d.review.issue_coverage[0].requoted, quote, "the substitution is recorded in the review");
506
+ });
507
+ it("#5164 matchCriterion: loose prefix or a unique token match; a paraphrase, an ambiguous or a short quote matches nothing", () => {
508
+ const t = "- [ ] Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.\n- The scrape route must release the spend reservation on 429.\n- The search route must release the spend reservation on 429.";
509
+ assert.deepEqual(criterionLines(t)[0], "Every manifest step kind from #38826 compiles to engine nodes, with a test per kind.");
510
+ assert.equal(matchCriterion("every manifest step kind from #1 compiles to engine node with a test per kinds", t), criterionLines(t)[0], "prefix tier");
511
+ assert.equal(matchCriterion("The scrape route must release the spend reservations on 429", t), "The scrape route must release the spend reservation on 429.");
512
+ assert.equal(matchCriterion("route must release the spend reservation", t), null, "two lines tie");
513
+ assert.equal(matchCriterion("scrape should release money on rate limits", t), null);
514
+ assert.equal(matchCriterion("engine node", t), null);
515
+ });
516
+ it("#5164 a quote that matches no criterion still fails as incomplete, naming the nearest criterion line; it is re-askable, a real gap is not", () => {
517
+ const d = decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx);
518
+ assert.equal(d.state, "review.incomplete");
519
+ assert.equal(d.review.issue_coverage[0].requoted, undefined);
520
+ const problems = d.failures.map((f) => f.detail);
521
+ assert.match(problems[0], /nearest criterion line: "The scrape route must release/);
522
+ assert.ok(reaskable(problems));
523
+ assert.ok(reaskable(["R1: evidence path /layout.tsx does not exist"]), "#4327 evidence paths stay re-askable");
524
+ assert.ok(!reaskable([...problems, "requirement R1 needs exactly one row (has 0)"]));
525
+ assert.ok(!reaskable(["issue_coverage is missing or empty"]));
526
+ assert.ok(!reaskable([]));
527
+ assert.deepEqual(fillEngineRows(good, ctx).issue_coverage, good.issue_coverage, "verbatim rows are untouched");
528
+ });
497
529
  it("omission: an issue requirement missing from or narrowed by the contract", () => {
498
530
  const d = decide({ ...good, issue_coverage: [...good.issue_coverage.slice(0, 2), { quote: "Billing pricing is unchanged.", status: "missing" }] }, ctx);
499
531
  assert.deepEqual(codes(d), ["conformance.omission"]);
@@ -903,6 +935,22 @@ describe("#4594 amend-requirement", () => {
903
935
  it("the reviewer prompt tells it to compare amended_from with the amendment (#4996)", () => {
904
936
  assert.match(readFileSync(new URL("./prompts.mjs", import.meta.url), "utf8"), /amended_from\.quote and amended_from\.why with amendment\.text/);
905
937
  });
938
+ it("followupMap matches a key whose quote repeats its own R<n>: prefix, as the script collapses it (#5141)", () => {
939
+ const items = followupItems([{ id: "R6", disposition: "defer", bucket: "host", quote: "R6: Fleet: x in (browser, replay) over 48h.", why: "w" }, { id: "R1", disposition: "defer", bucket: "new-issue", quote: "R3: Named accounts", why: "w" }]);
940
+ assert.deepEqual(followupMap("R6: Fleet: x in (browser, replay) over 48h.\t901\nR1: Named accounts\t902\n", items), { [items[0].key]: 901, [items[1].key]: 902 });
941
+ });
942
+
943
+ it("renderPendingFollowups lists each unfiled follow-up with its text and the FORGE:DEFERRED_FROM marker (#5141)", () => {
944
+ const md = renderPendingFollowups([{ id: "R2", disposition: "defer", quote: "a\tb\nc", why: "w", marker: "FORGE:DEFERRED_FROM: #7" }], 12);
945
+ assert.match(md, /^## Follow-up pending\nMerged by PR #12/);
946
+ assert.match(md, /\n- R2 \(defer\): a b c \| why: w \| `FORGE:DEFERRED_FROM: #7`$/);
947
+ });
948
+ it("followupMap falls back to the single echoed row carrying the item id, never to an ambiguous one (#5141)", () => {
949
+ assert.deepEqual(followupMap("R2: drifted text\t7\n", [{ id: "R2", key: "R2: original text" }]), { "R2: original text": 7 });
950
+ assert.deepEqual(followupMap("R2: a\t7\nR2: b\t8\n", [{ id: "R2", key: "R2: c" }]), {});
951
+ assert.deepEqual(followupMap("R20: a\t7\n", [{ id: "R2", key: "R2: c" }]), {});
952
+ });
953
+
906
954
  it("followupMap matches an item key whose backticks the script stripped (#4984)", () => {
907
955
  assert.deepEqual(followupMap("R2: wire pd.py in\t20\n", [{ key: "R2: wire `pd.py` in" }]), { "R2: wire `pd.py` in": 20 });
908
956
  });
@@ -1343,6 +1391,20 @@ describe("#5087 a plan decision may not weaken an e2e/integration criterion to m
1343
1391
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1344
1392
  }
1345
1393
  });
1394
+ it("#5145 unittest/unittests and substitute units are mocks; a negator across a preposition is not a weakener", () => {
1395
+ for (const choice of ["Replace e2e with unittest cases", "Skip e2e, covered by unittests", "e2e dropped, use units", "Use unittest cases instead of the e2e check"]) {
1396
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1397
+ }
1398
+ for (const choice of ["Keep playwright e2e coverage; add unit tests (no new fixtures needed for e2e)"]) {
1399
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), [], choice);
1400
+ }
1401
+ });
1402
+ it("#5157 negated verbs and bare negators before a preposition still weaken e2e", () => {
1403
+ for (const choice of ["We do not need to run e2e; mocked route tests cover it", "will not rely on e2e, mock the API", "not with e2e: unit tests used", "not for e2e, unit tests used", "no need to run e2e; mocks cover it", "no need for e2e, mocks cover it"]) {
1404
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice, source: "doc" }] }), ["K1"], choice);
1405
+ }
1406
+ assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Keep playwright e2e coverage; add unit tests (no new fixtures needed for e2e)", source: "doc" }] }), []);
1407
+ });
1346
1408
  it("leaves unrelated decisions and contracts without decisions alone", () => {
1347
1409
  assert.deepEqual(weakenedE2eDecisions(C), []);
1348
1410
  assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);