pi-supernova 0.4.0 → 0.6.0

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.
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import { isString } from "../shared/decode.js";
3
4
  import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
@@ -12,6 +13,22 @@ function textResult(text, details) {
12
13
  return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
13
14
  }
14
15
 
16
+ async function candidateFileList(index, root, includeHidden = false, signal) {
17
+ const stat = await fs.stat(root).catch(() => null);
18
+
19
+ if (stat?.isFile()) return [root];
20
+
21
+ return index.files(root, includeHidden, signal);
22
+ }
23
+
24
+ function pendingInScope(root, pendingPaths) {
25
+ return pendingPaths.filter(file => {
26
+ const relative = path.relative(root, file);
27
+
28
+ return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
29
+ });
30
+ }
31
+
15
32
  /** One bounded direct search for all changed names; no repository index or per-name spawn. */
16
33
  export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
17
34
  const references = new Map(names.map(name => [name, []]));
@@ -48,10 +65,10 @@ export async function referencesForNames({ root, names, excludePath, overlayText
48
65
  if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
49
66
  }
50
67
 
51
- for (const file of pendingPaths) {
68
+ for (const file of pendingInScope(root, pendingPaths)) {
52
69
  const text = overlayText(file);
53
70
 
54
- if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
71
+ if (text !== undefined && Buffer.byteLength(text, "utf8") <= 512 * 1024) text.split("\n").forEach((line, i) => add(file, i + 1, line));
55
72
  }
56
73
 
57
74
  return { references, incomplete: result.outputTruncated === true };
@@ -59,29 +76,57 @@ export async function referencesForNames({ root, names, excludePath, overlayText
59
76
 
60
77
  export function rgGrepArgs(pattern, params, searchPath) {
61
78
  const args = ["--line-number", "--no-heading", "--color", "never"];
79
+ const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
62
80
 
63
- if (params?.caseSensitive !== true) args.push("--ignore-case");
81
+ if (!caseSensitive) args.push("--ignore-case");
64
82
 
65
83
  if (params?.glob) args.push("--glob", String(params.glob));
84
+ if (Number.isInteger(params?.limit) && params.limit > 0) args.push("--max-count", String(Math.min(params.limit, 2000)));
66
85
  args.push("--", pattern, searchPath);
67
86
 
68
87
  return args;
69
88
  }
70
89
 
71
90
  /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
72
- export async function listWithTools(searchDir, pattern, cwd, signal) {
91
+ export async function listWithTools(searchDir, pattern, cwd, signal, pendingPaths = []) {
92
+ const stat = await fs.stat(searchDir).catch(() => null);
93
+ const pendingAbs = pendingInScope(searchDir, pendingPaths);
94
+ const pending = pendingAbs.map(file => relativeSlash(cwd, file));
95
+
96
+ let matcher = null;
97
+
98
+ if (pattern) {
99
+ try { matcher = globToRegExp(pattern); }
100
+ catch { matcher = /^$/; }
101
+ }
102
+
103
+ if (stat?.isFile() || (!stat?.isDirectory() && pending.length)) {
104
+ const rel = stat?.isFile() ? relativeSlash(cwd, searchDir) : null;
105
+ const rows = [...new Set([...(rel ? [rel] : []), ...pending])].filter(file => !matcher || matcher.test(file));
106
+
107
+ return textResult(rows.length ? rows.join("\n") + "\n" : "", { via: pending.length ? "vfs" : "file" });
108
+ }
109
+
110
+ const pendingMerged = pendingAbs.filter((_, i) => !matcher || matcher.test(pending[i]));
111
+ const mergePending = stdout => {
112
+ const diskRows = String(stdout || "").split("\n").filter(Boolean)
113
+ .map(row => relativeSlash(cwd, path.isAbsolute(row) ? row : path.resolve(cwd, row)));
114
+ const rows = [...new Set([...diskRows, ...pendingMerged])];
115
+
116
+ return rows.length ? rows.join("\n") + "\n" : "";
117
+ };
73
118
  const args = ["--files"];
74
119
 
75
120
  if (pattern) args.push("-g", pattern);
76
121
  const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
77
122
 
78
- if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
123
+ if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(mergePending(res.stdout), { via: "rg", outputTruncated: res.outputTruncated === true });
79
124
  const findArgs = [searchDir];
80
125
 
81
126
  if (pattern) findArgs.push("-name", pattern);
82
127
  const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
83
128
 
84
- return textResult(findRes.stdout, { via: "find" });
129
+ return textResult(mergePending(findRes.stdout), { via: "find", outputTruncated: findRes.outputTruncated === true });
85
130
  }
86
131
 
87
132
  const GLOB_CHARS = /[*?[\]{}]/;
@@ -90,9 +135,9 @@ const GLOB_CHARS = /[*?[\]{}]/;
90
135
  * fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
91
136
  * Returns "path" rows (best first) or null when the pattern is a real glob.
92
137
  */
93
- export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
138
+ export async function fuzzyFind(index, root, cwd, pattern, limit = 20, pendingPaths = []) {
94
139
  if (!pattern || GLOB_CHARS.test(pattern)) return null;
95
- const files = await index.files(root);
140
+ const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
96
141
 
97
142
  if (!index.canScan(files)) return null;
98
143
  const rel = files.map((f) => relativeSlash(cwd, f));
@@ -109,12 +154,12 @@ export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
109
154
  }
110
155
 
111
156
  /** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
112
- export async function grepIndexed(index, pattern, params, searchPath, cwd) {
157
+ export async function grepIndexed(index, pattern, params, searchPath, cwd, overlayText = () => undefined, pendingPaths = []) {
113
158
  const compiled = grepRegex(pattern, params);
114
159
 
115
160
  if (!compiled) return null;
116
161
  const { regex, caseSensitive } = compiled;
117
- let files = await index.files(searchPath);
162
+ let files = [...new Set([...await candidateFileList(index, searchPath), ...pendingInScope(searchPath, pendingPaths)])];
118
163
 
119
164
  if (!index.canScan(files)) return null;
120
165
 
@@ -123,14 +168,19 @@ export async function grepIndexed(index, pattern, params, searchPath, cwd) {
123
168
  files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
124
169
  }
125
170
 
126
- const rows = index.grepRows(files, regex, cwd);
127
- const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
171
+ for (const file of files) {
172
+ const overlay = overlayText(file);
173
+
174
+ if (overlay === undefined && index.entry(file) === null) return null;
175
+ }
176
+ const rows = index.grepRows(files, regex, cwd, overlayText);
177
+ const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText) : rows;
128
178
 
129
179
  return formatGrepRows(fallback, grepLimit(params));
130
180
  }
131
181
 
132
182
  function grepLimit(params) {
133
- return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
183
+ return Number.isInteger(params?.limit) && params.limit > 0 ? Math.min(params.limit, 2000) : 200;
134
184
  }
135
185
 
136
186
  function grepRegex(pattern, params) {
@@ -144,12 +194,15 @@ function grepRegex(pattern, params) {
144
194
  }
145
195
 
146
196
  /** Zero literal hits: retry each line fuzzily (1 typo, 2 for long names) within a tight span, so IsOffTheRecord finds is_off_the_record. */
147
- function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
197
+ function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive, overlayText = () => undefined) {
148
198
  const maxTypos = pattern.length >= 8 ? 2 : 1;
149
199
  const rows = [];
150
200
 
151
201
  for (const filePath of files) {
152
- const e = index.entry(filePath);
202
+ const pending = overlayText(filePath);
203
+ const e = pending === undefined
204
+ ? index.entry(filePath)
205
+ : Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
153
206
 
154
207
  if (!e) continue;
155
208
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
@@ -197,8 +250,8 @@ function formatGrepRows(rows, limit) {
197
250
  }
198
251
 
199
252
  /** rg --files [-g pattern] served from the index; null when the tree is too large. */
200
- export async function listIndexed(index, root, cwd, pattern) {
201
- const files = await index.files(root);
253
+ export async function listIndexed(index, root, cwd, pattern, pendingPaths = []) {
254
+ const files = [...new Set([...await candidateFileList(index, root), ...pendingInScope(root, pendingPaths)])];
202
255
 
203
256
  if (!index.canScan(files)) return null;
204
257
  const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
@@ -3,6 +3,8 @@ import { isString } from "../shared/decode.js";
3
3
  import { truncateChars } from "../output/format.js";
4
4
  import * as fs from "node:fs/promises";
5
5
  import { extractStructuralSurface } from "./surface.js";
6
+ import { WorkspaceIndex } from "./repo-index.js";
7
+ import { pickSpan, spanCandidate, spanWindow } from "./spans.js";
6
8
  import { rankPaths } from "./fuzzy.js";
7
9
  import { isTestPath, runCommand, relativeSlash } from "../fs/workspace.js";
8
10
 
@@ -18,6 +20,8 @@ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
18
20
 
19
21
  const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
20
22
 
23
+ const MAX_NEEDLE_CHARS = 128;
24
+
21
25
  const MAX_ALTERNATIVES = 3;
22
26
 
23
27
  /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
@@ -80,21 +84,25 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
80
84
  const lower = relative.toLowerCase();
81
85
  const base = path.basename(lower);
82
86
 
87
+ const extension = path.extname(base);
88
+ const stemBase = extension ? base.slice(0, -extension.length) : base;
83
89
  const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
84
- || base.slice(0, -path.extname(base).length) === query.toLowerCase();
90
+ || stemBase === query.toLowerCase();
91
+
92
+ const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
85
93
 
86
94
  return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
87
- pathCoverage: tokens.filter(token => lower.includes(token)).length,
95
+ pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
88
96
  matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
89
- line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
97
+ line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
90
98
  }
91
99
 
92
- function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
100
+ function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
93
101
  const text = raw.replace(/\r?\n$/, "");
94
102
  const lower = text.toLowerCase();
95
103
 
96
104
  if (isMatch) {
97
- const matches = tokens.filter(token => lower.includes(token));
105
+ const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
98
106
 
99
107
  for (const token of matches) candidate.matched.add(token);
100
108
  const ext = path.extname(candidate.path).toLowerCase();
@@ -106,7 +114,7 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
106
114
  for (const item of items) {
107
115
  const name = item.name.toLowerCase();
108
116
  const itemExact = name === query.toLowerCase();
109
- const coverage = tokens.filter(token => name.includes(token)).length;
117
+ const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
110
118
 
111
119
  if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
112
120
 
@@ -115,6 +123,8 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
115
123
 
116
124
  const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
117
125
 
126
+ if (exact) candidate.exactLines.add(lineNumber);
127
+
118
128
  if (score > candidate.anchorScore) {
119
129
  candidate.anchorScore = score;
120
130
  candidate.line = lineNumber;
@@ -136,33 +146,47 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
136
146
  if (candidate.recent.length > 2) candidate.recent.shift();
137
147
  }
138
148
 
139
- function inspectOverlay(candidate, text, needles, query, tokens) {
140
- const lines = text.split("\n");
141
- const matches = [];
142
-
143
- for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
144
-
145
- for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
146
- candidate.context.clear();
149
+ function inspectOverlay(candidate, text, needles, query, tokens, signal) {
150
+ let start = 0, line = 1, truncated = false;
151
+
152
+ // Keep only the candidate and its short context, not another copy of every
153
+ // line in a staged document. Oversized individual lines disclose uncertainty.
154
+ while (start < text.length) {
155
+ if ((line & 127) === 0) signal?.throwIfAborted();
156
+ const newline = text.indexOf("\n", start);
157
+ const end = newline < 0 ? text.length : newline + 1;
158
+
159
+ if (end - start > MAX_SEARCH_CHARS) truncated = true;
160
+ else {
161
+ const row = text.slice(start, end);
162
+ const lower = row.toLowerCase();
163
+ inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
164
+ }
165
+ start = end;
166
+ line++;
167
+ }
147
168
 
148
- for (let i = Math.max(0, candidate.line - 3); i < Math.min(lines.length, candidate.line + 4); i++) candidate.context.set(i + 1, truncateChars(lines[i], 240, "source line").text);
169
+ return truncated;
149
170
  }
150
171
 
151
- async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
152
- const needles = exact ? [query.toLowerCase()] : tokens;
172
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
173
+ const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
174
+ const searchNeedles = [...new Set(needles)];
175
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
176
+ const candidates = new Map();
177
+
153
178
  const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
154
179
 
155
180
  if (includeHidden) args.push("--hidden");
156
181
  args.push("-g", "!.git/**", "-g", "!**/.git/**");
157
182
 
158
- for (const needle of needles) args.push("-e", needle);
159
- args.push("--", dir);
183
+ for (const needle of searchNeedles) args.push("-e", needle);
184
+ args.push("--", focusFile ?? dir);
160
185
 
161
- const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
186
+ const response = diskFiles || (focusFile && overlayText(focusFile) === undefined) ? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
162
187
  : { stdout: "", stderr: "", exitCode: 1 };
163
188
 
164
189
  if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
165
- const candidates = new Map();
166
190
  const records = response.stdout.split("\n");
167
191
 
168
192
  for (let i = 0; i < records.length; i++) {
@@ -186,24 +210,26 @@ async function contentCandidates({ dir, includeHidden, query, tokens, flags, pen
186
210
  let candidate = candidates.get(filePath);
187
211
 
188
212
  if (!candidate) {
189
- candidate = makeCandidate(filePath, dir, query, tokens, flags);
213
+ candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
190
214
  candidates.set(filePath, candidate);
191
215
  }
192
216
 
193
- inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
217
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
194
218
  }
195
219
 
220
+ let overlayTruncated = false;
221
+
196
222
  for (const filePath of pendingPaths) {
197
223
  const pending = overlayText(filePath);
198
224
 
199
225
  if (pending === undefined) continue;
200
- const candidate = makeCandidate(filePath, dir, query, tokens, flags);
201
- inspectOverlay(candidate, pending, needles, query, tokens);
226
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
227
+ overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
202
228
 
203
229
  if (candidate.matched.size) candidates.set(filePath, candidate);
204
230
  }
205
231
 
206
- return { candidates, truncated: response.outputTruncated === true };
232
+ return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
207
233
  }
208
234
 
209
235
  function rankScore(candidate, tokenCount) {
@@ -220,13 +246,61 @@ function location(candidate, root) {
220
246
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
221
247
  }
222
248
 
249
+ async function spanCandidates(filePath, lines, root, overlayText, signal) {
250
+ const staged = overlayText(filePath);
251
+ const rel = path.relative(root, filePath);
252
+ let text = staged;
253
+
254
+ if (text === undefined) {
255
+ const file = await fs.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
256
+
257
+ try {
258
+ const stat = await file.stat();
259
+
260
+ if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
261
+ if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
262
+ text = await file.readFile({ encoding: "utf8", signal });
263
+ } finally { await file.close(); }
264
+ }
265
+ const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
266
+
267
+ return lines.map(line => {
268
+ const span = pickSpan(spans, { line }) ?? { start: line, end: line };
269
+ const end = Math.min(span.end, span.start + 119);
270
+
271
+ return spanCandidate(rel, line, spanWindow(text, span.start, end));
272
+ });
273
+ }
274
+
275
+ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
276
+ const out = [];
277
+
278
+ for (const candidate of ranked) {
279
+ const lines = candidate.exactLines?.size ? [...candidate.exactLines].sort((a, b) => a - b) : [candidate.line];
280
+ const staged = overlayText(candidate.path);
281
+ let large = false;
282
+
283
+ if (staged !== undefined) large = Buffer.byteLength(staged) > 512 * 1024;
284
+ else try { large = (await fs.stat(candidate.path)).size > 512 * 1024; } catch {}
285
+
286
+ if (large) out.push(location(candidate, root));
287
+ else {
288
+ try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
289
+ catch (error) { signal?.throwIfAborted(); out.push(location(candidate, root)); }
290
+ }
291
+ if (out.length >= MAX_ALTERNATIVES) break;
292
+ }
293
+
294
+ return out.slice(0, MAX_ALTERNATIVES);
295
+ }
296
+
223
297
  export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
224
298
  const flags = tokenizeQuery(query);
299
+
300
+ if (flags.tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
225
301
  const tokens = [...new Set(flags.tokens.map(stem))];
226
302
 
227
303
  if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
228
-
229
- if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
230
304
  query = query.trim();
231
305
  const dir = path.resolve(searchDir || process.cwd());
232
306
 
@@ -235,15 +309,21 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
235
309
  flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
236
310
  pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
237
311
 
238
- const diskFiles = await fs.stat(dir).then(stat => stat.isDirectory(), error => {
239
- if (error.code !== "ENOENT" || !pendingPaths.length) throw error;
312
+ const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
313
+ const dirStat = await fs.stat(dir).catch(error => {
314
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
240
315
 
241
- return false;
316
+ return null;
242
317
  });
243
318
 
244
- const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
319
+ if (!dirStat && !pendingPaths.length) return { ...empty, status: "not_found" };
320
+ const diskFiles = dirStat?.isDirectory() === true;
321
+ const focusFile = dirStat?.isFile() === true || pendingPaths.includes(dir) ? dir : null;
322
+
245
323
  const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
246
- const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles });
324
+
325
+ if (!tokens.length) return { ...empty, status: "not_found" };
326
+ const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile });
247
327
  // A declaration hit needs no prerequisite file listing or persistent index.
248
328
  // Bare names can name files, even when callers mention the same word.
249
329
  const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
@@ -254,26 +334,27 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
254
334
 
255
335
  if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
256
336
 
257
- const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...pendingPaths])]
337
+ const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...(focusFile ? [focusFile] : []), ...pendingPaths])]
258
338
  .filter(file => inScope(file, dir, includeHidden));
339
+ const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
259
340
 
260
341
  for (const filePath of paths) {
261
342
  if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
262
- const relative = path.relative(dir, filePath).toLowerCase();
343
+ const relative = path.relative(candidateRoot, filePath).toLowerCase();
263
344
 
264
345
  if (!tokens.some(token => relative.includes(token))) continue;
265
346
 
266
347
  if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
267
- const candidate = makeCandidate(filePath, dir, query, tokens, flags);
348
+ const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
268
349
 
269
- if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
350
+ if (focusFile || candidate.pathScore > 0) search.candidates.set(filePath, candidate);
270
351
  }
271
352
 
272
353
  const ranked = [];
273
354
 
274
355
  for (const candidate of search.candidates.values()) {
275
- if (candidate.pathScore > -50) {
276
- ranked.push({ ...candidate, score: rankScore(candidate, tokens.length) });
356
+ if (focusFile || candidate.pathScore > -50) {
357
+ ranked.push({ ...candidate, score: focusFile ? Math.max(1, rankScore(candidate, tokens.length)) : rankScore(candidate, tokens.length) });
277
358
  }
278
359
  }
279
360
 
@@ -306,7 +387,14 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
306
387
  const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
307
388
  const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
308
389
 
309
- if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
390
+ if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) {
391
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates(ranked, relativeRoot, overlayText, signal) };
392
+ }
393
+
394
+ if (best.exactLines.size > 1) {
395
+ return { ...empty, status: "ambiguous", candidates: await rankedSpanCandidates([best], relativeRoot, overlayText, signal) };
396
+ }
397
+
310
398
  const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
311
399
 
312
400
  return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
@@ -0,0 +1,39 @@
1
+ import { truncateChars } from "../output/format.js";
2
+
3
+ export function pickSpan(spans, { line, name } = {}) {
4
+ const needle = typeof name === "string" && /^[A-Za-z_$][\w$]*$/.test(name.trim()) ? name.trim().toLowerCase() : "";
5
+ const named = needle ? spans.filter(item => item.name.toLowerCase() === needle) : [];
6
+
7
+ if (named.length === 1) return named[0];
8
+ if (!line) return;
9
+
10
+ return spans.find(item => item.start === line)
11
+ ?? spans.filter(item => item.start <= line && line <= item.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
12
+ }
13
+
14
+ export function spanWindow(text, start, end) {
15
+ const raw = text.split("\n");
16
+
17
+ return {
18
+ start,
19
+ end,
20
+ text: raw.slice(start - 1, end).join("\n"),
21
+ signature: truncateChars((raw[start - 1] ?? "").trim().replace(/\{.*$/, "").trim(), 240, "signature").text,
22
+ context: Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => {
23
+ const n = start + i;
24
+
25
+ return { line: n, text: raw[n - 1] ?? "" };
26
+ }),
27
+ };
28
+ }
29
+
30
+ export function spanCandidate(relPath, line, window) {
31
+ return {
32
+ path: relPath,
33
+ line,
34
+ lines: [window.start, window.end],
35
+ text: window.text,
36
+ signature: window.signature,
37
+ context: window.context.map(row => (row.line === line ? "►" : " ") + row.line + " " + row.text),
38
+ };
39
+ }
@@ -8,14 +8,20 @@ function scanPython(lines) {
8
8
  const line = lines[i];
9
9
  const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
10
10
 
11
- if (!match) continue;
12
- items.push({
13
- kind: match[2].includes("def") ? "function" : "class",
14
- name: match[3],
15
- signature: match[0].trim(),
16
- line: i + 1,
17
- depth: Math.floor(match[1].length / 4),
18
- });
11
+ if (match) {
12
+ items.push({
13
+ kind: match[2].includes("def") ? "function" : "class",
14
+ name: match[3],
15
+ signature: match[0].trim(),
16
+ line: i + 1,
17
+ depth: Math.floor(match[1].length / 4),
18
+ });
19
+ continue;
20
+ }
21
+
22
+ const constant = /^([ \t]*)([A-Z][A-Z0-9_]*)\s*(?::[^=\n]+)?=/.exec(line);
23
+
24
+ if (constant && constant[1].length === 0) items.push({ kind: "constant", name: constant[2], signature: constant[0].replace(/=\s*$/, "=").trim(), line: i + 1, depth: 0 });
19
25
  }
20
26
 
21
27
  return items;
@@ -81,10 +87,10 @@ const JS_DECL_PATTERNS = [
81
87
  // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
82
88
  const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
83
89
 
84
- // Indented methods (object-literal adapters, class members) that open a block on the same line.
85
- const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
90
+ // Indented methods (object-literal adapters, class members), including one-liners.
91
+ const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{/;
86
92
 
87
- const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
93
+ const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{/;
88
94
 
89
95
  const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
90
96
 
@@ -93,16 +99,18 @@ function methodItem(line, lineNumber, depth) {
93
99
 
94
100
  if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
95
101
 
96
- return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
102
+ return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{.*$/, "").trim(), line: lineNumber, depth };
97
103
  }
98
104
 
99
105
  function declarationItem(line, rawLine, lineNumber) {
106
+ const indent = rawLine.length - rawLine.trimStart().length;
107
+ const depth = Math.floor(indent / 2);
100
108
  const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
101
109
 
102
110
  for (const [pattern, isExport] of patterns) {
103
111
  const match = pattern.exec(line);
104
112
 
105
- if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
113
+ if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth };
106
114
  }
107
115
 
108
116
  return null;
@@ -116,7 +124,7 @@ function scanJavaScript(lines) {
116
124
 
117
125
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
118
126
  const indent = lines[i].length - lines[i].trimStart().length;
119
- const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
127
+ const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
120
128
 
121
129
  if (item) items.push(item);
122
130
  }
@@ -139,7 +147,7 @@ const SCANNERS = {
139
147
  export function extractStructuralSurface(code, extension = "js") {
140
148
  if (!isString(code) || !code.trim()) return { items: [], lineCount: 0 };
141
149
  const lines = code.split("\n");
142
- const ext = extension.replace(/^\./, "").toLowerCase();
150
+ const ext = String(extension ?? "").replace(/^\./, "").toLowerCase();
143
151
  const scanner = SCANNERS[ext] || SCANNERS.js;
144
152
  const items = scanner(lines);
145
153
 
package/src/fs/check.js CHANGED
@@ -60,7 +60,7 @@ function skipComment(text, i) {
60
60
 
61
61
  const end = text.indexOf("*/", i + 2);
62
62
 
63
- return end < 0 ? text.length : end + 2;
63
+ return end < 0 ? -1 : end + 2;
64
64
  }
65
65
 
66
66
  function skipRegex(text, i) {
@@ -126,7 +126,13 @@ function consumeLiteral(text, i, stack, prev) {
126
126
 
127
127
  if (c !== "/") return null;
128
128
 
129
- if (text[i + 1] === "/" || text[i + 1] === "*") return { end: skipComment(text, i), prev };
129
+ if (text[i + 1] === "/" || text[i + 1] === "*") {
130
+ const end = skipComment(text, i);
131
+
132
+ if (end < 0) return { error: "unterminated comment", at: i };
133
+
134
+ return { end, prev };
135
+ }
130
136
 
131
137
  if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
132
138
  const end = skipRegex(text, i);
@@ -190,6 +196,8 @@ const CODE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts",
190
196
 
191
197
  /** { ok: true } | { ok: false, message }; message names the problem and line. */
192
198
  export function quickCheck(text, ext) {
199
+ ext = String(ext ?? "").toLowerCase();
200
+
193
201
  if (ext === ".json") {
194
202
  try {
195
203
  JSON.parse(text);
@@ -43,7 +43,11 @@ export function jsonProjector(json) {
43
43
  return function* (root) {
44
44
  for (const steps of plans) yield steps.reduce((value, step) => {
45
45
  if (step.key !== undefined) {
46
- if (!isObject(value) || !Object.hasOwn(value, step.key)) throw new Error("JSON field not found: " + JSON.stringify(step.key));
46
+ if (!isObject(value) || !Object.hasOwn(value, step.key)) {
47
+ const keys = isObject(value) ? Object.keys(value) : [];
48
+ const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
49
+ throw new Error("JSON field not found: " + JSON.stringify(step.key) + preview);
50
+ }
47
51
 
48
52
  return value[step.key];
49
53
  }