* fix(cli): port upstream symbol lookup consistency (#1656, #1512) Port ferrine/fix/symbol-lookup-consistency at c0ccbacd3f52007b65ce5b9599fa7a086501ac39 onto current main. Qualified CLI queries use the shared matcher and ambiguous names disclose their targets. Keep total/limit/truncated and the human truncation notice from #1674, and share the matcher with main's named-symbol-flow module. Refs #1512. Upstream PR: #1656. Co-authored-by: ferres <justferres@yandex.ru> * fix(cli): group traversal results by definition (#1512) Extend upstream PR #1656, ported in 7038fb4f, so callers/callees/impact show separate sections for each definition and accept --file using the same groupDefinitions helper as MCP. Preserve same-file overload groups, path/suffix matching, and the explicit fallback when no file matches. JSON definitions carry their roots, own neighbors/affected nodes, and edges. Retain the legacy top-level lists as an explicitly labeled union and preserve #1674 total/limit/truncated; each callers/callees definition also reports its own limit and truncation metadata. Validation: npm run build; 141 tests across 14 targeted suites, including 32 CLI regression tests. The Linux /workspace/cg1512-repro failure now passes for all three commands, with and without --file. Fixes #1512. Upstream PR: #1656 (ferrine/fix/symbol-lookup-consistency @ c0ccbacd). --------- Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com> Co-authored-by: ferres <justferres@yandex.ru>
This commit is contained in:
co-authored by
Colby McHenry
ferres
parent
71d049cd28
commit
8c9c4761b0
+217
-233
@@ -59,6 +59,8 @@ import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
||||
// server itself is loaded lazily inside the `ui` action. See ui-server/constants.
|
||||
import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
|
||||
import type { UiServerHandle } from '../ui-server';
|
||||
import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../graph/symbol-lookup';
|
||||
import type { Node, Edge } from '../types';
|
||||
|
||||
// Decided once, before `--color`/`--no-color` are stripped from argv below
|
||||
// (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output.
|
||||
@@ -362,6 +364,20 @@ function warn(message: string): void {
|
||||
console.log(chalk.yellow(getGlyphs().warn) + ' ' + message);
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
/** Attribute a group's edges to every overload of this definition. */
|
||||
function cliDefinition(group: Node[]) {
|
||||
const head = group[0]!;
|
||||
return {
|
||||
definition: { ...cliNode(head), id: head.id, qualifiedName: head.qualifiedName, language: head.language },
|
||||
roots: group.map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
type IndexResult = {
|
||||
success: boolean;
|
||||
filesIndexed: number;
|
||||
@@ -2149,184 +2165,140 @@ program
|
||||
});
|
||||
|
||||
/**
|
||||
* codegraph callers <symbol>
|
||||
*
|
||||
* CLI parity with the MCP graph tools (codegraph_callers/callees/impact) so the
|
||||
* traversal queries work in scripts, CI, and git hooks without a running MCP
|
||||
* server.
|
||||
* CLI parity with MCP callers/callees: resolve once, then collect and limit
|
||||
* within each definition. The legacy JSON list remains an explicitly labeled
|
||||
* union, with its original total/limit/truncated contract (#1674).
|
||||
*/
|
||||
program
|
||||
.command('callers <symbol>')
|
||||
.description('Find all functions/methods that call a specific symbol')
|
||||
.option('-p, --path <path>', 'Project path')
|
||||
.option('-l, --limit <number>', 'Maximum results', '20')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => {
|
||||
const projectPath = resolveProjectPath(options.path);
|
||||
for (const direction of ['callers', 'callees'] as const) {
|
||||
const title = direction === 'callers' ? 'Callers' : 'Callees';
|
||||
program
|
||||
.command(`${direction} <symbol>`)
|
||||
.description(direction === 'callers'
|
||||
? 'Find all functions/methods that call a specific symbol'
|
||||
: 'Find all functions/methods called by a specific symbol')
|
||||
.option('-p, --path <path>', 'Project path')
|
||||
.option('-f, --file <path>', 'Narrow definitions by file path or suffix (no match: show all with a note)')
|
||||
.option('-l, --limit <number>', 'Maximum results per definition (also caps the JSON union)', '20')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (symbol: string, options: { path?: string; file?: string; limit?: string; json?: boolean }) => {
|
||||
const projectPath = resolveProjectPath(options.path);
|
||||
|
||||
try {
|
||||
if (!isInitialized(projectPath)) {
|
||||
error(`CodeGraph not initialized in ${projectPath}`);
|
||||
try {
|
||||
if (!isInitialized(projectPath)) {
|
||||
error(`CodeGraph not initialized in ${projectPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
try {
|
||||
const limit = parseInt(options.limit || '20', 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol);
|
||||
if (targets.length === 0) {
|
||||
info(`Symbol "${symbol}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { groups, filteredOut } = groupDefinitions(targets, options.file);
|
||||
const ambiguous = groups.length > 1;
|
||||
const note = filteredOut
|
||||
? `no definition of "${symbol}" matches file "${options.file}" — showing all definitions instead.`
|
||||
: undefined;
|
||||
const collected = groups.map((group) => {
|
||||
const nodes = new Map<string, Node>();
|
||||
const edges = new Map<string, Edge>();
|
||||
for (const target of group) {
|
||||
const connections = direction === 'callers' ? cg.getCallers(target.id) : cg.getCallees(target.id);
|
||||
for (const { node, edge } of connections) {
|
||||
nodes.set(node.id, node);
|
||||
edges.set(`${edge.source}->${edge.target}:${edge.kind}`, edge);
|
||||
}
|
||||
}
|
||||
return { group, nodes: [...nodes.values()], edges: [...edges.values()] };
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
const definitions = collected.map(({ group, nodes, edges }) => {
|
||||
const limited = nodes.slice(0, limit);
|
||||
const shown = new Set(limited.map((node) => node.id));
|
||||
return {
|
||||
...cliDefinition(group),
|
||||
[direction]: limited.map((node) => ({ id: node.id, ...cliNode(node) })),
|
||||
edges: edges.filter((edge) => shown.has(direction === 'callers' ? edge.source : edge.target)),
|
||||
total: nodes.length,
|
||||
limit,
|
||||
truncated: nodes.length > limit,
|
||||
};
|
||||
});
|
||||
const union = new Map<string, Node>();
|
||||
for (const { nodes } of collected) {
|
||||
for (const node of nodes) union.set(node.id, node);
|
||||
}
|
||||
const total = union.size;
|
||||
console.log(JSON.stringify({
|
||||
symbol,
|
||||
targets: groups.flat().map((node) => cliDefinition([node]).definition),
|
||||
ambiguous,
|
||||
aggregation: ambiguous ? 'union' : 'definition',
|
||||
file: options.file,
|
||||
filteredOut,
|
||||
note,
|
||||
definitions,
|
||||
[direction]: [...union.values()].slice(0, limit).map(cliNode),
|
||||
total,
|
||||
limit,
|
||||
truncated: total > limit,
|
||||
}, null, 2));
|
||||
} else {
|
||||
if (note) warn(note);
|
||||
if (ambiguous) {
|
||||
console.log(chalk.bold(`\n${title} of "${symbol}" — ${groups.length} distinct definitions (narrow with --file):`));
|
||||
}
|
||||
for (const { group, nodes } of collected) {
|
||||
const limited = nodes.slice(0, limit);
|
||||
const total = nodes.length;
|
||||
const truncated = total > limit;
|
||||
const count = truncated ? `${limited.length} of ${total}` : String(total);
|
||||
if (ambiguous) {
|
||||
console.log(chalk.bold(`\n${describeSymbolNode(group[0]!)} (${count}):\n`));
|
||||
} else {
|
||||
console.log(chalk.bold(`\n${title} of "${symbol}" (${count}):\n`));
|
||||
console.log(chalk.dim(describeSymbolNode(group[0]!)));
|
||||
}
|
||||
if (total === 0) {
|
||||
if (ambiguous) console.log(chalk.dim(` (no ${direction})`));
|
||||
else info(`No ${direction} found for "${symbol}"`);
|
||||
}
|
||||
for (const node of limited) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(chalk.cyan(node.kind.padEnd(12)) + chalk.white(node.name));
|
||||
console.log(chalk.dim(` ${node.filePath}${loc}`));
|
||||
console.log();
|
||||
}
|
||||
if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cg.destroy();
|
||||
}
|
||||
} catch (err) {
|
||||
error(`${direction} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const limit = parseInt(options.limit || '20', 10);
|
||||
|
||||
const matches = cg.searchNodes(symbol, { limit: 50 });
|
||||
if (matches.length === 0) {
|
||||
info(`Symbol "${symbol}" not found`);
|
||||
cg.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
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 c of cg.getCallers(match.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
const total = allCallers.length;
|
||||
const truncated = total > limit;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ symbol, callers: limited, total, limit, truncated }, null, 2));
|
||||
} else if (limited.length === 0) {
|
||||
info(`No callers found for "${symbol}"`);
|
||||
} else {
|
||||
const count = truncated ? `${limited.length} of ${total}` : String(total);
|
||||
console.log(chalk.bold(`\nCallers of "${symbol}" (${count}):\n`));
|
||||
for (const node of limited) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(
|
||||
chalk.cyan(node.kind.padEnd(12)) +
|
||||
chalk.white(node.name)
|
||||
);
|
||||
console.log(chalk.dim(` ${node.filePath}${loc}`));
|
||||
console.log();
|
||||
}
|
||||
if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
|
||||
}
|
||||
|
||||
cg.destroy();
|
||||
} catch (err) {
|
||||
error(`callers failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* codegraph callees <symbol>
|
||||
*/
|
||||
program
|
||||
.command('callees <symbol>')
|
||||
.description('Find all functions/methods that a specific symbol calls')
|
||||
.option('-p, --path <path>', 'Project path')
|
||||
.option('-l, --limit <number>', 'Maximum results', '20')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (symbol: string, options: { path?: string; limit?: string; json?: boolean }) => {
|
||||
const projectPath = resolveProjectPath(options.path);
|
||||
|
||||
try {
|
||||
if (!isInitialized(projectPath)) {
|
||||
error(`CodeGraph not initialized in ${projectPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const limit = parseInt(options.limit || '20', 10);
|
||||
|
||||
const matches = cg.searchNodes(symbol, { limit: 50 });
|
||||
if (matches.length === 0) {
|
||||
info(`Symbol "${symbol}" not found`);
|
||||
cg.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
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 c of cg.getCallees(match.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const total = allCallees.length;
|
||||
const truncated = total > limit;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ symbol, callees: limited, total, limit, truncated }, null, 2));
|
||||
} else if (limited.length === 0) {
|
||||
info(`No callees found for "${symbol}"`);
|
||||
} else {
|
||||
const count = truncated ? `${limited.length} of ${total}` : String(total);
|
||||
console.log(chalk.bold(`\nCallees of "${symbol}" (${count}):\n`));
|
||||
for (const node of limited) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(
|
||||
chalk.cyan(node.kind.padEnd(12)) +
|
||||
chalk.white(node.name)
|
||||
);
|
||||
console.log(chalk.dim(` ${node.filePath}${loc}`));
|
||||
console.log();
|
||||
}
|
||||
if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`));
|
||||
}
|
||||
|
||||
cg.destroy();
|
||||
} catch (err) {
|
||||
error(`callees failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* codegraph impact <symbol>
|
||||
* codegraph impact <symbol> — one blast radius per distinct definition.
|
||||
*/
|
||||
program
|
||||
.command('impact <symbol>')
|
||||
.description('Analyze what code is affected by changing a symbol')
|
||||
.option('-p, --path <path>', 'Project path')
|
||||
.option('-f, --file <path>', 'Narrow definitions by file path or suffix (no match: show all with a note)')
|
||||
.option('-d, --depth <number>', 'Traversal depth', '2')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (symbol: string, options: { path?: string; depth?: string; json?: boolean }) => {
|
||||
.action(async (symbol: string, options: { path?: string; file?: string; depth?: string; json?: boolean }) => {
|
||||
const projectPath = resolveProjectPath(options.path);
|
||||
|
||||
try {
|
||||
@@ -2337,77 +2309,89 @@ program
|
||||
|
||||
const { default: CodeGraph } = await loadCodeGraph();
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
|
||||
try {
|
||||
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`);
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = cg.searchNodes(symbol, { limit: 50 });
|
||||
if (matches.length === 0) {
|
||||
info(`Symbol "${symbol}" not found`);
|
||||
const { groups, filteredOut } = groupDefinitions(targets, options.file);
|
||||
const ambiguous = groups.length > 1;
|
||||
const note = filteredOut
|
||||
? `no definition of "${symbol}" matches file "${options.file}" — showing all definitions instead.`
|
||||
: undefined;
|
||||
const collected = groups.map((group) => {
|
||||
const nodes = new Map<string, Node>();
|
||||
const edges = new Map<string, Edge>();
|
||||
for (const target of group) {
|
||||
const impact = cg.getImpactRadius(target.id, depth);
|
||||
for (const [id, node] of impact.nodes) nodes.set(id, node);
|
||||
for (const edge of impact.edges) edges.set(`${edge.source}->${edge.target}:${edge.kind}`, edge);
|
||||
}
|
||||
return { group, nodes, edges };
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
const unionNodes = new Map<string, Node>();
|
||||
const unionEdges = new Map<string, Edge>();
|
||||
const definitions = collected.map(({ group, nodes, edges }) => {
|
||||
for (const [id, node] of nodes) unionNodes.set(id, node);
|
||||
for (const [key, edge] of edges) unionEdges.set(key, edge);
|
||||
return {
|
||||
...cliDefinition(group),
|
||||
nodeCount: nodes.size,
|
||||
edgeCount: edges.size,
|
||||
affected: [...nodes.values()].map((node) => ({ id: node.id, ...cliNode(node) })),
|
||||
edges: [...edges.values()],
|
||||
};
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
symbol,
|
||||
depth,
|
||||
targets: groups.flat().map((node) => cliDefinition([node]).definition),
|
||||
ambiguous,
|
||||
aggregation: ambiguous ? 'union' : 'definition',
|
||||
file: options.file,
|
||||
filteredOut,
|
||||
note,
|
||||
definitions,
|
||||
nodeCount: unionNodes.size,
|
||||
edgeCount: unionEdges.size,
|
||||
affected: [...unionNodes.values()].map(cliNode),
|
||||
}, null, 2));
|
||||
} else {
|
||||
if (note) warn(note);
|
||||
if (ambiguous) {
|
||||
console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${groups.length} distinct definitions (each with its own blast radius; narrow with --file):`));
|
||||
}
|
||||
for (const { group, nodes } of collected) {
|
||||
if (ambiguous) {
|
||||
console.log(chalk.bold(`\n${describeSymbolNode(group[0]!)} — ${nodes.size} affected symbols:\n`));
|
||||
} else {
|
||||
console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${nodes.size} affected symbols:\n`));
|
||||
console.log(chalk.dim(describeSymbolNode(group[0]!)));
|
||||
}
|
||||
const byFile = new Map<string, Node[]>();
|
||||
for (const node of nodes.values()) {
|
||||
const list = byFile.get(node.filePath) || [];
|
||||
list.push(node);
|
||||
byFile.set(node.filePath, list);
|
||||
}
|
||||
for (const [file, affected] of byFile) {
|
||||
console.log(chalk.cyan(file));
|
||||
for (const node of affected) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(` ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cg.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge impact subgraphs across all exact-matching symbols
|
||||
const mergedNodes = new Map<string, { name: string; kind: string; filePath: string; startLine?: number }>();
|
||||
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;
|
||||
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 });
|
||||
}
|
||||
for (const e of impact.edges) {
|
||||
const key = `${e.source}->${e.target}:${e.kind}`;
|
||||
if (!seenEdges.has(key)) {
|
||||
seenEdges.add(key);
|
||||
edgeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
depth,
|
||||
nodeCount: mergedNodes.size,
|
||||
edgeCount,
|
||||
affected: Array.from(mergedNodes.values()),
|
||||
}, null, 2));
|
||||
} else if (mergedNodes.size === 0) {
|
||||
info(`No affected symbols found for "${symbol}"`);
|
||||
} else {
|
||||
console.log(chalk.bold(`\nImpact of changing "${symbol}" — ${mergedNodes.size} affected symbols:\n`));
|
||||
|
||||
// Group by file
|
||||
const byFile = new Map<string, Array<{ name: string; kind: string; startLine?: number }>>();
|
||||
for (const node of mergedNodes.values()) {
|
||||
const list = byFile.get(node.filePath) || [];
|
||||
list.push({ name: node.name, kind: node.kind, startLine: node.startLine });
|
||||
byFile.set(node.filePath, list);
|
||||
}
|
||||
|
||||
for (const [file, nodes] of byFile) {
|
||||
console.log(chalk.cyan(file));
|
||||
for (const node of nodes) {
|
||||
const loc = node.startLine ? `:${node.startLine}` : '';
|
||||
console.log(` ${chalk.dim(node.kind.padEnd(12))}${node.name}${chalk.dim(loc)}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
cg.destroy();
|
||||
} catch (err) {
|
||||
error(`impact failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -36,93 +36,10 @@ import type CodeGraph from '../index';
|
||||
import type { Node, Edge } from '../types';
|
||||
import { isTestFile } from '../search/query-utils';
|
||||
|
||||
/**
|
||||
* Rust path roots that have no file-system equivalent — `crate` is the
|
||||
* current crate, `super` is the parent module, `self` is the current
|
||||
* module. Used by `matchesSymbol` to strip these before file-path
|
||||
* matching so `crate::configurator::stage_apply::run` resolves the
|
||||
* same as `configurator::stage_apply::run`.
|
||||
*/
|
||||
export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
|
||||
import { lastQualifierPart, matchesSymbol } from './symbol-lookup';
|
||||
|
||||
/**
|
||||
* Last `::` / `.` / `/`-separated segment of a qualified symbol. An Erlang
|
||||
* arity tail (`mod::fn/3`, `fn/3`) is stripped first — the useful last segment
|
||||
* is the function name, never the digits (#1610).
|
||||
*/
|
||||
export function lastQualifierPart(symbol: string): string {
|
||||
const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
|
||||
const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
return parts[parts.length - 1] ?? symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node matches a symbol query.
|
||||
*
|
||||
* Accepts simple names (`run`) and three flavors of qualifier:
|
||||
* - dotted `Session.request` (TS/JS/Python)
|
||||
* - colon-pair `stage_apply::run` (Rust, C++, Ruby)
|
||||
* - slash `configurator/stage_apply` (path-ish)
|
||||
*
|
||||
* Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
|
||||
* works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
|
||||
* the canonical `crate::module::symbol` form resolves.
|
||||
*
|
||||
* Resolution order, last part must always equal `node.name`:
|
||||
* 1. Suffix-match against `qualifiedName` (handles class-scoped methods
|
||||
* where the extractor builds the qualified name from the AST stack)
|
||||
* 2. File-path containment (handles file-derived modules in Rust/
|
||||
* Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
|
||||
*/
|
||||
export function matchesSymbol(node: Node, symbol: string): boolean {
|
||||
// Erlang arity spelling (`fn/3`, `mod:fn/3` → normalized `mod.fn/3`): when
|
||||
// the node's qualifiedName carries an arity (`mod::fn/3`, #1610), the
|
||||
// written arity must match it exactly; the remaining comparison then runs
|
||||
// on the arity-less spelling. A node with no arity in its qualifiedName
|
||||
// keeps the original symbol (a `/` there means a path-ish name instead).
|
||||
const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
|
||||
if (aritySpelling) {
|
||||
const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
|
||||
if (nodeArity !== undefined) {
|
||||
if (nodeArity !== aritySpelling[2]) return false;
|
||||
symbol = aritySpelling[1]!;
|
||||
}
|
||||
}
|
||||
// Simple name match
|
||||
if (node.name === symbol) return true;
|
||||
// File basename match (e.g., "product-card" matches "product-card.liquid")
|
||||
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
|
||||
|
||||
// Qualified-name lookups: split on any supported separator. `\w` keeps
|
||||
// identifier chars (incl. `_`) intact; everything else is treated as
|
||||
// a separator we tolerate.
|
||||
if (!/[.\/]|::/.test(symbol)) return false;
|
||||
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
if (parts.length < 2) return false;
|
||||
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
if (node.name !== lastPart) return false;
|
||||
|
||||
// Stage 1: qualified-name suffix match. The extractor joins the
|
||||
// semantic hierarchy with `::`, so `Session.request` and
|
||||
// `Session::request` both become `Session::request` here.
|
||||
const colonSuffix = parts.join('::');
|
||||
if (node.qualifiedName.includes(colonSuffix)) return true;
|
||||
|
||||
// Stage 2: file-path containment. Rust modules and Python packages
|
||||
// are not in `qualifiedName` — they're encoded in the file path. So
|
||||
// `stage_apply::run` matches a `run` in any file whose path
|
||||
// contains a `stage_apply` segment (with or without an extension).
|
||||
//
|
||||
// Filter out Rust path prefixes that have no file-system equivalent.
|
||||
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
|
||||
if (containerHints.length === 0) return false;
|
||||
|
||||
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
||||
return containerHints.every((hint) =>
|
||||
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
|
||||
);
|
||||
}
|
||||
// Preserve the existing imports while sharing the matcher with the CLI and MCP.
|
||||
export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-lookup';
|
||||
|
||||
/**
|
||||
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Symbol Lookup — the single "what did the user mean by this name?" path.
|
||||
*
|
||||
* Every verb that takes a symbol NAME from a human (or an agent) has to turn
|
||||
* that string into node(s). `codegraph_node` and `codegraph_explore` went
|
||||
* through the matcher below; the `callers` / `callees` / `impact` CLI verbs
|
||||
* carried their own ad-hoc filter instead:
|
||||
*
|
||||
* node.name === symbol || node.name.endsWith('.' + symbol)
|
||||
*
|
||||
* which compares the query against the BARE name only. That produced two
|
||||
* opposite failures in the same repository:
|
||||
*
|
||||
* - a bare name over-reported: `callers group` silently merged the callers of
|
||||
* every distinct symbol named `group` — in any language — into one list
|
||||
* headed "Callers of group", with nothing saying they were different
|
||||
* symbols;
|
||||
* - a qualified name under-reported: `Foo.Bar.baz` can never equal a bare
|
||||
* `baz`, so every candidate failed the filter and the code fell through to
|
||||
* an arbitrary top-of-FTS hit — or reported "not found" for a symbol that
|
||||
* plainly exists.
|
||||
*
|
||||
* Both are fixed by routing all of them through one resolver, which this module
|
||||
* owns so the CLI and the MCP tools cannot drift apart again.
|
||||
*/
|
||||
|
||||
import type { Node } from '../types';
|
||||
|
||||
/** Rust path prefixes that name no directory (`crate::x`, `super::y`). */
|
||||
export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
|
||||
|
||||
/** Does this query carry any scope qualifier at all? */
|
||||
export function isQualifiedSymbol(symbol: string): boolean {
|
||||
return /[.\/]|::/.test(symbol);
|
||||
}
|
||||
|
||||
/** The bare identifier at the end of a qualified query (arity spelling stripped). */
|
||||
export function lastQualifierPart(symbol: string): string {
|
||||
const noArity = symbol.replace(/\/\d{1,3}$/, '') || symbol;
|
||||
const parts = noArity.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
return parts[parts.length - 1] ?? symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite every scope separator to `.` so a query and a stored qualifiedName
|
||||
* written in different conventions can be compared directly. The extractors
|
||||
* join hierarchy with `::` while users type the language's own spelling
|
||||
* (`Session.request`, `stage_apply::run`, `pkg/mod.Fn`).
|
||||
*/
|
||||
function canonicalScope(text: string): string {
|
||||
return text.replace(/::/g, '.').replace(/\//g, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `node` satisfy the user's symbol query?
|
||||
*
|
||||
* Bare queries match the name. Qualified queries are checked against the
|
||||
* qualifiedName under both separator conventions, then — for languages whose
|
||||
* hierarchy lives in the file path rather than the name (Rust modules, Python
|
||||
* packages) — against the path.
|
||||
*/
|
||||
export function matchesSymbol(node: Node, symbol: string): boolean {
|
||||
// Erlang arity spelling (`fn/3`, `mod:fn/3`): when the node's qualifiedName
|
||||
// carries an arity (#1610) the written arity must match exactly, and the rest
|
||||
// of the comparison runs on the arity-less spelling. A node with no arity
|
||||
// keeps the original symbol (a `/` there means a path-ish name instead).
|
||||
const aritySpelling = /^(.+)\/(\d{1,3})$/.exec(symbol);
|
||||
if (aritySpelling) {
|
||||
const nodeArity = /\/(\d{1,3})$/.exec(node.qualifiedName ?? '')?.[1];
|
||||
if (nodeArity !== undefined) {
|
||||
if (nodeArity !== aritySpelling[2]) return false;
|
||||
symbol = aritySpelling[1]!;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.name === symbol) return true;
|
||||
// File basename match ("product-card" matches "product-card.liquid").
|
||||
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
|
||||
|
||||
if (!isQualifiedSymbol(symbol)) return false;
|
||||
const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
|
||||
if (parts.length < 2) return false;
|
||||
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
if (node.name !== lastPart) return false;
|
||||
|
||||
// Stage 1: qualified-name containment under the extractor's `::` convention.
|
||||
if (node.qualifiedName.includes(parts.join('::'))) return true;
|
||||
|
||||
// Stage 1b: boundary-aligned suffix under a canonical separator.
|
||||
//
|
||||
// Splitting on EVERY separator assumes no scope component contains one —
|
||||
// false for any language whose module names are themselves dotted (Elixir
|
||||
// `AppWeb.Format`, a Java/C# package, a Python dotted module). There the
|
||||
// stored qualifiedName is `AppWeb.Format::group`, so the stage-1 spelling
|
||||
// `AppWeb::Format::group` cannot match and a perfectly precise query
|
||||
// resolved to nothing. Canonicalising both sides and requiring the match to
|
||||
// land on a separator boundary handles both conventions with one rule, and
|
||||
// is strictly tighter than the `includes` above.
|
||||
const canonicalQuery = canonicalScope(symbol);
|
||||
const canonicalNode = canonicalScope(node.qualifiedName);
|
||||
if (canonicalNode === canonicalQuery || canonicalNode.endsWith(`.${canonicalQuery}`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stage 2: file-path containment. Rust modules and Python packages are not in
|
||||
// qualifiedName — they are encoded in the path — so `stage_apply::run`
|
||||
// matches a `run` in any file with a `stage_apply` path segment.
|
||||
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
|
||||
if (containerHints.length === 0) return false;
|
||||
const segments = node.filePath.split('/').filter((s) => s.length > 0);
|
||||
return containerHints.every((hint) =>
|
||||
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
|
||||
);
|
||||
}
|
||||
|
||||
/** The slice of CodeGraph a symbol lookup needs — keeps this module testable. */
|
||||
export interface SymbolLookupHost {
|
||||
getNodesByName(name: string): Node[];
|
||||
searchNodes(query: string, options?: { limit?: number }): Array<{ node: Node }>;
|
||||
generatedFilePredicate(paths: string[]): (path: string) => boolean;
|
||||
}
|
||||
|
||||
export interface SymbolLookupResult {
|
||||
/** Every definition the query names, keepers before generated stubs. */
|
||||
nodes: Node[];
|
||||
/**
|
||||
* The query named more than one distinct definition. Callers that aggregate
|
||||
* across all of them MUST surface this — an aggregate presented as one
|
||||
* symbol's answer is the over-reporting failure described at the top.
|
||||
*/
|
||||
ambiguous: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One group per (filePath, qualifiedName): same-file overloads stay together,
|
||||
* while unrelated definitions keep their own edges. Shared by CLI and MCP.
|
||||
* A non-matching file hint keeps all definitions and must be disclosed.
|
||||
*/
|
||||
export function groupDefinitions(
|
||||
nodes: Node[],
|
||||
fileFilter?: string
|
||||
): { groups: Node[][]; filteredOut: boolean } {
|
||||
let pool = nodes;
|
||||
let filteredOut = false;
|
||||
if (fileFilter) {
|
||||
const wanted = fileFilter.replace(/^\.\//, '');
|
||||
const narrowed = pool.filter(
|
||||
(n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`)
|
||||
);
|
||||
if (narrowed.length > 0) pool = narrowed;
|
||||
else filteredOut = true;
|
||||
}
|
||||
const byDef = new Map<string, Node[]>();
|
||||
for (const n of pool) {
|
||||
const key = `${n.filePath}|${n.qualifiedName}`;
|
||||
const group = byDef.get(key);
|
||||
if (group) group.push(n);
|
||||
else byDef.set(key, [n]);
|
||||
}
|
||||
return { groups: [...byDef.values()], filteredOut };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied symbol name to the definitions it names.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
export function lookupSymbolNodes(cg: SymbolLookupHost, symbol: string): SymbolLookupResult {
|
||||
const qualified = isQualifiedSymbol(symbol);
|
||||
|
||||
// Exact-name index, then filter by the qualifier the user actually wrote.
|
||||
const tail = qualified ? lastQualifierPart(symbol) : symbol;
|
||||
let nodes = tail ? cg.getNodesByName(tail) : [];
|
||||
if (qualified) nodes = nodes.filter((n) => matchesSymbol(n, symbol));
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const hits = cg.searchNodes(symbol, { limit: 50 }).map((h) => h.node);
|
||||
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).
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return { nodes: [], ambiguous: false };
|
||||
|
||||
// Keepers before generated stubs (.pb.go and friends), stable otherwise.
|
||||
const isGenerated = cg.generatedFilePredicate(nodes.map((n) => n.filePath));
|
||||
const ranked = [...nodes].sort(
|
||||
(a, b) => (isGenerated(a.filePath) ? 1 : 0) - (isGenerated(b.filePath) ? 1 : 0)
|
||||
);
|
||||
return { nodes: ranked, ambiguous: groupDefinitions(ranked).groups.length > 1 };
|
||||
}
|
||||
|
||||
/** One-line "kind at path:line" label used when disclosing an ambiguous query. */
|
||||
export function describeSymbolNode(node: Node): string {
|
||||
return `${node.kind} ${node.qualifiedName || node.name} (${node.language}) — ${node.filePath}:${node.startLine}`;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ calls; a grep/read exploration is dozens.
|
||||
- **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, riding dynamic-dispatch hops, and returns their source.
|
||||
- **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.
|
||||
- Qualified symbol names accept dots, \`::\`, or slashes, including containers whose names contain dots (for example, \`AppWeb.Format.group\`).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
|
||||
+3
-24
@@ -32,6 +32,7 @@ import {
|
||||
import type { PendingFile } from '../sync';
|
||||
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
|
||||
import { isTestFile, normalizeNameToken } from '../search/query-utils';
|
||||
import { groupDefinitions, lastQualifierPart, matchesSymbol } from '../graph/symbol-lookup';
|
||||
import { extractQueryPaths, queryMightContainPaths } from '../search/query-paths';
|
||||
import {
|
||||
existsSync,
|
||||
@@ -44,8 +45,6 @@ import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranc
|
||||
import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
|
||||
import { countImplementers } from '../graph/type-hierarchy';
|
||||
import {
|
||||
lastQualifierPart,
|
||||
matchesSymbol,
|
||||
findAllSymbols,
|
||||
resolveNamedSymbolFlow,
|
||||
} from '../graph/named-symbol-flow';
|
||||
@@ -2350,27 +2349,7 @@ export class ToolHandler {
|
||||
nodes: Node[],
|
||||
fileFilter: string | undefined
|
||||
): { groups: Node[][]; filteredOut: boolean } {
|
||||
let pool = nodes;
|
||||
let filteredOut = false;
|
||||
if (fileFilter) {
|
||||
const wanted = fileFilter.replace(/^\.\//, '');
|
||||
const narrowed = pool.filter(
|
||||
(n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`)
|
||||
);
|
||||
if (narrowed.length > 0) {
|
||||
pool = narrowed;
|
||||
} else {
|
||||
filteredOut = true;
|
||||
}
|
||||
}
|
||||
const byDef = new Map<string, Node[]>();
|
||||
for (const n of pool) {
|
||||
const key = `${n.filePath}|${n.qualifiedName}`;
|
||||
const group = byDef.get(key);
|
||||
if (group) group.push(n);
|
||||
else byDef.set(key, [n]);
|
||||
}
|
||||
return { groups: [...byDef.values()], filteredOut };
|
||||
return groupDefinitions(nodes, fileFilter);
|
||||
}
|
||||
|
||||
/** Section heading for one distinct definition in grouped output. */
|
||||
@@ -6853,7 +6832,7 @@ export class ToolHandler {
|
||||
*/
|
||||
/**
|
||||
* Check if a node matches a symbol query — see `matchesSymbol` in
|
||||
* `../graph/named-symbol-flow`, which owns the rules.
|
||||
* `../graph/symbol-lookup`, which owns the rules.
|
||||
*/
|
||||
private matchesSymbol(node: Node, symbol: string): boolean {
|
||||
return matchesSymbol(node, symbol);
|
||||
|
||||
Reference in New Issue
Block a user