From 046231feda5749f70f260f19fc6407a62f6b7d26 Mon Sep 17 00:00:00 2001 From: millareskenneth Date: Sat, 6 Jun 2026 23:54:33 +0800 Subject: [PATCH] feat: add dead code detection and configurable entry points - Add minimatch dependency for glob pattern matching - Implement getDeadFiles() function to identify unreferenced and isolated files - Add DEFAULT_ENTRY_PATTERNS constant with built-in entry point patterns - Introduce entryPatterns and replaceDefaultEntryPatterns options to analyze() - Add --dead-code, --entry, and --show-entry-patterns CLI flags - Classify files as entry points, reachable, or dead in dependency graph - Add graph tests for entry point detection and dead code classification - Update renderer to display dead files with visual distinction - Update HTML report generator to include dead code section - Enables users to detect unused files and configure custom entry points for analysis --- package-lock.json | 61 +++++-- package.json | 1 + src/analyze.ts | 25 ++- src/cli.ts | 54 +++++- src/extractor.ts | 48 +++-- src/graph.test.ts | 116 +++++++++--- src/graph.ts | 179 ++++++++++++++++-- src/html-report.ts | 446 ++++++++++++++++++++++++++++----------------- src/index.ts | 4 +- src/renderer.ts | 99 +++++++--- 10 files changed, 747 insertions(+), 286 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3766d7..051550f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "commander": "13.1.0", + "minimatch": "^10.2.5", "open": "^10.1.0", "typescript": "5.8.3" }, @@ -1791,6 +1792,32 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/utils": { "version": "8.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.0.tgz", @@ -2063,13 +2090,24 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -3471,16 +3509,15 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/package.json b/package.json index 33df1fe..ecfbd33 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "packageManager": "npm@10.9.2", "dependencies": { "commander": "13.1.0", + "minimatch": "^10.2.5", "open": "^10.1.0", "typescript": "5.8.3" }, diff --git a/src/analyze.ts b/src/analyze.ts index 18aeb94..8b13594 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -8,32 +8,41 @@ import type { DependencyGraph } from './graph.js' export interface AnalyzeOptions { /** Additional directories to ignore during file scanning */ ignore?: string[] + /** + * Extra glob patterns (relative to the scanned directory) that identify + * intentional entry points. Merged with the built-in defaults. + * Example: ['src/server.ts', 'workers/**'] + */ + entryPatterns?: string[] + /** + * When true, the built-in default entry patterns are not used — only the + * patterns you provide in `entryPatterns` are active. + */ + replaceDefaultEntryPatterns?: boolean } /** - * Full Phase-1 pipeline: + * Full analysis pipeline: * 1. Scan for source files * 2. Parse imports from each file * 3. Resolve relative imports to absolute paths - * 4. Build the dependency graph + * 4. Build and classify the dependency graph */ export async function analyze(directory: string, options: AnalyzeOptions = {}): Promise { const rootDir = resolve(directory) - // 1. File Scanner const files = await scanFiles(rootDir, { ignore: options.ignore }) if (files.length === 0) { throw new Error(`No supported source files found in: ${rootDir}`) } - // 2. AST Parser — parse all files in parallel const allImports = (await Promise.all(files.map((f) => parseImports(f)))).flat() - - // 3. Dependency Extractor — resolve relative specifiers const deps = await extractDependencies(allImports) - // 4. Graph Builder - const graph = buildGraph(deps, files, rootDir) + const graph = buildGraph(deps, files, rootDir, { + entryPatterns: options.entryPatterns, + replaceDefaultEntryPatterns: options.replaceDefaultEntryPatterns, + }) return graph } diff --git a/src/cli.ts b/src/cli.ts index eec96f5..f9accd7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { analyze } from './analyze.js' import { renderToTerminal } from './renderer.js' import { serveGraph } from './serve.js' import { generateHtmlReport } from './html-report.js' +import { getDeadFiles, DEFAULT_ENTRY_PATTERNS } from './graph.js' const program = new Command() @@ -17,16 +18,59 @@ program .option('--save', 'write the HTML report to disk instead of serving it') .option('--out-dir ', 'output directory when using --save', 'report') .option('--port ', 'port for the live server (default: 4242)', '4242') + .option('--dead-code', 'report dead code (unreferenced + isolated files), exit 1 if any found') + .option( + '--entry ', + 'comma-separated glob patterns that mark extra entry points (merged with built-in defaults)', + '', + ) + .option( + '--show-entry-patterns', + 'print the active entry-point patterns and exit', + ) .option('--show-external', 'include external npm packages in the output') .option('--ignore ', 'comma-separated list of directory names to ignore', '') .action(async ( directory: string, - options: { html?: boolean; save?: boolean; outDir?: string; port?: string; showExternal?: boolean; ignore?: string }, + options: { + html?: boolean + save?: boolean + outDir?: string + port?: string + deadCode?: boolean + entry?: string + showEntryPatterns?: boolean + showExternal?: boolean + ignore?: string + }, ) => { - const ignore = options.ignore ? options.ignore.split(',').map((d) => d.trim()).filter(Boolean) : [] + // --show-entry-patterns: just print defaults and exit, no analysis needed + if (options.showEntryPatterns) { + console.log('\n Active entry-point patterns (built-in defaults):\n') + for (const p of DEFAULT_ENTRY_PATTERNS) { + console.log(` ${p}`) + } + if (options.entry) { + const extra = options.entry.split(',').map((s) => s.trim()).filter(Boolean) + if (extra.length > 0) { + console.log('\n Extra patterns (--entry):\n') + for (const p of extra) console.log(` ${p}`) + } + } + console.log('') + return + } + + const ignore = options.ignore + ? options.ignore.split(',').map((d) => d.trim()).filter(Boolean) + : [] + + const entryPatterns = options.entry + ? options.entry.split(',').map((s) => s.trim()).filter(Boolean) + : [] try { - const graph = await analyze(directory, { ignore }) + const graph = await analyze(directory, { ignore, entryPatterns }) if (options.save) { const { htmlFile, jsonFile } = await generateHtmlReport(graph, { outDir: options.outDir }) @@ -39,6 +83,10 @@ program const url = await serveGraph(graph, { port }) console.log(`\n Serving graph at ${url}`) console.log(` Press Ctrl+C to stop.\n`) + } else if (options.deadCode) { + renderToTerminal(graph, { deadCodeOnly: true }) + const dead = getDeadFiles(graph) + if (dead.length > 0) process.exit(1) } else { renderToTerminal(graph, { showExternal: options.showExternal }) } diff --git a/src/extractor.ts b/src/extractor.ts index 7ebaea5..eafd86c 100644 --- a/src/extractor.ts +++ b/src/extractor.ts @@ -16,33 +16,53 @@ export interface Dependency { // Extensions tried when a relative import has no extension const RESOLVE_ORDER = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] +// JS→TS extension remapping for TypeScript ESM projects that write `.js` imports +const JS_TO_TS: Record = { + '.js': ['.ts', '.tsx'], + '.jsx': ['.tsx', '.jsx'], + '.mjs': ['.mts'], + '.cjs': ['.cts'], +} + /** * Attempts to resolve a relative specifier to an absolute file path. * Handles: - * - exact paths ./foo.ts - * - extension-less ./foo → tries .ts, .tsx, … - * - directory index ./foo/ → ./foo/index.ts, … + * - exact paths ./foo.ts + * - TS ESM remapped ./foo.js → ./foo.ts (TypeScript ESM convention) + * - extension-less ./foo → tries .ts, .tsx, … + * - directory index ./foo/ → ./foo/index.ts, … */ async function resolveRelative(specifier: string, fromFile: string): Promise { const base = resolve(dirname(fromFile), specifier) + const ext = extname(base) - // 1. Try the specifier as-is (already has an extension) - if (extname(base) !== '') { + if (ext !== '') { + // 1a. Try as-is (e.g. import './foo.ts' directly) if (await exists(base)) return base + + // 1b. TypeScript ESM remapping: ./foo.js → ./foo.ts + const tsAlternatives = JS_TO_TS[ext] + if (tsAlternatives) { + const stem = base.slice(0, -ext.length) + for (const tsExt of tsAlternatives) { + const candidate = stem + tsExt + if (await exists(candidate)) return candidate + } + } + + return undefined } - // 2. Try appending each supported extension - for (const ext of RESOLVE_ORDER) { - const candidate = base + ext + // 2. Extension-less: try appending each supported extension + for (const tryExt of RESOLVE_ORDER) { + const candidate = base + tryExt if (await exists(candidate)) return candidate } - // 3. Try as a directory with an index file - const name = basename(base) - // avoid double-appending if the specifier already ends in "index" - if (name !== 'index') { - for (const ext of RESOLVE_ORDER) { - const candidate = resolve(base, `index${ext}`) + // 3. Directory index: ./foo → ./foo/index.ts, … + if (basename(base) !== 'index') { + for (const tryExt of RESOLVE_ORDER) { + const candidate = resolve(base, `index${tryExt}`) if (await exists(candidate)) return candidate } } diff --git a/src/graph.test.ts b/src/graph.test.ts index 925901e..ef94e30 100644 --- a/src/graph.test.ts +++ b/src/graph.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildGraph } from './graph.js' +import { buildGraph, getDeadFiles, getIsolatedNodes } from './graph.js' import type { Dependency } from './extractor.js' const ROOT = '/project' @@ -8,7 +8,12 @@ function dep(from: string, to: string | undefined, specifier: string, external = return { from: `${ROOT}/${from}`, to: to ? `${ROOT}/${to}` : undefined, specifier, external } } -describe('buildGraph', () => { +// Helper: get ids of unreferenced files by kind +function ids(graph: ReturnType, kind: 'entry' | 'unreferenced' | 'isolated') { + return graph.unreferencedFiles.filter((f) => f.kind === kind).map((f) => f.id) +} + +describe('buildGraph — nodes and edges', () => { it('creates nodes for all scanned files', () => { const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`] const graph = buildGraph([], files, ROOT) @@ -25,14 +30,6 @@ describe('buildGraph', () => { expect(graph.edges[0]?.to).toBe(`${ROOT}/b.ts`) }) - it('marks files with no edges as isolated', () => { - const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/orphan.ts`] - const deps = [dep('a.ts', 'b.ts', './b')] - const graph = buildGraph(deps, files, ROOT) - expect(graph.isolatedNodes).toContain(`${ROOT}/orphan.ts`) - expect(graph.isolatedNodes).not.toContain(`${ROOT}/a.ts`) - }) - it('creates virtual nodes for external packages', () => { const files = [`${ROOT}/a.ts`] const externalDep: Dependency = { from: `${ROOT}/a.ts`, to: undefined, specifier: 'commander', external: true } @@ -41,44 +38,103 @@ describe('buildGraph', () => { expect(graph.nodes.get('external:commander')?.external).toBe(true) }) + it('uses relative labels for display', () => { + const files = [`${ROOT}/src/service.ts`] + const graph = buildGraph([], files, ROOT) + expect(graph.nodes.get(`${ROOT}/src/service.ts`)?.label).toBe('src/service.ts') + }) +}) + +describe('buildGraph — cycle detection', () => { it('detects direct cycles', () => { const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`] - const deps = [ - dep('a.ts', 'b.ts', './b'), - dep('b.ts', 'a.ts', './a'), - ] + const deps = [dep('a.ts', 'b.ts', './b'), dep('b.ts', 'a.ts', './a')] const graph = buildGraph(deps, files, ROOT) expect(graph.cycles.length).toBeGreaterThan(0) }) it('detects transitive cycles', () => { const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/c.ts`] - const deps = [ - dep('a.ts', 'b.ts', './b'), - dep('b.ts', 'c.ts', './c'), - dep('c.ts', 'a.ts', './a'), - ] + const deps = [dep('a.ts', 'b.ts', './b'), dep('b.ts', 'c.ts', './c'), dep('c.ts', 'a.ts', './a')] const graph = buildGraph(deps, files, ROOT) expect(graph.cycles.length).toBeGreaterThan(0) - const cycleFlat = graph.cycles.flat() - expect(cycleFlat.some((id) => id.endsWith('a.ts'))).toBe(true) - expect(cycleFlat.some((id) => id.endsWith('b.ts'))).toBe(true) - expect(cycleFlat.some((id) => id.endsWith('c.ts'))).toBe(true) + const flat = graph.cycles.flat() + expect(flat.some((id) => id.endsWith('a.ts'))).toBe(true) + expect(flat.some((id) => id.endsWith('b.ts'))).toBe(true) + expect(flat.some((id) => id.endsWith('c.ts'))).toBe(true) }) it('reports no cycles for acyclic graphs', () => { const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/c.ts`] - const deps = [ - dep('a.ts', 'b.ts', './b'), - dep('b.ts', 'c.ts', './c'), - ] + const deps = [dep('a.ts', 'b.ts', './b'), dep('b.ts', 'c.ts', './c')] const graph = buildGraph(deps, files, ROOT) expect(graph.cycles).toHaveLength(0) }) +}) - it('uses relative labels for display', () => { - const files = [`${ROOT}/src/service.ts`] +describe('buildGraph — unreferenced classification', () => { + it('classifies isolated files (no edges at all)', () => { + // orphan.ts has no imports and nothing imports it + const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/orphan.ts`] + const deps = [dep('a.ts', 'b.ts', './b')] + const graph = buildGraph(deps, files, ROOT, { replaceDefaultEntryPatterns: true }) + expect(getIsolatedNodes(graph)).toContain(`${ROOT}/orphan.ts`) + expect(getIsolatedNodes(graph)).not.toContain(`${ROOT}/a.ts`) + }) + + it('classifies unreferenced files (imports others but never imported itself)', () => { + // a.ts imports b.ts, but nothing imports a.ts + const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`] + const deps = [dep('a.ts', 'b.ts', './b')] + const graph = buildGraph(deps, files, ROOT, { replaceDefaultEntryPatterns: true }) + expect(ids(graph, 'unreferenced')).toContain(`${ROOT}/a.ts`) + expect(ids(graph, 'unreferenced')).not.toContain(`${ROOT}/b.ts`) + }) + + it('does not flag files that are imported', () => { + // a → b → c: b and c both have incoming edges + const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/c.ts`] + const deps = [dep('a.ts', 'b.ts', './b'), dep('b.ts', 'c.ts', './c')] + const graph = buildGraph(deps, files, ROOT, { replaceDefaultEntryPatterns: true }) + const unreferencedIds = graph.unreferencedFiles.map((f) => f.id) + expect(unreferencedIds).not.toContain(`${ROOT}/b.ts`) + expect(unreferencedIds).not.toContain(`${ROOT}/c.ts`) + }) + + it('classifies entry points via default patterns', () => { + // scanner.test.ts matches the default **/*.test.ts pattern + const files = [`${ROOT}/scanner.test.ts`, `${ROOT}/a.ts`] const graph = buildGraph([], files, ROOT) - expect(graph.nodes.get(`${ROOT}/src/service.ts`)?.label).toBe('src/service.ts') + expect(ids(graph, 'entry')).toContain(`${ROOT}/scanner.test.ts`) + }) + + it('classifies entry points via custom patterns', () => { + const files = [`${ROOT}/workers/job.ts`, `${ROOT}/a.ts`] + const deps = [dep('workers/job.ts', 'a.ts', './a')] + const graph = buildGraph(deps, files, ROOT, { entryPatterns: ['workers/**'], replaceDefaultEntryPatterns: true }) + expect(ids(graph, 'entry')).toContain(`${ROOT}/workers/job.ts`) + expect(ids(graph, 'unreferenced')).not.toContain(`${ROOT}/workers/job.ts`) + }) + + it('records the matched pattern on entry files', () => { + const files = [`${ROOT}/src/cli.ts`] + const graph = buildGraph([], files, ROOT) + const entry = graph.unreferencedFiles.find((f) => f.id === `${ROOT}/src/cli.ts`) + expect(entry?.kind).toBe('entry') + expect(entry?.matchedPattern).toBeDefined() + }) + + it('getDeadFiles returns only unreferenced and isolated', () => { + const files = [`${ROOT}/a.ts`, `${ROOT}/b.ts`, `${ROOT}/orphan.ts`, `${ROOT}/src/cli.ts`] + const deps = [dep('a.ts', 'b.ts', './b')] + const graph = buildGraph(deps, files, ROOT) + const dead = getDeadFiles(graph) + const deadIds = dead.map((f) => f.id) + // orphan is isolated — dead code + expect(deadIds).toContain(`${ROOT}/orphan.ts`) + // cli.ts matches entry pattern — NOT dead code + expect(deadIds).not.toContain(`${ROOT}/src/cli.ts`) + // b.ts is imported — not dead + expect(deadIds).not.toContain(`${ROOT}/b.ts`) }) }) diff --git a/src/graph.ts b/src/graph.ts index d044880..fc96a18 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,4 +1,5 @@ import { relative } from 'node:path' +import { minimatch } from 'minimatch' import type { Dependency } from './extractor.js' export interface GraphNode { @@ -13,13 +14,105 @@ export interface GraphEdge { specifier: string } +/** + * How a file that has no incoming edges is classified. + * + * - `entry` Matched a known entry-point glob (cli, index, test, config…). + * These are intentional roots — not dead code. + * - `unreferenced` Not matched by any entry-point pattern and never imported. + * Genuinely suspicious — likely dead code. + * - `isolated` Has no edges at all (neither imports nor is imported). + * Most suspicious — orphaned file with no connections. + */ +export type UnreferencedKind = 'entry' | 'unreferenced' | 'isolated' + +export interface UnreferencedFile { + /** Absolute path */ + id: string + /** Path relative to rootDir */ + label: string + kind: UnreferencedKind + /** Which glob pattern matched (only set when kind === 'entry') */ + matchedPattern?: string +} + export interface DependencyGraph { nodes: Map edges: GraphEdge[] - /** Files that were scanned but have no imports or importers */ - isolatedNodes: string[] + /** + * All files that have no incoming internal edges, classified by kind. + * Use `.filter(f => f.kind === 'unreferenced' || f.kind === 'isolated')` + * to get only genuine dead-code candidates. + */ + unreferencedFiles: UnreferencedFile[] /** Circular dependency chains */ cycles: string[][] + /** + * The entry-point glob patterns that were used during classification. + * Included so callers can display which patterns are active. + */ + entryPatterns: string[] +} + +// --------------------------------------------------------------------------- +// Default entry-point patterns +// These cover the most common "root" file types that are never imported by +// other source files but are clearly intentional. +// --------------------------------------------------------------------------- + +export const DEFAULT_ENTRY_PATTERNS: string[] = [ + // Test files (Jest, Vitest, Mocha, etc.) + '**/*.test.ts', + '**/*.test.tsx', + '**/*.test.js', + '**/*.test.jsx', + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.spec.js', + '**/*.spec.jsx', + '**/__tests__/**', + + // Tool config files (never imported by app code) + '**/*.config.ts', + '**/*.config.mts', + '**/*.config.js', + '**/*.config.mjs', + '**/*.config.cjs', + + // Common script / tooling directories + 'scripts/**', + 'bin/**', + + // CLI entry points by convention + '**/cli.ts', + '**/cli.js', + '**/cli.mjs', + '**/main.ts', + '**/main.js', + '**/main.mjs', + + // Library / package entry points + '**/index.ts', + '**/index.tsx', + '**/index.js', + '**/index.mjs', +] + +// --------------------------------------------------------------------------- +// Graph builder +// --------------------------------------------------------------------------- + +export interface BuildGraphOptions { + /** + * Glob patterns (relative to rootDir) that identify intentional entry points. + * Merged with DEFAULT_ENTRY_PATTERNS unless `replaceDefaultEntryPatterns` is true. + */ + entryPatterns?: string[] + /** + * When true, use only the provided `entryPatterns` and skip the defaults. + * Useful when the caller wants full control. + */ + replaceDefaultEntryPatterns?: boolean } /** @@ -28,8 +121,14 @@ export interface DependencyGraph { * @param deps - Resolved dependencies from extractDependencies() * @param allFiles - All scanned source files (ensures isolated files appear in the graph) * @param rootDir - Project root used for computing display labels + * @param options - Entry point patterns and other build options */ -export function buildGraph(deps: Dependency[], allFiles: string[], rootDir: string): DependencyGraph { +export function buildGraph( + deps: Dependency[], + allFiles: string[], + rootDir: string, + options: BuildGraphOptions = {}, +): DependencyGraph { const nodes = new Map() const edges: GraphEdge[] = [] @@ -40,13 +139,11 @@ export function buildGraph(deps: Dependency[], allFiles: string[], rootDir: stri // Process edges for (const dep of deps) { - // Ensure the source node exists (it should, but be defensive) if (!nodes.has(dep.from)) { nodes.set(dep.from, { id: dep.from, label: relative(rootDir, dep.from), external: false }) } if (dep.external || dep.to === undefined) { - // External or unresolved — add a virtual node for the package name const extId = `external:${dep.specifier}` if (!nodes.has(extId)) { nodes.set(extId, { id: extId, label: dep.specifier, external: true }) @@ -60,20 +157,72 @@ export function buildGraph(deps: Dependency[], allFiles: string[], rootDir: stri } } - // Detect isolated nodes (no edges at all) - const connectedIds = new Set() + // Build set of files that have at least one incoming internal edge + const importedIds = new Set() + for (const edge of edges) { + if (!edge.to.startsWith('external:')) { + importedIds.add(edge.to) + } + } + + // Build set of files that have at least one outgoing edge (imports something) + const hasOutgoing = new Set() for (const edge of edges) { - connectedIds.add(edge.from) - connectedIds.add(edge.to) + hasOutgoing.add(edge.from) + } + + // Resolve active entry patterns + const customPatterns = options.entryPatterns ?? [] + const activePatterns = options.replaceDefaultEntryPatterns + ? customPatterns + : [...DEFAULT_ENTRY_PATTERNS, ...customPatterns] + + // Classify every internal file that has no incoming edges + const unreferencedFiles: UnreferencedFile[] = [] + + for (const [id, node] of nodes) { + if (id.startsWith('external:')) continue + if (importedIds.has(id)) continue // has incoming edges → referenced, skip + + const label = node.label + const noOutgoing = !hasOutgoing.has(id) + + // Check entry-point patterns against the relative label + const matchedPattern = activePatterns.find((p) => minimatch(label, p, { dot: true })) + + if (matchedPattern !== undefined) { + unreferencedFiles.push({ id, label, kind: 'entry', matchedPattern }) + } else if (noOutgoing) { + unreferencedFiles.push({ id, label, kind: 'isolated' }) + } else { + unreferencedFiles.push({ id, label, kind: 'unreferenced' }) + } } - const isolatedNodes = [...nodes.keys()].filter( - (id) => !id.startsWith('external:') && !connectedIds.has(id), - ) - // Detect cycles using DFS + // Sort: dead code first (unreferenced, isolated), then entries — all alphabetical within group + const kindOrder: Record = { unreferenced: 0, isolated: 1, entry: 2 } + unreferencedFiles.sort((a, b) => { + const ko = kindOrder[a.kind] - kindOrder[b.kind] + return ko !== 0 ? ko : a.label.localeCompare(b.label) + }) + const cycles = detectCycles(nodes, edges) - return { nodes, edges, isolatedNodes, cycles } + return { nodes, edges, unreferencedFiles, cycles, entryPatterns: activePatterns } +} + +// --------------------------------------------------------------------------- +// Backward-compat helpers (used by existing code / tests) +// --------------------------------------------------------------------------- + +/** Files with zero edges at all (isolated). Subset of unreferencedFiles. */ +export function getIsolatedNodes(graph: DependencyGraph): string[] { + return graph.unreferencedFiles.filter((f) => f.kind === 'isolated').map((f) => f.id) +} + +/** Files never imported and not matching entry patterns. The real dead code. */ +export function getDeadFiles(graph: DependencyGraph): UnreferencedFile[] { + return graph.unreferencedFiles.filter((f) => f.kind === 'unreferenced' || f.kind === 'isolated') } // --------------------------------------------------------------------------- @@ -81,7 +230,6 @@ export function buildGraph(deps: Dependency[], allFiles: string[], rootDir: stri // --------------------------------------------------------------------------- function detectCycles(nodes: Map, edges: GraphEdge[]): string[][] { - // Build adjacency list (skip external virtual nodes) const adj = new Map() for (const node of nodes.keys()) { if (!node.startsWith('external:')) adj.set(node, []) @@ -97,7 +245,6 @@ function detectCycles(nodes: Map, edges: GraphEdge[]): string function dfs(node: string, path: string[]): void { if (inStack.has(node)) { - // Found a cycle — slice the path to just the cycle const cycleStart = path.indexOf(node) foundCycles.push([...path.slice(cycleStart), node]) return diff --git a/src/html-report.ts b/src/html-report.ts index 39382ef..6f6c7b7 100644 --- a/src/html-report.ts +++ b/src/html-report.ts @@ -30,10 +30,18 @@ interface SerializedEdge { specifier: string } +// kind: 'entry' | 'unreferenced' | 'isolated' +interface SerializedUnreferencedFile { + id: string + label: string + kind: string + matchedPattern?: string +} + interface SerializedGraph { nodes: SerializedNode[] edges: SerializedEdge[] - isolatedNodes: string[] + unreferencedFiles: SerializedUnreferencedFile[] cycles: string[][] } @@ -49,7 +57,12 @@ export function serializeGraph(graph: DependencyGraph): SerializedGraph { to: e.to, specifier: e.specifier, })), - isolatedNodes: graph.isolatedNodes, + unreferencedFiles: graph.unreferencedFiles.map((f) => ({ + id: f.id, + label: f.label, + kind: f.kind, + matchedPattern: f.matchedPattern, + })), cycles: graph.cycles, } } @@ -59,7 +72,7 @@ export function serializeGraph(graph: DependencyGraph): SerializedGraph { // --------------------------------------------------------------------------- export function buildHtml(title: string, graphJson: string): string { - return /* html */ ` + return ` @@ -79,7 +92,6 @@ export function buildHtml(title: string, graphJson: string): string { overflow: hidden; } - /* ── Top bar ── */ #topbar { flex-shrink: 0; display: flex; @@ -116,26 +128,16 @@ export function buildHtml(title: string, graphJson: string): string { border-radius: 99px; white-space: nowrap; } - .badge-blue { background: #1e3a5f; color: #7dd3fc; } - .badge-green { background: #14532d; color: #86efac; } - .badge-red { background: #450a0a; color: #fca5a5; } - .badge-yellow{ background: #422006; color: #fde68a; } - - /* ── Main layout ── */ - #main { - flex: 1; - display: flex; - overflow: hidden; - } + .badge-blue { background: #1e3a5f; color: #7dd3fc; } + .badge-green { background: #14532d; color: #86efac; } + .badge-red { background: #450a0a; color: #fca5a5; } + .badge-yellow { background: #422006; color: #fde68a; } + .badge-purple { background: #2e1065; color: #c4b5fd; } - /* ── Graph canvas ── */ - #cy { - flex: 1; - height: 100%; - background: #0f1117; - } + #main { flex: 1; display: flex; overflow: hidden; } + + #cy { flex: 1; height: 100%; background: #0f1117; } - /* ── Side panel ── */ #panel { width: 300px; flex-shrink: 0; @@ -167,7 +169,7 @@ export function buildHtml(title: string, graphJson: string): string { .stat-value { font-weight: 600; } #node-detail { display: none; } - #node-detail h2 { color: #7dd3fc; } + #node-detail h2 { color: #7dd3fc; font-size: 13px; text-transform: none; } .detail-section { margin-top: 10px; } .detail-section h3 { @@ -187,16 +189,24 @@ export function buildHtml(title: string, graphJson: string): string { } .dep-item span { color: #e2e8f0; } - #cycles-list { display: flex; flex-direction: column; gap: 6px; } - .cycle-item { + .list-section { display: flex; flex-direction: column; gap: 6px; } + .list-item { font-size: 11px; - background: #2d0a0a; - border: 1px solid #7f1d1d; border-radius: 6px; padding: 6px 8px; line-height: 1.6; word-break: break-all; } + .list-item-dead-unreferenced { background: #1e0a3d; border: 1px solid #5b21b6; } + .list-item-dead-isolated { background: #2d0a0a; border: 1px solid #7f1d1d; } + .list-item-entry { background: #0a1f0f; border: 1px solid #166534; color: #86efac; } + .list-item-cycle { background: #2d0a0a; border: 1px solid #7f1d1d; } + + .list-item .item-tag { + font-size: 10px; + opacity: 0.6; + margin-left: 6px; + } #btn-reset { margin-top: auto; @@ -225,37 +235,62 @@ export function buildHtml(title: string, graphJson: string): string { word-break: break-all; z-index: 9999; } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: #64748b; + } + .legend-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; + } -
-

codeviz — Dependency Graph

- - 0 files - 0 deps - 0 isolated - 0 cycles +

codeviz

+ + 0 files + 0 deps + 0 dead + 0 entries + 0 cycles
-
- +

Summary

-
Files scanned
-
Internal deps
-
Isolated files
-
Circular deps
+
Files scanned
+
Internal deps
+
Entry points
+
Dead code
+
Circular deps
+ +
+
normal
+
entry point
+
unreferenced
+
isolated
+
cycle
+
npm pkg
+
-

+

Imports

@@ -266,10 +301,19 @@ export function buildHtml(title: string, graphJson: string): string {
- + + + + @@ -280,44 +324,57 @@ export function buildHtml(title: string, graphJson: string): string { - -`; +`; } function escHtml(s: string): string { @@ -590,28 +698,22 @@ function escHtml(s: string): string { // Public API // --------------------------------------------------------------------------- -/** - * Generates a self-contained HTML report and a companion `graph.json` file - * inside `outDir`. Returns the paths of the created files. - */ export async function generateHtmlReport( graph: DependencyGraph, options: HtmlReportOptions = {}, ): Promise { const outDir = resolve(options.outDir ?? 'report') - const title = options.title ?? 'codeviz — Dependency Graph' + const title = options.title ?? 'codeviz — Dependency Graph' await mkdir(outDir, { recursive: true }) - const serialized = serializeGraph(graph) - const graphJson = JSON.stringify(serialized, null, 2) + const graphJson = JSON.stringify(serializeGraph(graph), null, 2) const jsonFile = join(outDir, 'graph.json') await writeFile(jsonFile, graphJson, 'utf8') - const html = buildHtml(title, graphJson) const htmlFile = join(outDir, 'index.html') - await writeFile(htmlFile, html, 'utf8') + await writeFile(htmlFile, buildHtml(title, graphJson), 'utf8') return { htmlFile, jsonFile } } diff --git a/src/index.ts b/src/index.ts index 6ae5137..7334190 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ export { analyze } from './analyze.js' export { scanFiles } from './scanner.js' export { parseImports } from './parser.js' export { extractDependencies } from './extractor.js' -export { buildGraph } from './graph.js' +export { buildGraph, getDeadFiles, getIsolatedNodes, DEFAULT_ENTRY_PATTERNS } from './graph.js' export { renderToTerminal } from './renderer.js' export { generateHtmlReport } from './html-report.js' export { serveGraph } from './serve.js' @@ -13,7 +13,7 @@ export type { AnalyzeOptions } from './analyze.js' export type { ScanOptions } from './scanner.js' export type { ImportRecord } from './parser.js' export type { Dependency } from './extractor.js' -export type { DependencyGraph, GraphNode, GraphEdge } from './graph.js' +export type { DependencyGraph, GraphNode, GraphEdge, UnreferencedFile, UnreferencedKind, BuildGraphOptions } from './graph.js' export type { RenderOptions } from './renderer.js' export type { HtmlReportOptions, HtmlReportResult } from './html-report.js' export type { ServeOptions } from './serve.js' diff --git a/src/renderer.ts b/src/renderer.ts index bc5389f..e775fda 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,7 +1,8 @@ import type { DependencyGraph } from './graph.js' +import { getDeadFiles } from './graph.js' // --------------------------------------------------------------------------- -// Minimal ANSI helpers — no external dependency needed +// Minimal ANSI helpers // --------------------------------------------------------------------------- const c = { reset: '\x1b[0m', @@ -14,9 +15,9 @@ const c = { gray: '\x1b[90m', } -function bold(s: string) { return `${c.bold}${s}${c.reset}` } -function dim(s: string) { return `${c.dim}${s}${c.reset}` } -function cyan(s: string) { return `${c.cyan}${s}${c.reset}` } +function bold(s: string) { return `${c.bold}${s}${c.reset}` } +function dim(s: string) { return `${c.dim}${s}${c.reset}` } +function cyan(s: string) { return `${c.cyan}${s}${c.reset}` } function yellow(s: string) { return `${c.yellow}${s}${c.reset}` } function red(s: string) { return `${c.red}${s}${c.reset}` } function green(s: string) { return `${c.green}${s}${c.reset}` } @@ -35,48 +36,59 @@ export interface RenderOptions { showExternal?: boolean /** Max number of edges to print per file to keep output readable */ maxEdgesPerFile?: number + /** When true, only print the dead code report and skip the dependency tree */ + deadCodeOnly?: boolean } export function renderToTerminal(graph: DependencyGraph, options: RenderOptions = {}): void { - const { showExternal = false, maxEdgesPerFile = 20 } = options + const { showExternal = false, maxEdgesPerFile = 20, deadCodeOnly = false } = options const internalNodes = [...graph.nodes.values()].filter((n) => !n.external) const internalEdges = graph.edges.filter((e) => !e.to.startsWith('external:')) + const deadFiles = getDeadFiles(graph) + const entryFiles = graph.unreferencedFiles.filter((f) => f.kind === 'entry') - // ── Header ────────────────────────────────────────────────────────────── + // ── Header ──────────────────────────────────────────────────────────────── console.log('') console.log(hr('═')) - console.log(bold(' codeviz — Dependency Analysis')) + console.log(bold(deadCodeOnly ? ' codeviz — Dead Code Report' : ' codeviz — Dependency Analysis')) console.log(hr('═')) - // ── Summary ───────────────────────────────────────────────────────────── + // ── Summary ─────────────────────────────────────────────────────────────── console.log('') console.log(bold(' Summary')) console.log(hr()) console.log(` ${cyan('Files scanned')} ${internalNodes.length}`) console.log(` ${cyan('Dependencies')} ${internalEdges.length} internal`) - console.log(` ${cyan('Isolated files')} ${graph.isolatedNodes.length}`) + console.log(` ${cyan('Entry points')} ${entryFiles.length}`) + console.log(` ${cyan('Dead code')} ${deadFiles.length === 0 ? green('none') : red(String(deadFiles.length))}`) console.log(` ${cyan('Circular deps')} ${graph.cycles.length === 0 ? green('none') : red(String(graph.cycles.length))}`) - // ── Dependency tree ────────────────────────────────────────────────────── + // ── Dead-code-only mode ──────────────────────────────────────────────────── + if (deadCodeOnly) { + renderDeadCode(graph) + console.log('') + console.log(hr('═')) + console.log('') + return + } + + // ── Dependency tree ──────────────────────────────────────────────────────── console.log('') console.log(bold(' Dependency Tree')) console.log(hr()) - // Build a map: file → files it imports (internal only) - const importedBy = new Map() + const importMap = new Map() for (const edge of internalEdges) { - const list = importedBy.get(edge.from) ?? [] + const list = importMap.get(edge.from) ?? [] list.push(edge.to) - importedBy.set(edge.from, list) + importMap.set(edge.from, list) } - // Print each internal file that has at least one import let printed = 0 - for (const [from, tos] of importedBy) { + for (const [from, tos] of importMap) { const fromLabel = graph.nodes.get(from)?.label ?? from console.log(` ${cyan(fromLabel)}`) - const visible = tos.slice(0, maxEdgesPerFile) for (const to of visible) { const toLabel = graph.nodes.get(to)?.label ?? to @@ -92,7 +104,7 @@ export function renderToTerminal(graph: DependencyGraph, options: RenderOptions console.log(dim(' No internal dependencies found.')) } - // ── External packages ──────────────────────────────────────────────────── + // ── External packages ────────────────────────────────────────────────────── if (showExternal) { const externalEdges = graph.edges.filter((e) => e.to.startsWith('external:')) if (externalEdges.length > 0) { @@ -106,18 +118,10 @@ export function renderToTerminal(graph: DependencyGraph, options: RenderOptions } } - // ── Isolated files ─────────────────────────────────────────────────────── - if (graph.isolatedNodes.length > 0) { - console.log('') - console.log(bold(' Isolated Files') + dim(' (no imports, not imported)')) - console.log(hr()) - for (const id of graph.isolatedNodes) { - const label = graph.nodes.get(id)?.label ?? id - console.log(` ${yellow('⚠')} ${label}`) - } - } + // ── Dead code ────────────────────────────────────────────────────────────── + renderDeadCode(graph) - // ── Circular dependencies ──────────────────────────────────────────────── + // ── Circular dependencies ────────────────────────────────────────────────── if (graph.cycles.length > 0) { console.log('') console.log(bold(' Circular Dependencies')) @@ -135,3 +139,40 @@ export function renderToTerminal(graph: DependencyGraph, options: RenderOptions console.log(hr('═')) console.log('') } + +// --------------------------------------------------------------------------- +// Dead code section +// --------------------------------------------------------------------------- + +function renderDeadCode(graph: DependencyGraph): void { + const deadFiles = getDeadFiles(graph) + + if (deadFiles.length === 0) { + console.log('') + console.log(` ${green('✓')} No dead code detected.`) + return + } + + const unreferenced = deadFiles.filter((f) => f.kind === 'unreferenced') + const isolated = deadFiles.filter((f) => f.kind === 'isolated') + + // Unreferenced: has outgoing imports but is never imported itself + if (unreferenced.length > 0) { + console.log('') + console.log(bold(' Unreferenced Files') + dim(' (imports others but is never imported)')) + console.log(hr()) + for (const f of unreferenced) { + console.log(` ${yellow('⚠')} ${f.label}`) + } + } + + // Isolated: no imports at all, and not imported — fully disconnected + if (isolated.length > 0) { + console.log('') + console.log(bold(' Isolated Files') + dim(' (no imports and not imported)')) + console.log(hr()) + for (const f of isolated) { + console.log(` ${red('⊗')} ${f.label}`) + } + } +}