forgedock 1.43.1 → 1.45.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 (57) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -2
  2. package/bin/engine/telemetry-seam.mjs +1 -1
  3. package/bin/engine-cli.mjs +50 -11
  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/lean/autochildren.mjs +13 -5
  8. package/bin/lean/autochildren.test.mjs +36 -18
  9. package/bin/lean/batch.test.mjs +46 -0
  10. package/bin/lean/conformance.mjs +107 -16
  11. package/bin/lean/conformance.test.mjs +126 -13
  12. package/bin/lean/decision-engine.mjs +1 -1
  13. package/bin/lean/decision-engine.test.mjs +11 -0
  14. package/bin/lean/execution.mjs +7 -4
  15. package/bin/lean/execution.test.mjs +21 -0
  16. package/bin/lean/gatewatch.test.mjs +5 -5
  17. package/bin/lean/lane.mjs +22 -4
  18. package/bin/lean/lane.test.mjs +33 -1
  19. package/bin/lean/lean.mjs +49 -16
  20. package/bin/lean/lean.test.mjs +66 -0
  21. package/bin/lean/orchestrate-blocked.test.mjs +35 -0
  22. package/bin/lean/orchestrate-children.test.mjs +16 -4
  23. package/bin/lean/orchestrate.mjs +61 -13
  24. package/bin/lean/pmap.mjs +17 -0
  25. package/bin/lean/pmap.test.mjs +27 -0
  26. package/bin/lean/postdeploy-check.test.mjs +3 -1
  27. package/bin/lean/preflight.mjs +8 -3
  28. package/bin/lean/preflight.test.mjs +13 -1
  29. package/bin/lean/prodwatch-triage.mjs +6 -0
  30. package/bin/lean/prodwatch-triage.test.mjs +14 -1
  31. package/bin/lean/prodwatch.mjs +1 -3
  32. package/bin/lean/prompts.mjs +1 -1
  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/sandbox-provider.contract.test.mjs +15 -4
  37. package/bin/lean/sandbox-remote.mjs +19 -7
  38. package/bin/lean/sched.mjs +25 -2
  39. package/bin/lean/sched.test.mjs +58 -1
  40. package/bin/lean/selftest-live.mjs +56 -31
  41. package/bin/lean/selftest-live.test.mjs +48 -6
  42. package/bin/lean/selftest-nightly.mjs +7 -3
  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 +16 -3
  47. package/bin/lean/signoff.test.mjs +52 -1
  48. package/bin/pool/agent.mjs +3 -3
  49. package/bin/pool/cli.mjs +29 -1
  50. package/bin/pool/router.mjs +17 -6
  51. package/commands/orchestrate.md +5 -2
  52. package/docs/CONFIG.md +10 -4
  53. package/docs/DECISIONS.md +5 -1
  54. package/docs/OUTCOME-CODES.md +2 -2
  55. package/docs/TELEMETRY.md +1 -1
  56. package/package.json +1 -1
  57. package/scripts/plan-validate-live.mjs +15 -8
@@ -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", deferred: "defer", deferral: "defer", "scope-deferral": "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
 
@@ -341,7 +345,8 @@ function normalizeAmendment(a) {
341
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
342
346
  if (typeof n.kind === "string") {
343
347
  const k = n.kind.trim().toLowerCase().replace(/_/g, "-");
344
- if (AMEND_KINDS.includes(k) || Object.hasOwn(AMEND_SLIPS, k)) n.kind = k;
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";
345
350
  }
346
351
  if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
347
352
  if (SUPERSEDES_SYNONYMS.includes(n.kind)) n.kind = "supersedes";
@@ -368,6 +373,7 @@ function amendmentProblem(a, tag) {
368
373
  const kind = a?.kind;
369
374
  const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
370
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)"
371
377
  : kind === "decision" ? "needs decision {choice, anchors: [paths or globs], symbols?, supersedes?: decision id}" : null;
372
378
  const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
373
379
  return `unknown or incomplete amendment: ${why}: ${tag}`;
@@ -407,6 +413,7 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
407
413
  for (let { a, raw } of rows) {
408
414
  // an unrelated out-of-diff CI failure is not a contract change: informational, never a problem
409
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; }
410
417
  const tag = JSON.stringify(raw ?? null).slice(0, 400);
411
418
  if (a && blank(a.why) && !blank(defaultWhy)) a = { ...a, why: String(defaultWhy) }; // the engine demanded the change: its reason is the why
412
419
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
@@ -424,12 +431,20 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
424
431
  // #4857: a runnable equivalent of an unrunnable frozen validate command; same id, same checks as a planned command
425
432
  const v = (c.validate ?? []).find((x) => x.id === a.validate);
426
433
  if (!v) { problems.push(`substitute-validate names unknown validate ${a.validate}`); continue; }
427
- const bad = blank(a.command) ? "no command" : shellSyntaxError(a.command) ? "not valid shell (bash -n)" : noopCommand(a.command) ? "runs no check (true, :, exit 0, echo)" : movingBaseDiff(a.command) ? "diffs against a moving base tip" : tmpdirInputs(a.command).length ? "reads a $TMPDIR file it did not create" : null;
434
+ const bad = blank(a.command) ? "no command" : substituteShellProblem(a.command) ? "not valid shell (bash -n)" : noopCommand(a.command) ? "runs no check (true, :, exit 0, echo)" : movingBaseDiff(a.command) ? "diffs against a moving base tip" : tmpdirInputs(a.command).length ? "reads a $TMPDIR file it did not create" : null;
428
435
  if (bad) { problems.push(`substitute-validate ${a.validate}: ${bad}`); continue; }
429
436
  // SEC-11: a second substitute of the same id keeps the frozen command as `from`, so the engine always reruns the original
430
437
  v.substituted = { from: v.substituted?.from ?? v.command, why: String(a.why).trim() }; v.command = String(a.command).trim();
431
438
  applied.push({ kind: a.kind, target: a.validate, why: String(a.why).trim() });
432
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;
433
448
  } else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
434
449
  if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
435
450
  if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
@@ -465,18 +480,24 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
465
480
  if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
466
481
  if (!TRACKED.includes(r.disposition)) {
467
482
  if (a.bucket != null) r.bucket = a.bucket; // #4724
468
- if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
483
+ if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { // #5020: no runnable check: a manual or credentialed action is deferred to the orchestrator, never a conformance failure
484
+ r.disposition = "defer"; r.why = a.why; r.amended = true; r.bucket = String(a.bucket ?? "").startsWith("owner:") ? a.bucket : ORCHESTRATOR_BUCKET; toTrack.push(r); continue;
485
+ }
469
486
  r.disposition = a.kind; r.why = a.why; r.amended = true; r.bucket ??= "post-deploy"; toTrack.push(r); // #4724: an amendment without a bucket defaults to post-deploy
470
487
  if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
471
488
  }
472
489
  } else if (a.kind === "decision") {
473
490
  const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
474
491
  if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
475
- // a file the lane adds and the contract lists as a deliverable is a new file the issue declared: a valid anchor
492
+ // #5036: any file the lane adds is a valid anchor; one the contract does not list becomes a deliverable
476
493
  const listed = new Set(c.deliverables.map((x) => x.path));
477
- const added = (Array.isArray(addedPaths) ? addedPaths : []).filter((x) => listed.has(x));
494
+ const added = Array.isArray(addedPaths) ? addedPaths : [];
478
495
  const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, [...baseFiles, ...added]) : [];
479
496
  if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
497
+ for (const f of added) {
498
+ if (listed.has(f) || (Array.isArray(baseFiles) && baseFiles.includes(f)) || !d.anchors.some((x) => anchorMatches(x, f))) continue;
499
+ 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);
500
+ }
480
501
  if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
481
502
  decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
482
503
  applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
@@ -489,6 +510,7 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
489
510
 
490
511
  const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
491
512
  const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
513
+ const POETRY_NOT_FOUND_RE = /^\s*Command not found: (\S+)/m;
492
514
  const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
493
515
  /** #4857: why a command is not valid bash (`bash -n`), or null when it parses (or bash is unavailable). Catches prose left on a command. */
494
516
  export function shellSyntaxError(command) {
@@ -496,6 +518,49 @@ export function shellSyntaxError(command) {
496
518
  if (r.error || r.status === 0 || r.status == null) return null;
497
519
  return String(r.stderr || "syntax error").trim().split("\n")[0].replace(/^bash: (?:line \d+: )?/, "").slice(0, 160);
498
520
  }
521
+ /** #5020: like shellSyntaxError, but fails closed: when bash cannot run the check the command is not shown to parse. */
522
+ export function substituteShellProblem(command) {
523
+ const r = spawnSync("bash", ["-n", "-c", String(command ?? "")], { encoding: "utf8", timeout: 5000 });
524
+ if (r.error || r.status == null) return "bash -n could not run, so the command is unverified";
525
+ return r.status === 0 ? null : shellSyntaxError(command) ?? "syntax error";
526
+ }
527
+ export const ORCHESTRATOR_BUCKET = "owner:orchestrator";
528
+ /**
529
+ * #5020: a post-deploy requirement that names no check id (a manual or production-credential action) becomes a `defer` with an
530
+ * `owner:orchestrator` bucket, the legal disposition the engine files as a follow-up. Pure; returns {contract, converted[]}.
531
+ */
532
+ export function deferChecklessPostDeploy(contract) {
533
+ const converted = [];
534
+ const requirements = (Array.isArray(contract?.requirements) ? contract.requirements : []).map((r) => {
535
+ if (r?.disposition !== "post-deploy" || CHECK_ID_RE.test(String(r?.check ?? ""))) return r;
536
+ converted.push(r.id);
537
+ return { ...r, disposition: "defer", bucket: String(r.bucket ?? "").startsWith("owner:") ? r.bucket : ORCHESTRATOR_BUCKET, why: r.why ?? "post-deploy action with no registered check: deferred to the orchestrator" };
538
+ });
539
+ return { contract: converted.length ? { ...contract, requirements } : contract, converted };
540
+ }
541
+ /**
542
+ * #5020: apply forge.yaml `validate.recipes` ([{match, replace}], match a regex source) to each validate command and the
543
+ * repro_test command at freeze, first matching recipe per command. Pure; returns {contract, applied[{id, from, to}]}.
544
+ */
545
+ export function applyValidateRecipes(contract, recipes) {
546
+ const list = (Array.isArray(recipes) ? recipes : []).map((r) => { try { return typeof r?.replace === "string" && r?.match ? { re: new RegExp(String(r.match)), replace: r.replace } : null; } catch { return null; } }).filter(Boolean);
547
+ if (!list.length || !contract || typeof contract !== "object") return { contract, applied: [] };
548
+ const applied = [];
549
+ const rewrite = (id, cmd) => {
550
+ if (typeof cmd !== "string") return cmd;
551
+ for (const { re, replace } of list) if (re.test(cmd)) { const to = cmd.replace(re, replace); if (to !== cmd) applied.push({ id, from: cmd, to }); return to; }
552
+ return cmd;
553
+ };
554
+ const out = { ...contract };
555
+ if (Array.isArray(contract.validate)) out.validate = contract.validate.map((v) => (v && typeof v === "object" ? { ...v, command: rewrite(v.id, v.command) } : v));
556
+ const t = contract.repro_test;
557
+ if (t && typeof t === "object" && !Array.isArray(t)) out.repro_test = { ...t, command: rewrite("repro_test", t.command) };
558
+ return { contract: applied.length ? out : contract, applied };
559
+ }
560
+ /** #5020: the env.* verdict for a repro command that failed on head because a tool is missing (e.g. an uninstalled Poetry env); null when the failure is the test's own. */
561
+ export function reproEnvMiss(run, opts) {
562
+ return run && run.rc !== 0 && run.rc !== 124 ? missingTool(run, opts) : null;
563
+ }
499
564
  const NOOP_SEGMENT_RE = /^(?:true|false|:|exit(?:\s+\d+)?|echo(?:\s.*)?|printf(?:\s.*)?|sleep(?:\s+[\d.]+)?|test\s+-?\w+|\[\s+-?\w+\s+\]|fi|done|node\s+-e\s+['"]?[\d;\s]*['"]?|python3?\s+-c\s+['"]?(?:pass|0|None)?['"]?)$/;
500
565
  const KEYWORD_RE = /^(if|then|else|elif|do|while|until)(?:\s+|$)/;
501
566
  const CONST_FALSE_RE = /^(?:false|\[\s+0\s+=\s+1\s+\]|\[\s+1\s+=\s+0\s+\]|test\s+0\s+=\s+1|test\s+-n\s+(?:""|'')|\[\s+-n\s+(?:""|'')\s+\])$/;
@@ -537,14 +602,35 @@ export function substituteVerdict(run, { repoHas = () => false, command = "" } =
537
602
  * a whitespace token equal to the path, or a directory token above it. A builder that removes the target of its frozen
538
603
  * validate makes the engine's rerun read "unrunnable", so the substitute is judged on the diff instead.
539
604
  */
540
- export function removedNamedPaths(command, removed = []) {
541
- const toks = String(command ?? "").split(/\s+/).map((t) => t.replace(/^['"`(]+|['"`);]+$/g, "").replace(/^\.\//, "").replace(/\/+$/, "")).filter((t) => t && t !== ".");
542
- return removed.filter((p) => toks.some((t) => p === t || p.startsWith(`${t}/`)));
605
+ 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$/;
606
+ /** #5003: a runnable test FILE (by basename), unlike `isTestPath`, which also matches fixtures under tests/. */
607
+ const isTestFile = (p) => TEST_FILE_RE.test(String(p).split("/").pop());
608
+ /**
609
+ * #4990 SEC-13: the paths of `removed` (deleted, or the old side of a rename, in the diff) that a frozen command names.
610
+ * #5003: a token is stripped of quotes, a `--flag=` prefix and a pytest `::node` suffix; a glob is expanded against
611
+ * `baseFiles`; an exact path matches itself; a directory or bare-word token matches only a removed test file under it.
612
+ */
613
+ export function removedNamedPaths(command, removed = [], baseFiles = []) {
614
+ const toks = String(command ?? "").split(/\s+/).map((t) => t.replace(/^['"`(]+|['"`);]+$/g, "").replace(/^-[^=\/]*=/, "").replace(/::.*$/, "").replace(/^['"`]+|['"`]+$/g, "").replace(/^\.\//, "").replace(/\/+$/, "")).filter((t) => t && t !== ".");
615
+ const files = new Set(baseFiles);
616
+ return removed.filter((p) => toks.some((t) => {
617
+ if (/[*?]/.test(t)) return globRe(t).test(p) && (files.size === 0 || files.has(p));
618
+ return p === t || (p.startsWith(`${t}/`) && isTestFile(p));
619
+ }));
543
620
  }
544
- /** #4990: paths the lane's diff deletes or renames away from (the old side), per `git diff --name-status -M`. */
621
+ /** #4990: paths the lane's diff deletes or renames away from (the old side). #5003: `-z` (unquoted), null on a git error (fail closed). */
545
622
  export async function removedPathsOf(git, base) {
546
- const rows = String(await git(["diff", "--name-status", "-M", `${base}...HEAD`]).catch(() => "")).split("\n").map((l) => l.split("\t"));
547
- return rows.filter((r) => /^[DR]/.test(r[0] ?? "") && r[1]).map((r) => r[1]);
623
+ let out;
624
+ try { out = String(await git(["diff", "--name-status", "-z", "-M", `${base}...HEAD`])); } catch { return null; }
625
+ const f = out.split("\0"), gone = [];
626
+ for (let i = 0; i < f.length; i++) {
627
+ const st = f[i];
628
+ if (!/^[A-Z]/.test(st)) continue;
629
+ const two = /^[RC]/.test(st);
630
+ if (/^[DR]/.test(st) && f[i + 1]) gone.push(f[i + 1]);
631
+ i += two ? 2 : 1;
632
+ }
633
+ return gone;
548
634
  }
549
635
  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;
550
636
  /** #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. */
@@ -605,6 +691,8 @@ export function missingTool({ rc, output } = {}, { repoHas = () => false, comman
605
691
  const tool = scoped ? spec.split("/").slice(0, 2).join("/") : spec;
606
692
  return local(tool) || (scoped && !tool.includes("/")) ? null : { tool, kind: "node-module" };
607
693
  }
694
+ const po = POETRY_NOT_FOUND_RE.exec(out)?.[1]; // #5020: `poetry run pytest` in an env that was never installed
695
+ if (po) return local(po) ? null : { tool: po, kind: "command" };
608
696
  const cmd = CMD_NOT_FOUND_RE.exec(out)?.[1] ?? (rc === 127 ? SH_NOT_FOUND_RE.exec(out)?.[1] ?? String(command).trim().split(/\s+/)[0] : null);
609
697
  if (!cmd || cmd.includes("/") || local(cmd)) return null;
610
698
  return { tool: cmd, kind: "command" };
@@ -632,7 +720,7 @@ export function repoTracks(paths) {
632
720
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
633
721
  * tool), for the trajectory; always an array.
634
722
  */
635
- 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 = [] }) {
723
+ 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 = [] }) {
636
724
  const failures = [], superseded = [], ciCovered = [];
637
725
  // #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)
638
726
  if (checkIds) for (const r of contract.requirements ?? []) {
@@ -648,7 +736,10 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
648
736
  for (const v of contract.validate ?? []) {
649
737
  // #4979 SPEC-2: a substitute counts only when the frozen command it replaced cannot run (or passes); checked first, never CI-covered
650
738
  if (v.substituted) {
651
- const gone = removedNamedPaths(v.substituted.from, removedPaths);
739
+ const shellBad = substituteShellProblem(v.command); // #5020: re-checked before the substitute is used
740
+ if (shellBad) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} substitute \`${v.command}\` is not valid shell (bash -n: ${shellBad})` }); continue; }
741
+ 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; }
742
+ const gone = removedNamedPaths(v.substituted.from, removedPaths, baseFiles);
652
743
  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; }
653
744
  const verdict = substituteVerdict(substitutedRuns?.[v.id], { repoHas, command: v.substituted.from });
654
745
  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, 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";
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, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET } 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.";
@@ -78,8 +78,8 @@ describe("amendment kind aliases (#4856)", () => {
78
78
  assert.deepEqual(a.toTrack.map((r) => r.id), ["R1"], kind);
79
79
  }
80
80
  });
81
- it("scope-note and other unlisted kinds stay unknown and list the valid kinds", () => {
82
- for (const kind of ["scope-note", "Scope_Note", "flaky-note"]) {
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
83
  assert.match(applyAmendments(C, [{ kind, requirement: "R1", why: "w" }]).problems[0], new RegExp(`unknown kind '${kind}'; valid kinds are add-deliverable`));
84
84
  }
85
85
  });
@@ -94,9 +94,9 @@ describe("#4655 multi-requirement amendment references", () => {
94
94
  assert.deepEqual(a.applied.map((x) => x.target), ["R1", "R2"], requirement);
95
95
  }
96
96
  });
97
- 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", () => {
98
98
  assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
99
- 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/);
100
100
  });
101
101
  it("an unrelated-ci-failure amendment (kind or type) is an informational no-op; other unknown kinds still fail", () => {
102
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" }]) {
@@ -232,9 +232,25 @@ describe("precheck (deterministic, no reviewer)", () => {
232
232
  assert.match(gone.failures[0].detail, /test\/leak\.test\.js/);
233
233
  const env = precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "Unknown option `-q`" } }, removedPaths: ["docs/other.md"] });
234
234
  assert.deepEqual(env.failures, [], "intact named files: env-unrunnable substitute stands");
235
- 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"]);
236
236
  assert.deepEqual(removedNamedPaths("pytest -k leak", ["leak.py"]), []);
237
- 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"]);
238
254
  });
239
255
  it("#4857 describesUnrunnableCommand reads a blocked summary", () => {
240
256
  assert.ok(describesUnrunnableCommand("V2 is unrunnable: poetry found no pyproject at the repo root"));
@@ -894,9 +910,10 @@ describe("post-deploy check ids (#4626)", () => {
894
910
  assert.ok(validateContract(pd({ check: "Bad Id" }), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
895
911
  assert.ok(!validateContract(pd({ check: "login-smoke" }), ISSUE).problems.some((p) => /check id/.test(p)));
896
912
  });
897
- it("applyAmendments: a post-deploy amendment needs a check id", () => {
913
+ it("applyAmendments: a post-deploy amendment without a check id is deferred to the orchestrator (#5020)", () => {
898
914
  const bad = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w" }], ISSUE);
899
- assert.ok(bad.problems.some((p) => /needs a check id/.test(p)));
915
+ assert.deepEqual(bad.problems, []);
916
+ assert.equal(bad.contract.requirements[0].disposition, "defer");
900
917
  const ok = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "login-smoke" }], ISSUE);
901
918
  assert.equal(ok.contract.requirements[0].check, "login-smoke");
902
919
  });
@@ -1005,10 +1022,12 @@ describe("decision amendments (#4695)", () => {
1005
1022
  assert.match(a.problems[0], /match no file on the base/);
1006
1023
  assert.equal(a.decisions.length, 0);
1007
1024
  });
1008
- it("an anchor on a lane-added file the contract lists as a deliverable conforms; an unlisted added file does not", () => {
1009
- const withNew = { ...withRow, deliverables: [...withRow.deliverables, { id: "D3", path: "scripts/seed.py" }] };
1010
- const go = (added) => applyAmendments(withNew, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: ["scripts/seed.py"] } }], null, { baseFiles: ["src/scrape.js"], addedPaths: added });
1011
- assert.deepEqual(go(["scripts/seed.py"]).problems, []);
1025
+ 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", () => {
1026
+ 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 });
1027
+ const ok = go(["scripts/seed.py"]);
1028
+ assert.deepEqual(ok.problems, []);
1029
+ assert.ok(ok.contract.deliverables.some((d) => d.path === "scripts/seed.py" && d.amended));
1030
+ assert.equal(ok.contract.deliverables.filter((d) => d.path === "scripts/seed.py").length, 1);
1012
1031
  assert.match(go(["scripts/other.py"]).problems[0], /match no file on the base/);
1013
1032
  });
1014
1033
  it("a blank-why amendment takes the engine's defaultWhy; without one it is still rejected", () => {
@@ -1154,3 +1173,97 @@ describe("mutation check (#4913)", () => {
1154
1173
  assert.equal(g("status", "--porcelain").trim(), "");
1155
1174
  });
1156
1175
  });
1176
+
1177
+ describe("builder amendment slips (#5036)", () => {
1178
+ it("deferral-shaped kinds naming a requirement apply as a tracked defer", () => {
1179
+ for (const kind of ["scope_narrowing", "scope_reduction", "defer-verification", "deferred_measurement", "Scope-Narrowing"]) {
1180
+ const a = applyAmendments(C, [{ kind, requirement: "R1", why: "w" }]);
1181
+ assert.deepEqual(a.problems, [], kind);
1182
+ assert.equal(a.contract.requirements.find((r) => r.id === "R1").disposition, "defer", kind);
1183
+ assert.deepEqual(a.toTrack.map((r) => r.id), ["R1"], kind);
1184
+ }
1185
+ assert.match(applyAmendments(C, [{ kind: "scope_narrowing", why: "w" }]).problems[0], /unknown kind 'scope_narrowing'; valid kinds are/);
1186
+ });
1187
+ it("verification_substitution applies as substitute-validate with the same checks", () => {
1188
+ const base = { requirements: [], validate: [{ id: "V2", command: "poetry run pytest" }] };
1189
+ const ok = applyAmendments(base, [{ kind: "verification_substitution", validate: "V2", command: "node --test t.mjs", why: "no poetry" }]);
1190
+ assert.deepEqual(ok.problems, []);
1191
+ assert.equal(ok.contract.validate[0].command, "node --test t.mjs");
1192
+ assert.equal(ok.contract.validate[0].substituted.from, "poetry run pytest");
1193
+ for (const command of ["echo (oops", "true", "git diff origin/main"]) {
1194
+ assert.equal(applyAmendments(base, [{ kind: "verification_substitution", validate: "V2", command, why: "w" }]).problems.length, 1, command);
1195
+ }
1196
+ assert.match(applyAmendments(base, [{ kind: "verification_substitution", why: "w" }]).problems[0], /needs validate/);
1197
+ });
1198
+ it("out_of_band_verification marks a deliver requirement out_of_band with its evidence", () => {
1199
+ const ok = applyAmendments(C, [{ kind: "out_of_band_verification", requirement: "R1", evidence: ".github/workflows/perf.yml", why: "field metric" }]);
1200
+ assert.deepEqual(ok.problems, []);
1201
+ const r = ok.contract.requirements.find((x) => x.id === "R1");
1202
+ assert.equal(r.verify, "out_of_band"); assert.equal(r.evidence, ".github/workflows/perf.yml");
1203
+ assert.equal(applyAmendments(C, [{ kind: "out_of_band_verification", requirement: "R1", why: "w" }]).problems.length, 1);
1204
+ });
1205
+ it("annotation-only kinds are informational: recorded, no problem, no contract change", () => {
1206
+ for (const kind of ["scope-note", "scope_clarification", "interpretation", "environment", "scope", "validation_unavailable"]) {
1207
+ const a = applyAmendments(C, [{ kind, note: "n" }, { kind, why: "w" }]);
1208
+ assert.deepEqual(a.problems, [], kind);
1209
+ assert.deepEqual(a.contract, C, kind);
1210
+ assert.equal(a.applied.length, 2, kind);
1211
+ }
1212
+ });
1213
+ it("a truly unknown kind is still rejected with the valid kinds", () => {
1214
+ assert.match(applyAmendments(C, [{ kind: "flaky-note", why: "w" }]).problems[0], /unknown kind 'flaky-note'; valid kinds are .*substitute-validate/);
1215
+ });
1216
+ });
1217
+
1218
+ describe("#5020 freeze-time environment defects", () => {
1219
+ const base = { requirements: [{ id: "R1", quote: "q", disposition: "deliver" }], validate: [{ id: "V1", command: "cd services/api && poetry run pytest tests/x.py" }, { id: "V2", command: "node --test a.test.mjs" }], repro_test: { path: "t.py", id: "t", command: "cd services/api && poetry run pytest t.py" } };
1220
+ const recipes = [{ match: "poetry run pytest", replace: "uv run --with-requirements r.txt pytest" }];
1221
+
1222
+ it("applyValidateRecipes rewrites matching validate and repro commands and leaves the rest", () => {
1223
+ const { contract, applied } = applyValidateRecipes(base, recipes);
1224
+ assert.equal(contract.validate[0].command, "cd services/api && uv run --with-requirements r.txt pytest tests/x.py");
1225
+ assert.equal(contract.validate[1].command, "node --test a.test.mjs");
1226
+ assert.match(contract.repro_test.command, /uv run --with-requirements r\.txt pytest t\.py/);
1227
+ assert.deepEqual(applied.map((a) => a.id), ["V1", "repro_test"]);
1228
+ assert.equal(base.validate[0].command.includes("poetry"), true, "input is not mutated");
1229
+ });
1230
+ it("applyValidateRecipes ignores no recipes and invalid regexes", () => {
1231
+ assert.equal(applyValidateRecipes(base, null).contract, base);
1232
+ assert.equal(applyValidateRecipes(base, [{ match: "(", replace: "x" }]).applied.length, 0);
1233
+ });
1234
+ it("Poetry's missing-env output is env.tool_missing material, and a repro head that dies on it is an env miss", () => {
1235
+ const run = { rc: 1, output: "Creating virtualenv api-py3.12\n\nCommand not found: pytest\n" };
1236
+ assert.deepEqual(missingTool(run), { tool: "pytest", kind: "command" });
1237
+ assert.deepEqual(reproEnvMiss(run), { tool: "pytest", kind: "command" });
1238
+ assert.equal(reproEnvMiss({ rc: 1, output: "1 failed" }), null);
1239
+ // release review BUG-1: a module the repo tracks is a real failure of the change, not an env miss
1240
+ const mod = { rc: 1, output: "ModuleNotFoundError: No module named 'mypkg'" };
1241
+ assert.deepEqual(reproEnvMiss(mod, { command: "pytest t.py" }), { tool: "mypkg", kind: "python-module" });
1242
+ assert.equal(reproEnvMiss(mod, { command: "pytest t.py", repoHas: repoTracks(["src/mypkg/__init__.py"]) }), null);
1243
+ assert.equal(reproEnvMiss({ rc: 0, output: "" }), null);
1244
+ });
1245
+ it("a post-deploy requirement with no check id is deferred to the orchestrator; one with a check is untouched", () => {
1246
+ const c = { requirements: [{ id: "R1", quote: "q", disposition: "post-deploy", why: "needs prod creds" }, { id: "R2", quote: "q", disposition: "post-deploy", check: "login-smoke", why: "w" }] };
1247
+ const { contract, converted } = deferChecklessPostDeploy(c);
1248
+ assert.deepEqual(converted, ["R1"]);
1249
+ assert.equal(contract.requirements[0].disposition, "defer");
1250
+ assert.equal(contract.requirements[0].bucket, ORCHESTRATOR_BUCKET);
1251
+ assert.equal(contract.requirements[1].disposition, "post-deploy");
1252
+ assert.deepEqual(bucketProblems(contract.requirements[0]), []);
1253
+ });
1254
+ it("a post-deploy amendment with no check becomes a tracked defer instead of a problem", () => {
1255
+ const c = { requirements: [{ id: "R1", quote: "q", disposition: "deliver" }], deliverables: [], validate: [] };
1256
+ const r = applyAmendments(c, [{ kind: "post-deploy", requirement: "R1", why: "manual prod action" }], "q");
1257
+ assert.deepEqual(r.problems, []);
1258
+ assert.equal(r.contract.requirements[0].disposition, "defer");
1259
+ assert.equal(r.contract.requirements[0].bucket, ORCHESTRATOR_BUCKET);
1260
+ });
1261
+ it("a substitute-validate that is not valid shell is rejected", () => {
1262
+ assert.match(substituteShellProblem("pytest tests/ && ("), /./);
1263
+ assert.equal(substituteShellProblem("pytest tests/"), null);
1264
+ const c = { requirements: [{ id: "R1", quote: "q", disposition: "deliver" }], deliverables: [], validate: [{ id: "V1", command: "poetry run pytest" }] };
1265
+ const r = applyAmendments(c, [{ kind: "substitute-validate", validate: "V1", command: "uv run pytest tests/ && (", why: "poetry env missing" }], "q");
1266
+ assert.ok(r.problems.some((p) => /not valid shell/.test(p)));
1267
+ assert.equal(r.contract.validate[0].command, "poetry run pytest");
1268
+ });
1269
+ });
@@ -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
+ });
@@ -562,15 +562,18 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
562
562
  for (;;) {
563
563
  try { const v = await fn(); st.waited = 0; st.tries = 0; return v; } catch (e) {
564
564
  if (!isTransportError(e)) throw e;
565
- if (st.waited >= unreachableWindowMs) throw Object.assign(new Error(`unreachable for ${Math.round(st.waited / 1000)}s: ${errText(e)}`), { unreachable: true });
565
+ // release review BUG-6: a 401 that outlasts the window is an auth failure (capped sandbox.auth), not an uncapped unreachable pool
566
+ if (st.waited >= unreachableWindowMs) throw Object.assign(new Error(`unreachable for ${Math.round(st.waited / 1000)}s: ${errText(e)}`), { unreachable: true, ...(e?.status === 401 ? { auth: true, hop: e.hop } : {}) });
566
567
  const d = Math.min(pollMs * 2 ** Math.min(st.tries++, 10), 30000, Math.max(1, unreachableWindowMs - st.waited));
567
568
  st.waited += d;
568
569
  await sleep(d);
569
570
  }
570
571
  }
571
572
  };
572
- const unreachable = (h, e) => ({ terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
573
- detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
573
+ const unreachable = (h, e) => (e?.auth
574
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${h.id} kept answering 401 (${e.hop === "agent" ? "the pool agent refused the router" : "check the pool token"}): ${errText(e)}; kept for adopt on resume` }
575
+ : { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
576
+ detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
574
577
  const tailOf = async (h) => { try { return typeof logTail === "function" ? String(await logTail(h) ?? "") : ""; } catch { return ""; } };
575
578
  const settleClone = (r, id) => (r?.terminalReason === "engine-error" && r.code === undefined && isCloneFailure(r.detail) ? cloneDeferred(id, errLine(r.detail)) : r);
576
579
  const wait = async (h, g = guard({ waited: 0, tries: 0 })) => {
@@ -590,7 +593,7 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
590
593
  // A start failure repeats on a relaunch: typed, never engine-error (#4254). Retryable (capacity) defers with backoff.
591
594
  // #4685: a refused or missing credential defers with its own code (a fixed token is picked up on the retry), never needs-human.
592
595
  const startFailed = (id, e) => ([401, 403].includes(e?.status)
593
- ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)` }
596
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; ${e?.hop === "agent" ? "the pool agent refused the router (re-enroll or fix the agent token)" : "check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)"}` }
594
597
  : e?.retryable === true
595
598
  ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
596
599
  : isCloneFailure(errText(e)) ? cloneDeferred(id, errText(e))
@@ -116,6 +116,27 @@ describe("providerLanes: an unreachable provider is retried, then deferred witho
116
116
  assert.deepEqual(ledgerFor(7).entries("sandbox").map((e) => e.value), ["fake-7"]);
117
117
  });
118
118
 
119
+ it("#5053 a 401 on status/result of a launched sandbox is retried, then ends sandbox.auth (capped), never engine-error", async () => {
120
+ const e401 = () => Object.assign(new Error("sandbox status: HTTP 401 (agent a1 auth)"), { status: 401, hop: "agent", transient: true });
121
+ const mk = (failPolls) => { const prov = flaky(0); let polls = 0; prov.start = async () => ({ provider: "fake", id: "fake-7", issue: 7 });
122
+ prov.status = async (h) => { if (polls++ < failPolls) throw e401(); return prov.live.has(h.issue) ? "running" : "exited"; }; return prov; };
123
+ const ok = mk(2); ok.results[7] = { terminalReason: "merged", pr: 1 }; ok.live.delete(7);
124
+ assert.equal((await lanes(ok).l.runOne(7, "staging")).terminalReason, "merged");
125
+ const down = mk(1e9);
126
+ const r = await lanes(down, { unreachableWindowMs: 5000 }).l.runOne(7, "staging");
127
+ // release review BUG-6: a persistent 401 is sandbox.auth (capped by max_deferrals), not the uncapped sandbox.unreachable
128
+ assert.deepEqual([r.terminalReason, r.code, r.retryInSeconds], ["deferred", "sandbox.auth", 300]);
129
+ assert.match(r.detail, /agent a1 auth/);
130
+ assert.match(r.detail, /pool agent refused the router/);
131
+ });
132
+
133
+ it("#5053 launch 401 from the agent hop says the agent refused, not the forgedock token", async () => {
134
+ const prov = fakeProvider(); prov.start = async () => { throw Object.assign(new Error("sandbox launch: HTTP 401 (agent a1 auth)"), { status: 401, hop: "agent" }); };
135
+ const r = await lanes(prov).l.runOne(7, "staging");
136
+ assert.equal(r.code, "sandbox.auth");
137
+ assert.match(r.detail, /agent a1 auth.*pool agent refused the router/);
138
+ });
139
+
119
140
  it("unreachable: adopt applies the same tolerance, and a non-transport error is still engine-error", async () => {
120
141
  const down = flaky(1e9);
121
142
  const { l } = lanes(down, { unreachableWindowMs: 3000 });
@@ -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
  });