Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-dead-export-false-negatives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@wolfcola/dead-export-finder': patch
---

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

- Fix empty package filter caused by `Options.repeated` + `Options.optional` returning `Some([])` instead of `None`, which silently filtered out all packages and reported zero dead exports
- Inherit `.gitignore` patterns from workspace root so `dist/` build artifacts are excluded from per-package scans
- Add default ignore patterns for config files (`*.config.{ts,mjs,cjs,js}`) that export for tooling, not for code
7 changes: 5 additions & 2 deletions packages/dead-export-finder/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const scanWorkspace = (
workspace.packages,
Arr.map((pkg) =>
pipe(
scanner.scan(pkg.root, ignoreGlobs),
scanner.scan(pkg.root, ignoreGlobs, workspace.root),
Effect.catchTag('GlobError', (e) =>
Effect.gen(function* () {
const msg = `failed to scan files in ${pkg.root}: ${String(e.cause)}`;
Expand Down Expand Up @@ -279,7 +279,10 @@ const command = Command.make(
yield* Console.log(`Found ${workspace.packages.length} packages`);
}

const packageFilter = packagesOpt._tag === 'Some' ? new Set(packagesOpt.value) : null;
const packageFilter =
packagesOpt._tag === 'Some' && packagesOpt.value.length > 0
? new Set(packagesOpt.value)
: null;

const targetPackages =
packageFilter !== null
Expand Down
107 changes: 107 additions & 0 deletions packages/dead-export-finder/src/lib/export-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,113 @@ it.effect('package specifier re-export does not crash or create incorrect edges'
}),
);

it.effect('empty packages array produces zero dead exports', () =>
Effect.gen(function* () {
// When no packages are provided (the bug that occurred when Options.repeated
// returned Some([]) instead of None), every file is unmappable and silently
// skipped, producing zero dead exports even though dead code exists.
const allExports = new Map<string, readonly ExportedSymbol[]>([
['/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<string, readonly ImportedSymbol[]>();

const result = yield* analyze([], allExports, allImports);

// With no packages, nothing can be attributed → nothing flagged
expect(result.deadExports).toHaveLength(0);
// But exports are still counted
expect(result.totalExports).toBe(2);
}),
);

it.effect('file not covered by star re-export is flagged as dead', () =>
Effect.gen(function* () {
// index.ts re-exports everything from ./lib/used via export *, but
// ./lib/orphan.ts is never re-exported or imported by anyone.
const pkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']);

const allExports = new Map<string, readonly ExportedSymbol[]>([
[
'/test/utils/src/index.ts',
[makeExport('*', '/test/utils/src/index.ts', 1, false, true, './lib/used')],
],
[
'/test/utils/src/lib/used.ts',
[
makeExport('a', '/test/utils/src/lib/used.ts'),
makeExport('b', '/test/utils/src/lib/used.ts'),
],
],
['/test/utils/src/lib/orphan.ts', [makeExport('dead', '/test/utils/src/lib/orphan.ts')]],
]);

const allImports = new Map<string, readonly ImportedSymbol[]>();

const result = yield* analyze([pkg], allExports, allImports);

const deadNames = result.deadExports.map((d) => d.symbol.name);
// a and b are protected by the star re-export
expect(deadNames).not.toContain('a');
expect(deadNames).not.toContain('b');
// dead is in a file nobody re-exports or imports
expect(deadNames).toContain('dead');
expect(result.deadExports).toHaveLength(1);
}),
);

it.effect('cross-package named import marks export as consumed', () =>
Effect.gen(function* () {
// @test/app imports { helperFn } from '@test/utils' by package name.
// helperFn lives in a non-entry-point file, but the package-specifier
// import should mark it as consumed.
const utilsPkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']);
const appPkg = makePackage('@test/app', '/test/app', ['./src/index.ts']);

const allExports = new Map<string, readonly ExportedSymbol[]>([
['/test/utils/src/index.ts', []],
['/test/utils/src/internal.ts', [makeExport('helperFn', '/test/utils/src/internal.ts')]],
['/test/app/src/index.ts', []],
]);

const allImports = new Map<string, readonly ImportedSymbol[]>([
['/test/app/src/index.ts', [makeImport('helperFn', '/test/app/src/index.ts', '@test/utils')]],
]);

const result = yield* analyze([utilsPkg, appPkg], allExports, allImports);

const deadNames = result.deadExports.map((d) => d.symbol.name);
expect(deadNames).not.toContain('helperFn');
}),
);

it.effect('cross-package named import does not protect unrelated exports', () =>
Effect.gen(function* () {
// @test/app imports { used } from '@test/utils', but @test/utils also
// exports { unused } from a different file. Only unused should be dead.
const utilsPkg = makePackage('@test/utils', '/test/utils', ['./src/index.ts']);
const appPkg = makePackage('@test/app', '/test/app', ['./src/index.ts']);

const allExports = new Map<string, readonly ExportedSymbol[]>([
['/test/utils/src/index.ts', []],
['/test/utils/src/used.ts', [makeExport('used', '/test/utils/src/used.ts')]],
['/test/utils/src/unused.ts', [makeExport('unused', '/test/utils/src/unused.ts')]],
['/test/app/src/index.ts', []],
]);

const allImports = new Map<string, readonly ImportedSymbol[]>([
['/test/app/src/index.ts', [makeImport('used', '/test/app/src/index.ts', '@test/utils')]],
]);

const result = yield* analyze([utilsPkg, appPkg], allExports, allImports);

const deadNames = result.deadExports.map((d) => d.symbol.name);
expect(deadNames).not.toContain('used');
expect(deadNames).toContain('unused');
}),
);

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']);
Expand Down
58 changes: 58 additions & 0 deletions packages/dead-export-finder/src/lib/file-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,64 @@ layer(NodeContext.layer)('FileScanner', (it) => {
),
);

it.scoped('inherits .gitignore from workspace root', () =>
withScanner(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tmpDir = yield* fs.makeTempDirectoryScoped();

// Workspace root has .gitignore excluding dist/
yield* fs.writeFileString(path.join(tmpDir, '.gitignore'), 'dist/\n');

// Package is nested under packages/my-lib
const pkgDir = path.join(tmpDir, 'packages', 'my-lib');
const srcDir = path.join(pkgDir, 'src');
const distDir = path.join(pkgDir, 'dist');
yield* fs.makeDirectory(srcDir, { recursive: true });
yield* fs.makeDirectory(distDir, { recursive: true });

yield* fs.writeFileString(path.join(srcDir, 'index.ts'), '');
yield* fs.writeFileString(path.join(distDir, 'index.js'), '');
yield* fs.writeFileString(path.join(distDir, 'index.d.ts'), '');

const scanner = yield* FileScanner;
// Pass workspaceRoot so parent .gitignore is found
const files = yield* scanner.scan(pkgDir, [], tmpDir);

const names = files.map((f) => path.basename(f));
expect(names).toContain('index.ts');
expect(names).not.toContain('index.js');
expect(names).not.toContain('index.d.ts');
}),
),
);

it.scoped('excludes config files by default', () =>
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, 'vite.config.ts'), '');
yield* fs.writeFileString(path.join(tmpDir, 'eslint.config.mjs'), '');
yield* fs.writeFileString(path.join(tmpDir, 'vitest.config.ts'), '');
yield* fs.writeFileString(path.join(tmpDir, 'tsconfig.json'), '');

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).not.toContain('vite.config.ts');
expect(names).not.toContain('eslint.config.mjs');
expect(names).not.toContain('vitest.config.ts');
}),
),
);

it.scoped('respects custom ignore globs', () =>
withScanner(
Effect.gen(function* () {
Expand Down
52 changes: 40 additions & 12 deletions packages/dead-export-finder/src/lib/file-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@ class GlobError extends Data.TaggedError('GlobError')<{

// ─── Pure helpers ────────────────────────────────────────────────────────────

const loadGitignorePatterns = (
const loadGitignoreAt = (
fs: FileSystem.FileSystem,
pathSvc: Path.Path,
root: string,
): Effect.Effect<ReadonlyArray<string>> => {
const gitignorePath = pathSvc.join(root, '.gitignore');
return pipe(
gitignorePath: string,
): Effect.Effect<ReadonlyArray<string>> =>
pipe(
fs.exists(gitignorePath),
Effect.orElseSucceed(() => false),
Effect.flatMap((exists) =>
Expand All @@ -30,17 +28,45 @@ const loadGitignorePatterns = (
: Effect.succeed([] as ReadonlyArray<string>),
),
);

const loadGitignorePatterns = (
fs: FileSystem.FileSystem,
pathSvc: Path.Path,
root: string,
workspaceRoot: string | undefined,
): Effect.Effect<ReadonlyArray<string>> => {
const dirs = [root];
if (workspaceRoot !== undefined && workspaceRoot !== root) {
// Walk from package root up to workspace root, collecting .gitignore files
let current = pathSvc.dirname(root);
while (current.length >= workspaceRoot.length && current !== pathSvc.dirname(current)) {
dirs.push(current);
if (current === workspaceRoot) break;
current = pathSvc.dirname(current);
}
}

return pipe(
dirs,
Arr.map((dir) => loadGitignoreAt(fs, pathSvc.join(dir, '.gitignore'))),
(effects) => Effect.all(effects),
Effect.map(Arr.flatten),
);
};

const DEFAULT_IGNORE: ReadonlyArray<string> = [
'node_modules',
'*.config.ts',
'*.config.mjs',
'*.config.cjs',
'*.config.js',
];

const buildIgnorePatterns = (
gitignorePatterns: ReadonlyArray<string>,
customGlobs: readonly string[],
): ReadonlyArray<string> =>
pipe(
['node_modules'] as ReadonlyArray<string>,
Arr.appendAll(gitignorePatterns),
Arr.appendAll(customGlobs),
);
pipe(DEFAULT_IGNORE, Arr.appendAll(gitignorePatterns), Arr.appendAll(customGlobs));

const discoverFiles = (root: string): Effect.Effect<ReadonlyArray<string>, GlobError> =>
Effect.tryPromise({
Expand Down Expand Up @@ -78,6 +104,7 @@ export interface FileScannerShape {
readonly scan: (
root: string,
ignoreGlobs: readonly string[],
workspaceRoot?: string,
) => Effect.Effect<readonly string[], GlobError>;
}

Expand All @@ -96,9 +123,10 @@ export const FileScannerLive = Layer.effect(
const scan = (
root: string,
ignoreGlobs: readonly string[],
workspaceRoot?: string,
): Effect.Effect<readonly string[], GlobError> =>
pipe(
loadGitignorePatterns(fs, pathSvc, root),
loadGitignorePatterns(fs, pathSvc, root, workspaceRoot),
Effect.map((gitignorePatterns) => buildIgnorePatterns(gitignorePatterns, ignoreGlobs)),
Effect.flatMap((patterns) =>
pipe(
Expand Down
Loading
Loading