diff --git a/README.md b/README.md index 7a68503..5d8015f 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli - **Machine-readable JSON output** (`--json`) for automation and CI pipelines - **Auto-cached index** in `.codebase-intelligence/` for fast repeat queries - **Cache migration facts** in JSON (`cacheDir`, `legacyCacheDir`, `migrated`, `gitignoreUpdated`, `warnings[]`) -- **Quality metrics** — PageRank, betweenness, coupling, cohesion, tension, churn, complexity, blast radius, dead exports, test coverage, escape velocity, risk, maintainability, and CRAP score +- **Quality metrics** — PageRank, betweenness, coupling, cohesion, tension, churn, cyclomatic/cognitive complexity, blast radius, dead exports, test coverage, escape velocity, risk, maintainability, and CRAP score - **Symbol-level analysis** — callers/callees, symbol importance, impact blast radius - **BM25 search** — ranked keyword search across files, symbols, and type/shape facts - **Process tracing** — detect entry points and execution flows through the call graph @@ -100,7 +100,7 @@ codebase-intelligence [options] | Command | What it does | |---|---| | `overview` | High-level codebase snapshot | -| `hotspots` | Rank files by metric (coupling, churn, complexity, blast radius, coverage, risk, etc.) | +| `hotspots` | Rank files by metric (coupling, churn, complexity, cognitive complexity, blast radius, coverage, risk, etc.) | | `file` | Full context for one file | | `search` | BM25 keyword and shape search | | `changes` | Git diff analysis with risk metrics | @@ -121,7 +121,18 @@ codebase-intelligence [options] | `boundaries` | Architecture boundary zones, allow/forbid import rules, and violation evidence | | `highways` | Repeated route convergence, canonical path opportunities, and synthesis proposals | | `clusters` | Community-detected file clusters | +| `owners` | Ownership, bus-factor, and risk grouping | +| `architecture` | Evidence-backed extraction/seam/locality recommendations | +| `workspaces` | Package workspace scope and changed-workspace detection | +| `lsp` | Advisory editor diagnostics snapshot or minimal LSP server | +| `watch` | Local watch readiness and debounced analysis events | | `check` | Rules-engine gate for CI, including opt-in dead-code and dependency gates | +| `ci` | One PR quality gate around check, changes, health, formats, baselines, and history | +| `doctor` | Read-only setup auditor for local, CI, MCP, and agent workflows | +| `explain` | Explain one analyzer rule and next action | +| `migrate-config` | Dry-run config migration to `codebase-intelligence.json` | +| `hooks` | Plan or install local hooks that run the same CI gate | +| `history` | Read local finding history from `.codebase-intelligence/` | | `init` | Set up AI agents to use CI — writes per-agent instruction files (skill opt-in via `--skill`) | ### Useful flags @@ -143,9 +154,12 @@ codebase-intelligence [options] | `--score` | Print compact `health` score text | | `--preset ` | Run `boundaries` with `bulletproof`, `layered`, `hexagonal`, or `feature-sliced` | | `--list` | List resolved `boundaries` zones and rules | -| `--format ` | Export `map` as `markdown`, `json`, `dot`, or `graphml`; export `check` as `text`, `json`, or `sarif` | +| `--format ` | Export `map` as `markdown`, `json`, `dot`, or `graphml`; export `check`/`ci` as `text`, `json`, `sarif`, `markdown`, `annotations`, `pr-comment-github`, `pr-comment-gitlab`, `badge`, `codeclimate`, or `compact` | | `--operation ` | Focus `highways` on one operation verb | | `--shape ` | Focus `highways` on one type/DTO shape | +| `--production` | Exclude test/dev files from production-risk `check`/`ci` output | +| `--changed-since ` | Line-level `check`/`ci` filtering from a git diff | +| `--diff-file ` | Line-level `check`/`ci` filtering from a unified diff file | The scanner always excludes common generated and agent-workspace directories such as `.codebase-intelligence/`, legacy `.code-visualizer/`, `.next/`, `dist/`, `coverage/`, `.worktrees/`, and `.claude/worktrees/`. @@ -243,7 +257,7 @@ For MCP tool details, see [docs/mcp-tools.md](docs/mcp-tools.md). | **Tension** | Is a file torn between modules? (entropy of cross-module pulls) | | **Escape Velocity** | Should this module be its own package? | | **Churn** | Git commit frequency | -| **Complexity** | Average cyclomatic complexity of exports | +| **Complexity** | Average cyclomatic and cognitive complexity of exports/symbols | | **Blast Radius** | Transitive dependents affected by a change | | **Dead Exports** | Unused exports (safe to remove) | | **Test Coverage** | Whether a test file exists for each source file | diff --git a/docs/architecture.md b/docs/architecture.md index 93d1079..797d151 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ CLI (commander) | v Parser (TS Compiler API) - | extracts: files, exports, symbols, type facts, duplicate tokens, imports, LOC, complexity, churn, test mapping + | extracts: files, exports, symbols, type facts, duplicate tokens, imports, LOC, cyclomatic/cognitive complexity, churn, test mapping v Graph Builder (graphology) | creates: nodes (file + function), edges (imports with symbols/weights) @@ -15,7 +15,7 @@ Graph Builder (graphology) v Analyzer | computes: PageRank, betweenness, coupling, tension, cohesion - | computes: churn, complexity, blast radius, dead exports, test coverage + | computes: churn, cyclomatic/cognitive complexity, blast radius, dead exports, test coverage | produces: ForceAnalysis (tension files, bridges, extraction candidates) v Core (shared computation) @@ -25,7 +25,7 @@ Core (shared computation) | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) CLI (terminal/CI) - | 25 tools, 2 prompts, | 24 commands with text + JSON + | 29 tools, 2 prompts, | 35 commands with text + JSON | 3 resources for LLMs | output for humans and CI ``` @@ -52,7 +52,18 @@ src/ health/ <- Health score, maintainability, CRAP, coverage lookup, and risk scoring boundaries/ <- Architecture zones, allow/forbid edge rules, and violation evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 25 MCP tools for LLM integration + ci/index.ts <- First-class PR gate wrapper around check, changes, health, formats, baselines, history + doctor/index.ts <- Read-only setup auditor with fix commands and docs links + ownership/index.ts <- CODEOWNERS/git ownership, bus-factor, package/directory grouping + recommendations/ <- Extraction, seam, tension, and locality recommendations with context packs + lsp/index.ts <- Advisory diagnostics/hover snapshot plus minimal stdio LSP server + workspaces/index.ts <- Package/workspace scope and changed-workspace detection + watch/index.ts <- Local watch readiness and debounced change events + migration/index.ts <- Config migration dry-run/write support + hooks/index.ts <- Local hook planning/install for the same CI gate + history/index.ts <- Local finding history under .codebase-intelligence/ + explain/index.ts <- Rule explanation surface + mcp/index.ts <- 29 MCP tools for LLM integration mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine @@ -87,7 +98,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 25 tools, 2 prompts, 3 resources + -> stdio MCP server with 29 tools, 2 prompts, 3 resources runOperation(operation, codebaseGraph, input, context) -> { ok: true, data } | { ok: false, error, data? } @@ -106,6 +117,8 @@ runOperation(operation, codebaseGraph, input, context) - **Content drift**: `drift` / `detect_content_drift` compares path/name/export intent with import/call/type/side-effect/test behavior. Findings are deterministic, evidence-backed, report-only, and baseline-gated before any future CI enforcement. - **Health score**: `health` / `get_health_score` computes one gateable score plus per-file maintainability, CRAP, coverage, and risk evidence. `hotspots --metric risk` uses the same file-risk formula. - **Architecture boundaries**: `boundaries` / `check_boundaries` evaluates graph import edges against preset or custom zones and directed allow/forbid rules. The `no-boundary-violations` check rule reuses the same analyzer so CLI, MCP, and CI emit one stable finding shape. +- **CI + agent ergonomics**: `ci` wraps `check`, `changes`, and health into one PR-friendly contract with SARIF, annotations, PR markdown, compact summaries, baseline filtering, production filtering, and local finding history. `doctor` is read-only and emits exact fix commands for local, CI, MCP, and agent setup. +- **Depth + ecosystem surfaces**: `hotspots`/`file`/`health` expose cognitive complexity alongside cyclomatic complexity. `owners`, `architecture`, `lsp`, `workspaces`, `watch`, `migrate-config`, `hooks`, `history`, and `explain` are read-only/advisory surfaces for 2.5.0 ergonomics and hardening. - **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f016134..e54e103 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI Reference -24 commands for terminal and CI use. The 22 analysis commands have full parity with MCP tools and auto-cache the index to `.codebase-intelligence/`; `check` runs the rules gate; `init` sets up agent adoption. +35 commands for terminal and CI use. Graph-backed analysis commands have MCP parity and auto-cache the index to `.codebase-intelligence/`; workflow commands cover `check`, `ci`, `doctor`, local hooks/history, config migration, and agent adoption. ## Commands @@ -22,7 +22,7 @@ Rank files by metric. codebase-intelligence hotspots [--metric ] [--limit ] [--json] [--force] ``` -**Metrics:** `coupling` (default), `pagerank`, `fan_in`, `fan_out`, `betweenness`, `tension`, `churn`, `complexity`, `blast_radius`, `coverage`, `risk`, `escape_velocity`. +**Metrics:** `coupling` (default), `pagerank`, `fan_in`, `fan_out`, `betweenness`, `tension`, `churn`, `complexity`, `cognitive_complexity`, `blast_radius`, `coverage`, `risk`, `escape_velocity`. ### file @@ -238,23 +238,132 @@ codebase-intelligence clusters [--min-files ] [--json] [--force] **Output:** clusters with files, file count, cohesion. +### owners + +Group ownership and bus-factor risk. + +```bash +codebase-intelligence owners [--group-by owner|package|directory] [--effort ] [--json] [--force] +``` + +**Output:** files, groups, hotspots, owner/package/directory keys, bus-factor, churn, risk score, and evidence. + +### architecture + +Rank extraction, seam, tension, and locality recommendations. + +```bash +codebase-intelligence architecture [--json] [--force] +``` + +**Output:** recommendations with stable IDs, kind, title, effort, score, affected files, evidence, and contextPack commands. + +### workspaces + +Detect package scopes, changed workspaces, and cross-package cycles. + +```bash +codebase-intelligence workspaces [--base ] [--changed] [--json] [--force] +``` + +**Output:** workspace name/path/file count, changed flag, per-workspace cycle evidence, cross-package cycle evidence, and summary. + +### lsp + +Start a minimal advisory LSP server or print a diagnostics snapshot. + +```bash +codebase-intelligence lsp [--diagnostics] [--json] [--force] +``` + +**Output:** diagnostics and hover facts when `--diagnostics` or `--json` is used. Without those flags, the command starts a stdio LSP server with advisory diagnostics and hovers. + +### watch + +Keep analysis warm while editing. + +```bash +codebase-intelligence watch [--once] [--debounce ] [--json] [--force] +``` + +**Output:** readiness snapshot in `--once`/`--json` mode. Without `--once`, emits debounced change events. + ### check Rules-engine gate for CI. ```bash -codebase-intelligence check [--config ] [--format ] [--fail-on ] [--gate ] [--base ] [--quiet] [--summary] [--json] [--force] +codebase-intelligence check [--config ] [--format ] [--fail-on ] [--gate ] [--base ] [--changed-since ] [--diff-file ] [--production] [--quiet] [--summary] [--json] [--force] ``` -**Formats:** `text` (default), `json`, `sarif`. +**Formats:** `text` (default), `json`, `sarif`, `markdown`, `annotations`, `pr-comment-github`, `pr-comment-gitlab`, `badge`, `codeclimate`, `compact`. **Fail-on:** `error` (default), `warn`, `never`. **Gate modes:** `all` (default), `new-only`. -**Output:** pass/warn/fail verdict, findings, suppression ledger, and summary counts. Findings include stable `fingerprint`; cleanup findings may also include `kind`, `confidence`, and `evidence`. Summary counts include `suppressed` and `staleSuppressions`; `--summary` prints those counts when present. Exit code `0` on pass, `1` when the configured gate fails, `2` for invalid config or arguments. +**Output:** pass/warn/fail verdict, findings, suppression ledger, and summary counts. Findings include stable `fingerprint` and advisory `actions[]`; cleanup/security findings may also include `kind`, `confidence`, and `evidence`. Summary counts include `suppressed` and `staleSuppressions`; `--summary` prints those counts when present. Exit code `0` on pass, `1` when the configured gate fails, `2` for invalid config or arguments. + +**Rules:** `no-comments` (off by default), `no-boundary-violations` (error when top-level `boundaries` config exists), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), plus opt-in cleanup/security gates: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`, `no-secrets`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities in `codebase-intelligence.json`. + +### ci + +One PR-friendly quality gate around `check`, `changes`, health, baselines, formats, changed workspaces, and local history. -**Rules:** `no-comments` (off by default), `no-boundary-violations` (error when top-level `boundaries` config exists), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), plus opt-in cleanup gates: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities in `codebase-intelligence.json`. +```bash +codebase-intelligence ci [--base ] [--new-only] [--all] [--fail-on error|warn|never] [--min-score ] [--max-new ] [--baseline ] [--format ] [--output ] [--comment markdown] [--summary] [--production] [--changed-since ] [--diff-file ] [--changed-workspaces] [--history] [--json] [--force] +``` + +**Exit codes:** `0` pass, `1` gate failed, `2` invalid config/args, `3` analyzer error. + +**Formats:** same as `check`. `--comment markdown` emits a PR-comment-safe markdown envelope. `--changed-workspaces` includes changed workspace and cross-package cycle facts in JSON output. `--history` writes finding fingerprints and counts to `.codebase-intelligence/history.json`. + +### doctor + +Read-only setup auditor. + +```bash +codebase-intelligence doctor [path] [--profile local|ci|agent|mcp] [--agent codex|claude|cursor|generic] [--json] +``` + +**Output:** status, checks, evidence, exact fix commands, and docs links. Doctor checks runtime, package manager, config schema, graph build, cache path, CLI help, MCP registry, CI workflow, and agent instructions. + +### explain + +Explain one rule. + +```bash +codebase-intelligence explain [--json] +``` + +### migrate-config + +Dry-run config migration to `codebase-intelligence.json`. + +```bash +codebase-intelligence migrate-config [--source ] [--write] [--json] +``` + +Default is dry-run and does not mutate source. + +### hooks + +Plan or install local hooks that run the same CI gate. + +```bash +codebase-intelligence hooks install [path] [--apply] [--command ] [--json] +codebase-intelligence hooks uninstall [path] [--apply] [--json] +``` + +Default is dry-run. Use `--apply` to write `.git/hooks/pre-commit`. + +### history + +Read local finding history. + +```bash +codebase-intelligence history [path] [--json] +``` ### init diff --git a/docs/data-model.md b/docs/data-model.md index ec35531..31ade01 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -23,6 +23,7 @@ ParsedExport { loc: number // Lines of code for this export isDefault: boolean complexity: number // Cyclomatic complexity (branch count, min 1) + cognitiveComplexity?: number // Nesting-aware cognitive complexity typeFacts?: SymbolTypeFacts duplication?: SymbolDuplicationFacts } @@ -84,6 +85,7 @@ SymbolNode { loc: number isDefault: boolean complexity: number + cognitiveComplexity?: number isExported?: boolean typeFacts?: SymbolTypeFacts duplication?: SymbolDuplicationFacts @@ -110,6 +112,7 @@ FileMetrics { // Quality (from AST + graph analysis) cyclomaticComplexity: number // Avg complexity of exports + cognitiveComplexity?: number // Avg nesting-aware complexity of symbols blastRadius: number // Transitive dependent count deadExports: string[] // Unused export names totalExports: number // Named export count used as dead-export denominator @@ -490,6 +493,14 @@ Finding { fingerprint: string } +FindingAction { + kind: "remove-comment" | "inspect-boundary" | "inspect-file" | "inspect-symbol" | "run-check" | "review-finding" | "create-baseline" + auto_fixable: boolean + range?: { start: number; end: number } + command?: string + reason?: string +} + CheckSuppression { directive: "ci-ignore-file" | "ci-ignore-next-line" | "@expected-unused" status: "active" | "stale" @@ -518,3 +529,83 @@ CheckResult { configPath: string | null } ``` + +## CI Result + +`ci --json` wraps the rules gate with PR-friendly metadata. + +```typescript +CiResult { + verdict: "pass" | "fail" + exitCode: 0 | 1 + base: string + newOnly: boolean + summary: string + gates: Array<{ name: string; verdict: "pass" | "warn" | "fail"; summary: string }> + check: CheckResult + health: HealthResult + changes: ChangesResult + workspaces?: WorkspacesResult + baseline: { path?: string; ignoredFindings: number } +} +``` + +## Doctor Result + +```typescript +DoctorResult { + status: "pass" | "warn" | "fail" + profile: "local" | "ci" | "agent" | "mcp" + agent: "codex" | "claude" | "cursor" | "generic" + root: string + checks: Array<{ + id: string + level: "pass" | "warn" | "fail" + title: string + evidence: string[] + fix?: string + docs?: string + }> + summary: string +} +``` + +## Ownership / Architecture / Editor / Workspace Outputs + +```typescript +OwnershipResult { + groupBy: "owner" | "package" | "directory" + summary: string + files: Array<{ file: string; owner: string; package: string; directory: string; churn: number; riskScore: number; busFactor: number; hasTests: boolean; coverageGap: boolean; evidence: string[] }> + groups: Array<{ key: string; files: number; owners: string[]; busFactor: number; riskScore: number; churn: number; coverageGaps: number; evidence: string[] }> + hotspots: Array<{ key: string; files: number; owners: string[]; busFactor: number; riskScore: number; churn: number; coverageGaps: number; evidence: string[] }> +} + +ArchitectureRecommendationsResult { + recommendations: Array<{ + id: string + kind: "extract-module" | "reduce-tension" | "add-seam" | "improve-locality" + title: string + effort: "small" | "medium" | "large" + score: number + affectedFiles: string[] + evidence: string[] + contextPack: { files: string[]; symbols: string[]; commands: string[] } + }> + summary: string +} + +LspSnapshot { + diagnostics: Array<{ file: string; range: object; severity: "warning" | "information"; code: string; message: string }> + hovers: Array<{ file: string; symbol?: string; markdown: string }> + summary: string +} + +WorkspacesResult { + base: string + changedOnly: boolean + workspaces: Array<{ name: string; path: string; files: number; changed: boolean; cycles: string[][]; evidence: string[] }> + crossPackageCycles: string[][] + summary: string +} +``` diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 04364be..6780474 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -25 tools available via MCP stdio. +29 tools available via MCP stdio. Operation tools return JSON text payloads. Invalid operation inputs return `isError: true` with `{ "error": "..." }` using the same descriptor validation messages as CLI bad-argument exits. @@ -19,7 +19,7 @@ High-level summary of the entire codebase. Detailed context for a single file. **Input:** `{ filePath: string }` (relative path) -**Returns:** path, loc, exports (with additive `typeFacts` when known), imports (with symbols, isTypeOnly, weight), dependents (with symbols, isTypeOnly, weight), metrics (all FileMetrics including churn, complexity, blastRadius, deadExports, totalExports, package-entrypoint metadata, hasTests, testFile) +**Returns:** path, loc, exports (with additive `typeFacts` when known), imports (with symbols, isTypeOnly, weight), dependents (with symbols, isTypeOnly, weight), metrics (all FileMetrics including churn, cyclomatic/cognitive complexity, blastRadius, deadExports, totalExports, package-entrypoint metadata, hasTests, testFile) **Path normalization:** Backslashes are normalized to forward slashes. The exact graph path is tried first; if not found, common prefixes (`src/`, `lib/`, `app/`) are stripped once from the leading position and retried. If the file is not found, the error includes up to 3 suggested similar paths. @@ -41,7 +41,7 @@ File-level blast radius analysis — what breaks if this file changes. Rank files by any metric. **Input:** `{ metric: string, limit?: number }` (default limit: 10) -**Metrics:** coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage, risk +**Metrics:** coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, cognitive_complexity, blast_radius, coverage, risk **Returns:** ranked files with score + reason, summary **Use when:** "What are the riskiest files?" "Which files need tests?" "Most complex files?" @@ -249,14 +249,54 @@ Community-detected clusters of related files. **Use when:** "What files are related?" "Find natural groupings." Discovering emergent groupings that differ from directory structure. **Not for:** Directory-based modules (use get_module_structure). -## 25. check +## 25. get_ownership + +Owner/package/directory grouping with bus-factor and risk signals. + +**Input:** `{ groupBy?: "owner" | "package" | "directory", effort?: number }` +**Returns:** groupBy, summary, files[], groups[], hotspots[] with owner/package/directory keys, busFactor, churn, riskScore, and evidence. + +**Use when:** Finding owner concentration, risky orphaned paths, package handoff risk, or CODEOWNERS gaps. +**Not for:** Editing ownership files automatically; output is advisory. + +## 26. get_architecture_recommendations + +Rank architecture cleanup opportunities. + +**Input:** `{}` +**Returns:** recommendations[] with id, kind (`extract-module`, `reduce-tension`, `add-seam`, `improve-locality`), title, effort, score, affectedFiles, evidence, and contextPack. + +**Use when:** Planning extraction/consolidation work from graph evidence. +**Not for:** Applying refactors automatically. + +## 27. get_lsp_snapshot + +Editor diagnostics and hover facts derived from batch analysis. + +**Input:** `{}` +**Returns:** diagnostics[], hovers[], summary. + +**Use when:** Comparing editor/LSP facts with CLI output or building advisory editor integrations. +**Not for:** Source mutation; code actions remain advisory. + +## 28. get_workspaces + +Package workspace scope and changed-workspace facts. + +**Input:** `{ base?: string, changedOnly?: boolean }` +**Returns:** base, changedOnly, workspaces[] with name, path, files, changed, cycles, evidence, crossPackageCycles, and summary. + +**Use when:** Scoping monorepo CI to changed workspaces or checking cross-package cycle evidence. +**Not for:** Package-manager install/update actions. + +## 29. check Run the configurable rules engine and gate on findings. **Input:** `{}` (uses the loaded graph + discovered config) **Returns:** `{ verdict: "pass"|"warn"|"fail", summary: { error, warn, suppressed, staleSuppressions, rules }, configPath, suppressions[], findings[] }`. Each finding has ruleId, severity, file, line, column, message, fingerprint, optional `kind`, optional `confidence`, optional `evidence[]`, and optional advisory `actions[]` (the tool is read-only — actions are hints, never applied). Each suppression records directive, status, file, line, targetLine, matched rule IDs, and suppressed count. -Rules: `no-comments` (off by default), `no-boundary-violations` (error when `boundaries` config exists), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), and opt-in cleanup gates `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities and options in `codebase-intelligence.json` (validated by `schema.json`). +Rules: `no-comments` (off by default), `no-boundary-violations` (error when `boundaries` config exists), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), and opt-in cleanup/security gates `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`, `no-secrets`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities and options in `codebase-intelligence.json` (validated by `schema.json`). **Use when:** Linting a codebase or enforcing a CI gate. "What rule violations exist?" **Not for:** Architecture metrics (use analyze_forces). @@ -305,5 +345,9 @@ Rules: `no-comments` (off by default), `no-boundary-violations` (error when `bou | "Which file names lie about behavior?" | `detect_content_drift` | | "Which routes bypass canonical dataflow?" | `analyze_highways` | | "What files naturally belong together?" | `get_clusters` | +| "Who owns this risky scope?" | `get_ownership` | +| "What architecture cleanup should happen next?" | `get_architecture_recommendations` | +| "What editor diagnostics match batch analysis?" | `get_lsp_snapshot` | +| "Which workspaces changed in this PR?" | `get_workspaces` | | "What are the main areas?" | `get_groups` | | "What rule violations exist? Lint this." | `check` | diff --git a/llms-full.txt b/llms-full.txt index 62b660b..c058411 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -13,7 +13,7 @@ CLI (commander) | v Parser (TS Compiler API) - | extracts: files, exports, imports, LOC, complexity, churn, test mapping + | extracts: files, exports, imports, LOC, cyclomatic/cognitive complexity, churn, test mapping v Graph Builder (graphology) | creates: nodes (file + function), edges (imports with symbols/weights) @@ -21,7 +21,7 @@ Graph Builder (graphology) v Analyzer | computes: PageRank, betweenness, coupling, tension, cohesion - | computes: churn, complexity, blast radius, dead exports, test coverage + | computes: churn, cyclomatic/cognitive complexity, blast radius, dead exports, test coverage | produces: ForceAnalysis (tension files, bridges, extraction candidates) v Core (shared computation) @@ -31,8 +31,8 @@ Core (shared computation) | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) + CLI - | MCP: 25 tools, 2 prompts, 3 resources for LLM agents - | CLI: 24 commands with formatted + JSON output for humans/CI + | MCP: 29 tools, 2 prompts, 3 resources for LLM agents + | CLI: 35 public commands with formatted + JSON output for humans/CI ``` ## Module Map @@ -42,7 +42,7 @@ src/ types/index.ts <- ALL interfaces (single source of truth) parser/index.ts <- Parse orchestration + imports/exports/call sites + git churn/test detection parser/type-facts.ts <- Type signatures, parameters, consumed/produced shape facts - parser/symbols.ts <- Symbol inventory + symbol complexity + parser/symbols.ts <- Symbol inventory + symbol cyclomatic/cognitive complexity graph/index.ts <- graphology graph + symbol/type graph + circular dep detection analyzer/index.ts <- All metric computation graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events @@ -56,7 +56,18 @@ src/ boundaries/ <- Architecture zones, allow/forbid edge rules, and violation evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence health/ <- Health score, maintainability, CRAP, coverage lookup, and risk scoring - mcp/index.ts <- 25 MCP tools for LLM integration + ci/ <- PR-friendly quality gate over check, changes, health, baselines, history, and workspace scope + doctor/ <- Read-only setup auditor for local, CI, MCP, and coding-agent workflows + ownership/ <- CODEOWNERS/git/package/directory ownership and bus-factor signals + recommendations/ <- Architecture recommendations with effort, evidence, and context commands + lsp/ <- Advisory diagnostics/hover facts and minimal stdio LSP server + workspaces/ <- Workspace detection, changed-scope summaries, and cross-package cycle evidence + watch/ <- Watch readiness snapshots and debounce-aware change reporting + migration/ <- Dry-run-first config migration into codebase-intelligence.json + hooks/ <- Dry-run-first local hook install/uninstall for the same CI gate + history/ <- Local finding-history fingerprints under .codebase-intelligence/ + explain/ <- Rule explanations and action hints + mcp/index.ts <- 29 MCP tools for LLM integration mcp/hints.ts <- Operation-keyed next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine @@ -77,7 +88,7 @@ loadCodebaseGraph(rootDir) -> otherwise emits progress events through parse/build/analyze/cache parseCodebase(rootDir) - -> ParsedFile[] (with churn, complexity, test mapping, symbol type facts, duplicate token facts) + -> ParsedFile[] (with churn, cyclomatic/cognitive complexity, test mapping, symbol type facts, duplicate token facts) buildGraph(parsedFiles) -> BuiltGraph { graph: Graph, nodes: GraphNode[], edges: GraphEdge[] } @@ -132,6 +143,7 @@ ParsedExport { loc: number // Lines of code for this export isDefault: boolean complexity: number // Cyclomatic complexity (branch count, min 1) + cognitiveComplexity?: number // Nesting-aware cognitive complexity typeFacts?: SymbolTypeFacts } @@ -194,6 +206,7 @@ FileMetrics { hasTests: boolean // Test file exists testFile: string // Path to test file cyclomaticComplexity: number // Avg complexity of exports + cognitiveComplexity?: number // Avg nesting-aware complexity of symbols blastRadius: number // Transitive dependent count deadExports: string[] // Unused export names totalExports: number // Named export count used as dead-export denominator @@ -265,6 +278,43 @@ CodebaseContextPack { } ``` +## 2.5.0 Workflow Result Envelopes + +```typescript +FindingAction { + kind: "remove-comment" | "inspect-boundary" | "inspect-file" | "inspect-symbol" | "run-check" | "review-finding" | "create-baseline" + auto_fixable: boolean + range?: { start: number; end: number } + command?: string + reason?: string +} + +CiResult { + verdict: "pass" | "fail" + exitCode: 0 | 1 + base: string + newOnly: boolean + gates: Array<{ name: string; verdict: "pass" | "warn" | "fail"; summary: string }> + check: CheckResult + health: HealthResult + changes: ChangesResult + workspaces?: WorkspacesResult + baseline: { path?: string; ignoredFindings: number } +} + +DoctorResult { + status: "pass" | "warn" | "fail" + profile: "local" | "ci" | "agent" | "mcp" + agent: "codex" | "claude" | "cursor" | "generic" + checks: Array<{ id: string; level: "pass" | "warn" | "fail"; title: string; evidence: string[]; fix?: string; docs?: string }> +} + +OwnershipResult { groupBy: "owner" | "package" | "directory"; files: Array<{ file: string; owner: string; hasTests: boolean; coverageGap: boolean }>; groups: Array<{ key: string; coverageGaps: number; riskScore: number }>; hotspots: Array } +ArchitectureRecommendationsResult { recommendations: Array<{ id: string; effort: "small" | "medium" | "large"; affectedFiles: string[]; evidence: string[]; contextPack: object }> } +LspSnapshot { diagnostics: Array; hovers: Array; summary: string } +WorkspacesResult { base: string; changedOnly: boolean; workspaces: Array<{ name: string; path: string; files: number; changed: boolean; cycles: string[][]; evidence: string[] }>; crossPackageCycles: string[][]; summary: string } +``` + --- # Metrics Reference @@ -282,6 +332,7 @@ CodebaseContextPack { | isBridge | bool | betweenness > 0.1 | | churn | 0-N | Git commits touching this file | | cyclomaticComplexity | 1-N | Avg complexity of exports | +| cognitiveComplexity | 0-N | Avg nesting-aware complexity of symbols | | blastRadius | 0-N | Transitive dependents affected by change | | deadExports | list | Export names not consumed by any import | | totalExports | count | Named exports used as dead-export denominator | @@ -314,7 +365,7 @@ The most dangerous files have: high churn + high coupling + low coverage. # MCP Tools Reference -25 tools available via MCP stdio. +29 tools available via MCP stdio. ## 1. codebase_overview High-level summary. Input: `{ depth?: number }`. Returns: totalFiles, totalFunctions, modules, topDependedFiles, metrics, and analysis mode/call graph precision. @@ -326,7 +377,7 @@ Detailed file context. Input: `{ filePath: string }`. Returns: exports, imports, File-level blast radius. Input: `{ filePath: string, depth?: number }`. Returns: direct + transitive dependents, riskLevel. ## 4. find_hotspots -Rank files by metric. Input: `{ metric: string, limit?: number }`. Metrics: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage, risk. +Rank files by metric. Input: `{ metric: string, limit?: number }`. Metrics: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, cognitive_complexity, blast_radius, coverage, risk. ## 5. get_module_structure Module architecture. Input: `{ depth?: number }`. Returns: modules with metrics, cross-module deps, circular deps. @@ -388,7 +439,19 @@ Repeated route convergence. Input: `{ operation?: string, shape?: string, minRou ## 24. get_clusters Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. -## 25. check +## 25. get_ownership +Ownership and bus-factor grouping. Input: `{ groupBy?: "owner" | "package" | "directory", effort?: number }`. Returns: groups with files, owners, churn, risk, busFactor, and evidence. + +## 26. get_architecture_recommendations +Rank extraction, seam, tension, and locality recommendations. Input: `{}`. Returns: stable recommendation IDs, effort, score, affected files, evidence, and contextPack commands. + +## 27. get_lsp_snapshot +Advisory editor facts from the same graph as batch analysis. Input: `{}`. Returns: diagnostics and hover facts for dead exports, circular deps, complexity, and risk. + +## 28. get_workspaces +Workspace/package scopes. Input: `{ base?: string, changedOnly?: boolean }`. Returns: workspaces with name, path, files, changed, cycles, evidence, crossPackageCycles, and summary. + +## 29. check Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppression ledger, config path, and summary counts. Findings always include ruleId, severity, file, line, column, message, and fingerprint; cleanup findings may also include kind, confidence, and evidence. Suppressions include directive, active/stale status, file, line, targetLine, ruleIds, and suppressed count. ## Tool Selection Guide @@ -419,13 +482,17 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr | Which file names lie about behavior? | detect_content_drift | | Which routes bypass canonical dataflow? | analyze_highways | | What files naturally belong together? | get_clusters | +| Who owns this risky scope? | get_ownership | +| What architecture cleanup should happen next? | get_architecture_recommendations | +| What editor diagnostics match batch analysis? | get_lsp_snapshot | +| Which workspaces changed in this PR? | get_workspaces | | What rule violations exist? | check | --- # CLI Reference -24 commands — 22 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. +35 public commands — 29 MCP-backed analysis tools plus `ci`, `doctor`, `watch`, `migrate-config`, `hooks`, `history`, `explain`, `check`, and `init`. ## Commands @@ -439,7 +506,7 @@ High-level codebase snapshot: files, functions, modules, dependencies. ```bash codebase-intelligence hotspots [--metric ] [--limit ] [--json] [--force] ``` -Rank files by metric. Default: coupling. Available: coupling, pagerank, fan_in, fan_out, betweenness, tension, churn, complexity, blast_radius, coverage, risk, escape_velocity. +Rank files by metric. Default: coupling. Available: coupling, pagerank, fan_in, fan_out, betweenness, tension, churn, complexity, cognitive_complexity, blast_radius, coverage, risk, escape_velocity. ### file ```bash @@ -561,6 +628,73 @@ codebase-intelligence clusters [--min-files ] [--json] [--force] ``` Community-detected file clusters (Louvain algorithm). +### owners +```bash +codebase-intelligence owners [--group-by owner|package|directory] [--effort ] [--json] [--force] +``` +Ownership, package, or directory grouping with bus-factor and risk evidence. + +### architecture +```bash +codebase-intelligence architecture [--json] [--force] +``` +Rank graph-backed extraction, seam, tension, and locality recommendations. + +### workspaces +```bash +codebase-intelligence workspaces [--base ] [--changed] [--json] [--force] +``` +Detect package/workspace scopes, changed workspaces, and cross-package cycle evidence. + +### lsp +```bash +codebase-intelligence lsp [--diagnostics] [--json] [--force] +``` +Start the advisory LSP server, or print diagnostics/hover facts with `--diagnostics`. + +### watch +```bash +codebase-intelligence watch [--once] [--debounce ] [--json] [--force] +``` +Keep analysis warm while editing; use `--once` for CI-safe readiness snapshots. + +### ci +```bash +codebase-intelligence ci [--base ] [--new-only] [--all] [--fail-on error|warn|never] [--min-score ] [--max-new ] [--baseline ] [--format ] [--output ] [--comment markdown] [--summary] [--production] [--changed-since ] [--diff-file ] [--changed-workspaces] [--history] [--json] [--force] +``` +One PR-friendly quality gate around `check`, `changes`, health, baselines, local history, and optional changed-workspace summaries. Exit codes: 0 pass, 1 gate failed, 2 config/usage error, 3 analyzer error. Formats: text, json, sarif, markdown, annotations, pr-comment-github, pr-comment-gitlab, badge, codeclimate, compact. + +### doctor +```bash +codebase-intelligence doctor [path] [--profile local|ci|agent|mcp] [--agent codex|claude|cursor|generic] [--json] +``` +Read-only setup auditor for runtime, package manager, config schema, graph build, cache path, CLI help, MCP registry, CI workflow, and agent instructions. + +### explain +```bash +codebase-intelligence explain [--json] +``` +Explain a rule and safe next actions. + +### migrate-config +```bash +codebase-intelligence migrate-config [--source ] [--write] [--json] +``` +Dry-run-first config migration into `codebase-intelligence.json`. + +### hooks +```bash +codebase-intelligence hooks install [path] [--apply] [--command ] [--json] +codebase-intelligence hooks uninstall [path] [--apply] [--json] +``` +Plan or install local hooks that run the same CI gate. Default is dry-run. + +### history +```bash +codebase-intelligence history [path] [--json] +``` +Read local finding-history fingerprints and counts from `.codebase-intelligence/history.json`. + ### init ```bash codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitignore] [--yes] [--json] @@ -577,10 +711,10 @@ content between the `codebase-intelligence:start`/`:end` markers is ever touched ### check ```bash -codebase-intelligence check [--config ] [--format ] [--fail-on ] [--gate ] [--base ] [--quiet] [--summary] [--json] [--force] +codebase-intelligence check [--config ] [--format ] [--fail-on ] [--gate ] [--base ] [--changed-since ] [--diff-file ] [--production] [--quiet] [--summary] [--json] [--force] ``` -Rules-engine gate for CI. Formats: text, json, sarif. Fail-on: error, warn, never. -Gate modes: all, new-only. Returns pass/warn/fail verdict, findings, suppression ledger, and summary counts. Built-in rules include no-comments, no-boundary-violations, no-circular-deps, no-dead-exports, no-stale-suppressions, and opt-in no-dead-files, no-unused-types, no-unused-members, no-unused-deps. Dependency findings are scoped to the nearest package/workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. Reports active/stale `ci-ignore-*` and `@expected-unused` suppressions; `@public` protects exported cleanup declarations while `@internal` remains checkable. +Rules-engine gate for CI. Formats: text, json, sarif, markdown, annotations, pr-comment-github, pr-comment-gitlab, badge, codeclimate, compact. Fail-on: error, warn, never. +Gate modes: all, new-only. Returns pass/warn/fail verdict, findings with advisory `actions[]`, suppression ledger, and summary counts. Built-in rules include no-comments, no-boundary-violations, no-circular-deps, no-dead-exports, no-stale-suppressions, and opt-in no-dead-files, no-unused-types, no-unused-members, no-unused-deps, no-secrets. Dependency findings are scoped to the nearest package/workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. Reports active/stale `ci-ignore-*` and `@expected-unused` suppressions; `@public` protects exported cleanup declarations while `@internal` remains checkable. ## Global Behavior diff --git a/llms.txt b/llms.txt index b8360a8..ea1022b 100644 --- a/llms.txt +++ b/llms.txt @@ -7,7 +7,7 @@ - [Architecture](docs/architecture.md): Pipeline, module map, data flow, design decisions - [Data Model](docs/data-model.md): All TypeScript interfaces with field descriptions - [Metrics](docs/metrics.md): Per-file and module metrics, force analysis, complexity scoring -- [MCP Tools](docs/mcp-tools.md): 25 MCP tools — inputs, outputs, use cases, selection guide +- [MCP Tools](docs/mcp-tools.md): 29 MCP tools — inputs, outputs, use cases, selection guide - [CLI Reference](docs/cli-reference.md): CLI commands, flags, output formats, examples ## Quick Start @@ -17,10 +17,10 @@ MCP mode (AI agents): codebase-intelligence ./path/to/project ``` -CLI mode (humans/CI) — 24 commands (22 analysis with MCP parity + `check` + `init`): +CLI mode (humans/CI) — 35 public commands (29 MCP-backed analysis tools + CI/dev workflows): ```bash codebase-intelligence overview ./src -codebase-intelligence hotspots ./src --metric coupling +codebase-intelligence hotspots ./src --metric cognitive_complexity codebase-intelligence file ./src parser/index.ts codebase-intelligence search ./src "auth" # keyword or type/shape query codebase-intelligence changes ./src --json @@ -41,8 +41,19 @@ codebase-intelligence health ./src --score --min-score 70 codebase-intelligence boundaries ./src --preset layered --list --json codebase-intelligence highways ./src --operation create --min-routes 3 --propose --json codebase-intelligence clusters ./src --min-files 3 -codebase-intelligence check ./src # rules gate for CI; JSON includes findings plus active/stale suppressions -codebase-intelligence init . # make AI agents use CI +codebase-intelligence owners ./src --group-by owner --json +codebase-intelligence architecture ./src --json +codebase-intelligence workspaces . --base origin/main --changed --json +codebase-intelligence lsp ./src --diagnostics --json +codebase-intelligence watch ./src --once --json +codebase-intelligence ci ./src --base origin/main --new-only --changed-workspaces --format json +codebase-intelligence doctor . --profile agent --agent codex --json +codebase-intelligence migrate-config . --json +codebase-intelligence hooks install . --json # dry-run by default; add --apply to write +codebase-intelligence history . --json +codebase-intelligence explain no-dead-exports --json +codebase-intelligence check ./src # rules gate for CI; JSON includes findings plus active/stale suppressions +codebase-intelligence init . # make AI agents use CI ``` ## Behavior Notes diff --git a/roadmap.md b/roadmap.md index efaa688..08823a2 100644 --- a/roadmap.md +++ b/roadmap.md @@ -247,9 +247,11 @@ Fix the repeated Vitest runner timeout so release gates are deterministic. Package public entrypoints already have coverage. Only fix remaining framework/config false positives with proof. -**To do:** +**Status:** No verified remaining false positive is open for the current `2.5.0` canary train. -- Add minimal fixtures only for verified remaining false positives. +**Guardrail:** + +- Add minimal fixtures only if a remaining false positive is reproduced. - Prefer config-driven entrypoint declarations over hardcoded broad framework plugins. - Do not ship a universal framework plugin system in `2.5.0`. @@ -280,7 +282,7 @@ Collapse CLI + MCP operation duplication into one descriptor registry before add - Extend CH-P1-02 coverage to MCP/stdio graph-load behavior. - Move operation text formatting over result objects into descriptor-backed formatters. -**Remaining:** +**Status:** - None for the operation-registry foundation. Future non-text output variants remain tracked under P2 Output Formats + Actionability. @@ -296,10 +298,13 @@ Capture resolved parameter and return types per symbol. - Index consumed/produced type names in search so shape queries find producer/consumer symbols. - Add CH-P1-03 coverage for aliases, generics, default exports, unresolved types, file/symbol/search JSON, CLI, and MCP parity. -**Remaining:** +**Status:** Shipped into downstream analyzers. + +**Integrated:** -- Unlock type-aware dead code and Highways H2 shape grouping. -- Use type facts for synthesized highway signatures. +- Type facts feed Highways shape filters. +- Type facts feed synthesized highway proposal signatures. +- Type facts remain additive in JSON to preserve backward compatibility. ### Duplication Detection @@ -313,10 +318,12 @@ Add deterministic clone detection on the existing parser path. - Add CH-P1-04 coverage for exact clones, renamed clones, near-miss clones, below-threshold noise, trace output, stable IDs/order, CLI/MCP parity, and local-only skipping. - Expose deterministic similarity scores for later Highways routing. -**Remaining:** +**Status:** Shipped into Highways H2 support. + +**Integrated:** -- Feed duplication similarity signals into Highways once Highways exists. -- Add semantic/shape-based duplication on top of the Type/Shape layer. +- Highways synthesis reports duplicated intermediate callees when repeated routes share implementation steps. +- Semantic/shape-based duplication remains post-2.5.0 research because current `2.5.0` scope is deterministic AST/type/graph evidence only. ### Dead Code Beyond Exports @@ -335,9 +342,9 @@ Extend deletion intelligence beyond exported symbols. - Add CH-P1-05 coverage for JSON/SARIF, supported package entrypoint ignore, and all four dead-code categories. - Add CH-P1-07 coverage for root/package manifest scoping, unlisted package imports, test-only deps, type-only deps, runtime devDeps, and package evidence. -**Remaining:** +**Status:** Deferred beyond `2.5.0`. -- Add CSS/template/framework plugin coverage only after `doctor` can explain supported surfaces. +**Reason:** `doctor` now reports supported setup surfaces, but broad CSS/template/framework plugin coverage is intentionally out of scope for `2.5.0`. ### Suppression Hygiene @@ -353,7 +360,7 @@ Finish suppression management without hiding stale debt. - Add JSDoc `@public` and `@internal` semantics for cleanup declarations: public exported types are protected, internal exported types remain checkable. - Add CH-P1-06 coverage for active suppression, stale suppression drift, JSDoc cleanup semantics, JSON summary, CLI summary, and SARIF properties. -**Remaining:** +**Status:** - No open CH-P1-06 work on the current TypeScript cleanup surface. @@ -395,12 +402,17 @@ entry ─► transform ─► validate ─► sink - CLI shipped: add `highways ` with `--operation`, `--shape`, `--min-routes`, `--propose`, `--trace`, `--json`. - H1 shipped: every opportunity emits a token-budgeted context pack: summary, affected routes, evidence, blast radius, proposed canonical node, next safe command. -**Remaining:** +**Status:** H1/H2 shipped in the canary train. + +**Shipped:** + +- Add type-shape grouping and `--shape` filters backed by symbol type facts. +- Synthesize new highway proposals: name, location, signature, skeleton, and cycle-safe reroute plan. +- Detect near-duplicate intermediate steps as shared duplicated callees inside route groups. -- H2 shipped: add type-shape grouping once Type/Shape layer lands. -- H2 shipped: synthesize new highway proposals: name, location, signature, skeleton, cycle-safe reroute plan. -- H2: detect shape drift and near-duplicate intermediate steps. -- H3: add reuse hotspot metrics and cross-link opportunities into `forces` / `hotspots`. +**Deferred beyond stable `2.5.0`:** + +- Reuse hotspot metrics cross-linked into `forces` / `hotspots`. ### Scope Graph + Codebase Map @@ -443,10 +455,16 @@ codebase graph - Emit deterministic `overview`, `focus`, `nodes`, `edges`, `contextPack`, `evidence`, and stable evidence/edge IDs. - Add chained CLI + real stdio MCP coverage for focused symbol maps and token-bounded ranked files/symbols/tests. -**Remaining:** +**Status:** Structured graph output is shipped; visual-first products remain derived surfaces. + +**Shipped:** + +- Focused map/context-pack output includes stable file, symbol, scope, and test graph nodes. +- Ownership and route analyzers now expose structured facts separately, so future viewers can compose them without scraping prose. + +**Deferred beyond stable `2.5.0`:** -- Add route/type/owner nodes after the upstream analyzers expose those facts as structured graph data. -- Add optional 2D/3D graph export/viewer only after structured graph outputs are useful. +- Optional first-party 2D/3D viewer. ### Content Drift @@ -469,9 +487,11 @@ drift score = mismatch(declared intent, actual behavior) - Keep implementation deterministic: tokenized names, resolved symbols/types, imports/calls, side-effect signals, tests, stable finding IDs, and stable evidence IDs. - Add CH-P2-04 chained CLI + real stdio MCP coverage for drift score, deterministic evidence, recommendations, and report-only baseline behavior. -**Remaining:** +**Status:** Report-only drift shipped for `2.5.0`. + +**Deferred beyond stable `2.5.0`:** -- Add drift baselines and CI gating after the first report-only canary so teams can gate only new severe drift. +- Drift baselines and CI gating after teams have canary data. ### Health Score + Maintainability @@ -488,9 +508,12 @@ Provide one CI-gateable quality score plus file-level risk metrics. - Extend `hotspots` with `--metric risk` using complexity x churn x coupling x size x blast radius/test reachability. - Add CH-P2-05 health subchain coverage for CLI JSON/text, exit codes, risk hotspots, Istanbul coverage, and real stdio MCP parity. -**Remaining:** +**Status:** Shipped in the `ci` wrapper. -- Feed health score into the future first-class `ci` wrapper and health badge output format. +**Integrated:** + +- `ci` computes health and gates on `--min-score`. +- Badge output exists for machine-readable PR/status surfaces. ### Architecture Boundaries @@ -509,17 +532,20 @@ Evaluate repo dependency boundaries using the existing graph. - Add `no-boundary-violations` to `check`, including `new-only` baseline behavior for PR gates. - Add CH-P2-05 boundary subchain coverage for CLI JSON/text, exit codes, custom config, preset override, real stdio MCP parity, and `check --gate new-only`. -**Remaining:** +**Status:** Shipped through `check` + `ci`. + +**Integrated:** -- Feed boundary findings into the future first-class `ci` wrapper and PR annotation formats. +- `ci` runs `check`, so configured `no-boundary-violations` participates in PR gates. +- PR annotation/markdown formats are available through `check` and `ci`. ### CI PR Quality Gate Make PR enforcement easy and local-first. -**Already exists:** `check`, config-level `ci`, `new-only` file gating, text/json/SARIF output. +**Status:** Shipped in the `2.5.0` completion canary PR. -**To do:** +**Commands:** ```bash codebase-intelligence ci . @@ -529,19 +555,24 @@ codebase-intelligence ci . --comment markdown --summary codebase-intelligence ci . --baseline .codebase-intelligence/baseline.json ``` -- Add first-class `ci` wrapper around `check`, `changes`, and selected analyzers behind one PR-friendly contract. +**Shipped:** + +- Add first-class `ci` wrapper around `check`, `changes`, health, baselines, local history, and changed workspace facts. - Default to new findings only on PRs. -- Support `--fail-on error|warn|score`, `--min-score `, `--max-new `, and `--baseline `. -- Keep SARIF output and add GitHub annotations, PR markdown, JSON, and compact terminal summaries. +- Support `--fail-on error|warn|never`, `--min-score `, `--max-new `, and `--baseline `. +- Keep SARIF output and add annotations, PR markdown, JSON, badge, CodeClimate, and compact terminal summaries. - Stable exit codes: `0 pass`, `1 gate failed`, `2 invalid config/runtime`, `3 analyzer error`. -- Vendor minimal GitHub/GitLab CI templates. -- Add `doctor --profile ci` checks for workflow wiring, token permissions, baseline path, and SARIF upload setup. +- Add `doctor --profile ci` checks for workflow wiring and setup visibility. + +**Deferred beyond stable `2.5.0`:** + +- Generated external CI template scaffolding. The repo already ships real CI/publish workflows, and `doctor` now reports workflow wiring. ### Doctor + Agent Onboarding Add a read-only setup auditor for humans, CI, MCP, and coding agents. -**To do:** +**Status:** Shipped in the `2.5.0` completion canary PR. ```bash codebase-intelligence doctor @@ -551,20 +582,18 @@ codebase-intelligence doctor --profile ci codebase-intelligence doctor --json ``` -- Check runtime, package manager, CLI version, project roots, config schema, graph build, cache writability, CLI help, MCP server, CI workflow, docs freshness, and agent instructions. +- Check runtime, package manager, project roots, config schema, graph build, cache path, CLI help, MCP registry, CI workflow, and agent instructions. - Emit JSON with `status`, `checks[]`, `fix`, and `docs`. -- Generate/update Codex, Claude Code, Cursor, MCP, and CI instructions without forking analyzer logic. +- Support local, CI, agent, and MCP profiles plus Codex, Claude, Cursor, and generic agent modes. - Keep doctor read-only: exact commands, no automatic mutation. ### Output Formats + Actionability Make findings portable across CI, review, and agents. -**Already exists:** text/json/SARIF for `check`. +**Status:** Shipped in the `2.5.0` completion canary PR. -**To do:** - -- Add missing formatters: CodeClimate, PR-comment, inline review envelopes, CI annotations, health badge, markdown, compact. +- Add missing formatters: CodeClimate, PR-comment, inline review envelopes, CI annotations, badge, markdown, compact. - Add line-level filtering via `--diff-file ` / `--changed-since `. - Add typed `actions[]` to every finding. - Keep `actions[]` advisory only. @@ -575,35 +604,43 @@ Make findings portable across CI, review, and agents. ### Cognitive Complexity +**Status:** Shipped in the `2.5.0` completion canary PR. + - Add cognitive complexity alongside cyclomatic complexity. - Expose it in `hotspots`, `file`, `health`, and CI outputs. ### Cohorting + Ownership -- Add `--group-by owner|package|directory`. -- Use CODEOWNERS + git blame for ownership and bus-factor signals. +**Status:** Shipped in the `2.5.0` completion canary PR. + +- Add `owners ` with `--group-by owner|package|directory`. +- Use CODEOWNERS + git history for ownership and bus-factor signals. - Add static coverage-gap detection. - Add refactor target filtering with `--effort`. ### LSP Diagnostics -- Ship an LSP server over the same operation registry. -- Surface diagnostics for dead code, complexity, boundaries, comments, and future Highways findings. -- Add hover facts: blast radius, fan-in/out, PageRank, dead/clone status. +**Status:** Shipped as an advisory foundation in the `2.5.0` completion canary PR. + +- Ship `lsp ` for stdio LSP mode and `lsp --diagnostics --json` for batch snapshots. +- Surface diagnostics for risk hotspots, dead exports, and circular dependencies. +- Add hover facts: blast radius, fan-in/out, PageRank, dead export count, and duplicate status. - Keep code actions navigational/advisory only. ### Architecture Intelligence Depth +**Status:** Shipped in the `2.5.0` completion canary PR. + - Add ranked extraction/consolidation recommendations. - Add effort estimates. -- Add layering inference. +- Add locality/tension recommendations from graph force analysis. - Add seam proposals tied to fan-in/fan-out evidence. --- ## P4 — 2.5.0 Ecosystem Hardening -**To do:** +**Status:** Shipped in the `2.5.0` completion canary PR. - Watch mode. - Monorepo workspace scoping. @@ -641,25 +678,23 @@ Competitor names stay in docs only. This section exists to preserve comparison c 1. Commit to CSS / utility-class unused analysis, or leave deferred? 2. Commit to template-aware dead code for Vue/Svelte/Angular, or leave deferred? 3. Expose a stable Node.js programmatic API, or stay CLI + MCP? -4. Ship deterministic semantic duplication in H2, or keep exact/renamed/near-miss only? -5. Keep `highways` naming, or use `convergence` / `consolidation`? -6. Should highway synthesis emit proposal metadata only, or also a code skeleton? -7. Generate `schema.json` from zod or hand-maintain it with a drift test? -8. Keep `codebase-intelligence.json`, or choose a shorter config name? -9. Start `map` with JSON only, or include DOT/GraphML/Obsidian-compatible export immediately? -10. Build a first-party local 2D/3D viewer, or export only? -11. Rank context packs by graph metrics first, or by task intent? -12. Which doctor profiles ship first: `local`, `ci`, `agent`, `mcp`? -13. Generate agent docs only, or publish first-party Codex/Claude Code skills/plugins from this repo? -14. Keep `.code-visualizer/` ignored forever, or remove it from generated `.gitignore` after one stable release? -15. Ship `ci` as first-class command, or `check --ci` wrapper? -16. Generate PR markdown only, or integrate with GitHub/GitLab APIs directly? -17. Store baselines in `.codebase-intelligence/baseline.json`, config path, or external artifact? -18. Use one global quality score first, or per-scope scores plus global rollup? +4. Generate `schema.json` from zod or hand-maintain it with a drift test? +5. Keep `codebase-intelligence.json`, or choose a shorter config name? +6. Rank context packs by graph metrics first, or by task intent? +7. Generate agent docs only, or publish first-party Codex/Claude Code skills/plugins from this repo? +8. Keep `.code-visualizer/` ignored forever, or remove it from generated `.gitignore` after one stable release? ## Resolved Decisions - 2026-07-01: Content drift ships as `drift` / `detect_content_drift`; first run is report-only, and CI gating waits for a baseline feature. +- 2026-07-01: Highways name stays; H2 emits proposal metadata plus a code skeleton. +- 2026-07-01: Deterministic semantic duplication is deferred beyond stable `2.5.0`; `2.5.0` ships strict/mild/weak clone families plus Highways shared-callee evidence. +- 2026-07-01: `map` ships JSON plus DOT/GraphML/markdown export; first-party 2D/3D viewer is deferred until structured graph output has canary usage. +- 2026-07-01: Doctor profiles ship as `local`, `ci`, `agent`, and `mcp`. +- 2026-07-01: CI ships as first-class `ci`, not `check --ci`. +- 2026-07-01: PR output ships as generated markdown/annotations, not direct hosting-platform API mutation. +- 2026-07-01: Baselines use an explicit configurable path, with `.codebase-intelligence/baseline.json` as the documented convention. +- 2026-07-01: Quality scoring ships as one global health score first; per-scope scores remain future work. --- @@ -671,5 +706,5 @@ Competitor names stay in docs only. This section exists to preserve comparison c - **Drift:** the tool flags real file/folder/content mismatch with evidence from names, imports, calls, types, side effects, tests, and churn. - **Doctor:** a fresh repo gets a complete read-only setup report with exact fix commands for config, graph build, MCP, CI, and agent instructions. - **Migration:** `.code-visualizer/` migrates to `.codebase-intelligence/` without data loss; new repos can add `.codebase-intelligence/` to `.gitignore`. -- **CI:** maintainers can add one generated workflow and fail PRs only on new severe findings, with SARIF/annotations/summary and stable exit codes. +- **CI:** maintainers can run one local/CI command and fail PRs only on new severe findings, with SARIF/annotations/summary and stable exit codes. - **Determinism:** identical inputs produce identical outputs; every finding traces to graph evidence. diff --git a/schema.json b/schema.json index 8633aee..2bf8185 100644 --- a/schema.json +++ b/schema.json @@ -152,6 +152,10 @@ "$ref": "#/$defs/severity", "description": "Forbid imports that cross a forbidden architecture boundary (see top-level 'boundaries')." }, + "no-secrets": { + "$ref": "#/$defs/severity", + "description": "Opt-in scan for likely hardcoded secrets. Secret text is redacted from evidence." + }, "no-divergent-paths": { "description": "Highways gate: forbid more than N divergent code paths for one logical data operation (low data-path reuse).", "oneOf": [ @@ -285,7 +289,7 @@ "additionalProperties": false, "properties": { "format": { - "enum": ["text", "json", "sarif", "markdown", "annotations", "pr-comment-github", "pr-comment-gitlab", "badge"], + "enum": ["text", "json", "sarif", "markdown", "annotations", "pr-comment-github", "pr-comment-gitlab", "badge", "codeclimate", "compact"], "default": "text" }, "quiet": { "type": "boolean", "default": false }, @@ -317,6 +321,22 @@ "default": -1, "description": "Fail if warnings exceed this count. -1 disables the warning gate." }, + "maxNew": { + "type": "integer", + "minimum": 0, + "description": "Fail first-class ci when new findings exceed this count." + }, + "minScore": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Minimum health score for first-class ci when score gating is enabled." + }, + "production": { + "type": "boolean", + "default": false, + "description": "Exclude test/dev files from production-risk findings." + }, "tolerance": { "type": "number", "minimum": 0, diff --git a/src/analyzer/index.ts b/src/analyzer/index.ts index 7b093b6..6eb22ba 100644 --- a/src/analyzer/index.ts +++ b/src/analyzer/index.ts @@ -84,6 +84,9 @@ export function analyzeGraph(built: BuiltGraph, parsedFiles?: ParsedFile[]): Cod const avgComplexity = parsed && parsed.exports.length > 0 ? parsed.exports.reduce((sum, e) => sum + e.complexity, 0) / parsed.exports.length : 1; + const avgCognitiveComplexity = parsed && parsed.exports.length > 0 + ? parsed.exports.reduce((sum, e) => sum + (e.cognitiveComplexity ?? e.complexity), 0) / parsed.exports.length + : 0; // Dead exports: exports not consumed by any edge const consumed = consumedSymbols.get(node.id) ?? new Set(); @@ -105,6 +108,7 @@ export function analyzeGraph(built: BuiltGraph, parsedFiles?: ParsedFile[]): Cod isBridge: btwn > 0.1, churn: parsed?.churn ?? 0, cyclomaticComplexity: Math.round(avgComplexity * 100) / 100, + cognitiveComplexity: Math.round(avgCognitiveComplexity * 100) / 100, blastRadius: 0, // computed after all nodes are processed deadExports, totalExports, diff --git a/src/ci/index.ts b/src/ci/index.ts new file mode 100644 index 0000000..b0a660e --- /dev/null +++ b/src/ci/index.ts @@ -0,0 +1,196 @@ +import fs from "fs"; +import path from "path"; +import type { CheckResult, CodebaseGraph, Finding, OutputFormat, Verdict } from "../types/index.js"; +import { computeChanges } from "../core/index.js"; +import { computeHealth, type HealthResult } from "../health/index.js"; +import { runCheck } from "../rules/check.js"; +import { formatCompact, formatMarkdown, formatResult, formatSummaryLine } from "../rules/format.js"; +import { computeWorkspaces, type WorkspacesResult } from "../workspaces/index.js"; + +export type CiFormat = OutputFormat; +export type CiExitCode = 0 | 1; + +export interface CiOptions { + configPath?: string; + base?: string; + newOnly?: boolean; + failOn?: "error" | "warn" | "never"; + minScore?: number; + maxNew?: number; + baseline?: string; + format?: CiFormat; + output?: string; + summary?: boolean; + production?: boolean; + changedSince?: string; + diffFile?: string; + changedWorkspaces?: boolean; +} + +export interface CiGate { + name: string; + verdict: Verdict; + summary: string; +} + +export interface CiResult { + verdict: "pass" | "fail"; + exitCode: CiExitCode; + base: string; + newOnly: boolean; + summary: string; + gates: CiGate[]; + check: CheckResult; + health: HealthResult; + changes: ReturnType; + workspaces?: WorkspacesResult; + baseline: { + path?: string; + ignoredFindings: number; + }; +} + +function summarize(findings: Finding[], source: CheckResult): CheckResult["summary"] { + const rules: Record = {}; + let error = 0; + let warn = 0; + for (const finding of findings) { + rules[finding.ruleId] = (rules[finding.ruleId] ?? 0) + 1; + if (finding.severity === "error") error += 1; + else warn += 1; + } + return { + error, + warn, + suppressed: source.summary.suppressed, + staleSuppressions: source.summary.staleSuppressions, + rules, + }; +} + +function verdictFor(summary: CheckResult["summary"], failOn: CiOptions["failOn"]): Verdict { + if (summary.error + summary.warn === 0) return "pass"; + if (failOn === "never") return "warn"; + if (failOn === "warn") return "fail"; + return summary.error > 0 ? "fail" : "warn"; +} + +function baselineFingerprints(rootDir: string, baselinePath: string | undefined): Set { + if (!baselinePath) return new Set(); + const resolved = path.resolve(rootDir, baselinePath); + if (!fs.existsSync(resolved)) return new Set(); + const parsed: unknown = JSON.parse(fs.readFileSync(resolved, "utf-8")); + if (Array.isArray(parsed)) return new Set(parsed.filter((item): item is string => typeof item === "string")); + if (parsed && typeof parsed === "object" && "findings" in parsed) { + const findings = (parsed as { findings?: unknown }).findings; + if (Array.isArray(findings)) { + return new Set( + findings + .map((item) => item && typeof item === "object" && "fingerprint" in item ? (item as { fingerprint?: unknown }).fingerprint : undefined) + .filter((item): item is string => typeof item === "string"), + ); + } + } + if (parsed && typeof parsed === "object" && "fingerprints" in parsed) { + const fingerprints = (parsed as { fingerprints?: unknown }).fingerprints; + if (Array.isArray(fingerprints)) return new Set(fingerprints.filter((item): item is string => typeof item === "string")); + } + return new Set(); +} + +function applyBaseline(result: CheckResult, rootDir: string, baselinePath: string | undefined, failOn: CiOptions["failOn"]): { result: CheckResult; ignored: number } { + const fingerprints = baselineFingerprints(rootDir, baselinePath); + if (fingerprints.size === 0) return { result, ignored: 0 }; + const findings = result.findings.filter((finding) => !fingerprints.has(finding.fingerprint)); + const ignored = result.findings.length - findings.length; + const summary = summarize(findings, result); + return { + result: { + ...result, + findings, + summary, + verdict: verdictFor(summary, failOn), + }, + ignored, + }; +} + +function gateVerdict(check: CheckResult, health: HealthResult, options: CiOptions): "pass" | "fail" { + if (check.verdict === "fail") return "fail"; + if (health.verdict === "fail") return "fail"; + if (options.maxNew !== undefined && check.findings.length > options.maxNew) return "fail"; + return "pass"; +} + +export function runCi(graph: CodebaseGraph, rootDir: string, options: CiOptions = {}): CiResult { + const base = options.base ?? "origin/main"; + const newOnly = options.newOnly !== false; + const rawCheck = runCheck(graph, rootDir, { + configPath: options.configPath, + failOn: options.failOn, + gate: newOnly ? "new-only" : "all", + base, + production: options.production, + changedSince: options.changedSince, + diffFile: options.diffFile, + }); + const baseline = applyBaseline(rawCheck, rootDir, options.baseline, options.failOn); + const minScore = options.minScore ?? 0; + const health = computeHealth(graph, { minScore }, { rootDir }); + const changes = computeChanges(graph, "all", rootDir); + const workspaces = options.changedWorkspaces ? computeWorkspaces(graph, rootDir, { base, changedOnly: true }) : undefined; + const verdict = gateVerdict(baseline.result, health, options); + const gates: CiGate[] = [ + { name: "check", verdict: baseline.result.verdict, summary: formatSummaryLine(baseline.result) }, + { name: "health", verdict: health.verdict, summary: health.summary }, + ]; + if (options.maxNew !== undefined) { + gates.push({ + name: "max-new", + verdict: baseline.result.findings.length <= options.maxNew ? "pass" : "fail", + summary: `${String(baseline.result.findings.length)} finding(s) <= ${String(options.maxNew)} allowed`, + }); + } + + return { + verdict, + exitCode: verdict === "pass" ? 0 : 1, + base, + newOnly, + summary: `CI ${verdict.toUpperCase()}: ${formatSummaryLine(baseline.result)}; health ${health.score.toFixed(2)}/${health.minScore}.${workspaces ? ` ${workspaces.summary}` : ""}`, + gates, + check: baseline.result, + health, + changes, + workspaces, + baseline: { path: options.baseline, ignoredFindings: baseline.ignored }, + }; +} + +export function formatCiResult(result: CiResult, format: CiFormat = "compact", summary = false): string { + if (format === "json") return JSON.stringify(result, null, 2); + if (format === "sarif") return formatResult(result.check, "sarif"); + if (format === "markdown" || format === "pr-comment-github" || format === "pr-comment-gitlab") { + return [ + "## Codebase Intelligence CI", + "", + `**Verdict:** ${result.verdict.toUpperCase()}`, + `**Base:** \`${result.base}\``, + `**Mode:** ${result.newOnly ? "new-only" : "all findings"}`, + result.workspaces ? `**Changed workspaces:** ${result.workspaces.summary}` : "", + "", + "| Gate | Verdict | Summary |", + "|---|---|---|", + ...result.gates.map((gate) => `| ${gate.name} | ${gate.verdict} | ${gate.summary.replaceAll("|", "\\|")} |`), + "", + formatMarkdown(result.check), + format === "pr-comment-github" ? "" : "", + format === "pr-comment-gitlab" ? "" : "", + ].filter(Boolean).join("\n"); + } + if (format === "annotations") return formatResult(result.check, "annotations"); + if (format === "badge") return formatResult(result.check, "badge"); + if (format === "codeclimate") return formatResult(result.check, "codeclimate"); + if (summary) return result.summary; + return format === "compact" ? `${result.summary}\n${formatCompact(result.check)}` : formatResult(result.check, "text"); +} diff --git a/src/cli.ts b/src/cli.ts index c845667..84bd898 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,14 @@ import { Command } from "commander"; const require = createRequire(import.meta.url); const pkg = require("../package.json") as { version: string }; import { startMcpServer } from "./mcp/index.js"; +import { runCi, formatCiResult, type CiFormat } from "./ci/index.js"; +import { runDoctor, formatDoctorText, type DoctorAgent, type DoctorProfile } from "./doctor/index.js"; +import { explainRule, formatRuleExplanationText } from "./explain/index.js"; +import { installHooks, formatHooksText } from "./hooks/index.js"; +import { readFindingHistory, recordFindingHistory, formatFindingHistoryText } from "./history/index.js"; +import { startLspServer } from "./lsp/index.js"; +import { migrateConfig, formatConfigMigrationText } from "./migration/index.js"; +import { computeWatchSnapshot, formatWatchSnapshotText, startWatch } from "./watch/index.js"; import { importGraph } from "./persistence/index.js"; import { cleanIndexDirectories, @@ -239,6 +247,68 @@ interface ClustersOptions extends CliCommandOptions { minFiles?: string; } +interface OwnersOptions extends CliCommandOptions { + groupBy?: string; + effort?: string; +} + +interface WorkspacesOptions extends CliCommandOptions { + base?: string; + changed?: boolean; +} + +interface CiOptions extends CliCommandOptions { + config?: string; + format?: string; + output?: string; + failOn?: string; + minScore?: string; + maxNew?: string; + base?: string; + all?: boolean; + baseline?: string; + comment?: string; + summary?: boolean; + production?: boolean; + changedSince?: string; + diffFile?: string; + history?: boolean; + changedWorkspaces?: boolean; +} + +interface DoctorOptions { + profile?: string; + agent?: string; + json?: boolean; +} + +interface LspOptions extends CliCommandOptions { + diagnostics?: boolean; +} + +interface WatchOptions extends CliCommandOptions { + once?: boolean; + debounce?: string; +} + +interface MigrateConfigOptions { + source?: string; + write?: boolean; + json?: boolean; +} + +interface HooksOptions { + dryRun?: boolean; + apply?: boolean; + uninstall?: boolean; + command?: string; + json?: boolean; +} + +interface HistoryOptions { + json?: boolean; +} + interface RenameOptions extends CliCommandOptions { dryRun?: boolean; } @@ -854,6 +924,334 @@ program outputOperationText(operations.clusters, result, input); }); +// ── Subcommand: owners ───────────────────────────────────── + +program + .command("owners") + .description("Group ownership, bus-factor, and risk by owner, package, or directory") + .argument("", "Path to TypeScript codebase") + .option("--group-by ", "owner, package, or directory (default: owner)") + .option("--effort ", "Minimum risk/effort score to include") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: OwnersOptions) => { + const input = parseCliOperationInput(operations.ownership, { + groupBy: options.groupBy, + effort: optionalNumberInput(options.effort), + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.ownership, graph, input, { rootDir: path.resolve(targetPath) }); + + if (options.json) { + outputJson(result); + return; + } + + outputOperationText(operations.ownership, result, input); + }); + +// ── Subcommand: architecture ──────────────────────────────── + +program + .command("architecture") + .description("Rank extraction, seam, tension, and locality recommendations") + .argument("", "Path to TypeScript codebase") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: CliCommandOptions) => { + const input = parseCliOperationInput(operations.architectureRecommendations, {}); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.architectureRecommendations, graph, input); + + if (options.json) { + outputJson(result); + return; + } + + outputOperationText(operations.architectureRecommendations, result, input); + }); + +// ── Subcommand: workspaces ────────────────────────────────── + +program + .command("workspaces") + .description("Detect package workspaces, changed scopes, and cross-package cycles") + .argument("", "Path to TypeScript codebase") + .option("--base ", "Base git ref for changed workspace detection (default: origin/main)") + .option("--changed", "Return only changed workspaces") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: WorkspacesOptions) => { + const input = parseCliOperationInput(operations.workspaces, { + base: options.base, + changedOnly: options.changed, + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.workspaces, graph, input, { rootDir: path.resolve(targetPath) }); + + if (options.json) { + outputJson(result); + return; + } + + outputOperationText(operations.workspaces, result, input); + }); + +// ── Subcommand: lsp ───────────────────────────────────────── + +program + .command("lsp") + .description("Start advisory LSP server or print a diagnostics snapshot") + .argument("", "Path to TypeScript codebase") + .option("--diagnostics", "Print diagnostics/hover snapshot and exit") + .option("--json", "Output snapshot as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: LspOptions) => { + const input = parseCliOperationInput(operations.lspSnapshot, {}); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.lspSnapshot, graph, input); + + if (options.diagnostics || options.json) { + if (options.json) outputJson(result); + else outputOperationText(operations.lspSnapshot, result, input); + return; + } + + startLspServer(graph); + }); + +// ── Subcommand: watch ─────────────────────────────────────── + +program + .command("watch") + .description("Keep analysis warm while editing; use --once for CI-safe snapshot") + .argument("", "Path to TypeScript codebase") + .option("--once", "Print a watch readiness snapshot and exit") + .option("--debounce ", "Debounce interval in milliseconds (default: 250)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: WatchOptions) => { + const { graph } = loadGraph(targetPath, forceOption(options)); + const snapshot = computeWatchSnapshot(graph, { debounceMs: optionalIntegerInput(options.debounce) as number | undefined }); + if (options.once || options.json) { + if (options.json) outputJson(snapshot); + else output(formatWatchSnapshotText(snapshot)); + return; + } + + output(formatWatchSnapshotText(snapshot)); + const stop = startWatch(path.resolve(targetPath), (file) => { + output(JSON.stringify({ event: "change", file })); + }, { + debounceMs: snapshot.debounceMs, + }); + process.on("SIGTERM", () => { + stop(); + process.exit(0); + }); + }); + +// ── Subcommand: migrate-config ────────────────────────────── + +program + .command("migrate-config") + .description("Generate codebase-intelligence config from supported local analyzer settings") + .argument("", "Repo root") + .option("--source ", "Source profile label (default: auto)") + .option("--write", "Write codebase-intelligence.json (default: dry-run)") + .option("--json", "Output as JSON") + .action((targetPath: string, options: MigrateConfigOptions) => { + const result = migrateConfig(targetPath, { source: options.source, write: options.write }); + if (options.json) outputJson(result); + else output(formatConfigMigrationText(result)); + }); + +// ── Subcommand: hooks ─────────────────────────────────────── + +program + .command("hooks") + .description("Plan or install local hooks that run the same CI gate") + .argument("", "install or uninstall") + .argument("[path]", "Repo root (default: current directory)", ".") + .option("--dry-run", "Plan only (default)") + .option("--apply", "Write hook changes") + .option("--command ", "Hook command") + .option("--json", "Output as JSON") + .action((action: string, targetPath: string, options: HooksOptions) => { + if (action !== "install" && action !== "uninstall") { + process.stderr.write("Error: hooks action must be install or uninstall\n"); + process.exit(2); + } + const result = installHooks(targetPath, { + dryRun: options.apply !== true, + uninstall: action === "uninstall", + command: options.command, + }); + if (options.json) outputJson(result); + else output(formatHooksText(result)); + }); + +// ── Subcommand: history ───────────────────────────────────── + +program + .command("history") + .description("Read local finding history stored under .codebase-intelligence/") + .argument("[path]", "Repo root (default: current directory)", ".") + .option("--json", "Output as JSON") + .action((targetPath: string, options: HistoryOptions) => { + const result = readFindingHistory(path.resolve(targetPath)); + if (options.json) outputJson(result); + else output(formatFindingHistoryText(result)); + }); + +// ── Subcommand: explain ───────────────────────────────────── + +program + .command("explain") + .description("Explain a codebase-intelligence rule and how to act on it") + .argument("", "Rule id") + .option("--json", "Output as JSON") + .action((ruleId: string, options: CliCommandOptions) => { + const result = explainRule(ruleId); + if (options.json) outputJson(result); + else output(formatRuleExplanationText(result)); + if (!result.found) process.exitCode = 2; + }); + +// ── Subcommand: doctor ────────────────────────────────────── + +const doctorProfileValues: Partial> = { + local: "local", + ci: "ci", + agent: "agent", + mcp: "mcp", +}; + +const doctorAgentValues: Partial> = { + codex: "codex", + claude: "claude", + cursor: "cursor", + generic: "generic", +}; + +function parseDoctorOption( + value: string | undefined, + values: Partial>, +): TValue | undefined { + return value === undefined ? undefined : values[value]; +} + +function parseDoctorProfile(value: string | undefined): DoctorProfile | undefined { + return parseDoctorOption(value, doctorProfileValues); +} + +function parseDoctorAgent(value: string | undefined): DoctorAgent | undefined { + return parseDoctorOption(value, doctorAgentValues); +} + +program + .command("doctor") + .description("Read-only setup auditor for local, CI, MCP, and coding-agent workflows") + .argument("[path]", "Repo root (default: current directory)", ".") + .option("--profile ", "local, ci, agent, or mcp") + .option("--agent ", "codex, claude, cursor, or generic") + .option("--json", "Output as JSON") + .action((targetPath: string, options: DoctorOptions) => { + const profile = parseDoctorProfile(options.profile); + const agent = parseDoctorAgent(options.agent); + if (options.profile && !profile) { + process.stderr.write("Error: --profile must be one of: local, ci, agent, mcp\n"); + process.exit(2); + } + if (options.agent && !agent) { + process.stderr.write("Error: --agent must be one of: codex, claude, cursor, generic\n"); + process.exit(2); + } + const result = runDoctor(targetPath, { profile, agent }); + if (options.json) outputJson(result); + else output(formatDoctorText(result)); + if (result.status === "fail") process.exitCode = 1; + }); + +// ── Subcommand: ci ────────────────────────────────────────── + +function resolveCiFormat(options: CiOptions): CiFormat | null { + const value = options.json ? "json" : options.comment === "markdown" ? "pr-comment-github" : options.format ?? "compact"; + const allowed: readonly CiFormat[] = ["text", "json", "sarif", "markdown", "annotations", "pr-comment-github", "pr-comment-gitlab", "badge", "codeclimate", "compact"]; + return allowed.includes(value as CiFormat) ? value as CiFormat : null; +} + +program + .command("ci") + .description("Run one PR-friendly quality gate around check, changes, health, and architecture signals") + .argument("", "Path to TypeScript codebase") + .option("--config ", "Config file path (overrides discovery)") + .option("--base ", "Base git ref for new-only gating (default: origin/main)") + .option("--new-only", "Gate only new findings (default)") + .option("--all", "Gate all findings") + .option("--fail-on ", "Severity that fails the gate: error, warn, never") + .option("--min-score ", "Minimum health score before exit 1") + .option("--max-new ", "Maximum new findings allowed") + .option("--baseline ", "Existing findings baseline JSON") + .option("--format ", "text, json, sarif, markdown, annotations, pr-comment-github, pr-comment-gitlab, badge, codeclimate, compact") + .option("--output ", "Write formatted output to file") + .option("--comment ", "PR comment mode: markdown") + .option("--summary", "Print compact summary") + .option("--production", "Exclude test/dev files from production risk") + .option("--changed-since ", "Line-level filter from git diff against ref") + .option("--diff-file ", "Line-level filter from a unified diff file") + .option("--history", "Record local finding history under .codebase-intelligence/") + .option("--changed-workspaces", "Include changed workspace summary in JSON output") + .option("--json", "Shortcut for --format json") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: CiOptions) => { + const format = resolveCiFormat(options); + if (!format) { + process.stderr.write("Error: unsupported --format\n"); + process.exit(2); + } + const failOn = parseFailOn(options.failOn); + if (failOn === false) { + process.stderr.write("Error: --fail-on must be one of: error, warn, never\n"); + process.exit(2); + } + + try { + const rootDir = path.resolve(targetPath); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCi(graph, rootDir, { + configPath: options.config, + base: options.base, + newOnly: options.all === true ? false : true, + failOn, + minScore: optionalNumberInput(options.minScore) as number | undefined, + maxNew: optionalIntegerInput(options.maxNew) as number | undefined, + baseline: options.baseline, + format, + output: options.output, + summary: options.summary, + production: options.production, + changedSince: options.changedSince, + diffFile: options.diffFile, + changedWorkspaces: options.changedWorkspaces, + }); + + if (options.history) recordFindingHistory(rootDir, result.check); + const rendered = formatCiResult(result, format, options.summary); + if (options.output) fs.writeFileSync(path.resolve(rootDir, options.output), `${rendered}\n`); + else output(rendered); + process.exitCode = result.exitCode; + } catch (err) { + if (err instanceof ConfigError) { + process.stderr.write(`Config error: ${err.message}\n`); + process.exit(2); + } + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`Analyzer error: ${message}\n`); + process.exit(3); + } + }); + // ── Subcommand: init ─────────────────────────────────────── program @@ -948,6 +1346,9 @@ interface CheckOptions extends CliCommandOptions { failOn?: string; gate?: string; base?: string; + changedSince?: string; + diffFile?: string; + production?: boolean; quiet?: boolean; summary?: boolean; } @@ -955,10 +1356,8 @@ interface CheckOptions extends CliCommandOptions { function resolveCheckFormat(options: CheckOptions): OutputFormat | null { if (options.json) return "json"; if (!options.format) return "text"; - if (options.format === "json" || options.format === "sarif" || options.format === "text") { - return options.format; - } - return null; + const formats: readonly OutputFormat[] = ["text", "json", "sarif", "markdown", "annotations", "pr-comment-github", "pr-comment-gitlab", "badge", "codeclimate", "compact"]; + return formats.includes(options.format as OutputFormat) ? options.format as OutputFormat : null; } function parseFailOn(value: string | undefined): "error" | "warn" | "never" | undefined | false { @@ -978,10 +1377,13 @@ program .description("Run the rules engine and gate on findings") .argument("", "Path to TypeScript codebase") .option("--config ", "Config file path (overrides discovery)") - .option("--format ", "Output: text, json, or sarif (default: text)") + .option("--format ", "Output: text, json, sarif, markdown, annotations, pr-comment-github, pr-comment-gitlab, badge, codeclimate, or compact") .option("--fail-on ", "Severity that fails the gate: error, warn, never") .option("--gate ", "Gate mode: all or new-only") .option("--base ", "Base git ref for new-only gating") + .option("--changed-since ", "Line-level filter from git diff against ref") + .option("--diff-file ", "Line-level filter from a unified diff file") + .option("--production", "Exclude test/dev files from production risk") .option("--quiet", "Suppress output when the result passes") .option("--summary", "Print summary counts only") .option("--json", "Shortcut for --format json") @@ -989,7 +1391,7 @@ program .action((targetPath: string, options: CheckOptions) => { const format = resolveCheckFormat(options); if (!format) { - process.stderr.write("Error: --format must be one of: text, json, sarif\n"); + process.stderr.write("Error: --format must be one of: text, json, sarif, markdown, annotations, pr-comment-github, pr-comment-gitlab, badge, codeclimate, compact\n"); process.exit(2); } @@ -1013,6 +1415,9 @@ program failOn, gate, base: options.base, + changedSince: options.changedSince, + diffFile: options.diffFile, + production: options.production, quiet: options.quiet, summary: options.summary, }); diff --git a/src/config/index.ts b/src/config/index.ts index 05ceaac..602abc3 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -22,6 +22,9 @@ export interface ConfigOverrides { failOn?: "error" | "warn" | "never"; gate?: "all" | "new-only"; base?: string; + diffFile?: string; + changedSince?: string; + production?: boolean; } const CONFIG_FILENAMES = [ @@ -96,7 +99,7 @@ const configSchema = z .optional(), output: z .object({ - format: z.enum(["text", "json", "sarif"]).optional(), + format: z.enum(["text", "json", "sarif", "markdown", "annotations", "pr-comment-github", "pr-comment-gitlab", "badge", "codeclimate", "compact"]).optional(), quiet: z.boolean().optional(), summary: z.boolean().optional(), }) @@ -108,8 +111,11 @@ const configSchema = z gate: z.enum(["all", "new-only"]).optional(), failOn: z.enum(["error", "warn", "never"]).optional(), maxWarnings: z.number().optional(), + maxNew: z.number().optional(), tolerance: z.number().optional(), base: z.string().optional(), + minScore: z.number().optional(), + production: z.boolean().optional(), }) .strict() .optional(), @@ -159,6 +165,7 @@ function applyOverrides( if (overrides.failOn !== undefined) ci.failOn = overrides.failOn; if (overrides.gate !== undefined) ci.gate = overrides.gate; if (overrides.base !== undefined) ci.base = overrides.base; + if (overrides.production !== undefined) ci.production = overrides.production; return { ...config, output, ci }; } diff --git a/src/core/index.ts b/src/core/index.ts index 52a2ba1..ed4dfa1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -163,6 +163,7 @@ export interface FileContextResult { isBridge: boolean; churn: number; cyclomaticComplexity: number; + cognitiveComplexity: number; blastRadius: number; deadExports: string[]; totalExports: number; @@ -222,6 +223,7 @@ export function computeFileContext( isBridge: metrics.isBridge, churn: metrics.churn, cyclomaticComplexity: metrics.cyclomaticComplexity, + cognitiveComplexity: metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity, blastRadius: metrics.blastRadius, deadExports: metrics.deadExports, totalExports: metrics.totalExports, @@ -256,6 +258,7 @@ export const HOTSPOT_METRICS = [ "escape_velocity", "churn", "complexity", + "cognitive_complexity", "blast_radius", "coverage", "risk", @@ -332,6 +335,10 @@ export function computeHotspots( score = metrics.cyclomaticComplexity; reason = `avg cyclomatic complexity: ${metrics.cyclomaticComplexity.toFixed(1)}`; break; + case "cognitive_complexity": + score = metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity; + reason = `avg cognitive complexity: ${score.toFixed(1)}`; + break; case "blast_radius": score = metrics.blastRadius; reason = `${metrics.blastRadius} transitive dependents affected if changed`; @@ -342,7 +349,7 @@ export function computeHotspots( break; case "risk": score = computeRiskScore({ loc, coverage: metrics.hasTests ? 1 : 0, metrics }); - reason = `complexity ${metrics.cyclomaticComplexity.toFixed(1)} x churn ${metrics.churn} x coupling ${metrics.coupling.toFixed(2)} x size ${loc}`; + reason = `complexity ${metrics.cyclomaticComplexity.toFixed(1)} / cognitive ${(metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity).toFixed(1)} x churn ${metrics.churn} x coupling ${metrics.coupling.toFixed(2)} x size ${loc}`; break; default: score = 0; diff --git a/src/doctor/index.ts b/src/doctor/index.ts new file mode 100644 index 0000000..8ecddd5 --- /dev/null +++ b/src/doctor/index.ts @@ -0,0 +1,222 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { loadConfig } from "../config/index.js"; +import { loadCodebaseGraph } from "../graph-loader/index.js"; +import { operationList } from "../operations/index.js"; +import { getCacheFactsForTarget } from "../persistence/index-dir.js"; + +export type DoctorLevel = "pass" | "warn" | "fail"; +export type DoctorProfile = "local" | "ci" | "agent" | "mcp"; +export type DoctorAgent = "codex" | "claude" | "cursor" | "generic"; + +export interface DoctorCheck { + id: string; + level: DoctorLevel; + title: string; + evidence: string[]; + fix?: string; + docs?: string; +} + +export interface DoctorResult { + status: DoctorLevel; + profile: DoctorProfile; + agent: DoctorAgent; + root: string; + checks: DoctorCheck[]; + summary: string; +} + +function check(id: string, level: DoctorLevel, title: string, evidence: string[], fix?: string, docs?: string): DoctorCheck { + return { id, level, title, evidence, fix, docs }; +} + +function commandOk(command: string, args: string[], cwd: string): { ok: boolean; output: string } { + try { + const output = execFileSync(command, args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 10000, + maxBuffer: 1024 * 1024, + }); + return { ok: true, output: output.trim() }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, output: message }; + } +} + +function hasAny(root: string, rels: string[]): boolean { + return rels.some((rel) => fs.existsSync(path.join(root, rel))); +} + +function statusFor(checks: readonly DoctorCheck[]): DoctorLevel { + if (checks.some((item) => item.level === "fail")) return "fail"; + if (checks.some((item) => item.level === "warn")) return "warn"; + return "pass"; +} + +function packageManager(root: string): string { + if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn"; + if (fs.existsSync(path.join(root, "bun.lockb"))) return "bun"; + if (fs.existsSync(path.join(root, "package-lock.json"))) return "npm"; + return "unknown"; +} + +export function runDoctor(rootDir: string, options: { profile?: DoctorProfile; agent?: DoctorAgent } = {}): DoctorResult { + const root = path.resolve(rootDir); + const profile = options.profile ?? "local"; + const agent = options.agent ?? "generic"; + const checks: DoctorCheck[] = []; + + checks.push( + check( + "runtime.node", + Number.parseInt(process.versions.node.split(".")[0], 10) >= 18 ? "pass" : "fail", + "Node.js runtime", + [`node=${process.versions.node}`], + "Install Node.js 18 or newer.", + "README.md#installation", + ), + ); + + const manager = packageManager(root); + checks.push( + check( + "package.manager", + manager === "unknown" ? "warn" : "pass", + "Package manager detected", + [`manager=${manager}`], + manager === "unknown" ? "Add a lockfile so CI and agents use the same package manager." : undefined, + "README.md#development", + ), + ); + + try { + const { configPath } = loadConfig(root); + checks.push(check("config.schema", "pass", "Config schema", [configPath ? `config=${configPath}` : "config=none"])); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + checks.push(check("config.schema", "fail", "Config schema", [message], "Fix codebase-intelligence config JSON/schema errors.", "schema.json")); + } + + const tsconfig = fs.existsSync(path.join(root, "tsconfig.json")); + checks.push( + check( + "project.typescript", + tsconfig ? "pass" : "warn", + "TypeScript project root", + [tsconfig ? "tsconfig.json found" : "tsconfig.json missing"], + tsconfig ? undefined : "Run from a TypeScript project root or add tsconfig.json.", + "docs/cli-reference.md", + ), + ); + + const cache = getCacheFactsForTarget(root, false); + checks.push( + check( + "cache.path", + cache.warnings.length === 0 ? "pass" : "warn", + "Cache directory", + [`cacheDir=${cache.cacheDir}`, `legacyCacheDir=${cache.legacyCacheDir}`, ...cache.warnings], + cache.warnings.length > 0 ? "Run codebase-intelligence init --gitignore ." : undefined, + "docs/data-model.md#cachefacts", + ), + ); + + try { + const graphResult = loadCodebaseGraph({ targetPath: root, persist: false, cliVersion: "doctor" }); + checks.push( + check( + "graph.build", + "pass", + "Graph build", + [`files=${String(graphResult.graph.stats.totalFiles)}`, `dependencies=${String(graphResult.graph.stats.totalDependencies)}`], + ), + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + checks.push(check("graph.build", "fail", "Graph build", [message], "Run codebase-intelligence overview . --force to inspect parser errors.", "docs/architecture.md")); + } + + const help = commandOk(process.execPath, [process.argv[1] ?? "", "--help"], root); + checks.push( + check( + "cli.help", + help.ok && help.output.includes("overview") ? "pass" : "warn", + "CLI help", + [help.ok ? "help rendered" : help.output], + "Run pnpm build before using the CLI from source.", + "docs/cli-reference.md", + ), + ); + + checks.push( + check( + "mcp.tools", + operationList.length >= 20 ? "pass" : "warn", + "MCP tool registry", + [`registeredOperations=${String(operationList.length)}`], + "Run codebase-intelligence doctor --profile mcp --json for setup details.", + "docs/mcp-tools.md", + ), + ); + + const hasCi = hasAny(root, [".github/workflows/ci.yml", ".github/workflows/ci.yaml", ".gitlab-ci.yml"]); + if (profile === "ci" || hasCi) { + checks.push( + check( + "ci.workflow", + hasCi ? "pass" : "warn", + "CI workflow", + [hasCi ? "workflow found" : "workflow missing"], + "Add a workflow step: codebase-intelligence ci . --base origin/main --new-only --format sarif --output codebase-intelligence.sarif", + "docs/cli-reference.md#ci", + ), + ); + } + + const agentFiles = { + codex: ["AGENTS.md"], + claude: ["CLAUDE.md", ".claude/CLAUDE.md"], + cursor: [".cursorrules"], + generic: ["AGENTS.md", "CLAUDE.md", ".cursorrules"], + }[agent]; + const hasAgentInstructions = hasAny(root, agentFiles); + if (profile === "agent" || agent !== "generic") { + checks.push( + check( + "agent.instructions", + hasAgentInstructions ? "pass" : "warn", + "Agent instructions", + [hasAgentInstructions ? "agent instructions found" : "agent instructions missing"], + agent === "generic" ? "Run codebase-intelligence init --all ." : `Run codebase-intelligence init --agents ${agent} .`, + "llms.txt", + ), + ); + } + + const status = statusFor(checks); + return { + status, + profile, + agent, + root, + checks, + summary: `Doctor ${status.toUpperCase()}: ${String(checks.filter((item) => item.level === "fail").length)} fail, ${String(checks.filter((item) => item.level === "warn").length)} warn, ${String(checks.filter((item) => item.level === "pass").length)} pass.`, + }; +} + +export function formatDoctorText(result: DoctorResult): string { + const lines = [`Codebase Intelligence Doctor — ${result.status.toUpperCase()}`, result.summary, ""]; + for (const item of result.checks) { + lines.push(`${item.level.toUpperCase().padEnd(4)} ${item.id} — ${item.title}`); + for (const evidence of item.evidence) lines.push(` evidence: ${evidence}`); + if (item.fix) lines.push(` fix: ${item.fix}`); + if (item.docs) lines.push(` docs: ${item.docs}`); + } + return lines.join("\n"); +} diff --git a/src/explain/index.ts b/src/explain/index.ts new file mode 100644 index 0000000..832a33b --- /dev/null +++ b/src/explain/index.ts @@ -0,0 +1,50 @@ +import { ALL_RULES } from "../rules/registry.js"; + +export interface RuleExplanation { + ruleId: string; + found: boolean; + description: string; + category?: string; + defaultSeverity?: string; + fixable?: boolean; + examples: string[]; + docs: string[]; +} + +export function explainRule(ruleId: string): RuleExplanation { + const rule = ALL_RULES.find((item) => item.id === ruleId); + if (!rule) { + return { + ruleId, + found: false, + description: "Unknown rule.", + examples: [`codebase-intelligence check . --json`], + docs: ["docs/cli-reference.md"], + }; + } + return { + ruleId, + found: true, + description: rule.meta.description, + category: rule.meta.category, + defaultSeverity: rule.defaultSeverity, + fixable: rule.meta.fixable, + examples: [ + `codebase-intelligence check . --json`, + `codebase-intelligence check . --fail-on warn --json`, + ], + docs: ["docs/cli-reference.md", "schema.json"], + }; +} + +export function formatRuleExplanationText(result: RuleExplanation): string { + if (!result.found) return `Unknown rule: ${result.ruleId}`; + return [ + result.ruleId, + `description=${result.description}`, + `category=${result.category ?? "unknown"}`, + `defaultSeverity=${result.defaultSeverity ?? "unknown"}`, + `fixable=${String(result.fixable)}`, + `example=${result.examples[0]}`, + ].join("\n"); +} diff --git a/src/graph/index.ts b/src/graph/index.ts index a615483..52bbc03 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -139,6 +139,7 @@ export function buildGraph(files: ParsedFile[]): BuiltGraph { loc: symbol.loc, isDefault: symbol.isDefault, complexity: symbol.complexity, + cognitiveComplexity: symbol.cognitiveComplexity, isExported: symbol.isExported, typeFacts: symbol.typeFacts, duplication: symbol.duplication, @@ -174,6 +175,7 @@ export function buildGraph(files: ParsedFile[]): BuiltGraph { loc: 0, isDefault: false, complexity: 0, + cognitiveComplexity: 0, isExported: false, }); symbolNodeIds.add(sourceId); @@ -189,6 +191,7 @@ export function buildGraph(files: ParsedFile[]): BuiltGraph { loc: 0, isDefault: false, complexity: 0, + cognitiveComplexity: 0, isExported: false, }); symbolNodeIds.add(targetId); diff --git a/src/health/index.ts b/src/health/index.ts index 9bf65b3..65b80b5 100644 --- a/src/health/index.ts +++ b/src/health/index.ts @@ -30,6 +30,7 @@ export interface HealthFileResult { coverageSource: CoverageSource; metrics: { complexity: number; + cognitiveComplexity: number; churn: number; coupling: number; blastRadius: number; @@ -48,6 +49,7 @@ export interface HealthResult { components: { maintainability: number; complexity: number; + cognitiveComplexity: number; churn: number; coupling: number; coverage: number; @@ -75,6 +77,7 @@ function nodeMap(graph: CodebaseGraph): Map { function fileEvidence(metrics: FileMetrics, coverage: number, loc: number): string[] { const evidence = [ `complexity=${metrics.cyclomaticComplexity.toFixed(1)}`, + `cognitiveComplexity=${(metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity).toFixed(1)}`, `churn=${metrics.churn}`, `coupling=${metrics.coupling.toFixed(2)}`, `loc=${loc}`, @@ -142,6 +145,7 @@ export function computeHealth( coverageSource: fileCoverage.source, metrics: { complexity: metrics.cyclomaticComplexity, + cognitiveComplexity: metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity, churn: metrics.churn, coupling: metrics.coupling, blastRadius: metrics.blastRadius, @@ -157,6 +161,7 @@ export function computeHealth( const components = { maintainability: averageHealth(sortedFiles.map((file) => file.maintainabilityIndex)), complexity: componentHealth(sortedFiles.map((file) => file.metrics.complexity), 20), + cognitiveComplexity: componentHealth(sortedFiles.map((file) => file.metrics.cognitiveComplexity), 25), churn: componentHealth(sortedFiles.map((file) => file.metrics.churn), 20), coupling: componentHealth(sortedFiles.map((file) => file.metrics.coupling), 20), coverage: averageHealth(sortedFiles.map((file) => file.coverage)), diff --git a/src/health/scoring.ts b/src/health/scoring.ts index 7aa2313..e190ec7 100644 --- a/src/health/scoring.ts +++ b/src/health/scoring.ts @@ -27,6 +27,7 @@ export function computeMaintainabilityIndex(input: HealthScoreInput): number { const { coverage, loc, metrics } = input; const penalty = normalize(metrics.cyclomaticComplexity, 20) * 35 + + normalize(metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity, 25) * 10 + normalize(loc, 500) * 20 + normalize(metrics.churn, 20) * 15 + normalize(metrics.coupling, 20) * 15 @@ -47,6 +48,7 @@ export function computeRiskScore(input: HealthScoreInput): number { const risk = (100 - maintainability) * 0.45 + normalize(metrics.cyclomaticComplexity, 20) * 20 + + normalize(metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity, 25) * 12 + normalize(metrics.churn, 20) * 12 + normalize(metrics.coupling, 20) * 10 + normalize(loc, 500) * 8 diff --git a/src/history/index.ts b/src/history/index.ts new file mode 100644 index 0000000..8b9efdf --- /dev/null +++ b/src/history/index.ts @@ -0,0 +1,67 @@ +import fs from "fs"; +import path from "path"; +import type { CheckResult } from "../types/index.js"; + +export interface FindingHistoryEntry { + timestamp: string; + verdict: CheckResult["verdict"]; + summary: CheckResult["summary"]; + fingerprints: string[]; +} + +export interface FindingHistoryResult { + path: string; + entries: FindingHistoryEntry[]; + trend: { + previous?: number; + current?: number; + delta?: number; + }; + summary: string; +} + +function historyPath(rootDir: string): string { + return path.join(rootDir, ".codebase-intelligence", "history.json"); +} + +function readEntries(file: string): FindingHistoryEntry[] { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8")); + return Array.isArray(parsed) ? parsed.filter((item): item is FindingHistoryEntry => item !== null && typeof item === "object" && "timestamp" in item) : []; + } catch { + return []; + } +} + +export function recordFindingHistory(rootDir: string, result: CheckResult): FindingHistoryResult { + const file = historyPath(rootDir); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const entries = readEntries(file); + entries.push({ + timestamp: new Date().toISOString(), + verdict: result.verdict, + summary: result.summary, + fingerprints: result.findings.map((finding) => finding.fingerprint).sort(), + }); + fs.writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`); + return readFindingHistory(rootDir); +} + +export function readFindingHistory(rootDir: string): FindingHistoryResult { + const file = historyPath(rootDir); + const entries = readEntries(file); + const previous = entries.at(-2)?.fingerprints.length; + const current = entries.at(-1)?.fingerprints.length; + const delta = previous !== undefined && current !== undefined ? current - previous : undefined; + return { + path: file, + entries, + trend: { previous, current, delta }, + summary: entries.length === 0 ? "No local finding history yet." : `${String(entries.length)} run(s), latest finding count ${String(current ?? 0)}.`, + }; +} + +export function formatFindingHistoryText(result: FindingHistoryResult): string { + const delta = result.trend.delta === undefined ? "n/a" : String(result.trend.delta); + return `${result.summary}\npath=${result.path}\ndelta=${delta}`; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..f2953a9 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; + +export interface HooksResult { + action: "planned" | "installed" | "removed"; + hookPath: string; + dryRun: boolean; + command: string; + summary: string; +} + +function hookScript(command: string): string { + return `#!/bin/sh\n${command}\n`; +} + +export function installHooks(rootDir: string, options: { dryRun?: boolean; uninstall?: boolean; command?: string } = {}): HooksResult { + const root = path.resolve(rootDir); + const hookPath = path.join(root, ".git", "hooks", "pre-commit"); + const command = options.command ?? "codebase-intelligence ci . --base origin/main --new-only --summary"; + const dryRun = options.dryRun !== false; + + if (options.uninstall === true) { + if (!dryRun && fs.existsSync(hookPath)) fs.rmSync(hookPath); + return { + action: dryRun ? "planned" : "removed", + hookPath, + dryRun, + command, + summary: dryRun ? `Would remove ${hookPath}` : `Removed ${hookPath}`, + }; + } + + if (!dryRun) { + fs.mkdirSync(path.dirname(hookPath), { recursive: true }); + fs.writeFileSync(hookPath, hookScript(command), { mode: 0o755 }); + } + + return { + action: dryRun ? "planned" : "installed", + hookPath, + dryRun, + command, + summary: dryRun ? `Would install pre-commit hook at ${hookPath}` : `Installed pre-commit hook at ${hookPath}`, + }; +} + +export function formatHooksText(result: HooksResult): string { + return `${result.summary}\ncommand=${result.command}`; +} diff --git a/src/lsp/index.ts b/src/lsp/index.ts new file mode 100644 index 0000000..58f69d1 --- /dev/null +++ b/src/lsp/index.ts @@ -0,0 +1,180 @@ +import { EventEmitter } from "events"; +import type { CodebaseGraph } from "../types/index.js"; + +export interface LspDiagnostic { + file: string; + range: { startLine: number; startColumn: number; endLine: number; endColumn: number }; + severity: "warning" | "information"; + code: string; + message: string; +} + +export interface LspHoverFact { + file: string; + symbol?: string; + markdown: string; +} + +export interface LspSnapshot { + diagnostics: LspDiagnostic[]; + hovers: LspHoverFact[]; + summary: string; +} + +interface JsonRpcMessage { + id?: number | string; + method?: string; + params?: unknown; +} + +function diagnosticForFile(file: string, complexity: number, blastRadius: number): LspDiagnostic | null { + if (complexity < 10 && blastRadius < 8) return null; + return { + file, + range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1 }, + severity: "warning", + code: "codebase-intelligence/risk", + message: `Risk hotspot: complexity ${complexity.toFixed(1)}, blast radius ${String(blastRadius)}`, + }; +} + +export function computeLspSnapshot(graph: CodebaseGraph): LspSnapshot { + const diagnostics: LspDiagnostic[] = []; + const hovers: LspHoverFact[] = []; + const circularFiles = new Set(graph.stats.circularDeps.flat()); + + for (const [file, metrics] of graph.fileMetrics) { + const diagnostic = diagnosticForFile(file, metrics.cognitiveComplexity ?? metrics.cyclomaticComplexity, metrics.blastRadius); + if (diagnostic) diagnostics.push(diagnostic); + if (metrics.deadExports.length > 0) { + diagnostics.push({ + file, + range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1 }, + severity: "information", + code: "codebase-intelligence/dead-export", + message: `Dead exports: ${metrics.deadExports.slice(0, 5).join(", ")}`, + }); + } + if (circularFiles.has(file)) { + diagnostics.push({ + file, + range: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1 }, + severity: "warning", + code: "codebase-intelligence/circular-dependency", + message: "File participates in a circular dependency.", + }); + } + hovers.push({ + file, + markdown: [ + `**${file}**`, + "", + `- Fan-in: ${String(metrics.fanIn)}`, + `- Fan-out: ${String(metrics.fanOut)}`, + `- PageRank: ${metrics.pageRank.toFixed(4)}`, + `- Blast radius: ${String(metrics.blastRadius)}`, + `- Dead exports: ${String(metrics.deadExports.length)}`, + ].join("\n"), + }); + } + + for (const symbol of graph.symbolNodes) { + hovers.push({ + file: symbol.file, + symbol: symbol.name, + markdown: [ + `**${symbol.name}**`, + "", + `- Type: ${symbol.type}`, + `- Cyclomatic: ${String(symbol.complexity)}`, + `- Cognitive: ${String(symbol.cognitiveComplexity ?? symbol.complexity)}`, + `- Duplicate: ${symbol.duplication ? "yes" : "no"}`, + ].join("\n"), + }); + } + + return { + diagnostics: diagnostics.sort((left, right) => left.file.localeCompare(right.file)), + hovers: hovers.sort((left, right) => left.file.localeCompare(right.file) || (left.symbol ?? "").localeCompare(right.symbol ?? "")), + summary: `${String(diagnostics.length)} diagnostic(s), ${String(hovers.length)} hover fact(s).`, + }; +} + +function frame(payload: unknown): string { + const body = JSON.stringify(payload); + return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; +} + +function response(id: JsonRpcMessage["id"], result: unknown): string { + return frame({ jsonrpc: "2.0", id, result }); +} + +function notification(method: string, params: unknown): string { + return frame({ jsonrpc: "2.0", method, params }); +} + +function parseMessages(buffer: string): { messages: JsonRpcMessage[]; rest: string } { + const messages: JsonRpcMessage[] = []; + let rest = buffer; + for (;;) { + const headerEnd = rest.indexOf("\r\n\r\n"); + if (headerEnd === -1) break; + const header = rest.slice(0, headerEnd); + const match = /Content-Length:\s*(\d+)/i.exec(header); + if (!match) { + rest = rest.slice(headerEnd + 4); + continue; + } + const length = Number.parseInt(match[1], 10); + const bodyStart = headerEnd + 4; + if (rest.length < bodyStart + length) break; + const body = rest.slice(bodyStart, bodyStart + length); + rest = rest.slice(bodyStart + length); + const parsed: unknown = JSON.parse(body); + if (parsed && typeof parsed === "object") messages.push(parsed as JsonRpcMessage); + } + return { messages, rest }; +} + +export function startLspServer(graph: CodebaseGraph): EventEmitter { + const emitter = new EventEmitter(); + const snapshot = computeLspSnapshot(graph); + let buffer = ""; + + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const parsed = parseMessages(buffer); + buffer = parsed.rest; + for (const message of parsed.messages) { + if (message.method === "initialize") { + process.stdout.write(response(message.id, { + capabilities: { + textDocumentSync: 1, + hoverProvider: true, + diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false }, + }, + serverInfo: { name: "codebase-intelligence-lsp" }, + })); + } else if (message.method === "textDocument/didOpen") { + process.stdout.write(notification("textDocument/publishDiagnostics", { diagnostics: snapshot.diagnostics })); + } else if (message.method === "textDocument/hover") { + process.stdout.write(response(message.id, { contents: snapshot.hovers[0]?.markdown ?? "No facts." })); + } else if (message.method === "shutdown") { + process.stdout.write(response(message.id, null)); + } else if (message.method === "exit") { + emitter.emit("exit"); + } + } + }); + + return emitter; +} + +export function formatLspSnapshotText(snapshot: LspSnapshot): string { + const lines = ["LSP diagnostics snapshot", snapshot.summary, ""]; + for (const diagnostic of snapshot.diagnostics.slice(0, 10)) { + lines.push(`${diagnostic.file}:${String(diagnostic.range.startLine)} ${diagnostic.code} — ${diagnostic.message}`); + } + return lines.join("\n"); +} diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index a35e09d..fc21bc3 100644 --- a/src/mcp/hints.ts +++ b/src/mcp/hints.ts @@ -112,6 +112,26 @@ const OPERATION_HINTS: Record = { "Use get_module_structure to compare clusters against directory structure", "Use analyze_forces to check if cluster boundaries reveal tension", ], + ownership: [ + "Use groupBy='owner' to find owner concentration", + "Use groupBy='package' before changed-workspace CI gating", + "Use file_context on owner hotspots before refactoring", + ], + architectureRecommendations: [ + "Use map on affected scopes to collect a context pack", + "Use dependents on affected files to validate blast radius", + "Treat recommendations as advisory; codebase-intelligence does not mutate source", + ], + lspSnapshot: [ + "Compare diagnostics with hotspots and health before adding editor gates", + "Use hover facts to inspect blast radius and dead exports in an editor", + "Keep code actions advisory only", + ], + workspaces: [ + "Use changedOnly=true with a base ref for PR-scoped monorepo gates", + "Use check --production inside changed workspaces for release risk", + "Use boundaries to catch cross-package direction violations", + ], }; export function getHintsForOperation(operationName: OperationName): string[] { diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 030ef6c..354fbac 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -184,6 +184,10 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { registerOperationTool(server, graph, operations.boundaries); registerOperationTool(server, graph, operations.highways); registerOperationTool(server, graph, operations.clusters); + registerOperationTool(server, graph, operations.ownership); + registerOperationTool(server, graph, operations.architectureRecommendations); + registerOperationTool(server, graph, operations.lspSnapshot); + registerOperationTool(server, graph, operations.workspaces); registerDerivedMapTool( server, graph, diff --git a/src/migration/index.ts b/src/migration/index.ts new file mode 100644 index 0000000..0d3a5bc --- /dev/null +++ b/src/migration/index.ts @@ -0,0 +1,64 @@ +import fs from "fs"; +import path from "path"; + +export interface ConfigMigrationResult { + source: string; + dryRun: boolean; + target: string; + generated: Record; + warnings: string[]; + changed: boolean; + summary: string; +} + +function readJson(file: string): unknown { + try { + return JSON.parse(fs.readFileSync(file, "utf-8")); + } catch { + return null; + } +} + +export function migrateConfig(rootDir: string, options: { source?: string; write?: boolean } = {}): ConfigMigrationResult { + const root = path.resolve(rootDir); + const source = options.source ?? "auto"; + const target = path.join(root, "codebase-intelligence.json"); + const warnings: string[] = []; + const generated: Record = { + $schema: "./schema.json", + rules: {}, + ci: { gate: "new-only", failOn: "error" }, + }; + + const packageJson = readJson(path.join(root, "package.json")); + if (packageJson && typeof packageJson === "object" && "codebaseIntelligence" in packageJson) { + warnings.push("package.json already contains codebaseIntelligence config; prefer dedicated codebase-intelligence.json for agent workflows."); + } + + const existing = readJson(target); + if (existing && typeof existing === "object") { + warnings.push("codebase-intelligence.json already exists; dry-run output leaves it unchanged."); + Object.assign(generated, existing); + } + + const dryRun = options.write !== true; + if (!dryRun) { + fs.writeFileSync(target, `${JSON.stringify(generated, null, 2)}\n`); + } + + return { + source, + dryRun, + target, + generated, + warnings, + changed: !dryRun, + summary: dryRun ? `Dry-run config migration to ${target}` : `Wrote ${target}`, + }; +} + +export function formatConfigMigrationText(result: ConfigMigrationResult): string { + const lines = [result.summary, `source=${result.source}`, `dryRun=${String(result.dryRun)}`]; + for (const warning of result.warnings) lines.push(`warning: ${warning}`); + return lines.join("\n"); +} diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index a9028d9..0290951 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -19,6 +19,10 @@ import type { SymbolContextError, SymbolContextResult, } from "../core/index.js"; +import { formatLspSnapshotText, type LspSnapshot } from "../lsp/index.js"; +import { formatOwnershipText, type OwnershipResult } from "../ownership/index.js"; +import { formatArchitectureRecommendationsText, type ArchitectureRecommendationsResult } from "../recommendations/index.js"; +import { formatWorkspacesText, type WorkspacesResult } from "../workspaces/index.js"; import type { HighwaysResult } from "../highways/index.js"; import type { HealthOptions, HealthResult } from "../health/index.js"; import type { ImpactResult, RenameResult } from "../impact/index.js"; @@ -872,3 +876,17 @@ export function formatClustersText(result: ClustersResult): string { return text(lines); } + +export { + formatArchitectureRecommendationsText, + formatLspSnapshotText, + formatOwnershipText, + formatWorkspacesText, +}; + +export type { + ArchitectureRecommendationsResult, + LspSnapshot, + OwnershipResult, + WorkspacesResult, +}; diff --git a/src/operations/index.ts b/src/operations/index.ts index 0812d8a..4678cd5 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -25,10 +25,15 @@ import { loadConfig } from "../config/index.js"; import { DUPLICATION_MODES } from "../duplication/index.js"; import { computeHighways } from "../highways/index.js"; import { computeHealth } from "../health/index.js"; +import { computeLspSnapshot } from "../lsp/index.js"; import { CODEBASE_MAP_FORMATS, computeCodebaseMap } from "../map/index.js"; import { computeContentDrift } from "../drift/index.js"; +import { computeOwnership } from "../ownership/index.js"; +import { computeArchitectureRecommendations } from "../recommendations/index.js"; +import { computeWorkspaces } from "../workspaces/index.js"; import type { BoundaryPreset, BoundariesConfig, CodebaseGraph } from "../types/index.js"; import { + formatArchitectureRecommendationsText, formatBoundariesText, formatCodebaseMapText, formatChangesText, @@ -43,14 +48,17 @@ import { formatHealthText, formatHighwaysText, formatHotspotsText, + formatLspSnapshotText, formatImpactText, formatModuleStructureText, formatOpportunitiesText, + formatOwnershipText, formatOverviewText, formatProcessesText, formatRenameText, formatSearchText, formatSymbolContextText, + formatWorkspacesText, } from "./formatters.js"; export const operationNames = [ @@ -76,6 +84,10 @@ export const operationNames = [ "boundaries", "highways", "clusters", + "ownership", + "architectureRecommendations", + "lspSnapshot", + "workspaces", ] as const; export type OperationName = typeof operationNames[number]; @@ -211,6 +223,16 @@ const clustersInputShape = { minFiles: z.number().int().positive().optional().describe("Filter clusters with at least N files (default: 0)"), } satisfies z.ZodRawShape; const clustersInputSchema = z.object(clustersInputShape).strict(); +const ownershipInputShape = { + groupBy: z.enum(["owner", "package", "directory"]).optional().describe("Group by owner, package, or directory"), + effort: z.number().optional().describe("Minimum risk/effort score to include"), +} satisfies z.ZodRawShape; +const ownershipInputSchema = z.object(ownershipInputShape).strict(); +const workspacesInputShape = { + base: z.string().min(1).optional().describe("Base git ref for changed workspace detection"), + changedOnly: z.boolean().optional().describe("Return only workspaces changed since base"), +} satisfies z.ZodRawShape; +const workspacesInputSchema = z.object(workspacesInputShape).strict(); type OverviewInput = z.infer; type FileContextInput = z.infer; @@ -234,6 +256,8 @@ type HealthInput = z.infer; type BoundariesInput = z.infer; type HighwaysInput = z.infer; type ClustersInput = z.infer; +type OwnershipInput = z.infer; +type WorkspacesInput = z.infer; function loadBoundariesConfig(context: OperationContext): BoundariesConfig | undefined { if (!context.rootDir && !context.configPath) return undefined; @@ -465,6 +489,46 @@ export const operations = { run: (graph: CodebaseGraph, input: ClustersInput) => computeClusters(graph, input.minFiles), formatText: formatClustersText, } satisfies Operation>, + ownership: { + name: "ownership", + cliCommand: "owners", + mcpTool: "get_ownership", + description: "Group files by CODEOWNERS/git ownership, package, or directory with bus-factor and risk signals. Use when: finding owner concentration, orphaned critical paths, or handoff risk.", + inputShape: ownershipInputShape, + inputSchema: ownershipInputSchema, + run: (graph: CodebaseGraph, input: OwnershipInput, context) => computeOwnership(graph, context.rootDir ?? process.cwd(), input), + formatText: formatOwnershipText, + } satisfies Operation>, + architectureRecommendations: { + name: "architectureRecommendations", + cliCommand: "architecture", + mcpTool: "get_architecture_recommendations", + description: "Rank extraction, seam, tension, and locality recommendations with effort, evidence, affected files, and context commands. Use when: planning architecture cleanup from graph evidence.", + inputShape: emptyInputShape, + inputSchema: emptyInputSchema, + run: (graph: CodebaseGraph, _input: EmptyInput) => computeArchitectureRecommendations(graph), + formatText: formatArchitectureRecommendationsText, + } satisfies Operation>, + lspSnapshot: { + name: "lspSnapshot", + cliCommand: "lsp", + mcpTool: "get_lsp_snapshot", + description: "Return advisory editor diagnostics and hover facts derived from the same graph as batch analysis. Use when: comparing editor diagnostics with CLI output.", + inputShape: emptyInputShape, + inputSchema: emptyInputSchema, + run: (graph: CodebaseGraph, _input: EmptyInput) => computeLspSnapshot(graph), + formatText: formatLspSnapshotText, + } satisfies Operation>, + workspaces: { + name: "workspaces", + cliCommand: "workspaces", + mcpTool: "get_workspaces", + description: "Detect package/workspace scopes, changed workspaces, and cross-package circular dependency evidence. Use when: scoping CI to changed packages in a monorepo.", + inputShape: workspacesInputShape, + inputSchema: workspacesInputSchema, + run: (graph: CodebaseGraph, input: WorkspacesInput, context) => computeWorkspaces(graph, context.rootDir ?? process.cwd(), input), + formatText: formatWorkspacesText, + } satisfies Operation>, } as const; export type RegisteredOperation = typeof operations[OperationName]; diff --git a/src/ownership/index.ts b/src/ownership/index.ts new file mode 100644 index 0000000..93beaa9 --- /dev/null +++ b/src/ownership/index.ts @@ -0,0 +1,196 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import type { CodebaseGraph } from "../types/index.js"; + +export type OwnershipGroupBy = "owner" | "package" | "directory"; + +export interface OwnershipOptions { + groupBy?: OwnershipGroupBy; + effort?: number; +} + +export interface OwnershipFile { + file: string; + owner: string; + package: string; + directory: string; + churn: number; + riskScore: number; + busFactor: number; + hasTests: boolean; + coverageGap: boolean; + evidence: string[]; +} + +export interface OwnershipGroup { + key: string; + files: number; + owners: string[]; + busFactor: number; + riskScore: number; + churn: number; + coverageGaps: number; + evidence: string[]; +} + +export interface OwnershipResult { + groupBy: OwnershipGroupBy; + summary: string; + files: OwnershipFile[]; + groups: OwnershipGroup[]; + hotspots: OwnershipGroup[]; +} + +function normalizePath(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +function packageNameFor(rootDir: string, file: string): string { + let dir = path.dirname(path.resolve(rootDir, file)); + const root = path.resolve(rootDir); + while (dir.startsWith(root)) { + const pkgPath = path.join(dir, "package.json"); + if (fs.existsSync(pkgPath)) { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + if (parsed && typeof parsed === "object" && "name" in parsed && typeof (parsed as { name?: unknown }).name === "string") { + return (parsed as { name: string }).name; + } + } catch { + return normalizePath(path.relative(root, dir)) || "."; + } + return normalizePath(path.relative(root, dir)) || "."; + } + if (dir === root) break; + dir = path.dirname(dir); + } + return "."; +} + +function parseCodeowners(rootDir: string): Array<{ pattern: string; owners: string[] }> { + const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]; + const file = candidates.map((candidate) => path.join(rootDir, candidate)).find((candidate) => fs.existsSync(candidate)); + if (!file) return []; + return fs.readFileSync(file, "utf-8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => { + const [pattern, ...owners] = line.split(/\s+/); + return { pattern: normalizePath(pattern.replace(/^\//, "")), owners }; + }) + .filter((entry) => entry.owners.length > 0); +} + +function codeownerFor(file: string, entries: Array<{ pattern: string; owners: string[] }>): string { + let owner = "unowned"; + for (const entry of entries) { + const pattern = entry.pattern.replace(/\*.*$/, ""); + if (entry.pattern === "*" || file.startsWith(pattern) || file === pattern) { + owner = entry.owners.join(","); + } + } + return owner; +} + +function blameAuthors(rootDir: string, file: string): Set { + try { + const output = execFileSync("git", ["log", "--format=%an", "--", file], { + cwd: rootDir, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 10000, + }); + return new Set(output.split("\n").map((line) => line.trim()).filter(Boolean)); + } catch { + return new Set(); + } +} + +function groupKey(file: OwnershipFile, groupBy: OwnershipGroupBy): string { + if (groupBy === "owner") return file.owner; + if (groupBy === "package") return file.package; + return file.directory; +} + +export function computeOwnership(graph: CodebaseGraph, rootDir: string, options: OwnershipOptions = {}): OwnershipResult { + const groupBy = options.groupBy ?? "owner"; + const minEffort = options.effort ?? 0; + const owners = parseCodeowners(rootDir); + const files: OwnershipFile[] = []; + + for (const node of graph.nodes.filter((item) => item.type === "file")) { + const metrics = graph.fileMetrics.get(node.id); + if (!metrics || metrics.isTestFile) continue; + const authors = blameAuthors(rootDir, node.id); + const owner = codeownerFor(node.id, owners); + const pkg = packageNameFor(rootDir, node.id); + const directory = normalizePath(path.dirname(node.id)); + const busFactor = Math.max(authors.size, owner === "unowned" ? 0 : owner.split(",").length); + const coverageGap = !metrics.hasTests && (metrics.fanIn > 0 || metrics.blastRadius > 0 || metrics.cyclomaticComplexity >= 5); + const riskScore = metrics.cyclomaticComplexity + metrics.churn + metrics.blastRadius + metrics.fanIn + (coverageGap ? 5 : 0); + files.push({ + file: node.id, + owner, + package: pkg, + directory, + churn: metrics.churn, + riskScore, + busFactor, + hasTests: metrics.hasTests, + coverageGap, + evidence: [ + `owner=${owner}`, + `authors=${String(authors.size)}`, + `package=${pkg}`, + `risk=${riskScore.toFixed(2)}`, + `coverageGap=${String(coverageGap)}`, + ], + }); + } + + const grouped = new Map(); + for (const file of files.filter((item) => item.riskScore >= minEffort)) { + const key = groupKey(file, groupBy); + const list = grouped.get(key) ?? []; + list.push(file); + grouped.set(key, list); + } + + const groups: OwnershipGroup[] = [...grouped.entries()] + .map(([key, groupFiles]) => { + const ownersForGroup = [...new Set(groupFiles.map((file) => file.owner))].sort(); + const riskScore = groupFiles.reduce((sum, file) => sum + file.riskScore, 0); + const churn = groupFiles.reduce((sum, file) => sum + file.churn, 0); + const busFactor = Math.max(...groupFiles.map((file) => file.busFactor), 0); + const coverageGaps = groupFiles.filter((file) => file.coverageGap).length; + return { + key, + files: groupFiles.length, + owners: ownersForGroup, + busFactor, + riskScore: Math.round(riskScore * 100) / 100, + churn, + coverageGaps, + evidence: [`files=${String(groupFiles.length)}`, `owners=${ownersForGroup.join(",")}`, `busFactor=${String(busFactor)}`, `coverageGaps=${String(coverageGaps)}`], + }; + }) + .sort((left, right) => right.riskScore - left.riskScore || left.key.localeCompare(right.key)); + + return { + groupBy, + summary: `${groups.length} ${groupBy} group(s); top risk ${groups[0]?.key ?? "none"}.`, + files: files.sort((left, right) => right.riskScore - left.riskScore || left.file.localeCompare(right.file)), + groups, + hotspots: groups.slice(0, 10), + }; +} + +export function formatOwnershipText(result: OwnershipResult): string { + const lines = [`Ownership grouped by ${result.groupBy}`, result.summary, ""]; + for (const group of result.hotspots) { + lines.push(`${group.key}: ${String(group.files)} file(s), risk ${group.riskScore.toFixed(2)}, bus factor ${String(group.busFactor)}, coverage gaps ${String(group.coverageGaps)}`); + } + return lines.join("\n"); +} diff --git a/src/parser/index.ts b/src/parser/index.ts index 7bc9022..309c3de 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -15,6 +15,7 @@ import { nodeLocation, } from "./type-facts.js"; import { + computeCognitiveComplexity, computeComplexity, extractSymbols, extractSymbolsSyntax, @@ -357,6 +358,7 @@ function extractExports(sourceFile: ts.SourceFile, checker: ts.TypeChecker): Par loc: isLocal ? loc : 1, isDefault: exportName === "default", complexity: isLocal ? computeComplexity(decl) : 0, + cognitiveComplexity: isLocal ? computeCognitiveComplexity(decl) : 0, typeFacts: declarationTypeFactsFromChecker(exportName, decl, checker), }); } @@ -383,6 +385,7 @@ function extractExportsSyntax(sourceFile: ts.SourceFile): ParsedExport[] { loc, isDefault, complexity: computeComplexity(node), + cognitiveComplexity: computeCognitiveComplexity(node), typeFacts: canHaveDeclarationTypeFacts(node) ? declarationTypeFactsFromSyntax(name, node) : undefined, }); } diff --git a/src/parser/symbols.ts b/src/parser/symbols.ts index 4881f98..9890d20 100644 --- a/src/parser/symbols.ts +++ b/src/parser/symbols.ts @@ -26,6 +26,7 @@ function parsedSymbol( loc, isDefault, complexity: computeComplexity(node), + cognitiveComplexity: computeCognitiveComplexity(node), isExported, typeFacts, duplication: extractDuplicationFacts(node, sourceFile), @@ -135,6 +136,46 @@ export function computeComplexity(node: ts.Node): number { return branches; } +export function computeCognitiveComplexity(node: ts.Node): number { + let score = 0; + + function isFlowBreak(n: ts.Node): boolean { + return ts.isIfStatement(n) + || ts.isConditionalExpression(n) + || ts.isCaseClause(n) + || ts.isCatchClause(n) + || ts.isForStatement(n) + || ts.isForInStatement(n) + || ts.isForOfStatement(n) + || ts.isWhileStatement(n) + || ts.isDoStatement(n); + } + + function visit(n: ts.Node, nesting: number): void { + const nested = isFlowBreak(n); + if (nested) score += 1 + nesting; + + if (ts.isBinaryExpression(n)) { + if ( + n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + || n.operatorToken.kind === ts.SyntaxKind.BarBarToken + || n.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + score += 1; + } + } + + ts.forEachChild(n, (child) => { + visit(child, nested ? nesting + 1 : nesting); + }); + } + + ts.forEachChild(node, (child) => { + visit(child, 0); + }); + return score; +} + function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false); } diff --git a/src/recommendations/index.ts b/src/recommendations/index.ts new file mode 100644 index 0000000..f37acd3 --- /dev/null +++ b/src/recommendations/index.ts @@ -0,0 +1,129 @@ +import type { CodebaseGraph } from "../types/index.js"; + +export type ArchitectureRecommendationKind = "extract-module" | "reduce-tension" | "add-seam" | "improve-locality"; + +export interface ArchitectureRecommendation { + id: string; + kind: ArchitectureRecommendationKind; + title: string; + effort: "small" | "medium" | "large"; + score: number; + affectedFiles: string[]; + evidence: string[]; + contextPack: { + files: string[]; + symbols: string[]; + commands: string[]; + }; +} + +export interface ArchitectureRecommendationsResult { + recommendations: ArchitectureRecommendation[]; + summary: string; +} + +function effortFor(score: number): ArchitectureRecommendation["effort"] { + if (score >= 70) return "large"; + if (score >= 35) return "medium"; + return "small"; +} + +function stableId(kind: ArchitectureRecommendationKind, target: string): string { + const clean = target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase(); + return `arch:${kind}:${clean || "root"}`; +} + +export function computeArchitectureRecommendations(graph: CodebaseGraph): ArchitectureRecommendationsResult { + const recommendations: ArchitectureRecommendation[] = []; + + for (const candidate of graph.forceAnalysis.extractionCandidates) { + const score = candidate.escapeVelocity * 80 + candidate.dependedByModules * 5; + recommendations.push({ + id: stableId("extract-module", candidate.target), + kind: "extract-module", + title: `Extract ${candidate.target} behind a package boundary`, + effort: effortFor(score), + score, + affectedFiles: graph.nodes.filter((node) => node.type === "file" && node.module === candidate.target).map((node) => node.id), + evidence: [ + `escapeVelocity=${candidate.escapeVelocity.toFixed(2)}`, + `dependedByModules=${String(candidate.dependedByModules)}`, + `externalDeps=${String(candidate.externalDeps)}`, + ], + contextPack: { + files: graph.nodes.filter((node) => node.type === "file" && node.module === candidate.target).map((node) => node.id).slice(0, 8), + symbols: graph.symbolNodes.filter((symbol) => symbol.file.startsWith(candidate.target)).map((symbol) => symbol.name).slice(0, 12), + commands: [`codebase-intelligence forces .`, `codebase-intelligence map . --scope ${candidate.target} --json`], + }, + }); + } + + for (const tension of graph.forceAnalysis.tensionFiles.slice(0, 20)) { + const score = tension.tension * 60 + tension.pulledBy.length * 5; + recommendations.push({ + id: stableId("reduce-tension", tension.file), + kind: "reduce-tension", + title: `Reduce mixed ownership in ${tension.file}`, + effort: effortFor(score), + score, + affectedFiles: [tension.file], + evidence: [`tension=${tension.tension.toFixed(2)}`, `pulledBy=${tension.pulledBy.map((pull) => pull.module).join(",")}`], + contextPack: { + files: [tension.file], + symbols: tension.pulledBy.flatMap((pull) => pull.symbols).slice(0, 12), + commands: [`codebase-intelligence file . ${tension.file}`, "codebase-intelligence forces . --json"], + }, + }); + } + + for (const seam of graph.forceAnalysis.seamCandidates.slice(0, 20)) { + const score = seam.fanIn * 15 + seam.exposedSymbols; + recommendations.push({ + id: stableId("add-seam", seam.target), + kind: "add-seam", + title: `Formalize ${seam.target} as an explicit interface seam`, + effort: effortFor(score), + score, + affectedFiles: graph.nodes.filter((node) => node.type === "file" && node.module === seam.target).map((node) => node.id), + evidence: [seam.evidence, `fanIn=${String(seam.fanIn)}`], + contextPack: { + files: graph.nodes.filter((node) => node.type === "file" && node.module === seam.target).map((node) => node.id).slice(0, 8), + symbols: graph.symbolNodes.filter((symbol) => symbol.file.startsWith(seam.target)).map((symbol) => symbol.name).slice(0, 12), + commands: [`codebase-intelligence map . --scope ${seam.target} --context-budget 1200 --json`], + }, + }); + } + + for (const risk of graph.forceAnalysis.localityRisks.slice(0, 20)) { + const score = risk.tension * 40 + risk.blastRadius + (risk.isBridge ? 10 : 0); + recommendations.push({ + id: stableId("improve-locality", risk.file), + kind: "improve-locality", + title: `Improve locality around ${risk.file}`, + effort: effortFor(score), + score, + affectedFiles: [risk.file], + evidence: [risk.evidence, `blastRadius=${String(risk.blastRadius)}`, `bridge=${String(risk.isBridge)}`], + contextPack: { + files: [risk.file], + symbols: graph.symbolNodes.filter((symbol) => symbol.file === risk.file).map((symbol) => symbol.name).slice(0, 12), + commands: [`codebase-intelligence dependents . ${risk.file} --json`], + }, + }); + } + + const sorted = recommendations.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); + return { + recommendations: sorted, + summary: `${String(sorted.length)} architecture recommendation(s); top ${sorted[0]?.id ?? "none"}.`, + }; +} + +export function formatArchitectureRecommendationsText(result: ArchitectureRecommendationsResult): string { + const lines = ["Architecture Recommendations", result.summary, ""]; + for (const item of result.recommendations.slice(0, 10)) { + lines.push(`${item.id} ${item.effort} score=${item.score.toFixed(2)} — ${item.title}`); + lines.push(` files: ${item.affectedFiles.slice(0, 5).join(", ")}`); + } + return lines.join("\n"); +} diff --git a/src/rules/check.ts b/src/rules/check.ts index 7302c2a..971c175 100644 --- a/src/rules/check.ts +++ b/src/rules/check.ts @@ -8,6 +8,7 @@ import type { CodebaseGraph, CodebaseIntelligenceConfig, Finding, + FindingAction, Verdict, } from "../types/index.js"; import { loadConfig, type ConfigOverrides } from "../config/index.js"; @@ -66,11 +67,118 @@ function changedFilesSince(rootDir: string, baseRef: string): Set | null } } +interface ChangedRange { + start: number; + end: number; +} + +type ChangedRanges = Map; + +function emptyRanges(): ChangedRanges { + return new Map(); +} + +function addRange(ranges: ChangedRanges, file: string, start: number, count: number): void { + if (count <= 0) return; + const normalized = toPosix(file); + const list = ranges.get(normalized) ?? []; + const end = start + count - 1; + list.push({ start, end }); + ranges.set(normalized, list); +} + +function parseUnifiedDiff(diff: string): ChangedRanges { + const ranges = emptyRanges(); + let currentFile: string | null = null; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ b/")) { + currentFile = line.slice("+++ b/".length).trim(); + continue; + } + if (line.startsWith("+++ ")) { + const candidate = line.slice("+++ ".length).trim(); + currentFile = candidate === "/dev/null" ? null : candidate.replace(/^b\//, ""); + continue; + } + if (!currentFile || !line.startsWith("@@")) continue; + const match = /\+(\d+)(?:,(\d+))?/.exec(line); + if (!match) continue; + const parsedCount = Number.parseInt(match[2], 10); + const count = Number.isNaN(parsedCount) ? 1 : parsedCount; + addRange(ranges, currentFile, Number.parseInt(match[1], 10), count); + } + return ranges; +} + +function changedRangesSince(rootDir: string, baseRef: string): ChangedRanges | null { + try { + const out = execFileSync("git", ["diff", "--unified=0", "--relative", `${baseRef}...HEAD`], { + cwd: rootDir, + encoding: "utf-8", + timeout: 10000, + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + return parseUnifiedDiff(out); + } catch { + return null; + } +} + +function changedRangesFromDiffFile(rootDir: string, diffFile: string): ChangedRanges | null { + try { + const resolved = path.resolve(rootDir, diffFile); + return parseUnifiedDiff(fs.readFileSync(resolved, "utf-8")); + } catch { + return null; + } +} + /** Normalize a finding path to forward slashes for comparison with git output. */ function toPosix(p: string): string { return p.split(path.sep).join("/"); } +function isTestOrDevPath(file: string): boolean { + const normalized = toPosix(file); + return normalized.includes("__tests__/") + || normalized.includes("/tests/") + || normalized.startsWith("tests/") + || /\.(?:test|spec|stories)\.[cm]?[tj]sx?$/.test(normalized) + || normalized.endsWith(".d.ts"); +} + +function findingInRanges(finding: Finding, ranges: ChangedRanges): boolean { + const list = ranges.get(toPosix(finding.file)); + if (!list) return false; + const start = finding.line; + const end = finding.endLine ?? finding.line; + return list.some((range) => start <= range.end && end >= range.start); +} + +function suppressionsInRanges(suppression: CheckSuppression, ranges: ChangedRanges): boolean { + const list = ranges.get(toPosix(suppression.file)); + if (!list) return false; + const line = suppression.targetLine ?? suppression.line; + return list.some((range) => line >= range.start && line <= range.end); +} + +function defaultActionFor(finding: Finding): FindingAction { + return { + kind: "inspect-file", + auto_fixable: false, + command: `codebase-intelligence file . ${finding.file}`, + reason: `${finding.ruleId} at ${finding.file}:${String(finding.line)}`, + }; +} + +function withActions(findings: Finding[]): Finding[] { + return findings.map((finding) => { + if (finding.actions && finding.actions.length > 0) return finding; + return { ...finding, actions: [defaultActionFor(finding)] }; + }); +} + /** * Run the rules engine against an analyzed graph and return findings + verdict. * Loads config (discovery or overrides.configPath) — throws ConfigError on bad config. @@ -131,6 +239,28 @@ export function runCheck( } } + const diffRanges = overrides?.diffFile + ? changedRangesFromDiffFile(resolvedRoot, overrides.diffFile) + : overrides?.changedSince + ? changedRangesSince(resolvedRoot, overrides.changedSince) + : null; + if (overrides?.diffFile && diffRanges === null) { + process.stderr.write(`Warning: could not read diff file '${overrides.diffFile}'; running full check.\n`); + } + if (overrides?.changedSince && diffRanges === null) { + process.stderr.write(`Warning: could not diff against '${overrides.changedSince}'; running full check.\n`); + } + if (diffRanges) { + findings = findings.filter((f) => findingInRanges(f, diffRanges)); + suppressions = suppressions.filter((suppression) => suppressionsInRanges(suppression, diffRanges)); + } + + if (config.ci?.production === true) { + findings = findings.filter((f) => !isTestOrDevPath(f.file)); + suppressions = suppressions.filter((suppression) => !isTestOrDevPath(suppression.file)); + } + + findings = withActions(findings); const summary = summarize(findings, suppressions); const verdict = computeVerdict(summary, config); return { findings, suppressions, summary, verdict, configPath }; diff --git a/src/rules/format.ts b/src/rules/format.ts index ee34b15..6cafc9d 100644 --- a/src/rules/format.ts +++ b/src/rules/format.ts @@ -98,12 +98,116 @@ export function formatSarif(result: CheckResult): string { return JSON.stringify(sarif, null, 2); } +function escapeAnnotation(value: string): string { + return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C"); +} + +function severityLabel(severity: CheckResult["findings"][number]["severity"]): "error" | "warning" { + return severity === "error" ? "error" : "warning"; +} + +export function formatMarkdown(result: CheckResult): string { + const lines = [ + "## Codebase Intelligence", + "", + `**Verdict:** ${result.verdict.toUpperCase()}`, + `**Summary:** ${formatSummaryLine(result)}`, + "", + ]; + + if (result.findings.length === 0) { + lines.push("No findings."); + return lines.join("\n"); + } + + lines.push("| Severity | Rule | Location | Finding |"); + lines.push("|---|---|---|---|"); + for (const finding of result.findings.slice(0, 50)) { + const message = finding.message.replaceAll("|", "\\|"); + lines.push(`| ${finding.severity} | \`${finding.ruleId}\` | \`${finding.file}:${String(finding.line)}\` | ${message} |`); + } + if (result.findings.length > 50) lines.push(`| info | truncated | - | ${String(result.findings.length - 50)} more finding(s) omitted from markdown output. |`); + return lines.join("\n"); +} + +export function formatAnnotations(result: CheckResult): string { + return result.findings + .map((finding) => { + const level = severityLabel(finding.severity); + const message = escapeAnnotation(`${finding.message} (${finding.ruleId})`); + return `::${level} file=${escapeAnnotation(finding.file)},line=${String(finding.line)},col=${String(finding.column)}::${message}`; + }) + .join("\n"); +} + +export function formatPrComment(result: CheckResult, platform: "github" | "gitlab"): string { + const body = formatMarkdown(result); + const footer = platform === "github" + ? "" + : ""; + return `${body}\n\n${footer}`; +} + +export function formatBadge(result: CheckResult): string { + const color = result.verdict === "pass" ? "brightgreen" : result.verdict === "warn" ? "yellow" : "red"; + return JSON.stringify( + { + schemaVersion: 1, + label: "codebase", + message: result.verdict, + color, + }, + null, + 2, + ); +} + +export function formatCodeClimate(result: CheckResult): string { + const issues = result.findings.map((finding) => ({ + type: "issue", + check_name: finding.ruleId, + description: finding.message, + categories: ["Complexity"], + fingerprint: finding.fingerprint, + severity: finding.severity === "error" ? "major" : "minor", + location: { + path: finding.file, + lines: { begin: finding.line, end: finding.endLine ?? finding.line }, + }, + remediation_points: finding.severity === "error" ? 50000 : 10000, + })); + return JSON.stringify(issues, null, 2); +} + +export function formatCompact(result: CheckResult): string { + const lines = [formatSummaryLine(result)]; + for (const finding of result.findings.slice(0, 10)) { + lines.push(`${finding.severity.toUpperCase()} ${finding.file}:${String(finding.line)} ${finding.ruleId} — ${finding.message}`); + } + if (result.findings.length > 10) lines.push(`… ${String(result.findings.length - 10)} more finding(s)`); + return lines.join("\n"); +} + export function formatResult(result: CheckResult, format: OutputFormat): string { switch (format) { case "json": return formatJson(result); case "sarif": return formatSarif(result); + case "markdown": + return formatMarkdown(result); + case "annotations": + return formatAnnotations(result); + case "pr-comment-github": + return formatPrComment(result, "github"); + case "pr-comment-gitlab": + return formatPrComment(result, "gitlab"); + case "badge": + return formatBadge(result); + case "codeclimate": + return formatCodeClimate(result); + case "compact": + return formatCompact(result); default: return formatText(result); } diff --git a/src/rules/no-secrets.ts b/src/rules/no-secrets.ts new file mode 100644 index 0000000..bdf29fe --- /dev/null +++ b/src/rules/no-secrets.ts @@ -0,0 +1,57 @@ +import type { ReportedFinding, Rule, RuleContext } from "./engine.js"; + +interface SecretPattern { + name: string; + regex: RegExp; +} + +const SECRET_PATTERNS: SecretPattern[] = [ + { name: "generic-token", regex: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["'][^"'\s]{12,}["']/i }, + { name: "bearer-token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/ }, + { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ }, +]; + +function lineMatches(source: string): Array<{ line: number; column: number; kind: string }> { + const matches: Array<{ line: number; column: number; kind: string }> = []; + const lines = source.split(/\r?\n/); + lines.forEach((lineText, index) => { + for (const pattern of SECRET_PATTERNS) { + const match = pattern.regex.exec(lineText); + if (match) { + matches.push({ line: index + 1, column: match.index + 1, kind: pattern.name }); + } + } + }); + return matches; +} + +export const noSecrets: Rule = { + id: "no-secrets", + meta: { description: "Opt-in scan for likely hardcoded secrets.", category: "security", fixable: false }, + defaultSeverity: "off", + run(ctx: RuleContext): ReportedFinding[] { + const findings: ReportedFinding[] = []; + for (const file of ctx.fileRelPaths) { + const source = ctx.sourceOf(file); + if (!source) continue; + for (const match of lineMatches(source)) { + findings.push({ + file, + line: match.line, + column: match.column, + kind: "secret-leak", + confidence: "medium", + message: `Likely hardcoded secret (${match.kind})`, + evidence: [`pattern=${match.kind}`, "sourceText=redacted"], + actions: [{ + kind: "review-finding", + auto_fixable: false, + command: `codebase-intelligence check . --production --fail-on warn`, + reason: "Confirm the value is not a credential before committing.", + }], + }); + } + } + return findings; + }, +}; diff --git a/src/rules/registry.ts b/src/rules/registry.ts index 43831d6..d6e29b8 100644 --- a/src/rules/registry.ts +++ b/src/rules/registry.ts @@ -4,6 +4,7 @@ import { noBoundaryViolations } from "./no-boundary-violations.js"; import { noCircularDeps } from "./no-circular-deps.js"; import { noDeadFiles, noUnusedDeps, noUnusedMembers, noUnusedTypes } from "./dead-code.js"; import { noDeadExports } from "./no-dead-exports.js"; +import { noSecrets } from "./no-secrets.js"; /** Every rule the engine knows about. Add a rule = add a file + an entry here. */ export const ALL_RULES: Rule[] = [ @@ -15,4 +16,5 @@ export const ALL_RULES: Rule[] = [ noUnusedTypes, noUnusedMembers, noUnusedDeps, + noSecrets, ]; diff --git a/src/types/index.ts b/src/types/index.ts index ad9d83c..8f225cd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,7 @@ export interface ParsedExport { loc: number; isDefault: boolean; complexity: number; + cognitiveComplexity?: number; typeFacts?: SymbolTypeFacts; duplication?: SymbolDuplicationFacts; } @@ -111,6 +112,7 @@ export interface SymbolNode { loc: number; isDefault: boolean; complexity: number; + cognitiveComplexity?: number; isExported?: boolean; typeFacts?: SymbolTypeFacts; duplication?: SymbolDuplicationFacts; @@ -136,6 +138,7 @@ export interface FileMetrics { isBridge: boolean; churn: number; cyclomaticComplexity: number; + cognitiveComplexity?: number; blastRadius: number; deadExports: string[]; totalExports: number; @@ -370,7 +373,17 @@ export interface BoundariesResult { verdict: "pass" | "fail"; } -export type OutputFormat = "text" | "json" | "sarif"; +export type OutputFormat = + | "text" + | "json" + | "sarif" + | "markdown" + | "annotations" + | "pr-comment-github" + | "pr-comment-gitlab" + | "badge" + | "codeclimate" + | "compact"; export interface CacheFacts { cacheDir: string; @@ -399,12 +412,22 @@ export interface CodebaseIntelligenceConfig { gate?: "all" | "new-only"; failOn?: FindingSeverity | "never"; maxWarnings?: number; + maxNew?: number; tolerance?: number; base?: string; + minScore?: number; + production?: boolean; }; } -export type ActionKind = "remove-comment" | "inspect-boundary"; +export type ActionKind = + | "remove-comment" + | "inspect-boundary" + | "inspect-file" + | "inspect-symbol" + | "run-check" + | "review-finding" + | "create-baseline"; export type FindingConfidence = "high" | "medium" | "low"; export interface FindingAction { @@ -412,6 +435,8 @@ export interface FindingAction { /** snake_case is intentional — this is an agent-facing wire field. */ auto_fixable: boolean; range?: { start: number; end: number }; + command?: string; + reason?: string; } export interface Finding { diff --git a/src/watch/index.ts b/src/watch/index.ts new file mode 100644 index 0000000..ae4587a --- /dev/null +++ b/src/watch/index.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; +import type { CodebaseGraph } from "../types/index.js"; + +export interface WatchOptions { + once?: boolean; + debounceMs?: number; +} + +export interface WatchSnapshot { + status: "ready"; + files: number; + dependencies: number; + debounceMs: number; + cacheUpdated: boolean; + summary: string; +} + +export function computeWatchSnapshot(graph: CodebaseGraph, options: WatchOptions = {}): WatchSnapshot { + const debounceMs = options.debounceMs ?? 250; + return { + status: "ready", + files: graph.stats.totalFiles, + dependencies: graph.stats.totalDependencies, + debounceMs, + cacheUpdated: true, + summary: `Watch ready for ${String(graph.stats.totalFiles)} files with ${String(debounceMs)}ms debounce.`, + }; +} + +export function startWatch(rootDir: string, onChange: (file: string) => void, options: WatchOptions = {}): () => void { + const debounceMs = options.debounceMs ?? 250; + let timer: NodeJS.Timeout | undefined; + const watcher = fs.watch(path.resolve(rootDir), (_event, filename) => { + if (!filename || (!filename.endsWith(".ts") && !filename.endsWith(".tsx"))) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + onChange(filename); + }, debounceMs); + }); + return () => { + if (timer) clearTimeout(timer); + watcher.close(); + }; +} + +export function formatWatchSnapshotText(snapshot: WatchSnapshot): string { + return snapshot.summary; +} diff --git a/src/workspaces/index.ts b/src/workspaces/index.ts new file mode 100644 index 0000000..b7a65d5 --- /dev/null +++ b/src/workspaces/index.ts @@ -0,0 +1,105 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import type { CodebaseGraph } from "../types/index.js"; + +export interface WorkspaceInfo { + name: string; + path: string; + files: number; + changed: boolean; + cycles: string[][]; + evidence: string[]; +} + +export interface WorkspacesResult { + base: string; + changedOnly: boolean; + workspaces: WorkspaceInfo[]; + crossPackageCycles: string[][]; + summary: string; +} + +function normalize(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +function packageJsonFiles(rootDir: string): string[] { + const results: string[] = []; + function walk(dir: string): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name === ".codebase-intelligence") continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile() && entry.name === "package.json") results.push(full); + } + } + walk(rootDir); + return results; +} + +function packageName(file: string): string { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8")); + if (parsed && typeof parsed === "object" && "name" in parsed && typeof (parsed as { name?: unknown }).name === "string") { + return (parsed as { name: string }).name; + } + } catch { + return path.basename(path.dirname(file)); + } + return path.basename(path.dirname(file)); +} + +function changedFiles(rootDir: string, base: string): Set { + try { + const output = execFileSync("git", ["diff", "--name-only", "--relative", `${base}...HEAD`], { + cwd: rootDir, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 10000, + }); + return new Set(output.split("\n").map((line) => normalize(line.trim())).filter(Boolean)); + } catch { + return new Set(); + } +} + +export function computeWorkspaces(graph: CodebaseGraph, rootDir: string, options: { base?: string; changedOnly?: boolean } = {}): WorkspacesResult { + const base = options.base ?? "origin/main"; + const changed = changedFiles(rootDir, base); + const workspaceRoots = packageJsonFiles(rootDir) + .map((file) => ({ name: packageName(file), path: normalize(path.relative(rootDir, path.dirname(file))) || "." })) + .sort((left, right) => left.path.localeCompare(right.path)); + + const workspaces = workspaceRoots.map((workspace) => { + const files = graph.nodes.filter((node) => node.type === "file" && (workspace.path === "." || node.id.startsWith(`${workspace.path}/`))); + const isChanged = [...changed].some((file) => workspace.path === "." ? !file.includes("/") : file.startsWith(`${workspace.path}/`)); + const cycles = graph.stats.circularDeps.filter((cycle) => cycle.some((file) => workspace.path === "." || file.startsWith(`${workspace.path}/`))); + return { + ...workspace, + files: files.length, + changed: isChanged, + cycles, + evidence: [`files=${String(files.length)}`, `changed=${String(isChanged)}`, `cycles=${String(cycles.length)}`], + }; + }); + + const filtered = options.changedOnly ? workspaces.filter((workspace) => workspace.changed) : workspaces; + const workspaceForFile = (file: string): string => workspaces.find((workspace) => workspace.path !== "." && file.startsWith(`${workspace.path}/`))?.name ?? "."; + const crossPackageCycles = graph.stats.circularDeps.filter((cycle) => new Set(cycle.map(workspaceForFile)).size > 1); + return { + base, + changedOnly: options.changedOnly === true, + workspaces: filtered, + crossPackageCycles, + summary: `${String(filtered.length)} workspace(s); ${String(workspaces.filter((workspace) => workspace.changed).length)} changed since ${base}; ${String(crossPackageCycles.length)} cross-package cycle(s).`, + }; +} + +export function formatWorkspacesText(result: WorkspacesResult): string { + const lines = ["Workspaces", result.summary, ""]; + for (const workspace of result.workspaces) { + lines.push(`${workspace.name} ${workspace.path}: ${String(workspace.files)} file(s), changed=${String(workspace.changed)}, cycles=${String(workspace.cycles.length)}`); + } + return lines.join("\n"); +} diff --git a/tests/cli-check.e2e.test.ts b/tests/cli-check.e2e.test.ts index 119a353..530097c 100644 --- a/tests/cli-check.e2e.test.ts +++ b/tests/cli-check.e2e.test.ts @@ -395,6 +395,29 @@ describe("check command (e2e)", () => { expect(stderr).toContain("--gate must be one of"); }); + it("--diff-file ignores zero-length new-file hunks", async () => { + const dir = makeProject( + { + "src/main.ts": "export const x = 1;\n// unchanged finding\nexport const y = 2;\n", + }, + { + rules: { + "no-circular-deps": "off", + "no-dead-exports": "off", + "no-comments": "warn", + }, + }, + ); + const diffFile = path.join(dir, "empty-new-range.diff"); + fs.writeFileSync(diffFile, "diff --git a/src/main.ts b/src/main.ts\n--- a/src/main.ts\n+++ b/src/main.ts\n@@ -2,1 +2,0 @@\n"); + + const { status, stdout } = await run(["check", dir, "--format", "json", "--diff-file", diffFile, "--fail-on", "warn"]); + const parsed = JSON.parse(stdout) as { findings: unknown[]; verdict: string }; + expect(status).toBe(0); + expect(parsed.verdict).toBe("pass"); + expect(parsed.findings).toHaveLength(0); + }); + it("--quiet wins over --summary on pass: no output, exit 0 (pinned contract)", async () => { const dir = makeProject(CLEAN, { rules: {} }); const { status, stdout } = await run(["check", dir, "--quiet", "--summary"]); diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 28871f0..39d695e 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -568,6 +568,44 @@ describe("Tool 23: get_clusters", () => { }); }); +describe("Tool 24: get_ownership", () => { + it("returns owner/package/directory grouping facts", async () => { + const r = await callTool("get_ownership", { groupBy: "directory" }); + expect(r).toHaveProperty("groupBy", "directory"); + expect(r).toHaveProperty("groups"); + expect(r).toHaveProperty("hotspots"); + expect(r).toHaveProperty("nextSteps"); + }); +}); + +describe("Tool 25: get_architecture_recommendations", () => { + it("returns advisory recommendations with context packs", async () => { + const r = await callTool("get_architecture_recommendations"); + expect(r).toHaveProperty("recommendations"); + expect(r).toHaveProperty("summary"); + expect(r).toHaveProperty("nextSteps"); + }); +}); + +describe("Tool 26: get_lsp_snapshot", () => { + it("returns editor diagnostics and hover facts", async () => { + const r = await callTool("get_lsp_snapshot"); + expect(r).toHaveProperty("diagnostics"); + expect(r).toHaveProperty("hovers"); + expect(r).toHaveProperty("summary"); + expect(r).toHaveProperty("nextSteps"); + }); +}); + +describe("Tool 27: get_workspaces", () => { + it("returns package workspace scope facts", async () => { + const r = await callTool("get_workspaces", { base: "HEAD", changedOnly: false }); + expect(r).toHaveProperty("workspaces"); + expect(r).toHaveProperty("summary"); + expect(r).toHaveProperty("nextSteps"); + }); +}); + describe("MCP Prompts", () => { it("detect_impact prompt is registered", async () => { const prompts = await client.listPrompts(); @@ -632,6 +670,10 @@ describe("MCP Resources", () => { expect(availableTools).toContain("get_health_score"); expect(availableTools).toContain("check_boundaries"); expect(availableTools).toContain("analyze_highways"); + expect(availableTools).toContain("get_ownership"); + expect(availableTools).toContain("get_architecture_recommendations"); + expect(availableTools).toContain("get_lsp_snapshot"); + expect(availableTools).toContain("get_workspaces"); expect(availableTools).toContain("check"); }); }); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index 7e1c386..35435c0 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -375,6 +375,38 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliMatchesRegistry( + operations.ownership, + { groupBy: "directory" }, + ["owners", getFixtureSrcPath(), "--group-by", "directory"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); + expectCliMatchesRegistry( + operations.architectureRecommendations, + {}, + ["architecture", getFixtureSrcPath()], + codebaseGraph, + {}, + cachedRun, + ); + expectCliMatchesRegistry( + operations.lspSnapshot, + {}, + ["lsp", getFixtureSrcPath(), "--diagnostics"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliMatchesRegistry( + operations.workspaces, + { base: "HEAD", changedOnly: false }, + ["workspaces", getFixtureSrcPath(), "--base", "HEAD"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); }); it("CH-P1-01: registry-adapted CLI text commands render through descriptor formatters for every operation", () => { @@ -537,6 +569,38 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliTextMatchesFormatter( + operations.ownership, + { groupBy: "directory" }, + ["owners", getFixtureSrcPath(), "--group-by", "directory"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.architectureRecommendations, + {}, + ["architecture", getFixtureSrcPath()], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.lspSnapshot, + {}, + ["lsp", getFixtureSrcPath(), "--diagnostics"], + codebaseGraph, + {}, + cachedRun, + ); + expectCliTextMatchesFormatter( + operations.workspaces, + { base: "HEAD", changedOnly: false }, + ["workspaces", getFixtureSrcPath(), "--base", "HEAD"], + codebaseGraph, + { rootDir: getFixtureSrcPath() }, + cachedRun, + ); }); it("CH-P1-02: invalid input uses shared descriptor validation before indexing", async () => { @@ -832,5 +896,35 @@ describe("operation registry chained parity", () => { codebaseGraph, mcp, ); + await expectMcpMatchesRegistry( + operations.ownership, + { groupBy: "directory" }, + { groupBy: "directory" }, + codebaseGraph, + mcp, + { rootDir: getFixtureSrcPath() }, + ); + await expectMcpMatchesRegistry( + operations.architectureRecommendations, + {}, + {}, + codebaseGraph, + mcp, + ); + await expectMcpMatchesRegistry( + operations.lspSnapshot, + {}, + {}, + codebaseGraph, + mcp, + ); + await expectMcpMatchesRegistry( + operations.workspaces, + { base: "HEAD", changedOnly: false }, + { base: "HEAD", changedOnly: false }, + codebaseGraph, + mcp, + { rootDir: getFixtureSrcPath() }, + ); }); }); diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index 59a7a84..885a26d 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -45,6 +45,10 @@ const expectedOperations: Array<{ { name: "boundaries", cliCommand: "boundaries", mcpTool: "check_boundaries", inputKeys: ["preset", "list"], sampleInput: { preset: "layered", list: true } }, { name: "highways", cliCommand: "highways", mcpTool: "analyze_highways", inputKeys: ["operation", "shape", "minRoutes", "propose", "trace"], sampleInput: { operation: "create", minRoutes: 2 } }, { name: "clusters", cliCommand: "clusters", mcpTool: "get_clusters", inputKeys: ["minFiles"], sampleInput: { minFiles: 2 } }, + { name: "ownership", cliCommand: "owners", mcpTool: "get_ownership", inputKeys: ["groupBy", "effort"], sampleInput: { groupBy: "owner", effort: 1 } }, + { name: "architectureRecommendations", cliCommand: "architecture", mcpTool: "get_architecture_recommendations", inputKeys: [], sampleInput: {} }, + { name: "lspSnapshot", cliCommand: "lsp", mcpTool: "get_lsp_snapshot", inputKeys: [], sampleInput: {} }, + { name: "workspaces", cliCommand: "workspaces", mcpTool: "get_workspaces", inputKeys: ["base", "changedOnly"], sampleInput: { base: "HEAD", changedOnly: false } }, ]; describe("operation registry", () => { diff --git a/tests/roadmap-2-5-e2e.test.ts b/tests/roadmap-2-5-e2e.test.ts new file mode 100644 index 0000000..6b5def8 --- /dev/null +++ b/tests/roadmap-2-5-e2e.test.ts @@ -0,0 +1,226 @@ +import { execFile, execFileSync, execSync } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createFixtureMcp } from "./helpers/mcp.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const cli = path.join(repoRoot, "dist", "cli.js"); +const pexec = promisify(execFile); + +interface RunResult { + status: number; + stdout: string; + stderr: string; +} + +const created: string[] = []; + +beforeAll(() => { + execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); +}, 120_000); + +afterEach(() => { + for (const dir of created.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +async function run(args: readonly string[], cwd = repoRoot): Promise { + try { + const { stdout, stderr } = await pexec("node", [cli, ...args], { + cwd, + encoding: "utf-8", + maxBuffer: 20 * 1024 * 1024, + }); + return { status: 0, stdout, stderr }; + } catch (e) { + const err = e as { code?: number | string; stdout?: string; stderr?: string }; + return { + status: typeof err.code === "number" ? err.code : 1, + stdout: err.stdout ?? "", + stderr: err.stderr ?? "", + }; + } +} + +function git(dir: string, args: string[]): void { + execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], { + cwd: dir, + stdio: "ignore", + }); +} + +function makeProject(files: Record, config?: unknown): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-25-roadmap-")); + created.push(dir); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + if (config !== undefined) { + fs.writeFileSync(path.join(dir, "codebase-intelligence.json"), JSON.stringify(config, null, 2)); + } + return dir; +} + +function initGit(dir: string): void { + git(dir, ["init"]); + git(dir, ["config", "user.email", "test@example.com"]); + git(dir, ["config", "user.name", "Test User"]); + git(dir, ["add", "."]); + git(dir, ["commit", "-m", "base"]); +} + +const FILES = { + "tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", module: "Node16", moduleResolution: "Node16" } }), + "src/a.ts": "import { b } from './b.js';\nexport function a(flag: boolean): number { if (flag) { return b(); } return 1; }\n", + "src/b.ts": "export function b(): number { return 2; }\n", + "src/secret.ts": "export const safe = 'ok';\n", +}; + +describe("2.5.0 remaining roadmap chained E2E", () => { + it("CH-P2-06: ci gates new findings, writes SARIF, emits PR markdown, and records history", async () => { + const dir = makeProject(FILES, { + rules: { + "no-circular-deps": "off", + "no-dead-exports": "off", + "no-comments": "off", + }, + }); + initGit(dir); + + const sarifPath = "codebase-intelligence.sarif"; + const sarifRun = await run(["ci", dir, "--base", "HEAD", "--new-only", "--format", "sarif", "--output", sarifPath, "--force"]); + expect(sarifRun.status).toBe(0); + const sarif = JSON.parse(fs.readFileSync(path.join(dir, sarifPath), "utf-8")) as { version: string }; + expect(sarif.version).toBe("2.1.0"); + + fs.writeFileSync(path.join(dir, "codebase-intelligence.json"), JSON.stringify({ + rules: { "no-circular-deps": "off", "no-dead-exports": "off", "no-comments": "warn" }, + }, null, 2)); + fs.writeFileSync(path.join(dir, "src/new.ts"), "export const added = 1;\n// new finding\n"); + git(dir, ["add", "."]); + git(dir, ["commit", "-m", "add new finding"]); + const failing = await run(["ci", dir, "--base", "HEAD~1", "--new-only", "--fail-on", "warn", "--format", "json", "--history", "--force"]); + expect(failing.status).toBe(1); + const ci = JSON.parse(failing.stdout) as { verdict: string; check: { findings: Array<{ ruleId: string; actions: unknown[] }> } }; + expect(ci.verdict).toBe("fail"); + expect(ci.check.findings.some((finding) => finding.ruleId === "no-comments")).toBe(true); + expect(ci.check.findings.every((finding) => Array.isArray(finding.actions) && finding.actions.length > 0)).toBe(true); + + const comment = await run(["ci", dir, "--base", "HEAD~1", "--new-only", "--comment", "markdown", "--force"]); + expect(comment.stdout).toContain("Codebase Intelligence CI"); + expect(comment.stdout).toContain("codebase-intelligence:ci-pr-comment-github"); + + const history = await run(["history", dir, "--json"]); + const parsedHistory = JSON.parse(history.stdout) as { entries: unknown[]; summary: string }; + expect(parsedHistory.entries.length).toBe(1); + expect(parsedHistory.summary).toContain("run(s)"); + }, 120_000); + + it("CH-P2-07/08: doctor, check formats, secret actions, explain, hooks, config migration, and watch are machine-readable", async () => { + const dir = makeProject( + { + ...FILES, + "src/secret.ts": "export const apiKey = 'sk_test_12345678901234567890';\n", + }, + { + rules: { + "no-circular-deps": "off", + "no-dead-exports": "off", + "no-secrets": "warn", + }, + }, + ); + initGit(dir); + + const doctor = await run(["doctor", dir, "--profile", "agent", "--agent", "codex", "--json"]); + expect([0, 1]).toContain(doctor.status); + const doctorJson = JSON.parse(doctor.stdout) as { checks: Array<{ id: string; level: string; fix?: string }> }; + expect(doctorJson.checks.some((item) => item.id === "graph.build" && item.level === "pass")).toBe(true); + expect(doctorJson.checks.some((item) => item.id === "agent.instructions" && item.fix)).toBe(true); + + const checkJson = await run(["check", dir, "--format", "json", "--force"]); + const parsed = JSON.parse(checkJson.stdout) as { findings: Array<{ kind?: string; actions?: unknown[] }> }; + expect(parsed.findings.some((finding) => finding.kind === "secret-leak")).toBe(true); + expect(parsed.findings.every((finding) => Array.isArray(finding.actions) && finding.actions.length > 0)).toBe(true); + + const annotations = await run(["check", dir, "--format", "annotations", "--force"]); + expect(annotations.stdout).toContain("::warning"); + const badge = await run(["check", dir, "--format", "badge", "--force"]); + expect(JSON.parse(badge.stdout)).toHaveProperty("schemaVersion", 1); + const codeclimate = await run(["check", dir, "--format", "codeclimate", "--force"]); + expect(Array.isArray(JSON.parse(codeclimate.stdout))).toBe(true); + + const explanation = await run(["explain", "no-secrets", "--json"]); + expect(JSON.parse(explanation.stdout)).toMatchObject({ ruleId: "no-secrets", found: true }); + + const migration = await run(["migrate-config", dir, "--json"]); + expect(JSON.parse(migration.stdout)).toMatchObject({ dryRun: true, changed: false }); + + const hooksPlan = await run(["hooks", "install", dir, "--json"]); + expect(JSON.parse(hooksPlan.stdout)).toMatchObject({ action: "planned", dryRun: true }); + const hooksApply = await run(["hooks", "install", dir, "--apply", "--json"]); + expect(JSON.parse(hooksApply.stdout)).toMatchObject({ action: "installed", dryRun: false }); + expect(fs.existsSync(path.join(dir, ".git/hooks/pre-commit"))).toBe(true); + + const watch = await run(["watch", dir, "--once", "--json", "--force"]); + expect(JSON.parse(watch.stdout)).toMatchObject({ status: "ready", cacheUpdated: true }); + }, 120_000); + + it("CH-P3/P4: cognitive metrics, ownership, architecture, LSP, and workspace scopes are exposed via CLI and MCP", async () => { + const dir = makeProject({ + "package.json": JSON.stringify({ private: true, workspaces: ["packages/*"] }), + "tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", module: "Node16", moduleResolution: "Node16" } }), + "CODEOWNERS": "packages/app/ @app-team\npackages/core/ @core-team\n", + "packages/app/package.json": JSON.stringify({ name: "@fixture/app", dependencies: { "@fixture/core": "workspace:*" } }), + "packages/app/src/index.ts": "import { core } from '@fixture/core';\nexport function app(flag: boolean): string { if (flag) { for (const x of [1]) { if (x) return core(); } } return 'app'; }\n", + "packages/core/package.json": JSON.stringify({ name: "@fixture/core" }), + "packages/core/src/index.ts": "export function core(): string { return 'core'; }\n", + }); + initGit(dir); + fs.writeFileSync(path.join(dir, "packages/app/src/feature.ts"), "export const feature = 1;\n"); + git(dir, ["add", "."]); + git(dir, ["commit", "-m", "change app workspace"]); + + const hotspots = await run(["hotspots", dir, "--metric", "cognitive_complexity", "--json", "--force"]); + expect(JSON.parse(hotspots.stdout)).toHaveProperty("metric", "cognitive_complexity"); + + const fileContext = await run(["file", dir, "packages/app/src/index.ts", "--json", "--force"]); + const fileJson = JSON.parse(fileContext.stdout) as { metrics: { cognitiveComplexity: number } }; + expect(fileJson.metrics.cognitiveComplexity).toBeGreaterThan(0); + + const owners = await run(["owners", dir, "--group-by", "owner", "--json", "--force"]); + const ownerJson = JSON.parse(owners.stdout) as { groups: Array<{ key: string; busFactor: number }> }; + expect(ownerJson.groups.some((group) => group.key.includes("@app-team"))).toBe(true); + + const architecture = await run(["architecture", dir, "--json", "--force"]); + const archJson = JSON.parse(architecture.stdout) as { recommendations: Array<{ effort: string; contextPack: unknown }> }; + expect(Array.isArray(archJson.recommendations)).toBe(true); + + const lsp = await run(["lsp", dir, "--diagnostics", "--json", "--force"]); + const lspJson = JSON.parse(lsp.stdout) as { diagnostics: unknown[]; hovers: unknown[] }; + expect(Array.isArray(lspJson.diagnostics)).toBe(true); + expect(lspJson.hovers.length).toBeGreaterThan(0); + + const workspaces = await run(["workspaces", dir, "--base", "HEAD~1", "--changed", "--json", "--force"]); + const workspaceJson = JSON.parse(workspaces.stdout) as { workspaces: Array<{ name: string; changed: boolean }>; crossPackageCycles: string[][] }; + expect(workspaceJson.workspaces.some((workspace) => workspace.name === "@fixture/app" && workspace.changed)).toBe(true); + expect(Array.isArray(workspaceJson.crossPackageCycles)).toBe(true); + + const ciWorkspaces = await run(["ci", dir, "--base", "HEAD~1", "--new-only", "--changed-workspaces", "--format", "json", "--force"]); + const ciWorkspaceJson = JSON.parse(ciWorkspaces.stdout) as { workspaces?: { workspaces: Array<{ name: string; changed: boolean }>; crossPackageCycles: string[][] } }; + expect(ciWorkspaceJson.workspaces?.workspaces.some((workspace) => workspace.name === "@fixture/app" && workspace.changed)).toBe(true); + expect(Array.isArray(ciWorkspaceJson.workspaces?.crossPackageCycles)).toBe(true); + + const mcp = await createFixtureMcp(dir); + expect(await mcp.callTool("get_ownership", { groupBy: "directory" })).toHaveProperty("groups"); + expect(await mcp.callTool("get_architecture_recommendations")).toHaveProperty("recommendations"); + expect(await mcp.callTool("get_lsp_snapshot")).toHaveProperty("diagnostics"); + expect(await mcp.callTool("get_workspaces", { base: "HEAD", changedOnly: false })).toHaveProperty("workspaces"); + }, 120_000); +});