helpforbegs 0.0.0-stage → 1.0.0
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 +146 -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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// src/configReader.js
|
|
2
|
+
// WHY: Packages don't just live in require() or import statements.
|
|
3
|
+
// Many critical packages are declared inside configuration files (ESLint plugins,
|
|
4
|
+
// Babel presets, Prettier plugins, tsconfig compiler types, and peerDependencies).
|
|
5
|
+
// Scanning configs ensures beginners never accidentally uninstall tools their configs rely on.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Safely parses JSON files, stripping single-line and multi-line comments
|
|
12
|
+
* which are common in JSONC files like tsconfig.json and .eslintrc.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} filePath
|
|
15
|
+
* @returns {any|null}
|
|
16
|
+
*/
|
|
17
|
+
function readJsonWithComments(filePath) {
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
23
|
+
// Remove single-line comments (//...) and multi-line comments (/*...*/)
|
|
24
|
+
const cleanJson = raw
|
|
25
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
26
|
+
.replace(/\/\/.*/g, '');
|
|
27
|
+
return JSON.parse(cleanJson);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extracts custom path aliases from tsconfig.json / jsconfig.json and package.json imports.
|
|
35
|
+
* e.g. "@/*" -> "@", "~/components/*" -> "~"
|
|
36
|
+
*
|
|
37
|
+
* @param {string} projectDir
|
|
38
|
+
* @param {object} [pkgJson]
|
|
39
|
+
* @returns {Set<string>}
|
|
40
|
+
*/
|
|
41
|
+
function extractPathAliases(projectDir, pkgJson = {}) {
|
|
42
|
+
const aliases = new Set();
|
|
43
|
+
|
|
44
|
+
// 1. From tsconfig.json or jsconfig.json
|
|
45
|
+
const tsconfigPath = path.join(projectDir, 'tsconfig.json');
|
|
46
|
+
const jsconfigPath = path.join(projectDir, 'jsconfig.json');
|
|
47
|
+
|
|
48
|
+
const config = readJsonWithComments(tsconfigPath) || readJsonWithComments(jsconfigPath);
|
|
49
|
+
if (config && config.compilerOptions && config.compilerOptions.paths) {
|
|
50
|
+
for (const pattern of Object.keys(config.compilerOptions.paths)) {
|
|
51
|
+
// e.g. "@/*" -> "@", "utils/*" -> "utils"
|
|
52
|
+
const clean = pattern.replace(/\/\*$/, '').replace(/\*$/, '');
|
|
53
|
+
if (clean) {
|
|
54
|
+
aliases.add(clean);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. From package.json "imports" field (Node.js subpath imports like "#internal/*")
|
|
60
|
+
if (pkgJson && pkgJson.imports && typeof pkgJson.imports === 'object') {
|
|
61
|
+
for (const key of Object.keys(pkgJson.imports)) {
|
|
62
|
+
const clean = key.replace(/\/\*$/, '').replace(/\*$/, '');
|
|
63
|
+
if (clean) {
|
|
64
|
+
aliases.add(clean);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return aliases;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reads the user's .helpforbegsignore file if present.
|
|
74
|
+
* Lines starting with # are comments.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} projectDir
|
|
77
|
+
* @returns {Set<string>}
|
|
78
|
+
*/
|
|
79
|
+
function readIgnoreFile(projectDir) {
|
|
80
|
+
const ignoreFilePath = path.join(projectDir, '.helpforbegsignore');
|
|
81
|
+
const ignored = new Set();
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(ignoreFilePath)) {
|
|
84
|
+
return ignored;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const content = fs.readFileSync(ignoreFilePath, 'utf8');
|
|
89
|
+
const lines = content.split(/\r?\n/);
|
|
90
|
+
for (const rawLine of lines) {
|
|
91
|
+
const line = rawLine.trim();
|
|
92
|
+
if (!line || line.startsWith('#')) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
ignored.add(line);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore read errors
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return ignored;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads node_modules to find all peerDependencies declared by installed packages.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectDir
|
|
108
|
+
* @returns {Set<string>}
|
|
109
|
+
*/
|
|
110
|
+
function collectPeerDependencies(projectDir) {
|
|
111
|
+
const peers = new Set();
|
|
112
|
+
const nodeModulesPath = path.join(projectDir, 'node_modules');
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
115
|
+
return peers;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readPeers(pkgJsonPath) {
|
|
119
|
+
try {
|
|
120
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
121
|
+
const data = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
122
|
+
if (data.peerDependencies && typeof data.peerDependencies === 'object') {
|
|
123
|
+
for (const peerName of Object.keys(data.peerDependencies)) {
|
|
124
|
+
peers.add(peerName);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore unparseable files
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const entries = fs.readdirSync(nodeModulesPath, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (!entry.isDirectory()) continue;
|
|
137
|
+
|
|
138
|
+
if (entry.name.startsWith('@')) {
|
|
139
|
+
const scopePath = path.join(nodeModulesPath, entry.name);
|
|
140
|
+
try {
|
|
141
|
+
const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true });
|
|
142
|
+
for (const scoped of scopedEntries) {
|
|
143
|
+
if (scoped.isDirectory()) {
|
|
144
|
+
readPeers(path.join(scopePath, scoped.name, 'package.json'));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Skip unreadable scope
|
|
149
|
+
}
|
|
150
|
+
} else if (!entry.name.startsWith('.')) {
|
|
151
|
+
readPeers(path.join(nodeModulesPath, entry.name, 'package.json'));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
return peers;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return peers;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Scans ESLint, Prettier, Babel, PostCSS, and TypeScript configuration files
|
|
163
|
+
* for declared packages, plugins, presets, and extends.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} projectDir
|
|
166
|
+
* @returns {{ usedPackages: Set<string>, hasTsConfig: boolean }}
|
|
167
|
+
*/
|
|
168
|
+
function scanConfigFiles(projectDir) {
|
|
169
|
+
const usedPackages = new Set();
|
|
170
|
+
let hasTsConfig = false;
|
|
171
|
+
|
|
172
|
+
// 1. TypeScript tsconfig.json / jsconfig.json
|
|
173
|
+
const tsconfigPath = path.join(projectDir, 'tsconfig.json');
|
|
174
|
+
if (fs.existsSync(tsconfigPath)) {
|
|
175
|
+
hasTsConfig = true;
|
|
176
|
+
const tsconfig = readJsonWithComments(tsconfigPath);
|
|
177
|
+
if (tsconfig) {
|
|
178
|
+
// Extends: e.g. "@tsconfig/node18/tsconfig.json" -> "@tsconfig/node18"
|
|
179
|
+
if (typeof tsconfig.extends === 'string') {
|
|
180
|
+
const ext = tsconfig.extends;
|
|
181
|
+
if (!ext.startsWith('.')) {
|
|
182
|
+
const parts = ext.split('/');
|
|
183
|
+
const pkg = ext.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
184
|
+
usedPackages.add(pkg);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Types: e.g. ["jest", "node"] -> "@types/jest", "@types/node"
|
|
189
|
+
if (tsconfig.compilerOptions && Array.isArray(tsconfig.compilerOptions.types)) {
|
|
190
|
+
for (const typeItem of tsconfig.compilerOptions.types) {
|
|
191
|
+
usedPackages.add(typeItem);
|
|
192
|
+
usedPackages.add(`@types/${typeItem}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 2. ESLint configs (.eslintrc, .eslintrc.json, .eslintrc.js, etc.)
|
|
199
|
+
const eslintFiles = [
|
|
200
|
+
'.eslintrc',
|
|
201
|
+
'.eslintrc.json',
|
|
202
|
+
'.eslintrc.js',
|
|
203
|
+
'.eslintrc.cjs',
|
|
204
|
+
'.eslintrc.yaml',
|
|
205
|
+
'.eslintrc.yml',
|
|
206
|
+
'eslint.config.js',
|
|
207
|
+
'eslint.config.mjs',
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
for (const name of eslintFiles) {
|
|
211
|
+
const fullPath = path.join(projectDir, name);
|
|
212
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
213
|
+
|
|
214
|
+
// For JS configs, they will also be parsed by fileFinder + astParser,
|
|
215
|
+
// but reading the raw text or JSON captures plugin strings too
|
|
216
|
+
try {
|
|
217
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
218
|
+
const json = name.endsWith('.json') || name === '.eslintrc' ? readJsonWithComments(fullPath) : null;
|
|
219
|
+
|
|
220
|
+
if (json) {
|
|
221
|
+
// Handle extends
|
|
222
|
+
const rawExtends = Array.isArray(json.extends) ? json.extends : [json.extends].filter(Boolean);
|
|
223
|
+
for (const ext of rawExtends) {
|
|
224
|
+
if (typeof ext !== 'string') continue;
|
|
225
|
+
if (ext.startsWith('plugin:')) {
|
|
226
|
+
const pluginPart = ext.slice('plugin:'.length).split('/')[0];
|
|
227
|
+
if (pluginPart.startsWith('@')) {
|
|
228
|
+
usedPackages.add(`${pluginPart}/eslint-plugin`);
|
|
229
|
+
} else {
|
|
230
|
+
usedPackages.add(`eslint-plugin-${pluginPart}`);
|
|
231
|
+
}
|
|
232
|
+
} else if (!ext.startsWith('eslint:') && !ext.startsWith('.')) {
|
|
233
|
+
// e.g. "airbnb" -> "eslint-config-airbnb", "prettier" -> "eslint-config-prettier"
|
|
234
|
+
if (!ext.startsWith('eslint-config-') && !ext.includes('/')) {
|
|
235
|
+
usedPackages.add(`eslint-config-${ext}`);
|
|
236
|
+
} else {
|
|
237
|
+
usedPackages.add(ext);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Handle plugins: e.g. ["react", "@typescript-eslint"]
|
|
243
|
+
if (Array.isArray(json.plugins)) {
|
|
244
|
+
for (const plugin of json.plugins) {
|
|
245
|
+
if (typeof plugin !== 'string') continue;
|
|
246
|
+
if (plugin.startsWith('@')) {
|
|
247
|
+
usedPackages.add(`${plugin}/eslint-plugin`);
|
|
248
|
+
} else if (!plugin.startsWith('eslint-plugin-')) {
|
|
249
|
+
usedPackages.add(`eslint-plugin-${plugin}`);
|
|
250
|
+
} else {
|
|
251
|
+
usedPackages.add(plugin);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
// Regex search for plugin/config package names inside JS/YAML eslint configs
|
|
257
|
+
const pluginRegex = /['"`](eslint-(?:config|plugin)-[^'"`]+|@[^'"`]+\/eslint-(?:config|plugin)[^'"`]*)['"`]/g;
|
|
258
|
+
let match;
|
|
259
|
+
while ((match = pluginRegex.exec(content)) !== null) {
|
|
260
|
+
usedPackages.add(match[1]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// Ignore config read errors
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3. Babel configs (.babelrc, babel.config.json, babel.config.js)
|
|
269
|
+
const babelFiles = ['.babelrc', '.babelrc.json', 'babel.config.json', 'babel.config.js'];
|
|
270
|
+
for (const name of babelFiles) {
|
|
271
|
+
const fullPath = path.join(projectDir, name);
|
|
272
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
273
|
+
|
|
274
|
+
const json = readJsonWithComments(fullPath);
|
|
275
|
+
if (json) {
|
|
276
|
+
const presets = Array.isArray(json.presets) ? json.presets : [];
|
|
277
|
+
for (const preset of presets) {
|
|
278
|
+
const presetName = Array.isArray(preset) ? preset[0] : preset;
|
|
279
|
+
if (typeof presetName === 'string') {
|
|
280
|
+
if (presetName.startsWith('@')) {
|
|
281
|
+
usedPackages.add(presetName);
|
|
282
|
+
} else if (!presetName.startsWith('babel-preset-') && !presetName.startsWith('.')) {
|
|
283
|
+
usedPackages.add(`babel-preset-${presetName}`);
|
|
284
|
+
} else {
|
|
285
|
+
usedPackages.add(presetName);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const plugins = Array.isArray(json.plugins) ? json.plugins : [];
|
|
291
|
+
for (const plugin of plugins) {
|
|
292
|
+
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
|
293
|
+
if (typeof pluginName === 'string') {
|
|
294
|
+
if (pluginName.startsWith('@')) {
|
|
295
|
+
usedPackages.add(pluginName);
|
|
296
|
+
} else if (!pluginName.startsWith('babel-plugin-') && !pluginName.startsWith('.')) {
|
|
297
|
+
usedPackages.add(`babel-plugin-${pluginName}`);
|
|
298
|
+
} else {
|
|
299
|
+
usedPackages.add(pluginName);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 4. Prettier configs (.prettierrc, .prettierrc.json)
|
|
307
|
+
const prettierFiles = ['.prettierrc', '.prettierrc.json'];
|
|
308
|
+
for (const name of prettierFiles) {
|
|
309
|
+
const fullPath = path.join(projectDir, name);
|
|
310
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
311
|
+
const json = readJsonWithComments(fullPath);
|
|
312
|
+
if (json && Array.isArray(json.plugins)) {
|
|
313
|
+
for (const plug of json.plugins) {
|
|
314
|
+
if (typeof plug === 'string') {
|
|
315
|
+
usedPackages.add(plug);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
usedPackages,
|
|
323
|
+
hasTsConfig,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Checks whether dotenv is invoked via CLI flags in package.json scripts
|
|
329
|
+
* e.g. "-r dotenv/config" or "node --require dotenv/config app.js"
|
|
330
|
+
*
|
|
331
|
+
* @param {Record<string, string>} scripts
|
|
332
|
+
* @returns {boolean}
|
|
333
|
+
*/
|
|
334
|
+
function checkDotenvInScripts(scripts = {}) {
|
|
335
|
+
const dotenvFlagRegex = /(?:-r|--require)\s+dotenv(?:\/config)?/;
|
|
336
|
+
for (const cmd of Object.values(scripts)) {
|
|
337
|
+
if (typeof cmd === 'string' && dotenvFlagRegex.test(cmd)) {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
readJsonWithComments,
|
|
346
|
+
extractPathAliases,
|
|
347
|
+
readIgnoreFile,
|
|
348
|
+
collectPeerDependencies,
|
|
349
|
+
scanConfigFiles,
|
|
350
|
+
checkDotenvInScripts,
|
|
351
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/fallbackParser.js
|
|
2
|
+
// WHY: In the real world, developers might have files with experimental syntax,
|
|
3
|
+
// syntax typos, or custom template tags that strict AST parsers choke on.
|
|
4
|
+
// Instead of crashing the whole CLI, we fall back to a line-by-line regex scanner
|
|
5
|
+
// for that file, and flag a gentle note in the report: "Could not fully read <file>".
|
|
6
|
+
|
|
7
|
+
const { builtinModules } = require('module');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const BUILTINS = new Set(builtinModules);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalizes a raw module specifier into a clean npm package name.
|
|
14
|
+
* Returns null if it is a relative file, built-in, or alias.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} rawSpecifier - e.g. "lodash/debounce", "./utils", "node:fs"
|
|
17
|
+
* @param {Set<string>} [knownAliases=new Set()]
|
|
18
|
+
* @returns {string|null}
|
|
19
|
+
*/
|
|
20
|
+
function normalizePackageName(rawSpecifier, knownAliases = new Set()) {
|
|
21
|
+
if (!rawSpecifier || typeof rawSpecifier !== 'string') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const trimmed = rawSpecifier.trim();
|
|
26
|
+
|
|
27
|
+
// Skip relative paths (./foo, ../bar) and root-relative/absolute paths
|
|
28
|
+
if (trimmed.startsWith('.') || trimmed.startsWith('/') || path.isAbsolute(trimmed)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Skip common framework path aliases (@/components, ~/utils, #internal)
|
|
33
|
+
if (trimmed.startsWith('@/') || trimmed.startsWith('~/') || trimmed.startsWith('#')) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Skip custom tsconfig/jsconfig aliases passed in
|
|
38
|
+
for (const alias of knownAliases) {
|
|
39
|
+
if (trimmed === alias || trimmed.startsWith(alias + '/')) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Any specifier starting with the 'node:' prefix (e.g. node:fs, node:test)
|
|
45
|
+
// is reserved by Node.js for built-in modules and is never an npm package.
|
|
46
|
+
if (trimmed.startsWith('node:')) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Skip Node.js built-in modules without prefix (fs, path, crypto, http, etc.)
|
|
51
|
+
if (BUILTINS.has(trimmed)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const withoutNodePrefix = trimmed;
|
|
56
|
+
|
|
57
|
+
// Scoped packages: @scope/pkg/deep -> @scope/pkg
|
|
58
|
+
if (withoutNodePrefix.startsWith('@')) {
|
|
59
|
+
const parts = withoutNodePrefix.split('/');
|
|
60
|
+
if (parts.length >= 2) {
|
|
61
|
+
return `${parts[0]}/${parts[1]}`;
|
|
62
|
+
}
|
|
63
|
+
return withoutNodePrefix;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Regular packages: lodash/fp -> lodash
|
|
67
|
+
return withoutNodePrefix.split('/')[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fallback regex scanner for files that fail to parse with Babel.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} code - File content
|
|
74
|
+
* @param {string} filePath - Path for reporting line numbers
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {Set<string>} [options.knownAliases]
|
|
77
|
+
* @returns {Array<{ packageName: string, rawSpecifier: string, line: number, isTypeOnly: boolean }>}
|
|
78
|
+
*/
|
|
79
|
+
function scanWithRegex(code, filePath, options = {}) {
|
|
80
|
+
const knownAliases = options.knownAliases || new Set();
|
|
81
|
+
const results = [];
|
|
82
|
+
const lines = code.split(/\r?\n/);
|
|
83
|
+
|
|
84
|
+
// Regexes matching common import/require patterns
|
|
85
|
+
const requireRegex = /\brequire\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
86
|
+
const requireResolveRegex = /\brequire\.resolve\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
87
|
+
const dynamicImportRegex = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
|
|
88
|
+
const staticImportRegex = /\bimport\s+(?:type\s+)?(?:[\s\S]*?from\s+)?['"`]([^'"`]+)['"`]/g;
|
|
89
|
+
const exportFromRegex = /\bexport\s+(?:type\s+)?(?:[\s\S]*?from\s+)?['"`]([^'"`]+)['"`]/g;
|
|
90
|
+
|
|
91
|
+
lines.forEach((lineText, index) => {
|
|
92
|
+
const lineNumber = index + 1;
|
|
93
|
+
|
|
94
|
+
// Check each pattern on this line
|
|
95
|
+
const patterns = [
|
|
96
|
+
{ regex: requireRegex, isTypeOnly: false },
|
|
97
|
+
{ regex: requireResolveRegex, isTypeOnly: false },
|
|
98
|
+
{ regex: dynamicImportRegex, isTypeOnly: false },
|
|
99
|
+
{ regex: staticImportRegex, isTypeOnly: lineText.includes('import type') },
|
|
100
|
+
{ regex: exportFromRegex, isTypeOnly: lineText.includes('export type') },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const { regex, isTypeOnly } of patterns) {
|
|
104
|
+
// Reset regex index for each line
|
|
105
|
+
regex.lastIndex = 0;
|
|
106
|
+
let match;
|
|
107
|
+
while ((match = regex.exec(lineText)) !== null) {
|
|
108
|
+
const rawSpecifier = match[1];
|
|
109
|
+
const packageName = normalizePackageName(rawSpecifier, knownAliases);
|
|
110
|
+
|
|
111
|
+
if (packageName) {
|
|
112
|
+
results.push({
|
|
113
|
+
packageName,
|
|
114
|
+
rawSpecifier,
|
|
115
|
+
line: lineNumber,
|
|
116
|
+
isTypeOnly,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
scanWithRegex,
|
|
128
|
+
normalizePackageName,
|
|
129
|
+
BUILTINS,
|
|
130
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/fileFinder.js
|
|
2
|
+
// WHY: To analyze a project's dependencies accurately, we must discover every
|
|
3
|
+
// single JavaScript/TypeScript file where code might be imported, while skipping
|
|
4
|
+
// heavy dependency folders (like node_modules) and generated build outputs (dist/).
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// 1 Megabyte in bytes. We skip files larger than this to prevent memory spikes
|
|
10
|
+
// and keep scanning blazing fast, especially if someone accidentally committed a minified bundle.
|
|
11
|
+
const MAX_FILE_SIZE_BYTES = 1024 * 1024; // 1 MB
|
|
12
|
+
|
|
13
|
+
// Folders that should always be ignored.
|
|
14
|
+
// Scanning these would produce false positives or freeze the CLI.
|
|
15
|
+
const DEFAULT_IGNORED_DIRS = new Set([
|
|
16
|
+
'node_modules',
|
|
17
|
+
'.git',
|
|
18
|
+
'dist',
|
|
19
|
+
'build',
|
|
20
|
+
'coverage',
|
|
21
|
+
'.next',
|
|
22
|
+
'.nuxt',
|
|
23
|
+
'out',
|
|
24
|
+
'.cache',
|
|
25
|
+
'.turbo',
|
|
26
|
+
'fixtures',
|
|
27
|
+
'__fixtures__',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Extensions we want to inspect for imports and requires.
|
|
31
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
32
|
+
'.js',
|
|
33
|
+
'.mjs',
|
|
34
|
+
'.cjs',
|
|
35
|
+
'.jsx',
|
|
36
|
+
'.ts',
|
|
37
|
+
'.tsx',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parses a .gitignore file into a list of pattern matchers.
|
|
42
|
+
* Supports basic gitignore patterns (*, /dir, dir/, filename).
|
|
43
|
+
*
|
|
44
|
+
* @param {string} rootDir
|
|
45
|
+
* @returns {Array<(relPath: string, isDirectory: boolean) => boolean>}
|
|
46
|
+
*/
|
|
47
|
+
function loadGitignoreMatchers(rootDir) {
|
|
48
|
+
const gitignorePath = path.join(rootDir, '.gitignore');
|
|
49
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
55
|
+
const lines = content.split(/\r?\n/);
|
|
56
|
+
const matchers = [];
|
|
57
|
+
|
|
58
|
+
for (const rawLine of lines) {
|
|
59
|
+
const line = rawLine.trim();
|
|
60
|
+
// Ignore empty lines and comments
|
|
61
|
+
if (!line || line.startsWith('#')) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isDirOnly = line.endsWith('/');
|
|
66
|
+
let cleanPattern = line.replace(/\/+$/, ''); // Remove trailing slashes
|
|
67
|
+
const isRootAnchored = cleanPattern.startsWith('/');
|
|
68
|
+
if (isRootAnchored) {
|
|
69
|
+
cleanPattern = cleanPattern.slice(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Convert a simple glob (*, ?) into a regular expression
|
|
73
|
+
// e.g. *.log -> ^.*\.log$
|
|
74
|
+
const escaped = cleanPattern
|
|
75
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
76
|
+
.replace(/\*/g, '.*')
|
|
77
|
+
.replace(/\?/g, '.');
|
|
78
|
+
|
|
79
|
+
const regex = new RegExp(isRootAnchored ? `^${escaped}` : `(^|/)${escaped}(/|$)`);
|
|
80
|
+
|
|
81
|
+
matchers.push((relPath, isDirectory) => {
|
|
82
|
+
// Normalize Windows backslashes to forward slashes for matching
|
|
83
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
84
|
+
if (isDirOnly && !isDirectory) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return regex.test(normalized);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return matchers;
|
|
92
|
+
} catch {
|
|
93
|
+
// If .gitignore cannot be read, continue without crashing
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scans a folder recursively to find all source code files.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} rootDir - Absolute path to the project directory to scan
|
|
102
|
+
* @param {object} options
|
|
103
|
+
* @param {string[]} [options.extraIgnores=[]] - Additional patterns or folders to ignore
|
|
104
|
+
* @returns {{ files: Array<{ fullPath: string, relativePath: string, ext: string }>, skippedLargeFiles: Array<{ fullPath: string, relativePath: string, size: number }> }}
|
|
105
|
+
*/
|
|
106
|
+
function findFiles(rootDir, options = {}) {
|
|
107
|
+
const files = [];
|
|
108
|
+
const skippedLargeFiles = [];
|
|
109
|
+
const gitignoreMatchers = loadGitignoreMatchers(rootDir);
|
|
110
|
+
|
|
111
|
+
function walk(currentDir) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
115
|
+
} catch {
|
|
116
|
+
// Permission error or unreadable directory; skip gracefully
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
122
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
123
|
+
|
|
124
|
+
// Check standard ignored folders
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
if (DEFAULT_IGNORED_DIRS.has(entry.name)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check gitignore for directory
|
|
131
|
+
const isIgnoredByGitignore = gitignoreMatchers.some((matcher) =>
|
|
132
|
+
matcher(relativePath, true)
|
|
133
|
+
);
|
|
134
|
+
if (isIgnoredByGitignore) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Recurse into subdirectory
|
|
139
|
+
walk(fullPath);
|
|
140
|
+
} else if (entry.isFile()) {
|
|
141
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
142
|
+
|
|
143
|
+
// Check if the file extension is one of our target JS/TS code formats.
|
|
144
|
+
// NOTE: Config files like `vite.config.js` or `.eslintrc.js` end in .js,
|
|
145
|
+
// so they are intentionally included here so their imports count as usage!
|
|
146
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check gitignore for file
|
|
151
|
+
const isIgnoredByGitignore = gitignoreMatchers.some((matcher) =>
|
|
152
|
+
matcher(relativePath, false)
|
|
153
|
+
);
|
|
154
|
+
if (isIgnoredByGitignore) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check file size to avoid processing massive files
|
|
159
|
+
try {
|
|
160
|
+
const stats = fs.statSync(fullPath);
|
|
161
|
+
if (stats.size > MAX_FILE_SIZE_BYTES) {
|
|
162
|
+
skippedLargeFiles.push({
|
|
163
|
+
fullPath,
|
|
164
|
+
relativePath,
|
|
165
|
+
size: stats.size,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// If stat fails (e.g. broken symlink), skip safely
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
files.push({
|
|
175
|
+
fullPath,
|
|
176
|
+
relativePath,
|
|
177
|
+
ext,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
walk(rootDir);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
files,
|
|
187
|
+
skippedLargeFiles,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
findFiles,
|
|
193
|
+
MAX_FILE_SIZE_BYTES,
|
|
194
|
+
DEFAULT_IGNORED_DIRS,
|
|
195
|
+
SUPPORTED_EXTENSIONS,
|
|
196
|
+
};
|