pi-supernova 0.5.0 → 0.7.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 (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
package/src/fs/vfs.js CHANGED
@@ -1,20 +1,240 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { isString } from "../shared/decode.js";
4
- import { randomUUID } from "node:crypto";
5
-
6
- const VFS_CACHE_MAX = 1024;
4
+ import { createHash, randomUUID } from "node:crypto";
7
5
 
8
6
  // Serialize validation + replacement across Supernova transactions in this host.
9
7
  let commitTail = Promise.resolve();
10
8
 
9
+ function textSignature(text) {
10
+ return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
11
+ }
12
+
13
+ function sameFileVersion(a, b) {
14
+ return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
15
+ }
16
+
17
+ async function fileSignature(target, signal, observed) {
18
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
19
+
20
+ try {
21
+ const actual = await file.stat();
22
+
23
+ if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
24
+ if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
25
+ const hash = createHash("sha256");
26
+
27
+ for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
28
+ const after = await file.stat();
29
+
30
+ if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
31
+
32
+ return { size: actual.size, sha256: hash.digest("hex") };
33
+ } finally {
34
+ await file.close();
35
+ }
36
+ }
37
+
38
+ // realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
39
+ // ancestor so two symlink spellings still share one commit destination.
40
+ async function canonicalNewPath(target) {
41
+ let ancestor = path.dirname(target);
42
+
43
+ for (;;) {
44
+ try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
45
+ catch (error) {
46
+ if (error.code !== "ENOENT") throw error;
47
+ const parent = path.dirname(ancestor);
48
+
49
+ if (parent === ancestor) throw error;
50
+ ancestor = parent;
51
+ }
52
+ }
53
+ }
54
+
55
+ function sameSignature(a, b) {
56
+ return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
57
+ }
58
+
59
+ function tooLargeRead(label, maxBytes) {
60
+ return new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
61
+ }
62
+
63
+ function overlayOrThrow(overlay, maxBytes, label) {
64
+ if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw tooLargeRead(label, maxBytes);
65
+
66
+ return overlay;
67
+ }
68
+
69
+ function assertReadableFile(stat, target) {
70
+ if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
71
+
72
+ if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
73
+ }
74
+
75
+ async function readLimitedBytes(file, stat, maxBytes, label, signal) {
76
+ if (stat.size > maxBytes) throw tooLargeRead(label, maxBytes);
77
+ const chunks = [];
78
+ let size = 0;
79
+
80
+ for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal })) {
81
+ size += chunk.length;
82
+
83
+ if (size > maxBytes) throw tooLargeRead(label, maxBytes);
84
+ chunks.push(chunk);
85
+ }
86
+
87
+ return Buffer.concat(chunks);
88
+ }
89
+
90
+ function remapReadError(err, target) {
91
+ if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
92
+
93
+ if (err.code === "ENOENT") {
94
+ const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
95
+ missing.code = "ENOENT";
96
+ throw missing;
97
+ }
98
+
99
+ throw err;
100
+ }
101
+
102
+ async function resolveExistingFile(logicalPath) {
103
+ const target = await fs.realpath(logicalPath);
104
+ const stat = await fs.stat(target);
105
+
106
+ if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
107
+
108
+ return { target, stat };
109
+ }
110
+
111
+ async function resolveCommitTarget(logicalPath) {
112
+ try {
113
+ return await resolveExistingFile(logicalPath);
114
+ } catch (err) {
115
+ if (err.code !== "ENOENT") throw err;
116
+
117
+ return { target: await canonicalNewPath(logicalPath), stat: undefined };
118
+ }
119
+ }
120
+
121
+ async function collectMissingAncestors(parent) {
122
+ const missing = [];
123
+ let probe = parent;
124
+
125
+ for (;;) {
126
+ try { await fs.stat(probe); break; } catch (err) {
127
+ if (err.code !== "ENOENT") throw err;
128
+ missing.push(probe);
129
+ probe = path.dirname(probe);
130
+ }
131
+ }
132
+
133
+ return missing;
134
+ }
135
+
136
+ async function writeTemporary(entry, content, stat) {
137
+ await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
138
+
139
+ if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
140
+ }
141
+
142
+ async function stageReplacement(entry, content, stat, target) {
143
+ const replacement = writeTemporary(entry, content, stat);
144
+ // These touch separate staging files. Settle both before cleanup, even
145
+ // on failure: Promise.all could leave a late backup after rollback.
146
+ const staging = [replacement];
147
+
148
+ if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
149
+ const outcomes = await Promise.allSettled(staging);
150
+ const failure = outcomes.find(outcome => outcome.status === "rejected");
151
+
152
+ if (failure) throw failure.reason;
153
+ }
154
+
155
+ function makeStageEntry(logicalPath, target, content, parent, stat) {
156
+ const token = ".supernova-" + randomUUID();
157
+
158
+ return { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
159
+ }
160
+
161
+ async function recoverReplaced(staged) {
162
+ const recoveryErrors = [];
163
+
164
+ for (const entry of staged.toReversed()) {
165
+ if (!entry.replaced) continue;
166
+
167
+ try {
168
+ if (entry.existed) await fs.rename(entry.backup, entry.target);
169
+ else await fs.unlink(entry.target);
170
+ } catch (err) {
171
+ // Keep the backup if recovery fails; never delete the remaining original.
172
+ entry.keepBackup = true;
173
+ recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
174
+ }
175
+ }
176
+
177
+ return recoveryErrors;
178
+ }
179
+
180
+ async function cleanupStaged(staged, failed, createdDirs) {
181
+ for (const entry of staged) {
182
+ // A successful rename consumed the temporary path. These are known
183
+ // files, so unlink avoids rm's extra type probe; missing files stay benign.
184
+ if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
185
+
186
+ if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
187
+ }
188
+
189
+ if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
190
+ }
191
+
192
+ async function assertExpectedSignature(vfs, logicalPath, target, stat) {
193
+ if (!vfs.expected.has(logicalPath)) return;
194
+ const current = stat ? await fileSignature(target, vfs.signal) : null;
195
+
196
+ if (!sameSignature(current, vfs.expected.get(logicalPath))) {
197
+ throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
198
+ }
199
+ }
200
+
201
+ async function installStaged(vfs, staged) {
202
+ for (const entry of staged) {
203
+ vfs.signal?.throwIfAborted();
204
+ await fs.rename(entry.temporary, entry.target);
205
+ entry.replaced = true;
206
+ }
207
+
208
+ for (const entry of staged) {
209
+ vfs.expected.set(entry.logicalPath, textSignature(entry.content));
210
+ }
211
+
212
+ // Canonical commit destinations must not rewrite established event paths
213
+ // for newly created files, whose callers supplied a logical cwd spelling.
214
+ if (staged.length) vfs.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
215
+ }
216
+
217
+ async function failCommit(vfs, staged, error) {
218
+ const recoveryErrors = await recoverReplaced(staged);
219
+ // Keep CAS baselines: a failed commit must not forgive conflicts on files it
220
+ // never touched. If recovery left disk diverging from a baseline, the next
221
+ // write to that path fails loudly and forces a re-read instead of silently
222
+ // re-capturing unknown bytes as the new truth.
223
+
224
+ if (recoveryErrors.length) vfs.mutations.recoveryFailed = true;
225
+
226
+ if (recoveryErrors.length) vfs.onNewFile?.(null);
227
+
228
+ if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
229
+ throw error;
230
+ }
231
+
11
232
  export class CausalVfs {
12
233
  constructor(onNewFile, validateWrite) {
13
234
  this.validateWrite = validateWrite;
14
- // Last-seen original bytes for write CAS, not a read cache. read() always
15
- // hits disk unless an overlay is staged. Serving cache on read would be a
16
- // false-valid against editors/git between two reads.
17
- this.cache = new Map();
235
+ // No body cache: every read hits disk (or its overlay) so observed bytes
236
+ // are never stale. CAS baselines in `expected` are the only retained
237
+ // per-file state, cleared only at external-mutation boundaries.
18
238
  this.overlays = [];
19
239
  this.expected = new Map();
20
240
  this.onNewFile = onNewFile;
@@ -28,11 +248,6 @@ export class CausalVfs {
28
248
  this.signal?.throwIfAborted();
29
249
  }
30
250
 
31
- setCache(target, content) {
32
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) this.cache.delete(this.cache.keys().next().value);
33
- this.cache.set(target, content);
34
- }
35
-
36
251
  getOverlay(target) {
37
252
  for (let i = this.overlays.length - 1; i >= 0; i--) {
38
253
  if (this.overlays[i].has(target)) return this.overlays[i].get(target);
@@ -43,60 +258,54 @@ export class CausalVfs {
43
258
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
44
259
  }
45
260
 
46
- async read(target, { preserveRead = false, maxBytes } = {}) {
261
+ async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
47
262
  const overlay = this.getOverlay(target);
48
263
 
49
- if (overlay !== undefined) {
50
- if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
51
-
52
- return overlay;
53
- }
264
+ if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label);
54
265
 
55
266
  // External editors and captured tools can change a file between any two reads.
267
+ // Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
56
268
  try {
57
- let text;
58
-
59
- if (maxBytes === undefined) text = await fs.readFile(target, "utf8");
60
- else {
61
- const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
62
-
63
- try {
64
- const stat = await file.stat();
65
-
66
- if (!stat.isFile()) throw new Error("JSON read requires a regular file: " + target);
67
- const tooLarge = () => new Error("JSON input exceeds " + maxBytes + " bytes; use a streaming parser through bash");
68
-
69
- if (stat.size > maxBytes) throw tooLarge();
70
- const chunks = [];
71
- let size = 0;
72
-
73
- for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false, signal: this.signal })) {
74
- size += chunk.length;
269
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
270
+ let bytes;
271
+
272
+ try {
273
+ const stat = await file.stat();
274
+ assertReadableFile(stat, target);
275
+ bytes = maxBytes === undefined
276
+ ? await file.readFile({ signal: this.signal })
277
+ : await readLimitedBytes(file, stat, maxBytes, label, this.signal);
278
+ if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
279
+ } finally { await file.close(); }
280
+
281
+ // Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
282
+ if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
283
+
284
+ return bytes.toString("utf8");
285
+ } catch (err) {
286
+ remapReadError(err, target);
287
+ }
288
+ }
75
289
 
76
- if (size > maxBytes) throw tooLarge();
77
- chunks.push(chunk);
78
- }
290
+ async #diskSignature(target) {
291
+ let stat;
79
292
 
80
- text = Buffer.concat(chunks).toString("utf8");
81
- } finally { await file.close(); }
82
- }
293
+ try { stat = await fs.stat(target); }
294
+ catch (error) { if (error.code !== "ENOENT") throw error; }
83
295
 
84
- if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
85
-
86
- return text;
87
- } catch (err) {
88
- this.cache.delete(target);
296
+ return stat?.isFile() ? await fileSignature(target, this.signal, stat) : null;
297
+ }
89
298
 
90
- if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
299
+ async captureExpected(target) {
300
+ if (this.getOverlay(target) !== undefined || this.expected.has(target)) return;
91
301
 
92
- if (err.code === "ENOENT") {
93
- const missing = new Error("no such file: " + target + ' (locate it with read using a directory path or source question)');
94
- missing.code = "ENOENT";
95
- throw missing;
96
- }
302
+ this.expected.set(target, await this.#diskSignature(target));
303
+ }
97
304
 
98
- throw err;
99
- }
305
+ async recordExpected(target, observed) {
306
+ if (this.getOverlay(target) !== undefined) return;
307
+ const signature = observed ? await fileSignature(target, this.signal, observed) : await this.#diskSignature(target);
308
+ this.expected.set(target, signature);
100
309
  }
101
310
 
102
311
  async write(target, content) {
@@ -112,14 +321,7 @@ export class CausalVfs {
112
321
 
113
322
  this.assertWritable();
114
323
 
115
- if (this.getOverlay(target) === undefined) {
116
- let original;
117
-
118
- try { original = this.cache.has(target) ? this.cache.get(target) : await fs.readFile(target, "utf8"); }
119
- catch (error) { if (error.code !== "ENOENT") throw error; original = null; }
120
-
121
- this.expected.set(target, original);
122
- }
324
+ await this.captureExpected(target);
123
325
 
124
326
  this.assertWritable();
125
327
 
@@ -159,114 +361,28 @@ export class CausalVfs {
159
361
  try {
160
362
  for (const [logicalPath, content] of writes) {
161
363
  this.signal?.throwIfAborted();
162
- let target = logicalPath;
163
- let stat;
164
-
165
- try {
166
- target = await fs.realpath(logicalPath);
167
- stat = await fs.stat(target);
168
-
169
- if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
170
- } catch (err) {
171
- if (err.code !== "ENOENT") throw err;
172
- }
173
-
364
+ const { target, stat } = await resolveCommitTarget(logicalPath);
174
365
  await this.validateWrite?.(logicalPath);
175
366
 
176
367
  if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
177
368
  targets.add(target);
178
-
179
- if (this.expected.has(logicalPath)) {
180
- const current = stat ? await fs.readFile(target, "utf8") : null;
181
-
182
- if (current !== this.expected.get(logicalPath)) {
183
- throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
184
- }
185
- }
186
-
369
+ await assertExpectedSignature(this, logicalPath, target, stat);
187
370
  const parent = path.dirname(target);
188
- const missing = [];
189
- let probe = parent;
190
-
191
- for (;;) {
192
- try { await fs.stat(probe); break; } catch (err) {
193
- if (err.code !== "ENOENT") throw err;
194
- missing.push(probe);
195
- probe = path.dirname(probe);
196
- }
197
- }
198
-
371
+ const missing = await collectMissingAncestors(parent);
199
372
  await fs.mkdir(parent, { recursive: true });
200
373
  createdDirs.push(...missing.reverse());
201
- const token = ".supernova-" + randomUUID();
202
- const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
374
+ const entry = makeStageEntry(logicalPath, target, content, parent, stat);
203
375
  staged.push(entry);
204
-
205
- const replacement = (async () => {
206
- await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
207
-
208
- if (stat) await fs.chmod(entry.temporary, stat.mode & 0o7777);
209
- })();
210
-
211
- // These touch separate staging files. Settle both before cleanup, even
212
- // on failure: Promise.all could leave a late backup after rollback.
213
- const staging = [replacement];
214
-
215
- if (stat) staging.push(fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL));
216
- const outcomes = await Promise.allSettled(staging);
217
- const failure = outcomes.find(outcome => outcome.status === "rejected");
218
-
219
- if (failure) throw failure.reason;
376
+ await stageReplacement(entry, content, stat, target);
220
377
  }
221
378
 
222
- for (const entry of staged) {
223
- this.signal?.throwIfAborted();
224
- await fs.rename(entry.temporary, entry.target);
225
- entry.replaced = true;
226
- }
227
-
228
- for (const entry of staged) {
229
- this.setCache(entry.logicalPath, entry.content);
230
- this.expected.delete(entry.logicalPath);
231
- }
232
-
233
- if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
379
+ await installStaged(this, staged);
234
380
  this.mutations.committed += staged.length;
235
381
  } catch (error) {
236
382
  failed = true;
237
- const recoveryErrors = [];
238
-
239
- for (const entry of staged.toReversed()) {
240
- if (!entry.replaced) continue;
241
-
242
- try {
243
- if (entry.existed) await fs.rename(entry.backup, entry.target);
244
- else await fs.unlink(entry.target);
245
- } catch (err) {
246
- // Keep the backup if recovery fails; never delete the remaining original.
247
- entry.keepBackup = true;
248
- recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
249
- }
250
- }
251
-
252
- this.invalidateCache();
253
-
254
- if (recoveryErrors.length) this.mutations.recoveryFailed = true;
255
-
256
- if (recoveryErrors.length) this.onNewFile?.(null);
257
-
258
- if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
259
- throw error;
383
+ await failCommit(this, staged, error);
260
384
  } finally {
261
- for (const entry of staged) {
262
- // A successful rename consumed the temporary path. These are known
263
- // files, so unlink avoids rm's extra type probe; missing files stay benign.
264
- if (!entry.replaced) await fs.unlink(entry.temporary).catch(() => {});
265
-
266
- if (entry.existed && !entry.keepBackup) await fs.unlink(entry.backup).catch(() => {});
267
- }
268
-
269
- if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
385
+ await cleanupStaged(staged, failed, createdDirs);
270
386
  }
271
387
  }
272
388
 
@@ -291,9 +407,8 @@ export class CausalVfs {
291
407
  const top = this.overlays.pop();
292
408
  this.mutations.rolledBack += top?.size ?? 0;
293
409
 
294
- for (const target of top?.keys() ?? []) {
295
- if (this.getOverlay(target) === undefined) this.expected.delete(target);
296
- }
410
+ // Rolling back staged writes does not undo observations of disk. Keep the
411
+ // read snapshot, including for files without a surviving parent overlay.
297
412
 
298
413
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
299
414
  }
@@ -316,7 +431,19 @@ export class CausalVfs {
316
431
  return pending.size > 0;
317
432
  }
318
433
 
319
- invalidateCache() { this.cache.clear(); }
320
- getCacheSize() { return this.cache.size; }
434
+ /** External-mutation boundary: drop CAS baselines so the next access re-observes disk. */
435
+ invalidateObserved() { this.expected.clear(); }
321
436
  getOverlayDepth() { return this.overlays.length; }
437
+ describeOverlays() {
438
+ let files = 0, bytes = 0;
439
+
440
+ for (const layer of this.overlays) {
441
+ for (const content of layer.values()) {
442
+ files++;
443
+ bytes += Buffer.byteLength(content, "utf8");
444
+ }
445
+ }
446
+
447
+ return { files, bytes };
448
+ }
322
449
  }