diff --git a/README.md b/README.md index 71b1f8b..e10fa60 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ codebase-intelligence [options] | `rename` | Reference discovery for rename planning | | `processes` | Entry-point execution flow tracing | | `clusters` | Community-detected file clusters | -| `check` | Rules-engine gate for CI | +| `check` | Rules-engine gate for CI, including opt-in dead-code and dependency gates | | `init` | Set up AI agents to use CI — writes per-agent instruction files (skill opt-in via `--skill`) | ### Useful flags diff --git a/docs/architecture.md b/docs/architecture.md index 7455ff5..a3e05b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,7 @@ runOperation(operation, codebaseGraph, input, context) - **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, discriminated run results, and result-object text formatters. MCP tool registration and CLI command execution consume those descriptors; CLI JSON remains raw result data plus cache facts. - **Type/Shape facts**: Full-program parsing stores compact parameter/return/type-parameter facts on parsed symbols. `file`, `symbol`, and `search` JSON expose those facts additively; search indexes consumed/produced shape tokens so agents can ask which symbols touch a shape without a new command. - **Duplication families**: Parser stores deterministic function-body token streams on symbols. `duplicates` / `find_duplicates` groups symbols into strict, renamed, and near-miss clone families, with optional trace evidence for AI agents before refactors. +- **Dead-code gates**: `check` can opt into unused-file, unused-type, unused-member, and dependency hygiene rules. These rules use graph metrics plus local TypeScript AST facts and emit confidence/evidence in JSON and SARIF. - **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 cd18a11..c36476c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -192,7 +192,9 @@ codebase-intelligence check [--config ] [--format ] [--fail-on **Gate modes:** `all` (default), `new-only`. -**Output:** pass/warn/fail verdict, findings, and summary counts. Exit code `0` on pass, `1` when the configured gate fails, `2` for invalid config or arguments. +**Output:** pass/warn/fail verdict, findings, and summary counts. Findings include stable `fingerprint`; cleanup findings may also include `kind`, `confidence`, and `evidence`. Exit code `0` on pass, `1` when the configured gate fails, `2` for invalid config or arguments. + +**Rules:** `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn), plus opt-in cleanup gates: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. Configure severities in `codebase-intelligence.json`. ### init diff --git a/docs/data-model.md b/docs/data-model.md index 7a3cfa3..d510d98 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -170,3 +170,26 @@ CacheFacts { warnings: string[] // Non-fatal legacy/cache state warnings } ``` + +## Check Findings + +`check --json` and MCP `check` return deterministic findings. `kind`, `confidence`, +and `evidence` are additive fields for rules that can explain cleanup confidence. + +```typescript +Finding { + ruleId: string + severity: "warn" | "error" + kind?: string + confidence?: "high" | "medium" | "low" + file: string + line: number + column: number + endLine?: number + endColumn?: number + message: string + evidence?: string[] + actions?: FindingAction[] + fingerprint: string +} +``` diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index cb7aa1f..46f1e60 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -184,9 +184,9 @@ Community-detected clusters of related files. 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). +**Returns:** `{ verdict: "pass"|"warn"|"fail", summary: { error, warn, rules }, configPath, findings[] }`. Each finding has ruleId, severity, file, line, column, message, fingerprint, optional `kind`, optional `confidence`, optional `evidence[]`, and optional advisory `actions[]` (the tool is read-only — actions are hints, never applied). -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`). +Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn), and opt-in cleanup gates `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. 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). diff --git a/llms-full.txt b/llms-full.txt index cbc9ac1..4e090df 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -315,7 +315,7 @@ Entry point execution flows. Input: `{ entryPoint?: string, limit?: number }`. R Community-detected file clusters. Input: `{ minFiles?: number }`. Returns: clusters with cohesion. ## 18. check -Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, config path, and summary counts. +Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, 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. ## Tool Selection Guide @@ -470,7 +470,7 @@ content between the `codebase-intelligence:start`/`:end` markers is ever touched codebase-intelligence check [--config ] [--format ] [--fail-on ] [--gate ] [--base ] [--quiet] [--summary] [--json] [--force] ``` Rules-engine gate for CI. Formats: text, json, sarif. Fail-on: error, warn, never. -Gate modes: all, new-only. Returns pass/warn/fail verdict, findings, and summary counts. +Gate modes: all, new-only. Returns pass/warn/fail verdict, findings, and summary counts. Built-in rules include no-comments, no-circular-deps, no-dead-exports, and opt-in no-dead-files, no-unused-types, no-unused-members, no-unused-deps. ## Global Behavior diff --git a/llms.txt b/llms.txt index b3993a3..d7c25a3 100644 --- a/llms.txt +++ b/llms.txt @@ -36,7 +36,7 @@ codebase-intelligence impact ./src getUserById codebase-intelligence rename ./src oldName newName codebase-intelligence processes ./src --entry main codebase-intelligence clusters ./src --min-files 3 -codebase-intelligence check ./src # rules gate for CI +codebase-intelligence check ./src # rules gate for CI; JSON findings may include kind/confidence/evidence codebase-intelligence init . # make AI agents use CI ``` diff --git a/roadmap.md b/roadmap.md index 729c242..f045a6e 100644 --- a/roadmap.md +++ b/roadmap.md @@ -321,13 +321,21 @@ Add deterministic clone detection on the existing parser path. Extend deletion intelligence beyond exported symbols. -**To do:** +**Foundation slice:** + +- Add opt-in `check` rules: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. +- Detect unimported non-entrypoint files with confidence/evidence. +- Detect local unused type/interface declarations and exported dead types outside supported entrypoints. +- Detect unused private class members and non-exported enum members. +- Detect unused, unlisted, type-only, and test-only package dependencies. +- Emit `kind`, `confidence`, and `evidence[]` in JSON and SARIF findings. +- Add CH-P1-05 coverage for JSON/SARIF, supported package entrypoint ignore, and all four dead-code categories. + +**Remaining:** -- Detect unused files. -- Detect unused types. -- Detect unused enum/class members. -- Detect unused / unlisted / type-only / test-only dependencies. -- Keep findings framework-aware to avoid false positives. +- Add stale suppression reporting and `@public` / `@expected-unused` semantics in CH-P1-06. +- Add workspace/package-section dependency policy beyond single-package `package.json`. +- Add CSS/template/framework plugin coverage only after `doctor` can explain supported surfaces. ### Suppression Hygiene diff --git a/schema.json b/schema.json index 4856bd1..161d62a 100644 --- a/schema.json +++ b/schema.json @@ -69,7 +69,15 @@ }, "no-dead-files": { "$ref": "#/$defs/severity", - "description": "Report files unreachable from any entry point." + "description": "Report unimported files that are not recognized entrypoints." + }, + "no-unused-types": { + "$ref": "#/$defs/severity", + "description": "Report local type/interface declarations and exported type declarations that appear unused." + }, + "no-unused-members": { + "$ref": "#/$defs/severity", + "description": "Report unused private class members and non-exported enum members." }, "no-unused-deps": { "$ref": "#/$defs/severity", diff --git a/src/cli.ts b/src/cli.ts index cd823c8..44acf12 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -788,7 +788,7 @@ function parseGate(value: string | undefined): "all" | "new-only" | undefined | program .command("check") - .description("Run the rules engine and gate on findings (comments, circular deps, dead exports)") + .description("Run the rules engine and gate on findings") .argument("", "Path to TypeScript codebase") .option("--config ", "Config file path (overrides discovery)") .option("--format ", "Output: text, json, or sarif (default: text)") diff --git a/src/rules/dead-code.ts b/src/rules/dead-code.ts new file mode 100644 index 0000000..7a500b5 --- /dev/null +++ b/src/rules/dead-code.ts @@ -0,0 +1,647 @@ +import fs from "node:fs"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import ts from "typescript"; +import type { FileMetrics } from "../types/index.js"; +import type { ReportedFinding, Rule, RuleContext } from "./engine.js"; + +type DependencySection = "dependencies" | "optionalDependencies" | "peerDependencies" | "devDependencies"; + +interface TypeDeclarationFact { + file: string; + name: string; + kind: "type" | "interface"; + line: number; + column: number; + exported: boolean; +} + +interface MemberDeclarationFact { + file: string; + name: string; + owner: string; + kind: "private-class-member" | "enum-member"; + line: number; + column: number; + used: boolean; +} + +interface DependencyDeclaration { + name: string; + section: DependencySection; + line: number; +} + +interface DependencyUse { + packageName: string; + specifier: string; + file: string; + line: number; + isTypeOnly: boolean; + isTestFile: boolean; +} + +interface ParsedSourceFacts { + types: TypeDeclarationFact[]; + members: MemberDeclarationFact[]; + dependencyUses: DependencyUse[]; +} + +const BUILTIN_PACKAGES = new Set([ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`), +]); + +const DEPENDENCY_SECTIONS: DependencySection[] = [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", +]; + +const SOURCE_FACTS_CACHE = new WeakMap(); + +export const noDeadFiles: Rule = { + id: "no-dead-files", + meta: { description: "Report files that are not imported and are not recognized entrypoints.", category: "cleanup", fixable: false }, + defaultSeverity: "off", + run(ctx: RuleContext): ReportedFinding[] { + return collectDeadFileFindings(ctx); + }, +}; + +export const noUnusedTypes: Rule = { + id: "no-unused-types", + meta: { description: "Report local type and interface declarations with no references.", category: "cleanup", fixable: false }, + defaultSeverity: "off", + run(ctx: RuleContext): ReportedFinding[] { + return collectUnusedTypeFindings(ctx); + }, +}; + +export const noUnusedMembers: Rule = { + id: "no-unused-members", + meta: { description: "Report unused private class members and non-exported enum members.", category: "cleanup", fixable: false }, + defaultSeverity: "off", + run(ctx: RuleContext): ReportedFinding[] { + return collectUnusedMemberFindings(ctx); + }, +}; + +export const noUnusedDeps: Rule = { + id: "no-unused-deps", + meta: { description: "Report unused, unlisted, type-only, and test-only package dependencies.", category: "cleanup", fixable: false }, + defaultSeverity: "off", + run(ctx: RuleContext): ReportedFinding[] { + return collectDependencyFindings(ctx); + }, +}; + +function collectDeadFileFindings(ctx: RuleContext): ReportedFinding[] { + const findings: ReportedFinding[] = []; + const files = [...ctx.graph.fileMetrics.entries()].sort(([left], [right]) => left.localeCompare(right)); + + for (const [file, metrics] of files) { + if (metrics.fanIn > 0) continue; + if (entrypointReason(ctx, file, metrics)) continue; + + findings.push({ + file, + line: 1, + column: 1, + kind: "unused-file", + confidence: metrics.fanOut === 0 ? "high" : "medium", + message: `Unused file: ${file}`, + evidence: [ + "fanIn=0", + `fanOut=${String(metrics.fanOut)}`, + `exports=${String(metrics.totalExports)}`, + "entrypoint=false", + ], + }); + } + + return findings; +} + +function collectUnusedTypeFindings(ctx: RuleContext): ReportedFinding[] { + const findings: ReportedFinding[] = []; + + for (const facts of collectSourceFacts(ctx)) { + for (const typeFact of facts.types) { + const metrics = ctx.graph.fileMetrics.get(typeFact.file); + if (typeFact.exported) { + if (!metrics?.deadExports.includes(typeFact.name)) continue; + if (entrypointReason(ctx, typeFact.file, metrics)) continue; + findings.push({ + file: typeFact.file, + line: typeFact.line, + column: typeFact.column, + kind: "unused-exported-type", + confidence: "medium", + message: `Unused exported ${typeFact.kind}: ${typeFact.name}`, + evidence: ["deadExport=true", "entrypoint=false"], + }); + continue; + } + + findings.push({ + file: typeFact.file, + line: typeFact.line, + column: typeFact.column, + kind: `unused-${typeFact.kind}`, + confidence: metrics?.isPackageEntrypoint === true ? "medium" : "high", + message: `Unused ${typeFact.kind}: ${typeFact.name}`, + evidence: ["localDeclaration=true", "references=0"], + }); + } + } + + return findings; +} + +function collectUnusedMemberFindings(ctx: RuleContext): ReportedFinding[] { + const findings: ReportedFinding[] = []; + + for (const facts of collectSourceFacts(ctx)) { + for (const member of facts.members) { + if (member.used) continue; + findings.push({ + file: member.file, + line: member.line, + column: member.column, + kind: member.kind, + confidence: "high", + message: `Unused ${member.kind === "enum-member" ? "enum member" : "private class member"}: ${member.owner}.${member.name}`, + evidence: ["localMember=true", "references=0"], + }); + } + } + + return findings; +} + +function collectDependencyFindings(ctx: RuleContext): ReportedFinding[] { + const packageJson = readPackageJson(ctx.rootDir); + const declarations = packageJson ? collectDependencyDeclarations(packageJson) : new Map(); + const uses = collectSourceFacts(ctx).flatMap((facts) => facts.dependencyUses); + const usesByPackage = groupUsesByPackage(uses); + const ignored = new Set(ctx.config.ignore?.dependencies ?? []); + const findings: ReportedFinding[] = []; + + for (const [packageName, declaration] of [...declarations.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (ignored.has(packageName)) continue; + if (declaration.section !== "dependencies" && declaration.section !== "optionalDependencies") continue; + + const packageUses = usesByPackage.get(packageName) ?? []; + if (packageUses.length === 0) { + findings.push({ + file: "package.json", + line: declaration.line, + column: 1, + kind: "unused-dependency", + confidence: "medium", + message: `Unused dependency: ${packageName}`, + evidence: [`declaredIn=${declaration.section}`, "imports=0"], + }); + continue; + } + + if (packageUses.every((use) => use.isTestFile)) { + findings.push({ + file: "package.json", + line: declaration.line, + column: 1, + kind: "test-only-dependency", + confidence: "medium", + message: `Dependency is only used from tests: ${packageName}`, + evidence: [`declaredIn=${declaration.section}`, ...sampleUseEvidence(packageUses)], + }); + continue; + } + + if (packageUses.every((use) => use.isTypeOnly)) { + findings.push({ + file: "package.json", + line: declaration.line, + column: 1, + kind: "type-only-dependency", + confidence: "medium", + message: `Dependency is only used as types: ${packageName}`, + evidence: [`declaredIn=${declaration.section}`, ...sampleUseEvidence(packageUses)], + }); + } + } + + for (const [packageName, packageUses] of [...usesByPackage.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (ignored.has(packageName)) continue; + if (declarations.has(packageName)) continue; + if (BUILTIN_PACKAGES.has(packageName)) continue; + const firstUse = packageUses[0]; + + findings.push({ + file: firstUse.file, + line: firstUse.line, + column: 1, + kind: "unlisted-dependency", + confidence: "high", + message: `Unlisted dependency import: ${packageName}`, + evidence: [`specifier=${firstUse.specifier}`, "declared=false"], + }); + } + + return findings; +} + +function collectSourceFacts(ctx: RuleContext): ParsedSourceFacts[] { + const cached = SOURCE_FACTS_CACHE.get(ctx); + if (cached) return cached; + + const facts = ctx.fileRelPaths + .slice() + .sort((left, right) => left.localeCompare(right)) + .map((file) => parseSourceFacts(ctx, file)) + .filter((facts): facts is ParsedSourceFacts => facts !== null); + SOURCE_FACTS_CACHE.set(ctx, facts); + return facts; +} + +function parseSourceFacts(ctx: RuleContext, file: string): ParsedSourceFacts | null { + const source = ctx.sourceOf(file); + if (source === null) return null; + + const sourceFile = ts.createSourceFile( + path.join(ctx.rootDir, file), + source, + ts.ScriptTarget.ES2022, + true, + file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + const typeDeclarations = new Map(); + const typeDeclarationPositions = new Set(); + const identifierReferences = new Map(); + const enumMembers: Array = []; + const classMembers: Array = []; + const propertyReferences = new Set(); + const dependencyUses: DependencyUse[] = []; + const metrics = ctx.graph.fileMetrics.get(file); + + function collectDeclarations(node: ts.Node): void { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + const dependencyUse = dependencyUseFromModuleSpecifier(node, sourceFile, file, metrics); + if (dependencyUse) dependencyUses.push(dependencyUse); + } else if (ts.isImportEqualsDeclaration(node)) { + const dependencyUse = dependencyUseFromImportEquals(node, sourceFile, file, metrics); + if (dependencyUse) dependencyUses.push(dependencyUse); + } else if (ts.isCallExpression(node)) { + const dependencyUse = dependencyUseFromCall(node, sourceFile, file, metrics); + if (dependencyUse) dependencyUses.push(dependencyUse); + } else if (ts.isImportTypeNode(node)) { + const dependencyUse = dependencyUseFromImportType(node, sourceFile, file, metrics); + if (dependencyUse) dependencyUses.push(dependencyUse); + } + + if (ts.isTypeAliasDeclaration(node)) { + typeDeclarationPositions.add(node.name.getStart(sourceFile)); + typeDeclarations.set(node.name.text, { + file, + name: node.name.text, + kind: "type", + ...locationOf(sourceFile, node.name), + exported: isExported(node), + }); + } else if (ts.isInterfaceDeclaration(node)) { + typeDeclarationPositions.add(node.name.getStart(sourceFile)); + typeDeclarations.set(node.name.text, { + file, + name: node.name.text, + kind: "interface", + ...locationOf(sourceFile, node.name), + exported: isExported(node), + }); + } else if (ts.isEnumDeclaration(node) && !isExported(node)) { + typeDeclarationPositions.add(node.name.getStart(sourceFile)); + for (const member of node.members) { + const name = propertyNameText(member.name, sourceFile); + if (!name) continue; + typeDeclarationPositions.add(member.name.getStart(sourceFile)); + enumMembers.push({ + file, + name, + owner: node.name.text, + enumName: node.name.text, + kind: "enum-member", + ...locationOf(sourceFile, member.name), + used: false, + }); + } + } else if (ts.isClassDeclaration(node) && node.name) { + const className = node.name.text; + for (const member of node.members) { + const name = classMemberName(member, sourceFile); + if (!name || !isPrivateClassMember(member)) continue; + typeDeclarationPositions.add(member.name.getStart(sourceFile)); + classMembers.push({ + file, + name, + owner: className, + className, + kind: "private-class-member", + ...locationOf(sourceFile, member.name), + used: false, + }); + } + } + + ts.forEachChild(node, collectDeclarations); + } + + function collectReferences(node: ts.Node): void { + if (ts.isIdentifier(node) && !typeDeclarationPositions.has(node.getStart(sourceFile))) { + identifierReferences.set(node.text, (identifierReferences.get(node.text) ?? 0) + 1); + } + + if (ts.isPropertyAccessExpression(node)) { + const name = propertyNameText(node.name, sourceFile); + const owner = propertyAccessOwner(node.expression, sourceFile); + if (name && owner) propertyReferences.add(`${owner}.${name}`); + } else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) { + const owner = propertyAccessOwner(node.expression, sourceFile); + if (owner) propertyReferences.add(`${owner}.${node.argumentExpression.text}`); + } + + ts.forEachChild(node, collectReferences); + } + + collectDeclarations(sourceFile); + collectReferences(sourceFile); + + const unusedTypes = [...typeDeclarations.values()].filter((typeFact) => { + if (typeFact.exported) return true; + return (identifierReferences.get(typeFact.name) ?? 0) === 0; + }); + + const members = [ + ...enumMembers.map((member) => ({ + ...member, + used: propertyReferences.has(`${member.enumName}.${member.name}`), + })), + ...classMembers.map((member) => ({ + ...member, + used: propertyReferences.has(`this.${member.name}`) || propertyReferences.has(`${member.className}.${member.name}`), + })), + ]; + + return { types: unusedTypes, members, dependencyUses }; +} + +function dependencyUseFromModuleSpecifier( + node: ts.ImportDeclaration | ts.ExportDeclaration, + sourceFile: ts.SourceFile, + file: string, + metrics: FileMetrics | undefined, +): DependencyUse | null { + if (!node.moduleSpecifier || !ts.isStringLiteralLike(node.moduleSpecifier)) return null; + const packageName = packageNameFromSpecifier(node.moduleSpecifier.text); + if (!packageName) return null; + const { line } = locationOf(sourceFile, node.moduleSpecifier); + + return { + packageName, + specifier: node.moduleSpecifier.text, + file, + line, + isTypeOnly: isTypeOnlyModuleReference(node), + isTestFile: metrics?.isTestFile ?? isTestPath(file), + }; +} + +function dependencyUseFromCall( + node: ts.CallExpression, + sourceFile: ts.SourceFile, + file: string, + metrics: FileMetrics | undefined, +): DependencyUse | null { + const isRequireCall = ts.isIdentifier(node.expression) && node.expression.text === "require"; + const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword; + if (!isRequireCall && !isDynamicImport) return null; + if (node.arguments.length === 0) return null; + const firstArg = node.arguments[0]; + if (!ts.isStringLiteralLike(firstArg)) return null; + const packageName = packageNameFromSpecifier(firstArg.text); + if (!packageName) return null; + const { line } = locationOf(sourceFile, firstArg); + + return { + packageName, + specifier: firstArg.text, + file, + line, + isTypeOnly: false, + isTestFile: metrics?.isTestFile ?? isTestPath(file), + }; +} + +function dependencyUseFromImportEquals( + node: ts.ImportEqualsDeclaration, + sourceFile: ts.SourceFile, + file: string, + metrics: FileMetrics | undefined, +): DependencyUse | null { + if (!ts.isExternalModuleReference(node.moduleReference)) return null; + const expression = node.moduleReference.expression; + if (!ts.isStringLiteralLike(expression)) return null; + const packageName = packageNameFromSpecifier(expression.text); + if (!packageName) return null; + const { line } = locationOf(sourceFile, expression); + + return { + packageName, + specifier: expression.text, + file, + line, + isTypeOnly: node.isTypeOnly, + isTestFile: metrics?.isTestFile ?? isTestPath(file), + }; +} + +function dependencyUseFromImportType( + node: ts.ImportTypeNode, + sourceFile: ts.SourceFile, + file: string, + metrics: FileMetrics | undefined, +): DependencyUse | null { + const argument = node.argument; + if (!ts.isLiteralTypeNode(argument) || !ts.isStringLiteralLike(argument.literal)) return null; + const packageName = packageNameFromSpecifier(argument.literal.text); + if (!packageName) return null; + const { line } = locationOf(sourceFile, argument.literal); + + return { + packageName, + specifier: argument.literal.text, + file, + line, + isTypeOnly: true, + isTestFile: metrics?.isTestFile ?? isTestPath(file), + }; +} + +function isTypeOnlyModuleReference(node: ts.ImportDeclaration | ts.ExportDeclaration): boolean { + if (ts.isExportDeclaration(node)) { + if (node.isTypeOnly) return true; + if (!node.exportClause || !ts.isNamedExports(node.exportClause)) return false; + return node.exportClause.elements.length > 0 && node.exportClause.elements.every((element) => element.isTypeOnly); + } + if (!node.importClause) return false; + if (node.importClause.phaseModifier === ts.SyntaxKind.TypeKeyword) return true; + if (node.importClause.name) return false; + const bindings = node.importClause.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) return false; + return bindings.elements.length > 0 && bindings.elements.every((element) => element.isTypeOnly); +} + +function packageNameFromSpecifier(specifier: string): string | null { + if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#")) return null; + if (specifier.startsWith("node:")) return specifier; + const [first, second] = specifier.split("/"); + if (!first) return null; + if (first.startsWith("@")) return second ? `${first}/${second}` : first; + return first; +} + +function collectDependencyDeclarations(packageJson: { source: string; data: Record }): Map { + const declarations = new Map(); + + for (const section of DEPENDENCY_SECTIONS) { + const sectionValue = packageJson.data[section]; + if (!isRecord(sectionValue)) continue; + for (const [name, value] of Object.entries(sectionValue)) { + if (typeof value !== "string") continue; + declarations.set(name, { name, section, line: lineOfNeedle(packageJson.source, `"${name}"`) }); + } + } + + return declarations; +} + +function groupUsesByPackage(uses: DependencyUse[]): Map { + const grouped = new Map(); + for (const use of uses) { + if (BUILTIN_PACKAGES.has(use.packageName)) continue; + const existing = grouped.get(use.packageName) ?? []; + existing.push(use); + grouped.set(use.packageName, existing); + } + return grouped; +} + +function sampleUseEvidence(uses: DependencyUse[]): string[] { + return uses + .slice(0, 3) + .map((use) => `usedIn=${use.file}:${String(use.line)}:${use.isTypeOnly ? "type" : "runtime"}`); +} + +function readPackageJson(rootDir: string): { source: string; data: Record } | null { + const packageJsonPath = path.join(rootDir, "package.json"); + try { + const source = fs.readFileSync(packageJsonPath, "utf-8"); + const parsed: unknown = JSON.parse(source); + if (!isRecord(parsed)) return null; + return { source, data: parsed }; + } catch { + return null; + } +} + +function entrypointReason(ctx: RuleContext, file: string, metrics: FileMetrics): string | null { + if (metrics.isPackageEntrypoint) return metrics.packageEntrypointReason || "package entrypoint"; + if (metrics.isTestFile || isTestPath(file)) return "test file"; + + const configuredEntry = ctx.config.entry?.find((pattern) => globMatches(file, pattern)); + if (configuredEntry) return `config.entry:${configuredEntry}`; + + const frameworkReason = frameworkEntrypointReason(file); + if (frameworkReason) return frameworkReason; + + const basename = path.posix.basename(file); + if (basename === "index.ts" || basename === "index.tsx") return `conventional entrypoint:${basename}`; + if ((basename === "main.ts" || basename === "main.tsx" || basename === "cli.ts" || basename === "cli.tsx") && file.startsWith("src/")) { + return `conventional entrypoint:${basename}`; + } + + return null; +} + +function frameworkEntrypointReason(file: string): string | null { + if (/^(?:src\/)?app\/.*(?:page|layout|route|loading|error|not-found)\.tsx?$/.test(file)) return "next-app-router"; + if (/^(?:src\/)?pages\/.*\.(?:ts|tsx)$/.test(file)) return "next-pages-router"; + if (/^convex\/.*\.ts$/.test(file)) return "convex-functions"; + return null; +} + +function globMatches(file: string, pattern: string): boolean { + const normalizedFile = normalizePath(file); + const normalizedPattern = normalizePath(pattern).replace(/^\.\//, ""); + if (normalizedFile === normalizedPattern) return true; + + const escaped = normalizedPattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "\0") + .replace(/\*/g, "[^/]*") + .replace(/\0/g, ".*"); + + return new RegExp(`^${escaped}$`).test(normalizedFile); +} + +function isTestPath(file: string): boolean { + return file.includes("__tests__/") || file.includes(".test.") || file.includes(".spec."); +} + +function isExported(node: ts.Node): boolean { + return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false); +} + +function isPrivateClassMember(member: ts.ClassElement): member is ts.PropertyDeclaration | ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration { + if (!("name" in member) || !member.name) return false; + if (ts.isPrivateIdentifier(member.name)) return true; + return ts.canHaveModifiers(member) && (ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword) ?? false); +} + +function classMemberName(member: ts.ClassElement, sourceFile: ts.SourceFile): string | null { + if (!("name" in member) || !member.name) return null; + return propertyNameText(member.name, sourceFile); +} + +function propertyNameText(name: ts.PropertyName | ts.MemberName, sourceFile: ts.SourceFile): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; + if (ts.isPrivateIdentifier(name)) return name.getText(sourceFile).replace(/^#/, ""); + return null; +} + +function propertyAccessOwner(expression: ts.Expression, sourceFile: ts.SourceFile): string | null { + if (expression.kind === ts.SyntaxKind.ThisKeyword) return "this"; + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) return propertyNameText(expression.name, sourceFile); + return null; +} + +function locationOf(sourceFile: ts.SourceFile, node: ts.Node): { line: number; column: number } { + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return { line: position.line + 1, column: position.character + 1 }; +} + +function lineOfNeedle(source: string, needle: string): number { + const index = source.indexOf(needle); + if (index < 0) return 1; + return source.slice(0, index).split(/\r?\n/).length; +} + +function normalizePath(file: string): string { + return file.replace(/\\/g, "/"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/rules/engine.ts b/src/rules/engine.ts index 4bcad57..d5fd3e5 100644 --- a/src/rules/engine.ts +++ b/src/rules/engine.ts @@ -4,6 +4,7 @@ import type { CodebaseIntelligenceConfig, Finding, FindingAction, + FindingConfidence, RuleSetting, Severity, } from "../types/index.js"; @@ -26,7 +27,10 @@ export interface ReportedFinding { column: number; endLine?: number; endColumn?: number; + kind?: string; + confidence?: FindingConfidence; message: string; + evidence?: string[]; actions?: FindingAction[]; } @@ -149,12 +153,15 @@ export function runEngine( out.push({ ruleId: rule.id, severity, + kind: r.kind, + confidence: r.confidence, file: r.file, line: r.line, column: r.column, endLine: r.endLine, endColumn: r.endColumn, message: r.message, + evidence: r.evidence, actions: r.actions, fingerprint: fingerprint(rule.id, r.file, r.line, r.message), }); diff --git a/src/rules/format.ts b/src/rules/format.ts index 1a654b7..11c60dc 100644 --- a/src/rules/format.ts +++ b/src/rules/format.ts @@ -45,6 +45,13 @@ export function formatText(result: CheckResult): string { export function formatSarif(result: CheckResult): string { const ruleIds = [...new Set(result.findings.map((f) => f.ruleId))]; + const propertiesFor = (finding: CheckResult["findings"][number]): Record | undefined => { + const properties: Record = {}; + if (finding.kind) properties.kind = finding.kind; + if (finding.confidence) properties.confidence = finding.confidence; + if (finding.evidence) properties.evidence = finding.evidence; + return Object.keys(properties).length > 0 ? properties : undefined; + }; const sarif = { $schema: "https://json.schemastore.org/sarif-2.1.0.json", version: "2.1.0", @@ -78,6 +85,7 @@ export function formatSarif(result: CheckResult): string { ], // Position-derived key — may shift when lines change, hence partialFingerprints. partialFingerprints: { ciFingerprint: f.fingerprint }, + ...(propertiesFor(f) ? { properties: propertiesFor(f) } : {}), })), }, ], diff --git a/src/rules/registry.ts b/src/rules/registry.ts index 494b4c3..a107ddf 100644 --- a/src/rules/registry.ts +++ b/src/rules/registry.ts @@ -1,7 +1,16 @@ import type { Rule } from "./engine.js"; import { noComments } from "./no-comments.js"; import { noCircularDeps } from "./no-circular-deps.js"; +import { noDeadFiles, noUnusedDeps, noUnusedMembers, noUnusedTypes } from "./dead-code.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]; +export const ALL_RULES: Rule[] = [ + noComments, + noCircularDeps, + noDeadExports, + noDeadFiles, + noUnusedTypes, + noUnusedMembers, + noUnusedDeps, +]; diff --git a/src/types/index.ts b/src/types/index.ts index 7fd645a..0e54f4c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -360,6 +360,7 @@ export interface CodebaseIntelligenceConfig { } export type ActionKind = "remove-comment"; +export type FindingConfidence = "high" | "medium" | "low"; export interface FindingAction { kind: ActionKind; @@ -371,12 +372,15 @@ export interface FindingAction { export interface Finding { ruleId: string; severity: FindingSeverity; + kind?: string; + confidence?: FindingConfidence; file: string; line: number; column: number; endLine?: number; endColumn?: number; message: string; + evidence?: string[]; actions?: FindingAction[]; fingerprint: string; } diff --git a/tests/cli-check.e2e.test.ts b/tests/cli-check.e2e.test.ts index 76061c6..e9a4331 100644 --- a/tests/cli-check.e2e.test.ts +++ b/tests/cli-check.e2e.test.ts @@ -76,6 +76,66 @@ const DEAD = { "src/b.ts": "export function used(): number { return 1; }\nexport function deadOne(): number { return 2; }\n", }; +const DEAD_CODE_EXPANSION = { + "package.json": JSON.stringify( + { + name: "dead-code-fixture", + exports: "./src/index.ts", + dependencies: { + "@sinclair/typebox": "1.0.0", + "left-pad": "1.0.0", + "unused-runtime": "1.0.0", + vitest: "1.0.0", + }, + }, + null, + 2, + ), + "src/index.ts": 'export { live } from "./live.js";\n', + "src/live.ts": "export function live(): number { return 1; }\n", + "src/orphan.ts": 'import { live } from "./live.js";\nexport const orphan = live();\n', + "src/types.ts": [ + "type LocalDraft = { id: string };", + "interface LocalView { title: string }", + "export type DeadPublic = { value: string };", + "export const marker = 1;", + "", + ].join("\n"), + "src/members.ts": [ + "class Worker {", + " private unusedTask(): number { return 1; }", + " private usedTask(): number { return 2; }", + " run(): number { return this.usedTask(); }", + "}", + "enum State { Idle = 'idle', Active = 'active' }", + "export const state = State.Active;", + "export const worker = new Worker().run();", + "", + ].join("\n"), + "src/deps.ts": [ + 'import type { Static } from "@sinclair/typebox";', + 'import equalsRuntime = require("equals-runtime");', + 'import leftPad from "left-pad";', + 'import missingRuntime from "missing-runtime";', + "type Only = Static;", + "export async function loadDynamic(): Promise { return import('dynamic-runtime'); }", + "export const depUse = leftPad(`${missingRuntime}${equalsRuntime}`, 3);", + "", + ].join("\n"), + "src/deps.test.ts": 'import { describe } from "vitest";\ndescribe("deps", () => {});\n', +}; + +const DEAD_CODE_RULES = { + rules: { + "no-circular-deps": "off", + "no-dead-exports": "off", + "no-dead-files": "warn", + "no-unused-types": "warn", + "no-unused-members": "warn", + "no-unused-deps": "warn", + }, +}; + beforeAll(() => { if (!fs.existsSync(cli)) execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); }, 120_000); @@ -216,4 +276,64 @@ describe("check command (e2e)", () => { expect(status).toBe(0); expect(stdout).toContain("No findings."); }); + + it("CH-P1-05: gates dead files, types, members, and dependency drift with confidence evidence", async () => { + const dir = makeProject(DEAD_CODE_EXPANSION, DEAD_CODE_RULES); + const jsonRun = await run(["check", dir, "--json", "--fail-on", "warn", "--force"]); + expect(jsonRun.status).toBe(1); + + const parsed = JSON.parse(jsonRun.stdout) as { + findings: Array<{ + ruleId: string; + kind?: string; + confidence?: string; + file: string; + message: string; + evidence?: string[]; + }>; + }; + + const findings = parsed.findings; + const unusedFile = findings.find((finding) => finding.kind === "unused-file" && finding.file === "src/orphan.ts"); + expect(unusedFile?.ruleId).toBe("no-dead-files"); + expect(unusedFile?.confidence).toBe("medium"); + expect(unusedFile?.evidence).toContain("entrypoint=false"); + expect(findings.some((finding) => finding.ruleId === "no-dead-files" && finding.file === "src/index.ts")).toBe(false); + + expect(findings.some((finding) => finding.kind === "unused-type" && finding.message.includes("LocalDraft"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unused-interface" && finding.message.includes("LocalView"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unused-exported-type" && finding.message.includes("DeadPublic"))).toBe(true); + + expect(findings.some((finding) => finding.kind === "private-class-member" && finding.message.includes("Worker.unusedTask"))).toBe(true); + expect(findings.some((finding) => finding.kind === "enum-member" && finding.message.includes("State.Idle"))).toBe(true); + expect(findings.some((finding) => finding.message.includes("Worker.usedTask"))).toBe(false); + expect(findings.some((finding) => finding.message.includes("State.Active"))).toBe(false); + + expect(findings.some((finding) => finding.kind === "unused-dependency" && finding.message.includes("unused-runtime"))).toBe(true); + expect(findings.some((finding) => finding.kind === "type-only-dependency" && finding.message.includes("@sinclair/typebox"))).toBe(true); + expect(findings.some((finding) => finding.kind === "test-only-dependency" && finding.message.includes("vitest"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unlisted-dependency" && finding.message.includes("missing-runtime"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unlisted-dependency" && finding.message.includes("equals-runtime"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unlisted-dependency" && finding.message.includes("dynamic-runtime"))).toBe(true); + expect(findings.some((finding) => finding.message.includes("left-pad"))).toBe(false); + + const sarifRun = await run(["check", dir, "--format", "sarif", "--fail-on", "warn", "--force"]); + expect(sarifRun.status).toBe(1); + const sarif = JSON.parse(sarifRun.stdout) as { + runs: Array<{ + results: Array<{ + ruleId: string; + locations?: Array<{ physicalLocation?: { artifactLocation?: { uri?: string } } }>; + properties?: { confidence?: string; evidence?: string[]; kind?: string }; + }>; + }>; + }; + const sarifFinding = sarif.runs[0].results.find( + (finding) => finding.properties?.kind === "unused-file" + && finding.locations?.[0]?.physicalLocation?.artifactLocation?.uri === "src/orphan.ts", + ); + expect(sarifFinding?.ruleId).toBe("no-dead-files"); + expect(sarifFinding?.properties?.confidence).toBe("medium"); + expect(sarifFinding?.properties?.evidence).toContain("entrypoint=false"); + }, 90_000); });