forgedock 1.35.0 → 1.37.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
-
import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
|
|
9
|
+
import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
|
|
10
10
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane, detachedLanes } from "./batch.mjs";
|
|
11
11
|
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
|
|
12
12
|
|
|
@@ -89,6 +89,21 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
89
89
|
assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
it("#4685 a breaker tripped by transport errors closes itself after the cooldown; any other stays tripped", async () => {
|
|
93
|
+
const run = async (detail) => {
|
|
94
|
+
let t = 0; const updates = []; const saves = [];
|
|
95
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, readBody: async () => "", now: () => t, sleep: async (ms) => { t += ms; }, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
|
|
96
|
+
onUpdate: (r) => updates.push(r), store: { load: () => null, save: (s) => saves.push(s) },
|
|
97
|
+
runOne: async (n) => { t += 1000; return n <= 3 ? { terminalReason: "engine-error", detail } : { terminalReason: "merged", pr: 5 }; } });
|
|
98
|
+
return { rows, updates, saves };
|
|
99
|
+
};
|
|
100
|
+
const tr = await run("sandbox launch: fetch failed");
|
|
101
|
+
const trip = tr.saves.map((s) => s.breaker).find(Boolean);
|
|
102
|
+
assert.equal(trip?.transport, true);
|
|
103
|
+
const other = await run("boom");
|
|
104
|
+
assert.equal(other.saves.map((s) => s.breaker).find(Boolean)?.transport, undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
92
107
|
it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
|
|
93
108
|
const { now, sleep } = fakeClock();
|
|
94
109
|
const updates = [];
|
|
@@ -105,6 +120,31 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
105
120
|
assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
|
|
106
121
|
});
|
|
107
122
|
|
|
123
|
+
it("gh.auth deferrals (forge#4662) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
|
|
124
|
+
const { now, sleep } = fakeClock();
|
|
125
|
+
const updates = [];
|
|
126
|
+
let calls = 0;
|
|
127
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
|
|
128
|
+
onUpdate: (r) => updates.push(r),
|
|
129
|
+
runOne: async () => (++calls <= 3
|
|
130
|
+
? { terminalReason: "deferred", code: "gh.auth", retryInSeconds: 120, detail: "gh auth failed (HTTP 401)" }
|
|
131
|
+
: { terminalReason: "merged", pr: 5 }) });
|
|
132
|
+
assert.equal(calls, 4);
|
|
133
|
+
assert.equal(rows[0].outcome, "merged");
|
|
134
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
135
|
+
assert.equal(updates.filter((u) => /requeued in 120s: gh.auth/.test(u.detail ?? "")).length, 3);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("#4690 env.lane_limit (needs-human) is never counted by the breaker and the batch keeps admitting", async () => {
|
|
139
|
+
const { now, sleep } = fakeClock();
|
|
140
|
+
const updates = [], ran = [];
|
|
141
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", now, sleep, breaker: { errors: 1, window_min: 30 }, onUpdate: (r) => updates.push(r),
|
|
142
|
+
runOne: async (n) => { ran.push(n); return n === 1 ? { terminalReason: "needs-human", code: "env.lane_limit", detail: "TasksMax" } : { terminalReason: "merged", pr: n }; } });
|
|
143
|
+
assert.deepEqual(ran, [1, 2, 3]);
|
|
144
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
145
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "merged", "merged"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
108
148
|
it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
|
|
109
149
|
const { now, sleep, c } = fakeClock();
|
|
110
150
|
const t0 = c.t, starts = {};
|
|
@@ -309,6 +349,26 @@ describe("#4318: the batch dispatcher's signal relabel and the needs-human relab
|
|
|
309
349
|
const b = hook({ state: "OPEN", body: "" }, { comment: true });
|
|
310
350
|
assert.equal(await b.run(), true);
|
|
311
351
|
});
|
|
352
|
+
it("#4736: a mechanical park creates its parked:<code> label before the edit", async () => {
|
|
353
|
+
const calls = [];
|
|
354
|
+
const gh = async (a) => { calls.push(a.slice(0, 3).join(" ")); return a[1] === "view" ? JSON.stringify({ state: "OPEN", body: "" }) : ""; };
|
|
355
|
+
assert.equal(await makeRelabelNeedsHuman({ gh, repo: "o/r", now: () => 1000 })({ issue: 7, evidence: "e", parked: { code: "ci.red", pr: 3 } }), true);
|
|
356
|
+
assert.deepEqual(calls.slice(0, 3), ["issue view 7", "label create parked:ci.red", "issue edit 7"]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("#4736: a parked relabel that reports failed is not reported as queued for remediation", async () => {
|
|
361
|
+
const leanIo = { gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })), git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") };
|
|
362
|
+
const out = [], l = console.log;
|
|
363
|
+
console.log = (...x) => out.push(x.join(" "));
|
|
364
|
+
try {
|
|
365
|
+
await orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("pf-"), batchRoot: tmpDir("pf-batch-"), allowTmpfs: true,
|
|
366
|
+
emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null, decide: () => {},
|
|
367
|
+
orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } },
|
|
368
|
+
relabelNeedsHuman: async (x) => (x.parked ? "failed" : true),
|
|
369
|
+
leanModule: { runCore: async () => ({ terminalReason: "needs-human", code: "ci.red", detail: "x", pr: 5 }) } });
|
|
370
|
+
} finally { console.log = l; }
|
|
371
|
+
assert.doesNotMatch(out.join("\n"), /remediation queue/);
|
|
312
372
|
});
|
|
313
373
|
});
|
|
314
374
|
|
|
@@ -428,6 +488,19 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
|
|
|
428
488
|
assert.deepEqual(plain.map((r) => r.issue), [1]);
|
|
429
489
|
});
|
|
430
490
|
|
|
491
|
+
it("#4687 --add re-queues an issue that is skipped in the batch; any other ended issue stays 'already in batch'", () => {
|
|
492
|
+
const root = tmpDir("batch-add-skipped-");
|
|
493
|
+
const b = openBatch(root, "b1");
|
|
494
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
495
|
+
b.store.save({ order: [1, 2, 3], results: { 1: { outcome: "needs-human" }, 2: { outcome: "skipped", detail: "dependency #1 ended needs-human" }, 3: { outcome: "merged" } } });
|
|
496
|
+
const r = b.addIssues([2, 3, 1]);
|
|
497
|
+
assert.deepEqual(r.requeued, [2]);
|
|
498
|
+
assert.deepEqual(r.added, []);
|
|
499
|
+
assert.deepEqual(r.skipped.map((x) => [x.issue, x.reason]), [[3, "already in batch"], [1, "already in batch"]]);
|
|
500
|
+
assert.deepEqual(b.readRequeued().map((e) => e.issue), [2]);
|
|
501
|
+
assert.deepEqual(b.readAdded(), []);
|
|
502
|
+
});
|
|
503
|
+
|
|
431
504
|
it("the add file: integers only, no issue twice, cumulative; --add through the CLI skips closed and unreadable issues", async () => {
|
|
432
505
|
const root = tmpDir("batch-add-");
|
|
433
506
|
const b = openBatch(root, "b1");
|
|
@@ -528,6 +601,37 @@ describe("#4376: forgedock orchestrate --cancel", () => {
|
|
|
528
601
|
assert.deepEqual(saved.deferrals, { 1: 1 });
|
|
529
602
|
});
|
|
530
603
|
|
|
604
|
+
for (const stopRemote of [false, true]) {
|
|
605
|
+
it(`a cancel ${stopRemote ? "with" : "without"} stopRemote ${stopRemote ? "stops" : "leaves"} a remote lane (#4612)`, async () => {
|
|
606
|
+
let cancel = null, saved = null;
|
|
607
|
+
const store = { load: () => saved, save: (s) => { saved = s; }, log: () => {} };
|
|
608
|
+
const started = [], stops = [];
|
|
609
|
+
let release;
|
|
610
|
+
const gate = new Promise((r) => { release = r; });
|
|
611
|
+
const done = orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", store, getCancelled: () => cancel,
|
|
612
|
+
stopLane: (n, rec) => { stops.push([n, rec.remote]); release(); },
|
|
613
|
+
runOne: async (n, _lane, ctx) => {
|
|
614
|
+
started.push(n);
|
|
615
|
+
ctx.onStart?.({ remote: true, handle: { id: "sbx-1" } });
|
|
616
|
+
if (!stopRemote) setTimeout(release, 100);
|
|
617
|
+
await gate;
|
|
618
|
+
return { terminalReason: "deferred", code: "run.interrupted" };
|
|
619
|
+
} });
|
|
620
|
+
await until(() => started.includes(1));
|
|
621
|
+
cancel = { at: new Date().toISOString(), reason: "operator", ...(stopRemote ? { stopRemote: true } : {}) };
|
|
622
|
+
await done;
|
|
623
|
+
assert.deepEqual(stops, stopRemote ? [[1, true]] : []);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
it("requestCancel records stopRemote only when asked (#4612)", () => {
|
|
628
|
+
const b = openBatch(tmpDir("batch-cancel-stop-"), "b1");
|
|
629
|
+
b.requestCancel({});
|
|
630
|
+
assert.equal(b.readCancel().stopRemote, undefined);
|
|
631
|
+
b.requestCancel({ stopRemote: true });
|
|
632
|
+
assert.equal(b.readCancel().stopRemote, true);
|
|
633
|
+
});
|
|
634
|
+
|
|
531
635
|
it("through the CLI: --cancel during a lane holds the rest, a finished batch is left alone, unknown ids reject, --resume clears it", async () => {
|
|
532
636
|
const root = tmpDir("batch-cancel-cli-");
|
|
533
637
|
const leanIo = {
|
|
@@ -628,7 +732,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
628
732
|
|
|
629
733
|
it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
|
|
630
734
|
const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
|
|
631
|
-
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
|
|
735
|
+
if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
|
|
632
736
|
if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
|
|
633
737
|
assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
|
|
634
738
|
if (prs instanceof Error) throw prs;
|
|
@@ -642,6 +746,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
|
|
|
642
746
|
assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
|
|
643
747
|
assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
|
|
644
748
|
assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
|
|
749
|
+
assert.deepEqual(await mk('{"state":"CLOSED","stateReason":"NOT_PLANNED"}', "[]")(9), { outcome: "invalid", detail: "issue closed (not planned)", final: true }, "#4687: not planned is final");
|
|
645
750
|
assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
|
|
646
751
|
assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
|
|
647
752
|
assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
|
|
@@ -711,6 +816,75 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
|
|
|
711
816
|
});
|
|
712
817
|
});
|
|
713
818
|
|
|
819
|
+
describe("#4550: forgedock orchestrate --requeue", () => {
|
|
820
|
+
const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
|
|
821
|
+
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 () => {
|
|
822
|
+
const bodies = { 1: "", 2: "Depends on #1", 3: "" };
|
|
823
|
+
let entries = [];
|
|
824
|
+
const started = [], g3 = gate(), log = [], decisions = [];
|
|
825
|
+
const done = orchestrate({ issues: [1, 2, 3], parallel: 3, pollMs: 5, readBody: async (n) => bodies[n] ?? "", getRequeued: () => entries,
|
|
826
|
+
decide: (d) => { decisions.push(d); }, onUpdate: (r) => log.push(r),
|
|
827
|
+
runOne: async (n) => {
|
|
828
|
+
started.push(n);
|
|
829
|
+
if (n === 3) { await g3.p; return { terminalReason: "merged" }; }
|
|
830
|
+
if (n === 1 && started.filter((x) => x === 1).length === 1) return { terminalReason: "needs-human", detail: "builder.blocked: product-decision" };
|
|
831
|
+
return { terminalReason: "merged" };
|
|
832
|
+
} });
|
|
833
|
+
await until(() => log.some((r) => r.issue === 2 && r.outcome === "skipped"));
|
|
834
|
+
entries = [{ issue: 1, reason: "decided in scope", by: "operator", at: "2026-10-24T00:00:00Z" }];
|
|
835
|
+
await until(() => started.filter((x) => x === 2).length === 1, 3000);
|
|
836
|
+
g3.open();
|
|
837
|
+
const rows = await done;
|
|
838
|
+
assert.deepEqual(started.filter((x) => x === 1).length, 2, "relaunched once");
|
|
839
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [3, "merged"]]);
|
|
840
|
+
assert.equal(rows[0].requeued.reason, "decided in scope");
|
|
841
|
+
assert.equal(rows[0].requeued.prior[0].outcome, "needs-human");
|
|
842
|
+
assert.deepEqual(formatOutcomeLists(rows), ["requeued: #1 (decided in scope)"]);
|
|
843
|
+
assert.ok(decisions.some((d) => d.issue === 1 && /requeued/.test(d.what) && d.why === "decided in scope"));
|
|
844
|
+
assert.ok(started.indexOf(2) > started.lastIndexOf(1) - 1 && started.filter((x) => x === 2).length === 1, "#2 ran once, after #1 was relaunched");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("an entry is applied once; one for an issue that is not terminal changes nothing", async () => {
|
|
848
|
+
const entries = [{ issue: 1, reason: "r", by: "operator", at: "t1" }];
|
|
849
|
+
let runs = 0;
|
|
850
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", getRequeued: () => entries,
|
|
851
|
+
runOne: async () => { runs++; return { terminalReason: "merged" }; } });
|
|
852
|
+
assert.equal(runs, 1);
|
|
853
|
+
assert.equal(rows[0].requeued, undefined);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("batch.requeue: terminal issues are recorded; unknown, merged and double requests are refused; --add still says already in batch", () => {
|
|
857
|
+
const b = openBatch(tmpDir("batch-requeue-"), "b1");
|
|
858
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
859
|
+
b.store.save({ order: [1, 2, 3, 4], results: { 1: { outcome: "needs-human" }, 2: { outcome: "merged" }, 3: { outcome: "dependency-blocked" } } });
|
|
860
|
+
const r = b.requeue([1, 2, 3, 4, 9, "x", 1], { reason: "why", by: "me" });
|
|
861
|
+
assert.deepEqual(r.requeued, [1, 3]);
|
|
862
|
+
assert.deepEqual(r.refused.map((x) => [x.issue, x.reason]), [[2, "not terminal"], [4, "not terminal"], [9, "not in batch"], ["x", "not an integer"], [1, "already requeued"]]);
|
|
863
|
+
assert.deepEqual(b.readRequeued().map((e) => [e.issue, e.reason, e.by]), [[1, "why", "me"], [3, "why", "me"]]);
|
|
864
|
+
assert.equal(b.unrunRequeues().length, 2);
|
|
865
|
+
assert.deepEqual(b.addIssues([1]).skipped, [{ issue: 1, reason: "already in batch" }]);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("the CLI refuses a needs-human-labelled or closed issue and writes nothing; an open, cleared one is requeued (a finished batch's report is cleared)", async () => {
|
|
869
|
+
const root = tmpDir("batch-requeue-cli-");
|
|
870
|
+
const b = openBatch(root, "b1");
|
|
871
|
+
b.writePlan({ argv: ["1"], repo: "o/r" });
|
|
872
|
+
b.store.save({ order: [5, 6, 7], results: { 5: { outcome: "needs-human" }, 6: { outcome: "needs-human" }, 7: { outcome: "needs-human" } } });
|
|
873
|
+
b.writeReport({ rows: [], report: "x" });
|
|
874
|
+
const io = { gh: async (a) => JSON.stringify({ state: a[2] === "6" ? "CLOSED" : "OPEN", labels: a[2] === "7" ? [{ name: "needs-human" }] : [] }) };
|
|
875
|
+
const bad = await quiet(() => orchestrateFromCli(["--requeue", "b1", "6", "7", "--reason", "r"], { batchRoot: root, io }));
|
|
876
|
+
assert.deepEqual(bad.requeued, []);
|
|
877
|
+
assert.deepEqual(bad.refused.map((x) => x.issue), [6, 7]);
|
|
878
|
+
assert.match(bad.refused[1].reason, /needs-human/);
|
|
879
|
+
assert.deepEqual(b.readRequeued(), []);
|
|
880
|
+
assert.ok(b.readReport(), "nothing changed");
|
|
881
|
+
const ok = await quiet(() => orchestrateFromCli(["--requeue", "b1", "5", "--reason", "decided"], { batchRoot: root, io }));
|
|
882
|
+
assert.deepEqual(ok.requeued, [5]);
|
|
883
|
+
assert.equal(b.readRequeued()[0].reason, "decided");
|
|
884
|
+
assert.equal(b.readReport(), null, "the finished batch is reopened for --resume");
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
714
888
|
describe("#4540: remote admissions are capped to the provider's free capacity", () => {
|
|
715
889
|
const run = async (capacity) => {
|
|
716
890
|
const c = { t: 1_000_000 };
|
|
@@ -56,7 +56,7 @@ const contract = (reqs) => ({ requirements: reqs, deliverables: reqs.filter((r)
|
|
|
56
56
|
preserve: [{ id: "P1", what: `${Q.AC3} (src/billing.js price())` }, { id: "P2", what: `${Q.AC2} (test/routes.test.js)` }], validate: [{ id: "V1", command: V1 }] });
|
|
57
57
|
const FULL = () => contract([req("R1"), req("R2"), req("R3"), req("R4"), req("R5")]);
|
|
58
58
|
const OMIT = () => contract([req("R1"), req("R2"), req("R4"), req("R5")]);
|
|
59
|
-
const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
|
|
59
|
+
const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { bucket: "new-issue", why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
|
|
60
60
|
|
|
61
61
|
// ---- world ----
|
|
62
62
|
function makeWorld(dir, script) {
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -27,6 +27,8 @@ const INCIDENTAL_RE = /(^|\/)(CHANGELOG|CHANGES|HISTORY)(\.[a-z]+)?$|(^|\/)(pack
|
|
|
27
27
|
export const MIN_QUOTE_CHARS = 12;
|
|
28
28
|
/** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
|
|
29
29
|
export const MAX_CONTRACT_CHARS = 40000;
|
|
30
|
+
import { CHECK_ID_RE } from "./postdeploy-check.mjs";
|
|
31
|
+
import { normaliseDecision, deadAnchors } from "./decisions.mjs";
|
|
30
32
|
const C = OUTCOME_CODES;
|
|
31
33
|
|
|
32
34
|
export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
@@ -231,7 +233,10 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
231
233
|
if (!DISPOSITIONS.includes(r?.disposition)) problems.push(`${id}: disposition must be one of ${DISPOSITIONS.join("|")}`);
|
|
232
234
|
if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
|
|
233
235
|
if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
|
|
236
|
+
if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
|
|
234
237
|
if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
|
|
238
|
+
for (const m of bucketProblems(r)) problems.push(`${id}: ${m}`);
|
|
239
|
+
if (r?.class != null && !FOLLOWUP_CLASSES.includes(r.class)) problems.push(`${id}: class must be one of ${FOLLOWUP_CLASSES.join("|")}`);
|
|
235
240
|
if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
|
|
236
241
|
if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
|
|
237
242
|
else if (isOutOfBand(r) && blank(r?.evidence)) problems.push(`${id}: verify out_of_band needs evidence naming the job or workflow that measures it (or set verify "in_lane" if this change can check it)`);
|
|
@@ -266,7 +271,8 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
266
271
|
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
267
272
|
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
268
273
|
|
|
269
|
-
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
|
|
274
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement", "supersedes", "decision"];
|
|
275
|
+
const GUARD_ID_RE = /^#?\d+$/;
|
|
270
276
|
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
271
277
|
|
|
272
278
|
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
@@ -280,11 +286,26 @@ function normalizeAmendment(a) {
|
|
|
280
286
|
return n;
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
const REF_SEP_RE = /\s*(?:[/,;&+]|\band\b)\s*/i, REF_RANGE_RE = /^R(\d+)\s*[-\u2013\u2014]\s*R?(\d+)$/i, REF_MAX = 50;
|
|
290
|
+
|
|
291
|
+
/** forge#4655: a slash/comma-joined or ranged requirement reference (`R2/R3`, `R2, R3`, `R2–R3`) as its individual ids; a single id or anything else is returned as is. */
|
|
292
|
+
function splitRequirementRefs(ref) {
|
|
293
|
+
if (typeof ref !== "string") return [ref];
|
|
294
|
+
const ids = [];
|
|
295
|
+
for (const part of ref.trim().split(REF_SEP_RE)) {
|
|
296
|
+
const m = REF_RANGE_RE.exec(part);
|
|
297
|
+
if (m && +m[1] <= +m[2] && +m[2] - +m[1] < REF_MAX) for (let i = +m[1]; i <= +m[2]; i++) ids.push(`R${i}`);
|
|
298
|
+
else ids.push(part);
|
|
299
|
+
}
|
|
300
|
+
return ids.length > 1 ? ids : [ref];
|
|
301
|
+
}
|
|
302
|
+
|
|
283
303
|
/** The rejection reason, naming the valid kinds or the missing field so the builder's correction round can fix it. */
|
|
284
304
|
function amendmentProblem(a, tag) {
|
|
285
305
|
const kind = a?.kind;
|
|
286
306
|
const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
|
|
287
|
-
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)"
|
|
307
|
+
: 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)"
|
|
308
|
+
: kind === "decision" ? "needs decision {choice, anchors: [paths or globs], symbols?, supersedes?: decision id}" : null;
|
|
288
309
|
const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
|
|
289
310
|
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
290
311
|
}
|
|
@@ -294,14 +315,25 @@ function amendmentProblem(a, tag) {
|
|
|
294
315
|
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
295
316
|
* `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
|
|
296
317
|
* matching path is refused; the repo's generator or bot owns that file.
|
|
318
|
+
* `decision` {decision:{choice, anchors, symbols?, supersedes?}} (#4695): every anchor must match a file of `baseFiles` (the
|
|
319
|
+
* base tree; null skips the check); `supersedes` names the decision whose engine-authored preserve row it replaces and
|
|
320
|
+
* drops that row; returns the records in `decisions`.
|
|
297
321
|
*/
|
|
298
|
-
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
|
|
322
|
+
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [], baseFiles = null } = {}) {
|
|
299
323
|
const c = structuredClone(frozen);
|
|
300
324
|
c.deliverables = c.deliverables ?? [];
|
|
301
|
-
const applied = [], toTrack = [], problems = [];
|
|
325
|
+
const applied = [], toTrack = [], problems = [], decisions = [];
|
|
302
326
|
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
327
|
+
const rows = [];
|
|
303
328
|
for (const raw of Array.isArray(amendments) ? amendments : []) {
|
|
304
|
-
const
|
|
329
|
+
const n = normalizeAmendment(raw);
|
|
330
|
+
if (n && (TRACKED.includes(n.kind) || n.kind === "amend-requirement") && typeof n.requirement === "string") {
|
|
331
|
+
const ids = splitRequirementRefs(n.requirement);
|
|
332
|
+
if (ids.length > 1) { for (const id of ids) rows.push({ a: { ...n, requirement: id }, raw }); continue; }
|
|
333
|
+
}
|
|
334
|
+
rows.push({ a: n, raw });
|
|
335
|
+
}
|
|
336
|
+
for (const { a, raw } of rows) {
|
|
305
337
|
const tag = JSON.stringify(raw ?? null).slice(0, 400);
|
|
306
338
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
307
339
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
@@ -323,6 +355,12 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
323
355
|
if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
|
|
324
356
|
c.requirements.push(r);
|
|
325
357
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
358
|
+
} else if (a.kind === "supersedes" && GUARD_ID_RE.test(String(a.guard ?? "").trim())) {
|
|
359
|
+
// #4693: retires one guard's guard_broken for this lane; the reviewer judges it, the engine tells the guarded issue after merge
|
|
360
|
+
const guard = `#${String(a.guard).trim().replace(/^#/, "")}`;
|
|
361
|
+
c.supersedes = [...(c.supersedes ?? []).filter((s) => s.guard !== guard), { guard, why: String(a.why).trim() }];
|
|
362
|
+
applied.push({ kind: a.kind, target: guard, why: String(a.why).trim() });
|
|
363
|
+
continue;
|
|
326
364
|
} else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
|
|
327
365
|
// #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
|
|
328
366
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
@@ -331,11 +369,25 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
331
369
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
332
370
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
333
371
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
334
|
-
if (!TRACKED.includes(r.disposition)) {
|
|
372
|
+
if (!TRACKED.includes(r.disposition)) {
|
|
373
|
+
if (a.bucket != null) r.bucket = a.bucket; // #4724
|
|
374
|
+
if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
|
|
375
|
+
r.disposition = a.kind; r.why = a.why; r.amended = true; r.bucket ??= "post-deploy"; toTrack.push(r); // #4724: an amendment without a bucket defaults to post-deploy
|
|
376
|
+
if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
|
|
377
|
+
}
|
|
378
|
+
} else if (a.kind === "decision") {
|
|
379
|
+
const d = normaliseDecision({ ...a.decision, why: a.decision?.why ?? a.why });
|
|
380
|
+
if (!d) { problems.push(amendmentProblem(a, tag)); continue; }
|
|
381
|
+
const dead = Array.isArray(baseFiles) ? deadAnchors(d.anchors, baseFiles) : [];
|
|
382
|
+
if (dead.length) { problems.push(`decision anchor(s) ${dead.join(", ")} match no file on the base; anchor a path that exists there`); continue; }
|
|
383
|
+
if (!blank(a.decision?.supersedes)) c.preserve = (c.preserve ?? []).filter((p) => p.decision !== a.decision.supersedes && p.id !== a.decision.supersedes);
|
|
384
|
+
decisions.push(a.decision?.supersedes ? { ...d, supersedes: String(a.decision.supersedes) } : d);
|
|
385
|
+
applied.push({ kind: a.kind, target: d.choice.slice(0, 60), why: String(a.why).trim() });
|
|
386
|
+
continue;
|
|
335
387
|
} else { problems.push(amendmentProblem(a, tag)); continue; }
|
|
336
388
|
applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
|
|
337
389
|
}
|
|
338
|
-
return { contract: c, applied, toTrack, problems };
|
|
390
|
+
return { contract: c, applied, toTrack, problems, decisions };
|
|
339
391
|
}
|
|
340
392
|
|
|
341
393
|
const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
|
|
@@ -387,8 +439,12 @@ export function repoTracks(paths) {
|
|
|
387
439
|
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
388
440
|
* tool), for the trajectory; always an array.
|
|
389
441
|
*/
|
|
390
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
|
|
442
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null }) {
|
|
391
443
|
const failures = [], superseded = [], ciCovered = [];
|
|
444
|
+
// #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)
|
|
445
|
+
if (checkIds) for (const r of contract.requirements ?? []) {
|
|
446
|
+
if (r.disposition === "post-deploy" && !(r.check && checkIds.has(r.check))) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "check_unknown", detail: `${r.id} post-deploy check ${r.check ? `'${r.check}'` : "id"} is neither on the default branch nor added by this PR (.forge/post-deploy-checks.json)` });
|
|
447
|
+
}
|
|
392
448
|
const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
|
|
393
449
|
const creep = outside(contract);
|
|
394
450
|
const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
|
|
@@ -425,6 +481,9 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
425
481
|
}
|
|
426
482
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
427
483
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
484
|
+
// #4693: a broken guard fails unless the contract carries a supersedes amendment for it
|
|
485
|
+
const retired = new Set((contract.supersedes ?? []).map((s) => s.guard));
|
|
486
|
+
for (const f of guardFailures ?? []) if (!retired.has(f.guard)) failures.push(f);
|
|
428
487
|
return { ok: failures.length === 0, failures, superseded, ciCovered };
|
|
429
488
|
}
|
|
430
489
|
|
|
@@ -622,6 +681,26 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
|
|
|
622
681
|
return { complete: p.length === 0, problems: p, missing: [...ids] };
|
|
623
682
|
}
|
|
624
683
|
|
|
684
|
+
|
|
685
|
+
/** #4726: a requirement quote that carries a `[type:e2e]` criterion. */
|
|
686
|
+
const wantsE2e = (r) => /\[type:e2e\]/i.test(String(r?.quote ?? ""));
|
|
687
|
+
const E2E_CMD_RE = /\b(?:playwright|cypress|e2e|live[- ]stack|wdio)\b/i;
|
|
688
|
+
/**
|
|
689
|
+
* #4726: whether a validate command runs a real end-to-end check: an entry of `verify.e2e_checks`, a file of the e2e spec set
|
|
690
|
+
* (`e2e.isSpec`), or Playwright/Cypress/e2e/live-stack. A unit test file with stubbed I/O is not one.
|
|
691
|
+
*/
|
|
692
|
+
export function isE2eCommand(command, e2e = null) {
|
|
693
|
+
const cmd = String(command ?? "");
|
|
694
|
+
if ((e2e?.checks ?? []).some((c) => c && cmd.includes(c))) return true;
|
|
695
|
+
if (typeof e2e?.isSpec === "function" && cmd.split(/\s+/).some((t) => t && e2e.isSpec(t.replace(/^["']+|["']+$/g, "")))) return true;
|
|
696
|
+
return E2E_CMD_RE.test(cmd);
|
|
697
|
+
}
|
|
698
|
+
/** #4726: true when a V-ref named in the row's `validated_by` resolves to a contract validate command that is a real e2e check. */
|
|
699
|
+
export function e2eVerified(row, contract, e2e = null) {
|
|
700
|
+
const by = String(row?.validated_by ?? "");
|
|
701
|
+
const refs = (contract?.validate ?? []).filter((v) => v?.id && new RegExp(`(?:^|[^A-Za-z0-9_])${v.id}(?![A-Za-z0-9_])`).test(by));
|
|
702
|
+
return refs.some((v) => isE2eCommand(v.command, e2e)) || (!refs.length && isE2eCommand(by, e2e));
|
|
703
|
+
}
|
|
625
704
|
/**
|
|
626
705
|
* The engine's decision from the reviewer's rows. state: conforms | nonconforming | review.unparseable | review.incomplete
|
|
627
706
|
* | review.inconsistent. failures[] are {code, detail}; `inconsistent` marks a CONFORMS verdict whose rows fail;
|
|
@@ -643,6 +722,8 @@ export function decide(raw, ctx) {
|
|
|
643
722
|
if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
644
723
|
} else if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
645
724
|
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
725
|
+
} else if (r.disposition === "deliver" && wantsE2e(r) && !e2eVerified(row, ctx.contract, ctx.e2e)) {
|
|
726
|
+
f.push({ code: C.CONFORMANCE_E2E_UNVERIFIED, detail: `${r.id} is a [type:e2e] criterion but ${row.validated_by || "its validated_by"} is not a real end-to-end check (the repo's e2e set, Playwright/browser/live stack); run one, or mark ${r.id} post-deploy with a tracked follow-up` });
|
|
646
727
|
}
|
|
647
728
|
if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
|
|
648
729
|
}
|
|
@@ -742,10 +823,54 @@ export function formatShadowSummary(rows) {
|
|
|
742
823
|
return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}`;
|
|
743
824
|
}
|
|
744
825
|
|
|
745
|
-
/**
|
|
746
|
-
export
|
|
826
|
+
/** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
|
|
827
|
+
export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops"];
|
|
828
|
+
/** #4724: the bucket every deferred row gets at contract time. `date:<YYYY-MM-DD>` and `owner:<action>` carry a value. */
|
|
829
|
+
export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue"]);
|
|
830
|
+
const DATE_BUCKET_RE = /^date:(\d{4})-(\d{2})-(\d{2})$/;
|
|
831
|
+
/** The kind of a bucket (`date:2026-12-01` -> `date`), or null when absent or unknown. */
|
|
832
|
+
export const bucketKind = (b) => { const k = String(b ?? "").split(":")[0]; return BUCKETS.includes(k) ? k : null; };
|
|
833
|
+
/** #4724: problems with a deferred row's bucket (a defer or post-deploy row needs exactly one; owner actions are never post-deploy). */
|
|
834
|
+
export function bucketProblems(r) {
|
|
835
|
+
const b = r?.bucket;
|
|
836
|
+
if (!TRACKED.includes(r?.disposition)) return b == null ? [] : ["bucket applies only to a defer or post-deploy requirement"];
|
|
837
|
+
if (b == null || b === "") return [`${r.disposition} needs a bucket (${BUCKETS.join("|")}; \`date:<YYYY-MM-DD>\`, \`owner:<action>\`)`];
|
|
838
|
+
const k = bucketKind(b), out = [];
|
|
839
|
+
if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action> or new-issue`);
|
|
840
|
+
else if (k === "date") {
|
|
841
|
+
const m = DATE_BUCKET_RE.exec(String(b)), d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
842
|
+
if (!m || d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) out.push(`bucket '${b}' must be an absolute date:<YYYY-MM-DD>, not a relative wait`);
|
|
843
|
+
} else if (k === "owner" && blank(String(b).slice(6))) out.push("bucket owner needs the action: owner:<action>");
|
|
844
|
+
if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
|
|
845
|
+
if (k === "owner" && r.class != null && r.class !== "external" && r.class !== "ops") out.push("an owner bucket belongs in class external or ops, never post-deploy");
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
/** The follow-up class of a requirement: owner -> external, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
|
|
849
|
+
export function followupClass(r) {
|
|
850
|
+
const k = bucketKind(r?.bucket);
|
|
851
|
+
if (k === "owner") return r?.class === "ops" ? "ops" : "external";
|
|
852
|
+
if (k === "new-issue") return r?.class && r.class !== "post-deploy" && FOLLOWUP_CLASSES.includes(r.class) ? r.class : "engineering";
|
|
853
|
+
if (k) return "post-deploy";
|
|
854
|
+
return FOLLOWUP_CLASSES.includes(r?.class) ? r.class : r?.disposition === "post-deploy" ? "post-deploy" : "engineering";
|
|
855
|
+
}
|
|
856
|
+
/** #4724: deferred items per bucket kind (`none` = no bucket), for the batch telemetry line. */
|
|
857
|
+
export const bucketTally = (reqs) => (reqs ?? []).reduce((t, r) => { const k = bucketKind(r?.bucket) ?? "none"; t[k] = (t[k] ?? 0) + 1; return t; }, {});
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* One line per requirement for the follow-up script: a stable key (id + quote), a why and a class. Tabs and newlines removed.
|
|
861
|
+
* #4724: a bucketed row's why carries `[bucket:<kind>]` (the script counts and routes on it) and a date bucket's `due:<date>`
|
|
862
|
+
* plus `from:<date>` (the contract-time anchor, `now` ms) that the gate re-anchors when the parent ships later.
|
|
863
|
+
*/
|
|
864
|
+
export function followupItems(reqs, { now = Date.now() } = {}) {
|
|
747
865
|
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
748
|
-
|
|
866
|
+
const check = (r) => (r.disposition === "post-deploy" && r.check ? `[check:${flat(r.check)}] ` : ""); // #4626: the follow-up item names its check
|
|
867
|
+
return reqs.map((r) => {
|
|
868
|
+
const k = bucketKind(r.bucket);
|
|
869
|
+
const tag = k ? `[bucket:${k}] ` : "";
|
|
870
|
+
const due = k === "date" ? `due:${String(r.bucket).slice(5)} from:${new Date(now).toISOString().slice(0, 10)} ` : "";
|
|
871
|
+
const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
|
|
872
|
+
return { id: r.id, class: followupClass(r), key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${head}${tag}${check(r)}${due}${r.why ?? ""}`).slice(0, 400).trim() };
|
|
873
|
+
});
|
|
749
874
|
}
|
|
750
875
|
|
|
751
876
|
/** #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. */
|