From bb5d9058dfb10f4805487ee9bc6f7ca29beb8617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Fri, 28 Aug 2026 20:47:52 +0800 Subject: [PATCH 1/2] feat: add visual graph export, path tracing CLI, architecture metrics, code health audit, and MCP extensions --- __tests__/secondary-features.test.ts | 268 ++++++++++++++++++++++++++ src/bin/codegraph.ts | 271 +++++++++++++++++++++++++++ src/graph/export.ts | 208 ++++++++++++++++++++ src/graph/index.ts | 3 + src/graph/metrics.ts | 170 +++++++++++++++++ src/index.ts | 118 +++++++++++- src/mcp/tools.ts | 182 ++++++++++++++++++ 7 files changed, 1217 insertions(+), 3 deletions(-) create mode 100644 __tests__/secondary-features.test.ts create mode 100644 src/graph/export.ts create mode 100644 src/graph/metrics.ts diff --git a/__tests__/secondary-features.test.ts b/__tests__/secondary-features.test.ts new file mode 100644 index 000000000..ff13236eb --- /dev/null +++ b/__tests__/secondary-features.test.ts @@ -0,0 +1,268 @@ +/** + * Secondary Development Features Test Suite + * + * Tests for the 5 newly implemented features: + * 1. Visual Graph Exporter (Mermaid, DOT, JSON) + * 2. Multi-hop Call Path Tracing (findPathBetweenSymbols) + * 3. Architectural & Coupling Metrics (Afferent/Efferent coupling, Instability, Hotspots) + * 4. Dead Code & Circular Dependency Auditor (auditProject) + * 5. MCP Tool Handlers (codegraph_trace, codegraph_export, codegraph_metrics) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph, { + exportGraph, + exportToMermaid, + exportToDot, + exportToJson, + MetricsAnalyzer, +} from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('Secondary Development Features', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-feat-test-')); + + const srcDir = path.join(testDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // File A: auth service + fs.writeFileSync( + path.join(srcDir, 'auth.ts'), + ` +export class AuthService { + login(token: string): boolean { + return this.validate(token); + } + + validate(token: string): boolean { + return token.length > 0; + } +} + +// Dead internal helper +function unusedAuthSecret(): string { + return 'secret_123'; +} +` + ); + + // File B: user controller calling auth + fs.writeFileSync( + path.join(srcDir, 'controller.ts'), + ` +import { AuthService } from './auth'; + +export class UserController { + private auth: AuthService; + + constructor() { + this.auth = new AuthService(); + } + + handleLogin(t: string): boolean { + return this.auth.login(t); + } +} +` + ); + + // File C: app entrypoint calling controller + fs.writeFileSync( + path.join(srcDir, 'app.ts'), + ` +import { UserController } from './controller'; + +export function bootstrap(): void { + const controller = new UserController(); + controller.handleLogin('test_token'); +} +` + ); + + cg = CodeGraph.initSync(testDir, { + config: { + include: ['src/**/*.ts'], + exclude: [], + }, + }); + + await cg.indexAll(); + cg.resolveReferences(); + }); + + afterEach(() => { + if (cg) { + cg.destroy(); + } + try { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + } catch { + // Ignore Windows temp file release delay + } + }); + + describe('Feature 1: Visual Graph Exporter', () => { + it('exports a subgraph to Mermaid markdown diagram', () => { + const controllerNode = cg.getNodesByName('UserController')[0]; + expect(controllerNode).toBeDefined(); + + const callGraph = cg.getCallGraph(controllerNode!.id, 2); + const mermaid = exportToMermaid(callGraph, { direction: 'TD', title: 'User Controller Graph' }); + + expect(mermaid).toContain('graph TD'); + expect(mermaid).toContain('title: User Controller Graph'); + expect(mermaid).toContain('UserController'); + }); + + it('exports a subgraph to Graphviz DOT format', () => { + const authNode = cg.getNodesByName('AuthService')[0]; + expect(authNode).toBeDefined(); + + const impact = cg.getImpactRadius(authNode!.id, 2); + const dot = exportToDot(impact, { title: 'AuthImpact' }); + + expect(dot).toContain('digraph AuthImpact {'); + expect(dot).toContain('AuthService'); + expect(dot).toContain('}'); + }); + + it('exports a subgraph to JSON Graph format', () => { + const appNode = cg.getNodesByName('bootstrap')[0]; + expect(appNode).toBeDefined(); + + const callGraph = cg.getCallGraph(appNode!.id, 2); + const jsonStr = exportToJson(callGraph); + const parsed = JSON.parse(jsonStr); + + expect(parsed).toHaveProperty('nodeCount'); + expect(parsed).toHaveProperty('nodes'); + expect(parsed).toHaveProperty('edges'); + expect(parsed.nodes.some((n: any) => n.name === 'bootstrap')).toBe(true); + }); + + it('exports symbol graph directly via CodeGraph API', () => { + const output = cg.exportSymbolGraph('AuthService', { + format: 'mermaid', + depth: 2, + }); + + expect(output).toContain('graph TD'); + expect(output).toContain('AuthService'); + }); + }); + + describe('Feature 2: Multi-hop Call Path Tracing', () => { + it('finds path from bootstrap to validate across multiple hops', () => { + const pathResult = cg.findPathBetweenSymbols('bootstrap', 'validate'); + + expect(pathResult).not.toBeNull(); + expect(pathResult!.length).toBeGreaterThanOrEqual(2); + + const names = pathResult!.map((p) => p.node.name); + expect(names[0]).toBe('bootstrap'); + expect(names[names.length - 1]).toBe('validate'); + }); + + it('returns null when no path exists between disconnected symbols', () => { + const pathResult = cg.findPathBetweenSymbols('validate', 'unusedAuthSecret'); + expect(pathResult).toBeNull(); + }); + }); + + describe('Feature 3: Architectural & Coupling Metrics', () => { + it('calculates file coupling and instability index', () => { + const analyzer = new MetricsAnalyzer((cg as any).queries); + const fileMetrics = analyzer.computeFileMetrics(); + + expect(fileMetrics.length).toBe(3); + + const authMetric = fileMetrics.find((m) => m.filePath.endsWith('auth.ts')); + expect(authMetric).toBeDefined(); + // auth.ts is depended upon by controller.ts (Ca >= 1) + expect(authMetric!.afferentCoupling).toBeGreaterThanOrEqual(1); + + const appMetric = fileMetrics.find((m) => m.filePath.endsWith('app.ts')); + expect(appMetric).toBeDefined(); + // app.ts depends on controller.ts (Ce >= 1) + expect(appMetric!.efferentCoupling).toBeGreaterThanOrEqual(1); + }); + + it('identifies structural hotspots and computes project summary', () => { + const metrics = cg.getMetrics(5); + + expect(metrics.summary.totalFiles).toBe(3); + expect(metrics.summary.totalSymbols).toBeGreaterThan(0); + expect(typeof metrics.summary.avgAfferentCoupling).toBe('number'); + expect(typeof metrics.summary.avgInstability).toBe('number'); + }); + }); + + describe('Feature 4: Dead Code & Circular Dependency Audit', () => { + it('detects unreferenced non-exported functions as dead code', () => { + const audit = cg.auditProject(); + + expect(audit.deadCode.length).toBeGreaterThan(0); + const deadNames = audit.deadCode.map((n) => n.name); + expect(deadNames).toContain('unusedAuthSecret'); + }); + + it('reports zero circular dependencies on clean acyclic architecture', () => { + const audit = cg.auditProject(); + expect(audit.circularDependencies.length).toBe(0); + }); + }); + + describe('Feature 5: MCP Tool Suite Extension', () => { + let handler: ToolHandler; + + beforeEach(() => { + handler = new ToolHandler(cg); + }); + + it('executes codegraph_trace MCP tool successfully', async () => { + const result = await handler.execute('codegraph_trace', { + from: 'bootstrap', + to: 'validate', + }); + + expect(result.isError).toBeFalsy(); + const text = result.content[0]?.text ?? ''; + expect(text).toContain('Path from bootstrap to validate'); + expect(text).toContain('bootstrap'); + expect(text).toContain('validate'); + }); + + it('executes codegraph_export MCP tool successfully', async () => { + const result = await handler.execute('codegraph_export', { + symbol: 'UserController', + format: 'mermaid', + }); + + expect(result.isError).toBeFalsy(); + const text = result.content[0]?.text ?? ''; + expect(text).toContain('graph TD'); + expect(text).toContain('UserController'); + }); + + it('executes codegraph_metrics MCP tool successfully', async () => { + const result = await handler.execute('codegraph_metrics', { + limit: 5, + }); + + expect(result.isError).toBeFalsy(); + const text = result.content[0]?.text ?? ''; + expect(text).toContain('Project Architecture Metrics'); + expect(text).toContain('Total Files:'); + expect(text).toContain('Instability Index'); + }); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 19038df1b..380de245c 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2335,8 +2335,279 @@ program } }); +/** + * codegraph trace + */ +program + .command('trace ') + .description('Trace the shortest call or dependency path between two symbols') + .option('-p, --path ', 'Project path') + .option('-j, --json', 'Output as JSON') + .action(async (from: string, to: string, options: { path?: 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 pathResult = cg.findPathBetweenSymbols(from, to); + + if (!pathResult || pathResult.length === 0) { + if (options.json) { + console.log(JSON.stringify({ from, to, found: false, path: [] }, null, 2)); + } else { + info(`No path found between "${from}" and "${to}".`); + } + cg.destroy(); + return; + } + + if (options.json) { + const formatted = pathResult.map((step) => ({ + name: step.node.name, + kind: step.node.kind, + filePath: step.node.filePath, + startLine: step.node.startLine, + edgeKind: step.edge?.kind ?? null, + })); + console.log(JSON.stringify({ from, to, found: true, hops: pathResult.length - 1, path: formatted }, null, 2)); + } else { + console.log(chalk.bold(`\nPath from "${from}" to "${to}" (${pathResult.length - 1} hops):\n`)); + for (let i = 0; i < pathResult.length; i++) { + const step = pathResult[i]!; + const loc = step.node.startLine ? `:${step.node.startLine}` : ''; + const nodeStr = `${chalk.cyan(step.node.name)} ${chalk.dim(`(${step.node.kind} — ${step.node.filePath}${loc})`)}`; + + if (i === 0) { + console.log(` ${chalk.green('●')} ${nodeStr}`); + } else { + const edgeKind = pathResult[i]?.edge?.kind || 'references'; + console.log(` ${chalk.dim('│')}`); + console.log(` ${chalk.dim('▼')} ${chalk.yellow(`[${edgeKind}]`)}`); + console.log(` ${i === pathResult.length - 1 ? chalk.red('◼') : chalk.green('●')} ${nodeStr}`); + } + } + console.log(); + } + + cg.destroy(); + } catch (err) { + error(`Trace failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph export [symbol] + */ +program + .command('export [symbol]') + .description('Export code knowledge graph or symbol call graph to Mermaid, DOT, or JSON') + .option('-p, --path ', 'Project path') + .option('-f, --format ', 'Export format: "mermaid", "dot", "json"', 'mermaid') + .option('-d, --direction ', 'Mermaid/DOT direction (TD, LR)', 'TD') + .option('--depth ', 'Depth for symbol export', '2') + .option('--mode ', 'Symbol mode: "callgraph" or "impact"', 'callgraph') + .option('-o, --output ', 'Output file path') + .action(async (symbol: string | undefined, options: { + path?: string; + format?: 'mermaid' | 'dot' | 'json'; + direction?: 'TD' | 'LR'; + depth?: string; + mode?: 'callgraph' | 'impact'; + output?: string; + }) => { + const projectPath = resolveProjectPath(options.path); + + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + + const { default: CodeGraph, exportGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + const depth = parseInt(options.depth || '2', 10); + const format = options.format || 'mermaid'; + const direction = options.direction || 'TD'; + + let outputText: string; + + if (symbol) { + outputText = cg.exportSymbolGraph(symbol, { + format, + direction, + depth, + mode: options.mode || 'callgraph', + }); + } else { + const files = cg.getFiles(); + const fullSubgraph = { + nodes: new Map(), + edges: [] as any[], + roots: [] as string[], + }; + for (const f of files.slice(0, 30)) { + const fnodes = cg.getNodesInFile(f.path); + for (const fn of fnodes) { + fullSubgraph.nodes.set(fn.id, fn); + const outEdges = cg.getOutgoingEdges(fn.id); + for (const oe of outEdges) { + fullSubgraph.edges.push(oe); + } + } + } + outputText = exportGraph(fullSubgraph, { + format, + direction, + title: path.basename(projectPath), + }); + } + + if (options.output) { + fs.writeFileSync(options.output, outputText, 'utf-8'); + success(`Exported graph to ${options.output}`); + } else { + console.log(outputText); + } + + cg.destroy(); + } catch (err) { + error(`Export failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph metrics + */ +program + .command('metrics') + .description('Analyze architectural coupling, instability, and symbol hotspots') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Top hotspots to show', '10') + .option('-j, --json', 'Output as JSON') + .action(async (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 || '10', 10); + const metrics = cg.getMetrics(limit); + + if (options.json) { + console.log(JSON.stringify(metrics, null, 2)); + } else { + console.log(chalk.bold(`\nArchitectural Metrics Summary:`)); + console.log(` Total Files: ${formatNumber(metrics.summary.totalFiles)}`); + console.log(` Total Symbols: ${formatNumber(metrics.summary.totalSymbols)}`); + console.log(` Total Edges: ${formatNumber(metrics.summary.totalEdges)}`); + console.log(` Avg Afferent Coupling: ${metrics.summary.avgAfferentCoupling} (incoming)`); + console.log(` Avg Efferent Coupling: ${metrics.summary.avgEfferentCoupling} (outgoing)`); + console.log(` Avg Instability Index: ${metrics.summary.avgInstability} (0.0=stable, 1.0=volatile)`); + + if (metrics.topHotspots.length > 0) { + console.log(chalk.bold(`\nTop Structural Hotspots (High Fan-in / Fan-out / Blast Radius):`)); + for (const h of metrics.topHotspots) { + const loc = h.startLine ? `:${h.startLine}` : ''; + console.log(` ${chalk.cyan(h.name.padEnd(28))} ${chalk.dim(h.kind.padEnd(10))} Fan-in: ${chalk.yellow(String(h.fanIn))} | Fan-out: ${chalk.yellow(String(h.fanOut))} | Blast Radius: ${chalk.red(String(h.blastRadius))}`); + console.log(` ${chalk.dim(`${h.filePath}${loc}`)}`); + } + } + + console.log(); + } + + cg.destroy(); + } catch (err) { + error(`Metrics analysis failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + +/** + * codegraph audit + */ +program + .command('audit') + .description('Audit codebase for dead code (unreferenced non-exported symbols) and circular dependencies') + .option('-p, --path ', 'Project path') + .option('-j, --json', 'Output as JSON') + .action(async (options: { path?: 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 audit = cg.auditProject(); + + if (options.json) { + console.log(JSON.stringify({ + totalIssues: audit.totalIssues, + deadCodeCount: audit.deadCode.length, + circularDependencyCount: audit.circularDependencies.length, + deadCode: audit.deadCode.map((n) => ({ + name: n.name, + kind: n.kind, + filePath: n.filePath, + startLine: n.startLine, + })), + circularDependencies: audit.circularDependencies, + }, null, 2)); + } else { + console.log(chalk.bold(`\nCode Health Audit Results (${audit.totalIssues} issues found):\n`)); + + if (audit.circularDependencies.length > 0) { + console.log(chalk.yellow(`Circular Dependencies (${audit.circularDependencies.length}):`)); + for (const cycle of audit.circularDependencies) { + console.log(` ${cycle.join(' ──> ')}`); + } + console.log(); + } else { + console.log(chalk.green(`${getGlyphs().ok} No circular dependencies detected.`)); + } + + if (audit.deadCode.length > 0) { + console.log(chalk.yellow(`\nUnreferenced Non-Exported Symbols (${audit.deadCode.length}):`)); + for (const n of audit.deadCode.slice(0, 30)) { + const loc = n.startLine ? `:${n.startLine}` : ''; + console.log(` ${chalk.dim(n.kind.padEnd(10))} ${chalk.cyan(n.name)} ${chalk.dim(`(${n.filePath}${loc})`)}`); + } + if (audit.deadCode.length > 30) { + console.log(chalk.dim(` ... and ${audit.deadCode.length - 30} more.`)); + } + console.log(); + } else { + console.log(chalk.green(`${getGlyphs().ok} No dead/unreferenced internal symbols detected.`)); + } + } + + cg.destroy(); + } catch (err) { + error(`Audit failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph install + */ program .command('install') diff --git a/src/graph/export.ts b/src/graph/export.ts new file mode 100644 index 000000000..0db271509 --- /dev/null +++ b/src/graph/export.ts @@ -0,0 +1,208 @@ +/** + * Graph Export Module + * + * Provides formatters to export subgraphs and call graphs to Mermaid.js diagrams, + * Graphviz DOT, and JSON graph formats for documentation and visual rendering. + */ + +import type { Subgraph } from '../types'; + + +export type ExportFormat = 'mermaid' | 'dot' | 'json'; + +export interface ExportOptions { + /** Output format: 'mermaid' | 'dot' | 'json' */ + format?: ExportFormat; + /** Layout direction for Mermaid: 'TD' (top-down) | 'LR' (left-right) */ + direction?: 'TD' | 'LR' | 'TB' | 'BT' | 'RL'; + /** Title or name for the graph */ + title?: string; + /** Filter to specific edge kinds */ + edgeKinds?: string[]; + /** Include file path in node labels */ + includeLocations?: boolean; +} + +/** + * Sanitize an identifier for use in Mermaid or DOT node IDs + */ +function sanitizeId(id: string): string { + return id.replace(/[^a-zA-Z0-9_]/g, '_'); +} + +/** + * Escape text for Mermaid node labels + */ +function escapeMermaidLabel(text: string): string { + return text.replace(/"/g, '#quot;').replace(/[\r\n]+/g, ' '); +} + +/** + * Escape text for Graphviz DOT labels + */ +function escapeDotLabel(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); +} + +/** + * Export a Subgraph to Mermaid flowchart markdown syntax + */ +export function exportToMermaid(subgraph: Subgraph, options: ExportOptions = {}): string { + const dir = options.direction || 'TD'; + const lines: string[] = []; + + if (options.title) { + lines.push(`---`); + lines.push(`title: ${options.title}`); + lines.push(`---`); + } + lines.push(`graph ${dir}`); + + const edgeKindsFilter = options.edgeKinds ? new Set(options.edgeKinds) : null; + const nodes = Array.from(subgraph.nodes.values()); + + // Render nodes + for (const node of nodes) { + const safeId = sanitizeId(node.id); + let label = `${escapeMermaidLabel(node.name)} (${node.kind})`; + if (options.includeLocations && node.filePath) { + label += `
${escapeMermaidLabel(node.filePath)}${node.startLine ? `:${node.startLine}` : ''}`; + } + + // Use different shape brackets for different kinds + if (node.kind === 'class' || node.kind === 'struct') { + lines.push(` ${safeId}["${label}"]`); + } else if (node.kind === 'interface' || node.kind === 'trait') { + lines.push(` ${safeId}["<<interface>>
${label}"]`); + } else if (node.kind === 'function' || node.kind === 'method') { + lines.push(` ${safeId}(["${label}"])`); + } else if (node.kind === 'file') { + lines.push(` ${safeId}[/"${label}"/]`); + } else { + lines.push(` ${safeId}["${label}"]`); + } + } + + // Render edges + const seenEdges = new Set(); + for (const edge of subgraph.edges) { + if (edgeKindsFilter && !edgeKindsFilter.has(edge.kind)) continue; + const sourceNode = subgraph.nodes.get(edge.source); + const targetNode = subgraph.nodes.get(edge.target); + if (!sourceNode || !targetNode) continue; + + const sourceId = sanitizeId(edge.source); + const targetId = sanitizeId(edge.target); + const key = `${sourceId}->${targetId}:${edge.kind}`; + if (seenEdges.has(key)) continue; + seenEdges.add(key); + + const edgeLabel = edge.kind ? `|${edge.kind}|` : ''; + lines.push(` ${sourceId} -->${edgeLabel} ${targetId}`); + } + + return lines.join('\n'); +} + +/** + * Export a Subgraph to Graphviz DOT syntax + */ +export function exportToDot(subgraph: Subgraph, options: ExportOptions = {}): string { + const graphName = options.title ? sanitizeId(options.title) : 'CodeGraph'; + const lines: string[] = [ + `digraph ${graphName} {`, + ` node [shape=box, style="rounded,filled", fillcolor="#f8fafc", fontname="sans-serif", fontsize=10];`, + ` edge [fontname="sans-serif", fontsize=8, color="#64748b"];`, + ` rankdir=${options.direction === 'LR' ? 'LR' : 'TB'};`, + ]; + + const edgeKindsFilter = options.edgeKinds ? new Set(options.edgeKinds) : null; + const nodes = Array.from(subgraph.nodes.values()); + + for (const node of nodes) { + const safeId = sanitizeId(node.id); + let label = `${node.name}\\n(${node.kind})`; + if (options.includeLocations && node.filePath) { + label += `\\n${node.filePath}${node.startLine ? `:${node.startLine}` : ''}`; + } + const escaped = escapeDotLabel(label); + + let color = '#f8fafc'; + if (node.kind === 'class' || node.kind === 'struct') color = '#e0f2fe'; + else if (node.kind === 'function' || node.kind === 'method') color = '#f0fdf4'; + else if (node.kind === 'interface') color = '#fef3c7'; + + lines.push(` "${safeId}" [label="${escaped}", fillcolor="${color}"];`); + } + + const seenEdges = new Set(); + for (const edge of subgraph.edges) { + if (edgeKindsFilter && !edgeKindsFilter.has(edge.kind)) continue; + const sourceNode = subgraph.nodes.get(edge.source); + const targetNode = subgraph.nodes.get(edge.target); + if (!sourceNode || !targetNode) continue; + + const sourceId = sanitizeId(edge.source); + const targetId = sanitizeId(edge.target); + const key = `${sourceId}->${targetId}:${edge.kind}`; + if (seenEdges.has(key)) continue; + seenEdges.add(key); + + lines.push(` "${sourceId}" -> "${targetId}" [label="${escapeDotLabel(edge.kind)}"];`); + } + + lines.push(`}`); + return lines.join('\n'); +} + +/** + * Export a Subgraph to JSON Graph format + */ +export function exportToJson(subgraph: Subgraph): string { + const nodes = Array.from(subgraph.nodes.values()).map((n) => ({ + id: n.id, + name: n.name, + kind: n.kind, + filePath: n.filePath, + startLine: n.startLine, + endLine: n.endLine, + isExported: n.isExported, + })); + + const edges = subgraph.edges.map((e) => ({ + source: e.source, + target: e.target, + kind: e.kind, + line: e.line, + column: e.column, + provenance: e.provenance, + })); + + return JSON.stringify( + { + roots: subgraph.roots, + nodeCount: nodes.length, + edgeCount: edges.length, + nodes, + edges, + }, + null, + 2 + ); +} + +/** + * Export graph dispatcher + */ +export function exportGraph(subgraph: Subgraph, options: ExportOptions = {}): string { + const format = options.format || 'mermaid'; + switch (format) { + case 'dot': + return exportToDot(subgraph, options); + case 'json': + return exportToJson(subgraph); + case 'mermaid': + default: + return exportToMermaid(subgraph, options); + } +} diff --git a/src/graph/index.ts b/src/graph/index.ts index b7fb639d7..93424144b 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -6,3 +6,6 @@ export { GraphTraverser } from './traversal'; export { GraphQueryManager } from './queries'; +export { exportGraph, exportToMermaid, exportToDot, exportToJson, ExportFormat, ExportOptions } from './export'; +export { MetricsAnalyzer, FileCouplingMetric, SymbolHotspotMetric, ProjectMetrics } from './metrics'; + diff --git a/src/graph/metrics.ts b/src/graph/metrics.ts new file mode 100644 index 000000000..b8e148aaa --- /dev/null +++ b/src/graph/metrics.ts @@ -0,0 +1,170 @@ +/** + * Architectural and Complexity Metrics Module + * + * Computes software architecture metrics: + * - Afferent Coupling (Ca): Incoming dependencies to a module/file + * - Efferent Coupling (Ce): Outgoing dependencies from a module/file + * - Instability Index (I): Ce / (Ca + Ce), measuring architectural stability (0 = stable, 1 = volatile) + * - Hotspot Analysis: High fan-in / high fan-out / large blast radius symbols + */ + +import type { Node } from '../types'; +import { QueryBuilder } from '../db/queries'; +import { GraphTraverser } from './traversal'; + +export interface FileCouplingMetric { + filePath: string; + afferentCoupling: number; // Ca (incoming from other files) + efferentCoupling: number; // Ce (outgoing to other files) + instability: number; // I = Ce / (Ca + Ce), 0 to 1 (0 = stable, 1 = unstable) + symbolsCount: number; +} + +export interface SymbolHotspotMetric { + id: string; + name: string; + kind: Node['kind']; + filePath: string; + startLine?: number; + fanIn: number; // Incoming references / callers + fanOut: number; // Outgoing calls / references + blastRadius: number; // Affected nodes within depth 2 +} + +export interface ProjectMetrics { + summary: { + totalFiles: number; + totalSymbols: number; + totalEdges: number; + avgAfferentCoupling: number; + avgEfferentCoupling: number; + avgInstability: number; + }; + fileMetrics: FileCouplingMetric[]; + topHotspots: SymbolHotspotMetric[]; +} + +export class MetricsAnalyzer { + private queries: QueryBuilder; + private traverser: GraphTraverser; + + constructor(queries: QueryBuilder) { + this.queries = queries; + this.traverser = new GraphTraverser(queries); + } + + /** + * Compute file-level coupling and instability metrics + */ + computeFileMetrics(): FileCouplingMetric[] { + const files = this.queries.getAllFiles(); + const result: FileCouplingMetric[] = []; + + for (const file of files) { + const incomingFiles = this.queries.getDependentFilePaths(file.path); + const outgoingFiles = this.queries.getDependencyFilePaths(file.path); + + const ca = incomingFiles.length; + const ce = outgoingFiles.length; + const totalCoupling = ca + ce; + const instability = totalCoupling === 0 ? 0 : parseFloat((ce / totalCoupling).toFixed(3)); + + result.push({ + filePath: file.path, + afferentCoupling: ca, + efferentCoupling: ce, + instability, + symbolsCount: file.nodeCount || 0, + }); + } + + // Sort by total coupling (Ca + Ce) descending + return result.sort( + (a, b) => (b.afferentCoupling + b.efferentCoupling) - (a.afferentCoupling + a.efferentCoupling) + ); + } + + /** + * Find structural hotspots (symbols with high fan-in, high fan-out, or large blast radius) + */ + findHotspots(limit: number = 15): SymbolHotspotMetric[] { + const callableKinds: Node['kind'][] = ['function', 'method', 'class', 'struct', 'interface']; + const candidates: SymbolHotspotMetric[] = []; + + for (const kind of callableKinds) { + const nodes = this.queries.getNodesByKind(kind); + for (const node of nodes) { + const incoming = this.queries.getIncomingEdges(node.id).filter((e) => e.kind !== 'contains'); + const outgoing = this.queries.getOutgoingEdges(node.id).filter((e) => e.kind !== 'contains'); + + const fanIn = incoming.length; + const fanOut = outgoing.length; + + // Only evaluate if it has at least some coupling + if (fanIn >= 2 || fanOut >= 3) { + const impact = this.traverser.getImpactRadius(node.id, 2); + candidates.push({ + id: node.id, + name: node.name, + kind: node.kind, + filePath: node.filePath, + startLine: node.startLine, + fanIn, + fanOut, + blastRadius: impact.nodes.size, + }); + } + } + } + + // Rank hotspots by score: (fanIn * 2) + fanOut + (blastRadius * 1.5) + candidates.sort((a, b) => { + const scoreA = a.fanIn * 2 + a.fanOut + a.blastRadius * 1.5; + const scoreB = b.fanIn * 2 + b.fanOut + b.blastRadius * 1.5; + return scoreB - scoreA; + }); + + return candidates.slice(0, limit); + } + + /** + * Compute comprehensive project metrics + */ + getProjectMetrics(hotspotLimit: number = 15): ProjectMetrics { + const fileMetrics = this.computeFileMetrics(); + const topHotspots = this.findHotspots(hotspotLimit); + const files = this.queries.getAllFiles(); + const counts = this.queries.getNodeAndEdgeCount(); + + const totalFiles = files.length; + const totalSymbols = counts.nodes; + const totalEdges = counts.edges; + + let sumCa = 0; + let sumCe = 0; + let sumI = 0; + + for (const fm of fileMetrics) { + sumCa += fm.afferentCoupling; + sumCe += fm.efferentCoupling; + sumI += fm.instability; + } + + const avgCa = totalFiles > 0 ? parseFloat((sumCa / totalFiles).toFixed(2)) : 0; + const avgCe = totalFiles > 0 ? parseFloat((sumCe / totalFiles).toFixed(2)) : 0; + const avgI = totalFiles > 0 ? parseFloat((sumI / totalFiles).toFixed(3)) : 0; + + return { + summary: { + totalFiles, + totalSymbols, + totalEdges, + avgAfferentCoupling: avgCa, + avgEfferentCoupling: avgCe, + avgInstability: avgI, + }, + fileMetrics, + topHotspots, + }; + } +} diff --git a/src/index.ts b/src/index.ts index 90397c55c..757474608 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,8 +46,17 @@ import { createResolver, ResolutionResult, } from './resolution'; -import { GraphTraverser, GraphQueryManager } from './graph'; +import { + GraphTraverser, + GraphQueryManager, + MetricsAnalyzer, + exportGraph, + type ExportOptions, + type ProjectMetrics, +} from './graph'; import { ContextBuilder, createContextBuilder } from './context'; + + import { Mutex, FileLock } from './utils'; import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync'; import { EXTRACTION_VERSION } from './extraction/extraction-version'; @@ -94,6 +103,19 @@ export { export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils'; export { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync'; export { MCPServer } from './mcp'; +export { + MetricsAnalyzer, + exportGraph, + exportToMermaid, + exportToDot, + exportToJson, + ExportFormat, + ExportOptions, + ProjectMetrics, + FileCouplingMetric, + SymbolHotspotMetric, +} from './graph'; + /** * Options for initializing a new CodeGraph project @@ -1836,8 +1858,98 @@ export class CodeGraph { return this.graphManager.getNodeMetrics(nodeId); } - // =========================================================================== - // Context Building + /** + * Get project architectural and coupling metrics + * + * @param hotspotLimit - Number of hotspot symbols to return (default: 15) + */ + getMetrics(hotspotLimit: number = 15): ProjectMetrics { + const analyzer = new MetricsAnalyzer(this.queries); + return analyzer.getProjectMetrics(hotspotLimit); + } + + /** + * Audit project for code health issues (dead code & circular dependencies) + */ + auditProject(options?: { kinds?: Node['kind'][] }): { + deadCode: Node[]; + circularDependencies: string[][]; + totalIssues: number; + } { + const deadCode = this.findDeadCode(options?.kinds); + const circularDependencies = this.findCircularDependencies(); + return { + deadCode, + circularDependencies, + totalIssues: deadCode.length + circularDependencies.length, + }; + } + + /** + * Find a path between two symbol names + * + * @param fromSymbol - Starting symbol name (exact or search match) + * @param toSymbol - Target symbol name (exact or search match) + * @param edgeKinds - Optional edge kinds filter + */ + findPathBetweenSymbols( + fromSymbol: string, + toSymbol: string, + edgeKinds?: Edge['kind'][] + ): Array<{ node: Node; edge: Edge | null }> | null { + const fromMatches = this.queries.getNodesByName(fromSymbol); + const toMatches = this.queries.getNodesByName(toSymbol); + + const fromNode = fromMatches[0] ?? this.queries.searchNodes(fromSymbol, { limit: 1 })[0]?.node; + const toNode = toMatches[0] ?? this.queries.searchNodes(toSymbol, { limit: 1 })[0]?.node; + + if (!fromNode || !toNode) { + return null; + } + + return this.traverser.findPath(fromNode.id, toNode.id, edgeKinds); + } + + /** + * Export a subgraph to Mermaid, Graphviz DOT, or JSON + * + * @param subgraph - Subgraph to export + * @param options - Export options (format, direction, title) + */ + exportGraph(subgraph: Subgraph, options?: ExportOptions): string { + return exportGraph(subgraph, options); + } + + /** + * Export the call graph or impact subgraph of a symbol to Mermaid, DOT, or JSON + * + * @param symbol - Symbol name to visualize + * @param options - Export options + */ + exportSymbolGraph( + symbol: string, + options?: ExportOptions & { depth?: number; mode?: 'callgraph' | 'impact' } + ): string { + const matches = this.queries.getNodesByName(symbol); + const node = matches[0] ?? this.queries.searchNodes(symbol, { limit: 1 })[0]?.node; + + if (!node) { + throw new Error(`Symbol "${symbol}" not found in index.`); + } + + const depth = options?.depth ?? 2; + const mode = options?.mode ?? 'callgraph'; + const subgraph = mode === 'impact' + ? this.getImpactRadius(node.id, depth) + : this.getCallGraph(node.id, depth); + + return exportGraph(subgraph, { + title: options?.title || `${symbol} (${mode})`, + ...options, + }); + } + + // =========================================================================== /** diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5c23f675d..2538da131 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1247,8 +1247,83 @@ export const tools: ToolDefinition[] = [ }, annotations: READ_ONLY_ANNOTATIONS, }, + { + name: 'codegraph_trace', + description: 'Find the multi-hop call or dependency path between two symbols (from -> to).', + inputSchema: { + type: 'object', + properties: { + from: { + type: 'string', + description: 'Starting symbol name', + }, + to: { + type: 'string', + description: 'Target symbol name', + }, + projectPath: projectPathProperty, + }, + required: ['from', 'to'], + }, + annotations: READ_ONLY_ANNOTATIONS, + }, + { + name: 'codegraph_export', + description: 'Export code knowledge graph or symbol call graph to Mermaid, DOT, or JSON format for visualization.', + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'Optional symbol name to export its call/impact graph. If omitted, exports top project structure.', + }, + format: { + type: 'string', + description: 'Export format: "mermaid" (default), "dot", "json"', + enum: ['mermaid', 'dot', 'json'], + default: 'mermaid', + }, + direction: { + type: 'string', + description: 'Mermaid/DOT layout direction: "TD" (default) or "LR"', + enum: ['TD', 'LR'], + default: 'TD', + }, + depth: { + type: 'number', + description: 'Traversal depth for symbol mode (default: 2)', + default: 2, + }, + mode: { + type: 'string', + description: 'Symbol export mode: "callgraph" (default) or "impact"', + enum: ['callgraph', 'impact'], + default: 'callgraph', + }, + projectPath: projectPathProperty, + }, + }, + annotations: READ_ONLY_ANNOTATIONS, + }, + { + name: 'codegraph_metrics', + description: 'Get architectural metrics (Afferent/Efferent coupling, Instability index, and top structural hotspots).', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Number of hotspots to return (default: 10)', + default: 10, + }, + projectPath: projectPathProperty, + }, + }, + annotations: READ_ONLY_ANNOTATIONS, + }, ]; + /** * Return `defs` with `projectPath` marked `required` in each tool's inputSchema. * @@ -2149,14 +2224,121 @@ export class ToolHandler { case 'codegraph_explore': return await this.handleExplore(args); case 'codegraph_node': return await this.handleNode(args); case 'codegraph_files': return await this.handleFiles(args); + case 'codegraph_trace': return await this.handleTrace(args); + case 'codegraph_export': return await this.handleExport(args); + case 'codegraph_metrics': return await this.handleMetrics(args); default: return this.errorResult(`Unknown tool: ${toolName}`); } + + } + + /** + * Handle codegraph_trace + */ + private async handleTrace(args: Record): Promise { + const from = this.validateString(args.from, 'from'); + if (typeof from !== 'string') return from; + const to = this.validateString(args.to, 'to'); + if (typeof to !== 'string') return to; + + const cg = this.getCodeGraph(args.projectPath as string | undefined); + const pathResult = cg.findPathBetweenSymbols(from, to); + + if (!pathResult || pathResult.length === 0) { + return this.textResult(`No call or dependency path found between "${from}" and "${to}".`); + } + + const lines: string[] = [ + `**Path from ${from} to ${to} (${pathResult.length - 1} hops):**\n`, + ]; + + for (let i = 0; i < pathResult.length; i++) { + const step = pathResult[i]!; + const loc = step.node.startLine ? `:${step.node.startLine}` : ''; + if (i === 0) { + lines.push(`- **${step.node.name}** (${step.node.kind}) in \`${step.node.filePath}${loc}\``); + } else { + const edgeKind = step.edge?.kind || 'references'; + lines.push(` ↳ *${edgeKind}* → **${step.node.name}** (${step.node.kind}) in \`${step.node.filePath}${loc}\``); + } + } + + return this.textResult(this.truncateOutput(lines.join('\n'))); + } + + /** + * Handle codegraph_export + */ + private async handleExport(args: Record): Promise { + const cg = this.getCodeGraph(args.projectPath as string | undefined); + const symbol = typeof args.symbol === 'string' && args.symbol.trim() ? args.symbol.trim() : undefined; + const format = (args.format as 'mermaid' | 'dot' | 'json') || 'mermaid'; + const direction = (args.direction as 'TD' | 'LR') || 'TD'; + const depth = clamp(Number(args.depth) || 2, 1, 10); + const mode = (args.mode as 'callgraph' | 'impact') || 'callgraph'; + + try { + let output: string; + if (symbol) { + output = cg.exportSymbolGraph(symbol, { format, direction, depth, mode }); + } else { + const files = cg.getFiles(); + const fullSubgraph = { + nodes: new Map(), + edges: [] as any[], + roots: [] as string[], + }; + for (const f of files.slice(0, 30)) { + const fnodes = cg.getNodesInFile(f.path); + for (const fn of fnodes) { + fullSubgraph.nodes.set(fn.id, fn); + for (const oe of cg.getOutgoingEdges(fn.id)) { + fullSubgraph.edges.push(oe); + } + } + } + output = cg.exportGraph(fullSubgraph, { format, direction, title: 'Project Structure' }); + } + return this.textResult(output); + } catch (err) { + return this.textResult(`Export failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** + * Handle codegraph_metrics + */ + private async handleMetrics(args: Record): Promise { + const cg = this.getCodeGraph(args.projectPath as string | undefined); + const limit = clamp(Number(args.limit) || 10, 1, 50); + const metrics = cg.getMetrics(limit); + + const lines: string[] = [ + `### Project Architecture Metrics`, + `- **Total Files:** ${metrics.summary.totalFiles}`, + `- **Total Symbols:** ${metrics.summary.totalSymbols}`, + `- **Total Edges:** ${metrics.summary.totalEdges}`, + `- **Avg Afferent Coupling (Ca):** ${metrics.summary.avgAfferentCoupling} (incoming dependencies)`, + `- **Avg Efferent Coupling (Ce):** ${metrics.summary.avgEfferentCoupling} (outgoing dependencies)`, + `- **Avg Instability Index (I):** ${metrics.summary.avgInstability} (0.0=stable, 1.0=volatile)`, + ]; + + if (metrics.topHotspots.length > 0) { + lines.push('', '### Top Structural Hotspots (High Fan-in / Blast Radius):'); + for (const h of metrics.topHotspots) { + const loc = h.startLine ? `:${h.startLine}` : ''; + lines.push(`- **${h.name}** (${h.kind}) — \`${h.filePath}${loc}\` | Fan-in: ${h.fanIn}, Fan-out: ${h.fanOut}, Blast Radius: ${h.blastRadius}`); + } + } + + return this.textResult(this.truncateOutput(lines.join('\n'))); } /** * Handle codegraph_search */ private async handleSearch(args: Record): Promise { + const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; From b200a155b0a2da56a331a1d05cfd5ee17786239e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Mon, 31 Aug 2026 00:11:43 +0800 Subject: [PATCH 2/2] fix: align secondary features with codegraph architecture --- __tests__/secondary-features.test.ts | 63 ++++++++++--------------- src/bin/codegraph.ts | 69 +--------------------------- src/index.ts | 25 ---------- src/mcp/tools.ts | 59 +----------------------- 4 files changed, 27 insertions(+), 189 deletions(-) diff --git a/__tests__/secondary-features.test.ts b/__tests__/secondary-features.test.ts index ff13236eb..75365a055 100644 --- a/__tests__/secondary-features.test.ts +++ b/__tests__/secondary-features.test.ts @@ -1,12 +1,11 @@ /** * Secondary Development Features Test Suite * - * Tests for the 5 newly implemented features: + * Tests for the newly implemented features: * 1. Visual Graph Exporter (Mermaid, DOT, JSON) - * 2. Multi-hop Call Path Tracing (findPathBetweenSymbols) - * 3. Architectural & Coupling Metrics (Afferent/Efferent coupling, Instability, Hotspots) - * 4. Dead Code & Circular Dependency Auditor (auditProject) - * 5. MCP Tool Handlers (codegraph_trace, codegraph_export, codegraph_metrics) + * 2. Architectural & Coupling Metrics (Afferent/Efferent coupling, Instability, Hotspots) + * 3. Dead Code & Circular Dependency Auditor (auditProject) + * 4. MCP Tool Handlers (codegraph_export, codegraph_metrics) */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -160,25 +159,7 @@ export function bootstrap(): void { }); }); - describe('Feature 2: Multi-hop Call Path Tracing', () => { - it('finds path from bootstrap to validate across multiple hops', () => { - const pathResult = cg.findPathBetweenSymbols('bootstrap', 'validate'); - - expect(pathResult).not.toBeNull(); - expect(pathResult!.length).toBeGreaterThanOrEqual(2); - - const names = pathResult!.map((p) => p.node.name); - expect(names[0]).toBe('bootstrap'); - expect(names[names.length - 1]).toBe('validate'); - }); - - it('returns null when no path exists between disconnected symbols', () => { - const pathResult = cg.findPathBetweenSymbols('validate', 'unusedAuthSecret'); - expect(pathResult).toBeNull(); - }); - }); - - describe('Feature 3: Architectural & Coupling Metrics', () => { + describe('Feature 2: Architectural & Coupling Metrics', () => { it('calculates file coupling and instability index', () => { const analyzer = new MetricsAnalyzer((cg as any).queries); const fileMetrics = analyzer.computeFileMetrics(); @@ -206,7 +187,7 @@ export function bootstrap(): void { }); }); - describe('Feature 4: Dead Code & Circular Dependency Audit', () => { + describe('Feature 3: Dead Code & Circular Dependency Audit', () => { it('detects unreferenced non-exported functions as dead code', () => { const audit = cg.auditProject(); @@ -221,26 +202,13 @@ export function bootstrap(): void { }); }); - describe('Feature 5: MCP Tool Suite Extension', () => { + describe('Feature 4: MCP Tool Suite Extension', () => { let handler: ToolHandler; beforeEach(() => { handler = new ToolHandler(cg); }); - it('executes codegraph_trace MCP tool successfully', async () => { - const result = await handler.execute('codegraph_trace', { - from: 'bootstrap', - to: 'validate', - }); - - expect(result.isError).toBeFalsy(); - const text = result.content[0]?.text ?? ''; - expect(text).toContain('Path from bootstrap to validate'); - expect(text).toContain('bootstrap'); - expect(text).toContain('validate'); - }); - it('executes codegraph_export MCP tool successfully', async () => { const result = await handler.execute('codegraph_export', { symbol: 'UserController', @@ -253,6 +221,23 @@ export function bootstrap(): void { expect(text).toContain('UserController'); }); + it('exports all indexed files in project mode', async () => { + for (let i = 1; i <= 31; i++) { + fs.writeFileSync( + path.join(testDir, 'src', `extra-${i}.ts`), + `export function extra${i}(): number { return ${i}; }\n` + ); + } + await cg.indexAll(); + cg.resolveReferences(); + + const result = await handler.execute('codegraph_export', {}); + + expect(result.isError).toBeFalsy(); + const text = result.content[0]?.text ?? ''; + expect(text).toContain('extra-31.ts'); + }); + it('executes codegraph_metrics MCP tool successfully', async () => { const result = await handler.execute('codegraph_metrics', { limit: 5, diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 380de245c..88420a841 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2335,73 +2335,6 @@ program } }); -/** - * codegraph trace - */ -program - .command('trace ') - .description('Trace the shortest call or dependency path between two symbols') - .option('-p, --path ', 'Project path') - .option('-j, --json', 'Output as JSON') - .action(async (from: string, to: string, options: { path?: 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 pathResult = cg.findPathBetweenSymbols(from, to); - - if (!pathResult || pathResult.length === 0) { - if (options.json) { - console.log(JSON.stringify({ from, to, found: false, path: [] }, null, 2)); - } else { - info(`No path found between "${from}" and "${to}".`); - } - cg.destroy(); - return; - } - - if (options.json) { - const formatted = pathResult.map((step) => ({ - name: step.node.name, - kind: step.node.kind, - filePath: step.node.filePath, - startLine: step.node.startLine, - edgeKind: step.edge?.kind ?? null, - })); - console.log(JSON.stringify({ from, to, found: true, hops: pathResult.length - 1, path: formatted }, null, 2)); - } else { - console.log(chalk.bold(`\nPath from "${from}" to "${to}" (${pathResult.length - 1} hops):\n`)); - for (let i = 0; i < pathResult.length; i++) { - const step = pathResult[i]!; - const loc = step.node.startLine ? `:${step.node.startLine}` : ''; - const nodeStr = `${chalk.cyan(step.node.name)} ${chalk.dim(`(${step.node.kind} — ${step.node.filePath}${loc})`)}`; - - if (i === 0) { - console.log(` ${chalk.green('●')} ${nodeStr}`); - } else { - const edgeKind = pathResult[i]?.edge?.kind || 'references'; - console.log(` ${chalk.dim('│')}`); - console.log(` ${chalk.dim('▼')} ${chalk.yellow(`[${edgeKind}]`)}`); - console.log(` ${i === pathResult.length - 1 ? chalk.red('◼') : chalk.green('●')} ${nodeStr}`); - } - } - console.log(); - } - - cg.destroy(); - } catch (err) { - error(`Trace failed: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); - /** * codegraph export [symbol] */ @@ -2452,7 +2385,7 @@ program edges: [] as any[], roots: [] as string[], }; - for (const f of files.slice(0, 30)) { + for (const f of files) { const fnodes = cg.getNodesInFile(f.path); for (const fn of fnodes) { fullSubgraph.nodes.set(fn.id, fn); diff --git a/src/index.ts b/src/index.ts index 757474608..db42e0ebe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1885,31 +1885,6 @@ export class CodeGraph { }; } - /** - * Find a path between two symbol names - * - * @param fromSymbol - Starting symbol name (exact or search match) - * @param toSymbol - Target symbol name (exact or search match) - * @param edgeKinds - Optional edge kinds filter - */ - findPathBetweenSymbols( - fromSymbol: string, - toSymbol: string, - edgeKinds?: Edge['kind'][] - ): Array<{ node: Node; edge: Edge | null }> | null { - const fromMatches = this.queries.getNodesByName(fromSymbol); - const toMatches = this.queries.getNodesByName(toSymbol); - - const fromNode = fromMatches[0] ?? this.queries.searchNodes(fromSymbol, { limit: 1 })[0]?.node; - const toNode = toMatches[0] ?? this.queries.searchNodes(toSymbol, { limit: 1 })[0]?.node; - - if (!fromNode || !toNode) { - return null; - } - - return this.traverser.findPath(fromNode.id, toNode.id, edgeKinds); - } - /** * Export a subgraph to Mermaid, Graphviz DOT, or JSON * diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 2538da131..fb6521687 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1247,26 +1247,6 @@ export const tools: ToolDefinition[] = [ }, annotations: READ_ONLY_ANNOTATIONS, }, - { - name: 'codegraph_trace', - description: 'Find the multi-hop call or dependency path between two symbols (from -> to).', - inputSchema: { - type: 'object', - properties: { - from: { - type: 'string', - description: 'Starting symbol name', - }, - to: { - type: 'string', - description: 'Target symbol name', - }, - projectPath: projectPathProperty, - }, - required: ['from', 'to'], - }, - annotations: READ_ONLY_ANNOTATIONS, - }, { name: 'codegraph_export', description: 'Export code knowledge graph or symbol call graph to Mermaid, DOT, or JSON format for visualization.', @@ -2224,7 +2204,6 @@ export class ToolHandler { case 'codegraph_explore': return await this.handleExplore(args); case 'codegraph_node': return await this.handleNode(args); case 'codegraph_files': return await this.handleFiles(args); - case 'codegraph_trace': return await this.handleTrace(args); case 'codegraph_export': return await this.handleExport(args); case 'codegraph_metrics': return await this.handleMetrics(args); default: return this.errorResult(`Unknown tool: ${toolName}`); @@ -2232,40 +2211,6 @@ export class ToolHandler { } - /** - * Handle codegraph_trace - */ - private async handleTrace(args: Record): Promise { - const from = this.validateString(args.from, 'from'); - if (typeof from !== 'string') return from; - const to = this.validateString(args.to, 'to'); - if (typeof to !== 'string') return to; - - const cg = this.getCodeGraph(args.projectPath as string | undefined); - const pathResult = cg.findPathBetweenSymbols(from, to); - - if (!pathResult || pathResult.length === 0) { - return this.textResult(`No call or dependency path found between "${from}" and "${to}".`); - } - - const lines: string[] = [ - `**Path from ${from} to ${to} (${pathResult.length - 1} hops):**\n`, - ]; - - for (let i = 0; i < pathResult.length; i++) { - const step = pathResult[i]!; - const loc = step.node.startLine ? `:${step.node.startLine}` : ''; - if (i === 0) { - lines.push(`- **${step.node.name}** (${step.node.kind}) in \`${step.node.filePath}${loc}\``); - } else { - const edgeKind = step.edge?.kind || 'references'; - lines.push(` ↳ *${edgeKind}* → **${step.node.name}** (${step.node.kind}) in \`${step.node.filePath}${loc}\``); - } - } - - return this.textResult(this.truncateOutput(lines.join('\n'))); - } - /** * Handle codegraph_export */ @@ -2288,7 +2233,7 @@ export class ToolHandler { edges: [] as any[], roots: [] as string[], }; - for (const f of files.slice(0, 30)) { + for (const f of files) { const fnodes = cg.getNodesInFile(f.path); for (const fn of fnodes) { fullSubgraph.nodes.set(fn.id, fn); @@ -2299,7 +2244,7 @@ export class ToolHandler { } output = cg.exportGraph(fullSubgraph, { format, direction, title: 'Project Structure' }); } - return this.textResult(output); + return this.textResult(this.truncateOutput(output)); } catch (err) { return this.textResult(`Export failed: ${err instanceof Error ? err.message : String(err)}`); }