diff --git a/CLAUDE.md b/CLAUDE.md index e5a47d58..f76eb2dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,10 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The | `native.js` | Native napi-rs addon loader with WASM fallback | | `registry.js` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | | `resolve.js` | Import resolution (supports native batch mode) | +| `complexity.js` | Cognitive, cyclomatic, Halstead, MI computation from AST; `complexity` CLI command | +| `communities.js` | Louvain community detection, drift analysis | +| `manifesto.js` | Configurable rule engine with warn/fail thresholds; CI gate | +| `paginate.js` | Pagination helpers for bounded query results | | `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) | **Key design decisions:** @@ -71,7 +75,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The - **MCP single-repo isolation:** `startMCPServer` defaults to single-repo mode โ€” tools have no `repo` property and `list_repos` is not exposed. Passing `--multi-repo` or `--repos` to the CLI (or `options.multiRepo` / `options.allowedRepos` programmatically) enables multi-repo access. `buildToolList(multiRepo)` builds the tool list dynamically; the backward-compatible `TOOLS` export equals `buildToolList(true)` - **Credential resolution:** `loadConfig` pipeline is `mergeConfig โ†’ applyEnvOverrides โ†’ resolveSecrets`. The `apiKeyCommand` config field shells out to an external secret manager via `execFileSync` (no shell). Priority: command output > env var > file config > defaults. On failure, warns and falls back gracefully -**Database:** SQLite at `.codegraph/graph.db` with tables: `nodes`, `edges`, `metadata`, `embeddings` +**Database:** SQLite at `.codegraph/graph.db` with tables: `nodes`, `edges`, `metadata`, `embeddings`, `function_complexity` ## Test Structure @@ -118,6 +122,9 @@ node src/cli.js stats # Graph health and quality score node src/cli.js fn -T # Function call chain (callers + callees) node src/cli.js deps src/.js # File-level imports and importers node src/cli.js diff-impact main # Impact of current branch vs main +node src/cli.js complexity -T # Per-function complexity metrics +node src/cli.js communities -T # Community detection & drift analysis +node src/cli.js manifesto -T # Rule engine pass/fail check node src/cli.js cycles # Check for circular dependencies node src/cli.js search "" # Semantic search (requires `embed` first) ``` diff --git a/README.md b/README.md index e7664cc0..c595f9cd 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ cd your-project codegraph build ``` -That's it. No config files, no Docker, no JVM, no API keys, no accounts. The graph is ready to query. Add `codegraph mcp` to your AI agent's config and it has full access to your dependency graph through 21 MCP tools (22 in multi-repo mode). +That's it. No config files, no Docker, no JVM, no API keys, no accounts. The graph is ready to query. Add `codegraph mcp` to your AI agent's config and it has full access to your dependency graph through 24 MCP tools (25 in multi-repo mode). ### Why it matters @@ -97,7 +97,7 @@ That's it. No config files, no Docker, no JVM, no API keys, no accounts. The gra | **๐Ÿ”“** | **Zero-cost core, LLM-enhanced when you want** | Full graph analysis with no API keys, no accounts, no cost. Optionally bring your own LLM provider โ€” your code only goes where you choose | | **๐Ÿ”ฌ** | **Function-level, not just files** | Traces `handleAuth()` โ†’ `validateToken()` โ†’ `decryptJWT()` and shows 14 callers across 9 files break if `decryptJWT` changes | | **๐Ÿท๏ธ** | **Role classification** | Every symbol auto-tagged as `entry`/`core`/`utility`/`adapter`/`dead`/`leaf` โ€” agents instantly know what they're looking at | -| **๐Ÿค–** | **Built for AI agents** | 21-tool [MCP server](https://modelcontextprotocol.io/) โ€” AI assistants query your graph directly. Single-repo by default | +| **๐Ÿค–** | **Built for AI agents** | 24-tool [MCP server](https://modelcontextprotocol.io/) โ€” AI assistants query your graph directly. Single-repo by default | | **๐ŸŒ** | **Multi-language, one CLI** | JS/TS + Python + Go + Rust + Java + C# + PHP + Ruby + HCL in a single graph | | **๐Ÿ’ฅ** | **Git diff impact** | `codegraph diff-impact` shows changed functions, their callers, and full blast radius โ€” enriched with historically coupled files from git co-change analysis. Ships with a GitHub Actions workflow | | **๐Ÿง ** | **Semantic search** | Local embeddings by default, LLM-powered when opted in โ€” multi-query with RRF ranking via `"auth; token; JWT"` | @@ -144,7 +144,7 @@ After modifying code: Or connect directly via MCP: ```bash -codegraph mcp # 21-tool MCP server โ€” AI queries the graph directly +codegraph mcp # 24-tool MCP server โ€” AI queries the graph directly ``` Full agent setup: [AI Agent Guide](docs/guides/ai-agent-guide.md) · [CLAUDE.md template](docs/guides/ai-agent-guide.md#claudemd-template) @@ -170,8 +170,11 @@ Full agent setup: [AI Agent Guide](docs/guides/ai-agent-guide.md) · [CLAU | ๐Ÿ“ค | **Export** | DOT (Graphviz), Mermaid, and JSON graph export | | ๐Ÿง  | **Semantic search** | Embeddings-powered natural language search with multi-query RRF ranking | | ๐Ÿ‘€ | **Watch mode** | Incrementally update the graph as files change | -| ๐Ÿค– | **MCP server** | 21-tool MCP server for AI assistants; single-repo by default, opt-in multi-repo | +| ๐Ÿค– | **MCP server** | 24-tool MCP server for AI assistants; single-repo by default, opt-in multi-repo | | โšก | **Always fresh** | Three-tier incremental detection โ€” sub-second rebuilds even on large codebases | +| ๐Ÿงฎ | **Complexity metrics** | Cognitive, cyclomatic, nesting depth, Halstead, and Maintainability Index per function | +| ๐Ÿ˜๏ธ | **Community detection** | Louvain clustering to discover natural module boundaries and architectural drift | +| ๐Ÿ“œ | **Manifesto rule engine** | Configurable pass/fail rules with warn/fail thresholds for CI gates (exit code 1 on fail) | See [docs/examples](docs/examples) for real-world CLI and MCP usage examples. @@ -250,6 +253,20 @@ codegraph hotspots # Files with extreme fan-in, fan-out, or density codegraph hotspots --metric coupling --level directory --no-tests ``` +### Code Health & Architecture + +```bash +codegraph complexity # Per-function cognitive, cyclomatic, nesting, MI +codegraph complexity --health -T # Full Halstead health view (volume, effort, bugs, MI) +codegraph complexity --sort mi -T # Sort by worst maintainability index +codegraph complexity --above-threshold -T # Only functions exceeding warn thresholds +codegraph communities # Louvain community detection โ€” natural module boundaries +codegraph communities --drift -T # Drift analysis only โ€” split/merge candidates +codegraph communities --functions # Function-level community detection +codegraph manifesto # Pass/fail rule engine (exit code 1 on fail) +codegraph manifesto -T # Exclude test files from rule evaluation +``` + ### Export & Visualization ```bash @@ -319,7 +336,7 @@ codegraph registry remove # Unregister | Flag | Description | |---|---| | `-d, --db ` | Custom path to `graph.db` | -| `-T, --no-tests` | Exclude `.test.`, `.spec.`, `__test__` files (available on `fn`, `fn-impact`, `path`, `context`, `explain`, `where`, `diff-impact`, `search`, `map`, `hotspots`, `roles`, `co-change`, `deps`, `impact`) | +| `-T, --no-tests` | Exclude `.test.`, `.spec.`, `__test__` files (available on `fn`, `fn-impact`, `path`, `context`, `explain`, `where`, `diff-impact`, `search`, `map`, `hotspots`, `roles`, `co-change`, `deps`, `impact`, `complexity`, `communities`, `manifesto`) | | `--depth ` | Transitive trace depth (default varies by command) | | `-j, --json` | Output as JSON | | `-v, --verbose` | Enable debug output | @@ -431,7 +448,7 @@ Optional: `@huggingface/transformers` (semantic search), `@modelcontextprotocol/ ### MCP Server -Codegraph includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server with 21 tools (22 in multi-repo mode), so AI assistants can query your dependency graph directly: +Codegraph includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server with 24 tools (25 in multi-repo mode), so AI assistants can query your dependency graph directly: ```bash codegraph mcp # Single-repo mode (default) โ€” only local project @@ -470,6 +487,9 @@ This project uses codegraph. The database is at `.codegraph/graph.db`. - `codegraph roles --role dead -T` โ€” find dead code (unreferenced symbols) - `codegraph roles --role core -T` โ€” find core symbols (high fan-in) - `codegraph co-change ` โ€” files that historically change together +- `codegraph complexity -T` โ€” per-function complexity metrics (cognitive, cyclomatic, MI) +- `codegraph communities --drift -T` โ€” module boundary drift analysis +- `codegraph manifesto -T` โ€” pass/fail rule check (CI gate, exit code 1 on fail) - `codegraph search ""` โ€” semantic search (requires `codegraph embed`) - `codegraph cycles` โ€” check for circular dependencies @@ -550,6 +570,26 @@ Create a `.codegraphrc.json` in your project root to customize behavior: > **Tip:** `excludeTests` can also be set at the top level as a shorthand โ€” `{ "excludeTests": true }` is equivalent to nesting it under `query`. If both are present, the nested `query.excludeTests` takes precedence. +### Manifesto rules + +Configure pass/fail thresholds for `codegraph manifesto`: + +```json +{ + "manifesto": { + "rules": { + "cognitive_complexity": { "warn": 15, "fail": 30 }, + "cyclomatic_complexity": { "warn": 10, "fail": 20 }, + "nesting_depth": { "warn": 4, "fail": 6 }, + "maintainability_index": { "warn": 40, "fail": 20 }, + "halstead_bugs": { "warn": 0.5, "fail": 1.0 } + } + } +} +``` + +When any function exceeds a `fail` threshold, `codegraph manifesto` exits with code 1 โ€” perfect for CI gates. + ### LLM credentials Codegraph supports an `apiKeyCommand` field for secure credential management. Instead of storing API keys in config files or environment variables, you can shell out to a secret manager at runtime: diff --git a/docs/examples/CLI.md b/docs/examples/CLI.md index f5795a9a..e6eb5c2d 100644 --- a/docs/examples/CLI.md +++ b/docs/examples/CLI.md @@ -758,6 +758,173 @@ Top co-change pairs: --- +## complexity โ€” Per-function complexity metrics + +```bash +codegraph complexity -T --limit 5 +``` + +``` +# Function Complexity + + Function File Cog Cyc Nest MI + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ + buildGraph src/builder.js 495 185 9 - ! + extractJavaSymbols src/extractors/java.js 208 64 10 13.9 ! + extractSymbolsWalk src/extractors/javascript.js 197 72 11 11.1 ! + walkJavaNode src/extractors/java.js 161 59 9 16 ! + walkJavaScriptNode src/extractors/javascript.js 160 72 10 11.6 ! + + 339 functions analyzed | avg cognitive: 18.8 | avg cyclomatic: 10.5 | avg MI: 22 | 106 above threshold +``` + +Full Halstead health view: + +```bash +codegraph complexity --health -T --limit 5 +``` + +``` +# Function Complexity + + Function File MI Vol Diff Effort Bugs LOC SLOC + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ + buildGraph src/builder.js 0 0 0 0 0 0 0 + extractJavaSymbols src/extractors/java.js 13.9!6673.96 70.52 470637.77 2.2247 225 212 + extractSymbolsWalk โ€ฆtractors/javascript.js 11.1!7911.66 50.02 395780.68 2.6372 251 239 + walkJavaNode src/extractors/java.js 16!5939.15 65.25 387509.16 1.9797 198 188 + walkJavaScriptNode โ€ฆtractors/javascript.js 11.6!7624.39 47.67 363429.06 2.5415 240 230 + + 339 functions analyzed | avg cognitive: 18.8 | avg cyclomatic: 10.5 | avg MI: 22 | 106 above threshold +``` + +Only functions exceeding warn thresholds: + +```bash +codegraph complexity --above-threshold -T --limit 5 +``` + +``` +# Functions Above Threshold + + Function File Cog Cyc Nest MI + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ + buildGraph src/builder.js 495 185 9 - ! + extractJavaSymbols src/extractors/java.js 208 64 10 13.9 ! + extractSymbolsWalk src/extractors/javascript.js 197 72 11 11.1 ! + walkJavaNode src/extractors/java.js 161 59 9 16 ! + walkJavaScriptNode src/extractors/javascript.js 160 72 10 11.6 ! + + 339 functions analyzed | avg cognitive: 18.8 | avg cyclomatic: 10.5 | avg MI: 22 | 106 above threshold +``` + +Sort by worst maintainability index: + +```bash +codegraph complexity --sort mi -T --limit 5 +``` + +``` +# Function Complexity + + Function File Cog Cyc Nest MI + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ + median scripts/benchmark.js 1 2 1 - + round1 scripts/benchmark.js 0 1 0 - + selectTargets scripts/benchmark.js 1 2 1 - + benchmarkEngine scripts/benchmark.js 5 5 2 - + benchQuery scripts/benchmark.js 1 2 1 - + + 339 functions analyzed | avg cognitive: 18.8 | avg cyclomatic: 10.5 | avg MI: 22 | 106 above threshold +``` + +--- + +## communities โ€” Community detection & drift analysis + +```bash +codegraph communities -T +``` + +``` +# File-Level Communities + + 41 communities | 73 nodes | modularity: 0.4114 | drift: 39% + + Community 34 (16 members): src (16) + - src/cochange.js + - src/communities.js + - src/cycles.js + - src/embedder.js + - src/logger.js + - src/registry.js + - src/structure.js + - src/update-check.js + ... and 8 more + Community 35 (12 members): src/extractors (11), src (1) + - src/extractors/csharp.js + - src/extractors/go.js + - src/extractors/helpers.js + - src/extractors/javascript.js + - src/extractors/php.js + ... and 7 more + Community 33 (6 members): src (6) + - src/builder.js + - src/constants.js + - src/journal.js + - src/native.js + - src/resolve.js + - src/watcher.js +``` + +Drift analysis only: + +```bash +codegraph communities --drift -T +``` + +``` +# File-Level Communities + + 41 communities | 73 nodes | modularity: 0.4114 | drift: 39% + +# Drift Analysis (score: 39%) + + Split candidates (directories spanning multiple communities): + - scripts โ†’ 13 communities + - crates/codegraph-core/src/extractors โ†’ 11 communities + - crates/codegraph-core/src โ†’ 7 communities + - src โ†’ 4 communities + - tests/fixtures/sample-project โ†’ 3 communities + - (root) โ†’ 2 communities + Merge candidates (communities spanning multiple directories): + - Community 35 (12 members) โ†’ 2 dirs: src/extractors, src +``` + +--- + +## manifesto โ€” Rule engine pass/fail + +```bash +codegraph manifesto -T +``` + +``` +# Manifesto Results + + Rule Status Threshold Violations + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + cognitive_complexity FAIL warn>15 fail>30 84 functions + cyclomatic_complexity FAIL warn>10 fail>20 42 functions + nesting_depth FAIL warn>4 fail>6 28 functions + maintainability_index FAIL warn<40 fail<20 52 functions + halstead_bugs WARN warn>0.5 fail>1 18 functions + + Result: FAIL (exit code 1) +``` + +--- + ## path โ€” Shortest path between two symbols ```bash diff --git a/docs/examples/MCP.md b/docs/examples/MCP.md index e64ce9ac..c6a586cf 100644 --- a/docs/examples/MCP.md +++ b/docs/examples/MCP.md @@ -732,6 +732,158 @@ Path: buildGraph โ†’ isTestFile (2 hops) --- +## complexity โ€” Per-function complexity metrics + +```json +{ + "tool": "complexity", + "arguments": { "no_tests": true, "limit": 5 } +} +``` + +``` +# Function Complexity + + Function File Cog Cyc Nest MI + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ + buildGraph src/builder.js 495 185 9 - ! + extractJavaSymbols src/extractors/java.js 208 64 10 13.9 ! + extractSymbolsWalk src/extractors/javascript.js 197 72 11 11.1 ! + walkJavaNode src/extractors/java.js 161 59 9 16 ! + walkJavaScriptNode src/extractors/javascript.js 160 72 10 11.6 ! + + 339 functions analyzed | avg cognitive: 18.8 | avg cyclomatic: 10.5 | avg MI: 22 | 106 above threshold +``` + +With health view (Halstead metrics): + +```json +{ + "tool": "complexity", + "arguments": { "no_tests": true, "health": true, "limit": 5 } +} +``` + +``` +# Function Complexity + + Function File MI Vol Diff Effort Bugs LOC SLOC + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ + buildGraph src/builder.js 0 0 0 0 0 0 0 + extractJavaSymbols src/extractors/java.js 13.9!6673.96 70.52 470637.77 2.2247 225 212 + extractSymbolsWalk โ€ฆtractors/javascript.js 11.1!7911.66 50.02 395780.68 2.6372 251 239 + walkJavaNode src/extractors/java.js 16!5939.15 65.25 387509.16 1.9797 198 188 + walkJavaScriptNode โ€ฆtractors/javascript.js 11.6!7624.39 47.67 363429.06 2.5415 240 230 + + 339 functions analyzed | avg cognitive: 18.8 | avg cyclomatic: 10.5 | avg MI: 22 | 106 above threshold +``` + +Scope to a specific file: + +```json +{ + "tool": "complexity", + "arguments": { "file": "src/builder.js", "no_tests": true } +} +``` + +--- + +## communities โ€” Community detection & drift analysis + +```json +{ + "tool": "communities", + "arguments": { "no_tests": true } +} +``` + +``` +# File-Level Communities + + 41 communities | 73 nodes | modularity: 0.4114 | drift: 39% + + Community 34 (16 members): src (16) + - src/cochange.js + - src/communities.js + - src/cycles.js + - src/embedder.js + - src/logger.js + - src/registry.js + - src/structure.js + - src/update-check.js + ... and 8 more + Community 35 (12 members): src/extractors (11), src (1) + - src/extractors/csharp.js + - src/extractors/go.js + - src/extractors/helpers.js + - src/extractors/javascript.js + ... and 8 more + Community 33 (6 members): src (6) + - src/builder.js + - src/constants.js + - src/journal.js + - src/native.js + - src/resolve.js + - src/watcher.js +``` + +Drift analysis only: + +```json +{ + "tool": "communities", + "arguments": { "drift": true, "no_tests": true } +} +``` + +``` +# Drift Analysis (score: 39%) + + Split candidates (directories spanning multiple communities): + - scripts โ†’ 13 communities + - crates/codegraph-core/src/extractors โ†’ 11 communities + - src โ†’ 4 communities + Merge candidates (communities spanning multiple directories): + - Community 35 (12 members) โ†’ 2 dirs: src/extractors, src +``` + +Function-level community detection: + +```json +{ + "tool": "communities", + "arguments": { "functions": true, "no_tests": true } +} +``` + +--- + +## manifesto โ€” Rule engine pass/fail + +```json +{ + "tool": "manifesto", + "arguments": { "no_tests": true } +} +``` + +``` +# Manifesto Results + + Rule Status Threshold Violations + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + cognitive_complexity FAIL warn>15 fail>30 84 functions + cyclomatic_complexity FAIL warn>10 fail>20 42 functions + nesting_depth FAIL warn>4 fail>6 28 functions + maintainability_index FAIL warn<40 fail<20 52 functions + halstead_bugs WARN warn>0.5 fail>1 18 functions + + Result: FAIL (exit code 1) +``` + +--- + ## list_repos โ€” Multi-repo registry (multi-repo mode only) Only available when the MCP server is started with `--multi-repo`. diff --git a/docs/guides/recommended-practices.md b/docs/guides/recommended-practices.md index 102b0070..a1e21c73 100644 --- a/docs/guides/recommended-practices.md +++ b/docs/guides/recommended-practices.md @@ -110,6 +110,17 @@ Add a threshold check to your CI pipeline: fi ``` +### Code health gate + +Use `manifesto` to enforce code health rules in CI โ€” it exits with code 1 when any function exceeds a fail-level threshold: + +```yaml +- name: Code health gate + run: | + npx codegraph build + npx codegraph manifesto -T # exits 1 on fail-level breach +``` + ### Caching the graph database Speed up CI by caching `.codegraph/`: @@ -143,7 +154,7 @@ By default, the MCP server runs in **single-repo mode** โ€” the AI agent can onl Enable `--multi-repo` to let the agent query any registered repository, or use `--repos` to restrict access to a specific set of repos. -The server exposes 21 tools (22 in multi-repo mode): `query_function`, `file_deps`, `impact_analysis`, `find_cycles`, `module_map`, `fn_deps`, `fn_impact`, `symbol_path`, `context`, `explain`, `where`, `diff_impact`, `semantic_search`, `export_graph`, `list_functions`, `structure`, `hotspots`, `node_roles`, `co_changes`, `execution_flow`, `list_entry_points`, and `list_repos` (multi-repo only). See the [AI Agent Guide MCP reference](./ai-agent-guide.md#mcp-server-reference) for the full tool-to-CLI mapping table. +The server exposes 24 tools (25 in multi-repo mode): `query_function`, `file_deps`, `impact_analysis`, `find_cycles`, `module_map`, `fn_deps`, `fn_impact`, `symbol_path`, `context`, `explain`, `where`, `diff_impact`, `semantic_search`, `export_graph`, `list_functions`, `structure`, `hotspots`, `node_roles`, `co_changes`, `execution_flow`, `list_entry_points`, `complexity`, `communities`, `manifesto`, and `list_repos` (multi-repo only). See the [AI Agent Guide MCP reference](./ai-agent-guide.md#mcp-server-reference) for the full tool-to-CLI mapping table. ### CLAUDE.md for your project @@ -172,6 +183,9 @@ This project uses codegraph. The database is at `.codegraph/graph.db`. - `codegraph roles --role dead -T` โ€” find dead code (unreferenced symbols) - `codegraph roles --role core -T` โ€” find core symbols (high fan-in) - `codegraph co-change ` โ€” files that historically change together +- `codegraph complexity -T` โ€” per-function complexity metrics (cognitive, cyclomatic, MI) +- `codegraph communities --drift -T` โ€” module boundary drift analysis +- `codegraph manifesto -T` โ€” pass/fail rule check (CI gate, exit code 1 on fail) - `codegraph search ""` โ€” semantic search (requires `codegraph embed`) - `codegraph cycles` โ€” check for circular dependencies @@ -292,6 +306,12 @@ codegraph fn-impact myFunction --no-tests # what breaks if this changes codegraph path myFunction otherFunction -T # how two symbols are connected ``` +Check complexity before refactoring: + +```bash +codegraph complexity --file src/utils/auth.ts -T # complexity metrics for functions in a file +``` + Before touching a file: ```bash @@ -549,9 +569,12 @@ cp node_modules/@optave/codegraph/.github/workflows/codegraph-impact.yml .github # 6. (Optional) Scan git history for co-change coupling codegraph co-change --analyze -# 7. (Optional) Build embeddings for semantic search +# 7. (Optional) Verify code health rules pass +codegraph manifesto -T + +# 8. (Optional) Build embeddings for semantic search codegraph embed -# 8. (Optional) Add CLAUDE.md for AI agents +# 9. (Optional) Add CLAUDE.md for AI agents # See docs/guides/ai-agent-guide.md for the full template ``` diff --git a/docs/roadmap/BACKLOG.md b/docs/roadmap/BACKLOG.md index e2d8e418..d4bc2808 100644 --- a/docs/roadmap/BACKLOG.md +++ b/docs/roadmap/BACKLOG.md @@ -1,6 +1,6 @@ # Codegraph Feature Backlog -**Last updated:** 2026-02-25 +**Last updated:** 2026-02-26 **Source:** Features derived from [COMPETITIVE_ANALYSIS.md](../../generated/COMPETITIVE_ANALYSIS.md) and internal roadmap discussions. --- @@ -42,12 +42,12 @@ Non-breaking, ordered by problem-fit: | 13 | Architecture boundary rules | User-defined rules for allowed/forbidden dependencies between modules (e.g., "controllers must not import from other controllers"). Violations flagged in `diff-impact` and CI. Inspired by codegraph-rust, stratify. | Architecture | Prevents architectural decay in CI; agents are warned before introducing forbidden cross-module dependencies | โœ“ | โœ“ | 3 | No | | 15 | Hybrid BM25 + semantic search | Combine BM25 keyword matching with embedding-based semantic search using Reciprocal Rank Fusion. Better recall than either approach alone. Inspired by GitNexus, claude-context-local. | Search | Search results improve dramatically โ€” keyword matches catch exact names, embeddings catch conceptual matches, RRF merges both | โœ“ | โœ“ | 3 | No | | 18 | CODEOWNERS integration | Map graph nodes to CODEOWNERS entries. Show who owns each function, surface ownership boundaries in `diff-impact`. Inspired by CKB. | Developer Experience | `diff-impact` tells agents which teams to notify; ownership-aware impact analysis reduces missed reviews | โœ“ | โœ“ | 3 | No | -| 22 | Manifesto-driven pass/fail | User-defined rule engine with custom thresholds (e.g. "cognitive > 15 = fail", "cyclomatic > 10 = fail", "imports > 10 = decompose"). Outputs pass/fail per function/file. Generalizes ID 13 (boundary rules) into a generic rule system. | Analysis | Enables autonomous multi-agent audit workflows (GAUNTLET pattern); CI integration for code health gates with configurable thresholds | โœ“ | โœ“ | 3 | No | +| 22 | ~~Manifesto-driven pass/fail~~ | ~~User-defined rule engine with custom thresholds (e.g. "cognitive > 15 = fail", "cyclomatic > 10 = fail", "imports > 10 = decompose"). Outputs pass/fail per function/file. Generalizes ID 13 (boundary rules) into a generic rule system.~~ | Analysis | ~~Enables autonomous multi-agent audit workflows (GAUNTLET pattern); CI integration for code health gates with configurable thresholds~~ | โœ“ | โœ“ | 3 | No | **DONE** โ€” `codegraph manifesto` with 9 configurable rules (cognitive, cyclomatic, nesting, MI, Halstead volume/effort/bugs, fan-in, fan-out). Warn/fail thresholds via `.codegraphrc.json`. Exit code 1 on any fail-level breach โ€” CI gate ready. PR #138. | | 31 | Graph snapshots | `codegraph snapshot save ` / `codegraph snapshot restore ` for lightweight SQLite DB backup and restore. Enables orchestrators to checkpoint before refactoring passes and instantly rollback without rebuilding. After Phase 4, also preserves embeddings and semantic metadata. Inspired by [Titan Paradigm](../docs/use-cases/titan-paradigm.md) STATE MACHINE phase. | Orchestration | Multi-agent workflows get instant rollback without re-running expensive builds or LLM calls โ€” orchestrator checkpoints before each pass and restores on failure | โœ“ | โœ“ | 3 | No | -| 6 | Formal code health metrics | Cyclomatic complexity, Maintainability Index, and Halstead metrics per function โ€” we already parse the AST, the data is there. Inspired by code-health-meter (published in ACM TOSEM 2025). | Analysis | Agents can prioritize refactoring targets; `hotspots` becomes richer with quantitative health scores per function | โœ“ | โœ“ | 2 | No | +| 6 | ~~Formal code health metrics~~ | ~~Cyclomatic complexity, Maintainability Index, and Halstead metrics per function โ€” we already parse the AST, the data is there. Inspired by code-health-meter (published in ACM TOSEM 2025).~~ | Analysis | ~~Agents can prioritize refactoring targets; `hotspots` becomes richer with quantitative health scores per function~~ | โœ“ | โœ“ | 2 | No | **DONE** โ€” `codegraph complexity` provides cognitive, cyclomatic, nesting depth, Halstead (volume, difficulty, effort, bugs), and Maintainability Index per function. `--health` for full Halstead view, `--sort mi` to rank by MI, `--above-threshold` for flagged functions. `function_complexity` DB table. `complexity` MCP tool. PR #130 + #139. | | 7 | OWASP/CWE pattern detection | Security pattern scanning on the existing AST โ€” hardcoded secrets, SQL injection patterns, eval usage, XSS sinks. Lightweight static rules, not full taint analysis. Inspired by narsil-mcp, CKB. | Security | Catches low-hanging security issues during `diff-impact`; agents can flag risky patterns before they're committed | โœ“ | โœ“ | 2 | No | -| 11 | Community detection | Leiden/Louvain algorithm to discover natural module boundaries vs actual file organization. Reveals which symbols are tightly coupled and whether the directory structure matches. Inspired by axon, GitNexus, CodeGraphMCPServer. | Intelligence | Surfaces architectural drift โ€” when directory structure no longer matches actual dependency clusters; guides refactoring | โœ“ | โœ“ | 2 | No | -| 21 | Cognitive + cyclomatic complexity | Cognitive Complexity (SonarSource) as the primary readability metric โ€” penalizes nesting, so it subsumes nesting depth analysis. Cyclomatic complexity (McCabe) as secondary testability metric. Both computed from existing tree-sitter AST in a single traversal. Cognitive > 15 or cyclomatic > 10 = flag for refactoring. Extends ID 6 with the two most actionable metrics. | Analysis | Agents can flag hard-to-understand and hard-to-test functions in one pass; cognitive complexity captures both decision complexity and nesting depth in a single score | โœ“ | โœ“ | 2 | No | +| 11 | ~~Community detection~~ | ~~Leiden/Louvain algorithm to discover natural module boundaries vs actual file organization. Reveals which symbols are tightly coupled and whether the directory structure matches. Inspired by axon, GitNexus, CodeGraphMCPServer.~~ | Intelligence | ~~Surfaces architectural drift โ€” when directory structure no longer matches actual dependency clusters; guides refactoring~~ | โœ“ | โœ“ | 2 | No | **DONE** โ€” `codegraph communities` with Louvain algorithm. File-level and `--functions` function-level detection. `--drift` for drift analysis (split/merge candidates). `--resolution` tunable. `communities` MCP tool. PR #133/#134. | +| 21 | ~~Cognitive + cyclomatic complexity~~ | ~~Cognitive Complexity (SonarSource) as the primary readability metric โ€” penalizes nesting, so it subsumes nesting depth analysis. Cyclomatic complexity (McCabe) as secondary testability metric. Both computed from existing tree-sitter AST in a single traversal. Cognitive > 15 or cyclomatic > 10 = flag for refactoring. Extends ID 6 with the two most actionable metrics.~~ | Analysis | ~~Agents can flag hard-to-understand and hard-to-test functions in one pass; cognitive complexity captures both decision complexity and nesting depth in a single score~~ | โœ“ | โœ“ | 2 | No | **DONE** โ€” Subsumed by ID 6. `codegraph complexity` provides both cognitive and cyclomatic metrics (plus nesting depth, Halstead, MI) in a single command. PR #130. | Breaking (penalized to end of tier): diff --git a/docs/use-cases/titan-paradigm.md b/docs/use-cases/titan-paradigm.md index 11f10e15..18aaea46 100644 --- a/docs/use-cases/titan-paradigm.md +++ b/docs/use-cases/titan-paradigm.md @@ -47,7 +47,17 @@ codegraph hotspots --no-tests codegraph stats ``` -An orchestrating agent can use `map` and `hotspots` to build a priority queue: audit the most-connected files first, because changes there have the highest blast radius. The `--json` flag on every command makes it trivial to feed results into a state file or orchestration script. +Use `communities` to discover natural module boundaries and identify architectural drift โ€” where the directory structure no longer matches actual dependency clusters: + +```bash +# Discover natural module boundaries via Louvain clustering +codegraph communities -T + +# Drift analysis: which directories should be split or merged? +codegraph communities --drift -T +``` + +An orchestrating agent can use `map`, `hotspots`, and `communities` to build a priority queue: audit the most-connected files first, because changes there have the highest blast radius. The `--json` flag on every command makes it trivial to feed results into a state file or orchestration script. ```bash # JSON output for programmatic consumption @@ -89,6 +99,19 @@ codegraph fn-impact wasmExtractSymbols -T codegraph fn wasmExtractSymbols -T --depth 5 ``` +Use `complexity` to get quantitative metrics for every function in the file, and `manifesto` to run the full rule engine: + +```bash +# 5. Per-function complexity metrics โ€” cognitive, cyclomatic, nesting, MI +codegraph complexity --file src/parser.js -T + +# 6. Full Halstead health view โ€” volume, effort, estimated bugs, MI +codegraph complexity --file src/parser.js --health -T + +# 7. Pass/fail rule check โ€” does this file meet the manifesto? +codegraph manifesto -T +``` + When a sub-agent decides a function needs decomposition (complexity > 7, nesting > 3, 10+ mocks), it needs to know what breaks. `fn-impact` gives the complete blast radius **before** the agent writes a single line of code. The `--json` flag lets the orchestrator aggregate results across all sub-agents: @@ -144,7 +167,14 @@ codegraph diff-impact --staged --format mermaid -T codegraph diff-impact --staged -T --json > state/impact-check.json ``` -The orchestrator can gate every commit: run `diff-impact --staged --json`, check that the blast radius is within acceptable bounds, and auto-rollback if it exceeds thresholds. Combined with `codegraph watch` for real-time graph updates, the state machine always has a current picture of the codebase. +Use `manifesto` as a CI gate โ€” it exits with code 1 when any function exceeds a fail-level threshold: + +```bash +# Pass/fail rule check โ€” exit code 1 = fail โ†’ rollback trigger +codegraph manifesto -T +``` + +The orchestrator can gate every commit: run `diff-impact --staged --json` to check blast radius, and `manifesto -T` to verify code health rules. Auto-rollback if either exceeds thresholds. Combined with `codegraph watch` for real-time graph updates, the state machine always has a current picture of the codebase. ```bash # Watch mode โ€” graph updates automatically as agents edit files @@ -171,9 +201,10 @@ Several planned features would make codegraph even more powerful for the Titan P | Feature | Status | How it helps | |---------|--------|-------------| -| **Formal code health metrics** ([Backlog #6](../../roadmap/BACKLOG.md)) | Planned | Cyclomatic complexity, Maintainability Index, and Halstead metrics per function โ€” directly maps to the Gauntlet's "complexity > 7 is a failure" rule. Computed from the AST we already parse | +| **Formal code health metrics** ([Backlog #6](../../roadmap/BACKLOG.md)) | **Done** | `codegraph complexity` provides cognitive, cyclomatic, nesting depth, Halstead (volume, effort, bugs), and Maintainability Index per function. `--health` for full view, `--sort mi` to rank by MI, `--above-threshold` for flagged functions. Maps directly to the Gauntlet's "complexity > 7 is a failure" rule. PR #130 + #139 | +| **Manifesto-driven pass/fail** ([Backlog #22](../../roadmap/BACKLOG.md)) | **Done** | `codegraph manifesto` with 9 configurable rules and warn/fail thresholds. Exit code 1 on fail โ€” the Gauntlet gets first-class pass/fail signals without parsing JSON. PR #138 | +| **Community detection** ([Backlog #11](../../roadmap/BACKLOG.md)) | **Done** | `codegraph communities` with Louvain algorithm discovers natural module boundaries vs actual file organization. `--drift` reveals which directories should be split or merged. `--functions` for function-level clustering. PR #133/#134 | | **Build-time semantic metadata** ([Roadmap Phase 4.4](../../roadmap/ROADMAP.md#44--build-time-semantic-metadata)) | Planned | LLM-generated `complexity_notes`, `risk_score`, and `side_effects` per function. A sub-agent could query `codegraph assess ` and get "3 responsibilities, low cohesion โ€” consider splitting" without analyzing the code itself | -| **Community detection** ([Backlog #11](../../roadmap/BACKLOG.md)) | Planned | Leiden/Louvain algorithm to discover natural module boundaries vs actual file organization. Reveals which functions are tightly coupled and whether decomposition should follow the directory structure or propose a new one | ### For GLOBAL SYNC @@ -259,7 +290,7 @@ After LLM integration, snapshots would also preserve embeddings, descriptions, a ### 6. MCP-native orchestration -The Titan Paradigm's agents could run entirely through codegraph's [MCP server](../examples/MCP.md) instead of shelling out to the CLI. With 21 tools already exposed, the main gap is the `audit`/`triage`/`check` commands described above. After Phase 4, adding these as MCP tools โ€” alongside [`ask_codebase`](../../roadmap/ROADMAP.md#53--mcp-integration) (Phase 5.3) for natural-language queries โ€” would let orchestrators like Claude Code's agent teams query the graph with zero CLI overhead. The RECON agent asks the MCP server "what are the riskiest files?", each Gauntlet agent asks "should this function be decomposed?", and the STATE MACHINE asks "is this change safe?" โ€” all through the same protocol. +The Titan Paradigm's agents could run entirely through codegraph's [MCP server](../examples/MCP.md) instead of shelling out to the CLI. With 24 tools already exposed, the main gap is the `audit`/`triage`/`check` commands described above. After Phase 4, adding these as MCP tools โ€” alongside [`ask_codebase`](../../roadmap/ROADMAP.md#53--mcp-integration) (Phase 5.3) for natural-language queries โ€” would let orchestrators like Claude Code's agent teams query the graph with zero CLI overhead. The RECON agent asks the MCP server "what are the riskiest files?", each Gauntlet agent asks "should this function be decomposed?", and the STATE MACHINE asks "is this change safe?" โ€” all through the same protocol. --- diff --git a/src/cli.js b/src/cli.js index b7c13d17..1c77ab83 100644 --- a/src/cli.js +++ b/src/cli.js @@ -100,8 +100,17 @@ program .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { - queryName(name, opts.db, { noTests: resolveNoTests(opts), json: opts.json }); + queryName(name, opts.db, { + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); }); program @@ -282,13 +291,23 @@ program .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action((name, opts) => { if (!name && !opts.file) { console.error('Provide a symbol name or use --file '); process.exit(1); } const target = opts.file || name; - where(target, opts.db, { file: !!opts.file, noTests: resolveNoTests(opts), json: opts.json }); + where(target, opts.db, { + file: !!opts.file, + noTests: resolveNoTests(opts), + json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, + }); }); program @@ -604,6 +623,9 @@ program .option('-T, --no-tests', 'Exclude test/spec files') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action((opts) => { if (opts.role && !VALID_ROLES.includes(opts.role)) { console.error(`Invalid role "${opts.role}". Valid roles: ${VALID_ROLES.join(', ')}`); @@ -614,6 +636,9 @@ program file: opts.file, noTests: resolveNoTests(opts), json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, }); }); @@ -692,6 +717,9 @@ program .option('-T, --no-tests', 'Exclude test/spec files from results') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('-j, --json', 'Output as JSON') + .option('--limit ', 'Max results to return') + .option('--offset ', 'Skip N results (default: 0)') + .option('--ndjson', 'Newline-delimited JSON output') .action(async (name, opts) => { if (!name && !opts.list) { console.error('Provide a function/entry point name or use --list to see all entry points.'); @@ -709,16 +737,24 @@ program kind: opts.kind, noTests: resolveNoTests(opts), json: opts.json, + limit: opts.limit ? parseInt(opts.limit, 10) : undefined, + offset: opts.offset ? parseInt(opts.offset, 10) : undefined, + ndjson: opts.ndjson, }); }); program .command('complexity [target]') - .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth)') + .description('Show per-function complexity metrics (cognitive, cyclomatic, nesting depth, MI)') .option('-d, --db ', 'Path to graph.db') .option('-n, --limit ', 'Max results', '20') - .option('--sort ', 'Sort by: cognitive | cyclomatic | nesting', 'cognitive') + .option( + '--sort ', + 'Sort by: cognitive | cyclomatic | nesting | mi | volume | effort | bugs | loc', + 'cognitive', + ) .option('--above-threshold', 'Only functions exceeding warn thresholds') + .option('--health', 'Show health metrics (Halstead, MI) columns') .option('-f, --file ', 'Scope to file (partial match)') .option('-k, --kind ', 'Filter by symbol kind') .option('-T, --no-tests', 'Exclude test/spec files from results') @@ -735,6 +771,7 @@ program limit: parseInt(opts.limit, 10), sort: opts.sort, aboveThreshold: opts.aboveThreshold, + health: opts.health, file: opts.file, kind: opts.kind, noTests: resolveNoTests(opts), diff --git a/src/complexity.js b/src/complexity.js index d3686361..d205561a 100644 --- a/src/complexity.js +++ b/src/complexity.js @@ -55,6 +55,259 @@ export const COMPLEXITY_RULES = new Map([ ['tsx', JS_TS_RULES], ]); +// โ”€โ”€โ”€ Halstead Operator/Operand Classification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const JS_TS_HALSTEAD = { + operatorLeafTypes: new Set([ + // Arithmetic + '+', + '-', + '*', + '/', + '%', + '**', + // Assignment + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '**=', + '<<=', + '>>=', + '>>>=', + '&=', + '|=', + '^=', + '&&=', + '||=', + '??=', + // Comparison + '==', + '===', + '!=', + '!==', + '<', + '>', + '<=', + '>=', + // Logical + '&&', + '||', + '!', + '??', + // Bitwise + '&', + '|', + '^', + '~', + '<<', + '>>', + '>>>', + // Unary + '++', + '--', + // Keywords as operators + 'typeof', + 'instanceof', + 'new', + 'return', + 'throw', + 'yield', + 'await', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'try', + 'catch', + 'finally', + // Arrow, spread, ternary, access + '=>', + '...', + '?', + ':', + '.', + '?.', + // Delimiters counted as operators + ',', + ';', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'property_identifier', + 'shorthand_property_identifier', + 'shorthand_property_identifier_pattern', + 'number', + 'string_fragment', + 'regex_pattern', + 'true', + 'false', + 'null', + 'undefined', + 'this', + 'super', + 'private_property_identifier', + ]), + compoundOperators: new Set([ + 'call_expression', + 'subscript_expression', + 'new_expression', + 'template_substitution', + ]), + skipTypes: new Set(['type_annotation', 'type_parameters', 'return_type', 'implements_clause']), +}; + +export const HALSTEAD_RULES = new Map([ + ['javascript', JS_TS_HALSTEAD], + ['typescript', JS_TS_HALSTEAD], + ['tsx', JS_TS_HALSTEAD], +]); + +// โ”€โ”€โ”€ Halstead Metrics Computation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Compute Halstead metrics for a function's AST subtree. + * + * @param {object} functionNode - tree-sitter node for the function + * @param {string} language - Language ID + * @returns {{ n1: number, n2: number, bigN1: number, bigN2: number, vocabulary: number, length: number, volume: number, difficulty: number, effort: number, bugs: number } | null} + */ +export function computeHalsteadMetrics(functionNode, language) { + const rules = HALSTEAD_RULES.get(language); + if (!rules) return null; + + const operators = new Map(); // type -> count + const operands = new Map(); // text -> count + + function walk(node) { + if (!node) return; + + // Skip type annotation subtrees + if (rules.skipTypes.has(node.type)) return; + + // Compound operators (non-leaf): count the node type as an operator + if (rules.compoundOperators.has(node.type)) { + operators.set(node.type, (operators.get(node.type) || 0) + 1); + } + + // Leaf nodes: classify as operator or operand + if (node.childCount === 0) { + if (rules.operatorLeafTypes.has(node.type)) { + operators.set(node.type, (operators.get(node.type) || 0) + 1); + } else if (rules.operandLeafTypes.has(node.type)) { + const text = node.text; + operands.set(text, (operands.get(text) || 0) + 1); + } + } + + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)); + } + } + + walk(functionNode); + + const n1 = operators.size; // distinct operators + const n2 = operands.size; // distinct operands + let bigN1 = 0; // total operators + for (const c of operators.values()) bigN1 += c; + let bigN2 = 0; // total operands + for (const c of operands.values()) bigN2 += c; + + const vocabulary = n1 + n2; + const length = bigN1 + bigN2; + + // Guard against zero + const volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0; + const difficulty = n2 > 0 ? (n1 / 2) * (bigN2 / n2) : 0; + const effort = difficulty * volume; + const bugs = volume / 3000; + + return { + n1, + n2, + bigN1, + bigN2, + vocabulary, + length, + volume: +volume.toFixed(2), + difficulty: +difficulty.toFixed(2), + effort: +effort.toFixed(2), + bugs: +bugs.toFixed(4), + }; +} + +// โ”€โ”€โ”€ LOC Metrics Computation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Compute LOC metrics from a function node's source text. + * + * @param {object} functionNode - tree-sitter node + * @returns {{ loc: number, sloc: number, commentLines: number }} + */ +export function computeLOCMetrics(functionNode) { + const text = functionNode.text; + const lines = text.split('\n'); + const loc = lines.length; + + let commentLines = 0; + let blankLines = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '') { + blankLines++; + } else if ( + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.startsWith('*/') + ) { + commentLines++; + } + } + + const sloc = Math.max(1, loc - blankLines - commentLines); + return { loc, sloc, commentLines }; +} + +// โ”€โ”€โ”€ Maintainability Index โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Compute normalized Maintainability Index (0-100 scale). + * + * Original SEI formula: MI = 171 - 5.2*ln(V) - 0.23*G - 16.2*ln(LOC) + 50*sin(sqrt(2.4*CM)) + * Microsoft normalization: max(0, min(100, MI * 100/171)) + * + * @param {number} volume - Halstead volume + * @param {number} cyclomatic - Cyclomatic complexity + * @param {number} sloc - Source lines of code + * @param {number} [commentRatio] - Comment ratio (0-1), optional + * @returns {number} Normalized MI (0-100) + */ +export function computeMaintainabilityIndex(volume, cyclomatic, sloc, commentRatio) { + // Guard against zero/negative values in logarithms + const safeVolume = Math.max(volume, 1); + const safeSLOC = Math.max(sloc, 1); + + let mi = 171 - 5.2 * Math.log(safeVolume) - 0.23 * cyclomatic - 16.2 * Math.log(safeSLOC); + + if (commentRatio != null && commentRatio > 0) { + mi += 50 * Math.sin(Math.sqrt(2.4 * commentRatio)); + } + + // Microsoft normalization: 0-100 scale + const normalized = Math.max(0, Math.min(100, (mi * 100) / 171)); + return +normalized.toFixed(1); +} + // โ”€โ”€โ”€ Algorithm: Single-Traversal DFS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** @@ -264,7 +517,14 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp const { getParser } = await import('./parser.js'); const upsert = db.prepare( - 'INSERT OR REPLACE INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', + `INSERT OR REPLACE INTO function_complexity + (node_id, cognitive, cyclomatic, max_nesting, + loc, sloc, comment_lines, + halstead_n1, halstead_n2, halstead_big_n1, halstead_big_n2, + halstead_vocabulary, halstead_length, halstead_volume, + halstead_difficulty, halstead_effort, halstead_bugs, + maintainability_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const getNodeId = db.prepare( "SELECT id FROM nodes WHERE name = ? AND kind IN ('function','method') AND file = ? AND line = ?", @@ -336,10 +596,36 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp const result = computeFunctionComplexity(funcNode, langId); if (!result) continue; + const halstead = computeHalsteadMetrics(funcNode, langId); + const loc = computeLOCMetrics(funcNode); + + const volume = halstead ? halstead.volume : 0; + const commentRatio = loc.loc > 0 ? loc.commentLines / loc.loc : 0; + const mi = computeMaintainabilityIndex(volume, result.cyclomatic, loc.sloc, commentRatio); + const row = getNodeId.get(def.name, relPath, def.line); if (!row) continue; - upsert.run(row.id, result.cognitive, result.cyclomatic, result.maxNesting); + upsert.run( + row.id, + result.cognitive, + result.cyclomatic, + result.maxNesting, + loc.loc, + loc.sloc, + loc.commentLines, + halstead ? halstead.n1 : 0, + halstead ? halstead.n2 : 0, + halstead ? halstead.bigN1 : 0, + halstead ? halstead.bigN2 : 0, + halstead ? halstead.vocabulary : 0, + halstead ? halstead.length : 0, + volume, + halstead ? halstead.difficulty : 0, + halstead ? halstead.effort : 0, + halstead ? halstead.bugs : 0, + mi, + ); analyzed++; } @@ -413,18 +699,25 @@ export function complexityData(customDbPath, opts = {}) { params.push(kindFilter); } + const isValidThreshold = (v) => typeof v === 'number' && Number.isFinite(v); + let having = ''; if (aboveThreshold) { const conditions = []; - if (thresholds.cognitive?.warn != null) { + if (isValidThreshold(thresholds.cognitive?.warn)) { conditions.push(`fc.cognitive >= ${thresholds.cognitive.warn}`); } - if (thresholds.cyclomatic?.warn != null) { + if (isValidThreshold(thresholds.cyclomatic?.warn)) { conditions.push(`fc.cyclomatic >= ${thresholds.cyclomatic.warn}`); } - if (thresholds.maxNesting?.warn != null) { + if (isValidThreshold(thresholds.maxNesting?.warn)) { conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`); } + if (isValidThreshold(thresholds.maintainabilityIndex?.warn)) { + conditions.push( + `fc.maintainability_index > 0 AND fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`, + ); + } if (conditions.length > 0) { having = `AND (${conditions.join(' OR ')})`; } @@ -434,6 +727,11 @@ export function complexityData(customDbPath, opts = {}) { cognitive: 'fc.cognitive DESC', cyclomatic: 'fc.cyclomatic DESC', nesting: 'fc.max_nesting DESC', + mi: 'fc.maintainability_index ASC', + volume: 'fc.halstead_volume DESC', + effort: 'fc.halstead_effort DESC', + bugs: 'fc.halstead_bugs DESC', + loc: 'fc.loc DESC', }; const orderBy = orderMap[sort] || 'fc.cognitive DESC'; @@ -442,7 +740,9 @@ export function complexityData(customDbPath, opts = {}) { rows = db .prepare( `SELECT n.name, n.kind, n.file, n.line, n.end_line, - fc.cognitive, fc.cyclomatic, fc.max_nesting + fc.cognitive, fc.cyclomatic, fc.max_nesting, + fc.loc, fc.sloc, fc.maintainability_index, + fc.halstead_volume, fc.halstead_difficulty, fc.halstead_effort, fc.halstead_bugs FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id ${where} ${having} @@ -460,12 +760,21 @@ export function complexityData(customDbPath, opts = {}) { const functions = filtered.map((r) => { const exceeds = []; - if (thresholds.cognitive?.warn != null && r.cognitive >= thresholds.cognitive.warn) + if (isValidThreshold(thresholds.cognitive?.warn) && r.cognitive >= thresholds.cognitive.warn) exceeds.push('cognitive'); - if (thresholds.cyclomatic?.warn != null && r.cyclomatic >= thresholds.cyclomatic.warn) + if (isValidThreshold(thresholds.cyclomatic?.warn) && r.cyclomatic >= thresholds.cyclomatic.warn) exceeds.push('cyclomatic'); - if (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn) + if ( + isValidThreshold(thresholds.maxNesting?.warn) && + r.max_nesting >= thresholds.maxNesting.warn + ) exceeds.push('maxNesting'); + if ( + isValidThreshold(thresholds.maintainabilityIndex?.warn) && + r.maintainability_index > 0 && + r.maintainability_index <= thresholds.maintainabilityIndex.warn + ) + exceeds.push('maintainabilityIndex'); return { name: r.name, @@ -476,6 +785,15 @@ export function complexityData(customDbPath, opts = {}) { cognitive: r.cognitive, cyclomatic: r.cyclomatic, maxNesting: r.max_nesting, + loc: r.loc || 0, + sloc: r.sloc || 0, + maintainabilityIndex: r.maintainability_index || 0, + halstead: { + volume: r.halstead_volume || 0, + difficulty: r.halstead_difficulty || 0, + effort: r.halstead_effort || 0, + bugs: r.halstead_bugs || 0, + }, exceeds: exceeds.length > 0 ? exceeds : undefined, }; }); @@ -485,7 +803,7 @@ export function complexityData(customDbPath, opts = {}) { try { const allRows = db .prepare( - `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id WHERE n.kind IN ('function','method') ${noTests ? `AND n.file NOT LIKE '%.test.%' AND n.file NOT LIKE '%.spec.%' AND n.file NOT LIKE '%__test__%' AND n.file NOT LIKE '%__tests__%' AND n.file NOT LIKE '%.stories.%'` : ''}`, @@ -493,17 +811,26 @@ export function complexityData(customDbPath, opts = {}) { .all(); if (allRows.length > 0) { + const miValues = allRows.map((r) => r.maintainability_index || 0); summary = { analyzed: allRows.length, avgCognitive: +(allRows.reduce((s, r) => s + r.cognitive, 0) / allRows.length).toFixed(1), avgCyclomatic: +(allRows.reduce((s, r) => s + r.cyclomatic, 0) / allRows.length).toFixed(1), maxCognitive: Math.max(...allRows.map((r) => r.cognitive)), maxCyclomatic: Math.max(...allRows.map((r) => r.cyclomatic)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), aboveWarn: allRows.filter( (r) => - (thresholds.cognitive?.warn != null && r.cognitive >= thresholds.cognitive.warn) || - (thresholds.cyclomatic?.warn != null && r.cyclomatic >= thresholds.cyclomatic.warn) || - (thresholds.maxNesting?.warn != null && r.max_nesting >= thresholds.maxNesting.warn), + (isValidThreshold(thresholds.cognitive?.warn) && + r.cognitive >= thresholds.cognitive.warn) || + (isValidThreshold(thresholds.cyclomatic?.warn) && + r.cyclomatic >= thresholds.cyclomatic.warn) || + (isValidThreshold(thresholds.maxNesting?.warn) && + r.max_nesting >= thresholds.maxNesting.warn) || + (isValidThreshold(thresholds.maintainabilityIndex?.warn) && + r.maintainability_index > 0 && + r.maintainability_index <= thresholds.maintainabilityIndex.warn), ).length, }; } @@ -540,27 +867,48 @@ export function complexity(customDbPath, opts = {}) { const header = opts.aboveThreshold ? 'Functions Above Threshold' : 'Function Complexity'; console.log(`\n# ${header}\n`); - // Table header - console.log( - ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)}`, - ); - console.log( - ` ${'โ”€'.repeat(40)} ${'โ”€'.repeat(30)} ${'โ”€'.repeat(4)} ${'โ”€'.repeat(4)} ${'โ”€'.repeat(5)}`, - ); + if (opts.health) { + // Health-focused view with Halstead + MI columns + console.log( + ` ${'Function'.padEnd(35)} ${'File'.padEnd(25)} ${'MI'.padStart(5)} ${'Vol'.padStart(7)} ${'Diff'.padStart(6)} ${'Effort'.padStart(9)} ${'Bugs'.padStart(6)} ${'LOC'.padStart(5)} ${'SLOC'.padStart(5)}`, + ); + console.log( + ` ${'โ”€'.repeat(35)} ${'โ”€'.repeat(25)} ${'โ”€'.repeat(5)} ${'โ”€'.repeat(7)} ${'โ”€'.repeat(6)} ${'โ”€'.repeat(9)} ${'โ”€'.repeat(6)} ${'โ”€'.repeat(5)} ${'โ”€'.repeat(5)}`, + ); - for (const fn of data.functions) { - const name = fn.name.length > 38 ? `${fn.name.slice(0, 37)}โ€ฆ` : fn.name; - const file = fn.file.length > 28 ? `โ€ฆ${fn.file.slice(-27)}` : fn.file; - const warn = fn.exceeds ? ' !' : ''; + for (const fn of data.functions) { + const name = fn.name.length > 33 ? `${fn.name.slice(0, 32)}โ€ฆ` : fn.name; + const file = fn.file.length > 23 ? `โ€ฆ${fn.file.slice(-22)}` : fn.file; + const miWarn = fn.exceeds?.includes('maintainabilityIndex') ? '!' : ' '; + console.log( + ` ${name.padEnd(35)} ${file.padEnd(25)} ${String(fn.maintainabilityIndex).padStart(5)}${miWarn}${String(fn.halstead.volume).padStart(7)} ${String(fn.halstead.difficulty).padStart(6)} ${String(fn.halstead.effort).padStart(9)} ${String(fn.halstead.bugs).padStart(6)} ${String(fn.loc).padStart(5)} ${String(fn.sloc).padStart(5)}`, + ); + } + } else { + // Default view with MI column appended + console.log( + ` ${'Function'.padEnd(40)} ${'File'.padEnd(30)} ${'Cog'.padStart(4)} ${'Cyc'.padStart(4)} ${'Nest'.padStart(5)} ${'MI'.padStart(5)}`, + ); console.log( - ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)}${warn}`, + ` ${'โ”€'.repeat(40)} ${'โ”€'.repeat(30)} ${'โ”€'.repeat(4)} ${'โ”€'.repeat(4)} ${'โ”€'.repeat(5)} ${'โ”€'.repeat(5)}`, ); + + for (const fn of data.functions) { + const name = fn.name.length > 38 ? `${fn.name.slice(0, 37)}โ€ฆ` : fn.name; + const file = fn.file.length > 28 ? `โ€ฆ${fn.file.slice(-27)}` : fn.file; + const warn = fn.exceeds ? ' !' : ''; + const mi = fn.maintainabilityIndex > 0 ? String(fn.maintainabilityIndex) : '-'; + console.log( + ` ${name.padEnd(40)} ${file.padEnd(30)} ${String(fn.cognitive).padStart(4)} ${String(fn.cyclomatic).padStart(4)} ${String(fn.maxNesting).padStart(5)} ${mi.padStart(5)}${warn}`, + ); + } } if (data.summary) { const s = data.summary; + const miPart = s.avgMI != null ? ` | avg MI: ${s.avgMI}` : ''; console.log( - `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic} | ${s.aboveWarn} above threshold`, + `\n ${s.analyzed} functions analyzed | avg cognitive: ${s.avgCognitive} | avg cyclomatic: ${s.avgCyclomatic}${miPart} | ${s.aboveWarn} above threshold`, ); } console.log(); diff --git a/src/config.js b/src/config.js index c51b6a26..b0bceaac 100644 --- a/src/config.js +++ b/src/config.js @@ -29,6 +29,7 @@ export const DEFAULTS = { cognitive: { warn: 15, fail: null }, cyclomatic: { warn: 10, fail: null }, maxNesting: { warn: 4, fail: null }, + maintainabilityIndex: { warn: 20, fail: null }, importCount: { warn: null, fail: null }, exportCount: { warn: null, fail: null }, lineCount: { warn: null, fail: null }, diff --git a/src/db.js b/src/db.js index 87a07354..9a35f322 100644 --- a/src/db.js +++ b/src/db.js @@ -124,6 +124,26 @@ export const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_fc_cyclomatic ON function_complexity(cyclomatic DESC); `, }, + { + version: 9, + up: ` + ALTER TABLE function_complexity ADD COLUMN loc INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN sloc INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN comment_lines INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_n1 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_n2 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_big_n1 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_big_n2 INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_vocabulary INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_length INTEGER DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_volume REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_difficulty REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_effort REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN halstead_bugs REAL DEFAULT 0; + ALTER TABLE function_complexity ADD COLUMN maintainability_index REAL DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_fc_mi ON function_complexity(maintainability_index ASC); + `, + }, ]; export function getBuildMeta(db, key) { diff --git a/src/export.js b/src/export.js index bbcbcdeb..e13ca5ef 100644 --- a/src/export.js +++ b/src/export.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { paginateResult } from './paginate.js'; import { isTestFile } from './queries.js'; const DEFAULT_MIN_CONFIDENCE = 0.5; @@ -10,6 +11,7 @@ export function exportDOT(db, opts = {}) { const fileLevel = opts.fileLevel !== false; const noTests = opts.noTests || false; const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const edgeLimit = opts.limit; const lines = [ 'digraph codegraph {', ' rankdir=LR;', @@ -30,6 +32,8 @@ export function exportDOT(db, opts = {}) { `) .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + const totalFileEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); // Try to use directory nodes from DB (built by structure analysis) const hasDirectoryNodes = @@ -95,6 +99,9 @@ export function exportDOT(db, opts = {}) { for (const { source, target } of edges) { lines.push(` "${source}" -> "${target}";`); } + if (edgeLimit && totalFileEdges > edgeLimit) { + lines.push(` // Truncated: showing ${edges.length} of ${totalFileEdges} edges`); + } } else { let edges = db .prepare(` @@ -111,6 +118,8 @@ export function exportDOT(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + const totalFnEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); for (const e of edges) { const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_'); @@ -119,6 +128,9 @@ export function exportDOT(db, opts = {}) { lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`); lines.push(` ${sId} -> ${tId};`); } + if (edgeLimit && totalFnEdges > edgeLimit) { + lines.push(` // Truncated: showing ${edges.length} of ${totalFnEdges} edges`); + } } lines.push('}'); @@ -169,6 +181,7 @@ export function exportMermaid(db, opts = {}) { const noTests = opts.noTests || false; const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; const direction = opts.direction || 'LR'; + const edgeLimit = opts.limit; const lines = [`flowchart ${direction}`]; let nodeCounter = 0; @@ -190,6 +203,8 @@ export function exportMermaid(db, opts = {}) { `) .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + const totalMermaidFileEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); // Collect all files referenced in edges const allFiles = new Set(); @@ -248,6 +263,9 @@ export function exportMermaid(db, opts = {}) { for (const { source, target, labels } of edgeMap.values()) { lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`); } + if (edgeLimit && totalMermaidFileEdges > edgeLimit) { + lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFileEdges} edges`); + } } else { let edges = db .prepare(` @@ -265,6 +283,8 @@ export function exportMermaid(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + const totalMermaidFnEdges = edges.length; + if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit); // Group nodes by file for subgraphs const fileNodes = new Map(); @@ -301,6 +321,9 @@ export function exportMermaid(db, opts = {}) { const tId = nodeId(`${e.target_file}::${e.target_name}`); lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`); } + if (edgeLimit && totalMermaidFnEdges > edgeLimit) { + lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFnEdges} edges`); + } // Role styling โ€” query roles for all referenced nodes const allKeys = [...nodeIdMap.keys()]; @@ -348,5 +371,6 @@ export function exportJSON(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); - return { nodes, edges }; + const base = { nodes, edges }; + return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset }); } diff --git a/src/flow.js b/src/flow.js index 77d3130f..93381652 100644 --- a/src/flow.js +++ b/src/flow.js @@ -6,6 +6,7 @@ */ import { openReadonlyOrFail } from './db.js'; +import { paginateResult } from './paginate.js'; import { isTestFile, kindIcon } from './queries.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; @@ -69,7 +70,8 @@ export function listEntryPointsData(dbPath, opts = {}) { } db.close(); - return { entries, byType, count: entries.length }; + const base = { entries, byType, count: entries.length }; + return paginateResult(base, 'entries', { limit: opts.limit, offset: opts.offset }); } /** @@ -285,7 +287,16 @@ function findBestMatch(db, name, opts = {}) { */ export function flow(name, dbPath, opts = {}) { if (opts.list) { - const data = listEntryPointsData(dbPath, { noTests: opts.noTests }); + const data = listEntryPointsData(dbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const e of data.entries) console.log(JSON.stringify(e)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; diff --git a/src/index.js b/src/index.js index 11a15a61..2b539e12 100644 --- a/src/index.js +++ b/src/index.js @@ -26,6 +26,10 @@ export { complexity, complexityData, computeFunctionComplexity, + computeHalsteadMetrics, + computeLOCMetrics, + computeMaintainabilityIndex, + HALSTEAD_RULES, } from './complexity.js'; // Configuration export { loadConfig } from './config.js'; @@ -66,6 +70,8 @@ export { setVerbose } from './logger.js'; export { manifesto, manifestoData, RULE_DEFS } from './manifesto.js'; // Native engine export { isNativeAvailable } from './native.js'; +// Pagination utilities +export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult } from './paginate.js'; // Unified parser API export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js'; diff --git a/src/mcp.js b/src/mcp.js index c9b5e542..abd41893 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -8,6 +8,7 @@ import { createRequire } from 'node:module'; import { findCycles } from './cycles.js'; import { findDbPath } from './db.js'; +import { MCP_DEFAULTS, MCP_MAX_LIMIT } from './paginate.js'; import { ALL_SYMBOL_KINDS, diffImpactMermaid, VALID_ROLES } from './queries.js'; const REPO_PROP = { @@ -17,6 +18,11 @@ const REPO_PROP = { }, }; +const PAGINATION_PROPS = { + limit: { type: 'number', description: 'Max results to return (pagination)' }, + offset: { type: 'number', description: 'Skip this many results (pagination, default: 0)' }, +}; + const BASE_TOOLS = [ { name: 'query_function', @@ -31,6 +37,7 @@ const BASE_TOOLS = [ default: 2, }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['name'], }, @@ -214,6 +221,7 @@ const BASE_TOOLS = [ default: false, }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, required: ['target'], }, @@ -266,6 +274,7 @@ const BASE_TOOLS = [ description: 'File-level graph (true) or function-level (false)', default: true, }, + ...PAGINATION_PROPS, }, required: ['format'], }, @@ -280,6 +289,7 @@ const BASE_TOOLS = [ file: { type: 'string', description: 'Filter by file path (partial match)' }, pattern: { type: 'string', description: 'Filter by function name (partial match)' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, @@ -319,6 +329,7 @@ const BASE_TOOLS = [ }, file: { type: 'string', description: 'Scope to a specific file (partial match)' }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, @@ -400,13 +411,14 @@ const BASE_TOOLS = [ type: 'object', properties: { no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, + ...PAGINATION_PROPS, }, }, }, { name: 'complexity', description: - 'Show per-function complexity metrics (cognitive, cyclomatic, max nesting depth). Sorted by most complex first.', + 'Show per-function complexity metrics (cognitive, cyclomatic, nesting, Halstead, Maintainability Index). Sorted by most complex first.', inputSchema: { type: 'object', properties: { @@ -415,7 +427,7 @@ const BASE_TOOLS = [ limit: { type: 'number', description: 'Max results', default: 20 }, sort: { type: 'string', - enum: ['cognitive', 'cyclomatic', 'nesting'], + enum: ['cognitive', 'cyclomatic', 'nesting', 'mi', 'volume', 'effort', 'bugs', 'loc'], description: 'Sort metric', default: 'cognitive', }, @@ -424,6 +436,11 @@ const BASE_TOOLS = [ description: 'Only functions exceeding warn thresholds', default: false, }, + health: { + type: 'boolean', + description: 'Include Halstead and Maintainability Index metrics', + default: false, + }, no_tests: { type: 'boolean', description: 'Exclude test files', default: false }, kind: { type: 'string', @@ -599,7 +616,11 @@ export async function startMCPServer(customDbPath, options = {}) { let result; switch (name) { case 'query_function': - result = queryNameData(args.name, dbPath, { noTests: args.no_tests }); + result = queryNameData(args.name, dbPath, { + noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.query_function, MCP_MAX_LIMIT), + offset: args.offset ?? 0, + }); break; case 'file_deps': result = fileDepsData(args.file, dbPath, { noTests: args.no_tests }); @@ -661,6 +682,8 @@ export async function startMCPServer(customDbPath, options = {}) { result = whereData(args.target, dbPath, { file: args.file_mode, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.where, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'diff_impact': @@ -700,15 +723,21 @@ export async function startMCPServer(customDbPath, options = {}) { const { exportDOT, exportMermaid, exportJSON } = await import('./export.js'); const db = new Database(findDbPath(dbPath), { readonly: true }); const fileLevel = args.file_level !== false; + const exportLimit = args.limit + ? Math.min(args.limit, MCP_MAX_LIMIT) + : MCP_DEFAULTS.export_graph; switch (args.format) { case 'dot': - result = exportDOT(db, { fileLevel }); + result = exportDOT(db, { fileLevel, limit: exportLimit }); break; case 'mermaid': - result = exportMermaid(db, { fileLevel }); + result = exportMermaid(db, { fileLevel, limit: exportLimit }); break; case 'json': - result = exportJSON(db); + result = exportJSON(db, { + limit: exportLimit, + offset: args.offset ?? 0, + }); break; default: db.close(); @@ -730,6 +759,8 @@ export async function startMCPServer(customDbPath, options = {}) { file: args.file, pattern: args.pattern, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.list_functions, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'node_roles': @@ -737,6 +768,8 @@ export async function startMCPServer(customDbPath, options = {}) { role: args.role, file: args.file, noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.node_roles, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; case 'structure': { @@ -788,6 +821,8 @@ export async function startMCPServer(customDbPath, options = {}) { const { listEntryPointsData } = await import('./flow.js'); result = listEntryPointsData(dbPath, { noTests: args.no_tests, + limit: Math.min(args.limit ?? MCP_DEFAULTS.list_entry_points, MCP_MAX_LIMIT), + offset: args.offset ?? 0, }); break; } @@ -799,6 +834,7 @@ export async function startMCPServer(customDbPath, options = {}) { limit: args.limit, sort: args.sort, aboveThreshold: args.above_threshold, + health: args.health, noTests: args.no_tests, kind: args.kind, }); diff --git a/src/paginate.js b/src/paginate.js new file mode 100644 index 00000000..522a60bb --- /dev/null +++ b/src/paginate.js @@ -0,0 +1,70 @@ +/** + * Pagination utilities for bounded, context-friendly query results. + * + * Offset/limit pagination โ€” the DB is a read-only snapshot so data doesn't + * change between pages; offset/limit is simpler and maps directly to SQL. + */ + +/** Default limits applied by MCP tool handlers (not by the programmatic API). */ +export const MCP_DEFAULTS = { + list_functions: 100, + query_function: 50, + where: 50, + node_roles: 100, + list_entry_points: 100, + export_graph: 500, +}; + +/** Hard cap to prevent abuse via MCP. */ +export const MCP_MAX_LIMIT = 1000; + +/** + * Paginate an array. + * + * When `limit` is undefined the input is returned unchanged (no-op). + * + * @param {any[]} items + * @param {{ limit?: number, offset?: number }} opts + * @returns {{ items: any[], pagination?: { total: number, offset: number, limit: number, hasMore: boolean, returned: number } }} + */ +export function paginate(items, { limit, offset } = {}) { + if (limit === undefined && limit !== 0) { + return { items }; + } + const total = items.length; + const off = Math.max(0, Math.min(offset || 0, total)); + const lim = Math.max(0, limit); + const page = items.slice(off, off + lim); + return { + items: page, + pagination: { + total, + offset: off, + limit: lim, + hasMore: off + lim < total, + returned: page.length, + }, + }; +} + +/** + * Apply pagination to a named array field on a result object. + * + * When `limit` is undefined the result is returned unchanged (backward compat). + * When active, `_pagination` metadata is added to the result. + * + * @param {object} result - The result object (e.g. `{ count: 42, functions: [...] }`) + * @param {string} field - The array field name to paginate (e.g. `'functions'`) + * @param {{ limit?: number, offset?: number }} opts + * @returns {object} - Result with paginated field + `_pagination` (if active) + */ +export function paginateResult(result, field, { limit, offset } = {}) { + if (limit === undefined && limit !== 0) { + return result; + } + const arr = result[field]; + if (!Array.isArray(arr)) return result; + + const { items, pagination } = paginate(arr, { limit, offset }); + return { ...result, [field]: items, _pagination: pagination }; +} diff --git a/src/queries.js b/src/queries.js index e7549516..91b4989d 100644 --- a/src/queries.js +++ b/src/queries.js @@ -5,6 +5,7 @@ import { coChangeForFiles } from './cochange.js'; import { findCycles } from './cycles.js'; import { findDbPath, openReadonlyOrFail } from './db.js'; import { debug } from './logger.js'; +import { paginateResult } from './paginate.js'; import { LANGUAGE_REGISTRY } from './parser.js'; /** @@ -248,7 +249,8 @@ export function queryNameData(name, customDbPath, opts = {}) { }); db.close(); - return { query: name, results }; + const base = { query: name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function impactAnalysisData(file, customDbPath, opts = {}) { @@ -1153,7 +1155,8 @@ export function listFunctionsData(customDbPath, opts = {}) { if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); db.close(); - return { count: rows.length, functions: rows }; + const base = { count: rows.length, functions: rows }; + return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); } export function statsData(customDbPath, opts = {}) { @@ -1368,18 +1371,21 @@ export function statsData(customDbPath, opts = {}) { try { const cRows = db .prepare( - `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id WHERE n.kind IN ('function','method') ${testFilter}`, ) .all(); if (cRows.length > 0) { + const miValues = cRows.map((r) => r.maintainability_index || 0); complexity = { analyzed: cRows.length, avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1), avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1), maxCognitive: Math.max(...cRows.map((r) => r.cognitive)), maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), }; } } catch { @@ -1521,8 +1527,9 @@ export async function stats(customDbPath, opts = {}) { // Complexity if (data.complexity) { const cx = data.complexity; + const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; console.log( - `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}`, + `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, ); } @@ -1540,7 +1547,16 @@ export async function stats(customDbPath, opts = {}) { // โ”€โ”€โ”€ Human-readable output (original formatting) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function queryName(name, customDbPath, opts = {}) { - const data = queryNameData(name, customDbPath, { noTests: opts.noTests }); + const data = queryNameData(name, customDbPath, { + noTests: opts.noTests, + limit: opts.limit, + offset: opts.offset, + }); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const r of data.results) console.log(JSON.stringify(r)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2001,7 +2017,7 @@ export function contextData(name, customDbPath, opts = {}) { try { const cRow = db .prepare( - 'SELECT cognitive, cyclomatic, max_nesting FROM function_complexity WHERE node_id = ?', + 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?', ) .get(node.id); if (cRow) { @@ -2009,6 +2025,8 @@ export function contextData(name, customDbPath, opts = {}) { cognitive: cRow.cognitive, cyclomatic: cRow.cyclomatic, maxNesting: cRow.max_nesting, + maintainabilityIndex: cRow.maintainability_index || 0, + halsteadVolume: cRow.halstead_volume || 0, }; } } catch { @@ -2062,9 +2080,10 @@ export function context(name, customDbPath, opts = {}) { // Complexity if (r.complexity) { const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; console.log('## Complexity'); console.log( - ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}`, + ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, ); console.log(); } @@ -2292,7 +2311,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { try { const cRow = db .prepare( - 'SELECT cognitive, cyclomatic, max_nesting FROM function_complexity WHERE node_id = ?', + 'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?', ) .get(node.id); if (cRow) { @@ -2300,6 +2319,8 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { cognitive: cRow.cognitive, cyclomatic: cRow.cyclomatic, maxNesting: cRow.max_nesting, + maintainabilityIndex: cRow.maintainability_index || 0, + halsteadVolume: cRow.halstead_volume || 0, }; } } catch { @@ -2468,8 +2489,9 @@ export function explain(target, customDbPath, opts = {}) { if (r.complexity) { const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; console.log( - `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}`, + `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, ); } @@ -2616,11 +2638,17 @@ export function whereData(target, customDbPath, opts = {}) { const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests); db.close(); - return { target, mode: fileMode ? 'file' : 'symbol', results }; + const base = { target, mode: fileMode ? 'file' : 'symbol', results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); } export function where(target, customDbPath, opts = {}) { const data = whereData(target, customDbPath, opts); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const r of data.results) console.log(JSON.stringify(r)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; @@ -2702,11 +2730,17 @@ export function rolesData(customDbPath, opts = {}) { } db.close(); - return { count: rows.length, summary, symbols: rows }; + const base = { count: rows.length, summary, symbols: rows }; + return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); } export function roles(customDbPath, opts = {}) { const data = rolesData(customDbPath, opts); + if (opts.ndjson) { + if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination })); + for (const s of data.symbols) console.log(JSON.stringify(s)); + return; + } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; diff --git a/tests/integration/complexity.test.js b/tests/integration/complexity.test.js index d4db314e..dff1c3a1 100644 --- a/tests/integration/complexity.test.js +++ b/tests/integration/complexity.test.js @@ -21,10 +21,52 @@ function insertNode(db, name, kind, file, line, endLine = null) { .run(name, kind, file, line, endLine).lastInsertRowid; } -function insertComplexity(db, nodeId, cognitive, cyclomatic, maxNesting) { +function insertComplexity( + db, + nodeId, + cognitive, + cyclomatic, + maxNesting, + { + loc = 10, + sloc = 8, + commentLines = 1, + volume = 100, + difficulty = 5, + effort = 500, + bugs = 0.03, + mi = 60, + } = {}, +) { db.prepare( - 'INSERT INTO function_complexity (node_id, cognitive, cyclomatic, max_nesting) VALUES (?, ?, ?, ?)', - ).run(nodeId, cognitive, cyclomatic, maxNesting); + `INSERT INTO function_complexity + (node_id, cognitive, cyclomatic, max_nesting, + loc, sloc, comment_lines, + halstead_n1, halstead_n2, halstead_big_n1, halstead_big_n2, + halstead_vocabulary, halstead_length, halstead_volume, + halstead_difficulty, halstead_effort, halstead_bugs, + maintainability_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + nodeId, + cognitive, + cyclomatic, + maxNesting, + loc, + sloc, + commentLines, + 10, + 15, + 30, + 40, + 25, + 70, + volume, + difficulty, + effort, + bugs, + mi, + ); } // โ”€โ”€โ”€ Fixture DB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -47,12 +89,52 @@ beforeAll(() => { const fn4 = insertNode(db, 'handleRequest', 'method', 'src/handler.js', 10, 50); const fn5 = insertNode(db, 'testHelper', 'function', 'tests/helper.test.js', 1, 10); - // Insert complexity data - insertComplexity(db, fn1, 0, 1, 0); // trivial - insertComplexity(db, fn2, 18, 8, 4); // above cognitive warn - insertComplexity(db, fn3, 12, 11, 3); // above cyclomatic warn - insertComplexity(db, fn4, 25, 15, 5); // above all thresholds - insertComplexity(db, fn5, 5, 3, 2); // test file + // Insert complexity data with health metrics + insertComplexity(db, fn1, 0, 1, 0, { + loc: 3, + sloc: 2, + volume: 20, + difficulty: 1, + effort: 20, + bugs: 0.007, + mi: 90, + }); + insertComplexity(db, fn2, 18, 8, 4, { + loc: 35, + sloc: 28, + volume: 800, + difficulty: 15, + effort: 12000, + bugs: 0.27, + mi: 35, + }); + insertComplexity(db, fn3, 12, 11, 3, { + loc: 20, + sloc: 16, + volume: 500, + difficulty: 10, + effort: 5000, + bugs: 0.17, + mi: 45, + }); + insertComplexity(db, fn4, 25, 15, 5, { + loc: 40, + sloc: 32, + volume: 1500, + difficulty: 25, + effort: 37500, + bugs: 0.5, + mi: 15, + }); + insertComplexity(db, fn5, 5, 3, 2, { + loc: 10, + sloc: 8, + volume: 100, + difficulty: 5, + effort: 500, + bugs: 0.03, + mi: 65, + }); db.close(); }); @@ -154,4 +236,102 @@ describe('complexityData', () => { const data = complexityData(dbPath, { target: 'nonexistent_xyz' }); expect(data.functions.length).toBe(0); }); + + test('handles non-numeric thresholds gracefully', () => { + // Patch config to inject non-numeric thresholds via opts override + // complexityData reads thresholds from config, so we test by calling + // with aboveThreshold=true and verifying correct behavior even when + // the underlying config could have bad values. + // Here we verify that the baseline with valid thresholds still + // produces correct exceeds and summary.aboveWarn values. + const data = complexityData(dbPath); + expect(data.summary.aboveWarn).toBeGreaterThan(0); + const handleReq = data.functions.find((f) => f.name === 'handleRequest'); + expect(handleReq.exceeds).toBeDefined(); + expect(handleReq.exceeds.length).toBeGreaterThan(0); + }); + + // โ”€โ”€โ”€ Halstead / MI Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + test('functions include halstead and MI data', () => { + const data = complexityData(dbPath); + const fn = data.functions.find((f) => f.name === 'processItems'); + expect(fn.maintainabilityIndex).toBe(35); + expect(fn.loc).toBe(35); + expect(fn.sloc).toBe(28); + expect(fn.halstead).toBeDefined(); + expect(fn.halstead.volume).toBe(800); + expect(fn.halstead.difficulty).toBe(15); + expect(fn.halstead.effort).toBe(12000); + expect(fn.halstead.bugs).toBe(0.27); + }); + + test('sort by mi (ascending โ€” worst first)', () => { + const data = complexityData(dbPath, { sort: 'mi' }); + expect(data.functions.length).toBeGreaterThanOrEqual(2); + // MI ascending: lowest MI first + expect(data.functions[0].maintainabilityIndex).toBeLessThanOrEqual( + data.functions[1].maintainabilityIndex, + ); + }); + + test('sort by volume (descending)', () => { + const data = complexityData(dbPath, { sort: 'volume' }); + expect(data.functions[0].halstead.volume).toBeGreaterThanOrEqual( + data.functions[1].halstead.volume, + ); + }); + + test('sort by effort (descending)', () => { + const data = complexityData(dbPath, { sort: 'effort' }); + expect(data.functions[0].halstead.effort).toBeGreaterThanOrEqual( + data.functions[1].halstead.effort, + ); + }); + + test('sort by bugs (descending)', () => { + const data = complexityData(dbPath, { sort: 'bugs' }); + expect(data.functions[0].halstead.bugs).toBeGreaterThanOrEqual(data.functions[1].halstead.bugs); + }); + + test('sort by loc (descending)', () => { + const data = complexityData(dbPath, { sort: 'loc' }); + expect(data.functions[0].loc).toBeGreaterThanOrEqual(data.functions[1].loc); + }); + + test('aboveThreshold considers MI threshold', () => { + const data = complexityData(dbPath, { aboveThreshold: true }); + const names = data.functions.map((f) => f.name); + // handleRequest has MI=15, below warn=20 โ†’ should be included + expect(names).toContain('handleRequest'); + }); + + test('exceeds includes maintainabilityIndex for low-MI functions', () => { + const data = complexityData(dbPath); + const handler = data.functions.find((f) => f.name === 'handleRequest'); + expect(handler.exceeds).toContain('maintainabilityIndex'); + + const simple = data.functions.find((f) => f.name === 'simpleAdd'); + expect(simple.exceeds).toBeUndefined(); + }); + + test('summary includes avgMI and minMI', () => { + const data = complexityData(dbPath); + expect(data.summary.avgMI).toBeDefined(); + expect(data.summary.minMI).toBeDefined(); + expect(data.summary.avgMI).toBeGreaterThan(0); + expect(data.summary.minMI).toBeLessThanOrEqual(data.summary.avgMI); + }); + + test('JSON output contains halstead object', () => { + const data = complexityData(dbPath); + for (const fn of data.functions) { + expect(fn.halstead).toBeDefined(); + expect(typeof fn.halstead.volume).toBe('number'); + expect(typeof fn.halstead.difficulty).toBe('number'); + expect(typeof fn.halstead.effort).toBe('number'); + expect(typeof fn.halstead.bugs).toBe('number'); + expect(typeof fn.maintainabilityIndex).toBe('number'); + } + }); }); diff --git a/tests/integration/pagination.test.js b/tests/integration/pagination.test.js new file mode 100644 index 00000000..4bf652f8 --- /dev/null +++ b/tests/integration/pagination.test.js @@ -0,0 +1,363 @@ +/** + * Integration tests for pagination utilities and paginated data functions. + * + * Tests cover: + * - paginate() utility: no-op, slicing, hasMore, offset clamping, returned count + * - paginateResult() utility: wraps result, preserves fields, no-op without limit + * - listFunctionsData with pagination + * - rolesData with pagination (summary still full) + * - queryNameData with pagination + * - whereData with pagination + * - listEntryPointsData with pagination + * - MCP default limits + * - Export limiting (DOT/Mermaid truncation, JSON edge pagination) + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { initSchema } from '../../src/db.js'; +import { exportDOT, exportJSON, exportMermaid } from '../../src/export.js'; +import { listEntryPointsData } from '../../src/flow.js'; +import { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult } from '../../src/paginate.js'; +import { listFunctionsData, queryNameData, rolesData, whereData } from '../../src/queries.js'; + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function insertNode(db, name, kind, file, line, role = null) { + return db + .prepare('INSERT INTO nodes (name, kind, file, line, role) VALUES (?, ?, ?, ?, ?)') + .run(name, kind, file, line, role).lastInsertRowid; +} + +function insertEdge(db, sourceId, targetId, kind, confidence = 1.0) { + db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, 0)', + ).run(sourceId, targetId, kind, confidence); +} + +// โ”€โ”€โ”€ Fixture DB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +let tmpDir, dbPath, dbForExport; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pagination-')); + fs.mkdirSync(path.join(tmpDir, '.codegraph')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + const fA = insertNode(db, 'a.js', 'file', 'a.js', 0); + const fB = insertNode(db, 'b.js', 'file', 'b.js', 0); + const fC = insertNode(db, 'c.js', 'file', 'c.js', 0); + + // Function nodes with roles + const fn1 = insertNode(db, 'alpha', 'function', 'a.js', 1, 'entry'); + const fn2 = insertNode(db, 'beta', 'function', 'a.js', 10, 'core'); + const fn3 = insertNode(db, 'gamma', 'function', 'b.js', 1, 'utility'); + const fn4 = insertNode(db, 'delta', 'function', 'b.js', 10, 'leaf'); + const fn5 = insertNode(db, 'epsilon', 'function', 'c.js', 1, 'core'); + insertNode(db, 'route:GET /health', 'function', 'c.js', 20, 'entry'); + + // Import edges + insertEdge(db, fA, fB, 'imports'); + insertEdge(db, fB, fC, 'imports'); + insertEdge(db, fA, fC, 'imports'); + + // Call edges + insertEdge(db, fn1, fn2, 'calls'); + insertEdge(db, fn2, fn3, 'calls'); + insertEdge(db, fn3, fn4, 'calls'); + insertEdge(db, fn1, fn5, 'calls'); + insertEdge(db, fn5, fn4, 'calls'); + + db.close(); + + // Keep a read-only handle for export tests + dbForExport = new Database(dbPath, { readonly: true }); +}); + +afterAll(() => { + if (dbForExport) dbForExport.close(); + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// โ”€โ”€โ”€ paginate() utility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('paginate()', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + test('no-op without limit', () => { + const result = paginate(items, {}); + expect(result.items).toEqual(items); + expect(result.pagination).toBeUndefined(); + }); + + test('no-op with undefined limit', () => { + const result = paginate(items, { limit: undefined }); + expect(result.items).toEqual(items); + expect(result.pagination).toBeUndefined(); + }); + + test('correct slicing with limit', () => { + const result = paginate(items, { limit: 3 }); + expect(result.items).toEqual([1, 2, 3]); + expect(result.pagination).toEqual({ + total: 10, + offset: 0, + limit: 3, + hasMore: true, + returned: 3, + }); + }); + + test('offset + limit', () => { + const result = paginate(items, { limit: 3, offset: 5 }); + expect(result.items).toEqual([6, 7, 8]); + expect(result.pagination.offset).toBe(5); + expect(result.pagination.hasMore).toBe(true); + }); + + test('hasMore is false at end', () => { + const result = paginate(items, { limit: 3, offset: 8 }); + expect(result.items).toEqual([9, 10]); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.returned).toBe(2); + }); + + test('offset clamping beyond length', () => { + const result = paginate(items, { limit: 5, offset: 100 }); + expect(result.items).toEqual([]); + expect(result.pagination.returned).toBe(0); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.offset).toBe(10); + }); + + test('negative offset treated as 0', () => { + const result = paginate(items, { limit: 2, offset: -5 }); + expect(result.items).toEqual([1, 2]); + expect(result.pagination.offset).toBe(0); + }); + + test('limit 0 returns empty page', () => { + const result = paginate(items, { limit: 0 }); + expect(result.items).toEqual([]); + expect(result.pagination.total).toBe(10); + expect(result.pagination.returned).toBe(0); + }); +}); + +// โ”€โ”€โ”€ paginateResult() utility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('paginateResult()', () => { + const result = { count: 5, functions: ['a', 'b', 'c', 'd', 'e'], extra: 'preserved' }; + + test('no-op without limit', () => { + const out = paginateResult(result, 'functions', {}); + expect(out).toEqual(result); + expect(out._pagination).toBeUndefined(); + }); + + test('wraps result correctly', () => { + const out = paginateResult(result, 'functions', { limit: 2 }); + expect(out.functions).toEqual(['a', 'b']); + expect(out._pagination.total).toBe(5); + expect(out._pagination.hasMore).toBe(true); + expect(out._pagination.returned).toBe(2); + }); + + test('preserves other fields', () => { + const out = paginateResult(result, 'functions', { limit: 2 }); + expect(out.count).toBe(5); + expect(out.extra).toBe('preserved'); + }); + + test('non-array field returns result unchanged', () => { + const obj = { count: 1, data: 'not-an-array' }; + const out = paginateResult(obj, 'data', { limit: 5 }); + expect(out).toEqual(obj); + }); +}); + +// โ”€โ”€โ”€ listFunctionsData with pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('listFunctionsData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = listFunctionsData(dbPath); + expect(data.functions.length).toBeGreaterThanOrEqual(5); + expect(data._pagination).toBeUndefined(); + }); + + test('returns page with _pagination', () => { + const data = listFunctionsData(dbPath, { limit: 2 }); + expect(data.functions).toHaveLength(2); + expect(data._pagination).toBeDefined(); + expect(data._pagination.total).toBeGreaterThanOrEqual(5); + expect(data._pagination.hasMore).toBe(true); + expect(data._pagination.returned).toBe(2); + }); + + test('second page via offset', () => { + const page1 = listFunctionsData(dbPath, { limit: 2, offset: 0 }); + const page2 = listFunctionsData(dbPath, { limit: 2, offset: 2 }); + const names1 = page1.functions.map((f) => f.name); + const names2 = page2.functions.map((f) => f.name); + // Pages should not overlap + for (const n of names2) { + expect(names1).not.toContain(n); + } + }); +}); + +// โ”€โ”€โ”€ rolesData with pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('rolesData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = rolesData(dbPath); + expect(data.symbols.length).toBeGreaterThanOrEqual(5); + expect(data._pagination).toBeUndefined(); + }); + + test('summary contains full aggregation even when paginated', () => { + const full = rolesData(dbPath); + const paginated = rolesData(dbPath, { limit: 2 }); + // Summary should be identical (computed before pagination) + expect(paginated.summary).toEqual(full.summary); + expect(paginated.count).toBe(full.count); + expect(paginated.symbols).toHaveLength(2); + expect(paginated._pagination.total).toBe(full.count); + }); +}); + +// โ”€โ”€โ”€ queryNameData with pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('queryNameData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = queryNameData('a', dbPath); + expect(data._pagination).toBeUndefined(); + }); + + test('paginated results', () => { + const data = queryNameData('a', dbPath, { limit: 1 }); + expect(data.results).toHaveLength(1); + expect(data._pagination).toBeDefined(); + expect(data._pagination.returned).toBe(1); + }); + + test('second page returns remaining', () => { + const full = queryNameData('a', dbPath); + if (full.results.length > 1) { + const page2 = queryNameData('a', dbPath, { limit: 1, offset: 1 }); + expect(page2.results[0].name).toBe(full.results[1].name); + } + }); +}); + +// โ”€โ”€โ”€ whereData with pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('whereData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = whereData('alpha', dbPath); + expect(data._pagination).toBeUndefined(); + }); + + test('paginated results', () => { + // 'a' should match multiple symbols + const full = whereData('a', dbPath); + if (full.results.length > 1) { + const paginated = whereData('a', dbPath, { limit: 1 }); + expect(paginated.results).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + expect(paginated._pagination.total).toBe(full.results.length); + } + }); +}); + +// โ”€โ”€โ”€ listEntryPointsData with pagination โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('listEntryPointsData with pagination', () => { + test('backward compat: no limit returns all', () => { + const data = listEntryPointsData(dbPath); + expect(data._pagination).toBeUndefined(); + expect(data.entries.length).toBeGreaterThanOrEqual(1); + }); + + test('paginated entries', () => { + const full = listEntryPointsData(dbPath); + const paginated = listEntryPointsData(dbPath, { limit: 1 }); + expect(paginated.entries).toHaveLength(Math.min(1, full.entries.length)); + if (full.entries.length > 1) { + expect(paginated._pagination.hasMore).toBe(true); + } + }); +}); + +// โ”€โ”€โ”€ MCP default limits โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('MCP defaults', () => { + test('MCP_DEFAULTS has expected keys', () => { + expect(MCP_DEFAULTS.list_functions).toBe(100); + expect(MCP_DEFAULTS.query_function).toBe(50); + expect(MCP_DEFAULTS.where).toBe(50); + expect(MCP_DEFAULTS.node_roles).toBe(100); + expect(MCP_DEFAULTS.list_entry_points).toBe(100); + expect(MCP_DEFAULTS.export_graph).toBe(500); + }); + + test('MCP_MAX_LIMIT is 1000', () => { + expect(MCP_MAX_LIMIT).toBe(1000); + }); + + test('MCP handler applies default limit to listFunctionsData', () => { + // Simulate what the MCP handler does + const limit = Math.min(MCP_DEFAULTS.list_functions, MCP_MAX_LIMIT); + const data = listFunctionsData(dbPath, { limit, offset: 0 }); + expect(data._pagination).toBeDefined(); + expect(data._pagination.limit).toBe(100); + }); +}); + +// โ”€โ”€โ”€ Export limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('export limiting', () => { + test('DOT truncation comment when limit exceeded', () => { + const dot = exportDOT(dbForExport, { fileLevel: true, limit: 1 }); + expect(dot).toContain('// Truncated: showing'); + }); + + test('DOT no truncation comment when under limit', () => { + const dot = exportDOT(dbForExport, { fileLevel: true, limit: 1000 }); + expect(dot).not.toContain('// Truncated'); + }); + + test('Mermaid truncation comment when limit exceeded', () => { + const mermaid = exportMermaid(dbForExport, { fileLevel: true, limit: 1 }); + expect(mermaid).toContain('%% Truncated: showing'); + }); + + test('Mermaid no truncation when under limit', () => { + const mermaid = exportMermaid(dbForExport, { fileLevel: true, limit: 1000 }); + expect(mermaid).not.toContain('%% Truncated'); + }); + + test('JSON edge pagination', () => { + const full = exportJSON(dbForExport); + if (full.edges.length > 1) { + const paginated = exportJSON(dbForExport, { limit: 1 }); + expect(paginated.edges).toHaveLength(1); + expect(paginated._pagination).toBeDefined(); + expect(paginated._pagination.total).toBe(full.edges.length); + expect(paginated._pagination.hasMore).toBe(true); + } + }); + + test('JSON no pagination without limit', () => { + const result = exportJSON(dbForExport); + expect(result._pagination).toBeUndefined(); + }); +}); diff --git a/tests/unit/complexity.test.js b/tests/unit/complexity.test.js index fc064df4..af56a161 100644 --- a/tests/unit/complexity.test.js +++ b/tests/unit/complexity.test.js @@ -6,7 +6,14 @@ */ import { beforeAll, describe, expect, it } from 'vitest'; -import { COMPLEXITY_RULES, computeFunctionComplexity } from '../../src/complexity.js'; +import { + COMPLEXITY_RULES, + computeFunctionComplexity, + computeHalsteadMetrics, + computeLOCMetrics, + computeMaintainabilityIndex, + HALSTEAD_RULES, +} from '../../src/complexity.js'; import { createParsers } from '../../src/parser.js'; let jsParser; @@ -252,3 +259,193 @@ describe('COMPLEXITY_RULES', () => { expect(COMPLEXITY_RULES.has('go')).toBe(false); }); }); + +// โ”€โ”€โ”€ Halstead Metrics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function analyzeHalstead(code) { + const root = parse(code); + const funcNode = getFunctionBody(root); + if (!funcNode) throw new Error('No function found in code snippet'); + return computeHalsteadMetrics(funcNode, 'javascript'); +} + +describe('computeHalsteadMetrics', () => { + it('returns null for unsupported language', () => { + const result = computeHalsteadMetrics({}, 'unknown_lang'); + expect(result).toBeNull(); + }); + + it('simple function has n1>0, n2>0, volume>0', () => { + const result = analyzeHalstead(` + function add(a, b) { + return a + b; + } + `); + expect(result).not.toBeNull(); + expect(result.n1).toBeGreaterThan(0); + expect(result.n2).toBeGreaterThan(0); + expect(result.volume).toBeGreaterThan(0); + expect(result.difficulty).toBeGreaterThan(0); + expect(result.effort).toBeGreaterThan(0); + expect(result.bugs).toBeGreaterThan(0); + }); + + it('empty function body does not crash', () => { + const result = analyzeHalstead(` + function empty() {} + `); + expect(result).not.toBeNull(); + expect(result.vocabulary).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(result.volume)).toBe(true); + expect(Number.isFinite(result.difficulty)).toBe(true); + }); + + it('complex function has greater volume than simple', () => { + const simple = analyzeHalstead(` + function add(a, b) { return a + b; } + `); + const complex = analyzeHalstead(` + function process(items, options) { + const results = []; + for (let i = 0; i < items.length; i++) { + if (items[i].type === 'A') { + results.push(items[i].value * 2 + options.offset); + } else if (items[i].type === 'B') { + results.push(items[i].value / 3 - options.offset); + } + } + return results; + } + `); + expect(complex.volume).toBeGreaterThan(simple.volume); + }); + + it('repeated operands increase difficulty', () => { + // Same identifier used many times vs distinct identifiers + const repeated = analyzeHalstead(` + function rep(x) { + return x + x + x + x + x; + } + `); + const distinct = analyzeHalstead(` + function dist(a, b, c, d, e) { + return a + b + c + d + e; + } + `); + // With more distinct operands, difficulty per operand is lower + expect(repeated.difficulty).toBeGreaterThan(distinct.difficulty); + }); +}); + +describe('HALSTEAD_RULES', () => { + it('supports javascript, typescript, tsx', () => { + expect(HALSTEAD_RULES.has('javascript')).toBe(true); + expect(HALSTEAD_RULES.has('typescript')).toBe(true); + expect(HALSTEAD_RULES.has('tsx')).toBe(true); + }); + + it('does not support python or go', () => { + expect(HALSTEAD_RULES.has('python')).toBe(false); + expect(HALSTEAD_RULES.has('go')).toBe(false); + }); +}); + +// โ”€โ”€โ”€ LOC Metrics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('computeLOCMetrics', () => { + it('counts lines correctly', () => { + const root = parse(` + function multi(a, b) { + // comment + const x = a + b; + + return x; + } + `); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.loc).toBeGreaterThan(1); + expect(result.sloc).toBeGreaterThan(0); + expect(result.commentLines).toBeGreaterThanOrEqual(1); + }); + + it('detects comment lines', () => { + const root = parse(` + function commented() { + // line comment + /* block comment */ + * star comment + return 1; + } + `); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.commentLines).toBeGreaterThanOrEqual(3); + }); + + it('SLOC excludes blanks and comments', () => { + const root = parse(` + function blank() { + + // comment + + return 1; + } + `); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.sloc).toBeLessThan(result.loc); + }); + + it('single-line function', () => { + const root = parse('function one() { return 1; }'); + const funcNode = getFunctionBody(root); + const result = computeLOCMetrics(funcNode); + expect(result.loc).toBe(1); + expect(result.sloc).toBe(1); + expect(result.commentLines).toBe(0); + }); +}); + +// โ”€โ”€โ”€ Maintainability Index โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('computeMaintainabilityIndex', () => { + it('trivial function has high MI (>70)', () => { + // Low volume, low cyclomatic, low SLOC โ†’ high MI + const mi = computeMaintainabilityIndex(10, 1, 3); + expect(mi).toBeGreaterThan(70); + }); + + it('complex function has low MI (<30)', () => { + // High volume, high cyclomatic, high SLOC โ†’ low MI + const mi = computeMaintainabilityIndex(5000, 30, 200); + expect(mi).toBeLessThan(30); + }); + + it('comments improve MI', () => { + const without = computeMaintainabilityIndex(500, 10, 50); + const with_ = computeMaintainabilityIndex(500, 10, 50, 0.3); + expect(with_).toBeGreaterThan(without); + }); + + it('normalized to 0-100 range', () => { + // Very high values should clamp to 0 + const low = computeMaintainabilityIndex(100000, 100, 5000); + expect(low).toBeGreaterThanOrEqual(0); + expect(low).toBeLessThanOrEqual(100); + + // Very low values should clamp near 100 + const high = computeMaintainabilityIndex(1, 1, 1); + expect(high).toBeGreaterThanOrEqual(0); + expect(high).toBeLessThanOrEqual(100); + }); + + it('handles zero guards (no NaN/Infinity)', () => { + const result = computeMaintainabilityIndex(0, 0, 0); + expect(Number.isFinite(result)).toBe(true); + expect(Number.isNaN(result)).toBe(false); + + const result2 = computeMaintainabilityIndex(0, 0, 0, 0); + expect(Number.isFinite(result2)).toBe(true); + }); +}); diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js index fd6427d5..a221fafb 100644 --- a/tests/unit/mcp.test.js +++ b/tests/unit/mcp.test.js @@ -503,6 +503,8 @@ describe('startMCPServer handler dispatch', () => { file: 'utils', pattern: 'parse', noTests: undefined, + limit: 100, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -560,6 +562,8 @@ describe('startMCPServer handler dispatch', () => { expect(result.isError).toBeUndefined(); expect(queryMock).toHaveBeenCalledWith('test', '/resolved/path/.codegraph/graph.db', { noTests: undefined, + limit: 50, + offset: 0, }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); @@ -720,7 +724,11 @@ describe('startMCPServer handler dispatch', () => { params: { name: 'query_function', arguments: { name: 'test', repo: 'my-repo' } }, }); expect(result.isError).toBeUndefined(); - expect(queryMock).toHaveBeenCalledWith('test', '/resolved/db', { noTests: undefined }); + expect(queryMock).toHaveBeenCalledWith('test', '/resolved/db', { + noTests: undefined, + limit: 50, + offset: 0, + }); vi.doUnmock('@modelcontextprotocol/sdk/server/index.js'); vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js');