diff --git a/__tests__/secondary-features.test.ts b/__tests__/secondary-features.test.ts new file mode 100644 index 000000000..75365a055 --- /dev/null +++ b/__tests__/secondary-features.test.ts @@ -0,0 +1,253 @@ +/** + * Secondary Development Features Test Suite + * + * Tests for the newly implemented features: + * 1. Visual Graph Exporter (Mermaid, DOT, JSON) + * 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'; +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: 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 3: 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 4: MCP Tool Suite Extension', () => { + let handler: ToolHandler; + + beforeEach(() => { + handler = new ToolHandler(cg); + }); + + 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('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, + }); + + 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..88420a841 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2335,8 +2335,212 @@ program } }); +/** + * 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) { + 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..db42e0ebe 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,73 @@ 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, + }; + } + + /** + * 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..fb6521687 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1247,8 +1247,63 @@ export const tools: ToolDefinition[] = [ }, 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 +2204,86 @@ 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_export': return await this.handleExport(args); + case 'codegraph_metrics': return await this.handleMetrics(args); default: return this.errorResult(`Unknown tool: ${toolName}`); } + + } + + /** + * 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) { + 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(this.truncateOutput(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;