Skip to content

Commit 2e952f9

Browse files
authored
feat(rules): config + ESLint-style rules engine with check command (#42)
Implements `specs/backlog/2026-06-02-config-rules-engine.md` end-to-end. ## What - **Config** (`src/config`) — discovery (`codebase-intelligence.json` + dotfile variants + `package.json#codebaseIntelligence`, walk-up), zod validation against the committed `schema.json`, CLI overrides, `ConfigError` → exit 2. - **Rules engine** (`src/rules/engine.ts`) — pluggable `Rule` contract, ESLint-style severities (`off|warn|error` or `0|1|2`, `[severity, options]`), `// ci-ignore-file` / `// ci-ignore-next-line <rules>` suppressions, stable fingerprints, deterministic ordering. - **Rules** — `no-comments` (the requested one; configurable — keeps JSDoc, tool/compiler directives, and a file-leading license header by default; `style: line|block|all`), `no-circular-deps`, `no-dead-exports`. - **Formatters** — `text`, `json`, `sarif`. - **CLI `check`** — exit codes `0` clean / `1` findings ≥ failOn / `2` config or usage error; flags `--format`/`--json`/`--config`/`--fail-on`/`--gate`/`--base`/`--quiet`/`--summary`. - **MCP `check` tool** — returns `{verdict, summary, findings}`. Read-only — `actions[]` are advisory hints, never applied (per roadmap §3). - **Fix:** `getGitChurn`/`getHeadHash` no longer leak git's stderr on non-git dirs. ## Tests (38 new, no internal mocks) - `config-loader` — discovery, package.json key, walk-up, invalid JSON / unknown-key → `ConfigError`, overrides. - `rules-engine` — real parser→graph→analyzer→engine pipeline: no-comments option matrix, suppressions, circular/dead-export rules, formatters, verdict gating. - `cli-check.e2e` — spawns the real built binary, asserts exit codes + stdout (json, sarif, suppression, config error, `--config`). - `mcp-check` — real in-memory MCP server + client. ## Validation - `pnpm lint` clean · `pnpm typecheck` clean · `pnpm build` clean. - `pnpm vitest run` (parallel): **380 passed, 0 errors**. - Coverage **95.71%** lines / 86.8% branches (thresholds 80/70/80/80); `src/rules` 97.5%. > Note: the local `--coverage --no-file-parallelism` run hit vitest's known `onTaskUpdate` worker-RPC timeout under heavy serial load on a loaded dev box (suite 460s vs 35s parallel). All tests pass and coverage thresholds are met; CI's dedicated runner is the authoritative gate for the serial coverage step. ## Not in scope (follow-ups) Boundary rules engine, audit `new-only` diffing, more formatters (codeclimate/pr-comment), `init`/`hooks`/`watch` — speced, not built here.
1 parent 2e67941 commit 2e952f9

23 files changed

Lines changed: 1634 additions & 15 deletions

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ src/
1212
parser/index.ts <- TS Compiler API parser (files, functions, imports)
1313
graph/index.ts <- graphology graph builder + circular dep detection
1414
analyzer/index.ts <- Metrics engine (PageRank, betweenness, cohesion, tension, churn, complexity, blast radius, dead exports)
15-
mcp/index.ts <- MCP stdio server (15 tools, 2 prompts, 3 resources)
15+
mcp/index.ts <- MCP stdio server (16 tools, 2 prompts, 3 resources)
1616
mcp/hints.ts <- Next-step hints for MCP tool responses
1717
server/graph-store.ts <- Global graph state (shared by CLI + MCP)
1818
impact/index.ts <- Symbol-level impact analysis + rename planning
@@ -21,12 +21,14 @@ src/
2121
community/index.ts <- Louvain clustering
2222
persistence/index.ts <- Graph export/import to .code-visualizer/
2323
install/index.ts <- Agent adoption: managed-block engine + per-agent files + skill (init)
24+
config/index.ts <- Config discovery + zod validation (codebase-intelligence.json)
25+
rules/index.ts <- Rules engine + registry (check command + MCP check tool)
2426
cli.ts <- CLI entry point (commander)
2527
docs/
2628
architecture.md <- Pipeline, module map, data flow, design decisions
2729
data-model.md <- All TypeScript interfaces with field descriptions
2830
metrics.md <- Per-file + module metrics, force analysis, complexity scoring
29-
mcp-tools.md <- 15 MCP tools: inputs, outputs, use cases, selection guide
31+
mcp-tools.md <- 16 MCP tools: inputs, outputs, use cases, selection guide
3032
specs/
3133
active/ <- Current spec
3234
```
@@ -149,7 +151,7 @@ LLM knowledge base for building this tool. Single source of truth per topic:
149151
| `docs/architecture.md` | Pipeline, module map, data flow, design decisions | New module or pipeline change |
150152
| `docs/data-model.md` | All TypeScript interfaces (mirrors `src/types/index.ts`) | Type changes |
151153
| `docs/metrics.md` | Per-file + module metrics, force analysis, complexity scoring | New metric added |
152-
| `docs/mcp-tools.md` | 15 MCP tools with inputs/outputs/use cases | New tool or param change |
154+
| `docs/mcp-tools.md` | 16 MCP tools with inputs/outputs/use cases | New tool or param change |
153155

154156
## Testing (BLOCKING)
155157

codebase-intelligence.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"quiet": false
4343
},
4444
"ci": {
45-
"gate": "new-only",
45+
"gate": "all",
4646
"failOn": "error",
4747
"maxWarnings": -1
4848
}

docs/architecture.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Core (shared computation)
2222
| result builders used by both MCP and CLI
2323
v
2424
MCP (stdio) CLI (terminal/CI)
25-
| 15 tools, 2 prompts, | 5 commands: overview, hotspots,
25+
| 16 tools, 2 prompts, | 5 commands: overview, hotspots,
2626
| 3 resources for LLMs | file, search, changes + --json
2727
```
2828

@@ -35,7 +35,9 @@ src/
3535
graph/index.ts <- graphology graph + circular dep detection
3636
analyzer/index.ts <- All metric computation
3737
core/index.ts <- Shared result computation (MCP + CLI)
38-
mcp/index.ts <- 15 MCP tools for LLM integration
38+
config/index.ts <- Config discovery + zod validation
39+
rules/index.ts <- Rules engine + registry (check command + MCP check tool)
40+
mcp/index.ts <- 16 MCP tools for LLM integration
3941
mcp/hints.ts <- Next-step hints for MCP tool responses
4042
impact/index.ts <- Symbol-level impact analysis + rename planning
4143
search/index.ts <- BM25 search engine
@@ -64,7 +66,7 @@ analyzeGraph(builtGraph, parsedFiles)
6466
}
6567
6668
startMcpServer(codebaseGraph)
67-
-> stdio MCP server with 15 tools, 2 prompts, 3 resources
69+
-> stdio MCP server with 16 tools, 2 prompts, 3 resources
6870
```
6971

7072
## Key Design Decisions

docs/mcp-tools.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# MCP Tools Reference
22

3-
15 tools available via MCP stdio.
3+
16 tools available via MCP stdio.
44

55
## 1. codebase_overview
66

@@ -155,6 +155,18 @@ Community-detected clusters of related files.
155155
**Use when:** "What files are related?" "Find natural groupings." Discovering emergent groupings that differ from directory structure.
156156
**Not for:** Directory-based modules (use get_module_structure).
157157

158+
## 16. check
159+
160+
Run the configurable rules engine and gate on findings.
161+
162+
**Input:** `{}` (uses the loaded graph + discovered config)
163+
**Returns:** `{ verdict: "pass"|"warn"|"fail", summary: { error, warn, rules }, configPath, findings[] }`. Each finding has ruleId, severity, file, line, column, message, fingerprint, and optional advisory `actions[]` (the tool is read-only — actions are hints, never applied).
164+
165+
Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn). Configure severities and options in `codebase-intelligence.json` (validated by `schema.json`).
166+
167+
**Use when:** Linting a codebase or enforcing a CI gate. "What rule violations exist?"
168+
**Not for:** Architecture metrics (use analyze_forces).
169+
158170
## MCP Prompts
159171

160172
| Prompt | Description |
@@ -190,3 +202,4 @@ Community-detected clusters of related files.
190202
| "How does data flow through the app?" | `get_processes` |
191203
| "What files naturally belong together?" | `get_clusters` |
192204
| "What are the main areas?" | `get_groups` |
205+
| "What rule violations exist? Lint this." | `check` |

schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196
},
197197
"allow": {
198198
"type": "array",
199-
"description": "Additional substrings/patterns whose comments are allowed (e.g. 'TODO', 'FIXME', '@public').",
199+
"description": "Allow comments whose body (delimiters stripped) starts with one of these strings, e.g. 'TODO', 'FIXME', '@public'.",
200200
"items": { "type": "string" }
201201
}
202202
}

src/cli.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { parseCodebase } from "./parser/index.js";
2121
import { buildGraph } from "./graph/index.js";
2222
import { analyzeGraph } from "./analyzer/index.js";
2323
import { startMcpServer } from "./mcp/index.js";
24-
import { setIndexedHead } from "./server/graph-store.js";
24+
import { setIndexedHead, setRoot } from "./server/graph-store.js";
2525
import { exportGraph, importGraph } from "./persistence/index.js";
2626
import {
2727
computeOverview,
@@ -47,7 +47,10 @@ import {
4747
ALL_AGENT_IDS,
4848
} from "./install/index.js";
4949
import { promptSelection } from "./install/prompt.js";
50-
import type { CodebaseGraph } from "./types/index.js";
50+
import { runCheck, exitCodeFor } from "./rules/check.js";
51+
import { formatResult, formatSummaryLine } from "./rules/format.js";
52+
import { ConfigError } from "./config/index.js";
53+
import type { CodebaseGraph, OutputFormat } from "./types/index.js";
5154

5255
const INDEX_DIR_NAME = ".code-visualizer";
5356

@@ -63,6 +66,7 @@ function getHeadHash(targetPath: string): string {
6366
cwd: path.resolve(targetPath),
6467
encoding: "utf-8",
6568
timeout: 5000,
69+
stdio: ["ignore", "pipe", "ignore"],
6670
}).trim();
6771
} catch {
6872
return "unknown";
@@ -88,6 +92,7 @@ function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; h
8892
process.stderr.write(`Error: Path does not exist: ${targetPath}\n`);
8993
process.exit(1);
9094
}
95+
setRoot(resolved);
9196

9297
const indexDir = getIndexDir(targetPath);
9398
const headHash = getHeadHash(targetPath);
@@ -1011,6 +1016,90 @@ program
10111016
output(`Re-run anytime — writes are idempotent (managed blocks only).`);
10121017
});
10131018

1019+
// ── Subcommand: check ──────────────────────────────────────
1020+
1021+
interface CheckOptions extends CliCommandOptions {
1022+
config?: string;
1023+
format?: string;
1024+
failOn?: string;
1025+
gate?: string;
1026+
base?: string;
1027+
quiet?: boolean;
1028+
summary?: boolean;
1029+
}
1030+
1031+
function resolveCheckFormat(options: CheckOptions): OutputFormat | null {
1032+
if (options.json) return "json";
1033+
if (!options.format) return "text";
1034+
if (options.format === "json" || options.format === "sarif" || options.format === "text") {
1035+
return options.format;
1036+
}
1037+
return null;
1038+
}
1039+
1040+
function parseFailOn(value: string | undefined): "error" | "warn" | "never" | undefined | false {
1041+
if (value === undefined) return undefined;
1042+
if (value === "error" || value === "warn" || value === "never") return value;
1043+
return false;
1044+
}
1045+
1046+
function parseGate(value: string | undefined): "all" | "new-only" | undefined {
1047+
return value === "all" || value === "new-only" ? value : undefined;
1048+
}
1049+
1050+
program
1051+
.command("check")
1052+
.description("Run the rules engine and gate on findings (comments, circular deps, dead exports)")
1053+
.argument("<path>", "Path to TypeScript codebase")
1054+
.option("--config <path>", "Config file path (overrides discovery)")
1055+
.option("--format <fmt>", "Output: text, json, or sarif (default: text)")
1056+
.option("--fail-on <severity>", "Severity that fails the gate: error, warn, never")
1057+
.option("--gate <mode>", "Gate mode: all or new-only")
1058+
.option("--base <ref>", "Base git ref for new-only gating")
1059+
.option("--quiet", "Suppress output when the result passes")
1060+
.option("--summary", "Print summary counts only")
1061+
.option("--json", "Shortcut for --format json")
1062+
.option("--force", "Re-index even if HEAD unchanged")
1063+
.action((targetPath: string, options: CheckOptions) => {
1064+
const format = resolveCheckFormat(options);
1065+
if (!format) {
1066+
process.stderr.write("Error: --format must be one of: text, json, sarif\n");
1067+
process.exit(2);
1068+
}
1069+
1070+
const failOn = parseFailOn(options.failOn);
1071+
if (failOn === false) {
1072+
process.stderr.write("Error: --fail-on must be one of: error, warn, never\n");
1073+
process.exit(2);
1074+
}
1075+
1076+
try {
1077+
const { graph } = loadGraph(targetPath, options.force);
1078+
const result = runCheck(graph, path.resolve(targetPath), {
1079+
configPath: options.config,
1080+
format,
1081+
failOn,
1082+
gate: parseGate(options.gate),
1083+
base: options.base,
1084+
quiet: options.quiet,
1085+
summary: options.summary,
1086+
});
1087+
1088+
const silent = options.quiet === true && result.verdict === "pass";
1089+
if (!silent) {
1090+
output(options.summary ? formatSummaryLine(result) : formatResult(result, format));
1091+
}
1092+
1093+
process.exit(exitCodeFor(result));
1094+
} catch (err) {
1095+
if (err instanceof ConfigError) {
1096+
process.stderr.write(`Config error: ${err.message}\n`);
1097+
process.exit(2);
1098+
}
1099+
throw err;
1100+
}
1101+
});
1102+
10141103
// ── MCP fallback (backward compat) ──────────────────────────
10151104

10161105
program
@@ -1025,6 +1114,7 @@ program
10251114

10261115
async function runMcpMode(targetPath: string, options: McpOptions): Promise<void> {
10271116
const indexDir = getIndexDir(targetPath);
1117+
setRoot(path.resolve(targetPath));
10281118

10291119
if (options.clean) {
10301120
if (fs.existsSync(indexDir)) {

0 commit comments

Comments
 (0)