Browse Source

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>
zhuchaokn 1 month ago
parent
commit
1f11de73ff
2 changed files with 264 additions and 0 deletions
  1. 3 0
      README.md
  2. 261 0
      src/bin/codegraph.ts

+ 3 - 0
README.md

@@ -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
 ```

+ 261 - 0
src/bin/codegraph.ts

@@ -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...]
  *