pi-supernova 0.0.11 → 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 +56 -3
- package/README.md +33 -13
- package/bottleneck.js +1 -1
- package/catalog.js +8 -7
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -1
- package/decode.js +0 -10
- package/evidence.js +13 -30
- package/fuzzy.js +182 -0
- package/guest-worker.js +10 -8
- package/host-bridge.js +152 -103
- package/index.js +31 -40
- package/ledger.js +178 -0
- package/omp-frame.js +7 -22
- package/outline.js +80 -0
- package/package.json +6 -1
- package/render-measure.js +2 -30
- package/render.js +32 -146
- package/repo-index.js +105 -11
- package/runtime.js +3 -20
- package/search.js +141 -0
- package/snap.js +1 -6
- package/surface.js +22 -13
- package/vfs.js +0 -5
- package/workspace.js +14 -0
package/render.js
CHANGED
|
@@ -12,124 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { stripVTControlCharacters } from "node:util";
|
|
14
14
|
import { isString, isObject, isFunction } from "./decode.js";
|
|
15
|
-
import {
|
|
16
|
-
measureWidth,
|
|
17
|
-
hardTruncate,
|
|
18
|
-
clampLine,
|
|
19
|
-
wrapPlainToWidth,
|
|
20
|
-
fitPath,
|
|
21
|
-
} from "./render-measure.js";
|
|
15
|
+
import { measureWidth, hardTruncate, clampLine, fitPath } from "./render-measure.js";
|
|
22
16
|
import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
|
|
23
17
|
import { formatValue } from "./format.js";
|
|
24
18
|
|
|
25
|
-
export { measureWidth, hardTruncate, clampLine
|
|
26
|
-
|
|
27
|
-
function fitOutputLines(text, width) {
|
|
28
|
-
const w = Math.max(1, width | 0);
|
|
29
|
-
const out = [];
|
|
30
|
-
for (const line of String(text ?? "")
|
|
31
|
-
.replace(/\t/g, " ")
|
|
32
|
-
.split("\n")) {
|
|
33
|
-
if (measureWidth(line) <= w) {
|
|
34
|
-
out.push(line);
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
// Wrap on plain text so long paths continue on the next line instead of
|
|
38
|
-
// dying as `packages/pi-supern…`. ANSI is dropped on wrap (crash-safety).
|
|
39
|
-
const plain = stripVTControlCharacters(line);
|
|
40
|
-
for (const chunk of wrapPlainToWidth(plain, w)) {
|
|
41
|
-
out.push(clampLine(chunk, w));
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return out.length > 0 ? out : [""];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Compact bounded text; the host supplies the card background and borders. */
|
|
48
|
-
export class SafeText {
|
|
49
|
-
constructor(text = "") {
|
|
50
|
-
this.text = text;
|
|
51
|
-
}
|
|
52
|
-
setText(text) {
|
|
53
|
-
this.text = text;
|
|
54
|
-
}
|
|
55
|
-
invalidate() {}
|
|
56
|
-
render(width = 80) {
|
|
57
|
-
const raw = String(this.text ?? "");
|
|
58
|
-
return raw.trim() ? fitOutputLines(raw, Math.max(1, width | 0)) : [];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Keep names some tests / older call sites may import.
|
|
63
|
-
export const visibleWidth = measureWidth;
|
|
64
|
-
export const truncateToWidth = hardTruncate;
|
|
65
|
-
|
|
66
|
-
export function extractOperationsFromCode(code) {
|
|
67
|
-
const trimmed = String(code || "").trim();
|
|
68
|
-
if (!trimmed) return [];
|
|
69
|
-
|
|
70
|
-
const ops = [];
|
|
71
|
-
const seen = new Set();
|
|
72
|
-
|
|
73
|
-
const addOp = (tool, target) => {
|
|
74
|
-
const key = `${tool}:${target}`;
|
|
75
|
-
if (!seen.has(key)) {
|
|
76
|
-
seen.add(key);
|
|
77
|
-
ops.push({ tool, target });
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const callRegex = /nova\.call\s*\(\s*["'`]([a-zA-Z0-9_-]+)["'`](?:\s*,\s*(\{[\s\S]*?\}))?/g;
|
|
82
|
-
let match;
|
|
83
|
-
while ((match = callRegex.exec(trimmed)) !== null) {
|
|
84
|
-
const tool = match[1];
|
|
85
|
-
let target = "";
|
|
86
|
-
if (match[2]) {
|
|
87
|
-
const pathMatch = /path\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
88
|
-
const cmdMatch = /command\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
89
|
-
const patMatch = /pattern\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
90
|
-
if (pathMatch) target = pathMatch[1];
|
|
91
|
-
else if (cmdMatch) target = cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + "…" : cmdMatch[1];
|
|
92
|
-
else if (patMatch) target = patMatch[1];
|
|
93
|
-
}
|
|
94
|
-
addOp(tool, target);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const callManyRegex = /nova\.callMany\s*\(\s*\[([\s\S]*?)\]\s*\)/g;
|
|
98
|
-
while ((match = callManyRegex.exec(trimmed)) !== null) {
|
|
99
|
-
const inner = match[1];
|
|
100
|
-
const subCalls = inner.matchAll(/name\s*:\s*["'`]([a-zA-Z0-9_-]+)["'`]/g);
|
|
101
|
-
for (const sub of subCalls) addOp(sub[1], "");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const namedCalls = [
|
|
105
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?read\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "read", wrap: (p) => p },
|
|
106
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "write", wrap: (p) => p },
|
|
107
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?edit\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
|
|
108
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "patch", wrap: (p) => p },
|
|
109
|
-
{
|
|
110
|
-
regex: /(?:^|[^\w$.])(?:nova\.)?bash\s*\(\s*["'`]([^"'`]+)["'`]/gm,
|
|
111
|
-
tool: "bash",
|
|
112
|
-
wrap: (c) => (c.length > 32 ? c.slice(0, 29) + "…" : c),
|
|
113
|
-
},
|
|
114
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "exec", wrap: (c) => c },
|
|
115
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "search", wrap: (q) => `"${q}"` },
|
|
116
|
-
{ regex: /(?:^|[^\w$.])nova\.describe\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "describe", wrap: (name) => name },
|
|
117
|
-
{ regex: /(?:^|[^\w$.])nova\.has\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "has", wrap: (name) => name },
|
|
118
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "surface", wrap: (p) => p },
|
|
119
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "snap", wrap: (q) => `"${q}"` },
|
|
120
|
-
];
|
|
121
|
-
for (const item of namedCalls) {
|
|
122
|
-
while ((match = item.regex.exec(trimmed)) !== null) {
|
|
123
|
-
addOp(item.tool, item.wrap(match[1]));
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (/nova\.speculate\s*\(/.test(trimmed)) {
|
|
128
|
-
addOp("speculate", "(branch foam)");
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return ops;
|
|
132
|
-
}
|
|
19
|
+
export { measureWidth, hardTruncate, clampLine };
|
|
133
20
|
|
|
134
21
|
function formatDiffRows(diff, theme, maxShown = 6) {
|
|
135
22
|
if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return [];
|
|
@@ -217,7 +104,7 @@ export function normalizeCallRenderArgs(a, b, c) {
|
|
|
217
104
|
/**
|
|
218
105
|
* Dual-host renderResult args:
|
|
219
106
|
* Pi: (result, {expanded,isPartial}, theme, context)
|
|
220
|
-
* OMP: (result, {expanded,isPartial}, theme, args)
|
|
107
|
+
* OMP: (result, {expanded,isPartial}, theme, args) (4th is args, not context)
|
|
221
108
|
*
|
|
222
109
|
* Call shapes share the first three positions, so host is inferred from the
|
|
223
110
|
* fourth argument's context-versus-args shape.
|
|
@@ -237,7 +124,7 @@ function detectResultHost(options, ctxOrArgs) {
|
|
|
237
124
|
return "pi";
|
|
238
125
|
}
|
|
239
126
|
|
|
240
|
-
|
|
127
|
+
function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
|
|
241
128
|
if (isTheme(themeOrCtx)) {
|
|
242
129
|
const opts = isObject(options) ? options : {};
|
|
243
130
|
const context = contextFrom(opts, ctxOrArgs);
|
|
@@ -333,20 +220,21 @@ function operationsFromTrace(trace) {
|
|
|
333
220
|
.filter(Boolean);
|
|
334
221
|
}
|
|
335
222
|
|
|
223
|
+
// The call slot is always empty: the result card owns the whole lifecycle in both hosts.
|
|
224
|
+
const EMPTY_CALL = { render: () => [], invalidate() {} };
|
|
225
|
+
|
|
336
226
|
export function renderSupernovaCall(a, b, c) {
|
|
337
227
|
const { context, options } = normalizeCallRenderArgs(a, b, c);
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
comp.setText("");
|
|
342
|
-
return comp;
|
|
228
|
+
if (options) options.lastComponent = EMPTY_CALL;
|
|
229
|
+
else if (context) context.lastComponent = EMPTY_CALL;
|
|
230
|
+
return EMPTY_CALL;
|
|
343
231
|
}
|
|
344
232
|
|
|
345
233
|
const TOOL_COL = 7;
|
|
346
234
|
const DURATION_COL = 6;
|
|
347
235
|
const PREVIEW_LINES = 24;
|
|
348
236
|
|
|
349
|
-
|
|
237
|
+
function formatDuration(ms) {
|
|
350
238
|
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
351
239
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
352
240
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -418,11 +306,8 @@ function formatOpRow(theme, op, width, isPartial, isError) {
|
|
|
418
306
|
return prefix.trimEnd();
|
|
419
307
|
}
|
|
420
308
|
|
|
421
|
-
function operationsFor(payload, context
|
|
422
|
-
|
|
423
|
-
const traced = operationsFromTrace(trace);
|
|
424
|
-
if (traced.length > 0) return traced;
|
|
425
|
-
return extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
|
|
309
|
+
function operationsFor(payload, context) {
|
|
310
|
+
return operationsFromTrace(payload?.trace || context?.state?.trace || []);
|
|
426
311
|
}
|
|
427
312
|
|
|
428
313
|
function resultLines(value, maxLines) {
|
|
@@ -475,33 +360,34 @@ class UnifiedResultCard {
|
|
|
475
360
|
set(theme, model) {
|
|
476
361
|
this.theme = theme;
|
|
477
362
|
this.model = model;
|
|
478
|
-
this.
|
|
363
|
+
this.cache = undefined;
|
|
479
364
|
}
|
|
480
365
|
invalidate() {
|
|
481
|
-
this.
|
|
366
|
+
this.cache = undefined;
|
|
482
367
|
}
|
|
483
368
|
render(width = 80) {
|
|
484
369
|
const { theme, model } = this;
|
|
485
370
|
if (!theme || !model) return [];
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
371
|
+
if (this.cache?.width === width) return this.cache.lines;
|
|
372
|
+
const view = buildBodyLines(theme, Math.max(1, width - 4), model);
|
|
373
|
+
const header = novaStatusLine(theme, {
|
|
374
|
+
icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
|
|
375
|
+
title: "nova",
|
|
376
|
+
description: describeCard(model, view.opCount),
|
|
377
|
+
});
|
|
378
|
+
// A program with no host calls has nothing to frame: one status line, no empty box.
|
|
379
|
+
const lines = view.lines.length === 0
|
|
380
|
+
? [clampLine(header, width)]
|
|
381
|
+
: novaFramedBlock(theme, () => ({
|
|
382
|
+
header,
|
|
383
|
+
sections: [{ lines: view.lines }],
|
|
497
384
|
state: model.isError ? "error" : model.isPartial ? "pending" : "success",
|
|
498
385
|
// borderMuted is invisible on OMP's card background; dim matches the duration column.
|
|
499
386
|
borderColor: model.isError ? "error" : "dim",
|
|
500
|
-
width
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return this.frame.render(width);
|
|
387
|
+
width,
|
|
388
|
+
})).render(width);
|
|
389
|
+
this.cache = { width, lines };
|
|
390
|
+
return lines;
|
|
505
391
|
}
|
|
506
392
|
}
|
|
507
393
|
|
package/repo-index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { extractStructuralSurface } from "./surface.js";
|
|
4
|
+
import { Frecency } from "./fuzzy.js";
|
|
5
|
+
import { relativeSlash } from "./workspace.js";
|
|
4
6
|
|
|
5
7
|
// In-process workspace index: the gitignore-aware file list comes from one
|
|
6
8
|
// \`rg --files\` spawn and is then reused; file text, lowercase text, and the
|
|
7
9
|
// structural surface are cached per path and validated by mtime. snap/grep/glob
|
|
8
10
|
// read from here instead of spawning, so a warm call is sub-millisecond.
|
|
9
11
|
|
|
12
|
+
// With a working fs.watch the list only refreshes on change; the TTL is the fallback when watching fails.
|
|
10
13
|
const LIST_TTL_MS = 10_000;
|
|
14
|
+
const WATCHED_TTL_MS = 5 * 60_000;
|
|
15
|
+
const WATCH_DEBOUNCE_MS = 150;
|
|
11
16
|
const MAX_INDEXED_FILES = 4000;
|
|
12
17
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
13
18
|
const BINARY_EXT = new Set([
|
|
@@ -20,7 +25,12 @@ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
|
20
25
|
const EMPTY = Object.freeze([]);
|
|
21
26
|
const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
/** Declared identifier on a line (function/class/const/…), or ""; the same rule snap and grep use. */
|
|
29
|
+
export function declaredName(line) {
|
|
30
|
+
return DEF_PATTERN.exec(String(line).trim())?.[2] ?? "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isTextCandidate(filePath) {
|
|
24
34
|
return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
|
|
25
35
|
}
|
|
26
36
|
|
|
@@ -69,17 +79,81 @@ export class WorkspaceIndex {
|
|
|
69
79
|
this.runCommand = runCommand;
|
|
70
80
|
this.lists = new Map();
|
|
71
81
|
this.entries = new Map();
|
|
82
|
+
this.watchers = new Map();
|
|
83
|
+
this.frecency = new Frecency();
|
|
84
|
+
this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
|
|
85
|
+
this.lastTouched = null;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
invalidate() {
|
|
75
89
|
this.lists.clear();
|
|
76
90
|
}
|
|
77
91
|
|
|
92
|
+
/** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
|
|
93
|
+
touch(relPath) {
|
|
94
|
+
this.frecency.record(relPath);
|
|
95
|
+
this.lastTouched = relPath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
watch(root) {
|
|
99
|
+
if (this.watchers.has(root)) return this.watchers.get(root);
|
|
100
|
+
let ok = false;
|
|
101
|
+
try {
|
|
102
|
+
let timer = null;
|
|
103
|
+
const watcher = fs.watch(root, { recursive: true }, () => {
|
|
104
|
+
if (timer) return;
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
timer = null;
|
|
107
|
+
this.lists.clear();
|
|
108
|
+
this.gitModified.delete(root);
|
|
109
|
+
}, WATCH_DEBOUNCE_MS);
|
|
110
|
+
});
|
|
111
|
+
watcher.on("error", () => {
|
|
112
|
+
this.watchers.set(root, false);
|
|
113
|
+
this.lists.clear();
|
|
114
|
+
});
|
|
115
|
+
if (typeof watcher.unref === "function") watcher.unref();
|
|
116
|
+
ok = true;
|
|
117
|
+
} catch {
|
|
118
|
+
ok = false;
|
|
119
|
+
}
|
|
120
|
+
this.watchers.set(root, ok);
|
|
121
|
+
return ok;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Paths git reports as modified/added/untracked (fff's git-status boost); one spawn per list refresh. */
|
|
125
|
+
async modifiedFiles(root) {
|
|
126
|
+
const cached = this.gitModified.get(root);
|
|
127
|
+
if (cached) return cached;
|
|
128
|
+
const set = new Set();
|
|
129
|
+
try {
|
|
130
|
+
const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
|
|
131
|
+
if (res.exitCode === 0) {
|
|
132
|
+
for (const row of res.stdout.split("\0")) {
|
|
133
|
+
if (row.length > 3) set.add(row.slice(3));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
this.gitModified.set(root, set);
|
|
138
|
+
return set;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mtimeSeconds(filePath) {
|
|
142
|
+
const e = this.entries.get(filePath);
|
|
143
|
+
if (e) return e.mtimeMs / 1000;
|
|
144
|
+
try {
|
|
145
|
+
return fs.statSync(filePath).mtimeMs / 1000;
|
|
146
|
+
} catch {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
78
151
|
/** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
|
|
79
152
|
async files(root, includeHidden = false) {
|
|
80
153
|
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
81
154
|
const cached = this.lists.get(key);
|
|
82
|
-
|
|
155
|
+
const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
|
|
156
|
+
if (cached && Date.now() - cached.at < ttl) return cached.files;
|
|
83
157
|
const args = ["rg", "--files"];
|
|
84
158
|
if (includeHidden) args.push("--hidden");
|
|
85
159
|
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
@@ -114,16 +188,16 @@ export class WorkspaceIndex {
|
|
|
114
188
|
return null;
|
|
115
189
|
}
|
|
116
190
|
if (text.includes("\0")) return null;
|
|
117
|
-
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined };
|
|
191
|
+
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
118
192
|
this.entries.set(filePath, created);
|
|
119
193
|
return created;
|
|
120
194
|
}
|
|
121
195
|
|
|
122
196
|
static fromText(filePath, text) {
|
|
123
|
-
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined };
|
|
197
|
+
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
124
198
|
}
|
|
125
199
|
|
|
126
|
-
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens
|
|
200
|
+
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens, computed once per entry. */
|
|
127
201
|
static linesOf(entry) {
|
|
128
202
|
if (entry.lines) return entry.lines;
|
|
129
203
|
const raw = entry.text.split("\n");
|
|
@@ -140,6 +214,25 @@ export class WorkspaceIndex {
|
|
|
140
214
|
return entry.lines;
|
|
141
215
|
}
|
|
142
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Declaration spans [start, end] (1-based, inclusive) in file order, trailing blank lines trimmed.
|
|
219
|
+
* A span runs to the line before the next declaration; the file's leading header is not a span.
|
|
220
|
+
*/
|
|
221
|
+
static spansOf(entry) {
|
|
222
|
+
if (entry.spans) return entry.spans;
|
|
223
|
+
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
224
|
+
const { lower } = WorkspaceIndex.linesOf(entry);
|
|
225
|
+
const spans = [];
|
|
226
|
+
for (let i = 0; i < items.length; i++) {
|
|
227
|
+
const start = items[i].line;
|
|
228
|
+
let end = Math.min(i + 1 < items.length ? items[i + 1].line - 1 : lineCount, lineCount);
|
|
229
|
+
while (end > start && lower[end - 1] === "") end--;
|
|
230
|
+
spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
|
|
231
|
+
}
|
|
232
|
+
entry.spans = spans;
|
|
233
|
+
return spans;
|
|
234
|
+
}
|
|
235
|
+
|
|
143
236
|
static surfaceOf(entry) {
|
|
144
237
|
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
145
238
|
return entry.surface;
|
|
@@ -162,16 +255,17 @@ export class WorkspaceIndex {
|
|
|
162
255
|
return hits;
|
|
163
256
|
}
|
|
164
257
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
258
|
+
/** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
|
|
259
|
+
grepRows(files, regex, root) {
|
|
167
260
|
const out = [];
|
|
261
|
+
const nameRegex = new RegExp(regex.source, "i");
|
|
168
262
|
for (const filePath of files) {
|
|
169
263
|
const e = this.entry(filePath);
|
|
170
264
|
if (!e || !regex.test(e.text)) continue;
|
|
171
|
-
const
|
|
172
|
-
const rel =
|
|
173
|
-
for (let i = 0; i <
|
|
174
|
-
if (regex.test(
|
|
265
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
266
|
+
const rel = relativeSlash(root, filePath);
|
|
267
|
+
for (let i = 0; i < raw.length; i++) {
|
|
268
|
+
if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
|
|
175
269
|
}
|
|
176
270
|
}
|
|
177
271
|
return out;
|
package/runtime.js
CHANGED
|
@@ -94,15 +94,6 @@ export function warmGuestWorker(config) {
|
|
|
94
94
|
return handle.ready;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** Terminate every guest worker (tests, shutdown). */
|
|
98
|
-
export async function shutdownGuestWorkers() {
|
|
99
|
-
if (!idleWorker) return;
|
|
100
|
-
const handle = idleWorker;
|
|
101
|
-
idleWorker = null;
|
|
102
|
-
handle.dead = true;
|
|
103
|
-
await handle.worker.terminate();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
97
|
const RPC_METHODS = {
|
|
107
98
|
call: (nova, args) => {
|
|
108
99
|
if (!isFunction(nova?.call)) throw new Error("nova.call unavailable");
|
|
@@ -122,17 +113,9 @@ const RPC_METHODS = {
|
|
|
122
113
|
if (!isFunction(nova?.describe)) throw new Error("nova.describe unavailable");
|
|
123
114
|
return nova.describe(args[0]);
|
|
124
115
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
},
|
|
129
|
-
snap: (nova, args) => {
|
|
130
|
-
if (isFunction(nova?.snap)) return nova.snap(args[0], args[1]);
|
|
131
|
-
return nova.call("snap", { query: args[0], path: args[1] });
|
|
132
|
-
},
|
|
133
|
-
speculateBegin: (nova) => (isFunction(nova?.speculateBegin) ? nova.speculateBegin() : undefined),
|
|
134
|
-
speculateCommit: (nova) => (isFunction(nova?.speculateCommit) ? nova.speculateCommit() : undefined),
|
|
135
|
-
speculateRollback: (nova) => (isFunction(nova?.speculateRollback) ? nova.speculateRollback() : undefined),
|
|
116
|
+
speculateBegin: (nova) => nova?.speculateBegin?.(),
|
|
117
|
+
speculateCommit: (nova) => nova?.speculateCommit?.(),
|
|
118
|
+
speculateRollback: (nova) => nova?.speculateRollback?.(),
|
|
136
119
|
};
|
|
137
120
|
|
|
138
121
|
async function dispatchRpc(nova, method, args) {
|
package/search.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
3
|
+
import { rankPaths, smartCase, fuzzyMatch } from "./fuzzy.js";
|
|
4
|
+
import { runCommand, relativeSlash } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
// Search served from the in-process index: fuzzy path find (fff port), smart-case grep with
|
|
7
|
+
// definition-first rows and fuzzy fallback, glob listing. rg is spawned only for trees too
|
|
8
|
+
// large to scan in-process.
|
|
9
|
+
|
|
10
|
+
function textResult(text, details) {
|
|
11
|
+
return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function rgGrepArgs(pattern, params, searchPath) {
|
|
15
|
+
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
16
|
+
if (params?.caseSensitive !== true) args.push("--ignore-case");
|
|
17
|
+
if (params?.glob) args.push("--glob", String(params.glob));
|
|
18
|
+
args.push("--", pattern, searchPath);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
|
|
23
|
+
export async function listWithTools(searchDir, pattern, cwd, signal) {
|
|
24
|
+
const args = ["--files"];
|
|
25
|
+
if (pattern) args.push("-g", pattern);
|
|
26
|
+
const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
|
|
27
|
+
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
|
|
28
|
+
const findArgs = [searchDir];
|
|
29
|
+
if (pattern) findArgs.push("-name", pattern);
|
|
30
|
+
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
31
|
+
return textResult(findRes.stdout, { via: "find" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
|
|
38
|
+
* Returns "path" rows (best first) or null when the pattern is a real glob.
|
|
39
|
+
*/
|
|
40
|
+
export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
41
|
+
if (!pattern || GLOB_CHARS.test(pattern)) return null;
|
|
42
|
+
const files = await index.files(root);
|
|
43
|
+
if (!index.canScan(files)) return null;
|
|
44
|
+
const rel = files.map((f) => relativeSlash(cwd, f));
|
|
45
|
+
const absolute = new Map(rel.map((r, i) => [r, files[i]]));
|
|
46
|
+
// mtime is only consulted for paths that matched; never stat the whole tree.
|
|
47
|
+
const mtimeOf = (r) => index.mtimeSeconds(absolute.get(r));
|
|
48
|
+
const ranked = rankPaths(pattern, rel, { frecency: index.frecency, mtimeOf, modified: await index.modifiedFiles(cwd), currentFile: index.lastTouched });
|
|
49
|
+
// fff weak-match detector: when nothing matches exactly and the best is mostly typos, say so instead of flooding.
|
|
50
|
+
const rows = ranked.slice(0, limit);
|
|
51
|
+
if (rows.length === 0) return "";
|
|
52
|
+
return rows.map((r) => r.path).join("\n") + "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
|
|
56
|
+
export async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
57
|
+
const compiled = grepRegex(pattern, params);
|
|
58
|
+
if (!compiled) return null;
|
|
59
|
+
const { regex, caseSensitive } = compiled;
|
|
60
|
+
let files = await index.files(searchPath);
|
|
61
|
+
if (!index.canScan(files)) return null;
|
|
62
|
+
if (params?.glob) {
|
|
63
|
+
const matcher = globToRegExp(String(params.glob));
|
|
64
|
+
files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
|
|
65
|
+
}
|
|
66
|
+
const rows = index.grepRows(files, regex, cwd);
|
|
67
|
+
const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
|
|
68
|
+
return formatGrepRows(fallback, grepLimit(params));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function grepLimit(params) {
|
|
72
|
+
return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function grepRegex(pattern, params) {
|
|
76
|
+
const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
|
|
77
|
+
try {
|
|
78
|
+
return { regex: new RegExp(pattern, caseSensitive ? "" : "i"), caseSensitive };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Zero literal hits: retry each line fuzzily (1 typo, 2 for long names) within a tight span, so IsOffTheRecord finds is_off_the_record. */
|
|
85
|
+
function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
|
|
86
|
+
const maxTypos = pattern.length >= 8 ? 2 : 1;
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const filePath of files) {
|
|
89
|
+
const e = index.entry(filePath);
|
|
90
|
+
if (!e) continue;
|
|
91
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
92
|
+
const rel = relativeSlash(cwd, filePath);
|
|
93
|
+
for (let i = 0; i < raw.length && rows.length <= 400; i++) {
|
|
94
|
+
const m = fuzzyMatch(pattern, raw[i], { maxTypos, caseSensitive });
|
|
95
|
+
if (!m || m.end - m.start > pattern.length + 2) continue;
|
|
96
|
+
rows.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && fuzzyMatch(pattern, defNames[i], { maxTypos }) !== null });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** fff definition-first hinting: files that declare the name come first, declarations first within a file; one header per file. */
|
|
103
|
+
function formatGrepRows(rows, limit) {
|
|
104
|
+
if (rows.length === 0) return "";
|
|
105
|
+
const groups = new Map();
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
if (!groups.has(r.rel)) groups.set(r.rel, []);
|
|
108
|
+
groups.get(r.rel).push(r);
|
|
109
|
+
}
|
|
110
|
+
const files = [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
|
|
111
|
+
let out = "";
|
|
112
|
+
let shown = 0;
|
|
113
|
+
for (const group of files) {
|
|
114
|
+
if (shown >= limit) break;
|
|
115
|
+
out += group[0].rel + "\n";
|
|
116
|
+
group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
|
|
117
|
+
for (const r of group) {
|
|
118
|
+
if (shown++ >= limit) break;
|
|
119
|
+
out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (rows.length > limit) out += "… " + (rows.length - limit) + " more matches (pass limit or narrow the pattern)\n";
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** rg --files [-g pattern] served from the index; null when the tree is too large. */
|
|
127
|
+
export async function listIndexed(index, root, cwd, pattern) {
|
|
128
|
+
const files = await index.files(root);
|
|
129
|
+
if (!index.canScan(files)) return null;
|
|
130
|
+
const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
|
|
131
|
+
if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
|
|
132
|
+
let matcher;
|
|
133
|
+
try {
|
|
134
|
+
matcher = globToRegExp(pattern);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const hits = rel.filter((f) => matcher.test(f));
|
|
139
|
+
return hits.length ? hits.join("\n") + "\n" : "";
|
|
140
|
+
}
|
|
141
|
+
|
package/snap.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { isString } from "./decode.js";
|
|
4
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",
|
|
@@ -137,12 +138,6 @@ function mergeGrepHits(candidates, grepHits) {
|
|
|
137
138
|
return candidates;
|
|
138
139
|
}
|
|
139
140
|
|
|
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
141
|
function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
|
|
147
142
|
if (candidates.length >= 5) return candidates;
|
|
148
143
|
const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
|
package/surface.js
CHANGED
|
@@ -68,25 +68,34 @@ const JS_DECL_PATTERNS = [
|
|
|
68
68
|
];
|
|
69
69
|
// Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
|
|
70
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
|
+
}
|
|
71
90
|
|
|
72
91
|
function scanJavaScript(lines) {
|
|
73
92
|
const items = [];
|
|
74
93
|
for (let i = 0; i < lines.length; i++) {
|
|
75
94
|
const line = lines[i].trim();
|
|
76
95
|
if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!match) continue;
|
|
81
|
-
items.push({
|
|
82
|
-
kind: match[1],
|
|
83
|
-
name: match[2],
|
|
84
|
-
isExport,
|
|
85
|
-
signature: line.replace(/\{.*$/, "").trim(),
|
|
86
|
-
line: i + 1,
|
|
87
|
-
});
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
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);
|
|
90
99
|
}
|
|
91
100
|
return items;
|
|
92
101
|
}
|