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/CHANGELOG.md +56 -0
- package/README.md +27 -16
- package/bottleneck.js +32 -41
- package/catalog.js +72 -22
- package/config.default.json +2 -1
- package/config.js +19 -10
- package/diff.js +12 -4
- package/evidence.js +446 -0
- package/format.js +95 -0
- package/guest-worker.js +345 -0
- package/host-bridge.js +215 -463
- package/index.js +107 -97
- package/omp-frame.js +68 -51
- package/package.json +8 -1
- package/parallel.js +19 -20
- package/patch.js +106 -0
- package/render-measure.js +63 -49
- package/render.js +203 -166
- package/repo-index.js +179 -0
- package/runtime.js +266 -241
- package/snap.js +153 -151
- package/surface.js +16 -30
- package/vfs.js +144 -0
- package/workspace.js +129 -0
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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
if (
|
|
40
|
-
if (
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
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:
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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:
|
|
98
|
-
name:
|
|
99
|
-
isExport
|
|
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
|
+
}
|