forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,312 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Shared dependency cache (#4220): lanes on one lock hash share one install. Layout under the root
5
+ * (`FORGE_DEPCACHE_ROOT` or `~/.forge/depcache`):
6
+ * <repoKey>/<eco>-<key>/ published snapshot: the trees (node_modules dirs at their relative paths, or .venv)
7
+ * + manifest.json {eco,key,prefix,trees,created} + last-used. Files are read-only and
8
+ * never written again; lanes get hardlinks, so an in-place write fails instead of
9
+ * corrupting it.
10
+ * <repoKey>/<eco>-<key>.lock per-key lock {host,pid,created} (open "wx"; a dead or expired holder is stale)
11
+ * <repoKey>/<eco>-<key>.tmp-<pid> snapshot being published (atomic rename into place)
12
+ * stores/{pnpm-store,npm-cache,uv-cache,poetry-cache,pip-cache} the package managers' own shared stores
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.
15
+ */
16
+ import * as nodeFs from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { homedir, hostname } from "node:os";
19
+ import { join, dirname } from "node:path";
20
+ import { safeSegment } from "./ledger.mjs";
21
+
22
+ export const depcacheRoot = (env = process.env) => String(env.FORGE_DEPCACHE_ROOT ?? "").trim() || join(homedir(), ".forge", "depcache");
23
+ export const DEPCACHE_INSTALL_TIMEOUT_MS = 15 * 60000;
24
+ export const DEPCACHE_MAX_AGE_MS = 7 * 24 * 3600 * 1000;
25
+ const STORES = "stores";
26
+ const SNAP_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}$/;
27
+ const TMP_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.tmp-(\d+)$/;
28
+ const LOCK_RE = /^(pnpm|npm|yarn|uv|poetry)-[0-9a-f]{16}\.lock$/;
29
+
30
+ 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);
32
+
33
+ /** Lockfile -> ecosystem, in priority order: at most one JS and one Python ecosystem per repo. */
34
+ function detect(fs, wt) {
35
+ const has = (f) => fs.existsSync(join(wt, f));
36
+ const out = [];
37
+ if (has("pnpm-lock.yaml")) out.push({ eco: "pnpm", lock: "pnpm-lock.yaml" });
38
+ else if (has("package-lock.json")) out.push({ eco: "npm", lock: "package-lock.json" });
39
+ else if (has("npm-shrinkwrap.json")) out.push({ eco: "npm", lock: "npm-shrinkwrap.json" });
40
+ else if (has("yarn.lock")) {
41
+ const rc = has(".yarnrc.yml") ? String(fs.readFileSync(join(wt, ".yarnrc.yml"))) : null;
42
+ // Yarn berry defaults to Plug'n'Play (no node_modules): only cache it with an explicit node-modules linker.
43
+ const pnp = has(".pnp.cjs") || (rc !== null && !/^\s*nodeLinker:\s*["']?(node-modules|pnpm)/m.test(rc));
44
+ out.push(pnp ? { eco: "yarn", lock: "yarn.lock", skip: "yarn Plug'n'Play has no node_modules" } : { eco: "yarn", lock: "yarn.lock", berry: rc !== null });
45
+ }
46
+ if (has("uv.lock")) out.push({ eco: "uv", lock: "uv.lock" });
47
+ else if (has("poetry.lock")) out.push({ eco: "poetry", lock: "poetry.lock" });
48
+ return out;
49
+ }
50
+
51
+ const JS = new Set(["pnpm", "npm", "yarn"]);
52
+ /** Full frozen install on a miss, and the warm no-op repair pass on a hit (null: nothing path-dependent to repair). */
53
+ function commands(d) {
54
+ switch (d.eco) {
55
+ case "pnpm": return { install: [["pnpm", ["install", "--frozen-lockfile"]]], repair: [["pnpm", ["install", "--frozen-lockfile", "--offline"]]] };
56
+ // npm/yarn node_modules hold only relative links; npm's hidden lockfile is rewritten in place, which the read-only
57
+ // snapshot (correctly) refuses, so they get no repair pass.
58
+ case "npm": return { install: [["npm", ["ci", "--no-audit", "--no-fund"]]], repair: null };
59
+ case "yarn": return { install: [["yarn", ["install", d.berry ? "--immutable" : "--frozen-lockfile"]]], repair: null };
60
+ case "uv": return { install: [["uv", ["venv", "--relocatable", ".venv"]], ["uv", ["sync", "--frozen"]]], repair: [["uv", ["sync", "--frozen"]]] };
61
+ case "poetry": return { install: [["poetry", ["install", "--sync"]]], repair: [["poetry", ["install", "--sync"]]] };
62
+ default: return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {object} [o]
68
+ * @param {string} [o.root] cache root (default FORGE_DEPCACHE_ROOT or ~/.forge/depcache)
69
+ * @param {Function} o.exec (cmd, args, {cwd, env, timeoutMs}) => Promise<stdout>; rejects on failure
70
+ */
71
+ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = () => Date.now(), alive = pidAlive, host = hostname(), pid = process.pid,
72
+ env: procEnv = process.env, installTimeoutMs = DEPCACHE_INSTALL_TIMEOUT_MS, lockWaitMs = DEPCACHE_INSTALL_TIMEOUT_MS, pollMs = 1000,
73
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
74
+ const disabled = () => String(procEnv.FORGE_DEPCACHE ?? "").trim() === "0";
75
+ const store = (n) => join(root, STORES, n);
76
+
77
+ /** Shared package-manager stores for every runner call, so the builder's own installs reuse them too. */
78
+ function env() {
79
+ if (disabled()) return {};
80
+ return { npm_config_store_dir: store("pnpm-store"), npm_config_cache: store("npm-cache"), UV_CACHE_DIR: store("uv-cache"), UV_LINK_MODE: "hardlink",
81
+ POETRY_CACHE_DIR: store("poetry-cache"), PIP_CACHE_DIR: store("pip-cache"), POETRY_VIRTUALENVS_IN_PROJECT: "true" };
82
+ }
83
+ const run = (cmd, args, cwd) => exec(cmd, args, { cwd, env: env(), timeoutMs: installTimeoutMs });
84
+ const runtime = async (cmd) => { try { return String(await exec(cmd, ["--version"], { timeoutMs: 30000 })).trim(); } catch { return "unknown"; } };
85
+
86
+ /** Tracked package.json files (workspace members), relative; root only when git cannot list them. */
87
+ async function manifests(wt) {
88
+ try {
89
+ const out = String(await exec("git", ["ls-files", "-z", "--", "package.json", ":(glob)**/package.json"], { cwd: wt, timeoutMs: 60000 }));
90
+ const l = [...new Set(out.split("\0").map((s) => s.trim()).filter((s) => s && !s.split("/").includes("node_modules")))].sort();
91
+ if (l.length) return l;
92
+ } catch { /* not a git checkout */ }
93
+ return ["package.json"];
94
+ }
95
+
96
+ async function keyOf(wt, d, pkgs) {
97
+ const h = createHash("sha256").update(`v1\0${d.eco}\0${process.platform}-${process.arch}\0`);
98
+ h.update(await runtime(JS.has(d.eco) ? "node" : "python3")).update("\0");
99
+ const files = JS.has(d.eco) ? [d.lock, ...pkgs, "pnpm-workspace.yaml", ".npmrc", ".yarnrc.yml"] : [d.lock, "pyproject.toml", ".python-version"];
100
+ for (const f of files) {
101
+ h.update(`${f}\0`);
102
+ try { h.update(fs.readFileSync(join(wt, f))); } catch { h.update("<absent>"); }
103
+ h.update("\0");
104
+ }
105
+ return h.digest("hex").slice(0, 16);
106
+ }
107
+
108
+ // ---- per-key lock ----
109
+ function tryLock(path) {
110
+ try {
111
+ const fd = fs.openSync(path, "wx");
112
+ try { fs.writeSync(fd, JSON.stringify({ host, pid, created: now() })); } finally { fs.closeSync(fd); }
113
+ return true;
114
+ } catch (e) { if (e?.code !== "EEXIST") throw e; }
115
+ let held = null;
116
+ try { held = JSON.parse(String(fs.readFileSync(path))); } catch { /* being written or garbage: stale only when old */ }
117
+ let mtime = now();
118
+ try { mtime = fs.statSync(path).mtimeMs; } catch { return false; }
119
+ const stale = held ? ((held.host === host && !alive(held.pid)) || now() - Number(held.created) > installTimeoutMs * 2) : now() - mtime > 60000;
120
+ if (!stale) return false;
121
+ try { fs.rmSync(path, { force: true }); } catch { return false; }
122
+ return tryLock(path);
123
+ }
124
+ async function withLock(path, fn, { wait = true } = {}) {
125
+ const t0 = now();
126
+ for (;;) {
127
+ if (tryLock(path)) break;
128
+ if (!wait || now() - t0 >= lockWaitMs) return { locked: false };
129
+ await sleep(pollMs);
130
+ }
131
+ try { return { locked: true, value: await fn() }; } finally { try { fs.rmSync(path, { force: true }); } catch { /* best effort */ } }
132
+ }
133
+
134
+ // ---- trees ----
135
+ const boundary = (p) => new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?=$|[/\\\\'\"\\s:;])", "g");
136
+ /** Hardlink `src` into `dst`; symlinks recreated; files in bin dirs that name `from` are copied with `to` instead. */
137
+ function linkTree(src, dst, { from = null, to = null, binDir = () => false } = {}) {
138
+ const re = from && to && from !== to ? boundary(from) : null;
139
+ const walk = (s, d, rel) => {
140
+ fs.mkdirSync(d, { recursive: true });
141
+ for (const ent of fs.readdirSync(s, { withFileTypes: true })) {
142
+ const sp = join(s, ent.name), dp = join(d, ent.name), r = rel ? `${rel}/${ent.name}` : ent.name;
143
+ if (ent.isSymbolicLink()) {
144
+ const t = String(fs.readlinkSync(sp));
145
+ fs.symlinkSync(re ? t.replace(re, to) : t, dp);
146
+ } else if (ent.isDirectory()) walk(sp, dp, r);
147
+ else if (ent.isFile()) {
148
+ if (re && binDir(rel)) {
149
+ const buf = fs.readFileSync(sp);
150
+ if (!buf.includes(0) && buf.includes(from)) {
151
+ fs.writeFileSync(dp, String(buf).replace(re, to), { mode: (fs.statSync(sp).mode & 0o7777) | 0o200 });
152
+ continue;
153
+ }
154
+ }
155
+ fs.linkSync(sp, dp);
156
+ }
157
+ }
158
+ };
159
+ walk(src, dst, "");
160
+ }
161
+ function readOnly(dir) {
162
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const p = join(dir, ent.name);
164
+ if (ent.isDirectory()) readOnly(p);
165
+ else if (ent.isFile()) fs.chmodSync(p, fs.statSync(p).mode & 0o7555);
166
+ }
167
+ }
168
+ /** Trees an ecosystem installs: every existing node_modules next to a tracked package.json, or the project .venv. */
169
+ const treesOf = (wt, d, pkgs) => (JS.has(d.eco) ? pkgs.map((p) => (dirname(p) === "." ? "node_modules" : `${dirname(p)}/node_modules`)) : [".venv"])
170
+ .filter((t, i, a) => a.indexOf(t) === i && fs.existsSync(join(wt, t)));
171
+ const binDirFor = (d) => (JS.has(d.eco) ? (rel) => rel.split("/").includes(".bin") : (rel) => rel === "bin");
172
+ const rmTrees = (wt, trees) => { for (const t of trees) { try { fs.rmSync(join(wt, t), { recursive: true, force: true }); } catch { /* best effort */ } } };
173
+
174
+ function publish(wt, d, key, trees, snap) {
175
+ const tmp = `${snap}.tmp-${pid}`;
176
+ try {
177
+ fs.rmSync(tmp, { recursive: true, force: true });
178
+ for (const t of trees) linkTree(join(wt, t), join(tmp, t));
179
+ readOnly(tmp);
180
+ fs.writeFileSync(join(tmp, "manifest.json"), JSON.stringify({ v: 1, eco: d.eco, key, prefix: wt, trees, created: now() }) + "\n");
181
+ fs.writeFileSync(join(tmp, "last-used"), String(now()));
182
+ try { fs.renameSync(tmp, snap); } catch (e) {
183
+ if (!fs.existsSync(join(snap, "manifest.json"))) throw e;
184
+ fs.rmSync(tmp, { recursive: true, force: true }); // another lane published first: never write into its snapshot
185
+ }
186
+ return null;
187
+ } catch (e) {
188
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
189
+ return e?.code === "EXDEV" ? "cache is on another filesystem than the worktree (EXDEV)" : `publish failed: ${msg(e)}`;
190
+ }
191
+ }
192
+
193
+ async function prepareOne(wt, keyRoot, d) {
194
+ const t0 = now();
195
+ const done = (status, extra = {}) => ({ status, eco: d.eco, key: extra.key ?? null, ms: now() - t0, reason: extra.reason ?? null, ...(extra.published === undefined ? {} : { published: extra.published }) });
196
+ if (d.skip) return done("skipped", { reason: d.skip });
197
+ const pkgs = JS.has(d.eco) ? await manifests(wt) : [];
198
+ const key = await keyOf(wt, d, pkgs);
199
+ const snap = join(keyRoot, `${d.eco}-${key}`);
200
+ const cmds = commands(d);
201
+ const expected = JS.has(d.eco) ? [...new Set(pkgs.map((p) => (dirname(p) === "." ? "node_modules" : `${dirname(p)}/node_modules`)))] : [".venv"];
202
+ const existing = expected.filter((t) => fs.existsSync(join(wt, t)));
203
+ if (existing.length) return done("skipped", { key, reason: `${existing[0]} already exists in the worktree` });
204
+ fs.mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
205
+
206
+ const materialize = async () => {
207
+ let man;
208
+ try { man = JSON.parse(String(fs.readFileSync(join(snap, "manifest.json")))); } catch { return null; }
209
+ const created = [];
210
+ try {
211
+ for (const t of man.trees ?? []) { created.push(t); linkTree(join(snap, t), join(wt, t), { from: man.prefix, to: wt, binDir: binDirFor(d) }); }
212
+ for (const [c, a] of cmds.repair ?? []) await run(c, a, wt);
213
+ try { fs.writeFileSync(join(snap, "last-used"), String(now())); } catch { /* best effort */ }
214
+ return done("hit", { key });
215
+ } catch (e) {
216
+ rmTrees(wt, created);
217
+ 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)}` });
218
+ }
219
+ };
220
+ const install = async () => {
221
+ try { for (const [c, a] of cmds.install) await run(c, a, wt); } catch (e) {
222
+ rmTrees(wt, expected);
223
+ return { failed: done("failed", { key, reason: `${e?.code === "ENOENT" ? "tool not found" : "install failed"}: ${msg(e)}` }) };
224
+ }
225
+ return { trees: treesOf(wt, d, pkgs) };
226
+ };
227
+
228
+ const lockPath = `${snap}.lock`;
229
+ const r = await withLock(lockPath, async () => {
230
+ if (fs.existsSync(join(snap, "manifest.json"))) return materialize(); // hit, or another lane published while we waited
231
+ const ins = await install();
232
+ if (ins.failed) return ins.failed;
233
+ if (!ins.trees.length) return done("miss-installed", { key, published: false, reason: "the install produced no node_modules/.venv" });
234
+ const why = publish(wt, d, key, ins.trees, snap);
235
+ return done("miss-installed", { key, published: !why, reason: why });
236
+ });
237
+ if (r.locked) return r.value ?? done("failed", { key, reason: "snapshot manifest unreadable" });
238
+ // The lock stayed busy past the wait: install in this lane without touching the cache.
239
+ const ins = await install();
240
+ return ins.failed ?? done("miss-installed", { key, published: false, reason: "cache lock busy" });
241
+ }
242
+
243
+ /** Materialize or build node_modules/.venv for `wt`; one result per detected ecosystem. Never throws. */
244
+ async function prepare({ wt, repoKey } = {}) {
245
+ if (disabled()) return [{ status: "skipped", eco: null, key: null, ms: 0, reason: "FORGE_DEPCACHE=0" }];
246
+ let found;
247
+ try { found = detect(fs, wt); } catch (e) { return [{ status: "failed", eco: null, key: null, ms: 0, reason: msg(e) }]; }
248
+ if (!found.length) return [{ status: "skipped", eco: null, key: null, ms: 0, reason: "no lockfile" }];
249
+ const keyRoot = join(root, safeSegment(repoKey ?? "repo"));
250
+ const out = [];
251
+ for (const d of found) {
252
+ try { out.push(await prepareOne(wt, keyRoot, d)); } catch (e) { out.push({ status: "failed", eco: d.eco, key: null, ms: 0, reason: msg(e) }); }
253
+ }
254
+ return out;
255
+ }
256
+
257
+ /**
258
+ * Read-only scan for the reaper: snapshots unused for maxAgeMs, tmp dirs of dead publishers, locks of dead holders on
259
+ * this host. Only names this module creates are listed. Each entry: {name, path, kind: snapshot|tmp|lock, ageMs, locked?, reason};
260
+ * a snapshot whose lock a live (or other-host) holder owns is listed with locked: true and is never removed.
261
+ */
262
+ function scan({ maxAgeMs = DEPCACHE_MAX_AGE_MS } = {}) {
263
+ const out = [];
264
+ let repos = [];
265
+ try { repos = fs.readdirSync(root).filter((n) => n !== STORES); } catch { return out; }
266
+ for (const rk of repos) {
267
+ const dir = join(root, rk);
268
+ let names = [];
269
+ try { if (!fs.lstatSync(dir).isDirectory()) continue; names = fs.readdirSync(dir); } catch { continue; }
270
+ for (const n of names) {
271
+ const p = join(dir, n), name = `${rk}/${n}`;
272
+ try {
273
+ const tm = TMP_RE.exec(n);
274
+ if (tm) { if (!alive(Number(tm[2]))) out.push({ name, path: p, kind: "tmp", ageMs: null, reason: "publisher dead" }); continue; }
275
+ if (LOCK_RE.test(n)) {
276
+ const held = JSON.parse(String(fs.readFileSync(p)));
277
+ if (held.host === host && !alive(held.pid)) out.push({ name, path: p, kind: "lock", ageMs: now() - Number(held.created), reason: "lock holder dead" });
278
+ continue;
279
+ }
280
+ if (!SNAP_RE.test(n) || !fs.existsSync(join(p, "manifest.json"))) continue;
281
+ let last = 0;
282
+ try { last = Number(String(fs.readFileSync(join(p, "last-used")))); } catch { /* fall back to created */ }
283
+ if (!Number.isFinite(last) || last <= 0) last = Number(JSON.parse(String(fs.readFileSync(join(p, "manifest.json")))).created) || 0;
284
+ if (now() - last < maxAgeMs) continue;
285
+ let locked = false;
286
+ try { const h = JSON.parse(String(fs.readFileSync(`${p}.lock`))); locked = !(h.host === host && !alive(h.pid)); } catch { /* no lock */ }
287
+ out.push({ name, path: p, kind: "snapshot", ageMs: now() - last, locked, reason: locked ? "locked by a live lane" : "snapshot unused" });
288
+ } catch { /* unreadable or not ours: leave it */ }
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /** Remove one scanned entry; a snapshot only while holding its lock. Returns false when it is locked or not ours. */
295
+ async function remove(entry) {
296
+ const p = String(entry?.path ?? "");
297
+ if (!p.startsWith(root + "/") || p.slice(root.length + 1).split("/").length !== 2) return false;
298
+ if (entry.kind === "snapshot") return (await withLock(`${p}.lock`, async () => { fs.rmSync(p, { recursive: true, force: true }); return true; }, { wait: false })).locked;
299
+ fs.rmSync(p, { recursive: true, force: true });
300
+ return true;
301
+ }
302
+
303
+ /** scan + remove: snapshots unused for maxAgeMs (taking their lock), dead publishers' tmp dirs, stale locks. Returns names. */
304
+ async function prune({ maxAgeMs = DEPCACHE_MAX_AGE_MS } = {}) {
305
+ const removed = [];
306
+ for (const e of scan({ maxAgeMs })) { if (e.locked) continue; try { if (await remove(e)) removed.push(e.name); } catch { /* leave it */ } }
307
+ return removed;
308
+ }
309
+
310
+ return { env, prepare, prune, scan, remove, root };
311
+ }
312
+
@@ -0,0 +1,298 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** #4220 shared dependency cache: JS (pnpm/npm) and Python (uv/poetry) fixture repos, fake installers (no real tools). */
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import * as fs from "node:fs";
7
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync, readlinkSync, readdirSync, symlinkSync, rmSync, renameSync } from "node:fs";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { join, dirname, relative } from "node:path";
10
+ import { makeDepCache } from "./depcache.mjs";
11
+
12
+ const scratch = () => tmpDir("fd-depcache-");
13
+ const put = (p, text, mode) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text, mode ? { mode } : undefined); };
14
+ /** Replace a file the way package managers do (tmp + rename), never writing into the existing inode. */
15
+ const replace = (p, text) => { put(`${p}.new`, text); renameSync(`${p}.new`, p); };
16
+
17
+ const FIXTURES = {
18
+ pnpm(wt, lock = "lockfileVersion: 9\nlodash: 4.17.21\n") {
19
+ put(join(wt, "package.json"), '{"name":"web","private":true}');
20
+ put(join(wt, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
21
+ put(join(wt, "packages/a/package.json"), '{"name":"a"}');
22
+ put(join(wt, "pnpm-lock.yaml"), lock);
23
+ },
24
+ npm(wt, lock = '{"lockfileVersion":3,"packages":{"node_modules/left-pad":{"version":"1.3.0"}}}') {
25
+ put(join(wt, "package.json"), '{"name":"app"}');
26
+ put(join(wt, "package-lock.json"), lock);
27
+ },
28
+ uv(wt, lock = 'version = 1\n[[package]]\nname = "requests"\nversion = "2.32.0"\n') {
29
+ put(join(wt, "pyproject.toml"), '[project]\nname = "proj"\nversion = "0.1.0"\n');
30
+ put(join(wt, "uv.lock"), lock);
31
+ },
32
+ poetry(wt, lock = '[[package]]\nname = "requests"\nversion = "2.32.0"\n') {
33
+ put(join(wt, "pyproject.toml"), '[tool.poetry]\nname = "proj"\nversion = "0.1.0"\n');
34
+ put(join(wt, "poetry.lock"), lock);
35
+ },
36
+ };
37
+ const LOCKFILE = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", uv: "uv.lock", poetry: "poetry.lock" };
38
+
39
+ /** Fake package managers: write what a real install leaves behind, including the absolute paths that need rewriting. */
40
+ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
41
+ const calls = [];
42
+ const venv = (cwd, lock) => {
43
+ const v = join(cwd, ".venv");
44
+ if (!existsSync(join(v, "pyvenv.cfg"))) put(join(v, "pyvenv.cfg"), "home = /usr/bin\n");
45
+ // Satisfied dependencies are left alone (a warm sync over a complete venv); only missing files are written.
46
+ const once = (p, text, mode) => { if (!existsSync(p)) put(p, text, mode); };
47
+ once(join(v, "lib/python3.12/site-packages/requests/__init__.py"), `# ${lock.length}\n`);
48
+ once(join(v, "bin/pytest"), `#!${v}/bin/python\nimport pytest\n`, 0o755);
49
+ once(join(v, "bin/activate"), `VIRTUAL_ENV='${v}'\n`);
50
+ if (!existsSync(join(v, "bin/python"))) symlinkSync("/usr/bin/python3", join(v, "bin/python"));
51
+ replace(join(v, "lib/python3.12/site-packages/_proj.pth"), `${cwd}/src\n`); // editable project: per worktree
52
+ };
53
+ const exec = async (cmd, args, { cwd } = {}) => {
54
+ calls.push({ cmd, args: [...args], cwd });
55
+ if (cmd === "git" && args[0] === "ls-files") {
56
+ const out = [];
57
+ const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.name === "node_modules" || e.name.startsWith(".")) continue; const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.name === "package.json") out.push(relative(cwd, p)); } };
58
+ walk(cwd);
59
+ return out.join("\0");
60
+ }
61
+ if (args[0] === "--version") return cmd === "node" ? "v22.1.0\n" : "Python 3.12.3\n";
62
+ const full = (cmd === "pnpm" && args.includes("--frozen-lockfile") && !args.includes("--offline")) || (cmd === "npm" && args[0] === "ci")
63
+ || (cmd === "uv" && args[0] === "venv") || (cmd === "poetry" && !existsSync(join(cwd, ".venv")));
64
+ if (full && installDelayMs) await new Promise((r) => setTimeout(r, installDelayMs));
65
+ 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" }); }
66
+ if (cmd === "pnpm") {
67
+ if (args.includes("--offline")) return ""; // warm repair pass: nothing to do
68
+ const lock = readFileSync(join(cwd, "pnpm-lock.yaml"), "utf8");
69
+ put(join(cwd, "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js"), `module.exports = ${JSON.stringify(lock)};\n`);
70
+ symlinkSync(".pnpm/lodash@4/node_modules/lodash", join(cwd, "node_modules/lodash"));
71
+ put(join(cwd, "node_modules/.bin/lodash"), `#!/bin/sh\nexport NODE_PATH="${cwd}/node_modules/.pnpm/node_modules"\nexec node "$basedir/../lodash/cli.js"\n`, 0o755);
72
+ put(join(cwd, "node_modules/.modules.yaml"), "layoutVersion: 5\n");
73
+ mkdirSync(join(cwd, "packages/a/node_modules"), { recursive: true });
74
+ symlinkSync("../../../node_modules/.pnpm/lodash@4/node_modules/lodash", join(cwd, "packages/a/node_modules/lodash"));
75
+ return "";
76
+ }
77
+ if (cmd === "npm") {
78
+ put(join(cwd, "node_modules/left-pad/index.js"), `// ${readFileSync(join(cwd, "package-lock.json"), "utf8").length}\n`);
79
+ put(join(cwd, "node_modules/left-pad/cli.js"), "#!/usr/bin/env node\n", 0o755);
80
+ mkdirSync(join(cwd, "node_modules/.bin"), { recursive: true });
81
+ symlinkSync("../left-pad/cli.js", join(cwd, "node_modules/.bin/left-pad"));
82
+ return "";
83
+ }
84
+ if (cmd === "uv" && args[0] === "venv") { put(join(cwd, ".venv/pyvenv.cfg"), "home = /usr/bin\nrelocatable = true\n"); return ""; }
85
+ if (cmd === "uv" || cmd === "poetry") { venv(cwd, readFileSync(join(cwd, cmd === "uv" ? "uv.lock" : "poetry.lock"), "utf8")); return ""; }
86
+ throw Object.assign(new Error(`spawn ${cmd} ENOENT`), { code: "ENOENT" });
87
+ };
88
+ return { exec, calls, installs: () => calls.filter((c) => c.cmd !== "git" && c.args[0] !== "--version") };
89
+ }
90
+
91
+ /** path -> {ino, content} for every regular file, symlink target for links: a byte- and inode-exact picture of a tree. */
92
+ function snapshotOf(dir) {
93
+ const out = {};
94
+ const walk = (d) => {
95
+ for (const e of readdirSync(d, { withFileTypes: true })) {
96
+ const p = join(d, e.name), r = relative(dir, p);
97
+ if (e.isSymbolicLink()) out[r] = `-> ${readlinkSync(p)}`;
98
+ else if (e.isDirectory()) walk(p);
99
+ else { const s = statSync(p); out[r] = `${s.ino}:${s.mode.toString(8)}:${readFileSync(p, "utf8")}`; }
100
+ }
101
+ };
102
+ walk(dir);
103
+ return out;
104
+ }
105
+ const snapDirs = (root, eco) => {
106
+ const out = [];
107
+ for (const rk of existsSync(root) ? readdirSync(root) : []) {
108
+ if (rk === "stores") continue;
109
+ for (const n of readdirSync(join(root, rk))) if (new RegExp(`^${eco}-[0-9a-f]{16}$`).test(n)) out.push(join(root, rk, n));
110
+ }
111
+ return out;
112
+ };
113
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
114
+ const setup = (eco, o = {}) => {
115
+ const base = scratch(), root = join(base, "cache");
116
+ const f = fakeExec(o);
117
+ const cache = makeDepCache({ root, exec: f.exec, env: {}, pollMs: 5, lockWaitMs: o.lockWaitMs ?? 5000, ...(o.cache ?? {}) });
118
+ const lane = (name, lock) => { const wt = join(base, "wt", name); mkdirSync(wt, { recursive: true }); FIXTURES[eco](wt, lock); return wt; };
119
+ return { base, root, f, cache, lane };
120
+ };
121
+
122
+ describe("depcache (#4220): shared dependency installs keyed by lock hash", () => {
123
+ for (const eco of ["pnpm", "npm", "uv", "poetry"]) {
124
+ it(`${eco}: a miss installs once and publishes; a lane on the same lock hardlinks it and runs no install`, async () => {
125
+ const { root, f, cache, lane } = setup(eco);
126
+ const a = lane("lean-1");
127
+ const [ra] = await cache.prepare({ wt: a, repoKey: "repo-1" });
128
+ assert.equal(ra.status, "miss-installed", JSON.stringify(ra));
129
+ assert.equal(ra.published, true);
130
+ assert.equal(ra.eco, eco);
131
+ const [snap] = snapDirs(root, eco);
132
+ assert.ok(snap && existsSync(join(snap, "manifest.json")));
133
+ const fullInstalls = f.installs().length;
134
+ assert.ok(fullInstalls >= 1);
135
+
136
+ const b = lane("lean-2");
137
+ const [rb] = await cache.prepare({ wt: b, repoKey: "repo-1" });
138
+ assert.equal(rb.status, "hit", JSON.stringify(rb));
139
+ assert.equal(rb.key, ra.key);
140
+ const after = f.installs().slice(fullInstalls);
141
+ // ≈ 0 install: only the warm repair pass (pnpm --offline / uv sync / poetry install over a complete venv), never a full install.
142
+ for (const c of after) assert.ok(!(c.cmd === "npm" && c.args[0] === "ci") && !(c.cmd === "pnpm" && !c.args.includes("--offline")) && !(c.cmd === "uv" && c.args[0] === "venv"), `full install on a hit: ${c.cmd} ${c.args.join(" ")}`);
143
+ if (eco === "npm") assert.equal(after.length, 0, "npm has no repair pass");
144
+
145
+ if (eco === "pnpm" || eco === "npm") {
146
+ const file = eco === "pnpm" ? "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js" : "node_modules/left-pad/index.js";
147
+ assert.equal(statSync(join(b, file)).ino, statSync(join(snap, file)).ino, "node_modules files are hardlinks of the snapshot");
148
+ assert.equal(statSync(join(b, file)).ino, statSync(join(a, file)).ino);
149
+ if (eco === "pnpm") {
150
+ assert.equal(readlinkSync(join(b, "node_modules/lodash")), ".pnpm/lodash@4/node_modules/lodash");
151
+ assert.ok(lstatSync(join(b, "packages/a/node_modules/lodash")).isSymbolicLink(), "workspace member node_modules are materialized too");
152
+ const shim = readFileSync(join(b, "node_modules/.bin/lodash"), "utf8");
153
+ assert.ok(shim.includes(`${b}/node_modules/.pnpm`) && !shim.includes(`${a}/`), ".bin shims point at this lane");
154
+ assert.notEqual(statSync(join(b, "node_modules/.bin/lodash")).ino, statSync(join(snap, "node_modules/.bin/lodash")).ino);
155
+ }
156
+ } else {
157
+ const sp = ".venv/lib/python3.12/site-packages/requests/__init__.py";
158
+ assert.equal(statSync(join(b, sp)).ino, statSync(join(snap, sp)).ino, "venv files are hardlinks of the snapshot");
159
+ assert.equal(readFileSync(join(b, ".venv/bin/pytest"), "utf8").split("\n")[0], `#!${b}/.venv/bin/python`, "console scripts point at this lane's venv");
160
+ assert.ok(readFileSync(join(b, ".venv/bin/activate"), "utf8").includes(`'${b}/.venv'`));
161
+ assert.equal(readlinkSync(join(b, ".venv/bin/python")), "/usr/bin/python3");
162
+ assert.equal(readFileSync(join(b, ".venv/lib/python3.12/site-packages/_proj.pth"), "utf8"), `${b}/src\n`, "the repair pass reinstalls the project for this lane");
163
+ assert.ok(after.length >= 1, "repair pass ran");
164
+ }
165
+ });
166
+ }
167
+
168
+ it("REGRESSION: a lane whose lockfile changed gets its own install and never corrupts the shared snapshot", async () => {
169
+ for (const eco of ["pnpm", "uv"]) {
170
+ const { root, f, cache, lane } = setup(eco);
171
+ const a = lane("lean-1");
172
+ const [ra] = await cache.prepare({ wt: a, repoKey: "r" });
173
+ const b = lane("lean-2");
174
+ assert.equal((await cache.prepare({ wt: b, repoKey: "r" }))[0].status, "hit");
175
+ const [snapA] = snapDirs(root, eco);
176
+ const before = snapshotOf(snapA);
177
+
178
+ const n = f.installs().length;
179
+ const c = lane("lean-3", eco === "pnpm" ? "lockfileVersion: 9\nlodash: 4.17.22\n" : 'version = 1\n[[package]]\nname = "requests"\nversion = "2.33.0"\n');
180
+ const [rc] = await cache.prepare({ wt: c, repoKey: "r" });
181
+ assert.equal(rc.status, "miss-installed", JSON.stringify(rc));
182
+ assert.notEqual(rc.key, ra.key, "a changed lockfile is a different key");
183
+ assert.ok(f.installs().slice(n).some((x) => (x.cmd === "pnpm" && !x.args.includes("--offline")) || (x.cmd === "uv" && x.args[0] === "venv")), "own full install");
184
+ assert.equal(snapDirs(root, eco).length, 2);
185
+
186
+ // A lane writing in place into a linked file fails instead of changing the shared inode.
187
+ const linked = eco === "pnpm" ? join(b, "node_modules/.pnpm/lodash@4/node_modules/lodash/index.js") : join(b, ".venv/lib/python3.12/site-packages/requests/__init__.py");
188
+ if (!isRoot) assert.throws(() => writeFileSync(linked, "corrupted"), (e) => e.code === "EACCES");
189
+ // Package-manager style replacement and deleting a lane's tree only unlink that lane's links.
190
+ replace(linked, "lane B's own copy");
191
+ rmSync(join(b, eco === "pnpm" ? "node_modules" : ".venv"), { recursive: true, force: true });
192
+ rmSync(join(a, eco === "pnpm" ? "node_modules" : ".venv"), { recursive: true, force: true });
193
+ assert.deepEqual(snapshotOf(snapA), before, "snapshot A is byte- and inode-identical");
194
+
195
+ const d = lane("lean-4");
196
+ assert.equal((await cache.prepare({ wt: d, repoKey: "r" }))[0].status, "hit", "the original lock still hits");
197
+ }
198
+ });
199
+
200
+ it("concurrent lanes on one key: one install, one snapshot, no tmp left; a dead holder's lock is taken over", async () => {
201
+ const { root, f, cache, lane } = setup("npm", { installDelayMs: 60 });
202
+ const lanes = ["l1", "l2", "l3"].map((n) => lane(n));
203
+ const rs = (await Promise.all(lanes.map((wt) => cache.prepare({ wt, repoKey: "r" })))).map((x) => x[0]);
204
+ assert.equal(f.calls.filter((c) => c.cmd === "npm" && c.args[0] === "ci").length, 1);
205
+ assert.deepEqual(rs.map((r) => r.status).sort(), ["hit", "hit", "miss-installed"]);
206
+ for (const wt of lanes) assert.ok(existsSync(join(wt, "node_modules/left-pad/index.js")));
207
+ const names = readdirSync(join(root, "r"));
208
+ assert.equal(names.filter((n) => /^npm-[0-9a-f]{16}$/.test(n)).length, 1);
209
+ assert.equal(names.filter((n) => /\.tmp-|\.lock$/.test(n)).length, 0, names.join(","));
210
+
211
+ const s2 = setup("npm", { cache: { alive: () => false } });
212
+ const wt = s2.lane("x");
213
+ const key = (await makeDepCache({ root: join(s2.base, "probe"), exec: s2.f.exec, env: {} }).prepare({ wt: s2.lane("probe"), repoKey: "r" }))[0].key;
214
+ put(join(s2.root, "r", `npm-${key}.lock`), JSON.stringify({ host: (await import("node:os")).hostname(), pid: 999999, created: Date.now() }));
215
+ const [r] = await s2.cache.prepare({ wt, repoKey: "r" });
216
+ assert.equal(r.status, "miss-installed");
217
+ assert.equal(r.published, true, "the stale lock was replaced");
218
+ });
219
+
220
+ it("a failed install, a missing tool or another filesystem leaves no snapshot and no partial tree", async () => {
221
+ const s = setup("pnpm", { failInstall: true });
222
+ const wt = s.lane("x");
223
+ const [r] = await s.cache.prepare({ wt, repoKey: "r" });
224
+ assert.equal(r.status, "failed");
225
+ assert.match(r.reason, /install failed/);
226
+ assert.ok(!existsSync(join(wt, "node_modules")));
227
+ assert.equal(snapDirs(s.root, "pnpm").length, 0);
228
+
229
+ const s2 = setup("npm");
230
+ const exdev = { ...fs, linkSync: () => { throw Object.assign(new Error("EXDEV: cross-device link"), { code: "EXDEV" }); } };
231
+ const c2 = makeDepCache({ root: s2.root, exec: s2.f.exec, env: {}, fs: exdev });
232
+ const wt2 = s2.lane("y");
233
+ const [r2] = await c2.prepare({ wt: wt2, repoKey: "r" });
234
+ assert.equal(r2.status, "miss-installed");
235
+ assert.equal(r2.published, false);
236
+ assert.match(r2.reason, /EXDEV/);
237
+ assert.ok(existsSync(join(wt2, "node_modules/left-pad/index.js")), "the lane keeps its own install");
238
+ assert.equal(snapDirs(s2.root, "npm").length, 0);
239
+ assert.deepEqual(readdirSync(join(s2.root, "r")), [], "no tmp or lock left");
240
+ // A published snapshot that cannot be linked (EXDEV) is skipped and leaves nothing half-linked.
241
+ await s2.cache.prepare({ wt: s2.lane("z"), repoKey: "r" });
242
+ const wt3 = s2.lane("w");
243
+ const [r3] = await c2.prepare({ wt: wt3, repoKey: "r" });
244
+ assert.equal(r3.status, "skipped");
245
+ assert.ok(!existsSync(join(wt3, "node_modules")));
246
+
247
+ const s4 = setup("npm");
248
+ const c4 = makeDepCache({ root: s4.root, exec: async (cmd, args) => { if (cmd === "git" || args[0] === "--version") return s4.f.exec(cmd, args, { cwd: s4.base }); throw Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" }); }, env: {} });
249
+ const [r4] = await c4.prepare({ wt: s4.lane("v"), repoKey: "r" });
250
+ assert.equal(r4.status, "failed");
251
+ assert.match(r4.reason, /tool not found/);
252
+ });
253
+
254
+ it("no lockfile, yarn Plug'n'Play, an existing node_modules and FORGE_DEPCACHE=0 are skipped untouched", async () => {
255
+ const s = setup("npm");
256
+ const empty = join(s.base, "empty"); mkdirSync(empty);
257
+ assert.deepEqual((await s.cache.prepare({ wt: empty, repoKey: "r" })).map((r) => [r.status, r.reason]), [["skipped", "no lockfile"]]);
258
+ const y = join(s.base, "yarn"); put(join(y, "package.json"), "{}"); put(join(y, "yarn.lock"), "# yarn"); put(join(y, ".yarnrc.yml"), "yarnPath: x\n");
259
+ assert.equal((await s.cache.prepare({ wt: y, repoKey: "r" }))[0].status, "skipped");
260
+ const pre = s.lane("pre"); put(join(pre, "node_modules/keep.js"), "mine");
261
+ const [r] = await s.cache.prepare({ wt: pre, repoKey: "r" });
262
+ assert.equal(r.status, "skipped");
263
+ assert.equal(readFileSync(join(pre, "node_modules/keep.js"), "utf8"), "mine");
264
+ const off = makeDepCache({ root: s.root, exec: s.f.exec, env: { FORGE_DEPCACHE: "0" } });
265
+ assert.equal((await off.prepare({ wt: s.lane("off"), repoKey: "r" }))[0].status, "skipped");
266
+ assert.deepEqual(off.env(), {});
267
+ assert.equal(s.f.installs().length, 0);
268
+ const e = s.cache.env();
269
+ for (const k of ["npm_config_store_dir", "npm_config_cache", "UV_CACHE_DIR", "POETRY_CACHE_DIR", "PIP_CACHE_DIR"]) assert.ok(e[k].startsWith(join(s.root, "stores")), k);
270
+ });
271
+
272
+ it("prune removes unused unlocked snapshots, dead publishers' tmp dirs and dead locks; keeps fresh or locked ones", async () => {
273
+ let clock = Date.now();
274
+ const s = setup("npm", { cache: { now: () => clock } });
275
+ await s.cache.prepare({ wt: s.lane("a"), repoKey: "r" });
276
+ await s.cache.prepare({ wt: s.lane("b", '{"lockfileVersion":3,"other":1}'), repoKey: "r" });
277
+ const [old, fresh] = snapDirs(s.root, "npm");
278
+ clock += 8 * 24 * 3600 * 1000;
279
+ writeFileSync(join(fresh, "last-used"), String(clock));
280
+ put(`${old}.tmp-999999`, "x");
281
+ const alive = (pid) => pid === process.pid;
282
+ const pruner = makeDepCache({ root: s.root, exec: s.f.exec, env: {}, now: () => clock, alive });
283
+ writeFileSync(`${fresh}.lock`, JSON.stringify({ host: (await import("node:os")).hostname(), pid: process.pid, created: clock }));
284
+ const removed = await pruner.prune();
285
+ assert.ok(!existsSync(old), "old snapshot removed (read-only files and all)");
286
+ assert.ok(existsSync(fresh), "recently used snapshot kept");
287
+ assert.ok(!existsSync(`${old}.tmp-999999`));
288
+ assert.equal(removed.length, 2);
289
+ // A locked old snapshot is kept: someone is materializing or publishing it.
290
+ clock += 8 * 24 * 3600 * 1000;
291
+ writeFileSync(`${fresh}.lock`, JSON.stringify({ host: (await import("node:os")).hostname(), pid: process.pid, created: clock }));
292
+ assert.deepEqual(await pruner.prune(), []);
293
+ assert.ok(existsSync(fresh));
294
+ rmSync(`${fresh}.lock`);
295
+ assert.equal((await pruner.prune()).length, 1);
296
+ assert.ok(!existsSync(fresh));
297
+ });
298
+ });