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