diff --git a/docs/superpowers/specs/2026-05-12-dead-export-finder-pure-refactor-design.md b/docs/superpowers/specs/2026-05-12-dead-export-finder-pure-refactor-design.md new file mode 100644 index 0000000..7d8ff27 --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-dead-export-finder-pure-refactor-design.md @@ -0,0 +1,73 @@ +# Dead Export Finder — Pure Functional Refactor + +## Goal + +Eliminate all mutation from the `dead-export-finder` package internals. Use pipe syntax with small, well-named composed functions. Leverage Effect's `HashSet`/`HashMap` internally where structural equality matters, keep native `ReadonlyMap`/`ReadonlyArray` at service boundaries. + +## Constraints + +- Service topology unchanged (WorkspaceDetector, FileScanner, ExportParser, ImportParser, ExportGraph, Reporter) +- All existing tests pass without modification (Map is assignable to ReadonlyMap) +- Effect boundary stays at parse/IO level — pure synchronous functions inside + +## Service Interface Changes + +- `ExportGraph.analyze`: parameters become `ReadonlyMap` instead of `Map` +- `Reporter.format`: `packageRoots` becomes `ReadonlyMap` +- No new services added, none removed + +## File-by-File Plan + +### export-parser.ts + +- Replace `symbols.push()` in switch/case with `Array.flatMap(extractExportsFromNode)` over `program.body` +- Small pure extractors: `extractNamedDeclaration`, `extractDefaultDeclaration`, `extractAllDeclaration`, `extractCjsExports` — each returns `ReadonlyArray` +- `extractDeclarationNames` returns `ReadonlyArray` +- `lineFromOffset` stays as pure function (already is), replace mutable counter +- `Effect.try` boundary stays for `oxc.parseSync` + +### import-parser.ts + +- `walkNode(node, filePath, symbols)` → `collectSymbols(node, filePath): ReadonlyArray` — pure recursive, returns results +- Children via `Array.flatMap` recurse +- Static imports: `Array.flatMap(extractStaticImports)` over ImportDeclaration nodes +- Concatenate with `collectSymbols` results +- Same `Effect.try` boundary + +### export-graph.ts + +- `resolveEntryPoints(packages, scannedFiles) → HashSet` +- `buildFileToPackageMap(packages, exportedFilePaths) → ReadonlyMap` +- `buildConsumedSets(allImports, allExports) → { byRelative: HashSet, byPackage: HashSet, byNamespace: HashSet }` + - `collectImportEdges` + `collectReExportEdges` merged with `HashSet.union` +- `findDeadExports(...)` — pure filter with `Array.filter(isNotConsumed)` +- `analyze` becomes ~15 lines piping through steps 1–4 + +### reporter.ts + +- `Array.groupBy` for package/file grouping +- `formatPackageSection` returns `ReadonlyArray` +- Top-level: header → sections → summary, `Array.join("\n")` + +### file-scanner.ts + +- `loadGitignorePatterns(root)` → `Effect>` +- `buildIgnorePatterns(gitignore, custom)` — pure concatenation +- Single `ignore()` construction from full pattern list + +### workspace-detector.ts + +- Nested if/else → `pipe(detectPnpm, Effect.orElse(detectNpm), Effect.orElse(detectNx), Effect.orElse(detectTurbo), Effect.orElse(detectSingle))` +- Each detector is a small function returning `Effect` + +### index.ts (CLI) + +- `scanWorkspace(workspace, ignoreGlobs, verbose)` → immutable file map + warnings +- `parseAllFiles(filesByPackage, verbose)` → immutable exports/imports maps + warnings +- `analyzeAndReport(...)` → calls graph + reporter +- Command handler becomes ~20 lines +- Warnings concatenated at end with `Array.appendAll` + +## Testing + +Zero test file changes. All `Map` constructions in tests are assignable to `ReadonlyMap`. All assertions remain valid. diff --git a/packages/dead-export-finder/src/index.ts b/packages/dead-export-finder/src/index.ts index 8be107a..2acda0d 100644 --- a/packages/dead-export-finder/src/index.ts +++ b/packages/dead-export-finder/src/index.ts @@ -20,14 +20,15 @@ export type { import { Command, Options } from '@effect/cli'; import { NodeContext, NodeRuntime } from '@effect/platform-node'; import { FileSystem } from '@effect/platform'; -import { Console, Data, Effect, Layer } from 'effect'; +import { Console, Data, Effect, Layer, Array as Arr, Option, pipe } from 'effect'; import { WorkspaceDetector, WorkspaceDetectorLive } from './lib/workspace-detector.js'; +import type { WorkspaceResult } from './lib/workspace-detector.js'; import { FileScanner, FileScannerLive } from './lib/file-scanner.js'; import { ExportParser, ExportParserLive } from './lib/export-parser.js'; import { ImportParser, ImportParserLive } from './lib/import-parser.js'; import { ExportGraph, ExportGraphLive } from './lib/export-graph.js'; import { Reporter, ReporterLive } from './lib/reporter.js'; -import type { ExportedSymbol, ImportedSymbol } from './lib/schemas.js'; +import type { PackageInfo, ExportedSymbol, ImportedSymbol } from './lib/schemas.js'; // ─── Exit code error ────────────────────────────────────────────────────────── @@ -68,149 +69,290 @@ const AppLayer = Layer.mergeAll( FileScannerLive, ).pipe(Layer.provideMerge(NodeContext.layer)); +// ─── Pipeline stages ──────────────────────────────────────────────────────── + +interface ScanResult { + readonly filesByPackage: ReadonlyArray]>; + readonly warnings: ReadonlyArray; +} + +const scanWorkspace = ( + workspace: WorkspaceResult, + ignoreGlobs: readonly string[], + isVerbose: boolean, +): Effect.Effect => + Effect.gen(function* () { + const scanner = yield* FileScanner; + + const results = yield* Effect.all( + pipe( + workspace.packages, + Arr.map((pkg) => + pipe( + scanner.scan(pkg.root, ignoreGlobs), + Effect.catchTag('GlobError', (e) => + Effect.gen(function* () { + const msg = `failed to scan files in ${pkg.root}: ${String(e.cause)}`; + if (isVerbose) yield* Console.log(`Warning: ${msg}`); + return { files: [] as readonly string[], warning: msg }; + }), + ), + Effect.map((result) => + 'warning' in (result as object) + ? (result as { files: readonly string[]; warning: string }) + : { files: result as readonly string[], warning: null as string | null }, + ), + Effect.map((r) => ({ pkg, files: r.files, warning: r.warning })), + ), + ), + ), + ); + + return { + filesByPackage: pipe( + results, + Arr.map((r) => [r.pkg, r.files] as const), + ), + warnings: pipe( + results, + Arr.filterMap((r) => (r.warning !== null ? Option.some(r.warning) : Option.none())), + ), + }; + }); + +interface ParseResult { + readonly allExports: ReadonlyMap; + readonly allImports: ReadonlyMap; + readonly warnings: ReadonlyArray; +} + +const parseAllFiles = ( + filesByPackage: ReadonlyArray]>, + isVerbose: boolean, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exportParser = yield* ExportParser; + const importParser = yield* ImportParser; + + const allFiles = pipe( + filesByPackage, + Arr.flatMap(([, files]) => files), + ); + + const fileResults = yield* Effect.all( + pipe( + allFiles, + Arr.map((filePath) => + pipe( + fs.readFileString(filePath, 'utf-8'), + Effect.either, + Effect.flatMap((sourceResult) => { + if (sourceResult._tag === 'Left') { + const msg = `could not read ${filePath}: ${String(sourceResult.left)}`; + return isVerbose + ? pipe( + Console.log(`Warning: ${msg}`), + Effect.map(() => ({ + filePath, + exports: [] as readonly ExportedSymbol[], + imports: [] as readonly ImportedSymbol[], + warning: msg as string | null, + })), + ) + : Effect.succeed({ + filePath, + exports: [] as readonly ExportedSymbol[], + imports: [] as readonly ImportedSymbol[], + warning: msg as string | null, + }); + } + + const source = sourceResult.right; + + const parseExports = pipe( + exportParser.parse(filePath, source), + Effect.map( + (symbols): { symbols: readonly ExportedSymbol[]; warning: string | null } => ({ + symbols, + warning: null, + }), + ), + Effect.catchTag('ParseError', (e) => { + const msg = `failed to parse exports in ${e.filePath}: ${e.message}`; + const result = { symbols: [] as readonly ExportedSymbol[], warning: msg }; + return isVerbose + ? pipe( + Console.log(`Warning: ${msg}`), + Effect.map(() => result), + ) + : Effect.succeed(result); + }), + ); + + const parseImports = pipe( + importParser.parse(filePath, source), + Effect.map( + (symbols): { symbols: readonly ImportedSymbol[]; warning: string | null } => ({ + symbols, + warning: null, + }), + ), + Effect.catchTag('ParseError', (e) => { + const msg = `failed to parse imports in ${e.filePath}: ${e.message}`; + const result = { symbols: [] as readonly ImportedSymbol[], warning: msg }; + return isVerbose + ? pipe( + Console.log(`Warning: ${msg}`), + Effect.map(() => result), + ) + : Effect.succeed(result); + }), + ); + + return pipe( + Effect.all({ exports: parseExports, imports: parseImports }), + Effect.map(({ exports: exp, imports: imp }) => ({ + filePath, + exports: exp.symbols, + imports: imp.symbols, + warning: exp.warning ?? imp.warning, + })), + ); + }), + ), + ), + ), + ); + + const exportEntries = pipe( + fileResults, + Arr.filter((r) => r.exports.length > 0), + Arr.map((r) => [r.filePath, r.exports] as const), + ); + + const importEntries = pipe( + fileResults, + Arr.filter((r) => r.imports.length > 0), + Arr.map((r) => [r.filePath, r.imports] as const), + ); + + const warnings = pipe( + fileResults, + Arr.filterMap((r) => (r.warning !== null ? Option.some(r.warning) : Option.none())), + ); + + return { + allExports: new Map(exportEntries) as ReadonlyMap, + allImports: new Map(importEntries) as ReadonlyMap, + warnings, + }; + }); + +const analyzeAndReport = ( + targetPackages: ReadonlyArray, + allPackages: ReadonlyArray, + allExports: ReadonlyMap, + allImports: ReadonlyMap, +): Effect.Effect< + { readonly deadCount: number; readonly warnings: ReadonlyArray }, + never, + ExportGraph | Reporter +> => + Effect.gen(function* () { + const graph = yield* ExportGraph; + const reporter = yield* Reporter; + + const result = yield* graph.analyze(targetPackages, allExports, allImports); + + const packageRoots: ReadonlyMap = new Map( + pipe( + allPackages, + Arr.map((p) => [p.name, p.root] as const), + ), + ); + + const report = reporter.format(result, packageRoots); + yield* Console.log(report); + + return { deadCount: result.deadExports.length, warnings: result.warnings }; + }); + // ─── Command ────────────────────────────────────────────────────────────────── const command = Command.make( 'dead-export-finder', { packages, ignore, verbose }, - ({ packages: packagesOpt, ignore: ignoreOpt, verbose }) => + ({ packages: packagesOpt, ignore: ignoreOpt, verbose: isVerbose }) => Effect.gen(function* () { const startTime = Date.now(); const detector = yield* WorkspaceDetector; - const scanner = yield* FileScanner; - const exportParser = yield* ExportParser; - const importParser = yield* ImportParser; - const graph = yield* ExportGraph; - const reporter = yield* Reporter; - const fs = yield* FileSystem.FileSystem; - - // 1. Detect workspace const cwd = process.cwd(); const workspace = yield* detector.detect(cwd); - if (verbose) { + if (isVerbose) { yield* Console.log(`Detected workspace type: ${workspace.type}`); yield* Console.log(`Found ${workspace.packages.length} packages`); } - // 2. Determine target packages (for analysis) vs all packages (for imports) const packageFilter = packagesOpt._tag === 'Some' ? new Set(packagesOpt.value) : null; const targetPackages = packageFilter !== null - ? workspace.packages.filter((p) => packageFilter.has(p.name)) - : workspace.packages; - - const allPackages = workspace.packages; + ? pipe( + workspace.packages, + Arr.filter((p) => packageFilter.has(p.name)), + ) + : [...workspace.packages]; const ignoreGlobs: readonly string[] = ignoreOpt._tag === 'Some' ? ignoreOpt.value : []; - if (verbose && packageFilter !== null && targetPackages.length > 0) { - yield* Console.log(`Scoping to packages: ${targetPackages.map((p) => p.name).join(', ')}`); - } - - // 3. Scan ALL workspace packages for files - const allExports = new Map(); - const allImports = new Map(); - const parseWarnings: string[] = []; - - for (const pkg of allPackages) { - const files = yield* scanner.scan(pkg.root, ignoreGlobs).pipe( - Effect.catchTag('GlobError', (e) => - Effect.gen(function* () { - const msg = `failed to scan files in ${pkg.root}: ${String(e.cause)}`; - parseWarnings.push(msg); - if (verbose) { - yield* Console.log(`Warning: ${msg}`); - } - return [] as readonly string[]; - }), - ), + if (isVerbose && packageFilter !== null && targetPackages.length > 0) { + yield* Console.log( + `Scoping to packages: ${pipe( + targetPackages, + Arr.map((p) => p.name), + Arr.join(', '), + )}`, ); + } - for (const filePath of files) { - const sourceResult = yield* fs.readFileString(filePath, 'utf-8').pipe(Effect.either); - - if (sourceResult._tag === 'Left') { - const msg = `could not read ${filePath}: ${String(sourceResult.left)}`; - parseWarnings.push(msg); - if (verbose) { - yield* Console.log(`Warning: ${msg}`); - } - continue; - } - - const source = sourceResult.right; + const scanResult = yield* scanWorkspace(workspace, ignoreGlobs, isVerbose); - // 4. Parse exports - const exports = yield* exportParser.parse(filePath, source).pipe( - Effect.catchTag('ParseError', (e) => - Effect.gen(function* () { - const msg = `failed to parse exports in ${e.filePath}: ${e.message}`; - parseWarnings.push(msg); - if (verbose) { - yield* Console.log(`Warning: ${msg}`); - } - return [] as readonly ExportedSymbol[]; - }), - ), - ); - if (exports.length > 0) { - allExports.set(filePath, exports); - } - - // Parse imports - const imports = yield* importParser.parse(filePath, source).pipe( - Effect.catchTag('ParseError', (e) => - Effect.gen(function* () { - const msg = `failed to parse imports in ${e.filePath}: ${e.message}`; - parseWarnings.push(msg); - if (verbose) { - yield* Console.log(`Warning: ${msg}`); - } - return [] as readonly ImportedSymbol[]; - }), - ), - ); - if (imports.length > 0) { - allImports.set(filePath, imports); - } - } - } + const parseResult = yield* parseAllFiles(scanResult.filesByPackage, isVerbose); - if (verbose) { + if (isVerbose) { yield* Console.log( - `Scanned ${allExports.size} files with exports, ${allImports.size} files with imports`, + `Scanned ${parseResult.allExports.size} files with exports, ${parseResult.allImports.size} files with imports`, ); } - // 5. Analyze with ExportGraph (target packages for analysis, all imports) - const result = yield* graph.analyze(targetPackages, allExports, allImports); - - // 6. Build package roots map for reporter - const packageRoots = new Map(allPackages.map((p) => [p.name, p.root])); + const { deadCount, warnings: analysisWarnings } = yield* analyzeAndReport( + targetPackages, + [...workspace.packages], + parseResult.allExports, + parseResult.allImports, + ); - // Format and print report - const report = reporter.format(result, packageRoots); - yield* Console.log(report); + const allWarnings = pipe( + scanResult.warnings, + Arr.appendAll(parseResult.warnings), + Arr.appendAll(analysisWarnings), + ); - // 7. Surface warnings (always, not just verbose) - const allWarnings = [...parseWarnings, ...result.warnings]; - if (allWarnings.length > 0 && !verbose) { + if (allWarnings.length > 0 && !isVerbose) { yield* Console.log( `\nWarning: ${allWarnings.length} issue(s) during analysis — results may be incomplete. Run with --verbose for details.`, ); } - // 8. Verbose timing - if (verbose) { + if (isVerbose) { const elapsed = Date.now() - startTime; yield* Console.log(`\nCompleted in ${elapsed}ms`); } - // 9. Exit code 1 if dead exports found - if (result.deadExports.length > 0) { + if (deadCount > 0) { return yield* new ExitWithCode({ code: 1 }); } }), diff --git a/packages/dead-export-finder/src/lib/export-graph.ts b/packages/dead-export-finder/src/lib/export-graph.ts index 986a706..3d95bca 100644 --- a/packages/dead-export-finder/src/lib/export-graph.ts +++ b/packages/dead-export-finder/src/lib/export-graph.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer } from 'effect'; +import { Context, Effect, Layer, Array as Arr, HashSet, Option, pipe } from 'effect'; import path from 'node:path'; import type { PackageInfo, @@ -10,51 +10,265 @@ import type { // ─── Extension stripping ─────────────────────────────────────────────────────── -const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; +const EXTENSIONS: ReadonlyArray = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; -function stripExtension(filePath: string): string { - for (const ext of EXTENSIONS) { - if (filePath.endsWith(ext)) { - return filePath.slice(0, -ext.length); - } - } - return filePath; -} +const stripExtension = (filePath: string): string => + pipe( + EXTENSIONS, + Arr.findFirst((ext) => filePath.endsWith(ext)), + (opt) => (opt._tag === 'Some' ? filePath.slice(0, -opt.value.length) : filePath), + ); + +const BUILD_DIR_MAPPINGS: ReadonlyArray = [ + ['/dist/', '/src/'], + ['/build/', '/src/'], + ['/out/', '/src/'], +]; -/** Map a build-output entry point (e.g. ./dist/index.js) to its likely source path. */ -function resolveEntryPointToSource( +const resolveEntryPointToSource = ( entryPoint: string, - scannedFiles: Set, - scannedStripped: Set, -): string | null { - // If the raw path already matches a scanned file, use it directly. - if (scannedFiles.has(entryPoint)) return entryPoint; + scannedFiles: HashSet.HashSet, + scannedStripped: HashSet.HashSet, +): string | null => { + if (HashSet.has(scannedFiles, entryPoint)) return entryPoint; const stripped = stripExtension(entryPoint); - if (scannedStripped.has(stripped)) return stripped; + if (HashSet.has(scannedStripped, stripped)) return stripped; + + return pipe( + BUILD_DIR_MAPPINGS, + Arr.findFirst(([buildDir]) => entryPoint.includes(buildDir)), + (opt) => { + if (opt._tag === 'None') return null; + const [buildDir, sourceDir] = opt.value; + const sourcePath = entryPoint.replace(buildDir, sourceDir); + if (HashSet.has(scannedFiles, sourcePath)) return sourcePath; + const sourceStripped = stripExtension(sourcePath); + if (HashSet.has(scannedStripped, sourceStripped)) return sourceStripped; + return null; + }, + ); +}; + +// ─── Pure pipeline stages ──────────────────────────────────────────────────── + +const resolveEntryPoints = ( + packages: ReadonlyArray, + scannedFiles: HashSet.HashSet, + scannedStripped: HashSet.HashSet, +): HashSet.HashSet => + pipe( + packages, + Arr.flatMap((pkg) => + pipe( + pkg.entryPoints, + Arr.filterMap((ep) => { + const resolved = path.resolve(pkg.root, ep); + const sourcePath = resolveEntryPointToSource(resolved, scannedFiles, scannedStripped); + return sourcePath !== null ? Option.some(sourcePath) : Option.none(); + }), + ), + ), + HashSet.fromIterable, + ); + +const buildFileToPackageMap = ( + packages: ReadonlyArray, + exportedFilePaths: ReadonlyArray, +): ReadonlyMap => + new Map( + pipe( + exportedFilePaths, + Arr.filterMap((filePath) => { + const pkg = pipe( + packages, + Arr.findFirst((p) => filePath.startsWith(p.root + path.sep) || filePath === p.root), + ); + return pkg._tag === 'Some' ? Option.some([filePath, pkg.value] as const) : Option.none(); + }), + ), + ); + +// ─── Consumed sets ────────────────────────────────────────────────────────── + +interface ConsumedSets { + readonly byRelative: HashSet.HashSet; + readonly byPackage: HashSet.HashSet; + readonly byNamespace: HashSet.HashSet; +} - // Try mapping common build output dirs to source dirs. - const buildDirs = ['/dist/', '/build/', '/out/']; - const sourceDirs = ['/src/', '/src/', '/src/']; +const isRelativePath = (src: string): boolean => + src.startsWith('./') || src.startsWith('../') || src.startsWith('/'); + +const collectImportEdges = ( + allImports: ReadonlyMap, +): ConsumedSets => { + const entries = pipe( + [...allImports.entries()], + Arr.flatMap(([importerFile, imports]) => + pipe( + imports, + Arr.map((imp) => ({ importerFile, imp })), + ), + ), + ); + + const relativeEntries = pipe( + entries, + Arr.filter(({ imp }) => isRelativePath(imp.source)), + ); + + const packageEntries = pipe( + entries, + Arr.filter(({ imp }) => !isRelativePath(imp.source)), + ); + + const byRelative = pipe( + relativeEntries, + Arr.filter(({ imp }) => !imp.isNamespace && imp.name !== '*'), + Arr.map(({ importerFile, imp }) => { + const importerDir = path.dirname(importerFile); + const resolved = stripExtension(path.resolve(importerDir, imp.source)); + return `${resolved}:${imp.name}`; + }), + HashSet.fromIterable, + ); + + const byPackage = pipe( + packageEntries, + Arr.filter(({ imp }) => !imp.isNamespace && imp.name !== '*'), + Arr.map(({ imp }) => `${imp.source}:${imp.name}`), + HashSet.fromIterable, + ); + + const relativeNamespaces = pipe( + relativeEntries, + Arr.filter(({ imp }) => imp.isNamespace || imp.name === '*'), + Arr.map(({ importerFile, imp }) => { + const importerDir = path.dirname(importerFile); + return stripExtension(path.resolve(importerDir, imp.source)); + }), + ); + + const packageNamespaces = pipe( + packageEntries, + Arr.filter(({ imp }) => imp.isNamespace || imp.name === '*'), + Arr.map(({ imp }) => imp.source), + ); + + const byNamespace = pipe([...relativeNamespaces, ...packageNamespaces], HashSet.fromIterable); + + return { byRelative, byPackage, byNamespace }; +}; + +const collectReExportEdges = ( + allExports: ReadonlyMap, +): ConsumedSets => { + const reExports = pipe( + [...allExports.entries()], + Arr.flatMap(([filePath, exports]) => + pipe( + exports, + Arr.filter((exp) => exp.isReExport && exp.reExportSource !== undefined), + Arr.filter((exp) => isRelativePath(exp.reExportSource!)), + Arr.map((exp) => ({ filePath, exp })), + ), + ), + ); + + const byNamespace = pipe( + reExports, + Arr.filter(({ exp }) => exp.name === '*'), + Arr.map(({ filePath, exp }) => { + const dir = path.dirname(filePath); + return stripExtension(path.resolve(dir, exp.reExportSource!)); + }), + HashSet.fromIterable, + ); + + const byRelative = pipe( + reExports, + Arr.filter(({ exp }) => exp.name !== '*'), + Arr.map(({ filePath, exp }) => { + const dir = path.dirname(filePath); + const resolved = stripExtension(path.resolve(dir, exp.reExportSource!)); + const consumedName = exp.reExportLocalName ?? exp.name; + return `${resolved}:${consumedName}`; + }), + HashSet.fromIterable, + ); - for (let i = 0; i < buildDirs.length; i++) { - if (entryPoint.includes(buildDirs[i])) { - const sourcePath = entryPoint.replace(buildDirs[i], sourceDirs[i]); - if (scannedFiles.has(sourcePath)) return sourcePath; - const sourceStripped = stripExtension(sourcePath); - if (scannedStripped.has(sourceStripped)) return sourceStripped; - } - } + return { + byRelative, + byPackage: HashSet.empty(), + byNamespace, + }; +}; - return null; -} +const mergeConsumedSets = (a: ConsumedSets, b: ConsumedSets): ConsumedSets => ({ + byRelative: HashSet.union(a.byRelative, b.byRelative), + byPackage: HashSet.union(a.byPackage, b.byPackage), + byNamespace: HashSet.union(a.byNamespace, b.byNamespace), +}); + +const buildConsumedSets = ( + allImports: ReadonlyMap, + allExports: ReadonlyMap, +): ConsumedSets => + mergeConsumedSets(collectImportEdges(allImports), collectReExportEdges(allExports)); + +// ─── Dead export detection ────────────────────────────────────────────────── + +const isConsumed = ( + exp: ExportedSymbol, + strippedFilePath: string, + pkg: PackageInfo, + consumed: ConsumedSets, +): boolean => { + if (exp.name === '*') return true; + if (HashSet.has(consumed.byRelative, `${strippedFilePath}:${exp.name}`)) return true; + if (HashSet.has(consumed.byPackage, `${pkg.name}:${exp.name}`)) return true; + if (HashSet.has(consumed.byNamespace, strippedFilePath)) return true; + if (HashSet.has(consumed.byNamespace, pkg.name)) return true; + return false; +}; + +const findDeadExports = ( + allExports: ReadonlyMap, + entryPoints: HashSet.HashSet, + fileToPackage: ReadonlyMap, + consumed: ConsumedSets, +): ReadonlyArray => + pipe( + [...allExports.entries()], + Arr.filter(([filePath]) => !HashSet.has(entryPoints, filePath)), + Arr.flatMap(([filePath, exports]) => { + const pkg = fileToPackage.get(filePath); + if (pkg === undefined) return []; + + const strippedFilePath = stripExtension(filePath); + + return pipe( + exports, + Arr.filter((exp) => !isConsumed(exp, strippedFilePath, pkg, consumed)), + Arr.map((exp): DeadExport => ({ symbol: exp, packageName: pkg.name })), + ); + }), + ); + +const countTotalExports = (allExports: ReadonlyMap): number => + pipe( + [...allExports.values()], + Arr.map((exports) => exports.length), + Arr.reduce(0, (acc, n) => acc + n), + ); // ─── Service interface ──────────────────────────────────────────────────────── export interface ExportGraphShape { readonly analyze: ( packages: readonly PackageInfo[], - allExports: Map, - allImports: Map, + allExports: ReadonlyMap, + allImports: ReadonlyMap, ) => Effect.Effect; } @@ -64,158 +278,32 @@ export class ExportGraph extends Context.Tag('ExportGraph'), - allImports: Map, -): AnalysisResult { - // Step 1: Build set of entry point file paths (resolved to source paths) - const scannedFiles = new Set(allExports.keys()); - const scannedStripped = new Set(); - for (const f of scannedFiles) scannedStripped.add(stripExtension(f)); - - const entryPointPaths = new Set(); - for (const pkg of packages) { - for (const ep of pkg.entryPoints) { - const resolved = path.resolve(pkg.root, ep); - const sourcePath = resolveEntryPointToSource(resolved, scannedFiles, scannedStripped); - if (sourcePath !== null) { - entryPointPaths.add(sourcePath); - } - } - } - - // Step 2: Build map of filePath -> package - const fileToPackage = new Map(); - for (const pkg of packages) { - for (const [filePath] of allExports) { - // A file belongs to a package if its path starts with the package root - if (filePath.startsWith(pkg.root + path.sep) || filePath === pkg.root) { - if (!fileToPackage.has(filePath)) { - fileToPackage.set(filePath, pkg); - } - } - } - } - - // Step 3: Collect consumed symbols from all imports - // relative-resolved: Set of `strippedPath:name` - const consumedByRelative = new Set(); - // package: Set of `packageName:name` - const consumedByPackage = new Set(); - // namespace: Set of strippedPath (source file) — everything from this file is consumed - const consumedByNamespace = new Set(); - - for (const [importerFile, imports] of allImports) { - for (const imp of imports) { - const src = imp.source; - const isRelative = src.startsWith('./') || src.startsWith('../') || src.startsWith('/'); - - if (isRelative) { - // Resolve relative import to absolute path - const importerDir = path.dirname(importerFile); - const resolved = path.resolve(importerDir, src); - const strippedResolved = stripExtension(resolved); - - if (imp.isNamespace || imp.name === '*') { - consumedByNamespace.add(strippedResolved); - } else { - consumedByRelative.add(`${strippedResolved}:${imp.name}`); - } - } else { - // Package import — use source as-is (package name) - if (imp.isNamespace || imp.name === '*') { - // namespace import of a package: track as namespace - consumedByNamespace.add(src); - } else { - consumedByPackage.add(`${src}:${imp.name}`); - } - } - } - } - - // Step 3b: Treat re-exports as import edges - // If file A re-exports { foo } from './bar', that means bar's `foo` is consumed. - // This applies to ALL files, not just entry points, so multi-hop chains work. - for (const [filePath, exports] of allExports) { - for (const exp of exports) { - if (!exp.isReExport || !exp.reExportSource) continue; - - const reExportSource = exp.reExportSource; - // Skip package specifiers — path.resolve would produce nonsense - const isRelative = - reExportSource.startsWith('./') || - reExportSource.startsWith('../') || - reExportSource.startsWith('/'); - if (!isRelative) continue; - - const dir = path.dirname(filePath); - const resolved = stripExtension(path.resolve(dir, reExportSource)); - - if (exp.name === '*') { - consumedByNamespace.add(resolved); - } else { - // For renamed re-exports (export { foo as bar }), consume the local name - const consumedName = exp.reExportLocalName ?? exp.name; - consumedByRelative.add(`${resolved}:${consumedName}`); - } - } - } - - // Step 4: Determine dead exports - const deadExports: DeadExport[] = []; - const warnings: string[] = []; - let totalExports = 0; - const totalFiles = allExports.size; - - for (const [filePath, exports] of allExports) { - // Skip entry point files — their exports are always safe - if (entryPointPaths.has(filePath)) { - totalExports += exports.length; - continue; - } - - const pkg = fileToPackage.get(filePath); - if (pkg === undefined) { - // File doesn't belong to any known package — skip silently - totalExports += exports.length; - continue; - } - - const strippedFilePath = stripExtension(filePath); - - for (const exp of exports) { - totalExports++; - - // Skip star re-exports (export * from '...') - if (exp.name === '*') continue; - - // Check if consumed by a relative import - const relKey = `${strippedFilePath}:${exp.name}`; - if (consumedByRelative.has(relKey)) continue; - - // Check if consumed by a package import - const pkgKey = `${pkg.name}:${exp.name}`; - if (consumedByPackage.has(pkgKey)) continue; - - // Check if source file is consumed by a namespace import - if (consumedByNamespace.has(strippedFilePath)) continue; - if (consumedByNamespace.has(pkg.name)) continue; - - // Not consumed — dead export - deadExports.push({ symbol: exp, packageName: pkg.name }); - } - } + allExports: ReadonlyMap, + allImports: ReadonlyMap, +): AnalysisResult => { + const scannedFiles = HashSet.fromIterable(allExports.keys()); + const scannedStripped = pipe( + [...allExports.keys()], + Arr.map(stripExtension), + HashSet.fromIterable, + ); + + const entryPoints = resolveEntryPoints(packages, scannedFiles, scannedStripped); + const fileToPackage = buildFileToPackageMap(packages, [...allExports.keys()]); + const consumed = buildConsumedSets(allImports, allExports); + const deadExports = findDeadExports(allExports, entryPoints, fileToPackage, consumed); return { - deadExports, - totalExports, - totalFiles, - warnings, + deadExports: [...deadExports], + totalExports: countTotalExports(allExports), + totalFiles: allExports.size, + warnings: [], }; -} +}; export const ExportGraphLive = Layer.succeed(ExportGraph, { analyze: (packages, allExports, allImports) => - Effect.sync(() => analyzeSync(packages, allExports, allImports)), + Effect.sync(() => analyze(packages, allExports, allImports)), }); diff --git a/packages/dead-export-finder/src/lib/export-parser.ts b/packages/dead-export-finder/src/lib/export-parser.ts index 87b768f..3355d52 100644 --- a/packages/dead-export-finder/src/lib/export-parser.ts +++ b/packages/dead-export-finder/src/lib/export-parser.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer } from 'effect'; +import { Context, Effect, Layer, Array as Arr, pipe } from 'effect'; import oxc from 'oxc-parser'; import type { ExportedSymbol } from './schemas.js'; import { ParseError } from './errors.js'; @@ -130,17 +130,12 @@ interface OxcProgram { body: OxcBodyNode[]; } -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Pure helpers ──────────────────────────────────────────────────────────── -function lineFromOffset(source: string, offset: number): number { - let line = 1; - for (let i = 0; i < offset && i < source.length; i++) { - if (source[i] === '\n') line++; - } - return line; -} +const lineFromOffset = (source: string, offset: number): number => + pipe(source.slice(0, offset).split('\n'), Arr.length); -function extractDeclarationNames(decl: OxcDeclaration): string[] { +const extractDeclarationNames = (decl: OxcDeclaration): ReadonlyArray => { switch (decl.type) { case 'FunctionDeclaration': case 'ClassDeclaration': { @@ -149,8 +144,9 @@ function extractDeclarationNames(decl: OxcDeclaration): string[] { } case 'VariableDeclaration': { const d = decl as OxcVariableDeclaration; - return d.declarations.flatMap((v) => - v.id.type === 'Identifier' ? [(v.id as OxcIdent).name] : [], + return pipe( + d.declarations, + Arr.flatMap((v) => (v.id.type === 'Identifier' ? [(v.id as OxcIdent).name] : [])), ); } case 'TSTypeAliasDeclaration': @@ -161,13 +157,84 @@ function extractDeclarationNames(decl: OxcDeclaration): string[] { default: return []; } -} +}; + +const extractNamedDeclaration = ( + node: OxcExportNamedDeclaration, + filePath: string, + source: string, +): ReadonlyArray => { + const isReExport = node.source !== null; + const reExportSource = node.source ? String(node.source.value) : undefined; + + if (node.declaration !== null) { + return pipe( + extractDeclarationNames(node.declaration), + Arr.map( + (name): ExportedSymbol => + ({ + name, + filePath, + line: lineFromOffset(source, node.start), + isDefault: false, + isReExport: false, + }) as ExportedSymbol, + ), + ); + } -function extractCjsExports( + return pipe( + node.specifiers, + Arr.map((spec): ExportedSymbol => { + const localName = (spec as { local?: OxcIdent }).local?.name; + const isRenamed = localName !== undefined && localName !== spec.exported.name; + return { + name: spec.exported.name, + filePath, + line: lineFromOffset(source, spec.start), + isDefault: false, + isReExport, + ...(reExportSource !== undefined ? { reExportSource } : {}), + ...(isRenamed ? { reExportLocalName: localName } : {}), + } as ExportedSymbol; + }), + ); +}; + +const extractDefaultDeclaration = ( + node: OxcExportDefaultDeclaration, + filePath: string, + source: string, +): ReadonlyArray => [ + { + name: 'default', + filePath, + line: lineFromOffset(source, node.start), + isDefault: true, + isReExport: false, + } as ExportedSymbol, +]; + +const extractAllDeclaration = ( + node: OxcExportAllDeclaration, + filePath: string, + source: string, +): ReadonlyArray => [ + { + name: node.exported ? node.exported.name : '*', + filePath, + line: lineFromOffset(source, node.start), + isDefault: false, + isReExport: true, + reExportSource: String(node.source.value), + } as ExportedSymbol, +]; + +const extractCjsExports = ( node: OxcExpressionStatement, filePath: string, source: string, -): ExportedSymbol[] { +): ReadonlyArray => { const expr = node.expression; if (expr.type !== 'AssignmentExpression') return []; @@ -194,7 +261,7 @@ function extractCjsExports( ]; } - // module.exports.foo = ... (nested MemberExpression) + // module.exports.foo = ... (nested MemberExpression) if (member.object.type === 'MemberExpression') { const inner = member.object as OxcMemberExpression; const innerObj = @@ -218,25 +285,47 @@ function extractCjsExports( const right = assign.right; if (right.type !== 'ObjectExpression') return []; const obj = right as OxcObjectExpression; - return obj.properties.flatMap((prop) => { - if (prop.type === 'Property' && prop.key.type === 'Identifier') { - const key = prop.key as OxcIdent; - return [ - { - name: key.name, - filePath, - line: lineFromOffset(source, prop.start), - isDefault: false, - isReExport: false, - } as ExportedSymbol, - ]; - } - return []; - }); + return pipe( + obj.properties, + Arr.flatMap((prop): ReadonlyArray => { + if (prop.type === 'Property' && prop.key.type === 'Identifier') { + const key = prop.key as OxcIdent; + return [ + { + name: key.name, + filePath, + line: lineFromOffset(source, prop.start), + isDefault: false, + isReExport: false, + } as ExportedSymbol, + ]; + } + return []; + }), + ); } return []; -} +}; + +const extractExportsFromNode = ( + node: OxcBodyNode, + filePath: string, + source: string, +): ReadonlyArray => { + switch (node.type) { + case 'ExportNamedDeclaration': + return extractNamedDeclaration(node as OxcExportNamedDeclaration, filePath, source); + case 'ExportDefaultDeclaration': + return extractDefaultDeclaration(node as OxcExportDefaultDeclaration, filePath, source); + case 'ExportAllDeclaration': + return extractAllDeclaration(node as OxcExportAllDeclaration, filePath, source); + case 'ExpressionStatement': + return extractCjsExports(node as OxcExpressionStatement, filePath, source); + default: + return []; + } +}; // ─── Service interface ──────────────────────────────────────────────────────── @@ -267,85 +356,11 @@ const parseSource = ( } const program = result.program as unknown as OxcProgram; - const symbols: ExportedSymbol[] = []; - - for (const node of program.body) { - switch (node.type) { - case 'ExportNamedDeclaration': { - const n = node as OxcExportNamedDeclaration; - const isReExport = n.source !== null; - const reExportSource = n.source ? String(n.source.value) : undefined; - - if (n.declaration !== null) { - // export const foo = ..., export function bar() {}, etc. - const names = extractDeclarationNames(n.declaration); - for (const name of names) { - symbols.push({ - name, - filePath, - line: lineFromOffset(source, n.start), - isDefault: false, - isReExport: false, - } as ExportedSymbol); - } - } else { - // export { foo, bar } or export { foo } from './other' - for (const spec of n.specifiers) { - const localName = (spec as { local?: OxcIdent }).local?.name; - const isRenamed = localName !== undefined && localName !== spec.exported.name; - symbols.push({ - name: spec.exported.name, - filePath, - line: lineFromOffset(source, spec.start), - isDefault: false, - isReExport, - ...(reExportSource !== undefined ? { reExportSource } : {}), - ...(isRenamed ? { reExportLocalName: localName } : {}), - } as ExportedSymbol); - } - } - break; - } - - case 'ExportDefaultDeclaration': { - const n = node as OxcExportDefaultDeclaration; - symbols.push({ - name: 'default', - filePath, - line: lineFromOffset(source, n.start), - isDefault: true, - isReExport: false, - } as ExportedSymbol); - break; - } - - case 'ExportAllDeclaration': { - const n = node as OxcExportAllDeclaration; - const name = n.exported ? n.exported.name : '*'; - symbols.push({ - name, - filePath, - line: lineFromOffset(source, n.start), - isDefault: false, - isReExport: true, - reExportSource: String(n.source.value), - } as ExportedSymbol); - break; - } - - case 'ExpressionStatement': { - const n = node as OxcExpressionStatement; - const cjsExports = extractCjsExports(n, filePath, source); - symbols.push(...cjsExports); - break; - } - - default: - break; - } - } - return symbols; + return pipe( + program.body, + Arr.flatMap((node) => extractExportsFromNode(node, filePath, source)), + ); }, catch: (e) => new ParseError({ diff --git a/packages/dead-export-finder/src/lib/file-scanner.ts b/packages/dead-export-finder/src/lib/file-scanner.ts index 2dc64e9..2eef326 100644 --- a/packages/dead-export-finder/src/lib/file-scanner.ts +++ b/packages/dead-export-finder/src/lib/file-scanner.ts @@ -1,4 +1,4 @@ -import { Context, Data, Effect, Layer } from 'effect'; +import { Context, Data, Effect, Layer, Array as Arr, pipe } from 'effect'; import { FileSystem, Path } from '@effect/platform'; import fg from 'fast-glob'; import ignore from 'ignore'; @@ -9,6 +9,69 @@ class GlobError extends Data.TaggedError('GlobError')<{ readonly cause: unknown; }> {} +// ─── Pure helpers ──────────────────────────────────────────────────────────── + +const loadGitignorePatterns = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + root: string, +): Effect.Effect> => { + const gitignorePath = pathSvc.join(root, '.gitignore'); + return pipe( + fs.exists(gitignorePath), + Effect.orElseSucceed(() => false), + Effect.flatMap((exists) => + exists + ? pipe( + fs.readFileString(gitignorePath, 'utf-8'), + Effect.orElseSucceed(() => ''), + Effect.map((content) => content.split('\n')), + ) + : Effect.succeed([] as ReadonlyArray), + ), + ); +}; + +const buildIgnorePatterns = ( + gitignorePatterns: ReadonlyArray, + customGlobs: readonly string[], +): ReadonlyArray => + pipe( + ['node_modules'] as ReadonlyArray, + Arr.appendAll(gitignorePatterns), + Arr.appendAll(customGlobs), + ); + +const discoverFiles = (root: string): Effect.Effect, GlobError> => + Effect.tryPromise({ + try: () => + fg('**/*.{ts,tsx,js,jsx,mjs,cjs}', { + cwd: root, + absolute: true, + onlyFiles: true, + ignore: ['**/node_modules/**'], + }), + catch: (cause) => new GlobError({ cause }), + }); + +const filterWithIgnore = ( + files: ReadonlyArray, + patterns: ReadonlyArray, + pathSvc: Path.Path, + root: string, +): ReadonlyArray => { + const ig = ignore(); + ig.add([...patterns]); + + return pipe( + files, + Arr.filter((absPath) => { + const rel = pathSvc.relative(root, absPath); + return !ig.ignores(rel); + }), + ); +}; + // ─── Service interface ──────────────────────────────────────────────────────── export interface FileScannerShape { @@ -28,57 +91,22 @@ export const FileScannerLive = Layer.effect( FileScanner, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + const pathSvc = yield* Path.Path; const scan = ( root: string, ignoreGlobs: readonly string[], ): Effect.Effect => - Effect.gen(function* () { - // Build the ignore filter instance - const ig = ignore(); - - // Always ignore node_modules (handled by fast-glob ignore option too, - // but also add it here to be safe with the filter step) - ig.add('node_modules'); - - // Load .gitignore if present - const gitignorePath = path.join(root, '.gitignore'); - const hasGitignore = yield* fs - .exists(gitignorePath) - .pipe(Effect.orElseSucceed(() => false)); - if (hasGitignore) { - const gitignoreContent = yield* fs - .readFileString(gitignorePath, 'utf-8') - .pipe(Effect.orElseSucceed(() => '')); - ig.add(gitignoreContent); - } - - // Add custom ignore globs - if (ignoreGlobs.length > 0) { - ig.add([...ignoreGlobs]); - } - - // Use fast-glob to discover source files - const absoluteFiles = yield* Effect.tryPromise({ - try: () => - fg('**/*.{ts,tsx,js,jsx,mjs,cjs}', { - cwd: root, - absolute: true, - onlyFiles: true, - ignore: ['**/node_modules/**'], - }), - catch: (cause) => new GlobError({ cause }), - }); - - // Filter through the ignore instance using relative paths - const filtered = absoluteFiles.filter((absPath) => { - const rel = path.relative(root, absPath); - return !ig.ignores(rel); - }); - - return filtered; - }); + pipe( + loadGitignorePatterns(fs, pathSvc, root), + Effect.map((gitignorePatterns) => buildIgnorePatterns(gitignorePatterns, ignoreGlobs)), + Effect.flatMap((patterns) => + pipe( + discoverFiles(root), + Effect.map((files) => filterWithIgnore(files, patterns, pathSvc, root)), + ), + ), + ); return { scan }; }), diff --git a/packages/dead-export-finder/src/lib/import-parser.ts b/packages/dead-export-finder/src/lib/import-parser.ts index 42a5912..12db85d 100644 --- a/packages/dead-export-finder/src/lib/import-parser.ts +++ b/packages/dead-export-finder/src/lib/import-parser.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer } from 'effect'; +import { Context, Effect, Layer, Array as Arr, pipe } from 'effect'; import oxc from 'oxc-parser'; import type { ImportedSymbol } from './schemas.js'; import { ParseError } from './errors.js'; @@ -55,63 +55,137 @@ interface OxcProgram { body: OxcNode[]; } -// ─── AST walker ─────────────────────────────────────────────────────────────── +// ─── Pure extractors ───────────────────────────────────────────────────────── -function walkNode(node: OxcNode, filePath: string, symbols: ImportedSymbol[]): void { - if (node === null || typeof node !== 'object') return; +const extractSpecifier = ( + spec: OxcImportSpecifierKind, + filePath: string, + importSource: string, +): ReadonlyArray => { + switch (spec.type) { + case 'ImportSpecifier': { + const s = spec as OxcImportSpecifier; + return [ + { + name: s.imported.name, + filePath, + source: importSource, + isNamespace: false, + isDynamic: false, + } as ImportedSymbol, + ]; + } + case 'ImportDefaultSpecifier': + return [ + { + name: 'default', + filePath, + source: importSource, + isNamespace: false, + isDynamic: false, + } as ImportedSymbol, + ]; + case 'ImportNamespaceSpecifier': + return [ + { + name: '*', + filePath, + source: importSource, + isNamespace: true, + isDynamic: false, + } as ImportedSymbol, + ]; + default: + return []; + } +}; + +const extractStaticImports = (node: OxcNode, filePath: string): ReadonlyArray => { + if (node.type !== 'ImportDeclaration') return []; + const n = node as unknown as OxcImportDeclaration; + if (n.specifiers.length === 0) return []; + + return pipe( + n.specifiers, + Arr.flatMap((spec) => extractSpecifier(spec, filePath, n.source.value)), + ); +}; + +const collectSymbols = (node: OxcNode, filePath: string): ReadonlyArray => { + if (node === null || typeof node !== 'object') return []; if (node.type === 'ImportExpression') { const n = node as unknown as OxcImportExpression; if (n.source.type === 'Literal') { const lit = n.source as OxcStringLiteral; - symbols.push({ - name: '*', - filePath, - source: lit.value, - isNamespace: false, - isDynamic: true, - } as ImportedSymbol); + return [ + { + name: '*', + filePath, + source: lit.value, + isNamespace: false, + isDynamic: true, + } as ImportedSymbol, + ]; } - // variable source — skip (can't statically resolve) - return; + return []; } - if (node.type === 'CallExpression') { - const n = node as unknown as OxcCallExpression; - const callee = n.callee; - if (callee.type === 'Identifier' && callee.name === 'require') { - const arg = n.arguments[0]; - if (arg !== undefined && arg.type === 'Literal') { - const lit = arg as OxcStringLiteral; - symbols.push({ + const currentSymbols: ReadonlyArray = + node.type === 'CallExpression' + ? extractRequireCall(node as unknown as OxcCallExpression, filePath) + : []; + + const childSymbols = pipe( + Object.keys(node), + Arr.flatMap((key): ReadonlyArray => { + const child = node[key]; + if (Array.isArray(child)) { + return pipe( + child, + Arr.filter( + (item): item is OxcNode => + item !== null && typeof item === 'object' && typeof item.type === 'string', + ), + Arr.flatMap((item) => collectSymbols(item, filePath)), + ); + } + if ( + child !== null && + typeof child === 'object' && + typeof (child as OxcNode).type === 'string' + ) { + return collectSymbols(child as OxcNode, filePath); + } + return []; + }), + ); + + return pipe(currentSymbols, Arr.appendAll(childSymbols)); +}; + +const extractRequireCall = ( + node: OxcCallExpression, + filePath: string, +): ReadonlyArray => { + const callee = node.callee; + if (callee.type === 'Identifier' && callee.name === 'require') { + const arg = node.arguments[0]; + if (arg !== undefined && arg.type === 'Literal') { + const lit = arg as OxcStringLiteral; + return [ + { name: '*', filePath, source: lit.value, isNamespace: true, isDynamic: false, - } as ImportedSymbol); - } + } as ImportedSymbol, + ]; } } - - // Recurse into all object-valued properties - for (const key of Object.keys(node)) { - const child = node[key]; - if (Array.isArray(child)) { - for (const item of child) { - if (item !== null && typeof item === 'object' && typeof item.type === 'string') { - walkNode(item as OxcNode, filePath, symbols); - } - } - } else if ( - child !== null && - typeof child === 'object' && - typeof (child as OxcNode).type === 'string' - ) { - walkNode(child as OxcNode, filePath, symbols); - } - } -} + return []; +}; // ─── Service interface ──────────────────────────────────────────────────────── @@ -142,61 +216,18 @@ const parseSource = ( } const program = result.program as unknown as OxcProgram; - const symbols: ImportedSymbol[] = []; - - // Walk top-level body for static ImportDeclarations - for (const node of program.body) { - if (node.type === 'ImportDeclaration') { - const n = node as unknown as OxcImportDeclaration; - const importSource = n.source.value; - - // Skip side-effect-only imports (no specifiers) - if (n.specifiers.length === 0) continue; - - for (const spec of n.specifiers) { - switch (spec.type) { - case 'ImportSpecifier': { - const s = spec as OxcImportSpecifier; - symbols.push({ - name: s.imported.name, - filePath, - source: importSource, - isNamespace: false, - isDynamic: false, - } as ImportedSymbol); - break; - } - case 'ImportDefaultSpecifier': { - symbols.push({ - name: 'default', - filePath, - source: importSource, - isNamespace: false, - isDynamic: false, - } as ImportedSymbol); - break; - } - case 'ImportNamespaceSpecifier': { - symbols.push({ - name: '*', - filePath, - source: importSource, - isNamespace: true, - isDynamic: false, - } as ImportedSymbol); - break; - } - } - } - } - } - // Walk entire AST for dynamic imports and require() calls - for (const node of program.body) { - walkNode(node as OxcNode, filePath, symbols); - } + const staticImports = pipe( + program.body, + Arr.flatMap((node) => extractStaticImports(node as OxcNode, filePath)), + ); + + const dynamicImports = pipe( + program.body, + Arr.flatMap((node) => collectSymbols(node as OxcNode, filePath)), + ); - return symbols; + return pipe(staticImports, Arr.appendAll(dynamicImports)); }, catch: (e) => new ParseError({ diff --git a/packages/dead-export-finder/src/lib/reporter.ts b/packages/dead-export-finder/src/lib/reporter.ts index 91dc2ee..ad7d140 100644 --- a/packages/dead-export-finder/src/lib/reporter.ts +++ b/packages/dead-export-finder/src/lib/reporter.ts @@ -1,87 +1,107 @@ -import { Context, Layer } from 'effect'; +import { Context, Layer, Array as Arr, Order, pipe } from 'effect'; import path from 'node:path'; -import type { AnalysisResult } from './schemas.js'; - -// ─── Service interface ──────────────────────────────────────────────────────── - -export interface ReporterShape { - readonly format: (result: AnalysisResult, packageRoots: ReadonlyMap) => string; -} - -// ─── Tag ───────────────────────────────────────────────────────────────────── - -export class Reporter extends Context.Tag('Reporter')() {} - -// ─── Live implementation ────────────────────────────────────────────────────── - -function formatImpl(result: AnalysisResult, packageRoots: ReadonlyMap): string { +import type { AnalysisResult, DeadExport } from './schemas.js'; + +// ─── Pure formatting helpers ───────────────────────────────────────────────── + +const pluralize = (count: number, singular: string, plural: string): string => + count === 1 ? singular : plural; + +const formatFileSection = ( + filePath: string, + fileExports: ReadonlyArray, + pkgRoot: string, +): ReadonlyArray => { + const relPath = pkgRoot ? path.relative(pkgRoot, filePath) : filePath; + const sorted = pipe( + fileExports, + Arr.sort(Order.mapInput(Order.number, (d: DeadExport) => d.symbol.line)), + ); + + return [ + ` ${relPath}`, + ...pipe( + sorted, + Arr.map((dead) => ` :${String(dead.symbol.line).padEnd(4)} ${dead.symbol.name}`), + ), + ]; +}; + +const formatPackageSection = ( + pkgName: string, + pkgDeadExports: ReadonlyArray, + packageRoots: ReadonlyMap, +): ReadonlyArray => { + const pkgRoot = packageRoots.get(pkgName) ?? ''; + const count = pkgDeadExports.length; + const exportWord = pluralize(count, 'dead export', 'dead exports'); + + const byFile = pipe( + pkgDeadExports, + Arr.groupBy((dead) => dead.symbol.filePath), + ); + + const sortedFiles = pipe(Object.keys(byFile), Arr.sort(Order.string)); + + return [ + `${pkgName} (${count} ${exportWord})`, + ...pipe( + sortedFiles, + Arr.flatMap((filePath) => formatFileSection(filePath, byFile[filePath]!, pkgRoot)), + ), + '', + ]; +}; + +const formatReport = ( + result: AnalysisResult, + packageRoots: ReadonlyMap, +): string => { const { deadExports, totalExports, totalFiles } = result; if (deadExports.length === 0) { return `No dead exports found. Scanned ${totalExports} exports across ${totalFiles} files.`; } - // Group by package name - const byPackage = new Map(); - for (const dead of deadExports) { - const list = byPackage.get(dead.packageName) ?? []; - byPackage.set(dead.packageName, [...list, dead]); - } - - const sortedPackages = [...byPackage.keys()].sort(); + const byPackage = pipe( + deadExports, + Arr.groupBy((dead) => dead.packageName), + ); - const lines: string[] = []; + const sortedPackages = pipe(Object.keys(byPackage), Arr.sort(Order.string)); - // Header - lines.push('Dead Export Report'); - lines.push('══════════════════'); - lines.push(''); + const header: ReadonlyArray = ['Dead Export Report', '══════════════════', '']; - for (const pkgName of sortedPackages) { - const pkgDeadExports = byPackage.get(pkgName)!; - const pkgRoot = packageRoots.get(pkgName) ?? ''; + const packageSections = pipe( + sortedPackages, + Arr.flatMap((pkgName) => formatPackageSection(pkgName, byPackage[pkgName]!, packageRoots)), + ); - const count = pkgDeadExports.length; - const exportWord = count === 1 ? 'dead export' : 'dead exports'; - lines.push(`${pkgName} (${count} ${exportWord})`); + const totalDead = deadExports.length; + const pkgCount = sortedPackages.length; + const deadWord = pluralize(totalDead, 'dead export', 'dead exports'); + const pkgWord = pluralize(pkgCount, 'package', 'packages'); - // Group by file path within package - const byFile = new Map(); - for (const dead of pkgDeadExports) { - const filePath = dead.symbol.filePath; - const list = byFile.get(filePath) ?? []; - byFile.set(filePath, [...list, dead]); - } + const summary: ReadonlyArray = [ + '────────────────────────────', + `Summary: ${totalDead} ${deadWord} across ${pkgCount} ${pkgWord}`, + ]; - const sortedFiles = [...byFile.keys()].sort(); + return pipe(header, Arr.appendAll(packageSections), Arr.appendAll(summary), Arr.join('\n')); +}; - for (const filePath of sortedFiles) { - const fileExports = byFile.get(filePath)!; - const relPath = pkgRoot ? path.relative(pkgRoot, filePath) : filePath; - lines.push(` ${relPath}`); +// ─── Service interface ──────────────────────────────────────────────────────── - // Sort by line number - const sorted = [...fileExports].sort((a, b) => a.symbol.line - b.symbol.line); - for (const dead of sorted) { - const lineNum = String(dead.symbol.line); - lines.push(` :${lineNum.padEnd(4)} ${dead.symbol.name}`); - } - } +export interface ReporterShape { + readonly format: (result: AnalysisResult, packageRoots: ReadonlyMap) => string; +} - lines.push(''); - } +// ─── Tag ───────────────────────────────────────────────────────────────────── - // Summary - const totalDead = deadExports.length; - const pkgCount = sortedPackages.length; - const deadWord = totalDead === 1 ? 'dead export' : 'dead exports'; - const pkgWord = pkgCount === 1 ? 'package' : 'packages'; - lines.push('────────────────────────────'); - lines.push(`Summary: ${totalDead} ${deadWord} across ${pkgCount} ${pkgWord}`); +export class Reporter extends Context.Tag('Reporter')() {} - return lines.join('\n'); -} +// ─── Live implementation ────────────────────────────────────────────────────── export const ReporterLive = Layer.succeed(Reporter, { - format: formatImpl, + format: formatReport, }); diff --git a/packages/dead-export-finder/src/lib/workspace-detector.ts b/packages/dead-export-finder/src/lib/workspace-detector.ts index dd1c827..f5ff3eb 100644 --- a/packages/dead-export-finder/src/lib/workspace-detector.ts +++ b/packages/dead-export-finder/src/lib/workspace-detector.ts @@ -1,4 +1,4 @@ -import { Context, Data, Effect, Layer } from 'effect'; +import { Context, Data, Effect, Layer, Array as Arr, Option, pipe } from 'effect'; import { FileSystem, Path } from '@effect/platform'; import fg from 'fast-glob'; import YAML from 'yaml'; @@ -32,64 +32,58 @@ class GlobError extends Data.TaggedError('GlobError')<{ readonly cause: unknown; }> {} -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Pure helpers ──────────────────────────────────────────────────────────── -/** - * Walk an exports field value and collect all string leaf values. - * Handles: string, string[], nested condition/subpath objects. - */ -function collectExportsStrings(value: unknown): string[] { +const collectExportsStrings = (value: unknown): ReadonlyArray => { if (typeof value === 'string') return [value]; - if (Array.isArray(value)) return value.flatMap(collectExportsStrings); + if (Array.isArray(value)) return pipe(value, Arr.flatMap(collectExportsStrings)); if (value !== null && typeof value === 'object') { - return Object.values(value as Record).flatMap(collectExportsStrings); + return pipe( + Object.values(value as Record), + Arr.flatMap(collectExportsStrings), + ); } return []; -} +}; -/** - * Extract entry points from a parsed package.json object. - * Prefers `exports` field (all string leaves), falls back to main/module/types. - */ -function extractEntryPoints(pkg: Record): string[] { +const extractEntryPoints = (pkg: Record): ReadonlyArray => { if (pkg['exports'] !== undefined) { - const collected = collectExportsStrings(pkg['exports']); - return [...new Set(collected)]; + return pipe(collectExportsStrings(pkg['exports']), Arr.dedupe); } - const fallbacks: string[] = []; - for (const field of ['main', 'module', 'types'] as const) { - const val = pkg[field]; - if (typeof val === 'string') fallbacks.push(val); - } - return [...new Set(fallbacks)]; -} + return pipe( + ['main', 'module', 'types'] as const, + Arr.filterMap((field) => { + const val = pkg[field]; + return typeof val === 'string' ? Option.some(val) : Option.none(); + }), + Arr.dedupe, + ); +}; -/** - * Read and parse a package.json, returning a PackageInfo. - * Absorbs all errors and returns null when the file is missing or unparseable. - */ const readPackageInfo = ( fs: FileSystem.FileSystem, - path: Path.Path, + pathSvc: Path.Path, pkgDir: string, ): Effect.Effect => { - const pkgPath = path.join(pkgDir, 'package.json'); + const pkgPath = pathSvc.join(pkgDir, 'package.json'); - return fs.exists(pkgPath).pipe( + return pipe( + fs.exists(pkgPath), Effect.flatMap((exists) => { if (!exists) return Effect.succeed(null); - return fs.readFileString(pkgPath, 'utf-8').pipe( - Effect.map((contents) => { - let parsed: Record; + return pipe( + fs.readFileString(pkgPath, 'utf-8'), + Effect.map((contents): PackageInfo | null => { try { - parsed = JSON.parse(contents) as Record; + const parsed = JSON.parse(contents) as Record; + const name = + typeof parsed['name'] === 'string' ? parsed['name'] : pathSvc.basename(pkgDir); + const entryPoints = extractEntryPoints(parsed); + return { name, root: pkgDir, entryPoints: [...entryPoints] } as PackageInfo; } catch { return null; } - const name = typeof parsed['name'] === 'string' ? parsed['name'] : path.basename(pkgDir); - const entryPoints = extractEntryPoints(parsed); - return { name, root: pkgDir, entryPoints } as PackageInfo; }), Effect.catchAll(() => Effect.succeed(null)), ); @@ -98,23 +92,197 @@ const readPackageInfo = ( ); }; -/** - * Resolve workspace glob patterns (e.g. "packages/*") relative to root, - * then return all directories containing a package.json. - */ const resolveWorkspaceGlobs = ( - path: Path.Path, + pathSvc: Path.Path, + root: string, + globs: ReadonlyArray, +): Effect.Effect, GlobError> => + pipe( + Effect.tryPromise({ + try: () => + fg( + pipe( + globs, + Arr.map((g) => `${g}/package.json`), + ), + { cwd: root, absolute: true, onlyFiles: true }, + ), + catch: (cause) => new GlobError({ cause }), + }), + Effect.map((files) => + pipe( + files, + Arr.map((p) => pathSvc.dirname(p)), + ), + ), + ); + +const readPkgDirs = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, root: string, - globs: string[], -): Effect.Effect => - Effect.tryPromise({ - try: () => - fg( - globs.map((g) => `${g}/package.json`), - { cwd: root, absolute: true, onlyFiles: true }, + globs: ReadonlyArray, +): Effect.Effect => + pipe( + resolveWorkspaceGlobs(pathSvc, root, globs), + Effect.flatMap((dirs) => + Effect.all( + pipe( + dirs, + Arr.map((d) => readPackageInfo(fs, pathSvc, d)), + ), + ), + ), + Effect.map((infos) => + pipe( + infos, + Arr.filter((p): p is PackageInfo => p !== null), ), - catch: (cause) => new GlobError({ cause }), - }).pipe(Effect.map((files) => files.map((p) => path.dirname(p)))); + ), + Effect.catchTag('GlobError', () => Effect.succeed([] as PackageInfo[])), + ); + +// ─── Workspace detection strategies ───────────────────────────────────────── + +const extractWorkspaceGlobs = (workspaces: unknown): ReadonlyArray => { + if (Array.isArray(workspaces)) { + return pipe( + workspaces, + Arr.filter((g): g is string => typeof g === 'string'), + ); + } + if (typeof workspaces === 'object' && workspaces !== null) { + const obj = workspaces as { packages?: unknown }; + if (Array.isArray(obj.packages)) { + return pipe( + obj.packages, + Arr.filter((g): g is string => typeof g === 'string'), + ); + } + } + return []; +}; + +const detectPnpm = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + cwd: string, +): Effect.Effect => + pipe( + fs.exists(pathSvc.join(cwd, 'pnpm-workspace.yaml')), + Effect.orDie, + Effect.flatMap((exists) => + exists + ? pipe( + fs.readFileString(pathSvc.join(cwd, 'pnpm-workspace.yaml'), 'utf-8'), + Effect.orDie, + Effect.map((raw) => { + const parsed = YAML.parse(raw) as { packages?: string[] } | null; + return parsed?.packages ?? []; + }), + Effect.flatMap((globs) => readPkgDirs(fs, pathSvc, cwd, globs)), + Effect.map( + (packages): WorkspaceResult => ({ + type: 'pnpm' as WorkspaceType, + root: cwd, + packages, + }), + ), + ) + : Effect.fail(new WorkspaceNotFoundError({ cwd })), + ), + ); + +const detectNpmWorkspaces = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + cwd: string, + rootPkg: Record, +): Effect.Effect => { + const globs = extractWorkspaceGlobs(rootPkg['workspaces']); + return globs.length > 0 + ? pipe( + readPkgDirs(fs, pathSvc, cwd, globs), + Effect.map( + (packages): WorkspaceResult => ({ + type: 'npm' as WorkspaceType, + root: cwd, + packages, + }), + ), + ) + : Effect.fail(new WorkspaceNotFoundError({ cwd })); +}; + +const detectNx = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + cwd: string, +): Effect.Effect => + pipe( + fs.exists(pathSvc.join(cwd, 'nx.json')), + Effect.orDie, + Effect.flatMap((exists) => + exists + ? pipe( + readPkgDirs(fs, pathSvc, cwd, ['packages/*', 'libs/*', 'apps/*']), + Effect.flatMap((packages) => + packages.length > 0 + ? Effect.succeed({ + type: 'nx' as WorkspaceType, + root: cwd, + packages, + } as WorkspaceResult) + : Effect.fail(new WorkspaceNotFoundError({ cwd })), + ), + ) + : Effect.fail(new WorkspaceNotFoundError({ cwd })), + ), + ); + +const detectTurbo = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + cwd: string, +): Effect.Effect => + pipe( + fs.exists(pathSvc.join(cwd, 'turbo.json')), + Effect.orDie, + Effect.flatMap((exists) => + exists + ? pipe( + readPkgDirs(fs, pathSvc, cwd, ['packages/*', 'apps/*']), + Effect.flatMap((packages) => + packages.length > 0 + ? Effect.succeed({ + type: 'turborepo' as WorkspaceType, + root: cwd, + packages, + } as WorkspaceResult) + : Effect.fail(new WorkspaceNotFoundError({ cwd })), + ), + ) + : Effect.fail(new WorkspaceNotFoundError({ cwd })), + ), + ); + +const detectSingle = ( + fs: FileSystem.FileSystem, + pathSvc: Path.Path, + cwd: string, +): Effect.Effect => + pipe( + readPackageInfo(fs, pathSvc, cwd), + Effect.flatMap((pkg) => + pkg !== null + ? Effect.succeed({ + type: 'single' as WorkspaceType, + root: cwd, + packages: [pkg], + } as WorkspaceResult) + : Effect.fail(new WorkspaceNotFoundError({ cwd })), + ), + ); // ─── Live implementation ────────────────────────────────────────────────────── @@ -122,87 +290,40 @@ export const WorkspaceDetectorLive = Layer.effect( WorkspaceDetector, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const readPkgDirs = (root: string, globs: string[]): Effect.Effect => - resolveWorkspaceGlobs(path, root, globs).pipe( - Effect.flatMap((dirs) => Effect.all(dirs.map((d) => readPackageInfo(fs, path, d)))), - Effect.map((infos) => infos.filter((p): p is PackageInfo => p !== null)), - Effect.catchTag('GlobError', () => Effect.succeed([] as PackageInfo[])), - ); + const pathSvc = yield* Path.Path; const detect = (cwd: string): Effect.Effect => - Effect.gen(function* () { - // 1. Check for pnpm-workspace.yaml - const pnpmYamlPath = path.join(cwd, 'pnpm-workspace.yaml'); - const hasPnpmYaml = yield* fs.exists(pnpmYamlPath).pipe(Effect.orDie); - - if (hasPnpmYaml) { - const raw = yield* fs.readFileString(pnpmYamlPath, 'utf-8').pipe(Effect.orDie); - const parsed = YAML.parse(raw) as { packages?: string[] } | null; - const globs = parsed?.packages ?? []; - const packages = yield* readPkgDirs(cwd, globs); - return { type: 'pnpm' as WorkspaceType, root: cwd, packages }; - } - - // 2. Check for package.json with workspaces field - const rootPkgPath = path.join(cwd, 'package.json'); - const hasRootPkg = yield* fs.exists(rootPkgPath).pipe(Effect.orDie); - - if (hasRootPkg) { - const raw = yield* fs.readFileString(rootPkgPath, 'utf-8').pipe(Effect.orDie); - const rootPkg = yield* Effect.try({ - try: () => JSON.parse(raw) as Record, - catch: () => new WorkspaceNotFoundError({ cwd }), - }); - - // npm / yarn workspaces - const workspaces = rootPkg['workspaces']; - let globs: string[] = []; - if (Array.isArray(workspaces)) { - globs = workspaces.filter((g): g is string => typeof g === 'string'); - } else if (typeof workspaces === 'object' && workspaces !== null) { - // Yarn classic: workspaces: { packages: ["packages/*"] } - const obj = workspaces as { packages?: unknown }; - if (Array.isArray(obj.packages)) { - globs = obj.packages.filter((g): g is string => typeof g === 'string'); - } - } - if (globs.length > 0) { - const packages = yield* readPkgDirs(cwd, globs); - return { type: 'npm' as WorkspaceType, root: cwd, packages }; - } - - // 3. Check for nx.json - const nxJsonPath = path.join(cwd, 'nx.json'); - const hasNxJson = yield* fs.exists(nxJsonPath).pipe(Effect.orDie); - if (hasNxJson) { - const packages = yield* readPkgDirs(cwd, ['packages/*', 'libs/*', 'apps/*']); - if (packages.length > 0) { - return { type: 'nx' as WorkspaceType, root: cwd, packages }; - } - } - - // 4. Check for turbo.json - const turboJsonPath = path.join(cwd, 'turbo.json'); - const hasTurboJson = yield* fs.exists(turboJsonPath).pipe(Effect.orDie); - if (hasTurboJson) { - const packages = yield* readPkgDirs(cwd, ['packages/*', 'apps/*']); - if (packages.length > 0) { - return { type: 'turborepo' as WorkspaceType, root: cwd, packages }; - } - } + pipe( + detectPnpm(fs, pathSvc, cwd), + Effect.catchTag('WorkspaceNotFoundError', () => + pipe( + fs.exists(pathSvc.join(cwd, 'package.json')), + Effect.orDie, + Effect.flatMap((hasRootPkg) => { + if (!hasRootPkg) return Effect.fail(new WorkspaceNotFoundError({ cwd })); - // 5. Single package fallback - const singlePkg = yield* readPackageInfo(fs, path, cwd); - if (singlePkg !== null) { - return { type: 'single' as WorkspaceType, root: cwd, packages: [singlePkg] }; - } - } - - // 6. No package.json at cwd — workspace cannot be determined - return yield* new WorkspaceNotFoundError({ cwd }); - }); + return pipe( + fs.readFileString(pathSvc.join(cwd, 'package.json'), 'utf-8'), + Effect.orDie, + Effect.flatMap((raw) => + Effect.try({ + try: () => JSON.parse(raw) as Record, + catch: () => new WorkspaceNotFoundError({ cwd }), + }), + ), + Effect.flatMap((rootPkg) => + pipe( + detectNpmWorkspaces(fs, pathSvc, cwd, rootPkg), + Effect.catchTag('WorkspaceNotFoundError', () => detectNx(fs, pathSvc, cwd)), + Effect.catchTag('WorkspaceNotFoundError', () => detectTurbo(fs, pathSvc, cwd)), + Effect.catchTag('WorkspaceNotFoundError', () => detectSingle(fs, pathSvc, cwd)), + ), + ), + ); + }), + ), + ), + ); return { detect }; }),