Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ runOperation(operation, codebaseGraph, input, context)
- **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.
- **Suppression hygiene**: `check` reports active and stale `ci-ignore-*` / `@expected-unused` suppressions, emits stale suppression findings via `no-stale-suppressions`, and treats `@public` exported type declarations as intentional public API while `@internal` stays checkable.
- **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.
Expand Down
4 changes: 2 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,9 @@ codebase-intelligence check <path> [--config <path>] [--format <fmt>] [--fail-on

**Gate modes:** `all` (default), `new-only`.

**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.
**Output:** pass/warn/fail verdict, findings, suppression ledger, and summary counts. Findings include stable `fingerprint`; cleanup findings may also include `kind`, `confidence`, and `evidence`. Summary counts include `suppressed` and `staleSuppressions`; `--summary` prints those counts when present. Exit code `0` on pass, `1` when the configured gate fails, `2` for invalid config or arguments.

**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`.
**Rules:** `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), plus opt-in cleanup gates: `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities in `codebase-intelligence.json`.

### init

Expand Down
33 changes: 31 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,9 @@ CacheFacts {

## Check Findings

`check --json` and MCP `check` return deterministic findings. `kind`, `confidence`,
and `evidence` are additive fields for rules that can explain cleanup confidence.
`check --json` and MCP `check` return deterministic findings plus a suppression
ledger. `kind`, `confidence`, and `evidence` are additive fields for rules that
can explain cleanup confidence.

```typescript
Finding {
Expand All @@ -192,4 +193,32 @@ Finding {
actions?: FindingAction[]
fingerprint: string
}

CheckSuppression {
directive: "ci-ignore-file" | "ci-ignore-next-line" | "@expected-unused"
status: "active" | "stale"
file: string
line: number
targetLine?: number
ruleIds: string[]
matchesAllRules: boolean
suppressed: number
message: string
}

CheckSummary {
error: number
warn: number
suppressed: number
staleSuppressions: number
rules: Record<string, number>
}

CheckResult {
findings: Finding[]
suppressions: CheckSuppression[]
summary: CheckSummary
verdict: "pass" | "warn" | "fail"
configPath: string | null
}
```
4 changes: 2 additions & 2 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, optional `kind`, optional `confidence`, optional `evidence[]`, and optional advisory `actions[]` (the tool is read-only — actions are hints, never applied).
**Returns:** `{ verdict: "pass"|"warn"|"fail", summary: { error, warn, suppressed, staleSuppressions, rules }, configPath, suppressions[], findings[] }`. Each finding has ruleId, severity, file, line, column, message, fingerprint, optional `kind`, optional `confidence`, optional `evidence[]`, and optional advisory `actions[]` (the tool is read-only — actions are hints, never applied). Each suppression records directive, status, file, line, targetLine, matched rule IDs, and suppressed count.

Rules: `no-comments` (off by default), `no-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`).
Rules: `no-comments` (off by default), `no-circular-deps` (error), `no-dead-exports` (warn), `no-stale-suppressions` (warn), and opt-in cleanup gates `no-dead-files`, `no-unused-types`, `no-unused-members`, `no-unused-deps`. `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions are reported as active/stale; JSDoc `@public` protects exported cleanup declarations while `@internal` remains checkable. Configure severities and options in `codebase-intelligence.json` (validated by `schema.json`).

**Use when:** Linting a codebase or enforcing a CI gate. "What rule violations exist?"
**Not for:** Architecture metrics (use analyze_forces).
Expand Down
4 changes: 2 additions & 2 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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. Findings always include ruleId, severity, file, line, column, message, and fingerprint; cleanup findings may also include kind, confidence, and evidence.
Rules-engine gate. Input: `{}`. Returns: pass/warn/fail verdict, findings, suppression ledger, config path, and summary counts. Findings always include ruleId, severity, file, line, column, message, and fingerprint; cleanup findings may also include kind, confidence, and evidence. Suppressions include directive, active/stale status, file, line, targetLine, ruleIds, and suppressed count.

## Tool Selection Guide

Expand Down Expand Up @@ -470,7 +470,7 @@ content between the `codebase-intelligence:start`/`:end` markers is ever touched
codebase-intelligence check <path> [--config <path>] [--format <fmt>] [--fail-on <severity>] [--gate <mode>] [--base <ref>] [--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. 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.
Gate modes: all, new-only. Returns pass/warn/fail verdict, findings, suppression ledger, and summary counts. Built-in rules include no-comments, no-circular-deps, no-dead-exports, no-stale-suppressions, and opt-in no-dead-files, no-unused-types, no-unused-members, no-unused-deps. Reports active/stale `ci-ignore-*` and `@expected-unused` suppressions; `@public` protects exported cleanup declarations while `@internal` remains checkable.

## Global Behavior

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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; JSON findings may include kind/confidence/evidence
codebase-intelligence check ./src # rules gate for CI; JSON includes findings plus active/stale suppressions
codebase-intelligence init . # make AI agents use CI
```

Expand Down
15 changes: 10 additions & 5 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,6 @@ Extend deletion intelligence beyond exported symbols.

**Remaining:**

- 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.

Expand All @@ -343,11 +342,17 @@ Finish suppression management without hiding stale debt.

**Already exists:** `ci-ignore-next-line` and `ci-ignore-file`.

**To do:**
**Foundation slice:**

- Report active and stale `ci-ignore-file`, `ci-ignore-next-line`, and JSDoc `@expected-unused` suppressions in JSON.
- Add `no-stale-suppressions` warnings so stale suppressions can fail CI with `--fail-on warn`.
- Include `suppressed` and `staleSuppressions` in check summaries and `--summary` output when relevant.
- Add JSDoc `@public` and `@internal` semantics for cleanup declarations: public exported types are protected, internal exported types remain checkable.
- Add CH-P1-06 coverage for active suppression, stale suppression drift, JSDoc cleanup semantics, JSON summary, CLI summary, and SARIF properties.

**Remaining:**

- Add JSDoc `@public`, `@internal`, and `@expected-unused` semantics.
- Add stale-suppression detection.
- Report suppressions in JSON and CI summaries.
- No open CH-P1-06 work on the current TypeScript cleanup surface.

---

Expand Down
4 changes: 4 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
"$ref": "#/$defs/severity",
"description": "Report package.json dependencies that are never imported."
},
"no-stale-suppressions": {
"$ref": "#/$defs/severity",
"description": "Report ci-ignore and @expected-unused suppressions that no longer hide any finding."
},
"no-duplication": {
"description": "Report duplicated code (clone families).",
"oneOf": [
Expand Down
22 changes: 16 additions & 6 deletions src/rules/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,32 @@ import path from "path";
import type {
CheckResult,
CheckSummary,
CheckSuppression,
CodebaseGraph,
CodebaseIntelligenceConfig,
Finding,
Verdict,
} from "../types/index.js";
import { loadConfig, type ConfigOverrides } from "../config/index.js";
import { runEngine, type RuleContext } from "./engine.js";
import { runEngineWithSuppressions, type RuleContext } from "./engine.js";
import { ALL_RULES } from "./registry.js";

function summarize(findings: Finding[]): CheckSummary {
function summarize(findings: Finding[], suppressions: CheckSuppression[]): CheckSummary {
const rules: Record<string, number> = {};
let error = 0;
let warn = 0;
let suppressed = 0;
let staleSuppressions = 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 };
for (const suppression of suppressions) {
if (suppression.status === "active") suppressed += suppression.suppressed;
else staleSuppressions += 1;
}
return { error, warn, suppressed, staleSuppressions, rules };
}

function computeVerdict(summary: CheckSummary, config: CodebaseIntelligenceConfig): Verdict {
Expand Down Expand Up @@ -105,7 +112,9 @@ export function runCheck(
};

const ctx: RuleContext = { graph, rootDir: resolvedRoot, config, fileRelPaths, sourceOf };
let findings = runEngine(ctx, ALL_RULES, config);
const engineResult = runEngineWithSuppressions(ctx, ALL_RULES, config);
let findings = engineResult.findings;
let suppressions = engineResult.suppressions;

if (config.ci?.gate === "new-only") {
const base = config.ci.base;
Expand All @@ -117,13 +126,14 @@ export function runCheck(
process.stderr.write(`Warning: could not diff against '${base}'; running full check.\n`);
} else {
findings = findings.filter((f) => changed.has(toPosix(f.file)));
suppressions = suppressions.filter((suppression) => changed.has(toPosix(suppression.file)));
}
}
}

const summary = summarize(findings);
const summary = summarize(findings, suppressions);
const verdict = computeVerdict(summary, config);
return { findings, summary, verdict, configPath };
return { findings, suppressions, summary, verdict, configPath };
}

/** Process exit code for a check result: 1 on fail, 0 otherwise. (Config errors → 2, handled by the CLI.) */
Expand Down
15 changes: 15 additions & 0 deletions src/rules/dead-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface TypeDeclarationFact {
line: number;
column: number;
exported: boolean;
jsDocTags: Set<string>;
}

interface MemberDeclarationFact {
Expand All @@ -24,6 +25,7 @@ interface MemberDeclarationFact {
line: number;
column: number;
used: boolean;
jsDocTags: Set<string>;
}

interface DependencyDeclaration {
Expand Down Expand Up @@ -131,6 +133,7 @@ function collectUnusedTypeFindings(ctx: RuleContext): ReportedFinding[] {
for (const typeFact of facts.types) {
const metrics = ctx.graph.fileMetrics.get(typeFact.file);
if (typeFact.exported) {
if (hasJsDocTag(typeFact, "public")) continue;
if (!metrics?.deadExports.includes(typeFact.name)) continue;
if (entrypointReason(ctx, typeFact.file, metrics)) continue;
findings.push({
Expand Down Expand Up @@ -310,6 +313,7 @@ function parseSourceFacts(ctx: RuleContext, file: string): ParsedSourceFacts | n
kind: "type",
...locationOf(sourceFile, node.name),
exported: isExported(node),
jsDocTags: jsDocTagNames(node),
});
} else if (ts.isInterfaceDeclaration(node)) {
typeDeclarationPositions.add(node.name.getStart(sourceFile));
Expand All @@ -319,6 +323,7 @@ function parseSourceFacts(ctx: RuleContext, file: string): ParsedSourceFacts | n
kind: "interface",
...locationOf(sourceFile, node.name),
exported: isExported(node),
jsDocTags: jsDocTagNames(node),
});
} else if (ts.isEnumDeclaration(node) && !isExported(node)) {
typeDeclarationPositions.add(node.name.getStart(sourceFile));
Expand All @@ -334,6 +339,7 @@ function parseSourceFacts(ctx: RuleContext, file: string): ParsedSourceFacts | n
kind: "enum-member",
...locationOf(sourceFile, member.name),
used: false,
jsDocTags: jsDocTagNames(member),
});
}
} else if (ts.isClassDeclaration(node) && node.name) {
Expand All @@ -350,6 +356,7 @@ function parseSourceFacts(ctx: RuleContext, file: string): ParsedSourceFacts | n
kind: "private-class-member",
...locationOf(sourceFile, member.name),
used: false,
jsDocTags: jsDocTagNames(member),
});
}
}
Expand Down Expand Up @@ -603,6 +610,14 @@ function isExported(node: ts.Node): boolean {
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false);
}

function jsDocTagNames(node: ts.Node): Set<string> {
return new Set(ts.getJSDocTags(node).map((tag) => tag.tagName.getText().replace(/^@/, "")));
}

function hasJsDocTag(fact: { jsDocTags: Set<string> }, tag: string): boolean {
return fact.jsDocTags.has(tag);
}

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;
Expand Down
Loading
Loading