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.
- package/bin/engine/outcome-codes.mjs +2 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +50 -11
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.test.mjs +46 -0
- package/bin/lean/conformance.mjs +107 -16
- package/bin/lean/conformance.test.mjs +126 -13
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +49 -16
- package/bin/lean/lean.test.mjs +66 -0
- package/bin/lean/orchestrate-blocked.test.mjs +35 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +61 -13
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/prompts.mjs +1 -1
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/selftest-live.mjs +56 -31
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +7 -3
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +16 -3
- package/bin/lean/signoff.test.mjs +52 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/cli.mjs +29 -1
- package/bin/pool/router.mjs +17 -6
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +10 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +2 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
package/bin/lean/conformance.mjs
CHANGED
|
@@ -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" :
|
|
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 ?? ""))) {
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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),
|
|
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
|
-
|
|
547
|
-
|
|
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
|
|
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("
|
|
82
|
-
for (const kind of ["scope-
|
|
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;
|
|
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-
|
|
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'
|
|
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\
|
|
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
|
|
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.
|
|
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
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1011
|
-
assert.deepEqual(
|
|
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
|
-
*
|
|
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
|
+
});
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -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
|
-
|
|
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) => (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
57
|
-
assert.deepEqual(lines, ["remediation queue (/work-on:remediate): #1 (parked:conformance.
|
|
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
|
});
|