forgedock 1.43.0 → 1.44.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 (58) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +59 -10
  4. package/bin/forgedock.mjs +50 -24
  5. package/bin/gc.mjs +3 -1
  6. package/bin/hooks/pre-tool-use.mjs +54 -3
  7. package/bin/journey.mjs +2 -2
  8. package/bin/lean/autochildren.mjs +13 -5
  9. package/bin/lean/autochildren.test.mjs +36 -18
  10. package/bin/lean/batch.mjs +3 -1
  11. package/bin/lean/batch.test.mjs +83 -0
  12. package/bin/lean/conformance.mjs +72 -13
  13. package/bin/lean/conformance.test.mjs +100 -9
  14. package/bin/lean/decision-engine.mjs +1 -1
  15. package/bin/lean/decision-engine.test.mjs +11 -0
  16. package/bin/lean/effects.mjs +1 -1
  17. package/bin/lean/execution.test.mjs +9 -6
  18. package/bin/lean/gatewatch.test.mjs +5 -5
  19. package/bin/lean/issuewatch.mjs +2 -2
  20. package/bin/lean/lane.mjs +22 -4
  21. package/bin/lean/lane.test.mjs +33 -1
  22. package/bin/lean/lean.mjs +65 -17
  23. package/bin/lean/lean.test.mjs +84 -2
  24. package/bin/lean/orchestrate-blocked.test.mjs +28 -0
  25. package/bin/lean/orchestrate-children.test.mjs +16 -4
  26. package/bin/lean/orchestrate.mjs +58 -11
  27. package/bin/lean/pmap.mjs +17 -0
  28. package/bin/lean/pmap.test.mjs +27 -0
  29. package/bin/lean/probe.mjs +1 -1
  30. package/bin/lean/prodwatch-triage.mjs +6 -0
  31. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  32. package/bin/lean/prodwatch.mjs +1 -3
  33. package/bin/lean/recovery.test.mjs +21 -4
  34. package/bin/lean/retry.mjs +11 -0
  35. package/bin/lean/retry.test.mjs +16 -0
  36. package/bin/lean/sched.mjs +25 -2
  37. package/bin/lean/sched.test.mjs +58 -1
  38. package/bin/lean/schedd.mjs +38 -5
  39. package/bin/lean/schedd.test.mjs +109 -0
  40. package/bin/lean/selftest-live.mjs +57 -32
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +8 -4
  43. package/bin/lean/selftest-nightly.test.mjs +24 -2
  44. package/bin/lean/selftest-static.mjs +18 -11
  45. package/bin/lean/selftest-static.test.mjs +8 -0
  46. package/bin/lean/signoff.mjs +14 -2
  47. package/bin/lean/signoff.test.mjs +45 -1
  48. package/bin/pool/agent.mjs +1 -1
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/runner.mjs +1 -1
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +9 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +3 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/ci-local/run.mjs +1 -1
  58. package/scripts/plan-validate-live.mjs +15 -8
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { resolveLane, makeLaneCache } from "./lane.mjs";
9
10
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
10
11
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
11
12
  import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
@@ -288,6 +289,8 @@ describe("lane launch env (#4179)", () => {
288
289
  assert.equal(pid, 99);
289
290
  assert.equal(spawned[0].opts.env.FORGEDOCK_BATCH_ID, batch.id);
290
291
  assert.equal(spawned[0].opts.env.KEEP, "1");
292
+ assert.equal(spawned[0].opts.windowsHide, true, "a detached lane must not open a console window on Windows");
293
+ assert.equal(spawned[0].opts.detached, true);
291
294
  assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
292
295
  });
293
296
  });
@@ -888,6 +891,20 @@ describe("#4784: usage-limit deferrals", () => {
888
891
  b.store.save({ order: [1], results: { 1: { outcome: "deferred" } } });
889
892
  assert.deepEqual(b.requeue([1]).requeued, [1]);
890
893
  });
894
+ it("#4957: a blocker deferred on a usage limit past maxDeferrals keeps its dependents pending; the chain runs in order after the hold", async () => {
895
+ const { c, now, sleep } = fakeClock();
896
+ const bodies = { 1: "", 2: "Depends on #1", 3: "Depends on #2" };
897
+ const starts = [];
898
+ let limited = 0;
899
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, readBody: async (n) => bodies[n], now, sleep, maxDeferrals: 3,
900
+ runOne: async (n) => {
901
+ starts.push(n);
902
+ if (n === 1 && limited++ < 5) return { terminalReason: "deferred", code: "rate_limited", resetAtEpochMs: c.t + 600_000, detail: "usage limit hit" };
903
+ return { terminalReason: "merged" };
904
+ } });
905
+ assert.deepEqual(starts, [1, 1, 1, 1, 1, 1, 2, 3], "#2 and #3 never start before #1 merges");
906
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
907
+ });
891
908
  it("a requeuable deferred row: an issue that gave up after N deferrals (#4984)", async () => {
892
909
  const { now, sleep } = fakeClock();
893
910
  const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
@@ -967,6 +984,27 @@ describe("#4550: forgedock orchestrate --requeue", () => {
967
984
  assert.equal(b.readRequeued()[0].reason, "decided");
968
985
  assert.equal(b.readReport(), null, "the finished batch is reopened for --resume");
969
986
  });
987
+
988
+ it("#5000: --add/--requeue on a finished batch without a daemon clear the report and say it is not hosted", async () => {
989
+ const root = tmpDir("batch-add-finished-");
990
+ const b = openBatch(root, "b1");
991
+ b.writePlan({ argv: ["1"], repo: "o/r" });
992
+ b.store.save({ order: [5], results: { 5: { outcome: "needs-human" } } });
993
+ b.writeReport({ rows: [], report: "x" });
994
+ const io = { gh: async () => JSON.stringify({ state: "OPEN", labels: [], body: "", author: { login: "a" } }) };
995
+ const lines = [];
996
+ const run = async (argv) => { const l = console.log; console.log = (x) => lines.push(String(x)); try { return await orchestrateFromCli(argv, { batchRoot: root, io }); } finally { console.log = l; } };
997
+ const add = await run(["--add", "b1", "7"]);
998
+ assert.deepEqual(add.added, [7]);
999
+ assert.equal(b.readReport(), null, "a plain add reopens the finished batch for --resume");
1000
+ assert.ok(lines.some((l) => /NOT hosted.*--resume b1/.test(l)), "the CLI says nothing is scheduled yet");
1001
+ b.writeReport({ rows: [], report: "x" });
1002
+ lines.length = 0;
1003
+ const rq = await run(["--requeue", "b1", "5"]);
1004
+ assert.deepEqual(rq.requeued, [5]);
1005
+ assert.equal(b.readReport(), null);
1006
+ assert.ok(lines.some((l) => /NOT hosted.*--resume b1/.test(l)));
1007
+ });
970
1008
  });
971
1009
 
972
1010
  describe("#4540: remote admissions are capped to the provider's free capacity", () => {
@@ -1157,4 +1195,49 @@ describe("batch plan secrets (#4787)", () => {
1157
1195
  assert.equal(statSync(join(b.dir, "plan.json")).mode & 0o777, 0o600);
1158
1196
  assert.deepEqual(b.scrubPlan(), []);
1159
1197
  });
1198
+
1199
+ it("setup reads run with bounded concurrency, keep order and deps, and write an early setup state (#4963)", async () => {
1200
+ const saves = []; let live = 0, peak = 0;
1201
+ const bodies = { 1: "Blocked by #2", 2: "", 3: "Depends on #1", 4: "", 5: "" };
1202
+ const store = { load: () => null, save: (s) => saves.push(JSON.parse(JSON.stringify(s))) };
1203
+ await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 1, store, setupConcurrency: 2, trusted: () => true,
1204
+ readBody: async (n) => { live++; peak = Math.max(peak, live); await new Promise((r) => setTimeout(r, 15 - n * 2)); live--; return bodies[n]; },
1205
+ runOne: async () => ({ terminalReason: "merged" }), now: () => Date.now(), sleep: async () => {}, pollMs: 1 });
1206
+ assert.equal(peak, 2);
1207
+ const setupSaves = saves.filter((s) => s.phase === "setup");
1208
+ assert.deepEqual(setupSaves[0].setup, { read: 0, total: 5 });
1209
+ assert.equal(setupSaves.at(-1).setup.read, 5);
1210
+ assert.deepEqual(setupSaves[0].order, [1, 2, 3, 4, 5]);
1211
+ const last = saves.at(-1);
1212
+ assert.equal(last.phase, undefined);
1213
+ assert.deepEqual(last.deps, { 1: [2], 2: [], 3: [1], 4: [], 5: [] });
1214
+ });
1215
+
1216
+ it("a setup save never records an issue as read before its lane is resolved (#4963 resume safety)", async () => {
1217
+ const saves = [];
1218
+ const store = { load: () => null, save: (s) => saves.push(JSON.parse(JSON.stringify(s))) };
1219
+ await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, store, setupConcurrency: 4, trusted: () => true,
1220
+ readBody: async () => "",
1221
+ resolveLane: async (n) => { await new Promise((r) => setTimeout(r, 5 + n * 10)); return { ok: true, lane: "staging" }; },
1222
+ runOne: async () => ({ terminalReason: "merged" }), now: () => Date.now(), sleep: async () => {}, pollMs: 1 });
1223
+ for (const s of saves.filter((x) => x.phase === "setup")) for (const n of Object.keys(s.deps ?? {})) assert.ok(s.lanes?.[n], `setup save lists #${n} in deps without its lane`);
1224
+ });
1225
+
1226
+ it("5 issues with slow gh/git calls reach their first admitted lane in a fraction of the serial setup time (#4963)", async () => {
1227
+ const LAT = 30; // ms per gh/git call, standing in for a loaded host
1228
+ const wait = () => new Promise((r) => setTimeout(r, LAT));
1229
+ const firstLane = async (setupConcurrency, cache) => {
1230
+ const io = { gh: async (a) => { await wait(); return a[0] === "repo" ? "main\n" : JSON.stringify({ milestone: null }); }, git: async () => { await wait(); return "abc\trefs/heads/staging\n"; } };
1231
+ const t0 = Date.now(); let first = null;
1232
+ await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 5, setupConcurrency, trusted: () => true, now: () => Date.now(), sleep: async () => {}, pollMs: 1,
1233
+ readBody: async () => { await wait(); return ""; },
1234
+ resolveLane: (n) => resolveLane({ issue: n, repo: "o/r", io, cwd: "/r", ...(cache ? { cache: makeLaneCache(), milestone: null } : {}) }),
1235
+ runOne: async () => { first ??= Date.now() - t0; return { terminalReason: "merged" }; } });
1236
+ return first;
1237
+ };
1238
+ const serial = await firstLane(1, false), fast = await firstLane(6, true);
1239
+ // Serial: 5 issues x 4 calls x LAT = 600 ms; bounded + shared lookups: about 3 rounds = 90 ms. The 2-minute target is a 5x cut or better here.
1240
+ assert.ok(fast < serial / 3, `fast ${fast} ms vs serial ${serial} ms`);
1241
+ assert.ok(fast < 10 * LAT, `first lane after ${fast} ms`);
1242
+ });
1160
1243
  });
@@ -32,7 +32,7 @@ export const MIN_QUOTE_CHARS = 12;
32
32
  /** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
33
33
  export const MAX_CONTRACT_CHARS = 40000;
34
34
  import { CHECK_ID_RE } from "./postdeploy-check.mjs";
35
- import { normaliseDecision, deadAnchors } from "./decisions.mjs";
35
+ import { normaliseDecision, deadAnchors, anchorMatches } from "./decisions.mjs";
36
36
  const C = OUTCOME_CODES;
37
37
 
38
38
  /** #4789: folds what a builder retyping a quote changes: unicode punctuation, markdown links/markup, escapes, invisible chars. */
@@ -325,10 +325,14 @@ export function validateContract(c, issueText, { repro = false } = {}) {
325
325
  /** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
326
326
  const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
327
327
 
328
- const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision", "substitute-validate"];
328
+ const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision", "substitute-validate", "out-of-band"];
329
329
  const UNRELATED_CI_KIND = "unrelated-ci-failure";
330
330
  const GUARD_ID_RE = /^#?\d+$/;
331
- const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
331
+ const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer", deferred: "defer", deferral: "defer", "scope-deferral": "defer", "verification-substitution": "substitute-validate", "out-of-band-verification": "out-of-band" };
332
+ // #5036: annotation-only kinds change no requirement: recorded for the reviewer, never a problem (like UNRELATED_CI_KIND)
333
+ const INFO_KINDS = ["scope-note", "scope", "scope-clarification", "interpretation", "environment", "validation-unavailable"];
334
+ // #5036: deferral-shaped kinds (scope narrowing/reduction, defer-*, deferred-*) are `defer` once they name a requirement
335
+ const DEFER_FAMILY_RE = /^(?:scope-(?:narrowing|reduction)|defer-.+|deferred-.+)$/;
332
336
  // #4916: near-synonyms builders emit for the per-guard `supersedes` amendment
333
337
  const SUPERSEDES_SYNONYMS = ["supersede-guard", "supersede", "supersedes-guard"];
334
338
 
@@ -338,6 +342,12 @@ function normalizeAmendment(a) {
338
342
  const n = { ...a };
339
343
  if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
340
344
  // #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
345
+ // #4856: kinds compare case-insensitively with `_` as `-`; only a recognised kind or alias is rewritten, so unknown kinds keep their spelling in the error
346
+ if (typeof n.kind === "string") {
347
+ const k = n.kind.trim().toLowerCase().replace(/_/g, "-");
348
+ if (AMEND_KINDS.includes(k) || Object.hasOwn(AMEND_SLIPS, k) || INFO_KINDS.includes(k)) n.kind = k;
349
+ else if (DEFER_FAMILY_RE.test(k) && (!blank(n.requirement) || /^R\d+$/.test(String(n.id ?? "")))) n.kind = "defer";
350
+ }
341
351
  if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
342
352
  if (SUPERSEDES_SYNONYMS.includes(n.kind)) n.kind = "supersedes";
343
353
  if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
@@ -363,6 +373,7 @@ function amendmentProblem(a, tag) {
363
373
  const kind = a?.kind;
364
374
  const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
365
375
  : kind === "supersedes" ? "needs guard: \"#N\" (the guarded issue)" : TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)"
376
+ : kind === "substitute-validate" ? "needs validate: \"V#\" and command" : kind === "out-of-band" ? "needs requirement: \"R#\" and evidence (the job or workflow that measures it)"
366
377
  : kind === "decision" ? "needs decision {choice, anchors: [paths or globs], symbols?, supersedes?: decision id}" : null;
367
378
  const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
368
379
  return `unknown or incomplete amendment: ${why}: ${tag}`;
@@ -402,6 +413,7 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
402
413
  for (let { a, raw } of rows) {
403
414
  // an unrelated out-of-diff CI failure is not a contract change: informational, never a problem
404
415
  if (a?.kind === UNRELATED_CI_KIND) { applied.push({ kind: a.kind, target: String(a.target ?? "unrelated check"), why: String(a.why ?? "").trim() || "unrelated to the diff" }); continue; }
416
+ if (INFO_KINDS.includes(a?.kind)) { applied.push({ kind: a.kind, target: String(a.target ?? a.requirement ?? a.id ?? "note").slice(0, 60), why: String(a.why ?? a.text ?? a.note ?? "").trim() || "informational" }); continue; }
405
417
  const tag = JSON.stringify(raw ?? null).slice(0, 400);
406
418
  if (a && blank(a.why) && !blank(defaultWhy)) a = { ...a, why: String(defaultWhy) }; // the engine demanded the change: its reason is the why
407
419
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
@@ -425,6 +437,14 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
425
437
  v.substituted = { from: v.substituted?.from ?? v.command, why: String(a.why).trim() }; v.command = String(a.command).trim();
426
438
  applied.push({ kind: a.kind, target: a.validate, why: String(a.why).trim() });
427
439
  continue;
440
+ } else if (a.kind === "out-of-band" && !blank(a.requirement) && !blank(a.evidence ?? a.job)) {
441
+ // #5036: the existing out_of_band path for a deliver requirement; evidence names the job that measures it
442
+ const r = c.requirements.find((x) => x.id === a.requirement);
443
+ if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
444
+ if (r.disposition !== "deliver") { problems.push(`out-of-band ${r.id}: applies only to a deliver requirement`); continue; }
445
+ r.verify = "out_of_band"; r.evidence = String(a.evidence ?? a.job).trim(); r.amended = true;
446
+ applied.push({ kind: a.kind, target: r.id, why: String(a.why).trim() });
447
+ continue;
428
448
  } else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
429
449
  if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
430
450
  if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
@@ -467,11 +487,15 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
467
487
  } else if (a.kind === "decision") {
468
488
  const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
469
489
  if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
470
- // a file the lane adds and the contract lists as a deliverable is a new file the issue declared: a valid anchor
490
+ // #5036: any file the lane adds is a valid anchor; one the contract does not list becomes a deliverable
471
491
  const listed = new Set(c.deliverables.map((x) => x.path));
472
- const added = (Array.isArray(addedPaths) ? addedPaths : []).filter((x) => listed.has(x));
492
+ const added = Array.isArray(addedPaths) ? addedPaths : [];
473
493
  const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, [...baseFiles, ...added]) : [];
474
494
  if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
495
+ for (const f of added) {
496
+ if (listed.has(f) || (Array.isArray(baseFiles) && baseFiles.includes(f)) || !d.anchors.some((x) => anchorMatches(x, f))) continue;
497
+ c.deliverables.push({ id: `A${c.deliverables.length + 1}`, path: f, change: `new file anchoring decision: ${d.choice.slice(0, 80)}`, amended: true }); listed.add(f);
498
+ }
475
499
  if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
476
500
  decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
477
501
  applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
@@ -532,14 +556,35 @@ export function substituteVerdict(run, { repoHas = () => false, command = "" } =
532
556
  * a whitespace token equal to the path, or a directory token above it. A builder that removes the target of its frozen
533
557
  * validate makes the engine's rerun read "unrunnable", so the substitute is judged on the diff instead.
534
558
  */
535
- export function removedNamedPaths(command, removed = []) {
536
- const toks = String(command ?? "").split(/\s+/).map((t) => t.replace(/^['"`(]+|['"`);]+$/g, "").replace(/^\.\//, "").replace(/\/+$/, "")).filter((t) => t && t !== ".");
537
- return removed.filter((p) => toks.some((t) => p === t || p.startsWith(`${t}/`)));
559
+ const TEST_FILE_RE = /\.(?:test|spec)\.[a-z0-9]+$|^test_[^/]+\.[a-z0-9]+$|_test\.[a-z0-9]+$|Tests?\.(?:java|kt|kts|cs|php|scala|swift)$|_spec\.rb$|^tests\.rs$|^conftest\.py$|\.bats$/;
560
+ /** #5003: a runnable test FILE (by basename), unlike `isTestPath`, which also matches fixtures under tests/. */
561
+ const isTestFile = (p) => TEST_FILE_RE.test(String(p).split("/").pop());
562
+ /**
563
+ * #4990 SEC-13: the paths of `removed` (deleted, or the old side of a rename, in the diff) that a frozen command names.
564
+ * #5003: a token is stripped of quotes, a `--flag=` prefix and a pytest `::node` suffix; a glob is expanded against
565
+ * `baseFiles`; an exact path matches itself; a directory or bare-word token matches only a removed test file under it.
566
+ */
567
+ export function removedNamedPaths(command, removed = [], baseFiles = []) {
568
+ const toks = String(command ?? "").split(/\s+/).map((t) => t.replace(/^['"`(]+|['"`);]+$/g, "").replace(/^-[^=\/]*=/, "").replace(/::.*$/, "").replace(/^['"`]+|['"`]+$/g, "").replace(/^\.\//, "").replace(/\/+$/, "")).filter((t) => t && t !== ".");
569
+ const files = new Set(baseFiles);
570
+ return removed.filter((p) => toks.some((t) => {
571
+ if (/[*?]/.test(t)) return globRe(t).test(p) && (files.size === 0 || files.has(p));
572
+ return p === t || (p.startsWith(`${t}/`) && isTestFile(p));
573
+ }));
538
574
  }
539
- /** #4990: paths the lane's diff deletes or renames away from (the old side), per `git diff --name-status -M`. */
575
+ /** #4990: paths the lane's diff deletes or renames away from (the old side). #5003: `-z` (unquoted), null on a git error (fail closed). */
540
576
  export async function removedPathsOf(git, base) {
541
- const rows = String(await git(["diff", "--name-status", "-M", `${base}...HEAD`]).catch(() => "")).split("\n").map((l) => l.split("\t"));
542
- return rows.filter((r) => /^[DR]/.test(r[0] ?? "") && r[1]).map((r) => r[1]);
577
+ let out;
578
+ try { out = String(await git(["diff", "--name-status", "-z", "-M", `${base}...HEAD`])); } catch { return null; }
579
+ const f = out.split("\0"), gone = [];
580
+ for (let i = 0; i < f.length; i++) {
581
+ const st = f[i];
582
+ if (!/^[A-Z]/.test(st)) continue;
583
+ const two = /^[RC]/.test(st);
584
+ if (/^[DR]/.test(st) && f[i + 1]) gone.push(f[i + 1]);
585
+ i += two ? 2 : 1;
586
+ }
587
+ return gone;
543
588
  }
544
589
  const UNRUNNABLE_RE = /no tests ran|collected 0 items|no tests found|could not find (?:the )?pyproject|poetry could not find|syntax error near|unexpected EOF while looking/i;
545
590
  /** #4857: a failed rerun that died before any test ran (no tests selected, no project at the cwd, invalid shell): a defect of the frozen command. */
@@ -568,6 +613,18 @@ export function normalizeRunnerCommand(command) {
568
613
  export function cliUsageError({ rc, output } = {}) {
569
614
  return rc !== 0 && CLI_USAGE_RE.test(String(output ?? ""));
570
615
  }
616
+ const FORGEDOCK_ENTRY_RE = /^\/.*\/forgedock\/bin\/(?:lean\/checks|forgedock)\.mjs$/;
617
+ const FORGEDOCK_TOKEN_RE = /(?<![\w./-])\/[^\s'"`;&|()]*\/forgedock\/bin\/(lean\/checks|forgedock)\.mjs/g;
618
+ /**
619
+ * #5005: a frozen validate command names forgedock's own entry point by the absolute path of the install that wrote it; on
620
+ * the host that runs it that path may not exist. Rewrites each such token whose file is missing to this install's path.
621
+ */
622
+ export function resolveForgedockPaths(command, { exists = () => true, checksCli, forgedockCli } = {}) {
623
+ return String(command ?? "").replace(FORGEDOCK_TOKEN_RE, (tok, which) => {
624
+ const here = which === "forgedock" ? forgedockCli : checksCli;
625
+ return here && !exists(tok) ? here : tok;
626
+ });
627
+ }
571
628
  const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
572
629
  /**
573
630
  * forge#4323: the host tool or module a failed command could not find, from its {rc, output}: bash/sh "X: command not found"
@@ -581,6 +638,7 @@ export function missingTool({ rc, output } = {}, { repoHas = () => false, comman
581
638
  const py = PY_MISSING_RE.exec(out);
582
639
  if (py) { const tool = String(py[1] ?? py[2]).split(".")[0]; return local(tool) ? null : { tool, kind: "python-module" }; }
583
640
  const js = NODE_MISSING_RE.exec(out);
641
+ if (js && FORGEDOCK_ENTRY_RE.test(js[1])) return { tool: "forgedock", kind: "command" }; // #5005: the host's forgedock install is not at the frozen path
584
642
  if (js) {
585
643
  const spec = js[1], scoped = spec.startsWith("@");
586
644
  if (!scoped && spec.includes("/")) return null;
@@ -614,7 +672,7 @@ export function repoTracks(paths) {
614
672
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
615
673
  * tool), for the trajectory; always an array.
616
674
  */
617
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [] }) {
675
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [], baseFiles = [] }) {
618
676
  const failures = [], superseded = [], ciCovered = [];
619
677
  // #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
620
678
  if (checkIds) for (const r of contract.requirements ?? []) {
@@ -630,7 +688,8 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
630
688
  for (const v of contract.validate ?? []) {
631
689
  // #4979 SPEC-2: a substitute counts only when the frozen command it replaced cannot run (or passes); checked first, never CI-covered
632
690
  if (v.substituted) {
633
- const gone = removedNamedPaths(v.substituted.from, removedPaths);
691
+ if (removedPaths === null) { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_unverified", detail: `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\`, but the engine could not read the diff's removed paths to show the frozen command's target survives` }); continue; }
692
+ const gone = removedNamedPaths(v.substituted.from, removedPaths, baseFiles);
634
693
  if (gone.length) { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_removed_path", detail: `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\`, but the diff removes ${gone.map((x) => `\`${x}\``).join(", ")} that the frozen command names: a substitute may not replace a check by deleting its target` }); continue; }
635
694
  const verdict = substituteVerdict(substitutedRuns?.[v.id], { repoHas, command: v.substituted.from });
636
695
  const from = `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\``;
@@ -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, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, 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 } from "./conformance.mjs";
6
+ import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, 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 } 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.";
@@ -69,6 +69,22 @@ describe("amendments", () => {
69
69
  });
70
70
  });
71
71
 
72
+ describe("amendment kind aliases (#4856)", () => {
73
+ it("deferral spellings apply as a tracked defer of R1", () => {
74
+ for (const kind of ["deferred", "deferral", "scope_deferral", "Scope-Deferral"]) {
75
+ const a = applyAmendments(C, [{ kind, requirement: "R1", why: "w" }]);
76
+ assert.deepEqual(a.problems, [], kind);
77
+ assert.equal(a.contract.requirements.find((r) => r.id === "R1").disposition, "defer", kind);
78
+ assert.deepEqual(a.toTrack.map((r) => r.id), ["R1"], kind);
79
+ }
80
+ });
81
+ it("unlisted kinds stay unknown and list the valid kinds (scope-note is informational since #5036)", () => {
82
+ for (const kind of ["scope-creep", "Scope_Creep", "flaky-note"]) {
83
+ assert.match(applyAmendments(C, [{ kind, requirement: "R1", why: "w" }]).problems[0], new RegExp(`unknown kind '${kind}'; valid kinds are add-deliverable`));
84
+ }
85
+ });
86
+ });
87
+
72
88
  describe("#4655 multi-requirement amendment references", () => {
73
89
  it("R1/R2, 'R1, R2' and an en-dash range split into one applied row per id", () => {
74
90
  for (const requirement of ["R1/R2", "R1, R2", "R1\u2013R2", "R1 and R2"]) {
@@ -78,9 +94,9 @@ describe("#4655 multi-requirement amendment references", () => {
78
94
  assert.deepEqual(a.applied.map((x) => x.target), ["R1", "R2"], requirement);
79
95
  }
80
96
  });
81
- it("an unknown id inside a list is still a problem; scope-note stays an unknown kind", () => {
97
+ it("an unknown id inside a list is still a problem; an unknown kind with a requirement is still unknown", () => {
82
98
  assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
83
- assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
99
+ assert.match(applyAmendments(C, [{ kind: "scope-creep", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-creep'; valid kinds are/);
84
100
  });
85
101
  it("an unrelated-ci-failure amendment (kind or type) is an informational no-op; other unknown kinds still fail", () => {
86
102
  for (const a of [{ kind: "unrelated-ci-failure", target: "core rate-limit test", why: "diff touches no core file" }, { type: "unrelated-ci-failure", target: "t", why: "w" }]) {
@@ -216,9 +232,25 @@ describe("precheck (deterministic, no reviewer)", () => {
216
232
  assert.match(gone.failures[0].detail, /test\/leak\.test\.js/);
217
233
  const env = precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "Unknown option `-q`" } }, removedPaths: ["docs/other.md"] });
218
234
  assert.deepEqual(env.failures, [], "intact named files: env-unrunnable substitute stands");
219
- assert.deepEqual(removedNamedPaths("node --test ./test/a.js 'b/c.js' tests/", ["test/a.js", "b/c.js", "tests/x.js", "z.js"]), ["test/a.js", "b/c.js", "tests/x.js"]);
235
+ assert.deepEqual(removedNamedPaths("node --test ./test/a.js 'b/c.js' z.js", ["test/a.js", "b/c.js", "tests/x.js", "z.js"]), ["test/a.js", "b/c.js", "z.js"]);
220
236
  assert.deepEqual(removedNamedPaths("pytest -k leak", ["leak.py"]), []);
221
- assert.deepEqual(await removedPathsOf(async () => "D\ta.js\nR100\told.js\tnew.js\nM\tm.js\nA\tn.js", "b"), ["a.js", "old.js"]);
237
+ assert.deepEqual(await removedPathsOf(async () => "D\0a.js\0R100\0old.js\0new.js\0M\0m.js\0A\0n.js\0", "b"), ["a.js", "old.js"]);
238
+ });
239
+ it("#5003 removed-path matching: node ids, globs, flag paths, directory tokens, -z, fail closed", async () => {
240
+ assert.deepEqual(removedNamedPaths("pytest tests/a.py::test_x", ["tests/a.py"]), ["tests/a.py"]);
241
+ assert.deepEqual(removedNamedPaths("pytest --junit=tests/a.py", ["tests/a.py"]), ["tests/a.py"]);
242
+ assert.deepEqual(removedNamedPaths('pytest "tests/a.py::T::t"', ["tests/a.py"]), ["tests/a.py"]);
243
+ const base = ["tests/test_a.py", "tests/test_b.py", "tests/fixtures/d.json", "src/x.py"];
244
+ assert.deepEqual(removedNamedPaths("pytest tests/test_*.py", ["tests/test_a.py", "tests/fixtures/d.json", "src/x.py"], base), ["tests/test_a.py"]);
245
+ assert.deepEqual(removedNamedPaths("pytest tests/", ["tests/fixtures/d.json"]), []);
246
+ assert.deepEqual(removedNamedPaths("pytest tests/", ["tests/test_a.py"]), ["tests/test_a.py"]);
247
+ assert.deepEqual(removedNamedPaths("npm test", ["test/fixtures/d.json"]), []);
248
+ assert.deepEqual(removedNamedPaths("npm test", ["test/a.test.js"]), ["test/a.test.js"]);
249
+ assert.deepEqual(await removedPathsOf(async (a) => (a.includes("-z") ? "D\0tests/é.py\0" : '"tests/\\303\\251.py"'), "b"), ["tests/é.py"]);
250
+ assert.equal(await removedPathsOf(async () => { throw new Error("git"); }, "b"), null);
251
+ const sub = applyAmendments({ ...C, validate: [{ id: "V1", command: "node --test test/leak.test.js" }] }, [{ kind: "substitute-validate", validate: "V1", command: "node --test test/weak.test.js", why: "w" }]).contract;
252
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
253
+ assert.deepEqual(precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "no tests found" } }, removedPaths: null }).failures.map((f) => f.kind), ["substitute_unverified"]);
222
254
  });
223
255
  it("#4857 describesUnrunnableCommand reads a blocked summary", () => {
224
256
  assert.ok(describesUnrunnableCommand("V2 is unrunnable: poetry found no pyproject at the repo root"));
@@ -281,6 +313,22 @@ describe("precheck (deterministic, no reviewer)", () => {
281
313
  assert.equal(missingTool({ rc: 1, output: "ModuleNotFoundError: No module named 'app'" }, { repoHas: repoTracks(["app.py"]) }), null);
282
314
  assert.equal(missingTool({ rc: 1, output: "1 test failed: expected not found" }), null, "rc != 127 needs command not found");
283
315
  });
316
+ it("#5005 a missing forgedock install is env.tool_missing, not conformance; other absolute paths stay code defects", () => {
317
+ const out = "node:internal/modules/cjs/loader\nError: Cannot find module '/opt/node/lib/node_modules/forgedock/bin/lean/checks.mjs'\n code: 'MODULE_NOT_FOUND'";
318
+ assert.deepEqual(missingTool({ rc: 1, output: out }), { tool: "forgedock", kind: "command" });
319
+ assert.equal(missingTool({ rc: 1, output: "Error: Cannot find module '/repo/forgedock/src/x.mjs'" }), null);
320
+ const contract = { requirements: [], deliverables: [], validate: [{ id: "V2", command: "node /opt/node/lib/node_modules/forgedock/bin/lean/checks.mjs --base origin/staging" }] };
321
+ const result = { validation: [{ id: "V2", command: "", exit: 1 }] };
322
+ const pc = precheck({ contract, result, changedFiles: [], validateRuns: { V2: { rc: 1, output: out } } });
323
+ assert.deepEqual(pc.failures.map((f) => f.code), ["env.tool_missing"]);
324
+ });
325
+ it("#5005 resolveForgedockPaths rewrites a missing forgedock entry point to this install, leaves an existing one", () => {
326
+ const o = { checksCli: "/h/forgedock/bin/lean/checks.mjs", forgedockCli: "/h/forgedock/bin/forgedock.mjs" };
327
+ const cmd = "node /opt/node/lib/node_modules/forgedock/bin/lean/checks.mjs --base origin/staging && node /opt/node/lib/node_modules/forgedock/bin/forgedock.mjs check";
328
+ assert.equal(resolveForgedockPaths(cmd, { ...o, exists: () => false }), "node /h/forgedock/bin/lean/checks.mjs --base origin/staging && node /h/forgedock/bin/forgedock.mjs check");
329
+ assert.equal(resolveForgedockPaths(cmd, { ...o, exists: () => true }), cmd);
330
+ assert.equal(resolveForgedockPaths("node bin/x.mjs", { ...o, exists: () => false }), "node bin/x.mjs");
331
+ });
284
332
  it("a deliver requirement reported deferred without an amendment -> untracked_deferral; partial -> unimplemented", () => {
285
333
  const r = { ...res, requirements: [{ id: "R1", status: "deferred" }, { id: "R2", status: "partial" }] };
286
334
  assert.deepEqual(codes(precheck({ contract: C, result: r, changedFiles: files })), ["conformance.untracked_deferral", "conformance.unimplemented"]);
@@ -973,10 +1021,12 @@ describe("decision amendments (#4695)", () => {
973
1021
  assert.match(a.problems[0], /match no file on the base/);
974
1022
  assert.equal(a.decisions.length, 0);
975
1023
  });
976
- it("an anchor on a lane-added file the contract lists as a deliverable conforms; an unlisted added file does not", () => {
977
- const withNew = { ...withRow, deliverables: [...withRow.deliverables, { id: "D3", path: "scripts/seed.py" }] };
978
- const go = (added) => applyAmendments(withNew, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: ["scripts/seed.py"] } }], null, { baseFiles: ["src/scrape.js"], addedPaths: added });
979
- assert.deepEqual(go(["scripts/seed.py"]).problems, []);
1024
+ it("an anchor on a lane-added file conforms and the unlisted file becomes a deliverable; an anchor matching neither base nor an added file is rejected", () => {
1025
+ const go = (added, anchor = "scripts/seed.py") => applyAmendments(withRow, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: [anchor] } }], null, { baseFiles: ["src/scrape.js"], addedPaths: added });
1026
+ const ok = go(["scripts/seed.py"]);
1027
+ assert.deepEqual(ok.problems, []);
1028
+ assert.ok(ok.contract.deliverables.some((d) => d.path === "scripts/seed.py" && d.amended));
1029
+ assert.equal(ok.contract.deliverables.filter((d) => d.path === "scripts/seed.py").length, 1);
980
1030
  assert.match(go(["scripts/other.py"]).problems[0], /match no file on the base/);
981
1031
  });
982
1032
  it("a blank-why amendment takes the engine's defaultWhy; without one it is still rejected", () => {
@@ -1122,3 +1172,44 @@ describe("mutation check (#4913)", () => {
1122
1172
  assert.equal(g("status", "--porcelain").trim(), "");
1123
1173
  });
1124
1174
  });
1175
+
1176
+ describe("builder amendment slips (#5036)", () => {
1177
+ it("deferral-shaped kinds naming a requirement apply as a tracked defer", () => {
1178
+ for (const kind of ["scope_narrowing", "scope_reduction", "defer-verification", "deferred_measurement", "Scope-Narrowing"]) {
1179
+ const a = applyAmendments(C, [{ kind, requirement: "R1", why: "w" }]);
1180
+ assert.deepEqual(a.problems, [], kind);
1181
+ assert.equal(a.contract.requirements.find((r) => r.id === "R1").disposition, "defer", kind);
1182
+ assert.deepEqual(a.toTrack.map((r) => r.id), ["R1"], kind);
1183
+ }
1184
+ assert.match(applyAmendments(C, [{ kind: "scope_narrowing", why: "w" }]).problems[0], /unknown kind 'scope_narrowing'; valid kinds are/);
1185
+ });
1186
+ it("verification_substitution applies as substitute-validate with the same checks", () => {
1187
+ const base = { requirements: [], validate: [{ id: "V2", command: "poetry run pytest" }] };
1188
+ const ok = applyAmendments(base, [{ kind: "verification_substitution", validate: "V2", command: "node --test t.mjs", why: "no poetry" }]);
1189
+ assert.deepEqual(ok.problems, []);
1190
+ assert.equal(ok.contract.validate[0].command, "node --test t.mjs");
1191
+ assert.equal(ok.contract.validate[0].substituted.from, "poetry run pytest");
1192
+ for (const command of ["echo (oops", "true", "git diff origin/main"]) {
1193
+ assert.equal(applyAmendments(base, [{ kind: "verification_substitution", validate: "V2", command, why: "w" }]).problems.length, 1, command);
1194
+ }
1195
+ assert.match(applyAmendments(base, [{ kind: "verification_substitution", why: "w" }]).problems[0], /needs validate/);
1196
+ });
1197
+ it("out_of_band_verification marks a deliver requirement out_of_band with its evidence", () => {
1198
+ const ok = applyAmendments(C, [{ kind: "out_of_band_verification", requirement: "R1", evidence: ".github/workflows/perf.yml", why: "field metric" }]);
1199
+ assert.deepEqual(ok.problems, []);
1200
+ const r = ok.contract.requirements.find((x) => x.id === "R1");
1201
+ assert.equal(r.verify, "out_of_band"); assert.equal(r.evidence, ".github/workflows/perf.yml");
1202
+ assert.equal(applyAmendments(C, [{ kind: "out_of_band_verification", requirement: "R1", why: "w" }]).problems.length, 1);
1203
+ });
1204
+ it("annotation-only kinds are informational: recorded, no problem, no contract change", () => {
1205
+ for (const kind of ["scope-note", "scope_clarification", "interpretation", "environment", "scope", "validation_unavailable"]) {
1206
+ const a = applyAmendments(C, [{ kind, note: "n" }, { kind, why: "w" }]);
1207
+ assert.deepEqual(a.problems, [], kind);
1208
+ assert.deepEqual(a.contract, C, kind);
1209
+ assert.equal(a.applied.length, 2, kind);
1210
+ }
1211
+ });
1212
+ it("a truly unknown kind is still rejected with the valid kinds", () => {
1213
+ assert.match(applyAmendments(C, [{ kind: "flaky-note", why: "w" }]).problems[0], /unknown kind 'flaky-note'; valid kinds are .*substitute-validate/);
1214
+ });
1215
+ });
@@ -10,7 +10,7 @@
10
10
  * analysis attached, never a guess. Shadow mode (the default) posts `FORGE:DECISION (shadow)` and keeps the label; live mode
11
11
  * records the decision, clears needs-human and requeues. A veto (`forge:decision-veto` label, or a reply within
12
12
  * `veto_hours`) reverts it; a reversal rate above `reversal_max` forces shadow. Never throws: an error is an `error` row.
13
- * The issue-watch stage (#4765) calls `settle` once per escalation (key: the escalation comment id).
13
+ * Not yet called in production: the issue-watch stage (#4765) is meant to call `settle` once per escalation (key: the escalation comment id).
14
14
  */
15
15
  import { parseForgeYaml } from "../forge-utils.mjs";
16
16
  import { foldDecisions, recordDecisions as recordToRegister } from "./decisions.mjs";
@@ -157,3 +157,14 @@ test("#4766 tally, telemetry mapping and the batch report 'decided' section", ()
157
157
  assert.match(lines.join("\n"), /decided .*\n- #7: use option A/);
158
158
  assert.deepEqual(formatOutcomeLists([{ issue: 7, outcome: "merged" }]), []);
159
159
  });
160
+
161
+ test("#4826 docs say 'not yet wired' exactly while no production module calls settle", async () => {
162
+ const { readFileSync, readdirSync } = await import("node:fs");
163
+ const dir = new URL(".", import.meta.url);
164
+ const callers = readdirSync(dir).filter((f) => f.endsWith(".mjs") && !f.endsWith(".test.mjs") && f !== "decision-engine.mjs")
165
+ .filter((f) => /\b(settle|revertVeto)\b[^;]*from "\.\/decision-engine\.mjs"/.test(readFileSync(new URL(f, dir), "utf8")));
166
+ const wired = callers.length > 0;
167
+ for (const p of ["../../docs/DECISIONS.md", "../../docs/CONFIG.md"]) {
168
+ assert.equal(/not yet wired/i.test(readFileSync(new URL(p, import.meta.url), "utf8")), !wired, `${p}: banner must match wiring state (${callers.join(",") || "no callers"})`);
169
+ }
170
+ });
@@ -254,7 +254,7 @@ export function runCheckCommand({ command, cwd, timeoutMs = 300000, env = null }
254
254
  return new Promise((done) => {
255
255
  let out = "", timedOut = false, child;
256
256
  const add = (b) => { out += String(b); if (out.length > 2 * CHECK_OUTPUT_CAP) out = out.slice(-CHECK_OUTPUT_CAP); };
257
- try { child = spawn("bash", ["-c", String(command)], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env: { ...process.env, ...env } } : {}) }); }
257
+ try { child = spawn("bash", ["-c", String(command)], { cwd, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env: { ...process.env, ...env } } : {}) }); }
258
258
  catch (e) { done({ rc: 127, output: String(e?.message ?? e) }); return; }
259
259
  const kill = () => { try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } };
260
260
  const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
@@ -528,15 +528,18 @@ describe("execution budget and sandbox cost (#4255)", () => {
528
528
  const prov = fakeProvider({ finish: (n) => ({ terminalReason: "merged", pr: 100 + n, cost_usd: 2 }), usage: async (h) => ({ minutes: 5 * h.issue, cost_usd: 0.1 * h.issue }) });
529
529
  const events = [];
530
530
  const file = join(work, "day.json");
531
- const res = await quiet(() => orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter"], base(work, { batchId: "bc1",
531
+ const printed = []; // #4917: #3 stays pending (budget hold), so the batch is not finalised: no report.json, the printed report carries the sandbox lines
532
+ const log0 = console.log; console.log = (l) => { printed.push(String(l)); };
533
+ const res = await (async () => { try { return await orchestrateFromCli(["1", "2", "3", "--repo", "o/r", "--no-prefilter"], base(work, { batchId: "bc1",
532
534
  execution: parseExecution({ mode: "remote", max_remote: "1", budget_minutes: "15" }), sandboxProvider: prov,
533
535
  sandboxLedger: (n) => makeLedger({ root: ledRoot, batch: "bc1", lane: `sandbox-${n}`, owner: "t", issue: n }),
534
- executionBudget: openExecutionBudget({ budget: parseExecution({ budget_minutes: "15" }).budget, file }), emitTelemetry: (rec) => { events.push(rec); } })));
536
+ executionBudget: openExecutionBudget({ budget: parseExecution({ budget_minutes: "15" }).budget, file }), emitTelemetry: (rec) => { events.push(rec); } })); } finally { console.log = log0; } })();
535
537
  assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.sandbox?.minutes ?? null]), [[1, "merged", 5], [2, "merged", 10], [3, "held", null]]);
536
- const rep = openBatch(join(work, "batches"), "bc1").readReport();
537
- assert.deepEqual(rep.rows[1].sandbox, { minutes: 10, cost_usd: 0.2, attempts: 1 });
538
- assert.match(rep.report, /sandbox: 2 lanes, 15\.0 min, \$0\.30 \(token cost \$4\.00\); budget: batch 15 min/);
539
- assert.match(rep.report, /sbx-min/);
538
+ assert.equal(openBatch(join(work, "batches"), "bc1").readReport(), null);
539
+ assert.deepEqual(res.rows[1].sandbox, { minutes: 10, cost_usd: 0.2, attempts: 1 });
540
+ const text = printed.join("\n");
541
+ assert.match(text, /sandbox: 2 lanes, 15\.0 min, \$0\.30 \(token cost \$4\.00\); budget: batch 15 min/);
542
+ assert.match(text, /sbx-min/);
540
543
  assert.deepEqual(events.filter((e) => e.event === "SANDBOX_USAGE").map((e) => [e.issue, e.minutes, e.cost_usd, e.batch]), [[1, 5, 0.1, "bc1"], [2, 10, 0.2, "bc1"]]);
541
544
  assert.equal(Object.keys(JSON.parse(readFileSync(file, "utf8")).days[new Date().toISOString().slice(0, 10)]).length, 2);
542
545
  });
@@ -17,7 +17,7 @@ describe("#4656 gate watchdog", () => {
17
17
  it("the batch runner reports GATE_DEADLOCK once per (gate, issue) for a parked issue on the gate, instead of waiting", async () => {
18
18
  const rows = [], decisions = [];
19
19
  const runOne = async (n) => n === 1
20
- ? { terminalReason: "needs-human", code: "conformance.unimplemented", detail: "unknown requirement", pr: 9 }
20
+ ? { terminalReason: "needs-human", code: "conformance.regression", detail: "unknown requirement", pr: 9 }
21
21
  : { terminalReason: "merged", pr: 10 };
22
22
  const _unused = async (n) => n === 1
23
23
  ? { terminalReason: "needs-human", code: "ci.red", detail: "lint failed", pr: 9 }
@@ -29,13 +29,13 @@ describe("#4656 gate watchdog", () => {
29
29
  assert.equal(dl.length, 1);
30
30
  assert.match(dl[0].detail, /GATE_DEADLOCK issue=#1 gate=ship/);
31
31
  assert.ok(Array.isArray(res));
32
- assert.deepEqual(relabels.map((r) => r.parked?.code), ["conformance.unimplemented"]);
32
+ assert.deepEqual(relabels.map((r) => r.parked?.code), ["conformance.regression"]);
33
33
  });
34
34
  });
35
35
 
36
36
  describe("#4656 parked class and migration", () => {
37
37
  it("parked is opt-in and leaves the default classes untouched", () => {
38
- const row = { outcome: "needs-human", code: "conformance.unimplemented" };
38
+ const row = { outcome: "needs-human", code: "conformance.regression" };
39
39
  assert.equal(classifyNeedsHuman(row), "terminal");
40
40
  assert.equal(classifyNeedsHuman(row, { parked: true }), "parked");
41
41
  assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: "builder.blocked" }, { parked: true }), "human");
@@ -53,7 +53,7 @@ describe("#4656 parked class and migration", () => {
53
53
  describe("#4656 report routing", () => {
54
54
  it("a parked row past recovery is listed on the remediation queue line, not unrecovered or escalations", async () => {
55
55
  const { formatOutcomeLists } = await import("./orchestrate.mjs");
56
- const lines = formatOutcomeLists([{ issue: 1, outcome: "needs-human", code: "conformance.unimplemented", pr: 9, parkedQueued: true }]);
57
- assert.deepEqual(lines, ["remediation queue (/work-on:remediate): #1 (parked:conformance.unimplemented PR #9)"]);
56
+ const lines = formatOutcomeLists([{ issue: 1, outcome: "needs-human", code: "conformance.regression", pr: 9, parkedQueued: true }]);
57
+ assert.deepEqual(lines, ["remediation queue (/work-on:remediate): #1 (parked:conformance.regression PR #9)"]);
58
58
  });
59
59
  });
@@ -366,7 +366,7 @@ function realIo(repo, env = process.env) {
366
366
  },
367
367
  launch: (n, lane) => {
368
368
  let res = null;
369
- const p = spawn(process.execPath, [CLI, "run-issue", String(n), "--lane", lane], { detached: true, stdio: "ignore", cwd: process.cwd(), env });
369
+ const p = spawn(process.execPath, [CLI, "run-issue", String(n), "--lane", lane], { detached: true, windowsHide: true, stdio: "ignore", cwd: process.cwd(), env });
370
370
  p.on("close", (code) => { res = { code: code ?? 1 }; }); p.on("error", () => { res = { code: 1 }; });
371
371
  return { poll: () => res };
372
372
  },
@@ -375,7 +375,7 @@ function realIo(repo, env = process.env) {
375
375
  reported: (id) => existsSync(join(root, id, "report.json")),
376
376
  start: async (issues, parallel, lane) => {
377
377
  const before = new Set(listBatches(root));
378
- spawn(process.execPath, [CLI, "orchestrate", ...issues.map(String), "--parallel", String(parallel), "--lane", lane, "--repo", repo], { detached: true, stdio: "ignore", cwd: process.cwd(), env }).unref();
378
+ spawn(process.execPath, [CLI, "orchestrate", ...issues.map(String), "--parallel", String(parallel), "--lane", lane, "--repo", repo], { detached: true, windowsHide: true, stdio: "ignore", cwd: process.cwd(), env }).unref();
379
379
  for (let i = 0; i < 60; i++) { const id = listBatches(root).find((b) => !before.has(b)); if (id) return id; await new Promise((r) => setTimeout(r, 1000)); }
380
380
  throw new Error("orchestrate did not create a batch within 60s");
381
381
  },