forgedock 1.43.1 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +2 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +44 -8
- 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 +55 -14
- package/bin/lean/conformance.test.mjs +69 -10
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -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 +22 -6
- package/bin/lean/lean.test.mjs +31 -0
- package/bin/lean/orchestrate-blocked.test.mjs +12 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +58 -11
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- 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/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- 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 +5 -3
- package/bin/lean/signoff.test.mjs +21 -0
- package/bin/pool/cli.mjs +29 -1
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +9 -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; }
|
|
@@ -430,6 +437,14 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
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; }
|
|
@@ -472,11 +487,15 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
472
487
|
} else if (a.kind === "decision") {
|
|
473
488
|
const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
|
|
474
489
|
if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
|
|
475
|
-
//
|
|
490
|
+
// #5036: any file the lane adds is a valid anchor; one the contract does not list becomes a deliverable
|
|
476
491
|
const listed = new Set(c.deliverables.map((x) => x.path));
|
|
477
|
-
const added =
|
|
492
|
+
const added = Array.isArray(addedPaths) ? addedPaths : [];
|
|
478
493
|
const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, [...baseFiles, ...added]) : [];
|
|
479
494
|
if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
|
|
495
|
+
for (const f of added) {
|
|
496
|
+
if (listed.has(f) || (Array.isArray(baseFiles) && baseFiles.includes(f)) || !d.anchors.some((x) => anchorMatches(x, f))) continue;
|
|
497
|
+
c.deliverables.push({ id: `A${c.deliverables.length + 1}`, path: f, change: `new file anchoring decision: ${d.choice.slice(0, 80)}`, amended: true }); listed.add(f);
|
|
498
|
+
}
|
|
480
499
|
if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
|
|
481
500
|
decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
|
|
482
501
|
applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
|
|
@@ -537,14 +556,35 @@ export function substituteVerdict(run, { repoHas = () => false, command = "" } =
|
|
|
537
556
|
* a whitespace token equal to the path, or a directory token above it. A builder that removes the target of its frozen
|
|
538
557
|
* validate makes the engine's rerun read "unrunnable", so the substitute is judged on the diff instead.
|
|
539
558
|
*/
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
559
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.[a-z0-9]+$|^test_[^/]+\.[a-z0-9]+$|_test\.[a-z0-9]+$|Tests?\.(?:java|kt|kts|cs|php|scala|swift)$|_spec\.rb$|^tests\.rs$|^conftest\.py$|\.bats$/;
|
|
560
|
+
/** #5003: a runnable test FILE (by basename), unlike `isTestPath`, which also matches fixtures under tests/. */
|
|
561
|
+
const isTestFile = (p) => TEST_FILE_RE.test(String(p).split("/").pop());
|
|
562
|
+
/**
|
|
563
|
+
* #4990 SEC-13: the paths of `removed` (deleted, or the old side of a rename, in the diff) that a frozen command names.
|
|
564
|
+
* #5003: a token is stripped of quotes, a `--flag=` prefix and a pytest `::node` suffix; a glob is expanded against
|
|
565
|
+
* `baseFiles`; an exact path matches itself; a directory or bare-word token matches only a removed test file under it.
|
|
566
|
+
*/
|
|
567
|
+
export function removedNamedPaths(command, removed = [], baseFiles = []) {
|
|
568
|
+
const toks = String(command ?? "").split(/\s+/).map((t) => t.replace(/^['"`(]+|['"`);]+$/g, "").replace(/^-[^=\/]*=/, "").replace(/::.*$/, "").replace(/^['"`]+|['"`]+$/g, "").replace(/^\.\//, "").replace(/\/+$/, "")).filter((t) => t && t !== ".");
|
|
569
|
+
const files = new Set(baseFiles);
|
|
570
|
+
return removed.filter((p) => toks.some((t) => {
|
|
571
|
+
if (/[*?]/.test(t)) return globRe(t).test(p) && (files.size === 0 || files.has(p));
|
|
572
|
+
return p === t || (p.startsWith(`${t}/`) && isTestFile(p));
|
|
573
|
+
}));
|
|
543
574
|
}
|
|
544
|
-
/** #4990: paths the lane's diff deletes or renames away from (the old side),
|
|
575
|
+
/** #4990: paths the lane's diff deletes or renames away from (the old side). #5003: `-z` (unquoted), null on a git error (fail closed). */
|
|
545
576
|
export async function removedPathsOf(git, base) {
|
|
546
|
-
|
|
547
|
-
|
|
577
|
+
let out;
|
|
578
|
+
try { out = String(await git(["diff", "--name-status", "-z", "-M", `${base}...HEAD`])); } catch { return null; }
|
|
579
|
+
const f = out.split("\0"), gone = [];
|
|
580
|
+
for (let i = 0; i < f.length; i++) {
|
|
581
|
+
const st = f[i];
|
|
582
|
+
if (!/^[A-Z]/.test(st)) continue;
|
|
583
|
+
const two = /^[RC]/.test(st);
|
|
584
|
+
if (/^[DR]/.test(st) && f[i + 1]) gone.push(f[i + 1]);
|
|
585
|
+
i += two ? 2 : 1;
|
|
586
|
+
}
|
|
587
|
+
return gone;
|
|
548
588
|
}
|
|
549
589
|
const UNRUNNABLE_RE = /no tests ran|collected 0 items|no tests found|could not find (?:the )?pyproject|poetry could not find|syntax error near|unexpected EOF while looking/i;
|
|
550
590
|
/** #4857: a failed rerun that died before any test ran (no tests selected, no project at the cwd, invalid shell): a defect of the frozen command. */
|
|
@@ -632,7 +672,7 @@ export function repoTracks(paths) {
|
|
|
632
672
|
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
633
673
|
* tool), for the trajectory; always an array.
|
|
634
674
|
*/
|
|
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 = [] }) {
|
|
675
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [], baseFiles = [] }) {
|
|
636
676
|
const failures = [], superseded = [], ciCovered = [];
|
|
637
677
|
// #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
|
|
638
678
|
if (checkIds) for (const r of contract.requirements ?? []) {
|
|
@@ -648,7 +688,8 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
648
688
|
for (const v of contract.validate ?? []) {
|
|
649
689
|
// #4979 SPEC-2: a substitute counts only when the frozen command it replaced cannot run (or passes); checked first, never CI-covered
|
|
650
690
|
if (v.substituted) {
|
|
651
|
-
|
|
691
|
+
if (removedPaths === null) { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_unverified", detail: `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\`, but the engine could not read the diff's removed paths to show the frozen command's target survives` }); continue; }
|
|
692
|
+
const gone = removedNamedPaths(v.substituted.from, removedPaths, baseFiles);
|
|
652
693
|
if (gone.length) { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_removed_path", detail: `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\`, but the diff removes ${gone.map((x) => `\`${x}\``).join(", ")} that the frozen command names: a substitute may not replace a check by deleting its target` }); continue; }
|
|
653
694
|
const verdict = substituteVerdict(substitutedRuns?.[v.id], { repoHas, command: v.substituted.from });
|
|
654
695
|
const from = `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\``;
|
|
@@ -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"));
|
|
@@ -1005,10 +1021,12 @@ describe("decision amendments (#4695)", () => {
|
|
|
1005
1021
|
assert.match(a.problems[0], /match no file on the base/);
|
|
1006
1022
|
assert.equal(a.decisions.length, 0);
|
|
1007
1023
|
});
|
|
1008
|
-
it("an anchor on a lane-added file the
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1011
|
-
assert.deepEqual(
|
|
1024
|
+
it("an anchor on a lane-added file conforms and the unlisted file becomes a deliverable; an anchor matching neither base nor an added file is rejected", () => {
|
|
1025
|
+
const go = (added, anchor = "scripts/seed.py") => applyAmendments(withRow, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: [anchor] } }], null, { baseFiles: ["src/scrape.js"], addedPaths: added });
|
|
1026
|
+
const ok = go(["scripts/seed.py"]);
|
|
1027
|
+
assert.deepEqual(ok.problems, []);
|
|
1028
|
+
assert.ok(ok.contract.deliverables.some((d) => d.path === "scripts/seed.py" && d.amended));
|
|
1029
|
+
assert.equal(ok.contract.deliverables.filter((d) => d.path === "scripts/seed.py").length, 1);
|
|
1012
1030
|
assert.match(go(["scripts/other.py"]).problems[0], /match no file on the base/);
|
|
1013
1031
|
});
|
|
1014
1032
|
it("a blank-why amendment takes the engine's defaultWhy; without one it is still rejected", () => {
|
|
@@ -1154,3 +1172,44 @@ describe("mutation check (#4913)", () => {
|
|
|
1154
1172
|
assert.equal(g("status", "--porcelain").trim(), "");
|
|
1155
1173
|
});
|
|
1156
1174
|
});
|
|
1175
|
+
|
|
1176
|
+
describe("builder amendment slips (#5036)", () => {
|
|
1177
|
+
it("deferral-shaped kinds naming a requirement apply as a tracked defer", () => {
|
|
1178
|
+
for (const kind of ["scope_narrowing", "scope_reduction", "defer-verification", "deferred_measurement", "Scope-Narrowing"]) {
|
|
1179
|
+
const a = applyAmendments(C, [{ kind, requirement: "R1", why: "w" }]);
|
|
1180
|
+
assert.deepEqual(a.problems, [], kind);
|
|
1181
|
+
assert.equal(a.contract.requirements.find((r) => r.id === "R1").disposition, "defer", kind);
|
|
1182
|
+
assert.deepEqual(a.toTrack.map((r) => r.id), ["R1"], kind);
|
|
1183
|
+
}
|
|
1184
|
+
assert.match(applyAmendments(C, [{ kind: "scope_narrowing", why: "w" }]).problems[0], /unknown kind 'scope_narrowing'; valid kinds are/);
|
|
1185
|
+
});
|
|
1186
|
+
it("verification_substitution applies as substitute-validate with the same checks", () => {
|
|
1187
|
+
const base = { requirements: [], validate: [{ id: "V2", command: "poetry run pytest" }] };
|
|
1188
|
+
const ok = applyAmendments(base, [{ kind: "verification_substitution", validate: "V2", command: "node --test t.mjs", why: "no poetry" }]);
|
|
1189
|
+
assert.deepEqual(ok.problems, []);
|
|
1190
|
+
assert.equal(ok.contract.validate[0].command, "node --test t.mjs");
|
|
1191
|
+
assert.equal(ok.contract.validate[0].substituted.from, "poetry run pytest");
|
|
1192
|
+
for (const command of ["echo (oops", "true", "git diff origin/main"]) {
|
|
1193
|
+
assert.equal(applyAmendments(base, [{ kind: "verification_substitution", validate: "V2", command, why: "w" }]).problems.length, 1, command);
|
|
1194
|
+
}
|
|
1195
|
+
assert.match(applyAmendments(base, [{ kind: "verification_substitution", why: "w" }]).problems[0], /needs validate/);
|
|
1196
|
+
});
|
|
1197
|
+
it("out_of_band_verification marks a deliver requirement out_of_band with its evidence", () => {
|
|
1198
|
+
const ok = applyAmendments(C, [{ kind: "out_of_band_verification", requirement: "R1", evidence: ".github/workflows/perf.yml", why: "field metric" }]);
|
|
1199
|
+
assert.deepEqual(ok.problems, []);
|
|
1200
|
+
const r = ok.contract.requirements.find((x) => x.id === "R1");
|
|
1201
|
+
assert.equal(r.verify, "out_of_band"); assert.equal(r.evidence, ".github/workflows/perf.yml");
|
|
1202
|
+
assert.equal(applyAmendments(C, [{ kind: "out_of_band_verification", requirement: "R1", why: "w" }]).problems.length, 1);
|
|
1203
|
+
});
|
|
1204
|
+
it("annotation-only kinds are informational: recorded, no problem, no contract change", () => {
|
|
1205
|
+
for (const kind of ["scope-note", "scope_clarification", "interpretation", "environment", "scope", "validation_unavailable"]) {
|
|
1206
|
+
const a = applyAmendments(C, [{ kind, note: "n" }, { kind, why: "w" }]);
|
|
1207
|
+
assert.deepEqual(a.problems, [], kind);
|
|
1208
|
+
assert.deepEqual(a.contract, C, kind);
|
|
1209
|
+
assert.equal(a.applied.length, 2, kind);
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
it("a truly unknown kind is still rejected with the valid kinds", () => {
|
|
1213
|
+
assert.match(applyAmendments(C, [{ kind: "flaky-note", why: "w" }]).problems[0], /unknown kind 'flaky-note'; valid kinds are .*substitute-validate/);
|
|
1214
|
+
});
|
|
1215
|
+
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* analysis attached, never a guess. Shadow mode (the default) posts `FORGE:DECISION (shadow)` and keeps the label; live mode
|
|
11
11
|
* records the decision, clears needs-human and requeues. A veto (`forge:decision-veto` label, or a reply within
|
|
12
12
|
* `veto_hours`) reverts it; a reversal rate above `reversal_max` forces shadow. Never throws: an error is an `error` row.
|
|
13
|
-
*
|
|
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
|
+
});
|
|
@@ -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
|
});
|
package/bin/lean/lane.mjs
CHANGED
|
@@ -41,11 +41,25 @@ export async function defaultBranches({ repo, io, branches = {} }) {
|
|
|
41
41
|
return [...new Set([branches.default, gh].filter(Boolean))];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** #4963: one batch's shared lookups: the default branches once, `ls-remote` once per lane. Failures are evicted, so a retry re-reads. */
|
|
45
|
+
export function makeLaneCache() { return new Map(); }
|
|
46
|
+
const once = (cache, key, load, keep = () => true) => {
|
|
47
|
+
let p = cache.get(key);
|
|
48
|
+
if (!p) {
|
|
49
|
+
p = Promise.resolve().then(load);
|
|
50
|
+
cache.set(key, p);
|
|
51
|
+
p.then((v) => { if (!keep(v) && cache.get(key) === p) cache.delete(key); }, () => { if (cache.get(key) === p) cache.delete(key); });
|
|
52
|
+
}
|
|
53
|
+
return p;
|
|
54
|
+
};
|
|
55
|
+
|
|
44
56
|
/**
|
|
57
|
+
* `cache` (makeLaneCache) shares the default-branch and ls-remote reads across issues; `milestone` (a title, or null for none)
|
|
58
|
+
* is a pre-read value that skips the per-issue `gh issue view`; undefined reads it.
|
|
45
59
|
* @returns {Promise<{ok:true, lane:string, expected:string, milestone:?string, overridden:boolean, defaults:string[]}
|
|
46
60
|
* | {ok:false, code:string, detail:string}>}
|
|
47
61
|
*/
|
|
48
|
-
export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false }) {
|
|
62
|
+
export async function resolveLane({ issue, repo, io, cwd, branches = {}, requested = null, override = false, cache = null, milestone: known }) {
|
|
49
63
|
const refuse = (code, detail) => ({ ok: false, code, detail });
|
|
50
64
|
const transient = (detail) => ({ ...refuse(OUTCOME_CODES.LANE_UNRESOLVED, detail), transient: true }); // #4791: a gh/git command failed; retry
|
|
51
65
|
const staging = branches.staging || DEFAULT_STAGING_BRANCH;
|
|
@@ -53,9 +67,12 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
53
67
|
if (!pattern.includes("{slug}")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `forge.yaml branches.feature_pattern "${pattern}" has no {slug}`);
|
|
54
68
|
let milestone = null, defaults;
|
|
55
69
|
try {
|
|
56
|
-
|
|
70
|
+
if (known !== undefined) milestone = known || null;
|
|
71
|
+
else milestone = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "milestone"])))?.milestone?.title || null;
|
|
57
72
|
} catch (e) { return transient(`cannot read the milestone of #${issue}: ${String(e?.message ?? e).slice(0, 200)}`); }
|
|
58
|
-
try {
|
|
73
|
+
try {
|
|
74
|
+
defaults = cache ? await once(cache, "default-branches", () => defaultBranches({ repo, io, branches })) : await defaultBranches({ repo, io, branches });
|
|
75
|
+
} catch (e) {
|
|
59
76
|
return transient(`cannot read the default branch of ${repo}: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
60
77
|
}
|
|
61
78
|
let expected = staging;
|
|
@@ -78,7 +95,8 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
78
95
|
}
|
|
79
96
|
if (!LANE_RE.test(lane) || lane.includes("..")) return refuse(OUTCOME_CODES.LANE_UNRESOLVED, `invalid lane ${JSON.stringify(lane)}`);
|
|
80
97
|
let out;
|
|
81
|
-
|
|
98
|
+
const lsRemote = () => io.git(["ls-remote", "--heads", "origin", `refs/heads/${lane}`], { cwd });
|
|
99
|
+
try { out = String(cache ? await once(cache, `lane:${lane}`, async () => String(await lsRemote()), (v) => v.trim() !== "") : await lsRemote()); } catch (e) {
|
|
82
100
|
return transient(`cannot check origin for ${lane}: ${String(e?.message ?? e).slice(0, 200)}`);
|
|
83
101
|
}
|
|
84
102
|
if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
|
package/bin/lean/lane.test.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
|
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
7
|
-
import { milestoneSlug, readBranches, resolveLane } from "./lane.mjs";
|
|
7
|
+
import { milestoneSlug, readBranches, resolveLane, makeLaneCache } from "./lane.mjs";
|
|
8
8
|
|
|
9
9
|
/** Fake gh/git: the issue's milestone, GitHub's default branch, and the branches on origin. */
|
|
10
10
|
function io({ milestone = null, ghDefault = "main", onOrigin = ["staging", "main"], ghFails = null, gitFails = false } = {}) {
|
|
@@ -101,3 +101,35 @@ describe("lane resolution (#4125)", () => {
|
|
|
101
101
|
assert.deepEqual([slug.code, !!slug.transient], ["lane.unresolved", false]);
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
describe("per-batch lane lookups (#4963)", () => {
|
|
106
|
+
it("one default-branch read and one ls-remote per lane across issues; a supplied milestone skips the issue view", async () => {
|
|
107
|
+
const fake = io({ onOrigin: ["staging", "main", "milestone/m1"] }), cache = makeLaneCache();
|
|
108
|
+
const rs = await Promise.all([1, 2, 3, 4].map((issue) => resolveLane({ issue, repo: "o/r", io: fake, cwd: "/r", cache, milestone: issue % 2 ? "M1" : null })));
|
|
109
|
+
assert.deepEqual(rs.map((r) => r.lane), ["milestone/m1", "staging", "milestone/m1", "staging"]);
|
|
110
|
+
const n = (k, ...m) => fake.calls.filter((c) => c[0] === k && m.every((x, i) => c[i + 1] === x)).length;
|
|
111
|
+
assert.equal(n("gh", "repo", "view"), 1);
|
|
112
|
+
assert.equal(n("gh", "issue", "view"), 0);
|
|
113
|
+
assert.equal(fake.calls.filter((c) => c[1] === "ls-remote").length, 2);
|
|
114
|
+
});
|
|
115
|
+
it("without cache or milestone every call reads (unchanged)", async () => {
|
|
116
|
+
const fake = io();
|
|
117
|
+
await resolveLane({ issue: 1, repo: "o/r", io: fake, cwd: "/r" });
|
|
118
|
+
await resolveLane({ issue: 2, repo: "o/r", io: fake, cwd: "/r" });
|
|
119
|
+
assert.equal(fake.calls.filter((c) => c[1] === "issue").length, 2);
|
|
120
|
+
assert.equal(fake.calls.filter((c) => c[1] === "repo").length, 2);
|
|
121
|
+
});
|
|
122
|
+
it("a failed lookup is not cached: the retry re-reads", async () => {
|
|
123
|
+
const cache = makeLaneCache();
|
|
124
|
+
const bad = await resolveLane({ issue: 1, repo: "o/r", io: io({ ghFails: "repo" }), cwd: "/r", cache, milestone: null });
|
|
125
|
+
assert.equal(bad.transient, true);
|
|
126
|
+
const ok = await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null });
|
|
127
|
+
assert.equal(ok.ok, true);
|
|
128
|
+
});
|
|
129
|
+
it("a missing lane branch is not cached either", async () => {
|
|
130
|
+
const cache = makeLaneCache();
|
|
131
|
+
const miss = await resolveLane({ issue: 1, repo: "o/r", io: io({ onOrigin: ["main"] }), cwd: "/r", cache, milestone: null });
|
|
132
|
+
assert.equal(miss.ok, false);
|
|
133
|
+
assert.equal((await resolveLane({ issue: 1, repo: "o/r", io: io(), cwd: "/r", cache, milestone: null })).ok, true);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -82,6 +82,9 @@ export const RECOVER_HOW = Object.freeze({
|
|
|
82
82
|
[OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
|
|
83
83
|
[ENV_VALIDATE_TIMEOUT]: "a validate command timed out (exit 124) on the engine's rerun, even after one longer retry: host load, not a failed check; the change may already be correct. Do not edit the deliverables for it: run the same command narrowed to the changed files (or through the repo's own cached toolchain) with a longer timeout, record that exit under the same validate id and name the command in your summary",
|
|
84
84
|
[OUTCOME_CODES.ENV_CAPABILITY_REMOTE]: "the earlier run ran in a remote sandbox that lacks a host-only capability (SOPS/age key, ops credentials, read-only prod monitor SSH); this host has it. Do the work that needed it now, with the host's own tools and credentials, and never print or commit a secret in the clear",
|
|
85
|
+
[OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED]: "an earlier run's conformance check found a requirement or validate command not done after one builder resume; the branch holds its work. For each named gap deliver the requirement against the frozen contract, adding any file it needs as an `add-deliverable` amendment, and re-run the validate commands",
|
|
86
|
+
[OUTCOME_CODES.CONFORMANCE_OMISSION]: "an earlier run's review found a requirement the change left out or narrowed after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record an `add-requirement` / `defer` amendment with a why so the engine files a tracked follow-up instead of stopping",
|
|
87
|
+
[OUTCOME_CODES.CONFORMANCE_UNJUSTIFIED_DEFERRAL]: "an earlier run deferred a requirement without a sufficient why after one builder resume; the branch holds its work. For each named gap either deliver the requirement (adding any needed file as an `add-deliverable` amendment) or, when the clause is optional, unquantified or outside the issue's acceptance criteria, record a `defer` amendment with a concrete why so the engine files a tracked follow-up instead of stopping",
|
|
85
88
|
[OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
|
|
86
89
|
});
|
|
87
90
|
/** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
|
|
@@ -513,6 +516,10 @@ export async function runCore(o) {
|
|
|
513
516
|
return true;
|
|
514
517
|
} catch { return false; }
|
|
515
518
|
}
|
|
519
|
+
const labelFailed = (step, e) => { // #4905: a failed needs-human comment/label call is recorded, not swallowed (the issue showed needs-human with no label)
|
|
520
|
+
const error = String(e?.message ?? e).slice(0, 200);
|
|
521
|
+
T.anomalies.push(`needs-human ${step} failed: ${error}`); ev({ event: "NEEDS_HUMAN_LABEL_FAILED", step, error });
|
|
522
|
+
};
|
|
516
523
|
async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, { ask: askText, note: askNote = "", ...extra } = {}) {
|
|
517
524
|
// #4725: a recorded premise (`Premise: <kind>:<arg>`) that already holds is no blocker: no label, the lane runs again.
|
|
518
525
|
const predicate = parsePredicate(why);
|
|
@@ -528,8 +535,10 @@ export async function runCore(o) {
|
|
|
528
535
|
link = reg.existing ? `Same owner ask as #${reg.existing} (one ask, linked): answering it unblocks this issue too.` : reg.entry ?? "";
|
|
529
536
|
}
|
|
530
537
|
const notes = [premiseAnnotation(predicate), closedEdgeNote(closedDeps), link, askNote].filter(Boolean).join("\n");
|
|
531
|
-
await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
|
|
532
|
-
|
|
538
|
+
try { await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${notes ? `\n\n${notes}` : ""}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`); }
|
|
539
|
+
catch (e) { labelFailed("comment", e); }
|
|
540
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); }
|
|
541
|
+
catch (e) { labelFailed("label", e); }
|
|
533
542
|
} catch { /* best effort */ }
|
|
534
543
|
return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
|
|
535
544
|
}
|
|
@@ -642,11 +651,17 @@ export async function runCore(o) {
|
|
|
642
651
|
const signOffUnpushed = async () => {
|
|
643
652
|
try {
|
|
644
653
|
const r = await ensureSignedOff({ git, lane, branch });
|
|
645
|
-
if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten }); }
|
|
654
|
+
if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten, ...(r.dirty ? { dirty: r.dirty } : {}) }); }
|
|
646
655
|
} catch (e) { throw Object.assign(e, e?.noIdentity === true ? { laneCode: OUTCOME_CODES.CI_DCO } : { engineCode: OUTCOME_CODES.ENGINE_DCO_REWRITE }); } // #4997: a failed rewrite is mechanical (engine-error, recoverable), only a missing identity is a human block
|
|
647
656
|
};
|
|
648
657
|
const gitPush = async (args) => {
|
|
649
|
-
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
|
|
658
|
+
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() });
|
|
659
|
+
try { await signOffUnpushed(); }
|
|
660
|
+
catch (e) { // #4905: a failed rewrite must not cost the lane's finished commits: push the branch as-is before reporting
|
|
661
|
+
if (!e?.noIdentity) { try { await git(["push", "-u", "origin", branch]); ev({ event: "DCO_RESCUE_PUSH", branch }); } catch (pe) { ev({ event: "DCO_RESCUE_PUSH_FAILED", error: String(pe?.message ?? pe).slice(0, 200) }); } }
|
|
662
|
+
throw e;
|
|
663
|
+
}
|
|
664
|
+
} // #4590: never push .forge-lean/ or tool caches this branch added
|
|
650
665
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
651
666
|
catch (e) {
|
|
652
667
|
if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
@@ -2116,7 +2131,7 @@ export async function runCore(o) {
|
|
|
2116
2131
|
async function amend() {
|
|
2117
2132
|
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
2118
2133
|
const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
|
|
2119
|
-
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\
|
|
2134
|
+
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : null;
|
|
2120
2135
|
const addedPaths = wantsDecision ? String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").filter(Boolean) : [];
|
|
2121
2136
|
const own = [...(Array.isArray(result?.amendments) ? result.amendments : []), ...impliedDocs];
|
|
2122
2137
|
const ownKeys = new Set(own.map((x) => JSON.stringify(x)));
|
|
@@ -2298,7 +2313,8 @@ export async function runCore(o) {
|
|
|
2298
2313
|
// #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
|
|
2299
2314
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2300
2315
|
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2301
|
-
const
|
|
2316
|
+
const subBaseFiles = (effective.validate ?? []).some((v) => v.substituted) ? String(await git(["ls-tree", "-r", "-z", "--name-only", base]).catch(() => "")).split("\0").filter(Boolean) : []; // #5003
|
|
2317
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths, baseFiles: subBaseFiles });
|
|
2302
2318
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2303
2319
|
if (pc.superseded?.length) {
|
|
2304
2320
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -288,6 +288,15 @@ describe("core work-on", () => {
|
|
|
288
288
|
assert.equal((await run(w)).terminalReason, "invalid");
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
it("a failed needs-human comment is recorded as an anomaly and the lane still ends needs-human (#4905)", async () => {
|
|
292
|
+
const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
|
|
293
|
+
const gh = w.io.gh;
|
|
294
|
+
w.io.gh = async (a, opts) => { if (a[0] === "issue" && a[1] === "edit" && a.includes("--add-label")) throw new Error("label boom"); return gh(a, opts); };
|
|
295
|
+
const r = await run(w);
|
|
296
|
+
assert.equal(r.terminalReason, "needs-human");
|
|
297
|
+
assert.match(traj(w)[0], /needs-human label failed: label boom/);
|
|
298
|
+
});
|
|
299
|
+
|
|
291
300
|
it("invalid without evidence goes to needs-human and does not close", async () => {
|
|
292
301
|
const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
|
|
293
302
|
const r = await run(w);
|
|
@@ -2705,6 +2714,28 @@ describe("orchestrate", () => {
|
|
|
2705
2714
|
assert.match(t, /skipped \(dependency #1/);
|
|
2706
2715
|
});
|
|
2707
2716
|
|
|
2717
|
+
it("orchestrateFromCli reads the milestone with the body: no second issue view, one default-branch read, one ls-remote per lane (#4963)", async () => {
|
|
2718
|
+
const calls = [];
|
|
2719
|
+
const leanIo = {
|
|
2720
|
+
gh: async (a) => {
|
|
2721
|
+
calls.push(["gh", ...a]);
|
|
2722
|
+
if (a[0] === "repo" && a[1] === "view") return "main\n";
|
|
2723
|
+
if (a[0] === "api") return "OWNER\n";
|
|
2724
|
+
return JSON.stringify({ body: "", authorAssociation: "OWNER", milestone: a[2] === "2" ? null : { title: "M One" } });
|
|
2725
|
+
},
|
|
2726
|
+
git: async (a) => { calls.push(["git", ...a]); return a[0] === "ls-remote" && ["refs/heads/staging", "refs/heads/milestone/m-one"].includes(a.at(-1)) ? "abc\tref\n" : ""; },
|
|
2727
|
+
};
|
|
2728
|
+
const deps = { dir: tmpDir("core-orch-"), batchRoot: tmpDir("core-orch-batch-"), allowTmpfs: true, emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo,
|
|
2729
|
+
leanModule: { runCore: async (o) => ({ terminalReason: "merged", pr: 50 + o.issue, cost_usd: 1, wall_ms: 1 }) } };
|
|
2730
|
+
const l = console.log; console.log = () => {};
|
|
2731
|
+
let res;
|
|
2732
|
+
try { res = await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], deps); } finally { console.log = l; }
|
|
2733
|
+
assert.equal(res.ok, true);
|
|
2734
|
+
assert.equal(calls.filter((c) => c.includes("milestone") && !c.some((x) => /body/.test(x))).length, 0, "no milestone-only view");
|
|
2735
|
+
assert.equal(calls.filter((c) => c[1] === "repo" && c[2] === "view").length, 2, "the up-front --lane check plus one batch-wide lane lookup, not one per issue");
|
|
2736
|
+
assert.equal(calls.filter((c) => c[0] === "git" && c[1] === "ls-remote").length, 2);
|
|
2737
|
+
});
|
|
2738
|
+
|
|
2708
2739
|
it("orchestrateFromCli routes each issue to its own lane (no --lane), reports cross-lane edges, refuses --lane main", async () => {
|
|
2709
2740
|
const seen = [];
|
|
2710
2741
|
const logs = [];
|
|
@@ -85,3 +85,15 @@ describe("#4917: a deferred issue still waiting when the last running lane ends"
|
|
|
85
85
|
assert.deepEqual(saved.running, {});
|
|
86
86
|
});
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
describe("remote lanes with a registry lacking the #4964 methods", () => {
|
|
90
|
+
it("a remote-placed lane runs and settles when the registry stub has no noteRemote/dropRemote/notePool", async () => {
|
|
91
|
+
const calls = [];
|
|
92
|
+
const registry = { tryAcquire: () => { calls.push("acquire"); return { ok: true }; }, offer() {}, withdraw() {}, release() {}, setLanePid() {}, adoptSlot() {}, holder: () => null };
|
|
93
|
+
const rows = await orchestrate({ issues: [4], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry, batch: "B", repo: "o/r" },
|
|
94
|
+
remote: { max: 1, isRemote: () => true, capacity: () => null },
|
|
95
|
+
runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: "s-4" }, remote: true }); return merged; } });
|
|
96
|
+
assert.equal(rows[0].outcome, "merged");
|
|
97
|
+
assert.deepEqual(calls, [], "a remote lane never takes a host slot");
|
|
98
|
+
});
|
|
99
|
+
});
|