From 4cce9639ff109478d77269699ffc3747f2683c3c Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 19:35:13 -0600 Subject: [PATCH 1/4] fix(dead-export-finder): address review findings and restore files - Trace re-export chains across ALL files, not just entry points (fixes multi-hop false positives) - Guard against package specifier re-exports in graph analysis - Always surface parse warnings regardless of --verbose flag - Replace silent Effect.orElseSucceed with proper error handling that skips unreadable files and logs warnings - Show warning summary in non-verbose mode when issues found - Add tests: multi-hop chains, star re-exports, package re-exports, subpath entry points, cross-package imports - 39 tests passing across 7 test files Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/dead-export-finder/package.json | 48 +++ packages/dead-export-finder/src/index.ts | 219 ++++++++++++ packages/dead-export-finder/src/lib/errors.ts | 15 + .../src/lib/export-graph.test.ts | 246 +++++++++++++ .../src/lib/export-graph.ts | 185 ++++++++++ .../src/lib/export-parser.test.ts | 114 ++++++ .../src/lib/export-parser.ts | 336 ++++++++++++++++++ .../src/lib/file-scanner.test.ts | 136 +++++++ .../src/lib/file-scanner.ts | 85 +++++ .../src/lib/import-parser.test.ts | 98 +++++ .../src/lib/import-parser.ts | 210 +++++++++++ .../src/lib/integration.test.ts | 155 ++++++++ .../src/lib/reporter.test.ts | 91 +++++ .../dead-export-finder/src/lib/reporter.ts | 87 +++++ .../dead-export-finder/src/lib/schemas.ts | 40 +++ .../src/lib/workspace-detector.test.ts | 242 +++++++++++++ .../src/lib/workspace-detector.ts | 203 +++++++++++ packages/dead-export-finder/src/test-setup.ts | 2 + packages/dead-export-finder/tsconfig.json | 6 + packages/dead-export-finder/tsconfig.lib.json | 22 ++ .../dead-export-finder/tsconfig.spec.json | 18 + packages/dead-export-finder/vitest.config.mts | 19 + 22 files changed, 2577 insertions(+) create mode 100644 packages/dead-export-finder/package.json create mode 100644 packages/dead-export-finder/src/index.ts create mode 100644 packages/dead-export-finder/src/lib/errors.ts create mode 100644 packages/dead-export-finder/src/lib/export-graph.test.ts create mode 100644 packages/dead-export-finder/src/lib/export-graph.ts create mode 100644 packages/dead-export-finder/src/lib/export-parser.test.ts create mode 100644 packages/dead-export-finder/src/lib/export-parser.ts create mode 100644 packages/dead-export-finder/src/lib/file-scanner.test.ts create mode 100644 packages/dead-export-finder/src/lib/file-scanner.ts create mode 100644 packages/dead-export-finder/src/lib/import-parser.test.ts create mode 100644 packages/dead-export-finder/src/lib/import-parser.ts create mode 100644 packages/dead-export-finder/src/lib/integration.test.ts create mode 100644 packages/dead-export-finder/src/lib/reporter.test.ts create mode 100644 packages/dead-export-finder/src/lib/reporter.ts create mode 100644 packages/dead-export-finder/src/lib/schemas.ts create mode 100644 packages/dead-export-finder/src/lib/workspace-detector.test.ts create mode 100644 packages/dead-export-finder/src/lib/workspace-detector.ts create mode 100644 packages/dead-export-finder/src/test-setup.ts create mode 100644 packages/dead-export-finder/tsconfig.json create mode 100644 packages/dead-export-finder/tsconfig.lib.json create mode 100644 packages/dead-export-finder/tsconfig.spec.json create mode 100644 packages/dead-export-finder/vitest.config.mts diff --git a/packages/dead-export-finder/package.json b/packages/dead-export-finder/package.json new file mode 100644 index 0000000..cb3d6a8 --- /dev/null +++ b/packages/dead-export-finder/package.json @@ -0,0 +1,48 @@ +{ + "name": "@wolfcola/dead-export-finder", + "version": "0.0.0", + "type": "module", + "private": false, + "description": "Find dead exports across monorepo package boundaries", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "dead-export-finder": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!dist/*.tsbuildinfo" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.lib.json", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "effect": "catalog:effect", + "@effect/cli": "catalog:effect", + "@effect/platform": "catalog:effect", + "@effect/platform-node": "catalog:effect", + "oxc-parser": "^0.72.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.4", + "yaml": "^2.7.1" + }, + "devDependencies": { + "@effect/vitest": "catalog:effect", + "vitest": "catalog:vitest", + "vite": "catalog:vite", + "typescript": "5.8.3" + } +} diff --git a/packages/dead-export-finder/src/index.ts b/packages/dead-export-finder/src/index.ts new file mode 100644 index 0000000..8e49696 --- /dev/null +++ b/packages/dead-export-finder/src/index.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +// Public API +export { WorkspaceDetector, WorkspaceDetectorLive } from './lib/workspace-detector.js'; +export type { WorkspaceResult } from './lib/workspace-detector.js'; +export { FileScanner, FileScannerLive } from './lib/file-scanner.js'; +export { ExportParser, ExportParserLive } from './lib/export-parser.js'; +export { ImportParser, ImportParserLive } from './lib/import-parser.js'; +export { ExportGraph, ExportGraphLive } from './lib/export-graph.js'; +export { Reporter, ReporterLive } from './lib/reporter.js'; +export { WorkspaceNotFoundError, ParseError, EntryPointResolutionError } from './lib/errors.js'; +export type { + PackageInfo, + ExportedSymbol, + ImportedSymbol, + DeadExport, + AnalysisResult, +} from './lib/schemas.js'; + +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 { WorkspaceDetector, WorkspaceDetectorLive } 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'; + +// ─── Exit code error ────────────────────────────────────────────────────────── + +class ExitWithCode extends Data.TaggedError('ExitWithCode')<{ + readonly code: number; +}> {} + +// ─── Options ────────────────────────────────────────────────────────────────── + +const packages = Options.text('packages').pipe( + Options.withAlias('p'), + Options.withDescription('Scope analysis to specific package names (repeat for multiple).'), + Options.repeated, + Options.optional, +); + +const ignore = Options.text('ignore').pipe( + Options.withAlias('i'), + Options.withDescription('Glob patterns to exclude from scanning (repeat for multiple).'), + Options.repeated, + Options.optional, +); + +const verbose = Options.boolean('verbose').pipe( + Options.withAlias('v'), + Options.withDescription('Print verbose output including timing and parse warnings.'), + Options.withDefault(false), +); + +// ─── Layer ──────────────────────────────────────────────────────────────────── + +const AppLayer = Layer.mergeAll( + ExportParserLive, + ImportParserLive, + ExportGraphLive, + ReporterLive, + WorkspaceDetectorLive, + FileScannerLive, +).pipe(Layer.provideMerge(NodeContext.layer)); + +// ─── Command ────────────────────────────────────────────────────────────────── + +const command = Command.make( + 'dead-export-finder', + { packages, ignore, verbose }, + ({ packages: packagesOpt, ignore: ignoreOpt, verbose }) => + 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) { + 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; + + 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); + + 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; + + // 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); + } + } + } + + if (verbose) { + yield* Console.log( + `Scanned ${allExports.size} files with exports, ${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])); + + // Format and print report + const report = reporter.format(result, packageRoots); + yield* Console.log(report); + + // 7. Surface warnings (always, not just verbose) + const allWarnings = [...parseWarnings, ...result.warnings]; + if (allWarnings.length > 0 && !verbose) { + yield* Console.log( + `\nWarning: ${allWarnings.length} issue(s) during analysis — results may be incomplete. Run with --verbose for details.`, + ); + } + + // 8. Verbose timing + if (verbose) { + 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) { + return yield* new ExitWithCode({ code: 1 }); + } + }), +).pipe(Command.withDescription('Find dead exports across monorepo package boundaries.')); + +// ─── Runner ─────────────────────────────────────────────────────────────────── + +const cli = Command.run(command, { + name: 'Dead Export Finder', + version: '0.0.0', +}); + +cli(process.argv).pipe( + Effect.catchTag('ExitWithCode', (e) => Effect.sync(() => (process.exitCode = e.code))), + Effect.provide(AppLayer), + NodeRuntime.runMain, +); diff --git a/packages/dead-export-finder/src/lib/errors.ts b/packages/dead-export-finder/src/lib/errors.ts new file mode 100644 index 0000000..809de9a --- /dev/null +++ b/packages/dead-export-finder/src/lib/errors.ts @@ -0,0 +1,15 @@ +import { Data } from 'effect'; + +export class WorkspaceNotFoundError extends Data.TaggedError('WorkspaceNotFoundError')<{ + readonly cwd: string; +}> {} + +export class ParseError extends Data.TaggedError('ParseError')<{ + readonly filePath: string; + readonly message: string; +}> {} + +export class EntryPointResolutionError extends Data.TaggedError('EntryPointResolutionError')<{ + readonly packageName: string; + readonly entryPoint: string; +}> {} diff --git a/packages/dead-export-finder/src/lib/export-graph.test.ts b/packages/dead-export-finder/src/lib/export-graph.test.ts new file mode 100644 index 0000000..4ecc9ff --- /dev/null +++ b/packages/dead-export-finder/src/lib/export-graph.test.ts @@ -0,0 +1,246 @@ +import { it } from '@effect/vitest'; +import { expect } from 'vitest'; +import { Effect } from 'effect'; +import { ExportGraph, ExportGraphLive } from './export-graph.js'; +import type { PackageInfo, ExportedSymbol, ImportedSymbol } from './schemas.js'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const analyze = ( + packages: readonly PackageInfo[], + allExports: Map, + allImports: Map, +) => + Effect.gen(function* () { + const graph = yield* ExportGraph; + return yield* graph.analyze(packages, allExports, allImports); + }).pipe(Effect.provide(ExportGraphLive)); + +// ─── test data factories ─────────────────────────────────────────────────────── + +const makePackage = (name: string, root: string, entryPoints: string[]): PackageInfo => + ({ name, root, entryPoints }) as unknown as PackageInfo; + +const makeExport = ( + name: string, + filePath: string, + line = 1, + isDefault = false, + isReExport = false, + reExportSource?: string, +): ExportedSymbol => + ({ name, filePath, line, isDefault, isReExport, reExportSource }) as unknown as ExportedSymbol; + +const makeImport = ( + name: string, + filePath: string, + source: string, + isNamespace = false, + isDynamic = false, +): ImportedSymbol => + ({ name, filePath, source, isNamespace, isDynamic }) as unknown as ImportedSymbol; + +// ─── tests ──────────────────────────────────────────────────────────────────── + +it.effect('entry point exports are never flagged', () => + Effect.gen(function* () { + // Package has two files: index (entry point) and internal. + // Index exports `publicFn` but nobody imports it externally. + // Internal exports `helperFn` and nobody imports it. + // Only `helperFn` should be flagged; `publicFn` is sacred. + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + ['/test/utils/src/index.ts', [makeExport('publicFn', '/test/utils/src/index.ts')]], + ['/test/utils/src/internal.ts', [makeExport('helperFn', '/test/utils/src/internal.ts')]], + ]); + + const allImports = new Map(); + + const result = yield* analyze([pkg], allExports, allImports); + + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).not.toContain('publicFn'); + expect(deadNames).toContain('helperFn'); + }), +); + +it.effect('flags non-entry-point exports with no consumers', () => + Effect.gen(function* () { + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + ['/test/utils/src/index.ts', []], + ['/test/utils/src/internal.ts', [makeExport('helperFn', '/test/utils/src/internal.ts')]], + ]); + + const allImports = new Map(); + + const result = yield* analyze([pkg], allExports, allImports); + + expect(result.deadExports).toHaveLength(1); + expect(result.deadExports[0]?.symbol.name).toBe('helperFn'); + expect(result.deadExports[0]?.packageName).toBe('@test/utils'); + }), +); + +it.effect('does not flag exports consumed by other files via relative import', () => + Effect.gen(function* () { + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + ['/test/utils/src/index.ts', []], + ['/test/utils/src/internal.ts', [makeExport('helperFn', '/test/utils/src/internal.ts')]], + ]); + + // Index file imports helperFn from './internal' (relative, no extension) + const allImports = new Map([ + [ + '/test/utils/src/index.ts', + [makeImport('helperFn', '/test/utils/src/index.ts', './internal')], + ], + ]); + + const result = yield* analyze([pkg], allExports, allImports); + + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).not.toContain('helperFn'); + }), +); + +it.effect('treats namespace imports as consuming all exports from the source', () => + Effect.gen(function* () { + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + ['/test/utils/src/index.ts', []], + [ + '/test/utils/src/internal.ts', + [ + makeExport('a', '/test/utils/src/internal.ts'), + makeExport('b', '/test/utils/src/internal.ts'), + ], + ], + ]); + + // Consumer does: import * as utils from './internal' + const allImports = new Map([ + [ + '/test/utils/src/consumer.ts', + [makeImport('*', '/test/utils/src/consumer.ts', './internal', true)], + ], + ]); + + const result = yield* analyze([pkg], allExports, allImports); + + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).not.toContain('a'); + expect(deadNames).not.toContain('b'); + }), +); + +it.effect('multi-hop re-export chain protects source exports', () => + Effect.gen(function* () { + // entry -> barrel -> impl + // Entry re-exports from barrel, barrel re-exports from impl. + // impl's exports should NOT be flagged. + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + [ + '/test/utils/src/index.ts', + [makeExport('foo', '/test/utils/src/index.ts', 1, false, true, './barrel')], + ], + [ + '/test/utils/src/barrel.ts', + [makeExport('foo', '/test/utils/src/barrel.ts', 1, false, true, './impl')], + ], + ['/test/utils/src/impl.ts', [makeExport('foo', '/test/utils/src/impl.ts')]], + ]); + + const allImports = new Map(); + + const result = yield* analyze([pkg], allExports, allImports); + + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).not.toContain('foo'); + expect(result.deadExports).toHaveLength(0); + }), +); + +it.effect('star re-export from entry point protects all source exports', () => + Effect.gen(function* () { + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + [ + '/test/utils/src/index.ts', + [makeExport('*', '/test/utils/src/index.ts', 1, false, true, './internal')], + ], + [ + '/test/utils/src/internal.ts', + [ + makeExport('a', '/test/utils/src/internal.ts'), + makeExport('b', '/test/utils/src/internal.ts'), + ], + ], + ]); + + const allImports = new Map(); + + const result = yield* analyze([pkg], allExports, allImports); + + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).not.toContain('a'); + expect(deadNames).not.toContain('b'); + expect(result.deadExports).toHaveLength(0); + }), +); + +it.effect('package specifier re-export does not crash or create incorrect edges', () => + Effect.gen(function* () { + // Entry point re-exports from a package specifier like 'effect'. + // This should not crash and should not create any consumption edges. + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + [ + '/test/utils/src/index.ts', + [makeExport('Effect', '/test/utils/src/index.ts', 1, false, true, 'effect')], + ], + ['/test/utils/src/internal.ts', [makeExport('helperFn', '/test/utils/src/internal.ts')]], + ]); + + const allImports = new Map(); + + const result = yield* analyze([pkg], allExports, allImports); + + // helperFn is not consumed by anything — should be flagged + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).toContain('helperFn'); + // No crash occurred + expect(result.totalFiles).toBe(2); + }), +); + +it.effect('silently skips files that cannot be attributed to any package', () => + Effect.gen(function* () { + const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']); + + const allExports = new Map([ + ['/test/utils/src/index.ts', [makeExport('publicFn', '/test/utils/src/index.ts')]], + // This file is outside any known package root + ['/other/unknown/file.ts', [makeExport('orphan', '/other/unknown/file.ts')]], + ]); + + const allImports = new Map(); + + const result = yield* analyze([pkg], allExports, allImports); + + // orphan is not flagged as dead (it's outside any known package) + const deadNames = result.deadExports.map((d) => d.symbol.name); + expect(deadNames).not.toContain('orphan'); + // publicFn is in entry point — safe + expect(deadNames).not.toContain('publicFn'); + }), +); diff --git a/packages/dead-export-finder/src/lib/export-graph.ts b/packages/dead-export-finder/src/lib/export-graph.ts new file mode 100644 index 0000000..7962005 --- /dev/null +++ b/packages/dead-export-finder/src/lib/export-graph.ts @@ -0,0 +1,185 @@ +import { Context, Effect, Layer } from 'effect'; +import path from 'node:path'; +import type { + PackageInfo, + ExportedSymbol, + ImportedSymbol, + DeadExport, + AnalysisResult, +} from './schemas.js'; + +// ─── Extension stripping ─────────────────────────────────────────────────────── + +const EXTENSIONS = ['.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; +} + +// ─── Service interface ──────────────────────────────────────────────────────── + +export interface ExportGraphShape { + readonly analyze: ( + packages: readonly PackageInfo[], + allExports: Map, + allImports: Map, + ) => Effect.Effect; +} + +// ─── Tag ───────────────────────────────────────────────────────────────────── + +export class ExportGraph extends Context.Tag('ExportGraph')() {} + +// ─── Live implementation ────────────────────────────────────────────────────── + +function analyzeSync( + packages: readonly PackageInfo[], + allExports: Map, + allImports: Map, +): AnalysisResult { + // Step 1: Build set of entry point file paths (resolved absolute paths) + const entryPointPaths = new Set(); + for (const pkg of packages) { + for (const ep of pkg.entryPoints) { + const resolved = path.resolve(pkg.root, ep); + entryPointPaths.add(resolved); + } + } + + // 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 { + consumedByRelative.add(`${resolved}:${exp.name}`); + } + } + } + + // 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 } as unknown as DeadExport); + } + } + + return { + deadExports, + totalExports, + totalFiles, + warnings, + } as unknown as AnalysisResult; +} + +export const ExportGraphLive = Layer.succeed(ExportGraph, { + analyze: (packages, allExports, allImports) => + Effect.sync(() => analyzeSync(packages, allExports, allImports)), +}); diff --git a/packages/dead-export-finder/src/lib/export-parser.test.ts b/packages/dead-export-finder/src/lib/export-parser.test.ts new file mode 100644 index 0000000..6df4ff2 --- /dev/null +++ b/packages/dead-export-finder/src/lib/export-parser.test.ts @@ -0,0 +1,114 @@ +import { it } from '@effect/vitest'; +import { expect } from 'vitest'; +import { Effect } from 'effect'; +import { ExportParser, ExportParserLive } from './export-parser.js'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const parse = (filePath: string, source: string) => + Effect.gen(function* () { + const parser = yield* ExportParser; + return yield* parser.parse(filePath, source); + }).pipe(Effect.provide(ExportParserLive)); + +// ─── tests ──────────────────────────────────────────────────────────────────── + +it.effect('parses named export declarations', () => + Effect.gen(function* () { + const source = 'export const foo = 1; export function bar() {} export class Baz {}'; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(3); + const names = symbols.map((s) => s.name); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + expect(names).toContain('Baz'); + for (const s of symbols) { + expect(s.isDefault).toBe(false); + expect(s.isReExport).toBe(false); + } + }), +); + +it.effect('parses default export', () => + Effect.gen(function* () { + const source = 'export default function main() {}'; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('default'); + expect(symbols[0]?.isDefault).toBe(true); + expect(symbols[0]?.isReExport).toBe(false); + }), +); + +it.effect('parses re-exports', () => + Effect.gen(function* () { + const source = "export { foo, bar } from './other'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(2); + const names = symbols.map((s) => s.name); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + for (const s of symbols) { + expect(s.isReExport).toBe(true); + expect(s.reExportSource).toBe('./other'); + } + }), +); + +it.effect('parses export star', () => + Effect.gen(function* () { + const source = "export * from './utils'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('*'); + expect(symbols[0]?.isReExport).toBe(true); + expect(symbols[0]?.reExportSource).toBe('./utils'); + }), +); + +it.effect('parses export list from local bindings', () => + Effect.gen(function* () { + const source = 'const x = 1; export { x, y }'; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(2); + const names = symbols.map((s) => s.name); + expect(names).toContain('x'); + expect(names).toContain('y'); + for (const s of symbols) { + expect(s.isReExport).toBe(false); + } + }), +); + +it.effect('parses CJS module.exports', () => + Effect.gen(function* () { + const source = 'module.exports = { foo, bar }'; + const symbols = yield* parse('test.js', source); + expect(symbols).toHaveLength(2); + const names = symbols.map((s) => s.name); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + }), +); + +it.effect('parses CJS exports.name', () => + Effect.gen(function* () { + const source = 'exports.foo = function() {}; exports.bar = 42'; + const symbols = yield* parse('test.js', source); + expect(symbols).toHaveLength(2); + const names = symbols.map((s) => s.name); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + }), +); + +it.effect('parses type exports', () => + Effect.gen(function* () { + const source = 'export type Foo = string; export interface Bar {}'; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(2); + const names = symbols.map((s) => s.name); + expect(names).toContain('Foo'); + expect(names).toContain('Bar'); + }), +); diff --git a/packages/dead-export-finder/src/lib/export-parser.ts b/packages/dead-export-finder/src/lib/export-parser.ts new file mode 100644 index 0000000..bdca7bf --- /dev/null +++ b/packages/dead-export-finder/src/lib/export-parser.ts @@ -0,0 +1,336 @@ +import { Context, Effect, Layer } from 'effect'; +import oxc from 'oxc-parser'; +import type { ExportedSymbol } from './schemas.js'; +import { ParseError } from './errors.js'; + +// ─── AST node types (minimal) ───────────────────────────────────────────────── + +interface OxcIdent { + type: 'Identifier'; + name: string; + start: number; +} + +interface OxcLiteral { + type: 'Literal'; + value: unknown; + start: number; +} + +interface OxcVariableDeclarator { + type: 'VariableDeclarator'; + id: OxcIdent | { type: string; start: number }; +} + +interface OxcVariableDeclaration { + type: 'VariableDeclaration'; + start: number; + declarations: OxcVariableDeclarator[]; +} + +interface OxcFunctionDeclaration { + type: 'FunctionDeclaration'; + start: number; + id: OxcIdent | null; +} + +interface OxcClassDeclaration { + type: 'ClassDeclaration'; + start: number; + id: OxcIdent | null; +} + +interface OxcTSTypeAlias { + type: 'TSTypeAliasDeclaration'; + start: number; + id: OxcIdent; +} + +interface OxcTSInterface { + type: 'TSInterfaceDeclaration'; + start: number; + id: OxcIdent; +} + +type OxcDeclaration = + | OxcVariableDeclaration + | OxcFunctionDeclaration + | OxcClassDeclaration + | OxcTSTypeAlias + | OxcTSInterface + | { type: string; start: number }; + +interface OxcExportSpecifier { + type: 'ExportSpecifier'; + exported: OxcIdent; + start: number; +} + +interface OxcExportNamedDeclaration { + type: 'ExportNamedDeclaration'; + start: number; + declaration: OxcDeclaration | null; + specifiers: OxcExportSpecifier[]; + source: OxcLiteral | null; +} + +interface OxcExportDefaultDeclaration { + type: 'ExportDefaultDeclaration'; + start: number; +} + +interface OxcExportAllDeclaration { + type: 'ExportAllDeclaration'; + start: number; + exported: OxcIdent | null; + source: OxcLiteral; +} + +interface OxcMemberExpression { + type: 'MemberExpression'; + object: { type: string; name?: string }; + property: OxcIdent; +} + +interface OxcProperty { + type: 'Property'; + key: OxcIdent | { type: string }; + start: number; +} + +interface OxcObjectExpression { + type: 'ObjectExpression'; + start: number; + properties: OxcProperty[]; +} + +interface OxcAssignmentExpression { + type: 'AssignmentExpression'; + start: number; + left: OxcMemberExpression | { type: string }; + right: OxcObjectExpression | { type: string }; +} + +interface OxcExpressionStatement { + type: 'ExpressionStatement'; + start: number; + expression: OxcAssignmentExpression | { type: string }; +} + +type OxcBodyNode = + | OxcExportNamedDeclaration + | OxcExportDefaultDeclaration + | OxcExportAllDeclaration + | OxcExpressionStatement + | { type: string; start: number }; + +interface OxcProgram { + type: 'Program'; + body: OxcBodyNode[]; +} + +// ─── 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; +} + +function extractDeclarationNames(decl: OxcDeclaration): string[] { + switch (decl.type) { + case 'FunctionDeclaration': + case 'ClassDeclaration': { + const d = decl as OxcFunctionDeclaration | OxcClassDeclaration; + return d.id ? [d.id.name] : []; + } + case 'VariableDeclaration': { + const d = decl as OxcVariableDeclaration; + return d.declarations.flatMap((v) => + v.id.type === 'Identifier' ? [(v.id as OxcIdent).name] : [], + ); + } + case 'TSTypeAliasDeclaration': + case 'TSInterfaceDeclaration': { + const d = decl as OxcTSTypeAlias | OxcTSInterface; + return [d.id.name]; + } + default: + return []; + } +} + +function extractCjsExports( + node: OxcExpressionStatement, + filePath: string, + source: string, +): ExportedSymbol[] { + const expr = node.expression; + if (expr.type !== 'AssignmentExpression') return []; + + const assign = expr as OxcAssignmentExpression; + const left = assign.left; + + if (left.type !== 'MemberExpression') return []; + const member = left as OxcMemberExpression; + + const objName = + member.object.type === 'Identifier' ? (member.object as { name: string }).name : ''; + const propName = member.property.name; + + // exports.foo = ... + if (objName === 'exports') { + return [ + { + name: propName, + filePath, + line: lineFromOffset(source, assign.start), + isDefault: false, + isReExport: false, + } as ExportedSymbol, + ]; + } + + // module.exports = { foo, bar } + if (objName === 'module' && propName === 'exports') { + 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 []; +} + +// ─── Service interface ──────────────────────────────────────────────────────── + +export interface ExportParserShape { + readonly parse: ( + filePath: string, + source: string, + ) => Effect.Effect; +} + +// ─── Tag ───────────────────────────────────────────────────────────────────── + +export class ExportParser extends Context.Tag('ExportParser')() {} + +// ─── Live implementation ────────────────────────────────────────────────────── + +const parseSource = ( + filePath: string, + source: string, +): Effect.Effect => + Effect.try({ + try: () => { + const result = oxc.parseSync(filePath, source); + + if (result.errors.length > 0) { + const msg = (result.errors[0] as { message?: string }).message ?? 'parse error'; + throw new Error(msg); + } + + 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) { + symbols.push({ + name: spec.exported.name, + filePath, + line: lineFromOffset(source, spec.start), + isDefault: false, + isReExport, + ...(reExportSource !== undefined ? { reExportSource } : {}), + } 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; + }, + catch: (e) => + new ParseError({ + filePath, + message: e instanceof Error ? e.message : String(e), + }), + }); + +export const ExportParserLive = Layer.succeed(ExportParser, { + parse: parseSource, +}); diff --git a/packages/dead-export-finder/src/lib/file-scanner.test.ts b/packages/dead-export-finder/src/lib/file-scanner.test.ts new file mode 100644 index 0000000..66b71cf --- /dev/null +++ b/packages/dead-export-finder/src/lib/file-scanner.test.ts @@ -0,0 +1,136 @@ +import { expect, layer } from '@effect/vitest'; +import { Effect } from 'effect'; +import { FileSystem, Path } from '@effect/platform'; +import { NodeContext } from '@effect/platform-node'; +import { FileScanner, FileScannerLive } from './file-scanner.js'; + +// ─── tests ──────────────────────────────────────────────────────────────────── + +layer(NodeContext.layer)('FileScanner', (it) => { + const withScanner = ( + program: Effect.Effect, + ) => program.pipe(Effect.provide(FileScannerLive)); + + it.scoped('finds .ts and .tsx files, excludes other extensions', () => + withScanner( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, 'index.ts'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'App.tsx'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'styles.css'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'README.md'), ''); + + const scanner = yield* FileScanner; + const files = yield* scanner.scan(tmpDir, []); + + const names = files.map((f) => path.basename(f)); + expect(names).toContain('index.ts'); + expect(names).toContain('App.tsx'); + expect(names).not.toContain('styles.css'); + expect(names).not.toContain('README.md'); + }), + ), + ); + + it.scoped('excludes node_modules', () => + withScanner( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, 'index.ts'), ''); + + const nmDir = path.join(tmpDir, 'node_modules', 'some-pkg'); + yield* fs.makeDirectory(nmDir, { recursive: true }); + yield* fs.writeFileString(path.join(nmDir, 'index.ts'), ''); + + const scanner = yield* FileScanner; + const files = yield* scanner.scan(tmpDir, []); + + // All returned paths should not be inside node_modules + const hasNodeModules = files.some((f) => f.includes('node_modules')); + expect(hasNodeModules).toBe(false); + expect(files.length).toBe(1); + }), + ), + ); + + it.scoped('respects .gitignore patterns', () => + withScanner( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, '.gitignore'), 'dist/\n'); + yield* fs.writeFileString(path.join(tmpDir, 'index.ts'), ''); + + const distDir = path.join(tmpDir, 'dist'); + yield* fs.makeDirectory(distDir, { recursive: true }); + yield* fs.writeFileString(path.join(distDir, 'index.js'), ''); + yield* fs.writeFileString(path.join(distDir, 'index.d.ts'), ''); + + const scanner = yield* FileScanner; + const files = yield* scanner.scan(tmpDir, []); + + const hasDistFiles = files.some((f) => f.includes('/dist/')); + expect(hasDistFiles).toBe(false); + expect(files.some((f) => f.endsWith('index.ts'))).toBe(true); + }), + ), + ); + + it.scoped('finds all JS/TS extensions (.js, .jsx, .mjs, .cjs)', () => + withScanner( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, 'index.js'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'component.jsx'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'esm.mjs'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'cjs.cjs'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'styles.css'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'data.json'), ''); + + const scanner = yield* FileScanner; + const files = yield* scanner.scan(tmpDir, []); + + const names = files.map((f) => path.basename(f)); + expect(names).toContain('index.js'); + expect(names).toContain('component.jsx'); + expect(names).toContain('esm.mjs'); + expect(names).toContain('cjs.cjs'); + expect(names).not.toContain('styles.css'); + expect(names).not.toContain('data.json'); + }), + ), + ); + + it.scoped('respects custom ignore globs', () => + withScanner( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, 'index.ts'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'index.test.ts'), ''); + yield* fs.writeFileString(path.join(tmpDir, 'util.spec.ts'), ''); + + const scanner = yield* FileScanner; + const files = yield* scanner.scan(tmpDir, ['**/*.test.ts', '**/*.spec.ts']); + + const names = files.map((f) => path.basename(f)); + expect(names).toContain('index.ts'); + expect(names).not.toContain('index.test.ts'); + expect(names).not.toContain('util.spec.ts'); + }), + ), + ); +}); diff --git a/packages/dead-export-finder/src/lib/file-scanner.ts b/packages/dead-export-finder/src/lib/file-scanner.ts new file mode 100644 index 0000000..2dc64e9 --- /dev/null +++ b/packages/dead-export-finder/src/lib/file-scanner.ts @@ -0,0 +1,85 @@ +import { Context, Data, Effect, Layer } from 'effect'; +import { FileSystem, Path } from '@effect/platform'; +import fg from 'fast-glob'; +import ignore from 'ignore'; + +// ─── Internal errors ────────────────────────────────────────────────────────── + +class GlobError extends Data.TaggedError('GlobError')<{ + readonly cause: unknown; +}> {} + +// ─── Service interface ──────────────────────────────────────────────────────── + +export interface FileScannerShape { + readonly scan: ( + root: string, + ignoreGlobs: readonly string[], + ) => Effect.Effect; +} + +// ─── Tag ───────────────────────────────────────────────────────────────────── + +export class FileScanner extends Context.Tag('FileScanner')() {} + +// ─── Live implementation ────────────────────────────────────────────────────── + +export const FileScannerLive = Layer.effect( + FileScanner, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = 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; + }); + + return { scan }; + }), +); diff --git a/packages/dead-export-finder/src/lib/import-parser.test.ts b/packages/dead-export-finder/src/lib/import-parser.test.ts new file mode 100644 index 0000000..d7004b0 --- /dev/null +++ b/packages/dead-export-finder/src/lib/import-parser.test.ts @@ -0,0 +1,98 @@ +import { it } from '@effect/vitest'; +import { expect } from 'vitest'; +import { Effect } from 'effect'; +import { ImportParser, ImportParserLive } from './import-parser.js'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const parse = (filePath: string, source: string) => + Effect.gen(function* () { + const parser = yield* ImportParser; + return yield* parser.parse(filePath, source); + }).pipe(Effect.provide(ImportParserLive)); + +// ─── tests ──────────────────────────────────────────────────────────────────── + +it.effect('parses named imports', () => + Effect.gen(function* () { + const source = "import { foo, bar } from '@myorg/utils'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(2); + const names = symbols.map((s) => s.name); + expect(names).toContain('foo'); + expect(names).toContain('bar'); + for (const s of symbols) { + expect(s.source).toBe('@myorg/utils'); + expect(s.isNamespace).toBe(false); + expect(s.isDynamic).toBe(false); + } + }), +); + +it.effect('parses default import', () => + Effect.gen(function* () { + const source = "import foo from '@myorg/utils'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('default'); + expect(symbols[0]?.source).toBe('@myorg/utils'); + expect(symbols[0]?.isNamespace).toBe(false); + expect(symbols[0]?.isDynamic).toBe(false); + }), +); + +it.effect('parses namespace import', () => + Effect.gen(function* () { + const source = "import * as utils from '@myorg/utils'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('*'); + expect(symbols[0]?.source).toBe('@myorg/utils'); + expect(symbols[0]?.isNamespace).toBe(true); + expect(symbols[0]?.isDynamic).toBe(false); + }), +); + +it.effect('parses CJS require', () => + Effect.gen(function* () { + const source = "const { foo } = require('@myorg/utils')"; + const symbols = yield* parse('test.js', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('*'); + expect(symbols[0]?.source).toBe('@myorg/utils'); + expect(symbols[0]?.isNamespace).toBe(true); + expect(symbols[0]?.isDynamic).toBe(false); + }), +); + +it.effect('parses dynamic import with string literal', () => + Effect.gen(function* () { + const source = "const mod = await import('@myorg/utils')"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('*'); + expect(symbols[0]?.source).toBe('@myorg/utils'); + expect(symbols[0]?.isNamespace).toBe(false); + expect(symbols[0]?.isDynamic).toBe(true); + }), +); + +it.effect('returns empty for dynamic import with variable', () => + Effect.gen(function* () { + const source = 'const mod = await import(someVariable)'; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(0); + }), +); + +it.effect('parses type-only imports', () => + Effect.gen(function* () { + const source = "import type { Foo } from '@myorg/types'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(1); + expect(symbols[0]?.name).toBe('Foo'); + expect(symbols[0]?.source).toBe('@myorg/types'); + expect(symbols[0]?.isNamespace).toBe(false); + expect(symbols[0]?.isDynamic).toBe(false); + }), +); diff --git a/packages/dead-export-finder/src/lib/import-parser.ts b/packages/dead-export-finder/src/lib/import-parser.ts new file mode 100644 index 0000000..42a5912 --- /dev/null +++ b/packages/dead-export-finder/src/lib/import-parser.ts @@ -0,0 +1,210 @@ +import { Context, Effect, Layer } from 'effect'; +import oxc from 'oxc-parser'; +import type { ImportedSymbol } from './schemas.js'; +import { ParseError } from './errors.js'; + +// ─── AST node types (minimal) ───────────────────────────────────────────────── + +interface OxcStringLiteral { + type: 'Literal'; + value: string; +} + +interface OxcImportSpecifier { + type: 'ImportSpecifier'; + imported: { type: string; name: string }; +} + +interface OxcImportDefaultSpecifier { + type: 'ImportDefaultSpecifier'; +} + +interface OxcImportNamespaceSpecifier { + type: 'ImportNamespaceSpecifier'; +} + +type OxcImportSpecifierKind = + | OxcImportSpecifier + | OxcImportDefaultSpecifier + | OxcImportNamespaceSpecifier; + +interface OxcImportDeclaration { + type: 'ImportDeclaration'; + source: OxcStringLiteral; + specifiers: OxcImportSpecifierKind[]; +} + +interface OxcImportExpression { + type: 'ImportExpression'; + source: OxcStringLiteral | { type: string }; +} + +interface OxcCallExpression { + type: 'CallExpression'; + callee: { type: string; name?: string }; + arguments: Array; +} + +interface OxcNode { + type: string; + [key: string]: unknown; +} + +interface OxcProgram { + type: 'Program'; + body: OxcNode[]; +} + +// ─── AST walker ─────────────────────────────────────────────────────────────── + +function walkNode(node: OxcNode, filePath: string, symbols: ImportedSymbol[]): void { + 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); + } + // variable source — skip (can't statically resolve) + 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({ + name: '*', + filePath, + source: lit.value, + isNamespace: true, + isDynamic: false, + } 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); + } + } +} + +// ─── Service interface ──────────────────────────────────────────────────────── + +export interface ImportParserShape { + readonly parse: ( + filePath: string, + source: string, + ) => Effect.Effect; +} + +// ─── Tag ───────────────────────────────────────────────────────────────────── + +export class ImportParser extends Context.Tag('ImportParser')() {} + +// ─── Live implementation ────────────────────────────────────────────────────── + +const parseSource = ( + filePath: string, + source: string, +): Effect.Effect => + Effect.try({ + try: () => { + const result = oxc.parseSync(filePath, source); + + if (result.errors.length > 0) { + const msg = (result.errors[0] as { message?: string }).message ?? 'parse error'; + throw new Error(msg); + } + + 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); + } + + return symbols; + }, + catch: (e) => + new ParseError({ + filePath, + message: e instanceof Error ? e.message : String(e), + }), + }); + +export const ImportParserLive = Layer.succeed(ImportParser, { + parse: parseSource, +}); diff --git a/packages/dead-export-finder/src/lib/integration.test.ts b/packages/dead-export-finder/src/lib/integration.test.ts new file mode 100644 index 0000000..d5bb919 --- /dev/null +++ b/packages/dead-export-finder/src/lib/integration.test.ts @@ -0,0 +1,155 @@ +import { expect, layer } from '@effect/vitest'; +import { Effect, Layer } from 'effect'; +import { FileSystem, Path } from '@effect/platform'; +import { NodeContext } from '@effect/platform-node'; +import { WorkspaceDetector, WorkspaceDetectorLive } from './workspace-detector.js'; +import { FileScanner, FileScannerLive } from './file-scanner.js'; +import { ExportParser, ExportParserLive } from './export-parser.js'; +import { ImportParser, ImportParserLive } from './import-parser.js'; +import { ExportGraph, ExportGraphLive } from './export-graph.js'; +import { Reporter, ReporterLive } from './reporter.js'; +import type { ExportedSymbol, ImportedSymbol } from './schemas.js'; + +// ─── Test layer ─────────────────────────────────────────────────────────────── + +// NodeContext.layer provides FileSystem + Path to both test bodies and service layers. +// The service layers are provided on each test effect via withServices(). +const ServicesLayer = Layer.mergeAll( + WorkspaceDetectorLive, + FileScannerLive, + ExportParserLive, + ImportParserLive, + ExportGraphLive, + ReporterLive, +); + +// ─── Integration test ───────────────────────────────────────────────────────── + +layer(NodeContext.layer)('integration', (it) => { + it.scoped( + 'end-to-end: flags internal as dead, keeps slugify and capitalize alive', + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // ── Create synthetic monorepo in temp dir ────────────────────────────── + + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + // Root: pnpm-workspace.yaml + yield* fs.writeFileString( + path.join(tmpDir, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n', + ); + + // Package A: @test/utils + const utilsDir = path.join(tmpDir, 'packages', 'utils'); + const utilsSrcDir = path.join(utilsDir, 'src'); + yield* fs.makeDirectory(utilsSrcDir, { recursive: true }); + + yield* fs.writeFileString( + path.join(utilsDir, 'package.json'), + JSON.stringify({ + name: '@test/utils', + exports: { '.': './src/index.ts' }, + }), + ); + + yield* fs.writeFileString( + path.join(utilsSrcDir, 'index.ts'), + "export { slugify, capitalize } from './string.js';\n", + ); + + yield* fs.writeFileString( + path.join(utilsSrcDir, 'string.ts'), + [ + "export function slugify(s: string): string { return s.toLowerCase().replace(/\\s+/g, '-'); }", + 'export function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); }', + 'export function internal(s: string): string { return s.trim(); }', + ].join('\n') + '\n', + ); + + // Package B: @test/app + const appDir = path.join(tmpDir, 'packages', 'app'); + const appSrcDir = path.join(appDir, 'src'); + yield* fs.makeDirectory(appSrcDir, { recursive: true }); + + yield* fs.writeFileString( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: '@test/app', + exports: { '.': './src/index.ts' }, + }), + ); + + yield* fs.writeFileString( + path.join(appSrcDir, 'index.ts'), + "import { slugify, capitalize } from '@test/utils';\nexport { slugify, capitalize };\n", + ); + + // ── Run the analysis pipeline ────────────────────────────────────────── + + const detector = yield* WorkspaceDetector; + const scanner = yield* FileScanner; + const exportParser = yield* ExportParser; + const importParser = yield* ImportParser; + const graph = yield* ExportGraph; + const reporter = yield* Reporter; + + // Detect workspace + const workspace = yield* detector.detect(tmpDir); + + // Scan and parse each package + const allExports = new Map(); + const allImports = new Map(); + + for (const pkg of workspace.packages) { + const files = yield* scanner.scan(pkg.root, []); + + for (const filePath of files) { + const source = yield* fs.readFileString(filePath, 'utf-8'); + + const exports = yield* exportParser + .parse(filePath, source) + .pipe(Effect.catchTag('ParseError', () => Effect.succeed([]))); + + const imports = yield* importParser + .parse(filePath, source) + .pipe(Effect.catchTag('ParseError', () => Effect.succeed([]))); + + allExports.set(filePath, exports); + allImports.set(filePath, imports); + } + } + + // Analyze + const result = yield* graph.analyze(workspace.packages, allExports, allImports); + + // Build package roots map for reporter + const packageRoots = new Map( + workspace.packages.map((pkg) => [pkg.name, pkg.root]), + ); + + // Format output + const output = reporter.format(result, packageRoots); + + // ── Assertions ───────────────────────────────────────────────────────── + + const deadNames = result.deadExports.map((d) => d.symbol.name); + + // `internal` is exported from string.ts (non-entry-point) and nobody imports it + expect(deadNames).toContain('internal'); + + // `slugify` is re-exported from entry point index.ts — never flagged + expect(deadNames).not.toContain('slugify'); + + // `capitalize` is imported by @test/app — not dead + expect(deadNames).not.toContain('capitalize'); + + // Reporter output mentions the dead export + expect(output).toContain('internal'); + }).pipe(Effect.provide(ServicesLayer)), + { timeout: 30_000 }, + ); +}); diff --git a/packages/dead-export-finder/src/lib/reporter.test.ts b/packages/dead-export-finder/src/lib/reporter.test.ts new file mode 100644 index 0000000..0bfddc4 --- /dev/null +++ b/packages/dead-export-finder/src/lib/reporter.test.ts @@ -0,0 +1,91 @@ +import { it } from '@effect/vitest'; +import { expect } from 'vitest'; +import { Effect } from 'effect'; +import { Reporter, ReporterLive } from './reporter.js'; +import type { AnalysisResult, DeadExport } from './schemas.js'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const makeDeadExport = ( + name: string, + filePath: string, + line: number, + packageName: string, +): DeadExport => + ({ + symbol: { name, filePath, line, isDefault: false, isReExport: false }, + packageName, + }) as unknown as DeadExport; + +const makeResult = (deadExports: DeadExport[], totalExports = 10, totalFiles = 5): AnalysisResult => + ({ + deadExports, + totalExports, + totalFiles, + warnings: [], + }) as unknown as AnalysisResult; + +// ─── tests ──────────────────────────────────────────────────────────────────── + +it.effect('formats dead exports grouped by package and file', () => + Effect.gen(function* () { + const reporter = yield* Reporter; + + const deadExports: DeadExport[] = [ + makeDeadExport('slugify', '/repo/packages/utils/src/string.ts', 12, '@myorg/utils'), + makeDeadExport('camelToKebab', '/repo/packages/utils/src/string.ts', 45, '@myorg/utils'), + makeDeadExport( + 'useDebounce', + '/repo/packages/shared/src/hooks/useDebounce.ts', + 3, + '@myorg/shared', + ), + ]; + + const result = makeResult(deadExports); + + const packageRoots = new Map([ + ['@myorg/utils', '/repo/packages/utils'], + ['@myorg/shared', '/repo/packages/shared'], + ]); + + const output = reporter.format(result, packageRoots); + + // Header + expect(output).toContain('Dead Export Report'); + + // Package grouping with counts + expect(output).toContain('@myorg/utils (2 dead exports)'); + expect(output).toContain('@myorg/shared (1 dead export)'); + + // Relative file paths + expect(output).toContain('src/string.ts'); + expect(output).toContain('src/hooks/useDebounce.ts'); + + // Line numbers and export names + expect(output).toContain(':12'); + expect(output).toContain('slugify'); + expect(output).toContain(':45'); + expect(output).toContain('camelToKebab'); + expect(output).toContain(':3'); + expect(output).toContain('useDebounce'); + + // Summary line + expect(output).toContain('Summary: 3 dead exports across 2 packages'); + }).pipe(Effect.provide(ReporterLive)), +); + +it.effect('returns clean message when no dead exports found', () => + Effect.gen(function* () { + const reporter = yield* Reporter; + + const result = makeResult([], 42, 8); + const packageRoots = new Map(); + + const output = reporter.format(result, packageRoots); + + expect(output).toContain('No dead exports found'); + expect(output).toContain('42'); + expect(output).toContain('8'); + }).pipe(Effect.provide(ReporterLive)), +); diff --git a/packages/dead-export-finder/src/lib/reporter.ts b/packages/dead-export-finder/src/lib/reporter.ts new file mode 100644 index 0000000..91dc2ee --- /dev/null +++ b/packages/dead-export-finder/src/lib/reporter.ts @@ -0,0 +1,87 @@ +import { Context, Layer } 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 { + 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 lines: string[] = []; + + // Header + lines.push('Dead Export Report'); + lines.push('══════════════════'); + lines.push(''); + + for (const pkgName of sortedPackages) { + const pkgDeadExports = byPackage.get(pkgName)!; + const pkgRoot = packageRoots.get(pkgName) ?? ''; + + const count = pkgDeadExports.length; + const exportWord = count === 1 ? 'dead export' : 'dead exports'; + lines.push(`${pkgName} (${count} ${exportWord})`); + + // 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 sortedFiles = [...byFile.keys()].sort(); + + for (const filePath of sortedFiles) { + const fileExports = byFile.get(filePath)!; + const relPath = pkgRoot ? path.relative(pkgRoot, filePath) : filePath; + lines.push(` ${relPath}`); + + // 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}`); + } + } + + lines.push(''); + } + + // 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}`); + + return lines.join('\n'); +} + +export const ReporterLive = Layer.succeed(Reporter, { + format: formatImpl, +}); diff --git a/packages/dead-export-finder/src/lib/schemas.ts b/packages/dead-export-finder/src/lib/schemas.ts new file mode 100644 index 0000000..05f47a6 --- /dev/null +++ b/packages/dead-export-finder/src/lib/schemas.ts @@ -0,0 +1,40 @@ +import { Schema } from 'effect'; + +export const WorkspaceType = Schema.Literal('pnpm', 'npm', 'yarn', 'nx', 'turborepo', 'single'); + +export type WorkspaceType = typeof WorkspaceType.Type; + +export class PackageInfo extends Schema.Class('PackageInfo')({ + name: Schema.String, + root: Schema.String, + entryPoints: Schema.Array(Schema.String), +}) {} + +export class ExportedSymbol extends Schema.Class('ExportedSymbol')({ + name: Schema.String, + filePath: Schema.String, + line: Schema.Number, + isDefault: Schema.Boolean, + isReExport: Schema.Boolean, + reExportSource: Schema.optional(Schema.String), +}) {} + +export class ImportedSymbol extends Schema.Class('ImportedSymbol')({ + name: Schema.String, + filePath: Schema.String, + source: Schema.String, + isNamespace: Schema.Boolean, + isDynamic: Schema.Boolean, +}) {} + +export class DeadExport extends Schema.Class('DeadExport')({ + symbol: ExportedSymbol, + packageName: Schema.String, +}) {} + +export class AnalysisResult extends Schema.Class('AnalysisResult')({ + deadExports: Schema.Array(DeadExport), + totalExports: Schema.Number, + totalFiles: Schema.Number, + warnings: Schema.Array(Schema.String), +}) {} diff --git a/packages/dead-export-finder/src/lib/workspace-detector.test.ts b/packages/dead-export-finder/src/lib/workspace-detector.test.ts new file mode 100644 index 0000000..59ad89f --- /dev/null +++ b/packages/dead-export-finder/src/lib/workspace-detector.test.ts @@ -0,0 +1,242 @@ +import { expect, layer } from '@effect/vitest'; +import { Effect } from 'effect'; +import { FileSystem, Path } from '@effect/platform'; +import { NodeContext } from '@effect/platform-node'; +import { WorkspaceDetector, WorkspaceDetectorLive } from './workspace-detector.js'; +import { WorkspaceNotFoundError } from './errors.js'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const writeJson = (filePath: string, data: unknown) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(filePath, JSON.stringify(data)); + }); + +// ─── tests ──────────────────────────────────────────────────────────────────── +// Use NodeContext.layer as the outer layer (provides FileSystem + Path to test body). +// WorkspaceDetectorLive is provided per-test via Effect.provide so it can also +// access FileSystem from NodeContext. + +layer(NodeContext.layer)('WorkspaceDetector', (it) => { + // Helper: run a program that needs WorkspaceDetector, with WorkspaceDetectorLive + // provided on top of the already-running NodeContext. + const withDetector = ( + program: Effect.Effect, + ) => program.pipe(Effect.provide(WorkspaceDetectorLive)); + + it.scoped('detects pnpm workspace from pnpm-workspace.yaml', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString( + path.join(tmpDir, 'pnpm-workspace.yaml'), + 'packages:\n - "packages/*"\n', + ); + + const pkgDir = path.join(tmpDir, 'packages', 'my-pkg'); + yield* fs.makeDirectory(pkgDir, { recursive: true }); + yield* writeJson(path.join(pkgDir, 'package.json'), { + name: 'my-pkg', + main: './dist/index.js', + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('pnpm'); + expect(result.root).toBe(tmpDir); + expect(result.packages.length).toBeGreaterThan(0); + expect(result.packages[0].name).toBe('my-pkg'); + }), + ), + ); + + it.scoped('reads main/module/types fields as fallback entry points', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* writeJson(path.join(tmpDir, 'package.json'), { + name: 'my-lib', + main: './dist/index.cjs', + module: './dist/index.js', + types: './dist/index.d.ts', + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('single'); + expect(result.packages).toHaveLength(1); + const { entryPoints } = result.packages[0]; + expect(entryPoints).toContain('./dist/index.cjs'); + expect(entryPoints).toContain('./dist/index.js'); + expect(entryPoints).toContain('./dist/index.d.ts'); + }), + ), + ); + + it.scoped('reads subpath exports as entry points', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* writeJson(path.join(tmpDir, 'package.json'), { + name: 'exports-pkg', + exports: { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + }, + './utils': { + types: './dist/utils.d.ts', + import: './dist/utils.js', + }, + }, + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('single'); + expect(result.packages).toHaveLength(1); + const { entryPoints } = result.packages[0]; + expect(entryPoints).toContain('./dist/index.d.ts'); + expect(entryPoints).toContain('./dist/index.js'); + expect(entryPoints).toContain('./dist/utils.d.ts'); + expect(entryPoints).toContain('./dist/utils.js'); + }), + ), + ); + + it.scoped('detects npm workspace from package.json workspaces field', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* writeJson(path.join(tmpDir, 'package.json'), { + name: 'my-monorepo', + workspaces: ['packages/*'], + }); + + const pkgDir = path.join(tmpDir, 'packages', 'alpha'); + yield* fs.makeDirectory(pkgDir, { recursive: true }); + yield* writeJson(path.join(pkgDir, 'package.json'), { + name: 'alpha', + main: './dist/index.js', + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('npm'); + expect(result.root).toBe(tmpDir); + expect(result.packages.length).toBeGreaterThan(0); + expect(result.packages[0].name).toBe('alpha'); + }), + ), + ); + + it.scoped('detects nx workspace from nx.json', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, 'nx.json'), '{}'); + yield* writeJson(path.join(tmpDir, 'package.json'), { name: 'nx-root' }); + + const pkgDir = path.join(tmpDir, 'packages', 'foo'); + yield* fs.makeDirectory(pkgDir, { recursive: true }); + yield* writeJson(path.join(pkgDir, 'package.json'), { + name: 'foo', + main: './dist/index.js', + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('nx'); + expect(result.root).toBe(tmpDir); + expect(result.packages.length).toBeGreaterThan(0); + }), + ), + ); + + it.scoped('detects turborepo workspace from turbo.json', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* fs.writeFileString(path.join(tmpDir, 'turbo.json'), '{}'); + yield* writeJson(path.join(tmpDir, 'package.json'), { name: 'turbo-root' }); + + const pkgDir = path.join(tmpDir, 'packages', 'foo'); + yield* fs.makeDirectory(pkgDir, { recursive: true }); + yield* writeJson(path.join(pkgDir, 'package.json'), { + name: 'foo', + main: './dist/index.js', + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('turborepo'); + expect(result.root).toBe(tmpDir); + expect(result.packages.length).toBeGreaterThan(0); + }), + ), + ); + + it.scoped('yields WorkspaceNotFoundError when no package.json exists at cwd', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + // tmpDir is empty — no package.json, no workspace config + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir).pipe(Effect.flip); + + expect(result).toBeInstanceOf(WorkspaceNotFoundError); + expect((result as WorkspaceNotFoundError).cwd).toBe(tmpDir); + }), + ), + ); + + it.scoped('falls back to single package when no workspace detected', () => + withDetector( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fs.makeTempDirectoryScoped(); + + yield* writeJson(path.join(tmpDir, 'package.json'), { + name: 'standalone', + main: './index.js', + }); + + const detector = yield* WorkspaceDetector; + const result = yield* detector.detect(tmpDir); + + expect(result.type).toBe('single'); + expect(result.root).toBe(tmpDir); + expect(result.packages).toHaveLength(1); + expect(result.packages[0].name).toBe('standalone'); + }), + ), + ); +}); diff --git a/packages/dead-export-finder/src/lib/workspace-detector.ts b/packages/dead-export-finder/src/lib/workspace-detector.ts new file mode 100644 index 0000000..a00284f --- /dev/null +++ b/packages/dead-export-finder/src/lib/workspace-detector.ts @@ -0,0 +1,203 @@ +import { Context, Data, Effect, Layer } from 'effect'; +import { FileSystem, Path } from '@effect/platform'; +import fg from 'fast-glob'; +import YAML from 'yaml'; +import type { PackageInfo, WorkspaceType } from './schemas.js'; +import { WorkspaceNotFoundError } from './errors.js'; + +// ─── Result type ────────────────────────────────────────────────────────────── + +export interface WorkspaceResult { + readonly type: WorkspaceType; + readonly root: string; + readonly packages: readonly PackageInfo[]; +} + +// ─── Service interface ──────────────────────────────────────────────────────── + +export interface WorkspaceDetectorShape { + readonly detect: (cwd: string) => Effect.Effect; +} + +// ─── Tag ───────────────────────────────────────────────────────────────────── + +export class WorkspaceDetector extends Context.Tag('WorkspaceDetector')< + WorkspaceDetector, + WorkspaceDetectorShape +>() {} + +// ─── Internal errors ────────────────────────────────────────────────────────── + +class GlobError extends Data.TaggedError('GlobError')<{ + readonly cause: unknown; +}> {} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Walk an exports field value and collect all string leaf values. + * Handles: string, string[], nested condition/subpath objects. + */ +function collectExportsStrings(value: unknown): string[] { + if (typeof value === 'string') return [value]; + if (Array.isArray(value)) return value.flatMap(collectExportsStrings); + if (value !== null && typeof value === 'object') { + return Object.values(value as Record).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[] { + if (pkg['exports'] !== undefined) { + const collected = collectExportsStrings(pkg['exports']); + return [...new Set(collected)]; + } + + 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)]; +} + +/** + * 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, + pkgDir: string, +): Effect.Effect => { + const pkgPath = path.join(pkgDir, 'package.json'); + + return fs.exists(pkgPath).pipe( + Effect.flatMap((exists) => { + if (!exists) return Effect.succeed(null); + return fs.readFileString(pkgPath, 'utf-8').pipe( + Effect.map((contents) => { + let parsed: Record; + try { + parsed = JSON.parse(contents) as Record; + } 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)), + ); + }), + Effect.catchAll(() => Effect.succeed(null)), + ); +}; + +/** + * Resolve workspace glob patterns (e.g. "packages/*") relative to root, + * then return all directories containing a package.json. + */ +const resolveWorkspaceGlobs = ( + path: Path.Path, + root: string, + globs: string[], +): Effect.Effect => + Effect.tryPromise({ + try: () => + fg( + globs.map((g) => `${g}/package.json`), + { cwd: root, absolute: true, onlyFiles: true }, + ), + catch: (cause) => new GlobError({ cause }), + }).pipe(Effect.map((files) => files.map((p) => path.dirname(p)))); + +// ─── Live implementation ────────────────────────────────────────────────────── + +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.catchAll(() => Effect.succeed([] as PackageInfo[])), + ); + + 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: Record = (() => { + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } + })(); + + // npm / yarn workspaces + const workspaces = rootPkg['workspaces']; + if (Array.isArray(workspaces) && workspaces.length > 0) { + const globs = workspaces.filter((g): g is string => typeof g === 'string'); + 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 }; + } + } + + // 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 { detect }; + }), +); diff --git a/packages/dead-export-finder/src/test-setup.ts b/packages/dead-export-finder/src/test-setup.ts new file mode 100644 index 0000000..3aac00b --- /dev/null +++ b/packages/dead-export-finder/src/test-setup.ts @@ -0,0 +1,2 @@ +import { addEqualityTesters } from '@effect/vitest'; +addEqualityTesters(); diff --git a/packages/dead-export-finder/tsconfig.json b/packages/dead-export-finder/tsconfig.json new file mode 100644 index 0000000..2ef9b2f --- /dev/null +++ b/packages/dead-export-finder/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] +} diff --git a/packages/dead-export-finder/tsconfig.lib.json b/packages/dead-export-finder/tsconfig.lib.json new file mode 100644 index 0000000..6b2ec23 --- /dev/null +++ b/packages/dead-export-finder/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "module": "nodenext", + "moduleResolution": "nodenext", + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["vitest.config.mts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/test-setup.ts"], + "references": [] +} diff --git a/packages/dead-export-finder/tsconfig.spec.json b/packages/dead-export-finder/tsconfig.spec.json new file mode 100644 index 0000000..f2f0839 --- /dev/null +++ b/packages/dead-export-finder/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "module": "nodenext", + "moduleResolution": "nodenext", + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"] + }, + "include": ["vitest.config.mts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/test-setup.ts"], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/dead-export-finder/vitest.config.mts b/packages/dead-export-finder/vitest.config.mts new file mode 100644 index 0000000..3349178 --- /dev/null +++ b/packages/dead-export-finder/vitest.config.mts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/dead-export-finder', + test: { + name: 'dead-export-finder', + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['./src/test-setup.ts'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); From ff1cd3811b5b5adf971b5a19daf9f5e82fb9c314 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 19:44:18 -0600 Subject: [PATCH 2/4] fix(dead-export-finder): harden error handling and add ParseError tests - workspace-detector: catch only GlobError (not all errors) in readPkgDirs - workspace-detector: fail with WorkspaceNotFoundError on malformed root package.json instead of silently returning empty object - workspace-detector: use Effect.try instead of try/catch in Effect.gen - CLI: catch GlobError from scanner.scan, accumulate as warning - tests: add ParseError tests for both export-parser and import-parser 41 tests passing across 7 test files. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/dead-export-finder/src/index.ts | 13 ++++++++++++- .../src/lib/export-parser.test.ts | 10 ++++++++++ .../src/lib/import-parser.test.ts | 10 ++++++++++ .../src/lib/workspace-detector.ts | 13 +++++-------- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/dead-export-finder/src/index.ts b/packages/dead-export-finder/src/index.ts index 8e49696..671840b 100644 --- a/packages/dead-export-finder/src/index.ts +++ b/packages/dead-export-finder/src/index.ts @@ -116,7 +116,18 @@ const command = Command.make( const parseWarnings: string[] = []; for (const pkg of allPackages) { - const files = yield* scanner.scan(pkg.root, ignoreGlobs); + 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[]; + }), + ), + ); for (const filePath of files) { const sourceResult = yield* fs.readFileString(filePath, 'utf-8').pipe(Effect.either); diff --git a/packages/dead-export-finder/src/lib/export-parser.test.ts b/packages/dead-export-finder/src/lib/export-parser.test.ts index 6df4ff2..bf5d851 100644 --- a/packages/dead-export-finder/src/lib/export-parser.test.ts +++ b/packages/dead-export-finder/src/lib/export-parser.test.ts @@ -2,6 +2,7 @@ import { it } from '@effect/vitest'; import { expect } from 'vitest'; import { Effect } from 'effect'; import { ExportParser, ExportParserLive } from './export-parser.js'; +import { ParseError } from './errors.js'; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -112,3 +113,12 @@ it.effect('parses type exports', () => expect(names).toContain('Bar'); }), ); + +it.effect('returns ParseError for syntactically invalid source', () => + Effect.gen(function* () { + const error = yield* parse('bad.ts', '<<>>').pipe(Effect.flip); + expect(error).toBeInstanceOf(ParseError); + expect(error.filePath).toBe('bad.ts'); + expect(error.message).toBeTruthy(); + }), +); diff --git a/packages/dead-export-finder/src/lib/import-parser.test.ts b/packages/dead-export-finder/src/lib/import-parser.test.ts index d7004b0..f05741a 100644 --- a/packages/dead-export-finder/src/lib/import-parser.test.ts +++ b/packages/dead-export-finder/src/lib/import-parser.test.ts @@ -2,6 +2,7 @@ import { it } from '@effect/vitest'; import { expect } from 'vitest'; import { Effect } from 'effect'; import { ImportParser, ImportParserLive } from './import-parser.js'; +import { ParseError } from './errors.js'; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -96,3 +97,12 @@ it.effect('parses type-only imports', () => expect(symbols[0]?.isDynamic).toBe(false); }), ); + +it.effect('returns ParseError for syntactically invalid source', () => + Effect.gen(function* () { + const error = yield* parse('bad.ts', '<<>>').pipe(Effect.flip); + expect(error).toBeInstanceOf(ParseError); + expect(error.filePath).toBe('bad.ts'); + expect(error.message).toBeTruthy(); + }), +); diff --git a/packages/dead-export-finder/src/lib/workspace-detector.ts b/packages/dead-export-finder/src/lib/workspace-detector.ts index a00284f..02b39e1 100644 --- a/packages/dead-export-finder/src/lib/workspace-detector.ts +++ b/packages/dead-export-finder/src/lib/workspace-detector.ts @@ -128,7 +128,7 @@ export const WorkspaceDetectorLive = Layer.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.catchAll(() => Effect.succeed([] as PackageInfo[])), + Effect.catchTag('GlobError', () => Effect.succeed([] as PackageInfo[])), ); const detect = (cwd: string): Effect.Effect => @@ -151,13 +151,10 @@ export const WorkspaceDetectorLive = Layer.effect( if (hasRootPkg) { const raw = yield* fs.readFileString(rootPkgPath, 'utf-8').pipe(Effect.orDie); - const rootPkg: Record = (() => { - try { - return JSON.parse(raw) as Record; - } catch { - return {}; - } - })(); + const rootPkg = yield* Effect.try({ + try: () => JSON.parse(raw) as Record, + catch: () => new WorkspaceNotFoundError({ cwd }), + }); // npm / yarn workspaces const workspaces = rootPkg['workspaces']; From 0bc32596e97fbc154a9184a4101e5d9bd8285dec Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 12 May 2026 19:54:14 -0600 Subject: [PATCH 3/4] chore: update pnpm-lock.yaml for dead-export-finder dependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- pnpm-lock.yaml | 238 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e6ad5..7ff0a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,46 @@ importers: specifier: catalog:vitest version: 3.2.4(@types/node@22.19.18)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0) + packages/dead-export-finder: + dependencies: + '@effect/cli': + specifier: catalog:effect + version: 0.75.1(@effect/platform@0.96.1(effect@3.21.2))(@effect/printer-ansi@0.49.0(@effect/typeclass@0.40.0(effect@3.21.2))(effect@3.21.2))(@effect/printer@0.49.0(@effect/typeclass@0.40.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + '@effect/platform': + specifier: catalog:effect + version: 0.96.1(effect@3.21.2) + '@effect/platform-node': + specifier: catalog:effect + version: 0.106.0(@effect/cluster@0.58.2(@effect/platform@0.96.1(effect@3.21.2))(@effect/rpc@0.75.1(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.18.1(@effect/experimental@0.60.0(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.1(effect@3.21.2))(@effect/rpc@0.75.1(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.1(effect@3.21.2))(@effect/rpc@0.75.1(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + effect: + specifier: catalog:effect + version: 3.21.2 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + ignore: + specifier: ^7.0.4 + version: 7.0.5 + oxc-parser: + specifier: ^0.72.0 + version: 0.72.3 + yaml: + specifier: ^2.7.1 + version: 2.9.0 + devDependencies: + '@effect/vitest': + specifier: catalog:effect + version: 0.29.0(effect@3.21.2)(vitest@3.2.4(@types/node@22.19.18)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0)) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vite: + specifier: catalog:vite + version: 7.3.3(@types/node@22.19.18)(terser@5.47.1)(yaml@2.9.0) + vitest: + specifier: catalog:vitest + version: 3.2.4(@types/node@22.19.18)(jsdom@26.1.0)(terser@5.47.1)(yaml@2.9.0) + packages/devtools-bridge: dependencies: '@forgerock/davinci-client': @@ -546,6 +586,15 @@ packages: '@effect/rpc': ^0.75.1 effect: ^3.21.2 + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -1157,6 +1206,9 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1181,6 +1233,92 @@ packages: resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} engines: {node: ^20.17.0 || >=22.9.0} + '@oxc-parser/binding-darwin-arm64@0.72.3': + resolution: {integrity: sha512-g6wgcfL7At4wHNHutl0NmPZTAju+cUSmSX5WGUMyTJmozRzhx8E9a2KL4rTqNJPwEpbCFrgC29qX9f4fpDnUpA==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.72.3': + resolution: {integrity: sha512-pc+tplB2fd0AqdnXY90FguqSF2OwbxXwrMOLAMmsUiK4/ytr8Z/ftd49+d27GgvQJKeg2LfnIbskaQtY/j2tAA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.72.3': + resolution: {integrity: sha512-igBR6rOvL8t5SBm1f1rjtWNsjB53HNrM3au582JpYzWxOqCjeA5Jlm9KZbjQJC+J8SPB9xyljM7G+6yGZ2UAkQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.72.3': + resolution: {integrity: sha512-/izdr3wg7bK+2RmNhZXC2fQwxbaTH3ELeqdR+Wg4FiEJ/C7ZBIjfB0E734bZGgbDu+rbEJTBlbG77XzY0wRX/Q==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.72.3': + resolution: {integrity: sha512-Vz7C+qJb22HIFl3zXMlwvlTOR+MaIp5ps78060zsdeZh2PUGlYuUYkYXtGEjJV3kc8aKFj79XKqAY1EPG2NWQA==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.72.3': + resolution: {integrity: sha512-nomoMe2VpVxW767jhF+G3mDGmE0U6nvvi5nw9Edqd/5DIylQfq/lEGUWL7qITk+E72YXBsnwHtpRRlIAJOMyZg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.72.3': + resolution: {integrity: sha512-4DswiIK5dI7hFqcMKWtZ7IZnWkRuskh6poI1ad4gkY2p678NOGtl6uOGCCRlDmLOOhp3R27u4VCTzQ6zra977w==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.72.3': + resolution: {integrity: sha512-R9GEiA4WFPGU/3RxAhEd6SaMdpqongGTvGEyTvYCS/MAQyXKxX/LFvc2xwjdvESpjIemmc/12aTTq6if28vHkQ==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.72.3': + resolution: {integrity: sha512-/sEYJQMVqikZO8gK9VDPT4zXo9du3gvvu8jp6erMmW5ev+14PErWRypJjktp0qoTj+uq4MzXro0tg7U+t5hP1w==} + engines: {node: '>=14.0.0'} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.72.3': + resolution: {integrity: sha512-hlyljEZ0sMPKJQCd5pxnRh2sAf/w+Ot2iJecgV9Hl3brrYrYCK2kofC0DFaJM3NRmG/8ZB3PlxnSRSKZTocwCw==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.72.3': + resolution: {integrity: sha512-T17S8ORqAIq+YDFMvLfbNdAiYHYDM1+sLMNhesR5eWBtyTHX510/NbgEvcNemO9N6BNR7m4A9o+q468UG+dmbg==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-wasm32-wasi@0.72.3': + resolution: {integrity: sha512-x0Ojn/jyRUk6MllvVB/puSvI2tczZBIYweKVYHNv1nBatjPRiqo+6/uXiKrZwSfGLkGARrKkTuHSa5RdZBMOdA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.72.3': + resolution: {integrity: sha512-kRVAl87ugRjLZTm9vGUyiXU50mqxLPHY81rgnZUP1HtNcqcmTQtM/wUKQL2UdqvhA6xm6zciqzqCgJfU+RW8uA==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.72.3': + resolution: {integrity: sha512-vpVdoGAP5iGE5tIEPJgr7FkQJZA+sKjMkg5x1jarWJ1nnBamfGsfYiZum4QjCfW7jb+pl42rHVSS3lRmMPcyrQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.72.3': + resolution: {integrity: sha512-CfAC4wrmMkUoISpQkFAIfMVvlPfQV3xg7ZlcqPXPOIMQhdKIId44G8W0mCPgtpWdFFAyJ+SFtiM+9vbyCkoVng==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1519,6 +1657,9 @@ packages: '@textlint/types@15.6.0': resolution: {integrity: sha512-CvgYb1PiqF4BGyoZebGWzAJCZ4ChJAZ9gtWjpQIMKE4Xe2KlSwDA8m8MsiZIV321f5Ibx38BMjC1Z/2ZYP2GQg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -3655,6 +3796,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.72.3: + resolution: {integrity: sha512-JYQeJKDcUTTZ/uTdJ+fZBGFjAjkLD1h0p3Tf44ZYXRcoMk+57d81paNPFAAwzrzzqhZmkGvKKXDxwyhJXYZlpg==} + engines: {node: '>=14.0.0'} + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -5203,6 +5348,22 @@ snapshots: '@effect/rpc': 0.75.1(@effect/platform@0.96.1(effect@3.21.2))(effect@3.21.2) effect: 3.21.2 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -5670,6 +5831,13 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5698,6 +5866,52 @@ snapshots: '@npmcli/redact@4.0.0': {} + '@oxc-parser/binding-darwin-arm64@0.72.3': + optional: true + + '@oxc-parser/binding-darwin-x64@0.72.3': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.72.3': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.72.3': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.72.3': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.72.3': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.72.3': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.72.3': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.72.3': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.72.3': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.72.3': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.72.3': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.72.3': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.72.3': + optional: true + + '@oxc-project/types@0.72.3': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -5986,6 +6200,11 @@ snapshots: dependencies: '@textlint/ast-node-types': 15.6.0 + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -8537,6 +8756,25 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.72.3: + dependencies: + '@oxc-project/types': 0.72.3 + optionalDependencies: + '@oxc-parser/binding-darwin-arm64': 0.72.3 + '@oxc-parser/binding-darwin-x64': 0.72.3 + '@oxc-parser/binding-freebsd-x64': 0.72.3 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.72.3 + '@oxc-parser/binding-linux-arm-musleabihf': 0.72.3 + '@oxc-parser/binding-linux-arm64-gnu': 0.72.3 + '@oxc-parser/binding-linux-arm64-musl': 0.72.3 + '@oxc-parser/binding-linux-riscv64-gnu': 0.72.3 + '@oxc-parser/binding-linux-s390x-gnu': 0.72.3 + '@oxc-parser/binding-linux-x64-gnu': 0.72.3 + '@oxc-parser/binding-linux-x64-musl': 0.72.3 + '@oxc-parser/binding-wasm32-wasi': 0.72.3 + '@oxc-parser/binding-win32-arm64-msvc': 0.72.3 + '@oxc-parser/binding-win32-x64-msvc': 0.72.3 + p-cancelable@2.1.1: {} p-filter@2.1.0: From ae17304df4bcf49a78ec3dd98140b8c90c66b7c8 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 03:12:40 +0000 Subject: [PATCH 4/4] fix(dead-export-finder): address all 8 review findings --- packages/dead-export-finder/package.json | 7 ++- packages/dead-export-finder/src/index.ts | 12 ++++- .../src/lib/export-graph.test.ts | 7 ++- .../src/lib/export-graph.ts | 46 +++++++++++++++++-- .../src/lib/export-parser.test.ts | 2 +- .../src/lib/export-parser.ts | 23 ++++++++++ .../src/lib/import-parser.test.ts | 10 +++- .../dead-export-finder/src/lib/schemas.ts | 26 +++++++---- .../src/lib/workspace-detector.ts | 13 +++++- 9 files changed, 121 insertions(+), 25 deletions(-) diff --git a/packages/dead-export-finder/package.json b/packages/dead-export-finder/package.json index cb3d6a8..6dea102 100644 --- a/packages/dead-export-finder/package.json +++ b/packages/dead-export-finder/package.json @@ -5,6 +5,11 @@ "private": false, "description": "Find dead exports across monorepo package boundaries", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/ryanbas21/devtools.git", + "directory": "packages/dead-export-finder" + }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { @@ -12,7 +17,7 @@ }, "exports": { ".": { - "types": "./dist/index.js", + "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" } diff --git a/packages/dead-export-finder/src/index.ts b/packages/dead-export-finder/src/index.ts index 671840b..8be107a 100644 --- a/packages/dead-export-finder/src/index.ts +++ b/packages/dead-export-finder/src/index.ts @@ -224,7 +224,17 @@ const cli = Command.run(command, { }); cli(process.argv).pipe( - Effect.catchTag('ExitWithCode', (e) => Effect.sync(() => (process.exitCode = e.code))), + Effect.catchTags({ + ExitWithCode: (e) => Effect.sync(() => (process.exitCode = e.code)), + WorkspaceNotFoundError: (e) => + Console.error(`error: workspace not found at ${e.cwd}`).pipe( + Effect.zipRight( + Effect.sync(() => { + process.exitCode = 1; + }), + ), + ), + }), Effect.provide(AppLayer), NodeRuntime.runMain, ); diff --git a/packages/dead-export-finder/src/lib/export-graph.test.ts b/packages/dead-export-finder/src/lib/export-graph.test.ts index 4ecc9ff..5df79bd 100644 --- a/packages/dead-export-finder/src/lib/export-graph.test.ts +++ b/packages/dead-export-finder/src/lib/export-graph.test.ts @@ -19,7 +19,7 @@ const analyze = ( // ─── test data factories ─────────────────────────────────────────────────────── const makePackage = (name: string, root: string, entryPoints: string[]): PackageInfo => - ({ name, root, entryPoints }) as unknown as PackageInfo; + ({ name, root, entryPoints }) as PackageInfo; const makeExport = ( name: string, @@ -29,7 +29,7 @@ const makeExport = ( isReExport = false, reExportSource?: string, ): ExportedSymbol => - ({ name, filePath, line, isDefault, isReExport, reExportSource }) as unknown as ExportedSymbol; + ({ name, filePath, line, isDefault, isReExport, reExportSource }) as ExportedSymbol; const makeImport = ( name: string, @@ -37,8 +37,7 @@ const makeImport = ( source: string, isNamespace = false, isDynamic = false, -): ImportedSymbol => - ({ name, filePath, source, isNamespace, isDynamic }) as unknown as ImportedSymbol; +): ImportedSymbol => ({ name, filePath, source, isNamespace, isDynamic }) as ImportedSymbol; // ─── tests ──────────────────────────────────────────────────────────────────── diff --git a/packages/dead-export-finder/src/lib/export-graph.ts b/packages/dead-export-finder/src/lib/export-graph.ts index 7962005..986a706 100644 --- a/packages/dead-export-finder/src/lib/export-graph.ts +++ b/packages/dead-export-finder/src/lib/export-graph.ts @@ -21,6 +21,33 @@ function stripExtension(filePath: string): string { return filePath; } +/** Map a build-output entry point (e.g. ./dist/index.js) to its likely source path. */ +function 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; + const stripped = stripExtension(entryPoint); + if (scannedStripped.has(stripped)) return stripped; + + // Try mapping common build output dirs to source dirs. + const buildDirs = ['/dist/', '/build/', '/out/']; + const sourceDirs = ['/src/', '/src/', '/src/']; + + 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 null; +} + // ─── Service interface ──────────────────────────────────────────────────────── export interface ExportGraphShape { @@ -42,12 +69,19 @@ function analyzeSync( allExports: Map, allImports: Map, ): AnalysisResult { - // Step 1: Build set of entry point file paths (resolved absolute paths) + // 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); - entryPointPaths.add(resolved); + const sourcePath = resolveEntryPointToSource(resolved, scannedFiles, scannedStripped); + if (sourcePath !== null) { + entryPointPaths.add(sourcePath); + } } } @@ -121,7 +155,9 @@ function analyzeSync( if (exp.name === '*') { consumedByNamespace.add(resolved); } else { - consumedByRelative.add(`${resolved}:${exp.name}`); + // For renamed re-exports (export { foo as bar }), consume the local name + const consumedName = exp.reExportLocalName ?? exp.name; + consumedByRelative.add(`${resolved}:${consumedName}`); } } } @@ -167,7 +203,7 @@ function analyzeSync( if (consumedByNamespace.has(pkg.name)) continue; // Not consumed — dead export - deadExports.push({ symbol: exp, packageName: pkg.name } as unknown as DeadExport); + deadExports.push({ symbol: exp, packageName: pkg.name }); } } @@ -176,7 +212,7 @@ function analyzeSync( totalExports, totalFiles, warnings, - } as unknown as AnalysisResult; + }; } export const ExportGraphLive = Layer.succeed(ExportGraph, { diff --git a/packages/dead-export-finder/src/lib/export-parser.test.ts b/packages/dead-export-finder/src/lib/export-parser.test.ts index bf5d851..f9ceb9e 100644 --- a/packages/dead-export-finder/src/lib/export-parser.test.ts +++ b/packages/dead-export-finder/src/lib/export-parser.test.ts @@ -116,7 +116,7 @@ it.effect('parses type exports', () => it.effect('returns ParseError for syntactically invalid source', () => Effect.gen(function* () { - const error = yield* parse('bad.ts', '<<>>').pipe(Effect.flip); + const error = yield* parse('bad.ts', 'const = ;').pipe(Effect.flip); expect(error).toBeInstanceOf(ParseError); expect(error.filePath).toBe('bad.ts'); expect(error.message).toBeTruthy(); diff --git a/packages/dead-export-finder/src/lib/export-parser.ts b/packages/dead-export-finder/src/lib/export-parser.ts index bdca7bf..87b768f 100644 --- a/packages/dead-export-finder/src/lib/export-parser.ts +++ b/packages/dead-export-finder/src/lib/export-parser.ts @@ -63,6 +63,7 @@ type OxcDeclaration = interface OxcExportSpecifier { type: 'ExportSpecifier'; exported: OxcIdent; + local?: OxcIdent; start: number; } @@ -193,6 +194,25 @@ function extractCjsExports( ]; } + // module.exports.foo = ... (nested MemberExpression) + if (member.object.type === 'MemberExpression') { + const inner = member.object as OxcMemberExpression; + const innerObj = + inner.object.type === 'Identifier' ? (inner.object as { name: string }).name : ''; + const innerProp = inner.property.name; + if (innerObj === 'module' && innerProp === 'exports') { + return [ + { + name: propName, + filePath, + line: lineFromOffset(source, assign.start), + isDefault: false, + isReExport: false, + } as ExportedSymbol, + ]; + } + } + // module.exports = { foo, bar } if (objName === 'module' && propName === 'exports') { const right = assign.right; @@ -271,6 +291,8 @@ const parseSource = ( } 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, @@ -278,6 +300,7 @@ const parseSource = ( isDefault: false, isReExport, ...(reExportSource !== undefined ? { reExportSource } : {}), + ...(isRenamed ? { reExportLocalName: localName } : {}), } as ExportedSymbol); } } diff --git a/packages/dead-export-finder/src/lib/import-parser.test.ts b/packages/dead-export-finder/src/lib/import-parser.test.ts index f05741a..3b35c87 100644 --- a/packages/dead-export-finder/src/lib/import-parser.test.ts +++ b/packages/dead-export-finder/src/lib/import-parser.test.ts @@ -98,9 +98,17 @@ it.effect('parses type-only imports', () => }), ); +it.effect('returns empty for side-effect-only imports', () => + Effect.gen(function* () { + const source = "import './side-effects'"; + const symbols = yield* parse('test.ts', source); + expect(symbols).toHaveLength(0); + }), +); + it.effect('returns ParseError for syntactically invalid source', () => Effect.gen(function* () { - const error = yield* parse('bad.ts', '<<>>').pipe(Effect.flip); + const error = yield* parse('bad.ts', 'const = ;').pipe(Effect.flip); expect(error).toBeInstanceOf(ParseError); expect(error.filePath).toBe('bad.ts'); expect(error.message).toBeTruthy(); diff --git a/packages/dead-export-finder/src/lib/schemas.ts b/packages/dead-export-finder/src/lib/schemas.ts index 05f47a6..e663681 100644 --- a/packages/dead-export-finder/src/lib/schemas.ts +++ b/packages/dead-export-finder/src/lib/schemas.ts @@ -4,37 +4,43 @@ export const WorkspaceType = Schema.Literal('pnpm', 'npm', 'yarn', 'nx', 'turbor export type WorkspaceType = typeof WorkspaceType.Type; -export class PackageInfo extends Schema.Class('PackageInfo')({ +export const PackageInfo = Schema.Struct({ name: Schema.String, root: Schema.String, entryPoints: Schema.Array(Schema.String), -}) {} +}); +export type PackageInfo = Schema.Schema.Type; -export class ExportedSymbol extends Schema.Class('ExportedSymbol')({ +export const ExportedSymbol = Schema.Struct({ name: Schema.String, filePath: Schema.String, line: Schema.Number, isDefault: Schema.Boolean, isReExport: Schema.Boolean, reExportSource: Schema.optional(Schema.String), -}) {} + reExportLocalName: Schema.optional(Schema.String), +}); +export type ExportedSymbol = Schema.Schema.Type; -export class ImportedSymbol extends Schema.Class('ImportedSymbol')({ +export const ImportedSymbol = Schema.Struct({ name: Schema.String, filePath: Schema.String, source: Schema.String, isNamespace: Schema.Boolean, isDynamic: Schema.Boolean, -}) {} +}); +export type ImportedSymbol = Schema.Schema.Type; -export class DeadExport extends Schema.Class('DeadExport')({ +export const DeadExport = Schema.Struct({ symbol: ExportedSymbol, packageName: Schema.String, -}) {} +}); +export type DeadExport = Schema.Schema.Type; -export class AnalysisResult extends Schema.Class('AnalysisResult')({ +export const AnalysisResult = Schema.Struct({ deadExports: Schema.Array(DeadExport), totalExports: Schema.Number, totalFiles: Schema.Number, warnings: Schema.Array(Schema.String), -}) {} +}); +export type AnalysisResult = Schema.Schema.Type; diff --git a/packages/dead-export-finder/src/lib/workspace-detector.ts b/packages/dead-export-finder/src/lib/workspace-detector.ts index 02b39e1..dd1c827 100644 --- a/packages/dead-export-finder/src/lib/workspace-detector.ts +++ b/packages/dead-export-finder/src/lib/workspace-detector.ts @@ -158,8 +158,17 @@ export const WorkspaceDetectorLive = Layer.effect( // npm / yarn workspaces const workspaces = rootPkg['workspaces']; - if (Array.isArray(workspaces) && workspaces.length > 0) { - const globs = workspaces.filter((g): g is string => typeof g === 'string'); + 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 }; }