|
| 1 | +# Dead Export Finder — Pure Functional Refactor |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Eliminate all mutation from the `dead-export-finder` package internals. Use pipe syntax with small, well-named composed functions. Leverage Effect's `HashSet`/`HashMap` internally where structural equality matters, keep native `ReadonlyMap`/`ReadonlyArray` at service boundaries. |
| 6 | + |
| 7 | +## Constraints |
| 8 | + |
| 9 | +- Service topology unchanged (WorkspaceDetector, FileScanner, ExportParser, ImportParser, ExportGraph, Reporter) |
| 10 | +- All existing tests pass without modification (Map is assignable to ReadonlyMap) |
| 11 | +- Effect boundary stays at parse/IO level — pure synchronous functions inside |
| 12 | + |
| 13 | +## Service Interface Changes |
| 14 | + |
| 15 | +- `ExportGraph.analyze`: parameters become `ReadonlyMap` instead of `Map` |
| 16 | +- `Reporter.format`: `packageRoots` becomes `ReadonlyMap<string, string>` |
| 17 | +- No new services added, none removed |
| 18 | + |
| 19 | +## File-by-File Plan |
| 20 | + |
| 21 | +### export-parser.ts |
| 22 | + |
| 23 | +- Replace `symbols.push()` in switch/case with `Array.flatMap(extractExportsFromNode)` over `program.body` |
| 24 | +- Small pure extractors: `extractNamedDeclaration`, `extractDefaultDeclaration`, `extractAllDeclaration`, `extractCjsExports` — each returns `ReadonlyArray<ExportedSymbol>` |
| 25 | +- `extractDeclarationNames` returns `ReadonlyArray<string>` |
| 26 | +- `lineFromOffset` stays as pure function (already is), replace mutable counter |
| 27 | +- `Effect.try` boundary stays for `oxc.parseSync` |
| 28 | + |
| 29 | +### import-parser.ts |
| 30 | + |
| 31 | +- `walkNode(node, filePath, symbols)` → `collectSymbols(node, filePath): ReadonlyArray<ImportedSymbol>` — pure recursive, returns results |
| 32 | +- Children via `Array.flatMap` recurse |
| 33 | +- Static imports: `Array.flatMap(extractStaticImports)` over ImportDeclaration nodes |
| 34 | +- Concatenate with `collectSymbols` results |
| 35 | +- Same `Effect.try` boundary |
| 36 | + |
| 37 | +### export-graph.ts |
| 38 | + |
| 39 | +- `resolveEntryPoints(packages, scannedFiles) → HashSet<string>` |
| 40 | +- `buildFileToPackageMap(packages, exportedFilePaths) → ReadonlyMap<string, PackageInfo>` |
| 41 | +- `buildConsumedSets(allImports, allExports) → { byRelative: HashSet, byPackage: HashSet, byNamespace: HashSet }` |
| 42 | + - `collectImportEdges` + `collectReExportEdges` merged with `HashSet.union` |
| 43 | +- `findDeadExports(...)` — pure filter with `Array.filter(isNotConsumed)` |
| 44 | +- `analyze` becomes ~15 lines piping through steps 1–4 |
| 45 | + |
| 46 | +### reporter.ts |
| 47 | + |
| 48 | +- `Array.groupBy` for package/file grouping |
| 49 | +- `formatPackageSection` returns `ReadonlyArray<string>` |
| 50 | +- Top-level: header → sections → summary, `Array.join("\n")` |
| 51 | + |
| 52 | +### file-scanner.ts |
| 53 | + |
| 54 | +- `loadGitignorePatterns(root)` → `Effect<ReadonlyArray<string>>` |
| 55 | +- `buildIgnorePatterns(gitignore, custom)` — pure concatenation |
| 56 | +- Single `ignore()` construction from full pattern list |
| 57 | + |
| 58 | +### workspace-detector.ts |
| 59 | + |
| 60 | +- Nested if/else → `pipe(detectPnpm, Effect.orElse(detectNpm), Effect.orElse(detectNx), Effect.orElse(detectTurbo), Effect.orElse(detectSingle))` |
| 61 | +- Each detector is a small function returning `Effect<WorkspaceResult, WorkspaceNotFoundError>` |
| 62 | + |
| 63 | +### index.ts (CLI) |
| 64 | + |
| 65 | +- `scanWorkspace(workspace, ignoreGlobs, verbose)` → immutable file map + warnings |
| 66 | +- `parseAllFiles(filesByPackage, verbose)` → immutable exports/imports maps + warnings |
| 67 | +- `analyzeAndReport(...)` → calls graph + reporter |
| 68 | +- Command handler becomes ~20 lines |
| 69 | +- Warnings concatenated at end with `Array.appendAll` |
| 70 | + |
| 71 | +## Testing |
| 72 | + |
| 73 | +Zero test file changes. All `Map` constructions in tests are assignable to `ReadonlyMap`. All assertions remain valid. |
0 commit comments