fix(dead-export-finder): resolve false negatives hiding all dead exports#53
Conversation
Three bugs caused the CLI to report zero dead exports:
1. `Options.repeated` + `Options.optional` returns `Some([])` not `None`
when no `--packages` flag is given. The empty Set filter rejected all
packages, so `graph.analyze` received an empty array and found nothing.
2. The file scanner only loaded `.gitignore` from the package root, not
the workspace root. Since most monorepos have `dist/` in the root
`.gitignore`, per-package scans included build artifacts. The compiled
`.d.ts` files duplicated source exports, polluting results with false
positives once the primary bug was fixed.
3. Config files (`vite.config.ts`, `eslint.config.mjs`, etc.) export
`default` for tooling consumption, not for code. These were flagged
as dead exports.
Fixes:
- Check `packagesOpt.value.length > 0` before creating the filter set
- Walk from package root up to workspace root collecting `.gitignore` files
- Add `DEFAULT_IGNORE` patterns for `*.config.{ts,mjs,cjs,js}`
- Pass `workspace.root` through to `scanner.scan()` calls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
No new issues found.
TL;DR — Fixes a critical false-negative bug where Options.repeated + Options.optional returns Some([]) not None, causing the CLI's package filter to silently reject all packages. Adds .gitignore inheritance from workspace root and default config-file exclusion patterns. Well-tested, clean implementation.
Key changes
- Fix empty-package filter bug —
packagesOpt.value.length > 0guard preventsSome([])from creating aSetthat filters out all packages - Inherit
.gitignorefrom workspace root —loadGitignorePatternswalks ancestor directories up to workspace root loading.gitignorefiles per-directory, with concurrent I/O viaEffect.all - Add default config-file ignore patterns —
*.config.{ts,mjs,cjs,js}excluded by default since they export for tooling, not application code - Add 7 new tests — regression test for the core bug, orphan vs star-re-export test, cross-package named import consumption + non-protection, gitignore inheritance, config-file exclusion, and a real-filesystem end-to-end test
Summary | 6 files | 1 commit | base: main ← fix/dead-export-finder-false-negatives
Package filter fix
Before:
Some([])createsnew Set([])→ empty set rejects all packages → "No dead exports found"
After:packagesOpt.value.length > 0ensures only non-emptySomevalues create a filter
1-2 sentence explanation needed per conventions. The root cause is that @effect/cli's Options.repeated + Options.optional returns Some([]) when the option is not provided — semantically the empty list and absence are distinct, but for the consumer they must be treated identically. The fix is minimal and correct.
Gitignore inheritance
Before: scanner loaded
.gitignoreonly from the package root
After: scanner walks ancestor directories up to workspace root, collecting.gitignorefrom each level
The walker starts at dirname(root) and climbs via path.dirname until reaching workspaceRoot. Loop guards (current.length >= workspaceRoot.length and current !== pathSvc.dirname(current)) prevent runaway at the filesystem root. The per-directory .gitignore loads are dispatched concurrently.
file-scanner.ts · file-scanner.test.ts
Default config-file exclusions
Before: only
node_moduleswas excluded by default
After:*.config.{ts,mjs,cjs,js}are also excluded
Config files like vite.config.ts, eslint.config.mjs, vitest.config.ts, etc. export for their respective tools — including them in dead-export analysis produces noise with no signal. Placing DEFAULT_IGNORE before gitignore patterns and custom globs means users can still opt specific config files back in if needed.
file-scanner.ts · file-scanner.test.ts
Tests
7 new tests across 3 test files, covering:
- Empty packages regression — confirms
analyze([], …)produces zero dead exports - Orphan file vs star re-export — star re-exports protect their source files; files outside star re-exports are still flagged
- Cross-package named import — importing
{ helperFn }from a package specifier marks it consumed - Cross-package non-protection — importing one symbol doesn't protect unrelated symbols in the same package
- Gitignore inheritance — workspace-root
.gitignorepatterns are applied to nested packages - Config-file exclusion —
*.config.ts,*.config.mjsare excluded;index.tsis not - E2E barrel coverage — real-filesystem pipeline verifies orphan files are flagged while barrel-protected and cross-package-consumed symbols are not
export-graph.test.ts · file-scanner.test.ts · integration.test.ts

Summary
@effect/cli'sOptions.repeated+Options.optionalreturnsSome([])notNone, creating an empty package filter that rejected everything.gitignorepatterns from workspace root during per-package scans, sodist/build artifacts are properly excluded*.config.{ts,mjs,cjs,js}) that export for tooling, not for codeBefore:
dead-export-finder→ "No dead exports found" (always)After:
dead-export-finder→ correctly reports 93 dead exports across 9 packages in this repoTest plan
Tfromtest.tsis caught,dist/and config noise eliminated--packagesfilter still works when explicitly provided🤖 Generated with Claude Code