diff --git a/CLAUDE.md b/CLAUDE.md index 1a49111..17db831 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ src/ parser/index.ts <- TS Compiler API parser (files, functions, imports) graph/index.ts <- graphology graph builder + circular dep detection analyzer/index.ts <- Metrics engine (PageRank, betweenness, cohesion, tension, churn, complexity, blast radius, dead exports) - mcp/index.ts <- MCP stdio server (15 tools, 2 prompts, 3 resources) + mcp/index.ts <- MCP stdio server (16 tools, 2 prompts, 3 resources) mcp/hints.ts <- Next-step hints for MCP tool responses server/graph-store.ts <- Global graph state (shared by CLI + MCP) impact/index.ts <- Symbol-level impact analysis + rename planning @@ -21,12 +21,14 @@ src/ community/index.ts <- Louvain clustering persistence/index.ts <- Graph export/import to .code-visualizer/ install/index.ts <- Agent adoption: managed-block engine + per-agent files + skill (init) + config/index.ts <- Config discovery + zod validation (codebase-intelligence.json) + rules/index.ts <- Rules engine + registry (check command + MCP check tool) cli.ts <- CLI entry point (commander) docs/ architecture.md <- Pipeline, module map, data flow, design decisions data-model.md <- All TypeScript interfaces with field descriptions metrics.md <- Per-file + module metrics, force analysis, complexity scoring - mcp-tools.md <- 15 MCP tools: inputs, outputs, use cases, selection guide + mcp-tools.md <- 16 MCP tools: inputs, outputs, use cases, selection guide specs/ active/ <- Current spec ``` @@ -149,7 +151,7 @@ LLM knowledge base for building this tool. Single source of truth per topic: | `docs/architecture.md` | Pipeline, module map, data flow, design decisions | New module or pipeline change | | `docs/data-model.md` | All TypeScript interfaces (mirrors `src/types/index.ts`) | Type changes | | `docs/metrics.md` | Per-file + module metrics, force analysis, complexity scoring | New metric added | -| `docs/mcp-tools.md` | 15 MCP tools with inputs/outputs/use cases | New tool or param change | +| `docs/mcp-tools.md` | 16 MCP tools with inputs/outputs/use cases | New tool or param change | ## Testing (BLOCKING) diff --git a/codebase-intelligence.json b/codebase-intelligence.json index 9903267..bd0c100 100644 --- a/codebase-intelligence.json +++ b/codebase-intelligence.json @@ -42,7 +42,7 @@ "quiet": false }, "ci": { - "gate": "new-only", + "gate": "all", "failOn": "error", "maxWarnings": -1 } diff --git a/docs/architecture.md b/docs/architecture.md index 47ee643..f1eb2ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ Core (shared computation) | result builders used by both MCP and CLI v MCP (stdio) CLI (terminal/CI) - | 15 tools, 2 prompts, | 5 commands: overview, hotspots, + | 16 tools, 2 prompts, | 5 commands: overview, hotspots, | 3 resources for LLMs | file, search, changes + --json ``` @@ -35,7 +35,9 @@ src/ graph/index.ts <- graphology graph + circular dep detection analyzer/index.ts <- All metric computation core/index.ts <- Shared result computation (MCP + CLI) - mcp/index.ts <- 15 MCP tools for LLM integration + config/index.ts <- Config discovery + zod validation + rules/index.ts <- Rules engine + registry (check command + MCP check tool) + mcp/index.ts <- 16 MCP tools for LLM integration mcp/hints.ts <- Next-step hints for MCP tool responses impact/index.ts <- Symbol-level impact analysis + rename planning search/index.ts <- BM25 search engine @@ -64,7 +66,7 @@ analyzeGraph(builtGraph, parsedFiles) } startMcpServer(codebaseGraph) - -> stdio MCP server with 15 tools, 2 prompts, 3 resources + -> stdio MCP server with 16 tools, 2 prompts, 3 resources ``` ## Key Design Decisions diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 279497d..2a72bfe 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools Reference -15 tools available via MCP stdio. +16 tools available via MCP stdio. ## 1. codebase_overview @@ -155,6 +155,18 @@ 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). +## 16. 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, 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). + +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`). + +**Use when:** Linting a codebase or enforcing a CI gate. "What rule violations exist?" +**Not for:** Architecture metrics (use analyze_forces). + ## MCP Prompts | Prompt | Description | @@ -190,3 +202,4 @@ Community-detected clusters of related files. | "How does data flow through the app?" | `get_processes` | | "What files naturally belong together?" | `get_clusters` | | "What are the main areas?" | `get_groups` | +| "What rule violations exist? Lint this." | `check` | diff --git a/schema.json b/schema.json index ab122a8..4856bd1 100644 --- a/schema.json +++ b/schema.json @@ -196,7 +196,7 @@ }, "allow": { "type": "array", - "description": "Additional substrings/patterns whose comments are allowed (e.g. 'TODO', 'FIXME', '@public').", + "description": "Allow comments whose body (delimiters stripped) starts with one of these strings, e.g. 'TODO', 'FIXME', '@public'.", "items": { "type": "string" } } } diff --git a/src/cli.ts b/src/cli.ts index cbd68d5..cde1a32 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,7 +21,7 @@ import { parseCodebase } from "./parser/index.js"; import { buildGraph } from "./graph/index.js"; import { analyzeGraph } from "./analyzer/index.js"; import { startMcpServer } from "./mcp/index.js"; -import { setIndexedHead } from "./server/graph-store.js"; +import { setIndexedHead, setRoot } from "./server/graph-store.js"; import { exportGraph, importGraph } from "./persistence/index.js"; import { computeOverview, @@ -47,7 +47,10 @@ import { ALL_AGENT_IDS, } from "./install/index.js"; import { promptSelection } from "./install/prompt.js"; -import type { CodebaseGraph } from "./types/index.js"; +import { runCheck, exitCodeFor } from "./rules/check.js"; +import { formatResult, formatSummaryLine } from "./rules/format.js"; +import { ConfigError } from "./config/index.js"; +import type { CodebaseGraph, OutputFormat } from "./types/index.js"; const INDEX_DIR_NAME = ".code-visualizer"; @@ -63,6 +66,7 @@ function getHeadHash(targetPath: string): string { cwd: path.resolve(targetPath), encoding: "utf-8", timeout: 5000, + stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return "unknown"; @@ -88,6 +92,7 @@ function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; h process.stderr.write(`Error: Path does not exist: ${targetPath}\n`); process.exit(1); } + setRoot(resolved); const indexDir = getIndexDir(targetPath); const headHash = getHeadHash(targetPath); @@ -1011,6 +1016,90 @@ program output(`Re-run anytime — writes are idempotent (managed blocks only).`); }); +// ── Subcommand: check ────────────────────────────────────── + +interface CheckOptions extends CliCommandOptions { + config?: string; + format?: string; + failOn?: string; + gate?: string; + base?: string; + quiet?: boolean; + summary?: boolean; +} + +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; +} + +function parseFailOn(value: string | undefined): "error" | "warn" | "never" | undefined | false { + if (value === undefined) return undefined; + if (value === "error" || value === "warn" || value === "never") return value; + return false; +} + +function parseGate(value: string | undefined): "all" | "new-only" | undefined { + return value === "all" || value === "new-only" ? value : undefined; +} + +program + .command("check") + .description("Run the rules engine and gate on findings (comments, circular deps, dead exports)") + .argument("", "Path to TypeScript codebase") + .option("--config ", "Config file path (overrides discovery)") + .option("--format ", "Output: text, json, or sarif (default: text)") + .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("--quiet", "Suppress output when the result passes") + .option("--summary", "Print summary counts only") + .option("--json", "Shortcut for --format json") + .option("--force", "Re-index even if HEAD unchanged") + .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.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 { graph } = loadGraph(targetPath, options.force); + const result = runCheck(graph, path.resolve(targetPath), { + configPath: options.config, + format, + failOn, + gate: parseGate(options.gate), + base: options.base, + quiet: options.quiet, + summary: options.summary, + }); + + const silent = options.quiet === true && result.verdict === "pass"; + if (!silent) { + output(options.summary ? formatSummaryLine(result) : formatResult(result, format)); + } + + process.exit(exitCodeFor(result)); + } catch (err) { + if (err instanceof ConfigError) { + process.stderr.write(`Config error: ${err.message}\n`); + process.exit(2); + } + throw err; + } + }); + // ── MCP fallback (backward compat) ────────────────────────── program @@ -1025,6 +1114,7 @@ program async function runMcpMode(targetPath: string, options: McpOptions): Promise { const indexDir = getIndexDir(targetPath); + setRoot(path.resolve(targetPath)); if (options.clean) { if (fs.existsSync(indexDir)) { diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..05ceaac --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,213 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import type { CodebaseIntelligenceConfig, OutputFormat } from "../types/index.js"; + +/** Thrown on missing/invalid config. The CLI maps this to exit code 2. */ +export class ConfigError extends Error { + readonly configPath: string | undefined; + constructor(message: string, configPath?: string) { + super(message); + this.name = "ConfigError"; + this.configPath = configPath; + } +} + +/** Overrides supplied from CLI flags. They win over file values. */ +export interface ConfigOverrides { + configPath?: string; + format?: OutputFormat; + quiet?: boolean; + summary?: boolean; + failOn?: "error" | "warn" | "never"; + gate?: "all" | "new-only"; + base?: string; +} + +const CONFIG_FILENAMES = [ + "codebase-intelligence.json", + ".codebase-intelligence.json", + ".codebase-intelligencerc.json", + ".codebase-intelligencerc", +]; + +const severitySchema = z.union([ + z.enum(["off", "warn", "error"]), + z.literal(0), + z.literal(1), + z.literal(2), +]); + +// Options only make sense for an enabled rule — "off"/0 with options is rejected. +const activeSeveritySchema = z.union([z.enum(["warn", "error"]), z.literal(1), z.literal(2)]); + +const ruleSettingSchema = z.union([ + severitySchema, + z.tuple([activeSeveritySchema, z.record(z.string(), z.unknown())]), +]); + +const configSchema = z + .object({ + $schema: z.string().optional(), + root: z.string().optional(), + include: z.array(z.string()).optional(), + exclude: z.array(z.string()).optional(), + entry: z.array(z.string()).optional(), + ignore: z + .object({ + dependencies: z.array(z.string()).optional(), + unresolvedImports: z.array(z.string()).optional(), + exportsUsedInFile: z.boolean().optional(), + }) + .strict() + .optional(), + rules: z.record(z.string(), ruleSettingSchema).optional(), + boundaries: z + .object({ + preset: z.enum(["bulletproof", "layered", "hexagonal", "feature-sliced"]).optional(), + zones: z + .array( + z + .object({ + name: z.string(), + patterns: z.array(z.string()), + autoDiscover: z.boolean().optional(), + }) + .strict(), + ) + .optional(), + rules: z + .array( + z + .object({ + from: z.string(), + allow: z.array(z.string()).optional(), + forbid: z.array(z.string()).optional(), + }) + .strict(), + ) + .optional(), + }) + .strict() + .optional(), + thresholds: z + .object({ health: z.object({ minScore: z.number() }).strict().optional() }) + .strict() + .optional(), + output: z + .object({ + format: z.enum(["text", "json", "sarif"]).optional(), + quiet: z.boolean().optional(), + summary: z.boolean().optional(), + }) + .strict() + .optional(), + baseline: z.string().optional(), + ci: z + .object({ + gate: z.enum(["all", "new-only"]).optional(), + failOn: z.enum(["error", "warn", "never"]).optional(), + maxWarnings: z.number().optional(), + tolerance: z.number().optional(), + base: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Walk up from startDir looking for a config file or a package.json key. */ +export function findConfigFile(startDir: string): string | null { + let dir = path.resolve(startDir); + for (;;) { + for (const name of CONFIG_FILENAMES) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) return candidate; + } + const pkg = path.join(dir, "package.json"); + if (fs.existsSync(pkg)) { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(pkg, "utf-8")); + if (isRecord(parsed) && "codebaseIntelligence" in parsed) return pkg; + } catch { + /* malformed package.json — keep walking */ + } + } + // Stop at the repository root — don't inherit ambient config from parent directories. + if (fs.existsSync(path.join(dir, ".git"))) return null; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function applyOverrides( + config: CodebaseIntelligenceConfig, + overrides: ConfigOverrides | undefined, +): CodebaseIntelligenceConfig { + if (!overrides) return config; + const output = { ...config.output }; + if (overrides.format !== undefined) output.format = overrides.format; + if (overrides.quiet !== undefined) output.quiet = overrides.quiet; + if (overrides.summary !== undefined) output.summary = overrides.summary; + + const ci = { ...config.ci }; + if (overrides.failOn !== undefined) ci.failOn = overrides.failOn; + if (overrides.gate !== undefined) ci.gate = overrides.gate; + if (overrides.base !== undefined) ci.base = overrides.base; + + return { ...config, output, ci }; +} + +/** + * Load config for a project root. Discovers the file (or uses overrides.configPath), + * parses JSON, validates with zod, and applies CLI overrides. + * @throws {ConfigError} on missing file, invalid JSON, or schema violation. + */ +export function loadConfig( + rootDir: string, + overrides?: ConfigOverrides, +): { config: CodebaseIntelligenceConfig; configPath: string | null } { + const file = overrides?.configPath ? path.resolve(overrides.configPath) : findConfigFile(rootDir); + + let raw: unknown = {}; + let configPath: string | null = null; + + if (file) { + if (!fs.existsSync(file)) throw new ConfigError(`Config file not found: ${path.basename(file)}`, file); + configPath = file; + + let contents: string; + try { + contents = fs.readFileSync(file, "utf-8"); + } catch { + throw new ConfigError(`Config file is not readable: ${path.basename(file)}`, file); + } + + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + throw new ConfigError(`Invalid JSON in config file: ${path.basename(file)}`, file); + } + + if (path.basename(file) === "package.json") { + parsed = isRecord(parsed) ? parsed.codebaseIntelligence : undefined; + } + raw = parsed ?? {}; + } + + const result = configSchema.safeParse(raw); + if (!result.success) { + const issue = result.error.issues[0]; + const where = issue.path.length > 0 ? issue.path.join(".") : ""; + const label = configPath ? ` (${path.basename(configPath)})` : ""; + throw new ConfigError(`Invalid config${label}: ${where} — ${issue.message}`, configPath ?? undefined); + } + + return { config: applyOverrides(result.data, overrides), configPath }; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e30b65f..8b0fb4e 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -7,7 +7,9 @@ import type { CodebaseGraph } from "../types/index.js"; const require = createRequire(import.meta.url); const pkg = require("../../package.json") as { version: string }; import { getHints } from "./hints.js"; -import { getIndexedHead } from "../server/graph-store.js"; +import { getIndexedHead, getRoot } from "../server/graph-store.js"; +import { runCheck } from "../rules/check.js"; +import { formatJson } from "../rules/format.js"; import { computeOverview, computeFileContext, @@ -391,7 +393,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { "codebase_overview", "file_context", "get_dependents", "find_hotspots", "get_module_structure", "analyze_forces", "find_dead_exports", "get_groups", "symbol_context", "search", "detect_changes", "impact_analysis", "rename_symbol", - "get_processes", "get_clusters", + "get_processes", "get_clusters", "check", ], indexedHead, gettingStarted: [ @@ -410,6 +412,25 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { }; } ); + + // Tool: check — run the configurable rules engine and gate + server.tool( + "check", + "Run the configured rules engine and return findings with a pass/warn/fail verdict (rules + severities come from codebase-intelligence.json). Use when: linting a codebase or enforcing CI gates. Not for: architecture metrics (use analyze_forces)", + {}, + async () => { + try { + const result = runCheck(graph, getRoot()); + return { content: [{ type: "text" as const, text: formatJson(result) }] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], + isError: true, + }; + } + } + ); } export async function startMcpServer(graph: CodebaseGraph): Promise { diff --git a/src/parser/index.ts b/src/parser/index.ts index dcca0ed..2e51e59 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -442,7 +442,7 @@ function getGitChurn(rootDir: string): Map { const output = execFileSync( "git", ["log", "--all", "--name-only", "--format="], - { cwd: rootDir, encoding: "utf-8", timeout: 30000, maxBuffer: 50 * 1024 * 1024 } + { cwd: rootDir, encoding: "utf-8", timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] } ); for (const line of output.split("\n")) { const trimmed = line.trim(); diff --git a/src/rules/check.ts b/src/rules/check.ts new file mode 100644 index 0000000..c0dfc8a --- /dev/null +++ b/src/rules/check.ts @@ -0,0 +1,123 @@ +import { execFileSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import type { + CheckResult, + CheckSummary, + CodebaseGraph, + CodebaseIntelligenceConfig, + Finding, + Verdict, +} from "../types/index.js"; +import { loadConfig, type ConfigOverrides } from "../config/index.js"; +import { runEngine, type RuleContext } from "./engine.js"; +import { ALL_RULES } from "./registry.js"; + +function summarize(findings: Finding[]): CheckSummary { + const rules: Record = {}; + let error = 0; + let warn = 0; + for (const f of findings) { + rules[f.ruleId] = (rules[f.ruleId] ?? 0) + 1; + if (f.severity === "error") error++; + else warn++; + } + return { error, warn, rules }; +} + +function computeVerdict(summary: CheckSummary, config: CodebaseIntelligenceConfig): Verdict { + const failOn = config.ci?.failOn ?? "error"; + const maxWarnings = config.ci?.maxWarnings ?? -1; + if (summary.error + summary.warn === 0) return "pass"; + + let failing = false; + if (failOn === "error") failing = summary.error > 0; + else if (failOn === "warn") failing = summary.error > 0 || summary.warn > 0; + // maxWarnings is an independent count gate, but failOn:"never" disables all gating. + if (failOn !== "never" && maxWarnings >= 0 && summary.warn > maxWarnings) failing = true; + + return failing ? "fail" : "warn"; +} + +/** Files changed since baseRef (git, repo-relative paths). null when git/base is unavailable. */ +function changedFilesSince(rootDir: string, baseRef: string): Set | null { + try { + const out = execFileSync("git", ["diff", "--name-only", `${baseRef}...HEAD`], { + cwd: rootDir, + encoding: "utf-8", + timeout: 10000, + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean)); + } catch { + return null; + } +} + +/** + * Run the rules engine against an analyzed graph and return findings + verdict. + * Loads config (discovery or overrides.configPath) — throws ConfigError on bad config. + * + * When config.ci.gate is "new-only", findings are filtered to files changed since + * config.ci.base (file-level new-vs-base gating; assumes rootDir is the repo root). + * Source reads are confined to rootDir (symlinks resolving outside are dropped). + */ +export function runCheck( + graph: CodebaseGraph, + rootDir: string, + overrides?: ConfigOverrides, +): CheckResult { + const { config, configPath } = loadConfig(rootDir, overrides); + const resolvedRoot = path.resolve(rootDir); + let realRoot = resolvedRoot; + try { + realRoot = fs.realpathSync(resolvedRoot); + } catch { + /* root not resolvable — keep the lexical path */ + } + + const fileRelPaths = graph.nodes.filter((n) => n.type === "file").map((n) => n.id); + const cache = new Map(); + const sourceOf = (rel: string): string | null => { + if (cache.has(rel)) return cache.get(rel) ?? null; + let text: string | null = null; + try { + const real = fs.realpathSync(path.resolve(realRoot, rel)); + // Confinement: never read a path (or symlink target) outside the project root. + if (real === realRoot || real.startsWith(realRoot + path.sep)) { + text = fs.readFileSync(real, "utf-8"); + } + } catch { + text = null; + } + cache.set(rel, text); + return text; + }; + + const ctx: RuleContext = { graph, rootDir: resolvedRoot, config, fileRelPaths, sourceOf }; + let findings = runEngine(ctx, ALL_RULES, config); + + if (config.ci?.gate === "new-only") { + const base = config.ci.base; + if (!base) { + process.stderr.write("Warning: gate 'new-only' requires a base ref (--base); running full check.\n"); + } else { + const changed = changedFilesSince(resolvedRoot, base); + if (changed === null) { + process.stderr.write(`Warning: could not diff against '${base}'; running full check.\n`); + } else { + findings = findings.filter((f) => changed.has(f.file)); + } + } + } + + const summary = summarize(findings); + const verdict = computeVerdict(summary, config); + return { findings, summary, verdict, configPath }; +} + +/** Process exit code for a check result: 1 on fail, 0 otherwise. (Config errors → 2, handled by the CLI.) */ +export function exitCodeFor(result: CheckResult): number { + return result.verdict === "fail" ? 1 : 0; +} diff --git a/src/rules/engine.ts b/src/rules/engine.ts new file mode 100644 index 0000000..4bcad57 --- /dev/null +++ b/src/rules/engine.ts @@ -0,0 +1,168 @@ +import { createHash } from "crypto"; +import type { + CodebaseGraph, + CodebaseIntelligenceConfig, + Finding, + FindingAction, + RuleSetting, + Severity, +} from "../types/index.js"; + +/** Read-only context handed to every rule. */ +export interface RuleContext { + graph: CodebaseGraph; + rootDir: string; + config: CodebaseIntelligenceConfig; + /** Relative paths of every parsed file (file nodes). */ + fileRelPaths: string[]; + /** Lazily read + cached source for a file, or null if unreadable. */ + sourceOf: (relPath: string) => string | null; +} + +/** What a rule emits. The engine adds severity + fingerprint. */ +export interface ReportedFinding { + file: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; + message: string; + actions?: FindingAction[]; +} + +export interface Rule { + id: string; + meta: { description: string; category: string; fixable: boolean }; + defaultSeverity: Severity; + /** Each rule normalizes its own raw options (validated loosely by the config schema). */ + run: (ctx: RuleContext, options: Record | undefined) => ReportedFinding[]; +} + +export function normalizeSeverity(value: Severity | 0 | 1 | 2): Severity { + if (value === 0) return "off"; + if (value === 1) return "warn"; + if (value === 2) return "error"; + return value; +} + +function resolveSetting( + setting: RuleSetting | undefined, + fallback: Severity, +): { severity: Severity; options: Record | undefined } { + if (setting === undefined) return { severity: fallback, options: undefined }; + if (Array.isArray(setting)) return { severity: normalizeSeverity(setting[0]), options: setting[1] }; + return { severity: normalizeSeverity(setting), options: undefined }; +} + +/** + * Per-finding identifier. Position-derived (includes line) so it is unique within a file; + * it shifts when earlier lines are added/removed, so it is emitted as a SARIF + * partialFingerprint, not a stable cross-revision baseline key. + */ +function fingerprint(ruleId: string, file: string, line: number, message: string): string { + return createHash("sha1").update(`${ruleId}|${file}|${String(line)}|${message}`).digest("hex").slice(0, 12); +} + +type RuleSet = "all" | Set; + +interface FileSuppressions { + fileRules: RuleSet | null; + lineRules: Map; +} + +// Matches both line (`// ci-ignore-...`) and block (`/* ci-ignore-... */`) forms. +const SUPPRESS_RE = /(?:\/\/|\/\*)\s*ci-ignore-(file|next-line)\b([^\n*]*)/; + +function mergeRuleSet(existing: RuleSet | null, incoming: RuleSet): RuleSet { + if (existing === null) return incoming; + if (existing === "all" || incoming === "all") return "all"; + return new Set([...existing, ...incoming]); +} + +function parseSuppressions(source: string): FileSuppressions { + const lines = source.split(/\r?\n/); + let fileRules: RuleSet | null = null; + const lineRules = new Map(); + + lines.forEach((text, idx) => { + const match = SUPPRESS_RE.exec(text); + if (!match) return; + const list = match[2].trim(); + const set: RuleSet = list.length === 0 ? "all" : new Set(list.split(/[\s,]+/).filter(Boolean)); + if (match[1] === "file") { + fileRules = mergeRuleSet(fileRules, set); + } else { + // ci-ignore-next-line suppresses the finding on the following source line (1-based). + lineRules.set(idx + 2, mergeRuleSet(lineRules.get(idx + 2) ?? null, set)); + } + }); + + return { fileRules, lineRules }; +} + +function ruleSetMatches(set: RuleSet | null | undefined, ruleId: string): boolean { + if (!set) return false; + return set === "all" || set.has(ruleId); +} + +/** + * Run every enabled rule and return findings with severity + fingerprint applied, + * after dropping anything covered by ci-ignore suppressions. Deterministically sorted. + */ +export function runEngine( + ctx: RuleContext, + rules: Rule[], + config: CodebaseIntelligenceConfig, +): Finding[] { + const suppressionCache = new Map(); + const suppressionsFor = (file: string): FileSuppressions => { + const cached = suppressionCache.get(file); + if (cached) return cached; + const source = ctx.sourceOf(file); + const parsed = source ? parseSuppressions(source) : { fileRules: null, lineRules: new Map() }; + suppressionCache.set(file, parsed); + return parsed; + }; + + const out: Finding[] = []; + + for (const rule of rules) { + const { severity, options } = resolveSetting(config.rules?.[rule.id], rule.defaultSeverity); + if (severity === "off") continue; + + let reported: ReportedFinding[]; + try { + reported = rule.run(ctx, options); + } catch (err) { + // A failing rule drops to zero findings rather than crashing the whole run, but we + // surface it so a broken rule is never a silent false-negative in CI. + const reason = err instanceof Error ? err.message : String(err); + process.stderr.write(`Warning: rule '${rule.id}' threw during analysis (${reason}); its findings are omitted.\n`); + continue; + } + + for (const r of reported) { + const supp = suppressionsFor(r.file); + if (ruleSetMatches(supp.fileRules, rule.id)) continue; + if (ruleSetMatches(supp.lineRules.get(r.line), rule.id)) continue; + + out.push({ + ruleId: rule.id, + severity, + file: r.file, + line: r.line, + column: r.column, + endLine: r.endLine, + endColumn: r.endColumn, + message: r.message, + actions: r.actions, + fingerprint: fingerprint(rule.id, r.file, r.line, r.message), + }); + } + } + + out.sort( + (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId), + ); + return out; +} diff --git a/src/rules/format.ts b/src/rules/format.ts new file mode 100644 index 0000000..1a654b7 --- /dev/null +++ b/src/rules/format.ts @@ -0,0 +1,97 @@ +import type { CheckResult, OutputFormat } from "../types/index.js"; +import { ALL_RULES } from "./registry.js"; + +const RULE_DESCRIPTIONS = new Map(ALL_RULES.map((r) => [r.id, r.meta.description])); + +/** "N error(s), M warning(s) — VERDICT" — shared by text output and the CLI --summary flag. */ +export function formatSummaryLine(result: CheckResult): string { + return `${String(result.summary.error)} error(s), ${String(result.summary.warn)} warning(s) — ${result.verdict.toUpperCase()}`; +} + +export function formatJson(result: CheckResult): string { + return JSON.stringify( + { + verdict: result.verdict, + summary: result.summary, + configPath: result.configPath, + findings: result.findings, + }, + null, + 2, + ); +} + +export function formatText(result: CheckResult): string { + const lines: string[] = []; + + if (result.findings.length === 0) { + lines.push("No findings."); + } else { + let currentFile = ""; + for (const f of result.findings) { + if (f.file !== currentFile) { + currentFile = f.file; + lines.push(""); + lines.push(currentFile); + } + lines.push(` ${String(f.line)}:${String(f.column)} ${f.severity.padEnd(5)} ${f.message} (${f.ruleId})`); + } + lines.push(""); + } + + lines.push(formatSummaryLine(result)); + return lines.join("\n"); +} + +export function formatSarif(result: CheckResult): string { + const ruleIds = [...new Set(result.findings.map((f) => f.ruleId))]; + const sarif = { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "codebase-intelligence", + rules: ruleIds.map((id) => ({ + id, + shortDescription: { text: RULE_DESCRIPTIONS.get(id) ?? id }, + })), + }, + }, + results: result.findings.map((f) => ({ + ruleId: f.ruleId, + level: f.severity === "error" ? "error" : "warning", + message: { text: f.message }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: f.file }, + region: { + startLine: f.line, + startColumn: f.column, + ...(f.endLine !== undefined ? { endLine: f.endLine } : {}), + ...(f.endColumn !== undefined ? { endColumn: f.endColumn } : {}), + }, + }, + }, + ], + // Position-derived key — may shift when lines change, hence partialFingerprints. + partialFingerprints: { ciFingerprint: f.fingerprint }, + })), + }, + ], + }; + return JSON.stringify(sarif, null, 2); +} + +export function formatResult(result: CheckResult, format: OutputFormat): string { + switch (format) { + case "json": + return formatJson(result); + case "sarif": + return formatSarif(result); + default: + return formatText(result); + } +} diff --git a/src/rules/no-circular-deps.ts b/src/rules/no-circular-deps.ts new file mode 100644 index 0000000..2376287 --- /dev/null +++ b/src/rules/no-circular-deps.ts @@ -0,0 +1,18 @@ +import type { ReportedFinding, Rule, RuleContext } from "./engine.js"; + +/** Report every circular dependency cycle detected in the file graph. */ +export const noCircularDeps: Rule = { + id: "no-circular-deps", + meta: { description: "Forbid circular dependencies between files.", category: "architecture", fixable: false }, + defaultSeverity: "error", + run(ctx: RuleContext): ReportedFinding[] { + return ctx.graph.stats.circularDeps + .filter((cycle) => cycle.length > 0) + .map((cycle) => ({ + file: cycle[0], + line: 1, + column: 1, + message: `Circular dependency: ${cycle.join(" -> ")}`, + })); + }, +}; diff --git a/src/rules/no-comments.ts b/src/rules/no-comments.ts new file mode 100644 index 0000000..a986f55 --- /dev/null +++ b/src/rules/no-comments.ts @@ -0,0 +1,97 @@ +import ts from "typescript"; +import type { ReportedFinding, Rule, RuleContext } from "./engine.js"; + +interface NoCommentsOptions { + style: "line" | "block" | "all"; + allowJSDoc: boolean; + allowDirectives: boolean; + allowLicenseHeader: boolean; + allow: string[]; +} + +// Directive comments worth keeping even when comments are forbidden. +const DIRECTIVE_RE = /^\/\/\/?\s*(@ts-|eslint-|ci-ignore|prettier-|biome-| | undefined): NoCommentsOptions { + const o = raw ?? {}; + const style = o.style === "block" || o.style === "all" ? o.style : "line"; + const allow = Array.isArray(o.allow) ? o.allow.filter((x): x is string => typeof x === "string") : []; + return { + style, + allowJSDoc: o.allowJSDoc !== false, + allowDirectives: o.allowDirectives !== false, + allowLicenseHeader: o.allowLicenseHeader !== false, + allow, + }; +} + +function shouldReport( + text: string, + isLine: boolean, + start: number, + firstNonWs: number, + o: NoCommentsOptions, +): boolean { + if (o.style === "line" && !isLine) return false; + if (o.style === "block" && isLine) return false; + + const isJsDoc = !isLine && text.startsWith("/**"); + if (o.allowJSDoc && isJsDoc) return false; + if (o.allowDirectives && DIRECTIVE_RE.test(text)) return false; + if (o.allowLicenseHeader && start === firstNonWs) return false; + const body = commentBody(text); + if (o.allow.some((p) => body.startsWith(p))) return false; + return true; +} + +/** Comment text with delimiters stripped, e.g. "// TODO x" -> "TODO x". */ +function commentBody(text: string): string { + return text.replace(/^\/\*+|^\/\/+/, "").replace(/\*\/$/, "").trim(); +} + +/** + * Forbid comments. Defaults forbid `//` line comments while keeping JSDoc, + * tool/compiler directives, and a file-leading license header. `style: "all"` + * forbids every comment. + */ +export const noComments: Rule = { + id: "no-comments", + meta: { description: "Forbid comments (configurable: keeps JSDoc/directives/header by default).", category: "style", fixable: true }, + defaultSeverity: "off", + run(ctx: RuleContext, rawOptions): ReportedFinding[] { + const options = normalize(rawOptions); + const findings: ReportedFinding[] = []; + + for (const rel of ctx.fileRelPaths) { + const source = ctx.sourceOf(rel); + if (!source) continue; + + const sf = ts.createSourceFile(rel, source, ts.ScriptTarget.Latest, false); + const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source); + const firstNonWs = source.search(/\S/); + + let token = scanner.scan(); + while (token !== ts.SyntaxKind.EndOfFileToken) { + const isLine = token === ts.SyntaxKind.SingleLineCommentTrivia; + const isBlock = token === ts.SyntaxKind.MultiLineCommentTrivia; + if (isLine || isBlock) { + const start = scanner.getTokenStart(); + const text = scanner.getTokenText(); + if (shouldReport(text, isLine, start, firstNonWs, options)) { + const lc = sf.getLineAndCharacterOfPosition(start); + findings.push({ + file: rel, + line: lc.line + 1, + column: lc.character + 1, + message: "Comments are not allowed (no-comments)", + actions: [{ kind: "remove-comment", auto_fixable: true, range: { start, end: start + text.length } }], + }); + } + } + token = scanner.scan(); + } + } + + return findings; + }, +}; diff --git a/src/rules/no-dead-exports.ts b/src/rules/no-dead-exports.ts new file mode 100644 index 0000000..47ced25 --- /dev/null +++ b/src/rules/no-dead-exports.ts @@ -0,0 +1,17 @@ +import type { ReportedFinding, Rule, RuleContext } from "./engine.js"; + +/** Report exported symbols that are never imported (per-file dead exports). */ +export const noDeadExports: Rule = { + id: "no-dead-exports", + meta: { description: "Report exported symbols that are never imported.", category: "cleanup", fixable: false }, + defaultSeverity: "warn", + run(ctx: RuleContext): ReportedFinding[] { + const findings: ReportedFinding[] = []; + for (const [file, metrics] of ctx.graph.fileMetrics) { + for (const name of metrics.deadExports) { + findings.push({ file, line: 1, column: 1, message: `Unused export: ${name}` }); + } + } + return findings; + }, +}; diff --git a/src/rules/registry.ts b/src/rules/registry.ts new file mode 100644 index 0000000..494b4c3 --- /dev/null +++ b/src/rules/registry.ts @@ -0,0 +1,7 @@ +import type { Rule } from "./engine.js"; +import { noComments } from "./no-comments.js"; +import { noCircularDeps } from "./no-circular-deps.js"; +import { noDeadExports } from "./no-dead-exports.js"; + +/** Every rule the engine knows about. Add a rule = add a file + an entry here. */ +export const ALL_RULES: Rule[] = [noComments, noCircularDeps, noDeadExports]; diff --git a/src/server/graph-store.ts b/src/server/graph-store.ts index dbd86a2..717aa1b 100644 --- a/src/server/graph-store.ts +++ b/src/server/graph-store.ts @@ -5,6 +5,8 @@ declare global { var __codebaseGraph: CodebaseGraph | undefined; var __indexedHeadHash: string | undefined; + + var __codebaseRoot: string | undefined; } export function setGraph(graph: CodebaseGraph): void { @@ -25,3 +27,16 @@ export function setIndexedHead(hash: string): void { export function getIndexedHead(): string { return globalThis.__indexedHeadHash ?? ""; } + +export function setRoot(root: string): void { + globalThis.__codebaseRoot = root; +} + +/** + * Project root for the loaded graph. Falls back to process.cwd() if setRoot was never + * called — every CLI/MCP entry point calls setRoot first, so the fallback only applies to + * embedded/standalone use (where it may resolve the wrong directory). + */ +export function getRoot(): string { + return globalThis.__codebaseRoot ?? process.cwd(); +} diff --git a/src/types/index.ts b/src/types/index.ts index cc94223..59c02c3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -243,3 +243,96 @@ export interface CodebaseGraph { circularDeps: string[][]; }; } + +// ── Config + Rules Engine ──────────────────────────────── + +export type Severity = "off" | "warn" | "error"; +export type FindingSeverity = "warn" | "error"; + +export type RuleSetting = + | Severity + | 0 + | 1 + | 2 + | [Exclude | 1 | 2, Record]; + +export interface BoundaryZone { + name: string; + patterns: string[]; + autoDiscover?: boolean; +} + +export interface BoundaryRule { + from: string; + allow?: string[]; + forbid?: string[]; +} + +export interface BoundariesConfig { + preset?: "bulletproof" | "layered" | "hexagonal" | "feature-sliced"; + zones?: BoundaryZone[]; + rules?: BoundaryRule[]; +} + +export type OutputFormat = "text" | "json" | "sarif"; + +export interface CodebaseIntelligenceConfig { + root?: string; + include?: string[]; + exclude?: string[]; + entry?: string[]; + ignore?: { + dependencies?: string[]; + unresolvedImports?: string[]; + exportsUsedInFile?: boolean; + }; + rules?: Record; + boundaries?: BoundariesConfig; + thresholds?: { health?: { minScore?: number } }; + output?: { format?: OutputFormat; quiet?: boolean; summary?: boolean }; + baseline?: string; + ci?: { + gate?: "all" | "new-only"; + failOn?: FindingSeverity | "never"; + maxWarnings?: number; + tolerance?: number; + base?: string; + }; +} + +export type ActionKind = "remove-comment"; + +export interface FindingAction { + kind: ActionKind; + /** snake_case is intentional — this is an agent-facing wire field. */ + auto_fixable: boolean; + range?: { start: number; end: number }; +} + +export interface Finding { + ruleId: string; + severity: FindingSeverity; + file: string; + line: number; + column: number; + endLine?: number; + endColumn?: number; + message: string; + actions?: FindingAction[]; + fingerprint: string; +} + +export interface CheckSummary { + error: number; + warn: number; + rules: Record; +} + +export type Verdict = "pass" | "warn" | "fail"; + +export interface CheckResult { + findings: Finding[]; + summary: CheckSummary; + verdict: Verdict; + configPath: string | null; +} diff --git a/tests/cli-check.e2e.test.ts b/tests/cli-check.e2e.test.ts new file mode 100644 index 0000000..e32e946 --- /dev/null +++ b/tests/cli-check.e2e.test.ts @@ -0,0 +1,174 @@ +import { execFile, 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 { describe, it, expect, beforeAll, afterEach } from "vitest"; + +// End-to-end tests for the `check` command. Spawn the real compiled binary +// against real temp projects and assert exit codes + stdout. No mocking. +// Async execFile (not spawnSync) keeps the worker event loop responsive. + +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; +} + +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 = e as { code?: number | string; stdout?: string; stderr?: string }; + return { + status: typeof err.code === "number" ? err.code : 1, + stdout: err.stdout ?? "", + stderr: err.stderr ?? "", + }; + } +} + +const created: string[] = []; +afterEach(() => { + for (const d of created.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +function makeProject(files: Record, config?: unknown): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-check-e2e-")); + 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"), + typeof config === "string" ? config : JSON.stringify(config), + ); + } + return dir; +} + +const CLEAN = { + "src/main.ts": "export function main(): number { return 1; }\n", + "src/index.ts": 'import { main } from "./main.js";\nmain();\n', +}; + +const CIRCULAR = { + "src/a.ts": 'import { b } from "./b.js";\nexport function a(): number { return b() + 1; }\n', + "src/b.ts": 'import { a } from "./a.js";\nexport function b(): number { return a === undefined ? 0 : 1; }\n', +}; + +const DEAD = { + "src/a.ts": 'import { used } from "./b.js";\nexport function a(): number { return used(); }\n', + "src/b.ts": "export function used(): number { return 1; }\nexport function deadOne(): number { return 2; }\n", +}; + +beforeAll(() => { + if (!fs.existsSync(cli)) execSync("npm run build", { cwd: repoRoot, stdio: "inherit" }); +}, 120_000); + +describe("check command (e2e)", () => { + it("exits 0 with 'No findings.' on a clean project", async () => { + const dir = makeProject(CLEAN, { rules: {} }); + const { status, stdout } = await run(["check", dir]); + expect(status).toBe(0); + expect(stdout).toContain("No findings."); + }); + + it("exits 1 and prints the cycle on a circular dependency", async () => { + const dir = makeProject(CIRCULAR, { rules: { "no-dead-exports": "off" } }); + const { status, stdout } = await run(["check", dir]); + expect(status).toBe(1); + expect(stdout).toContain("Circular dependency"); + }); + + it("--json emits a parseable result with verdict + findings", async () => { + const dir = makeProject(CIRCULAR, { rules: { "no-dead-exports": "off" } }); + const { status, stdout } = await run(["check", dir, "--json"]); + expect(status).toBe(1); + const parsed = JSON.parse(stdout) as { + verdict: string; + findings: { ruleId: string }[]; + summary: { error: number }; + }; + expect(parsed.verdict).toBe("fail"); + expect(parsed.findings.some((f) => f.ruleId === "no-circular-deps")).toBe(true); + expect(parsed.summary.error).toBeGreaterThanOrEqual(1); + }); + + it("--format sarif emits SARIF 2.1.0", async () => { + const dir = makeProject(CIRCULAR, { rules: { "no-dead-exports": "off" } }); + const { stdout } = await run(["check", dir, "--format", "sarif"]); + const sarif = JSON.parse(stdout) as { version: string; runs: { results: unknown[] }[] }; + expect(sarif.version).toBe("2.1.0"); + expect(sarif.runs[0].results.length).toBeGreaterThanOrEqual(1); + }); + + it("flags an inline comment when no-comments is enabled (exit 1)", async () => { + const dir = makeProject( + { "src/x.ts": "export const x = 1;\nexport const y = x; // trailing\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off", "no-circular-deps": "off" } }, + ); + const { status, stdout } = await run(["check", dir, "--json"]); + expect(status).toBe(1); + expect(stdout).toContain("no-comments"); + }); + + it("honors a ci-ignore-next-line suppression", async () => { + const dir = makeProject( + { "src/x.ts": "export const x = 1;\n// ci-ignore-next-line no-comments\nexport const y = x; // hidden\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off", "no-circular-deps": "off" } }, + ); + const { status, stdout } = await run(["check", dir]); + expect(status).toBe(0); + expect(stdout).toContain("No findings."); + }); + + it("default failOn=error keeps a warn-only project green (exit 0)", async () => { + const dir = makeProject(DEAD, { rules: { "no-circular-deps": "off" } }); + const { status } = await run(["check", dir]); + expect(status).toBe(0); + }); + + it("--fail-on warn turns warnings into a failure (exit 1)", async () => { + const dir = makeProject(DEAD, { rules: { "no-circular-deps": "off" } }); + const { status } = await run(["check", dir, "--fail-on", "warn"]); + expect(status).toBe(1); + }); + + it("exits 2 with a config error on an invalid config", async () => { + const dir = makeProject(CLEAN, { bogusKey: true }); + const { status, stderr } = await run(["check", dir]); + expect(status).toBe(2); + expect(stderr).toContain("Config error"); + }); + + it("exits 2 on an invalid --format value", async () => { + const dir = makeProject(CLEAN, { rules: {} }); + const { status, stderr } = await run(["check", dir, "--format", "xml"]); + expect(status).toBe(2); + expect(stderr).toContain("--format must be one of"); + }); + + it("accepts an explicit --config path", async () => { + const dir = makeProject(CIRCULAR); + const cfg = path.join(dir, "custom.json"); + fs.writeFileSync(cfg, JSON.stringify({ rules: { "no-circular-deps": "off", "no-dead-exports": "off" } })); + const { status, stdout } = await run(["check", dir, "--config", cfg]); + expect(status).toBe(0); + expect(stdout).toContain("No findings."); + }); +}); diff --git a/tests/config-loader.test.ts b/tests/config-loader.test.ts new file mode 100644 index 0000000..c00ed39 --- /dev/null +++ b/tests/config-loader.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadConfig, findConfigFile, ConfigError } from "../src/config/index.js"; + +// Real temp config files on disk — no mocking of fs or the loader. + +describe("config loader", () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cfg-")); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (name: string, content: unknown): void => + fs.writeFileSync(path.join(dir, name), typeof content === "string" ? content : JSON.stringify(content)); + + it("discovers codebase-intelligence.json and parses rules", () => { + write("codebase-intelligence.json", { rules: { "no-comments": "error" } }); + const { config, configPath } = loadConfig(dir); + expect(configPath).toBe(path.join(dir, "codebase-intelligence.json")); + expect(config.rules?.["no-comments"]).toBe("error"); + }); + + it("returns empty config + null path when nothing is found", () => { + const { config, configPath } = loadConfig(dir); + expect(configPath).toBeNull(); + expect(config.rules).toBeUndefined(); + }); + + it("reads the codebaseIntelligence key from package.json", () => { + write("package.json", { name: "x", codebaseIntelligence: { rules: { "no-comments": 2 } } }); + const { config, configPath } = loadConfig(dir); + expect(configPath).toBe(path.join(dir, "package.json")); + expect(config.rules?.["no-comments"]).toBe(2); + }); + + it("walks up to find a parent config", () => { + write("codebase-intelligence.json", { rules: {} }); + const nested = path.join(dir, "a", "b"); + fs.mkdirSync(nested, { recursive: true }); + expect(findConfigFile(nested)).toBe(path.join(dir, "codebase-intelligence.json")); + }); + + it("throws ConfigError on invalid JSON", () => { + write("codebase-intelligence.json", "{ not json "); + expect(() => loadConfig(dir)).toThrow(ConfigError); + }); + + it("throws ConfigError on an unknown top-level key (strict schema)", () => { + write("codebase-intelligence.json", { bogusKey: 1 }); + expect(() => loadConfig(dir)).toThrow(ConfigError); + }); + + it("allows a $schema reference", () => { + write("codebase-intelligence.json", { $schema: "./schema.json", rules: {} }); + expect(() => loadConfig(dir)).not.toThrow(); + }); + + it("applies CLI overrides over file values", () => { + write("codebase-intelligence.json", { ci: { failOn: "error" }, output: { format: "text" } }); + const { config } = loadConfig(dir, { failOn: "warn", format: "json" }); + expect(config.ci?.failOn).toBe("warn"); + expect(config.output?.format).toBe("json"); + }); + + it("uses an explicit configPath override", () => { + const custom = path.join(dir, "my.json"); + fs.writeFileSync(custom, JSON.stringify({ rules: { "no-circular-deps": "off" } })); + const { config, configPath } = loadConfig(dir, { configPath: custom }); + expect(configPath).toBe(custom); + expect(config.rules?.["no-circular-deps"]).toBe("off"); + }); +}); diff --git a/tests/mcp-check.test.ts b/tests/mcp-check.test.ts new file mode 100644 index 0000000..ed37f5e --- /dev/null +++ b/tests/mcp-check.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { getFixturePipeline, getFixtureSrcPath } from "./helpers/pipeline.js"; +import { registerTools } from "../src/mcp/index.js"; +import { setGraph, setIndexedHead, setRoot } from "../src/server/graph-store.js"; + +// Real in-memory MCP server + client over the real fixture graph. No mocks. + +let client: Client; + +beforeAll(async () => { + const pipeline = getFixturePipeline(); + setGraph(pipeline.codebaseGraph); + setIndexedHead("abc123-test"); + setRoot(getFixtureSrcPath()); + + const server = new McpServer({ name: "test", version: "0.1.0" }); + registerTools(server, pipeline.codebaseGraph); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + client = new Client({ name: "test-client", version: "0.1.0" }); + await client.connect(clientTransport); +}); + +async function callCheck(): Promise> { + const result = await client.callTool({ name: "check", arguments: {} }); + const text = (result.content as Array<{ type: string; text: string }>)[0].text; + return JSON.parse(text) as Record; +} + +describe("MCP check tool", () => { + it("is registered and returns verdict + summary + findings", async () => { + const r = await callCheck(); + expect(r).toHaveProperty("verdict"); + expect(["pass", "warn", "fail"]).toContain(r.verdict); + expect(r).toHaveProperty("summary"); + expect(Array.isArray(r.findings)).toBe(true); + const summary = r.summary as Record; + expect(summary).toHaveProperty("error"); + expect(summary).toHaveProperty("warn"); + }); + + it("each finding carries a ruleId, file, and fingerprint", async () => { + const r = await callCheck(); + const findings = r.findings as Array>; + for (const f of findings) { + expect(typeof f.ruleId).toBe("string"); + expect(typeof f.file).toBe("string"); + expect(typeof f.fingerprint).toBe("string"); + } + }); +}); diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts index b6a76a6..ef07166 100644 --- a/tests/mcp-tools.test.ts +++ b/tests/mcp-tools.test.ts @@ -459,6 +459,7 @@ describe("MCP Resources", () => { expect(setup).toHaveProperty("project", "codebase-intelligence"); expect(setup).toHaveProperty("indexedHead", "abc123-test"); expect(setup).toHaveProperty("availableTools"); - expect((setup.availableTools as string[]).length).toBe(15); + expect((setup.availableTools as string[]).length).toBe(16); + expect(setup.availableTools as string[]).toContain("check"); }); }); diff --git a/tests/rules-engine.test.ts b/tests/rules-engine.test.ts new file mode 100644 index 0000000..cfa39b6 --- /dev/null +++ b/tests/rules-engine.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { parseCodebase } from "../src/parser/index.js"; +import { buildGraph } from "../src/graph/index.js"; +import { analyzeGraph } from "../src/analyzer/index.js"; +import { runCheck } from "../src/rules/check.js"; +import { formatText, formatJson, formatSarif, formatResult } from "../src/rules/format.js"; +import type { CheckResult, CodebaseIntelligenceConfig, Finding } from "../src/types/index.js"; + +// Real pipeline: parser -> graph -> analyzer -> rules engine. No mocks. +// Each case writes real .ts files + a real config to a temp dir. + +const created: string[] = []; +afterEach(() => { + for (const d of created.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +function projectResult(files: Record, config: CodebaseIntelligenceConfig): CheckResult { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-rules-")); + 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); + } + fs.writeFileSync(path.join(dir, "codebase-intelligence.json"), JSON.stringify(config)); + const parsed = parseCodebase(dir); + const graph = analyzeGraph(buildGraph(parsed), parsed); + return runCheck(graph, dir); +} + +function project(files: Record, config: CodebaseIntelligenceConfig): Finding[] { + return projectResult(files, config).findings; +} + +const CIRCULAR_FILES: Record = { + "src/a.ts": 'import { b } from "./b.js";\nexport function a(): number { return b() + 1; }\n', + "src/b.ts": 'import { a } from "./a.js";\nexport function b(): number { return a === undefined ? 0 : 1; }\n', +}; + +const DEAD_FILES: Record = { + "src/a.ts": 'import { used } from "./b.js";\nexport function a(): number { return used(); }\n', + "src/b.ts": "export function used(): number { return 1; }\nexport function deadOne(): number { return 2; }\n", +}; + +const ids = (findings: Finding[]): string[] => findings.map((f) => f.ruleId); + +describe("no-comments rule", () => { + it("is off by default — inline comments are not reported", () => { + const findings = project( + { "src/x.ts": "export const x = 1;\nexport const y = x; // trailing\n" }, + {}, + ); + expect(ids(findings)).not.toContain("no-comments"); + }); + + it("flags an inline // comment when enabled", () => { + const findings = project( + { "src/x.ts": "export const x = 1;\nexport const y = x; // trailing comment\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off" } }, + ); + const nc = findings.filter((f) => f.ruleId === "no-comments"); + expect(nc.length).toBe(1); + expect(nc[0].line).toBe(2); + expect(nc[0].severity).toBe("error"); + }); + + it("allows a file-leading license header by default", () => { + const findings = project( + { "src/x.ts": "// Copyright header\nexport const x = 1;\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off" } }, + ); + expect(ids(findings)).not.toContain("no-comments"); + }); + + it("allows JSDoc by default but flags it under allowJSDoc:false + style:all", () => { + const src = "export const x = 1;\n/** doc */\nexport function z(): void {}\n"; + const allowed = project({ "src/x.ts": src }, { rules: { "no-comments": "error", "no-dead-exports": "off" } }); + expect(ids(allowed)).not.toContain("no-comments"); + + const flagged = project( + { "src/x.ts": src }, + { rules: { "no-comments": ["error", { style: "all", allowJSDoc: false }], "no-dead-exports": "off" } }, + ); + expect(ids(flagged)).toContain("no-comments"); + }); + + it("respects the allow list", () => { + const findings = project( + { "src/x.ts": "export const x = 1;\nexport const y = x; // TODO later\n" }, + { rules: { "no-comments": ["error", { allow: ["TODO"] }], "no-dead-exports": "off" } }, + ); + expect(ids(findings)).not.toContain("no-comments"); + }); +}); + +describe("suppressions", () => { + it("ci-ignore-next-line suppresses the following line", () => { + const findings = project( + { + "src/x.ts": "export const x = 1;\n// ci-ignore-next-line no-comments\nexport const y = x; // hidden\n", + }, + { rules: { "no-comments": "error", "no-dead-exports": "off" } }, + ); + expect(ids(findings)).not.toContain("no-comments"); + }); + + it("ci-ignore-file suppresses the whole file", () => { + const findings = project( + { "src/x.ts": "// ci-ignore-file no-comments\nexport const x = 1;\nexport const y = x; // a\nexport const z = y; // b\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off" } }, + ); + expect(ids(findings)).not.toContain("no-comments"); + }); +}); + +describe("graph-backed rules", () => { + it("flags circular dependencies as errors by default", () => { + const findings = project( + { + "src/a.ts": 'import { b } from "./b.js";\nexport function a(): number { return b() + 1; }\n', + "src/b.ts": 'import { a } from "./a.js";\nexport function b(): number { return a === undefined ? 0 : 1; }\n', + }, + { rules: { "no-dead-exports": "off" } }, + ); + const circ = findings.filter((f) => f.ruleId === "no-circular-deps"); + expect(circ.length).toBeGreaterThanOrEqual(1); + expect(circ[0].severity).toBe("error"); + expect(circ[0].message).toContain("Circular dependency"); + }); + + it("reports dead exports as warnings", () => { + const findings = project( + { + "src/a.ts": 'import { used } from "./b.js";\nexport function a(): number { return used(); }\n', + "src/b.ts": "export function used(): number { return 1; }\nexport function deadOne(): number { return 2; }\n", + }, + { rules: { "no-circular-deps": "off" } }, + ); + const dead = findings.filter((f) => f.ruleId === "no-dead-exports"); + expect(dead.some((f) => f.message.includes("deadOne"))).toBe(true); + expect(dead.every((f) => f.severity === "warn")).toBe(true); + }); +}); + +describe("no-comments styles and directives", () => { + it("style:block flags block comments only", () => { + const findings = project( + { "src/x.ts": "export const a = 1;\nexport const b = a; /* block */ // line\n" }, + { rules: { "no-comments": ["error", { style: "block" }], "no-dead-exports": "off" } }, + ); + expect(findings.filter((f) => f.ruleId === "no-comments").length).toBe(1); + }); + + it("style:all flags every comment", () => { + const findings = project( + { "src/x.ts": "export const a = 1;\nexport const b = a; /* block */ // line\n" }, + { rules: { "no-comments": ["error", { style: "all" }], "no-dead-exports": "off" } }, + ); + expect(findings.filter((f) => f.ruleId === "no-comments").length).toBe(2); + }); + + it("keeps @ts directive comments by default", () => { + const findings = project( + { "src/x.ts": "export const a = 1;\n// @ts-expect-error intentional\nexport const b = a;\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off" } }, + ); + expect(findings.filter((f) => f.ruleId === "no-comments").length).toBe(0); + }); +}); + +describe("formatters", () => { + it("render a failing result in text, json, and sarif", () => { + const result = projectResult(CIRCULAR_FILES, { rules: { "no-dead-exports": "off" } }); + + expect(formatText(result)).toContain("Circular dependency"); + expect(formatResult(result, "text")).toBe(formatText(result)); + + const json = JSON.parse(formatJson(result)) as { verdict: string }; + expect(json.verdict).toBe("fail"); + expect(formatResult(result, "json")).toContain("verdict"); + + const sarif = JSON.parse(formatSarif(result)) as { version: string; runs: { results: unknown[] }[] }; + expect(sarif.version).toBe("2.1.0"); + expect(sarif.runs[0].results.length).toBeGreaterThanOrEqual(1); + expect(formatResult(result, "sarif")).toContain("2.1.0"); + }); + + it("renders 'No findings.' for a clean result", () => { + const result = projectResult( + { + "src/main.ts": "export function main(): number { return 1; }\n", + "src/index.ts": 'import { main } from "./main.js";\nmain();\n', + }, + { rules: {} }, + ); + expect(formatText(result)).toContain("No findings."); + }); +}); + +describe("verdict gating", () => { + it("failOn:never keeps errors from failing the gate", () => { + const result = projectResult(CIRCULAR_FILES, { rules: { "no-dead-exports": "off" }, ci: { failOn: "never" } }); + expect(result.verdict).not.toBe("fail"); + }); + + it("maxWarnings turns warnings into a failure", () => { + const result = projectResult(DEAD_FILES, { rules: { "no-circular-deps": "off" }, ci: { maxWarnings: 0 } }); + expect(result.verdict).toBe("fail"); + }); + + it("failOn:never disables the maxWarnings gate too", () => { + const result = projectResult(DEAD_FILES, { + rules: { "no-circular-deps": "off" }, + ci: { failOn: "never", maxWarnings: 0 }, + }); + expect(result.verdict).not.toBe("fail"); + }); +}); + +describe("no-comments precision (post-review)", () => { + it("block-comment ci-ignore-file suppresses the whole file", () => { + const findings = project( + { "src/x.ts": "export const x = 1;\n/* ci-ignore-file no-comments */\nexport const y = x; // hidden\n" }, + { rules: { "no-comments": "error", "no-dead-exports": "off" } }, + ); + expect(ids(findings)).not.toContain("no-comments"); + }); + + it("allow matches comment-body prefix, not arbitrary substrings", () => { + const kept = project( + { "src/x.ts": "export const x = 1;\nexport const y = x; // TODO later\n" }, + { rules: { "no-comments": ["error", { allow: ["TODO"] }], "no-dead-exports": "off" } }, + ); + expect(ids(kept)).not.toContain("no-comments"); + + // allow:["a"] must NOT over-allow "// bad" (substring "a") — body "bad" does not start with "a". + const flagged = project( + { "src/x.ts": "export const x = 1;\nexport const y = x; // bad\n" }, + { rules: { "no-comments": ["error", { allow: ["a"] }], "no-dead-exports": "off" } }, + ); + expect(ids(flagged)).toContain("no-comments"); + }); +}); + +describe("new-only gate", () => { + function git(dir: string, args: string[]): string { + return execFileSync("git", args, { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } + + it("filters findings to files changed since base", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-newonly-")); + created.push(dir); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "old.ts"), "export function old(): number { return 1; }\n"); + fs.writeFileSync(path.join(dir, "codebase-intelligence.json"), JSON.stringify({ rules: { "no-circular-deps": "off" } })); + git(dir, ["init"]); + git(dir, ["config", "user.email", "t@example.com"]); + git(dir, ["config", "user.name", "t"]); + git(dir, ["add", "-A"]); + git(dir, ["commit", "-m", "base", "--no-gpg-sign"]); + const base = git(dir, ["rev-parse", "HEAD"]); + fs.writeFileSync(path.join(dir, "src", "new.ts"), "export function fresh(): number { return 2; }\n"); + git(dir, ["add", "-A"]); + git(dir, ["commit", "-m", "new", "--no-gpg-sign"]); + + const parsed = parseCodebase(dir); + const graph = analyzeGraph(buildGraph(parsed), parsed); + + const full = runCheck(graph, dir).findings.filter((f) => f.ruleId === "no-dead-exports"); + expect(full.length).toBeGreaterThanOrEqual(2); + + const gated = runCheck(graph, dir, { gate: "new-only", base }).findings; + expect(gated.length).toBeGreaterThanOrEqual(1); + expect(gated.every((f) => f.file === "src/new.ts")).toBe(true); + }); +});