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.
Files changed (77) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +3 -3
  3. package/bin/engine-cli.mjs +56 -11
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.test.mjs +103 -2
  8. package/bin/lean/budget.mjs +7 -1
  9. package/bin/lean/budget.test.mjs +9 -1
  10. package/bin/lean/conformance.mjs +46 -10
  11. package/bin/lean/conformance.test.mjs +50 -2
  12. package/bin/lean/depcache.mjs +23 -3
  13. package/bin/lean/depcache.test.mjs +42 -1
  14. package/bin/lean/effects.mjs +1 -1
  15. package/bin/lean/execution.mjs +102 -12
  16. package/bin/lean/execution.test.mjs +101 -1
  17. package/bin/lean/hostkill.mjs +8 -0
  18. package/bin/lean/lean.mjs +49 -21
  19. package/bin/lean/lean.test.mjs +133 -7
  20. package/bin/lean/lightlane.mjs +38 -13
  21. package/bin/lean/lightlane.test.mjs +35 -1
  22. package/bin/lean/orchestrate.mjs +63 -17
  23. package/bin/lean/prompts.mjs +19 -9
  24. package/bin/lean/recovery.test.mjs +26 -0
  25. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  26. package/bin/lean/sandbox-remote.mjs +40 -7
  27. package/bin/lean/seqlease.mjs +49 -2
  28. package/bin/lean/seqlease.test.mjs +14 -0
  29. package/bin/lean/sequence.mjs +54 -6
  30. package/bin/lean/sequence.test.mjs +83 -1
  31. package/bin/pool/agent.mjs +435 -0
  32. package/bin/pool/cli.mjs +110 -0
  33. package/bin/pool/docker.mjs +56 -0
  34. package/bin/pool/http.mjs +55 -0
  35. package/bin/pool/router.mjs +299 -0
  36. package/bin/pool/store.mjs +25 -0
  37. package/bin/pool/version.mjs +44 -0
  38. package/bin/review-staging.mjs +14 -2
  39. package/bin/runner.mjs +1 -0
  40. package/bin/stats/aggregate.mjs +6 -0
  41. package/bin/stats/efficiency.mjs +103 -17
  42. package/bin/stats/efficiency.test.mjs +103 -4
  43. package/bin/stats/index.mjs +40 -6
  44. package/bin/stats/local-source.mjs +1 -1
  45. package/bin/stats/pool.mjs +51 -0
  46. package/bin/stats/pool.test.mjs +39 -0
  47. package/bin/stats/query.mjs +2 -2
  48. package/bin/stats/stats.test.mjs +15 -1
  49. package/commands/orchestrate.md +3 -3
  50. package/commands/review-pr-agents/protocols.md +1 -1
  51. package/commands/review-pr-staging.md +1 -1
  52. package/docs/CONFIG.md +23 -9
  53. package/docs/OUTCOME-CODES.md +5 -3
  54. package/docs/STATS.md +36 -1
  55. package/package.json +2 -1
  56. package/pool/README.md +13 -0
  57. package/pool/image/Dockerfile +46 -0
  58. package/pool/image/claude-shim +8 -0
  59. package/pool/image/cs-cred +12 -0
  60. package/pool/image/entrypoint.sh +61 -0
  61. package/pool/image/gh-shim +6 -0
  62. package/pool/install/install-agent-linux.sh +53 -0
  63. package/pool/install/install-agent.ps1 +225 -0
  64. package/pool/install/install-agent.sh +101 -0
  65. package/pool/install/install-router.sh +67 -0
  66. package/pool/install/keepalive.ps1 +36 -0
  67. package/pool/install/uninstall-agent.ps1 +20 -0
  68. package/scripts/hotspots.sh +33 -21
  69. package/scripts/hotspots.test.sh +22 -3
  70. package/scripts/review-finalize.sh +17 -1
  71. package/scripts/review-finalize.test.sh +41 -0
  72. package/scripts/review-pr-domain-map.test.sh +2 -2
  73. package/scripts/review-pr-staging-scope.test.sh +4 -2
  74. package/scripts/review-prepare.sh +62 -27
  75. package/scripts/review-prepare.test.sh +53 -0
  76. package/scripts/review-staging-pre.sh +29 -9
  77. package/scripts/review-staging-pre.test.sh +18 -0
@@ -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
+ });
@@ -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 needs-human `budget.exceeded` (recoverable once by the batch). Pure module: no I/O.
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
 
@@ -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);
@@ -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
- for (const a of Array.isArray(amendments) ? amendments : []) {
273
- const tag = JSON.stringify(a ?? null).slice(0, 100);
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(`unknown or incomplete amendment: ${tag}`); continue; }
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) that grew by more
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));
@@ -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
- const msg = (e) => String(e?.stderr || e?.message || e).trim().split("\n").slice(-3).join(" ").slice(0, 300);
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
- return done(e?.code === "EXDEV" ? "skipped" : "failed", { key, reason: e?.code === "EXDEV" ? "cache is on another filesystem than the worktree (EXDEV)" : `materialize failed: ${msg(e)}` });
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");
@@ -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). */