pi-supernova 0.0.7 → 0.0.8
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 +26 -0
- package/README.md +22 -15
- package/bottleneck.js +32 -41
- package/catalog.js +61 -15
- package/config.default.json +2 -1
- package/config.js +19 -10
- package/diff.js +12 -4
- package/format.js +95 -0
- package/guest-worker.js +344 -0
- package/host-bridge.js +130 -439
- package/index.js +75 -97
- package/omp-frame.js +68 -51
- package/package.json +6 -1
- package/parallel.js +19 -20
- package/patch.js +106 -0
- package/render-measure.js +63 -49
- package/render.js +193 -166
- package/runtime.js +266 -241
- package/snap.js +120 -101
- package/surface.js +13 -30
- package/vfs.js +138 -0
- package/workspace.js +112 -0
package/snap.js
CHANGED
|
@@ -63,37 +63,41 @@ export function scorePathTopology(filePath, tokens, { wantsTest, wantsDoc, wants
|
|
|
63
63
|
return score;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function
|
|
67
|
-
|
|
66
|
+
function isSkippableLine(line) {
|
|
67
|
+
return !line || line.startsWith("//") || line.startsWith("#") || line.startsWith("*");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function lineScoreFor(line, tokens, isDef) {
|
|
71
|
+
const lower = line.toLowerCase();
|
|
72
|
+
let lineScore = 0;
|
|
73
|
+
for (const token of tokens) {
|
|
74
|
+
if (lower.includes(token)) lineScore += isDef ? 40 : 5;
|
|
75
|
+
}
|
|
76
|
+
return lineScore;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function accumulateContentScore(lines, tokens, defPattern) {
|
|
68
80
|
let score = 0;
|
|
69
81
|
let bestLine = 1;
|
|
70
82
|
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
83
|
for (let i = 0; i < lines.length; i++) {
|
|
75
84
|
const line = lines[i].trim();
|
|
76
|
-
if (
|
|
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
|
-
|
|
85
|
+
if (isSkippableLine(line)) continue;
|
|
86
|
+
const lineScore = lineScoreFor(line, tokens, defPattern.test(line));
|
|
87
87
|
if (lineScore > bestLineScore) {
|
|
88
88
|
bestLineScore = lineScore;
|
|
89
89
|
bestLine = i + 1;
|
|
90
90
|
}
|
|
91
91
|
score += lineScore;
|
|
92
92
|
}
|
|
93
|
-
|
|
94
93
|
return { totalScore: score, bestLine, bestLineScore };
|
|
95
94
|
}
|
|
96
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
|
+
}
|
|
100
|
+
|
|
97
101
|
function relativeHasSegment(relativePath, segmentName) {
|
|
98
102
|
return relativePath.split(path.sep).includes(segmentName);
|
|
99
103
|
}
|
|
@@ -102,78 +106,83 @@ function relativeHasHiddenSegment(relativePath) {
|
|
|
102
106
|
return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
|
|
103
107
|
}
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
|
|
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 = [];
|
|
109
|
+
async function listCandidateFiles(dir, includeHidden, runCommand) {
|
|
110
|
+
const rgArgs = ["rg", "--files"];
|
|
111
|
+
if (includeHidden) rgArgs.push("--hidden");
|
|
112
|
+
rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
|
|
116
113
|
try {
|
|
117
|
-
const rgArgs = ["rg", "--files"];
|
|
118
|
-
if (includeHidden) rgArgs.push("--hidden");
|
|
119
|
-
rgArgs.push("-g", "!.git/**", "-g", "!**/.git/**", dir);
|
|
120
114
|
const res = await runCommand(rgArgs, { timeoutMs: 15_000 });
|
|
121
|
-
|
|
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();
|
|
122
117
|
} catch {
|
|
123
|
-
|
|
118
|
+
return [];
|
|
124
119
|
}
|
|
120
|
+
}
|
|
125
121
|
|
|
122
|
+
function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
|
|
123
|
+
const resolvedDir = path.resolve(dir);
|
|
126
124
|
const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
|
|
127
125
|
for (const pendingPath of pendingPaths) {
|
|
128
126
|
const absolutePath = path.resolve(pendingPath);
|
|
129
|
-
const relativePath = path.relative(
|
|
127
|
+
const relativePath = path.relative(resolvedDir, absolutePath);
|
|
130
128
|
const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
|
|
131
129
|
const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
|
|
132
130
|
if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
|
|
133
131
|
seenPaths.add(absolutePath);
|
|
134
132
|
fileList.push(absolutePath);
|
|
135
133
|
}
|
|
134
|
+
return fileList;
|
|
135
|
+
}
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
function mergeGrepHits(candidates, grepHits) {
|
|
138
|
+
const seen = new Set(candidates);
|
|
139
|
+
for (const h of grepHits) {
|
|
140
|
+
if (seen.has(h)) continue;
|
|
141
|
+
seen.add(h);
|
|
142
|
+
candidates.push(h);
|
|
143
|
+
if (candidates.length >= 15) break;
|
|
139
144
|
}
|
|
145
|
+
return candidates;
|
|
146
|
+
}
|
|
140
147
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
async function expandCandidatesWithGrep(candidates, fileList, tokens, flags, dir, includeHidden, runCommand) {
|
|
149
|
+
if (candidates.length >= 5) return candidates;
|
|
150
|
+
try {
|
|
151
|
+
// Tokens are lowercased; identifiers are not.
|
|
152
|
+
const grepArgs = ["-l", "-i", "--max-count=1", "-g", "!.git/**", "-g", "!**/.git/**"];
|
|
153
|
+
if (includeHidden) grepArgs.push("--hidden");
|
|
154
|
+
if (!flags.wantsTest) {
|
|
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);
|
|
145
163
|
}
|
|
164
|
+
return candidates;
|
|
165
|
+
}
|
|
146
166
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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);
|
|
167
|
+
function scoreSurfaceItems(items, tokens, fallbackLine) {
|
|
168
|
+
let bonus = 0;
|
|
169
|
+
let signature = "";
|
|
170
|
+
let anchorLine = fallbackLine;
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
const nameLower = item.name.toLowerCase();
|
|
173
|
+
for (const token of tokens) {
|
|
174
|
+
if (!nameLower.includes(token)) continue;
|
|
175
|
+
bonus += item.isExport ? 80 : 50;
|
|
176
|
+
if (signature) continue;
|
|
177
|
+
signature = item.signature;
|
|
178
|
+
anchorLine = item.line;
|
|
172
179
|
}
|
|
173
180
|
}
|
|
181
|
+
return { bonus, signature, anchorLine };
|
|
182
|
+
}
|
|
174
183
|
|
|
184
|
+
async function scoreCandidateContents(candidates, tokens, flags, vfs) {
|
|
175
185
|
const candidateScores = [];
|
|
176
|
-
|
|
177
186
|
for (const filePath of candidates) {
|
|
178
187
|
let content = "";
|
|
179
188
|
try {
|
|
@@ -181,71 +190,81 @@ export async function executeSnap({ query, searchDir, includeHidden = false, vfs
|
|
|
181
190
|
} catch {
|
|
182
191
|
continue;
|
|
183
192
|
}
|
|
184
|
-
|
|
185
193
|
const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(content, tokens);
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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;
|
|
194
|
+
const surface = extractStructuralSurface(content, path.extname(filePath));
|
|
195
|
+
const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
|
|
196
|
+
const lowerPath = filePath.toLowerCase();
|
|
197
|
+
const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
|
|
198
|
+
const testAdjustment = !isTestFile ? 0 : (flags.wantsTest ? 100 : -200);
|
|
209
199
|
candidateScores.push({
|
|
210
200
|
path: filePath,
|
|
211
|
-
score:
|
|
201
|
+
score: totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment,
|
|
212
202
|
anchorLine,
|
|
213
203
|
signature,
|
|
214
204
|
content,
|
|
215
205
|
});
|
|
216
206
|
}
|
|
217
|
-
|
|
218
207
|
candidateScores.sort((a, b) => b.score - a.score);
|
|
208
|
+
return candidateScores;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function rankCandidates(fileList, tokens, flags, dir, includeHidden, runCommand, vfs) {
|
|
212
|
+
const scoredPaths = [];
|
|
213
|
+
for (const f of fileList) {
|
|
214
|
+
const score = scorePathTopology(f, tokens, flags);
|
|
215
|
+
if (score > 0) scoredPaths.push({ path: f, score });
|
|
216
|
+
}
|
|
217
|
+
scoredPaths.sort((a, b) => b.score - a.score);
|
|
218
|
+
const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
|
|
219
|
+
const candidates = await expandCandidatesWithGrep(selected, fileList, tokens, flags, dir, includeHidden, runCommand);
|
|
220
|
+
const candidateScores = await scoreCandidateContents(candidates, tokens, flags, vfs);
|
|
221
|
+
return { candidates, candidateScores };
|
|
222
|
+
}
|
|
219
223
|
|
|
224
|
+
function buildSnapResult(candidates, candidateScores, fileList) {
|
|
220
225
|
if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
|
|
221
|
-
const fallbackPath = candidates[0] || fileList[0];
|
|
222
226
|
return {
|
|
223
|
-
path:
|
|
227
|
+
path: candidates[0] || fileList[0],
|
|
224
228
|
line: 1,
|
|
225
229
|
signature: "",
|
|
226
230
|
confidence: 0.3,
|
|
227
231
|
context: [],
|
|
228
232
|
};
|
|
229
233
|
}
|
|
230
|
-
|
|
231
234
|
const best = candidateScores[0];
|
|
232
235
|
const lines = best.content.split("\n");
|
|
233
236
|
const startLine = Math.max(1, best.anchorLine - 3);
|
|
234
237
|
const endLine = Math.min(lines.length, best.anchorLine + 8);
|
|
235
|
-
|
|
236
|
-
const contextLines = [];
|
|
238
|
+
const context = [];
|
|
237
239
|
for (let l = startLine; l <= endLine; l++) {
|
|
238
240
|
const marker = l === best.anchorLine ? "►" : " ";
|
|
239
|
-
|
|
241
|
+
context.push(`${marker} ${String(l).padStart(4)} │ ${lines[l - 1]}`);
|
|
240
242
|
}
|
|
241
|
-
|
|
242
243
|
const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
|
|
243
|
-
|
|
244
244
|
return {
|
|
245
245
|
path: best.path,
|
|
246
246
|
line: best.anchorLine,
|
|
247
247
|
signature: best.signature,
|
|
248
248
|
confidence: Number(confidence.toFixed(2)),
|
|
249
|
-
context
|
|
249
|
+
context,
|
|
250
250
|
};
|
|
251
251
|
}
|
|
252
|
+
|
|
253
|
+
export async function executeSnap({ query, searchDir, includeHidden = false, vfs, runCommand, pendingPaths = [] }) {
|
|
254
|
+
const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
|
|
255
|
+
if (tokens.length === 0) {
|
|
256
|
+
throw new Error("snap requires at least one searchable concept keyword");
|
|
257
|
+
}
|
|
258
|
+
const dir = searchDir || process.cwd();
|
|
259
|
+
if (path.resolve(dir).split(path.sep).includes(".git")) {
|
|
260
|
+
throw new Error("snap cannot search Git metadata");
|
|
261
|
+
}
|
|
262
|
+
const fileList = await listCandidateFiles(dir, includeHidden, runCommand);
|
|
263
|
+
mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
|
|
264
|
+
if (fileList.length === 0) {
|
|
265
|
+
throw new Error(`no files found to search in ${dir}`);
|
|
266
|
+
}
|
|
267
|
+
const flags = { wantsTest, wantsDoc, wantsType };
|
|
268
|
+
const { candidates, candidateScores } = await rankCandidates(fileList, tokens, flags, dir, includeHidden, runCommand, vfs);
|
|
269
|
+
return buildSnapResult(candidates, candidateScores, fileList);
|
|
270
|
+
}
|
package/surface.js
CHANGED
|
@@ -61,45 +61,28 @@ 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
|
+
|
|
64
70
|
function scanJavaScript(lines) {
|
|
65
71
|
const items = [];
|
|
66
72
|
for (let i = 0; i < lines.length; i++) {
|
|
67
73
|
const line = lines[i].trim();
|
|
68
74
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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) {
|
|
75
|
+
for (const [pattern, isExport] of JS_DECL_PATTERNS) {
|
|
76
|
+
const match = pattern.exec(line);
|
|
77
|
+
if (!match) continue;
|
|
96
78
|
items.push({
|
|
97
|
-
kind:
|
|
98
|
-
name:
|
|
99
|
-
isExport
|
|
79
|
+
kind: match[1],
|
|
80
|
+
name: match[2],
|
|
81
|
+
isExport,
|
|
100
82
|
signature: line.replace(/\{.*$/, "").trim(),
|
|
101
83
|
line: i + 1,
|
|
102
84
|
});
|
|
85
|
+
break;
|
|
103
86
|
}
|
|
104
87
|
}
|
|
105
88
|
return items;
|
package/vfs.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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() {
|
|
8
|
+
this.cache = new Map();
|
|
9
|
+
this.overlays = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setCache(target, content) {
|
|
13
|
+
if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
|
|
14
|
+
const oldest = this.cache.keys().next().value;
|
|
15
|
+
if (oldest !== undefined) this.cache.delete(oldest);
|
|
16
|
+
}
|
|
17
|
+
this.cache.set(target, content);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getOverlay(target) {
|
|
21
|
+
for (let i = this.overlays.length - 1; i >= 0; i--) {
|
|
22
|
+
if (this.overlays[i].has(target)) return this.overlays[i].get(target);
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getOverlayPaths() {
|
|
28
|
+
const paths = new Set();
|
|
29
|
+
for (const overlay of this.overlays) {
|
|
30
|
+
for (const target of overlay.keys()) paths.add(target);
|
|
31
|
+
}
|
|
32
|
+
return [...paths];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async read(target) {
|
|
36
|
+
const overlay = this.getOverlay(target);
|
|
37
|
+
if (overlay !== undefined) return overlay;
|
|
38
|
+
|
|
39
|
+
const cached = this.cache.get(target);
|
|
40
|
+
if (cached !== undefined) return cached;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const text = await fs.readFile(target, "utf8");
|
|
44
|
+
this.setCache(target, text);
|
|
45
|
+
return text;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === "EISDIR") {
|
|
48
|
+
throw new Error(`read path is a directory, not a file: ${target}`);
|
|
49
|
+
}
|
|
50
|
+
if (err.code === "ENOENT") {
|
|
51
|
+
const missing = new Error(`no such file: ${target} (locate it with nova.call("glob", {pattern}) or snap(query))`);
|
|
52
|
+
missing.code = "ENOENT";
|
|
53
|
+
throw missing;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async write(target, content) {
|
|
60
|
+
if (this.overlays.length > 0) {
|
|
61
|
+
this.overlays[this.overlays.length - 1].set(target, content);
|
|
62
|
+
return { speculative: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const stat = await fs.stat(target);
|
|
67
|
+
if (stat.isDirectory()) {
|
|
68
|
+
throw new Error(`cannot write to a directory: ${target}`);
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err.code !== "ENOENT") throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
75
|
+
await fs.writeFile(target, content, "utf8");
|
|
76
|
+
this.setCache(target, content);
|
|
77
|
+
return { speculative: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
begin() {
|
|
81
|
+
this.overlays.push(new Map());
|
|
82
|
+
return this.overlays.length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async commit() {
|
|
86
|
+
if (this.overlays.length === 0) return { committed: 0, depth: 0 };
|
|
87
|
+
const top = this.overlays.pop();
|
|
88
|
+
if (this.overlays.length > 0) {
|
|
89
|
+
const parent = this.overlays[this.overlays.length - 1];
|
|
90
|
+
for (const [k, v] of top.entries()) parent.set(k, v);
|
|
91
|
+
return { committed: top.size, depth: this.overlays.length };
|
|
92
|
+
}
|
|
93
|
+
for (const [filePath, fileContent] of top.entries()) {
|
|
94
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
95
|
+
await fs.writeFile(filePath, fileContent, "utf8");
|
|
96
|
+
this.setCache(filePath, fileContent);
|
|
97
|
+
}
|
|
98
|
+
return { committed: top.size, depth: 0 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
rollback() {
|
|
102
|
+
if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
|
|
103
|
+
const top = this.overlays.pop();
|
|
104
|
+
return { rolledBack: top.size, depth: this.overlays.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async prepareExternalMutation(name) {
|
|
108
|
+
if (this.overlays.length > 1) {
|
|
109
|
+
throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
|
|
110
|
+
}
|
|
111
|
+
if (this.overlays.length === 0) return false;
|
|
112
|
+
const pending = this.overlays[0];
|
|
113
|
+
for (const [filePath, fileContent] of pending.entries()) {
|
|
114
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
115
|
+
await fs.writeFile(filePath, fileContent, "utf8");
|
|
116
|
+
this.setCache(filePath, fileContent);
|
|
117
|
+
}
|
|
118
|
+
this.overlays[0] = new Map();
|
|
119
|
+
return pending.size > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
invalidateCache() {
|
|
123
|
+
this.cache.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clear() {
|
|
127
|
+
this.invalidateCache();
|
|
128
|
+
this.overlays.length = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getCacheSize() {
|
|
132
|
+
return this.cache.size;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getOverlayDepth() {
|
|
136
|
+
return this.overlays.length;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/workspace.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { isString } from "./decode.js";
|
|
5
|
+
|
|
6
|
+
let cachedCwd = null;
|
|
7
|
+
let cachedResolvedCwd = null;
|
|
8
|
+
|
|
9
|
+
function getResolvedCwd(cwd) {
|
|
10
|
+
if (cwd === cachedCwd && cachedResolvedCwd) return cachedResolvedCwd;
|
|
11
|
+
cachedCwd = cwd;
|
|
12
|
+
cachedResolvedCwd = path.resolve(cwd);
|
|
13
|
+
return cachedResolvedCwd;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertInside(rel, message) {
|
|
17
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
18
|
+
throw new Error(message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function realpathNearest(target) {
|
|
23
|
+
let probe = target;
|
|
24
|
+
while (true) {
|
|
25
|
+
try {
|
|
26
|
+
return await fs.realpath(probe);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
29
|
+
const parent = path.dirname(probe);
|
|
30
|
+
if (parent === probe) throw err;
|
|
31
|
+
probe = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
|
|
37
|
+
if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
|
|
38
|
+
throw new Error(`${opName} requires path`);
|
|
39
|
+
}
|
|
40
|
+
const resolvedCwd = getResolvedCwd(cwd);
|
|
41
|
+
const target = path.resolve(resolvedCwd, inputPath.trim());
|
|
42
|
+
assertInside(path.relative(resolvedCwd, target), `${opName} path escapes workspace: paths resolve relative to ${resolvedCwd}`);
|
|
43
|
+
if (!allowRoot && target === resolvedCwd) {
|
|
44
|
+
throw new Error(`${opName} path cannot be the workspace root directory`);
|
|
45
|
+
}
|
|
46
|
+
const realRoot = await fs.realpath(resolvedCwd);
|
|
47
|
+
const probe = await realpathNearest(target);
|
|
48
|
+
assertInside(path.relative(realRoot, probe), `${opName} path escapes workspace through symlink`);
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runCommand(argv, options = {}) {
|
|
53
|
+
const cwd = options.cwd || process.cwd();
|
|
54
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
55
|
+
const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
|
|
56
|
+
return await new Promise((resolve, reject) => {
|
|
57
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
58
|
+
cwd,
|
|
59
|
+
env: process.env,
|
|
60
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
61
|
+
});
|
|
62
|
+
let stdout = "";
|
|
63
|
+
let stderr = "";
|
|
64
|
+
let settled = false;
|
|
65
|
+
let outputTruncated = false;
|
|
66
|
+
let onAbort;
|
|
67
|
+
|
|
68
|
+
const cleanup = () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
|
|
71
|
+
};
|
|
72
|
+
const fail = (err) => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
cleanup();
|
|
76
|
+
reject(err);
|
|
77
|
+
};
|
|
78
|
+
const append = (current, chunk) => {
|
|
79
|
+
const remaining = Math.max(0, maxOutputChars - current.length);
|
|
80
|
+
if (chunk.length > remaining) outputTruncated = true;
|
|
81
|
+
return remaining > 0 ? current + chunk.slice(0, remaining) : current;
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
child.kill("SIGTERM");
|
|
85
|
+
fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
|
|
88
|
+
child.stdout.setEncoding("utf8");
|
|
89
|
+
child.stderr.setEncoding("utf8");
|
|
90
|
+
child.stdout.on("data", (chunk) => {
|
|
91
|
+
stdout = append(stdout, chunk);
|
|
92
|
+
});
|
|
93
|
+
child.stderr.on("data", (chunk) => {
|
|
94
|
+
stderr = append(stderr, chunk);
|
|
95
|
+
});
|
|
96
|
+
child.on("error", fail);
|
|
97
|
+
child.on("close", (code) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
cleanup();
|
|
101
|
+
resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
|
|
102
|
+
});
|
|
103
|
+
if (options.signal) {
|
|
104
|
+
onAbort = () => {
|
|
105
|
+
child.kill("SIGTERM");
|
|
106
|
+
fail(new Error("aborted"));
|
|
107
|
+
};
|
|
108
|
+
if (options.signal.aborted) onAbort();
|
|
109
|
+
else options.signal.addEventListener("abort", onAbort, { once: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|