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.
- package/README.md +73 -11
- package/docs/CHANGELOG.md +46 -0
- package/docs/TOKEN_COSTS.md +63 -29
- package/index.js +7 -4
- package/package.json +2 -2
- package/src/bridge/catalog.js +14 -10
- package/src/bridge/host-bridge.js +810 -145
- package/src/bridge/native-tools.js +16 -6
- package/src/context/evidence.js +13 -5
- package/src/context/fuzzy.js +34 -13
- package/src/context/outline.js +11 -2
- package/src/context/repo-index.js +150 -15
- package/src/context/search.js +70 -17
- package/src/context/snap.js +98 -46
- package/src/context/surface.js +15 -9
- package/src/fs/check.js +10 -2
- package/src/fs/patch.js +4 -2
- package/src/fs/vfs.js +133 -36
- package/src/fs/workspace.js +7 -1
- package/src/output/bottleneck.js +36 -11
- package/src/output/format.js +44 -29
- package/src/runtime/guest-worker.js +135 -39
- package/src/runtime/parallel.js +4 -1
- package/src/runtime/program-batch.js +17 -6
- package/src/runtime/program-file.js +6 -3
- package/src/runtime/reference.js +5 -5
- package/src/runtime/runtime.js +17 -6
- package/src/shared/decode.js +15 -3
- package/src/ui/omp-frame.js +11 -4
- package/src/ui/render.js +2 -2
package/src/context/snap.js
CHANGED
|
@@ -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
|
-
||
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
268
|
-
|
|
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
|
|
316
|
+
return null;
|
|
271
317
|
});
|
|
272
318
|
|
|
273
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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);
|
package/src/context/surface.js
CHANGED
|
@@ -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 (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 ?
|
|
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] === "*")
|
|
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
|
-
|
|
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) ? "" :
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
133
|
+
const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
134
|
+
let bytes;
|
|
58
135
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
81
|
-
}
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
276
|
+
const current = stat ? await fileSignature(target, this.signal) : null;
|
|
181
277
|
|
|
182
|
-
if (current
|
|
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.
|
|
326
|
+
this.expected.set(entry.logicalPath, textSignature(entry.content));
|
|
231
327
|
}
|
|
232
328
|
|
|
233
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
}
|