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.
- package/bin/engine/outcome-codes.mjs +2 -0
- package/bin/engine-cli.mjs +123 -20
- package/bin/forgedock.mjs +31 -3
- package/bin/lean/batch-health.mjs +219 -0
- package/bin/lean/batch-health.test.mjs +156 -0
- package/bin/lean/batch.mjs +12 -3
- package/bin/lean/batch.test.mjs +304 -8
- package/bin/lean/conformance.mjs +79 -7
- package/bin/lean/conformance.test.mjs +63 -1
- package/bin/lean/context.mjs +8 -2
- package/bin/lean/context.test.mjs +11 -1
- package/bin/lean/credentials.mjs +18 -0
- package/bin/lean/execution.mjs +23 -9
- package/bin/lean/execution.test.mjs +93 -0
- package/bin/lean/fixclosereasons.mjs +52 -0
- package/bin/lean/fixclosereasons.test.mjs +64 -0
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lean.mjs +93 -36
- package/bin/lean/lean.test.mjs +143 -19
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate-blocked.test.mjs +47 -0
- package/bin/lean/orchestrate.mjs +135 -17
- package/bin/lean/prefilter.mjs +4 -3
- package/bin/lean/prefilter.test.mjs +5 -4
- package/bin/lean/prompts.mjs +4 -2
- package/bin/lean/recovery.test.mjs +5 -5
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +9 -0
- package/bin/pool/agent.mjs +3 -1
- package/bin/pool/router.mjs +72 -7
- package/bin/review-staging.mjs +2 -0
- package/bin/runner.mjs +24 -0
- package/commands/orchestrate.md +2 -1
- package/commands/review-pr-staging.md +2 -2
- package/docs/CONFIG.md +39 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/pool/README.md +2 -0
- package/pool/image/cs-cred +8 -1
- package/scripts/review-staging-post.sh +70 -25
- package/scripts/review-staging-post.test.sh +40 -8
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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 []; } }
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -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(?:[- ]
|
|
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
|
-
|
|
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))
|
|
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
|
-
/**
|
|
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
|
-
|
|
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" }] }), []);
|