forgedock 1.42.0 → 1.43.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.
@@ -7,6 +7,7 @@
7
7
  * The reviewer supplies per-row judgements; the engine computes the verdict. A review without rows (a generic
8
8
  * "PASS") is incomplete, and incomplete never merges.
9
9
  */
10
+ import { spawnSync } from "node:child_process";
10
11
  import { OUTCOME_CODES, ENV_VALIDATE_TIMEOUT } from "../engine/outcome-codes.mjs";
11
12
  import { noComments } from "./context.mjs";
12
13
  import { activationFailures } from "./activation.mjs";
@@ -303,6 +304,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
303
304
  if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
304
305
  for (const p of tmpdirInputs(v.command)) problems.push(tmpInputProblem(v.id, p));
305
306
  if (vitestQuietFlag(v.command)) problems.push(`${v.id}: vitest 3 rejects -q/--quiet (usage error before any test runs); use --reporter=dot`);
307
+ const syn = shellSyntaxError(v.command);
308
+ if (syn) problems.push(`${v.id}: the command is not valid shell (bash -n: ${syn}); keep notes out of the command`);
306
309
  }
307
310
  if (repro) {
308
311
  const t = c.repro_test;
@@ -311,6 +314,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
311
314
  else {
312
315
  for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
313
316
  if (vitestQuietFlag(t.command)) problems.push("repro_test: vitest 3 rejects -q/--quiet; use --reporter=dot");
317
+ const syn = shellSyntaxError(t.command);
318
+ if (syn) problems.push(`repro_test: the command is not valid shell (bash -n: ${syn})`);
314
319
  }
315
320
  }
316
321
  if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
@@ -320,7 +325,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
320
325
  /** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
321
326
  const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
322
327
 
323
- const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
328
+ const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision", "substitute-validate"];
329
+ const UNRELATED_CI_KIND = "unrelated-ci-failure";
324
330
  const GUARD_ID_RE = /^#?\d+$/;
325
331
  const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
326
332
  // #4916: near-synonyms builders emit for the per-guard `supersedes` amendment
@@ -364,16 +370,17 @@ function amendmentProblem(a, tag) {
364
370
 
365
371
  /**
366
372
  * Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
367
- * defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
373
+ * defer|post-deploy {requirement: id}. Every amendment needs a why (or the engine's defaultWhy); `addedPaths` are lane-added files that count as decision anchors when a deliverable lists them. Returns {contract, applied[], toTrack[], problems[]}.
368
374
  * `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
369
375
  * matching path is refused; the repo's generator or bot owns that file.
370
376
  * `decision` {decision:{choice, anchors, symbols?, supersedes?}} (#4695): every anchor must match a file of `baseFiles` (the
371
377
  * base tree; null skips the check); `supersedes` names the decision whose engine-authored preserve row it replaces and
372
378
  * drops that row; returns the records in `decisions`.
373
379
  */
374
- export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null } = {}) {
380
+ export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null, addedPaths = [], defaultWhy = null } = {}) {
375
381
  const c = structuredClone(frozen);
376
382
  c.deliverables = c.deliverables ?? [];
383
+ for (const v of c.validate ?? []) if (v && typeof v === "object") delete v.substituted; // SEC-21: only a substitute-validate amendment sets it, never the frozen contract
377
384
  const applied = [], toTrack = [], problems = [], decisions = [];
378
385
  const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
379
386
  const rows = [];
@@ -392,8 +399,11 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
392
399
  }
393
400
  rows.push({ a: n, raw });
394
401
  }
395
- for (const { a, raw } of rows) {
402
+ for (let { a, raw } of rows) {
403
+ // an unrelated out-of-diff CI failure is not a contract change: informational, never a problem
404
+ 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; }
396
405
  const tag = JSON.stringify(raw ?? null).slice(0, 400);
406
+ if (a && blank(a.why) && !blank(defaultWhy)) a = { ...a, why: String(defaultWhy) }; // the engine demanded the change: its reason is the why
397
407
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
398
408
  if (a.kind === "add-deliverable" && !blank(a.path)) {
399
409
  if (genRes.some((re) => re.test(cleanPath(a.path)))) {
@@ -405,6 +415,16 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
405
415
  // package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
406
416
  if (have) { have.change = have.change ? `${have.change}; ${a.why}` : a.why; have.amended = true; }
407
417
  else c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
418
+ } else if (a.kind === "substitute-validate" && !blank(a.validate)) {
419
+ // #4857: a runnable equivalent of an unrunnable frozen validate command; same id, same checks as a planned command
420
+ const v = (c.validate ?? []).find((x) => x.id === a.validate);
421
+ if (!v) { problems.push(`substitute-validate names unknown validate ${a.validate}`); continue; }
422
+ 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;
423
+ if (bad) { problems.push(`substitute-validate ${a.validate}: ${bad}`); continue; }
424
+ // SEC-11: a second substitute of the same id keeps the frozen command as `from`, so the engine always reruns the original
425
+ v.substituted = { from: v.substituted?.from ?? v.command, why: String(a.why).trim() }; v.command = String(a.command).trim();
426
+ applied.push({ kind: a.kind, target: a.validate, why: String(a.why).trim() });
427
+ continue;
408
428
  } else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
409
429
  if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
410
430
  if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
@@ -424,6 +444,16 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
424
444
  // #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
425
445
  const r = c.requirements.find((x) => x.id === a.requirement);
426
446
  if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
447
+ if (TRACKED.includes(r.disposition)) {
448
+ // SEC-31/SPEC-33: a flip that would orphan a check id or a filed tracking issue must name it and say why it is now delivered
449
+ const named = [r.check, r.tracked ? String(r.tracked).replace(/\D/g, "") : null].filter((k) => !blank(k));
450
+ const said = `${a.why} ${a.text}`;
451
+ const missing = named.filter((k) => !new RegExp(`(^|[^\\w-])#?${String(k).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`).test(said));
452
+ if (missing.length) { problems.push(`amendment flips ${r.disposition} ${r.id} to deliver but does not name its ${missing.join(", ")} (check id or tracking issue) and why it is now delivered in the diff`); continue; }
453
+ // #4984: deliver text on a deferred requirement puts it in scope: it is delivered, not tracked in a follow-up; amended_from keeps the original for the reviewer
454
+ r.amended_from ??= Object.fromEntries(["quote", "disposition", "why", "bucket", "check", "tracked", "class"].filter((k) => r[k] != null).map((k) => [k, r[k]]));
455
+ r.disposition = "deliver"; for (const k of ["bucket", "why", "check", "tracked", "class"]) delete r[k];
456
+ }
427
457
  r.amendment = { text: String(a.text).trim(), why: String(a.why).trim() }; r.amended = true;
428
458
  } else if (TRACKED.includes(a.kind) && a.requirement) {
429
459
  const r = c.requirements.find((x) => x.id === a.requirement);
@@ -437,7 +467,10 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
437
467
  } else if (a.kind === "decision") {
438
468
  const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
439
469
  if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
440
- const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, baseFiles) : [];
470
+ // a file the lane adds and the contract lists as a deliverable is a new file the issue declared: a valid anchor
471
+ const listed = new Set(c.deliverables.map((x) => x.path));
472
+ const added = (Array.isArray(addedPaths) ? addedPaths : []).filter((x) => listed.has(x));
473
+ const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, [...baseFiles, ...added]) : [];
441
474
  if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
442
475
  if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
443
476
  decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
@@ -452,6 +485,74 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
452
485
  const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
453
486
  const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
454
487
  const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
488
+ /** #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. */
489
+ export function shellSyntaxError(command) {
490
+ const r = spawnSync("bash", ["-n", "-c", String(command ?? "")], { encoding: "utf8", timeout: 5000 });
491
+ if (r.error || r.status === 0 || r.status == null) return null;
492
+ return String(r.stderr || "syntax error").trim().split("\n")[0].replace(/^bash: (?:line \d+: )?/, "").slice(0, 160);
493
+ }
494
+ 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)?['"]?)$/;
495
+ const KEYWORD_RE = /^(if|then|else|elif|do|while|until)(?:\s+|$)/;
496
+ 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+\])$/;
497
+ /**
498
+ * #4979 SPEC-2: a command every segment of which is a no-op (true, :, exit N, echo, printf, sleep, a constant test,
499
+ * shell keywords, `node -e 0`, `python -c pass`) checks nothing. A best-effort denylist: the engine's rerun of the
500
+ * frozen command (`substituteVerdict`) is the real gate.
501
+ */
502
+ export function noopCommand(command) {
503
+ // SEC-22: a leading shell keyword is stripped and the rest judged, so `if [ -f x ]; then pytest; fi` is a real check
504
+ const segs = String(command ?? "").split(/&&|\|\||;|\||\n/).map((s) => s.trim()).filter(Boolean);
505
+ const live = [];
506
+ let dead = false; // #4995: inside the body of an if/elif/while whose condition is a constant false
507
+ for (const raw of segs) {
508
+ let s = raw, cond = false, m;
509
+ while ((m = KEYWORD_RE.exec(s))) {
510
+ s = s.slice(m[0].length);
511
+ if (m[1] === "else" || m[1] === "elif") dead = false;
512
+ cond = m[1] === "if" || m[1] === "elif" || m[1] === "while";
513
+ }
514
+ if (s === "fi" || s === "done") dead = false;
515
+ if (cond) { if (CONST_FALSE_RE.test(s)) dead = true; else if (!dead && s) live.push(s); continue; }
516
+ if (!dead && s) live.push(s);
517
+ }
518
+ return segs.length > 0 && live.every((s) => NOOP_SEGMENT_RE.test(s));
519
+ }
520
+ /**
521
+ * #4979 SPEC-2: a substitute-validate stands only when the frozen command it replaced cannot run (died before any test,
522
+ * a runner usage error, a missing host tool, exit 127) or passes anyway. A frozen command that runs and fails is a real
523
+ * failure the substitute may not hide. `run` is the engine's rerun of the frozen command; null when none was made.
524
+ */
525
+ export function substituteVerdict(run, { repoHas = () => false, command = "" } = {}) {
526
+ if (!run) return "unverified";
527
+ if (run.rc === 0 || run.rc === 127 || unrunnableValidate(run) || cliUsageError(run) || missingTool(run, { repoHas, command })) return "ok";
528
+ return run.rc === 124 ? "timeout" : "runs";
529
+ }
530
+ /**
531
+ * #4990 SEC-13: the paths of `removed` (deleted, or the old side of a rename, in the diff) that a frozen command names:
532
+ * a whitespace token equal to the path, or a directory token above it. A builder that removes the target of its frozen
533
+ * validate makes the engine's rerun read "unrunnable", so the substitute is judged on the diff instead.
534
+ */
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}/`)));
538
+ }
539
+ /** #4990: paths the lane's diff deletes or renames away from (the old side), per `git diff --name-status -M`. */
540
+ 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]);
543
+ }
544
+ 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
+ /** #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. */
546
+ export function unrunnableValidate({ rc, output } = {}) {
547
+ return rc !== 0 && rc !== 124 && UNRUNNABLE_RE.test(String(output ?? ""));
548
+ }
549
+ const BLOCK_VALIDATE_RE = /\b(?:V\d+|validate|repro)\b/i;
550
+ const BLOCK_UNRUNNABLE_RE = /unrunnable|cannot (?:be )?run|could not (?:be )?run|can't run|not runnable|command not found|No module named|no tests (?:ran|were collected)|collected 0 items|syntax error|pyproject|(?:tool|module)s? (?:is|are) missing|fails? before any test/i;
551
+ /** #4857: a blocked result's text that says a validate/repro command could not run: an environment defect, never a product decision. */
552
+ export function describesUnrunnableCommand(text) {
553
+ const t = String(text ?? "");
554
+ return BLOCK_VALIDATE_RE.test(t) && BLOCK_UNRUNNABLE_RE.test(t);
555
+ }
455
556
  // #4761: a runner rejecting its own flags is a defect of the frozen command, not of the change
456
557
  const CLI_USAGE_RE = /\bUnknown option\b|Unrecognized CLI Parameter|error: unrecognized arguments|unknown (?:shorthand )?flag\b|bad option:/i;
457
558
  const VITEST_QUIET_RE = /\bvitest\b[^&|;]*\s(?:-q|--quiet)(?=\s|$)/;
@@ -513,7 +614,7 @@ export function repoTracks(paths) {
513
614
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
514
615
  * tool), for the trajectory; always an array.
515
616
  */
516
- 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 }) {
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 = [] }) {
517
618
  const failures = [], superseded = [], ciCovered = [];
518
619
  // #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)
519
620
  if (checkIds) for (const r of contract.requirements ?? []) {
@@ -527,6 +628,16 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
527
628
  if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
528
629
  const runs = Array.isArray(result?.validation) ? result.validation : [];
529
630
  for (const v of contract.validate ?? []) {
631
+ // #4979 SPEC-2: a substitute counts only when the frozen command it replaced cannot run (or passes); checked first, never CI-covered
632
+ if (v.substituted) {
633
+ const gone = removedNamedPaths(v.substituted.from, removedPaths);
634
+ 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
+ const verdict = substituteVerdict(substitutedRuns?.[v.id], { repoHas, command: v.substituted.from });
636
+ const from = `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\``;
637
+ if (verdict === "unverified") { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_unverified", detail: `${from}, but the engine has no rerun of the frozen command to show it cannot run` }); continue; }
638
+ if (verdict === "runs") { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `${from}, but the frozen command runs and fails (exit ${substitutedRuns[v.id].rc}): a substitute-validate replaces only a command that cannot run, never a failing check` }); continue; }
639
+ if (verdict === "timeout" && !ciVerified) { failures.push({ code: ENV_VALIDATE_TIMEOUT, kind: "validate_timeout", detail: `${from}, and the frozen command timed out (exit 124) on the engine's rerun, so its substitution cannot be judged: infrastructure (host load)` }); continue; }
640
+ }
530
641
  const r = runs.find((x) => x?.id === v.id);
531
642
  // #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
532
643
  const leftToCi = !r || (r.ci === true && r.exit === undefined);
@@ -536,6 +647,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
536
647
  const rerun = validateRuns?.[v.id];
537
648
  if (rerun?.rc === 0) continue;
538
649
  if (rerun && cliUsageError(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) is a CLI usage error of the runner, a defect of the frozen command rather than of the change` }); continue; }
650
+ if (rerun && unrunnableValidate(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) died before any test ran (no tests selected, no project at its cwd or invalid shell), a defect of the frozen command rather than of the change; substitute a runnable equivalent with a substitute-validate amendment` }); continue; }
539
651
  // #4906: the rerun timed out (even after the longer retry): host load, not a verdict on the change. A CI-verified head covers it; else an environment error.
540
652
  if (rerun?.rc === 124) {
541
653
  if (ciVerified) { ciCovered.push(v.id); continue; }
@@ -962,8 +1074,9 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
962
1074
  /** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
963
1075
  export function followupMap(out, items) {
964
1076
  const echoed = new Map();
965
- for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(k.trim(), Number(n)); }
966
- return Object.fromEntries(items.filter((i) => echoed.has(String(i.key).trim())).map((i) => [i.key, echoed.get(String(i.key).trim())]));
1077
+ const norm = (k) => String(k).replace(/`/g, "").trim(); // #4984: the script strips backticks from the label it echoes
1078
+ for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(norm(k), Number(n)); }
1079
+ return Object.fromEntries(items.filter((i) => echoed.has(norm(i.key))).map((i) => [i.key, echoed.get(norm(i.key))]));
967
1080
  }
968
1081
 
969
1082
  /**
@@ -1,8 +1,9 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { readFileSync } from "node:fs";
3
4
  import { describe, it } from "node:test";
4
5
  import assert from "node:assert/strict";
5
- import { 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, 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
7
 
7
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.";
8
9
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -81,6 +82,15 @@ describe("#4655 multi-requirement amendment references", () => {
81
82
  assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
82
83
  assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
83
84
  });
85
+ it("an unrelated-ci-failure amendment (kind or type) is an informational no-op; other unknown kinds still fail", () => {
86
+ 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" }]) {
87
+ const r = applyAmendments(C, [a]);
88
+ assert.deepEqual(r.problems, []);
89
+ assert.deepEqual(r.contract, C);
90
+ assert.equal(r.applied[0].kind, "unrelated-ci-failure");
91
+ }
92
+ assert.match(applyAmendments(C, [{ kind: "flaky-note", why: "w" }]).problems[0], /unknown kind 'flaky-note'; valid kinds are/);
93
+ });
84
94
  it("supersede-guard guards[] expands to per-guard supersedes", () => {
85
95
  const a = applyAmendments(C, [{ kind: "supersede-guard", guards: ["#4879", "#4838"], why: "x" }]);
86
96
  assert.deepEqual(a.problems, []);
@@ -137,6 +147,83 @@ describe("precheck (deterministic, no reviewer)", () => {
137
147
  assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 1 }] }, changedFiles: files })), ["conformance.unimplemented"]);
138
148
  assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files })), ["conformance.unimplemented"]);
139
149
  });
150
+ it("#4857 validateContract rejects a validate command that fails bash -n", () => {
151
+ const prose = { ...C, validate: [{ id: "V3", command: "node checks.mjs --base origin/staging (then run the scoped tsc/eslint commands it prints)" }] };
152
+ assert.ok(validateContract(prose, ISSUE).problems.some((p) => /V3: the command is not valid shell/.test(p)));
153
+ assert.ok(!validateContract({ ...C, validate: [{ id: "V1", command: "node checks.mjs --base origin/staging" }] }, ISSUE).problems.some((p) => /valid shell/.test(p)));
154
+ assert.equal(shellSyntaxError("echo ok && true"), null);
155
+ assert.ok(shellSyntaxError("echo (oops"));
156
+ });
157
+ it("#4857 a command that died before any test ran is contract.invalid_command; substitute-validate swaps a runnable one", () => {
158
+ assert.ok(unrunnableValidate({ rc: 5, output: "collected 0 items / 1 deselected\nno tests ran" }));
159
+ assert.ok(unrunnableValidate({ rc: 1, output: "Poetry could not find a pyproject.toml file in /repo" }));
160
+ assert.ok(!unrunnableValidate({ rc: 1, output: "AssertionError: 1 !== 2" }));
161
+ assert.ok(!unrunnableValidate({ rc: 0, output: "no tests ran" }));
162
+ const r = applyAmendments({ requirements: [], validate: [{ id: "V2", command: "poetry run pytest -k 404_probe" }] }, [{ kind: "substitute-validate", validate: "V2", command: "poetry -C services/worker run pytest tests/test_probe.py", why: "no pyproject at root" }]);
163
+ assert.deepEqual(r.problems, []);
164
+ assert.equal(r.contract.validate[0].command, "poetry -C services/worker run pytest tests/test_probe.py");
165
+ assert.equal(r.contract.validate[0].substituted.from, "poetry run pytest -k 404_probe");
166
+ const bad = applyAmendments({ requirements: [], validate: [{ id: "V2", command: "x" }] }, [{ kind: "substitute-validate", validate: "V2", command: "echo (oops", why: "w" }]);
167
+ assert.ok(bad.problems.some((p) => /not valid shell/.test(p)));
168
+ assert.ok(applyAmendments({ requirements: [], validate: [] }, [{ kind: "substitute-validate", validate: "V9", command: "true", why: "w" }]).problems.length);
169
+ });
170
+ it("#4995 noopCommand bare keywords and constant-false guards", () => {
171
+ for (const cmd of ["if true\nthen\n :\nfi", "while true\ndo\n :\ndone", "if false; then pytest; fi", "if [ 0 = 1 ]; then pytest; fi", "if test -n \"\"; then node --test a.test.mjs; fi", "while false; do pytest; done", "if false\nthen\n pytest\nfi"]) assert.ok(noopCommand(cmd), JSON.stringify(cmd));
172
+ for (const cmd of ["if [ -f x ]; then pytest; fi", "for f in a b; do node --test $f; done", "if false; then :; else pytest; fi", "if true\nthen\n pytest\nfi", "if false; then :; elif [ -f x ]; then pytest; fi"]) assert.ok(!noopCommand(cmd), JSON.stringify(cmd));
173
+ });
174
+ it("#4979 SPEC-2 a substitute that runs no check is rejected at amendment time", () => {
175
+ for (const cmd of ["true", ":", "exit 0", "echo ok", "true && echo done", "printf x | true"]) {
176
+ assert.ok(noopCommand(cmd), cmd);
177
+ const r = applyAmendments({ requirements: [], validate: [{ id: "V2", command: "poetry run pytest -k probe" }] }, [{ kind: "substitute-validate", validate: "V2", command: cmd, why: "w" }]);
178
+ assert.ok(r.problems.some((p) => /runs no check/.test(p)), cmd);
179
+ }
180
+ for (const cmd of ["node --test a.test.mjs", "echo start && pytest tests/x.py", "test -f out.json", "[ -f a ] && npm test", "bash scripts/check.sh", "if [ -f x ]; then pytest; fi", "if true; then node --test a.test.mjs; fi", "for f in a b; do node --test $f; done"]) assert.ok(!noopCommand(cmd), cmd); // SEC-22: keywords around a real check
181
+ for (const cmd of ["test 1", "[ 1 ]", "sleep 0", "node -e 0", "true\ntrue", "if true; then :; fi", "python -c pass", "exit 1"]) assert.ok(noopCommand(cmd), JSON.stringify(cmd)); // SEC-12
182
+ });
183
+ it("#4979 SPEC-2 a substitute stands only when the frozen command cannot run", () => {
184
+ const sub = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak" }] }, [{ kind: "substitute-validate", validate: "V1", command: "node --test test/leak.test.js", why: "no pyproject at root" }]).contract;
185
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
186
+ const run = (substitutedRuns) => precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns });
187
+ assert.deepEqual(run({ V1: { rc: 1, output: "Poetry could not find a pyproject.toml file in /repo" } }).failures, [], "unrunnable original: substitute stands");
188
+ assert.deepEqual(run({ V1: { rc: 127, output: "poetry: command not found" } }).failures, [], "missing runner");
189
+ assert.deepEqual(run({ V1: { rc: 0, output: "1 passed" } }).failures, [], "original passes anyway");
190
+ const runs = run({ V1: { rc: 1, output: "FAILED tests/test_leak.py::test_release - AssertionError" } });
191
+ assert.deepEqual(runs.failures.map((f) => f.kind), ["validate_failed"], "a failing original may not be hidden");
192
+ assert.match(runs.failures[0].detail, /frozen command runs and fails \(exit 1\)/);
193
+ assert.deepEqual(run(null).failures.map((f) => f.kind), ["substitute_unverified"], "no rerun: fail closed");
194
+ assert.deepEqual(run({ V1: { rc: 124, output: "" } }).failures.map((f) => f.kind), ["validate_timeout"]);
195
+ assert.deepEqual(precheck({ contract: sub, result: res, changedFiles: files, ciVerified: true, substitutedRuns: { V1: { rc: 1, output: "AssertionError" } } }).failures.map((f) => f.kind), ["validate_failed"], "CI never covers a hidden failure");
196
+ assert.equal(substituteVerdict(undefined), "unverified");
197
+ // SEC-11: a second substitute of the same id never replaces the frozen command the engine reruns
198
+ const twice = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak" }] }, [
199
+ { kind: "substitute-validate", validate: "V1", command: "nonexistent-xyz", why: "a" },
200
+ { kind: "substitute-validate", validate: "V1", command: "node --test test/leak.test.js", why: "b" },
201
+ ]).contract;
202
+ assert.equal(twice.validate[0].substituted.from, "poetry run pytest -k leak");
203
+ assert.equal(twice.validate[0].command, "node --test test/leak.test.js");
204
+ // SEC-21: a planner-set substituted field on the frozen contract is dropped; only an amendment sets it
205
+ const planted = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak", substituted: { from: "true", why: "x" } }] }, []).contract;
206
+ assert.equal(planted.validate[0].substituted, undefined);
207
+ const plantedSub = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak", substituted: { from: "true", why: "x" } }] }, [{ kind: "substitute-validate", validate: "V1", command: "node --test test/leak.test.js", why: "b" }]).contract;
208
+ assert.equal(plantedSub.validate[0].substituted.from, "poetry run pytest -k leak");
209
+ });
210
+ it("#4990 a substitute whose frozen command names a removed path is rejected", async () => {
211
+ 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;
212
+ const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
213
+ const gone = precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "no tests found" } }, removedPaths: ["test/leak.test.js"] });
214
+ assert.deepEqual(gone.failures.map((f) => f.kind), ["substitute_removed_path"]);
215
+ assert.equal(gone.failures[0].code, "conformance.unimplemented");
216
+ assert.match(gone.failures[0].detail, /test\/leak\.test\.js/);
217
+ const env = precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "Unknown option `-q`" } }, removedPaths: ["docs/other.md"] });
218
+ 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"]);
220
+ 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"]);
222
+ });
223
+ it("#4857 describesUnrunnableCommand reads a blocked summary", () => {
224
+ assert.ok(describesUnrunnableCommand("V2 is unrunnable: poetry found no pyproject at the repo root"));
225
+ assert.ok(!describesUnrunnableCommand("needs a pricing decision from the product owner"));
226
+ });
140
227
  it("#4761 vitest -q is a contract problem; a runner usage error is contract.invalid_command, not conformance", () => {
141
228
  const bad = { ...C, validate: [{ id: "V1", command: "pnpm --filter x exec vitest run a.test.ts -q" }] };
142
229
  assert.ok(validateContract(bad, ISSUE).problems.some((p) => /V1: vitest 3 rejects -q/.test(p)));
@@ -721,6 +808,36 @@ describe("#4594 amend-requirement", () => {
721
808
  const r = a.contract.requirements.find((x) => x.id === "R1");
722
809
  assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text); assert.equal(r.amendment.why, am.why); assert.equal(r.quote, C.requirements[0].quote);
723
810
  });
811
+ it("amend-requirement with text on a defer requirement flips it to deliver and files no follow-up (#4984)", () => {
812
+ const D = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "later", bucket: "new-issue", tracked: "#5" }] };
813
+ const a = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id, why: "token stored hash-only; #5 is now delivered in the diff" }]);
814
+ assert.deepEqual(a.problems, []); assert.deepEqual(a.toTrack, []);
815
+ const r = a.contract.requirements[1];
816
+ assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text);
817
+ assert.ok(!("bucket" in r) && !("why" in r) && !("tracked" in r));
818
+ });
819
+ it("a trivial rewording of a deferred requirement keeps amended_from for the reviewer (#4996)", () => {
820
+ const D = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "later", bucket: "new-issue" }] };
821
+ const a = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id, text: "done", why: "trivial" }]);
822
+ assert.deepEqual(a.problems, []);
823
+ const r = a.contract.requirements[1];
824
+ assert.equal(r.disposition, "deliver"); assert.equal(r.amended_from.disposition, "defer"); assert.equal(r.amended_from.quote, C.requirements[1].quote); assert.equal(r.amended_from.why, "later");
825
+ });
826
+ it("post-deploy flip with a check id is rejected without naming it, accepted when named (#4996)", () => {
827
+ const chk = "pd-check-1";
828
+ const D = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "post-deploy", why: "after deploy", bucket: "post-deploy", check: chk }] };
829
+ const bad = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id }]);
830
+ assert.match(bad.problems[0], /does not name its pd-check-1/);
831
+ assert.equal(D.requirements[1].disposition, "post-deploy"); assert.equal(bad.contract.requirements[1].disposition, "post-deploy");
832
+ const ok = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id, why: `${chk} now runs in the diff` }]);
833
+ assert.deepEqual(ok.problems, []); assert.equal(ok.contract.requirements[1].amended_from.check, chk);
834
+ });
835
+ it("the reviewer prompt tells it to compare amended_from with the amendment (#4996)", () => {
836
+ assert.match(readFileSync(new URL("./prompts.mjs", import.meta.url), "utf8"), /amended_from\.quote and amended_from\.why with amendment\.text/);
837
+ });
838
+ it("followupMap matches an item key whose backticks the script stripped (#4984)", () => {
839
+ assert.deepEqual(followupMap("R2: wire pd.py in\t20\n", [{ key: "R2: wire `pd.py` in" }]), { "R2: wire `pd.py` in": 20 });
840
+ });
724
841
  it("an unknown requirement is a problem; the frozen contract is untouched", () => {
725
842
  assert.match(applyAmendments(C, [{ ...am, requirement: "R9" }]).problems[0], /unknown requirement R9/);
726
843
  assert.equal(C.requirements[0].amendment, undefined);
@@ -856,6 +973,19 @@ describe("decision amendments (#4695)", () => {
856
973
  assert.match(a.problems[0], /match no file on the base/);
857
974
  assert.equal(a.decisions.length, 0);
858
975
  });
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, []);
980
+ assert.match(go(["scripts/other.py"]).problems[0], /match no file on the base/);
981
+ });
982
+ it("a blank-why amendment takes the engine's defaultWhy; without one it is still rejected", () => {
983
+ const am = [{ kind: "add-deliverable", path: "x.js" }];
984
+ const ok = applyAmendments(C, am, null, { defaultWhy: "engine-demanded rename" });
985
+ assert.deepEqual(ok.problems, []);
986
+ assert.equal(ok.applied[0].why, "engine-demanded rename");
987
+ assert.match(applyAmendments(C, am).problems[0], /amendment without why/);
988
+ });
859
989
  it("supersedes drops the engine-authored preserve row; without it the row stays", () => {
860
990
  assert.ok(!amend({ supersedes: "d9-1" }).contract.preserve.some((p) => p.id === "H1"));
861
991
  assert.ok(amend({}).contract.preserve.some((p) => p.id === "H1"));
@@ -978,7 +1108,7 @@ describe("mutation check (#4913)", () => {
978
1108
  });
979
1109
  it("#4931: a real repo whose change deleted a non-test file is fully restored", async () => {
980
1110
  const { execFileSync } = await import("node:child_process");
981
- const { writeFileSync, existsSync, readFileSync } = await import("node:fs");
1111
+ const { writeFileSync, existsSync } = await import("node:fs");
982
1112
  const { join } = await import("node:path");
983
1113
  const { tmpDir } = await import("../tests/_tmp.mjs");
984
1114
  const d = tmpDir("mut-");
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { MECHANICAL_KINDS, FIX_PROMPT_MAX, citesFileLine, freshFix, issueSummary, capText, TRIGGERS, REWORK_TRIGGERS, firstPassOf, formatFirstPassSummary, sessionCostDelta } from "./fixround.mjs";
6
- import { fixSessionPrompt } from "./prompts.mjs";
6
+ import { fixSessionPrompt, ciFailurePrompt } from "./prompts.mjs";
7
7
 
8
8
  describe("#4363 fresh fix sessions: which rounds, and the prompt caps", () => {
9
9
  it("every mechanical kind is fresh; block/contract/conflict kinds are not", () => {
@@ -115,3 +115,11 @@ describe("#4407 sessionCostDelta: per-attempt cost from a resumed session's runn
115
115
  assert.equal(sessionCostDelta(last, { resume: "b", sessionId: "b", cost: 2 }).delta, 0.5);
116
116
  });
117
117
  });
118
+
119
+ describe("#4987 CI fix prompt: unrelated failures", () => {
120
+ it("tells the builder an out-of-diff failing check is a note, not an amendment", () => {
121
+ const p = ciFailurePrompt([{ job: "core-tests" }], "ci");
122
+ assert.match(p, /not an amendment/);
123
+ assert.match(p, /unknown amendment kinds are rejected/);
124
+ });
125
+ });
@@ -4,10 +4,11 @@
4
4
  * Guard gate (#4693): execution-based, no pattern matching on diff text. A lane whose diff changes lines a merged fix
5
5
  * protected (git blame on the lane's merge base names the guard's merge commit, or a later commit that kept the lines), or
6
6
  * edits/deletes a guard's test file, reruns that guard's test files *as they exist on base* against the lane's tree. A
7
- * failure is `conformance.guard_broken` (builder resumed once). The only override is a `supersedes` amendment (conformance.mjs).
7
+ * failure is `conformance.guard_broken` (builder resumed once) unless the same base test also fails on the merge-base tree (#4968). The only override is a `supersedes` amendment (conformance.mjs).
8
8
  * Deterministic, 0 LLM calls; a lane touching nothing guarded does one local index read and no git work.
9
9
  */
10
10
  import * as nodeFs from "node:fs";
11
+ import { tmpdir } from "node:os";
11
12
  import { dirname, extname, join } from "node:path";
12
13
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
13
14
  import { collectGuards, guardsIndexPath, readIndex, writeIndex } from "./guards.mjs";
@@ -94,9 +95,15 @@ export async function selectGuards({ guards, files, git, base }) {
94
95
  return { selected, mergeBase };
95
96
  }
96
97
 
97
- /** The command that runs one test file: the contract's own validate command with that path substituted, else a per-extension default; null when unknown. */
98
- export function commandFor(path, contract) {
98
+ /**
99
+ * The command that runs one test file: the repo's `verify.guard_command` template for the extension (`{path}` substituted,
100
+ * or the path appended), else the contract's own validate command with that path substituted, else a per-extension
101
+ * default; null when unknown. The command runs from the worktree root with the repo-relative path.
102
+ */
103
+ export function commandFor(path, contract, guardCommand = {}) {
99
104
  const ext = extname(path);
105
+ const tpl = String(guardCommand?.[ext.toLowerCase()] ?? "").trim();
106
+ if (tpl) return tpl.includes("{path}") ? tpl.replaceAll("{path}", path) : `${tpl} ${path}`;
100
107
  for (const v of contract?.validate ?? []) {
101
108
  const cmd = String(v?.command ?? "");
102
109
  if (/(?:^|\s)(?:-k|-t|--grep|--test-name-pattern|--testNamePattern)(?:\s|=)|::/.test(cmd)) continue;
@@ -112,22 +119,61 @@ export function commandFor(path, contract) {
112
119
  /** Sibling path for the base copy of a test file, so its relative imports resolve against the lane's tree. */
113
120
  export const basePathOf = (path) => { const e = extname(path); return `${path.slice(0, path.length - e.length)}__guardbase${e}`; };
114
121
 
122
+ /** Names of the failing tests in a runner's output: TAP/node:test `not ok` lines (SKIP/TODO excluded), the spec reporter's `✖ name (Nms)`, pytest `FAILED path::name`. null when the output names none. */
123
+ export function failingNames(output) {
124
+ const names = new Set();
125
+ for (const raw of String(output ?? "").split("\n")) {
126
+ const line = raw.replace(/\r$/, "");
127
+ let m = /^\s*not ok \d+ - (.+?)\s*$/.exec(line);
128
+ if (m && !/\s#\s*(?:SKIP|TODO)\b/i.test(m[1])) { names.add(m[1].replace(/\s+#\s.*$/, "").trim()); continue; }
129
+ m = /^\s*✖ (.+?) \([\d.]+m?s\)\s*$/.exec(line) ?? /^\s*FAILED (\S+?)(?: - .*)?\s*$/.exec(line);
130
+ if (m) names.add(m[1]);
131
+ }
132
+ return names.size ? [...names] : null;
133
+ }
134
+
115
135
  const tailOf = (out) => String(out ?? "").split("\n").filter((l) => l.trim()).slice(-40).join("\n").slice(-1500);
116
136
 
117
137
  /**
118
138
  * Rerun the selected guards' base test files against the lane tree, in a small pool, under one wall-time cap.
119
139
  * Returns {failures, runs, skipped[], ms}. A test that cannot be run (no base copy, unknown runner, over the cap) is skipped, never a failure.
120
140
  */
121
- export async function runGuards({ selected, mergeBase, contract, git, runCommand, cwd, env, capMs = GUARD_CAP_MS, parallelism = 2, fs = nodeFs, now = Date.now }) {
141
+ export async function runGuards({ selected, mergeBase, contract, guardCommand, git, runCommand, cwd, env, capMs = GUARD_CAP_MS, parallelism = 2, fs = nodeFs, now = Date.now }) {
122
142
  const t0 = now(), skipped = [], runs = [], jobs = [];
123
143
  for (const { guard } of selected) {
124
144
  for (const t of guard.tests ?? []) {
125
- const path = norm(t.path), copy = basePathOf(path), command = commandFor(copy, contract);
145
+ const path = norm(t.path), copy = basePathOf(path), command = commandFor(copy, contract, guardCommand);
126
146
  if (!command) { skipped.push(`#${guard.issue} ${path}: no runner known for ${extname(path) || "this file"}`); continue; }
127
147
  jobs.push({ guard, path, copy, command });
128
148
  }
129
149
  }
130
150
  const made = [];
151
+ let base = null; // #4968: one detached worktree of the merge base, made on the first lane failure and shared by the pool
152
+ const baseTree = () => (base ??= (async () => {
153
+ let dir = null;
154
+ try {
155
+ dir = fs.mkdtempSync(join(tmpdir(), "guardbase-"));
156
+ await git(["worktree", "add", "--detach", dir, mergeBase]);
157
+ try { if (fs.existsSync(join(cwd, "node_modules"))) fs.symlinkSync(join(cwd, "node_modules"), join(dir, "node_modules")); } catch { /* best effort */ }
158
+ return { dir };
159
+ } catch (e) { return { dir, error: String(e?.message ?? e).split("\n")[0].slice(0, 80) }; }
160
+ })());
161
+ /** Decide a failed lane run against the same test on the base tree: the names the lane newly fails, [] when base fails it too, null when base passes (every failure is the lane's). */
162
+ const newFailures = async (j, laneOut) => {
163
+ const left = capMs - (now() - t0);
164
+ if (left <= 0) return { skip: `over the ${Math.round(capMs / 1000)}s guard cap` };
165
+ const t = await baseTree();
166
+ if (t.error) return { skip: `baseline unavailable (${t.error})` };
167
+ const bcmd = commandFor(j.path, contract, guardCommand); // the same runner as the lane run (verify.guard_command, #4792)
168
+ let b;
169
+ try { b = await runCommand({ command: bcmd, cwd: t.dir, timeoutMs: left, env }); } catch (e) { b = { rc: 127, output: String(e?.message ?? e) }; }
170
+ if (b?.rc === TIMEOUT_RC || b?.rc === 127) return { skip: `baseline unavailable (${b.rc === 127 ? "runner unavailable" : "over the guard cap"})` };
171
+ if (b?.rc === 0) return { fresh: null };
172
+ const lane = failingNames(laneOut), prior = failingNames(b?.output);
173
+ if (!lane || !prior) return { skip: "fails on base" };
174
+ const fresh = lane.filter((n) => !prior.includes(n));
175
+ return fresh.length ? { fresh } : { skip: "fails on base" };
176
+ };
131
177
  try {
132
178
  const queue = jobs.slice();
133
179
  const work = async () => {
@@ -142,12 +188,25 @@ export async function runGuards({ selected, mergeBase, contract, git, runCommand
142
188
  try { r = await runCommand({ command: j.command, cwd, timeoutMs: left, env }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
143
189
  if (r?.rc === TIMEOUT_RC) { skipped.push(`#${j.guard.issue} ${j.path}: over the ${Math.round(capMs / 1000)}s guard cap`); continue; }
144
190
  if (r?.rc === 127) { skipped.push(`#${j.guard.issue} ${j.path}: runner unavailable`); continue; }
145
- runs.push({ issue: j.guard.issue, why: j.guard.why, test: j.path, command: j.command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
191
+ const rc = Number.isInteger(r?.rc) ? r.rc : 1, output = String(r?.output ?? "");
192
+ let failed = rc === 0 ? null : failingNames(output);
193
+ if (rc !== 0) {
194
+ const v = await newFailures(j, output);
195
+ if (v.skip) { skipped.push(`guard #${j.guard.issue} ${j.path}: ${v.skip}`); continue; }
196
+ if (v.fresh) failed = v.fresh;
197
+ }
198
+ runs.push({ issue: j.guard.issue, why: j.guard.why, test: j.path, command: j.command, rc, output, failed });
146
199
  }
147
200
  };
148
201
  await Promise.all(Array.from({ length: Math.max(1, Math.min(parallelism, jobs.length)) }, work));
149
202
  } finally {
150
203
  for (const p of made) { try { fs.rmSync(p, { force: true }); } catch { /* best effort */ } }
204
+ const t = base ? await base : null;
205
+ if (t?.dir) {
206
+ try { await git(["worktree", "remove", "--force", t.dir]); } catch { /* best effort */ }
207
+ try { fs.rmSync(t.dir, { recursive: true, force: true }); } catch { /* best effort */ }
208
+ try { await git(["worktree", "prune"]); } catch { /* best effort */ }
209
+ }
151
210
  }
152
211
  return { failures: guardFailures(runs), runs, skipped, ms: now() - t0 };
153
212
  }
@@ -159,7 +218,7 @@ const supersedesHint = (guard) => `If this change intentionally changes the guar
159
218
  export function guardFailures(runs) {
160
219
  return (runs ?? []).filter((r) => r.rc !== 0).map((r) => ({
161
220
  code: OUTCOME_CODES.CONFORMANCE_GUARD_BROKEN, kind: "guard_broken", guard: `#${r.issue}`, test: r.test,
162
- detail: `${supersedesHint(`#${r.issue}`)} guard #${r.issue} broken (${String(r.why ?? "").slice(0, 200)}): base test ${r.test} fails against your tree (\`${r.command}\`, exit ${r.rc}):\n${tailOf(r.output)}`,
221
+ detail: `${supersedesHint(`#${r.issue}`)} guard #${r.issue} broken (${String(r.why ?? "").slice(0, 200)}): base test ${r.test} fails against your tree (\`${r.command}\`, exit ${r.rc})${r.failed?.length ? `; failing tests: ${r.failed.slice(0, 20).join(" | ")}` : ""}:\n${tailOf(r.output)}`,
163
222
  }));
164
223
  }
165
224