forgedock 1.42.0 → 1.43.1
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 +1 -0
- package/bin/engine-cli.mjs +91 -19
- package/bin/journey.mjs +2 -2
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +11 -3
- package/bin/lean/batch.test.mjs +66 -2
- package/bin/lean/conformance.mjs +140 -9
- package/bin/lean/conformance.test.mjs +164 -2
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.test.mjs +9 -6
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/issuewatch.mjs +2 -2
- package/bin/lean/lean.mjs +80 -24
- package/bin/lean/lean.test.mjs +119 -7
- package/bin/lean/orchestrate-blocked.test.mjs +16 -0
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/probe.mjs +1 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +116 -19
- package/bin/lean/schedd.test.mjs +201 -2
- package/bin/lean/selftest-live.mjs +1 -1
- package/bin/lean/selftest-nightly.mjs +1 -1
- package/bin/lean/signoff.mjs +11 -1
- package/bin/lean/signoff.test.mjs +24 -1
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/bin/pool/agent.mjs +1 -1
- package/bin/runner.mjs +1 -1
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/OUTCOME-CODES.md +1 -0
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/ci-local/run.mjs +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -75,12 +75,13 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
75
75
|
{ code: "merge.transient" },
|
|
76
76
|
{ code: "push.transient" },
|
|
77
77
|
{ code: "cli.idle" },
|
|
78
|
+
{ code: "cli.idle" },
|
|
78
79
|
];
|
|
79
80
|
const starts = [];
|
|
80
81
|
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
|
|
81
82
|
runOne: async () => { starts.push(now()); const d = seq.shift(); return { terminalReason: "deferred", detail: d.code, ...d }; } });
|
|
82
83
|
const gaps = starts.slice(1).map((t, i) => (t - starts[i]) / 1000);
|
|
83
|
-
assert.deepEqual(gaps, [60, 600, DEFER_BACKOFF_S * 2, DEFER_BACKOFF_S * 4]);
|
|
84
|
+
assert.deepEqual(gaps, [60, 600, DEFER_BACKOFF_S, DEFER_BACKOFF_S * 2, DEFER_BACKOFF_S * 4]);
|
|
84
85
|
assert.equal(rows[0].outcome, "deferred");
|
|
85
86
|
assert.match(rows[0].detail, /gave up after 3 deferrals/);
|
|
86
87
|
// the usage-limit reset time and a lease expiry are honoured
|
|
@@ -287,6 +288,8 @@ describe("lane launch env (#4179)", () => {
|
|
|
287
288
|
assert.equal(pid, 99);
|
|
288
289
|
assert.equal(spawned[0].opts.env.FORGEDOCK_BATCH_ID, batch.id);
|
|
289
290
|
assert.equal(spawned[0].opts.env.KEEP, "1");
|
|
291
|
+
assert.equal(spawned[0].opts.windowsHide, true, "a detached lane must not open a console window on Windows");
|
|
292
|
+
assert.equal(spawned[0].opts.detached, true);
|
|
290
293
|
assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
|
|
291
294
|
});
|
|
292
295
|
});
|
|
@@ -844,7 +847,7 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
|
|
|
844
847
|
assert.deepEqual(deferPolicy({ code: "sandbox.capacity" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
845
848
|
assert.deepEqual(deferPolicy({ code: "sandbox.capacity", retryInSeconds: 30 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 30000, host: true });
|
|
846
849
|
assert.deepEqual(deferPolicy({ code: "sandbox.unreachable", retryInSeconds: 60 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 60000, host: true }); // #4788
|
|
847
|
-
for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "
|
|
850
|
+
for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "sandbox.start_failed"]) {
|
|
848
851
|
assert.deepEqual(deferPolicy({ code, retryInSeconds: 60 }, { now: 0, deferrals: 3, maxDeferrals: 3 }), { capped: true }, code);
|
|
849
852
|
}
|
|
850
853
|
});
|
|
@@ -873,6 +876,46 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
|
|
|
873
876
|
});
|
|
874
877
|
});
|
|
875
878
|
|
|
879
|
+
describe("#4784: usage-limit deferrals", () => {
|
|
880
|
+
it("rate_limited is never capped and keeps its hold; a deferred row is requeuable", async () => {
|
|
881
|
+
assert.deepEqual(deferPolicy({ code: "rate_limited", resetAtEpochMs: 5000 }, { now: 1000, deferrals: 99, maxDeferrals: 3 }), { delayMs: 4000, hold: true });
|
|
882
|
+
const { now, sleep } = fakeClock();
|
|
883
|
+
let runs = 0;
|
|
884
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
|
|
885
|
+
runOne: async () => (++runs <= 6 ? { terminalReason: "deferred", code: "rate_limited", retryInSeconds: 600 } : { terminalReason: "merged", pr: 9 }) });
|
|
886
|
+
assert.equal(runs, 7);
|
|
887
|
+
assert.equal(rows[0].outcome, "merged");
|
|
888
|
+
const b = openBatch(tmpDir("batch-requeue-deferred-"), "b1");
|
|
889
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
890
|
+
b.store.save({ order: [1], results: { 1: { outcome: "deferred" } } });
|
|
891
|
+
assert.deepEqual(b.requeue([1]).requeued, [1]);
|
|
892
|
+
});
|
|
893
|
+
it("#4957: a blocker deferred on a usage limit past maxDeferrals keeps its dependents pending; the chain runs in order after the hold", async () => {
|
|
894
|
+
const { c, now, sleep } = fakeClock();
|
|
895
|
+
const bodies = { 1: "", 2: "Depends on #1", 3: "Depends on #2" };
|
|
896
|
+
const starts = [];
|
|
897
|
+
let limited = 0;
|
|
898
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, readBody: async (n) => bodies[n], now, sleep, maxDeferrals: 3,
|
|
899
|
+
runOne: async (n) => {
|
|
900
|
+
starts.push(n);
|
|
901
|
+
if (n === 1 && limited++ < 5) return { terminalReason: "deferred", code: "rate_limited", resetAtEpochMs: c.t + 600_000, detail: "usage limit hit" };
|
|
902
|
+
return { terminalReason: "merged" };
|
|
903
|
+
} });
|
|
904
|
+
assert.deepEqual(starts, [1, 1, 1, 1, 1, 1, 2, 3], "#2 and #3 never start before #1 merges");
|
|
905
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
|
|
906
|
+
});
|
|
907
|
+
it("a requeuable deferred row: an issue that gave up after N deferrals (#4984)", async () => {
|
|
908
|
+
const { now, sleep } = fakeClock();
|
|
909
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3,
|
|
910
|
+
runOne: async () => ({ terminalReason: "deferred", code: "followup.unavailable", detail: "no tracking issue was returned for R2" }) });
|
|
911
|
+
assert.match(rows[0].detail, /gave up after 3 deferrals/);
|
|
912
|
+
const b = openBatch(tmpDir("batch-requeue-gaveup-"), "b1");
|
|
913
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
914
|
+
b.store.save({ order: [1], results: { 1: { outcome: rows[0].outcome, detail: rows[0].detail } } });
|
|
915
|
+
assert.deepEqual(b.requeue([1]).requeued, [1]);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
876
919
|
describe("#4550: forgedock orchestrate --requeue", () => {
|
|
877
920
|
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
878
921
|
it("a needs-human issue is requeued and relaunched; its skipped dependent returns to pending and runs after it merges; one row per issue", async () => {
|
|
@@ -940,6 +983,27 @@ describe("#4550: forgedock orchestrate --requeue", () => {
|
|
|
940
983
|
assert.equal(b.readRequeued()[0].reason, "decided");
|
|
941
984
|
assert.equal(b.readReport(), null, "the finished batch is reopened for --resume");
|
|
942
985
|
});
|
|
986
|
+
|
|
987
|
+
it("#5000: --add/--requeue on a finished batch without a daemon clear the report and say it is not hosted", async () => {
|
|
988
|
+
const root = tmpDir("batch-add-finished-");
|
|
989
|
+
const b = openBatch(root, "b1");
|
|
990
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
991
|
+
b.store.save({ order: [5], results: { 5: { outcome: "needs-human" } } });
|
|
992
|
+
b.writeReport({ rows: [], report: "x" });
|
|
993
|
+
const io = { gh: async () => JSON.stringify({ state: "OPEN", labels: [], body: "", author: { login: "a" } }) };
|
|
994
|
+
const lines = [];
|
|
995
|
+
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; } };
|
|
996
|
+
const add = await run(["--add", "b1", "7"]);
|
|
997
|
+
assert.deepEqual(add.added, [7]);
|
|
998
|
+
assert.equal(b.readReport(), null, "a plain add reopens the finished batch for --resume");
|
|
999
|
+
assert.ok(lines.some((l) => /NOT hosted.*--resume b1/.test(l)), "the CLI says nothing is scheduled yet");
|
|
1000
|
+
b.writeReport({ rows: [], report: "x" });
|
|
1001
|
+
lines.length = 0;
|
|
1002
|
+
const rq = await run(["--requeue", "b1", "5"]);
|
|
1003
|
+
assert.deepEqual(rq.requeued, [5]);
|
|
1004
|
+
assert.equal(b.readReport(), null);
|
|
1005
|
+
assert.ok(lines.some((l) => /NOT hosted.*--resume b1/.test(l)));
|
|
1006
|
+
});
|
|
943
1007
|
});
|
|
944
1008
|
|
|
945
1009
|
describe("#4540: remote admissions are capped to the provider's free capacity", () => {
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* The reviewer supplies per-row judgements; the engine computes the verdict. A review without rows (a generic
|
|
8
8
|
* "PASS") is incomplete, and incomplete never merges.
|
|
9
9
|
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
10
11
|
import { OUTCOME_CODES, ENV_VALIDATE_TIMEOUT } from "../engine/outcome-codes.mjs";
|
|
11
12
|
import { noComments } from "./context.mjs";
|
|
12
13
|
import { activationFailures } from "./activation.mjs";
|
|
@@ -303,6 +304,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
303
304
|
if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
|
|
304
305
|
for (const p of tmpdirInputs(v.command)) problems.push(tmpInputProblem(v.id, p));
|
|
305
306
|
if (vitestQuietFlag(v.command)) problems.push(`${v.id}: vitest 3 rejects -q/--quiet (usage error before any test runs); use --reporter=dot`);
|
|
307
|
+
const syn = shellSyntaxError(v.command);
|
|
308
|
+
if (syn) problems.push(`${v.id}: the command is not valid shell (bash -n: ${syn}); keep notes out of the command`);
|
|
306
309
|
}
|
|
307
310
|
if (repro) {
|
|
308
311
|
const t = c.repro_test;
|
|
@@ -311,6 +314,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
311
314
|
else {
|
|
312
315
|
for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
|
|
313
316
|
if (vitestQuietFlag(t.command)) problems.push("repro_test: vitest 3 rejects -q/--quiet; use --reporter=dot");
|
|
317
|
+
const syn = shellSyntaxError(t.command);
|
|
318
|
+
if (syn) problems.push(`repro_test: the command is not valid shell (bash -n: ${syn})`);
|
|
314
319
|
}
|
|
315
320
|
}
|
|
316
321
|
if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
|
|
@@ -320,9 +325,10 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
320
325
|
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
321
326
|
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
322
327
|
|
|
323
|
-
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
|
|
328
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision", "substitute-validate"];
|
|
329
|
+
const UNRELATED_CI_KIND = "unrelated-ci-failure";
|
|
324
330
|
const GUARD_ID_RE = /^#?\d+$/;
|
|
325
|
-
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
331
|
+
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer", deferred: "defer", deferral: "defer", "scope-deferral": "defer" };
|
|
326
332
|
// #4916: near-synonyms builders emit for the per-guard `supersedes` amendment
|
|
327
333
|
const SUPERSEDES_SYNONYMS = ["supersede-guard", "supersede", "supersedes-guard"];
|
|
328
334
|
|
|
@@ -332,6 +338,11 @@ function normalizeAmendment(a) {
|
|
|
332
338
|
const n = { ...a };
|
|
333
339
|
if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
|
|
334
340
|
// #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
|
|
341
|
+
// #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
|
+
if (typeof n.kind === "string") {
|
|
343
|
+
const k = n.kind.trim().toLowerCase().replace(/_/g, "-");
|
|
344
|
+
if (AMEND_KINDS.includes(k) || Object.hasOwn(AMEND_SLIPS, k)) n.kind = k;
|
|
345
|
+
}
|
|
335
346
|
if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
|
|
336
347
|
if (SUPERSEDES_SYNONYMS.includes(n.kind)) n.kind = "supersedes";
|
|
337
348
|
if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
|
|
@@ -364,16 +375,17 @@ function amendmentProblem(a, tag) {
|
|
|
364
375
|
|
|
365
376
|
/**
|
|
366
377
|
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
367
|
-
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
378
|
+
* defer|post-deploy {requirement: id}. Every amendment needs a why (or the engine's defaultWhy); `addedPaths` are lane-added files that count as decision anchors when a deliverable lists them. Returns {contract, applied[], toTrack[], problems[]}.
|
|
368
379
|
* `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
|
|
369
380
|
* matching path is refused; the repo's generator or bot owns that file.
|
|
370
381
|
* `decision` {decision:{choice, anchors, symbols?, supersedes?}} (#4695): every anchor must match a file of `baseFiles` (the
|
|
371
382
|
* base tree; null skips the check); `supersedes` names the decision whose engine-authored preserve row it replaces and
|
|
372
383
|
* drops that row; returns the records in `decisions`.
|
|
373
384
|
*/
|
|
374
|
-
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null } = {}) {
|
|
385
|
+
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null, addedPaths = [], defaultWhy = null } = {}) {
|
|
375
386
|
const c = structuredClone(frozen);
|
|
376
387
|
c.deliverables = c.deliverables ?? [];
|
|
388
|
+
for (const v of c.validate ?? []) if (v && typeof v === "object") delete v.substituted; // SEC-21: only a substitute-validate amendment sets it, never the frozen contract
|
|
377
389
|
const applied = [], toTrack = [], problems = [], decisions = [];
|
|
378
390
|
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
379
391
|
const rows = [];
|
|
@@ -392,8 +404,11 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
392
404
|
}
|
|
393
405
|
rows.push({ a: n, raw });
|
|
394
406
|
}
|
|
395
|
-
for (
|
|
407
|
+
for (let { a, raw } of rows) {
|
|
408
|
+
// an unrelated out-of-diff CI failure is not a contract change: informational, never a problem
|
|
409
|
+
if (a?.kind === UNRELATED_CI_KIND) { applied.push({ kind: a.kind, target: String(a.target ?? "unrelated check"), why: String(a.why ?? "").trim() || "unrelated to the diff" }); continue; }
|
|
396
410
|
const tag = JSON.stringify(raw ?? null).slice(0, 400);
|
|
411
|
+
if (a && blank(a.why) && !blank(defaultWhy)) a = { ...a, why: String(defaultWhy) }; // the engine demanded the change: its reason is the why
|
|
397
412
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
398
413
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
399
414
|
if (genRes.some((re) => re.test(cleanPath(a.path)))) {
|
|
@@ -405,6 +420,16 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
405
420
|
// package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
|
|
406
421
|
if (have) { have.change = have.change ? `${have.change}; ${a.why}` : a.why; have.amended = true; }
|
|
407
422
|
else c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
|
|
423
|
+
} else if (a.kind === "substitute-validate" && !blank(a.validate)) {
|
|
424
|
+
// #4857: a runnable equivalent of an unrunnable frozen validate command; same id, same checks as a planned command
|
|
425
|
+
const v = (c.validate ?? []).find((x) => x.id === a.validate);
|
|
426
|
+
if (!v) { problems.push(`substitute-validate names unknown validate ${a.validate}`); continue; }
|
|
427
|
+
const bad = blank(a.command) ? "no command" : shellSyntaxError(a.command) ? "not valid shell (bash -n)" : noopCommand(a.command) ? "runs no check (true, :, exit 0, echo)" : movingBaseDiff(a.command) ? "diffs against a moving base tip" : tmpdirInputs(a.command).length ? "reads a $TMPDIR file it did not create" : null;
|
|
428
|
+
if (bad) { problems.push(`substitute-validate ${a.validate}: ${bad}`); continue; }
|
|
429
|
+
// SEC-11: a second substitute of the same id keeps the frozen command as `from`, so the engine always reruns the original
|
|
430
|
+
v.substituted = { from: v.substituted?.from ?? v.command, why: String(a.why).trim() }; v.command = String(a.command).trim();
|
|
431
|
+
applied.push({ kind: a.kind, target: a.validate, why: String(a.why).trim() });
|
|
432
|
+
continue;
|
|
408
433
|
} else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
|
|
409
434
|
if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
|
|
410
435
|
if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
|
|
@@ -424,6 +449,16 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
424
449
|
// #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
|
|
425
450
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
426
451
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
452
|
+
if (TRACKED.includes(r.disposition)) {
|
|
453
|
+
// SEC-31/SPEC-33: a flip that would orphan a check id or a filed tracking issue must name it and say why it is now delivered
|
|
454
|
+
const named = [r.check, r.tracked ? String(r.tracked).replace(/\D/g, "") : null].filter((k) => !blank(k));
|
|
455
|
+
const said = `${a.why} ${a.text}`;
|
|
456
|
+
const missing = named.filter((k) => !new RegExp(`(^|[^\\w-])#?${String(k).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`).test(said));
|
|
457
|
+
if (missing.length) { problems.push(`amendment flips ${r.disposition} ${r.id} to deliver but does not name its ${missing.join(", ")} (check id or tracking issue) and why it is now delivered in the diff`); continue; }
|
|
458
|
+
// #4984: deliver text on a deferred requirement puts it in scope: it is delivered, not tracked in a follow-up; amended_from keeps the original for the reviewer
|
|
459
|
+
r.amended_from ??= Object.fromEntries(["quote", "disposition", "why", "bucket", "check", "tracked", "class"].filter((k) => r[k] != null).map((k) => [k, r[k]]));
|
|
460
|
+
r.disposition = "deliver"; for (const k of ["bucket", "why", "check", "tracked", "class"]) delete r[k];
|
|
461
|
+
}
|
|
427
462
|
r.amendment = { text: String(a.text).trim(), why: String(a.why).trim() }; r.amended = true;
|
|
428
463
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
429
464
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
@@ -437,7 +472,10 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
437
472
|
} else if (a.kind === "decision") {
|
|
438
473
|
const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
|
|
439
474
|
if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
|
|
440
|
-
|
|
475
|
+
// a file the lane adds and the contract lists as a deliverable is a new file the issue declared: a valid anchor
|
|
476
|
+
const listed = new Set(c.deliverables.map((x) => x.path));
|
|
477
|
+
const added = (Array.isArray(addedPaths) ? addedPaths : []).filter((x) => listed.has(x));
|
|
478
|
+
const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, [...baseFiles, ...added]) : [];
|
|
441
479
|
if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
|
|
442
480
|
if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
|
|
443
481
|
decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
|
|
@@ -452,6 +490,74 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
452
490
|
const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
|
|
453
491
|
const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
|
|
454
492
|
const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
|
|
493
|
+
/** #4857: why a command is not valid bash (`bash -n`), or null when it parses (or bash is unavailable). Catches prose left on a command. */
|
|
494
|
+
export function shellSyntaxError(command) {
|
|
495
|
+
const r = spawnSync("bash", ["-n", "-c", String(command ?? "")], { encoding: "utf8", timeout: 5000 });
|
|
496
|
+
if (r.error || r.status === 0 || r.status == null) return null;
|
|
497
|
+
return String(r.stderr || "syntax error").trim().split("\n")[0].replace(/^bash: (?:line \d+: )?/, "").slice(0, 160);
|
|
498
|
+
}
|
|
499
|
+
const NOOP_SEGMENT_RE = /^(?:true|false|:|exit(?:\s+\d+)?|echo(?:\s.*)?|printf(?:\s.*)?|sleep(?:\s+[\d.]+)?|test\s+-?\w+|\[\s+-?\w+\s+\]|fi|done|node\s+-e\s+['"]?[\d;\s]*['"]?|python3?\s+-c\s+['"]?(?:pass|0|None)?['"]?)$/;
|
|
500
|
+
const KEYWORD_RE = /^(if|then|else|elif|do|while|until)(?:\s+|$)/;
|
|
501
|
+
const CONST_FALSE_RE = /^(?:false|\[\s+0\s+=\s+1\s+\]|\[\s+1\s+=\s+0\s+\]|test\s+0\s+=\s+1|test\s+-n\s+(?:""|'')|\[\s+-n\s+(?:""|'')\s+\])$/;
|
|
502
|
+
/**
|
|
503
|
+
* #4979 SPEC-2: a command every segment of which is a no-op (true, :, exit N, echo, printf, sleep, a constant test,
|
|
504
|
+
* shell keywords, `node -e 0`, `python -c pass`) checks nothing. A best-effort denylist: the engine's rerun of the
|
|
505
|
+
* frozen command (`substituteVerdict`) is the real gate.
|
|
506
|
+
*/
|
|
507
|
+
export function noopCommand(command) {
|
|
508
|
+
// SEC-22: a leading shell keyword is stripped and the rest judged, so `if [ -f x ]; then pytest; fi` is a real check
|
|
509
|
+
const segs = String(command ?? "").split(/&&|\|\||;|\||\n/).map((s) => s.trim()).filter(Boolean);
|
|
510
|
+
const live = [];
|
|
511
|
+
let dead = false; // #4995: inside the body of an if/elif/while whose condition is a constant false
|
|
512
|
+
for (const raw of segs) {
|
|
513
|
+
let s = raw, cond = false, m;
|
|
514
|
+
while ((m = KEYWORD_RE.exec(s))) {
|
|
515
|
+
s = s.slice(m[0].length);
|
|
516
|
+
if (m[1] === "else" || m[1] === "elif") dead = false;
|
|
517
|
+
cond = m[1] === "if" || m[1] === "elif" || m[1] === "while";
|
|
518
|
+
}
|
|
519
|
+
if (s === "fi" || s === "done") dead = false;
|
|
520
|
+
if (cond) { if (CONST_FALSE_RE.test(s)) dead = true; else if (!dead && s) live.push(s); continue; }
|
|
521
|
+
if (!dead && s) live.push(s);
|
|
522
|
+
}
|
|
523
|
+
return segs.length > 0 && live.every((s) => NOOP_SEGMENT_RE.test(s));
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* #4979 SPEC-2: a substitute-validate stands only when the frozen command it replaced cannot run (died before any test,
|
|
527
|
+
* a runner usage error, a missing host tool, exit 127) or passes anyway. A frozen command that runs and fails is a real
|
|
528
|
+
* failure the substitute may not hide. `run` is the engine's rerun of the frozen command; null when none was made.
|
|
529
|
+
*/
|
|
530
|
+
export function substituteVerdict(run, { repoHas = () => false, command = "" } = {}) {
|
|
531
|
+
if (!run) return "unverified";
|
|
532
|
+
if (run.rc === 0 || run.rc === 127 || unrunnableValidate(run) || cliUsageError(run) || missingTool(run, { repoHas, command })) return "ok";
|
|
533
|
+
return run.rc === 124 ? "timeout" : "runs";
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* #4990 SEC-13: the paths of `removed` (deleted, or the old side of a rename, in the diff) that a frozen command names:
|
|
537
|
+
* a whitespace token equal to the path, or a directory token above it. A builder that removes the target of its frozen
|
|
538
|
+
* validate makes the engine's rerun read "unrunnable", so the substitute is judged on the diff instead.
|
|
539
|
+
*/
|
|
540
|
+
export function removedNamedPaths(command, removed = []) {
|
|
541
|
+
const toks = String(command ?? "").split(/\s+/).map((t) => t.replace(/^['"`(]+|['"`);]+$/g, "").replace(/^\.\//, "").replace(/\/+$/, "")).filter((t) => t && t !== ".");
|
|
542
|
+
return removed.filter((p) => toks.some((t) => p === t || p.startsWith(`${t}/`)));
|
|
543
|
+
}
|
|
544
|
+
/** #4990: paths the lane's diff deletes or renames away from (the old side), per `git diff --name-status -M`. */
|
|
545
|
+
export async function removedPathsOf(git, base) {
|
|
546
|
+
const rows = String(await git(["diff", "--name-status", "-M", `${base}...HEAD`]).catch(() => "")).split("\n").map((l) => l.split("\t"));
|
|
547
|
+
return rows.filter((r) => /^[DR]/.test(r[0] ?? "") && r[1]).map((r) => r[1]);
|
|
548
|
+
}
|
|
549
|
+
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
|
+
/** #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. */
|
|
551
|
+
export function unrunnableValidate({ rc, output } = {}) {
|
|
552
|
+
return rc !== 0 && rc !== 124 && UNRUNNABLE_RE.test(String(output ?? ""));
|
|
553
|
+
}
|
|
554
|
+
const BLOCK_VALIDATE_RE = /\b(?:V\d+|validate|repro)\b/i;
|
|
555
|
+
const BLOCK_UNRUNNABLE_RE = /unrunnable|cannot (?:be )?run|could not (?:be )?run|can't run|not runnable|command not found|No module named|no tests (?:ran|were collected)|collected 0 items|syntax error|pyproject|(?:tool|module)s? (?:is|are) missing|fails? before any test/i;
|
|
556
|
+
/** #4857: a blocked result's text that says a validate/repro command could not run: an environment defect, never a product decision. */
|
|
557
|
+
export function describesUnrunnableCommand(text) {
|
|
558
|
+
const t = String(text ?? "");
|
|
559
|
+
return BLOCK_VALIDATE_RE.test(t) && BLOCK_UNRUNNABLE_RE.test(t);
|
|
560
|
+
}
|
|
455
561
|
// #4761: a runner rejecting its own flags is a defect of the frozen command, not of the change
|
|
456
562
|
const CLI_USAGE_RE = /\bUnknown option\b|Unrecognized CLI Parameter|error: unrecognized arguments|unknown (?:shorthand )?flag\b|bad option:/i;
|
|
457
563
|
const VITEST_QUIET_RE = /\bvitest\b[^&|;]*\s(?:-q|--quiet)(?=\s|$)/;
|
|
@@ -467,6 +573,18 @@ export function normalizeRunnerCommand(command) {
|
|
|
467
573
|
export function cliUsageError({ rc, output } = {}) {
|
|
468
574
|
return rc !== 0 && CLI_USAGE_RE.test(String(output ?? ""));
|
|
469
575
|
}
|
|
576
|
+
const FORGEDOCK_ENTRY_RE = /^\/.*\/forgedock\/bin\/(?:lean\/checks|forgedock)\.mjs$/;
|
|
577
|
+
const FORGEDOCK_TOKEN_RE = /(?<![\w./-])\/[^\s'"`;&|()]*\/forgedock\/bin\/(lean\/checks|forgedock)\.mjs/g;
|
|
578
|
+
/**
|
|
579
|
+
* #5005: a frozen validate command names forgedock's own entry point by the absolute path of the install that wrote it; on
|
|
580
|
+
* the host that runs it that path may not exist. Rewrites each such token whose file is missing to this install's path.
|
|
581
|
+
*/
|
|
582
|
+
export function resolveForgedockPaths(command, { exists = () => true, checksCli, forgedockCli } = {}) {
|
|
583
|
+
return String(command ?? "").replace(FORGEDOCK_TOKEN_RE, (tok, which) => {
|
|
584
|
+
const here = which === "forgedock" ? forgedockCli : checksCli;
|
|
585
|
+
return here && !exists(tok) ? here : tok;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
470
588
|
const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
|
|
471
589
|
/**
|
|
472
590
|
* forge#4323: the host tool or module a failed command could not find, from its {rc, output}: bash/sh "X: command not found"
|
|
@@ -480,6 +598,7 @@ export function missingTool({ rc, output } = {}, { repoHas = () => false, comman
|
|
|
480
598
|
const py = PY_MISSING_RE.exec(out);
|
|
481
599
|
if (py) { const tool = String(py[1] ?? py[2]).split(".")[0]; return local(tool) ? null : { tool, kind: "python-module" }; }
|
|
482
600
|
const js = NODE_MISSING_RE.exec(out);
|
|
601
|
+
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
|
|
483
602
|
if (js) {
|
|
484
603
|
const spec = js[1], scoped = spec.startsWith("@");
|
|
485
604
|
if (!scoped && spec.includes("/")) return null;
|
|
@@ -513,7 +632,7 @@ export function repoTracks(paths) {
|
|
|
513
632
|
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
514
633
|
* tool), for the trajectory; always an array.
|
|
515
634
|
*/
|
|
516
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null }) {
|
|
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 = [] }) {
|
|
517
636
|
const failures = [], superseded = [], ciCovered = [];
|
|
518
637
|
// #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
|
|
519
638
|
if (checkIds) for (const r of contract.requirements ?? []) {
|
|
@@ -527,6 +646,16 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
527
646
|
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
528
647
|
const runs = Array.isArray(result?.validation) ? result.validation : [];
|
|
529
648
|
for (const v of contract.validate ?? []) {
|
|
649
|
+
// #4979 SPEC-2: a substitute counts only when the frozen command it replaced cannot run (or passes); checked first, never CI-covered
|
|
650
|
+
if (v.substituted) {
|
|
651
|
+
const gone = removedNamedPaths(v.substituted.from, removedPaths);
|
|
652
|
+
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
|
+
const verdict = substituteVerdict(substitutedRuns?.[v.id], { repoHas, command: v.substituted.from });
|
|
654
|
+
const from = `validate ${v.id} substituted \`${v.command}\` for the frozen \`${v.substituted.from}\``;
|
|
655
|
+
if (verdict === "unverified") { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "substitute_unverified", detail: `${from}, but the engine has no rerun of the frozen command to show it cannot run` }); continue; }
|
|
656
|
+
if (verdict === "runs") { failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `${from}, but the frozen command runs and fails (exit ${substitutedRuns[v.id].rc}): a substitute-validate replaces only a command that cannot run, never a failing check` }); continue; }
|
|
657
|
+
if (verdict === "timeout" && !ciVerified) { failures.push({ code: ENV_VALIDATE_TIMEOUT, kind: "validate_timeout", detail: `${from}, and the frozen command timed out (exit 124) on the engine's rerun, so its substitution cannot be judged: infrastructure (host load)` }); continue; }
|
|
658
|
+
}
|
|
530
659
|
const r = runs.find((x) => x?.id === v.id);
|
|
531
660
|
// #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
|
|
532
661
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
@@ -536,6 +665,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
536
665
|
const rerun = validateRuns?.[v.id];
|
|
537
666
|
if (rerun?.rc === 0) continue;
|
|
538
667
|
if (rerun && cliUsageError(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) is a CLI usage error of the runner, a defect of the frozen command rather than of the change` }); continue; }
|
|
668
|
+
if (rerun && unrunnableValidate(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) died before any test ran (no tests selected, no project at its cwd or invalid shell), a defect of the frozen command rather than of the change; substitute a runnable equivalent with a substitute-validate amendment` }); continue; }
|
|
539
669
|
// #4906: the rerun timed out (even after the longer retry): host load, not a verdict on the change. A CI-verified head covers it; else an environment error.
|
|
540
670
|
if (rerun?.rc === 124) {
|
|
541
671
|
if (ciVerified) { ciCovered.push(v.id); continue; }
|
|
@@ -962,8 +1092,9 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
|
|
|
962
1092
|
/** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
|
|
963
1093
|
export function followupMap(out, items) {
|
|
964
1094
|
const echoed = new Map();
|
|
965
|
-
|
|
966
|
-
|
|
1095
|
+
const norm = (k) => String(k).replace(/`/g, "").trim(); // #4984: the script strips backticks from the label it echoes
|
|
1096
|
+
for (const l of String(out ?? "").split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) echoed.set(norm(k), Number(n)); }
|
|
1097
|
+
return Object.fromEntries(items.filter((i) => echoed.has(norm(i.key))).map((i) => [i.key, echoed.get(norm(i.key))]));
|
|
967
1098
|
}
|
|
968
1099
|
|
|
969
1100
|
/**
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { describe, it } from "node:test";
|
|
4
5
|
import assert from "node:assert/strict";
|
|
5
|
-
import { vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables } from "./conformance.mjs";
|
|
6
|
+
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables } from "./conformance.mjs";
|
|
6
7
|
|
|
7
8
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
9
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -68,6 +69,22 @@ describe("amendments", () => {
|
|
|
68
69
|
});
|
|
69
70
|
});
|
|
70
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("scope-note and other unlisted kinds stay unknown and list the valid kinds", () => {
|
|
82
|
+
for (const kind of ["scope-note", "Scope_Note", "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
|
+
|
|
71
88
|
describe("#4655 multi-requirement amendment references", () => {
|
|
72
89
|
it("R1/R2, 'R1, R2' and an en-dash range split into one applied row per id", () => {
|
|
73
90
|
for (const requirement of ["R1/R2", "R1, R2", "R1\u2013R2", "R1 and R2"]) {
|
|
@@ -81,6 +98,15 @@ describe("#4655 multi-requirement amendment references", () => {
|
|
|
81
98
|
assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
|
|
82
99
|
assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
|
|
83
100
|
});
|
|
101
|
+
it("an unrelated-ci-failure amendment (kind or type) is an informational no-op; other unknown kinds still fail", () => {
|
|
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" }]) {
|
|
103
|
+
const r = applyAmendments(C, [a]);
|
|
104
|
+
assert.deepEqual(r.problems, []);
|
|
105
|
+
assert.deepEqual(r.contract, C);
|
|
106
|
+
assert.equal(r.applied[0].kind, "unrelated-ci-failure");
|
|
107
|
+
}
|
|
108
|
+
assert.match(applyAmendments(C, [{ kind: "flaky-note", why: "w" }]).problems[0], /unknown kind 'flaky-note'; valid kinds are/);
|
|
109
|
+
});
|
|
84
110
|
it("supersede-guard guards[] expands to per-guard supersedes", () => {
|
|
85
111
|
const a = applyAmendments(C, [{ kind: "supersede-guard", guards: ["#4879", "#4838"], why: "x" }]);
|
|
86
112
|
assert.deepEqual(a.problems, []);
|
|
@@ -137,6 +163,83 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
137
163
|
assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 1 }] }, changedFiles: files })), ["conformance.unimplemented"]);
|
|
138
164
|
assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files })), ["conformance.unimplemented"]);
|
|
139
165
|
});
|
|
166
|
+
it("#4857 validateContract rejects a validate command that fails bash -n", () => {
|
|
167
|
+
const prose = { ...C, validate: [{ id: "V3", command: "node checks.mjs --base origin/staging (then run the scoped tsc/eslint commands it prints)" }] };
|
|
168
|
+
assert.ok(validateContract(prose, ISSUE).problems.some((p) => /V3: the command is not valid shell/.test(p)));
|
|
169
|
+
assert.ok(!validateContract({ ...C, validate: [{ id: "V1", command: "node checks.mjs --base origin/staging" }] }, ISSUE).problems.some((p) => /valid shell/.test(p)));
|
|
170
|
+
assert.equal(shellSyntaxError("echo ok && true"), null);
|
|
171
|
+
assert.ok(shellSyntaxError("echo (oops"));
|
|
172
|
+
});
|
|
173
|
+
it("#4857 a command that died before any test ran is contract.invalid_command; substitute-validate swaps a runnable one", () => {
|
|
174
|
+
assert.ok(unrunnableValidate({ rc: 5, output: "collected 0 items / 1 deselected\nno tests ran" }));
|
|
175
|
+
assert.ok(unrunnableValidate({ rc: 1, output: "Poetry could not find a pyproject.toml file in /repo" }));
|
|
176
|
+
assert.ok(!unrunnableValidate({ rc: 1, output: "AssertionError: 1 !== 2" }));
|
|
177
|
+
assert.ok(!unrunnableValidate({ rc: 0, output: "no tests ran" }));
|
|
178
|
+
const r = applyAmendments({ requirements: [], validate: [{ id: "V2", command: "poetry run pytest -k 404_probe" }] }, [{ kind: "substitute-validate", validate: "V2", command: "poetry -C services/worker run pytest tests/test_probe.py", why: "no pyproject at root" }]);
|
|
179
|
+
assert.deepEqual(r.problems, []);
|
|
180
|
+
assert.equal(r.contract.validate[0].command, "poetry -C services/worker run pytest tests/test_probe.py");
|
|
181
|
+
assert.equal(r.contract.validate[0].substituted.from, "poetry run pytest -k 404_probe");
|
|
182
|
+
const bad = applyAmendments({ requirements: [], validate: [{ id: "V2", command: "x" }] }, [{ kind: "substitute-validate", validate: "V2", command: "echo (oops", why: "w" }]);
|
|
183
|
+
assert.ok(bad.problems.some((p) => /not valid shell/.test(p)));
|
|
184
|
+
assert.ok(applyAmendments({ requirements: [], validate: [] }, [{ kind: "substitute-validate", validate: "V9", command: "true", why: "w" }]).problems.length);
|
|
185
|
+
});
|
|
186
|
+
it("#4995 noopCommand bare keywords and constant-false guards", () => {
|
|
187
|
+
for (const cmd of ["if true\nthen\n :\nfi", "while true\ndo\n :\ndone", "if false; then pytest; fi", "if [ 0 = 1 ]; then pytest; fi", "if test -n \"\"; then node --test a.test.mjs; fi", "while false; do pytest; done", "if false\nthen\n pytest\nfi"]) assert.ok(noopCommand(cmd), JSON.stringify(cmd));
|
|
188
|
+
for (const cmd of ["if [ -f x ]; then pytest; fi", "for f in a b; do node --test $f; done", "if false; then :; else pytest; fi", "if true\nthen\n pytest\nfi", "if false; then :; elif [ -f x ]; then pytest; fi"]) assert.ok(!noopCommand(cmd), JSON.stringify(cmd));
|
|
189
|
+
});
|
|
190
|
+
it("#4979 SPEC-2 a substitute that runs no check is rejected at amendment time", () => {
|
|
191
|
+
for (const cmd of ["true", ":", "exit 0", "echo ok", "true && echo done", "printf x | true"]) {
|
|
192
|
+
assert.ok(noopCommand(cmd), cmd);
|
|
193
|
+
const r = applyAmendments({ requirements: [], validate: [{ id: "V2", command: "poetry run pytest -k probe" }] }, [{ kind: "substitute-validate", validate: "V2", command: cmd, why: "w" }]);
|
|
194
|
+
assert.ok(r.problems.some((p) => /runs no check/.test(p)), cmd);
|
|
195
|
+
}
|
|
196
|
+
for (const cmd of ["node --test a.test.mjs", "echo start && pytest tests/x.py", "test -f out.json", "[ -f a ] && npm test", "bash scripts/check.sh", "if [ -f x ]; then pytest; fi", "if true; then node --test a.test.mjs; fi", "for f in a b; do node --test $f; done"]) assert.ok(!noopCommand(cmd), cmd); // SEC-22: keywords around a real check
|
|
197
|
+
for (const cmd of ["test 1", "[ 1 ]", "sleep 0", "node -e 0", "true\ntrue", "if true; then :; fi", "python -c pass", "exit 1"]) assert.ok(noopCommand(cmd), JSON.stringify(cmd)); // SEC-12
|
|
198
|
+
});
|
|
199
|
+
it("#4979 SPEC-2 a substitute stands only when the frozen command cannot run", () => {
|
|
200
|
+
const sub = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak" }] }, [{ kind: "substitute-validate", validate: "V1", command: "node --test test/leak.test.js", why: "no pyproject at root" }]).contract;
|
|
201
|
+
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
202
|
+
const run = (substitutedRuns) => precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns });
|
|
203
|
+
assert.deepEqual(run({ V1: { rc: 1, output: "Poetry could not find a pyproject.toml file in /repo" } }).failures, [], "unrunnable original: substitute stands");
|
|
204
|
+
assert.deepEqual(run({ V1: { rc: 127, output: "poetry: command not found" } }).failures, [], "missing runner");
|
|
205
|
+
assert.deepEqual(run({ V1: { rc: 0, output: "1 passed" } }).failures, [], "original passes anyway");
|
|
206
|
+
const runs = run({ V1: { rc: 1, output: "FAILED tests/test_leak.py::test_release - AssertionError" } });
|
|
207
|
+
assert.deepEqual(runs.failures.map((f) => f.kind), ["validate_failed"], "a failing original may not be hidden");
|
|
208
|
+
assert.match(runs.failures[0].detail, /frozen command runs and fails \(exit 1\)/);
|
|
209
|
+
assert.deepEqual(run(null).failures.map((f) => f.kind), ["substitute_unverified"], "no rerun: fail closed");
|
|
210
|
+
assert.deepEqual(run({ V1: { rc: 124, output: "" } }).failures.map((f) => f.kind), ["validate_timeout"]);
|
|
211
|
+
assert.deepEqual(precheck({ contract: sub, result: res, changedFiles: files, ciVerified: true, substitutedRuns: { V1: { rc: 1, output: "AssertionError" } } }).failures.map((f) => f.kind), ["validate_failed"], "CI never covers a hidden failure");
|
|
212
|
+
assert.equal(substituteVerdict(undefined), "unverified");
|
|
213
|
+
// SEC-11: a second substitute of the same id never replaces the frozen command the engine reruns
|
|
214
|
+
const twice = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak" }] }, [
|
|
215
|
+
{ kind: "substitute-validate", validate: "V1", command: "nonexistent-xyz", why: "a" },
|
|
216
|
+
{ kind: "substitute-validate", validate: "V1", command: "node --test test/leak.test.js", why: "b" },
|
|
217
|
+
]).contract;
|
|
218
|
+
assert.equal(twice.validate[0].substituted.from, "poetry run pytest -k leak");
|
|
219
|
+
assert.equal(twice.validate[0].command, "node --test test/leak.test.js");
|
|
220
|
+
// SEC-21: a planner-set substituted field on the frozen contract is dropped; only an amendment sets it
|
|
221
|
+
const planted = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak", substituted: { from: "true", why: "x" } }] }, []).contract;
|
|
222
|
+
assert.equal(planted.validate[0].substituted, undefined);
|
|
223
|
+
const plantedSub = applyAmendments({ ...C, validate: [{ id: "V1", command: "poetry run pytest -k leak", substituted: { from: "true", why: "x" } }] }, [{ kind: "substitute-validate", validate: "V1", command: "node --test test/leak.test.js", why: "b" }]).contract;
|
|
224
|
+
assert.equal(plantedSub.validate[0].substituted.from, "poetry run pytest -k leak");
|
|
225
|
+
});
|
|
226
|
+
it("#4990 a substitute whose frozen command names a removed path is rejected", async () => {
|
|
227
|
+
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;
|
|
228
|
+
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
229
|
+
const gone = precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "no tests found" } }, removedPaths: ["test/leak.test.js"] });
|
|
230
|
+
assert.deepEqual(gone.failures.map((f) => f.kind), ["substitute_removed_path"]);
|
|
231
|
+
assert.equal(gone.failures[0].code, "conformance.unimplemented");
|
|
232
|
+
assert.match(gone.failures[0].detail, /test\/leak\.test\.js/);
|
|
233
|
+
const env = precheck({ contract: sub, result: res, changedFiles: files, substitutedRuns: { V1: { rc: 1, output: "Unknown option `-q`" } }, removedPaths: ["docs/other.md"] });
|
|
234
|
+
assert.deepEqual(env.failures, [], "intact named files: env-unrunnable substitute stands");
|
|
235
|
+
assert.deepEqual(removedNamedPaths("node --test ./test/a.js 'b/c.js' tests/", ["test/a.js", "b/c.js", "tests/x.js", "z.js"]), ["test/a.js", "b/c.js", "tests/x.js"]);
|
|
236
|
+
assert.deepEqual(removedNamedPaths("pytest -k leak", ["leak.py"]), []);
|
|
237
|
+
assert.deepEqual(await removedPathsOf(async () => "D\ta.js\nR100\told.js\tnew.js\nM\tm.js\nA\tn.js", "b"), ["a.js", "old.js"]);
|
|
238
|
+
});
|
|
239
|
+
it("#4857 describesUnrunnableCommand reads a blocked summary", () => {
|
|
240
|
+
assert.ok(describesUnrunnableCommand("V2 is unrunnable: poetry found no pyproject at the repo root"));
|
|
241
|
+
assert.ok(!describesUnrunnableCommand("needs a pricing decision from the product owner"));
|
|
242
|
+
});
|
|
140
243
|
it("#4761 vitest -q is a contract problem; a runner usage error is contract.invalid_command, not conformance", () => {
|
|
141
244
|
const bad = { ...C, validate: [{ id: "V1", command: "pnpm --filter x exec vitest run a.test.ts -q" }] };
|
|
142
245
|
assert.ok(validateContract(bad, ISSUE).problems.some((p) => /V1: vitest 3 rejects -q/.test(p)));
|
|
@@ -194,6 +297,22 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
194
297
|
assert.equal(missingTool({ rc: 1, output: "ModuleNotFoundError: No module named 'app'" }, { repoHas: repoTracks(["app.py"]) }), null);
|
|
195
298
|
assert.equal(missingTool({ rc: 1, output: "1 test failed: expected not found" }), null, "rc != 127 needs command not found");
|
|
196
299
|
});
|
|
300
|
+
it("#5005 a missing forgedock install is env.tool_missing, not conformance; other absolute paths stay code defects", () => {
|
|
301
|
+
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'";
|
|
302
|
+
assert.deepEqual(missingTool({ rc: 1, output: out }), { tool: "forgedock", kind: "command" });
|
|
303
|
+
assert.equal(missingTool({ rc: 1, output: "Error: Cannot find module '/repo/forgedock/src/x.mjs'" }), null);
|
|
304
|
+
const contract = { requirements: [], deliverables: [], validate: [{ id: "V2", command: "node /opt/node/lib/node_modules/forgedock/bin/lean/checks.mjs --base origin/staging" }] };
|
|
305
|
+
const result = { validation: [{ id: "V2", command: "", exit: 1 }] };
|
|
306
|
+
const pc = precheck({ contract, result, changedFiles: [], validateRuns: { V2: { rc: 1, output: out } } });
|
|
307
|
+
assert.deepEqual(pc.failures.map((f) => f.code), ["env.tool_missing"]);
|
|
308
|
+
});
|
|
309
|
+
it("#5005 resolveForgedockPaths rewrites a missing forgedock entry point to this install, leaves an existing one", () => {
|
|
310
|
+
const o = { checksCli: "/h/forgedock/bin/lean/checks.mjs", forgedockCli: "/h/forgedock/bin/forgedock.mjs" };
|
|
311
|
+
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";
|
|
312
|
+
assert.equal(resolveForgedockPaths(cmd, { ...o, exists: () => false }), "node /h/forgedock/bin/lean/checks.mjs --base origin/staging && node /h/forgedock/bin/forgedock.mjs check");
|
|
313
|
+
assert.equal(resolveForgedockPaths(cmd, { ...o, exists: () => true }), cmd);
|
|
314
|
+
assert.equal(resolveForgedockPaths("node bin/x.mjs", { ...o, exists: () => false }), "node bin/x.mjs");
|
|
315
|
+
});
|
|
197
316
|
it("a deliver requirement reported deferred without an amendment -> untracked_deferral; partial -> unimplemented", () => {
|
|
198
317
|
const r = { ...res, requirements: [{ id: "R1", status: "deferred" }, { id: "R2", status: "partial" }] };
|
|
199
318
|
assert.deepEqual(codes(precheck({ contract: C, result: r, changedFiles: files })), ["conformance.untracked_deferral", "conformance.unimplemented"]);
|
|
@@ -721,6 +840,36 @@ describe("#4594 amend-requirement", () => {
|
|
|
721
840
|
const r = a.contract.requirements.find((x) => x.id === "R1");
|
|
722
841
|
assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text); assert.equal(r.amendment.why, am.why); assert.equal(r.quote, C.requirements[0].quote);
|
|
723
842
|
});
|
|
843
|
+
it("amend-requirement with text on a defer requirement flips it to deliver and files no follow-up (#4984)", () => {
|
|
844
|
+
const D = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "later", bucket: "new-issue", tracked: "#5" }] };
|
|
845
|
+
const a = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id, why: "token stored hash-only; #5 is now delivered in the diff" }]);
|
|
846
|
+
assert.deepEqual(a.problems, []); assert.deepEqual(a.toTrack, []);
|
|
847
|
+
const r = a.contract.requirements[1];
|
|
848
|
+
assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text);
|
|
849
|
+
assert.ok(!("bucket" in r) && !("why" in r) && !("tracked" in r));
|
|
850
|
+
});
|
|
851
|
+
it("a trivial rewording of a deferred requirement keeps amended_from for the reviewer (#4996)", () => {
|
|
852
|
+
const D = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "later", bucket: "new-issue" }] };
|
|
853
|
+
const a = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id, text: "done", why: "trivial" }]);
|
|
854
|
+
assert.deepEqual(a.problems, []);
|
|
855
|
+
const r = a.contract.requirements[1];
|
|
856
|
+
assert.equal(r.disposition, "deliver"); assert.equal(r.amended_from.disposition, "defer"); assert.equal(r.amended_from.quote, C.requirements[1].quote); assert.equal(r.amended_from.why, "later");
|
|
857
|
+
});
|
|
858
|
+
it("post-deploy flip with a check id is rejected without naming it, accepted when named (#4996)", () => {
|
|
859
|
+
const chk = "pd-check-1";
|
|
860
|
+
const D = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "post-deploy", why: "after deploy", bucket: "post-deploy", check: chk }] };
|
|
861
|
+
const bad = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id }]);
|
|
862
|
+
assert.match(bad.problems[0], /does not name its pd-check-1/);
|
|
863
|
+
assert.equal(D.requirements[1].disposition, "post-deploy"); assert.equal(bad.contract.requirements[1].disposition, "post-deploy");
|
|
864
|
+
const ok = applyAmendments(D, [{ ...am, requirement: D.requirements[1].id, why: `${chk} now runs in the diff` }]);
|
|
865
|
+
assert.deepEqual(ok.problems, []); assert.equal(ok.contract.requirements[1].amended_from.check, chk);
|
|
866
|
+
});
|
|
867
|
+
it("the reviewer prompt tells it to compare amended_from with the amendment (#4996)", () => {
|
|
868
|
+
assert.match(readFileSync(new URL("./prompts.mjs", import.meta.url), "utf8"), /amended_from\.quote and amended_from\.why with amendment\.text/);
|
|
869
|
+
});
|
|
870
|
+
it("followupMap matches an item key whose backticks the script stripped (#4984)", () => {
|
|
871
|
+
assert.deepEqual(followupMap("R2: wire pd.py in\t20\n", [{ key: "R2: wire `pd.py` in" }]), { "R2: wire `pd.py` in": 20 });
|
|
872
|
+
});
|
|
724
873
|
it("an unknown requirement is a problem; the frozen contract is untouched", () => {
|
|
725
874
|
assert.match(applyAmendments(C, [{ ...am, requirement: "R9" }]).problems[0], /unknown requirement R9/);
|
|
726
875
|
assert.equal(C.requirements[0].amendment, undefined);
|
|
@@ -856,6 +1005,19 @@ describe("decision amendments (#4695)", () => {
|
|
|
856
1005
|
assert.match(a.problems[0], /match no file on the base/);
|
|
857
1006
|
assert.equal(a.decisions.length, 0);
|
|
858
1007
|
});
|
|
1008
|
+
it("an anchor on a lane-added file the contract lists as a deliverable conforms; an unlisted added file does not", () => {
|
|
1009
|
+
const withNew = { ...withRow, deliverables: [...withRow.deliverables, { id: "D3", path: "scripts/seed.py" }] };
|
|
1010
|
+
const go = (added) => applyAmendments(withNew, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: ["scripts/seed.py"] } }], null, { baseFiles: ["src/scrape.js"], addedPaths: added });
|
|
1011
|
+
assert.deepEqual(go(["scripts/seed.py"]).problems, []);
|
|
1012
|
+
assert.match(go(["scripts/other.py"]).problems[0], /match no file on the base/);
|
|
1013
|
+
});
|
|
1014
|
+
it("a blank-why amendment takes the engine's defaultWhy; without one it is still rejected", () => {
|
|
1015
|
+
const am = [{ kind: "add-deliverable", path: "x.js" }];
|
|
1016
|
+
const ok = applyAmendments(C, am, null, { defaultWhy: "engine-demanded rename" });
|
|
1017
|
+
assert.deepEqual(ok.problems, []);
|
|
1018
|
+
assert.equal(ok.applied[0].why, "engine-demanded rename");
|
|
1019
|
+
assert.match(applyAmendments(C, am).problems[0], /amendment without why/);
|
|
1020
|
+
});
|
|
859
1021
|
it("supersedes drops the engine-authored preserve row; without it the row stays", () => {
|
|
860
1022
|
assert.ok(!amend({ supersedes: "d9-1" }).contract.preserve.some((p) => p.id === "H1"));
|
|
861
1023
|
assert.ok(amend({}).contract.preserve.some((p) => p.id === "H1"));
|
|
@@ -978,7 +1140,7 @@ describe("mutation check (#4913)", () => {
|
|
|
978
1140
|
});
|
|
979
1141
|
it("#4931: a real repo whose change deleted a non-test file is fully restored", async () => {
|
|
980
1142
|
const { execFileSync } = await import("node:child_process");
|
|
981
|
-
const { writeFileSync, existsSync
|
|
1143
|
+
const { writeFileSync, existsSync } = await import("node:fs");
|
|
982
1144
|
const { join } = await import("node:path");
|
|
983
1145
|
const { tmpDir } = await import("../tests/_tmp.mjs");
|
|
984
1146
|
const d = tmpDir("mut-");
|