muaddib-scanner 2.2.28 → 2.3.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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Reachability analysis — determines which files are reachable from
3
+ * package entry points (main, bin, exports, browser, module, lifecycle scripts).
4
+ * Files not reachable from any entry point are likely tests, examples, or
5
+ * internal utilities shipped in tarballs but never executed at install time.
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists } = require('./module-graph.js');
10
+
11
+ /**
12
+ * Recursively extract file paths from the package.json `exports` field.
13
+ * Handles: string shorthand, condition objects, nested subpath objects, arrays.
14
+ * @param {*} exportsField
15
+ * @returns {string[]}
16
+ */
17
+ function extractExportsPaths(exportsField) {
18
+ if (!exportsField) return [];
19
+
20
+ // String shorthand: "exports": "./index.js"
21
+ if (typeof exportsField === 'string') {
22
+ return isLocalPath(exportsField) ? [exportsField] : [];
23
+ }
24
+
25
+ // Array form: ["./a.js", "./b.js"]
26
+ if (Array.isArray(exportsField)) {
27
+ const paths = [];
28
+ for (const item of exportsField) {
29
+ paths.push(...extractExportsPaths(item));
30
+ }
31
+ return paths;
32
+ }
33
+
34
+ // Object form — could be condition keys or subpath keys
35
+ if (typeof exportsField === 'object') {
36
+ const paths = [];
37
+ for (const value of Object.values(exportsField)) {
38
+ paths.push(...extractExportsPaths(value));
39
+ }
40
+ return paths;
41
+ }
42
+
43
+ return [];
44
+ }
45
+
46
+ /**
47
+ * Extract .js/.mjs/.cjs file paths referenced in lifecycle script commands.
48
+ * Matches patterns like: node scripts/install.js, node ./lib/post.mjs
49
+ * @param {string} scriptCmd
50
+ * @returns {string[]}
51
+ */
52
+ function extractScriptJsFiles(scriptCmd) {
53
+ if (!scriptCmd || typeof scriptCmd !== 'string') return [];
54
+ const matches = [];
55
+ // Match: node <path>.js/mjs/cjs (but not node -e '...')
56
+ const re = /\bnode\s+(?!-[a-z])([\w./_-]+\.(?:js|mjs|cjs))\b/g;
57
+ let m;
58
+ while ((m = re.exec(scriptCmd)) !== null) {
59
+ matches.push(m[1]);
60
+ }
61
+ return matches;
62
+ }
63
+
64
+ /**
65
+ * Check if a path is local (starts with ./ or ../ or has no protocol).
66
+ */
67
+ function isLocalPath(p) {
68
+ if (typeof p !== 'string') return false;
69
+ if (p.startsWith('./') || p.startsWith('../')) return true;
70
+ // Bare path without protocol (e.g., "index.js", "src/main.js")
71
+ if (!p.includes('://') && !p.startsWith('#')) return true;
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Resolve an entry point candidate against the filesystem.
77
+ * Tries: exact, .js, .mjs, .cjs, /index.js
78
+ * @param {string} candidate - Relative path from package root (e.g., "./src/index.js")
79
+ * @param {string} packagePath - Absolute path to package root
80
+ * @returns {string|null} Relative path (forward slashes) or null
81
+ */
82
+ function resolveEntryPoint(candidate, packagePath) {
83
+ // Strip leading ./ for path.resolve
84
+ const clean = candidate.replace(/^\.\//, '');
85
+ const abs = path.resolve(packagePath, clean);
86
+
87
+ if (isFileExists(abs)) return toRel(abs, packagePath);
88
+ if (isFileExists(abs + '.js')) return toRel(abs + '.js', packagePath);
89
+ if (isFileExists(abs + '.mjs')) return toRel(abs + '.mjs', packagePath);
90
+ if (isFileExists(abs + '.cjs')) return toRel(abs + '.cjs', packagePath);
91
+ if (isFileExists(path.join(abs, 'index.js'))) return toRel(path.join(abs, 'index.js'), packagePath);
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Extract entry points from package.json.
97
+ * Sources: main, bin, exports, browser, module, lifecycle scripts.
98
+ * @param {string} packagePath - Absolute path to package root
99
+ * @returns {string[]} Resolved relative file paths (forward slashes)
100
+ */
101
+ function getEntryPoints(packagePath) {
102
+ const pkgJsonPath = path.join(packagePath, 'package.json');
103
+ let pkg;
104
+ try {
105
+ pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
106
+ } catch {
107
+ return [];
108
+ }
109
+
110
+ const candidates = [];
111
+
112
+ // main
113
+ if (typeof pkg.main === 'string') {
114
+ candidates.push(pkg.main);
115
+ }
116
+
117
+ // bin (string or object)
118
+ if (typeof pkg.bin === 'string') {
119
+ candidates.push(pkg.bin);
120
+ } else if (pkg.bin && typeof pkg.bin === 'object') {
121
+ for (const v of Object.values(pkg.bin)) {
122
+ if (typeof v === 'string') candidates.push(v);
123
+ }
124
+ }
125
+
126
+ // exports (recursive)
127
+ if (pkg.exports) {
128
+ candidates.push(...extractExportsPaths(pkg.exports));
129
+ }
130
+
131
+ // browser (string or object)
132
+ if (typeof pkg.browser === 'string') {
133
+ candidates.push(pkg.browser);
134
+ } else if (pkg.browser && typeof pkg.browser === 'object') {
135
+ for (const v of Object.values(pkg.browser)) {
136
+ if (typeof v === 'string') candidates.push(v);
137
+ }
138
+ }
139
+
140
+ // module
141
+ if (typeof pkg.module === 'string') {
142
+ candidates.push(pkg.module);
143
+ }
144
+
145
+ // Lifecycle scripts: extract .js files from preinstall/install/postinstall/prepare
146
+ const lifecycleKeys = ['preinstall', 'install', 'postinstall', 'prepare'];
147
+ if (pkg.scripts) {
148
+ for (const key of lifecycleKeys) {
149
+ if (typeof pkg.scripts[key] === 'string') {
150
+ candidates.push(...extractScriptJsFiles(pkg.scripts[key]));
151
+ }
152
+ }
153
+ }
154
+
155
+ // Resolve candidates against filesystem
156
+ const resolved = new Set();
157
+ for (const c of candidates) {
158
+ if (!c || typeof c !== 'string') continue;
159
+ const r = resolveEntryPoint(c, packagePath);
160
+ if (r) resolved.add(r);
161
+ }
162
+
163
+ // Default fallback: try index.js
164
+ if (resolved.size === 0) {
165
+ const r = resolveEntryPoint('index.js', packagePath);
166
+ if (r) resolved.add(r);
167
+ }
168
+
169
+ return [...resolved];
170
+ }
171
+
172
+ /**
173
+ * Extract local .js file targets from child_process spawn/fork/execFile calls.
174
+ * Handles patterns like:
175
+ * fork('./worker.js')
176
+ * spawn('node', [path.join(__dirname, 'worker.js')])
177
+ * spawn(process.execPath, ['./stealer.js'])
178
+ * @param {string} absFile - Absolute path to the file to parse
179
+ * @param {string} packagePath - Package root
180
+ * @returns {string[]} Resolved relative paths of spawn targets
181
+ */
182
+ function extractSpawnTargets(absFile, packagePath) {
183
+ const ast = parseFile(absFile);
184
+ if (!ast) return [];
185
+
186
+ const fileDir = path.dirname(absFile);
187
+ const targets = [];
188
+
189
+ walkForSpawnTargets(ast, fileDir, packagePath, targets);
190
+ return [...new Set(targets)];
191
+ }
192
+
193
+ function walkForSpawnTargets(node, fileDir, packagePath, targets) {
194
+ if (!node || typeof node !== 'object') return;
195
+
196
+ if (node.type === 'CallExpression' && node.callee) {
197
+ const name = getSpawnCalleeName(node.callee);
198
+
199
+ if (name === 'fork' && node.arguments.length >= 1) {
200
+ // child_process.fork(modulePath) — first arg is a .js file
201
+ const target = resolvePathArg(node.arguments[0], fileDir, packagePath);
202
+ if (target) targets.push(target);
203
+ } else if ((name === 'spawn' || name === 'execFile') && node.arguments.length >= 2) {
204
+ // spawn('node', [filePath]) or spawn(process.execPath, [filePath])
205
+ const argsNode = node.arguments[1];
206
+ if (argsNode && argsNode.type === 'ArrayExpression' && argsNode.elements.length >= 1) {
207
+ const target = resolvePathArg(argsNode.elements[0], fileDir, packagePath);
208
+ if (target) targets.push(target);
209
+ }
210
+ }
211
+ }
212
+
213
+ for (const key of Object.keys(node)) {
214
+ if (key === 'type') continue;
215
+ const child = node[key];
216
+ if (Array.isArray(child)) {
217
+ for (const item of child) {
218
+ if (item && typeof item === 'object') walkForSpawnTargets(item, fileDir, packagePath, targets);
219
+ }
220
+ } else if (child && typeof child === 'object') {
221
+ walkForSpawnTargets(child, fileDir, packagePath, targets);
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Get the function name from a callee node (spawn, fork, cp.spawn, child_process.fork, etc.)
228
+ */
229
+ function getSpawnCalleeName(callee) {
230
+ if (callee.type === 'Identifier') return callee.name;
231
+ if (callee.type === 'MemberExpression' && callee.property) {
232
+ return callee.property.name || callee.property.value || '';
233
+ }
234
+ return '';
235
+ }
236
+
237
+ /**
238
+ * Resolve a path argument from AST node to a relative file path.
239
+ * Handles: string literals, path.join(__dirname, 'file.js'), template literals.
240
+ */
241
+ function resolvePathArg(argNode, fileDir, packagePath) {
242
+ if (!argNode) return null;
243
+
244
+ // Simple string literal: './worker.js' or 'worker.js'
245
+ if (argNode.type === 'Literal' && typeof argNode.value === 'string') {
246
+ const val = argNode.value;
247
+ if (val.endsWith('.js') || val.endsWith('.mjs') || val.endsWith('.cjs')) {
248
+ return resolveLocal(fileDir, val.startsWith('.') ? val : './' + val, packagePath);
249
+ }
250
+ return null;
251
+ }
252
+
253
+ // path.join(__dirname, 'worker.js') pattern
254
+ if (argNode.type === 'CallExpression' && argNode.callee &&
255
+ argNode.callee.type === 'MemberExpression' &&
256
+ argNode.callee.object && argNode.callee.object.name === 'path' &&
257
+ argNode.callee.property && argNode.callee.property.name === 'join') {
258
+ // Look for __dirname as first arg + string literals for the rest
259
+ const args = argNode.arguments;
260
+ if (args.length >= 2 && args[0].type === 'Identifier' && args[0].name === '__dirname') {
261
+ const parts = [];
262
+ for (let i = 1; i < args.length; i++) {
263
+ if (args[i].type === 'Literal' && typeof args[i].value === 'string') {
264
+ parts.push(args[i].value);
265
+ } else {
266
+ return null; // Can't resolve dynamic parts
267
+ }
268
+ }
269
+ const relPath = './' + parts.join('/');
270
+ return resolveLocal(fileDir, relPath, packagePath);
271
+ }
272
+ }
273
+
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * BFS traversal from entry points through local imports and spawn targets.
279
+ * @param {string} packagePath - Absolute path to package root
280
+ * @returns {{ reachableFiles: Set<string>, entryPoints: string[], skipped: boolean }}
281
+ */
282
+ function computeReachableFiles(packagePath) {
283
+ const entryPoints = getEntryPoints(packagePath);
284
+
285
+ if (entryPoints.length === 0) {
286
+ return { reachableFiles: new Set(), entryPoints: [], skipped: true };
287
+ }
288
+
289
+ const reachable = new Set();
290
+ const queue = [...entryPoints];
291
+
292
+ // Seed with entry points
293
+ for (const ep of entryPoints) {
294
+ reachable.add(ep);
295
+ }
296
+
297
+ while (queue.length > 0) {
298
+ const relFile = queue.shift();
299
+ const absFile = path.resolve(packagePath, relFile);
300
+
301
+ // Follow require/import edges
302
+ let imports;
303
+ try {
304
+ imports = extractLocalImports(absFile, packagePath);
305
+ } catch {
306
+ imports = [];
307
+ }
308
+
309
+ // Follow child_process spawn/fork targets
310
+ let spawnTargets;
311
+ try {
312
+ spawnTargets = extractSpawnTargets(absFile, packagePath);
313
+ } catch {
314
+ spawnTargets = [];
315
+ }
316
+
317
+ const allTargets = [...imports, ...spawnTargets];
318
+ for (const target of allTargets) {
319
+ if (!reachable.has(target)) {
320
+ reachable.add(target);
321
+ queue.push(target);
322
+ }
323
+ }
324
+ }
325
+
326
+ return { reachableFiles: reachable, entryPoints, skipped: false };
327
+ }
328
+
329
+ module.exports = { computeReachableFiles, getEntryPoints, extractExportsPaths, extractScriptJsFiles };