pi-supernova 0.0.8 → 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 +30 -0
- package/README.md +6 -2
- package/catalog.js +12 -8
- package/evidence.js +446 -0
- package/guest-worker.js +4 -3
- package/host-bridge.js +90 -29
- package/index.js +36 -4
- package/package.json +3 -1
- package/render.js +14 -4
- package/repo-index.js +179 -0
- package/snap.js +91 -108
- package/surface.js +4 -1
- package/vfs.js +7 -1
- package/workspace.js +19 -2
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,72 +30,73 @@ export function tokenizeQuery(query) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
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;
|
|
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/"];
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
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;
|
|
62
58
|
|
|
59
|
+
const basename = path.basename(norm);
|
|
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
63
|
return score;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function isSkippableLine(
|
|
67
|
-
return !
|
|
66
|
+
function isSkippableLine(lower) {
|
|
67
|
+
return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
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
72
|
let lineScore = 0;
|
|
73
73
|
for (const token of tokens) {
|
|
74
|
-
if (lower.includes(token))
|
|
74
|
+
if (!lower.includes(token)) continue;
|
|
75
|
+
lineScore += definedName.includes(token) ? 40 : 5;
|
|
75
76
|
}
|
|
76
77
|
return lineScore;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
|
81
87
|
let bestLine = 1;
|
|
82
88
|
let bestLineScore = 0;
|
|
83
|
-
for (let i = 0; i <
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const lineScore = lineScoreFor(line, tokens, defPattern.test(line));
|
|
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
|
-
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);
|
|
99
|
+
return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
function relativeHasSegment(relativePath, segmentName) {
|
|
@@ -106,17 +107,8 @@ function relativeHasHiddenSegment(relativePath) {
|
|
|
106
107
|
return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
107
108
|
}
|
|
108
109
|
|
|
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
|
-
}
|
|
110
|
+
async function listCandidateFiles(dir, includeHidden, index) {
|
|
111
|
+
return (await index.files(dir, includeHidden)).slice();
|
|
120
112
|
}
|
|
121
113
|
|
|
122
114
|
function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
|
|
@@ -145,53 +137,48 @@ function mergeGrepHits(candidates, grepHits) {
|
|
|
145
137
|
return candidates;
|
|
146
138
|
}
|
|
147
139
|
|
|
148
|
-
|
|
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
|
+
}
|
|
145
|
+
|
|
146
|
+
function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
|
|
149
147
|
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
|
-
}
|
|
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);
|
|
164
153
|
return candidates;
|
|
165
154
|
}
|
|
166
155
|
|
|
167
156
|
function scoreSurfaceItems(items, tokens, fallbackLine) {
|
|
168
157
|
let bonus = 0;
|
|
169
|
-
let
|
|
170
|
-
let
|
|
158
|
+
let best = null;
|
|
159
|
+
let bestMatches = 0;
|
|
171
160
|
for (const item of items) {
|
|
172
161
|
const nameLower = item.name.toLowerCase();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|
|
179
168
|
}
|
|
180
169
|
}
|
|
181
|
-
return { bonus, signature, anchorLine };
|
|
170
|
+
return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
|
|
182
171
|
}
|
|
183
172
|
|
|
184
|
-
|
|
173
|
+
function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
|
|
185
174
|
const candidateScores = [];
|
|
186
175
|
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));
|
|
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);
|
|
195
182
|
const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
|
|
196
183
|
const lowerPath = filePath.toLowerCase();
|
|
197
184
|
const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
|
|
@@ -208,7 +195,7 @@ async function scoreCandidateContents(candidates, tokens, flags, vfs) {
|
|
|
208
195
|
return candidateScores;
|
|
209
196
|
}
|
|
210
197
|
|
|
211
|
-
|
|
198
|
+
function rankCandidates(fileList, tokens, flags, index, overlayText) {
|
|
212
199
|
const scoredPaths = [];
|
|
213
200
|
for (const f of fileList) {
|
|
214
201
|
const score = scorePathTopology(f, tokens, flags);
|
|
@@ -216,33 +203,29 @@ async function rankCandidates(fileList, tokens, flags, dir, includeHidden, runCo
|
|
|
216
203
|
}
|
|
217
204
|
scoredPaths.sort((a, b) => b.score - a.score);
|
|
218
205
|
const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
|
|
219
|
-
const candidates =
|
|
220
|
-
const candidateScores =
|
|
206
|
+
const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
|
|
207
|
+
const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
|
|
221
208
|
return { candidates, candidateScores };
|
|
222
209
|
}
|
|
223
210
|
|
|
224
|
-
function buildSnapResult(candidates, candidateScores, fileList) {
|
|
211
|
+
function buildSnapResult(candidates, candidateScores, fileList, root) {
|
|
212
|
+
const relative = (p) => path.relative(root, p) || p;
|
|
225
213
|
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
|
-
};
|
|
214
|
+
return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
|
|
233
215
|
}
|
|
234
216
|
const best = candidateScores[0];
|
|
235
217
|
const lines = best.content.split("\n");
|
|
236
|
-
|
|
237
|
-
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);
|
|
238
221
|
const context = [];
|
|
239
222
|
for (let l = startLine; l <= endLine; l++) {
|
|
240
223
|
const marker = l === best.anchorLine ? "►" : " ";
|
|
241
|
-
context.push(
|
|
224
|
+
context.push(marker + l + " " + lines[l - 1]);
|
|
242
225
|
}
|
|
243
226
|
const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
|
|
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)),
|
|
@@ -250,7 +233,7 @@ function buildSnapResult(candidates, candidateScores, fileList) {
|
|
|
250
233
|
};
|
|
251
234
|
}
|
|
252
235
|
|
|
253
|
-
export async function executeSnap({ query, searchDir, includeHidden = false,
|
|
236
|
+
export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
|
|
254
237
|
const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
|
|
255
238
|
if (tokens.length === 0) {
|
|
256
239
|
throw new Error("snap requires at least one searchable concept keyword");
|
|
@@ -259,12 +242,12 @@ export async function executeSnap({ query, searchDir, includeHidden = false, vfs
|
|
|
259
242
|
if (path.resolve(dir).split(path.sep).includes(".git")) {
|
|
260
243
|
throw new Error("snap cannot search Git metadata");
|
|
261
244
|
}
|
|
262
|
-
const fileList = await listCandidateFiles(dir, includeHidden,
|
|
245
|
+
const fileList = await listCandidateFiles(dir, includeHidden, index);
|
|
263
246
|
mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
|
|
264
247
|
if (fileList.length === 0) {
|
|
265
248
|
throw new Error(`no files found to search in ${dir}`);
|
|
266
249
|
}
|
|
267
250
|
const flags = { wantsTest, wantsDoc, wantsType };
|
|
268
|
-
const { candidates, candidateScores } =
|
|
269
|
-
return buildSnapResult(candidates, candidateScores, fileList);
|
|
251
|
+
const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
|
|
252
|
+
return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
|
|
270
253
|
}
|
package/surface.js
CHANGED
|
@@ -66,13 +66,16 @@ 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*=/;
|
|
69
71
|
|
|
70
72
|
function scanJavaScript(lines) {
|
|
71
73
|
const items = [];
|
|
72
74
|
for (let i = 0; i < lines.length; i++) {
|
|
73
75
|
const line = lines[i].trim();
|
|
74
76
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
75
|
-
|
|
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) {
|
|
76
79
|
const match = pattern.exec(line);
|
|
77
80
|
if (!match) continue;
|
|
78
81
|
items.push({
|
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
|
}
|
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;
|
|
@@ -43,8 +51,17 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
|
|
|
43
51
|
if (!allowRoot && target === resolvedCwd) {
|
|
44
52
|
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
45
53
|
}
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
let realRoot = realRoots.get(resolvedCwd);
|
|
55
|
+
if (!realRoot) {
|
|
56
|
+
realRoot = await fs.realpath(resolvedCwd);
|
|
57
|
+
realRoots.set(resolvedCwd, realRoot);
|
|
58
|
+
}
|
|
59
|
+
let probe = realNearest.get(target);
|
|
60
|
+
if (!probe) {
|
|
61
|
+
probe = await realpathNearest(target);
|
|
62
|
+
if (realNearest.size >= PATH_CACHE_MAX) realNearest.clear();
|
|
63
|
+
realNearest.set(target, probe);
|
|
64
|
+
}
|
|
48
65
|
assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
|
|
49
66
|
return target;
|
|
50
67
|
}
|