Skip to content

fix(dead-export-finder): resolve false negatives hiding all dead exports#53

Merged
ryanbas21 merged 1 commit into
mainfrom
fix/dead-export-finder-false-negatives
May 14, 2026
Merged

fix(dead-export-finder): resolve false negatives hiding all dead exports#53
ryanbas21 merged 1 commit into
mainfrom
fix/dead-export-finder-false-negatives

Conversation

@ryanbas21
Copy link
Copy Markdown
Owner

Summary

  • Fix critical bug where CLI reported zero dead exports in all cases — @effect/cli's Options.repeated + Options.optional returns Some([]) not None, creating an empty package filter that rejected everything
  • Inherit .gitignore patterns from workspace root during per-package scans, so dist/ build artifacts are properly excluded
  • Add default ignore patterns for config files (*.config.{ts,mjs,cjs,js}) that export for tooling, not for code

Before: dead-export-finder → "No dead exports found" (always)
After: dead-export-finder → correctly reports 93 dead exports across 9 packages in this repo

Test plan

  • 7 new unit tests covering empty packages regression, orphan files, cross-package consumption, gitignore inheritance, and config file exclusion (49 total, all passing)
  • Verified on this repo: T from test.ts is caught, dist/ and config noise eliminated
  • Full workspace scan produces clean, actionable results
  • --packages filter still works when explicitly provided

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bugpackagesOpt.value.length > 0 guard prevents Some([]) from creating a Set that filters out all packages
  • Inherit .gitignore from workspace rootloadGitignorePatterns walks ancestor directories up to workspace root loading .gitignore files per-directory, with concurrent I/O via Effect.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: mainfix/dead-export-finder-false-negatives

Package filter fix

Before: Some([]) creates new Set([]) → empty set rejects all packages → "No dead exports found"
After: packagesOpt.value.length > 0 ensures only non-empty Some values 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.

cli.ts · export-graph.test.ts

Gitignore inheritance

Before: scanner loaded .gitignore only from the package root
After: scanner walks ancestor directories up to workspace root, collecting .gitignore from 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_modules was 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 .gitignore patterns are applied to nested packages
  • Config-file exclusion*.config.ts, *.config.mjs are excluded; index.ts is 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

Pullfrog  | View workflow run𝕏

@ryanbas21 ryanbas21 merged commit 3c6924d into main May 14, 2026
1 check passed
@ryanbas21 ryanbas21 deleted the fix/dead-export-finder-false-negatives branch May 14, 2026 04:20
@github-actions github-actions Bot mentioned this pull request May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant