pi-supernova 0.3.1 → 0.4.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.
@@ -16,51 +16,71 @@ function textResult(text, details) {
16
16
  export async function referencesForNames({ root, names, excludePath, overlayText, pendingPaths, signal, run = runCommand }) {
17
17
  const references = new Map(names.map(name => [name, []]));
18
18
  const patterns = names.map(name => new RegExp("(?<![\\w$])" + name.replaceAll("$", "\\$") + "(?![\\w$])"));
19
+
19
20
  const add = (file, line, text) => {
20
21
  if (file === excludePath) return;
22
+
21
23
  for (let i = 0; i < names.length; i++) {
22
24
  const hits = references.get(names[i]);
25
+
23
26
  if (hits.length < 7 && patterns[i].test(text)) hits.push(relativeSlash(root, file) + ":" + line);
24
27
  }
25
28
  };
29
+
26
30
  const result = await run(["rg", "--json", "--fixed-strings", ...names.flatMap(name => ["-e", name]), "--", root],
27
31
  { cwd: root, signal, timeoutMs: 5000, maxOutputChars: 65536 });
32
+
28
33
  if (result.exitCode !== 0 && result.exitCode !== 1) throw new Error(result.stderr.trim() || "reference search failed");
29
34
  const records = result.stdout.split("\n");
35
+
30
36
  for (let i = 0; i < records.length; i++) {
31
37
  signal?.throwIfAborted();
38
+
32
39
  if (!records[i]) continue;
33
40
  let record;
41
+
34
42
  try { record = JSON.parse(records[i]); }
35
43
  catch (error) { if (result.outputTruncated && i === records.length - 1) break; throw error; }
44
+
36
45
  if (record.type !== "match" || !isString(record.data?.path?.text) || !isString(record.data.lines?.text)) continue;
37
46
  const file = path.resolve(root, record.data.path.text);
47
+
38
48
  if (overlayText(file) === undefined) add(file, record.data.line_number, record.data.lines.text);
39
49
  }
50
+
40
51
  for (const file of pendingPaths) {
41
52
  const text = overlayText(file);
53
+
42
54
  if (text !== undefined) text.split("\n").forEach((line, i) => add(file, i + 1, line));
43
55
  }
56
+
44
57
  return { references, incomplete: result.outputTruncated === true };
45
58
  }
46
59
 
47
60
  export function rgGrepArgs(pattern, params, searchPath) {
48
61
  const args = ["--line-number", "--no-heading", "--color", "never"];
62
+
49
63
  if (params?.caseSensitive !== true) args.push("--ignore-case");
64
+
50
65
  if (params?.glob) args.push("--glob", String(params.glob));
51
66
  args.push("--", pattern, searchPath);
67
+
52
68
  return args;
53
69
  }
54
70
 
55
71
  /** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
56
72
  export async function listWithTools(searchDir, pattern, cwd, signal) {
57
73
  const args = ["--files"];
74
+
58
75
  if (pattern) args.push("-g", pattern);
59
76
  const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
77
+
60
78
  if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
61
79
  const findArgs = [searchDir];
80
+
62
81
  if (pattern) findArgs.push("-name", pattern);
63
82
  const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
83
+
64
84
  return textResult(findRes.stdout, { via: "find" });
65
85
  }
66
86
 
@@ -73,6 +93,7 @@ const GLOB_CHARS = /[*?[\]{}]/;
73
93
  export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
74
94
  if (!pattern || GLOB_CHARS.test(pattern)) return null;
75
95
  const files = await index.files(root);
96
+
76
97
  if (!index.canScan(files)) return null;
77
98
  const rel = files.map((f) => relativeSlash(cwd, f));
78
99
  const absolute = new Map(rel.map((r, i) => [r, files[i]]));
@@ -81,23 +102,30 @@ export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
81
102
  const ranked = rankPaths(pattern, rel, { frecency: index.frecency, mtimeOf, modified: await index.modifiedFiles(cwd), currentFile: index.lastTouched });
82
103
  // fff weak-match detector: when nothing matches exactly and the best is mostly typos, say so instead of flooding.
83
104
  const rows = ranked.slice(0, limit);
105
+
84
106
  if (rows.length === 0) return "";
107
+
85
108
  return rows.map((r) => r.path).join("\n") + "\n";
86
109
  }
87
110
 
88
111
  /** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
89
112
  export async function grepIndexed(index, pattern, params, searchPath, cwd) {
90
113
  const compiled = grepRegex(pattern, params);
114
+
91
115
  if (!compiled) return null;
92
116
  const { regex, caseSensitive } = compiled;
93
117
  let files = await index.files(searchPath);
118
+
94
119
  if (!index.canScan(files)) return null;
120
+
95
121
  if (params?.glob) {
96
122
  const matcher = globToRegExp(String(params.glob));
97
123
  files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
98
124
  }
125
+
99
126
  const rows = index.grepRows(files, regex, cwd);
100
127
  const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
128
+
101
129
  return formatGrepRows(fallback, grepLimit(params));
102
130
  }
103
131
 
@@ -107,6 +135,7 @@ function grepLimit(params) {
107
135
 
108
136
  function grepRegex(pattern, params) {
109
137
  const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
138
+
110
139
  try {
111
140
  return { regex: new RegExp(pattern, caseSensitive ? "" : "i"), caseSensitive };
112
141
  } catch {
@@ -118,17 +147,22 @@ function grepRegex(pattern, params) {
118
147
  function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
119
148
  const maxTypos = pattern.length >= 8 ? 2 : 1;
120
149
  const rows = [];
150
+
121
151
  for (const filePath of files) {
122
152
  const e = index.entry(filePath);
153
+
123
154
  if (!e) continue;
124
155
  const { raw, defNames } = WorkspaceIndex.linesOf(e);
125
156
  const rel = relativeSlash(cwd, filePath);
157
+
126
158
  for (let i = 0; i < raw.length && rows.length <= 400; i++) {
127
159
  const m = fuzzyMatch(pattern, raw[i], { maxTypos, caseSensitive });
160
+
128
161
  if (!m || m.end - m.start > pattern.length + 2) continue;
129
162
  rows.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && fuzzyMatch(pattern, defNames[i], { maxTypos }) !== null });
130
163
  }
131
164
  }
165
+
132
166
  return rows;
133
167
  }
134
168
 
@@ -136,39 +170,50 @@ function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
136
170
  function formatGrepRows(rows, limit) {
137
171
  if (rows.length === 0) return "";
138
172
  const groups = new Map();
173
+
139
174
  for (const r of rows) {
140
175
  if (!groups.has(r.rel)) groups.set(r.rel, []);
141
176
  groups.get(r.rel).push(r);
142
177
  }
178
+
143
179
  const files = [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
144
180
  let out = "";
145
181
  let shown = 0;
182
+
146
183
  for (const group of files) {
147
184
  if (shown >= limit) break;
148
185
  out += group[0].rel + "\n";
149
186
  group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
187
+
150
188
  for (const r of group) {
151
189
  if (shown++ >= limit) break;
152
190
  out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
153
191
  }
154
192
  }
193
+
155
194
  if (rows.length > limit) out += "… " + (rows.length - limit) + " more matches (pass limit or narrow the pattern)\n";
195
+
156
196
  return out;
157
197
  }
158
198
 
159
199
  /** rg --files [-g pattern] served from the index; null when the tree is too large. */
160
200
  export async function listIndexed(index, root, cwd, pattern) {
161
201
  const files = await index.files(root);
202
+
162
203
  if (!index.canScan(files)) return null;
163
204
  const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
205
+
164
206
  if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
165
207
  let matcher;
208
+
166
209
  try {
167
210
  matcher = globToRegExp(pattern);
168
211
  } catch {
169
212
  return null;
170
213
  }
214
+
171
215
  const hits = rel.filter((f) => matcher.test(f));
216
+
172
217
  return hits.length ? hits.join("\n") + "\n" : "";
173
218
  }
174
219
 
@@ -11,20 +11,26 @@ const STOP_WORDS = new Set([
11
11
  "by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
12
12
  "file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
13
13
  ]);
14
+
14
15
  const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
16
+
15
17
  const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
18
+
16
19
  const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
20
+
17
21
  const MAX_ALTERNATIVES = 3;
18
22
 
19
23
  /** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
20
24
  export function stem(token) {
21
25
  if (token.length < 5) return token;
26
+
22
27
  return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
23
28
  }
24
29
 
25
30
  export function tokenizeQuery(query) {
26
31
  if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
27
32
  const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
33
+
28
34
  return {
29
35
  tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
30
36
  wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
@@ -36,28 +42,36 @@ export function tokenizeQuery(query) {
36
42
  export function scorePathTopology(filePath, tokens, flags) {
37
43
  const normalized = filePath.replaceAll("\\", "/").toLowerCase();
38
44
  const parts = normalized.split("/");
45
+
39
46
  if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
40
47
  const test = isTestPath(normalized);
48
+
41
49
  if (test && !flags.wantsTest) return -50;
50
+
42
51
  if (!test && flags.wantsTest) return -20;
43
52
  const base = path.basename(normalized);
44
53
  const words = normalized.split(/[^a-zA-Z0-9]+/);
45
54
  const ext = path.extname(normalized);
46
55
  let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
56
+
47
57
  if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
58
+
48
59
  for (const token of tokens) {
49
60
  if (base === token || base.startsWith(token + ".")) score += 60;
50
61
  else if (base.includes(token)) score += 30;
51
62
  else if (words.includes(token)) score += 15;
52
63
  else if (normalized.includes(token)) score += 5;
53
64
  }
65
+
54
66
  return score;
55
67
  }
56
68
 
57
69
  function inScope(filePath, dir, includeHidden) {
58
70
  const relative = path.relative(dir, filePath);
71
+
59
72
  if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
60
73
  const parts = relative.split(path.sep);
74
+
61
75
  return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
62
76
  }
63
77
 
@@ -65,8 +79,10 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
65
79
  const relative = path.relative(dir, filePath);
66
80
  const lower = relative.toLowerCase();
67
81
  const base = path.basename(lower);
82
+
68
83
  const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
69
84
  || base.slice(0, -path.extname(base).length) === query.toLowerCase();
85
+
70
86
  return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
71
87
  pathCoverage: tokens.filter(token => lower.includes(token)).length,
72
88
  matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
@@ -76,22 +92,29 @@ function makeCandidate(filePath, dir, query, tokens, flags) {
76
92
  function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
77
93
  const text = raw.replace(/\r?\n$/, "");
78
94
  const lower = text.toLowerCase();
95
+
79
96
  if (isMatch) {
80
97
  const matches = tokens.filter(token => lower.includes(token));
98
+
81
99
  for (const token of matches) candidate.matched.add(token);
82
100
  const ext = path.extname(candidate.path).toLowerCase();
83
101
  const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
84
102
  let declaration;
85
103
  let definitionCoverage = 0;
86
104
  let exact = false;
105
+
87
106
  for (const item of items) {
88
107
  const name = item.name.toLowerCase();
89
108
  const itemExact = name === query.toLowerCase();
90
109
  const coverage = tokens.filter(token => name.includes(token)).length;
110
+
91
111
  if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
112
+
92
113
  if (exact) break;
93
114
  }
115
+
94
116
  const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
117
+
95
118
  if (score > candidate.anchorScore) {
96
119
  candidate.anchorScore = score;
97
120
  candidate.line = lineNumber;
@@ -100,63 +123,86 @@ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
100
123
  candidate.definitionCoverage = definitionCoverage;
101
124
  candidate.lineCoverage = matches.length;
102
125
  candidate.context.clear();
126
+
103
127
  for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
104
128
  }
105
129
  }
130
+
106
131
  const excerpt = truncateChars(text, 240, "source line").text;
132
+
107
133
  if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
108
134
  candidate.recent.push([lineNumber, excerpt]);
135
+
109
136
  if (candidate.recent.length > 2) candidate.recent.shift();
110
137
  }
111
138
 
112
139
  function inspectOverlay(candidate, text, needles, query, tokens) {
113
140
  const lines = text.split("\n");
114
141
  const matches = [];
142
+
115
143
  for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
144
+
116
145
  for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
117
146
  candidate.context.clear();
147
+
118
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);
119
149
  }
120
150
 
121
151
  async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles }) {
122
152
  const needles = exact ? [query.toLowerCase()] : tokens;
123
153
  const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
154
+
124
155
  if (includeHidden) args.push("--hidden");
125
156
  args.push("-g", "!.git/**", "-g", "!**/.git/**");
157
+
126
158
  for (const needle of needles) args.push("-e", needle);
127
159
  args.push("--", dir);
160
+
128
161
  const response = diskFiles ? await run(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
129
162
  : { stdout: "", stderr: "", exitCode: 1 };
163
+
130
164
  if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
131
165
  const candidates = new Map();
132
166
  const records = response.stdout.split("\n");
167
+
133
168
  for (let i = 0; i < records.length; i++) {
134
169
  if ((i & 127) === 0) signal?.throwIfAborted();
170
+
135
171
  if (!records[i]) continue;
136
172
  let record;
173
+
137
174
  try { record = JSON.parse(records[i]); } catch (error) {
138
175
  if (response.outputTruncated && i === records.length - 1) break;
139
176
  throw error;
140
177
  }
178
+
141
179
  if (record.type !== "match" && record.type !== "context") continue;
142
180
  const data = record.data;
181
+
143
182
  if (!data?.path?.text || !isString(data.lines?.text)) continue;
144
183
  const filePath = path.resolve(dir, data.path.text);
184
+
145
185
  if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) continue;
146
186
  let candidate = candidates.get(filePath);
187
+
147
188
  if (!candidate) {
148
189
  candidate = makeCandidate(filePath, dir, query, tokens, flags);
149
190
  candidates.set(filePath, candidate);
150
191
  }
192
+
151
193
  inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
152
194
  }
195
+
153
196
  for (const filePath of pendingPaths) {
154
197
  const pending = overlayText(filePath);
198
+
155
199
  if (pending === undefined) continue;
156
200
  const candidate = makeCandidate(filePath, dir, query, tokens, flags);
157
201
  inspectOverlay(candidate, pending, needles, query, tokens);
202
+
158
203
  if (candidate.matched.size) candidates.set(filePath, candidate);
159
204
  }
205
+
160
206
  return { candidates, truncated: response.outputTruncated === true };
161
207
  }
162
208
 
@@ -169,6 +215,7 @@ function rankScore(candidate, tokenCount) {
169
215
 
170
216
  function location(candidate, root) {
171
217
  const context = candidate.context;
218
+
172
219
  return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
173
220
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
174
221
  }
@@ -176,62 +223,91 @@ function location(candidate, root) {
176
223
  export async function executeSnap({ query, searchDir, root, includeHidden = false, run = runCommand, overlayText = () => undefined, pendingPaths = [], pathContext = {}, signal }) {
177
224
  const flags = tokenizeQuery(query);
178
225
  const tokens = [...new Set(flags.tokens.map(stem))];
226
+
179
227
  if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
228
+
180
229
  if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
181
230
  query = query.trim();
182
231
  const dir = path.resolve(searchDir || process.cwd());
232
+
183
233
  if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
184
234
  signal?.throwIfAborted();
185
235
  flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
186
236
  pendingPaths = pendingPaths.filter(file => inScope(file, dir, includeHidden));
237
+
187
238
  const diskFiles = await fs.stat(dir).then(stat => stat.isDirectory(), error => {
188
239
  if (error.code !== "ENOENT" || !pendingPaths.length) throw error;
240
+
189
241
  return false;
190
242
  });
243
+
191
244
  const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
192
245
  const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
193
246
  const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles });
194
247
  // A declaration hit needs no prerequisite file listing or persistent index.
195
248
  // Bare names can name files, even when callers mention the same word.
196
249
  const needsPaths = !search.candidates.size || (exact && ![...search.candidates.values()].some(candidate => candidate.exactDefinition));
250
+
197
251
  const listing = needsPaths && !search.truncated && diskFiles
198
252
  ? await run(["rg", "--files", "--null", ...(includeHidden ? ["--hidden"] : []), "-g", "!.git/**", "-g", "!**/.git/**", dir], { cwd: dir, signal, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS })
199
253
  : { stdout: "", exitCode: 1 };
254
+
200
255
  if (listing.exitCode !== 0 && listing.exitCode !== 1) throw new Error("source file listing failed: " + listing.stderr.trim());
201
- const paths = [...new Set([...listing.stdout.split("\0").filter(Boolean).map(file => path.resolve(dir, file)), ...pendingPaths])]
256
+
257
+ const paths = [...new Set([...listing.stdout.split("\0").flatMap(file => file ? [path.resolve(dir, file)] : []), ...pendingPaths])]
202
258
  .filter(file => inScope(file, dir, includeHidden));
259
+
203
260
  for (const filePath of paths) {
204
261
  if (!inScope(filePath, dir, includeHidden) || search.candidates.has(filePath)) continue;
205
262
  const relative = path.relative(dir, filePath).toLowerCase();
263
+
206
264
  if (!tokens.some(token => relative.includes(token))) continue;
265
+
207
266
  if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
208
267
  const candidate = makeCandidate(filePath, dir, query, tokens, flags);
268
+
209
269
  if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
210
270
  }
211
- const ranked = [...search.candidates.values()].filter(candidate => candidate.pathScore > -50)
212
- .map(candidate => ({ ...candidate, score: rankScore(candidate, tokens.length) }))
213
- .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
271
+
272
+ const ranked = [];
273
+
274
+ for (const candidate of search.candidates.values()) {
275
+ if (candidate.pathScore > -50) {
276
+ ranked.push({ ...candidate, score: rankScore(candidate, tokens.length) });
277
+ }
278
+ }
279
+
280
+ ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
281
+
214
282
  const incomplete = search.truncated || listing.outputTruncated === true;
215
283
  const relativeRoot = root ?? dir;
216
284
  const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot));
285
+
217
286
  if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
287
+
218
288
  if (!ranked.length) {
219
289
  // Reuse bounded filename discovery; fuzzy rank never authorizes a source selection.
220
290
  const eligible = exact && query.length >= 4 && query.length <= 64;
221
291
  const limited = eligible && paths.length > 1024;
292
+
222
293
  const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
223
294
  { ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
295
+
224
296
  if (fuzzy.length || limited) return { ...empty, status: limited ? "incomplete" : "ambiguous",
225
297
  candidates: fuzzy.map(hit => ({ path: hit.path, line: 1, context: [], match: "fuzzy" })),
226
298
  message: limited ? "No literal match; fuzzy hints cover only 1024 paths. Narrow the directory." : "No literal match. Fuzzy filename hints are not selected source; read an explicit path." };
299
+
227
300
  return { ...empty, status: "not_found" };
228
301
  }
302
+
229
303
  const best = ranked[0];
230
304
  const second = ranked[1];
231
305
  const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
232
306
  const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
233
307
  const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
308
+
234
309
  if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
235
310
  const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
311
+
236
312
  return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
237
313
  }
@@ -3,9 +3,11 @@ import { isString } from "../shared/decode.js";
3
3
 
4
4
  function scanPython(lines) {
5
5
  const items = [];
6
+
6
7
  for (let i = 0; i < lines.length; i++) {
7
8
  const line = lines[i];
8
9
  const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
10
+
9
11
  if (!match) continue;
10
12
  items.push({
11
13
  kind: match[2].includes("def") ? "function" : "class",
@@ -15,14 +17,17 @@ function scanPython(lines) {
15
17
  depth: Math.floor(match[1].length / 4),
16
18
  });
17
19
  }
20
+
18
21
  return items;
19
22
  }
20
23
 
21
24
  function scanRust(lines) {
22
25
  const items = [];
26
+
23
27
  for (let i = 0; i < lines.length; i++) {
24
28
  const line = lines[i].trim();
25
29
  const match = /^(pub\s+)?(async\s+)?(fn|struct|enum|trait|impl|type|const)\s+([a-zA-Z0-9_]+)(<.*?>)?(\(.*?\))?/.exec(line);
30
+
26
31
  if (!match) continue;
27
32
  items.push({
28
33
  kind: match[3],
@@ -31,14 +36,17 @@ function scanRust(lines) {
31
36
  line: i + 1,
32
37
  });
33
38
  }
39
+
34
40
  return items;
35
41
  }
36
42
 
37
43
  function scanGo(lines) {
38
44
  const items = [];
45
+
39
46
  for (let i = 0; i < lines.length; i++) {
40
47
  const line = lines[i].trim();
41
48
  const funcMatch = /^func\s+(\(.*?\)\s+)?([a-zA-Z0-9_]+)(\(.*?\))/.exec(line);
49
+
42
50
  if (funcMatch) {
43
51
  items.push({
44
52
  kind: "function",
@@ -48,7 +56,9 @@ function scanGo(lines) {
48
56
  });
49
57
  continue;
50
58
  }
59
+
51
60
  const typeMatch = /^type\s+([a-zA-Z0-9_]+)\s+(struct|interface)/.exec(line);
61
+
52
62
  if (typeMatch) {
53
63
  items.push({
54
64
  kind: typeMatch[2],
@@ -58,6 +68,7 @@ function scanGo(lines) {
58
68
  });
59
69
  }
60
70
  }
71
+
61
72
  return items;
62
73
  }
63
74
 
@@ -66,37 +77,50 @@ const JS_DECL_PATTERNS = [
66
77
  [/^(?:async\s+)?(function\*?|class)\s+([a-zA-Z0-9_$]+)/, false],
67
78
  [/^(interface|type)\s+([a-zA-Z0-9_$]+)/, false],
68
79
  ];
80
+
69
81
  // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
70
82
  const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
83
+
71
84
  // Indented methods (object-literal adapters, class members) that open a block on the same line.
72
85
  const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
86
+
73
87
  const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
88
+
74
89
  const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
75
90
 
76
91
  function methodItem(line, lineNumber, depth) {
77
92
  const match = JS_METHOD.exec(line) || JS_ARROW_PROPERTY.exec(line);
93
+
78
94
  if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
95
+
79
96
  return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
80
97
  }
81
98
 
82
99
  function declarationItem(line, rawLine, lineNumber) {
83
100
  const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
101
+
84
102
  for (const [pattern, isExport] of patterns) {
85
103
  const match = pattern.exec(line);
104
+
86
105
  if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
87
106
  }
107
+
88
108
  return null;
89
109
  }
90
110
 
91
111
  function scanJavaScript(lines) {
92
112
  const items = [];
113
+
93
114
  for (let i = 0; i < lines.length; i++) {
94
115
  const line = lines[i].trim();
116
+
95
117
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
96
118
  const indent = lines[i].length - lines[i].trimStart().length;
97
119
  const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
120
+
98
121
  if (item) items.push(item);
99
122
  }
123
+
100
124
  return items;
101
125
  }
102
126
 
@@ -118,5 +142,6 @@ export function extractStructuralSurface(code, extension = "js") {
118
142
  const ext = extension.replace(/^\./, "").toLowerCase();
119
143
  const scanner = SCANNERS[ext] || SCANNERS.js;
120
144
  const items = scanner(lines);
145
+
121
146
  return { items, lineCount: lines.length };
122
147
  }