@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 +35 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/bin/cli.js +111 -0
- package/package.json +65 -0
- package/src/analyzers/entrypoints.js +85 -0
- package/src/analyzers/graph.js +149 -0
- package/src/index.js +64 -0
- package/src/reporters/json.js +23 -0
- package/src/reporters/terminal.js +60 -0
- package/src/scanners/exports.js +148 -0
- package/src/scanners/file.js +47 -0
- package/src/scanners/imports.js +177 -0
- package/src/scanners/strip.js +130 -0
- package/src/utils/paths.js +56 -0
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
|
+
[](https://www.npmjs.com/package/@iamkurubaran/dead-code)
|
|
6
|
+
[](https://www.npmjs.com/package/@iamkurubaran/dead-code)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](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 };
|