diff --git a/src/core/entrypoints/detectEntryPoints.ts b/src/core/entrypoints/detectEntryPoints.ts index fe3412e..a78382c 100644 --- a/src/core/entrypoints/detectEntryPoints.ts +++ b/src/core/entrypoints/detectEntryPoints.ts @@ -11,7 +11,7 @@ import fs from "fs"; import path from "path"; import { normalizeFilePath } from "../fileSystem/normalizePath"; -interface EntryPointInfo { +export interface EntryPointInfo { all: Set; fromPackageJson: Set; fromConventions: Set; @@ -22,97 +22,137 @@ export function detectEntryPoints(projectRoot: string, projectFiles: string[]): const fromPackageJson = resolveFromPackageJson(projectRoot, normalizedFiles); const fromConventions = resolveFromConventions(projectRoot, normalizedFiles); - const all = new Set([...fromPackageJson, ...fromConventions]); return { all, fromPackageJson, fromConventions }; } -/** - * Extract entry points from package.json fields: - * main, module, types/typings, bin - */ function resolveFromPackageJson(projectRoot: string, projectFiles: Set): Set { - const result = new Set(); - const pkgPath = path.join(projectRoot, "package.json"); + const entryPoints = new Set(); - if (!fs.existsSync(pkgPath)) return result; + const pkgPath = path.join(projectRoot, "package.json"); + if (!fs.existsSync(pkgPath)) return entryPoints; let pkg: any; + try { pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); } catch { - return result; + return entryPoints; } - const candidates: string[] = []; + const candidatePaths: string[] = []; - if (typeof pkg.main === "string") candidates.push(pkg.main); - if (typeof pkg.module === "string") candidates.push(pkg.module); - if (typeof pkg.types === "string") candidates.push(pkg.types); - if (typeof pkg.typings === "string") candidates.push(pkg.typings); + // package.json fields + if (typeof pkg.main === "string") candidatePaths.push(pkg.main); + if (typeof pkg.module === "string") candidatePaths.push(pkg.module); + if (typeof pkg.types === "string") candidatePaths.push(pkg.types); + if (typeof pkg.typings === "string") candidatePaths.push(pkg.typings); + // package.json bin - string or object if (typeof pkg.bin === "string") { - candidates.push(pkg.bin); + candidatePaths.push(pkg.bin); } else if (pkg.bin && typeof pkg.bin === "object") { - for (const value of Object.values(pkg.bin)) { - if (typeof value === "string") candidates.push(value); + for (const value of Object.values(pkg.bin)) { + if (typeof value === "string") candidatePaths.push(value); } } - for (const rel of candidates) { - const resolved = resolveCandidateToFile(projectRoot, rel, projectFiles); - if (resolved) result.add(resolved); + // exports: string | object | nested conditions | arrays + candidatePaths.push(...collectExportsTargets(pkg.exports)); + + for (const rawCandidate of candidatePaths) { + const resolved = resolveCandidateToProjectFile(projectRoot, rawCandidate, projectFiles); + + if (resolved) entryPoints.add(resolved); } - return result; + return entryPoints; } -/** - * Maps a package.json path to a source file when possible. - * Attempts: - * • exact file match - * • dist/*.js → src/*.ts mapping - */ -function resolveCandidateToFile( +function collectExportsTargets(exportsField: unknown): string[] { + const targets: string[] = []; + + function walk(value: unknown) { + if (value == null) return; + + if (typeof value === "string") { + targets.push(value); + + return; + } + + if (Array.isArray(value)) { + for (const item of value) walk(item); + + return; + } + + if (typeof value === "object") { + for (const propVal of Object.values(value as Record)) { + walk(propVal); + } + } + } + + walk(exportsField); + + return targets; +} + +function resolveCandidateToProjectFile( projectRoot: string, - relativePath: string, + candidatePath: string, projectFiles: Set, ): string | null { - const abs = normalizeFilePath(path.resolve(projectRoot, relativePath)); + const absoluteCandidate = normalizeFilePath(path.resolve(projectRoot, candidatePath)); - if (projectFiles.has(abs)) return abs; + if (projectFiles.has(absoluteCandidate)) return absoluteCandidate; - // Basic .js/.jsx → .ts/.tsx mapping - const jsExts = [".js", ".jsx"]; - const tsExts = [".ts", ".tsx"]; + const resolvedByExt = trySwapExtensions(absoluteCandidate, projectFiles); + if (resolvedByExt) return resolvedByExt; - for (const jsExt of jsExts) { - if (abs.endsWith(jsExt)) { - const base = abs.slice(0, -jsExt.length); - for (const tsExt of tsExts) { - const tsCandidate = `${base}${tsExt}`; - if (projectFiles.has(tsCandidate)) return tsCandidate; - } + const resolvedByDistSrc = tryDistToSrcMapping(absoluteCandidate, projectFiles); + if (resolvedByDistSrc) return resolvedByDistSrc; + + return null; +} + +function trySwapExtensions(absoluteCandidate: string, projectFiles: Set): string | null { + const fromExtensions = [".js", ".jsx", ".mjs", ".cjs"]; + const toExtensions = [".ts", ".tsx"]; + + for (const fromExt of fromExtensions) { + if (!absoluteCandidate.endsWith(fromExt)) continue; + + const base = absoluteCandidate.slice(0, -fromExt.length); + + for (const toExt of toExtensions) { + const tsCandidate = `${base}${toExt}`; + + if (projectFiles.has(tsCandidate)) return tsCandidate; } } - // dist → src heuristic - if (abs.includes("/dist/")) { - const srcCandidate = abs.replace("/dist/", "/src/"); - if (projectFiles.has(srcCandidate)) return srcCandidate; - } + return null; +} + +function tryDistToSrcMapping(absoluteCandidate: string, projectFiles: Set): string | null { + if (!absoluteCandidate.includes("/dist/")) return null; + + const srcCandidate = absoluteCandidate.replace("/dist/", "/src/"); + if (projectFiles.has(srcCandidate)) return srcCandidate; + + const resolvedByExt = trySwapExtensions(srcCandidate, projectFiles); + if (resolvedByExt) return resolvedByExt; return null; } -/** - * Detect common entry point filenames when package.json does not specify them. - */ function resolveFromConventions(projectRoot: string, projectFiles: Set): Set { - const result = new Set(); + const entryPoints = new Set(); - const candidates = [ + const conventionalCandidates = [ path.join(projectRoot, "index.ts"), path.join(projectRoot, "index.tsx"), path.join(projectRoot, "src", "index.ts"), @@ -121,10 +161,11 @@ function resolveFromConventions(projectRoot: string, projectFiles: Set): path.join(projectRoot, "src", "main.tsx"), ]; - for (const abs of candidates) { - const normalized = normalizeFilePath(abs); - if (projectFiles.has(normalized)) result.add(normalized); + for (const candidate of conventionalCandidates) { + const normalized = normalizeFilePath(candidate); + + if (projectFiles.has(normalized)) entryPoints.add(normalized); } - return result; + return entryPoints; } diff --git a/src/core/fileSystem/normalizePath.ts b/src/core/fileSystem/normalizePath.ts index fb9e14f..b8df853 100644 --- a/src/core/fileSystem/normalizePath.ts +++ b/src/core/fileSystem/normalizePath.ts @@ -1,7 +1,7 @@ import { resolve } from "path"; // Memoize normalized paths, since this is called frequently -const normalizeCache = new Map(); +const normalizedFileCache = new Map(); /** * Normalize a file path to a canonical absolute form. @@ -11,12 +11,13 @@ const normalizeCache = new Map(); * - Fully memoized */ export function normalizeFilePath(path: string): string { - const cached = normalizeCache.get(path); + const cached = normalizedFileCache.get(path); if (cached !== undefined) return cached; - const abs = resolve(path).replace(/\\/g, "/"); - const normalized = process.platform === "win32" ? abs.toLowerCase() : abs; + const absolutePath = resolve(path).replace(/\\/g, "/"); + const normalized = process.platform === "win32" ? absolutePath.toLowerCase() : absolutePath; + + normalizedFileCache.set(path, normalized); - normalizeCache.set(path, normalized); return normalized; } diff --git a/src/core/usage/buildUsageGraph.ts b/src/core/usage/buildUsageGraph.ts index 8dbea98..bc53023 100644 --- a/src/core/usage/buildUsageGraph.ts +++ b/src/core/usage/buildUsageGraph.ts @@ -1,7 +1,9 @@ +import path from "path"; import ts from "typescript"; import { analyzeExportUsage } from "../exports/exportUsage"; import type { ExportInfo } from "../exports/scanExports"; +import { normalizeFilePath } from "../fileSystem/normalizePath"; import type { ImportRecord } from "../imports/buildImportGraph"; import { analyzeLocalUsage, type LocalUsageOptions } from "../locals/analyzeLocalUsage"; @@ -30,36 +32,149 @@ export interface UsageGraph { * Avoids labeling non-pure modules as unused. */ function hasSideEffects(_filePath: string, fileText: string): boolean { - const trimmedText = fileText.trim(); - const nonEmptyLines = trimmedText + const text = fileText.trim(); + + if (!text) return false; + + // export-only → assumed pure + const lines = text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - // export-only → assumed pure - if (nonEmptyLines.length > 0 && nonEmptyLines.every((line) => line.startsWith("export "))) { - return false; + if (lines.length > 0 && lines.every((line) => line.startsWith("export "))) return false; + + const sideEffectPatterns: RegExp[] = [ + /\bsetTimeout\s*\(/, + /\bsetInterval\s*\(/, + /\bconsole\.[a-zA-Z]+\s*\(/, + /\bprocess\./, + /\bnew\s+[A-Za-z_$][A-Za-z0-9_$]*/, + /^\s*\(/, // IIFE + ]; + + return sideEffectPatterns.some((regEx) => regEx.test(text)); +} + +function resolveModuleToProjectFile( + fromFile: string, + moduleSpecifier: string, + projectFilesSet: Set, +): string | null { + // Follow only relative specifiers; bare specifiers are external deps + if (!moduleSpecifier.startsWith(".")) return null; + + const baseDir = path.dirname(fromFile); + const resolvedBase = path.resolve(baseDir, moduleSpecifier); + + const candidates = [ + resolvedBase, + resolvedBase + ".ts", + resolvedBase + ".tsx", + resolvedBase + ".js", + resolvedBase + ".jsx", + path.join(resolvedBase, "index.ts"), + path.join(resolvedBase, "index.tsx"), + path.join(resolvedBase, "index.js"), + path.join(resolvedBase, "index.jsx"), + ].map(normalizeFilePath); + + for (const candidate of candidates) { + if (projectFilesSet.has(candidate)) return candidate; + } + + return null; +} + +function buildProjectAdjacency( + filePaths: string[], + importGraph: Record, + exportMap: Record, +): Map> { + const projectFilesSet = new Set(filePaths); + const fileDependencyGraph = new Map>(); + + function addEdge(from: string, to: string) { + if (!projectFilesSet.has(to)) return; + + let set = fileDependencyGraph.get(from); + + if (!set) { + set = new Set(); + fileDependencyGraph.set(from, set); + } + + set.add(to); + } + + // Import edges + for (const [from, edges] of Object.entries(importGraph)) { + for (const edge of edges) { + addEdge(from, edge.sourceFile); + } + } + + // Re-export edges (barrel dependencies) + for (const from of filePaths) { + const ex = exportMap[from]; + + if (!ex) continue; + + for (const spec of ex.wildcardReexports) { + const to = resolveModuleToProjectFile(from, spec, projectFilesSet); + + if (to) addEdge(from, to); + } + for (const nr of ex.namedReexports) { + const to = resolveModuleToProjectFile(from, nr.from, projectFilesSet); + + if (to) addEdge(from, to); + } } - // simple heuristics - if (/\bsetTimeout\s*\(/.test(trimmedText)) return true; - if (/\bsetInterval\s*\(/.test(trimmedText)) return true; - if (/\bconsole\.[a-zA-Z]+\s*\(/.test(trimmedText)) return true; - if (/\bprocess\./.test(trimmedText)) return true; - if (/\bnew\s+[A-Za-z_$]/.test(trimmedText)) return true; - if (/^\s*\(/.test(trimmedText)) return true; // IIFE + // Ensure every file exists as a key (even leaf nodes) + for (const file of filePaths) { + if (!fileDependencyGraph.has(file)) fileDependencyGraph.set(file, new Set()); + } - return false; + return fileDependencyGraph; } -// Returns true if a module contains any kind of export. -function fileExportsAnything(exportInfo: ExportInfo): boolean { - return ( - exportInfo.named.length > 0 || - exportInfo.default === true || - exportInfo.namedReexports.length > 0 || - exportInfo.wildcardReexports.length > 0 - ); +function computeIncomingCounts(adjacentsMap: Map>): Map { + const incoming = new Map(); + for (const [from, tos] of adjacentsMap.entries()) { + if (!incoming.has(from)) incoming.set(from, 0); + + for (const to of tos) { + incoming.set(to, (incoming.get(to) ?? 0) + 1); + } + } + return incoming; +} + +function findReachableFromRoots( + fileDependencyGraph: Map>, + roots: Set, +): Set { + const visited = new Set(); + const stack = [...roots]; + + while (stack.length > 0) { + const file = stack.pop()!; + + if (visited.has(file)) continue; + + visited.add(file); + + const next = fileDependencyGraph.get(file); + if (!next) continue; + + for (const to of next) { + if (!visited.has(to)) stack.push(to); + } + } + + return visited; } /** @@ -75,10 +190,7 @@ export function buildUsageGraph( options: LocalUsageOptions = {}, _entryPoints: Set = new Set(), ): UsageGraph { - const { used: usedExportsByFile, unused: unusedExportsByFile } = analyzeExportUsage( - exportMap, - importGraph, - ); + const { unused: unusedExportsByFile } = analyzeExportUsage(exportMap, importGraph); // Collect unused exports const unusedExports: { file: string; name: string }[] = []; @@ -97,6 +209,7 @@ export function buildUsageGraph( for (const filePath of filePaths) { const fileText = getSourceText(filePath); + if (!fileText) continue; const localUsage = analyzeLocalUsage(filePath, fileText, { @@ -109,27 +222,35 @@ export function buildUsageGraph( } // Detect unused files + const fileDependencyGraph = buildProjectAdjacency(filePaths, importGraph, exportMap); + const unusedFiles: string[] = []; - for (const filePath of filePaths) { - const exportInfo = exportMap[filePath]; - if (!exportInfo) continue; + if (_entryPoints.size === 0) { + // If no entrypoints are detected we default to orphan detection + // Mark the files with no incoming edges as unused unless they contain side effects. + const incoming = computeIncomingCounts(fileDependencyGraph); - const importRecords = importGraph[filePath] ?? []; - const moduleHasExports = fileExportsAnything(exportInfo); - const moduleHasImports = importRecords.length > 0; + for (const file of filePaths) { + if ((incoming.get(file) ?? 0) !== 0) continue; - const usedExportsInFile = usedExportsByFile[filePath] ?? new Set(); - const moduleExportsAreUsed = usedExportsInFile.size > 0; + const fileText = getSourceText(file) ?? ""; + if (hasSideEffects(file, fileText)) continue; - if (!moduleHasExports) continue; - if (moduleExportsAreUsed) continue; - if (moduleHasImports) continue; + unusedFiles.push(file); + } + } else { + // Mark files not reachable from entrypoints as unused unless they contain side effects. + const reachable = findReachableFromRoots(fileDependencyGraph, _entryPoints); + + for (const file of filePaths) { + if (reachable.has(file)) continue; - const fileText = getSourceText(filePath) ?? ""; - if (hasSideEffects(filePath, fileText)) continue; + const fileText = getSourceText(file) ?? ""; + if (hasSideEffects(file, fileText)) continue; - unusedFiles.push(filePath); + unusedFiles.push(file); + } } return { diff --git a/tests/cli/cli.cwd.test.ts b/tests/cli/cli.cwd.test.ts index d848e23..ecbf2a2 100644 --- a/tests/cli/cli.cwd.test.ts +++ b/tests/cli/cli.cwd.test.ts @@ -23,6 +23,8 @@ test("--cwd: resolves project root relative to cwd", () => { assert.equal(code, 0); assert.match(stdout, /Unused Exports/i); assert.match(stdout, /\bfoo\b/); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); }); test("--cwd: resolves internal mode file path relative to cwd", () => { @@ -46,6 +48,8 @@ usedFn(); assert.equal(code, 0); assert.match(stdout, /Internal Usage Analysis/i); assert.match(stdout, /\bunusedFn\b/); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); }); test("--cwd: errors if directory does not exist", () => { @@ -56,6 +60,8 @@ test("--cwd: errors if directory does not exist", () => { assert.equal(code, 1); assert.match(stdout, /--cwd/i); assert.match(stdout, /does not exist/i); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); }); test("--cwd: errors if path exists but is not a directory", () => { @@ -68,6 +74,8 @@ test("--cwd: errors if path exists but is not a directory", () => { assert.equal(code, 1); assert.match(stdout, /--cwd/i); assert.match(stdout, /not a directory/i); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); }); test("--cwd: works without --cwd (baseline behavior unchanged)", () => { @@ -80,4 +88,6 @@ test("--cwd: works without --cwd (baseline behavior unchanged)", () => { assert.equal(code, 0); assert.match(stdout, /\bfoo\b/); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); }); diff --git a/tests/entrypoints/entrypoint-export.test.ts b/tests/entrypoints/entrypoint-export.test.ts new file mode 100644 index 0000000..fd36ca4 --- /dev/null +++ b/tests/entrypoints/entrypoint-export.test.ts @@ -0,0 +1,86 @@ +import assert from "assert/strict"; +import fs from "fs"; +import test from "node:test"; +import os from "os"; +import path from "path"; + +import { detectEntryPoints } from "../../src/core/entrypoints/detectEntryPoints"; +import { normalizeFilePath } from "../../src/core/fileSystem/normalizePath"; + +function writeFile(filePath: string, contents: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, "utf8"); +} + +test("entrypoints: package.json exports (string) maps dist -> src", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "devoid-entrypoints-")); + + writeFile( + path.join(tmpRoot, "package.json"), + JSON.stringify({ exports: "./dist/index.js" }, null, 2), + ); + writeFile(path.join(tmpRoot, "src", "index.ts"), `export const foo = 1;\n`); + + const projectFiles = [normalizeFilePath(path.join(tmpRoot, "src", "index.ts"))]; + + const info = detectEntryPoints(tmpRoot, projectFiles); + assert.ok(info.fromPackageJson.has(normalizeFilePath(path.join(tmpRoot, "src", "index.ts")))); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +test("entrypoints: package.json exports (conditional object) maps dist -> src", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "devoid-entrypoints-")); + + writeFile( + path.join(tmpRoot, "package.json"), + JSON.stringify( + { + exports: { + ".": { + types: "./dist/index.d.ts", + require: "./dist/index.cjs", + import: "./dist/index.js", + default: "./dist/index.js", + }, + }, + }, + null, + 2, + ), + ); + writeFile(path.join(tmpRoot, "src", "index.ts"), `export const foo = 1;\n`); + + const projectFiles = [normalizeFilePath(path.join(tmpRoot, "src", "index.ts"))]; + + const info = detectEntryPoints(tmpRoot, projectFiles); + assert.ok(info.fromPackageJson.has(normalizeFilePath(path.join(tmpRoot, "src", "index.ts")))); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +test("entrypoints: package.json exports (array) finds string targets", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "devoid-entrypoints-")); + + writeFile( + path.join(tmpRoot, "package.json"), + JSON.stringify( + { + exports: { + ".": ["./dist/index.js", "./dist/fallback.js"], + }, + }, + null, + 2, + ), + ); + + writeFile(path.join(tmpRoot, "src", "index.ts"), `export const foo = 1;\n`); + + const projectFiles = [normalizeFilePath(path.join(tmpRoot, "src", "index.ts"))]; + + const info = detectEntryPoints(tmpRoot, projectFiles); + assert.ok(info.fromPackageJson.has(normalizeFilePath(path.join(tmpRoot, "src", "index.ts")))); + + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); diff --git a/tests/integrate/barrel.test.ts b/tests/integrate/barrel.test.ts index 050c35c..5df23b6 100644 --- a/tests/integrate/barrel.test.ts +++ b/tests/integrate/barrel.test.ts @@ -72,3 +72,12 @@ test("barrel modules: mixed named + wildcard re-exports propagate properly", () assert(!unused.has("beta"), "beta should be used"); assert(!unused.has("gamma"), "gamma should be used"); }); + +test("barrel modules: re-export target is not considered unused", () => { + const project = analyze(FIXTURE_ROOT); + const unusedFiles = new Set(project.unusedFiles); + + const utilPath = path.join(FIXTURE_ROOT, "shared", "util.ts"); + + assert(!unusedFiles.has(utilPath), "re-exported util should not be flagged unused"); +});