@iamkurubaran/dead-code 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.1] - 2026-07-07
9
+
10
+ ### Changed
11
+
12
+ - Updated repository references and packaging metadata to point to the correct GitHub repository.
13
+ - Refreshed the README to be more polished, professional, and developer-friendly.
14
+
15
+ ## [1.0.0] - 2026-07-07
16
+
17
+ ### Added
18
+
19
+ - Initial release.
20
+ - Cross-file, monorepo-aware detection of unused exports.
21
+ - Static analysis for ES modules (named, default, namespace, dynamic `import()`,
22
+ re-exports, `as` aliases), CommonJS (`require`, destructured require,
23
+ `module.exports`, `exports.x`), and TypeScript (`interface`, `type`, `enum`,
24
+ `namespace`, `export type`).
25
+ - Automatic entry-point discovery from every `package.json` in the tree
26
+ (`main`, `module`, `bin`, `exports`) plus user-supplied `--entry` globs.
27
+ - Extensionless and `index.*` directory import resolution, including `.js`
28
+ specifiers that resolve to `.ts` sources.
29
+ - Barrel (`export * from`) usage propagation to avoid false positives.
30
+ - Human-readable terminal reporter and machine-readable `--json` reporter.
31
+ - `--fail-on-found` flag for CI pipelines.
32
+ - Programmatic API via `findDeadCode()`.
33
+
34
+ [1.0.1]: https://github.com/iamkurubaran/DeadCode/releases/tag/v1.0.1
35
+ [1.0.0]: https://github.com/iamkurubaran/DeadCode/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kurubaran Anandhan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # @iamkurubaran/dead-code
2
+
3
+ A fast, zero-config CLI for finding exports that are imported nowhere in your project or monorepo.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@iamkurubaran/dead-code.svg)](https://www.npmjs.com/package/@iamkurubaran/dead-code)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@iamkurubaran/dead-code.svg)](https://www.npmjs.com/package/@iamkurubaran/dead-code)
7
+ [![node](https://img.shields.io/node/v/@iamkurubaran/dead-code.svg)](https://nodejs.org)
8
+ [![license](https://img.shields.io/npm/l/@iamkurubaran/dead-code.svg)](./LICENSE)
9
+ [![GitHub](https://img.shields.io/badge/GitHub-iamkurubaran%2FDeadCode-181717?logo=github)](https://github.com/iamkurubaran/DeadCode)
10
+
11
+ ## Why this tool exists
12
+
13
+ Dead exports are easy to miss in growing codebases. They often linger after refactors, redesigns, or API cleanup, and they can make maintenance harder than it needs to be.
14
+
15
+ `dead-code` helps you find exports that are no longer consumed anywhere in your repository so you can remove them confidently.
16
+
17
+ ## Key features
18
+
19
+ - Cross-file and cross-package analysis for monorepos
20
+ - Zero configuration and no build step required
21
+ - Support for ES modules, CommonJS, and TypeScript syntax
22
+ - Entry-point awareness for public APIs and package exports
23
+ - Human-readable terminal output and JSON output for CI
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install --save-dev @iamkurubaran/dead-code
29
+ ```
30
+
31
+ You can also run it without installing it globally:
32
+
33
+ ```bash
34
+ npx @iamkurubaran/dead-code
35
+ ```
36
+
37
+ Requirements: Node.js 18 or newer.
38
+
39
+ ## Quick start
40
+
41
+ From the root of your project:
42
+
43
+ ```bash
44
+ npx @iamkurubaran/dead-code
45
+ ```
46
+
47
+ To protect your public API from being reported as unused, add entry points:
48
+
49
+ ```bash
50
+ dead-code --entry "src/index.ts" --entry "src/index.js"
51
+ ```
52
+
53
+ For CI workflows, fail the build when dead exports are found:
54
+
55
+ ```bash
56
+ dead-code --fail-on-found --entry "src/index.ts"
57
+ ```
58
+
59
+ ## CLI reference
60
+
61
+ ```bash
62
+ dead-code [paths...] [options]
63
+ ```
64
+
65
+ ### Common options
66
+
67
+ - `-e, --entry <glob>`: treat matching files as public entry points
68
+ - `-i, --ignore <glob>`: exclude additional files or patterns
69
+ - `--cwd <dir>`: analyze a different project root
70
+ - `--json`: emit structured JSON output
71
+ - `--fail-on-found`: exit with code `1` when unused exports are found
72
+ - `-h, --help`: show help
73
+ - `-v, --version`: show the installed version
74
+
75
+ ## Programmatic API
76
+
77
+ ```js
78
+ import { findDeadCode } from '@iamkurubaran/dead-code';
79
+
80
+ const result = await findDeadCode({
81
+ cwd: process.cwd(),
82
+ patterns: ['src/**/*.ts'],
83
+ entry: ['src/index.ts'],
84
+ ignore: ['**/*.test.ts']
85
+ });
86
+
87
+ console.log(result.unused);
88
+ ```
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ git clone https://github.com/iamkurubaran/DeadCode.git
94
+ cd DeadCode
95
+ npm install
96
+ npm test
97
+ ```
98
+
99
+ ## Contributing
100
+
101
+ Contributions are welcome. Please open an issue or submit a pull request if you would like to improve the analyzer, add support for new syntax, or improve the documentation.
102
+
103
+ ## License
104
+
105
+ [MIT](./LICENSE) © Kurubaran Anandhan
package/bin/cli.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import pc from 'picocolors';
4
+ import { findDeadCode } from '../src/index.js';
5
+ import { reportTerminal } from '../src/reporters/terminal.js';
6
+ import { reportJson } from '../src/reporters/json.js';
7
+
8
+ const HELP = `
9
+ ${pc.bold('dead-code')} — find unused exports across a monorepo
10
+
11
+ ${pc.bold('USAGE')}
12
+ dead-code [paths...] [options]
13
+
14
+ ${pc.bold('ARGUMENTS')}
15
+ paths One or more glob patterns to scan
16
+ (default: **/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts})
17
+
18
+ ${pc.bold('OPTIONS')}
19
+ -e, --entry <glob> Mark files as public API entry points (repeatable).
20
+ Their exports are never reported as dead.
21
+ -i, --ignore <glob> Extra ignore pattern (repeatable).
22
+ --cwd <dir> Project root to analyze (default: current directory).
23
+ --json Output machine-readable JSON.
24
+ --fail-on-found Exit with code 1 if any dead exports are found (CI).
25
+ -h, --help Show this help.
26
+ -v, --version Show version.
27
+
28
+ ${pc.bold('EXAMPLES')}
29
+ dead-code
30
+ dead-code "packages/**/*.ts" --entry "packages/*/src/index.ts"
31
+ dead-code --json --fail-on-found
32
+ dead-code src --ignore "**/*.stories.tsx"
33
+ `;
34
+
35
+ async function main() {
36
+ const argv = process.argv.slice(2);
37
+ const opts = {
38
+ patterns: [],
39
+ entry: [],
40
+ ignore: [],
41
+ cwd: undefined,
42
+ json: false,
43
+ failOnFound: false,
44
+ };
45
+
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const arg = argv[i];
48
+ switch (arg) {
49
+ case '-h':
50
+ case '--help':
51
+ console.log(HELP);
52
+ return 0;
53
+ case '-v':
54
+ case '--version': {
55
+ const { readFile } = await import('node:fs/promises');
56
+ const { fileURLToPath } = await import('node:url');
57
+ const url = new URL('../package.json', import.meta.url);
58
+ const pkg = JSON.parse(await readFile(fileURLToPath(url), 'utf8'));
59
+ console.log(pkg.version);
60
+ return 0;
61
+ }
62
+ case '-e':
63
+ case '--entry':
64
+ opts.entry.push(argv[++i]);
65
+ break;
66
+ case '-i':
67
+ case '--ignore':
68
+ opts.ignore.push(argv[++i]);
69
+ break;
70
+ case '--cwd':
71
+ opts.cwd = argv[++i];
72
+ break;
73
+ case '--json':
74
+ opts.json = true;
75
+ break;
76
+ case '--fail-on-found':
77
+ opts.failOnFound = true;
78
+ break;
79
+ default:
80
+ if (arg.startsWith('-')) {
81
+ console.error(pc.red(`Unknown option: ${arg}`));
82
+ console.error(`Run ${pc.cyan('dead-code --help')} for usage.`);
83
+ return 2;
84
+ }
85
+ opts.patterns.push(arg);
86
+ }
87
+ }
88
+
89
+ const result = await findDeadCode({
90
+ cwd: opts.cwd,
91
+ patterns: opts.patterns,
92
+ ignore: opts.ignore,
93
+ entry: opts.entry,
94
+ });
95
+
96
+ if (opts.json) {
97
+ reportJson(result, result.root);
98
+ } else {
99
+ reportTerminal(result, result.root);
100
+ }
101
+
102
+ if (opts.failOnFound && result.unused.length > 0) return 1;
103
+ return 0;
104
+ }
105
+
106
+ main()
107
+ .then((code) => process.exit(code))
108
+ .catch((err) => {
109
+ console.error(pc.red('dead-code crashed:'), err.message);
110
+ process.exit(2);
111
+ });
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@iamkurubaran/dead-code",
3
+ "version": "1.0.2",
4
+ "description": "Find unused exports across a monorepo — not just per-file. A fast, zero-config CLI that builds a cross-file module graph and reports exports that are imported nowhere.",
5
+ "keywords": [
6
+ "dead-code",
7
+ "unused-exports",
8
+ "unused-code",
9
+ "monorepo",
10
+ "tree-shaking",
11
+ "linter",
12
+ "static-analysis",
13
+ "code-cleanup",
14
+ "refactoring",
15
+ "cli",
16
+ "typescript",
17
+ "javascript",
18
+ "esm",
19
+ "commonjs"
20
+ ],
21
+ "license": "MIT",
22
+ "author": "Kurubaran Anandhan",
23
+ "type": "module",
24
+ "bin": {
25
+ "dead-code": "bin/cli.js"
26
+ },
27
+ "main": "src/index.js",
28
+ "exports": {
29
+ ".": {
30
+ "import": "./src/index.js",
31
+ "default": "./src/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test test/*.test.js",
43
+ "start": "node bin/cli.js",
44
+ "lint:self": "node bin/cli.js \"src/**/*.js\" \"bin/**/*.js\" --entry \"src/index.js\" --entry \"bin/cli.js\"",
45
+ "prepublishOnly": "npm test"
46
+ },
47
+ "engines": {
48
+ "node": ">=18.0.0"
49
+ },
50
+ "dependencies": {
51
+ "fast-glob": "^3.3.2",
52
+ "picocolors": "^1.0.0"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/iamkurubaran/DeadCode.git"
57
+ },
58
+ "homepage": "https://github.com/iamkurubaran/DeadCode#readme",
59
+ "bugs": {
60
+ "url": "https://github.com/iamkurubaran/DeadCode/issues"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { resolveImport } from '../utils/paths.js';
5
+
6
+ /**
7
+ * Determine the set of entry-point files whose exports form the public API
8
+ * and should therefore never be flagged as dead.
9
+ *
10
+ * Sources:
11
+ * - package.json "main", "module", "bin", "exports"
12
+ * - test files (they consume exports but are leaves; we treat their imports
13
+ * as usage automatically, so no special handling needed here)
14
+ * - user-supplied entry globs
15
+ *
16
+ * @param {string} root project root
17
+ * @param {Set<string>} knownFiles resolved set of scanned files
18
+ * @param {string[]} userEntries extra glob patterns from CLI
19
+ * @returns {Promise<Set<string>>}
20
+ */
21
+ export async function resolveEntryPoints(root, knownFiles, userEntries = []) {
22
+ const entries = new Set();
23
+
24
+ // Walk every package.json in the tree (monorepo-aware).
25
+ const pkgFiles = await fg('**/package.json', {
26
+ cwd: root,
27
+ absolute: true,
28
+ ignore: ['**/node_modules/**'],
29
+ });
30
+
31
+ for (const pkgFile of pkgFiles) {
32
+ let pkg;
33
+ try {
34
+ pkg = JSON.parse(await fs.readFile(pkgFile, 'utf8'));
35
+ } catch {
36
+ continue;
37
+ }
38
+ const pkgDir = path.dirname(pkgFile);
39
+ const specifiers = collectPkgEntrySpecifiers(pkg);
40
+ for (const spec of specifiers) {
41
+ const normalized = spec.startsWith('.') ? spec : './' + spec;
42
+ const resolved = resolveImport(
43
+ path.join(pkgDir, 'package.json'),
44
+ normalized,
45
+ knownFiles
46
+ );
47
+ if (resolved) entries.add(resolved);
48
+ }
49
+ }
50
+
51
+ // User-supplied entry globs.
52
+ if (userEntries.length) {
53
+ const matched = await fg(userEntries, {
54
+ cwd: root,
55
+ absolute: true,
56
+ ignore: ['**/node_modules/**'],
57
+ });
58
+ for (const m of matched) if (knownFiles.has(m)) entries.add(m);
59
+ }
60
+
61
+ return entries;
62
+ }
63
+
64
+ function collectPkgEntrySpecifiers(pkg) {
65
+ const out = [];
66
+ if (typeof pkg.main === 'string') out.push(pkg.main);
67
+ if (typeof pkg.module === 'string') out.push(pkg.module);
68
+
69
+ if (typeof pkg.bin === 'string') out.push(pkg.bin);
70
+ else if (pkg.bin && typeof pkg.bin === 'object') out.push(...Object.values(pkg.bin));
71
+
72
+ collectExportsField(pkg.exports, out);
73
+ return out.filter((s) => typeof s === 'string');
74
+ }
75
+
76
+ function collectExportsField(exp, out) {
77
+ if (!exp) return;
78
+ if (typeof exp === 'string') {
79
+ out.push(exp);
80
+ return;
81
+ }
82
+ if (typeof exp === 'object') {
83
+ for (const value of Object.values(exp)) collectExportsField(value, out);
84
+ }
85
+ }
@@ -0,0 +1,149 @@
1
+ import { resolveImport } from '../utils/paths.js';
2
+
3
+ /**
4
+ * Build a cross-file module graph and compute which exports are never
5
+ * imported anywhere in the project.
6
+ *
7
+ * Key ideas that make this monorepo-aware (vs per-file linters):
8
+ * 1. We resolve every relative import to a concrete file on disk.
9
+ * 2. Usage is aggregated ACROSS all files: an export is "used" if ANY
10
+ * other file imports it (by name, namespace, or via export-star chains).
11
+ * 3. Entry points (bin, main, exports in package.json, and user globs) are
12
+ * roots whose exports are always considered "used" (public API surface).
13
+ *
14
+ * @param {Map<string, object>} fileMap absPath -> scanFile() record
15
+ * @param {object} opts
16
+ * @param {Set<string>} opts.entryFiles absolute paths treated as public roots
17
+ * @returns {{
18
+ * unused: Array<{file:string, name:string, line:number, kind:string}>,
19
+ * totalExports: number,
20
+ * usedExports: number,
21
+ * fileCount: number
22
+ * }}
23
+ */
24
+ export function analyze(fileMap, { entryFiles = new Set() } = {}) {
25
+ const knownFiles = new Set(fileMap.keys());
26
+
27
+ // usage[targetFile] = Set of names consumed from that file (or '*' for all)
28
+ const usage = new Map();
29
+ const markUsed = (file, name) => {
30
+ if (!usage.has(file)) usage.set(file, new Set());
31
+ usage.get(file).add(name);
32
+ };
33
+
34
+ // Track re-export star edges so we can propagate usage transitively:
35
+ // if file A does `export * from B` and something imports name X from A,
36
+ // then X counts as used in B too.
37
+ const starReexports = new Map(); // file -> [targetFile,...]
38
+
39
+ // ---- First pass: resolve imports and record direct usage --------------
40
+ for (const [absPath, record] of fileMap) {
41
+ for (const imp of record.imports) {
42
+ const target = resolveImport(absPath, imp.specifier, knownFiles);
43
+ if (!target) continue; // external package or unresolved
44
+
45
+ if (imp.sideEffectOnly) {
46
+ // Side-effect import doesn't consume any named export, but keeps the
47
+ // module "reachable". We record nothing name-specific.
48
+ continue;
49
+ }
50
+
51
+ if (imp.namespace) {
52
+ markUsed(target, '*');
53
+ // A namespace/star re-export also creates a passthrough edge.
54
+ starReexports.set(absPath, [...(starReexports.get(absPath) || []), target]);
55
+ continue;
56
+ }
57
+
58
+ for (const name of imp.names) {
59
+ markUsed(target, name);
60
+ }
61
+ }
62
+ }
63
+
64
+ // ---- Mark entry-point exports as used (public API) --------------------
65
+ for (const entry of entryFiles) {
66
+ if (fileMap.has(entry)) markUsed(entry, '*');
67
+ }
68
+
69
+ // ---- Propagate usage through export-star chains -----------------------
70
+ // If names are consumed from a barrel file that re-exports *, those names
71
+ // may actually live in the re-exported target.
72
+ propagateStarUsage(fileMap, usage, starReexports, knownFiles, markUsed);
73
+
74
+ // ---- Second pass: determine unused exports ----------------------------
75
+ const unused = [];
76
+ let totalExports = 0;
77
+ let usedExports = 0;
78
+
79
+ for (const [absPath, record] of fileMap) {
80
+ const usedHere = usage.get(absPath);
81
+ const allUsed = usedHere && usedHere.has('*');
82
+
83
+ for (const exp of record.exports) {
84
+ if (exp.kind === 'star') continue; // re-export star isn't a named export
85
+ totalExports++;
86
+
87
+ const isUsed = allUsed || (usedHere && usedHere.has(exp.name));
88
+
89
+ if (isUsed) {
90
+ usedExports++;
91
+ } else {
92
+ unused.push({
93
+ file: absPath,
94
+ name: exp.name,
95
+ line: exp.line,
96
+ kind: exp.kind,
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ return {
103
+ unused,
104
+ totalExports,
105
+ usedExports,
106
+ fileCount: fileMap.size,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * When a barrel re-exports `* from './target'`, and some file imports named
112
+ * bindings from the barrel, we can't know which names came from which target.
113
+ * Conservative rule: if a barrel has any '*' usage OR named usage, propagate
114
+ * '*' to all its star-reexport targets. This avoids false positives (marking
115
+ * genuinely-used code as dead) at the cost of occasionally missing dead code.
116
+ */
117
+ function propagateStarUsage(fileMap, usage, starReexports, knownFiles, markUsed) {
118
+ let changed = true;
119
+ const guard = new Set();
120
+ while (changed) {
121
+ changed = false;
122
+ for (const [absPath, record] of fileMap) {
123
+ // Does this file re-export * from somewhere?
124
+ const starTargets = [];
125
+ for (const imp of record.imports) {
126
+ if (imp.namespace && imp.specifier.startsWith('.')) {
127
+ const target = resolveImport(absPath, imp.specifier, knownFiles);
128
+ if (target) starTargets.push(target);
129
+ }
130
+ }
131
+ if (starTargets.length === 0) continue;
132
+
133
+ const usedHere = usage.get(absPath);
134
+ const consumed = usedHere && usedHere.size > 0;
135
+ if (!consumed) continue;
136
+
137
+ for (const target of starTargets) {
138
+ const key = absPath + '->' + target;
139
+ const before = usage.get(target)?.size || 0;
140
+ markUsed(target, '*');
141
+ const after = usage.get(target).size;
142
+ if (after !== before && !guard.has(key)) {
143
+ guard.add(key);
144
+ changed = true;
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
package/src/index.js ADDED
@@ -0,0 +1,64 @@
1
+ import path from 'node:path';
2
+ import fg from 'fast-glob';
3
+ import { scanFile } from './scanners/file.js';
4
+ import { analyze } from './analyzers/graph.js';
5
+ import { resolveEntryPoints } from './analyzers/entrypoints.js';
6
+ import { JS_EXTENSIONS } from './utils/paths.js';
7
+
8
+ const DEFAULT_IGNORE = [
9
+ '**/node_modules/**',
10
+ '**/dist/**',
11
+ '**/build/**',
12
+ '**/coverage/**',
13
+ '**/.next/**',
14
+ '**/*.d.ts',
15
+ ];
16
+
17
+ /**
18
+ * Run a full dead-code analysis over a project.
19
+ *
20
+ * @param {object} options
21
+ * @param {string} [options.cwd] project root (default: process.cwd())
22
+ * @param {string[]} [options.patterns] file globs to scan
23
+ * @param {string[]} [options.ignore] extra ignore globs
24
+ * @param {string[]} [options.entry] extra entry-point globs (kept as public API)
25
+ * @param {boolean} [options.includeTests] treat test files as scannable (default true)
26
+ * @returns {Promise<object>} analysis result
27
+ */
28
+ export async function findDeadCode(options = {}) {
29
+ const root = path.resolve(options.cwd || process.cwd());
30
+ const patterns =
31
+ options.patterns && options.patterns.length
32
+ ? options.patterns
33
+ : ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'];
34
+
35
+ const ignore = [...DEFAULT_IGNORE, ...(options.ignore || [])];
36
+
37
+ const files = await fg(patterns, {
38
+ cwd: root,
39
+ absolute: true,
40
+ ignore,
41
+ dot: false,
42
+ });
43
+
44
+ const supported = files.filter((f) => JS_EXTENSIONS.includes(path.extname(f)));
45
+
46
+ // Scan all files in parallel.
47
+ const records = await Promise.all(
48
+ supported.map((f) => scanFile(f).catch(() => null))
49
+ );
50
+
51
+ const fileMap = new Map();
52
+ for (const rec of records) {
53
+ if (rec) fileMap.set(rec.path, rec);
54
+ }
55
+
56
+ const knownFiles = new Set(fileMap.keys());
57
+ const entryFiles = await resolveEntryPoints(root, knownFiles, options.entry || []);
58
+
59
+ const result = analyze(fileMap, { entryFiles });
60
+ result.root = root;
61
+ return result;
62
+ }
63
+
64
+ export { analyze, scanFile };
@@ -0,0 +1,23 @@
1
+ import { toRelative } from '../utils/paths.js';
2
+
3
+ export function reportJson(result, root) {
4
+ const payload = {
5
+ summary: {
6
+ files: result.fileCount,
7
+ totalExports: result.totalExports,
8
+ usedExports: result.usedExports,
9
+ unusedExports: result.unused.length,
10
+ },
11
+ unused: result.unused
12
+ .map((u) => ({
13
+ file: toRelative(root, u.file),
14
+ name: u.name,
15
+ line: u.line,
16
+ kind: u.kind,
17
+ }))
18
+ .sort((a, b) =>
19
+ a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)
20
+ ),
21
+ };
22
+ console.log(JSON.stringify(payload, null, 2));
23
+ }
@@ -0,0 +1,60 @@
1
+ import path from 'node:path';
2
+ import pc from 'picocolors';
3
+ import { toRelative } from '../utils/paths.js';
4
+
5
+ const KIND_LABEL = {
6
+ default: 'default export',
7
+ variable: 'const/let/var',
8
+ function: 'function',
9
+ class: 'class',
10
+ type: 'type/interface',
11
+ named: 'named export',
12
+ cjs: 'commonjs export',
13
+ };
14
+
15
+ export function reportTerminal(result, root) {
16
+ const { unused, totalExports, usedExports, fileCount } = result;
17
+
18
+ if (unused.length === 0) {
19
+ console.log(
20
+ pc.green('✔ No unused exports found') +
21
+ pc.dim(
22
+ ` — scanned ${fileCount} files, ${totalExports} exports all reachable.`
23
+ )
24
+ );
25
+ return;
26
+ }
27
+
28
+ // Group by file for readable output.
29
+ const byFile = new Map();
30
+ for (const item of unused) {
31
+ if (!byFile.has(item.file)) byFile.set(item.file, []);
32
+ byFile.get(item.file).push(item);
33
+ }
34
+
35
+ console.log(
36
+ pc.bold(pc.red(`✖ Found ${unused.length} unused export${unused.length === 1 ? '' : 's'}`)) +
37
+ pc.dim(` across ${byFile.size} file${byFile.size === 1 ? '' : 's'}\n`)
38
+ );
39
+
40
+ const sortedFiles = [...byFile.keys()].sort();
41
+ for (const file of sortedFiles) {
42
+ const rel = toRelative(root, file);
43
+ console.log(pc.underline(pc.cyan(rel)));
44
+ const items = byFile.get(file).sort((a, b) => a.line - b.line);
45
+ for (const item of items) {
46
+ const loc = pc.dim(`${rel}:${item.line}`);
47
+ const kind = pc.dim(`(${KIND_LABEL[item.kind] || item.kind})`);
48
+ const name = item.name === 'default' ? pc.italic('default') : pc.yellow(item.name);
49
+ console.log(` ${pc.red('•')} ${name} ${kind} ${loc}`);
50
+ }
51
+ console.log('');
52
+ }
53
+
54
+ const pct = totalExports ? Math.round((usedExports / totalExports) * 100) : 100;
55
+ console.log(
56
+ pc.dim(
57
+ `Scanned ${fileCount} files · ${usedExports}/${totalExports} exports used (${pct}%)`
58
+ )
59
+ );
60
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Extract exported names from a piece of (comment/string-stripped) source.
3
+ * Returns an array of { name, line, kind } where name === 'default' for
4
+ * default exports and '*' for a re-export star.
5
+ */
6
+ export function extractExports(code) {
7
+ const results = [];
8
+ const seen = new Set();
9
+
10
+ const add = (name, index, kind) => {
11
+ if (!name) return;
12
+ const key = name + ':' + index;
13
+ if (seen.has(key)) return;
14
+ seen.add(key);
15
+ results.push({ name, line: lineAt(code, index), kind });
16
+ };
17
+
18
+ // export default ...
19
+ for (const m of code.matchAll(/\bexport\s+default\b/g)) {
20
+ add('default', m.index, 'default');
21
+ }
22
+
23
+ // export const/let/var NAME (also multiple: export const a = 1, b = 2)
24
+ // Capture the whole declaration up to the statement terminator so that
25
+ // additional comma-separated bindings after an `=` are not lost.
26
+ for (const m of code.matchAll(/\bexport\s+(?:const|let|var)\s+([\s\S]*?)(?:;|\n)/g)) {
27
+ for (const name of parseBindingNames(m[1])) add(name, m.index, 'variable');
28
+ }
29
+
30
+ // export function NAME / export async function NAME / export function* NAME
31
+ for (const m of code.matchAll(
32
+ /\bexport\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/g
33
+ )) {
34
+ add(m[1], m.index, 'function');
35
+ }
36
+
37
+ // export class NAME
38
+ for (const m of code.matchAll(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g)) {
39
+ add(m[1], m.index, 'class');
40
+ }
41
+
42
+ // TypeScript: export interface/type/enum NAME
43
+ for (const m of code.matchAll(
44
+ /\bexport\s+(?:declare\s+)?(?:interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/g
45
+ )) {
46
+ add(m[1], m.index, 'type');
47
+ }
48
+
49
+ // export { a, b as c } and export { a } from './x'
50
+ for (const m of code.matchAll(/\bexport\s*\{([^}]*)\}(\s*from\s*['"][^'"]*['"])?/g)) {
51
+ for (const name of parseExportSpecifiers(m[1])) add(name, m.index, 'named');
52
+ }
53
+
54
+ // export * from './x' — re-export star (treated as a usage passthrough)
55
+ for (const m of code.matchAll(/\bexport\s*\*\s*from\s*['"][^'"]*['"]/g)) {
56
+ add('*', m.index, 'star');
57
+ }
58
+
59
+ // CommonJS: module.exports.NAME = / exports.NAME =
60
+ for (const m of code.matchAll(/\b(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g)) {
61
+ add(m[1], m.index, 'cjs');
62
+ }
63
+
64
+ // CommonJS: module.exports = { a, b, c }
65
+ for (const m of code.matchAll(/\bmodule\.exports\s*=\s*\{([^}]*)\}/g)) {
66
+ for (const name of parseObjectKeys(m[1])) add(name, m.index, 'cjs');
67
+ }
68
+
69
+ return results;
70
+ }
71
+
72
+ function parseBindingNames(fragment) {
73
+ // Handles: "a = 1, b = 2" and destructuring "{ a, b }" / "[a, b]".
74
+ // We split on TOP-LEVEL commas only (depth 0), so commas inside an
75
+ // initializer like `a = f(1, 2), b = 3` don't create phantom bindings.
76
+ const names = [];
77
+ const parts = splitTopLevel(fragment);
78
+ for (let part of parts) {
79
+ part = part.trim();
80
+ if (!part) continue;
81
+ // Take only the binding side (before '=').
82
+ const binding = part.split('=')[0].trim();
83
+ // Destructuring pattern -> pull each identifier.
84
+ if (/^[{[]/.test(binding)) {
85
+ const inner = binding.replace(/[{}[\]]/g, ' ');
86
+ for (const seg of inner.split(',')) {
87
+ const m = seg.trim().match(/([A-Za-z_$][\w$]*)\s*$/);
88
+ if (m) names.push(m[1]);
89
+ }
90
+ } else {
91
+ const m = binding.match(/^([A-Za-z_$][\w$]*)/);
92
+ if (m) names.push(m[1]);
93
+ }
94
+ }
95
+ return names;
96
+ }
97
+
98
+ function splitTopLevel(str) {
99
+ const parts = [];
100
+ let depth = 0;
101
+ let current = '';
102
+ for (const ch of str) {
103
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
104
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
105
+ if (ch === ',' && depth === 0) {
106
+ parts.push(current);
107
+ current = '';
108
+ } else {
109
+ current += ch;
110
+ }
111
+ }
112
+ if (current.trim()) parts.push(current);
113
+ return parts;
114
+ }
115
+
116
+ function parseExportSpecifiers(inner) {
117
+ // "a, b as c, type D" -> exported names are a, c, D
118
+ const names = [];
119
+ for (let part of inner.split(',')) {
120
+ part = part.trim().replace(/^type\s+/, '');
121
+ if (!part) continue;
122
+ const asMatch = part.match(/\bas\s+([A-Za-z_$][\w$]*)/);
123
+ if (asMatch) {
124
+ names.push(asMatch[1]);
125
+ } else {
126
+ const m = part.match(/^([A-Za-z_$][\w$]*)/);
127
+ if (m) names.push(m[1]);
128
+ }
129
+ }
130
+ return names;
131
+ }
132
+
133
+ function parseObjectKeys(inner) {
134
+ const names = [];
135
+ for (const part of inner.split(',')) {
136
+ const m = part.trim().match(/^([A-Za-z_$][\w$]*)/);
137
+ if (m) names.push(m[1]);
138
+ }
139
+ return names;
140
+ }
141
+
142
+ function lineAt(code, index) {
143
+ let line = 1;
144
+ for (let i = 0; i < index && i < code.length; i++) {
145
+ if (code[i] === '\n') line++;
146
+ }
147
+ return line;
148
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs/promises';
2
+ import { stripNonCode } from './strip.js';
3
+ import { extractExports } from './exports.js';
4
+ import { extractImports } from './imports.js';
5
+
6
+ /**
7
+ * Scan a single file into a normalized record.
8
+ *
9
+ * @param {string} absPath
10
+ * @returns {Promise<{
11
+ * path: string,
12
+ * exports: Array<{name:string,line:number,kind:string}>,
13
+ * imports: Array<{specifier:string,names:string[],namespace:boolean,sideEffectOnly:boolean}>,
14
+ * identifiers: Set<string>
15
+ * }>}
16
+ */
17
+ export async function scanFile(absPath) {
18
+ const raw = await fs.readFile(absPath, 'utf8');
19
+ // For exports we defuse string contents (keywords in strings must not match).
20
+ // For imports we keep specifiers intact (they live inside strings).
21
+ const codeForExports = stripNonCode(raw, true);
22
+ const codeForImports = stripNonCode(raw, false);
23
+
24
+ const exportsFound = extractExports(codeForExports);
25
+ const importsFound = extractImports(codeForImports);
26
+ const identifiers = collectIdentifiers(codeForExports);
27
+
28
+ return {
29
+ path: absPath,
30
+ exports: exportsFound,
31
+ imports: importsFound,
32
+ identifiers,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Collect the set of identifier tokens used anywhere in the file. Used to
38
+ * detect re-exported names that are consumed via `export * from` chains and
39
+ * to support namespace-import member access heuristics.
40
+ */
41
+ function collectIdentifiers(code) {
42
+ const set = new Set();
43
+ for (const m of code.matchAll(/[A-Za-z_$][\w$]*/g)) {
44
+ set.add(m[0]);
45
+ }
46
+ return set;
47
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Extract imports from (stripped) source. Returns an array of
3
+ * { specifier, names, namespace, sideEffectOnly }.
4
+ *
5
+ * - names: array of imported binding names as they exist in the *source*
6
+ * module (i.e. the original exported names, resolving `as`).
7
+ * - namespace: true if `import * as X` or `require(...)` whole-module use,
8
+ * meaning ANY export of the target could be used.
9
+ * - sideEffectOnly: `import './x'` with no bindings.
10
+ */
11
+ export function extractImports(code) {
12
+ const imports = [];
13
+
14
+ // ---- ESM: import ... from 'spec' -------------------------------------
15
+ for (const m of code.matchAll(/\bimport\s+([^;'"]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
16
+ const clause = m[1].trim();
17
+ const specifier = m[2];
18
+ imports.push(parseImportClause(clause, specifier));
19
+ }
20
+
21
+ // ---- ESM: bare import 'spec' (side-effect only) ----------------------
22
+ for (const m of code.matchAll(/\bimport\s*['"]([^'"]+)['"]/g)) {
23
+ imports.push({
24
+ specifier: m[1],
25
+ names: [],
26
+ namespace: false,
27
+ sideEffectOnly: true,
28
+ });
29
+ }
30
+
31
+ // ---- ESM: dynamic import('spec') -------------------------------------
32
+ for (const m of code.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
33
+ // Dynamic import returns the namespace object -> any export may be used.
34
+ imports.push({
35
+ specifier: m[1],
36
+ names: [],
37
+ namespace: true,
38
+ sideEffectOnly: false,
39
+ });
40
+ }
41
+
42
+ // ---- ESM: export { a } from 'spec' / export * from 'spec' ------------
43
+ for (const m of code.matchAll(/\bexport\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
44
+ imports.push({
45
+ specifier: m[2],
46
+ names: parseReexportSourceNames(m[1]),
47
+ namespace: false,
48
+ sideEffectOnly: false,
49
+ });
50
+ }
51
+ for (const m of code.matchAll(/\bexport\s*\*\s*from\s*['"]([^'"]+)['"]/g)) {
52
+ imports.push({
53
+ specifier: m[1],
54
+ names: [],
55
+ namespace: true, // re-export star consumes everything
56
+ sideEffectOnly: false,
57
+ });
58
+ }
59
+
60
+ // ---- CJS: const X = require('spec') / destructured -------------------
61
+ for (const m of code.matchAll(
62
+ /\b(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
63
+ )) {
64
+ const binding = m[1].trim();
65
+ const specifier = m[2];
66
+ if (binding.startsWith('{')) {
67
+ imports.push({
68
+ specifier,
69
+ names: parseReexportSourceNames(binding.replace(/[{}]/g, '')),
70
+ namespace: false,
71
+ sideEffectOnly: false,
72
+ });
73
+ } else {
74
+ imports.push({ specifier, names: [], namespace: true, sideEffectOnly: false });
75
+ }
76
+ }
77
+
78
+ // ---- CJS: bare require('spec') ---------------------------------------
79
+ for (const m of code.matchAll(/(?<![.\w$])require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
80
+ // Only count as side-effect if not already captured by an assignment.
81
+ imports.push({
82
+ specifier: m[1],
83
+ names: [],
84
+ namespace: false,
85
+ sideEffectOnly: true,
86
+ });
87
+ }
88
+
89
+ return dedupe(imports);
90
+ }
91
+
92
+ function parseImportClause(clause, specifier) {
93
+ const result = { specifier, names: [], namespace: false, sideEffectOnly: false };
94
+ if (!clause) {
95
+ result.sideEffectOnly = true;
96
+ return result;
97
+ }
98
+
99
+ // import * as NS from '...'
100
+ if (/^\*\s+as\s+[A-Za-z_$][\w$]*$/.test(clause)) {
101
+ result.namespace = true;
102
+ return result;
103
+ }
104
+
105
+ // Default binding + optional named/namespace
106
+ // Examples: "Foo", "Foo, { a, b }", "Foo, * as NS", "{ a, b as c }"
107
+ let rest = clause;
108
+
109
+ // Leading default import (not a brace, not a star)
110
+ const defaultMatch = rest.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/);
111
+ if (defaultMatch && !rest.startsWith('{') && !rest.startsWith('*')) {
112
+ result.names.push('default');
113
+ rest = rest.slice(defaultMatch[0].length).trim();
114
+ }
115
+
116
+ if (rest.startsWith('*')) {
117
+ result.namespace = true;
118
+ return result;
119
+ }
120
+
121
+ const braceMatch = rest.match(/\{([^}]*)\}/);
122
+ if (braceMatch) {
123
+ for (const name of parseReexportSourceNames(braceMatch[1])) {
124
+ result.names.push(name);
125
+ }
126
+ }
127
+
128
+ if (result.names.length === 0 && !result.namespace) {
129
+ result.sideEffectOnly = true;
130
+ }
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Given the inside of `{ ... }` in an import/re-export, return the names as
136
+ * they exist in the *source* module. For `a as b`, the source name is `a`.
137
+ */
138
+ function parseReexportSourceNames(inner) {
139
+ const names = [];
140
+ for (let part of inner.split(',')) {
141
+ part = part.trim().replace(/^type\s+/, '');
142
+ if (!part) continue;
143
+ const asMatch = part.match(/^([A-Za-z_$][\w$]*)\s+as\s+/);
144
+ if (asMatch) {
145
+ names.push(asMatch[1]);
146
+ } else {
147
+ const m = part.match(/^([A-Za-z_$][\w$]*)/);
148
+ if (m) names.push(m[1]);
149
+ }
150
+ }
151
+ return names;
152
+ }
153
+
154
+ function dedupe(imports) {
155
+ const map = new Map();
156
+ for (const imp of imports) {
157
+ const existing = map.get(imp.specifier);
158
+ if (!existing) {
159
+ map.set(imp.specifier, {
160
+ specifier: imp.specifier,
161
+ names: new Set(imp.names),
162
+ namespace: imp.namespace,
163
+ sideEffectOnly: imp.sideEffectOnly,
164
+ });
165
+ } else {
166
+ imp.names.forEach((n) => existing.names.add(n));
167
+ existing.namespace = existing.namespace || imp.namespace;
168
+ existing.sideEffectOnly = existing.sideEffectOnly && imp.sideEffectOnly;
169
+ }
170
+ }
171
+ return [...map.values()].map((e) => ({
172
+ specifier: e.specifier,
173
+ names: [...e.names],
174
+ namespace: e.namespace,
175
+ sideEffectOnly: e.sideEffectOnly && e.names.size === 0 && !e.namespace,
176
+ }));
177
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Remove comments and string/template/regex literal *contents* from source
3
+ * while preserving byte length and line structure, so that later regex-based
4
+ * extraction never matches keywords that appear inside strings or comments.
5
+ *
6
+ * We replace removed characters with spaces (newlines preserved) so indices
7
+ * stay stable and line counts remain accurate for reporting.
8
+ *
9
+ * This is a lightweight lexer — not a full parser — but it correctly handles
10
+ * the tricky cases (regex vs division, template literals, nested comments in
11
+ * strings) well enough for import/export discovery.
12
+ *
13
+ * @param {string} src
14
+ * @returns {string}
15
+ */
16
+ export function stripNonCode(src, defuseStrings = false) {
17
+ const out = new Array(src.length);
18
+ let i = 0;
19
+ const n = src.length;
20
+
21
+ // Tracks whether the previous significant token allows a regex to follow.
22
+ let prevSignificant = '';
23
+
24
+ const blank = (from, to) => {
25
+ for (let k = from; k < to; k++) {
26
+ out[k] = src[k] === '\n' ? '\n' : ' ';
27
+ }
28
+ };
29
+
30
+ while (i < n) {
31
+ const c = src[i];
32
+ const next = src[i + 1];
33
+
34
+ // Line comment
35
+ if (c === '/' && next === '/') {
36
+ let j = i + 2;
37
+ while (j < n && src[j] !== '\n') j++;
38
+ blank(i, j);
39
+ i = j;
40
+ continue;
41
+ }
42
+
43
+ // Block comment
44
+ if (c === '/' && next === '*') {
45
+ let j = i + 2;
46
+ while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++;
47
+ j = Math.min(j + 2, n);
48
+ blank(i, j);
49
+ i = j;
50
+ continue;
51
+ }
52
+
53
+ // Strings: ' " `
54
+ // We PRESERVE the quotes and contents (import/require specifiers live in
55
+ // strings and must survive), but we neutralize any alphabetic run inside
56
+ // the string so stray keywords like "export" in a path can't be matched
57
+ // as code. Punctuation like . / ' " is kept intact.
58
+ if (c === '"' || c === "'" || c === '`') {
59
+ const quote = c;
60
+ out[i] = c; // keep opening quote
61
+ let j = i + 1;
62
+ while (j < n) {
63
+ if (src[j] === '\\') {
64
+ out[j] = src[j];
65
+ out[j + 1] = src[j + 1];
66
+ j += 2;
67
+ continue;
68
+ }
69
+ if (src[j] === quote) break;
70
+ // When defusing, replace letters so keywords in strings can't match.
71
+ out[j] = defuseStrings && /[A-Za-z]/.test(src[j]) ? 'x' : src[j];
72
+ j++;
73
+ }
74
+ out[j] = quote; // keep closing quote
75
+ i = j + 1;
76
+ prevSignificant = 'str';
77
+ continue;
78
+ }
79
+
80
+ // Regex literal — only if a regex can legally start here.
81
+ if (c === '/' && canRegexFollow(prevSignificant)) {
82
+ let j = i + 1;
83
+ let inClass = false;
84
+ let valid = false;
85
+ while (j < n) {
86
+ const cj = src[j];
87
+ if (cj === '\\') {
88
+ j += 2;
89
+ continue;
90
+ }
91
+ if (cj === '[') inClass = true;
92
+ else if (cj === ']') inClass = false;
93
+ else if (cj === '/' && !inClass) {
94
+ valid = true;
95
+ break;
96
+ } else if (cj === '\n') {
97
+ break;
98
+ }
99
+ j++;
100
+ }
101
+ if (valid) {
102
+ blank(i, j + 1);
103
+ // skip flags
104
+ let k = j + 1;
105
+ while (k < n && /[a-z]/i.test(src[k])) {
106
+ out[k] = ' ';
107
+ k++;
108
+ }
109
+ i = k;
110
+ prevSignificant = 'regex';
111
+ continue;
112
+ }
113
+ }
114
+
115
+ // Ordinary code character
116
+ out[i] = c;
117
+ if (!/\s/.test(c)) {
118
+ prevSignificant = /[\w$)\].]/.test(c) ? 'value' : c;
119
+ }
120
+ i++;
121
+ }
122
+
123
+ return out.join('');
124
+ }
125
+
126
+ function canRegexFollow(prev) {
127
+ // A regex cannot follow a value/identifier/closing bracket (that's division).
128
+ if (prev === 'value' || prev === 'str' || prev === 'regex') return false;
129
+ return true;
130
+ }
@@ -0,0 +1,56 @@
1
+ import path from 'node:path';
2
+
3
+ const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts'];
4
+ const INDEX_FILES = JS_EXTENSIONS.map((ext) => `index${ext}`);
5
+
6
+ /**
7
+ * Resolve a relative import specifier against the importing file's directory
8
+ * to an absolute file path on disk. Handles extensionless imports and
9
+ * directory/index resolution the way bundlers and Node do.
10
+ *
11
+ * @param {string} fromFile absolute path of the file containing the import
12
+ * @param {string} specifier the raw import string (e.g. './utils' or '../a.js')
13
+ * @param {Set<string>} knownFiles set of absolute file paths we scanned
14
+ * @returns {string|null} absolute resolved path, or null if unresolved
15
+ */
16
+ export function resolveImport(fromFile, specifier, knownFiles) {
17
+ // Only resolve relative imports. Bare specifiers are external packages.
18
+ if (!specifier.startsWith('.')) return null;
19
+
20
+ const base = path.resolve(path.dirname(fromFile), specifier);
21
+
22
+ // 1. Exact match (import already had an extension)
23
+ if (knownFiles.has(base)) return base;
24
+
25
+ // 2. Try appending each known extension
26
+ for (const ext of JS_EXTENSIONS) {
27
+ const candidate = base + ext;
28
+ if (knownFiles.has(candidate)) return candidate;
29
+ }
30
+
31
+ // 3. Directory import -> index file
32
+ for (const indexFile of INDEX_FILES) {
33
+ const candidate = path.join(base, indexFile);
34
+ if (knownFiles.has(candidate)) return candidate;
35
+ }
36
+
37
+ // 4. Handle the case where the specifier pointed at a .js file
38
+ // but the source on disk is .ts (common in TS projects using
39
+ // ESM-style ".js" import specifiers).
40
+ const parsed = path.parse(base);
41
+ if (parsed.ext) {
42
+ const withoutExt = path.join(parsed.dir, parsed.name);
43
+ for (const ext of JS_EXTENSIONS) {
44
+ const candidate = withoutExt + ext;
45
+ if (knownFiles.has(candidate)) return candidate;
46
+ }
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ export function toRelative(root, file) {
53
+ return path.relative(root, file) || path.basename(file);
54
+ }
55
+
56
+ export { JS_EXTENSIONS };