helpforbegs 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +147 -2
- package/bin/cli.js +108 -0
- package/package.json +43 -4
- package/src/analyzer.js +348 -0
- package/src/args.js +116 -0
- package/src/astParser.js +228 -0
- package/src/binMapper.js +167 -0
- package/src/configReader.js +351 -0
- package/src/fallbackParser.js +130 -0
- package/src/fileFinder.js +196 -0
- package/src/messages.js +82 -0
- package/src/remover.js +184 -0
- package/src/reporter.js +186 -0
- package/src/safeList.js +91 -0
- package/src/scorer.js +150 -0
- package/src/why.js +103 -0
package/src/args.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// src/args.js
|
|
2
|
+
// WHY: We avoid heavy argument parsing libraries (like commander or yargs)
|
|
3
|
+
// to keep helpforbegs lightweight, fast to install, and dependency-free for CLI parsing.
|
|
4
|
+
// Parsing process.argv manually teaches us exactly how Node.js receives command-line inputs.
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses raw command-line arguments (process.argv.slice(2))
|
|
10
|
+
* into an easy-to-use JavaScript object.
|
|
11
|
+
*
|
|
12
|
+
* @param {string[]} rawArgs - e.g. ['scan', '--roast', '--path', './my-app']
|
|
13
|
+
* @returns {object} Parsed command, package target (for `why`), and options
|
|
14
|
+
*/
|
|
15
|
+
function parseArgs(rawArgs = process.argv.slice(2)) {
|
|
16
|
+
const flags = {
|
|
17
|
+
json: false,
|
|
18
|
+
noColor: false,
|
|
19
|
+
roast: false,
|
|
20
|
+
targetDir: process.cwd(), // Default to the folder where the user ran the command
|
|
21
|
+
dryRun: false,
|
|
22
|
+
yes: false,
|
|
23
|
+
help: false,
|
|
24
|
+
version: false,
|
|
25
|
+
debug: false,
|
|
26
|
+
isInteractive: true, // Auto-adjusted below for CI / non-TTY
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const positional = [];
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
32
|
+
const arg = rawArgs[i];
|
|
33
|
+
|
|
34
|
+
if (arg === '--json') {
|
|
35
|
+
flags.json = true;
|
|
36
|
+
} else if (arg === '--no-color') {
|
|
37
|
+
flags.noColor = true;
|
|
38
|
+
} else if (arg === '--roast') {
|
|
39
|
+
flags.roast = true;
|
|
40
|
+
} else if (arg === '--dry-run') {
|
|
41
|
+
flags.dryRun = true;
|
|
42
|
+
} else if (arg === '--yes' || arg === '-y') {
|
|
43
|
+
flags.yes = true;
|
|
44
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
45
|
+
flags.help = true;
|
|
46
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
47
|
+
flags.version = true;
|
|
48
|
+
} else if (arg === '--debug') {
|
|
49
|
+
flags.debug = true;
|
|
50
|
+
} else if (arg === '--path') {
|
|
51
|
+
// The user provided --path <directory>. Look at the next argument.
|
|
52
|
+
const nextArg = rawArgs[i + 1];
|
|
53
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
54
|
+
flags.targetDir = path.resolve(process.cwd(), nextArg);
|
|
55
|
+
i++; // Skip the directory string in the next loop iteration
|
|
56
|
+
}
|
|
57
|
+
} else if (arg.startsWith('--path=')) {
|
|
58
|
+
// Support syntax like --path=./subfolder
|
|
59
|
+
const customPath = arg.slice('--path='.length);
|
|
60
|
+
flags.targetDir = path.resolve(process.cwd(), customPath);
|
|
61
|
+
} else if (!arg.startsWith('-')) {
|
|
62
|
+
positional.push(arg);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Determine the primary command:
|
|
67
|
+
// e.g. `helpforbegs` -> 'scan'
|
|
68
|
+
// `helpforbegs scan` -> 'scan'
|
|
69
|
+
// `helpforbegs remove` -> 'remove'
|
|
70
|
+
// `helpforbegs why lodash` -> command: 'why', targetPkg: 'lodash'
|
|
71
|
+
// `helpforbegs score` -> 'score'
|
|
72
|
+
let command = 'scan';
|
|
73
|
+
let targetPkg = null;
|
|
74
|
+
|
|
75
|
+
if (positional.length > 0) {
|
|
76
|
+
const first = positional[0].toLowerCase();
|
|
77
|
+
if (first === 'scan' || first === 'remove' || first === 'score') {
|
|
78
|
+
command = first;
|
|
79
|
+
} else if (first === 'why') {
|
|
80
|
+
command = 'why';
|
|
81
|
+
targetPkg = positional[1] || null; // e.g. 'why express' -> express
|
|
82
|
+
} else {
|
|
83
|
+
// If someone types `helpforbegs my-package` without saying 'why',
|
|
84
|
+
// or an unknown command, we keep 'scan' or treat it safely.
|
|
85
|
+
command = first;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// TTY and CI detection:
|
|
90
|
+
// WHY: If the CLI is run inside a script, redirected to a file (`helpforbegs > output.txt`),
|
|
91
|
+
// or executed inside a CI pipeline (like GitHub Actions), process.stdout.isTTY is false.
|
|
92
|
+
// In those environments:
|
|
93
|
+
// 1. We must not emit ANSI escape color codes (they turn into messy symbols in text files).
|
|
94
|
+
// 2. We cannot ask interactive readline questions (there is no human sitting at a keyboard).
|
|
95
|
+
const isCI = Boolean(process.env.CI);
|
|
96
|
+
const isNoColorEnv = Boolean(process.env.NO_COLOR);
|
|
97
|
+
const isNotTTY = !process.stdout.isTTY;
|
|
98
|
+
|
|
99
|
+
if (isCI || isNoColorEnv || isNotTTY || flags.noColor) {
|
|
100
|
+
flags.noColor = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isCI || isNotTTY) {
|
|
104
|
+
flags.isInteractive = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
command,
|
|
109
|
+
targetPkg,
|
|
110
|
+
flags,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
parseArgs,
|
|
116
|
+
};
|
package/src/astParser.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// src/astParser.js
|
|
2
|
+
// WHY: Regular expressions can be easily fooled by commented-out code
|
|
3
|
+
// or strings containing "require('foo')". Using @babel/parser ensures we
|
|
4
|
+
// inspect the real AST (Abstract Syntax Tree) with 100% syntactic precision.
|
|
5
|
+
|
|
6
|
+
const babelParser = require('@babel/parser');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { scanWithRegex, normalizePackageName } = require('./fallbackParser');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Determines the exact Babel plugins needed based on the file extension.
|
|
12
|
+
*
|
|
13
|
+
* Rule from user:
|
|
14
|
+
* - .ts: ['typescript']
|
|
15
|
+
* - .tsx: ['typescript', 'jsx']
|
|
16
|
+
* - .js / .jsx / .mjs / .cjs: ['jsx']
|
|
17
|
+
*
|
|
18
|
+
* @param {string} ext - e.g. '.tsx'
|
|
19
|
+
* @returns {string[]}
|
|
20
|
+
*/
|
|
21
|
+
function getBabelPlugins(ext) {
|
|
22
|
+
const normalizedExt = (ext || '').toLowerCase();
|
|
23
|
+
if (normalizedExt === '.ts') {
|
|
24
|
+
return ['typescript'];
|
|
25
|
+
}
|
|
26
|
+
if (normalizedExt === '.tsx') {
|
|
27
|
+
return ['typescript', 'jsx'];
|
|
28
|
+
}
|
|
29
|
+
return ['jsx'];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Traverses an AST tree without needing an external walker library.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} node - Current AST node
|
|
36
|
+
* @param {(node: object) => void} visitor - Function called for each node
|
|
37
|
+
*/
|
|
38
|
+
function walkAST(node, visitor) {
|
|
39
|
+
if (!node || typeof node !== 'object') {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
visitor(node);
|
|
44
|
+
|
|
45
|
+
for (const key of Object.keys(node)) {
|
|
46
|
+
// Avoid traversing parent references or metadata to prevent infinite loops
|
|
47
|
+
if (key === 'loc' || key === 'comments' || key === 'tokens') {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const child = node[key];
|
|
51
|
+
if (Array.isArray(child)) {
|
|
52
|
+
for (const item of child) {
|
|
53
|
+
if (item && typeof item === 'object') {
|
|
54
|
+
walkAST(item, visitor);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else if (child && typeof child === 'object') {
|
|
58
|
+
walkAST(child, visitor);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Safely extracts a string value from an argument node.
|
|
65
|
+
* Handles both plain 'string' literals and non-dynamic template literals like `pkg`.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} argNode
|
|
68
|
+
* @returns {string|null}
|
|
69
|
+
*/
|
|
70
|
+
function extractStringArgument(argNode) {
|
|
71
|
+
if (!argNode) return null;
|
|
72
|
+
if (argNode.type === 'StringLiteral') {
|
|
73
|
+
return argNode.value;
|
|
74
|
+
}
|
|
75
|
+
if (argNode.type === 'TemplateLiteral' && argNode.expressions.length === 0 && argNode.quasis.length === 1) {
|
|
76
|
+
return argNode.quasis[0].value.raw;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses a code string into AST, extracts all imported/required package names,
|
|
83
|
+
* and falls back to regex if the file has syntax errors.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} code - Source code text
|
|
86
|
+
* @param {string} filePath - Path to the file (used to decide plugins & line numbers)
|
|
87
|
+
* @param {object} [options]
|
|
88
|
+
* @param {Set<string>} [options.knownAliases] - Path aliases to ignore
|
|
89
|
+
* @returns {{ imports: Array<{ packageName: string, rawSpecifier: string, line: number, isTypeOnly: boolean }>, warning: string|null }}
|
|
90
|
+
*/
|
|
91
|
+
function parseImports(code, filePath, options = {}) {
|
|
92
|
+
const ext = path.extname(filePath);
|
|
93
|
+
const plugins = getBabelPlugins(ext);
|
|
94
|
+
const knownAliases = options.knownAliases || new Set();
|
|
95
|
+
const rawImports = [];
|
|
96
|
+
let warning = null;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const ast = babelParser.parse(code, {
|
|
100
|
+
sourceType: 'unambiguous', // Automatically detects CommonJS vs ES Modules
|
|
101
|
+
plugins: plugins,
|
|
102
|
+
errorRecovery: true, // Recovers from non-fatal syntax errors
|
|
103
|
+
attachComment: false,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
walkAST(ast, (node) => {
|
|
107
|
+
// 1. Static ES Module Imports: `import foo from 'pkg'` or `import 'pkg'`
|
|
108
|
+
if (node.type === 'ImportDeclaration') {
|
|
109
|
+
if (node.source && typeof node.source.value === 'string') {
|
|
110
|
+
const isTypeOnly = node.importKind === 'type';
|
|
111
|
+
rawImports.push({
|
|
112
|
+
rawSpecifier: node.source.value,
|
|
113
|
+
line: node.loc ? node.loc.start.line : 1,
|
|
114
|
+
isTypeOnly,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. ES Module Re-exports: `export { x } from 'pkg'` or `export * from 'pkg'`
|
|
120
|
+
else if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') {
|
|
121
|
+
if (node.source && typeof node.source.value === 'string') {
|
|
122
|
+
const isTypeOnly = node.exportKind === 'type';
|
|
123
|
+
rawImports.push({
|
|
124
|
+
rawSpecifier: node.source.value,
|
|
125
|
+
line: node.loc ? node.loc.start.line : 1,
|
|
126
|
+
isTypeOnly,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. CommonJS `require('pkg')`, `require.resolve('pkg')`, and dynamic `import('pkg')`
|
|
132
|
+
else if (node.type === 'CallExpression') {
|
|
133
|
+
// require('pkg')
|
|
134
|
+
if (node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments.length > 0) {
|
|
135
|
+
const specifier = extractStringArgument(node.arguments[0]);
|
|
136
|
+
if (specifier) {
|
|
137
|
+
rawImports.push({
|
|
138
|
+
rawSpecifier: specifier,
|
|
139
|
+
line: node.loc ? node.loc.start.line : 1,
|
|
140
|
+
isTypeOnly: false,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// require.resolve('pkg')
|
|
146
|
+
else if (
|
|
147
|
+
node.callee.type === 'MemberExpression' &&
|
|
148
|
+
node.callee.object.type === 'Identifier' &&
|
|
149
|
+
node.callee.object.name === 'require' &&
|
|
150
|
+
node.callee.property.type === 'Identifier' &&
|
|
151
|
+
node.callee.property.name === 'resolve' &&
|
|
152
|
+
node.arguments.length > 0
|
|
153
|
+
) {
|
|
154
|
+
const specifier = extractStringArgument(node.arguments[0]);
|
|
155
|
+
if (specifier) {
|
|
156
|
+
rawImports.push({
|
|
157
|
+
rawSpecifier: specifier,
|
|
158
|
+
line: node.loc ? node.loc.start.line : 1,
|
|
159
|
+
isTypeOnly: false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Dynamic import('pkg')
|
|
165
|
+
else if (node.callee.type === 'Import' && node.arguments.length > 0) {
|
|
166
|
+
const specifier = extractStringArgument(node.arguments[0]);
|
|
167
|
+
if (specifier) {
|
|
168
|
+
rawImports.push({
|
|
169
|
+
rawSpecifier: specifier,
|
|
170
|
+
line: node.loc ? node.loc.start.line : 1,
|
|
171
|
+
isTypeOnly: false,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 4. TypeScript `import foo = require('pkg')`
|
|
178
|
+
else if (node.type === 'TSImportEqualsDeclaration') {
|
|
179
|
+
if (
|
|
180
|
+
node.moduleReference &&
|
|
181
|
+
node.moduleReference.type === 'TSExternalModuleReference' &&
|
|
182
|
+
node.moduleReference.expression &&
|
|
183
|
+
typeof node.moduleReference.expression.value === 'string'
|
|
184
|
+
) {
|
|
185
|
+
rawImports.push({
|
|
186
|
+
rawSpecifier: node.moduleReference.expression.value,
|
|
187
|
+
line: node.loc ? node.loc.start.line : 1,
|
|
188
|
+
isTypeOnly: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// If AST parsing fails, use our safe fallback regex scanner for this file
|
|
195
|
+
warning = `Could not fully read ${filePath}`;
|
|
196
|
+
const fallbackResults = scanWithRegex(code, filePath, { knownAliases });
|
|
197
|
+
return {
|
|
198
|
+
imports: fallbackResults,
|
|
199
|
+
warning,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Normalize all raw specifiers into npm package names
|
|
204
|
+
const normalizedImports = [];
|
|
205
|
+
for (const item of rawImports) {
|
|
206
|
+
const packageName = normalizePackageName(item.rawSpecifier, knownAliases);
|
|
207
|
+
if (packageName) {
|
|
208
|
+
normalizedImports.push({
|
|
209
|
+
packageName,
|
|
210
|
+
rawSpecifier: item.rawSpecifier,
|
|
211
|
+
line: item.line,
|
|
212
|
+
isTypeOnly: item.isTypeOnly,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
imports: normalizedImports,
|
|
219
|
+
warning,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
parseImports,
|
|
225
|
+
getBabelPlugins,
|
|
226
|
+
normalizePackageName,
|
|
227
|
+
walkAST,
|
|
228
|
+
};
|
package/src/binMapper.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/binMapper.js
|
|
2
|
+
// WHY: When a user installs CLI tools like "rimraf", "nodemon", or "eslint",
|
|
3
|
+
// they don't `require('rimraf')` in code. Instead, they write `"clean": "rimraf dist"`
|
|
4
|
+
// in package.json scripts.
|
|
5
|
+
// By reading each installed package's "bin" field in node_modules, we discover
|
|
6
|
+
// what terminal commands it provides, and we can check if the user runs those commands in their scripts.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
// Standard fallback command mappings for common development tools.
|
|
12
|
+
// WHY: If a user hasn't run `npm install` yet, node_modules is missing.
|
|
13
|
+
// Having built-in knowledge of standard tool commands (e.g. `tsc` comes from `typescript`)
|
|
14
|
+
// ensures we still recognize script usage and avoid false positives!
|
|
15
|
+
const DEFAULT_KNOWN_BINS = new Map([
|
|
16
|
+
['tsc', 'typescript'],
|
|
17
|
+
['eslint', 'eslint'],
|
|
18
|
+
['prettier', 'prettier'],
|
|
19
|
+
['jest', 'jest'],
|
|
20
|
+
['nodemon', 'nodemon'],
|
|
21
|
+
['vite', 'vite'],
|
|
22
|
+
['webpack', 'webpack'],
|
|
23
|
+
['rimraf', 'rimraf'],
|
|
24
|
+
['cross-env', 'cross-env'],
|
|
25
|
+
['ts-node', 'ts-node'],
|
|
26
|
+
['tsx', 'tsx'],
|
|
27
|
+
['babel', '@babel/cli'],
|
|
28
|
+
['mocha', 'mocha'],
|
|
29
|
+
['vitest', 'vitest'],
|
|
30
|
+
['concurrently', 'concurrently'],
|
|
31
|
+
['npm-run-all', 'npm-run-all'],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Discovers CLI binary commands provided by all installed packages in node_modules.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} projectDir - Root folder containing package.json and node_modules
|
|
38
|
+
* @returns {{ binToPkg: Map<string, string>, pkgToBins: Map<string, string[]>, missingNodeModules: boolean }}
|
|
39
|
+
*/
|
|
40
|
+
function mapInstalledBins(projectDir) {
|
|
41
|
+
// Pre-seed with known standard binaries as a safety baseline
|
|
42
|
+
const binToPkg = new Map(DEFAULT_KNOWN_BINS);
|
|
43
|
+
const pkgToBins = new Map();
|
|
44
|
+
const nodeModulesPath = path.join(projectDir, 'node_modules');
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
47
|
+
// If node_modules hasn't been installed yet, don't crash!
|
|
48
|
+
// Simply report that node_modules is missing so the analyzer can inform the user.
|
|
49
|
+
return {
|
|
50
|
+
binToPkg,
|
|
51
|
+
pkgToBins,
|
|
52
|
+
missingNodeModules: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readPkgJson(pkgName, pkgJsonPath) {
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
59
|
+
const content = fs.readFileSync(pkgJsonPath, 'utf8');
|
|
60
|
+
const pkgData = JSON.parse(content);
|
|
61
|
+
|
|
62
|
+
if (pkgData.bin) {
|
|
63
|
+
const bins = [];
|
|
64
|
+
if (typeof pkgData.bin === 'string') {
|
|
65
|
+
// If "bin" is a string, the command name defaults to the package name
|
|
66
|
+
// (or unscoped name for scoped packages)
|
|
67
|
+
const defaultBinName = pkgName.startsWith('@') ? pkgName.split('/')[1] : pkgName;
|
|
68
|
+
binToPkg.set(defaultBinName, pkgName);
|
|
69
|
+
bins.push(defaultBinName);
|
|
70
|
+
} else if (typeof pkgData.bin === 'object' && pkgData.bin !== null) {
|
|
71
|
+
for (const binName of Object.keys(pkgData.bin)) {
|
|
72
|
+
binToPkg.set(binName, pkgName);
|
|
73
|
+
bins.push(binName);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (bins.length > 0) {
|
|
78
|
+
pkgToBins.set(pkgName, bins);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Ignore unparseable or corrupted package.json files inside node_modules
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true });
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory()) continue;
|
|
92
|
+
|
|
93
|
+
if (entry.name.startsWith('@')) {
|
|
94
|
+
// Handle scoped packages like @babel/cli inside node_modules/@babel/
|
|
95
|
+
const scopePath = path.join(nodeModulesPath, entry.name);
|
|
96
|
+
try {
|
|
97
|
+
const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true });
|
|
98
|
+
for (const scopedEntry of scopedEntries) {
|
|
99
|
+
if (scopedEntry.isDirectory()) {
|
|
100
|
+
const fullPkgName = `${entry.name}/${scopedEntry.name}`;
|
|
101
|
+
const pkgJsonPath = path.join(scopePath, scopedEntry.name, 'package.json');
|
|
102
|
+
readPkgJson(fullPkgName, pkgJsonPath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Skip unreadable scope folders
|
|
107
|
+
}
|
|
108
|
+
} else if (!entry.name.startsWith('.')) {
|
|
109
|
+
// Standard non-scoped package
|
|
110
|
+
const pkgJsonPath = path.join(nodeModulesPath, entry.name, 'package.json');
|
|
111
|
+
readPkgJson(entry.name, pkgJsonPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
return {
|
|
116
|
+
binToPkg,
|
|
117
|
+
pkgToBins,
|
|
118
|
+
missingNodeModules: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
binToPkg,
|
|
124
|
+
pkgToBins,
|
|
125
|
+
missingNodeModules: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks all package.json scripts to see which packages are executed via CLI commands.
|
|
131
|
+
*
|
|
132
|
+
* @param {Record<string, string>} scripts - The "scripts" object from package.json
|
|
133
|
+
* @param {Map<string, string>} binToPkg - Map of commandName -> packageName
|
|
134
|
+
* @returns {Map<string, Array<{ scriptName: string, command: string }>>} - Map of packageName -> where it was used
|
|
135
|
+
*/
|
|
136
|
+
function findBinUsageInScripts(scripts = {}, binToPkg = new Map()) {
|
|
137
|
+
const usageByPkg = new Map();
|
|
138
|
+
|
|
139
|
+
for (const [scriptName, scriptCmd] of Object.entries(scripts)) {
|
|
140
|
+
if (typeof scriptCmd !== 'string') continue;
|
|
141
|
+
|
|
142
|
+
// Check each known binary name to see if it is invoked as a word in the script
|
|
143
|
+
for (const [binName, pkgName] of binToPkg.entries()) {
|
|
144
|
+
// Escape regex special chars in command name
|
|
145
|
+
const escapedBin = binName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
146
|
+
// Match the command as a standalone word or at the start of a command pipeline
|
|
147
|
+
const wordRegex = new RegExp(`(^|[;&|\\s])${escapedBin}(\\s|$|[;&|])`);
|
|
148
|
+
|
|
149
|
+
if (wordRegex.test(scriptCmd)) {
|
|
150
|
+
if (!usageByPkg.has(pkgName)) {
|
|
151
|
+
usageByPkg.set(pkgName, []);
|
|
152
|
+
}
|
|
153
|
+
usageByPkg.get(pkgName).push({
|
|
154
|
+
scriptName,
|
|
155
|
+
command: binName,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return usageByPkg;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
mapInstalledBins,
|
|
166
|
+
findBinUsageInScripts,
|
|
167
|
+
};
|