pi-supernova 0.0.8 → 0.0.15
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 +85 -2
- package/README.md +36 -12
- package/bottleneck.js +1 -1
- package/catalog.js +20 -15
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -1
- package/decode.js +0 -10
- package/evidence.js +429 -0
- package/fuzzy.js +182 -0
- package/guest-worker.js +14 -11
- package/host-bridge.js +184 -74
- package/index.js +65 -42
- package/ledger.js +178 -0
- package/omp-frame.js +7 -22
- package/outline.js +80 -0
- package/package.json +8 -1
- package/render-measure.js +2 -30
- package/render.js +46 -150
- package/repo-index.js +273 -0
- package/runtime.js +3 -20
- package/search.js +141 -0
- package/snap.js +86 -108
- package/surface.js +24 -12
- package/vfs.js +7 -6
- package/workspace.js +33 -2
package/snap.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
import { isTestPath } from "./workspace.js";
|
|
5
6
|
|
|
6
7
|
const STOP_WORDS = new Set([
|
|
7
8
|
"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
@@ -30,72 +31,73 @@ export function tokenizeQuery(query) {
|
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const ext = path.extname(norm);
|
|
37
|
-
|
|
38
|
-
const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
|
|
39
|
-
if (isTest && !wantsTest) return -50;
|
|
40
|
-
if (!isTest && wantsTest) return -20;
|
|
34
|
+
const SOURCE_EXT = new Set([".ts", ".js", ".mjs", ".rs", ".py", ".go"]);
|
|
35
|
+
const TYPED_EXT = new Set([".ts", ".d.ts", ".rs", ".go"]);
|
|
36
|
+
const VENDOR_SEGMENTS = ["node_modules/", "dist/", "target/"];
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
function tokenPathScore(token, basename, pathParts, norm) {
|
|
39
|
+
if (basename === token || basename.startsWith(token + ".")) return 60;
|
|
40
|
+
if (basename.includes(token)) return 30;
|
|
41
|
+
if (pathParts.includes(token)) return 15;
|
|
42
|
+
if (norm.includes(token)) return 5;
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
function extensionBonus(ext, { wantsDoc, wantsType }) {
|
|
47
|
+
let bonus = 0;
|
|
48
|
+
if (SOURCE_EXT.has(ext) && !wantsDoc) bonus += 5;
|
|
49
|
+
if (wantsType && TYPED_EXT.has(ext)) bonus += 10;
|
|
50
|
+
return bonus;
|
|
51
|
+
}
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
export function scorePathTopology(filePath, tokens, flags) {
|
|
54
|
+
const norm = filePath.replaceAll("\\", "/").toLowerCase();
|
|
55
|
+
const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
|
|
56
|
+
if (isTest && !flags.wantsTest) return -50;
|
|
57
|
+
if (!isTest && flags.wantsTest) return -20;
|
|
58
|
+
if (VENDOR_SEGMENTS.some((segment) => norm.includes(segment))) return -100;
|
|
62
59
|
|
|
60
|
+
const basename = path.basename(norm);
|
|
61
|
+
const pathParts = norm.split(/[^a-zA-Z0-9]+/);
|
|
62
|
+
let score = extensionBonus(path.extname(norm), flags);
|
|
63
|
+
for (const token of tokens) score += tokenPathScore(token, basename, pathParts, norm);
|
|
63
64
|
return score;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function isSkippableLine(
|
|
67
|
-
return !
|
|
67
|
+
function isSkippableLine(lower) {
|
|
68
|
+
return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
/** A line defines a token only when the declared name contains it; `const x = foo(token)` is a mention. */
|
|
72
|
+
function lineScoreFor(lower, tokens, definedName) {
|
|
72
73
|
let lineScore = 0;
|
|
73
74
|
for (const token of tokens) {
|
|
74
|
-
if (lower.includes(token))
|
|
75
|
+
if (!lower.includes(token)) continue;
|
|
76
|
+
lineScore += definedName.includes(token) ? 40 : 5;
|
|
75
77
|
}
|
|
76
78
|
return lineScore;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
// Mentions are capped so a file that calls a symbol many times cannot outrank the file that defines it.
|
|
82
|
+
const MAX_MENTION_SCORE = 60;
|
|
83
|
+
|
|
84
|
+
function scoreContentDefinitions(entry, tokens) {
|
|
85
|
+
const { lower, defNames } = WorkspaceIndex.linesOf(entry);
|
|
86
|
+
let defScore = 0;
|
|
87
|
+
let mentionScore = 0;
|
|
81
88
|
let bestLine = 1;
|
|
82
89
|
let bestLineScore = 0;
|
|
83
|
-
for (let i = 0; i <
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const lineScore = lineScoreFor(line, tokens, defPattern.test(line));
|
|
90
|
+
for (let i = 0; i < lower.length; i++) {
|
|
91
|
+
if (isSkippableLine(lower[i])) continue;
|
|
92
|
+
const lineScore = lineScoreFor(lower[i], tokens, defNames[i]);
|
|
87
93
|
if (lineScore > bestLineScore) {
|
|
88
94
|
bestLineScore = lineScore;
|
|
89
95
|
bestLine = i + 1;
|
|
90
96
|
}
|
|
91
|
-
|
|
97
|
+
if (defNames[i]) defScore += lineScore;
|
|
98
|
+
else mentionScore += lineScore;
|
|
92
99
|
}
|
|
93
|
-
return { totalScore:
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function scoreContentDefinitions(content, tokens) {
|
|
97
|
-
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_$]+)/;
|
|
98
|
-
return accumulateContentScore(content.split("\n"), tokens, defRegex);
|
|
100
|
+
return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
function relativeHasSegment(relativePath, segmentName) {
|
|
@@ -106,17 +108,8 @@ function relativeHasHiddenSegment(relativePath) {
|
|
|
106
108
|
return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
async function listCandidateFiles(dir, includeHidden,
|
|
110
|
-
|
|
111
|
-
if (includeHidden) rgArgs.push("--hidden");
|
|
112
|
-
rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
|
|
113
|
-
try {
|
|
114
|
-
const res = await runCommand(rgArgs, { timeoutMs: 15_000 });
|
|
115
|
-
// rg --files is multithreaded and emits in nondeterministic order; ties must rank stably.
|
|
116
|
-
return res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).sort();
|
|
117
|
-
} catch {
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
111
|
+
async function listCandidateFiles(dir, includeHidden, index) {
|
|
112
|
+
return (await index.files(dir, includeHidden)).slice();
|
|
120
113
|
}
|
|
121
114
|
|
|
122
115
|
function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
|
|
@@ -145,53 +138,42 @@ function mergeGrepHits(candidates, grepHits) {
|
|
|
145
138
|
return candidates;
|
|
146
139
|
}
|
|
147
140
|
|
|
148
|
-
|
|
141
|
+
function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
|
|
149
142
|
if (candidates.length >= 5) return candidates;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
grepArgs.push("-g", "!test/**", "-g", "!tests/**", "-g", "!*.test.*", "-g", "!*.spec.*");
|
|
156
|
-
}
|
|
157
|
-
const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
|
|
158
|
-
for (const t of salient) grepArgs.push("-e", t);
|
|
159
|
-
const res = await runCommand(["rg", ...grepArgs, dir], { timeoutMs: 15_000 });
|
|
160
|
-
mergeGrepHits(candidates, res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).sort());
|
|
161
|
-
} catch {
|
|
162
|
-
if (candidates.length === 0) candidates = fileList.slice(0, 5);
|
|
163
|
-
}
|
|
143
|
+
const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
|
|
144
|
+
const scope = flags.wantsTest ? fileList : fileList.filter((f) => !isTestPath(f));
|
|
145
|
+
const hits = index.filesContaining(scope, salient, true);
|
|
146
|
+
mergeGrepHits(candidates, hits);
|
|
147
|
+
if (candidates.length === 0) return fileList.slice(0, 5);
|
|
164
148
|
return candidates;
|
|
165
149
|
}
|
|
166
150
|
|
|
167
151
|
function scoreSurfaceItems(items, tokens, fallbackLine) {
|
|
168
152
|
let bonus = 0;
|
|
169
|
-
let
|
|
170
|
-
let
|
|
153
|
+
let best = null;
|
|
154
|
+
let bestMatches = 0;
|
|
171
155
|
for (const item of items) {
|
|
172
156
|
const nameLower = item.name.toLowerCase();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
157
|
+
const matches = tokens.filter((token) => nameLower.includes(token)).length;
|
|
158
|
+
if (matches === 0) continue;
|
|
159
|
+
bonus += matches * (item.isExport ? 80 : 50);
|
|
160
|
+
if (matches > bestMatches) {
|
|
161
|
+
bestMatches = matches;
|
|
162
|
+
best = item;
|
|
179
163
|
}
|
|
180
164
|
}
|
|
181
|
-
return { bonus, signature, anchorLine };
|
|
165
|
+
return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
|
|
182
166
|
}
|
|
183
167
|
|
|
184
|
-
|
|
168
|
+
function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
|
|
185
169
|
const candidateScores = [];
|
|
186
170
|
for (const filePath of candidates) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(content, tokens);
|
|
194
|
-
const surface = extractStructuralSurface(content, path.extname(filePath));
|
|
171
|
+
const pending = overlayText(filePath);
|
|
172
|
+
const entry = pending === undefined ? index.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
|
|
173
|
+
if (!entry) continue;
|
|
174
|
+
const content = entry.text;
|
|
175
|
+
const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(entry, tokens);
|
|
176
|
+
const surface = WorkspaceIndex.surfaceOf(entry);
|
|
195
177
|
const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
|
|
196
178
|
const lowerPath = filePath.toLowerCase();
|
|
197
179
|
const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
|
|
@@ -208,7 +190,7 @@ async function scoreCandidateContents(candidates, tokens, flags, vfs) {
|
|
|
208
190
|
return candidateScores;
|
|
209
191
|
}
|
|
210
192
|
|
|
211
|
-
|
|
193
|
+
function rankCandidates(fileList, tokens, flags, index, overlayText) {
|
|
212
194
|
const scoredPaths = [];
|
|
213
195
|
for (const f of fileList) {
|
|
214
196
|
const score = scorePathTopology(f, tokens, flags);
|
|
@@ -216,33 +198,29 @@ async function rankCandidates(fileList, tokens, flags, dir, includeHidden, runCo
|
|
|
216
198
|
}
|
|
217
199
|
scoredPaths.sort((a, b) => b.score - a.score);
|
|
218
200
|
const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
|
|
219
|
-
const candidates =
|
|
220
|
-
const candidateScores =
|
|
201
|
+
const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
|
|
202
|
+
const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
|
|
221
203
|
return { candidates, candidateScores };
|
|
222
204
|
}
|
|
223
205
|
|
|
224
|
-
function buildSnapResult(candidates, candidateScores, fileList) {
|
|
206
|
+
function buildSnapResult(candidates, candidateScores, fileList, root) {
|
|
207
|
+
const relative = (p) => path.relative(root, p) || p;
|
|
225
208
|
if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
|
|
226
|
-
return {
|
|
227
|
-
path: candidates[0] || fileList[0],
|
|
228
|
-
line: 1,
|
|
229
|
-
signature: "",
|
|
230
|
-
confidence: 0.3,
|
|
231
|
-
context: [],
|
|
232
|
-
};
|
|
209
|
+
return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
|
|
233
210
|
}
|
|
234
211
|
const best = candidateScores[0];
|
|
235
212
|
const lines = best.content.split("\n");
|
|
236
|
-
|
|
237
|
-
const
|
|
213
|
+
// Two lines before and four after: enough to confirm the hit; read() is the tool for more.
|
|
214
|
+
const startLine = Math.max(1, best.anchorLine - 2);
|
|
215
|
+
const endLine = Math.min(lines.length, best.anchorLine + 4);
|
|
238
216
|
const context = [];
|
|
239
217
|
for (let l = startLine; l <= endLine; l++) {
|
|
240
218
|
const marker = l === best.anchorLine ? "►" : " ";
|
|
241
|
-
context.push(
|
|
219
|
+
context.push(marker + l + " " + lines[l - 1]);
|
|
242
220
|
}
|
|
243
221
|
const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
|
|
244
222
|
return {
|
|
245
|
-
path: best.path,
|
|
223
|
+
path: relative(best.path),
|
|
246
224
|
line: best.anchorLine,
|
|
247
225
|
signature: best.signature,
|
|
248
226
|
confidence: Number(confidence.toFixed(2)),
|
|
@@ -250,7 +228,7 @@ function buildSnapResult(candidates, candidateScores, fileList) {
|
|
|
250
228
|
};
|
|
251
229
|
}
|
|
252
230
|
|
|
253
|
-
export async function executeSnap({ query, searchDir, includeHidden = false,
|
|
231
|
+
export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
|
|
254
232
|
const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
|
|
255
233
|
if (tokens.length === 0) {
|
|
256
234
|
throw new Error("snap requires at least one searchable concept keyword");
|
|
@@ -259,12 +237,12 @@ export async function executeSnap({ query, searchDir, includeHidden = false, vfs
|
|
|
259
237
|
if (path.resolve(dir).split(path.sep).includes(".git")) {
|
|
260
238
|
throw new Error("snap cannot search Git metadata");
|
|
261
239
|
}
|
|
262
|
-
const fileList = await listCandidateFiles(dir, includeHidden,
|
|
240
|
+
const fileList = await listCandidateFiles(dir, includeHidden, index);
|
|
263
241
|
mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
|
|
264
242
|
if (fileList.length === 0) {
|
|
265
243
|
throw new Error(`no files found to search in ${dir}`);
|
|
266
244
|
}
|
|
267
245
|
const flags = { wantsTest, wantsDoc, wantsType };
|
|
268
|
-
const { candidates, candidateScores } =
|
|
269
|
-
return buildSnapResult(candidates, candidateScores, fileList);
|
|
246
|
+
const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
|
|
247
|
+
return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
|
|
270
248
|
}
|
package/surface.js
CHANGED
|
@@ -66,24 +66,36 @@ const JS_DECL_PATTERNS = [
|
|
|
66
66
|
[/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
|
|
67
67
|
[/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
|
|
68
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
|
+
// Indented methods (object-literal adapters, class members) that open a block on the same line.
|
|
72
|
+
const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
|
|
73
|
+
const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
|
|
74
|
+
const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
|
|
75
|
+
|
|
76
|
+
function methodItem(line, lineNumber, depth) {
|
|
77
|
+
const match = JS_METHOD.exec(line) || JS_ARROW_PROPERTY.exec(line);
|
|
78
|
+
if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
|
|
79
|
+
return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function declarationItem(line, rawLine, lineNumber) {
|
|
83
|
+
const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
|
|
84
|
+
for (const [pattern, isExport] of patterns) {
|
|
85
|
+
const match = pattern.exec(line);
|
|
86
|
+
if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
69
90
|
|
|
70
91
|
function scanJavaScript(lines) {
|
|
71
92
|
const items = [];
|
|
72
93
|
for (let i = 0; i < lines.length; i++) {
|
|
73
94
|
const line = lines[i].trim();
|
|
74
95
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
items.push({
|
|
79
|
-
kind: match[1],
|
|
80
|
-
name: match[2],
|
|
81
|
-
isExport,
|
|
82
|
-
signature: line.replace(/\{.*$/, "").trim(),
|
|
83
|
-
line: i + 1,
|
|
84
|
-
});
|
|
85
|
-
break;
|
|
86
|
-
}
|
|
96
|
+
const indent = lines[i].length - lines[i].trimStart().length;
|
|
97
|
+
const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
|
|
98
|
+
if (item) items.push(item);
|
|
87
99
|
}
|
|
88
100
|
return items;
|
|
89
101
|
}
|
package/vfs.js
CHANGED
|
@@ -4,9 +4,10 @@ import * as path from "node:path";
|
|
|
4
4
|
const VFS_CACHE_MAX = 1024;
|
|
5
5
|
|
|
6
6
|
export class CausalVfs {
|
|
7
|
-
constructor() {
|
|
7
|
+
constructor(onNewFile) {
|
|
8
8
|
this.cache = new Map();
|
|
9
9
|
this.overlays = [];
|
|
10
|
+
this.onNewFile = onNewFile;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
setCache(target, content) {
|
|
@@ -62,6 +63,7 @@ export class CausalVfs {
|
|
|
62
63
|
return { speculative: true };
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
let existed = true;
|
|
65
67
|
try {
|
|
66
68
|
const stat = await fs.stat(target);
|
|
67
69
|
if (stat.isDirectory()) {
|
|
@@ -69,11 +71,13 @@ export class CausalVfs {
|
|
|
69
71
|
}
|
|
70
72
|
} catch (err) {
|
|
71
73
|
if (err.code !== "ENOENT") throw err;
|
|
74
|
+
existed = false;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
75
78
|
await fs.writeFile(target, content, "utf8");
|
|
76
79
|
this.setCache(target, content);
|
|
80
|
+
if (!existed) this.onNewFile?.(target);
|
|
77
81
|
return { speculative: false };
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -95,6 +99,7 @@ export class CausalVfs {
|
|
|
95
99
|
await fs.writeFile(filePath, fileContent, "utf8");
|
|
96
100
|
this.setCache(filePath, fileContent);
|
|
97
101
|
}
|
|
102
|
+
if (top.size > 0) this.onNewFile?.();
|
|
98
103
|
return { committed: top.size, depth: 0 };
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -115,6 +120,7 @@ export class CausalVfs {
|
|
|
115
120
|
await fs.writeFile(filePath, fileContent, "utf8");
|
|
116
121
|
this.setCache(filePath, fileContent);
|
|
117
122
|
}
|
|
123
|
+
if (pending.size > 0) this.onNewFile?.();
|
|
118
124
|
this.overlays[0] = new Map();
|
|
119
125
|
return pending.size > 0;
|
|
120
126
|
}
|
|
@@ -123,11 +129,6 @@ export class CausalVfs {
|
|
|
123
129
|
this.cache.clear();
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
clear() {
|
|
127
|
-
this.invalidateCache();
|
|
128
|
-
this.overlays.length = 0;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
132
|
getCacheSize() {
|
|
132
133
|
return this.cache.size;
|
|
133
134
|
}
|
package/workspace.js
CHANGED
|
@@ -5,6 +5,14 @@ import { isString } from "./decode.js";
|
|
|
5
5
|
|
|
6
6
|
let cachedCwd = null;
|
|
7
7
|
let cachedResolvedCwd = null;
|
|
8
|
+
// realpath results per program: two syscalls per call otherwise dominate a cached read.
|
|
9
|
+
const realRoots = new Map();
|
|
10
|
+
const realNearest = new Map();
|
|
11
|
+
const PATH_CACHE_MAX = 2048;
|
|
12
|
+
|
|
13
|
+
export function clearPathCache() {
|
|
14
|
+
realNearest.clear();
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
function getResolvedCwd(cwd) {
|
|
10
18
|
if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
|
|
@@ -33,6 +41,20 @@ async function realpathNearest(target) {
|
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
/** Workspace-relative path with "/" separators, the form every model-facing surface uses. */
|
|
45
|
+
export function relativeSlash(root, absolute) {
|
|
46
|
+
return (path.relative(root, absolute) || absolute).split(path.sep).join("/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "spec"]);
|
|
50
|
+
|
|
51
|
+
/** One rule for "is this a test file" across snap, evidence, and outlines. */
|
|
52
|
+
export function isTestPath(filePath) {
|
|
53
|
+
const segments = filePath.split(/[\\/]/);
|
|
54
|
+
const base = segments[segments.length - 1];
|
|
55
|
+
return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
|
|
37
59
|
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
38
60
|
throw new Error(`${opName} requires path`);
|
|
@@ -43,8 +65,17 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
|
|
|
43
65
|
if (!allowRoot && target === resolvedCwd) {
|
|
44
66
|
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
45
67
|
}
|
|
46
|
-
|
|
47
|
-
|
|
68
|
+
let realRoot = realRoots.get(resolvedCwd);
|
|
69
|
+
if (!realRoot) {
|
|
70
|
+
realRoot = await fs.realpath(resolvedCwd);
|
|
71
|
+
realRoots.set(resolvedCwd, realRoot);
|
|
72
|
+
}
|
|
73
|
+
let probe = realNearest.get(target);
|
|
74
|
+
if (!probe) {
|
|
75
|
+
probe = await realpathNearest(target);
|
|
76
|
+
if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
|
|
77
|
+
realNearest.set(target, probe);
|
|
78
|
+
}
|
|
48
79
|
assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
|
|
49
80
|
return target;
|
|
50
81
|
}
|