knodin 0.13.0 → 0.14.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.
@@ -140,14 +140,38 @@ export function readSealAttestation(db) {
140
140
  * which is exactly the silent false negative the consumer needs to be able to
141
141
  * detect in order to fall back.
142
142
  */
143
- export function readSealedSource(db, filePath) {
143
+ export function readSealedSource(db, filePath, maxBytes) {
144
+ if (maxBytes !== undefined) {
145
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
146
+ throw new RangeError("Invalid sealed source byte limit");
147
+ const bounded = db
148
+ .query("SELECT s.bytes, s.compressed, s.rawBytes, s.storedBytes, s.sha256 FROM sealed_file f JOIN sealed_source s ON s.sha256=f.sha256 WHERE f.filePath=? AND length(s.bytes)<=?")
149
+ .get(filePath, maxBytes);
150
+ if (!bounded)
151
+ return null;
152
+ if (![0, 1].includes(bounded.compressed) ||
153
+ !Number.isSafeInteger(bounded.rawBytes) ||
154
+ bounded.rawBytes < 0 ||
155
+ bounded.rawBytes > maxBytes ||
156
+ bounded.storedBytes !== bounded.bytes.byteLength)
157
+ throw new Error("Invalid bounded sealed source metadata");
158
+ const raw = bounded.compressed
159
+ ? zlib.zstdDecompressSync(bounded.bytes, { maxOutputLength: maxBytes })
160
+ : Buffer.from(bounded.bytes);
161
+ if (raw.byteLength !== bounded.rawBytes ||
162
+ crypto.createHash("sha256").update(raw).digest("hex") !== bounded.sha256)
163
+ throw new Error("Sealed source integrity mismatch");
164
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
165
+ }
144
166
  const row = db
145
- .query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ?")
146
- .get(filePath);
167
+ .query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ? AND length(s.bytes) <= ?")
168
+ .get(filePath, maxBytes ?? Number.MAX_SAFE_INTEGER);
147
169
  if (!row)
148
170
  return null;
149
171
  const raw = Buffer.from(row.bytes);
150
- return (row.compressed ? zlib.zstdDecompressSync(raw) : raw).toString("utf8");
172
+ return (row.compressed
173
+ ? zlib.zstdDecompressSync(raw, maxBytes === undefined ? undefined : { maxOutputLength: maxBytes })
174
+ : raw).toString("utf8");
151
175
  }
152
176
  /**
153
177
  * Every path the sealed artifact carries source for.
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -32,6 +33,28 @@ export const MIRROR_STATE_DIR = "state";
32
33
  /** In-tree state directory name for a normal checkout. */
33
34
  export const IN_TREE_STATE_DIR = ".knodin";
34
35
  const REGISTRY_VERSION = 1;
36
+ const MIRROR_IDENTITY = /^repo_[0-9a-f]{20}$/;
37
+ function assertMirrorIdentity(identity) {
38
+ if (!MIRROR_IDENTITY.test(identity))
39
+ throw new Error("mirror registry: invalid repository identity");
40
+ }
41
+ function containedMirrorDirectory(parent, name) {
42
+ const target = path.join(parent, name);
43
+ for (const directory of [parent, target]) {
44
+ try {
45
+ const stat = fs.lstatSync(directory);
46
+ if (!stat.isDirectory() || stat.isSymbolicLink())
47
+ throw new Error("mirror registry: unsafe state directory");
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT")
51
+ throw error;
52
+ }
53
+ }
54
+ if (fs.existsSync(target) && path.dirname(fs.realpathSync(target)) !== fs.realpathSync(parent))
55
+ throw new Error("mirror registry: escaped state directory");
56
+ return target;
57
+ }
35
58
  const EMPTY_REGISTRY = { version: REGISTRY_VERSION, mirrors: [] };
36
59
  /**
37
60
  * Root for all mirror storage. Defaults to `~/.knodin/mirrors` rather than an
@@ -49,19 +72,20 @@ export function mirrorRegistryPath() {
49
72
  }
50
73
  /** Root for in-progress clones, renamed into place only once complete. */
51
74
  export function mirrorStagingRoot() {
52
- return path.join(mirrorRoot(), ".staging");
75
+ return containedMirrorDirectory(mirrorRoot(), ".staging");
53
76
  }
54
77
  /** Entry root for one mirror: the parent of both `source/` and `state/`. */
55
78
  export function mirrorEntryPath(identity) {
56
- return path.join(mirrorRoot(), identity);
79
+ assertMirrorIdentity(identity);
80
+ return containedMirrorDirectory(mirrorRoot(), identity);
57
81
  }
58
82
  /** Absolute path to a mirror's shallow clone. */
59
83
  export function mirrorSourcePath(identity) {
60
- return path.join(mirrorEntryPath(identity), MIRROR_SOURCE_DIR);
84
+ return containedMirrorDirectory(mirrorEntryPath(identity), MIRROR_SOURCE_DIR);
61
85
  }
62
86
  /** Absolute path to a mirror's graph state directory. */
63
87
  export function mirrorStatePath(identity) {
64
- return path.join(mirrorEntryPath(identity), MIRROR_STATE_DIR);
88
+ return containedMirrorDirectory(mirrorEntryPath(identity), MIRROR_STATE_DIR);
65
89
  }
66
90
  let cache = null;
67
91
  function isMirrorRecord(value) {
@@ -69,10 +93,13 @@ function isMirrorRecord(value) {
69
93
  return false;
70
94
  const record = value;
71
95
  return (typeof record.identity === "string" &&
96
+ MIRROR_IDENTITY.test(record.identity) &&
72
97
  typeof record.url === "string" &&
73
98
  typeof record.path === "string" &&
74
99
  typeof record.fetchedAt === "string" &&
75
- typeof record.sha === "string");
100
+ typeof record.sha === "string" &&
101
+ record.readOnly === true &&
102
+ path.isAbsolute(record.path));
76
103
  }
77
104
  function indexByPath(registry) {
78
105
  const byPath = new Map();
@@ -80,57 +107,172 @@ function indexByPath(registry) {
80
107
  byPath.set(path.resolve(record.path), record);
81
108
  return byPath;
82
109
  }
110
+ function assertMirrorRecordPath(record) {
111
+ const expected = mirrorSourcePath(record.identity);
112
+ try {
113
+ if (fs.realpathSync(record.path) !== fs.realpathSync(expected))
114
+ throw new Error("mirror registry: clone path does not match repository identity");
115
+ }
116
+ catch (error) {
117
+ if (error.code !== "ENOENT")
118
+ throw error;
119
+ // A missing clone remains registered for actionable recovery, but only at
120
+ // its deterministic managed location. A missing alias cannot be verified.
121
+ if (path.resolve(record.path) !== path.resolve(expected))
122
+ throw new Error("mirror registry: missing clone path does not match repository identity");
123
+ }
124
+ }
83
125
  /**
84
126
  * Reads the mirror registry, memoized against the file's mtime and size so a
85
127
  * mirror added by a concurrent `knodin remote add` is picked up without paying a
86
128
  * JSON parse on every path resolution.
87
129
  *
88
- * The memo key is (mtimeMs, size), which is a heuristic rather than a guarantee:
89
- * an out-of-process write landing in the same millisecond AND producing the same
90
- * byte length would not invalidate it. Accepted deliberately — that requires a
91
- * concurrent edit of identical length within one millisecond, the in-process
92
- * writer drops the memo explicitly, and the cost of being wrong is one stale
93
- * read, not corruption. Recorded so it reads as a decision, not an oversight.
130
+ * The metadata memo includes inode/device, ctime, mtime and size. Registry reads
131
+ * use a bounded no-follow descriptor and verify file identity before caching.
132
+ * This is substitution detection, not atomic isolation from hostile filesystem
133
+ * writers; every derived mirror-state path also checks directory containment.
94
134
  *
95
- * A missing or corrupt registry reads as empty. That is deliberate too: it
96
- * degrades to "no mirrors configured", which routes every repository to its
97
- * in-tree state — the pre-existing behaviour — rather than failing a query
98
- * outright.
135
+ * Only a genuinely absent registry reads as empty. Corrupt, unsafe or unreadable
136
+ * registries fail closed: fallback could redirect a mirror into its source tree.
99
137
  */
100
138
  export function readMirrorRegistry() {
101
139
  const file = mirrorRegistryPath();
102
140
  let stat;
103
141
  try {
104
- stat = fs.statSync(file);
142
+ stat = fs.lstatSync(file);
105
143
  }
106
- catch {
144
+ catch (error) {
145
+ cache = null;
146
+ if (error.code === "ENOENT")
147
+ return EMPTY_REGISTRY;
148
+ throw new Error("mirror registry: unavailable registry", { cause: error });
149
+ }
150
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_048_576) {
107
151
  cache = null;
108
- return EMPTY_REGISTRY;
152
+ throw new Error("mirror registry: unsafe or oversized registry");
109
153
  }
110
- if (cache?.mtimeMs === stat.mtimeMs && cache?.size === stat.size)
154
+ if (cache?.device === stat.dev &&
155
+ cache.inode === stat.ino &&
156
+ cache.ctimeMs === stat.ctimeMs &&
157
+ cache.mtimeMs === stat.mtimeMs &&
158
+ cache.size === stat.size)
111
159
  return cache.registry;
112
160
  try {
113
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
114
- const mirrors = typeof parsed === "object" &&
115
- parsed !== null &&
116
- Array.isArray(parsed.mirrors)
117
- ? parsed.mirrors.filter(isMirrorRecord)
118
- : [];
161
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
162
+ let parsed;
163
+ try {
164
+ const opened = fs.fstatSync(descriptor);
165
+ if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size)
166
+ throw new Error("registry changed during open");
167
+ const bytes = Buffer.alloc(stat.size + 1);
168
+ const count = fs.readSync(descriptor, bytes, 0, bytes.length, 0);
169
+ const final = fs.fstatSync(descriptor);
170
+ const current = fs.lstatSync(file);
171
+ if (count !== stat.size ||
172
+ final.size !== stat.size ||
173
+ final.ctimeMs !== stat.ctimeMs ||
174
+ current.dev !== stat.dev ||
175
+ current.ino !== stat.ino ||
176
+ current.ctimeMs !== stat.ctimeMs)
177
+ throw new Error("registry changed during read");
178
+ parsed = JSON.parse(bytes.subarray(0, count).toString("utf8"));
179
+ }
180
+ finally {
181
+ fs.closeSync(descriptor);
182
+ }
183
+ if (!parsed ||
184
+ typeof parsed !== "object" ||
185
+ parsed.version !== REGISTRY_VERSION ||
186
+ !Array.isArray(parsed.mirrors) ||
187
+ !parsed.mirrors.every(isMirrorRecord))
188
+ throw new Error("invalid mirror registry records");
189
+ const mirrors = parsed.mirrors;
190
+ if (new Set(mirrors.map((record) => record.identity)).size !== mirrors.length)
191
+ throw new Error("duplicate mirror identity");
192
+ for (const record of mirrors) {
193
+ mirrorStatePath(record.identity);
194
+ assertMirrorRecordPath(record);
195
+ }
196
+ const canonicalPaths = mirrors.map((record) => fs.existsSync(record.path) ? fs.realpathSync(record.path) : path.resolve(record.path));
197
+ if (new Set(canonicalPaths).size !== canonicalPaths.length)
198
+ throw new Error("duplicate mirror clone path");
119
199
  const registry = { version: REGISTRY_VERSION, mirrors };
120
- cache = { mtimeMs: stat.mtimeMs, size: stat.size, registry, byPath: indexByPath(registry) };
200
+ cache = {
201
+ device: stat.dev,
202
+ inode: stat.ino,
203
+ ctimeMs: stat.ctimeMs,
204
+ mtimeMs: stat.mtimeMs,
205
+ size: stat.size,
206
+ registry,
207
+ byPath: indexByPath(registry),
208
+ };
121
209
  return registry;
122
210
  }
123
- catch {
211
+ catch (error) {
124
212
  cache = null;
125
- return EMPTY_REGISTRY;
213
+ throw new Error("mirror registry: invalid or unsafe registry; restore it before graph access", {
214
+ cause: error,
215
+ });
126
216
  }
127
217
  }
128
218
  /** Persists the registry and drops the memo so the next read reloads it. */
129
219
  export function writeMirrorRegistry(registry) {
220
+ if (!registry.mirrors.every(isMirrorRecord))
221
+ throw new Error("mirror registry: invalid records");
222
+ for (const record of registry.mirrors) {
223
+ mirrorStatePath(record.identity);
224
+ assertMirrorRecordPath(record);
225
+ }
130
226
  const file = mirrorRegistryPath();
131
- fs.mkdirSync(path.dirname(file), { recursive: true });
227
+ const parent = path.dirname(file);
228
+ containedMirrorDirectory(parent, ".staging");
229
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
230
+ const parentIdentity = fs.lstatSync(parent);
231
+ const canonicalParent = fs.realpathSync(parent);
232
+ const verifyDestination = () => {
233
+ const current = fs.lstatSync(parent);
234
+ if (!current.isDirectory() ||
235
+ current.isSymbolicLink() ||
236
+ current.dev !== parentIdentity.dev ||
237
+ current.ino !== parentIdentity.ino ||
238
+ fs.realpathSync(parent) !== canonicalParent)
239
+ throw new Error("mirror registry: parent identity changed");
240
+ try {
241
+ const target = fs.lstatSync(file);
242
+ if (!target.isFile() || target.isSymbolicLink())
243
+ throw new Error("mirror registry: unsafe registry destination");
244
+ }
245
+ catch (error) {
246
+ if (error.code !== "ENOENT")
247
+ throw error;
248
+ }
249
+ };
250
+ verifyDestination();
132
251
  const document = { version: REGISTRY_VERSION, mirrors: registry.mirrors };
133
- fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
252
+ const bytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
253
+ if (bytes.length > 1_048_576)
254
+ throw new Error("mirror registry: oversized registry");
255
+ const temporary = path.join(parent, `.registry-${process.pid}-${crypto.randomUUID()}.tmp`);
256
+ try {
257
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
258
+ try {
259
+ fs.writeFileSync(descriptor, bytes);
260
+ fs.fsyncSync(descriptor);
261
+ }
262
+ finally {
263
+ fs.closeSync(descriptor);
264
+ }
265
+ verifyDestination();
266
+ fs.renameSync(temporary, file);
267
+ }
268
+ finally {
269
+ // Only remove the exclusive temporary name while its parent is unchanged.
270
+ const current = fs.lstatSync(parent);
271
+ if (current.dev === parentIdentity.dev &&
272
+ current.ino === parentIdentity.ino &&
273
+ !current.isSymbolicLink())
274
+ fs.rmSync(temporary, { force: true });
275
+ }
134
276
  cache = null;
135
277
  }
136
278
  /** Clears the in-process registry memo. Test seam. */
@@ -144,7 +286,26 @@ export function __resetMirrorRegistryCache() {
144
286
  */
145
287
  export function lookupMirror(repoPath) {
146
288
  readMirrorRegistry();
147
- return cache?.byPath.get(path.resolve(repoPath));
289
+ const exact = cache?.byPath.get(path.resolve(repoPath));
290
+ if (exact)
291
+ return exact;
292
+ let canonical;
293
+ try {
294
+ canonical = fs.realpathSync(repoPath);
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ for (const record of cache?.byPath.values() ?? []) {
300
+ try {
301
+ if (fs.realpathSync(record.path) === canonical)
302
+ return record;
303
+ }
304
+ catch {
305
+ /* Missing clones do not match. */
306
+ }
307
+ }
308
+ return undefined;
148
309
  }
149
310
  /** True when `repoPath` is a registered read-only mirror clone. */
150
311
  export function isMirror(repoPath) {
package/dist/src/init.js CHANGED
@@ -27,7 +27,8 @@ export class InitializationHealthError extends Error {
27
27
  const condition = indexResult.verification.issueCount > 0
28
28
  ? `${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain`
29
29
  : `graph verification reported status "${indexResult.verification.status}"`;
30
- super(`knodin init: lifecycle hooks were installed, but ${condition}.${detail} Run \`knodin repair\`.`);
30
+ const remedy = indexResult.verification.repairSteps?.[0] ?? "Run `knodin repair`.";
31
+ super(`knodin init: lifecycle hooks were installed, but ${condition}.${detail} ${remedy}`);
31
32
  this.name = "InitializationHealthError";
32
33
  this.indexResult = indexResult;
33
34
  }
@@ -543,6 +544,13 @@ while :; do
543
544
  KIND="$(sed -n '1p' "$EVENT")"
544
545
  ARG1="$(sed -n '2p' "$EVENT")"
545
546
  ARG2="$(sed -n '3p' "$EVENT")"
547
+ # Hand the event sequence to the CLI so its JSON record carries it. indexer.log
548
+ # stays one JSON object per line (pack-install smoke and diagnostics parse it
549
+ # that way); the record's own "at"/"event" fields tie a refresh back to the Git
550
+ # event that queued it. Without that, a refresh that "succeeded" while the
551
+ # graph stayed 33 commits behind could not be correlated (KNODIN-49).
552
+ KNODIN_HOOK_EVENT_SEQUENCE="$(sed -n 's/^sequence=//p' "$EVENT" | head -n 1)"
553
+ export KNODIN_HOOK_EVENT_SEQUENCE
546
554
  if [ "$KIND" = "commit" ]; then
547
555
  ${invocation} --repo "$REPO_ROOT" hook-refresh commit --json >> "$LOG_PATH" 2>> "$LOG_PATH.stderr"
548
556
  elif [ "$KIND" = "checkout" ] || [ "$KIND" = "merge" ]; then
@@ -558,8 +566,12 @@ while :; do
558
566
  cp "$EVENT" "$LAST_EVENT_PATH"
559
567
  rm -f "$EVENT" "$FAILURE_PATH"
560
568
  else
569
+ # The CLI's --json error envelope already landed on the log line; name the
570
+ # cause in the failure file too so knodin status can say what broke instead
571
+ # of only that something did.
572
+ DETAIL="$(tail -n 1 "$LOG_PATH.stderr" 2>/dev/null)"
561
573
  {
562
- printf 'knodin background refresh failed with exit code %s.\n' "$STATUS"
574
+ printf 'knodin background refresh failed at %s with exit code %s: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$STATUS" "$DETAIL"
563
575
  printf 'Pending event retained at %s.\n' "$EVENT"
564
576
  printf 'See .knodin/indexer.log for details.\n'
565
577
  } > "$FAILURE_TMP"
@@ -328,6 +328,13 @@ async function boundedChannelJson(url, options) {
328
328
  clearTimeout(timeout);
329
329
  }
330
330
  }
331
+ function compatibilityBreaking(value) {
332
+ if (value === "breaking")
333
+ return true;
334
+ if (value === "compatible" || value === "compatible-with-additions" || value === "non-breaking")
335
+ return false;
336
+ return null;
337
+ }
331
338
  function npmReleases(value) {
332
339
  const versions = value.versions;
333
340
  const times = value.time;
@@ -341,11 +348,7 @@ function npmReleases(value) {
341
348
  version,
342
349
  publishedAt: times[version] ?? "",
343
350
  yanked: manifest.deprecated === true || typeof manifest.deprecated === "string",
344
- breaking: policy?.compatibility === "non-breaking"
345
- ? false
346
- : policy?.compatibility === "breaking"
347
- ? true
348
- : null,
351
+ breaking: compatibilityBreaking(policy?.compatibility),
349
352
  channel: "npm",
350
353
  };
351
354
  })
@@ -362,11 +365,7 @@ function homebrewReleases(value) {
362
365
  version: String(entry.version),
363
366
  publishedAt: typeof entry.publishedAt === "string" ? entry.publishedAt : "",
364
367
  yanked: entry.yanked === true,
365
- breaking: entry.compatibility === "non-breaking"
366
- ? false
367
- : entry.compatibility === "breaking"
368
- ? true
369
- : null,
368
+ breaking: compatibilityBreaking(entry.compatibility),
370
369
  channel: "homebrew",
371
370
  }))
372
371
  .sort((left, right) => compareVersion(right.version, left.version));
@@ -278,6 +278,8 @@ export function refreshMirror(identity) {
278
278
  const source = record.path;
279
279
  if (!fs.existsSync(source))
280
280
  throw new MirrorError("unknown", `knodin remote: mirror ${identity} is registered but its clone is missing; remove and re-add it`);
281
+ if (fs.realpathSync(source) !== fs.realpathSync(mirrorSourcePath(identity)))
282
+ throw new MirrorError("unknown", "knodin remote: registered clone is outside its managed source directory; restore the registry before refresh");
281
283
  runGit(["fetch", "--depth", "1", "origin"], record.url, source);
282
284
  runGit(["reset", "--hard", "FETCH_HEAD"], record.url, source);
283
285
  const sha = runGit(["rev-parse", "HEAD"], record.url, source).trim();
@@ -36,6 +36,9 @@ function collectStrings(value, path = "$") {
36
36
  }
37
37
  const PROTECTED_CONTRACT_KEYS = new Set([
38
38
  "identity",
39
+ "chainIdentity",
40
+ "candidateReason",
41
+ "partialScope",
39
42
  "symbol",
40
43
  "file",
41
44
  "filePath",
@@ -70,7 +73,10 @@ function coveragePreviews(value) {
70
73
  return found;
71
74
  }
72
75
  function isTruncatableDetail(entry) {
73
- return (!entry.path.startsWith("$/flowAnalysis/limitations/") &&
76
+ return (!entry.path.startsWith("$/platformEntry/") &&
77
+ !entry.path.startsWith("$/deadCodeAnalysis/rootReachability/") &&
78
+ !entry.path.startsWith("$/deadCodeAnalysis/platformEntries/") &&
79
+ !entry.path.startsWith("$/flowAnalysis/limitations/") &&
74
80
  !entry.path.startsWith("$/sharedIndex/") &&
75
81
  !COVERAGE_SCALAR_PATH.test(entry.path) &&
76
82
  !IMPACT_CONTRACT_PATH.test(entry.path) &&
@@ -154,6 +160,9 @@ function truncatePayloadToBytes(root, byteLimit) {
154
160
  const candidate = collectArrays(root)
155
161
  .filter((entry) => !entry.path.startsWith("$/responseBudget") &&
156
162
  !PROTECTED_CONTRACT_ARRAY_PATHS.has(entry.path) &&
163
+ !entry.path.startsWith("$/platformEntry/") &&
164
+ !entry.path.startsWith("$/deadCodeAnalysis/rootReachability/") &&
165
+ !entry.path.startsWith("$/deadCodeAnalysis/platformEntries/") &&
157
166
  entry.value.length > 0)
158
167
  .sort((a, b) => bytes(b.value) - bytes(a.value))[0];
159
168
  if (!candidate)
@@ -273,7 +282,10 @@ export function applyResponseBudget(value, operation, request, defaults) {
273
282
  for (const entry of reportedTotals)
274
283
  totals[entry.path] = entry.value.length;
275
284
  for (const entry of initialArrays) {
276
- if (entry.value.length > itemLimit) {
285
+ if (!entry.path.startsWith("$/platformEntry/") &&
286
+ !entry.path.startsWith("$/deadCodeAnalysis/rootReachability/") &&
287
+ !entry.path.startsWith("$/deadCodeAnalysis/platformEntries/") &&
288
+ entry.value.length > itemLimit) {
277
289
  entry.value.splice(itemLimit);
278
290
  truncated = true;
279
291
  }
@@ -195,15 +195,25 @@ export async function restoreSharedIndex(options) {
195
195
  };
196
196
  }
197
197
  finally {
198
- lease?.release();
199
- if (candidate && !promoted) {
198
+ try {
199
+ lease?.release();
200
+ if (candidate && !promoted) {
201
+ try {
202
+ await options.engine.discardCandidate(candidate);
203
+ }
204
+ catch {
205
+ // Candidate cleanup never justifies touching the active graph.
206
+ }
207
+ }
208
+ }
209
+ finally {
200
210
  try {
201
- await options.engine.discardCandidate(candidate);
211
+ lifecycleLease?.release();
202
212
  }
203
- catch {
204
- // Candidate cleanup never justifies touching the active graph.
213
+ finally {
214
+ if (promoted)
215
+ options.engine.finalizePromotedStorage?.(options.repo);
205
216
  }
206
217
  }
207
- lifecycleLease?.release();
208
218
  }
209
219
  }
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /** Additional free space kept beyond scratch: 512 MiB, including for empty graphs. */
4
+ export const DEFAULT_STORAGE_RESERVE_BYTES = 512 * 1024 * 1024;
5
+ /** Conservative scratch allowance; allocation is not a promise of reclaimable APFS extents. */
6
+ export const DEFAULT_STORAGE_SCRATCH_FACTOR = 2;
7
+ export class StorageBudgetError extends Error {
8
+ constructor(message, options) {
9
+ super(`Storage capacity preflight refused: ${message}. Free disk space or resolve the measurement error, then retry.`, options);
10
+ this.name = "StorageBudgetError";
11
+ }
12
+ }
13
+ function safeBytes(value, label) {
14
+ if (!Number.isSafeInteger(value) || value < 0)
15
+ throw new StorageBudgetError(`${label} is unknown or outside safe integer bounds`);
16
+ return value;
17
+ }
18
+ function stateRoot(directory) {
19
+ const stat = fs.lstatSync(directory);
20
+ if (!stat.isDirectory() || stat.isSymbolicLink())
21
+ throw new StorageBudgetError("state directory must be a regular nonsymlink directory");
22
+ return fs.realpathSync(directory);
23
+ }
24
+ /** At most 32 ancestor metadata probes; never creates a directory to measure capacity. */
25
+ function capacityDirectory(directory) {
26
+ let current = path.resolve(directory);
27
+ for (let attempts = 0; attempts < 32; attempts++) {
28
+ try {
29
+ // The state directory itself must not be a symlink. For a new state,
30
+ // existing ancestors may be repository aliases: capacity is measured
31
+ // on their canonical filesystem without creating or trusting state.
32
+ const root = stateRoot(attempts === 0 ? current : fs.realpathSync(current));
33
+ return root;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== "ENOENT")
37
+ throw error;
38
+ const parent = path.dirname(current);
39
+ if (parent === current)
40
+ break;
41
+ current = parent;
42
+ }
43
+ }
44
+ throw new StorageBudgetError("no safe existing capacity directory within 32 ancestors");
45
+ }
46
+ /** Bounded metadata-only measurement of exactly DB, WAL and SHM; missing members are allowed. */
47
+ export function measureDatabaseFamily(stateDirectory, databasePath) {
48
+ try {
49
+ let root;
50
+ try {
51
+ root = stateRoot(stateDirectory);
52
+ }
53
+ catch (error) {
54
+ if (error.code !== "ENOENT")
55
+ throw error;
56
+ capacityDirectory(stateDirectory);
57
+ if (path.dirname(path.resolve(databasePath)) !== path.resolve(stateDirectory))
58
+ throw new StorageBudgetError("database family is outside its state directory");
59
+ return { apparentBytes: 0, allocatedBytes: 0, fileCount: 0 };
60
+ }
61
+ const target = path.resolve(databasePath);
62
+ if (fs.realpathSync(path.dirname(target)) !== root)
63
+ throw new StorageBudgetError("database family is outside its state directory");
64
+ let apparentBytes = 0;
65
+ let allocatedBytes = 0;
66
+ let fileCount = 0;
67
+ for (const suffix of ["", "-wal", "-shm"]) {
68
+ const member = `${target}${suffix}`;
69
+ let stat;
70
+ try {
71
+ stat = fs.lstatSync(member);
72
+ }
73
+ catch (error) {
74
+ if (error.code === "ENOENT")
75
+ continue;
76
+ throw error;
77
+ }
78
+ if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(member)) !== root)
79
+ throw new StorageBudgetError("database family must contain only contained regular nonsymlink files");
80
+ apparentBytes = safeBytes(apparentBytes + safeBytes(stat.size, "file size"), "family size");
81
+ if (typeof stat.blocks !== "number" || !Number.isSafeInteger(stat.blocks) || stat.blocks < 0)
82
+ allocatedBytes = null;
83
+ else if (allocatedBytes !== null)
84
+ allocatedBytes = safeBytes(allocatedBytes + safeBytes(stat.blocks * 512, "allocated file size"), "allocated family size");
85
+ fileCount++;
86
+ }
87
+ return { apparentBytes, allocatedBytes, fileCount };
88
+ }
89
+ catch (error) {
90
+ if (error instanceof StorageBudgetError)
91
+ throw error;
92
+ throw new StorageBudgetError("cannot measure database family", { cause: error });
93
+ }
94
+ }
95
+ /** bavail reflects blocks usable by this process, unlike privileged free-block totals. */
96
+ export function measureAvailableBytes(stateDirectory, statfs = (directory) => fs.statfsSync(directory, { bigint: true })) {
97
+ try {
98
+ const result = statfs(capacityDirectory(stateDirectory));
99
+ if (typeof result.bavail !== "bigint" ||
100
+ typeof result.bsize !== "bigint" ||
101
+ result.bavail < 0n ||
102
+ result.bsize <= 0n)
103
+ throw new StorageBudgetError("filesystem available capacity is unknown");
104
+ const bytes = result.bavail * result.bsize;
105
+ if (bytes > BigInt(Number.MAX_SAFE_INTEGER))
106
+ throw new StorageBudgetError("filesystem capacity exceeds safe integer bounds");
107
+ return Number(bytes);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof StorageBudgetError)
111
+ throw error;
112
+ throw new StorageBudgetError("filesystem free-space measurement failed or is unsupported", {
113
+ cause: error,
114
+ });
115
+ }
116
+ }
117
+ /** Read-only preflight, not a reservation: callers must still handle ENOSPC and races. */
118
+ export function assertStorageCapacity(options, dependencies = {}) {
119
+ try {
120
+ const reserveBytes = safeBytes(options.reserveBytes ?? DEFAULT_STORAGE_RESERVE_BYTES, "reserve bytes");
121
+ const scratchFactor = options.scratchFactor ?? DEFAULT_STORAGE_SCRATCH_FACTOR;
122
+ if (!Number.isSafeInteger(scratchFactor) || scratchFactor < 1)
123
+ throw new StorageBudgetError("scratch factor must be a positive safe integer");
124
+ const family = (dependencies.measureFamily ?? measureDatabaseFamily)(options.stateDirectory, options.databasePath);
125
+ if (family.allocatedBytes === null)
126
+ throw new StorageBudgetError("allocated family size is unknown on this filesystem");
127
+ const base = Math.max(safeBytes(family.apparentBytes, "family size"), safeBytes(family.allocatedBytes, "allocated family size"));
128
+ const requiredBytes = safeBytes(safeBytes(base * scratchFactor, "scratch bytes") + reserveBytes, "required bytes");
129
+ const availableBytes = safeBytes((dependencies.measureFree ?? measureAvailableBytes)(options.stateDirectory), "available bytes");
130
+ if (availableBytes < requiredBytes)
131
+ throw new StorageBudgetError(`${options.operation} requires ${requiredBytes} additional bytes but only ${availableBytes} bytes are available`);
132
+ return {
133
+ operation: options.operation,
134
+ family,
135
+ availableBytes,
136
+ requiredBytes,
137
+ reserveBytes,
138
+ scratchFactor,
139
+ };
140
+ }
141
+ catch (error) {
142
+ if (error instanceof StorageBudgetError)
143
+ throw error;
144
+ throw new StorageBudgetError("capacity measurement failed", { cause: error });
145
+ }
146
+ }