forgedock 1.32.1 → 1.34.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 +46 -10
- package/bin/lean/conformance.test.mjs +50 -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 +19 -9
- 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/review-staging.mjs +14 -2
- package/bin/runner.mjs +1 -0
- package/bin/stats/aggregate.mjs +6 -0
- package/bin/stats/efficiency.mjs +103 -17
- package/bin/stats/efficiency.test.mjs +103 -4
- package/bin/stats/index.mjs +40 -6
- 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/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +1 -1
- package/docs/CONFIG.md +23 -9
- package/docs/OUTCOME-CODES.md +5 -3
- package/docs/STATS.md +36 -1
- 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/scripts/review-finalize.sh +17 -1
- package/scripts/review-finalize.test.sh +41 -0
- package/scripts/review-pr-domain-map.test.sh +2 -2
- package/scripts/review-pr-staging-scope.test.sh +4 -2
- package/scripts/review-prepare.sh +62 -27
- package/scripts/review-prepare.test.sh +53 -0
- package/scripts/review-staging-pre.sh +29 -9
- package/scripts/review-staging-pre.test.sh +18 -0
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,51 @@ 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
|
+
|
|
267
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED];
|
|
268
|
+
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
269
|
+
|
|
270
|
+
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
271
|
+
function normalizeAmendment(a) {
|
|
272
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) return a;
|
|
273
|
+
const n = { ...a };
|
|
274
|
+
if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
|
|
275
|
+
if (Object.hasOwn(AMEND_SLIPS, n.kind)) n.kind = AMEND_SLIPS[n.kind];
|
|
276
|
+
if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
|
|
277
|
+
return n;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The rejection reason, naming the valid kinds or the missing field so the builder's correction round can fix it. */
|
|
281
|
+
function amendmentProblem(a, tag) {
|
|
282
|
+
const kind = a?.kind;
|
|
283
|
+
const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
|
|
284
|
+
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : null;
|
|
285
|
+
const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
|
|
286
|
+
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
/**
|
|
265
290
|
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
266
291
|
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
292
|
+
* `generatedPaths` (forge#4519, forge.yaml maintenance.generated_paths via scripts/hotspots.sh): an add-deliverable on a
|
|
293
|
+
* matching path is refused; the repo's generator or bot owns that file.
|
|
267
294
|
*/
|
|
268
|
-
export function applyAmendments(frozen, amendments, issueText = null) {
|
|
295
|
+
export function applyAmendments(frozen, amendments, issueText = null, { generatedPaths = [] } = {}) {
|
|
269
296
|
const c = structuredClone(frozen);
|
|
270
297
|
c.deliverables = c.deliverables ?? [];
|
|
271
298
|
const applied = [], toTrack = [], problems = [];
|
|
272
|
-
|
|
273
|
-
|
|
299
|
+
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
300
|
+
for (const raw of Array.isArray(amendments) ? amendments : []) {
|
|
301
|
+
const a = normalizeAmendment(raw);
|
|
302
|
+
const tag = JSON.stringify(raw ?? null).slice(0, 400);
|
|
274
303
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
275
304
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
305
|
+
if (genRes.some((re) => re.test(cleanPath(a.path)))) {
|
|
306
|
+
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`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
276
309
|
const have = c.deliverables.find((d) => d.path === a.path);
|
|
277
310
|
// An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
|
|
278
311
|
// package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
|
|
@@ -291,7 +324,7 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
291
324
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
292
325
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
293
326
|
if (!TRACKED.includes(r.disposition)) { r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r); }
|
|
294
|
-
} else { problems.push(
|
|
327
|
+
} else { problems.push(amendmentProblem(a, tag)); continue; }
|
|
295
328
|
applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
|
|
296
329
|
}
|
|
297
330
|
return { contract: c, applied, toTrack, problems };
|
|
@@ -343,9 +376,11 @@ export function repoTracks(paths) {
|
|
|
343
376
|
* `frozen` (#4350): the contract before typed amendments. A failed scope-check validate (isScopeCheck) whose only files
|
|
344
377
|
* outside `frozen` are covered by deliverables an amendment added, with no scope creep left, is superseded by the engine's
|
|
345
378
|
* scope gate: listed in `superseded` [{id, files}], not a failure. Without `frozen`, `superseded` is always [].
|
|
379
|
+
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
380
|
+
* tool), for the trajectory; always an array.
|
|
346
381
|
*/
|
|
347
382
|
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
|
|
348
|
-
const failures = [], superseded = [];
|
|
383
|
+
const failures = [], superseded = [], ciCovered = [];
|
|
349
384
|
const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
|
|
350
385
|
const creep = outside(contract);
|
|
351
386
|
const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
|
|
@@ -355,13 +390,13 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
355
390
|
const r = runs.find((x) => x?.id === v.id);
|
|
356
391
|
// #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
392
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
358
|
-
if (ciVerified && leftToCi) continue;
|
|
393
|
+
if (ciVerified && leftToCi) { ciCovered.push(v.id); continue; }
|
|
359
394
|
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
360
395
|
else if (r.exit !== 0) {
|
|
361
396
|
const rerun = validateRuns?.[v.id];
|
|
362
397
|
if (rerun?.rc === 0) continue;
|
|
363
398
|
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
364
|
-
if (miss && ciVerified) continue;
|
|
399
|
+
if (miss && ciVerified) { ciCovered.push(v.id); continue; }
|
|
365
400
|
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
401
|
else if (amendedOnly && isScopeCheck(v.command)) superseded.push({ id: v.id, files: amendedOnly });
|
|
367
402
|
else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
@@ -382,7 +417,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
382
417
|
}
|
|
383
418
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
384
419
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
385
|
-
return { ok: failures.length === 0, failures, superseded };
|
|
420
|
+
return { ok: failures.length === 0, failures, superseded, ciCovered };
|
|
386
421
|
}
|
|
387
422
|
|
|
388
423
|
/**
|
|
@@ -419,7 +454,8 @@ export function reproFailures(contract, { base, head } = {}) {
|
|
|
419
454
|
|
|
420
455
|
const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
|
|
421
456
|
/**
|
|
422
|
-
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded
|
|
457
|
+
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded; forge#4519: a
|
|
458
|
+
* `generated` row, from linguist-generated, maintenance.generated_paths or a generated header, never counts) that grew by more
|
|
423
459
|
* than `growth_lines` since the base, with no `hotspot-exempt: <reason>` in a covering deliverable's change or an
|
|
424
460
|
* add-deliverable amendment's why. `hotspots` is scripts/hotspots.sh --base output; null or disabled checks nothing.
|
|
425
461
|
*/
|
|
@@ -431,7 +467,7 @@ export function hotspotGrowth({ contract, result, hotspots, changedFiles = [] })
|
|
|
431
467
|
const exempt = (f) => (contract?.deliverables ?? []).some((d) => pathCovers(d.path, f) && EXEMPT_RE.test(String(d.change ?? "")))
|
|
432
468
|
|| amends.some((a) => a?.kind === "add-deliverable" && pathCovers(a.path, f) && EXEMPT_RE.test(String(a.why ?? "")));
|
|
433
469
|
return hotspots.files
|
|
434
|
-
.filter((h) => h?.over && !h.excluded && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
|
|
470
|
+
.filter((h) => h?.over && !h.excluded && !h.generated && Number(h.growth) > limit && changed.has(cleanPath(h.path)) && !exempt(h.path))
|
|
435
471
|
.map((h) => ({ code: C.MAINTENANCE_HOTSPOT_GROWTH, kind: "hotspot_growth",
|
|
436
472
|
detail: `${h.path} grew by ${h.growth} lines (now ${h.lines}, limit +${limit} over ${max}) without hotspot-exempt` }));
|
|
437
473
|
}
|
|
@@ -60,9 +60,30 @@ describe("amendments", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("#4574 amendment slips", () => {
|
|
64
|
+
it("type for kind, amend-requirement and limitation apply as defer; an R# id fills requirement", () => {
|
|
65
|
+
const a = applyAmendments(C, [{ kind: "amend-requirement", id: "R2", why: "stored hashed" }, { id: "A1", requirement: "R1", type: "limitation", why: "plugin limit" }]);
|
|
66
|
+
assert.deepEqual(a.problems, []);
|
|
67
|
+
assert.deepEqual(a.toTrack.map((r) => r.id).sort(), ["R1", "R2"]);
|
|
68
|
+
assert.ok(a.contract.requirements.every((r) => r.disposition === "defer"));
|
|
69
|
+
});
|
|
70
|
+
it("an unknown kind names the valid kinds and shows the full amendment", () => {
|
|
71
|
+
const why = "x".repeat(300);
|
|
72
|
+
const a = applyAmendments(C, [{ kind: "rewrite", why }]);
|
|
73
|
+
assert.match(a.problems[0], /unknown kind 'rewrite'; valid kinds are add-deliverable, add-requirement, defer, post-deploy/);
|
|
74
|
+
assert.ok(a.problems[0].includes(why));
|
|
75
|
+
});
|
|
76
|
+
it("an Object.prototype key as kind is an unknown kind, not a slip", () => {
|
|
77
|
+
assert.match(applyAmendments(C, [{ kind: "constructor", why: "w" }]).problems[0], /unknown kind 'constructor'; valid kinds are add-deliverable/);
|
|
78
|
+
});
|
|
79
|
+
it("an incomplete known kind names the missing field", () => {
|
|
80
|
+
assert.match(applyAmendments(C, [{ kind: "defer", why: "w" }]).problems[0], /'defer' needs requirement/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
63
84
|
describe("precheck (deterministic, no reviewer)", () => {
|
|
64
85
|
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: [] }));
|
|
86
|
+
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [], ciCovered: [] }));
|
|
66
87
|
it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
|
|
67
88
|
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
89
|
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 +106,15 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
85
106
|
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
107
|
assert.deepEqual(codes(local), ["conformance.unimplemented"], "a repo module the change did not create is a code defect");
|
|
87
108
|
});
|
|
109
|
+
it("#4527 a CI-verified head lists the validate ids it accepted without a local result; a ran-and-failed validate still fails", () => {
|
|
110
|
+
const C2 = { ...C, validate: [{ id: "V1", command: "node --test a.test.mjs" }, { id: "V2", command: "tsc" }, { id: "V3", command: "lint" }] };
|
|
111
|
+
const pc = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
112
|
+
assert.deepEqual(codes(pc), []); assert.deepEqual(pc.ciCovered, ["V1", "V2"]);
|
|
113
|
+
const local = precheck({ contract: C2, result: { ...res, validation: [{ id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files });
|
|
114
|
+
assert.deepEqual(local.failures.map((f) => f.kind), ["validate_missing", "validate_missing"]); assert.deepEqual(local.ciCovered, []);
|
|
115
|
+
const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
116
|
+
assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
|
|
117
|
+
});
|
|
88
118
|
it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
|
|
89
119
|
assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
|
|
90
120
|
assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
|
|
@@ -362,6 +392,24 @@ describe("hotspot growth precheck (forge#4195)", () => {
|
|
|
362
392
|
assert.match(pc.failures[0].detail, /^src\/scrape\.js grew by 200 lines \(now 2600, limit \+150 over 2000\) without hotspot-exempt$/);
|
|
363
393
|
});
|
|
364
394
|
|
|
395
|
+
it("#4519 generated paths: applyAmendments refuses add-deliverable on maintenance.generated_paths, hotspotGrowth skips generated rows", () => {
|
|
396
|
+
const gen = ["web/public/openapi*.json", "web/public/openapi-versions/*"];
|
|
397
|
+
const amends = [{ kind: "add-deliverable", path: "web/public/openapi-versions/3.29.2.json", why: "sync the spec archive" },
|
|
398
|
+
{ kind: "add-deliverable", path: "./web/public/openapi.json", why: "sync the spec" }, { kind: "add-deliverable", path: "src/shared.js", why: "helper" }];
|
|
399
|
+
const a = applyAmendments(C, amends, null, { generatedPaths: gen });
|
|
400
|
+
assert.deepEqual(a.applied.map((x) => x.target), ["src/shared.js"]);
|
|
401
|
+
assert.equal(a.problems.length, 2);
|
|
402
|
+
assert.match(a.problems[0], /^add-deliverable web\/public\/openapi-versions\/3\.29\.2\.json: generated path \(forge\.yaml maintenance\.generated_paths\)/);
|
|
403
|
+
assert.ok(!a.contract.deliverables.some((d) => d.path.includes("openapi")), "a refused path never becomes a deliverable");
|
|
404
|
+
assert.equal(applyAmendments(C, amends).applied.length, 3, "without generatedPaths every add-deliverable applies as before");
|
|
405
|
+
assert.equal(applyAmendments(C, amends, null, { generatedPaths: ["web/public/openapi.json"] }).applied.length, 2, "a literal glob matches only that path");
|
|
406
|
+
// linguist-generated / generated_paths / header rows (hotspots.sh `generated`) never trigger hotspot_growth
|
|
407
|
+
const big = H({ path: "src/scrape.js", lines: 83974, growth: 83974 });
|
|
408
|
+
assert.equal(run(hs([big])).ok, false);
|
|
409
|
+
assert.equal(run(hs([{ ...big, generated: true }])).ok, true);
|
|
410
|
+
assert.equal(run(hs([{ ...big, generated: true, excluded: true }])).ok, true);
|
|
411
|
+
});
|
|
412
|
+
|
|
365
413
|
it("within the limit, under the threshold, excluded, unchanged, disabled or no facts: no failure", () => {
|
|
366
414
|
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
415
|
hs([H()], { enabled: false }), hs([H({ growth: null })]), null, undefined, {}]) assert.equal(run(h).ok, true, JSON.stringify(h));
|
|
@@ -398,7 +446,7 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
398
446
|
});
|
|
399
447
|
it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
|
|
400
448
|
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: [] });
|
|
449
|
+
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [], ciCovered: [] });
|
|
402
450
|
for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
|
|
403
451
|
const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
|
|
404
452
|
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). */
|