Skip to content

Commit 1136271

Browse files
authored
Merge pull request #52 from ryanbas21/fix/split-cli-from-library-export
fix(dead-export-finder): split CLI from library exports
2 parents 00b1a06 + c52d8bf commit 1136271

3 files changed

Lines changed: 366 additions & 367 deletions

File tree

packages/dead-export-finder/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"main": "./dist/index.js",
1414
"types": "./dist/index.d.ts",
1515
"bin": {
16-
"dead-export-finder": "./dist/index.js"
16+
"dead-export-finder": "./dist/cli.js"
1717
},
1818
"exports": {
1919
".": {
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
#!/usr/bin/env node
2+
3+
import { Command, Options } from '@effect/cli';
4+
import { NodeContext, NodeRuntime } from '@effect/platform-node';
5+
import { FileSystem } from '@effect/platform';
6+
import { Console, Data, Effect, Layer, Array as Arr, Option, pipe } from 'effect';
7+
import { WorkspaceDetector, WorkspaceDetectorLive } from './lib/workspace-detector.js';
8+
import type { WorkspaceResult } from './lib/workspace-detector.js';
9+
import { FileScanner, FileScannerLive } from './lib/file-scanner.js';
10+
import { ExportParser, ExportParserLive } from './lib/export-parser.js';
11+
import { ImportParser, ImportParserLive } from './lib/import-parser.js';
12+
import { ExportGraph, ExportGraphLive } from './lib/export-graph.js';
13+
import { Reporter, ReporterLive } from './lib/reporter.js';
14+
import type { PackageInfo, ExportedSymbol, ImportedSymbol } from './lib/schemas.js';
15+
16+
// ─── Exit code error ──────────────────────────────────────────────────────────
17+
18+
class ExitWithCode extends Data.TaggedError('ExitWithCode')<{
19+
readonly code: number;
20+
}> {}
21+
22+
// ─── Options ──────────────────────────────────────────────────────────────────
23+
24+
const packages = Options.text('packages').pipe(
25+
Options.withAlias('p'),
26+
Options.withDescription('Scope analysis to specific package names (repeat for multiple).'),
27+
Options.repeated,
28+
Options.optional,
29+
);
30+
31+
const ignore = Options.text('ignore').pipe(
32+
Options.withAlias('i'),
33+
Options.withDescription('Glob patterns to exclude from scanning (repeat for multiple).'),
34+
Options.repeated,
35+
Options.optional,
36+
);
37+
38+
const verbose = Options.boolean('verbose').pipe(
39+
Options.withAlias('v'),
40+
Options.withDescription('Print verbose output including timing and parse warnings.'),
41+
Options.withDefault(false),
42+
);
43+
44+
// ─── Layer ────────────────────────────────────────────────────────────────────
45+
46+
const AppLayer = Layer.mergeAll(
47+
ExportParserLive,
48+
ImportParserLive,
49+
ExportGraphLive,
50+
ReporterLive,
51+
WorkspaceDetectorLive,
52+
FileScannerLive,
53+
).pipe(Layer.provideMerge(NodeContext.layer));
54+
55+
// ─── Pipeline stages ────────────────────────────────────────────────────────
56+
57+
interface ScanResult {
58+
readonly filesByPackage: ReadonlyArray<readonly [PackageInfo, ReadonlyArray<string>]>;
59+
readonly warnings: ReadonlyArray<string>;
60+
}
61+
62+
const scanWorkspace = (
63+
workspace: WorkspaceResult,
64+
ignoreGlobs: readonly string[],
65+
isVerbose: boolean,
66+
): Effect.Effect<ScanResult, never, FileScanner> =>
67+
Effect.gen(function* () {
68+
const scanner = yield* FileScanner;
69+
70+
const results = yield* Effect.all(
71+
pipe(
72+
workspace.packages,
73+
Arr.map((pkg) =>
74+
pipe(
75+
scanner.scan(pkg.root, ignoreGlobs),
76+
Effect.catchTag('GlobError', (e) =>
77+
Effect.gen(function* () {
78+
const msg = `failed to scan files in ${pkg.root}: ${String(e.cause)}`;
79+
if (isVerbose) yield* Console.log(`Warning: ${msg}`);
80+
return { files: [] as readonly string[], warning: msg };
81+
}),
82+
),
83+
Effect.map((result) =>
84+
'warning' in (result as object)
85+
? (result as { files: readonly string[]; warning: string })
86+
: { files: result as readonly string[], warning: null as string | null },
87+
),
88+
Effect.map((r) => ({ pkg, files: r.files, warning: r.warning })),
89+
),
90+
),
91+
),
92+
);
93+
94+
return {
95+
filesByPackage: pipe(
96+
results,
97+
Arr.map((r) => [r.pkg, r.files] as const),
98+
),
99+
warnings: pipe(
100+
results,
101+
Arr.filterMap((r) => (r.warning !== null ? Option.some(r.warning) : Option.none())),
102+
),
103+
};
104+
});
105+
106+
interface ParseResult {
107+
readonly allExports: ReadonlyMap<string, readonly ExportedSymbol[]>;
108+
readonly allImports: ReadonlyMap<string, readonly ImportedSymbol[]>;
109+
readonly warnings: ReadonlyArray<string>;
110+
}
111+
112+
const parseAllFiles = (
113+
filesByPackage: ReadonlyArray<readonly [PackageInfo, ReadonlyArray<string>]>,
114+
isVerbose: boolean,
115+
): Effect.Effect<ParseResult, never, ExportParser | ImportParser | FileSystem.FileSystem> =>
116+
Effect.gen(function* () {
117+
const fs = yield* FileSystem.FileSystem;
118+
const exportParser = yield* ExportParser;
119+
const importParser = yield* ImportParser;
120+
121+
const allFiles = pipe(
122+
filesByPackage,
123+
Arr.flatMap(([, files]) => files),
124+
);
125+
126+
const fileResults = yield* Effect.all(
127+
pipe(
128+
allFiles,
129+
Arr.map((filePath) =>
130+
pipe(
131+
fs.readFileString(filePath, 'utf-8'),
132+
Effect.either,
133+
Effect.flatMap((sourceResult) => {
134+
if (sourceResult._tag === 'Left') {
135+
const msg = `could not read ${filePath}: ${String(sourceResult.left)}`;
136+
return isVerbose
137+
? pipe(
138+
Console.log(`Warning: ${msg}`),
139+
Effect.map(() => ({
140+
filePath,
141+
exports: [] as readonly ExportedSymbol[],
142+
imports: [] as readonly ImportedSymbol[],
143+
warning: msg as string | null,
144+
})),
145+
)
146+
: Effect.succeed({
147+
filePath,
148+
exports: [] as readonly ExportedSymbol[],
149+
imports: [] as readonly ImportedSymbol[],
150+
warning: msg as string | null,
151+
});
152+
}
153+
154+
const source = sourceResult.right;
155+
156+
const parseExports = pipe(
157+
exportParser.parse(filePath, source),
158+
Effect.map(
159+
(symbols): { symbols: readonly ExportedSymbol[]; warning: string | null } => ({
160+
symbols,
161+
warning: null,
162+
}),
163+
),
164+
Effect.catchTag('ParseError', (e) => {
165+
const msg = `failed to parse exports in ${e.filePath}: ${e.message}`;
166+
const result = { symbols: [] as readonly ExportedSymbol[], warning: msg };
167+
return isVerbose
168+
? pipe(
169+
Console.log(`Warning: ${msg}`),
170+
Effect.map(() => result),
171+
)
172+
: Effect.succeed(result);
173+
}),
174+
);
175+
176+
const parseImports = pipe(
177+
importParser.parse(filePath, source),
178+
Effect.map(
179+
(symbols): { symbols: readonly ImportedSymbol[]; warning: string | null } => ({
180+
symbols,
181+
warning: null,
182+
}),
183+
),
184+
Effect.catchTag('ParseError', (e) => {
185+
const msg = `failed to parse imports in ${e.filePath}: ${e.message}`;
186+
const result = { symbols: [] as readonly ImportedSymbol[], warning: msg };
187+
return isVerbose
188+
? pipe(
189+
Console.log(`Warning: ${msg}`),
190+
Effect.map(() => result),
191+
)
192+
: Effect.succeed(result);
193+
}),
194+
);
195+
196+
return pipe(
197+
Effect.all({ exports: parseExports, imports: parseImports }),
198+
Effect.map(({ exports: exp, imports: imp }) => ({
199+
filePath,
200+
exports: exp.symbols,
201+
imports: imp.symbols,
202+
warning: exp.warning ?? imp.warning,
203+
})),
204+
);
205+
}),
206+
),
207+
),
208+
),
209+
);
210+
211+
const exportEntries = pipe(
212+
fileResults,
213+
Arr.filter((r) => r.exports.length > 0),
214+
Arr.map((r) => [r.filePath, r.exports] as const),
215+
);
216+
217+
const importEntries = pipe(
218+
fileResults,
219+
Arr.filter((r) => r.imports.length > 0),
220+
Arr.map((r) => [r.filePath, r.imports] as const),
221+
);
222+
223+
const warnings = pipe(
224+
fileResults,
225+
Arr.filterMap((r) => (r.warning !== null ? Option.some(r.warning) : Option.none())),
226+
);
227+
228+
return {
229+
allExports: new Map(exportEntries) as ReadonlyMap<string, readonly ExportedSymbol[]>,
230+
allImports: new Map(importEntries) as ReadonlyMap<string, readonly ImportedSymbol[]>,
231+
warnings,
232+
};
233+
});
234+
235+
const analyzeAndReport = (
236+
targetPackages: ReadonlyArray<PackageInfo>,
237+
allPackages: ReadonlyArray<PackageInfo>,
238+
allExports: ReadonlyMap<string, readonly ExportedSymbol[]>,
239+
allImports: ReadonlyMap<string, readonly ImportedSymbol[]>,
240+
): Effect.Effect<
241+
{ readonly deadCount: number; readonly warnings: ReadonlyArray<string> },
242+
never,
243+
ExportGraph | Reporter
244+
> =>
245+
Effect.gen(function* () {
246+
const graph = yield* ExportGraph;
247+
const reporter = yield* Reporter;
248+
249+
const result = yield* graph.analyze(targetPackages, allExports, allImports);
250+
251+
const packageRoots: ReadonlyMap<string, string> = new Map(
252+
pipe(
253+
allPackages,
254+
Arr.map((p) => [p.name, p.root] as const),
255+
),
256+
);
257+
258+
const report = reporter.format(result, packageRoots);
259+
yield* Console.log(report);
260+
261+
return { deadCount: result.deadExports.length, warnings: result.warnings };
262+
});
263+
264+
// ─── Command ──────────────────────────────────────────────────────────────────
265+
266+
const command = Command.make(
267+
'dead-export-finder',
268+
{ packages, ignore, verbose },
269+
({ packages: packagesOpt, ignore: ignoreOpt, verbose: isVerbose }) =>
270+
Effect.gen(function* () {
271+
const startTime = Date.now();
272+
273+
const detector = yield* WorkspaceDetector;
274+
const cwd = process.cwd();
275+
const workspace = yield* detector.detect(cwd);
276+
277+
if (isVerbose) {
278+
yield* Console.log(`Detected workspace type: ${workspace.type}`);
279+
yield* Console.log(`Found ${workspace.packages.length} packages`);
280+
}
281+
282+
const packageFilter = packagesOpt._tag === 'Some' ? new Set(packagesOpt.value) : null;
283+
284+
const targetPackages =
285+
packageFilter !== null
286+
? pipe(
287+
workspace.packages,
288+
Arr.filter((p) => packageFilter.has(p.name)),
289+
)
290+
: [...workspace.packages];
291+
292+
const ignoreGlobs: readonly string[] = ignoreOpt._tag === 'Some' ? ignoreOpt.value : [];
293+
294+
if (isVerbose && packageFilter !== null && targetPackages.length > 0) {
295+
yield* Console.log(
296+
`Scoping to packages: ${pipe(
297+
targetPackages,
298+
Arr.map((p) => p.name),
299+
Arr.join(', '),
300+
)}`,
301+
);
302+
}
303+
304+
const scanResult = yield* scanWorkspace(workspace, ignoreGlobs, isVerbose);
305+
306+
const parseResult = yield* parseAllFiles(scanResult.filesByPackage, isVerbose);
307+
308+
if (isVerbose) {
309+
yield* Console.log(
310+
`Scanned ${parseResult.allExports.size} files with exports, ${parseResult.allImports.size} files with imports`,
311+
);
312+
}
313+
314+
const { deadCount, warnings: analysisWarnings } = yield* analyzeAndReport(
315+
targetPackages,
316+
[...workspace.packages],
317+
parseResult.allExports,
318+
parseResult.allImports,
319+
);
320+
321+
const allWarnings = pipe(
322+
scanResult.warnings,
323+
Arr.appendAll(parseResult.warnings),
324+
Arr.appendAll(analysisWarnings),
325+
);
326+
327+
if (allWarnings.length > 0 && !isVerbose) {
328+
yield* Console.log(
329+
`\nWarning: ${allWarnings.length} issue(s) during analysis — results may be incomplete. Run with --verbose for details.`,
330+
);
331+
}
332+
333+
if (isVerbose) {
334+
const elapsed = Date.now() - startTime;
335+
yield* Console.log(`\nCompleted in ${elapsed}ms`);
336+
}
337+
338+
if (deadCount > 0) {
339+
return yield* new ExitWithCode({ code: 1 });
340+
}
341+
}),
342+
).pipe(Command.withDescription('Find dead exports across monorepo package boundaries.'));
343+
344+
// ─── Runner ───────────────────────────────────────────────────────────────────
345+
346+
const cli = Command.run(command, {
347+
name: 'Dead Export Finder',
348+
version: '0.0.0',
349+
});
350+
351+
cli(process.argv).pipe(
352+
Effect.catchTags({
353+
ExitWithCode: (e) => Effect.sync(() => (process.exitCode = e.code)),
354+
WorkspaceNotFoundError: (e) =>
355+
Console.error(`error: workspace not found at ${e.cwd}`).pipe(
356+
Effect.zipRight(
357+
Effect.sync(() => {
358+
process.exitCode = 1;
359+
}),
360+
),
361+
),
362+
}),
363+
Effect.provide(AppLayer),
364+
NodeRuntime.runMain,
365+
);

0 commit comments

Comments
 (0)