diff --git a/docs/architecture.md b/docs/architecture.md index 336fd8b..0bc73c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +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. +- **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; dependency hygiene is scoped to the nearest package/workspace manifest. - **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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 03f74cd..c0dabf5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -194,7 +194,7 @@ codebase-intelligence check [--config ] [--format ] [--fail-on **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), `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`. +**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`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `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 diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 4a11877..5ef73ca 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -186,7 +186,7 @@ Run the configurable rules engine and gate on findings. **Input:** `{}` (uses the loaded graph + discovered config) **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), `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`). +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`. Dependency findings are scoped to the nearest `package.json` / workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. `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). diff --git a/llms-full.txt b/llms-full.txt index 1024710..c73a0da 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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, 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. +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. Dependency findings are scoped to the nearest package/workspace manifest and include unused, unlisted, type-only, test-only, and runtime-from-devDependencies drift. Reports active/stale `ci-ignore-*` and `@expected-unused` suppressions; `@public` protects exported cleanup declarations while `@internal` remains checkable. ## Global Behavior diff --git a/roadmap.md b/roadmap.md index 7c0bbd5..fb4bca2 100644 --- a/roadmap.md +++ b/roadmap.md @@ -160,6 +160,7 @@ Purpose: prove the CLI, MCP server, library outputs, docs, CI behavior, and agen | CH-P1-04 duplication families | As US-CLI-HUMAN, I can find duplicate logic with deterministic IDs. | Fixture exact clone + renamed clone + near-miss + below-threshold noise -> run CLI/MCP -> assert family IDs, thresholds, trace output, stable ordering, and no local-skip false positive. | | CH-P1-05 dead code expansion | As US-CLI-CI, I can gate unused files/types/members/deps without framework false positives. | Fixture unused file/type/member/dependency plus supported entrypoint -> run check JSON/SARIF -> assert real findings, confidence, and ignored entrypoint evidence. | | CH-P1-06 suppression hygiene | As US-MAINTAINER, suppressions do not hide stale debt forever. | Add active suppression -> assert finding suppressed and reported -> remove underlying finding -> assert stale suppression warning -> assert JSDoc `@public`/`@internal`/`@expected-unused` behavior. | +| CH-P1-07 workspace dependency policy | As US-MONOREPO-MAINTAINER, dependency findings point at the package that owns the import. | Multi-package fixture -> run check JSON -> assert root/package manifests are scoped separately -> assert unlisted workspace imports, test-only deps, type-only deps, runtime devDeps, and unused deps with package evidence. | ### P2 Chains — Product Parity + Flagship Intelligence @@ -328,12 +329,14 @@ Extend deletion intelligence beyond exported symbols. - 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. +- Scope dependency hygiene to the nearest `package.json` / workspace manifest. +- Detect runtime imports declared only in `devDependencies`. - 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. +- Add CH-P1-07 coverage for root/package manifest scoping, unlisted package imports, test-only deps, type-only deps, runtime devDeps, and package evidence. **Remaining:** -- 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 bd80c18..a303030 100644 --- a/schema.json +++ b/schema.json @@ -81,7 +81,7 @@ }, "no-unused-deps": { "$ref": "#/$defs/severity", - "description": "Report package.json dependencies that are never imported." + "description": "Report unused, unlisted, type-only, test-only, and wrong-section dependencies per package.json/workspace manifest." }, "no-stale-suppressions": { "$ref": "#/$defs/severity", diff --git a/src/rules/dead-code.ts b/src/rules/dead-code.ts index afed6a1..2edb230 100644 --- a/src/rules/dead-code.ts +++ b/src/rules/dead-code.ts @@ -43,6 +43,15 @@ interface DependencyUse { isTestFile: boolean; } +interface PackageManifest { + relPath: string; + dir: string; + name: string | null; + source: string; + data: Record; + declarations: Map; +} + interface ParsedSourceFacts { types: TypeDeclarationFact[]; members: MemberDeclarationFact[]; @@ -185,74 +194,91 @@ function collectUnusedMemberFindings(ctx: RuleContext): ReportedFinding[] { } function collectDependencyFindings(ctx: RuleContext): ReportedFinding[] { - const packageJson = readPackageJson(ctx.rootDir); - const declarations = packageJson ? collectDependencyDeclarations(packageJson) : new Map(); + const manifests = collectPackageManifests(ctx); const uses = collectSourceFacts(ctx).flatMap((facts) => facts.dependencyUses); - const usesByPackage = groupUsesByPackage(uses); + const usesByManifest = groupUsesByManifest(uses, manifests); 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; + for (const manifest of manifests) { + const usesByPackage = usesByManifest.get(manifest) ?? new Map(); + for (const [packageName, declaration] of [...manifest.declarations.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (ignored.has(packageName)) 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; - } + const packageUses = usesByPackage.get(packageName) ?? []; + if (declaration.section === "devDependencies" && packageUses.some((use) => !use.isTestFile && !use.isTypeOnly)) { + findings.push({ + file: manifest.relPath, + line: declaration.line, + column: 1, + kind: "runtime-dev-dependency", + confidence: "high", + message: `Runtime import is declared in devDependencies: ${packageName}`, + evidence: [`package=${manifest.dir || "."}`, `declaredIn=${declaration.section}`, ...sampleUseEvidence(packageUses)], + }); + 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 (declaration.section !== "dependencies" && declaration.section !== "optionalDependencies") continue; + + if (packageUses.length === 0) { + findings.push({ + file: manifest.relPath, + line: declaration.line, + column: 1, + kind: "unused-dependency", + confidence: "medium", + message: `Unused dependency: ${packageName}`, + evidence: [`package=${manifest.dir || "."}`, `declaredIn=${declaration.section}`, "imports=0"], + }); + continue; + } + + if (packageUses.every((use) => use.isTestFile)) { + findings.push({ + file: manifest.relPath, + line: declaration.line, + column: 1, + kind: "test-only-dependency", + confidence: "medium", + message: `Dependency is only used from tests: ${packageName}`, + evidence: [`package=${manifest.dir || "."}`, `declaredIn=${declaration.section}`, ...sampleUseEvidence(packageUses)], + }); + continue; + } + + if (packageUses.every((use) => use.isTypeOnly)) { + findings.push({ + file: manifest.relPath, + line: declaration.line, + column: 1, + kind: "type-only-dependency", + confidence: "medium", + message: `Dependency is only used as types: ${packageName}`, + evidence: [`package=${manifest.dir || "."}`, `declaredIn=${declaration.section}`, ...sampleUseEvidence(packageUses)], + }); + } } - if (packageUses.every((use) => use.isTypeOnly)) { + for (const [packageName, packageUses] of [...usesByPackage.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (ignored.has(packageName)) continue; + if (manifest.declarations.has(packageName)) continue; + if (manifest.name === packageName) continue; + if (BUILTIN_PACKAGES.has(packageName)) continue; + const firstUse = packageUses[0]; + findings.push({ - file: "package.json", - line: declaration.line, + file: firstUse.file, + line: firstUse.line, column: 1, - kind: "type-only-dependency", - confidence: "medium", - message: `Dependency is only used as types: ${packageName}`, - evidence: [`declaredIn=${declaration.section}`, ...sampleUseEvidence(packageUses)], + kind: "unlisted-dependency", + confidence: "high", + message: `Unlisted dependency import: ${packageName}`, + evidence: [`package=${manifest.dir || "."}`, `specifier=${firstUse.specifier}`, "declared=false"], }); } } - 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; } @@ -533,35 +559,96 @@ function collectDependencyDeclarations(packageJson: { source: string; data: Reco 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); +function collectPackageManifests(ctx: RuleContext): PackageManifest[] { + const relPaths = new Set(); + const hasRootManifest = fs.existsSync(path.join(ctx.rootDir, "package.json")); + if (hasRootManifest) relPaths.add("package.json"); + + for (const file of ctx.fileRelPaths) { + let dir = path.posix.dirname(normalizePath(file)); + while (dir !== ".") { + const relPath = `${dir}/package.json`; + if (fs.existsSync(path.join(ctx.rootDir, relPath))) relPaths.add(relPath); + const parent = path.posix.dirname(dir); + if (parent === dir) break; + dir = parent; + } } - return grouped; + + const manifests = [...relPaths] + .sort((left, right) => left.localeCompare(right)) + .map((relPath) => readPackageJson(ctx.rootDir, relPath)) + .filter((manifest): manifest is PackageManifest => manifest !== null); + + if (hasRootManifest) return manifests; + return [syntheticRootManifest(), ...manifests]; } -function sampleUseEvidence(uses: DependencyUse[]): string[] { - return uses - .slice(0, 3) - .map((use) => `usedIn=${use.file}:${String(use.line)}:${use.isTypeOnly ? "type" : "runtime"}`); +function syntheticRootManifest(): PackageManifest { + return { + relPath: "package.json", + dir: "", + name: null, + source: "{}", + data: {}, + declarations: new Map(), + }; } -function readPackageJson(rootDir: string): { source: string; data: Record } | null { - const packageJsonPath = path.join(rootDir, "package.json"); +function readPackageJson(rootDir: string, relPath: string): PackageManifest | null { try { - const source = fs.readFileSync(packageJsonPath, "utf-8"); + const source = fs.readFileSync(path.join(rootDir, relPath), "utf-8"); const parsed: unknown = JSON.parse(source); if (!isRecord(parsed)) return null; - return { source, data: parsed }; + const name = typeof parsed.name === "string" ? parsed.name : null; + return { + relPath, + dir: path.posix.dirname(relPath) === "." ? "" : path.posix.dirname(relPath), + name, + source, + data: parsed, + declarations: collectDependencyDeclarations({ source, data: parsed }), + }; } catch { return null; } } +function groupUsesByManifest(uses: DependencyUse[], manifests: PackageManifest[]): Map> { + const grouped = new Map>(); + for (const use of uses) { + if (BUILTIN_PACKAGES.has(use.packageName)) continue; + const manifest = manifestForFile(use.file, manifests); + const scoped = grouped.get(manifest) ?? new Map(); + const existing = scoped.get(use.packageName) ?? []; + existing.push(use); + scoped.set(use.packageName, existing); + grouped.set(manifest, scoped); + } + return grouped; +} + +function manifestForFile(file: string, manifests: PackageManifest[]): PackageManifest { + const normalizedFile = normalizePath(file); + let best = manifests[0]; + for (const manifest of manifests) { + if (manifest.dir === "") { + best = manifest; + continue; + } + if (normalizedFile === manifest.dir || normalizedFile.startsWith(`${manifest.dir}/`)) { + if (manifest.dir.length > best.dir.length) best = manifest; + } + } + return best; +} + +function sampleUseEvidence(uses: DependencyUse[]): string[] { + return uses + .slice(0, 3) + .map((use) => `usedIn=${use.file}:${String(use.line)}:${use.isTypeOnly ? "type" : "runtime"}`); +} + 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"; diff --git a/tests/cli-check.e2e.test.ts b/tests/cli-check.e2e.test.ts index 6b84258..119a353 100644 --- a/tests/cli-check.e2e.test.ts +++ b/tests/cli-check.e2e.test.ts @@ -125,6 +125,71 @@ const DEAD_CODE_EXPANSION = { "src/deps.test.ts": 'import { describe } from "vitest";\ndescribe("deps", () => {});\n', }; +const WORKSPACE_DEPENDENCY_POLICY = { + "package.json": JSON.stringify( + { + private: true, + workspaces: ["packages/*"], + dependencies: { + "root-unused": "1.0.0", + }, + }, + null, + 2, + ), + "packages/app/package.json": JSON.stringify( + { + name: "@repo/app", + dependencies: { + "@repo/core": "workspace:*", + "app-left": "1.0.0", + "app-test-only": "1.0.0", + }, + devDependencies: { + "app-dev-runtime": "1.0.0", + }, + }, + null, + 2, + ), + "packages/app/src/index.ts": [ + 'import { core } from "@repo/core";', + 'import { tool } from "@repo/tool";', + 'import leftPad from "app-left";', + 'import devRuntime from "app-dev-runtime";', + 'import missing from "app-missing";', + "export const app = leftPad(`${core()}-${tool()}-${devRuntime}-${missing}`, 2);", + "", + ].join("\n"), + "packages/app/src/index.test.ts": 'import { describe } from "app-test-only";\ndescribe("app", () => {});\n', + "packages/core/package.json": JSON.stringify( + { + name: "@repo/core", + dependencies: { + "core-unused": "1.0.0", + "core-type-only": "1.0.0", + }, + }, + null, + 2, + ), + "packages/core/src/index.ts": [ + 'import type { Static } from "core-type-only";', + "type CoreShape = Static;", + "export function core(): string { return 'core'; }", + "export type { CoreShape };", + "", + ].join("\n"), + "packages/tool/package.json": JSON.stringify( + { + name: "@repo/tool", + }, + null, + 2, + ), + "packages/tool/src/index.ts": "export function tool(): string { return 'tool'; }\n", +}; + const DEAD_CODE_RULES = { rules: { "no-circular-deps": "off", @@ -413,4 +478,50 @@ describe("check command (e2e)", () => { expect(sarifFinding?.properties?.confidence).toBe("medium"); expect(sarifFinding?.properties?.evidence).toContain("entrypoint=false"); }, 90_000); + + it("CH-P1-07: scopes dependency hygiene to workspace package manifests", async () => { + const dir = makeProject( + WORKSPACE_DEPENDENCY_POLICY, + { rules: { "no-circular-deps": "off", "no-dead-exports": "off", "no-unused-deps": "warn" } }, + ); + 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<{ + kind?: string; + file: string; + message: string; + evidence?: string[]; + }>; + }; + const findings = parsed.findings; + + expect(findings.some((finding) => finding.kind === "unused-dependency" + && finding.file === "package.json" + && finding.message.includes("root-unused"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unused-dependency" + && finding.file === "packages/core/package.json" + && finding.message.includes("core-unused") + && finding.evidence?.includes("package=packages/core"))).toBe(true); + expect(findings.some((finding) => finding.kind === "type-only-dependency" + && finding.file === "packages/core/package.json" + && finding.message.includes("core-type-only"))).toBe(true); + expect(findings.some((finding) => finding.kind === "test-only-dependency" + && finding.file === "packages/app/package.json" + && finding.message.includes("app-test-only") + && finding.evidence?.includes("package=packages/app"))).toBe(true); + expect(findings.some((finding) => finding.kind === "runtime-dev-dependency" + && finding.file === "packages/app/package.json" + && finding.message.includes("app-dev-runtime"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unlisted-dependency" + && finding.file === "packages/app/src/index.ts" + && finding.message.includes("app-missing"))).toBe(true); + expect(findings.some((finding) => finding.kind === "unlisted-dependency" + && finding.file === "packages/app/src/index.ts" + && finding.message.includes("@repo/tool"))).toBe(true); + + expect(findings.some((finding) => finding.message.includes("app-left"))).toBe(false); + expect(findings.some((finding) => finding.message.includes("@repo/core"))).toBe(false); + }, 90_000); });