fix: stop callers/callees/impact from silently substituting fuzzy hits (#1473)

Exact-name only for CLI and MCP; missing names get not-found + did-you-mean instead of another symbol's results.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhangtianshuo
2026-07-30 16:35:30 +08:00
parent 572d22bfbe
commit 3ecf7479a1
4 changed files with 297 additions and 66 deletions
+35 -43
View File
@@ -356,6 +356,26 @@ function warn(message: string): void {
console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
}
/**
* Exact-name match for CLI callers/callees/impact (#1473).
* A single fuzzy FTS hit must NOT count as exact — that was the silent
* substitution path when `matches.length === 1`.
*/
function isCliExactSymbolMatch(nodeName: string, symbol: string): boolean {
return (
nodeName === symbol ||
nodeName.endsWith(`.${symbol}`) ||
nodeName.endsWith(`::${symbol}`)
);
}
/** "not found" (+ optional did-you-mean) when no exact symbol matches. */
function formatSymbolNotFound(symbol: string, fuzzyNames: string[]): string {
const suggestions = [...new Set(fuzzyNames.filter((n) => n !== symbol))].slice(0, 3);
if (suggestions.length === 0) return `Symbol "${symbol}" not found`;
return `Symbol "${symbol}" not found — did you mean: ${suggestions.join(', ')}?`;
}
type IndexResult = {
success: boolean;
filesIndexed: number;
@@ -1850,8 +1870,10 @@ program
const limit = parseInt(options.limit || '20', 10);
const matches = cg.searchNodes(symbol, { limit: 50 });
if (matches.length === 0) {
info(`Symbol "${symbol}" not found`);
const exact = matches.filter((m) => isCliExactSymbolMatch(m.node.name, symbol));
if (exact.length === 0) {
// #1473: never answer for a fuzzy hit under the typed name
info(formatSymbolNotFound(symbol, matches.map((m) => m.node.name)));
cg.destroy();
return;
}
@@ -1859,9 +1881,7 @@ program
const seen = new Set<string>();
const allCallers: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
if (!exactMatch && matches.length > 1) continue;
for (const match of exact) {
for (const c of cg.getCallers(match.node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
@@ -1870,16 +1890,6 @@ program
}
}
// Fallback: if exact filter removed everything, use the top match
if (allCallers.length === 0 && matches[0]) {
for (const c of cg.getCallers(matches[0].node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallers.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
}
}
}
const limited = allCallers.slice(0, limit);
if (options.json) {
@@ -1929,8 +1939,10 @@ program
const limit = parseInt(options.limit || '20', 10);
const matches = cg.searchNodes(symbol, { limit: 50 });
if (matches.length === 0) {
info(`Symbol "${symbol}" not found`);
const exact = matches.filter((m) => isCliExactSymbolMatch(m.node.name, symbol));
if (exact.length === 0) {
// #1473: never answer for a fuzzy hit under the typed name
info(formatSymbolNotFound(symbol, matches.map((m) => m.node.name)));
cg.destroy();
return;
}
@@ -1938,9 +1950,7 @@ program
const seen = new Set<string>();
const allCallees: Array<{ name: string; kind: string; filePath: string; startLine?: number }> = [];
for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
if (!exactMatch && matches.length > 1) continue;
for (const match of exact) {
for (const c of cg.getCallees(match.node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
@@ -1949,15 +1959,6 @@ program
}
}
if (allCallees.length === 0 && matches[0]) {
for (const c of cg.getCallees(matches[0].node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallees.push({ name: c.node.name, kind: c.node.kind, filePath: c.node.filePath, startLine: c.node.startLine });
}
}
}
const limited = allCallees.slice(0, limit);
if (options.json) {
@@ -2007,8 +2008,10 @@ program
const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
const matches = cg.searchNodes(symbol, { limit: 50 });
if (matches.length === 0) {
info(`Symbol "${symbol}" not found`);
const exact = matches.filter((m) => isCliExactSymbolMatch(m.node.name, symbol));
if (exact.length === 0) {
// #1473: never answer for a fuzzy hit under the typed name
info(formatSymbolNotFound(symbol, matches.map((m) => m.node.name)));
cg.destroy();
return;
}
@@ -2018,9 +2021,7 @@ program
const seenEdges = new Set<string>();
let edgeCount = 0;
for (const match of matches) {
const exactMatch = match.node.name === symbol || match.node.name.endsWith(`.${symbol}`) || match.node.name.endsWith(`::${symbol}`);
if (!exactMatch && matches.length > 1) continue;
for (const match of exact) {
const impact = cg.getImpactRadius(match.node.id, depth);
for (const [id, n] of impact.nodes) {
mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine });
@@ -2034,15 +2035,6 @@ program
}
}
// Fallback to top match if exact filter removed everything
if (mergedNodes.size === 0 && matches[0]) {
const impact = cg.getImpactRadius(matches[0].node.id, depth);
for (const [id, n] of impact.nodes) {
mergedNodes.set(id, { name: n.name, kind: n.kind, filePath: n.filePath, startLine: n.startLine });
}
edgeCount = impact.edges.length;
}
if (options.json) {
console.log(JSON.stringify({
symbol,
+42 -23
View File
@@ -1579,7 +1579,7 @@ export class ToolHandler {
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
return this.textResult(`Symbol "${symbol}" not found in the codebase${allMatches.note}`);
}
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
@@ -1652,7 +1652,7 @@ export class ToolHandler {
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
return this.textResult(`Symbol "${symbol}" not found in the codebase${allMatches.note}`);
}
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
@@ -1722,7 +1722,7 @@ export class ToolHandler {
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
return this.textResult(`Symbol "${symbol}" not found in the codebase${allMatches.note}`);
}
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
@@ -4494,6 +4494,10 @@ export class ToolHandler {
/**
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
* results across all matching symbols (e.g., multiple classes with an `execute` method).
*
* Exact matches only (#1473): a missing / mistyped name must NOT silently
* resolve to the top fuzzy FTS hit under the caller's typed label. Closest
* hits may appear in `note` as a did-you-mean hint when `nodes` is empty.
*/
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
// Nix option paths: the declaration is stored as `options.<path>` and
@@ -4516,41 +4520,56 @@ export class ToolHandler {
return { nodes, note: '' };
}
}
let results = cg.searchNodes(symbol, { limit: 50 });
// Mirror the fallback in `findSymbol` for qualified queries — FTS
// strips colons, so a module-qualified lookup needs a second pass
// by the bare last part.
if (results.length === 0 && /[.\/]|::/.test(symbol)) {
const tail = lastQualifierPart(symbol);
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
const isQualified = /[.\/]|::/.test(symbol);
let exactNodes: Node[];
if (!isQualified) {
// Direct index — every exact-name overload, case-sensitive. Avoids FTS
// ranking a differently-cased sibling above the real node (#1473 Fetch).
exactNodes = cg.getNodesByName(symbol);
} else {
let results = cg.searchNodes(symbol, { limit: 50 });
// Mirror findSymbolMatches — FTS strips colons, so re-search by bare tail.
if (results.length === 0) {
const tail = lastQualifierPart(symbol);
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
}
exactNodes = results
.filter((r) => this.matchesSymbol(r.node, symbol))
.map((r) => r.node);
}
if (results.length === 0) {
return { nodes: [], note: '' };
if (exactNodes.length === 0) {
const fuzzy = cg.searchNodes(symbol, { limit: 5 });
const suggestions = [
...new Set(fuzzy.map((r) => r.node.name).filter((n) => n !== symbol)),
].slice(0, 3);
const note =
suggestions.length > 0
? `\n\n> **Note:** no symbol named "${symbol}". Did you mean: ${suggestions.join(', ')}?`
: '';
return { nodes: [], note };
}
const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
if (exactMatches.length <= 1) {
const node = exactMatches[0]?.node ?? results[0]!.node;
return { nodes: [node], note: '' };
if (exactNodes.length === 1) {
return { nodes: exactNodes, note: '' };
}
// Same generated-file down-rank as findSymbol — keeps callers/callees
// /impact aggregation aligned (a query against "Send" returns the
// hand-written implementations before the protobuf scaffold).
const ranked = [...exactMatches].sort((a, b) => {
const aGen = isGeneratedFile(a.node.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.node.filePath) ? 1 : 0;
const ranked = [...exactNodes].sort((a, b) => {
const aGen = isGeneratedFile(a.filePath) ? 1 : 0;
const bGen = isGeneratedFile(b.filePath) ? 1 : 0;
return aGen - bGen;
});
const locations = ranked.map(r =>
`${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
const locations = ranked.map(
(n) => `${n.kind} at ${n.filePath}:${n.startLine}`
);
const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
return { nodes: ranked.map(r => r.node), note };
return { nodes: ranked, note };
}
/**