pi-supernova 0.5.0 → 0.6.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.
@@ -20,6 +20,8 @@ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
20
20
 
21
21
  const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
22
22
 
23
+ const MAX_NEEDLE_CHARS = 128;
24
+
23
25
  const MAX_ALTERNATIVES = 3;
24
26
 
25
27
  /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
@@ -82,21 +84,25 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
82
84
  const lower = relative.toLowerCase();
83
85
  const base = path.basename(lower);
84
86
 
87
+ const extension = path.extname(base);
88
+ const stemBase = extension ? base.slice(0, -extension.length) : base;
85
89
  const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
86
- || base.slice(0, -path.extname(base).length) === query.toLowerCase();
90
+ || stemBase === query.toLowerCase();
91
+
92
+ const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
87
93
 
88
94
  return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
89
- pathCoverage: tokens.filter(token => lower.includes(token)).length,
95
+ pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
90
96
  matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
91
97
  line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
92
98
  }
93
99
 
94
- function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
100
+ function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
95
101
  const text = raw.replace(/\r?\n$/, "");
96
102
  const lower = text.toLowerCase();
97
103
 
98
104
  if (isMatch) {
99
- const matches = tokens.filter(token => lower.includes(token));
105
+ const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
100
106
 
101
107
  for (const token of matches) candidate.matched.add(token);
102
108
  const ext = path.extname(candidate.path).toLowerCase();
@@ -108,7 +114,7 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
108
114
  for (const item of items) {
109
115
  const name = item.name.toLowerCase();
110
116
  const itemExact = name === query.toLowerCase();
111
- const coverage = tokens.filter(token => name.includes(token)).length;
117
+ const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
112
118
 
113
119
  if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
114
120
 
@@ -140,33 +146,47 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
140
146
  if (candidate.recent.length > 2) candidate.recent.shift();
141
147
  }
142
148
 
143
- function inspectOverlay(candidate, text, needles, query, tokens) {
144
- const lines = text.split("\n");
145
- const matches = [];
146
-
147
- for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
148
-
149
- for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
150
- candidate.context.clear();
149
+ function inspectOverlay(candidate, text, needles, query, tokens, signal) {
150
+ let start = 0, line = 1, truncated = false;
151
+
152
+ // Keep only the candidate and its short context, not another copy of every
153
+ // line in a staged document. Oversized individual lines disclose uncertainty.
154
+ while (start < text.length) {
155
+ if ((line & 127) === 0) signal?.throwIfAborted();
156
+ const newline = text.indexOf("\n", start);
157
+ const end = newline < 0 ? text.length : newline + 1;
158
+
159
+ if (end - start > MAX_SEARCH_CHARS) truncated = true;
160
+ else {
161
+ const row = text.slice(start, end);
162
+ const lower = row.toLowerCase();
163
+ inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
164
+ }
165
+ start = end;
166
+ line++;
167
+ }
151
168
 
152
- for (let i = Math.max(0, candidate.line - 3); i < Math.min(lines.length, candidate.line + 4); i++) candidate.context.set(i + 1, truncateChars(lines[i], 240, "source line").text);
169
+ return truncated;
153
170
  }
154
171
 
155
- async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
156
- const needles = exact ? [query.toLowerCase()] : tokens;
172
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
173
+ const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
174
+ const searchNeedles = [...new Set(needles)];
175
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
176
+ const candidates = new Map();
177
+
157
178
  const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
158
179
 
159
180
  if (includeHidden) args.push("--hidden");
160
181
  args.push("-g", "!.git/**", "-g", "!**/.git/**");
161
182
 
162
- for (const needle of needles) args.push("-e", needle);
163
- args.push("--", dir);
183
+ for (const needle of searchNeedles) args.push("-e", needle);
184
+ args.push("--", focusFile ?? dir);
164
185
 
165
- const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
186
+ const response = diskFiles || (focusFile && overlayText(focusFile) === undefined) ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
166
187
  : { stdout: "", stderr: "", exitCode: 1 };
167
188
 
168
189
  if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
169
- const candidates = new Map();
170
190
  const records = response.stdout.split("\n");
171
191
 
172
192
  for (let i = 0; i < records.length; i++) {
@@ -190,24 +210,26 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pen
190
210
  let candidate = candidates.get(filePath);
191
211
 
192
212
  if (!candidate) {
193
- candidate = makeCandidate(filePath, dir, query, tokens, flags);
213
+ candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
194
214
  candidates.set(filePath, candidate);
195
215
  }
196
216
 
197
- inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
217
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
198
218
  }
199
219
 
220
+ let overlayTruncated = false;
221
+
200
222
  for (const filePath of pendingPaths) {
201
223
  const pending = overlayText(filePath);
202
224
 
203
225
  if (pending === undefined) continue;
204
- const candidate = makeCandidate(filePath, dir, query, tokens, flags);
205
- inspectOverlay(candidate, pending, needles, query, tokens);
226
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
227
+ overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
206
228
 
207
229
  if (candidate.matched.size) candidates.set(filePath, candidate);
208
230
  }
209
231
 
210
- return { candidates, truncated: response.outputTruncated === true };
232
+ return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
211
233
  }
212
234
 
213
235
  function rankScore(candidate, tokenCount) {
@@ -224,25 +246,48 @@ function location(candidate, root) {
224
246
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
225
247
  }
226
248
 
227
- async function spanCandidates(filePath, lines, root, overlayText) {
249
+ async function spanCandidates(filePath, lines, root, overlayText, signal) {
228
250
  const staged = overlayText(filePath);
229
- const text = staged !== undefined ? staged : await fs.readFile(filePath, "utf8");
230
- const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
231
251
  const rel = path.relative(root, filePath);
252
+ let text = staged;
253
+
254
+ if (text === undefined) {
255
+ const file = await fs.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
256
+
257
+ try {
258
+ const stat = await file.stat();
259
+
260
+ if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
261
+ if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
262
+ text = await file.readFile({ encoding: "utf8", signal });
263
+ } finally { await file.close(); }
264
+ }
265
+ const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
232
266
 
233
267
  return lines.map(line => {
234
268
  const span = pickSpan(spans, { line }) ?? { start: line, end: line };
269
+ const end = Math.min(span.end, span.start + 119);
235
270
 
236
- return spanCandidate(rel, line, spanWindow(text, span.start, span.end));
271
+ return spanCandidate(rel, line, spanWindow(text, span.start, end));
237
272
  });
238
273
  }
239
274
 
240
- async function rankedSpanCandidates(ranked, root, overlayText) {
275
+ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
241
276
  const out = [];
242
277
 
243
278
  for (const candidate of ranked) {
244
279
  const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
245
- out.push(...await spanCandidates(candidate.path, lines, root, overlayText));
280
+ const staged = overlayText(candidate.path);
281
+ let large = false;
282
+
283
+ if (staged !== undefined) large = Buffer.byteLength(staged) > 512 * 1024;
284
+ else try { large = (await fs.stat(candidate.path)).size > 512 * 1024; } catch {}
285
+
286
+ if (large) out.push(location(candidate, root));
287
+ else {
288
+ try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
289
+ catch (error) { signal?.throwIfAborted(); out.push(location(candidate, root)); }
290
+ }
246
291
  if (out.length >= MAX_ALTERNATIVES) break;
247
292
  }
248
293
 
@@ -251,11 +296,11 @@ async function rankedSpanCandidates(ranked, root, overlayText) {
251
296
 
252
297
  export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
253
298
  const flags = tokenizeQuery(query);
299
+
300
+ if (flags.tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
254
301
  const tokens = [...new Set(flags.tokens.map(stem))];
255
302
 
256
303
  if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
257
-
258
- if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
259
304
  query = query.trim();
260
305
  const dir = path.resolve(searchDir || process.cwd());
261
306
 
@@ -264,15 +309,21 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
264
309
  flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
265
310
  pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
266
311
 
267
- const diskFiles = await fs.stat(dir).then(stat => stat.isDirectory(), error => {
268
- if (error.code !== "ENOENT" || !pendingPaths.length) throw error;
312
+ const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
313
+ const dirStat = await fs.stat(dir).catch(error => {
314
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
269
315
 
270
- return false;
316
+ return null;
271
317
  });
272
318
 
273
- const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
319
+ if (!dirStat && !pendingPaths.length) return { ...empty, status: "not_found" };
320
+ const diskFiles = dirStat?.isDirectory() === true;
321
+ const focusFile = dirStat?.isFile() === true || pendingPaths.includes(dir) ? dir : null;
322
+
274
323
  const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
275
- const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles });
324
+
325
+ if (!tokens.length) return { ...empty, status: "not_found" };
326
+ const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile });
276
327
  // A declaration hit needs no prerequisite file listing or persistent index.
277
328
  // Bare names can name files, even when callers mention the same word.
278
329
  const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
@@ -283,26 +334,27 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
283
334
 
284
335
  if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
285
336
 
286
- const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...pendingPaths])]
337
+ const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...(focusFile ? [focusFile] : []), ...pendingPaths])]
287
338
  .filter(file => inScope(file, dir, includeHidden));
339
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
288
340
 
289
341
  for (const filePath of paths) {
290
342
  if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
291
- const relative = path.relative(dir, filePath).toLowerCase();
343
+ const relative = path.relative(candidateRoot, filePath).toLowerCase();
292
344
 
293
345
  if (!tokens.some(token => relative.includes(token))) continue;
294
346
 
295
347
  if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
296
- const candidate = makeCandidate(filePath, dir, query, tokens, flags);
348
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
297
349
 
298
- if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
350
+ if (focusFile || candidate.pathScore > 0) search.candidates.set(filePath, candidate);
299
351
  }
300
352
 
301
353
  const ranked = [];
302
354
 
303
355
  for (const candidate of search.candidates.values()) {
304
- if (candidate.pathScore > -50) {
305
- ranked.push({ ...candidate, score: rankScore(candidate, tokens.length) });
356
+ if (focusFile || candidate.pathScore > -50) {
357
+ ranked.push({ ...candidate, score: focusFile ? Math.max(1, rankScore(candidate, tokens.length)) : rankScore(candidate, tokens.length) });
306
358
  }
307
359
  }
308
360
 
@@ -336,11 +388,11 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
336
388
  const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
337
389
 
338
390
  if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
339
- return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText) };
391
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText, signal) };
340
392
  }
341
393
 
342
394
  if (best.exactLines.size > 1) {
343
- return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText) };
395
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText, signal) };
344
396
  }
345
397
 
346
398
  const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
@@ -8,14 +8,20 @@ function scanPython(lines) {
8
8
  const line = lines[i];
9
9
  const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
10
10
 
11
- if (!match) continue;
12
- items.push({
13
- kind: match[2].includes("def") ? "function" : "class",
14
- name: match[3],
15
- signature: match[0].trim(),
16
- line: i + 1,
17
- depth: Math.floor(match[1].length / 4),
18
- });
11
+ if (match) {
12
+ items.push({
13
+ kind: match[2].includes("def") ? "function" : "class",
14
+ name: match[3],
15
+ signature: match[0].trim(),
16
+ line: i + 1,
17
+ depth: Math.floor(match[1].length / 4),
18
+ });
19
+ continue;
20
+ }
21
+
22
+ const constant = /^([ \t]*)([A-Z][A-Z0-9_]*)\s*(?::[^=\n]+)?=/.exec(line);
23
+
24
+ if (constant && constant[1].length === 0) items.push({ kind: "constant", name: constant[2], signature: constant[0].replace(/=\s*$/, "=").trim(), line: i + 1, depth: 0 });
19
25
  }
20
26
 
21
27
  return items;
@@ -141,7 +147,7 @@ const SCANNERS = {
141
147
  export function extractStructuralSurface(code, extension = "js") {
142
148
  if (!isString(code) || !code.trim()) return { items: [], lineCount: 0 };
143
149
  const lines = code.split("\n");
144
- const ext = extension.replace(/^\./, "").toLowerCase();
150
+ const ext = String(extension ?? "").replace(/^\./, "").toLowerCase();
145
151
  const scanner = SCANNERS[ext] || SCANNERS.js;
146
152
  const items = scanner(lines);
147
153
 
package/src/fs/check.js CHANGED
@@ -60,7 +60,7 @@ function skipComment(text, i) {
60
60
 
61
61
  const end = text.indexOf("*/", i + 2);
62
62
 
63
- return end < 0 ? text.length : end + 2;
63
+ return end < 0 ? -1 : end + 2;
64
64
  }
65
65
 
66
66
  function skipRegex(text, i) {
@@ -126,7 +126,13 @@ function consumeLiteral(text, i, stack, prev) {
126
126
 
127
127
  if (c !== "/") return null;
128
128
 
129
- if (text[i + 1] === "/" || text[i + 1] === "*") return { end: skipComment(text, i), prev };
129
+ if (text[i + 1] === "/" || text[i + 1] === "*") {
130
+ const end = skipComment(text, i);
131
+
132
+ if (end < 0) return { error: "unterminated comment", at: i };
133
+
134
+ return { end, prev };
135
+ }
130
136
 
131
137
  if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
132
138
  const end = skipRegex(text, i);
@@ -190,6 +196,8 @@ const CODE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts",
190
196
 
191
197
  /** { ok: true } | { ok: false, message }; message names the problem and line. */
192
198
  export function quickCheck(text, ext) {
199
+ ext = String(ext ?? "").toLowerCase();
200
+
193
201
  if (ext === ".json") {
194
202
  try {
195
203
  JSON.parse(text);
package/src/fs/patch.js CHANGED
@@ -67,7 +67,9 @@ function findHunkMatch(fileLines, expectedOld, nominal) {
67
67
 
68
68
  if (!expectedOld.length) return -1;
69
69
 
70
- for (let delta = 1; delta <= Math.max(fileLines.length, 100); delta++) {
70
+ const maxDrift = Math.min(Math.max(fileLines.length, 100), 200);
71
+
72
+ for (let delta = 1; delta <= maxDrift; delta++) {
71
73
  if (matchAt(nominal + delta)) return nominal + delta;
72
74
 
73
75
  if (matchAt(nominal - delta)) return nominal - delta;
@@ -103,7 +105,7 @@ export function applyPatchToText(originalText, patchText) {
103
105
  const line = hunk.lines[i];
104
106
 
105
107
  if (line[0] === "+") {
106
- replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : line.endsWith("\r") ? "\r\n" : ending });
108
+ replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : ending });
107
109
  } else {
108
110
  const original = fileLines[oldIndex++];
109
111
 
package/src/fs/vfs.js CHANGED
@@ -1,20 +1,73 @@
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";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
 
6
6
  const VFS_CACHE_MAX = 1024;
7
7
 
8
+ const VFS_CACHE_MAX_BYTES = 64 * 1024 * 1024;
9
+
8
10
  // Serialize validation + replacement across Supernova transactions in this host.
9
11
  let commitTail = Promise.resolve();
10
12
 
13
+ function textSignature(text) {
14
+ return { size: Buffer.byteLength(text, "utf8"), sha256: createHash("sha256").update(text, "utf8").digest("hex") };
15
+ }
16
+
17
+ function sameFileVersion(a, b) {
18
+ return ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every(key => a[key] === b[key]);
19
+ }
20
+
21
+ async function fileSignature(target, signal, observed) {
22
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
23
+
24
+ try {
25
+ const actual = await file.stat();
26
+
27
+ if (!actual.isFile()) throw new Error("read requires a regular file: " + target);
28
+ if (observed && !sameFileVersion(observed, actual)) throw new Error("file changed while reading: " + target);
29
+ const hash = createHash("sha256");
30
+
31
+ for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
32
+ const after = await file.stat();
33
+
34
+ if (!after.isFile() || !sameFileVersion(actual, after)) throw new Error("file changed while signing: " + target);
35
+
36
+ return { size: actual.size, sha256: hash.digest("hex") };
37
+ } finally {
38
+ await file.close();
39
+ }
40
+ }
41
+
42
+ // realpath() cannot resolve a missing leaf. Canonicalize its nearest existing
43
+ // ancestor so two symlink spellings still share one commit destination.
44
+ async function canonicalNewPath(target) {
45
+ let ancestor = path.dirname(target);
46
+
47
+ for (;;) {
48
+ try { return path.join(await fs.realpath(ancestor), path.relative(ancestor, target)); }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT") throw error;
51
+ const parent = path.dirname(ancestor);
52
+
53
+ if (parent === ancestor) throw error;
54
+ ancestor = parent;
55
+ }
56
+ }
57
+ }
58
+
59
+ function sameSignature(a, b) {
60
+ return a === b || (a !== null && b !== null && a.size === b.size && a.sha256 === b.sha256);
61
+ }
62
+
11
63
  export class CausalVfs {
12
64
  constructor(onNewFile, validateWrite) {
13
65
  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.
66
+ // Optional receipt bodies, never the authority for CAS. Signatures survive
67
+ // body eviction. Explicit reads hit disk and replace the observed snapshot;
68
+ // internal receipt reads preserve it until an external-mutation boundary.
17
69
  this.cache = new Map();
70
+ this.cacheBytes = 0;
18
71
  this.overlays = [];
19
72
  this.expected = new Map();
20
73
  this.onNewFile = onNewFile;
@@ -28,9 +81,31 @@ export class CausalVfs {
28
81
  this.signal?.throwIfAborted();
29
82
  }
30
83
 
84
+ dropCache(target) {
85
+ const previous = this.cache.get(target);
86
+
87
+ if (previous !== undefined && this.cache.delete(target)) this.cacheBytes -= Buffer.byteLength(previous, "utf8");
88
+ }
89
+
31
90
  setCache(target, content) {
32
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) this.cache.delete(this.cache.keys().next().value);
91
+ const bytes = Buffer.byteLength(content, "utf8");
92
+
93
+ if (bytes > VFS_CACHE_MAX_BYTES) {
94
+ this.dropCache(target);
95
+ return;
96
+ }
97
+
98
+ this.dropCache(target);
99
+
100
+ while ((this.cache.size >= VFS_CACHE_MAX || this.cacheBytes + bytes > VFS_CACHE_MAX_BYTES) && this.cache.size) {
101
+ const oldest = this.cache.keys().next().value;
102
+
103
+ this.cacheBytes -= Buffer.byteLength(this.cache.get(oldest), "utf8");
104
+ this.cache.delete(oldest);
105
+ }
106
+
33
107
  this.cache.set(target, content);
108
+ this.cacheBytes += bytes;
34
109
  }
35
110
 
36
111
  getOverlay(target) {
@@ -43,28 +118,30 @@ export class CausalVfs {
43
118
  return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
44
119
  }
45
120
 
46
- async read(target, { preserveRead = false, maxBytes } = {}) {
121
+ async read(target, { preserveRead = false, maxBytes, label = "read input" } = {}) {
47
122
  const overlay = this.getOverlay(target);
48
123
 
49
124
  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");
125
+ if (maxBytes !== undefined && Buffer.byteLength(overlay, "utf8") > maxBytes) throw new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
51
126
 
52
127
  return overlay;
53
128
  }
54
129
 
55
130
  // External editors and captured tools can change a file between any two reads.
131
+ // Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
56
132
  try {
57
- let text;
133
+ const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
134
+ let bytes;
58
135
 
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));
136
+ try {
137
+ const stat = await file.stat();
62
138
 
63
- try {
64
- const stat = await file.stat();
139
+ if (stat.isDirectory()) throw new Error("read path is a directory, not a file: " + target);
140
+ if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
65
141
 
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");
142
+ if (maxBytes === undefined) bytes = await file.readFile({ signal: this.signal });
143
+ else {
144
+ const tooLarge = () => new Error(label + " exceeds " + maxBytes + " bytes; use a streaming parser through bash");
68
145
 
69
146
  if (stat.size > maxBytes) throw tooLarge();
70
147
  const chunks = [];
@@ -77,15 +154,19 @@ export class CausalVfs {
77
154
  chunks.push(chunk);
78
155
  }
79
156
 
80
- text = Buffer.concat(chunks).toString("utf8");
81
- } finally { await file.close(); }
82
- }
157
+ bytes = Buffer.concat(chunks);
158
+ }
159
+ if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
160
+ } finally { await file.close(); }
83
161
 
84
- if (!preserveRead || !this.cache.has(target)) this.setCache(target, text);
162
+ // Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
163
+ if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
164
+ const text = bytes.toString("utf8");
165
+ this.setCache(target, text);
85
166
 
86
167
  return text;
87
168
  } catch (err) {
88
- this.cache.delete(target);
169
+ this.dropCache(target);
89
170
 
90
171
  if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
91
172
 
@@ -99,6 +180,27 @@ export class CausalVfs {
99
180
  }
100
181
  }
101
182
 
183
+ async #diskSignature(target) {
184
+ let stat;
185
+
186
+ try { stat = await fs.stat(target); }
187
+ catch (error) { if (error.code !== "ENOENT") throw error; }
188
+
189
+ return stat?.isFile() ? await fileSignature(target, this.signal, stat) : null;
190
+ }
191
+
192
+ async captureExpected(target) {
193
+ if (this.getOverlay(target) !== undefined || this.expected.has(target)) return;
194
+
195
+ this.expected.set(target, await this.#diskSignature(target));
196
+ }
197
+
198
+ async recordExpected(target, observed) {
199
+ if (this.getOverlay(target) !== undefined) return;
200
+ const signature = observed ? await fileSignature(target, this.signal, observed) : await this.#diskSignature(target);
201
+ this.expected.set(target, signature);
202
+ }
203
+
102
204
  async write(target, content) {
103
205
  this.assertWritable();
104
206
 
@@ -112,14 +214,7 @@ export class CausalVfs {
112
214
 
113
215
  this.assertWritable();
114
216
 
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
- }
217
+ await this.captureExpected(target);
123
218
 
124
219
  this.assertWritable();
125
220
 
@@ -169,6 +264,7 @@ export class CausalVfs {
169
264
  if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
170
265
  } catch (err) {
171
266
  if (err.code !== "ENOENT") throw err;
267
+ target = await canonicalNewPath(logicalPath);
172
268
  }
173
269
 
174
270
  await this.validateWrite?.(logicalPath);
@@ -177,9 +273,9 @@ export class CausalVfs {
177
273
  targets.add(target);
178
274
 
179
275
  if (this.expected.has(logicalPath)) {
180
- const current = stat ? await fs.readFile(target, "utf8") : null;
276
+ const current = stat ? await fileSignature(target, this.signal) : null;
181
277
 
182
- if (current !== this.expected.get(logicalPath)) {
278
+ if (!sameSignature(current, this.expected.get(logicalPath))) {
183
279
  throw new Error("write conflict: file changed since it was read: " + logicalPath + "; read it again before retrying");
184
280
  }
185
281
  }
@@ -227,10 +323,12 @@ export class CausalVfs {
227
323
 
228
324
  for (const entry of staged) {
229
325
  this.setCache(entry.logicalPath, entry.content);
230
- this.expected.delete(entry.logicalPath);
326
+ this.expected.set(entry.logicalPath, textSignature(entry.content));
231
327
  }
232
328
 
233
- if (staged.length) this.onNewFile?.(staged.map(entry => entry.target));
329
+ // Canonical commit destinations must not rewrite established event paths
330
+ // for newly created files, whose callers supplied a logical cwd spelling.
331
+ if (staged.length) this.onNewFile?.(staged.map(entry => entry.existed ? entry.target : entry.logicalPath));
234
332
  this.mutations.committed += staged.length;
235
333
  } catch (error) {
236
334
  failed = true;
@@ -291,9 +389,8 @@ export class CausalVfs {
291
389
  const top = this.overlays.pop();
292
390
  this.mutations.rolledBack += top?.size ?? 0;
293
391
 
294
- for (const target of top?.keys() ?? []) {
295
- if (this.getOverlay(target) === undefined) this.expected.delete(target);
296
- }
392
+ // Rolling back staged writes does not undo observations of disk. Keep the
393
+ // read snapshot, including for files without a surviving parent overlay.
297
394
 
298
395
  return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
299
396
  }
@@ -316,7 +413,7 @@ export class CausalVfs {
316
413
  return pending.size > 0;
317
414
  }
318
415
 
319
- invalidateCache() { this.cache.clear(); }
416
+ invalidateCache() { this.cache.clear(); this.cacheBytes = 0; this.expected.clear(); }
320
417
  getCacheSize() { return this.cache.size; }
321
418
  getOverlayDepth() { return this.overlays.length; }
322
419
  }