fix: stop silent fuzzy symbol substitution (#1473) (#1809)

Adapt @uvmplus's PR #1481 (3ecf7479) to the shared symbol lookup and
named-symbol flow resolver on current main. Missing names return not found
with suggestions, and exact matches with no callers stay empty.

Preserve #1512 definition grouping and --file narrowing, #173 qualified
misses, and codegraph_node's intentional fuzzy file lookup. Port the
upstream regression suite and cover the moved shared lookup paths.

Validation on Linux with Node 22: project build, 74 requested tests,
47 related flow tests, and 16 same-fixture CLI/MCP checks pass. Baseline
captured 12 failing tests and 13 failing fixture checks.

Fixes #1473.
Supersedes #1481.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 19:40:48 -05:00
committed by GitHub
co-authored by Colby McHenry
parent d3f9ef9bef
commit aed046e5c6
8 changed files with 306 additions and 33 deletions
+40 -21
View File
@@ -44,6 +44,10 @@ export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-l
/**
* 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.
*/
export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
// Nix option paths: the declaration is stored as `options.<path>` and
@@ -66,42 +70,57 @@ export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[];
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) => 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 => 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 isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
const ranked = [...exactMatches].sort((a, b) => {
const aGen = isGen(a.node.filePath) ? 1 : 0;
const bGen = isGen(b.node.filePath) ? 1 : 0;
const isGen = cg.generatedFilePredicate(exactNodes.map((n) => n.filePath));
const ranked = [...exactNodes].sort((a, b) => {
const aGen = isGen(a.filePath) ? 1 : 0;
const bGen = isGen(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 };
}
/** Node kinds that can sit on a call chain. */