forgedock 1.32.1 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +7 -3
- package/bin/engine/phases.mjs +3 -3
- package/bin/engine-cli.mjs +56 -11
- package/bin/forgedock.mjs +21 -1
- package/bin/lean/basesync.mjs +98 -0
- package/bin/lean/basesync.test.mjs +151 -0
- package/bin/lean/batch.test.mjs +103 -2
- package/bin/lean/budget.mjs +7 -1
- package/bin/lean/budget.test.mjs +9 -1
- package/bin/lean/conformance.mjs +20 -7
- package/bin/lean/conformance.test.mjs +29 -2
- package/bin/lean/depcache.mjs +23 -3
- package/bin/lean/depcache.test.mjs +42 -1
- package/bin/lean/effects.mjs +1 -1
- package/bin/lean/execution.mjs +102 -12
- package/bin/lean/execution.test.mjs +101 -1
- package/bin/lean/hostkill.mjs +8 -0
- package/bin/lean/lean.mjs +49 -21
- package/bin/lean/lean.test.mjs +133 -7
- package/bin/lean/lightlane.mjs +38 -13
- package/bin/lean/lightlane.test.mjs +35 -1
- package/bin/lean/orchestrate.mjs +63 -17
- package/bin/lean/prompts.mjs +17 -7
- package/bin/lean/recovery.test.mjs +26 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
- package/bin/lean/sandbox-remote.mjs +40 -7
- package/bin/lean/seqlease.mjs +49 -2
- package/bin/lean/seqlease.test.mjs +14 -0
- package/bin/lean/sequence.mjs +54 -6
- package/bin/lean/sequence.test.mjs +83 -1
- package/bin/pool/agent.mjs +435 -0
- package/bin/pool/cli.mjs +110 -0
- package/bin/pool/docker.mjs +56 -0
- package/bin/pool/http.mjs +55 -0
- package/bin/pool/router.mjs +299 -0
- package/bin/pool/store.mjs +25 -0
- package/bin/pool/version.mjs +44 -0
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/local-source.mjs +1 -1
- package/bin/stats/pool.mjs +51 -0
- package/bin/stats/pool.test.mjs +39 -0
- package/bin/stats/query.mjs +2 -2
- package/bin/stats/stats.test.mjs +15 -1
- package/commands/orchestrate.md +3 -3
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +7 -0
- package/package.json +2 -1
- package/pool/README.md +13 -0
- package/pool/image/Dockerfile +46 -0
- package/pool/image/claude-shim +8 -0
- package/pool/image/cs-cred +12 -0
- package/pool/image/entrypoint.sh +61 -0
- package/pool/image/gh-shim +6 -0
- package/pool/install/install-agent-linux.sh +53 -0
- package/pool/install/install-agent.ps1 +225 -0
- package/pool/install/install-agent.sh +101 -0
- package/pool/install/install-router.sh +67 -0
- package/pool/install/keepalive.ps1 +36 -0
- package/pool/install/uninstall-agent.ps1 +20 -0
- package/scripts/hotspots.sh +33 -21
- package/scripts/hotspots.test.sh +22 -3
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -120,6 +120,16 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
120
120
|
assert.equal(starts[1].length, 2);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it("#4518 cli.host_killed deferrals are requeued and never trip the breaker", async () => {
|
|
124
|
+
const { now, sleep } = fakeClock();
|
|
125
|
+
const updates = []; let calls = 0;
|
|
126
|
+
const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 1, window_min: 30 },
|
|
127
|
+
onUpdate: (r) => updates.push(r),
|
|
128
|
+
runOne: async () => (++calls <= 2 ? { terminalReason: "deferred", code: "cli.host_killed", retryInSeconds: 60, detail: "builder host kill" } : { terminalReason: "merged", pr: 5 }) });
|
|
129
|
+
assert.equal(rows[0].outcome, "merged");
|
|
130
|
+
assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
|
|
131
|
+
});
|
|
132
|
+
|
|
123
133
|
it("the breaker stops admissions after N engine-errors in M minutes and lets running lanes finish", async () => {
|
|
124
134
|
const started = [];
|
|
125
135
|
let releaseSlow;
|
|
@@ -203,9 +213,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
203
213
|
assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
|
|
204
214
|
assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
|
|
205
215
|
assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
|
|
206
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } });
|
|
216
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
207
217
|
writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
|
|
208
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 } });
|
|
218
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
|
|
209
219
|
});
|
|
210
220
|
|
|
211
221
|
it("run-issue --result-file writes the lane result, also when the run throws", async () => {
|
|
@@ -659,3 +669,94 @@ describe("#4504: a resume settles lanes that ended while no dispatcher ran", ()
|
|
|
659
669
|
assert.deepEqual(launched.sort(), [3, 4], "the dead lane without a result is requeued and run once");
|
|
660
670
|
});
|
|
661
671
|
});
|
|
672
|
+
|
|
673
|
+
describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () => {
|
|
674
|
+
it("sandbox.capacity is uncapped and honours retryInSeconds; other codes still cap at maxDeferrals", () => {
|
|
675
|
+
assert.deepEqual(deferPolicy({ code: "sandbox.capacity" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
|
|
676
|
+
assert.deepEqual(deferPolicy({ code: "sandbox.capacity", retryInSeconds: 30 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 30000, host: true });
|
|
677
|
+
for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "rate_limited", "sandbox.start_failed"]) {
|
|
678
|
+
assert.deepEqual(deferPolicy({ code, retryInSeconds: 60 }, { now: 0, deferrals: 3, maxDeferrals: 3 }), { capped: true }, code);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("with K above the pool size every issue eventually runs and none is skipped for capacity", async () => {
|
|
683
|
+
const c = { t: 1_000_000 };
|
|
684
|
+
const now = () => c.t;
|
|
685
|
+
// Fake clock that advances after a macrotask, so running lanes progress while deferred ones wait.
|
|
686
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
687
|
+
const POOL = 2;
|
|
688
|
+
let inUse = 0;
|
|
689
|
+
const capacityDefers = new Map();
|
|
690
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 4, maxDeferrals: 3, now, sleep, readBody: async () => "",
|
|
691
|
+
runOne: async (n) => {
|
|
692
|
+
if (inUse >= POOL) {
|
|
693
|
+
capacityDefers.set(n, (capacityDefers.get(n) ?? 0) + 1);
|
|
694
|
+
return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 60, detail: "sandbox launch: HTTP 503" };
|
|
695
|
+
}
|
|
696
|
+
inUse++;
|
|
697
|
+
try { await new Promise((r) => setTimeout(r, 20)); } finally { inUse--; }
|
|
698
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
699
|
+
} });
|
|
700
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.outcome]).sort((a, b) => a[0] - b[0]), [1, 2, 3, 4, 5, 6].map((n) => [n, "merged"]));
|
|
701
|
+
assert.ok(!rows.some((r) => /gave up/.test(r.detail ?? "")));
|
|
702
|
+
assert.ok(Math.max(...capacityDefers.values()) > 3, "an issue was deferred for capacity more than maxDeferrals times");
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe("#4540: remote admissions are capped to the provider's free capacity", () => {
|
|
707
|
+
const run = async (capacity) => {
|
|
708
|
+
const c = { t: 1_000_000 };
|
|
709
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
710
|
+
let live = 0, peak = 0, bounced = 0;
|
|
711
|
+
const POOL = 2;
|
|
712
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5], parallel: 5, now: () => c.t, sleep, pollMs: 1, readBody: async () => "",
|
|
713
|
+
remote: { max: 5, isRemote: () => true, capacity: capacity(() => POOL - live) },
|
|
714
|
+
runOne: async (n) => {
|
|
715
|
+
if (live >= POOL) { bounced++; return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "sandbox launch: HTTP 503" }; }
|
|
716
|
+
live++; peak = Math.max(peak, live);
|
|
717
|
+
try { await new Promise((r) => setTimeout(r, 10)); } finally { live--; }
|
|
718
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
719
|
+
} });
|
|
720
|
+
return { rows, peak, bounced };
|
|
721
|
+
};
|
|
722
|
+
it("queues beyond the free slots inside the batch: no 503 bounce, every issue still runs", async () => {
|
|
723
|
+
let at = 0;
|
|
724
|
+
const { rows, bounced } = await run((free) => () => ({ free: free(), at: ++at })); // a fresh sample on every read
|
|
725
|
+
assert.equal(bounced, 0);
|
|
726
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged", "merged", "merged"]);
|
|
727
|
+
});
|
|
728
|
+
it("unknown capacity (null) admits as before and the 503 deferral path is the backstop", async () => {
|
|
729
|
+
const { rows, bounced } = await run(() => () => null);
|
|
730
|
+
assert.ok(bounced > 0);
|
|
731
|
+
assert.deepEqual(rows.map((r) => r.outcome).filter((o) => o !== "merged"), []);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("#4547: opt-in local fallback in mode remote when no pool agent is online", () => {
|
|
736
|
+
const run = async (remoteExtra, online) => {
|
|
737
|
+
const c = { t: 1_000_000 };
|
|
738
|
+
const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
|
|
739
|
+
const where = new Map();
|
|
740
|
+
let remoteTries = 0;
|
|
741
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, now: () => c.t, sleep, pollMs: 1, readBody: async () => "",
|
|
742
|
+
remote: { max: 3, isRemote: () => true, capacity: () => (online === null ? null : { free: null, online, at: 1 }), ...remoteExtra },
|
|
743
|
+
runOne: async (n, _ln, ctx = {}) => {
|
|
744
|
+
where.set(n, ctx.placement?.where ?? "remote");
|
|
745
|
+
if (ctx.placement?.where !== "local") { remoteTries++; if (online === 0) return { terminalReason: "deferred", code: "sandbox.capacity", retryInSeconds: 1, detail: "HTTP 503" }; }
|
|
746
|
+
return { terminalReason: "merged", pr: 100 + n };
|
|
747
|
+
} });
|
|
748
|
+
return { rows, where, remoteTries };
|
|
749
|
+
};
|
|
750
|
+
it("every agent offline: the batch completes on the host", async () => {
|
|
751
|
+
const { rows, where, remoteTries } = await run({ offlineFallback: true }, 0);
|
|
752
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "merged"]);
|
|
753
|
+
assert.ok([...where.values()].every((w) => w === "local"));
|
|
754
|
+
assert.equal(remoteTries, 0);
|
|
755
|
+
});
|
|
756
|
+
it("an agent online, or liveness unknown, stays remote", async () => {
|
|
757
|
+
for (const online of [1, null]) {
|
|
758
|
+
const { where } = await run({ offlineFallback: true }, online);
|
|
759
|
+
assert.ok([...where.values()].every((w) => w === "remote"), String(online));
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
});
|
package/bin/lean/budget.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* else the frozen contract's distinct deliverable paths (S <= 2, M <= 5, else L). The budget for the size is forge.yaml
|
|
6
6
|
* `engine.turn_budget_s|m|l` (defaults 25/60/100; 0 disables that size). The implement stage (the build call and its
|
|
7
7
|
* context/timeout restarts) spends it; on overrun the builder gets one wrap-up grant to finish or stop with a digest, and
|
|
8
|
-
* an unfinished lane ends
|
|
8
|
+
* an unfinished lane ends the non-human `budget-exceeded` reason (code `budget.exceeded`, #4505): the batch recovers it once
|
|
9
|
+
* from the checkpoint at the next size (`recoverySize`); only a final recovery that overruns too ends needs-human. Pure module: no I/O.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
export const TURN_BUDGET_DEFAULTS = Object.freeze({ S: 25, M: 60, L: 100 });
|
|
@@ -36,6 +37,11 @@ export function laneSize({ contract = null, labels = [] } = {}) {
|
|
|
36
37
|
return n <= 2 ? "S" : n <= 5 ? "M" : "L";
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** #4505: the next size up (S -> M -> L; L stays L); anything else unchanged. */
|
|
41
|
+
export const nextSize = (size) => (SIZES.includes(size) ? SIZES[Math.min(SIZES.indexOf(size) + 1, SIZES.length - 1)] : size);
|
|
42
|
+
/** #4505: a `budget.exceeded` recovery lane runs at the next size's budget; any other lane keeps its size. */
|
|
43
|
+
export const recoverySize = (size, recover) => (recover?.code === "budget.exceeded" ? nextSize(size) : size);
|
|
44
|
+
|
|
39
45
|
/** Turns the wrap-up call may use after the budget is spent. */
|
|
40
46
|
export const wrapupGrant = (budget) => Math.max(5, Math.ceil(budget / 5));
|
|
41
47
|
|
package/bin/lean/budget.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { describe, it } from "node:test";
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { writeFileSync, chmodSync } from "node:fs";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
-
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
8
|
+
import { TURN_BUDGET_DEFAULTS, parseTurnBudgets, laneSize, nextSize, recoverySize, wrapupGrant, makeTurnBudget, budgetDigest, formatTurnSummary } from "./budget.mjs";
|
|
9
9
|
import { orchestrate } from "./orchestrate.mjs";
|
|
10
10
|
import { OUTCOME_CODES, RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
11
11
|
import { RECOVER_HOW } from "./lean.mjs";
|
|
@@ -34,6 +34,14 @@ describe("#4392 turn budget", () => {
|
|
|
34
34
|
assert.equal(laneSize({}), null);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it("#4505 a budget.exceeded recovery runs at the next size (S->M->L, L stays L); other lanes keep their size", () => {
|
|
38
|
+
assert.deepEqual(["S", "M", "L"].map(nextSize), ["M", "L", "L"]);
|
|
39
|
+
assert.equal(nextSize(null), null); assert.equal(nextSize("XL"), "XL");
|
|
40
|
+
assert.equal(recoverySize("S", { code: "budget.exceeded", attempt: 1, final: true }), "M");
|
|
41
|
+
assert.equal(recoverySize("L", { code: "budget.exceeded" }), "L");
|
|
42
|
+
assert.equal(recoverySize("S", { code: "ci.red" }), "S"); assert.equal(recoverySize("S", null), "S"); assert.equal(recoverySize(null, { code: "budget.exceeded" }), null);
|
|
43
|
+
});
|
|
44
|
+
|
|
37
45
|
it("the tally: build turns spend the budget, other attempts count only in total; off or unsized is unlimited", () => {
|
|
38
46
|
const tb = makeTurnBudget({ M: 60 });
|
|
39
47
|
assert.equal(tb.record(), null); assert.equal(tb.remaining(), Infinity);
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -261,18 +261,28 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
261
261
|
return { ok: problems.length === 0, problems };
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
265
|
+
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
266
|
+
|
|
264
267
|
/**
|
|
265
268
|
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
266
269
|
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
270
|
+
* `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
|
|
271
|
+
* matching path is refused; the repo's generator or bot owns that file.
|
|
267
272
|
*/
|
|
268
|
-
export function applyAmendments(frozen, amendments, issueText = null) {
|
|
273
|
+
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
|
|
269
274
|
const c = structuredClone(frozen);
|
|
270
275
|
c.deliverables = c.deliverables ?? [];
|
|
271
276
|
const applied = [], toTrack = [], problems = [];
|
|
277
|
+
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
272
278
|
for (const a of Array.isArray(amendments) ? amendments : []) {
|
|
273
279
|
const tag = JSON.stringify(a ?? null).slice(0, 100);
|
|
274
280
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
275
281
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
282
|
+
if (genRes.some((re) => re.test(cleanPath(a.path)))) {
|
|
283
|
+
problems.push(`add-deliverable ${a.path}: generated path (forge.yaml maintenance.generated_paths) is owned by the repo's generator/bot; revert the file instead of amending it in`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
276
286
|
const have = c.deliverables.find((d) => d.path === a.path);
|
|
277
287
|
// An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
|
|
278
288
|
// package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
|
|
@@ -343,9 +353,11 @@ export function repoTracks(paths) {
|
|
|
343
353
|
* `frozen` (#4350): the contract before typed amendments. A failed scope-check validate (isScopeCheck) whose only files
|
|
344
354
|
* outside `frozen` are covered by deliverables an amendment added, with no scope creep left, is superseded by the engine's
|
|
345
355
|
* scope gate: listed in `superseded` [{id, files}], not a failure. Without `frozen`, `superseded` is always [].
|
|
356
|
+
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
357
|
+
* tool), for the trajectory; always an array.
|
|
346
358
|
*/
|
|
347
359
|
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
|
|
348
|
-
const failures = [], superseded = [];
|
|
360
|
+
const failures = [], superseded = [], ciCovered = [];
|
|
349
361
|
const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
|
|
350
362
|
const creep = outside(contract);
|
|
351
363
|
const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
|
|
@@ -355,13 +367,13 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
355
367
|
const r = runs.find((x) => x?.id === v.id);
|
|
356
368
|
// #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
|
|
357
369
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
358
|
-
if (ciVerified && leftToCi) continue;
|
|
370
|
+
if (ciVerified && leftToCi) { ciCovered.push(v.id); continue; }
|
|
359
371
|
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
360
372
|
else if (r.exit !== 0) {
|
|
361
373
|
const rerun = validateRuns?.[v.id];
|
|
362
374
|
if (rerun?.rc === 0) continue;
|
|
363
375
|
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
364
|
-
if (miss && ciVerified) continue;
|
|
376
|
+
if (miss && ciVerified) { ciCovered.push(v.id); continue; }
|
|
365
377
|
if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
|
|
366
378
|
else if (amendedOnly && isScopeCheck(v.command)) superseded.push({ id: v.id, files: amendedOnly });
|
|
367
379
|
else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
@@ -382,7 +394,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
382
394
|
}
|
|
383
395
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
384
396
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
385
|
-
return { ok: failures.length === 0, failures, superseded };
|
|
397
|
+
return { ok: failures.length === 0, failures, superseded, ciCovered };
|
|
386
398
|
}
|
|
387
399
|
|
|
388
400
|
/**
|
|
@@ -419,7 +431,8 @@ export function reproFailures(contract, { base, head } = {}) {
|
|
|
419
431
|
|
|
420
432
|
const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
|
|
421
433
|
/**
|
|
422
|
-
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded
|
|
434
|
+
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded; forge#4519: a
|
|
435
|
+
* `generated` row, from linguist-generated, maintenance.generated_paths or a generated header, never counts) that grew by more
|
|
423
436
|
* than `growth_lines` since the base, with no `hotspot-exempt: <reason>` in a covering deliverable's change or an
|
|
424
437
|
* add-deliverable amendment's why. `hotspots` is scripts/hotspots.sh --base output; null or disabled checks nothing.
|
|
425
438
|
*/
|
|
@@ -431,7 +444,7 @@ export function hotspotGrowth({ contract, result, hotspots, changedFiles = [] })
|
|
|
431
444
|
const exempt = (f) => (contract?.deliverables ?? []).some((d) => pathCovers(d.path, f) && EXEMPT_RE.test(String(d.change ?? "")))
|
|
432
445
|
|| amends.some((a) => a?.kind === "add-deliverable" && pathCovers(a.path, f) && EXEMPT_RE.test(String(a.why ?? "")));
|
|
433
446
|
return hotspots.files
|
|
434
|
-
.filter((h) => h?.over && !h.excluded && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
|
|
447
|
+
.filter((h) => h?.over && !h.excluded && !h.generated && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
|
|
435
448
|
.map((h) => ({ code: C.MAINTENANCE_HOTSPOT_GROWTH, kind: "hotspot_growth",
|
|
436
449
|
detail: `${h.path} grew by ${h.growth} lines (now ${h.lines}, limit +${limit} over ${max}) without hotspot-exempt` }));
|
|
437
450
|
}
|
|
@@ -62,7 +62,7 @@ describe("amendments", () => {
|
|
|
62
62
|
|
|
63
63
|
describe("precheck (deterministic, no reviewer)", () => {
|
|
64
64
|
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
65
|
-
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [] }));
|
|
65
|
+
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [], ciCovered: [] }));
|
|
66
66
|
it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
|
|
67
67
|
assert.deepEqual(codes(precheck({ contract: C, result: res, changedFiles: [...files, "src/billing.js", ".forge-lean/x", "tests/test_x.py", "pkg/foo_test.go"], ignore: [".forge-lean/"] })), ["conformance.scope_creep"]);
|
|
68
68
|
for (const t of ["test/a.js", "src/a.test.ts", "tests/test_a.py", "a_test.go", "spec/a.spec.js"]) assert.ok(isTestPath(t), t);
|
|
@@ -85,6 +85,15 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
85
85
|
const local = precheck({ contract: C, result: failed, changedFiles: files, repoHas: repoTracks(["src/leak/__init__.py"]), validateRuns: { V1: { rc: 1, output: "ModuleNotFoundError: No module named 'leak.core'" } } });
|
|
86
86
|
assert.deepEqual(codes(local), ["conformance.unimplemented"], "a repo module the change did not create is a code defect");
|
|
87
87
|
});
|
|
88
|
+
it("#4527 a CI-verified head lists the validate ids it accepted without a local result; a ran-and-failed validate still fails", () => {
|
|
89
|
+
const C2 = { ...C, validate: [{ id: "V1", command: "node --test a.test.mjs" }, { id: "V2", command: "tsc" }, { id: "V3", command: "lint" }] };
|
|
90
|
+
const pc = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
91
|
+
assert.deepEqual(codes(pc), []); assert.deepEqual(pc.ciCovered, ["V1", "V2"]);
|
|
92
|
+
const local = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files });
|
|
93
|
+
assert.deepEqual(local.failures.map((f) => f.kind), ["validate_missing", "validate_missing"]); assert.deepEqual(local.ciCovered, []);
|
|
94
|
+
const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
95
|
+
assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
|
|
96
|
+
});
|
|
88
97
|
it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
|
|
89
98
|
assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
|
|
90
99
|
assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
|
|
@@ -362,6 +371,24 @@ describe("hotspot growth precheck (forge#4195)", () => {
|
|
|
362
371
|
assert.match(pc.failures[0].detail, /^src\/scrape\.js grew by 200 lines \(now 2600, limit \+150 over 2000\) without hotspot-exempt$/);
|
|
363
372
|
});
|
|
364
373
|
|
|
374
|
+
it("#4519 generated paths: applyAmendments refuses add-deliverable on maintenance.generated_paths, hotspotGrowth skips generated rows", () => {
|
|
375
|
+
const gen = ["web/public/openapi*.json", "web/public/openapi-versions/*"];
|
|
376
|
+
const amends = [{ kind: "add-deliverable", path: "web/public/openapi-versions/3.29.2.json", why: "sync the spec archive" },
|
|
377
|
+
{ kind: "add-deliverable", path: "./web/public/openapi.json", why: "sync the spec" }, { kind: "add-deliverable", path: "src/shared.js", why: "helper" }];
|
|
378
|
+
const a = applyAmendments(C, amends, null, { generatedPaths: gen });
|
|
379
|
+
assert.deepEqual(a.applied.map((x) => x.target), ["src/shared.js"]);
|
|
380
|
+
assert.equal(a.problems.length, 2);
|
|
381
|
+
assert.match(a.problems[0], /^add-deliverable web\/public\/openapi-versions\/3\.29\.2\.json: generated path \(forge\.yaml maintenance\.generated_paths\)/);
|
|
382
|
+
assert.ok(!a.contract.deliverables.some((d) => d.path.includes("openapi")), "a refused path never becomes a deliverable");
|
|
383
|
+
assert.equal(applyAmendments(C, amends).applied.length, 3, "without generatedPaths every add-deliverable applies as before");
|
|
384
|
+
assert.equal(applyAmendments(C, amends, null, { generatedPaths: ["web/public/openapi.json"] }).applied.length, 2, "a literal glob matches only that path");
|
|
385
|
+
// linguist-generated / generated_paths / header rows (hotspots.sh `generated`) never trigger hotspot_growth
|
|
386
|
+
const big = H({ path: "src/scrape.js", lines: 83974, growth: 83974 });
|
|
387
|
+
assert.equal(run(hs([big])).ok, false);
|
|
388
|
+
assert.equal(run(hs([{ ...big, generated: true }])).ok, true);
|
|
389
|
+
assert.equal(run(hs([{ ...big, generated: true, excluded: true }])).ok, true);
|
|
390
|
+
});
|
|
391
|
+
|
|
365
392
|
it("within the limit, under the threshold, excluded, unchanged, disabled or no facts: no failure", () => {
|
|
366
393
|
for (const h of [hs([H({ growth: 150 })]), hs([H({ over: false, lines: 1900 })]), hs([H({ excluded: true })]), hs([H({ path: "src/other.js" })]),
|
|
367
394
|
hs([H()], { enabled: false }), hs([H({ growth: null })]), null, undefined, {}]) assert.equal(run(h).ok, true, JSON.stringify(h));
|
|
@@ -398,7 +425,7 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
398
425
|
});
|
|
399
426
|
it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
|
|
400
427
|
const k = { ...C, repro_test: RT };
|
|
401
|
-
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [] });
|
|
428
|
+
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [], ciCovered: [] });
|
|
402
429
|
for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
|
|
403
430
|
const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
|
|
404
431
|
assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
|
package/bin/lean/depcache.mjs
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* <repoKey>/<eco>-<key>.tmp-<pid> snapshot being published (atomic rename into place)
|
|
12
12
|
* stores/{pnpm-store,npm-cache,uv-cache,poetry-cache,pip-cache} the package managers' own shared stores
|
|
13
13
|
* The repo's own package manager does every install (frozen lockfile). Every effect is injected; nothing here throws:
|
|
14
|
-
* a missing tool, another filesystem (EXDEV) or a failed install leaves the lane as it was before.
|
|
14
|
+
* a missing tool, another filesystem (EXDEV) or a failed install leaves the lane as it was before. A snapshot that fails
|
|
15
|
+
* to link or repair falls back to a fresh install in the lane (status materialize-fallback, #4522); failed only when that
|
|
16
|
+
* install fails too.
|
|
15
17
|
*/
|
|
16
18
|
import * as nodeFs from "node:fs";
|
|
17
19
|
import { createHash } from "node:crypto";
|
|
@@ -28,7 +30,17 @@ const TMP_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.tmp-(\d+)$/;
|
|
|
28
30
|
const LOCK_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.lock$/;
|
|
29
31
|
|
|
30
32
|
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; } };
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Error text for a reason: pnpm prints its ERR_PNPM_* errors on stdout (#4522), so the first such line from either
|
|
35
|
+
* stream leads, then the last lines of stderr and stdout; the message only when both streams are empty.
|
|
36
|
+
*/
|
|
37
|
+
const msg = (e) => {
|
|
38
|
+
const lines = (s) => String(s ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
39
|
+
const err = lines(e?.stderr), out = lines(e?.stdout);
|
|
40
|
+
const pm = [...err, ...out].find((l) => /\bERR_PNPM_[A-Z0-9_]+/.test(l));
|
|
41
|
+
const parts = [pm, ...err.slice(-3), ...out.slice(-3)].filter((l, i, a) => l && a.indexOf(l) === i);
|
|
42
|
+
return (parts.length ? parts.join(" ") : String(e?.message || e).trim().split("\n").slice(-3).join(" ")).slice(0, 500);
|
|
43
|
+
};
|
|
32
44
|
|
|
33
45
|
/** Lockfile -> ecosystem, in priority order: at most one JS and one Python ecosystem per repo. */
|
|
34
46
|
function detect(fs, wt) {
|
|
@@ -215,7 +227,8 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
215
227
|
return done("hit", { key });
|
|
216
228
|
} catch (e) {
|
|
217
229
|
rmTrees(wt, created);
|
|
218
|
-
|
|
230
|
+
if (e?.code === "EXDEV") return done("skipped", { key, reason: "cache is on another filesystem than the worktree (EXDEV)" });
|
|
231
|
+
return { fallback: `materialize failed: ${msg(e)}` }; // installed by the caller, outside the key lock
|
|
219
232
|
}
|
|
220
233
|
};
|
|
221
234
|
const install = async () => {
|
|
@@ -235,6 +248,13 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
|
|
|
235
248
|
const why = publish(wt, d, key, ins.trees, snap);
|
|
236
249
|
return done("miss-installed", { key, published: !why, reason: why });
|
|
237
250
|
});
|
|
251
|
+
if (r.locked && r.value?.fallback) {
|
|
252
|
+
// #4522: a broken snapshot must not leave the lane without dependencies. Install outside the lock so other lanes on
|
|
253
|
+
// this key are not serialised behind it; never publish (the snapshot exists and is never replaced).
|
|
254
|
+
const ins = await install();
|
|
255
|
+
if (ins.failed) return { ...ins.failed, reason: `${r.value.fallback}; ${ins.failed.reason}` };
|
|
256
|
+
return done("materialize-fallback", { key, published: false, reason: r.value.fallback });
|
|
257
|
+
}
|
|
238
258
|
if (r.locked) return r.value ?? done("failed", { key, reason: "snapshot manifest unreadable" });
|
|
239
259
|
// The lock stayed busy past the wait: install in this lane without touching the cache.
|
|
240
260
|
const ins = await install();
|
|
@@ -38,7 +38,7 @@ const FIXTURES = {
|
|
|
38
38
|
const LOCKFILE = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", uv: "uv.lock", poetry: "poetry.lock" };
|
|
39
39
|
|
|
40
40
|
/** Fake package managers: write what a real install leaves behind, including the absolute paths that need rewriting. */
|
|
41
|
-
function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
|
|
41
|
+
function fakeExec({ failInstall = false, failRepair = false, installDelayMs = 0 } = {}) {
|
|
42
42
|
const calls = [];
|
|
43
43
|
const venv = (cwd, lock) => {
|
|
44
44
|
const v = join(cwd, ".venv");
|
|
@@ -65,6 +65,10 @@ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
|
|
|
65
65
|
if (full && installDelayMs) await new Promise((r) => setTimeout(r, installDelayMs));
|
|
66
66
|
if (full && failInstall) { put(join(cwd, cmd === "uv" || cmd === "poetry" ? ".venv/partial" : "node_modules/partial"), "x"); throw Object.assign(new Error("install failed"), { stderr: "ERR_PNPM_FROZEN_LOCKFILE" }); }
|
|
67
67
|
if (cmd === "pnpm") {
|
|
68
|
+
if (args.includes("--offline") && failRepair) {
|
|
69
|
+
// pnpm prints its errors on stdout; execFile's error then carries an empty stderr (#4522).
|
|
70
|
+
throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile --offline"), { stderr: "", stdout: "Lockfile is up to date\n ERR_PNPM_TEST_REPAIR some cause\n" });
|
|
71
|
+
}
|
|
68
72
|
if (args.includes("--offline")) return ""; // warm repair pass: nothing to do
|
|
69
73
|
const lock = readFileSync(join(cwd, "pnpm-lock.yaml"), "utf8");
|
|
70
74
|
put(join(cwd, "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js"), `module.exports = ${JSON.stringify(lock)};\n`);
|
|
@@ -222,6 +226,43 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
|
|
|
222
226
|
assert.equal(r.published, true, "the stale lock was replaced");
|
|
223
227
|
});
|
|
224
228
|
|
|
229
|
+
it("#4522 a snapshot whose repair fails falls back to a fresh install outside the lock, keeps the snapshot and records pnpm's stdout error", async () => {
|
|
230
|
+
const s = setup("pnpm");
|
|
231
|
+
const [ra] = await s.cache.prepare({ wt: s.lane("a"), repoKey: "r" });
|
|
232
|
+
assert.equal(ra.status, "miss-installed", ra.reason);
|
|
233
|
+
const [snap] = snapDirs(s.root, "pnpm");
|
|
234
|
+
const before = snapshotOf(snap);
|
|
235
|
+
const broken = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true }).exec, env: {}, pollMs: 5 });
|
|
236
|
+
const wt = s.lane("b");
|
|
237
|
+
const [rb] = await broken.prepare({ wt, repoKey: "r" });
|
|
238
|
+
assert.equal(rb.status, "materialize-fallback", rb.reason);
|
|
239
|
+
assert.equal(rb.published, false);
|
|
240
|
+
assert.match(rb.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR {2}some cause/);
|
|
241
|
+
assert.ok(existsSync(join(wt, "node_modules/lodash/index.js")), "the lane has node_modules");
|
|
242
|
+
const after = snapshotOf(snap);
|
|
243
|
+
delete before["last-used"]; delete after["last-used"];
|
|
244
|
+
assert.deepEqual(after, before, "the snapshot is untouched");
|
|
245
|
+
assert.deepEqual(readdirSync(join(s.root, "r")).filter((n) => !/^pnpm-[0-9a-f]{16}$/.test(n)), [], "no lock or tmp left");
|
|
246
|
+
|
|
247
|
+
const both = makeDepCache({ root: s.root, exec: fakeExec({ failRepair: true, failInstall: true }).exec, env: {}, pollMs: 5 });
|
|
248
|
+
const wt2 = s.lane("c");
|
|
249
|
+
const [rc] = await both.prepare({ wt: wt2, repoKey: "r" });
|
|
250
|
+
assert.equal(rc.status, "failed");
|
|
251
|
+
assert.match(rc.reason, /^materialize failed: ERR_PNPM_TEST_REPAIR.*; install failed: ERR_PNPM_FROZEN_LOCKFILE/);
|
|
252
|
+
assert.ok(!existsSync(join(wt2, "node_modules")), "no partial tree");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("#4522 a failed pnpm install records the ERR_PNPM line it printed on stdout", async () => {
|
|
256
|
+
const s = setup("pnpm");
|
|
257
|
+
const exec = async (cmd, args, o) => {
|
|
258
|
+
if (cmd === "pnpm" && !args.includes("--offline")) throw Object.assign(new Error("Command failed: pnpm install --frozen-lockfile"), { stderr: "", stdout: "Progress: resolved 1\n ERR_PNPM_OUTDATED_LOCKFILE Cannot install with frozen-lockfile\n\nNote that in CI environments this setting is true by default.\nA\nB\nC\n" });
|
|
259
|
+
return s.f.exec(cmd, args, o);
|
|
260
|
+
};
|
|
261
|
+
const [r] = await makeDepCache({ root: s.root, exec, env: {} }).prepare({ wt: s.lane("x"), repoKey: "r" });
|
|
262
|
+
assert.equal(r.status, "failed");
|
|
263
|
+
assert.match(r.reason, /^install failed: ERR_PNPM_OUTDATED_LOCKFILE {2}Cannot install with frozen-lockfile A B C$/);
|
|
264
|
+
});
|
|
265
|
+
|
|
225
266
|
it("a failed install, a missing tool or another filesystem leaves no snapshot and no partial tree", async () => {
|
|
226
267
|
const s = setup("pnpm", { failInstall: true });
|
|
227
268
|
const wt = s.lane("x");
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -370,7 +370,7 @@ export function makeLeanIo({ repo }) {
|
|
|
370
370
|
sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
|
|
371
371
|
sequenceVerify: ({ cwd, base, before, moves }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before, moves }),
|
|
372
372
|
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
373
|
-
sequenceRenumber: ({ cwd, base, laneRef, regenerate }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate }),
|
|
373
|
+
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
|
|
374
374
|
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
375
375
|
sequenceLease: makeSequenceLeaseIo(),
|
|
376
376
|
/** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
|