feat(cli): add callers, callees, impact commands for CLI/MCP parity (#204)
Add `codegraph callers`, `codegraph callees`, and `codegraph impact` CLI commands, bringing the CLI to parity with the codegraph_callers/callees/impact MCP tools — so the graph-traversal queries work in scripts, CI, and git hooks without a running MCP server. All three support `--path` and `--json`; `impact` groups output by file to match the MCP layout. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fb45959af7
commit
1f11de73ff
@@ -352,6 +352,9 @@ codegraph status [path] # Show statistics
|
||||
codegraph query <search> # Search symbols (--kind, --limit, --json)
|
||||
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
|
||||
codegraph context <task> # Build context for AI (--format, --max-nodes)
|
||||
codegraph callers <symbol> # Find what calls a function/method (--limit, --json)
|
||||
codegraph callees <symbol> # Find what a function/method calls (--limit, --json)
|
||||
codegraph impact <symbol> # Analyze what code is affected by changing a symbol (--depth, --json)
|
||||
codegraph affected [files...] # Find test files affected by changes (see below)
|
||||
codegraph serve --mcp # Start MCP server
|
||||
```
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
* codegraph query <search> Search for symbols
|
||||
* codegraph files [options] Show project file structure
|
||||
* codegraph context <task> Build context for a task
|
||||
* codegraph callers <symbol> Find what calls a function/method
|
||||
* codegraph callees <symbol> Find what a function/method calls
|
||||
* codegraph impact <symbol> Analyze what code is affected by changing a symbol
|
||||
* codegraph affected [files] Find test files affected by changes
|
||||
*/
|
||||
|
||||
@@ -1207,6 +1210,264 @@ 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.
|
||||
*/
|
||||
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);
|
||||
|
||||
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 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);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ symbol, callers: limited }, null, 2));
|
||||
} else if (limited.length === 0) {
|
||||
info(`No callers found for "${symbol}"`);
|
||||
} else {
|
||||
console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ symbol, callees: limited }, null, 2));
|
||||
} else if (limited.length === 0) {
|
||||
info(`No callees found for "${symbol}"`);
|
||||
} else {
|
||||
console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\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();
|
||||
}
|
||||
}
|
||||
|
||||
cg.destroy();
|
||||
} catch (err) {
|
||||
error(`callees failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* codegraph impact <symbol>
|
||||
*/
|
||||
program
|
||||
.command('impact <symbol>')
|
||||
.description('Analyze what code is affected by changing a symbol')
|
||||
.option('-p, --path <path>', 'Project path')
|
||||
.option('-d, --depth <number>', 'Traversal depth', '2')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action(async (symbol: string, options: { path?: string; depth?: 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 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`);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* codegraph affected [files...]
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user