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:
co-authored by
Colby McHenry
parent
d3f9ef9bef
commit
aed046e5c6
@@ -365,6 +365,13 @@ function warn(message: string): void {
|
||||
console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
|
||||
}
|
||||
|
||||
/** "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(', ')}?`;
|
||||
}
|
||||
|
||||
/** Compact node shape retained by the CLI's existing JSON lists. */
|
||||
function cliNode(node: Node) {
|
||||
return { name: node.name, kind: node.kind, filePath: node.filePath, startLine: node.startLine };
|
||||
@@ -2212,7 +2219,7 @@ for (const direction of ['callers', 'callees'] as const) {
|
||||
const limit = parseInt(options.limit || '20', 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol);
|
||||
if (targets.length === 0) {
|
||||
info(`Symbol "${symbol}" not found`);
|
||||
info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2330,7 +2337,7 @@ program
|
||||
const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol);
|
||||
if (targets.length === 0) {
|
||||
info(`Symbol "${symbol}" not found`);
|
||||
info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -167,8 +167,8 @@ export function groupDefinitions(
|
||||
* The exact-name index is consulted FIRST and is authoritative: it is complete
|
||||
* and uncapped, whereas FTS ranks and truncates, and tokenises away `::` — so
|
||||
* a qualified query could miss a symbol that exists, or land on whatever
|
||||
* happened to rank first. FTS remains as the fallback for the fuzzy cases it is
|
||||
* genuinely good at (file basenames, partial names).
|
||||
* happened to rank first. FTS candidates still have to satisfy the matcher;
|
||||
* partial or mistyped names must never select the top fuzzy hit (#1473).
|
||||
*/
|
||||
export function lookupSymbolNodes(cg: SymbolLookupHost, symbol: string): SymbolLookupResult {
|
||||
const qualified = isQualifiedSymbol(symbol);
|
||||
@@ -183,12 +183,9 @@ export function lookupSymbolNodes(cg: SymbolLookupHost, symbol: string): SymbolL
|
||||
const exact = hits.filter((n) => matchesSymbol(n, symbol));
|
||||
if (exact.length > 0) {
|
||||
nodes = exact;
|
||||
} else if (!qualified && hits[0]) {
|
||||
// A bare name with no exact definition may still mean a file basename.
|
||||
nodes = [hits[0]];
|
||||
}
|
||||
// A qualified query with no exact match resolves to NOTHING rather than a
|
||||
// misleading fuzzy hit (#173).
|
||||
// Any query with no exact match resolves to NOTHING rather than a
|
||||
// misleading fuzzy hit (#1473; qualified lookups already did this in #173).
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return { nodes: [], ambiguous: false };
|
||||
|
||||
@@ -56,6 +56,7 @@ calls; a grep/read exploration is dozens.
|
||||
- **Reading or editing a file/symbol you can name** → put its name or file path in the \`codegraph_explore\` query — it returns that current line-numbered source (safe to \`Edit\` from) with the call path and blast radius attached, so you don't Read it separately. For an overloaded name it returns every matching definition's body in one call.
|
||||
- **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. Suggested call counts are advisory only, NOT a quota; extra calls are never rejected or rate-limited.
|
||||
- Qualified symbol names accept dots, \`::\`, or slashes, including containers whose names contain dots (for example, \`AppWeb.Format.group\`).
|
||||
- Named-symbol call paths require exact matches; partial or mistyped names are never silently substituted as flow endpoints. If a graph query reports a missing symbol with did-you-mean suggestions, query the suggested name explicitly.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
|
||||
+3
-3
@@ -2375,7 +2375,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);
|
||||
@@ -2456,7 +2456,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);
|
||||
@@ -2534,7 +2534,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);
|
||||
|
||||
Reference in New Issue
Block a user