pi-supernova 0.0.7 → 0.0.11

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/snap.js CHANGED
@@ -1,7 +1,7 @@
1
1
 
2
2
  import * as path from "node:path";
3
3
  import { isString } from "./decode.js";
4
- import { extractStructuralSurface } from "./surface.js";
4
+ import { WorkspaceIndex } from "./repo-index.js";
5
5
 
6
6
  const STOP_WORDS = new Set([
7
7
  "the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
@@ -30,68 +30,73 @@ export function tokenizeQuery(query) {
30
30
  };
31
31
  }
32
32
 
33
- export function scorePathTopology(filePath, tokens, { wantsTest, wantsDoc, wantsType }) {
34
- const norm = filePath.replace(/\\/g, "/").toLowerCase();
35
- const basename = path.basename(norm);
36
- const ext = path.extname(norm);
33
+ const SOURCE_EXT = new Set([".ts", ".js", ".mjs", ".rs", ".py", ".go"]);
34
+ const TYPED_EXT = new Set([".ts", ".d.ts", ".rs", ".go"]);
35
+ const VENDOR_SEGMENTS = ["node_modules/", "dist/", "target/"];
37
36
 
38
- const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
39
- if (isTest && !wantsTest) return -50;
40
- if (!isTest && wantsTest) return -20;
37
+ function tokenPathScore(token, basename, pathParts, norm) {
38
+ if (basename === token || basename.startsWith(token + ".")) return 60;
39
+ if (basename.includes(token)) return 30;
40
+ if (pathParts.includes(token)) return 15;
41
+ if (norm.includes(token)) return 5;
42
+ return 0;
43
+ }
41
44
 
42
- if (norm.includes("node_modules/") || norm.includes("dist/") || norm.includes("target/")) {
43
- return -100;
44
- }
45
+ function extensionBonus(ext, { wantsDoc, wantsType }) {
46
+ let bonus = 0;
47
+ if (SOURCE_EXT.has(ext) && !wantsDoc) bonus += 5;
48
+ if (wantsType && TYPED_EXT.has(ext)) bonus += 10;
49
+ return bonus;
50
+ }
45
51
 
46
- let score = 0;
52
+ export function scorePathTopology(filePath, tokens, flags) {
53
+ const norm = filePath.replaceAll("\\", "/").toLowerCase();
54
+ const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
55
+ if (isTest && !flags.wantsTest) return -50;
56
+ if (!isTest && flags.wantsTest) return -20;
57
+ if (VENDOR_SEGMENTS.some((segment) => norm.includes(segment))) return -100;
58
+
59
+ const basename = path.basename(norm);
47
60
  const pathParts = norm.split(/[^a-zA-Z0-9]+/);
61
+ let score = extensionBonus(path.extname(norm), flags);
62
+ for (const token of tokens) score += tokenPathScore(token, basename, pathParts, norm);
63
+ return score;
64
+ }
48
65
 
49
- for (const token of tokens) {
50
- if (basename === token || basename.startsWith(token + ".")) score += 60;
51
- else if (basename.includes(token)) score += 30;
52
- else if (pathParts.includes(token)) score += 15;
53
- else if (norm.includes(token)) score += 5;
54
- }
66
+ function isSkippableLine(lower) {
67
+ return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
68
+ }
55
69
 
56
- if ([".ts", ".js", ".mjs", ".rs", ".py", ".go"].includes(ext) && !wantsDoc) {
57
- score += 5;
58
- }
59
- if (wantsType && [".ts", ".d.ts", ".rs", ".go"].includes(ext)) {
60
- score += 10;
70
+ /** A line defines a token only when the declared name contains it; `const x = foo(token)` is a mention. */
71
+ function lineScoreFor(lower, tokens, definedName) {
72
+ let lineScore = 0;
73
+ for (const token of tokens) {
74
+ if (!lower.includes(token)) continue;
75
+ lineScore += definedName.includes(token) ? 40 : 5;
61
76
  }
62
-
63
- return score;
77
+ return lineScore;
64
78
  }
65
79
 
66
- function scoreContentDefinitions(content, tokens) {
67
- const lines = content.split("\n");
68
- let score = 0;
80
+ // Mentions are capped so a file that calls a symbol many times cannot outrank the file that defines it.
81
+ const MAX_MENTION_SCORE = 60;
82
+
83
+ function scoreContentDefinitions(entry, tokens) {
84
+ const { lower, defNames } = WorkspaceIndex.linesOf(entry);
85
+ let defScore = 0;
86
+ let mentionScore = 0;
69
87
  let bestLine = 1;
70
88
  let bestLineScore = 0;
71
-
72
- const defRegex = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
73
-
74
- for (let i = 0; i < lines.length; i++) {
75
- const line = lines[i].trim();
76
- if (!line || line.startsWith("//") || line.startsWith("#") || line.startsWith("*")) continue;
77
-
78
- let lineScore = 0;
79
- const isDef = defRegex.test(line);
80
-
81
- for (const token of tokens) {
82
- if (line.toLowerCase().includes(token)) {
83
- lineScore += isDef ? 40 : 5;
84
- }
85
- }
86
-
89
+ for (let i = 0; i < lower.length; i++) {
90
+ if (isSkippableLine(lower[i])) continue;
91
+ const lineScore = lineScoreFor(lower[i], tokens, defNames[i]);
87
92
  if (lineScore > bestLineScore) {
88
93
  bestLineScore = lineScore;
89
94
  bestLine = i + 1;
90
95
  }
91
- score += lineScore;
96
+ if (defNames[i]) defScore += lineScore;
97
+ else mentionScore += lineScore;
92
98
  }
93
-
94
- return { totalScore: score, bestLine, bestLineScore };
99
+ return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
95
100
  }
96
101
 
97
102
  function relativeHasSegment(relativePath, segmentName) {
@@ -102,150 +107,147 @@ function relativeHasHiddenSegment(relativePath) {
102
107
  return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
103
108
  }
104
109
 
105
- export async function executeSnap({ query, searchDir, includeHidden = false, vfs, runCommand, pendingPaths = [] }) {
106
- const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
107
- if (tokens.length === 0) {
108
- throw new Error("snap requires at least one searchable concept keyword");
109
- }
110
-
111
- const dir = searchDir || process.cwd();
112
- if (path.resolve(dir).split(path.sep).includes(".git")) {
113
- throw new Error("snap cannot search Git metadata");
114
- }
115
- let fileList = [];
116
- try {
117
- const rgArgs = ["rg", "--files"];
118
- if (includeHidden) rgArgs.push("--hidden");
119
- rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
120
- const res = await runCommand(rgArgs, { timeoutMs: 15_000 });
121
- fileList = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
122
- } catch {
123
- fileList = [];
124
- }
110
+ async function listCandidateFiles(dir, includeHidden, index) {
111
+ return (await index.files(dir, includeHidden)).slice();
112
+ }
125
113
 
114
+ function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
115
+ const resolvedDir = path.resolve(dir);
126
116
  const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
127
117
  for (const pendingPath of pendingPaths) {
128
118
  const absolutePath = path.resolve(pendingPath);
129
- const relativePath = path.relative(path.resolve(dir), absolutePath);
119
+ const relativePath = path.relative(resolvedDir, absolutePath);
130
120
  const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
131
121
  const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
132
122
  if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
133
123
  seenPaths.add(absolutePath);
134
124
  fileList.push(absolutePath);
135
125
  }
126
+ return fileList;
127
+ }
136
128
 
137
- if (fileList.length === 0) {
138
- throw new Error(`no files found to search in ${dir}`);
129
+ function mergeGrepHits(candidates, grepHits) {
130
+ const seen = new Set(candidates);
131
+ for (const h of grepHits) {
132
+ if (seen.has(h)) continue;
133
+ seen.add(h);
134
+ candidates.push(h);
135
+ if (candidates.length >= 15) break;
139
136
  }
137
+ return candidates;
138
+ }
140
139
 
141
- const scoredPaths = [];
142
- for (const f of fileList) {
143
- const score = scorePathTopology(f, tokens, { wantsTest, wantsDoc, wantsType });
144
- if (score > 0) scoredPaths.push({ path: f, score });
145
- }
140
+ function isTestPath(filePath) {
141
+ const segments = filePath.split(path.sep);
142
+ const base = segments[segments.length - 1];
143
+ return segments.includes("test") || segments.includes("tests") || base.includes(".test.") || base.includes(".spec.");
144
+ }
146
145
 
147
- scoredPaths.sort((a, b) => b.score - a.score);
146
+ function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
147
+ if (candidates.length >= 5) return candidates;
148
+ const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
149
+ const scope = flags.wantsTest ? fileList : fileList.filter((f) => !isTestPath(f));
150
+ const hits = index.filesContaining(scope, salient, true);
151
+ mergeGrepHits(candidates, hits);
152
+ if (candidates.length === 0) return fileList.slice(0, 5);
153
+ return candidates;
154
+ }
148
155
 
149
- let candidates = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
150
-
151
- if (candidates.length < 5) {
152
- try {
153
- const grepArgs = ["-l", "--max-count=1", "-g", "!.git/**", "-g", "!**/.git/**"];
154
- if (includeHidden) grepArgs.push("--hidden");
155
- if (!wantsTest) {
156
- grepArgs.push("-g", "!test/**", "-g", "!tests/**", "-g", "!*.test.*", "-g", "!*.spec.*");
157
- }
158
- const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
159
- for (const t of salient) grepArgs.push("-e", t);
160
- const res = await runCommand(["rg", ...grepArgs, dir], { timeoutMs: 15_000 });
161
- const grepHits = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
162
- const seen = new Set(candidates);
163
- for (const h of grepHits) {
164
- if (!seen.has(h)) {
165
- seen.add(h);
166
- candidates.push(h);
167
- if (candidates.length >= 15) break;
168
- }
169
- }
170
- } catch {
171
- if (candidates.length === 0) candidates = fileList.slice(0, 5);
156
+ function scoreSurfaceItems(items, tokens, fallbackLine) {
157
+ let bonus = 0;
158
+ let best = null;
159
+ let bestMatches = 0;
160
+ for (const item of items) {
161
+ const nameLower = item.name.toLowerCase();
162
+ const matches = tokens.filter((token) => nameLower.includes(token)).length;
163
+ if (matches === 0) continue;
164
+ bonus += matches * (item.isExport ? 80 : 50);
165
+ if (matches > bestMatches) {
166
+ bestMatches = matches;
167
+ best = item;
172
168
  }
173
169
  }
170
+ return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
171
+ }
174
172
 
173
+ function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
175
174
  const candidateScores = [];
176
-
177
175
  for (const filePath of candidates) {
178
- let content = "";
179
- try {
180
- content = await vfs.read(filePath);
181
- } catch {
182
- continue;
183
- }
184
-
185
- const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(content, tokens);
186
- const ext = path.extname(filePath);
187
- const surface = extractStructuralSurface(content, ext);
188
-
189
- let surfaceBonus = 0;
190
- let signature = "";
191
- let anchorLine = bestLine;
192
-
193
- for (const item of surface.items) {
194
- const nameLower = item.name.toLowerCase();
195
- for (const token of tokens) {
196
- if (nameLower.includes(token)) {
197
- surfaceBonus += item.isExport ? 80 : 50;
198
- if (!signature) {
199
- signature = item.signature;
200
- anchorLine = item.line;
201
- }
202
- }
203
- }
204
- }
205
-
206
- const isTestFile = filePath.toLowerCase().includes("test") || filePath.toLowerCase().includes("spec");
207
- const testAdjustment = isTestFile && !wantsTest ? -200 : (isTestFile && wantsTest ? 100 : 0);
208
- const finalScore = totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment;
176
+ const pending = overlayText(filePath);
177
+ const entry = pending === undefined ? index.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
178
+ if (!entry) continue;
179
+ const content = entry.text;
180
+ const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(entry, tokens);
181
+ const surface = WorkspaceIndex.surfaceOf(entry);
182
+ const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
183
+ const lowerPath = filePath.toLowerCase();
184
+ const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
185
+ const testAdjustment = !isTestFile ? 0 : (flags.wantsTest ? 100 : -200);
209
186
  candidateScores.push({
210
187
  path: filePath,
211
- score: finalScore,
188
+ score: totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment,
212
189
  anchorLine,
213
190
  signature,
214
191
  content,
215
192
  });
216
193
  }
217
-
218
194
  candidateScores.sort((a, b) => b.score - a.score);
195
+ return candidateScores;
196
+ }
219
197
 
220
- if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
221
- const fallbackPath = candidates[0] || fileList[0];
222
- return {
223
- path: fallbackPath,
224
- line: 1,
225
- signature: "",
226
- confidence: 0.3,
227
- context: [],
228
- };
198
+ function rankCandidates(fileList, tokens, flags, index, overlayText) {
199
+ const scoredPaths = [];
200
+ for (const f of fileList) {
201
+ const score = scorePathTopology(f, tokens, flags);
202
+ if (score > 0) scoredPaths.push({ path: f, score });
229
203
  }
204
+ scoredPaths.sort((a, b) => b.score - a.score);
205
+ const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
206
+ const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
207
+ const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
208
+ return { candidates, candidateScores };
209
+ }
230
210
 
211
+ function buildSnapResult(candidates, candidateScores, fileList, root) {
212
+ const relative = (p) => path.relative(root, p) || p;
213
+ if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
214
+ return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
215
+ }
231
216
  const best = candidateScores[0];
232
217
  const lines = best.content.split("\n");
233
- const startLine = Math.max(1, best.anchorLine - 3);
234
- const endLine = Math.min(lines.length, best.anchorLine + 8);
235
-
236
- const contextLines = [];
218
+ // Two lines before and four after: enough to confirm the hit; read() is the tool for more.
219
+ const startLine = Math.max(1, best.anchorLine - 2);
220
+ const endLine = Math.min(lines.length, best.anchorLine + 4);
221
+ const context = [];
237
222
  for (let l = startLine; l <= endLine; l++) {
238
223
  const marker = l === best.anchorLine ? "►" : " ";
239
- contextLines.push(`${marker} ${String(l).padStart(4)} │ ${lines[l - 1]}`);
224
+ context.push(marker + l + " " + lines[l - 1]);
240
225
  }
241
-
242
226
  const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
243
-
244
227
  return {
245
- path: best.path,
228
+ path: relative(best.path),
246
229
  line: best.anchorLine,
247
230
  signature: best.signature,
248
231
  confidence: Number(confidence.toFixed(2)),
249
- context: contextLines,
232
+ context,
250
233
  };
251
234
  }
235
+
236
+ export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
237
+ const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
238
+ if (tokens.length === 0) {
239
+ throw new Error("snap requires at least one searchable concept keyword");
240
+ }
241
+ const dir = searchDir || process.cwd();
242
+ if (path.resolve(dir).split(path.sep).includes(".git")) {
243
+ throw new Error("snap cannot search Git metadata");
244
+ }
245
+ const fileList = await listCandidateFiles(dir, includeHidden, index);
246
+ mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
247
+ if (fileList.length === 0) {
248
+ throw new Error(`no files found to search in ${dir}`);
249
+ }
250
+ const flags = { wantsTest, wantsDoc, wantsType };
251
+ const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
252
+ return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
253
+ }
package/surface.js CHANGED
@@ -61,45 +61,31 @@ function scanGo(lines) {
61
61
  return items;
62
62
  }
63
63
 
64
+ const JS_DECL_PATTERNS = [
65
+ [/^export\s+(?:default\s+)?(?:async\s+)?(function\*?|class|const|let|var|type|interface|enum)\s+([a-zA-Z0-9_$]+)/, true],
66
+ [/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
67
+ [/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
68
+ ];
69
+ // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
70
+ const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
71
+
64
72
  function scanJavaScript(lines) {
65
73
  const items = [];
66
74
  for (let i = 0; i < lines.length; i++) {
67
75
  const line = lines[i].trim();
68
76
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
69
-
70
- const expMatch = /^export\s+(?:default\s+)?(?:async\s+)?(function\*?|class|const|let|var|type|interface|enum)\s+([a-zA-Z0-9_$]+)/.exec(line);
71
- if (expMatch) {
72
- items.push({
73
- kind: expMatch[1],
74
- name: expMatch[2],
75
- isExport: true,
76
- signature: line.replace(/\{.*$/, "").trim(),
77
- line: i + 1,
78
- });
79
- continue;
80
- }
81
-
82
- const declMatch = /^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/.exec(line);
83
- if (declMatch) {
84
- items.push({
85
- kind: declMatch[1],
86
- name: declMatch[2],
87
- isExport: false,
88
- signature: line.replace(/\{.*$/, "").trim(),
89
- line: i + 1,
90
- });
91
- continue;
92
- }
93
-
94
- const tsMatch = /^(interface|type)\s+([a-zA-Z0-9_$]+)/.exec(line);
95
- if (tsMatch) {
77
+ const patterns = /^\S/.test(lines[i]) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
78
+ for (const [pattern, isExport] of patterns) {
79
+ const match = pattern.exec(line);
80
+ if (!match) continue;
96
81
  items.push({
97
- kind: tsMatch[1],
98
- name: tsMatch[2],
99
- isExport: false,
82
+ kind: match[1],
83
+ name: match[2],
84
+ isExport,
100
85
  signature: line.replace(/\{.*$/, "").trim(),
101
86
  line: i + 1,
102
87
  });
88
+ break;
103
89
  }
104
90
  }
105
91
  return items;
package/vfs.js ADDED
@@ -0,0 +1,144 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ const VFS_CACHE_MAX = 1024;
5
+
6
+ export class CausalVfs {
7
+ constructor(onNewFile) {
8
+ this.cache = new Map();
9
+ this.overlays = [];
10
+ this.onNewFile = onNewFile;
11
+ }
12
+
13
+ setCache(target, content) {
14
+ if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
15
+ const oldest = this.cache.keys().next().value;
16
+ if (oldest !== undefined) this.cache.delete(oldest);
17
+ }
18
+ this.cache.set(target, content);
19
+ }
20
+
21
+ getOverlay(target) {
22
+ for (let i = this.overlays.length - 1; i >= 0; i--) {
23
+ if (this.overlays[i].has(target)) return this.overlays[i].get(target);
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ getOverlayPaths() {
29
+ const paths = new Set();
30
+ for (const overlay of this.overlays) {
31
+ for (const target of overlay.keys()) paths.add(target);
32
+ }
33
+ return [...paths];
34
+ }
35
+
36
+ async read(target) {
37
+ const overlay = this.getOverlay(target);
38
+ if (overlay !== undefined) return overlay;
39
+
40
+ const cached = this.cache.get(target);
41
+ if (cached !== undefined) return cached;
42
+
43
+ try {
44
+ const text = await fs.readFile(target, "utf8");
45
+ this.setCache(target, text);
46
+ return text;
47
+ } catch (err) {
48
+ if (err.code === "EISDIR") {
49
+ throw new Error(`read path is a directory, not a file: ${target}`);
50
+ }
51
+ if (err.code === "ENOENT") {
52
+ const missing = new Error(`no such file: ${target} (locate it with nova.call("glob", {pattern}) or snap(query))`);
53
+ missing.code = "ENOENT";
54
+ throw missing;
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ async write(target, content) {
61
+ if (this.overlays.length > 0) {
62
+ this.overlays[this.overlays.length - 1].set(target, content);
63
+ return { speculative: true };
64
+ }
65
+
66
+ let existed = true;
67
+ try {
68
+ const stat = await fs.stat(target);
69
+ if (stat.isDirectory()) {
70
+ throw new Error(`cannot write to a directory: ${target}`);
71
+ }
72
+ } catch (err) {
73
+ if (err.code !== "ENOENT") throw err;
74
+ existed = false;
75
+ }
76
+
77
+ await fs.mkdir(path.dirname(target), { recursive: true });
78
+ await fs.writeFile(target, content, "utf8");
79
+ this.setCache(target, content);
80
+ if (!existed) this.onNewFile?.(target);
81
+ return { speculative: false };
82
+ }
83
+
84
+ begin() {
85
+ this.overlays.push(new Map());
86
+ return this.overlays.length;
87
+ }
88
+
89
+ async commit() {
90
+ if (this.overlays.length === 0) return { committed: 0, depth: 0 };
91
+ const top = this.overlays.pop();
92
+ if (this.overlays.length > 0) {
93
+ const parent = this.overlays[this.overlays.length - 1];
94
+ for (const [k, v] of top.entries()) parent.set(k, v);
95
+ return { committed: top.size, depth: this.overlays.length };
96
+ }
97
+ for (const [filePath, fileContent] of top.entries()) {
98
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
99
+ await fs.writeFile(filePath, fileContent, "utf8");
100
+ this.setCache(filePath, fileContent);
101
+ }
102
+ if (top.size > 0) this.onNewFile?.();
103
+ return { committed: top.size, depth: 0 };
104
+ }
105
+
106
+ rollback() {
107
+ if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
108
+ const top = this.overlays.pop();
109
+ return { rolledBack: top.size, depth: this.overlays.length };
110
+ }
111
+
112
+ async prepareExternalMutation(name) {
113
+ if (this.overlays.length > 1) {
114
+ throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
115
+ }
116
+ if (this.overlays.length === 0) return false;
117
+ const pending = this.overlays[0];
118
+ for (const [filePath, fileContent] of pending.entries()) {
119
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
120
+ await fs.writeFile(filePath, fileContent, "utf8");
121
+ this.setCache(filePath, fileContent);
122
+ }
123
+ if (pending.size > 0) this.onNewFile?.();
124
+ this.overlays[0] = new Map();
125
+ return pending.size > 0;
126
+ }
127
+
128
+ invalidateCache() {
129
+ this.cache.clear();
130
+ }
131
+
132
+ clear() {
133
+ this.invalidateCache();
134
+ this.overlays.length = 0;
135
+ }
136
+
137
+ getCacheSize() {
138
+ return this.cache.size;
139
+ }
140
+
141
+ getOverlayDepth() {
142
+ return this.overlays.length;
143
+ }
144
+ }