muaddib-scanner 2.4.3 → 2.4.5

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/src/utils.js CHANGED
@@ -1,327 +1,327 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { MAX_FILE_SIZE } = require('./shared/constants.js');
4
-
5
- /**
6
- * Directories excluded from scanning.
7
- * Only skip dependency/VCS/cache dirs - never skip user source code.
8
- */
9
- const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
10
-
11
- /**
12
- * Extra directories to exclude (set at runtime via --exclude flag).
13
- * Merged into every findFiles() call on top of the caller's excludedDirs.
14
- */
15
- let _extraExcludedDirs = [];
16
- let _scanRoot = '';
17
-
18
- /**
19
- * Memoization cache for findFiles(). Key = dir|extensions|excludedDirs.
20
- * Cleared between scans via clearFileListCache().
21
- */
22
- const _fileListCache = new Map();
23
-
24
- function setExtraExcludes(dirs, scanRoot) {
25
- _extraExcludedDirs = Array.isArray(dirs) ? dirs : [];
26
- _scanRoot = scanRoot || '';
27
- }
28
-
29
- function getExtraExcludes() {
30
- return _extraExcludedDirs;
31
- }
32
-
33
- /**
34
- * Patterns to identify dev/test files
35
- */
36
- const DEV_PATTERNS = [
37
- /^scripts\//,
38
- /^bin\//,
39
- /^tools\//,
40
- /^build\//,
41
- /^fixtures\//,
42
- /^examples\//,
43
- /^__tests__\//,
44
- /^__mocks__\//,
45
- /^benchmark/,
46
- /^docs?\//,
47
- /^compiler\//,
48
- /^packages\/.*\/scripts\//,
49
- /\.test\.js$/,
50
- /\.spec\.js$/,
51
- /test\.js$/,
52
- /spec\.js$/
53
- ];
54
-
55
- /**
56
- * Checks if a path corresponds to a dev/test file
57
- * @param {string} relativePath - Relative path of the file
58
- * @returns {boolean}
59
- */
60
- function isDevFile(relativePath) {
61
- return DEV_PATTERNS.some(pattern => pattern.test(relativePath));
62
- }
63
-
64
- /**
65
- * Generic recursive file finder with symlink protection and depth limit.
66
- * @param {string} dir - Starting directory
67
- * @param {object} [options] - Options
68
- * @param {string[]} [options.extensions=['.js']] - File extensions to match
69
- * @param {string[]} [options.excludedDirs=EXCLUDED_DIRS] - Dirs to skip
70
- * @param {number} [options.maxDepth=100] - Max recursion depth
71
- * @param {string[]} [options.results=[]] - Accumulator (internal)
72
- * @param {Set} [options.visitedInodes=new Set()] - Symlink loop detection (note: inode tracking
73
- * is unreliable on Windows where stat.ino may be 0; maxDepth serves as fallback protection)
74
- * @param {number} [options.depth=0] - Current depth (internal)
75
- * @returns {string[]} List of matching file paths
76
- */
77
- function findFiles(dir, options = {}) {
78
- const {
79
- extensions = ['.js'],
80
- excludedDirs = EXCLUDED_DIRS,
81
- maxDepth = 100,
82
- results = [],
83
- visitedInodes = new Set(),
84
- depth = 0
85
- } = options;
86
-
87
- // Top-level memoization: identical (dir, extensions, excludedDirs) → cached result
88
- if (depth === 0) {
89
- const cacheKey = dir + '|' + extensions.slice().sort().join(',') + '|' +
90
- [...excludedDirs, ..._extraExcludedDirs].sort().join(',');
91
- const cached = _fileListCache.get(cacheKey);
92
- if (cached) return [...cached]; // return copy to prevent mutation
93
- const result = _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
94
- _fileListCache.set(cacheKey, [...result]);
95
- return result;
96
- }
97
-
98
- return _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
99
- }
100
-
101
- function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth }) {
102
- if (depth > maxDepth) return results;
103
- if (!fs.existsSync(dir)) return results;
104
-
105
- // Merge runtime --exclude dirs so every scanner respects them
106
- const allExcludedDirs = _extraExcludedDirs.length > 0
107
- ? [...new Set([...excludedDirs, ..._extraExcludedDirs])]
108
- : excludedDirs;
109
-
110
- let items;
111
- try {
112
- items = fs.readdirSync(dir);
113
- } catch {
114
- return results;
115
- }
116
-
117
- for (const item of items) {
118
- const fullPath = path.join(dir, item);
119
-
120
- // Check both bare name ("tests") and relative path ("src/scanner")
121
- if (allExcludedDirs.includes(item)) continue;
122
- if (_extraExcludedDirs.length > 0 && _scanRoot) {
123
- const rel = path.relative(_scanRoot, fullPath).replace(/\\/g, '/');
124
- if (_extraExcludedDirs.some(ex => rel === ex || rel.startsWith(ex + '/'))) continue;
125
- }
126
-
127
- try {
128
- const lstat = fs.lstatSync(fullPath);
129
-
130
- // Symlink protection
131
- if (lstat.isSymbolicLink()) {
132
- try {
133
- const realPath = fs.realpathSync(fullPath);
134
- const realStat = fs.statSync(realPath);
135
- if (realStat.ino !== 0 && visitedInodes.has(realStat.ino)) continue;
136
- if (realStat.ino !== 0) visitedInodes.add(realStat.ino);
137
- if (realStat.isDirectory()) {
138
- _findFilesImpl(realPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
139
- } else if (extensions.some(ext => item.endsWith(ext))) {
140
- results.push(realPath);
141
- }
142
- } catch {
143
- // Broken symlink, skip
144
- }
145
- continue;
146
- }
147
-
148
- if (lstat.ino !== 0) visitedInodes.add(lstat.ino);
149
-
150
- if (lstat.isDirectory()) {
151
- _findFilesImpl(fullPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
152
- } else if (extensions.some(ext => item.endsWith(ext))) {
153
- results.push(fullPath);
154
- }
155
- } catch {
156
- // Ignore permission errors
157
- }
158
- }
159
-
160
- return results;
161
- }
162
-
163
- /**
164
- * Recursively searches for JavaScript files (convenience wrapper)
165
- * @param {string} dir - Starting directory
166
- * @param {string[]} [results=[]] - Accumulator array (internal use)
167
- * @returns {string[]} List of .js file paths
168
- */
169
- function findJsFiles(dir, results = []) {
170
- return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
171
- }
172
-
173
- function clearFileListCache() {
174
- _fileListCache.clear();
175
- }
176
-
177
- /**
178
- * Escapes HTML characters to prevent XSS
179
- * @param {string} str - String to escape
180
- * @returns {string} Escaped string
181
- */
182
- function escapeHtml(str) {
183
- if (str === null || str === undefined) return '';
184
- return String(str)
185
- .replace(/&/g, '&')
186
- .replace(/</g, '&lt;')
187
- .replace(/>/g, '&gt;')
188
- .replace(/"/g, '&quot;')
189
- .replace(/'/g, '&#x27;');
190
- }
191
-
192
- /**
193
- * Extracts the function/method name from a CallExpression AST node
194
- * @param {object} node - AST CallExpression node
195
- * @returns {string} Function name or empty string
196
- */
197
- function getCallName(node) {
198
- if (node.callee.type === 'Identifier') {
199
- return node.callee.name;
200
- }
201
- if (node.callee.type === 'MemberExpression' && node.callee.property) {
202
- return node.callee.property.name;
203
- }
204
- return '';
205
- }
206
-
207
- /**
208
- * Minimal CLI spinner (npm/ora style, no external deps).
209
- * Frames rotate every 100ms via setInterval.
210
- * Uses ANSI escapes to clear/rewrite the current line.
211
- */
212
- class Spinner {
213
- constructor() {
214
- this._frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
215
- this._index = 0;
216
- this._interval = null;
217
- this._text = '';
218
- }
219
-
220
- start(text) {
221
- this._text = text;
222
- this._index = 0;
223
- if (this._interval) clearInterval(this._interval);
224
- this._render();
225
- this._interval = setInterval(() => this._render(), 100);
226
- return this;
227
- }
228
-
229
- update(text) {
230
- this._text = text;
231
- }
232
-
233
- succeed(text) {
234
- this._stop();
235
- process.stdout.write('\r\x1b[K\x1b[32m\u2713\x1b[0m ' + text + '\n');
236
- }
237
-
238
- fail(text) {
239
- this._stop();
240
- process.stdout.write('\r\x1b[K\x1b[31m\u2717\x1b[0m ' + text + '\n');
241
- }
242
-
243
- _render() {
244
- const frame = this._frames[this._index % this._frames.length];
245
- process.stdout.write('\r\x1b[K' + frame + ' ' + this._text);
246
- this._index++;
247
- }
248
-
249
- _stop() {
250
- if (this._interval) {
251
- clearInterval(this._interval);
252
- this._interval = null;
253
- }
254
- }
255
- }
256
-
257
- /**
258
- * Iterates files with size guard and error handling.
259
- * Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
260
- */
261
- function forEachSafeFile(files, callback) {
262
- for (const file of files) {
263
- try {
264
- const stat = fs.statSync(file);
265
- if (stat.size > MAX_FILE_SIZE) continue;
266
- } catch { continue; }
267
- let content;
268
- try {
269
- content = fs.readFileSync(file, 'utf8');
270
- } catch { continue; }
271
- callback(file, content);
272
- }
273
- }
274
-
275
- /**
276
- * Lists installed packages in node_modules (handles scoped packages).
277
- * @param {string} targetPath - Root of the project
278
- * @returns {string[]} Package names (e.g. ['express', '@babel/core'])
279
- */
280
- function listInstalledPackages(targetPath) {
281
- const nm = path.join(targetPath, 'node_modules');
282
- if (!fs.existsSync(nm)) return [];
283
- const names = [];
284
- try {
285
- for (const item of fs.readdirSync(nm)) {
286
- if (item.startsWith('.')) continue;
287
- const itemPath = path.join(nm, item);
288
- try {
289
- const stat = fs.lstatSync(itemPath);
290
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
291
- if (item.startsWith('@')) {
292
- for (const si of fs.readdirSync(itemPath)) {
293
- const ss = fs.lstatSync(path.join(itemPath, si));
294
- if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
295
- }
296
- } else {
297
- names.push(item);
298
- }
299
- } catch { /* skip unreadable */ }
300
- }
301
- } catch { /* no node_modules readable */ }
302
- return names;
303
- }
304
-
305
- /**
306
- * Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
307
- */
308
- function debugLog(...args) {
309
- if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
310
- }
311
-
312
- module.exports = {
313
- EXCLUDED_DIRS,
314
- DEV_PATTERNS,
315
- isDevFile,
316
- findFiles,
317
- findJsFiles,
318
- clearFileListCache,
319
- escapeHtml,
320
- getCallName,
321
- Spinner,
322
- setExtraExcludes,
323
- getExtraExcludes,
324
- forEachSafeFile,
325
- listInstalledPackages,
326
- debugLog
327
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { MAX_FILE_SIZE } = require('./shared/constants.js');
4
+
5
+ /**
6
+ * Directories excluded from scanning.
7
+ * Only skip dependency/VCS/cache dirs - never skip user source code.
8
+ */
9
+ const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
10
+
11
+ /**
12
+ * Extra directories to exclude (set at runtime via --exclude flag).
13
+ * Merged into every findFiles() call on top of the caller's excludedDirs.
14
+ */
15
+ let _extraExcludedDirs = [];
16
+ let _scanRoot = '';
17
+
18
+ /**
19
+ * Memoization cache for findFiles(). Key = dir|extensions|excludedDirs.
20
+ * Cleared between scans via clearFileListCache().
21
+ */
22
+ const _fileListCache = new Map();
23
+
24
+ function setExtraExcludes(dirs, scanRoot) {
25
+ _extraExcludedDirs = Array.isArray(dirs) ? dirs : [];
26
+ _scanRoot = scanRoot || '';
27
+ }
28
+
29
+ function getExtraExcludes() {
30
+ return _extraExcludedDirs;
31
+ }
32
+
33
+ /**
34
+ * Patterns to identify dev/test files
35
+ */
36
+ const DEV_PATTERNS = [
37
+ /^scripts\//,
38
+ /^bin\//,
39
+ /^tools\//,
40
+ /^build\//,
41
+ /^fixtures\//,
42
+ /^examples\//,
43
+ /^__tests__\//,
44
+ /^__mocks__\//,
45
+ /^benchmark/,
46
+ /^docs?\//,
47
+ /^compiler\//,
48
+ /^packages\/.*\/scripts\//,
49
+ /\.test\.js$/,
50
+ /\.spec\.js$/,
51
+ /test\.js$/,
52
+ /spec\.js$/
53
+ ];
54
+
55
+ /**
56
+ * Checks if a path corresponds to a dev/test file
57
+ * @param {string} relativePath - Relative path of the file
58
+ * @returns {boolean}
59
+ */
60
+ function isDevFile(relativePath) {
61
+ return DEV_PATTERNS.some(pattern => pattern.test(relativePath));
62
+ }
63
+
64
+ /**
65
+ * Generic recursive file finder with symlink protection and depth limit.
66
+ * @param {string} dir - Starting directory
67
+ * @param {object} [options] - Options
68
+ * @param {string[]} [options.extensions=['.js']] - File extensions to match
69
+ * @param {string[]} [options.excludedDirs=EXCLUDED_DIRS] - Dirs to skip
70
+ * @param {number} [options.maxDepth=100] - Max recursion depth
71
+ * @param {string[]} [options.results=[]] - Accumulator (internal)
72
+ * @param {Set} [options.visitedInodes=new Set()] - Symlink loop detection (note: inode tracking
73
+ * is unreliable on Windows where stat.ino may be 0; maxDepth serves as fallback protection)
74
+ * @param {number} [options.depth=0] - Current depth (internal)
75
+ * @returns {string[]} List of matching file paths
76
+ */
77
+ function findFiles(dir, options = {}) {
78
+ const {
79
+ extensions = ['.js'],
80
+ excludedDirs = EXCLUDED_DIRS,
81
+ maxDepth = 100,
82
+ results = [],
83
+ visitedInodes = new Set(),
84
+ depth = 0
85
+ } = options;
86
+
87
+ // Top-level memoization: identical (dir, extensions, excludedDirs) → cached result
88
+ if (depth === 0) {
89
+ const cacheKey = dir + '|' + extensions.slice().sort().join(',') + '|' +
90
+ [...excludedDirs, ..._extraExcludedDirs].sort().join(',');
91
+ const cached = _fileListCache.get(cacheKey);
92
+ if (cached) return [...cached]; // return copy to prevent mutation
93
+ const result = _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
94
+ _fileListCache.set(cacheKey, [...result]);
95
+ return result;
96
+ }
97
+
98
+ return _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth });
99
+ }
100
+
101
+ function _findFilesImpl(dir, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth }) {
102
+ if (depth > maxDepth) return results;
103
+ if (!fs.existsSync(dir)) return results;
104
+
105
+ // Merge runtime --exclude dirs so every scanner respects them
106
+ const allExcludedDirs = _extraExcludedDirs.length > 0
107
+ ? [...new Set([...excludedDirs, ..._extraExcludedDirs])]
108
+ : excludedDirs;
109
+
110
+ let items;
111
+ try {
112
+ items = fs.readdirSync(dir);
113
+ } catch {
114
+ return results;
115
+ }
116
+
117
+ for (const item of items) {
118
+ const fullPath = path.join(dir, item);
119
+
120
+ // Check both bare name ("tests") and relative path ("src/scanner")
121
+ if (allExcludedDirs.includes(item)) continue;
122
+ if (_extraExcludedDirs.length > 0 && _scanRoot) {
123
+ const rel = path.relative(_scanRoot, fullPath).replace(/\\/g, '/');
124
+ if (_extraExcludedDirs.some(ex => rel === ex || rel.startsWith(ex + '/'))) continue;
125
+ }
126
+
127
+ try {
128
+ const lstat = fs.lstatSync(fullPath);
129
+
130
+ // Symlink protection
131
+ if (lstat.isSymbolicLink()) {
132
+ try {
133
+ const realPath = fs.realpathSync(fullPath);
134
+ const realStat = fs.statSync(realPath);
135
+ if (realStat.ino !== 0 && visitedInodes.has(realStat.ino)) continue;
136
+ if (realStat.ino !== 0) visitedInodes.add(realStat.ino);
137
+ if (realStat.isDirectory()) {
138
+ _findFilesImpl(realPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
139
+ } else if (extensions.some(ext => item.endsWith(ext))) {
140
+ results.push(realPath);
141
+ }
142
+ } catch {
143
+ // Broken symlink, skip
144
+ }
145
+ continue;
146
+ }
147
+
148
+ if (lstat.ino !== 0) visitedInodes.add(lstat.ino);
149
+
150
+ if (lstat.isDirectory()) {
151
+ _findFilesImpl(fullPath, { extensions, excludedDirs, maxDepth, results, visitedInodes, depth: depth + 1 });
152
+ } else if (extensions.some(ext => item.endsWith(ext))) {
153
+ results.push(fullPath);
154
+ }
155
+ } catch {
156
+ // Ignore permission errors
157
+ }
158
+ }
159
+
160
+ return results;
161
+ }
162
+
163
+ /**
164
+ * Recursively searches for JavaScript files (convenience wrapper)
165
+ * @param {string} dir - Starting directory
166
+ * @param {string[]} [results=[]] - Accumulator array (internal use)
167
+ * @returns {string[]} List of .js file paths
168
+ */
169
+ function findJsFiles(dir, results = []) {
170
+ return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
171
+ }
172
+
173
+ function clearFileListCache() {
174
+ _fileListCache.clear();
175
+ }
176
+
177
+ /**
178
+ * Escapes HTML characters to prevent XSS
179
+ * @param {string} str - String to escape
180
+ * @returns {string} Escaped string
181
+ */
182
+ function escapeHtml(str) {
183
+ if (str === null || str === undefined) return '';
184
+ return String(str)
185
+ .replace(/&/g, '&amp;')
186
+ .replace(/</g, '&lt;')
187
+ .replace(/>/g, '&gt;')
188
+ .replace(/"/g, '&quot;')
189
+ .replace(/'/g, '&#x27;');
190
+ }
191
+
192
+ /**
193
+ * Extracts the function/method name from a CallExpression AST node
194
+ * @param {object} node - AST CallExpression node
195
+ * @returns {string} Function name or empty string
196
+ */
197
+ function getCallName(node) {
198
+ if (node.callee.type === 'Identifier') {
199
+ return node.callee.name;
200
+ }
201
+ if (node.callee.type === 'MemberExpression' && node.callee.property) {
202
+ return node.callee.property.name;
203
+ }
204
+ return '';
205
+ }
206
+
207
+ /**
208
+ * Minimal CLI spinner (npm/ora style, no external deps).
209
+ * Frames rotate every 100ms via setInterval.
210
+ * Uses ANSI escapes to clear/rewrite the current line.
211
+ */
212
+ class Spinner {
213
+ constructor() {
214
+ this._frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
215
+ this._index = 0;
216
+ this._interval = null;
217
+ this._text = '';
218
+ }
219
+
220
+ start(text) {
221
+ this._text = text;
222
+ this._index = 0;
223
+ if (this._interval) clearInterval(this._interval);
224
+ this._render();
225
+ this._interval = setInterval(() => this._render(), 100);
226
+ return this;
227
+ }
228
+
229
+ update(text) {
230
+ this._text = text;
231
+ }
232
+
233
+ succeed(text) {
234
+ this._stop();
235
+ process.stdout.write('\r\x1b[K\x1b[32m\u2713\x1b[0m ' + text + '\n');
236
+ }
237
+
238
+ fail(text) {
239
+ this._stop();
240
+ process.stdout.write('\r\x1b[K\x1b[31m\u2717\x1b[0m ' + text + '\n');
241
+ }
242
+
243
+ _render() {
244
+ const frame = this._frames[this._index % this._frames.length];
245
+ process.stdout.write('\r\x1b[K' + frame + ' ' + this._text);
246
+ this._index++;
247
+ }
248
+
249
+ _stop() {
250
+ if (this._interval) {
251
+ clearInterval(this._interval);
252
+ this._interval = null;
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Iterates files with size guard and error handling.
259
+ * Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
260
+ */
261
+ function forEachSafeFile(files, callback) {
262
+ for (const file of files) {
263
+ try {
264
+ const stat = fs.statSync(file);
265
+ if (stat.size > MAX_FILE_SIZE) continue;
266
+ } catch { continue; }
267
+ let content;
268
+ try {
269
+ content = fs.readFileSync(file, 'utf8');
270
+ } catch { continue; }
271
+ callback(file, content);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Lists installed packages in node_modules (handles scoped packages).
277
+ * @param {string} targetPath - Root of the project
278
+ * @returns {string[]} Package names (e.g. ['express', '@babel/core'])
279
+ */
280
+ function listInstalledPackages(targetPath) {
281
+ const nm = path.join(targetPath, 'node_modules');
282
+ if (!fs.existsSync(nm)) return [];
283
+ const names = [];
284
+ try {
285
+ for (const item of fs.readdirSync(nm)) {
286
+ if (item.startsWith('.')) continue;
287
+ const itemPath = path.join(nm, item);
288
+ try {
289
+ const stat = fs.lstatSync(itemPath);
290
+ if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
291
+ if (item.startsWith('@')) {
292
+ for (const si of fs.readdirSync(itemPath)) {
293
+ const ss = fs.lstatSync(path.join(itemPath, si));
294
+ if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
295
+ }
296
+ } else {
297
+ names.push(item);
298
+ }
299
+ } catch { /* skip unreadable */ }
300
+ }
301
+ } catch { /* no node_modules readable */ }
302
+ return names;
303
+ }
304
+
305
+ /**
306
+ * Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
307
+ */
308
+ function debugLog(...args) {
309
+ if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
310
+ }
311
+
312
+ module.exports = {
313
+ EXCLUDED_DIRS,
314
+ DEV_PATTERNS,
315
+ isDevFile,
316
+ findFiles,
317
+ findJsFiles,
318
+ clearFileListCache,
319
+ escapeHtml,
320
+ getCallName,
321
+ Spinner,
322
+ setExtraExcludes,
323
+ getExtraExcludes,
324
+ forEachSafeFile,
325
+ listInstalledPackages,
326
+ debugLog
327
+ };