diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd9447e..6720b02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,22 @@ name: CI on: + push: + branches: [main] pull_request: branches: [main] concurrency: - group: ci-${{ github.head_ref }} + group: ci-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: quality: name: Quality Gates runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 22] steps: - uses: actions/checkout@v4 @@ -19,7 +24,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: pnpm - run: pnpm install --frozen-lockfile @@ -35,3 +40,20 @@ jobs: - name: Test run: pnpm test + + # Coverage is best-effort: vitest v3 + v8 provider has a known worker + # timeout bug (onTaskUpdate) on CI runners. Tests pass above; coverage + # collection may timeout without affecting correctness. + - name: Coverage + if: matrix.node-version == 22 + continue-on-error: true + run: pnpm vitest run --coverage --pool forks --no-file-parallelism + + - name: Upload coverage + if: matrix.node-version == 22 && always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 14 + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..708aedd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# Codebase Intelligence + +TypeScript codebase analysis engine. Parses source, builds dependency graphs, computes architectural metrics. + +## Setup + +```bash +npx codebase-intelligence@latest # One-shot (no install) +npm install -g codebase-intelligence # Global install +``` + +## Interfaces + +### MCP (for AI agents — preferred) + +Start the MCP stdio server: + +```bash +codebase-intelligence ./path/to/project +``` + +15 tools available: `codebase_overview`, `file_context`, `get_dependents`, `find_hotspots`, `get_module_structure`, `analyze_forces`, `find_dead_exports`, `get_groups`, `symbol_context`, `search`, `detect_changes`, `impact_analysis`, `rename_symbol`, `get_processes`, `get_clusters`. + +2 prompts: `detect_impact`, `generate_map`. +3 resources: `codebase://clusters`, `codebase://processes`, `codebase://setup`. + +### CLI (for humans and CI) + +15 commands — full parity with MCP tools: + +```bash +codebase-intelligence overview ./src # Codebase snapshot +codebase-intelligence hotspots ./src # Rank files by metric +codebase-intelligence file ./src auth/login.ts # File context +codebase-intelligence search ./src "auth" # Keyword search +codebase-intelligence changes ./src # Git diff analysis +codebase-intelligence dependents ./src types.ts # File blast radius +codebase-intelligence modules ./src # Module architecture +codebase-intelligence forces ./src # Force analysis +codebase-intelligence dead-exports ./src # Unused exports +codebase-intelligence groups ./src # Directory groups +codebase-intelligence symbol ./src parseCodebase # Symbol context +codebase-intelligence impact ./src getUserById # Symbol blast radius +codebase-intelligence rename ./src old new # Rename references +codebase-intelligence processes ./src # Execution flows +codebase-intelligence clusters ./src # File clusters +``` + +Add `--json` for machine-readable output. All commands auto-cache the index. + +### Tool Selection + +| Question | MCP Tool | CLI Command | +|----------|----------|-------------| +| What does this codebase look like? | `codebase_overview` | `overview` | +| Tell me about file X | `file_context` | `file` | +| What are the riskiest files? | `find_hotspots` | `hotspots` | +| Find files related to X | `search` | `search` | +| What changed? | `detect_changes` | `changes` | +| What breaks if I change file X? | `get_dependents` | `dependents` | +| What breaks if I change function X? | `impact_analysis` | `impact` | +| What's architecturally wrong? | `analyze_forces` | `forces` | +| Who calls this function? | `symbol_context` | `symbol` | +| Find all references for rename | `rename_symbol` | `rename` | +| What files naturally group together? | `get_clusters` | `clusters` | +| What can I safely delete? | `find_dead_exports` | `dead-exports` | +| How are modules organized? | `get_module_structure` | `modules` | +| What are the main areas? | `get_groups` | `groups` | +| How does data flow? | `get_processes` | `processes` | + +## Documentation + +- `docs/architecture.md` — Pipeline, module map, data flow +- `docs/data-model.md` — All TypeScript interfaces +- `docs/metrics.md` — Per-file and module metrics, force analysis +- `docs/mcp-tools.md` — 15 MCP tools with inputs, outputs, use cases +- `docs/cli-reference.md` — CLI commands with examples +- `llms.txt` — AI-consumable doc index +- `llms-full.txt` — Full documentation for context injection + +## Metrics + +Key file metrics: PageRank, betweenness, fan-in/out, coupling, tension, churn, cyclomatic complexity, blast radius, dead exports, test coverage. + +Module metrics: cohesion, escape velocity, verdict (LEAF/COHESIVE/MODERATE/JUNK_DRAWER). + +Force analysis: tension files, bridge files, extraction candidates. + +## Project Structure + +``` +src/ + cli.ts Entry point + CLI commands + core/index.ts Shared computation (used by MCP + CLI) + types/index.ts All interfaces (single source of truth) + parser/index.ts TypeScript AST parser + graph/index.ts Dependency graph builder (graphology) + analyzer/index.ts Metric computation engine + mcp/index.ts MCP stdio server (15 tools) + impact/index.ts Symbol-level impact analysis + search/index.ts BM25 search engine + process/index.ts Entry point + call chain tracing + community/index.ts Louvain clustering + persistence/index.ts Graph cache (.code-visualizer/) +``` diff --git a/docs/architecture.md b/docs/architecture.md index 0102522..eef5e9b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,8 +18,12 @@ Analyzer | computes: churn, complexity, blast radius, dead exports, test coverage | produces: ForceAnalysis (tension files, bridges, extraction candidates) v -MCP (stdio) - | exposes: 15 tools, 2 prompts, 3 resources for LLM agents +Core (shared computation) + | result builders used by both MCP and CLI + v +MCP (stdio) CLI (terminal/CI) + | 15 tools, 2 prompts, | 5 commands: overview, hotspots, + | 3 resources for LLMs | file, search, changes + --json ``` ## Module Map @@ -30,6 +34,7 @@ src/ parser/index.ts <- TS AST extraction + git churn + test detection graph/index.ts <- graphology graph + circular dep detection analyzer/index.ts <- All metric computation + core/index.ts <- Shared result computation (MCP + CLI) mcp/index.ts <- 15 MCP tools for LLM integration mcp/hints.ts <- Next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning @@ -38,7 +43,7 @@ src/ community/index.ts <- Louvain clustering persistence/index.ts <- Graph export/import to .code-visualizer/ server/graph-store.ts <- Global graph state (shared by CLI + MCP) - cli.ts <- Entry point, wires pipeline together + cli.ts <- Entry point, CLI commands + MCP fallback ``` ## Data Flow @@ -63,12 +68,12 @@ startMcpServer(codebaseGraph) ## Key Design Decisions -- **MCP-only**: No web UI or REST API. All interaction through MCP stdio for LLM agents. +- **Dual interface**: MCP stdio for LLM agents, CLI subcommands for humans/CI. Both consume `src/core/`. - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports (known limitation). - **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes. -- **Graph persistence**: Optional `--index` flag caches parsed graph to `.code-visualizer/` for instant startup on unchanged HEAD. +- **Graph persistence**: CLI commands always cache the graph index to `.code-visualizer/`. MCP mode (`codebase-intelligence `) requires `--index` to persist the cache. ## Adding a New Metric diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..0d018cd --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,192 @@ +# CLI Reference + +15 commands for terminal and CI use. Full parity with MCP tools. All commands auto-cache the index to `.code-visualizer/`. + +## Commands + +### overview + +High-level codebase snapshot. + +```bash +codebase-intelligence overview [--json] [--force] +``` + +**Output:** file count, function count, dependency count, modules (path, files, LOC, coupling, cohesion), top 5 depended files, avg LOC, max depth, circular dep count. + +### hotspots + +Rank files by metric. + +```bash +codebase-intelligence hotspots [--metric ] [--limit ] [--json] [--force] +``` + +**Metrics:** `coupling` (default), `pagerank`, `fan_in`, `fan_out`, `betweenness`, `tension`, `churn`, `complexity`, `blast_radius`, `coverage`, `escape_velocity`. + +### file + +Detailed file context. + +```bash +codebase-intelligence file [--json] [--force] +``` + +`` is relative to the codebase root (e.g., `parser/index.ts`). + +**Output:** LOC, exports, imports, dependents, all FileMetrics. Error: prints top 3 similar path suggestions. + +### search + +BM25 keyword search. + +```bash +codebase-intelligence search [--limit ] [--json] [--force] +``` + +**Output:** Ranked results grouped by file, with symbol name, type, LOC, and relevance score. + +### changes + +Git diff analysis with risk metrics. + +```bash +codebase-intelligence changes [--scope ] [--json] [--force] +``` + +**Scope:** `staged`, `unstaged`, `all` (default). + +### dependents + +File-level blast radius: direct + transitive dependents. + +```bash +codebase-intelligence dependents [--depth ] [--json] [--force] +``` + +**Output:** direct dependents with symbols, transitive dependents with paths, total affected, risk level (LOW/MEDIUM/HIGH). + +### modules + +Module architecture with cross-module dependencies. + +```bash +codebase-intelligence modules [--json] [--force] +``` + +**Output:** modules with cohesion/escape velocity, cross-module deps, circular deps. + +### forces + +Architectural force analysis. + +```bash +codebase-intelligence forces [--cohesion ] [--tension ] [--escape ] [--json] [--force] +``` + +**Output:** module cohesion verdicts, tension files, bridge files, extraction candidates, summary. + +### dead-exports + +Find unused exports across the codebase. + +```bash +codebase-intelligence dead-exports [--module ] [--limit ] [--json] [--force] +``` + +**Output:** dead export count, files with unused exports, summary. + +### groups + +Top-level directory groups with aggregate metrics. + +```bash +codebase-intelligence groups [--json] [--force] +``` + +**Output:** groups ranked by importance with files, LOC, coupling. + +### symbol + +Function/class context with callers and callees. + +```bash +codebase-intelligence symbol [--json] [--force] +``` + +**Output:** symbol metadata, fan-in/out, PageRank, betweenness, callers, callees. + +### impact + +Symbol-level blast radius with depth-grouped impact levels. + +```bash +codebase-intelligence impact [--json] [--force] +``` + +**Output:** impact levels (WILL BREAK / LIKELY / MAY NEED TESTING), total affected. + +### rename + +Find all references for rename planning (read-only by default). + +```bash +codebase-intelligence rename [--no-dry-run] [--json] [--force] +``` + +**Output:** references with file, symbol, and confidence level. + +### processes + +Entry point execution flows through the call graph. + +```bash +codebase-intelligence processes [--entry ] [--limit ] [--json] [--force] +``` + +**Output:** processes with entry point, steps, depth, modules touched. + +### clusters + +Community-detected file clusters (Louvain algorithm). + +```bash +codebase-intelligence clusters [--min-files ] [--json] [--force] +``` + +**Output:** clusters with files, file count, cohesion. + +## Flags + +| Flag | Available On | Description | +|------|-------------|-------------| +| `--json` | All commands | Output stable JSON to stdout | +| `--force` | All commands | Re-parse even if cached index matches HEAD | +| `--metric ` | hotspots | Metric to rank by (default: coupling) | +| `--limit ` | hotspots, search, dead-exports, processes | Max results | +| `--scope ` | changes | Git diff scope: staged, unstaged, all | +| `--depth ` | dependents | Max traversal depth (default: 2) | +| `--cohesion ` | forces | Min cohesion threshold (default: 0.6) | +| `--tension ` | forces | Min tension threshold (default: 0.3) | +| `--escape ` | forces | Min escape velocity threshold (default: 0.5) | +| `--module ` | dead-exports | Filter by module path | +| `--entry ` | processes | Filter by entry point name | +| `--min-files ` | clusters | Min files per cluster | +| `--no-dry-run` | rename | Actually perform the rename (default: dry run) | + +## Behavior + +**Auto-caching:** First CLI invocation parses the codebase and saves the index to `.code-visualizer/`. Subsequent commands use the cache if `git HEAD` hasn't changed. Add `.code-visualizer/` to `.gitignore`. + +**stdout/stderr:** Results go to stdout. Progress messages go to stderr. Safe for piping (`| jq`, `> file.json`). + +**Exit codes:** +- `0` — success +- `1` — runtime error (file not found, no TS files, git unavailable) +- `2` — bad args or usage error + +**MCP mode:** Running `codebase-intelligence ` without a subcommand starts the MCP stdio server (backward compatible). MCP-specific flags: +- `--index` — persist graph index to `.code-visualizer/` (CLI auto-caches, MCP requires this flag) +- `--status` — print index status and exit +- `--clean` — remove `.code-visualizer/` index and exit +- `--force` — re-index even if HEAD unchanged diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..0523749 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,386 @@ +# Codebase Intelligence — Full Documentation + +> TypeScript codebase analysis engine — dependency graphs, architectural metrics, MCP + CLI interfaces. + +--- + +# Architecture + +## Pipeline + +``` +CLI (commander) + | + v +Parser (TS Compiler API) + | extracts: files, exports, imports, LOC, complexity, churn, test mapping + v +Graph Builder (graphology) + | creates: nodes (file + function), edges (imports with symbols/weights) + | detects: circular dependencies (iterative DFS) + v +Analyzer + | computes: PageRank, betweenness, coupling, tension, cohesion + | computes: churn, complexity, blast radius, dead exports, test coverage + | produces: ForceAnalysis (tension files, bridges, extraction candidates) + v +MCP (stdio) + CLI + | MCP: 15 tools, 2 prompts, 3 resources for LLM agents + | CLI: 5 commands with formatted + JSON output for humans/CI +``` + +## Module Map + +``` +src/ + types/index.ts <- ALL interfaces (single source of truth) + parser/index.ts <- TS AST extraction + git churn + test detection + graph/index.ts <- graphology graph + circular dep detection + analyzer/index.ts <- All metric computation + core/index.ts <- Shared result computation (MCP + CLI) + mcp/index.ts <- 15 MCP tools for LLM integration + mcp/hints.ts <- Next-step hints for MCP tool responses + impact/index.ts <- Symbol-level impact analysis + rename planning + search/index.ts <- BM25 search engine + process/index.ts <- Entry point detection + call chain tracing + community/index.ts <- Louvain clustering + persistence/index.ts <- Graph export/import to .code-visualizer/ + server/graph-store.ts <- Global graph state (shared by CLI + MCP) + cli.ts <- Entry point, CLI commands + MCP fallback +``` + +## Data Flow + +``` +parseCodebase(rootDir) + -> ParsedFile[] (with churn, complexity, test mapping) + +buildGraph(parsedFiles) + -> BuiltGraph { graph: Graph, nodes: GraphNode[], edges: GraphEdge[] } + +analyzeGraph(builtGraph, parsedFiles) + -> CodebaseGraph { + nodes, edges, symbolNodes, callEdges, symbolMetrics, + fileMetrics, moduleMetrics, forceAnalysis, stats, + groups, processes, clusters + } +``` + +## Key Design Decisions + +- **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. +- **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. +- **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports. +- **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes. +- **Auto-caching**: CLI commands always cache the graph index to `.code-visualizer/`. MCP mode requires `--index` to persist. + +--- + +# Data Model + +All types defined in `src/types/index.ts`. + +## Parser Output + +```typescript +ParsedFile { + path: string // Absolute filesystem path + relativePath: string // Relative to root (used as graph node ID) + loc: number // Lines of code + exports: ParsedExport[] // Named exports + imports: ParsedImport[] // Relative imports (external skipped) + churn: number // Git commit count (0 if non-git) + isTestFile: boolean // Matches *.test.ts / *.spec.ts / __tests__/ + testFile?: string // Path to matching test file (for source files) +} + +ParsedExport { + name: string // Export name ("default" for default exports) + type: "function" | "class" | "variable" | "type" | "interface" | "enum" + loc: number // Lines of code for this export + isDefault: boolean + complexity: number // Cyclomatic complexity (branch count, min 1) +} + +ParsedImport { + from: string // Raw import path + resolvedFrom: string // Resolved relative path (after .js->.ts mapping) + symbols: string[] // Imported names (["default"] for default import) + isTypeOnly: boolean // import type { X } +} +``` + +## Graph Structure + +```typescript +GraphNode { + id: string // = relativePath for files, parentFile+name for functions + type: "file" | "function" + path: string // Display path + label: string // File basename or function name + loc: number + module: string // Top-level directory + parentFile?: string // For function nodes: which file owns this +} + +GraphEdge { + source: string // Importer file ID + target: string // Imported file ID + symbols: string[] // What's imported + isTypeOnly: boolean // Type-only import + weight: number // Edge weight (default 1) +} +``` + +## Computed Metrics + +```typescript +FileMetrics { + pageRank: number + betweenness: number + fanIn: number + fanOut: number + coupling: number // fanOut / (max(fanIn, 1) + fanOut) + tension: number // Entropy of multi-module pulls + isBridge: boolean // betweenness > 0.1 + churn: number // Git commit count + hasTests: boolean // Test file exists + testFile: string // Path to test file + cyclomaticComplexity: number // Avg complexity of exports + blastRadius: number // Transitive dependent count + deadExports: string[] // Unused export names + isTestFile: boolean // Whether this file is a test +} + +ModuleMetrics { + path: string + files: number + loc: number + exports: number + internalDeps: number + externalDeps: number + cohesion: number // internalDeps / totalDeps + escapeVelocity: number // Extraction readiness + dependsOn: string[] + dependedBy: string[] +} +``` + +--- + +# Metrics Reference + +## Per-File Metrics + +| Metric | Range | Description | +|--------|-------|-------------| +| pageRank | 0-1 | Importance in dependency graph | +| betweenness | 0-1 | Bridge frequency between shortest paths | +| fanIn | 0-N | Files that import this file | +| fanOut | 0-N | Files this file imports | +| coupling | 0-1 | fanOut / (max(fanIn, 1) + fanOut) | +| tension | 0-1 | Multi-module pull evenness. >0.3 = tension | +| isBridge | bool | betweenness > 0.1 | +| churn | 0-N | Git commits touching this file | +| cyclomaticComplexity | 1-N | Avg complexity of exports | +| blastRadius | 0-N | Transitive dependents affected by change | +| deadExports | list | Export names not consumed by any import | +| hasTests | bool | Matching test file exists | + +## Module Metrics + +| Metric | Description | +|--------|-------------| +| cohesion | internalDeps / totalDeps. 1=fully internal | +| escapeVelocity | Extraction readiness. High = few internal deps, many consumers | +| verdict | LEAF / COHESIVE / MODERATE / JUNK_DRAWER | + +## Force Analysis + +| Signal | Threshold | Meaning | +|--------|-----------|---------| +| Tension file | tension > 0.3 | Pulled by 2+ modules equally. Split candidate | +| Bridge file | betweenness > 0.05 | Removing disconnects graph. Critical path | +| Junk drawer | cohesion < 0.4 | Mostly external deps. Needs restructuring | +| Extraction candidate | escapeVelocity >= 0.5 | 0 internal deps, many consumers. Extract to package | + +## Risk Trifecta + +The most dangerous files have: high churn + high coupling + low coverage. + +--- + +# MCP Tools Reference + +15 tools available via MCP stdio. + +## 1. codebase_overview +High-level summary. Input: `{ depth?: number }`. Returns: totalFiles, totalFunctions, modules, topDependedFiles, metrics. + +## 2. file_context +Detailed file context. Input: `{ filePath: string }`. Returns: exports, imports, dependents, all FileMetrics. + +## 3. get_dependents +File-level blast radius. Input: `{ filePath: string, depth?: number }`. Returns: direct + transitive dependents, riskLevel. + +## 4. find_hotspots +Rank files by metric. Input: `{ metric: string, limit?: number }`. Metrics: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage. + +## 5. get_module_structure +Module architecture. Input: `{ depth?: number }`. Returns: modules with metrics, cross-module deps, circular deps. + +## 6. analyze_forces +Architectural force analysis. Input: `{ cohesionThreshold?, tensionThreshold?, escapeThreshold? }`. Returns: cohesion verdicts, tension files, bridge files, extraction candidates. + +## 7. find_dead_exports +Unused exports. Input: `{ module?: string, limit?: number }`. Returns: files with dead exports. + +## 8. get_groups +Top-level directory groups. Input: `{}`. Returns: groups with rank, files, loc, importance, coupling. + +## 9. symbol_context +Function/class context. Input: `{ name: string }`. Returns: callers, callees, metrics. + +## 10. search +Keyword search (BM25). Input: `{ query: string, limit?: number }`. Returns: ranked files + symbols. + +## 11. detect_changes +Git diff analysis. Input: `{ scope?: "staged" | "unstaged" | "all" }`. Returns: changed files, affected files, risk metrics. + +## 12. impact_analysis +Symbol-level blast radius. Input: `{ symbol: string }`. Returns: depth-grouped impact levels. + +## 13. rename_symbol +Reference finder for rename planning. Input: `{ oldName: string, newName: string, dryRun?: boolean }`. Returns: references with confidence. + +## 14. get_processes +Entry point execution flows. Input: `{ entryPoint?: string, limit?: number }`. Returns: processes with steps and depth. + +## 15. get_clusters +Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. + +## Tool Selection Guide + +| Question | Tool | +|----------|------| +| What does this codebase look like? | codebase_overview | +| Tell me about file X | file_context | +| What breaks if I change file X? | get_dependents | +| What breaks if I change function X? | impact_analysis | +| What are the riskiest files? | find_hotspots | +| Which files need tests? | find_hotspots (coverage) | +| What can I safely delete? | find_dead_exports | +| How are modules organized? | get_module_structure | +| What's architecturally wrong? | analyze_forces | +| Who calls this function? | symbol_context | +| Find files related to X | search | +| What changed? | detect_changes | +| Find all references to X | rename_symbol | +| How does data flow? | get_processes | +| What files naturally belong together? | get_clusters | + +--- + +# CLI Reference + +15 commands — full parity with MCP tools. + +## Commands + +### overview +```bash +codebase-intelligence overview [--json] [--force] +``` +High-level codebase snapshot: files, functions, modules, dependencies. + +### hotspots +```bash +codebase-intelligence hotspots [--metric ] [--limit ] [--json] [--force] +``` +Rank files by metric. Default: coupling. Available: coupling, pagerank, fan_in, fan_out, betweenness, tension, churn, complexity, blast_radius, coverage, escape_velocity. + +### file +```bash +codebase-intelligence file [--json] [--force] +``` +Detailed file context: exports, imports, dependents, all metrics. + +### search +```bash +codebase-intelligence search [--limit ] [--json] [--force] +``` +BM25 keyword search across files and symbols. + +### changes +```bash +codebase-intelligence changes [--scope ] [--json] [--force] +``` +Git diff analysis with risk metrics. Scope: staged, unstaged, all (default). + +### dependents +```bash +codebase-intelligence dependents [--depth ] [--json] [--force] +``` +File-level blast radius: direct + transitive dependents, risk level. + +### modules +```bash +codebase-intelligence modules [--json] [--force] +``` +Module architecture: cohesion, cross-module deps, circular deps. + +### forces +```bash +codebase-intelligence forces [--cohesion ] [--tension ] [--escape ] [--json] [--force] +``` +Architectural force analysis: tension files, bridges, extraction candidates. + +### dead-exports +```bash +codebase-intelligence dead-exports [--module ] [--limit ] [--json] [--force] +``` +Find unused exports across the codebase. + +### groups +```bash +codebase-intelligence groups [--json] [--force] +``` +Top-level directory groups with aggregate metrics. + +### symbol +```bash +codebase-intelligence symbol [--json] [--force] +``` +Function/class context: callers, callees, metrics. + +### impact +```bash +codebase-intelligence impact [--json] [--force] +``` +Symbol-level blast radius with depth-grouped impact levels. + +### rename +```bash +codebase-intelligence rename [--no-dry-run] [--json] [--force] +``` +Find all references for rename planning (read-only by default). + +### processes +```bash +codebase-intelligence processes [--entry ] [--limit ] [--json] [--force] +``` +Entry point execution flows through the call graph. + +### clusters +```bash +codebase-intelligence clusters [--min-files ] [--json] [--force] +``` +Community-detected file clusters (Louvain algorithm). + +## Global Behavior + +- **Auto-caching**: First run parses and saves index to `.code-visualizer/`. Subsequent runs use cache if HEAD unchanged. +- **Progress**: All progress messages go to stderr. Results go to stdout. +- **JSON mode**: `--json` outputs stable JSON schema to stdout. +- **Exit codes**: 0 = success, 1 = runtime error, 2 = bad args/usage. +- **MCP mode**: `codebase-intelligence ` (no subcommand) starts MCP stdio server. diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..90b2040 --- /dev/null +++ b/llms.txt @@ -0,0 +1,41 @@ +# Codebase Intelligence + +> TypeScript codebase analysis engine — dependency graphs, architectural metrics, MCP + CLI interfaces. + +## Documentation + +- [Architecture](docs/architecture.md): Pipeline, module map, data flow, design decisions +- [Data Model](docs/data-model.md): All TypeScript interfaces with field descriptions +- [Metrics](docs/metrics.md): Per-file and module metrics, force analysis, complexity scoring +- [MCP Tools](docs/mcp-tools.md): 15 MCP tools — inputs, outputs, use cases, selection guide +- [CLI Reference](docs/cli-reference.md): CLI commands, flags, output formats, examples + +## Quick Start + +MCP mode (AI agents): +```bash +codebase-intelligence ./path/to/project +``` + +CLI mode (humans/CI) — 15 commands, full MCP parity: +```bash +codebase-intelligence overview ./src +codebase-intelligence hotspots ./src --metric coupling +codebase-intelligence file ./src parser/index.ts +codebase-intelligence search ./src "auth" +codebase-intelligence changes ./src --json +codebase-intelligence dependents ./src types.ts --depth 3 +codebase-intelligence modules ./src +codebase-intelligence forces ./src +codebase-intelligence dead-exports ./src +codebase-intelligence groups ./src +codebase-intelligence symbol ./src parseCodebase +codebase-intelligence impact ./src getUserById +codebase-intelligence rename ./src oldName newName +codebase-intelligence processes ./src --entry main +codebase-intelligence clusters ./src --min-files 3 +``` + +## Optional + +For complete documentation in a single file, see [llms-full.txt](llms-full.txt). diff --git a/package.json b/package.json index ed87eb4..50aaffb 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "graphology-communities-louvain": "^2.0.2", "graphology-metrics": "^2.3.0", "graphology-shortest-path": "^2.1.0", + "picocolors": "^1.1.1", "typescript": "^5.7.0", "zod": "^3.23.0" }, @@ -62,6 +63,10 @@ "node": ">=18" }, "files": [ - "dist" + "dist", + "docs", + "AGENTS.md", + "llms.txt", + "llms-full.txt" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bd9f0c..7c45972 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: graphology-shortest-path: specifier: ^2.1.0 version: 2.1.0(graphology-types@0.24.8) + picocolors: + specifier: ^1.1.1 + version: 1.1.1 typescript: specifier: ^5.7.0 version: 5.9.3 diff --git a/src/cli.ts b/src/cli.ts index c44fcb3..e87191d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,9 +23,28 @@ import { analyzeGraph } from "./analyzer/index.js"; import { startMcpServer } from "./mcp/index.js"; import { setIndexedHead } from "./server/graph-store.js"; import { exportGraph, importGraph } from "./persistence/index.js"; +import { + computeOverview, + computeFileContext, + computeHotspots, + computeSearch, + computeChanges, + computeDependents, + computeModuleStructure, + computeForces, + computeDeadExports, + computeGroups, + computeSymbolContext, + computeProcesses, + computeClusters, + impactAnalysis, + renameSymbol, +} from "./core/index.js"; +import type { CodebaseGraph } from "./types/index.js"; const INDEX_DIR_NAME = ".code-visualizer"; -const program = new Command(); + +// ── Helpers ───────────────────────────────────────────────── function getIndexDir(targetPath: string): string { return path.join(path.resolve(targetPath), INDEX_DIR_NAME); @@ -43,104 +62,942 @@ function getHeadHash(targetPath: string): string { } } -interface CliOptions { - mcp?: boolean; +function progress(msg: string): void { + process.stderr.write(`${msg}\n`); +} + +function output(data: string): void { + process.stdout.write(`${data}\n`); +} + +function outputJson(data: unknown): void { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); +} + +/** Load (or parse+cache) the codebase graph for a target path. */ +function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; headHash: string } { + const resolved = path.resolve(targetPath); + if (!fs.existsSync(resolved)) { + process.stderr.write(`Error: Path does not exist: ${targetPath}\n`); + process.exit(1); + } + + const indexDir = getIndexDir(targetPath); + const headHash = getHeadHash(targetPath); + + if (!force && headHash !== "unknown") { + const cached = importGraph(indexDir); + if (cached?.headHash === headHash) { + progress(`Using cached index (HEAD: ${headHash.slice(0, 7)})`); + setIndexedHead(cached.headHash); + return { graph: cached.graph, headHash }; + } + } + + progress(`Parsing ${targetPath}...`); + const files = parseCodebase(targetPath); + progress(`Parsed ${files.length} files`); + + if (files.length === 0) { + process.stderr.write(`Error: No TypeScript files found at ${targetPath}\n`); + process.exit(1); + } + + const built = buildGraph(files); + progress( + `Built graph: ${built.nodes.filter((n) => n.type === "file").length} files, ` + + `${built.nodes.filter((n) => n.type === "function").length} functions, ` + + `${built.edges.length} dependencies`, + ); + + const graph = analyzeGraph(built, files); + progress( + `Analysis complete: ${graph.stats.circularDeps.length} circular deps, ` + + `${graph.forceAnalysis.tensionFiles.length} tension files`, + ); + + setIndexedHead(headHash); + + exportGraph(graph, indexDir, headHash); + progress(`Index saved to ${indexDir}`); + + return { graph, headHash }; +} + +// ── CLI Program ───────────────────────────────────────────── + +interface CliCommandOptions { + json?: boolean; + force?: boolean; +} + +interface HotspotOptions extends CliCommandOptions { + metric?: string; + limit?: string; +} + +interface SearchOptions extends CliCommandOptions { + limit?: string; +} + +interface ChangesOptions extends CliCommandOptions { + scope?: string; +} + +interface DependentsOptions extends CliCommandOptions { + depth?: string; +} + +interface ForcesOptions extends CliCommandOptions { + cohesion?: string; + tension?: string; + escape?: string; +} + +interface DeadExportsOptions extends CliCommandOptions { + module?: string; + limit?: string; +} + +interface ProcessesOptions extends CliCommandOptions { + entry?: string; + limit?: string; +} + +interface ClustersOptions extends CliCommandOptions { + minFiles?: string; +} + +interface RenameOptions extends CliCommandOptions { + dryRun?: boolean; +} + +interface McpOptions { index?: boolean; force?: boolean; status?: boolean; clean?: boolean; } +const program = new Command(); + program .name("codebase-intelligence") - .description("Codebase analysis engine with MCP integration for LLM-assisted code understanding") - .version(pkg.version) - .argument("", "Path to the TypeScript codebase to analyze") - .option("--mcp", "Start as MCP stdio server (accepted for backward compatibility)") - .option("--index", "Persist graph index to .code-visualizer/") + .description( + "Analyze TypeScript codebases — architecture, dependencies, metrics.\n\n" + + "Commands:\n" + + " overview High-level codebase snapshot\n" + + " hotspots Rank files by metric\n" + + " file Detailed file context\n" + + " search Keyword search\n" + + " changes Git diff analysis\n" + + " dependents File-level blast radius\n" + + " modules Module architecture\n" + + " forces Architectural force analysis\n" + + " dead-exports Find unused exports\n" + + " groups Top-level directory groups\n" + + " symbol Function/class context\n" + + " impact Symbol-level blast radius\n" + + " rename Find references for rename\n" + + " processes Entry point execution flows\n" + + " clusters Community-detected file clusters\n\n" + + "MCP mode:\n" + + " codebase-intelligence Start MCP stdio server\n\n" + + "Try: codebase-intelligence overview ./src", + ) + .version(pkg.version); + +// ── Subcommand: overview ──────────────────────────────────── + +program + .command("overview") + .description("High-level codebase snapshot: files, functions, modules, dependencies") + .argument("", "Path to TypeScript codebase") + .option("--json", "Output as JSON") .option("--force", "Re-index even if HEAD unchanged") - .option("--status", "Print index status and exit") - .option("--clean", "Remove .code-visualizer/ index and exit") - .action(async (targetPath: string, options: CliOptions) => { - try { - const indexDir = getIndexDir(targetPath); - - if (options.clean) { - if (fs.existsSync(indexDir)) { - fs.rmSync(indexDir, { recursive: true, force: true }); - console.log(`Removed index at ${indexDir}`); - } else { - console.log("No index found."); + .action((targetPath: string, options: CliCommandOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = computeOverview(graph); + + if (options.json) { + outputJson(result); + return; + } + + output(`Codebase Overview`); + output(`─────────────────`); + output(`Files: ${result.totalFiles}`); + output(`Functions: ${result.totalFunctions}`); + output(`Dependencies: ${result.totalDependencies}`); + output(`Avg LOC: ${result.metrics.avgLOC}`); + output(`Max Depth: ${result.metrics.maxDepth}`); + output(`Circular: ${result.metrics.circularDeps}`); + output(``); + output(`Modules`); + output(`${"Path".padEnd(40)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Coupling".padStart(10)} ${"Cohesion".padStart(10)}`); + output(`${"─".repeat(40)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(10)}`); + for (const m of result.modules) { + output( + `${m.path.padEnd(40)} ${String(m.files).padStart(6)} ${String(m.loc).padStart(8)} ${m.avgCoupling.padStart(10)} ${m.cohesion.toFixed(2).padStart(10)}`, + ); + } + output(``); + output(`Top Depended Files`); + for (const f of result.topDependedFiles) { + output(` ${f}`); + } + }); + +// ── Subcommand: hotspots ──────────────────────────────────── + +program + .command("hotspots") + .description("Rank files by metric (coupling, pagerank, churn, complexity, blast_radius, ...)") + .argument("", "Path to TypeScript codebase") + .option("--metric ", "Metric to rank by (default: coupling)") + .option("--limit ", "Number of results (default: 10)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: HotspotOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const metric = options.metric ?? "coupling"; + const limit = options.limit ? parseInt(options.limit, 10) : 10; + if (isNaN(limit) || limit < 1) { + process.stderr.write("Error: --limit must be a positive integer\n"); + process.exit(2); + } + const result = computeHotspots(graph, metric, limit); + + if (options.json) { + outputJson(result); + return; + } + + if (!options.metric) { + progress(`Showing coupling (default). Use --metric to change.`); + } + + output(`Hotspots: ${result.metric}`); + output(`──────────${"─".repeat(result.metric.length)}`); + output(`${"Path".padEnd(50)} ${"Score".padStart(10)} Reason`); + output(`${"─".repeat(50)} ${"─".repeat(10)} ${"─".repeat(30)}`); + for (const h of result.hotspots) { + output(`${h.path.padEnd(50)} ${h.score.toFixed(2).padStart(10)} ${h.reason}`); + } + output(``); + output(result.summary); + }); + +// ── Subcommand: file ──────────────────────────────────────── + +program + .command("file") + .description("Detailed file context: exports, imports, dependents, metrics") + .argument("", "Path to TypeScript codebase") + .argument("", "File to inspect (relative to codebase root)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, filePath: string, options: CliCommandOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = computeFileContext(graph, filePath); + + if ("error" in result) { + process.stderr.write(`Error: ${result.error}\n`); + if (result.suggestions.length > 0) { + process.stderr.write(`\nDid you mean:\n`); + for (const s of result.suggestions) { + process.stderr.write(` ${s}\n`); } - return; } + process.exit(1); + } + + if (options.json) { + outputJson(result); + return; + } + + output(`File: ${result.path}`); + output("─".repeat(6 + result.path.length)); + output(`LOC: ${result.loc}`); + output(``); + + if (result.exports.length > 0) { + output(`Exports (${result.exports.length})`); + for (const e of result.exports) { + output(` ${e.type.padEnd(12)} ${e.name} (${e.loc} LOC)`); + } + output(``); + } + + if (result.imports.length > 0) { + output(`Imports (${result.imports.length})`); + for (const i of result.imports) { + const typeTag = i.isTypeOnly ? " [type]" : ""; + output(` ${i.from} → {${i.symbols.join(", ")}}${typeTag}`); + } + output(``); + } + + if (result.dependents.length > 0) { + output(`Dependents (${result.dependents.length})`); + for (const d of result.dependents) { + const typeTag = d.isTypeOnly ? " [type]" : ""; + output(` ${d.path} → {${d.symbols.join(", ")}}${typeTag}`); + } + output(``); + } + + output(`Metrics`); + output(` PageRank: ${result.metrics.pageRank}`); + output(` Betweenness: ${result.metrics.betweenness}`); + output(` Fan-in: ${result.metrics.fanIn}`); + output(` Fan-out: ${result.metrics.fanOut}`); + output(` Coupling: ${result.metrics.coupling}`); + output(` Tension: ${result.metrics.tension}`); + output(` Bridge: ${result.metrics.isBridge ? "yes" : "no"}`); + output(` Churn: ${result.metrics.churn}`); + output(` Complexity: ${result.metrics.cyclomaticComplexity}`); + output(` Blast radius: ${result.metrics.blastRadius}`); + output(` Has tests: ${result.metrics.hasTests ? `yes (${result.metrics.testFile})` : "no"}`); + + if (result.metrics.deadExports.length > 0) { + output(` Dead exports: ${result.metrics.deadExports.join(", ")}`); + } + }); + +// ── Subcommand: search ────────────────────────────────────── + +program + .command("search") + .description("Keyword search across files and symbols (BM25)") + .argument("", "Path to TypeScript codebase") + .argument("", "Search query") + .option("--limit ", "Number of results (default: 20)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, query: string, options: SearchOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const limit = options.limit ? parseInt(options.limit, 10) : 20; + if (isNaN(limit) || limit < 1) { + process.stderr.write("Error: --limit must be a positive integer\n"); + process.exit(2); + } + const result = computeSearch(graph, query, limit); + + if (options.json) { + outputJson(result); + return; + } + + if (result.results.length === 0) { + output(`No results for "${query}"`); + if (result.suggestions && result.suggestions.length > 0) { + output(`\nDid you mean: ${result.suggestions.join(", ")}?`); + } + return; + } + + output(`Search: "${query}" (${result.results.length} results)`); + output("─".repeat(40)); + for (const r of result.results) { + output(`${r.file} (score: ${r.score.toFixed(2)})`); + for (const s of r.symbols) { + output(` ${s.type.padEnd(12)} ${s.name} (${s.loc} LOC, relevance: ${s.relevance.toFixed(2)})`); + } + } + }); + +// ── Subcommand: changes ───────────────────────────────────── + +program + .command("changes") + .description("Analyze git changes: affected files, symbols, risk metrics") + .argument("", "Path to TypeScript codebase") + .option("--scope ", "Diff scope: staged, unstaged, or all (default: all)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: ChangesOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = computeChanges(graph, options.scope); + + if ("error" in result) { + process.stderr.write(`Error: ${result.error}\n`); + process.exit(1); + } + + if (options.json) { + outputJson(result); + return; + } + + output(`Changes (${result.scope})`); + output("─".repeat(20)); + + if (result.changedFiles.length === 0) { + output(`No changes detected.`); + return; + } + + output(`Changed files (${result.changedFiles.length}):`); + for (const f of result.changedFiles) { + output(` ${f}`); + } + + if (result.changedSymbols.length > 0) { + output(``); + output(`Changed symbols:`); + for (const cs of result.changedSymbols) { + output(` ${cs.file}: ${cs.symbols.join(", ")}`); + } + } + + if (result.affectedFiles.length > 0) { + output(``); + output(`Affected files (${result.affectedFiles.length}):`); + for (const f of result.affectedFiles) { + output(` ${f}`); + } + } + + if (result.fileRiskMetrics.length > 0) { + output(``); + output(`Risk Metrics`); + output(`${"File".padEnd(50)} ${"Blast".padStart(8)} ${"Cmplx".padStart(8)} ${"Churn".padStart(8)}`); + output(`${"─".repeat(50)} ${"─".repeat(8)} ${"─".repeat(8)} ${"─".repeat(8)}`); + for (const m of result.fileRiskMetrics) { + output( + `${m.file.padEnd(50)} ${String(m.blastRadius).padStart(8)} ${m.complexity.toFixed(1).padStart(8)} ${String(m.churn).padStart(8)}`, + ); + } + } + }); + +// ── Subcommand: dependents ────────────────────────────────── + +program + .command("dependents") + .description("File-level blast radius: direct + transitive dependents") + .argument("", "Path to TypeScript codebase") + .argument("", "File to inspect (relative to codebase root)") + .option("--depth ", "Max traversal depth (default: 2)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, filePath: string, options: DependentsOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const depth = options.depth ? parseInt(options.depth, 10) : undefined; + if (depth !== undefined && (isNaN(depth) || depth < 1)) { + process.stderr.write("Error: --depth must be a positive integer\n"); + process.exit(2); + } + const result = computeDependents(graph, filePath, depth); + + if ("error" in result) { + process.stderr.write(`Error: ${result.error}\n`); + process.exit(1); + } + + if (options.json) { + outputJson(result); + return; + } + + output(`Dependents: ${result.file}`); + output("─".repeat(13 + result.file.length)); + output(`Risk level: ${result.riskLevel}`); + output(`Total affected: ${result.totalAffected}`); + output(``); + + if (result.directDependents.length > 0) { + output(`Direct dependents (${result.directDependents.length}):`); + for (const d of result.directDependents) { + output(` ${d.path} → {${d.symbols.join(", ")}}`); + } + output(``); + } + + if (result.transitiveDependents.length > 0) { + output(`Transitive dependents (${result.transitiveDependents.length}):`); + for (const t of result.transitiveDependents) { + output(` ${t.path} (depth ${t.depth}, via ${t.throughPath.join(" → ")})`); + } + } + }); + +// ── Subcommand: modules ──────────────────────────────────── + +program + .command("modules") + .description("Module architecture: cohesion, cross-module deps, circular deps") + .argument("", "Path to TypeScript codebase") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: CliCommandOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = computeModuleStructure(graph); + + if (options.json) { + outputJson(result); + return; + } + + output(`Module Structure`); + output(`────────────────`); + output(`${"Path".padEnd(30)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Cohesion".padStart(10)} ${"EscVel".padStart(8)}`); + output(`${"─".repeat(30)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(10)} ${"─".repeat(8)}`); + for (const m of result.modules) { + output( + `${m.path.padEnd(30)} ${String(m.files).padStart(6)} ${String(m.loc).padStart(8)} ${m.cohesion.toFixed(2).padStart(10)} ${m.escapeVelocity.toFixed(2).padStart(8)}`, + ); + } + + if (result.crossModuleDeps.length > 0) { + output(``); + output(`Cross-Module Dependencies (${result.crossModuleDeps.length}):`); + for (const d of result.crossModuleDeps.slice(0, 20)) { + output(` ${d.from} → ${d.to} (weight: ${d.weight})`); + } + } + + if (result.circularDeps.length > 0) { + output(``); + output(`Circular Dependencies (${result.circularDeps.length}):`); + for (const c of result.circularDeps) { + output(` [${c.severity}] ${c.cycle.map((p) => p.join(" → ")).join("; ")}`); + } + } + }); + +// ── Subcommand: forces ───────────────────────────────────── - if (options.status) { - const result = importGraph(indexDir); - if (!result) { - console.log("No index found. Run with --index to create one."); - return; +program + .command("forces") + .description("Architectural force analysis: tension, bridges, extraction candidates") + .argument("", "Path to TypeScript codebase") + .option("--cohesion ", "Min cohesion threshold (default: 0.6)") + .option("--tension ", "Min tension threshold (default: 0.3)") + .option("--escape ", "Min escape velocity threshold (default: 0.5)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: ForcesOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const cohesion = options.cohesion ? parseFloat(options.cohesion) : undefined; + const tension = options.tension ? parseFloat(options.tension) : undefined; + const escape = options.escape ? parseFloat(options.escape) : undefined; + const result = computeForces(graph, cohesion, tension, escape); + + if (options.json) { + outputJson(result); + return; + } + + output(`Force Analysis`); + output(`──────────────`); + output(result.summary); + output(``); + + output(`Module Cohesion:`); + for (const m of result.moduleCohesion) { + output(` ${m.path.padEnd(30)} ${m.verdict.padEnd(14)} cohesion: ${m.cohesion.toFixed(2)}`); + } + + if (result.tensionFiles.length > 0) { + output(``); + output(`Tension Files (${result.tensionFiles.length}):`); + for (const t of result.tensionFiles) { + output(` ${t.file} (tension: ${t.tension.toFixed(2)})`); + for (const p of t.pulledBy) { + output(` ← ${p.module} (strength: ${p.strength.toFixed(2)}, symbols: ${p.symbols.join(", ")})`); } - const metaPath = path.join(indexDir, "meta.json"); - const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")) as { - headHash: string; - timestamp: string; - }; - console.log(`Index status:`); - console.log(` Head: ${meta.headHash}`); - console.log(` Indexed: ${meta.timestamp}`); - console.log(` Files: ${result.graph.nodes.filter((n) => n.type === "file").length}`); - console.log(` Symbols: ${result.graph.symbolNodes.length}`); - console.log(` Edges: ${result.graph.edges.length}`); - return; - } - - const headHash = getHeadHash(targetPath); - - if (!options.force) { - const cached = importGraph(indexDir); - if (cached?.headHash === headHash) { - console.log(`Using cached index (HEAD: ${headHash.slice(0, 7)})`); - const codebaseGraph = cached.graph; - setIndexedHead(cached.headHash); - await startMcpServer(codebaseGraph); - return; + } + } + + if (result.bridgeFiles.length > 0) { + output(``); + output(`Bridge Files (${result.bridgeFiles.length}):`); + for (const b of result.bridgeFiles) { + output(` ${b.file} (betweenness: ${b.betweenness.toFixed(3)}, role: ${b.role})`); + } + } + + if (result.extractionCandidates.length > 0) { + output(``); + output(`Extraction Candidates (${result.extractionCandidates.length}):`); + for (const e of result.extractionCandidates) { + output(` ${e.target} (escape velocity: ${e.escapeVelocity.toFixed(2)})`); + output(` ${e.recommendation}`); + } + } + }); + +// ── Subcommand: dead-exports ─────────────────────────────── + +program + .command("dead-exports") + .description("Find unused exports across the codebase") + .argument("", "Path to TypeScript codebase") + .option("--module ", "Filter by module path") + .option("--limit ", "Max results (default: 20)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: DeadExportsOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const limit = options.limit ? parseInt(options.limit, 10) : undefined; + if (limit !== undefined && (isNaN(limit) || limit < 1)) { + process.stderr.write("Error: --limit must be a positive integer\n"); + process.exit(2); + } + const result = computeDeadExports(graph, options.module, limit); + + if (options.json) { + outputJson(result); + return; + } + + output(`Dead Exports`); + output(`────────────`); + output(result.summary); + + if (result.files.length > 0) { + output(``); + for (const f of result.files) { + output(`${f.path} (${f.deadExports.length}/${f.totalExports} unused):`); + for (const e of f.deadExports) { + output(` - ${e}`); } } + } + }); - console.log(`Parsing ${targetPath}...`); - const files = parseCodebase(targetPath); - console.log(`Parsed ${files.length} files`); +// ── Subcommand: groups ───────────────────────────────────── - const built = buildGraph(files); - console.log( - `Built graph: ${built.nodes.filter((n) => n.type === "file").length} files, ` + - `${built.nodes.filter((n) => n.type === "function").length} functions, ` + - `${built.edges.length} dependencies`, - ); +program + .command("groups") + .description("Top-level directory groups with aggregate metrics") + .argument("", "Path to TypeScript codebase") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: CliCommandOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = computeGroups(graph); - const codebaseGraph = analyzeGraph(built, files); - console.log( - `Analysis complete: ${codebaseGraph.stats.circularDeps.length} circular deps, ` + - `${codebaseGraph.forceAnalysis.tensionFiles.length} tension files`, + if (options.json) { + outputJson(result); + return; + } + + output(`Groups`); + output(`──────`); + output(`${"#".padStart(3)} ${"Name".padEnd(20)} ${"Files".padStart(6)} ${"LOC".padStart(8)} ${"Importance".padStart(12)} ${"Coupling".padStart(10)}`); + output(`${"─".repeat(3)} ${"─".repeat(20)} ${"─".repeat(6)} ${"─".repeat(8)} ${"─".repeat(12)} ${"─".repeat(10)}`); + for (const g of result.groups) { + output( + `${String(g.rank).padStart(3)} ${g.name.padEnd(20)} ${String(g.files).padStart(6)} ${String(g.loc).padStart(8)} ${g.importance.padStart(12)} ${String(g.coupling.total).padStart(10)}`, ); + } + }); + +// ── Subcommand: symbol ───────────────────────────────────── - setIndexedHead(headHash); +program + .command("symbol") + .description("Function/class context: callers, callees, metrics") + .argument("", "Path to TypeScript codebase") + .argument("", "Symbol name (e.g., 'AuthService', 'getUserById')") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, symbolName: string, options: CliCommandOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = computeSymbolContext(graph, symbolName); - if (options.index) { - exportGraph(codebaseGraph, indexDir, headHash); - console.log(`Index saved to ${indexDir}`); + if ("error" in result) { + process.stderr.write(`Error: ${result.error}\n`); + process.exit(1); + } + + if (options.json) { + outputJson(result); + return; + } + + output(`Symbol: ${result.name}`); + output("─".repeat(8 + result.name.length)); + output(`File: ${result.file}`); + output(`Type: ${result.type}`); + output(`LOC: ${result.loc}`); + output(`Default: ${result.isDefault ? "yes" : "no"}`); + output(`Complexity: ${result.complexity}`); + output(`Fan-in: ${result.fanIn}`); + output(`Fan-out: ${result.fanOut}`); + output(`PageRank: ${result.pageRank}`); + output(`Betweenness:${result.betweenness}`); + + if (result.callers.length > 0) { + output(``); + output(`Callers (${result.callers.length}):`); + for (const c of result.callers) { + output(` ${c.symbol} (${c.file}) [${c.confidence}]`); } + } - await startMcpServer(codebaseGraph); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } else { - console.error("Unknown error:", error); + if (result.callees.length > 0) { + output(``); + output(`Callees (${result.callees.length}):`); + for (const c of result.callees) { + output(` ${c.symbol} (${c.file}) [${c.confidence}]`); } + } + }); + +// ── Subcommand: impact ───────────────────────────────────── + +program + .command("impact") + .description("Symbol-level blast radius with depth-grouped impact levels") + .argument("", "Path to TypeScript codebase") + .argument("", "Symbol name (e.g., 'getUserById')") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, symbol: string, options: CliCommandOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const result = impactAnalysis(graph, symbol); + + if (result.notFound) { + process.stderr.write(`Error: Symbol not found: ${symbol}\n`); process.exit(1); } + + if (options.json) { + outputJson(result); + return; + } + + output(`Impact Analysis: ${symbol}`); + output("─".repeat(18 + symbol.length)); + output(`Total affected: ${result.totalAffected}`); + + if (result.levels.length > 0) { + output(``); + for (const level of result.levels) { + output(`Depth ${level.depth} — ${level.risk} (${level.affected.length}):`); + for (const a of level.affected) { + output(` ${a.symbol} (${a.file}) [${a.confidence}]`); + } + } + } + }); + +// ── Subcommand: rename ───────────────────────────────────── + +program + .command("rename") + .description("Find all references for rename planning (read-only)") + .argument("", "Path to TypeScript codebase") + .argument("", "Current symbol name") + .argument("", "New symbol name") + .option("--no-dry-run", "Actually perform the rename (default: dry run)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, oldName: string, newName: string, options: RenameOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const dryRun = options.dryRun !== false; + const result = renameSymbol(graph, oldName, newName, dryRun); + + if (options.json) { + outputJson(result); + return; + } + + output(`Rename: ${oldName} → ${newName}${dryRun ? " (dry run)" : ""}`); + output("─".repeat(40)); + + if (result.references.length === 0) { + output(`No references found for "${oldName}"`); + return; + } + + output(`References (${result.references.length}):`); + for (const ref of result.references) { + output(` ${ref.file} [${ref.confidence}] ${ref.symbol}`); + } + }); + +// ── Subcommand: processes ────────────────────────────────── + +program + .command("processes") + .description("Entry point execution flows through the call graph") + .argument("", "Path to TypeScript codebase") + .option("--entry ", "Filter by entry point name") + .option("--limit ", "Max processes to return") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: ProcessesOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const limit = options.limit ? parseInt(options.limit, 10) : undefined; + if (limit !== undefined && (isNaN(limit) || limit < 1)) { + process.stderr.write("Error: --limit must be a positive integer\n"); + process.exit(2); + } + const result = computeProcesses(graph, options.entry, limit); + + if (options.json) { + outputJson(result); + return; + } + + output(`Processes (${result.processes.length} of ${result.totalProcesses})`); + output("─".repeat(30)); + + if (result.processes.length === 0) { + output(`No processes found.`); + return; + } + + for (const p of result.processes) { + output(``); + output(`${p.name} (depth: ${p.depth}, modules: ${p.modulesTouched.join(", ")})`); + output(` Entry: ${p.entryPoint.file}::${p.entryPoint.symbol}`); + for (const s of p.steps) { + output(` ${String(s.step).padStart(3)}. ${s.file}::${s.symbol}`); + } + } + }); + +// ── Subcommand: clusters ─────────────────────────────────── + +program + .command("clusters") + .description("Community-detected file clusters (Louvain algorithm)") + .argument("", "Path to TypeScript codebase") + .option("--min-files ", "Min files per cluster (default: 0)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: ClustersOptions) => { + const { graph } = loadGraph(targetPath, options.force); + const minFiles = options.minFiles ? parseInt(options.minFiles, 10) : undefined; + if (minFiles !== undefined && (isNaN(minFiles) || minFiles < 1)) { + process.stderr.write("Error: --min-files must be a positive integer\n"); + process.exit(2); + } + const result = computeClusters(graph, minFiles); + + if (options.json) { + outputJson(result); + return; + } + + output(`Clusters (${result.clusters.length} of ${result.totalClusters})`); + output("─".repeat(30)); + + for (const c of result.clusters) { + output(``); + output(`${c.name} (${c.fileCount} files, cohesion: ${c.cohesion.toFixed(2)})`); + for (const f of c.files) { + output(` ${f}`); + } + } + }); + +// ── MCP fallback (backward compat) ────────────────────────── + +program + .command("mcp", { hidden: true }) + .description("Start MCP stdio server (explicit)") + .argument("", "Path to TypeScript codebase") + .option("--index", "Persist graph index") + .option("--force", "Re-index even if HEAD unchanged") + .action(async (targetPath: string, options: McpOptions) => { + await runMcpMode(targetPath, options); + }); + +async function runMcpMode(targetPath: string, options: McpOptions): Promise { + const indexDir = getIndexDir(targetPath); + + if (options.clean) { + if (fs.existsSync(indexDir)) { + fs.rmSync(indexDir, { recursive: true, force: true }); + progress(`Removed index at ${indexDir}`); + } else { + progress("No index found."); + } + return; + } + + if (options.status) { + const result = importGraph(indexDir); + if (!result) { + progress("No index found. Run with --index to create one."); + return; + } + const metaPath = path.join(indexDir, "meta.json"); + const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")) as { + headHash: string; + timestamp: string; + }; + progress(`Index status:`); + progress(` Head: ${meta.headHash}`); + progress(` Indexed: ${meta.timestamp}`); + progress(` Files: ${result.graph.nodes.filter((n) => n.type === "file").length}`); + progress(` Symbols: ${result.graph.symbolNodes.length}`); + progress(` Edges: ${result.graph.edges.length}`); + return; + } + + const headHash = getHeadHash(targetPath); + + if (!options.force && headHash !== "unknown") { + const cached = importGraph(indexDir); + if (cached?.headHash === headHash) { + progress(`Using cached index (HEAD: ${headHash.slice(0, 7)})`); + setIndexedHead(cached.headHash); + await startMcpServer(cached.graph); + return; + } + } + + progress(`Parsing ${targetPath}...`); + const files = parseCodebase(targetPath); + progress(`Parsed ${files.length} files`); + + const built = buildGraph(files); + progress( + `Built graph: ${built.nodes.filter((n) => n.type === "file").length} files, ` + + `${built.nodes.filter((n) => n.type === "function").length} functions, ` + + `${built.edges.length} dependencies`, + ); + + const codebaseGraph = analyzeGraph(built, files); + progress( + `Analysis complete: ${codebaseGraph.stats.circularDeps.length} circular deps, ` + + `${codebaseGraph.forceAnalysis.tensionFiles.length} tension files`, + ); + + setIndexedHead(headHash); + + if (options.index) { + exportGraph(codebaseGraph, indexDir, headHash); + progress(`Index saved to ${indexDir}`); + } + + await startMcpServer(codebaseGraph); +} + +// ── Default action: bare → MCP mode ────────────────── + +program + .argument("[path]", "Path to codebase (starts MCP mode)") + .option("--mcp", "Start as MCP stdio server (backward compatibility)") + .option("--index", "Persist graph index to .code-visualizer/") + .option("--force", "Re-index even if HEAD unchanged") + .option("--status", "Print index status and exit") + .option("--clean", "Remove .code-visualizer/ index and exit") + .action(async (targetPath: string | undefined, options: McpOptions) => { + if (!targetPath) { + program.help(); + return; + } + await runMcpMode(targetPath, options); }); program.parse(); diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..4d0177f --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,740 @@ +import { execSync } from "node:child_process"; +import type { CodebaseGraph } from "../types/index.js"; +import { createSearchIndex, search, getSuggestions } from "../search/index.js"; +import type { SearchIndex } from "../search/index.js"; +import { impactAnalysis, renameSymbol } from "../impact/index.js"; + +// ── Path helpers ──────────────────────────────────────────── + +export function normalizeFilePath(filePath: string): string { + let normalized = filePath.replace(/\\/g, "/"); + normalized = normalized.replace(/^(src|lib|app)\//, ""); + return normalized; +} + +export function resolveFilePath(normalizedPath: string, graph: CodebaseGraph): string | undefined { + if (graph.fileMetrics.has(normalizedPath)) return normalizedPath; + return undefined; +} + +export function suggestSimilarPaths(queryPath: string, graph: CodebaseGraph): string[] { + const allPaths = [...graph.fileMetrics.keys()]; + const queryLower = queryPath.toLowerCase(); + const queryBasename = queryPath.split("/").pop() ?? queryPath; + const queryBasenameLower = queryBasename.toLowerCase(); + + const scored = allPaths.map((p) => { + const pLower = p.toLowerCase(); + const pBasename = (p.split("/").pop() ?? p).toLowerCase(); + let score = 0; + if (pLower.includes(queryLower)) score += 10; + if (pBasename === queryBasenameLower) score += 5; + if (pLower.includes(queryBasenameLower)) score += 3; + + const shorter = queryLower.length < pLower.length ? queryLower : pLower; + const longer = queryLower.length < pLower.length ? pLower : queryLower; + let commonPrefix = 0; + for (let i = 0; i < shorter.length; i++) { + if (shorter[i] === longer[i]) commonPrefix++; + else break; + } + score += commonPrefix * 0.1; + + return { path: p, score }; + }); + + const matches = scored + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map((s) => s.path); + + if (matches.length > 0) return matches; + return allPaths.slice(0, 3); +} + +// ── Search index cache ────────────────────────────────────── + +const searchIndexCache = new WeakMap(); + +export function getSearchIndex(graph: CodebaseGraph): SearchIndex { + let idx = searchIndexCache.get(graph); + if (!idx) { + idx = createSearchIndex(graph); + searchIndexCache.set(graph, idx); + } + return idx; +} + +// ── Shared lookup helpers ─────────────────────────────────── + +function buildNodeById(graph: CodebaseGraph): Map { + return new Map(graph.nodes.map((n) => [n.id, n])); +} + +function buildReverseAdjacency(graph: CodebaseGraph): Map { + const rev = new Map(); + for (const e of graph.edges) { + const existing = rev.get(e.target); + if (existing) existing.push(e.source); + else rev.set(e.target, [e.source]); + } + return rev; +} + +// ── Result computation functions ──────────────────────────── +// Each returns a plain object. MCP wraps in protocol, CLI formats for terminal. + +export interface OverviewResult { + totalFiles: number; + totalFunctions: number; + totalDependencies: number; + modules: Array<{ path: string; files: number; loc: number; avgCoupling: string; cohesion: number }>; + topDependedFiles: string[]; + metrics: { avgLOC: number; maxDepth: number; circularDeps: number }; +} + +export function computeOverview(graph: CodebaseGraph): OverviewResult { + const modules = [...graph.moduleMetrics.values()].map((m) => ({ + path: m.path, + files: m.files, + loc: m.loc, + avgCoupling: m.cohesion < 0.4 ? "HIGH" : m.cohesion < 0.7 ? "MEDIUM" : "LOW", + cohesion: m.cohesion, + })); + + const topDepended = [...graph.fileMetrics.entries()] + .sort(([, a], [, b]) => b.fanIn - a.fanIn) + .slice(0, 5) + .map(([path, m]) => `${path} (${m.fanIn} dependents)`); + + const maxDepth = graph.nodes + .filter((n) => n.type === "file") + .reduce((max, n) => Math.max(max, n.path.split("/").length), 0); + + return { + totalFiles: graph.stats.totalFiles, + totalFunctions: graph.stats.totalFunctions, + totalDependencies: graph.stats.totalDependencies, + modules: modules.sort((a, b) => b.files - a.files), + topDependedFiles: topDepended, + metrics: { + avgLOC: Math.round( + graph.nodes.filter((n) => n.type === "file").reduce((sum, n) => sum + n.loc, 0) / + graph.stats.totalFiles + ), + maxDepth, + circularDeps: graph.stats.circularDeps.length, + }, + }; +} + +export interface FileContextResult { + path: string; + loc: number; + exports: Array<{ name: string; type: string; loc: number }>; + imports: Array<{ from: string; symbols: string[]; isTypeOnly: boolean; weight: number }>; + dependents: Array<{ path: string; symbols: string[]; isTypeOnly: boolean; weight: number }>; + metrics: { + pageRank: number; + betweenness: number; + fanIn: number; + fanOut: number; + coupling: number; + tension: number; + isBridge: boolean; + churn: number; + cyclomaticComplexity: number; + blastRadius: number; + deadExports: string[]; + hasTests: boolean; + testFile: string; + }; +} + +export type FileContextError = { error: string; suggestions: string[] }; + +export function computeFileContext( + graph: CodebaseGraph, + rawFilePath: string, +): FileContextResult | FileContextError { + const normalizedPath = normalizeFilePath(rawFilePath); + const filePath = resolveFilePath(normalizedPath, graph); + if (!filePath) { + return { error: `File not found in graph: ${normalizedPath}`, suggestions: suggestSimilarPaths(normalizedPath, graph) }; + } + + const metrics = graph.fileMetrics.get(filePath); + if (!metrics) { + return { error: `File not found in graph: ${normalizedPath}`, suggestions: suggestSimilarPaths(normalizedPath, graph) }; + } + + const nodeById = buildNodeById(graph); + const node = nodeById.get(filePath); + const fileExports = graph.nodes + .filter((n) => n.parentFile === filePath) + .map((n) => ({ name: n.label, type: n.type, loc: n.loc })); + + const imports = graph.edges + .filter((e) => e.source === filePath) + .map((e) => ({ from: e.target, symbols: e.symbols, isTypeOnly: e.isTypeOnly, weight: e.weight })); + + const dependents = graph.edges + .filter((e) => e.target === filePath) + .map((e) => ({ path: e.source, symbols: e.symbols, isTypeOnly: e.isTypeOnly, weight: e.weight })); + + return { + path: filePath, + loc: node?.loc ?? 0, + exports: fileExports, + imports, + dependents, + metrics: { + pageRank: Math.round(metrics.pageRank * 1000) / 1000, + betweenness: Math.round(metrics.betweenness * 100) / 100, + fanIn: metrics.fanIn, + fanOut: metrics.fanOut, + coupling: Math.round(metrics.coupling * 100) / 100, + tension: metrics.tension, + isBridge: metrics.isBridge, + churn: metrics.churn, + cyclomaticComplexity: metrics.cyclomaticComplexity, + blastRadius: metrics.blastRadius, + deadExports: metrics.deadExports, + hasTests: metrics.hasTests, + testFile: metrics.testFile, + }, + }; +} + +export interface HotspotEntry { + path: string; + score: number; + reason: string; +} + +export interface HotspotsResult { + metric: string; + hotspots: HotspotEntry[]; + summary: string; +} + +export function computeHotspots( + graph: CodebaseGraph, + metric: string, + limit?: number, +): HotspotsResult { + const maxResults = limit ?? 10; + const scored: HotspotEntry[] = []; + + if (metric === "escape_velocity") { + for (const mod of graph.moduleMetrics.values()) { + scored.push({ + path: mod.path, + score: mod.escapeVelocity, + reason: `${mod.dependedBy.length} modules depend on it, ${mod.externalDeps} external deps`, + }); + } + } else { + const filterTestFiles = metric === "coverage" || metric === "coupling"; + for (const [filePath, metrics] of graph.fileMetrics) { + if (filterTestFiles && metrics.isTestFile) continue; + + let score: number; + let reason: string; + + switch (metric) { + case "coupling": + score = metrics.coupling; + reason = `fan-in: ${metrics.fanIn}, fan-out: ${metrics.fanOut}`; + break; + case "pagerank": + score = metrics.pageRank; + reason = `${metrics.fanIn} dependents`; + break; + case "fan_in": + score = metrics.fanIn; + reason = `${metrics.fanIn} files import this`; + break; + case "fan_out": + score = metrics.fanOut; + reason = `imports ${metrics.fanOut} files`; + break; + case "betweenness": + score = metrics.betweenness; + reason = metrics.isBridge ? "bridge between clusters" : "on many shortest paths"; + break; + case "tension": + score = metrics.tension; + reason = score > 0 ? "pulled by multiple modules" : "no tension"; + break; + case "churn": + score = metrics.churn; + reason = `${metrics.churn} commits touching this file`; + break; + case "complexity": + score = metrics.cyclomaticComplexity; + reason = `avg cyclomatic complexity: ${metrics.cyclomaticComplexity.toFixed(1)}`; + break; + case "blast_radius": + score = metrics.blastRadius; + reason = `${metrics.blastRadius} transitive dependents affected if changed`; + break; + case "coverage": + score = metrics.hasTests ? 0 : 1; + reason = metrics.hasTests ? `tested (${metrics.testFile})` : "no test file found"; + break; + default: + score = 0; + reason = ""; + } + + scored.push({ path: filePath, score, reason }); + } + } + + const hotspots = scored.sort((a, b) => b.score - a.score).slice(0, maxResults); + const topIssue = hotspots[0]; + const summary = + hotspots.length > 0 + ? `Top ${metric} hotspot: ${topIssue.path} (${topIssue.score.toFixed(2)}). ${topIssue.reason}.` + : `No significant ${metric} hotspots found.`; + + return { metric, hotspots, summary }; +} + +export interface SearchResultEntry { + file: string; + score: number; + symbols: Array<{ name: string; type: string; loc: number; relevance: number }>; +} + +export interface SearchResult { + query: string; + results: SearchResultEntry[]; + suggestions?: string[]; +} + +export function computeSearch( + graph: CodebaseGraph, + query: string, + limit?: number, +): SearchResult { + const idx = getSearchIndex(graph); + const results = search(idx, query, limit ?? 20); + + if (results.length === 0) { + const suggestions = getSuggestions(idx, query); + return { query, results: [], suggestions }; + } + + const mapped = results.map((r) => ({ + file: r.file, + score: r.score, + symbols: r.symbols.map((s) => ({ + name: s.name, + type: s.type, + loc: s.loc, + relevance: s.score, + })), + })); + + return { query, results: mapped }; +} + +export interface ChangesResult { + scope: string; + changedFiles: string[]; + changedSymbols: Array<{ file: string; symbols: string[] }>; + affectedFiles: string[]; + fileRiskMetrics: Array<{ file: string; blastRadius: number; complexity: number; churn: number }>; +} + +export type ChangesError = { error: string; scope: string }; + +export function computeChanges( + graph: CodebaseGraph, + scope?: string, +): ChangesResult | ChangesError { + const diffScope = scope ?? "all"; + try { + let diffCmd: string; + switch (diffScope) { + case "staged": diffCmd = "git diff --cached --name-only"; break; + case "unstaged": diffCmd = "git diff --name-only"; break; + default: diffCmd = "git diff HEAD --name-only"; break; + } + + const output = execSync(diffCmd, { encoding: "utf-8", timeout: 5000 }).trim(); + const changedFiles = output ? output.split("\n").filter((f) => f.length > 0) : []; + + const changedSymbols: Array<{ file: string; symbols: string[] }> = []; + const affectedFiles: string[] = []; + const fileRiskMetrics: Array<{ file: string; blastRadius: number; complexity: number; churn: number }> = []; + + const symbolsByFile = new Map(); + for (const m of graph.symbolMetrics.values()) { + const existing = symbolsByFile.get(m.file); + if (existing) { + existing.push(m.name); + } else { + symbolsByFile.set(m.file, [m.name]); + } + } + + const edgesByTarget = new Map(); + for (const e of graph.edges) { + const existing = edgesByTarget.get(e.target); + if (existing) { + existing.push(e.source); + } else { + edgesByTarget.set(e.target, [e.source]); + } + } + + const fileMetricKeys = [...graph.fileMetrics.keys()]; + + for (const file of changedFiles) { + const fileSymbols = symbolsByFile.get(file) + ?? fileMetricKeys.filter((k) => file.endsWith(k)).flatMap((k) => symbolsByFile.get(k) ?? []); + if (fileSymbols.length > 0) { + changedSymbols.push({ file, symbols: fileSymbols }); + } + + const dependents = edgesByTarget.get(file) + ?? fileMetricKeys.filter((k) => file.endsWith(k)).flatMap((k) => edgesByTarget.get(k) ?? []); + affectedFiles.push(...dependents); + + const matchKey = graph.fileMetrics.has(file) + ? file + : fileMetricKeys.find((k) => file.endsWith(k)); + const metrics = matchKey ? graph.fileMetrics.get(matchKey) : undefined; + if (metrics) { + fileRiskMetrics.push({ + file, + blastRadius: metrics.blastRadius, + complexity: metrics.cyclomaticComplexity, + churn: metrics.churn, + }); + } + } + + return { + scope: diffScope, + changedFiles, + changedSymbols, + affectedFiles: [...new Set(affectedFiles)], + fileRiskMetrics, + }; + } catch { + return { error: "Git not available or not in a git repository", scope: diffScope }; + } +} + +// ── Dependents ────────────────────────────────────────────── + +export interface DependentsResult { + file: string; + directDependents: Array<{ path: string; symbols: string[] }>; + transitiveDependents: Array<{ path: string; throughPath: string[]; depth: number }>; + totalAffected: number; + riskLevel: string; +} + +export type DependentsError = { error: string }; + +export function computeDependents( + graph: CodebaseGraph, + filePath: string, + depth?: number, +): DependentsResult | DependentsError { + if (!graph.fileMetrics.has(filePath)) { + return { error: `File not found in graph: ${filePath}` }; + } + + const maxDepth = depth ?? 2; + const directDependents = graph.edges + .filter((e) => e.target === filePath) + .map((e) => ({ path: e.source, symbols: e.symbols })); + + const reverseAdj = buildReverseAdjacency(graph); + const transitive: Array<{ path: string; throughPath: string[]; depth: number }> = []; + const visited = new Set([filePath]); + const parentChain = new Map(); + parentChain.set(filePath, []); + + let currentLevel = [filePath]; + for (let currentDepth = 1; currentDepth <= maxDepth && currentLevel.length > 0; currentDepth++) { + const nextLevel: string[] = []; + for (const node of currentLevel) { + const deps = reverseAdj.get(node) ?? []; + for (const dep of deps) { + if (visited.has(dep)) continue; + visited.add(dep); + const chain = [...(parentChain.get(node) ?? []), node]; + parentChain.set(dep, chain); + if (currentDepth > 1) { + transitive.push({ path: dep, throughPath: chain, depth: currentDepth }); + } + nextLevel.push(dep); + } + } + currentLevel = nextLevel; + } + const totalAffected = visited.size - 1; + const riskLevel = totalAffected > 20 ? "HIGH" : totalAffected > 5 ? "MEDIUM" : "LOW"; + + return { file: filePath, directDependents, transitiveDependents: transitive, totalAffected, riskLevel }; +} + +// ── Module Structure ──────────────────────────────────────── + +export interface ModuleStructureResult { + modules: Array<{ + path: string; files: number; loc: number; exports: number; + internalDeps: number; externalDeps: number; cohesion: number; + escapeVelocity: number; dependsOn: string[]; dependedBy: string[]; + }>; + crossModuleDeps: Array<{ from: string; to: string; weight: number }>; + circularDeps: Array<{ cycle: string[][]; severity: string }>; +} + +export function computeModuleStructure(graph: CodebaseGraph): ModuleStructureResult { + const modules = [...graph.moduleMetrics.values()].map((m) => ({ + path: m.path, files: m.files, loc: m.loc, exports: m.exports, + internalDeps: m.internalDeps, externalDeps: m.externalDeps, + cohesion: m.cohesion, escapeVelocity: m.escapeVelocity, + dependsOn: m.dependsOn, dependedBy: m.dependedBy, + })); + + const nodeById = buildNodeById(graph); + const crossMap = new Map(); + for (const edge of graph.edges) { + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); + if (!sourceNode || !targetNode || sourceNode.module === targetNode.module) continue; + const key = `${sourceNode.module}\0${targetNode.module}`; + const existing = crossMap.get(key); + if (existing) existing.weight++; + else crossMap.set(key, { from: sourceNode.module, to: targetNode.module, weight: 1 }); + } + + const crossModuleDeps = [...crossMap.values()]; + + return { + modules: modules.sort((a, b) => b.files - a.files), + crossModuleDeps: crossModuleDeps.sort((a, b) => b.weight - a.weight), + circularDeps: graph.stats.circularDeps.map((cycle) => ({ + cycle: [cycle], + severity: cycle.length > 3 ? "HIGH" : "LOW", + })), + }; +} + +// ── Forces ────────────────────────────────────────────────── + +export interface ForcesResult { + moduleCohesion: Array<{ path: string; cohesion: number; verdict: string; files: number }>; + tensionFiles: Array<{ file: string; tension: number; pulledBy: Array<{ module: string; strength: number; symbols: string[] }>; recommendation: string }>; + bridgeFiles: Array<{ file: string; betweenness: number; connects: string[]; role: string }>; + extractionCandidates: Array<{ target: string; escapeVelocity: number; recommendation: string }>; + summary: string; +} + +export function computeForces( + graph: CodebaseGraph, + cohesionThreshold?: number, + tensionThreshold?: number, + escapeThreshold?: number, +): ForcesResult { + const cohesionMin = cohesionThreshold ?? 0.6; + const tensionMin = tensionThreshold ?? 0.3; + const escapeMin = escapeThreshold ?? 0.5; + + type CohesionVerdict = "COHESIVE" | "MODERATE" | "JUNK_DRAWER" | "LEAF"; + const moduleCohesion = graph.forceAnalysis.moduleCohesion.map((m) => { + if (m.verdict === "LEAF") return { path: m.path, cohesion: m.cohesion, verdict: "LEAF" as CohesionVerdict, files: m.files }; + const verdict: CohesionVerdict = m.cohesion >= cohesionMin ? "COHESIVE" : m.cohesion >= cohesionMin * 0.67 ? "MODERATE" : "JUNK_DRAWER"; + return { path: m.path, cohesion: m.cohesion, verdict, files: m.files }; + }); + + const tensionFiles = graph.forceAnalysis.tensionFiles.filter((t) => t.tension > tensionMin); + const extractionCandidates = graph.forceAnalysis.extractionCandidates + .filter((e) => e.escapeVelocity >= escapeMin) + .map((e) => ({ target: e.target, escapeVelocity: e.escapeVelocity, recommendation: e.recommendation })); + + return { + moduleCohesion, + tensionFiles, + bridgeFiles: graph.forceAnalysis.bridgeFiles, + extractionCandidates, + summary: graph.forceAnalysis.summary, + }; +} + +// ── Dead Exports ──────────────────────────────────────────── + +export interface DeadExportsResult { + totalDeadExports: number; + files: Array<{ path: string; module: string; deadExports: string[]; totalExports: number }>; + summary: string; +} + +export function computeDeadExports( + graph: CodebaseGraph, + module?: string, + limit?: number, +): DeadExportsResult { + const maxResults = limit ?? 20; + const nodeById = buildNodeById(graph); + const deadFiles: Array<{ path: string; module: string; deadExports: string[]; totalExports: number }> = []; + + for (const [filePath, metrics] of graph.fileMetrics) { + if (metrics.deadExports.length === 0) continue; + const node = nodeById.get(filePath); + if (!node) continue; + if (module && node.module !== module) continue; + const totalExports = graph.nodes.filter((n) => n.parentFile === filePath).length; + deadFiles.push({ path: filePath, module: node.module, deadExports: metrics.deadExports, totalExports }); + } + + deadFiles.sort((a, b) => b.deadExports.length - a.deadExports.length); + const totalDead = deadFiles.reduce((sum, f) => sum + f.deadExports.length, 0); + const sorted = deadFiles.slice(0, maxResults); + + return { + totalDeadExports: totalDead, + files: sorted, + summary: totalDead > 0 + ? `${totalDead} unused exports across ${sorted.length} files. Consider removing to reduce API surface.` + : "No dead exports found.", + }; +} + +// ── Groups ────────────────────────────────────────────────── + +export interface GroupsResult { + groups: Array<{ rank: number; name: string; files: number; loc: number; importance: string; coupling: { total: number; fanIn: number; fanOut: number } }>; +} + +export function computeGroups(graph: CodebaseGraph): GroupsResult { + return { + groups: graph.groups.map((g, i) => ({ + rank: i + 1, + name: g.name.toUpperCase(), + files: g.files, + loc: g.loc, + importance: `${(g.importance * 100).toFixed(1)}%`, + coupling: { total: g.fanIn + g.fanOut, fanIn: g.fanIn, fanOut: g.fanOut }, + })), + }; +} + +// ── Symbol Context ────────────────────────────────────────── + +export interface SymbolContextResult { + name: string; file: string; type: string; loc: number; + isDefault: boolean; complexity: number; + fanIn: number; fanOut: number; pageRank: number; betweenness: number; + callers: Array<{ symbol: string; file: string; confidence: string }>; + callees: Array<{ symbol: string; file: string; confidence: string }>; +} + +export type SymbolContextError = { error: string }; + +export function computeSymbolContext( + graph: CodebaseGraph, + symbolName: string, +): SymbolContextResult | SymbolContextError { + const matches = [...graph.symbolMetrics.values()].filter( + (m) => m.name === symbolName || m.symbolId.endsWith(`::${symbolName}`) + ); + + if (matches.length === 0) { + return { error: `Symbol not found: ${symbolName}` }; + } + + const sym = matches[0]; + const symNode = graph.symbolNodes.find((n) => n.id === sym.symbolId); + const callers = graph.callEdges + .filter((e) => e.calleeSymbol === symbolName || e.target === sym.symbolId) + .map((e) => ({ symbol: e.callerSymbol, file: e.source.split("::")[0], confidence: e.confidence })); + + const callees = graph.callEdges + .filter((e) => e.callerSymbol === symbolName || e.source === sym.symbolId) + .map((e) => ({ symbol: e.calleeSymbol, file: e.target.split("::")[0], confidence: e.confidence })); + + return { + name: sym.name, file: sym.file, + type: symNode?.type ?? "function", loc: symNode?.loc ?? 0, + isDefault: symNode?.isDefault ?? false, complexity: symNode?.complexity ?? 0, + fanIn: sym.fanIn, fanOut: sym.fanOut, + pageRank: Math.round(sym.pageRank * 10000) / 10000, + betweenness: Math.round(sym.betweenness * 10000) / 10000, + callers, callees, + }; +} + +// ── Processes ─────────────────────────────────────────────── + +export interface ProcessesResult { + processes: Array<{ + name: string; entryPoint: { file: string; symbol: string }; + steps: Array<{ step: number; file: string; symbol: string }>; + depth: number; modulesTouched: string[]; + }>; + totalProcesses: number; +} + +export function computeProcesses( + graph: CodebaseGraph, + entryPoint?: string, + limit?: number, +): ProcessesResult { + let processes = graph.processes; + if (entryPoint) { + processes = processes.filter((p) => + p.entryPoint.symbol === entryPoint || + p.name.toLowerCase().includes(entryPoint.toLowerCase()) + ); + } + if (limit) { + processes = processes.slice(0, limit); + } + + return { + processes: processes.map((p) => ({ + name: p.name, entryPoint: p.entryPoint, + steps: p.steps, depth: p.depth, modulesTouched: p.modulesTouched, + })), + totalProcesses: graph.processes.length, + }; +} + +// ── Clusters ──────────────────────────────────────────────── + +export interface ClustersResult { + clusters: Array<{ id: string; name: string; files: string[]; fileCount: number; cohesion: number }>; + totalClusters: number; +} + +export function computeClusters( + graph: CodebaseGraph, + minFiles?: number, +): ClustersResult { + let clusters = graph.clusters; + if (minFiles) { + clusters = clusters.filter((c) => c.files.length >= minFiles); + } + + return { + clusters: clusters.map((c) => ({ + id: c.id, name: c.name, files: c.files, + fileCount: c.files.length, cohesion: c.cohesion, + })), + totalClusters: graph.clusters.length, + }; +} + +// Re-export impact analysis functions +export { impactAnalysis, renameSymbol }; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 1a67d39..e30b65f 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -1,67 +1,30 @@ +import { createRequire } from "module"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { execSync } from "node:child_process"; import type { CodebaseGraph } from "../types/index.js"; + +const require = createRequire(import.meta.url); +const pkg = require("../../package.json") as { version: string }; import { getHints } from "./hints.js"; -import { createSearchIndex, search, getSuggestions } from "../search/index.js"; -import type { SearchIndex } from "../search/index.js"; import { getIndexedHead } from "../server/graph-store.js"; -import { impactAnalysis, renameSymbol } from "../impact/index.js"; - -let cachedSearchIndex: SearchIndex | undefined; - -function getSearchIndex(graph: CodebaseGraph): SearchIndex { - cachedSearchIndex ??= createSearchIndex(graph); - return cachedSearchIndex; -} - -function normalizeFilePath(filePath: string): string { - let normalized = filePath.replace(/\\/g, "/"); - normalized = normalized.replace(/^(src|lib|app)\//, ""); - return normalized; -} - -function resolveFilePath(normalizedPath: string, graph: CodebaseGraph): string | undefined { - if (graph.fileMetrics.has(normalizedPath)) return normalizedPath; - return undefined; -} - -function suggestSimilarPaths(queryPath: string, graph: CodebaseGraph): string[] { - const allPaths = [...graph.fileMetrics.keys()]; - const queryLower = queryPath.toLowerCase(); - const queryBasename = queryPath.split("/").pop() ?? queryPath; - const queryBasenameLower = queryBasename.toLowerCase(); - - const scored = allPaths.map((p) => { - const pLower = p.toLowerCase(); - const pBasename = (p.split("/").pop() ?? p).toLowerCase(); - let score = 0; - if (pLower.includes(queryLower)) score += 10; - if (pBasename === queryBasenameLower) score += 5; - if (pLower.includes(queryBasenameLower)) score += 3; - - const shorter = queryLower.length < pLower.length ? queryLower : pLower; - const longer = queryLower.length < pLower.length ? pLower : queryLower; - let commonPrefix = 0; - for (let i = 0; i < shorter.length; i++) { - if (shorter[i] === longer[i]) commonPrefix++; - else break; - } - score += commonPrefix * 0.1; - - return { path: p, score }; - }); - - const matches = scored - .filter((s) => s.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 3) - .map((s) => s.path); - - if (matches.length > 0) return matches; - return allPaths.slice(0, 3); -} +import { + computeOverview, + computeFileContext, + computeHotspots, + computeSearch, + computeChanges, + computeDependents, + computeModuleStructure, + computeForces, + computeDeadExports, + computeGroups, + computeSymbolContext, + computeProcesses, + computeClusters, + impactAnalysis, + renameSymbol, +} from "../core/index.js"; /** Register all MCP tools on a server instance. Shared by stdio and HTTP transports. */ export function registerTools(server: McpServer, graph: CodebaseGraph): void { @@ -71,39 +34,8 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { "Get a high-level overview of the codebase: total files, modules, top-depended files, and key metrics. Use when: first exploring a codebase, 'what does this project look like'. Not for: module details (use get_module_structure) or data flow (use analyze_forces)", { depth: z.number().optional().describe("Module depth (default: 1)") }, async (_params) => { - const modules = [...graph.moduleMetrics.values()].map((m) => ({ - path: m.path, - files: m.files, - loc: m.loc, - avgCoupling: m.cohesion < 0.4 ? "HIGH" : m.cohesion < 0.7 ? "MEDIUM" : "LOW", - cohesion: m.cohesion, - })); - - const topDepended = [...graph.fileMetrics.entries()] - .sort(([, a], [, b]) => b.fanIn - a.fanIn) - .slice(0, 5) - .map(([path, m]) => `${path} (${m.fanIn} dependents)`); - - const maxDepth = Math.max( - ...graph.nodes - .filter((n) => n.type === "file") - .map((n) => n.path.split("/").length) - ); - const overview = { - totalFiles: graph.stats.totalFiles, - totalFunctions: graph.stats.totalFunctions, - totalDependencies: graph.stats.totalDependencies, - modules: modules.sort((a, b) => b.files - a.files), - topDependedFiles: topDepended, - metrics: { - avgLOC: Math.round( - graph.nodes.filter((n) => n.type === "file").reduce((sum, n) => sum + n.loc, 0) / - graph.stats.totalFiles - ), - maxDepth, - circularDeps: graph.stats.circularDeps.length, - }, + ...computeOverview(graph), nextSteps: getHints("codebase_overview"), }; @@ -117,68 +49,15 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { "Get detailed context for a specific file: exports, imports, dependents, and all metrics. Use when: 'tell me about this file', understanding a file before modifying it. Not for: symbol-level detail (use symbol_context)", { filePath: z.string().describe("Relative path to the file") }, async ({ filePath: rawFilePath }) => { - const normalizedPath = normalizeFilePath(rawFilePath); - const filePath = resolveFilePath(normalizedPath, graph); - if (!filePath) { - const suggestions = suggestSimilarPaths(normalizedPath, graph); - return { - content: [{ type: "text" as const, text: JSON.stringify({ - error: `File not found in graph: ${normalizedPath}`, - suggestions, - }) }], - isError: true, - }; - } - - const metrics = graph.fileMetrics.get(filePath); - if (!metrics) { - const suggestions = suggestSimilarPaths(normalizedPath, graph); + const result = computeFileContext(graph, rawFilePath); + if ("error" in result) { return { - content: [{ type: "text" as const, text: JSON.stringify({ - error: `File not found in graph: ${normalizedPath}`, - suggestions, - }) }], + content: [{ type: "text" as const, text: JSON.stringify(result) }], isError: true, }; } - const node = graph.nodes.find((n) => n.id === filePath && n.type === "file"); - const fileExports = graph.nodes - .filter((n) => n.parentFile === filePath) - .map((n) => ({ name: n.label, type: n.type, loc: n.loc })); - - const imports = graph.edges - .filter((e) => e.source === filePath) - .map((e) => ({ from: e.target, symbols: e.symbols, isTypeOnly: e.isTypeOnly, weight: e.weight })); - - const dependents = graph.edges - .filter((e) => e.target === filePath) - .map((e) => ({ path: e.source, symbols: e.symbols, isTypeOnly: e.isTypeOnly, weight: e.weight })); - - const context = { - path: filePath, - loc: node?.loc ?? 0, - exports: fileExports, - imports, - dependents, - metrics: { - pageRank: Math.round(metrics.pageRank * 1000) / 1000, - betweenness: Math.round(metrics.betweenness * 100) / 100, - fanIn: metrics.fanIn, - fanOut: metrics.fanOut, - coupling: Math.round(metrics.coupling * 100) / 100, - tension: metrics.tension, - isBridge: metrics.isBridge, - churn: metrics.churn, - cyclomaticComplexity: metrics.cyclomaticComplexity, - blastRadius: metrics.blastRadius, - deadExports: metrics.deadExports, - hasTests: metrics.hasTests, - testFile: metrics.testFile, - }, - nextSteps: getHints("file_context"), - }; - + const context = { ...result, nextSteps: getHints("file_context") }; return { content: [{ type: "text" as const, text: JSON.stringify(context, null, 2) }] }; } ); @@ -192,54 +71,16 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { depth: z.number().optional().describe("Max traversal depth (default: 2)"), }, async ({ filePath, depth }) => { - if (!graph.fileMetrics.has(filePath)) { + const result = computeDependents(graph, filePath, depth); + if ("error" in result) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: `File not found in graph: ${filePath}` }) }], + content: [{ type: "text" as const, text: JSON.stringify(result) }], isError: true, }; } - - const maxDepth = depth ?? 2; - const directDependents = graph.edges - .filter((e) => e.target === filePath) - .map((e) => ({ path: e.source, symbols: e.symbols })); - - const transitive: Array<{ path: string; throughPath: string[]; depth: number }> = []; - const visited = new Set([filePath]); - - function bfs(current: string[], currentDepth: number, pathSoFar: string[]): void { - if (currentDepth > maxDepth) return; - const next: string[] = []; - - for (const node of current) { - const deps = graph.edges.filter((e) => e.target === node).map((e) => e.source); - for (const dep of deps) { - if (visited.has(dep)) continue; - visited.add(dep); - if (currentDepth > 1) { - transitive.push({ path: dep, throughPath: [...pathSoFar, node], depth: currentDepth }); - } - next.push(dep); - } - } - - if (next.length > 0) bfs(next, currentDepth + 1, [...pathSoFar, ...current]); - } - - bfs([filePath], 1, []); - - const totalAffected = visited.size - 1; - const riskLevel = totalAffected > 20 ? "HIGH" : totalAffected > 5 ? "MEDIUM" : "LOW"; - - const result = { - file: filePath, - directDependents, - transitiveDependents: transitive, - totalAffected, - riskLevel, - nextSteps: getHints("get_dependents"), + return { + content: [{ type: "text" as const, text: JSON.stringify({ ...result, nextSteps: getHints("get_dependents") }, null, 2) }], }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -254,85 +95,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { limit: z.number().optional().describe("Number of results (default: 10)"), }, async ({ metric, limit }) => { - const maxResults = limit ?? 10; - - type ScoredFile = { path: string; score: number; reason: string }; - const scored: ScoredFile[] = []; - - if (metric === "escape_velocity") { - for (const mod of graph.moduleMetrics.values()) { - scored.push({ - path: mod.path, - score: mod.escapeVelocity, - reason: `${mod.dependedBy.length} modules depend on it, ${mod.externalDeps} external deps`, - }); - } - } else { - const filterTestFiles = metric === "coverage" || metric === "coupling"; - for (const [filePath, metrics] of graph.fileMetrics) { - if (filterTestFiles && metrics.isTestFile) continue; - - let score: number; - let reason: string; - - switch (metric) { - case "coupling": - score = metrics.coupling; - reason = `fan-in: ${metrics.fanIn}, fan-out: ${metrics.fanOut}`; - break; - case "pagerank": - score = metrics.pageRank; - reason = `${metrics.fanIn} dependents`; - break; - case "fan_in": - score = metrics.fanIn; - reason = `${metrics.fanIn} files import this`; - break; - case "fan_out": - score = metrics.fanOut; - reason = `imports ${metrics.fanOut} files`; - break; - case "betweenness": - score = metrics.betweenness; - reason = metrics.isBridge ? "bridge between clusters" : "on many shortest paths"; - break; - case "tension": - score = metrics.tension; - reason = score > 0 ? "pulled by multiple modules" : "no tension"; - break; - case "churn": - score = metrics.churn; - reason = `${metrics.churn} commits touching this file`; - break; - case "complexity": - score = metrics.cyclomaticComplexity; - reason = `avg cyclomatic complexity: ${metrics.cyclomaticComplexity.toFixed(1)}`; - break; - case "blast_radius": - score = metrics.blastRadius; - reason = `${metrics.blastRadius} transitive dependents affected if changed`; - break; - case "coverage": - score = metrics.hasTests ? 0 : 1; - reason = metrics.hasTests ? `tested (${metrics.testFile})` : "no test file found"; - break; - default: - score = 0; - reason = ""; - } - - scored.push({ path: filePath, score, reason }); - } - } - - const hotspots = scored.sort((a, b) => b.score - a.score).slice(0, maxResults); - const topIssue = hotspots[0]; - const summary = - hotspots.length > 0 - ? `Top ${metric} hotspot: ${topIssue.path} (${topIssue.score.toFixed(2)}). ${topIssue.reason}.` - : `No significant ${metric} hotspots found.`; - - const result = { metric, hotspots, summary, nextSteps: getHints("find_hotspots") }; + const result = { + ...computeHotspots(graph, metric, limit), + nextSteps: getHints("find_hotspots"), + }; return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -343,47 +109,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { "Get module/directory structure with cross-module dependencies, cohesion scores, and circular deps. Use when: 'how are modules organized', 'what depends on what module'. Not for: emergent clusters (use get_clusters) or file-level metrics (use find_hotspots)", { depth: z.number().optional().describe("Module depth (default: 2)") }, async (_params) => { - const modules = [...graph.moduleMetrics.values()].map((m) => ({ - path: m.path, - files: m.files, - loc: m.loc, - exports: m.exports, - internalDeps: m.internalDeps, - externalDeps: m.externalDeps, - cohesion: m.cohesion, - escapeVelocity: m.escapeVelocity, - dependsOn: m.dependsOn, - dependedBy: m.dependedBy, - })); - - const crossModuleDeps: Array<{ from: string; to: string; weight: number }> = []; - const crossMap = new Map(); - - for (const edge of graph.edges) { - const sourceNode = graph.nodes.find((n) => n.id === edge.source); - const targetNode = graph.nodes.find((n) => n.id === edge.target); - if (!sourceNode || !targetNode) continue; - if (sourceNode.module === targetNode.module) continue; - - const key = `${sourceNode.module}->${targetNode.module}`; - crossMap.set(key, (crossMap.get(key) ?? 0) + 1); - } - - for (const [key, weight] of crossMap) { - const [from, to] = key.split("->"); - crossModuleDeps.push({ from, to, weight }); - } - const result = { - modules: modules.sort((a, b) => b.files - a.files), - crossModuleDeps: crossModuleDeps.sort((a, b) => b.weight - a.weight), - circularDeps: graph.stats.circularDeps.map((cycle) => ({ - cycle, - severity: cycle.length > 3 ? "HIGH" : "LOW", - })), + ...computeModuleStructure(graph), nextSteps: getHints("get_module_structure"), }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -398,29 +127,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { escapeThreshold: z.number().optional().describe("Min escape velocity to flag (default: 0.5)"), }, async ({ cohesionThreshold, tensionThreshold, escapeThreshold }) => { - const cohesionMin = cohesionThreshold ?? 0.6; - const tensionMin = tensionThreshold ?? 0.3; - const escapeMin = escapeThreshold ?? 0.5; - - type CohesionVerdict = "COHESIVE" | "MODERATE" | "JUNK_DRAWER" | "LEAF"; - const moduleCohesion = graph.forceAnalysis.moduleCohesion.map((m) => { - if (m.verdict === "LEAF") return { ...m, verdict: "LEAF" as CohesionVerdict }; - const verdict: CohesionVerdict = m.cohesion >= cohesionMin ? "COHESIVE" : m.cohesion >= cohesionMin * 0.67 ? "MODERATE" : "JUNK_DRAWER"; - return { ...m, verdict }; - }); - - const tensionFiles = graph.forceAnalysis.tensionFiles.filter((t) => t.tension > tensionMin); - const extractionCandidates = graph.forceAnalysis.extractionCandidates.filter((e) => e.escapeVelocity >= escapeMin); - const result = { - moduleCohesion, - tensionFiles, - bridgeFiles: graph.forceAnalysis.bridgeFiles, - extractionCandidates, - summary: graph.forceAnalysis.summary, + ...computeForces(graph, cohesionThreshold, tensionThreshold, escapeThreshold), nextSteps: getHints("analyze_forces"), }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -434,38 +144,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { limit: z.number().optional().describe("Max results (default: 20)"), }, async ({ module, limit }) => { - const maxResults = limit ?? 20; - const deadFiles: Array<{ path: string; module: string; deadExports: string[]; totalExports: number }> = []; - - for (const [filePath, metrics] of graph.fileMetrics) { - if (metrics.deadExports.length === 0) continue; - const node = graph.nodes.find((n) => n.id === filePath); - if (!node) continue; - if (module && node.module !== module) continue; - - const totalExports = graph.nodes.filter((n) => n.parentFile === filePath).length; - deadFiles.push({ - path: filePath, - module: node.module, - deadExports: metrics.deadExports, - totalExports, - }); - } - - const sorted = deadFiles - .sort((a, b) => b.deadExports.length - a.deadExports.length) - .slice(0, maxResults); - - const totalDead = sorted.reduce((sum, f) => sum + f.deadExports.length, 0); const result = { - totalDeadExports: totalDead, - files: sorted, - summary: totalDead > 0 - ? `${totalDead} unused exports across ${sorted.length} files. Consider removing to reduce API surface.` - : "No dead exports found.", + ...computeDeadExports(graph, module, limit), nextSteps: getHints("find_dead_exports"), }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -476,24 +158,11 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { "Get top-level directory groups with aggregate metrics: files, LOC, importance (PageRank), coupling. Use when: 'what are the main areas of this codebase', high-level grouping overview. Not for: detailed module metrics (use get_module_structure)", {}, async () => { - const groups = graph.groups; - - if (groups.length === 0) { + const computed = computeGroups(graph); + if (computed.groups.length === 0) { return { content: [{ type: "text" as const, text: JSON.stringify({ message: "No groups found.", nextSteps: getHints("get_groups") }) }] }; } - - const result = { - groups: groups.map((g, i) => ({ - rank: i + 1, - name: g.name.toUpperCase(), - files: g.files, - loc: g.loc, - importance: `${(g.importance * 100).toFixed(1)}%`, - coupling: { total: g.fanIn + g.fanOut, fanIn: g.fanIn, fanOut: g.fanOut }, - })), - nextSteps: getHints("get_groups"), - }; - + const result = { ...computed, nextSteps: getHints("get_groups") }; return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; }, ); @@ -504,44 +173,16 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { "Find all callers and callees of a function, class, or method with importance metrics. Use when: 'who calls X', 'trace this function', 'what depends on this symbol'. Not for: text search (use search) or file-level dependencies (use get_dependents)", { name: z.string().describe("Symbol name (e.g., 'AuthService', 'getUserById')") }, async ({ name: symbolName }) => { - const matches = [...graph.symbolMetrics.values()].filter( - (m) => m.name === symbolName || m.symbolId.endsWith(`::${symbolName}`) - ); - - if (matches.length === 0) { + const result = computeSymbolContext(graph, symbolName); + if ("error" in result) { return { - content: [{ type: "text" as const, text: JSON.stringify({ error: `Symbol not found: ${symbolName}` }) }], + content: [{ type: "text" as const, text: JSON.stringify(result) }], isError: true, }; } - - const sym = matches[0]; - const symNode = graph.symbolNodes.find((n) => n.id === sym.symbolId); - const callers = graph.callEdges - .filter((e) => e.calleeSymbol === symbolName || e.target === sym.symbolId) - .map((e) => ({ symbol: e.callerSymbol, file: e.source.split("::")[0], confidence: e.confidence })); - - const callees = graph.callEdges - .filter((e) => e.callerSymbol === symbolName || e.source === sym.symbolId) - .map((e) => ({ symbol: e.calleeSymbol, file: e.target.split("::")[0], confidence: e.confidence })); - - const result = { - name: sym.name, - file: sym.file, - type: symNode?.type ?? "function", - loc: symNode?.loc ?? 0, - isDefault: symNode?.isDefault ?? false, - complexity: symNode?.complexity ?? 0, - fanIn: sym.fanIn, - fanOut: sym.fanOut, - pageRank: Math.round(sym.pageRank * 10000) / 10000, - betweenness: Math.round(sym.betweenness * 10000) / 10000, - callers, - callees, - nextSteps: getHints("symbol_context"), + return { + content: [{ type: "text" as const, text: JSON.stringify({ ...result, nextSteps: getHints("symbol_context") }, null, 2) }], }; - - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -554,43 +195,14 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { limit: z.number().optional().describe("Max results (default: 20)"), }, async ({ query, limit }) => { - const idx = getSearchIndex(graph); - const results = search(idx, query, limit ?? 20); - - if (results.length === 0) { - const suggestions = getSuggestions(idx, query); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - query, - results: [], - suggestions, - nextSteps: getHints("search"), - }, null, 2), - }], - }; - } - - const mapped = results.map((r) => ({ - file: r.file, - score: r.score, - symbols: r.symbols.map((s) => ({ - name: s.name, - type: s.type, - loc: s.loc, - relevance: s.score, - })), - })); - + const result = { + ...computeSearch(graph, query, limit), + nextSteps: getHints("search"), + }; return { content: [{ type: "text" as const, - text: JSON.stringify({ - query, - results: mapped, - nextSteps: getHints("search"), - }, null, 2), + text: JSON.stringify(result, null, 2), }], }; } @@ -604,73 +216,28 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { scope: z.enum(["staged", "unstaged", "all"]).optional().describe("Git diff scope (default: all)"), }, async ({ scope }) => { - const diffScope = scope ?? "all"; - try { - let diffCmd: string; - switch (diffScope) { - case "staged": diffCmd = "git diff --cached --name-only"; break; - case "unstaged": diffCmd = "git diff --name-only"; break; - default: diffCmd = "git diff HEAD --name-only"; break; - } - - const output = execSync(diffCmd, { encoding: "utf-8", timeout: 5000 }).trim(); - const changedFiles = output ? output.split("\n").filter((f) => f.length > 0) : []; - - const changedSymbols: Array<{ file: string; symbols: string[] }> = []; - const affectedFiles: string[] = []; - const fileRiskMetrics: Array<{ file: string; blastRadius: number; complexity: number; churn: number }> = []; - - for (const file of changedFiles) { - const fileSymbols = [...graph.symbolMetrics.values()] - .filter((m) => m.file === file || file.endsWith(m.file)) - .map((m) => m.name); - if (fileSymbols.length > 0) { - changedSymbols.push({ file, symbols: fileSymbols }); - } - - const dependents = graph.edges - .filter((e) => e.target === file || file.endsWith(e.target)) - .map((e) => e.source); - affectedFiles.push(...dependents); - - const matchKey = [...graph.fileMetrics.keys()].find((k) => k === file || file.endsWith(k)); - const metrics = matchKey ? graph.fileMetrics.get(matchKey) : undefined; - if (metrics) { - fileRiskMetrics.push({ - file, - blastRadius: metrics.blastRadius, - complexity: metrics.cyclomaticComplexity, - churn: metrics.churn, - }); - } - } - + const result = computeChanges(graph, scope); + if ("error" in result) { return { content: [{ type: "text" as const, text: JSON.stringify({ - scope: diffScope, - changedFiles, - changedSymbols, - affectedFiles: [...new Set(affectedFiles)], - fileRiskMetrics, - nextSteps: getHints("detect_changes"), - }, null, 2), - }], - }; - } catch { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: "Git not available or not in a git repository", - scope: diffScope, + ...result, nextSteps: ["Ensure you are in a git repository"], }), }], isError: true, }; } + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + ...result, + nextSteps: getHints("detect_changes"), + }, null, 2), + }], + }; } ); @@ -727,29 +294,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { limit: z.number().optional().describe("Max processes to return (default: all)"), }, async ({ entryPoint, limit }) => { - let processes = graph.processes; - if (entryPoint) { - processes = processes.filter((p) => - p.entryPoint.symbol === entryPoint || - p.name.toLowerCase().includes(entryPoint.toLowerCase()) - ); - } - if (limit) { - processes = processes.slice(0, limit); - } - const result = { - processes: processes.map((p) => ({ - name: p.name, - entryPoint: p.entryPoint, - steps: p.steps, - depth: p.depth, - modulesTouched: p.modulesTouched, - })), - totalProcesses: graph.processes.length, + ...computeProcesses(graph, entryPoint, limit), nextSteps: getHints("get_processes"), }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -762,23 +310,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { minFiles: z.number().optional().describe("Filter clusters with at least N files (default: 0)"), }, async ({ minFiles }) => { - let clusters = graph.clusters; - if (minFiles) { - clusters = clusters.filter((c) => c.files.length >= minFiles); - } - const result = { - clusters: clusters.map((c) => ({ - id: c.id, - name: c.name, - files: c.files, - fileCount: c.files.length, - cohesion: c.cohesion, - })), - totalClusters: graph.clusters.length, + ...computeClusters(graph, minFiles), nextSteps: getHints("get_clusters"), }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } ); @@ -880,7 +415,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { export async function startMcpServer(graph: CodebaseGraph): Promise { const server = new McpServer({ name: "codebase-intelligence", - version: "0.1.0", + version: pkg.version, }); registerTools(server, graph); diff --git a/tests/cli-commands.test.ts b/tests/cli-commands.test.ts new file mode 100644 index 0000000..3f3284f --- /dev/null +++ b/tests/cli-commands.test.ts @@ -0,0 +1,595 @@ +import { describe, it, expect } from "vitest"; +import { getFixturePipeline } from "./helpers/pipeline.js"; +import { + computeOverview, + computeFileContext, + computeHotspots, + computeSearch, + computeChanges, + computeDependents, + computeModuleStructure, + computeForces, + computeDeadExports, + computeGroups, + computeSymbolContext, + computeProcesses, + computeClusters, + impactAnalysis, + renameSymbol, +} from "../src/core/index.js"; + +describe("CLI core commands (integration)", () => { + describe("computeOverview", () => { + it("returns file, function, and dependency counts", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeOverview(codebaseGraph); + + expect(result.totalFiles).toBeGreaterThan(0); + expect(result.totalFunctions).toBeGreaterThanOrEqual(0); + expect(result.totalDependencies).toBeGreaterThanOrEqual(0); + }); + + it("returns modules sorted by file count", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeOverview(codebaseGraph); + + expect(result.modules.length).toBeGreaterThan(0); + for (const m of result.modules) { + expect(m).toHaveProperty("path"); + expect(m).toHaveProperty("files"); + expect(m).toHaveProperty("loc"); + expect(m).toHaveProperty("avgCoupling"); + expect(m).toHaveProperty("cohesion"); + } + + for (let i = 1; i < result.modules.length; i++) { + expect(result.modules[i - 1].files).toBeGreaterThanOrEqual(result.modules[i].files); + } + }); + + it("returns top depended files", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeOverview(codebaseGraph); + + expect(result.topDependedFiles.length).toBeGreaterThan(0); + expect(result.topDependedFiles.length).toBeLessThanOrEqual(5); + for (const f of result.topDependedFiles) { + expect(f).toContain("dependents"); + } + }); + + it("returns global metrics", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeOverview(codebaseGraph); + + expect(result.metrics.avgLOC).toBeGreaterThan(0); + expect(result.metrics.maxDepth).toBeGreaterThan(0); + expect(typeof result.metrics.circularDeps).toBe("number"); + }); + + it("JSON output is valid and stable schema", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeOverview(codebaseGraph); + const json = JSON.stringify(result); + const parsed = JSON.parse(json) as Record; + + expect(parsed).toHaveProperty("totalFiles"); + expect(parsed).toHaveProperty("totalFunctions"); + expect(parsed).toHaveProperty("totalDependencies"); + expect(parsed).toHaveProperty("modules"); + expect(parsed).toHaveProperty("topDependedFiles"); + expect(parsed).toHaveProperty("metrics"); + }); + }); + + describe("computeHotspots", () => { + it("returns ranked files by coupling (default metric)", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeHotspots(codebaseGraph, "coupling", 5); + + expect(result.metric).toBe("coupling"); + expect(result.hotspots.length).toBeLessThanOrEqual(5); + expect(result.hotspots.length).toBeGreaterThan(0); + + for (let i = 1; i < result.hotspots.length; i++) { + expect(result.hotspots[i - 1].score).toBeGreaterThanOrEqual(result.hotspots[i].score); + } + }); + + it("each hotspot has path, score, and reason", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeHotspots(codebaseGraph, "coupling"); + + for (const h of result.hotspots) { + expect(h).toHaveProperty("path"); + expect(h).toHaveProperty("score"); + expect(h).toHaveProperty("reason"); + expect(typeof h.path).toBe("string"); + expect(typeof h.score).toBe("number"); + expect(typeof h.reason).toBe("string"); + } + }); + + it("returns summary with top hotspot info", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeHotspots(codebaseGraph, "coupling"); + + expect(result.summary).toContain("coupling"); + }); + + it("supports all valid metrics", () => { + const { codebaseGraph } = getFixturePipeline(); + const metrics = [ + "coupling", + "pagerank", + "fan_in", + "fan_out", + "betweenness", + "tension", + "churn", + "complexity", + "blast_radius", + "coverage", + "escape_velocity", + ]; + + for (const metric of metrics) { + const result = computeHotspots(codebaseGraph, metric, 3); + expect(result.metric).toBe(metric); + expect(result.hotspots).toBeDefined(); + } + }); + + it("respects limit parameter", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeHotspots(codebaseGraph, "coupling", 2); + + expect(result.hotspots.length).toBeLessThanOrEqual(2); + }); + + it("JSON output is valid", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeHotspots(codebaseGraph, "coupling"); + const json = JSON.stringify(result); + const parsed = JSON.parse(json) as Record; + + expect(parsed).toHaveProperty("metric"); + expect(parsed).toHaveProperty("hotspots"); + expect(parsed).toHaveProperty("summary"); + }); + }); + + describe("computeFileContext", () => { + it("returns file context for a known file", () => { + const { codebaseGraph } = getFixturePipeline(); + const knownFile = [...codebaseGraph.fileMetrics.keys()][0]; + const result = computeFileContext(codebaseGraph, knownFile); + + expect("error" in result).toBe(false); + if (!("error" in result)) { + expect(result.path).toBe(knownFile); + expect(typeof result.loc).toBe("number"); + expect(Array.isArray(result.exports)).toBe(true); + expect(Array.isArray(result.imports)).toBe(true); + expect(Array.isArray(result.dependents)).toBe(true); + expect(result.metrics).toHaveProperty("pageRank"); + expect(result.metrics).toHaveProperty("betweenness"); + expect(result.metrics).toHaveProperty("fanIn"); + expect(result.metrics).toHaveProperty("fanOut"); + expect(result.metrics).toHaveProperty("coupling"); + expect(result.metrics).toHaveProperty("tension"); + expect(result.metrics).toHaveProperty("isBridge"); + expect(result.metrics).toHaveProperty("churn"); + expect(result.metrics).toHaveProperty("cyclomaticComplexity"); + expect(result.metrics).toHaveProperty("blastRadius"); + expect(result.metrics).toHaveProperty("deadExports"); + expect(result.metrics).toHaveProperty("hasTests"); + expect(result.metrics).toHaveProperty("testFile"); + } + }); + + it("returns error with suggestions for unknown file", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeFileContext(codebaseGraph, "nonexistent/file.ts"); + + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toContain("not found"); + expect(result.suggestions.length).toBeGreaterThan(0); + expect(result.suggestions.length).toBeLessThanOrEqual(3); + } + }); + + it("strips common prefixes from file path", () => { + const { codebaseGraph } = getFixturePipeline(); + const knownFile = [...codebaseGraph.fileMetrics.keys()][0]; + const result = computeFileContext(codebaseGraph, `src/${knownFile}`); + + expect("error" in result).toBe(false); + }); + + it("JSON output is valid for success result", () => { + const { codebaseGraph } = getFixturePipeline(); + const knownFile = [...codebaseGraph.fileMetrics.keys()][0]; + const result = computeFileContext(codebaseGraph, knownFile); + + const json = JSON.stringify(result); + const parsed = JSON.parse(json) as Record; + expect(parsed).toHaveProperty("path"); + expect(parsed).toHaveProperty("metrics"); + }); + + it("JSON output is valid for error result", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeFileContext(codebaseGraph, "nonexistent.ts"); + + const json = JSON.stringify(result); + const parsed = JSON.parse(json) as Record; + expect(parsed).toHaveProperty("error"); + expect(parsed).toHaveProperty("suggestions"); + }); + }); + + describe("computeSearch", () => { + it("returns results for a term that exists in the fixture", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeSearch(codebaseGraph, "logger"); + + expect(result.query).toBe("logger"); + expect(result.results.length).toBeGreaterThan(0); + }); + + it("each result has file, score, and symbols", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeSearch(codebaseGraph, "logger"); + + for (const r of result.results) { + expect(r).toHaveProperty("file"); + expect(r).toHaveProperty("score"); + expect(r).toHaveProperty("symbols"); + expect(typeof r.file).toBe("string"); + expect(typeof r.score).toBe("number"); + expect(Array.isArray(r.symbols)).toBe(true); + } + }); + + it("returns suggestions for no-match query", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeSearch(codebaseGraph, "xyznonexistentterm"); + + expect(result.results.length).toBe(0); + }); + + it("respects limit parameter", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeSearch(codebaseGraph, "auth", 2); + + expect(result.results.length).toBeLessThanOrEqual(2); + }); + + it("JSON output is valid", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeSearch(codebaseGraph, "logger"); + const json = JSON.stringify(result); + const parsed = JSON.parse(json) as Record; + + expect(parsed).toHaveProperty("query"); + expect(parsed).toHaveProperty("results"); + }); + }); + + describe("computeChanges", () => { + it("returns changes result with scope", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeChanges(codebaseGraph); + + if ("error" in result) { + expect(result.error).toContain("Git"); + } else { + expect(result.scope).toBe("all"); + expect(Array.isArray(result.changedFiles)).toBe(true); + expect(Array.isArray(result.changedSymbols)).toBe(true); + expect(Array.isArray(result.affectedFiles)).toBe(true); + expect(Array.isArray(result.fileRiskMetrics)).toBe(true); + } + }); + + it("supports staged scope", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeChanges(codebaseGraph, "staged"); + + if (!("error" in result)) { + expect(result.scope).toBe("staged"); + } + }); + + it("JSON output is valid", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeChanges(codebaseGraph); + const json = JSON.stringify(result); + const parsed = JSON.parse(json) as Record; + + expect(parsed).toHaveProperty("scope"); + }); + }); + + describe("computeDependents", () => { + it("returns direct dependents for a known file", () => { + const { codebaseGraph } = getFixturePipeline(); + const knownFile = [...codebaseGraph.fileMetrics.keys()][0]; + const result = computeDependents(codebaseGraph, knownFile); + + expect("error" in result).toBe(false); + if (!("error" in result)) { + expect(result.file).toBe(knownFile); + expect(Array.isArray(result.directDependents)).toBe(true); + expect(Array.isArray(result.transitiveDependents)).toBe(true); + expect(typeof result.totalAffected).toBe("number"); + expect(["LOW", "MEDIUM", "HIGH"]).toContain(result.riskLevel); + } + }); + + it("returns error for unknown file", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeDependents(codebaseGraph, "nonexistent.ts"); + + expect("error" in result).toBe(true); + }); + + it("respects depth parameter", () => { + const { codebaseGraph } = getFixturePipeline(); + const knownFile = [...codebaseGraph.fileMetrics.keys()][0]; + const shallow = computeDependents(codebaseGraph, knownFile, 1); + const deep = computeDependents(codebaseGraph, knownFile, 5); + + if (!("error" in shallow) && !("error" in deep)) { + expect(deep.totalAffected).toBeGreaterThanOrEqual(shallow.totalAffected); + } + }); + }); + + describe("computeModuleStructure", () => { + it("returns modules with metrics", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeModuleStructure(codebaseGraph); + + expect(result.modules.length).toBeGreaterThan(0); + for (const m of result.modules) { + expect(m).toHaveProperty("path"); + expect(m).toHaveProperty("files"); + expect(m).toHaveProperty("cohesion"); + expect(m).toHaveProperty("escapeVelocity"); + } + }); + + it("includes cross-module dependencies", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeModuleStructure(codebaseGraph); + + expect(Array.isArray(result.crossModuleDeps)).toBe(true); + expect(Array.isArray(result.circularDeps)).toBe(true); + }); + + it("JSON output is stable", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeModuleStructure(codebaseGraph); + const parsed = JSON.parse(JSON.stringify(result)) as Record; + + expect(parsed).toHaveProperty("modules"); + expect(parsed).toHaveProperty("crossModuleDeps"); + expect(parsed).toHaveProperty("circularDeps"); + }); + }); + + describe("computeForces", () => { + it("returns force analysis with all sections", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeForces(codebaseGraph); + + expect(Array.isArray(result.moduleCohesion)).toBe(true); + expect(Array.isArray(result.tensionFiles)).toBe(true); + expect(Array.isArray(result.bridgeFiles)).toBe(true); + expect(Array.isArray(result.extractionCandidates)).toBe(true); + expect(typeof result.summary).toBe("string"); + }); + + it("module cohesion includes verdicts", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeForces(codebaseGraph); + + for (const m of result.moduleCohesion) { + expect(["COHESIVE", "MODERATE", "JUNK_DRAWER", "LEAF"]).toContain(m.verdict); + } + }); + + it("respects custom thresholds", () => { + const { codebaseGraph } = getFixturePipeline(); + const strict = computeForces(codebaseGraph, 0.9, 0.1, 0.1); + const lenient = computeForces(codebaseGraph, 0.1, 0.9, 0.9); + + expect(strict.tensionFiles.length).toBeGreaterThanOrEqual(lenient.tensionFiles.length); + }); + }); + + describe("computeDeadExports", () => { + it("returns dead exports result", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeDeadExports(codebaseGraph); + + expect(typeof result.totalDeadExports).toBe("number"); + expect(Array.isArray(result.files)).toBe(true); + expect(typeof result.summary).toBe("string"); + }); + + it("each dead export file has correct structure", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeDeadExports(codebaseGraph); + + for (const f of result.files) { + expect(f).toHaveProperty("path"); + expect(f).toHaveProperty("module"); + expect(f).toHaveProperty("deadExports"); + expect(f).toHaveProperty("totalExports"); + expect(f.deadExports.length).toBeGreaterThan(0); + } + }); + + it("respects limit parameter", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeDeadExports(codebaseGraph, undefined, 2); + + expect(result.files.length).toBeLessThanOrEqual(2); + }); + }); + + describe("computeGroups", () => { + it("returns ranked groups", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeGroups(codebaseGraph); + + expect(Array.isArray(result.groups)).toBe(true); + for (const g of result.groups) { + expect(g).toHaveProperty("rank"); + expect(g).toHaveProperty("name"); + expect(g).toHaveProperty("files"); + expect(g).toHaveProperty("loc"); + expect(g).toHaveProperty("importance"); + expect(g).toHaveProperty("coupling"); + } + }); + }); + + describe("computeSymbolContext", () => { + it("returns context for a known symbol", () => { + const { codebaseGraph } = getFixturePipeline(); + const firstSymbol = [...codebaseGraph.symbolMetrics.values()][0]; + if (!firstSymbol) return; + + const result = computeSymbolContext(codebaseGraph, firstSymbol.name); + + if (!("error" in result)) { + expect(result.name).toBe(firstSymbol.name); + expect(typeof result.file).toBe("string"); + expect(Array.isArray(result.callers)).toBe(true); + expect(Array.isArray(result.callees)).toBe(true); + } + }); + + it("returns error for unknown symbol", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeSymbolContext(codebaseGraph, "xyzNonexistentSymbol"); + + expect("error" in result).toBe(true); + }); + }); + + describe("computeProcesses", () => { + it("returns processes list", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeProcesses(codebaseGraph); + + expect(typeof result.totalProcesses).toBe("number"); + expect(Array.isArray(result.processes)).toBe(true); + + for (const p of result.processes) { + expect(p).toHaveProperty("name"); + expect(p).toHaveProperty("entryPoint"); + expect(p).toHaveProperty("steps"); + expect(p).toHaveProperty("depth"); + expect(p).toHaveProperty("modulesTouched"); + } + }); + + it("respects limit parameter", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeProcesses(codebaseGraph, undefined, 1); + + expect(result.processes.length).toBeLessThanOrEqual(1); + }); + }); + + describe("computeClusters", () => { + it("returns clusters list", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = computeClusters(codebaseGraph); + + expect(typeof result.totalClusters).toBe("number"); + expect(Array.isArray(result.clusters)).toBe(true); + + for (const c of result.clusters) { + expect(c).toHaveProperty("id"); + expect(c).toHaveProperty("name"); + expect(c).toHaveProperty("files"); + expect(c).toHaveProperty("fileCount"); + expect(c).toHaveProperty("cohesion"); + } + }); + + it("respects minFiles filter", () => { + const { codebaseGraph } = getFixturePipeline(); + const all = computeClusters(codebaseGraph); + const filtered = computeClusters(codebaseGraph, 100); + + expect(filtered.clusters.length).toBeLessThanOrEqual(all.clusters.length); + }); + }); + + describe("impactAnalysis (re-exported from core)", () => { + it("returns impact levels for a known symbol", () => { + const { codebaseGraph } = getFixturePipeline(); + const firstSymbol = [...codebaseGraph.symbolMetrics.values()][0]; + if (!firstSymbol) return; + + const result = impactAnalysis(codebaseGraph, firstSymbol.name); + + expect(result.symbol).toBe(firstSymbol.name); + expect(typeof result.totalAffected).toBe("number"); + expect(Array.isArray(result.levels)).toBe(true); + }); + + it("returns notFound for unknown symbol", () => { + const { codebaseGraph } = getFixturePipeline(); + const result = impactAnalysis(codebaseGraph, "xyzNonexistent"); + + expect(result.notFound).toBe(true); + }); + }); + + describe("renameSymbol (re-exported from core)", () => { + it("finds references for a known symbol", () => { + const { codebaseGraph } = getFixturePipeline(); + const firstSymbol = [...codebaseGraph.symbolMetrics.values()][0]; + if (!firstSymbol) return; + + const result = renameSymbol(codebaseGraph, firstSymbol.name, "newName", true); + + expect(result.dryRun).toBe(true); + expect(result.oldName).toBe(firstSymbol.name); + expect(result.newName).toBe("newName"); + expect(typeof result.totalReferences).toBe("number"); + expect(Array.isArray(result.references)).toBe(true); + }); + }); + + describe("MCP backward compatibility (core extraction)", () => { + it("all compute functions return consistent types", () => { + const { codebaseGraph } = getFixturePipeline(); + + const overview = computeOverview(codebaseGraph); + expect(typeof overview.totalFiles).toBe("number"); + + const hotspots = computeHotspots(codebaseGraph, "coupling"); + expect(typeof hotspots.metric).toBe("string"); + + const knownFile = [...codebaseGraph.fileMetrics.keys()][0]; + const fileCtx = computeFileContext(codebaseGraph, knownFile); + expect("path" in fileCtx || "error" in fileCtx).toBe(true); + + const search = computeSearch(codebaseGraph, "test"); + expect(typeof search.query).toBe("string"); + + const changes = computeChanges(codebaseGraph); + expect("scope" in changes || "error" in changes).toBe(true); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4390fa5..91a259a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ include: ["src/**/*.test.ts", "tests/**/*.test.ts"], testTimeout: 15000, hookTimeout: 15000, + teardownTimeout: 30000, coverage: { provider: "v8", include: ["src/**/*.ts"],