pi-supernova 0.0.15 → 0.2.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.
package/snap.js CHANGED
@@ -1,7 +1,8 @@
1
-
2
1
  import * as path from "node:path";
3
2
  import { isString } from "./decode.js";
3
+ import { truncateChars } from "./format.js";
4
4
  import { WorkspaceIndex } from "./repo-index.js";
5
+ import { extractStructuralSurface } from "./surface.js";
5
6
  import { isTestPath } from "./workspace.js";
6
7
 
7
8
  const STOP_WORDS = new Set([
@@ -9,240 +10,208 @@ const STOP_WORDS = new Set([
9
10
  "by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
10
11
  "file", "code", "function", "class", "method", "find", "get", "look",
11
12
  ]);
13
+ const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
14
+ const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
15
+ const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
16
+ const MAX_ALTERNATIVES = 3;
12
17
 
13
18
  export function tokenizeQuery(query) {
14
- if (!isString(query) || !query.trim()) {
15
- return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
16
- }
17
-
18
- const raw = query
19
- .replace(/([a-z])([A-Z])/g, "$1 $2")
20
- .toLowerCase()
21
- .split(/[^a-zA-Z0-9_]+/);
22
-
23
- const tokens = raw.filter((t) => t.length > 1 && !STOP_WORDS.has(t));
24
- const queryLower = query.toLowerCase();
25
-
19
+ if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
20
+ const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
26
21
  return {
27
- tokens: [...new Set(tokens)],
28
- wantsTest: queryLower.includes("test") || queryLower.includes("spec"),
29
- wantsType: queryLower.includes("type") || queryLower.includes("interface") || queryLower.includes("schema"),
30
- wantsDoc: queryLower.includes("doc") || queryLower.includes("readme"),
22
+ tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
23
+ wantsTest: words.some(word => ["test", "tests", "testing", "spec", "specs"].includes(word)),
24
+ wantsType: words.some(word => ["type", "types", "interface", "interfaces", "schema", "schemas"].includes(word)),
25
+ wantsDoc: words.some(word => ["doc", "docs", "documentation", "readme"].includes(word)),
31
26
  };
32
27
  }
33
28
 
34
- const SOURCE_EXT = new Set([".ts", ".js", ".mjs", ".rs", ".py", ".go"]);
35
- const TYPED_EXT = new Set([".ts", ".d.ts", ".rs", ".go"]);
36
- const VENDOR_SEGMENTS = ["node_modules/", "dist/", "target/"];
37
-
38
- function tokenPathScore(token, basename, pathParts, norm) {
39
- if (basename === token || basename.startsWith(token + ".")) return 60;
40
- if (basename.includes(token)) return 30;
41
- if (pathParts.includes(token)) return 15;
42
- if (norm.includes(token)) return 5;
43
- return 0;
44
- }
45
-
46
- function extensionBonus(ext, { wantsDoc, wantsType }) {
47
- let bonus = 0;
48
- if (SOURCE_EXT.has(ext) && !wantsDoc) bonus += 5;
49
- if (wantsType && TYPED_EXT.has(ext)) bonus += 10;
50
- return bonus;
51
- }
52
-
53
29
  export function scorePathTopology(filePath, tokens, flags) {
54
- const norm = filePath.replaceAll("\\", "/").toLowerCase();
55
- const isTest = norm.includes("test") || norm.includes("spec") || norm.includes("__tests__");
56
- if (isTest && !flags.wantsTest) return -50;
57
- if (!isTest && flags.wantsTest) return -20;
58
- if (VENDOR_SEGMENTS.some((segment) => norm.includes(segment))) return -100;
59
-
60
- const basename = path.basename(norm);
61
- const pathParts = norm.split(/[^a-zA-Z0-9]+/);
62
- let score = extensionBonus(path.extname(norm), flags);
63
- for (const token of tokens) score += tokenPathScore(token, basename, pathParts, norm);
64
- return score;
65
- }
66
-
67
- function isSkippableLine(lower) {
68
- return !lower || lower.startsWith("//") || lower.startsWith("#") || lower.startsWith("*");
69
- }
70
-
71
- /** A line defines a token only when the declared name contains it; `const x = foo(token)` is a mention. */
72
- function lineScoreFor(lower, tokens, definedName) {
73
- let lineScore = 0;
30
+ const normalized = filePath.replaceAll("\\", "/").toLowerCase();
31
+ const parts = normalized.split("/");
32
+ if (parts.some(part => ["node_modules", "dist", "target"].includes(part))) return -100;
33
+ const test = isTestPath(normalized);
34
+ if (test && !flags.wantsTest) return -50;
35
+ if (!test && flags.wantsTest) return -20;
36
+ const base = path.basename(normalized);
37
+ const words = normalized.split(/[^a-zA-Z0-9]+/);
38
+ const ext = path.extname(normalized);
39
+ let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
40
+ if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
74
41
  for (const token of tokens) {
75
- if (!lower.includes(token)) continue;
76
- lineScore += definedName.includes(token) ? 40 : 5;
42
+ if (base === token || base.startsWith(token + ".")) score += 60;
43
+ else if (base.includes(token)) score += 30;
44
+ else if (words.includes(token)) score += 15;
45
+ else if (normalized.includes(token)) score += 5;
77
46
  }
78
- return lineScore;
47
+ return score;
79
48
  }
80
49
 
81
- // Mentions are capped so a file that calls a symbol many times cannot outrank the file that defines it.
82
- const MAX_MENTION_SCORE = 60;
83
-
84
- function scoreContentDefinitions(entry, tokens) {
85
- const { lower, defNames } = WorkspaceIndex.linesOf(entry);
86
- let defScore = 0;
87
- let mentionScore = 0;
88
- let bestLine = 1;
89
- let bestLineScore = 0;
90
- for (let i = 0; i < lower.length; i++) {
91
- if (isSkippableLine(lower[i])) continue;
92
- const lineScore = lineScoreFor(lower[i], tokens, defNames[i]);
93
- if (lineScore > bestLineScore) {
94
- bestLineScore = lineScore;
95
- bestLine = i + 1;
50
+ function inScope(filePath, dir, includeHidden) {
51
+ const relative = path.relative(dir, filePath);
52
+ if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
53
+ const parts = relative.split(path.sep);
54
+ return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
55
+ }
56
+
57
+ function makeCandidate(filePath, dir, query, tokens, flags) {
58
+ const relative = path.relative(dir, filePath);
59
+ const lower = relative.toLowerCase();
60
+ const base = path.basename(lower);
61
+ const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
62
+ || base.slice(0, -path.extname(base).length) === query.toLowerCase();
63
+ return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
64
+ pathCoverage: tokens.filter(token => lower.includes(token)).length,
65
+ matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
66
+ line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1 };
67
+ }
68
+
69
+ function inspectLine(candidate, lineNumber, raw, query, tokens, isMatch) {
70
+ const text = raw.replace(/\r?\n$/, "");
71
+ const lower = text.toLowerCase();
72
+ if (isMatch) {
73
+ const matches = tokens.filter(token => lower.includes(token));
74
+ for (const token of matches) candidate.matched.add(token);
75
+ const ext = path.extname(candidate.path).toLowerCase();
76
+ const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
77
+ let declaration;
78
+ let definitionCoverage = 0;
79
+ let exact = false;
80
+ for (const item of items) {
81
+ const name = item.name.toLowerCase();
82
+ const itemExact = name === query.toLowerCase();
83
+ const coverage = tokens.filter(token => name.includes(token)).length;
84
+ if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
85
+ if (exact) break;
86
+ }
87
+ const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
88
+ if (score > candidate.anchorScore) {
89
+ candidate.anchorScore = score;
90
+ candidate.line = lineNumber;
91
+ candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
92
+ candidate.exactDefinition = exact;
93
+ candidate.definitionCoverage = definitionCoverage;
94
+ candidate.lineCoverage = matches.length;
95
+ candidate.context.clear();
96
+ for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
96
97
  }
97
- if (defNames[i]) defScore += lineScore;
98
- else mentionScore += lineScore;
99
- }
100
- return { totalScore: defScore + Math.min(mentionScore, MAX_MENTION_SCORE), bestLine, bestLineScore };
101
- }
102
-
103
- function relativeHasSegment(relativePath, segmentName) {
104
- return relativePath.split(path.sep).includes(segmentName);
105
- }
106
-
107
- function relativeHasHiddenSegment(relativePath) {
108
- return relativePath.split(path.sep).some((segment) => segment.startsWith(".") && segment.length > 1);
109
- }
110
-
111
- async function listCandidateFiles(dir, includeHidden, index) {
112
- return (await index.files(dir, includeHidden)).slice();
113
- }
114
-
115
- function mergePendingPaths(fileList, pendingPaths, dir, includeHidden = false) {
116
- const resolvedDir = path.resolve(dir);
117
- const seenPaths = new Set(fileList.map((filePath) => path.resolve(filePath)));
118
- for (const pendingPath of pendingPaths) {
119
- const absolutePath = path.resolve(pendingPath);
120
- const relativePath = path.relative(resolvedDir, absolutePath);
121
- const escapesDir = relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath);
122
- const hiddenRelativePath = relativeHasHiddenSegment(relativePath);
123
- if (escapesDir || relativeHasSegment(relativePath, ".git") || (!includeHidden && hiddenRelativePath) || seenPaths.has(absolutePath)) continue;
124
- seenPaths.add(absolutePath);
125
- fileList.push(absolutePath);
126
- }
127
- return fileList;
128
- }
129
-
130
- function mergeGrepHits(candidates, grepHits) {
131
- const seen = new Set(candidates);
132
- for (const h of grepHits) {
133
- if (seen.has(h)) continue;
134
- seen.add(h);
135
- candidates.push(h);
136
- if (candidates.length >= 15) break;
137
98
  }
138
- return candidates;
139
- }
140
-
141
- function expandCandidatesWithGrep(candidates, fileList, tokens, flags, index) {
142
- if (candidates.length >= 5) return candidates;
143
- const salient = tokens.filter((t) => t.length > 2).slice(0, 4);
144
- const scope = flags.wantsTest ? fileList : fileList.filter((f) => !isTestPath(f));
145
- const hits = index.filesContaining(scope, salient, true);
146
- mergeGrepHits(candidates, hits);
147
- if (candidates.length === 0) return fileList.slice(0, 5);
148
- return candidates;
149
- }
150
-
151
- function scoreSurfaceItems(items, tokens, fallbackLine) {
152
- let bonus = 0;
153
- let best = null;
154
- let bestMatches = 0;
155
- for (const item of items) {
156
- const nameLower = item.name.toLowerCase();
157
- const matches = tokens.filter((token) => nameLower.includes(token)).length;
158
- if (matches === 0) continue;
159
- bonus += matches * (item.isExport ? 80 : 50);
160
- if (matches > bestMatches) {
161
- bestMatches = matches;
162
- best = item;
99
+ const excerpt = truncateChars(text, 240, "source line").text;
100
+ if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
101
+ candidate.recent.push([lineNumber, excerpt]);
102
+ if (candidate.recent.length > 2) candidate.recent.shift();
103
+ }
104
+
105
+ function inspectOverlay(candidate, text, needles, query, tokens) {
106
+ const lines = text.split("\n");
107
+ const matches = [];
108
+ for (let i = 0; i < lines.length; i++) if (needles.some(needle => lines[i].toLowerCase().includes(needle))) matches.push(i);
109
+ for (const i of matches) inspectLine(candidate, i + 1, lines[i], query, tokens, true);
110
+ candidate.context.clear();
111
+ 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);
112
+ }
113
+
114
+ async function contentCandidates({ dir, includeHidden, query, tokens, flags, fileSet, index, overlayText, signal, exact, diskFiles }) {
115
+ const needles = exact ? [query.toLowerCase()] : tokens;
116
+ const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
117
+ if (includeHidden) args.push("--hidden");
118
+ args.push("-g", "!.git/**", "-g", "!**/.git/**");
119
+ for (const needle of needles) args.push("-e", needle);
120
+ args.push("--", dir);
121
+ const response = diskFiles ? await index.runCommand(args, { cwd: dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
122
+ : { stdout: "", stderr: "", exitCode: 1 };
123
+ if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
124
+ const candidates = new Map();
125
+ const records = response.stdout.split("\n");
126
+ for (let i = 0; i < records.length; i++) {
127
+ if (!records[i]) continue;
128
+ let record;
129
+ try { record = JSON.parse(records[i]); } catch (error) {
130
+ if (response.outputTruncated && i === records.length - 1) break;
131
+ throw error;
163
132
  }
133
+ if (record.type !== "match" && record.type !== "context") continue;
134
+ const data = record.data;
135
+ if (!data?.path?.text || !isString(data.lines?.text)) continue;
136
+ const filePath = path.resolve(dir, data.path.text);
137
+ if (!fileSet.has(filePath) || overlayText(filePath) !== undefined) continue;
138
+ let candidate = candidates.get(filePath);
139
+ if (!candidate) {
140
+ candidate = makeCandidate(filePath, dir, query, tokens, flags);
141
+ candidates.set(filePath, candidate);
142
+ }
143
+ inspectLine(candidate, data.line_number, data.lines.text, query, tokens, record.type === "match");
164
144
  }
165
- return { bonus, signature: best?.signature ?? "", anchorLine: best?.line ?? fallbackLine };
166
- }
167
-
168
- function scoreCandidateContents(candidates, tokens, flags, index, overlayText) {
169
- const candidateScores = [];
170
- for (const filePath of candidates) {
145
+ for (const filePath of fileSet) {
171
146
  const pending = overlayText(filePath);
172
- const entry = pending === undefined ? index.entry(filePath) : WorkspaceIndex.fromText(filePath, pending);
173
- if (!entry) continue;
174
- const content = entry.text;
175
- const { totalScore, bestLine, bestLineScore } = scoreContentDefinitions(entry, tokens);
176
- const surface = WorkspaceIndex.surfaceOf(entry);
177
- const { bonus: surfaceBonus, signature, anchorLine } = scoreSurfaceItems(surface.items, tokens, bestLine);
178
- const lowerPath = filePath.toLowerCase();
179
- const isTestFile = lowerPath.includes("test") || lowerPath.includes("spec");
180
- const testAdjustment = !isTestFile ? 0 : (flags.wantsTest ? 100 : -200);
181
- candidateScores.push({
182
- path: filePath,
183
- score: totalScore + surfaceBonus + (bestLineScore * 2) + testAdjustment,
184
- anchorLine,
185
- signature,
186
- content,
187
- });
147
+ if (pending === undefined) continue;
148
+ const candidate = makeCandidate(filePath, dir, query, tokens, flags);
149
+ inspectOverlay(candidate, pending, needles, query, tokens);
150
+ if (candidate.matched.size) candidates.set(filePath, candidate);
188
151
  }
189
- candidateScores.sort((a, b) => b.score - a.score);
190
- return candidateScores;
152
+ return { candidates, truncated: response.outputTruncated === true };
191
153
  }
192
154
 
193
- function rankCandidates(fileList, tokens, flags, index, overlayText) {
194
- const scoredPaths = [];
195
- for (const f of fileList) {
196
- const score = scorePathTopology(f, tokens, flags);
197
- if (score > 0) scoredPaths.push({ path: f, score });
198
- }
199
- scoredPaths.sort((a, b) => b.score - a.score);
200
- const selected = scoredPaths.filter((p) => p.score >= 25).slice(0, 10).map((p) => p.path);
201
- const candidates = expandCandidatesWithGrep(selected, fileList, tokens, flags, index);
202
- const candidateScores = scoreCandidateContents(candidates, tokens, flags, index, overlayText);
203
- return { candidates, candidateScores };
155
+ function rankScore(candidate, tokenCount) {
156
+ return (candidate.exactDefinition ? 10000 : 0) + (candidate.exactPath ? 500 : 0)
157
+ + candidate.definitionCoverage / tokenCount * 100 + candidate.matched.size / tokenCount * 30
158
+ + candidate.pathCoverage / tokenCount * 20 + candidate.lineCoverage / tokenCount * 10
159
+ + Math.max(-40, Math.min(20, candidate.pathScore / 5));
204
160
  }
205
161
 
206
- function buildSnapResult(candidates, candidateScores, fileList, root) {
207
- const relative = (p) => path.relative(root, p) || p;
208
- if (candidateScores.length === 0 || candidateScores[0].score <= 0) {
209
- return { path: relative(candidates[0] || fileList[0]), line: 1, signature: "", confidence: 0.3, context: [] };
210
- }
211
- const best = candidateScores[0];
212
- const lines = best.content.split("\n");
213
- // Two lines before and four after: enough to confirm the hit; read() is the tool for more.
214
- const startLine = Math.max(1, best.anchorLine - 2);
215
- const endLine = Math.min(lines.length, best.anchorLine + 4);
216
- const context = [];
217
- for (let l = startLine; l <= endLine; l++) {
218
- const marker = l === best.anchorLine ? "►" : " ";
219
- context.push(marker + l + " " + lines[l - 1]);
220
- }
221
- const confidence = Math.min(0.98, Math.max(0.65, best.score / 150));
222
- return {
223
- path: relative(best.path),
224
- line: best.anchorLine,
225
- signature: best.signature,
226
- confidence: Number(confidence.toFixed(2)),
227
- context,
228
- };
229
- }
230
-
231
- export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [] }) {
232
- const { tokens, wantsTest, wantsType, wantsDoc } = tokenizeQuery(query);
233
- if (tokens.length === 0) {
234
- throw new Error("snap requires at least one searchable concept keyword");
235
- }
236
- const dir = searchDir || process.cwd();
237
- if (path.resolve(dir).split(path.sep).includes(".git")) {
238
- throw new Error("snap cannot search Git metadata");
162
+ function location(candidate, root, index, overlayText) {
163
+ let context = candidate.context;
164
+ if (context.size === 0) {
165
+ const pending = overlayText(candidate.path);
166
+ const entry = pending === undefined ? index.entry(candidate.path) : WorkspaceIndex.fromText(candidate.path, pending);
167
+ const lines = entry?.text.split("\n") ?? [];
168
+ context = new Map(lines.slice(0, 7).map((line, i) => [i + 1, truncateChars(line, 240, "source line").text]));
239
169
  }
240
- const fileList = await listCandidateFiles(dir, includeHidden, index);
241
- mergePendingPaths(fileList, pendingPaths, dir, includeHidden);
242
- if (fileList.length === 0) {
243
- throw new Error(`no files found to search in ${dir}`);
170
+ return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
171
+ context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
172
+ }
173
+
174
+ export async function executeSnap({ query, searchDir, root, includeHidden = false, index, overlayText = () => undefined, pendingPaths = [], signal }) {
175
+ const flags = tokenizeQuery(query);
176
+ const { tokens } = flags;
177
+ if (tokens.length === 0) throw new Error("read requires a file path or a searchable source question");
178
+ if (tokens.length > 16) throw new Error("source question is too broad; use at most 16 keywords");
179
+ query = query.trim();
180
+ const dir = path.resolve(searchDir || process.cwd());
181
+ if (dir.split(path.sep).includes(".git")) throw new Error("cannot search Git metadata");
182
+ signal?.throwIfAborted();
183
+ flags.wantsTest ||= isTestPath(path.relative(root ?? dir, dir));
184
+ const files = await index.files(dir, includeHidden, signal);
185
+ const fileSet = new Set(files.filter(file => inScope(file, dir, includeHidden)));
186
+ for (const pending of pendingPaths) if (inScope(pending, dir, includeHidden)) fileSet.add(path.resolve(pending));
187
+ const listing = index.lists.get(dir + "\0" + (includeHidden ? "h" : ""));
188
+ if (listing?.error && !(listing.missing && fileSet.size)) throw new Error("source file listing failed: " + listing.error);
189
+ const empty = { path: null, line: null, signature: "", confidence: 0, context: [] };
190
+ if (fileSet.size === 0 && !listing?.truncated) return { ...empty, status: "not_found" };
191
+ const exact = /^[a-zA-Z_$][\w$]*$/.test(query);
192
+ const search = await contentCandidates({ dir, includeHidden, query, tokens, flags, fileSet, index, overlayText, signal, exact, diskFiles: files.length });
193
+ for (const filePath of fileSet) {
194
+ if (search.candidates.has(filePath)) continue;
195
+ const relative = path.relative(dir, filePath).toLowerCase();
196
+ if (!tokens.some(token => relative.includes(token))) continue;
197
+ if (exact && tokens.length > 1 && !relative.includes(query.toLowerCase())) continue;
198
+ const candidate = makeCandidate(filePath, dir, query, tokens, flags);
199
+ if (candidate.pathScore > 0) search.candidates.set(filePath, candidate);
244
200
  }
245
- const flags = { wantsTest, wantsDoc, wantsType };
246
- const { candidates, candidateScores } = rankCandidates(fileList, tokens, flags, index, overlayText);
247
- return buildSnapResult(candidates, candidateScores, fileList, root ?? dir);
201
+ const ranked = [...search.candidates.values()].filter(candidate => candidate.pathScore > -50)
202
+ .map(candidate => ({ ...candidate, score: rankScore(candidate, tokens.length) }))
203
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
204
+ const incomplete = search.truncated || listing?.truncated === true;
205
+ const relativeRoot = root ?? dir;
206
+ const candidates = ranked.slice(0, MAX_ALTERNATIVES).map(candidate => location(candidate, relativeRoot, index, overlayText));
207
+ if (incomplete) return { ...empty, status: "incomplete", candidates, message: "Search output exceeded its budget. Narrow the directory with read(path, {about: question})." };
208
+ if (!ranked.length) return { ...empty, status: "not_found" };
209
+ const best = ranked[0];
210
+ const second = ranked[1];
211
+ const margin = second ? (best.score - second.score) / Math.max(1, best.score) : 1;
212
+ const coverage = Math.max(best.matched.size, best.pathCoverage) / tokens.length;
213
+ const uniqueExact = best.exactDefinition && !second?.exactDefinition || best.exactPath && !second?.exactPath && !second?.exactDefinition;
214
+ if (!uniqueExact && (coverage < 0.6 || margin < 0.15 || best.definitionCoverage / tokens.length < 0.5)) return { ...empty, status: "ambiguous", candidates };
215
+ const confidence = uniqueExact ? 0.95 : Math.min(0.85, 0.5 + coverage * 0.2 + margin * 0.15);
216
+ return { ...candidates[0], status: "found", confidence: Number(confidence.toFixed(2)) };
248
217
  }