From 9730003ad71b5736b69583f95bcd4114ee20545b Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:29:25 +0200 Subject: [PATCH] feat(drift): add content drift reports --- README.md | 11 +- docs/architecture.md | 8 +- docs/cli-reference.md | 17 +- docs/data-model.md | 59 +++++ docs/mcp-tools.md | 19 +- llms-full.txt | 27 ++- llms.txt | 5 +- roadmap.md | 34 +-- src/cli.ts | 34 +++ src/drift/evidence.ts | 30 +++ src/drift/findings.ts | 307 +++++++++++++++++++++++++++ src/drift/index.ts | 52 +++++ src/drift/profiles.ts | 141 ++++++++++++ src/drift/tokens.ts | 145 +++++++++++++ src/drift/types.ts | 118 ++++++++++ src/mcp/hints.ts | 5 + src/mcp/index.ts | 1 + src/operations/formatters.ts | 32 +++ src/operations/index.ts | 20 ++ tests/drift.e2e.test.ts | 281 ++++++++++++++++++++++++ tests/mcp-tools.test.ts | 18 +- tests/operation-registry.e2e.test.ts | 23 ++ tests/operation-registry.test.ts | 1 + tools/verify-cli-real-codebases.mjs | 5 +- 24 files changed, 1353 insertions(+), 40 deletions(-) create mode 100644 src/drift/evidence.ts create mode 100644 src/drift/findings.ts create mode 100644 src/drift/index.ts create mode 100644 src/drift/profiles.ts create mode 100644 src/drift/tokens.ts create mode 100644 src/drift/types.ts create mode 100644 tests/drift.e2e.test.ts diff --git a/README.md b/README.md index d8b5db3..2e3e114 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli ## Features -- **21 CLI commands** for architecture analysis, dependency impact, duplicate families, focused map/context packs, Highways convergence, improvement opportunities, dead code detection, search, CI rules, and agent setup +- **22 CLI commands** for architecture analysis, dependency impact, duplicate families, focused map/context packs, content drift, Highways convergence, improvement opportunities, dead code detection, search, CI rules, and agent setup - **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[]`) @@ -66,10 +66,11 @@ claude mcp add -s user -t stdio codebase-intelligence -- npx -y codebase-intelli - **BM25 search** — ranked keyword search across files, symbols, and type/shape facts - **Process tracing** — detect entry points and execution flows through the call graph - **Codebase maps + context packs** — focused file/symbol/test graph with deterministic evidence IDs and token-bounded context for agents +- **Content drift** — report-only detection for file/name/scope/side-effect/shape/test placement mismatch with stable evidence - **Highways analysis** — find repeated routes that bypass canonical dataflow paths and synthesize safe proposed highways - **Community detection** — Louvain clustering for natural file groupings - **Agent adoption** — `init` writes per-agent instruction files + installs a skill so AI agents query CI before grep/read -- **MCP parity (secondary)** — same analysis and rules gate available as 22 MCP tools, 2 prompts, and 3 resources +- **MCP parity (secondary)** — same analysis and rules gate available as 23 MCP tools, 2 prompts, and 3 resources ## Installation @@ -113,6 +114,7 @@ codebase-intelligence [options] | `rename` | Reference discovery for rename planning | | `processes` | Entry-point execution flow tracing | | `map` | Focused codebase graph + token-bounded context pack | +| `drift` | Report-only content drift findings with evidence and recommendations | | `highways` | Repeated route convergence, canonical path opportunities, and synthesis proposals | | `clusters` | Community-detected file clusters | | `check` | Rules-engine gate for CI, including opt-in dead-code and dependency gates | @@ -126,13 +128,14 @@ codebase-intelligence [options] | `--force` | Rebuild index even if cache is valid | | `--limit ` | Limit results on supported commands | | `--metric ` | Select ranking metric for `hotspots` | -| `--scope ` | Select git diff scope for `changes`: `staged`, `unstaged`, `all` | +| `--scope ` | Select git diff scope for `changes`; directory/module scope for `map` and `drift` | | `--mode ` | Select clone mode for `duplicates`: `strict`, `mild`, `weak` | | `--min-tokens ` | Minimum duplicate token size for `duplicates` | | `--skip-local` | Ignore duplicate families confined to one file | | `--trace ` | Return token evidence for one duplicate family | -| `--focus ` | Focus `map` on one symbol, file, or scope | +| `--focus ` | Focus `map` or `drift` on one symbol, file, or scope | | `--context-budget ` | Bound `map` context pack size | +| `--min-score ` | Minimum drift score for `drift` findings | | `--format ` | Export `map` as `markdown`, `json`, `dot`, or `graphml`; export `check` as `text`, `json`, or `sarif` | | `--operation ` | Focus `highways` on one operation verb | | `--shape ` | Focus `highways` on one type/DTO shape | diff --git a/docs/architecture.md b/docs/architecture.md index b5b5274..0c6e588 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ Core (shared computation) | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) CLI (terminal/CI) - | 22 tools, 2 prompts, | 21 commands with text + JSON + | 23 tools, 2 prompts, | 22 commands with text + JSON | 3 resources for LLMs | output for humans and CI ``` @@ -48,8 +48,9 @@ src/ config/index.ts <- Config discovery + zod validation rules/index.ts <- Rules engine + registry (check command + MCP check tool) map/index.ts <- Focused codebase maps + token-bounded context packs + drift/ <- Content drift scoring, profiles, findings, tokens, and stable evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 22 MCP tools for LLM integration + mcp/index.ts <- 23 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 @@ -84,7 +85,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 22 tools, 2 prompts, 3 resources + -> stdio MCP server with 23 tools, 2 prompts, 3 resources runOperation(operation, codebaseGraph, input, context) -> { ok: true, data } | { ok: false, error, data? } @@ -100,6 +101,7 @@ runOperation(operation, codebaseGraph, input, context) - **Suppression hygiene**: `check` reports active and stale `ci-ignore-*` / `@expected-unused` suppressions, emits stale suppression findings via `no-stale-suppressions`, and treats `@public` exported type declarations as intentional public API while `@internal` stays checkable. - **Highways H1/H2**: `highways` / `analyze_highways` enumerates entry-to-sink call routes, groups repeated routes by operation/shape/sink, detects bypasses and cowpaths around an existing canonical node, and with `--propose` synthesizes a read-only proposed highway with name, file, signature, skeleton, reroute plan, cycle safety, evidence, blast radius, recommendations, and a context pack for agents. - **Codebase map context packs**: `map` / `get_codebase_map` builds deterministic file/symbol/test/scope graphs with stable evidence IDs. `get_scope_graph` and `get_context_pack` are MCP-only filtered views derived from the same result, so agents get compact context without a second analyzer path. +- **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. - **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 7f26e57..262c7ba 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # CLI Reference -21 commands for terminal and CI use. The 19 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. +22 commands for terminal and CI use. The 20 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. ## Commands @@ -180,6 +180,16 @@ codebase-intelligence map [--focus ] [--scope ] [--d **Output:** overview, focus node, nodes, edges, evidence, contextPack (ranked files, symbols, tests, token estimate/budget), and summary. JSON mode includes stable evidence IDs (`evidence-*`) and edge IDs (`edge-*`) for agent-safe references. +### drift + +Report-only content drift findings for files whose names, folders, side effects, type shapes, or tests no longer match behavior. + +```bash +codebase-intelligence drift [--focus ] [--scope ] [--min-score ] [--json] [--force] +``` + +**Output:** `mode: "report-only"`, baseline status, findings with stable `drift-*` IDs, `kind` (`name-drift`, `scope-drift`, `mixed-responsibility`, `hidden-side-effect`, `shape-drift`, `orphan-scope`, `misplaced-test`), score, severity, declared intent, actual behavior, evidence IDs/summaries, recommendations, and advisory actions. Drift does not fail CI until a baseline/gate is configured. + ### highways Find repeated routes that should converge on one canonical operation path. @@ -249,15 +259,16 @@ codebase-intelligence init [path] [--agents ] [--all] [--skill] [--gitigno | `--min-tokens ` | duplicates | Minimum function body token count (default: 30) | | `--skip-local` | duplicates | Ignore families confined to one file | | `--trace ` | duplicates, highways | Return evidence for one family/opportunity id | -| `--scope ` | changes, map | Git diff scope for `changes`; directory/module scope for `map` | +| `--scope ` | changes, map, drift | Git diff scope for `changes`; directory/module scope for `map` and `drift` | | `--depth ` | dependents, map | Max traversal depth | | `--cohesion ` | forces | Min cohesion threshold (default: 0.6) | | `--tension ` | forces | Min tension threshold (default: 0.3) | | `--escape ` | forces | Min escape velocity threshold (default: 0.5) | | `--module ` | dead-exports | Filter by module path | | `--entry ` | processes | Filter by entry point name | -| `--focus ` | map | Focus on one symbol, file, or scope | +| `--focus ` | map, drift | Focus on one symbol, file, or scope | | `--context-budget ` | map | Approximate token budget for context pack | +| `--min-score ` | drift | Minimum drift score to report | | `--operation ` | highways | Focus on one operation verb | | `--shape ` | highways | Focus on one type/DTO shape | | `--min-routes ` | highways | Minimum routes reaching a sink before reporting | diff --git a/docs/data-model.md b/docs/data-model.md index 16b3ca6..b3f45fc 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -237,6 +237,65 @@ CodebaseContextPack { } ``` +## Content Drift Result + +`drift --json` and MCP `detect_content_drift` return deterministic, report-only drift findings. The command never fails CI by itself; a baseline/gate must be configured before drift can become enforcement. + +```typescript +ContentDriftResult { + mode: "report-only" + baseline: { + status: "not-configured" + requiredForGate: true + reason: string + } + focus?: string + scope?: string + minScore: number + totalFindings: number + findings: ContentDriftFinding[] + evidence: ContentDriftEvidence[] + summary: string +} + +ContentDriftFinding { + id: string + kind: "name-drift" | "scope-drift" | "mixed-responsibility" | "hidden-side-effect" | "shape-drift" | "orphan-scope" | "misplaced-test" + severity: "low" | "medium" | "high" + score: number + file: string + scope: string + title: string + recommendation: string + declaredIntent: { + path: string + fileName: string + scope: string + tokens: string[] + exports: string[] + } + actualBehavior: { + tokens: string[] + imports: string[] + calls: string[] + types: string[] + sideEffects: string[] + tests: string[] + } + evidenceIds: string[] + evidence: string[] + actions: Array<{ kind: string; command: string; description: string }> +} + +ContentDriftEvidence { + id: string + kind: "name" | "scope" | "imports" | "calls" | "types" | "tests" | "metric" + summary: string + file?: string + symbol?: string +} +``` + ## Highways Result `highways --json` and MCP `analyze_highways` return deterministic route-convergence opportunities. diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 1c69a94..c2b3711 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -22 tools available via MCP stdio. +23 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. @@ -199,7 +199,17 @@ Token-bounded context pack derived from `get_codebase_map`. **Use when:** Passing compact, ranked code context to an LLM. **Not for:** Visual graph export (use get_codebase_map). -## 20. analyze_highways +## 20. detect_content_drift + +Detect report-only mismatch between declared file/folder intent and observed behavior. + +**Input:** `{ focus?: string, scope?: string, minScore?: number }` +**Returns:** mode, baseline, minScore, findings[] (id, kind, severity, score, file, scope, declaredIntent, actualBehavior, evidenceIds, evidence, recommendation, actions), evidence[], summary + +**Use when:** Finding file/folder/content drift before a refactor, roadmap cleanup, or baseline creation. +**Not for:** Failing CI without a baseline (use check or a future ci wrapper). + +## 21. analyze_highways Detect repeated entry-to-sink routes that should converge on one canonical operation path. @@ -209,7 +219,7 @@ Detect repeated entry-to-sink routes that should converge on one canonical opera **Use when:** Enforcing dataflow discipline, finding ad-hoc routes, or planning canonical shared paths. **Not for:** Raw execution flow listing (use get_processes). -## 21. get_clusters +## 22. get_clusters Community-detected clusters of related files. @@ -219,7 +229,7 @@ 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). -## 22. check +## 23. check Run the configurable rules engine and gate on findings. @@ -270,6 +280,7 @@ Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-expo | "What context should I give an LLM for this task?" | `get_context_pack` | | "Show a focused codebase graph" | `get_codebase_map` | | "Show only file/scope topology" | `get_scope_graph` | +| "Which file names lie about behavior?" | `detect_content_drift` | | "Which routes bypass canonical dataflow?" | `analyze_highways` | | "What files naturally belong together?" | `get_clusters` | | "What are the main areas?" | `get_groups` | diff --git a/llms-full.txt b/llms-full.txt index e1edc6f..ad5a75a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -31,8 +31,8 @@ Core (shared computation) | typed descriptors, input schemas, CLI/MCP adapters, result wrappers, text formatters v MCP (stdio) + CLI - | MCP: 22 tools, 2 prompts, 3 resources for LLM agents - | CLI: 21 commands with formatted + JSON output for humans/CI + | MCP: 23 tools, 2 prompts, 3 resources for LLM agents + | CLI: 22 commands with formatted + JSON output for humans/CI ``` ## Module Map @@ -52,8 +52,9 @@ src/ parser/duplication.ts <- Function-body clone token extraction duplication/index.ts <- Duplicate family detection + trace evidence map/index.ts <- Focused codebase maps + token-bounded context packs + drift/ <- Content drift findings, profiles, tokens, and stable evidence highways/index.ts <- Repeated route convergence + bypass/cowpath/synthesis evidence - mcp/index.ts <- 22 MCP tools for LLM integration + mcp/index.ts <- 23 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 @@ -310,7 +311,7 @@ The most dangerous files have: high churn + high coupling + low coverage. # MCP Tools Reference -22 tools available via MCP stdio. +23 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. @@ -369,13 +370,16 @@ File/scope/test topology from the same map result. Input: same as `get_codebase_ ## 19. get_context_pack Token-bounded context pack from the same map result. Input: same as `get_codebase_map`. Returns: tokenBudget, tokenEstimate, rankedFiles, rankedSymbols, tests, evidenceIds, nextCommands. -## 20. analyze_highways +## 20. detect_content_drift +Content drift. Input: `{ focus?: string, scope?: string, minScore?: number }`. Returns: report-only findings with stable IDs, kind, score, severity, declaredIntent, actualBehavior, evidence, recommendation, actions, and baseline status. + +## 21. analyze_highways Repeated route convergence. Input: `{ operation?: string, shape?: string, minRoutes?: number, propose?: boolean, trace?: string }`. Returns: bypass/cowpath/synthesis opportunities with route chains, evidence, blast radius, proposed canonical node, optional synthesis proposal, and context pack. -## 21. get_clusters +## 22. get_clusters Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. -## 22. check +## 23. 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 @@ -401,6 +405,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr | How does data flow? | get_processes | | What context should I give an LLM for this task? | get_context_pack | | Show a focused codebase graph | get_codebase_map | +| Which file names lie about behavior? | detect_content_drift | | Which routes bypass canonical dataflow? | analyze_highways | | What files naturally belong together? | get_clusters | | What rule violations exist? | check | @@ -409,7 +414,7 @@ Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppr # CLI Reference -21 commands — 19 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. +22 commands — 20 analysis commands (full parity with MCP tools), `check` for CI rules, and `init` for agent adoption. ## Commands @@ -515,6 +520,12 @@ codebase-intelligence map [--focus ] [--scope ] [--d ``` Focused codebase graph plus token-bounded context pack. Formats: markdown, json, dot, graphml. +### drift +```bash +codebase-intelligence drift [--focus ] [--scope ] [--min-score ] [--json] [--force] +``` +Report-only content drift findings. Returns stable `drift-*` IDs, drift kind, score/severity, declaredIntent, actualBehavior, evidence, recommendation, advisory actions, and baseline status. + ### highways ```bash codebase-intelligence highways [--operation ] [--shape ] [--min-routes ] [--propose] [--trace ] [--json] [--force] diff --git a/llms.txt b/llms.txt index 12233b0..9baf730 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): 22 MCP tools — inputs, outputs, use cases, selection guide +- [MCP Tools](docs/mcp-tools.md): 23 MCP tools — inputs, outputs, use cases, selection guide - [CLI Reference](docs/cli-reference.md): CLI commands, flags, output formats, examples ## Quick Start @@ -17,7 +17,7 @@ MCP mode (AI agents): codebase-intelligence ./path/to/project ``` -CLI mode (humans/CI) — 21 commands (19 analysis with MCP parity + `check` + `init`): +CLI mode (humans/CI) — 22 commands (20 analysis with MCP parity + `check` + `init`): ```bash codebase-intelligence overview ./src codebase-intelligence hotspots ./src --metric coupling @@ -36,6 +36,7 @@ codebase-intelligence impact ./src getUserById codebase-intelligence rename ./src oldName newName codebase-intelligence processes ./src --entry main codebase-intelligence map ./src --focus getUserById --context-budget 800 --json +codebase-intelligence drift ./src --min-score 50 --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 diff --git a/roadmap.md b/roadmap.md index 8c8a309..586aafc 100644 --- a/roadmap.md +++ b/roadmap.md @@ -458,13 +458,20 @@ actual behavior = imports + calls + types + side effects + tests + churn drift score = mismatch(declared intent, actual behavior) ``` -**To do:** +**Status:** CH-P2-04 shipped in the canary train. + +**Shipped:** - Emit `name-drift`, `scope-drift`, `mixed-responsibility`, `hidden-side-effect`, `shape-drift`, `orphan-scope`, and `misplaced-test`. - CLI: add `drift ` with `--focus`, `--scope`, `--min-score`, `--json`. - MCP: add `detect_content_drift`. -- Make first run report-only; allow CI gating only after a baseline exists. -- Keep implementation deterministic: tokenized names, resolved symbols/types, imports/calls, side-effect sinks, tests, churn. +- Make first run report-only with baseline status in JSON. +- 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:** + +- Add drift baselines and CI gating after the first report-only canary so teams can gate only new severe drift. ### Health Score + Maintainability @@ -625,16 +632,17 @@ Competitor names stay in docs only. This section exists to preserve comparison c 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. Name drift command `drift`, `content-drift`, or fold into `check --rule naming/*`? -13. Use report-only first run for drift, then gate only new severe drift? -14. Which doctor profiles ship first: `local`, `ci`, `agent`, `mcp`? -15. Generate agent docs only, or publish first-party Codex/Claude Code skills/plugins from this repo? -16. Keep `.code-visualizer/` ignored forever, or remove it from generated `.gitignore` after one stable release? -17. Name gitignore support `init --gitignore`, a separate command, or something else? -18. Ship `ci` as first-class command, or `check --ci` wrapper? -19. Generate PR markdown only, or integrate with GitHub/GitLab APIs directly? -20. Store baselines in `.codebase-intelligence/baseline.json`, config path, or external artifact? -21. Use one global quality score first, or per-scope scores plus global rollup? +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? + +## 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. --- diff --git a/src/cli.ts b/src/cli.ts index ea1184d..5b05512 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -210,6 +210,12 @@ interface CodebaseMapOptions extends CliCommandOptions { contextBudget?: string; } +interface ContentDriftOptions extends CliCommandOptions { + focus?: string; + scope?: string; + minScore?: string; +} + interface HighwaysOptions extends CliCommandOptions { operation?: string; shape?: string; @@ -691,6 +697,34 @@ program outputOperationText(operations.codebaseMap, result, input); }); +// ── Subcommand: drift ────────────────────────────────────── + +program + .command("drift") + .description("Detect file, folder, side-effect, shape, and test placement drift") + .argument("", "Path to TypeScript codebase") + .option("--focus ", "File, scope, or symbol text to focus on") + .option("--scope ", "Directory/module scope to include") + .option("--min-score ", "Minimum drift score to report (default: 35)") + .option("--json", "Output as JSON") + .option("--force", "Re-index even if HEAD unchanged") + .action((targetPath: string, options: ContentDriftOptions) => { + const input = parseCliOperationInput(operations.contentDrift, { + focus: options.focus, + scope: options.scope, + minScore: optionalNumberInput(options.minScore), + }); + const { graph } = loadGraph(targetPath, forceOption(options)); + const result = runCliOperation(operations.contentDrift, graph, input); + + if (options.json) { + outputJson(result); + return; + } + + outputOperationText(operations.contentDrift, result, input); + }); + // ── Subcommand: highways ─────────────────────────────────── program diff --git a/src/drift/evidence.ts b/src/drift/evidence.ts new file mode 100644 index 0000000..3b71ad2 --- /dev/null +++ b/src/drift/evidence.ts @@ -0,0 +1,30 @@ +import type { ContentDriftEvidenceKind, EvidenceRegistry } from "./types.js"; +import { hashId } from "./tokens.js"; + +function evidenceKey(kind: ContentDriftEvidenceKind, summary: string, file?: string, symbol?: string): string { + return hashId([kind, summary, file ?? "", symbol ?? ""]); +} + +export function createEvidenceRegistry(): EvidenceRegistry { + return { byId: new Map() }; +} + +export function addEvidence( + registry: EvidenceRegistry, + kind: ContentDriftEvidenceKind, + summary: string, + file?: string, + symbol?: string, +): string { + const id = `evidence-${evidenceKey(kind, summary, file, symbol)}`; + if (!registry.byId.has(id)) { + registry.byId.set(id, { id, kind, summary, file, symbol }); + } + return id; +} + +export function evidenceSummaries(registry: EvidenceRegistry, evidenceIds: readonly string[]): string[] { + return evidenceIds + .map((id) => registry.byId.get(id)?.summary) + .filter((summary): summary is string => summary !== undefined); +} diff --git a/src/drift/findings.ts b/src/drift/findings.ts new file mode 100644 index 0000000..a399f57 --- /dev/null +++ b/src/drift/findings.ts @@ -0,0 +1,307 @@ +import { addEvidence, evidenceSummaries } from "./evidence.js"; +import type { + ContentDriftAction, + ContentDriftFinding, + ContentDriftKind, + ContentDriftOptions, + EvidenceRegistry, + FileProfile, + FindingDraft, +} from "./types.js"; +import { DEFAULT_MIN_SCORE } from "./types.js"; +import { + dirname, + firstWords, + hashId, + hasOverlap, + normalizePath, + scoreSeverity, + SIDE_EFFECT_TOKENS, + splitWords, + uniqueSorted, +} from "./tokens.js"; + +function profileActions(profile: FileProfile, kind: ContentDriftKind): ContentDriftAction[] { + const actions: ContentDriftAction[] = [ + { + kind: "inspect-file", + command: `codebase-intelligence file . "${profile.file}" --json`, + description: "Inspect dependency and metric evidence before moving or renaming.", + }, + ]; + + if (kind === "misplaced-test") { + actions.push({ + kind: "move-test", + command: `codebase-intelligence drift . --focus "${profile.file}" --json`, + description: "Re-run drift after moving the test beside the covered implementation.", + }); + } else { + actions.push({ + kind: "rename-or-split", + command: `codebase-intelligence map . --focus "${profile.file}" --json`, + description: "Map the affected neighborhood before renaming or splitting responsibilities.", + }); + } + + actions.push({ + kind: "establish-baseline", + command: "codebase-intelligence drift . --json", + description: "Store a baseline before turning drift into a CI gate.", + }); + + return actions; +} + +function buildContentDriftFinding( + draft: FindingDraft, + profile: FileProfile, + registry: EvidenceRegistry, +): ContentDriftFinding { + const evidence = draft.evidenceIds; + const id = `drift-${hashId([draft.kind, draft.file, draft.scope, ...evidence])}`; + return { + id, + kind: draft.kind, + severity: scoreSeverity(draft.score), + score: Math.min(100, Math.round(draft.score)), + file: draft.file, + scope: draft.scope, + title: draft.title, + recommendation: draft.recommendation, + declaredIntent: draft.declaredIntent, + actualBehavior: draft.actualBehavior, + evidenceIds: evidence, + evidence: evidenceSummaries(registry, evidence), + actions: profileActions(profile, draft.kind), + }; +} + +function nameDrift(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (profile.isTestFile) return undefined; + const fileTokens = splitWords(profile.fileName); + if (fileTokens.length === 0) return undefined; + const behaviorTokens = profile.behavior.tokens; + if (behaviorTokens.length === 0) return undefined; + if (hasOverlap(fileTokens, behaviorTokens)) return undefined; + + const shownBehavior = firstWords(behaviorTokens, 4).join(", "); + const evidenceId = addEvidence( + evidence, + "name", + `File name tokens [${fileTokens.join(", ")}] do not match dominant behavior tokens [${shownBehavior}]`, + profile.file, + ); + return { + kind: "name-drift", + file: profile.file, + scope: profile.scope, + score: 62 + Math.min(18, behaviorTokens.length * 3), + title: `${profile.file} name does not describe its dominant behavior`, + recommendation: `Rename ${profile.fileName} or split behavior around ${shownBehavior}.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function scopeDrift(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (profile.isTestFile) return undefined; + const scopeTokens = splitWords(profile.scope); + if (scopeTokens.length === 0) return undefined; + const behaviorTokens = profile.behavior.tokens; + if (behaviorTokens.length === 0 || hasOverlap(scopeTokens, behaviorTokens)) return undefined; + if (profile.importEdges.length < 2 && (profile.metrics?.tension ?? 0) < 0.3) return undefined; + + const evidenceId = addEvidence( + evidence, + "scope", + `Scope [${scopeTokens.join(", ")}] differs from behavior [${firstWords(behaviorTokens, 5).join(", ")}]`, + profile.file, + ); + return { + kind: "scope-drift", + file: profile.file, + scope: profile.scope, + score: 55 + Math.min(25, Math.round((profile.metrics?.tension ?? 0) * 50) + profile.importEdges.length * 3), + title: `${profile.file} behaves outside its folder scope`, + recommendation: `Move ${profile.file} closer to ${behaviorTokens[0] ?? "its dominant behavior"} or extract the cross-scope behavior.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function mixedResponsibility(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (profile.isTestFile) return undefined; + const domains = profile.behavior.tokens.filter((token) => !profile.declared.tokens.includes(token)); + const uniqueDomains = firstWords(uniqueSorted(domains), 5); + const fanOut = profile.metrics?.fanOut ?? profile.importEdges.length; + const tension = profile.metrics?.tension ?? 0; + if (uniqueDomains.length < 3 || (fanOut < 4 && tension < 0.4 && profile.behavior.sideEffects.length === 0)) return undefined; + + const evidenceId = addEvidence( + evidence, + "metric", + `Behavior spans ${uniqueDomains.length} outside-domain tokens, fan-out ${fanOut}, tension ${tension.toFixed(2)}, and ${profile.behavior.sideEffects.length} side-effect signals`, + profile.file, + ); + return { + kind: "mixed-responsibility", + file: profile.file, + scope: profile.scope, + score: 50 + Math.min(30, uniqueDomains.length * 5 + fanOut * 2), + title: `${profile.file} mixes multiple responsibilities`, + recommendation: `Split ${profile.file} by dominant behavior tokens: ${uniqueDomains.join(", ")}.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function hiddenSideEffect(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (profile.isTestFile || profile.behavior.sideEffects.length === 0) return undefined; + const declaredSideEffects = profile.declared.tokens.filter((token) => SIDE_EFFECT_TOKENS.has(token)); + if (declaredSideEffects.length > 0) return undefined; + + const evidenceId = addEvidence( + evidence, + "calls", + `Side-effect calls/symbols [${profile.behavior.sideEffects.join(", ")}] are not declared by path/name tokens`, + profile.file, + ); + return { + kind: "hidden-side-effect", + file: profile.file, + scope: profile.scope, + score: 72 + Math.min(18, profile.behavior.sideEffects.length * 3), + title: `${profile.file} hides side-effect behavior`, + recommendation: `Move side effects behind an explicit gateway or rename the file to declare ${profile.behavior.sideEffects[0]}.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function shapeDrift(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (profile.isTestFile || profile.behavior.types.length === 0) return undefined; + const typeTokens = uniqueSorted(profile.behavior.types.flatMap((typeName) => splitWords(typeName))); + const outsideTypes = typeTokens.filter((token) => !profile.declared.tokens.includes(token)); + if (outsideTypes.length === 0) return undefined; + if (typeTokens.length > 0 && outsideTypes.length < typeTokens.length) return undefined; + + const evidenceId = addEvidence( + evidence, + "types", + `Type facts mention [${firstWords(profile.behavior.types, 5).join(", ")}] but declared tokens are [${profile.declared.tokens.join(", ")}]`, + profile.file, + ); + return { + kind: "shape-drift", + file: profile.file, + scope: profile.scope, + score: 58 + Math.min(22, outsideTypes.length * 4), + title: `${profile.file} moves shapes its path does not declare`, + recommendation: `Move shape handling near ${outsideTypes[0] ?? "the owning domain"} or rename the boundary.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function misplacedTest(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (!profile.isTestFile) return undefined; + const testedEdges = profile.importEdges.filter((edge) => !edge.isTypeOnly && !edge.target.includes(".spec.") && !edge.target.includes(".test.")); + const misplaced = testedEdges.find((edge) => dirname(edge.target) !== dirname(profile.file)); + if (!misplaced) return undefined; + + const evidenceId = addEvidence( + evidence, + "tests", + `Test ${profile.file} imports implementation ${misplaced.target} from a different scope`, + profile.file, + ); + return { + kind: "misplaced-test", + file: profile.file, + scope: profile.scope, + score: 70, + title: `${profile.file} is not close to the implementation it tests`, + recommendation: `Move the test beside ${misplaced.target} or rename its scope to match the covered implementation.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function orphanScope(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft | undefined { + if (profile.isTestFile || profile.scope === ".") return undefined; + const metrics = profile.metrics; + if (!metrics) return undefined; + if (metrics.fanIn > 0 || metrics.fanOut > 0 || profile.importedBy.length > 0 || profile.importEdges.length > 0) return undefined; + + const evidenceId = addEvidence( + evidence, + "scope", + `Scope ${profile.scope} has no import edges in or out for ${profile.file}`, + profile.file, + ); + return { + kind: "orphan-scope", + file: profile.file, + scope: profile.scope, + score: 42, + title: `${profile.scope} appears disconnected from the codebase graph`, + recommendation: `Keep ${profile.scope} only if it is a deliberate boundary; otherwise merge or delete it after inspection.`, + declaredIntent: profile.declared, + actualBehavior: profile.behavior, + evidenceIds: [evidenceId], + }; +} + +function draftFindings(profile: FileProfile, evidence: EvidenceRegistry): FindingDraft[] { + return [ + nameDrift(profile, evidence), + scopeDrift(profile, evidence), + mixedResponsibility(profile, evidence), + hiddenSideEffect(profile, evidence), + shapeDrift(profile, evidence), + misplacedTest(profile, evidence), + orphanScope(profile, evidence), + ].filter((finding): finding is FindingDraft => finding !== undefined); +} + +function normalizeScopeFilter(scope: string | undefined): string | undefined { + if (!scope) return undefined; + const normalized = normalizePath(scope).replace(/\/+$/, ""); + return normalized.length === 0 ? undefined : `${normalized}/`; +} + +export function buildFindings(profile: FileProfile, evidence: EvidenceRegistry): ContentDriftFinding[] { + return draftFindings(profile, evidence).map((draft) => buildContentDriftFinding(draft, profile, evidence)); +} + +export function matchesFilters(finding: ContentDriftFinding, options: ContentDriftOptions): boolean { + const focus = options.focus?.toLowerCase(); + if (focus) { + const haystack = [ + finding.file, + finding.scope, + finding.title, + ...finding.declaredIntent.exports, + ...finding.actualBehavior.calls, + ...finding.actualBehavior.types, + ].join(" ").toLowerCase(); + if (!haystack.includes(focus)) return false; + } + + const scope = normalizeScopeFilter(options.scope); + if (scope && !finding.file.startsWith(scope) && finding.scope !== scope) return false; + + return finding.score >= (options.minScore ?? DEFAULT_MIN_SCORE); +} + +export function resultSummary(total: number, minScore: number): string { + if (total === 0) return `No content drift findings at or above score ${minScore}.`; + return `${total} content drift findings at or above score ${minScore}. Report-only until a baseline is configured.`; +} diff --git a/src/drift/index.ts b/src/drift/index.ts new file mode 100644 index 0000000..e927d73 --- /dev/null +++ b/src/drift/index.ts @@ -0,0 +1,52 @@ +import type { CodebaseGraph } from "../types/index.js"; +import { createEvidenceRegistry } from "./evidence.js"; +import { buildFindings, matchesFilters, resultSummary } from "./findings.js"; +import { buildProfiles } from "./profiles.js"; +import type { ContentDriftEvidence, ContentDriftOptions, ContentDriftResult } from "./types.js"; +import { DEFAULT_MIN_SCORE } from "./types.js"; + +export { CONTENT_DRIFT_KINDS } from "./types.js"; +export type { + ContentDriftAction, + ContentDriftActionKind, + ContentDriftBaseline, + ContentDriftBehavior, + ContentDriftEvidence, + ContentDriftEvidenceKind, + ContentDriftFinding, + ContentDriftIntent, + ContentDriftKind, + ContentDriftOptions, + ContentDriftResult, + ContentDriftSeverity, +} from "./types.js"; + +export function computeContentDrift(graph: CodebaseGraph, options: ContentDriftOptions = {}): ContentDriftResult { + const minScore = options.minScore ?? DEFAULT_MIN_SCORE; + const evidenceRegistry = createEvidenceRegistry(); + const findings = buildProfiles(graph) + .flatMap((profile) => buildFindings(profile, evidenceRegistry)) + .filter((finding) => matchesFilters(finding, { ...options, minScore })) + .sort((left, right) => right.score - left.score || left.kind.localeCompare(right.kind) || left.file.localeCompare(right.file)); + + const evidenceIds = new Set(findings.flatMap((finding) => finding.evidenceIds)); + const evidence: ContentDriftEvidence[] = [...evidenceRegistry.byId.values()] + .filter((item) => evidenceIds.has(item.id)) + .sort((left, right) => left.id.localeCompare(right.id)); + + return { + mode: "report-only", + baseline: { + status: "not-configured", + requiredForGate: true, + reason: "Content drift is advisory until a drift baseline exists; CI gating is intentionally deferred.", + }, + focus: options.focus, + scope: options.scope, + minScore, + totalFindings: findings.length, + findings, + evidence, + summary: resultSummary(findings.length, minScore), + }; +} diff --git a/src/drift/profiles.ts b/src/drift/profiles.ts new file mode 100644 index 0000000..84a1755 --- /dev/null +++ b/src/drift/profiles.ts @@ -0,0 +1,141 @@ +import type { CodebaseGraph, GraphEdge, SymbolNode } from "../types/index.js"; +import type { ContentDriftBehavior, ContentDriftIntent, FileProfile } from "./types.js"; +import { + basename, + dominantTokens, + scopeOf, + SIDE_EFFECT_TOKENS, + splitWords, + stripExtension, + uniqueSorted, +} from "./tokens.js"; + +function fileNodePaths(graph: CodebaseGraph): string[] { + return graph.nodes + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort(); +} + +function symbolsByFile(graph: CodebaseGraph): Map { + const grouped = new Map(); + for (const symbol of graph.symbolNodes) { + const symbols = grouped.get(symbol.file) ?? []; + symbols.push(symbol); + grouped.set(symbol.file, symbols); + } + for (const symbols of grouped.values()) { + symbols.sort((left, right) => left.name.localeCompare(right.name)); + } + return grouped; +} + +function edgesBySource(edges: readonly GraphEdge[]): Map { + const grouped = new Map(); + for (const edge of edges) { + const group = grouped.get(edge.source) ?? []; + group.push(edge); + grouped.set(edge.source, group); + } + return grouped; +} + +function edgesByTarget(edges: readonly GraphEdge[]): Map { + const grouped = new Map(); + for (const edge of edges) { + const group = grouped.get(edge.target) ?? []; + group.push(edge); + grouped.set(edge.target, group); + } + return grouped; +} + +function calledSymbolsForFile(graph: CodebaseGraph, file: string): string[] { + return uniqueSorted( + graph.callEdges + .filter((edge) => edge.source.startsWith(`${file}::`)) + .map((edge) => edge.calleeSymbol), + ); +} + +function typeNamesForSymbols(symbols: readonly SymbolNode[]): string[] { + const names: string[] = []; + for (const symbol of symbols) { + if (!symbol.typeFacts) continue; + names.push(...symbol.typeFacts.consumes, ...symbol.typeFacts.produces); + } + return uniqueSorted(names); +} + +function declaredIntent(file: string, symbols: readonly SymbolNode[]): ContentDriftIntent { + const fileName = stripExtension(basename(file)); + const scope = scopeOf(file); + const exportNames = uniqueSorted(symbols.filter((symbol) => symbol.isExported).map((symbol) => symbol.name)); + const tokens = uniqueSorted([ + ...splitWords(scope), + ...splitWords(fileName), + ...exportNames.flatMap((name) => splitWords(name)), + ]); + return { + path: file, + fileName, + scope, + tokens, + exports: exportNames, + }; +} + +function behaviorProfile( + graph: CodebaseGraph, + file: string, + symbols: readonly SymbolNode[], + importEdges: readonly GraphEdge[], + importedBy: readonly GraphEdge[], +): ContentDriftBehavior { + const calls = calledSymbolsForFile(graph, file); + const types = typeNamesForSymbols(symbols); + const imports = uniqueSorted(importEdges.map((edge) => edge.target)); + const importTokens = imports.flatMap((target) => splitWords(stripExtension(target))); + const callTokens = calls.flatMap((call) => splitWords(call)); + const typeTokens = types.flatMap((typeName) => splitWords(typeName)); + const exportTokens = symbols.map((symbol) => symbol.name).flatMap((name) => splitWords(name)); + const importedByTokens = importedBy.flatMap((edge) => splitWords(stripExtension(edge.source))); + const sideEffects = uniqueSorted( + [...calls, ...symbols.map((symbol) => symbol.name)] + .filter((name) => splitWords(name).some((token) => SIDE_EFFECT_TOKENS.has(token))), + ); + + return { + tokens: dominantTokens([...importTokens, ...callTokens, ...typeTokens, ...exportTokens, ...importedByTokens]), + imports, + calls, + types, + sideEffects, + tests: importedBy.filter((edge) => edge.symbols.includes("tests")).map((edge) => edge.source).sort(), + }; +} + +export function buildProfiles(graph: CodebaseGraph): FileProfile[] { + const byFile = symbolsByFile(graph); + const bySource = edgesBySource(graph.edges); + const byTarget = edgesByTarget(graph.edges); + + return fileNodePaths(graph).map((file) => { + const symbols = byFile.get(file) ?? []; + const importEdges = bySource.get(file) ?? []; + const importedBy = byTarget.get(file) ?? []; + const metrics = graph.fileMetrics.get(file); + return { + file, + scope: scopeOf(file), + fileName: stripExtension(basename(file)), + declared: declaredIntent(file, symbols), + behavior: behaviorProfile(graph, file, symbols, importEdges, importedBy), + metrics, + importEdges, + importedBy, + symbols, + isTestFile: metrics?.isTestFile ?? /\.(test|spec)\.[cm]?[jt]sx?$/.test(file), + }; + }); +} diff --git a/src/drift/tokens.ts b/src/drift/tokens.ts new file mode 100644 index 0000000..37a5817 --- /dev/null +++ b/src/drift/tokens.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import type { ContentDriftSeverity } from "./types.js"; + +const STOP_WORDS = new Set([ + "app", + "by", + "common", + "config", + "constant", + "constants", + "core", + "data", + "default", + "file", + "folder", + "helper", + "helpers", + "index", + "lib", + "main", + "manager", + "module", + "repo", + "repository", + "route", + "routes", + "scope", + "server", + "service", + "shared", + "src", + "test", + "tests", + "type", + "types", + "util", + "utilities", + "utils", +]); + +export const SIDE_EFFECT_TOKENS = new Set([ + "audit", + "cache", + "delete", + "emit", + "fetch", + "insert", + "log", + "notify", + "publish", + "request", + "save", + "send", + "store", + "update", + "write", +]); + +export function hashId(parts: readonly string[]): string { + return createHash("sha1").update(parts.join("\0")).digest("hex").slice(0, 10); +} + +export function normalizePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\.?\//, ""); +} + +export function stripExtension(file: string): string { + return file + .replace(/\.(test|spec)\.[cm]?[jt]sx?$/, "") + .replace(/\.d\.[cm]?ts$/, "") + .replace(/\.[cm]?[jt]sx?$/, ""); +} + +export function basename(file: string): string { + const normalized = normalizePath(file); + const parts = normalized.split("/"); + return parts[parts.length - 1] ?? normalized; +} + +export function dirname(file: string): string { + const normalized = normalizePath(file); + const idx = normalized.lastIndexOf("/"); + return idx === -1 ? "" : normalized.slice(0, idx + 1); +} + +export function scopeOf(file: string): string { + const dir = dirname(file); + return dir === "" ? "." : dir; +} + +export function splitWords(value: string): string[] { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => word.toLowerCase()) + .map(normalizeToken) + .filter((word) => word.length > 1 && !STOP_WORDS.has(word)); +} + +function normalizeToken(word: string): string { + if (word.length > 4 && word.endsWith("ies")) return `${word.slice(0, -3)}y`; + if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1); + return word; +} + +export function uniqueSorted(values: Iterable): string[] { + return [...new Set(values)].sort(); +} + +export function firstWords(values: readonly string[], limit: number): string[] { + return values.slice(0, limit); +} + +function tokenSet(values: readonly string[]): Set { + return new Set(values); +} + +function overlapCount(left: readonly string[], right: readonly string[]): number { + const rightSet = tokenSet(right); + return left.filter((token) => rightSet.has(token)).length; +} + +export function hasOverlap(left: readonly string[], right: readonly string[]): boolean { + return overlapCount(left, right) > 0; +} + +export function dominantTokens(weighted: readonly string[], limit = 6): string[] { + const counts = new Map(); + for (const token of weighted) { + counts.set(token, (counts.get(token) ?? 0) + 1); + } + return [...counts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, limit) + .map(([token]) => token); +} + +export function scoreSeverity(score: number): ContentDriftSeverity { + if (score >= 75) return "high"; + if (score >= 50) return "medium"; + return "low"; +} diff --git a/src/drift/types.ts b/src/drift/types.ts new file mode 100644 index 0000000..60afc24 --- /dev/null +++ b/src/drift/types.ts @@ -0,0 +1,118 @@ +import type { FileMetrics, GraphEdge, SymbolNode } from "../types/index.js"; + +export const CONTENT_DRIFT_KINDS = [ + "name-drift", + "scope-drift", + "mixed-responsibility", + "hidden-side-effect", + "shape-drift", + "orphan-scope", + "misplaced-test", +] as const; + +export type ContentDriftKind = typeof CONTENT_DRIFT_KINDS[number]; +export type ContentDriftEvidenceKind = "name" | "scope" | "imports" | "calls" | "types" | "tests" | "metric"; +export type ContentDriftSeverity = "low" | "medium" | "high"; +export type ContentDriftActionKind = "inspect-file" | "map-scope" | "rename-or-split" | "move-test" | "establish-baseline"; + +export interface ContentDriftOptions { + focus?: string; + scope?: string; + minScore?: number; +} + +export interface ContentDriftEvidence { + id: string; + kind: ContentDriftEvidenceKind; + summary: string; + file?: string; + symbol?: string; +} + +export interface ContentDriftIntent { + path: string; + fileName: string; + scope: string; + tokens: string[]; + exports: string[]; +} + +export interface ContentDriftBehavior { + tokens: string[]; + imports: string[]; + calls: string[]; + types: string[]; + sideEffects: string[]; + tests: string[]; +} + +export interface ContentDriftAction { + kind: ContentDriftActionKind; + command: string; + description: string; +} + +export interface ContentDriftFinding { + id: string; + kind: ContentDriftKind; + severity: ContentDriftSeverity; + score: number; + file: string; + scope: string; + title: string; + recommendation: string; + declaredIntent: ContentDriftIntent; + actualBehavior: ContentDriftBehavior; + evidenceIds: string[]; + evidence: string[]; + actions: ContentDriftAction[]; +} + +export interface ContentDriftBaseline { + status: "not-configured"; + requiredForGate: true; + reason: string; +} + +export interface ContentDriftResult { + mode: "report-only"; + baseline: ContentDriftBaseline; + focus?: string; + scope?: string; + minScore: number; + totalFindings: number; + findings: ContentDriftFinding[]; + evidence: ContentDriftEvidence[]; + summary: string; +} + +export interface EvidenceRegistry { + byId: Map; +} + +export interface FileProfile { + file: string; + scope: string; + fileName: string; + declared: ContentDriftIntent; + behavior: ContentDriftBehavior; + metrics?: FileMetrics; + importEdges: GraphEdge[]; + importedBy: GraphEdge[]; + symbols: SymbolNode[]; + isTestFile: boolean; +} + +export interface FindingDraft { + kind: ContentDriftKind; + file: string; + scope: string; + score: number; + title: string; + recommendation: string; + evidenceIds: string[]; + declaredIntent: ContentDriftIntent; + actualBehavior: ContentDriftBehavior; +} + +export const DEFAULT_MIN_SCORE = 35; diff --git a/src/mcp/hints.ts b/src/mcp/hints.ts index 5629cf3..3116f24 100644 --- a/src/mcp/hints.ts +++ b/src/mcp/hints.ts @@ -87,6 +87,11 @@ const OPERATION_HINTS: Record = { "Use get_scope_graph when you only need file/scope nodes and edges", "Use symbol_context or file_context on top-ranked context entries before editing", ], + contentDrift: [ + "Use file_context on drift findings before moving or renaming files", + "Use map with the same focus to inspect neighboring files and symbols", + "Create a drift baseline before using drift findings as a CI gate", + ], highways: [ "Use impact_analysis on the proposed canonical node before editing", "Use symbol_context on bypass route entry points to inspect call chains", diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 741710e..b0113e3 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -179,6 +179,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { registerOperationTool(server, graph, operations.rename); registerOperationTool(server, graph, operations.processes); registerOperationTool(server, graph, operations.codebaseMap); + registerOperationTool(server, graph, operations.contentDrift); registerOperationTool(server, graph, operations.highways); registerOperationTool(server, graph, operations.clusters); registerDerivedMapTool( diff --git a/src/operations/formatters.ts b/src/operations/formatters.ts index da30d9c..10c764d 100644 --- a/src/operations/formatters.ts +++ b/src/operations/formatters.ts @@ -22,6 +22,7 @@ import type { import type { HighwaysResult } from "../highways/index.js"; import type { ImpactResult, RenameResult } from "../impact/index.js"; import type { CodebaseMapOptions, CodebaseMapResult } from "../map/index.js"; +import type { ContentDriftResult } from "../drift/index.js"; function text(lines: readonly string[]): string { return lines.join("\n"); @@ -684,6 +685,37 @@ export function formatCodebaseMapText(result: CodebaseMapResult, input: Codebase return text(lines); } +/** + * Format a content drift operation result for human CLI output. + */ +export function formatContentDriftText(result: ContentDriftResult): string { + const lines = [ + `Content Drift (${result.findings.length} of ${result.totalFindings})`, + "─".repeat(34), + `Mode: ${result.mode}`, + `Baseline: ${result.baseline.status}`, + `Min score: ${result.minScore}`, + result.focus ? `Focus: ${result.focus}` : "", + result.scope ? `Scope: ${result.scope}` : "", + "", + result.summary, + ].filter(Boolean); + + for (const finding of result.findings) { + lines.push( + "", + `${finding.id} [${finding.severity}] ${finding.kind} (${finding.score})`, + ` File: ${finding.file}`, + ` Intent: ${finding.declaredIntent.tokens.length > 0 ? finding.declaredIntent.tokens.join(", ") : "none"}`, + ` Behavior: ${finding.actualBehavior.tokens.length > 0 ? finding.actualBehavior.tokens.join(", ") : "none"}`, + ` Evidence: ${finding.evidenceIds.join(", ")}`, + ` Next: ${finding.actions[0]?.command ?? "Inspect manually"}`, + ); + } + + return text(lines); +} + /** * Format a highways operation result for human CLI output. */ diff --git a/src/operations/index.ts b/src/operations/index.ts index 71682c1..578131e 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -23,10 +23,12 @@ import { import { DUPLICATION_MODES } from "../duplication/index.js"; import { computeHighways } from "../highways/index.js"; import { CODEBASE_MAP_FORMATS, computeCodebaseMap } from "../map/index.js"; +import { computeContentDrift } from "../drift/index.js"; import type { CodebaseGraph } from "../types/index.js"; import { formatCodebaseMapText, formatChangesText, + formatContentDriftText, formatClustersText, formatDeadExportsText, formatDependentsText, @@ -64,6 +66,7 @@ export const operationNames = [ "rename", "processes", "codebaseMap", + "contentDrift", "highways", "clusters", ] as const; @@ -171,6 +174,12 @@ const codebaseMapInputShape = { contextBudget: z.number().int().positive().optional().describe("Approximate token budget for the context pack (default: 1200)"), } satisfies z.ZodRawShape; const codebaseMapInputSchema = z.object(codebaseMapInputShape).strict(); +const contentDriftInputShape = { + focus: z.string().min(1).optional().describe("File, scope, or symbol text to focus drift findings on"), + scope: z.string().min(1).optional().describe("Directory/module scope to include"), + minScore: z.number().min(0).max(100).optional().describe("Minimum drift score to report (default: 35)"), +} satisfies z.ZodRawShape; +const contentDriftInputSchema = z.object(contentDriftInputShape).strict(); const highwaysInputShape = { operation: z.string().min(1).optional().describe("Operation verb to focus on, such as create, update, or validate"), shape: z.string().min(1).optional().describe("Type/DTO shape to focus on"), @@ -201,6 +210,7 @@ type ImpactInput = z.infer; type RenameInput = z.infer; type ProcessesInput = z.infer; export type CodebaseMapInput = z.infer; +type ContentDriftInput = z.infer; type HighwaysInput = z.infer; type ClustersInput = z.infer; @@ -378,6 +388,16 @@ export const operations = { run: (graph: CodebaseGraph, input: CodebaseMapInput) => computeCodebaseMap(graph, input), formatText: formatCodebaseMapText, } satisfies Operation>, + contentDrift: { + name: "contentDrift", + cliCommand: "drift", + mcpTool: "detect_content_drift", + description: "Detect deterministic file, folder, side-effect, shape, and test placement drift. Use when: checking whether names and scopes still match behavior before refactors or CI baselining. Not for: enforcing CI gates without a baseline (use check or future ci wrapper)", + inputShape: contentDriftInputShape, + inputSchema: contentDriftInputSchema, + run: (graph: CodebaseGraph, input: ContentDriftInput) => computeContentDrift(graph, input), + formatText: formatContentDriftText, + } satisfies Operation>, highways: { name: "highways", cliCommand: "highways", diff --git a/tests/drift.e2e.test.ts b/tests/drift.e2e.test.ts new file mode 100644 index 0000000..caa80f0 --- /dev/null +++ b/tests/drift.e2e.test.ts @@ -0,0 +1,281 @@ +import { execFile, execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { beforeAll, describe, expect, it } from "vitest"; + +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; +} + +interface DriftFinding { + id: string; + kind: string; + score: number; + file: string; + evidenceIds: string[]; + recommendation: string; + actions: Array<{ kind: string; command: string }>; +} + +interface DriftEvidence { + id: string; + kind: string; + summary: string; +} + +interface DriftPayload { + mode: "report-only"; + baseline: { status: "not-configured"; requiredForGate: true }; + minScore: number; + totalFindings: number; + findings: DriftFinding[]; + evidence: DriftEvidence[]; + cache?: unknown; + nextSteps?: unknown; +} + +beforeAll(() => { + execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); +}, 120_000); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isDriftPayload(value: unknown): value is DriftPayload { + return isRecord(value) + && value.mode === "report-only" + && isRecord(value.baseline) + && Array.isArray(value.findings) + && Array.isArray(value.evidence); +} + +function parsePayload(stdout: string): DriftPayload { + const parsed: unknown = JSON.parse(stdout); + if (!isDriftPayload(parsed)) throw new Error("Expected content drift JSON object"); + return parsed; +} + +function withoutRuntimeFields(payload: DriftPayload): Omit { + const copy = { ...payload }; + delete copy.cache; + delete copy.nextSteps; + return copy; +} + +function textPayload(result: unknown): Record { + if (!isRecord(result) || !Array.isArray(result.content)) throw new Error("MCP result did not include content"); + const first = result.content[0]; + if (!isRecord(first) || typeof first.text !== "string") throw new Error("MCP result did not include text content"); + const parsed: unknown = JSON.parse(first.text); + if (!isRecord(parsed)) throw new Error("MCP text content was not a JSON object"); + return parsed; +} + +async function run(args: readonly string[]): Promise { + try { + const { stdout, stderr } = await pexec("node", [cli, ...args], { + cwd: repoRoot, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }); + return { status: 0, stdout, stderr }; + } catch (e) { + const err = isRecord(e) ? e : {}; + return { + status: typeof err.code === "number" ? err.code : 1, + stdout: typeof err.stdout === "string" ? err.stdout : "", + stderr: typeof err.stderr === "string" ? err.stderr : "", + }; + } +} + +function writeFile(root: string, relative: string, content: string): void { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function createDriftFixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-drift-")); + const src = path.join(root, "src"); + + writeFile(src, "users/profile.ts", ` +export interface UserProfile { + id: string; + email: string; +} + +export function createUserProfile(id: string): UserProfile { + return { id, email: \`\${id}@example.test\` }; +} + +export function getUserProfile(id: string): UserProfile { + return createUserProfile(id); +} +`); + + writeFile(src, "audit/audit-log.ts", ` +export function writeAuditLog(event: string): void { + void event; +} +`); + + writeFile(src, "mailer/email.ts", ` +import { writeAuditLog } from "../audit/audit-log"; +import type { UserProfile } from "../users/profile"; + +export function sendWelcomeEmail(profile: UserProfile): void { + writeAuditLog(profile.id); +} +`); + + writeFile(src, "billing/invoice-calculator.ts", ` +import { writeAuditLog } from "../audit/audit-log"; +import { sendWelcomeEmail } from "../mailer/email"; +import type { UserProfile } from "../users/profile"; + +export function calculateInvoice(profile: UserProfile): number { + writeAuditLog(profile.id); + return profile.email.length; +} + +export function validateUserAccess(profile: UserProfile): boolean { + return profile.id.length > 0; +} + +export function prepareWelcomeEmail(profile: UserProfile): void { + sendWelcomeEmail(profile); +} +`); + + writeFile(src, "auth/session.ts", ` +import { writeAuditLog } from "../audit/audit-log"; +import { calculateInvoice } from "../billing/invoice-calculator"; +import type { UserProfile } from "../users/profile"; + +export function refreshSession(profile: UserProfile): number { + writeAuditLog(profile.id); + return calculateInvoice(profile); +} +`); + + writeFile(src, "utils/formatter.ts", ` +import { writeAuditLog } from "../audit/audit-log"; + +export function formatDisplayName(name: string): string { + writeAuditLog(name); + return name.trim().toUpperCase(); +} +`); + + writeFile(src, "reports/session.spec.ts", ` +import { refreshSession } from "../auth/session"; +import { createUserProfile } from "../users/profile"; + +refreshSession(createUserProfile("1")); +`); + + writeFile(src, "orphans/lonely.ts", ` +export function lonelyFeature(): string { + return "alone"; +} +`); + + return src; +} + +function assertEvidenceReferences(payload: DriftPayload): void { + const evidenceIds = new Set(payload.evidence.map((item) => item.id)); + expect(evidenceIds.size).toBe(payload.evidence.length); + for (const evidence of payload.evidence) { + expect(evidence.id).toMatch(/^evidence-[a-f0-9]{10}$/); + expect(evidence.summary.length).toBeGreaterThan(0); + } + for (const finding of payload.findings) { + expect(finding.id).toMatch(/^drift-[a-f0-9]{10}$/); + expect(finding.score).toBeGreaterThanOrEqual(payload.minScore); + expect(finding.evidenceIds.length).toBeGreaterThan(0); + expect(finding.evidenceIds.every((id) => evidenceIds.has(id))).toBe(true); + expect(finding.recommendation.length).toBeGreaterThan(0); + expect(finding.actions.length).toBeGreaterThan(0); + } +} + +describe("CH-P2-04 content drift", () => { + it("detects deterministic drift evidence through CLI and MCP in report-only mode", async () => { + const src = createDriftFixture(); + try { + const args = ["drift", src, "--min-score", "35", "--json", "--force"]; + const cliRun = await run(args); + expect(cliRun.status).toBe(0); + expect(cliRun.stderr).toContain("Parsed"); + + const cliPayload = parsePayload(cliRun.stdout); + expect(cliPayload.mode).toBe("report-only"); + expect(cliPayload.baseline).toMatchObject({ status: "not-configured", requiredForGate: true }); + expect(cliPayload.minScore).toBe(35); + expect(cliPayload.totalFindings).toBe(cliPayload.findings.length); + assertEvidenceReferences(cliPayload); + + const kinds = [...new Set(cliPayload.findings.map((finding) => finding.kind))]; + expect(kinds).toEqual(expect.arrayContaining([ + "name-drift", + "scope-drift", + "mixed-responsibility", + "hidden-side-effect", + "shape-drift", + "orphan-scope", + "misplaced-test", + ])); + + const scopedRun = await run(["drift", src, "--scope", "auth", "--min-score", "35", "--json"]); + expect(scopedRun.status).toBe(0); + const scopedPayload = parsePayload(scopedRun.stdout); + expect(scopedPayload.findings.length).toBeGreaterThan(0); + expect(scopedPayload.findings.every((finding) => finding.file.startsWith("auth/"))).toBe(true); + + const stableRun = await run(args); + expect(stableRun.status).toBe(0); + expect(parsePayload(stableRun.stdout).findings.map((finding) => finding.id)).toEqual( + cliPayload.findings.map((finding) => finding.id), + ); + + const transport = new StdioClientTransport({ + command: "node", + args: [cli, src, "--force"], + cwd: repoRoot, + stderr: "pipe", + }); + const client = new Client({ name: "drift-e2e", version: "0.1.0" }); + await client.connect(transport); + try { + const result = await client.callTool({ + name: "detect_content_drift", + arguments: { minScore: 35 }, + }); + const mcpPayload = textPayload(result); + if (!isDriftPayload(mcpPayload)) throw new Error("Expected MCP drift payload"); + expect(withoutRuntimeFields(mcpPayload)).toEqual(withoutRuntimeFields(cliPayload)); + expect(mcpPayload).toHaveProperty("nextSteps"); + } finally { + await client.close(); + await transport.close(); + } + } finally { + fs.rmSync(path.dirname(src), { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index 08576d9..f61d7eb 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -483,7 +483,20 @@ describe("Tool 18: get_context_pack", () => { }); }); -describe("Tool 19: analyze_highways", () => { +describe("Tool 19: detect_content_drift", () => { + it("returns report-only drift findings with evidence", async () => { + const r = await callTool("detect_content_drift", { minScore: 35 }); + expect(r).toHaveProperty("mode", "report-only"); + expect(r).toHaveProperty("baseline"); + expect(r).toHaveProperty("findings"); + expect(r).toHaveProperty("evidence"); + expect(r).toHaveProperty("nextSteps"); + expect(Array.isArray(r.findings)).toBe(true); + expect(Array.isArray(r.evidence)).toBe(true); + }); +}); + +describe("Tool 20: analyze_highways", () => { it("returns highway opportunities envelope", async () => { const r = await callTool("analyze_highways", { operation: "get", minRoutes: 2 }); expect(r).toHaveProperty("totalRoutes"); @@ -495,7 +508,7 @@ describe("Tool 19: analyze_highways", () => { }); }); -describe("Tool 20: get_clusters", () => { +describe("Tool 21: get_clusters", () => { it("returns community-detected clusters", async () => { const r = await callTool("get_clusters"); expect(r).toHaveProperty("clusters"); @@ -576,6 +589,7 @@ describe("MCP Resources", () => { expect(availableTools).toContain("get_codebase_map"); expect(availableTools).toContain("get_scope_graph"); expect(availableTools).toContain("get_context_pack"); + expect(availableTools).toContain("detect_content_drift"); expect(availableTools).toContain("analyze_highways"); expect(availableTools).toContain("check"); }); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index 494e751..e38180d 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -335,6 +335,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliMatchesRegistry( + operations.contentDrift, + { scope: "users", minScore: 35 }, + ["drift", getFixtureSrcPath(), "--scope", "users", "--min-score", "35"], + codebaseGraph, + {}, + cachedRun, + ); expectCliMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, @@ -473,6 +481,14 @@ describe("operation registry chained parity", () => { {}, cachedRun, ); + expectCliTextMatchesFormatter( + operations.contentDrift, + { scope: "users", minScore: 35 }, + ["drift", getFixtureSrcPath(), "--scope", "users", "--min-score", "35"], + codebaseGraph, + {}, + cachedRun, + ); expectCliTextMatchesFormatter( operations.highways, { operation: "get", minRoutes: 2 }, @@ -748,6 +764,13 @@ describe("operation registry chained parity", () => { codebaseGraph, mcp, ); + await expectMcpMatchesRegistry( + operations.contentDrift, + { scope: "users", minScore: 35 }, + { scope: "users", minScore: 35 }, + codebaseGraph, + mcp, + ); await expectMcpMatchesRegistry( operations.highways, { operation: "get", minRoutes: 2 }, diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index 89ec912..a8cc724 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -40,6 +40,7 @@ const expectedOperations: Array<{ { name: "rename", cliCommand: "rename", mcpTool: "rename_symbol", inputKeys: ["oldName", "newName", "dryRun"], sampleInput: { oldName: "getUserById", newName: "findUserById" } }, { name: "processes", cliCommand: "processes", mcpTool: "get_processes", inputKeys: ["entryPoint", "limit"], sampleInput: { entryPoint: "main" } }, { name: "codebaseMap", cliCommand: "map", mcpTool: "get_codebase_map", inputKeys: ["focus", "scope", "depth", "format", "contextBudget"], sampleInput: { focus: "getUserById", depth: 1, contextBudget: 420 } }, + { name: "contentDrift", cliCommand: "drift", mcpTool: "detect_content_drift", inputKeys: ["focus", "scope", "minScore"], sampleInput: { scope: "users", minScore: 35 } }, { 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 } }, ]; diff --git a/tools/verify-cli-real-codebases.mjs b/tools/verify-cli-real-codebases.mjs index 4438da3..3f95995 100644 --- a/tools/verify-cli-real-codebases.mjs +++ b/tools/verify-cli-real-codebases.mjs @@ -372,7 +372,7 @@ for (const inputTarget of targets) { return `${result.totalAffected} affected`; }); - for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "map", "highways", "clusters"]) { + for (const command of ["modules", "forces", "dead-exports", "opportunities", "groups", "processes", "map", "drift", "highways", "clusters"]) { record(`${target.name}: ${command}`, () => { const args = command === "highways" ? [command, target.path, "--operation", "get", "--min-routes", "3"] @@ -384,6 +384,9 @@ for (const inputTarget of targets) { if (command === "map" && (!result.contextPack || !Array.isArray(result.nodes))) { throw new Error("bad map payload"); } + if (command === "drift" && (result.mode !== "report-only" || !Array.isArray(result.findings))) { + throw new Error("bad drift payload"); + } return "json ok"; }); }