@safetnsr/vet 0.4.0 → 0.6.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/dist/checks/debt.d.ts +2 -0
- package/dist/checks/debt.js +373 -0
- package/dist/checks/deps.d.ts +6 -0
- package/dist/checks/deps.js +276 -0
- package/dist/cli.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { join, basename } from 'node:path';
|
|
2
|
+
import { walkFiles, readFile } from '../util.js';
|
|
3
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
|
+
const SOURCE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx']);
|
|
5
|
+
function isSourceFile(f) {
|
|
6
|
+
const dot = f.lastIndexOf('.');
|
|
7
|
+
return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
|
|
8
|
+
}
|
|
9
|
+
function isTestFile(f) {
|
|
10
|
+
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || f.startsWith('test/') || f.startsWith('test\\');
|
|
11
|
+
}
|
|
12
|
+
function isEntryFile(f) {
|
|
13
|
+
const b = basename(f);
|
|
14
|
+
return /^(cli|main|index)\.[jt]sx?$/.test(b);
|
|
15
|
+
}
|
|
16
|
+
function isBarrelFile(f) {
|
|
17
|
+
const b = basename(f);
|
|
18
|
+
return /^index\.[jt]sx?$/.test(b);
|
|
19
|
+
}
|
|
20
|
+
/** Normalize a function body for comparison */
|
|
21
|
+
function normalize(body) {
|
|
22
|
+
let s = body;
|
|
23
|
+
// Replace string literals
|
|
24
|
+
s = s.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, '"S"');
|
|
25
|
+
// Replace number literals (but not in identifiers)
|
|
26
|
+
s = s.replace(/\b\d+\.?\d*\b/g, '0');
|
|
27
|
+
// Strip whitespace
|
|
28
|
+
s = s.replace(/\s+/g, '');
|
|
29
|
+
// Collapse variable names to single char (simple: replace camelCase identifiers)
|
|
30
|
+
s = s.replace(/\b[a-z][a-zA-Z0-9]{3,}\b/g, 'V');
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
/** Simple string hash */
|
|
34
|
+
function simpleHash(s) {
|
|
35
|
+
let h = 0;
|
|
36
|
+
for (let i = 0; i < s.length; i++) {
|
|
37
|
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
38
|
+
}
|
|
39
|
+
return h.toString(36);
|
|
40
|
+
}
|
|
41
|
+
/** Similarity ratio between two strings (0-1) */
|
|
42
|
+
function similarity(a, b) {
|
|
43
|
+
if (a === b)
|
|
44
|
+
return 1;
|
|
45
|
+
const longer = a.length >= b.length ? a : b;
|
|
46
|
+
const shorter = a.length >= b.length ? b : a;
|
|
47
|
+
if (longer.length === 0)
|
|
48
|
+
return 1;
|
|
49
|
+
// Count matching characters in sequence
|
|
50
|
+
let matches = 0;
|
|
51
|
+
const used = new Array(longer.length).fill(false);
|
|
52
|
+
for (let i = 0; i < shorter.length; i++) {
|
|
53
|
+
for (let j = 0; j < longer.length; j++) {
|
|
54
|
+
if (!used[j] && shorter[i] === longer[j]) {
|
|
55
|
+
matches++;
|
|
56
|
+
used[j] = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return matches / longer.length;
|
|
62
|
+
}
|
|
63
|
+
/** Extract function bodies with brace matching */
|
|
64
|
+
function extractBraceBody(source, startIdx) {
|
|
65
|
+
let idx = source.indexOf('{', startIdx);
|
|
66
|
+
if (idx === -1)
|
|
67
|
+
return null;
|
|
68
|
+
let depth = 0;
|
|
69
|
+
const start = idx;
|
|
70
|
+
for (let i = idx; i < source.length; i++) {
|
|
71
|
+
if (source[i] === '{')
|
|
72
|
+
depth++;
|
|
73
|
+
else if (source[i] === '}') {
|
|
74
|
+
depth--;
|
|
75
|
+
if (depth === 0)
|
|
76
|
+
return source.substring(start + 1, i);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/** Get line number for a character index */
|
|
82
|
+
function lineAt(source, idx) {
|
|
83
|
+
let line = 1;
|
|
84
|
+
for (let i = 0; i < idx && i < source.length; i++) {
|
|
85
|
+
if (source[i] === '\n')
|
|
86
|
+
line++;
|
|
87
|
+
}
|
|
88
|
+
return line;
|
|
89
|
+
}
|
|
90
|
+
/** Extract all named functions from source */
|
|
91
|
+
function extractFunctions(source, file) {
|
|
92
|
+
const fns = [];
|
|
93
|
+
// Named function declarations: function name(...)
|
|
94
|
+
const funcDeclRe = /\bfunction\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\([^)]*\)[^{]*/g;
|
|
95
|
+
let match;
|
|
96
|
+
while ((match = funcDeclRe.exec(source)) !== null) {
|
|
97
|
+
const body = extractBraceBody(source, match.index + match[0].length - 1);
|
|
98
|
+
if (body && body.trim().length > 10) {
|
|
99
|
+
const norm = normalize(body);
|
|
100
|
+
fns.push({
|
|
101
|
+
name: match[1],
|
|
102
|
+
body,
|
|
103
|
+
normalized: norm,
|
|
104
|
+
hash: simpleHash(norm),
|
|
105
|
+
file,
|
|
106
|
+
line: lineAt(source, match.index),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Arrow function assignments: const/let/var name = (...) => { ... }
|
|
111
|
+
// Also: export const name = (...) => { ... }
|
|
112
|
+
const arrowRe = /\b(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]*?)?\s*=>\s*\{/g;
|
|
113
|
+
while ((match = arrowRe.exec(source)) !== null) {
|
|
114
|
+
const braceStart = source.indexOf('{', match.index + match[0].length - 1);
|
|
115
|
+
const body = extractBraceBody(source, braceStart);
|
|
116
|
+
if (body && body.trim().length > 10) {
|
|
117
|
+
const norm = normalize(body);
|
|
118
|
+
fns.push({
|
|
119
|
+
name: match[1],
|
|
120
|
+
body,
|
|
121
|
+
normalized: norm,
|
|
122
|
+
hash: simpleHash(norm),
|
|
123
|
+
file,
|
|
124
|
+
line: lineAt(source, match.index),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return fns;
|
|
129
|
+
}
|
|
130
|
+
// ── A) Near-duplicate detection ──────────────────────────────────────────────
|
|
131
|
+
function findDuplicates(allFuncs) {
|
|
132
|
+
const issues = [];
|
|
133
|
+
const groups = new Map();
|
|
134
|
+
// Group by hash first (exact normalized match)
|
|
135
|
+
for (const fn of allFuncs) {
|
|
136
|
+
const existing = groups.get(fn.hash) || [];
|
|
137
|
+
existing.push(fn);
|
|
138
|
+
groups.set(fn.hash, existing);
|
|
139
|
+
}
|
|
140
|
+
const reported = new Set();
|
|
141
|
+
// Exact duplicates
|
|
142
|
+
for (const [, group] of groups) {
|
|
143
|
+
if (group.length < 2)
|
|
144
|
+
continue;
|
|
145
|
+
// Deduplicate by name+file
|
|
146
|
+
const key = group.map(f => `${f.file}:${f.name}`).sort().join('|');
|
|
147
|
+
if (reported.has(key))
|
|
148
|
+
continue;
|
|
149
|
+
reported.add(key);
|
|
150
|
+
const locations = group.map(f => `${f.name} (${f.file}:${f.line})`).join(', ');
|
|
151
|
+
issues.push({
|
|
152
|
+
severity: 'warning',
|
|
153
|
+
message: `near-duplicate functions: ${locations}`,
|
|
154
|
+
file: group[0].file,
|
|
155
|
+
line: group[0].line,
|
|
156
|
+
fixable: true,
|
|
157
|
+
fixHint: 'extract shared logic into a single function',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Similarity check for non-exact matches
|
|
161
|
+
const singles = allFuncs.filter(fn => {
|
|
162
|
+
const g = groups.get(fn.hash);
|
|
163
|
+
return !g || g.length < 2;
|
|
164
|
+
});
|
|
165
|
+
for (let i = 0; i < singles.length; i++) {
|
|
166
|
+
for (let j = i + 1; j < singles.length; j++) {
|
|
167
|
+
const a = singles[i];
|
|
168
|
+
const b = singles[j];
|
|
169
|
+
// Skip very short normalized bodies
|
|
170
|
+
if (a.normalized.length < 30 || b.normalized.length < 30)
|
|
171
|
+
continue;
|
|
172
|
+
const sim = similarity(a.normalized, b.normalized);
|
|
173
|
+
if (sim > 0.85) {
|
|
174
|
+
const key = [a.file + ':' + a.name, b.file + ':' + b.name].sort().join('|');
|
|
175
|
+
if (reported.has(key))
|
|
176
|
+
continue;
|
|
177
|
+
reported.add(key);
|
|
178
|
+
issues.push({
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
|
|
181
|
+
file: a.file,
|
|
182
|
+
line: a.line,
|
|
183
|
+
fixable: true,
|
|
184
|
+
fixHint: 'consider merging or extracting shared logic',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return issues;
|
|
190
|
+
}
|
|
191
|
+
// ── B) Orphaned exports ──────────────────────────────────────────────────────
|
|
192
|
+
function findOrphanedExports(cwd, files) {
|
|
193
|
+
const issues = [];
|
|
194
|
+
const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
|
|
195
|
+
// Collect all named exports
|
|
196
|
+
const exports = [];
|
|
197
|
+
for (const file of sourceFiles) {
|
|
198
|
+
if (isBarrelFile(file) || isEntryFile(file))
|
|
199
|
+
continue;
|
|
200
|
+
const content = readFile(join(cwd, file));
|
|
201
|
+
if (!content)
|
|
202
|
+
continue;
|
|
203
|
+
const lines = content.split('\n');
|
|
204
|
+
for (let i = 0; i < lines.length; i++) {
|
|
205
|
+
const line = lines[i];
|
|
206
|
+
// export function name
|
|
207
|
+
const funcMatch = line.match(/^export\s+(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
208
|
+
if (funcMatch) {
|
|
209
|
+
exports.push({ name: funcMatch[1], file, line: i + 1 });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// export const/let/var name
|
|
213
|
+
const constMatch = line.match(/^export\s+(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
214
|
+
if (constMatch) {
|
|
215
|
+
exports.push({ name: constMatch[1], file, line: i + 1 });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// export { name, name2 } — but skip type exports
|
|
219
|
+
if (/^export\s+type\s/.test(line))
|
|
220
|
+
continue;
|
|
221
|
+
const braceMatch = line.match(/^export\s*\{([^}]+)\}/);
|
|
222
|
+
if (braceMatch) {
|
|
223
|
+
const names = braceMatch[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
if (name === 'default' || name === 'type')
|
|
226
|
+
continue;
|
|
227
|
+
exports.push({ name, file, line: i + 1 });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Scan all files for imports of each name
|
|
233
|
+
const allContent = [];
|
|
234
|
+
for (const file of sourceFiles) {
|
|
235
|
+
const content = readFile(join(cwd, file));
|
|
236
|
+
if (content)
|
|
237
|
+
allContent.push(content);
|
|
238
|
+
}
|
|
239
|
+
const allText = allContent.join('\n');
|
|
240
|
+
for (const exp of exports) {
|
|
241
|
+
// Check if name appears in import statements across all files
|
|
242
|
+
// import { name } from or import { x, name } from or import { name as y }
|
|
243
|
+
const importPattern = new RegExp(`import\\s+[^;]*\\b${exp.name}\\b[^;]*from\\s+`, 'm');
|
|
244
|
+
if (!importPattern.test(allText)) {
|
|
245
|
+
issues.push({
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
message: `orphaned export: "${exp.name}" is exported but never imported`,
|
|
248
|
+
file: exp.file,
|
|
249
|
+
line: exp.line,
|
|
250
|
+
fixable: true,
|
|
251
|
+
fixHint: 'remove the export keyword or delete the function',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return issues;
|
|
256
|
+
}
|
|
257
|
+
// ── C) Wrapper pass-throughs ─────────────────────────────────────────────────
|
|
258
|
+
function findWrappers(allFuncs) {
|
|
259
|
+
const issues = [];
|
|
260
|
+
for (const fn of allFuncs) {
|
|
261
|
+
const trimmed = fn.body.trim();
|
|
262
|
+
// return someFn(args) or return someFn(...args)
|
|
263
|
+
if (/^return\s+[a-zA-Z_$][a-zA-Z0-9_$.]*\s*\([^)]*\)\s*;?\s*$/.test(trimmed)) {
|
|
264
|
+
issues.push({
|
|
265
|
+
severity: 'info',
|
|
266
|
+
message: `wrapper pass-through: ${fn.name} just delegates to another function`,
|
|
267
|
+
file: fn.file,
|
|
268
|
+
line: fn.line,
|
|
269
|
+
fixable: true,
|
|
270
|
+
fixHint: 'call the inner function directly instead',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return issues;
|
|
275
|
+
}
|
|
276
|
+
// ── D) Naming drift ─────────────────────────────────────────────────────────
|
|
277
|
+
function findNamingDrift(allFuncs) {
|
|
278
|
+
const issues = [];
|
|
279
|
+
// Common prefixes that indicate the same action
|
|
280
|
+
const actionPrefixes = ['get', 'fetch', 'load', 'retrieve', 'find', 'query', 'read', 'create', 'make', 'build', 'generate', 'set', 'update', 'save', 'write', 'delete', 'remove', 'destroy', 'handle', 'process', 'parse', 'format', 'validate', 'check', 'verify', 'is', 'has', 'can', 'should', 'init', 'setup', 'configure', 'start', 'stop', 'enable', 'disable', 'show', 'hide', 'render', 'display', 'transform', 'convert', 'map', 'filter', 'reduce', 'sort', 'merge', 'split', 'join', 'send', 'emit', 'dispatch', 'trigger', 'on', 'listen', 'subscribe', 'publish', 'notify', 'log', 'print', 'debug', 'warn', 'error'];
|
|
281
|
+
// Extract suffix groups: for each function name, find its suffix after stripping known prefixes
|
|
282
|
+
const suffixMap = new Map();
|
|
283
|
+
for (const fn of allFuncs) {
|
|
284
|
+
const name = fn.name;
|
|
285
|
+
for (const prefix of actionPrefixes) {
|
|
286
|
+
if (name.length > prefix.length && name.startsWith(prefix) && name[prefix.length] === name[prefix.length].toUpperCase()) {
|
|
287
|
+
const suffix = name.substring(prefix.length);
|
|
288
|
+
const existing = suffixMap.get(suffix) || [];
|
|
289
|
+
// Avoid duplicate entries
|
|
290
|
+
if (!existing.some(e => e.name === name)) {
|
|
291
|
+
existing.push({ prefix, name, file: fn.file });
|
|
292
|
+
suffixMap.set(suffix, existing);
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const [suffix, entries] of suffixMap) {
|
|
299
|
+
const uniquePrefixes = new Set(entries.map(e => e.prefix));
|
|
300
|
+
if (uniquePrefixes.size >= 3) {
|
|
301
|
+
const names = entries.map(e => e.name).join(', ');
|
|
302
|
+
issues.push({
|
|
303
|
+
severity: 'info',
|
|
304
|
+
message: `naming drift: ${uniquePrefixes.size} prefixes for "${suffix}": ${names}`,
|
|
305
|
+
fixable: false,
|
|
306
|
+
fixHint: 'standardize on one prefix pattern',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return issues;
|
|
311
|
+
}
|
|
312
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
313
|
+
export async function checkDebt(cwd, ignore) {
|
|
314
|
+
const allFiles = walkFiles(cwd, ignore);
|
|
315
|
+
const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f));
|
|
316
|
+
if (sourceFiles.length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
name: 'debt',
|
|
319
|
+
score: 10,
|
|
320
|
+
maxScore: 10,
|
|
321
|
+
issues: [],
|
|
322
|
+
summary: 'no source files to analyze',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// Extract all functions
|
|
326
|
+
const allFuncs = [];
|
|
327
|
+
for (const file of sourceFiles) {
|
|
328
|
+
const content = readFile(join(cwd, file));
|
|
329
|
+
if (!content)
|
|
330
|
+
continue;
|
|
331
|
+
allFuncs.push(...extractFunctions(content, file));
|
|
332
|
+
}
|
|
333
|
+
const issues = [];
|
|
334
|
+
// A) Duplicates
|
|
335
|
+
const dupIssues = findDuplicates(allFuncs);
|
|
336
|
+
issues.push(...dupIssues);
|
|
337
|
+
// B) Orphaned exports
|
|
338
|
+
const orphanIssues = findOrphanedExports(cwd, allFiles);
|
|
339
|
+
issues.push(...orphanIssues);
|
|
340
|
+
// C) Wrappers
|
|
341
|
+
const wrapperIssues = findWrappers(allFuncs);
|
|
342
|
+
issues.push(...wrapperIssues);
|
|
343
|
+
// D) Naming drift
|
|
344
|
+
const driftIssues = findNamingDrift(allFuncs);
|
|
345
|
+
issues.push(...driftIssues);
|
|
346
|
+
// ── Scoring ──────────────────────────────────────────────────────────────
|
|
347
|
+
const dupPenalty = Math.min(6, dupIssues.length * 1.5);
|
|
348
|
+
const orphanPenalty = Math.min(3, orphanIssues.length * 0.5);
|
|
349
|
+
const wrapperPenalty = Math.min(1.5, wrapperIssues.length * 0.3);
|
|
350
|
+
const driftPenalty = Math.min(1, driftIssues.length * 0.2);
|
|
351
|
+
const rawScore = 10 - dupPenalty - orphanPenalty - wrapperPenalty - driftPenalty;
|
|
352
|
+
const finalScore = Math.max(0, Math.round(rawScore * 10) / 10);
|
|
353
|
+
// ── Summary ──────────────────────────────────────────────────────────────
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (dupIssues.length > 0)
|
|
356
|
+
parts.push(`${dupIssues.length} duplicate${dupIssues.length !== 1 ? 's' : ''}`);
|
|
357
|
+
if (orphanIssues.length > 0)
|
|
358
|
+
parts.push(`${orphanIssues.length} orphaned export${orphanIssues.length !== 1 ? 's' : ''}`);
|
|
359
|
+
if (wrapperIssues.length > 0)
|
|
360
|
+
parts.push(`${wrapperIssues.length} wrapper${wrapperIssues.length !== 1 ? 's' : ''}`);
|
|
361
|
+
if (driftIssues.length > 0)
|
|
362
|
+
parts.push(`${driftIssues.length} naming drift`);
|
|
363
|
+
const summary = parts.length === 0
|
|
364
|
+
? `${sourceFiles.length} files analyzed, no technical debt found`
|
|
365
|
+
: `${sourceFiles.length} files: ${parts.join(', ')}`;
|
|
366
|
+
return {
|
|
367
|
+
name: 'debt',
|
|
368
|
+
score: finalScore,
|
|
369
|
+
maxScore: 10,
|
|
370
|
+
issues,
|
|
371
|
+
summary,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export declare function levenshtein(a: string, b: string): number;
|
|
3
|
+
export declare function extractImports(source: string): string[];
|
|
4
|
+
export declare function extractPackageName(specifier: string): string | null;
|
|
5
|
+
export declare function isBuiltin(specifier: string): boolean;
|
|
6
|
+
export declare function checkDeps(cwd: string): Promise<CheckResult>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { walkFiles, readFile } from '../util.js';
|
|
4
|
+
// ── Top packages list (~150 popular npm packages) ────────────────────────────
|
|
5
|
+
const TOP_PACKAGES = [
|
|
6
|
+
'react', 'react-dom', 'next', 'vue', 'angular', 'express', 'koa', 'fastify', 'hono',
|
|
7
|
+
'axios', 'node-fetch', 'chalk', 'commander', 'yargs', 'inquirer', 'lodash', 'underscore',
|
|
8
|
+
'ramda', 'moment', 'dayjs', 'date-fns', 'uuid', 'nanoid', 'dotenv', 'cors', 'helmet',
|
|
9
|
+
'morgan', 'winston', 'pino', 'debug', 'zod', 'joi', 'yup', 'ajv', 'prettier', 'eslint',
|
|
10
|
+
'typescript', 'webpack', 'vite', 'rollup', 'esbuild', 'swc', 'babel', 'jest', 'vitest',
|
|
11
|
+
'mocha', 'chai', 'sinon', 'supertest', 'playwright', 'puppeteer', 'cypress', 'mongoose',
|
|
12
|
+
'prisma', 'drizzle-orm', 'knex', 'sequelize', 'pg', 'mysql2', 'better-sqlite3', 'redis',
|
|
13
|
+
'ioredis', 'bullmq', 'sharp', 'jimp', 'multer', 'formidable', 'nodemailer', 'socket.io',
|
|
14
|
+
'ws', 'mqtt', 'graphql', 'apollo-server', 'trpc', 'stripe', 'aws-sdk', 'firebase',
|
|
15
|
+
'supabase', 'openai', 'langchain', 'oclif', 'glob', 'minimatch', 'micromatch', 'semver',
|
|
16
|
+
'minimist', 'cross-env', 'concurrently', 'tsx', 'ts-node', 'rimraf', 'mkdirp', 'fs-extra',
|
|
17
|
+
'chokidar', 'ora', 'listr2', 'boxen', 'figlet', 'gradient-string', 'conf', 'cosmiconfig',
|
|
18
|
+
'execa', 'got', 'ky', 'undici', 'cheerio', 'jsdom', 'marked', 'gray-matter', 'unified',
|
|
19
|
+
'rehype', 'remark', 'mdast', 'hast', 'three', 'd3', 'chart.js', 'tailwindcss', 'postcss',
|
|
20
|
+
'sass', 'less', 'styled-components', 'emotion',
|
|
21
|
+
];
|
|
22
|
+
// ── Node.js builtins ─────────────────────────────────────────────────────────
|
|
23
|
+
const NODE_BUILTINS = new Set([
|
|
24
|
+
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants',
|
|
25
|
+
'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', 'events', 'fs', 'http',
|
|
26
|
+
'http2', 'https', 'inspector', 'module', 'net', 'os', 'path', 'perf_hooks',
|
|
27
|
+
'process', 'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder',
|
|
28
|
+
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'wasi',
|
|
29
|
+
'worker_threads', 'zlib', 'test',
|
|
30
|
+
// also with node: prefix variants handled separately
|
|
31
|
+
'fs/promises', 'stream/promises', 'timers/promises', 'dns/promises',
|
|
32
|
+
'stream/web', 'stream/consumers', 'readline/promises', 'util/types',
|
|
33
|
+
]);
|
|
34
|
+
// ── Levenshtein distance ─────────────────────────────────────────────────────
|
|
35
|
+
export function levenshtein(a, b) {
|
|
36
|
+
const m = a.length;
|
|
37
|
+
const n = b.length;
|
|
38
|
+
if (m === 0)
|
|
39
|
+
return n;
|
|
40
|
+
if (n === 0)
|
|
41
|
+
return m;
|
|
42
|
+
const dp = [];
|
|
43
|
+
for (let i = 0; i <= m; i++) {
|
|
44
|
+
dp[i] = [i];
|
|
45
|
+
}
|
|
46
|
+
for (let j = 1; j <= n; j++) {
|
|
47
|
+
dp[0][j] = j;
|
|
48
|
+
}
|
|
49
|
+
for (let i = 1; i <= m; i++) {
|
|
50
|
+
for (let j = 1; j <= n; j++) {
|
|
51
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
52
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return dp[m][n];
|
|
56
|
+
}
|
|
57
|
+
// ── Import extraction ────────────────────────────────────────────────────────
|
|
58
|
+
export function extractImports(source) {
|
|
59
|
+
const imports = new Set();
|
|
60
|
+
// import ... from 'pkg'
|
|
61
|
+
const importFrom = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
62
|
+
let match;
|
|
63
|
+
while ((match = importFrom.exec(source)) !== null) {
|
|
64
|
+
imports.add(match[1]);
|
|
65
|
+
}
|
|
66
|
+
// require('pkg')
|
|
67
|
+
const requirePat = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
68
|
+
while ((match = requirePat.exec(source)) !== null) {
|
|
69
|
+
imports.add(match[1]);
|
|
70
|
+
}
|
|
71
|
+
// import('pkg')
|
|
72
|
+
const dynamicImport = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
73
|
+
while ((match = dynamicImport.exec(source)) !== null) {
|
|
74
|
+
imports.add(match[1]);
|
|
75
|
+
}
|
|
76
|
+
return [...imports];
|
|
77
|
+
}
|
|
78
|
+
// ── Package name extraction ──────────────────────────────────────────────────
|
|
79
|
+
export function extractPackageName(specifier) {
|
|
80
|
+
// Skip relative imports
|
|
81
|
+
if (specifier.startsWith('.') || specifier.startsWith('/'))
|
|
82
|
+
return null;
|
|
83
|
+
// Skip node: builtins
|
|
84
|
+
if (specifier.startsWith('node:'))
|
|
85
|
+
return null;
|
|
86
|
+
// Scoped packages: @scope/name or @scope/name/sub
|
|
87
|
+
if (specifier.startsWith('@')) {
|
|
88
|
+
const parts = specifier.split('/');
|
|
89
|
+
if (parts.length < 2)
|
|
90
|
+
return null;
|
|
91
|
+
return `${parts[0]}/${parts[1]}`;
|
|
92
|
+
}
|
|
93
|
+
// Regular package: name or name/sub
|
|
94
|
+
return specifier.split('/')[0];
|
|
95
|
+
}
|
|
96
|
+
// ── Builtin check ────────────────────────────────────────────────────────────
|
|
97
|
+
export function isBuiltin(specifier) {
|
|
98
|
+
if (specifier.startsWith('node:'))
|
|
99
|
+
return true;
|
|
100
|
+
const name = specifier.split('/')[0];
|
|
101
|
+
if (NODE_BUILTINS.has(name))
|
|
102
|
+
return true;
|
|
103
|
+
// Also check full specifier for subpath builtins
|
|
104
|
+
if (NODE_BUILTINS.has(specifier))
|
|
105
|
+
return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
// ── Registry check with concurrency limit ────────────────────────────────────
|
|
109
|
+
async function checkRegistry(packages) {
|
|
110
|
+
const results = new Map();
|
|
111
|
+
const queue = [...packages];
|
|
112
|
+
let networkError = false;
|
|
113
|
+
async function checkOne(pkg) {
|
|
114
|
+
try {
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
117
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg}`, {
|
|
118
|
+
method: 'HEAD',
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
});
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
results.set(pkg, res.status !== 404);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
networkError = true;
|
|
126
|
+
results.set(pkg, true); // assume exists on error
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Process in batches of 5
|
|
130
|
+
const concurrency = 5;
|
|
131
|
+
for (let i = 0; i < queue.length; i += concurrency) {
|
|
132
|
+
const batch = queue.slice(i, i + concurrency);
|
|
133
|
+
await Promise.all(batch.map(checkOne));
|
|
134
|
+
}
|
|
135
|
+
if (networkError) {
|
|
136
|
+
results.set('__network_error__', true);
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
141
|
+
export async function checkDeps(cwd) {
|
|
142
|
+
const issues = [];
|
|
143
|
+
// Read package.json
|
|
144
|
+
let declaredDeps = {};
|
|
145
|
+
let hasPkgJson = false;
|
|
146
|
+
try {
|
|
147
|
+
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
148
|
+
if (pkgRaw) {
|
|
149
|
+
const pkg = JSON.parse(pkgRaw);
|
|
150
|
+
hasPkgJson = true;
|
|
151
|
+
declaredDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch { /* skip */ }
|
|
155
|
+
if (!hasPkgJson) {
|
|
156
|
+
return {
|
|
157
|
+
name: 'deps',
|
|
158
|
+
score: 10,
|
|
159
|
+
maxScore: 10,
|
|
160
|
+
issues: [],
|
|
161
|
+
summary: 'no package.json found',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const declaredNames = Object.keys(declaredDeps);
|
|
165
|
+
// ── 1. Registry check (nonexistent packages) ──────────────────────────────
|
|
166
|
+
const registryResults = await checkRegistry(declaredNames);
|
|
167
|
+
if (registryResults.get('__network_error__')) {
|
|
168
|
+
issues.push({
|
|
169
|
+
severity: 'info',
|
|
170
|
+
message: 'could not reach npm registry — skipping existence checks',
|
|
171
|
+
fixable: false,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
for (const pkg of declaredNames) {
|
|
175
|
+
if (registryResults.get(pkg) === false) {
|
|
176
|
+
issues.push({
|
|
177
|
+
severity: 'error',
|
|
178
|
+
message: `phantom dependency: "${pkg}" does not exist on npm`,
|
|
179
|
+
file: 'package.json',
|
|
180
|
+
fixable: true,
|
|
181
|
+
fixHint: 'remove from package.json',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ── 2. Typosquat detection ─────────────────────────────────────────────────
|
|
186
|
+
const topSet = new Set(TOP_PACKAGES);
|
|
187
|
+
for (const pkg of declaredNames) {
|
|
188
|
+
if (topSet.has(pkg))
|
|
189
|
+
continue; // it IS the popular package
|
|
190
|
+
for (const top of TOP_PACKAGES) {
|
|
191
|
+
const dist = levenshtein(pkg, top);
|
|
192
|
+
if (dist >= 1 && dist <= 2) {
|
|
193
|
+
issues.push({
|
|
194
|
+
severity: 'error',
|
|
195
|
+
message: `possible typosquat: "${pkg}" is ${dist} edit${dist > 1 ? 's' : ''} from "${top}"`,
|
|
196
|
+
file: 'package.json',
|
|
197
|
+
fixable: true,
|
|
198
|
+
fixHint: `did you mean "${top}"?`,
|
|
199
|
+
});
|
|
200
|
+
break; // one match is enough
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── 3 & 4. Dead deps + phantom imports ─────────────────────────────────────
|
|
205
|
+
const sourceExts = new Set(['.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
|
|
206
|
+
const allFiles = walkFiles(cwd);
|
|
207
|
+
const sourceFiles = allFiles.filter(f => {
|
|
208
|
+
const ext = f.substring(f.lastIndexOf('.'));
|
|
209
|
+
return sourceExts.has(ext);
|
|
210
|
+
});
|
|
211
|
+
const importedPackages = new Set();
|
|
212
|
+
for (const file of sourceFiles) {
|
|
213
|
+
try {
|
|
214
|
+
const content = readFileSync(join(cwd, file), 'utf-8');
|
|
215
|
+
const rawImports = extractImports(content);
|
|
216
|
+
for (const imp of rawImports) {
|
|
217
|
+
if (isBuiltin(imp))
|
|
218
|
+
continue;
|
|
219
|
+
const pkg = extractPackageName(imp);
|
|
220
|
+
if (pkg)
|
|
221
|
+
importedPackages.add(pkg);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch { /* skip unreadable files */ }
|
|
225
|
+
}
|
|
226
|
+
// Dead deps: declared but never imported
|
|
227
|
+
const declaredSet = new Set(declaredNames);
|
|
228
|
+
for (const pkg of declaredNames) {
|
|
229
|
+
if (!importedPackages.has(pkg)) {
|
|
230
|
+
// Check if it's a CLI tool / plugin / type package (common false positives)
|
|
231
|
+
// Still flag it, but as info
|
|
232
|
+
issues.push({
|
|
233
|
+
severity: 'info',
|
|
234
|
+
message: `unused dependency: "${pkg}" is declared but never imported`,
|
|
235
|
+
file: 'package.json',
|
|
236
|
+
fixable: true,
|
|
237
|
+
fixHint: 'remove from package.json',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Phantom imports: imported but not declared
|
|
242
|
+
for (const pkg of importedPackages) {
|
|
243
|
+
if (!declaredSet.has(pkg)) {
|
|
244
|
+
issues.push({
|
|
245
|
+
severity: 'warning',
|
|
246
|
+
message: `phantom import: "${pkg}" is imported but not in package.json`,
|
|
247
|
+
fixable: true,
|
|
248
|
+
fixHint: `run: npm install ${pkg}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ── Scoring ────────────────────────────────────────────────────────────────
|
|
253
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
254
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
255
|
+
const rawScore = 10 - (errors * 3) - (warnings * 1);
|
|
256
|
+
const finalScore = Math.max(0, Math.min(10, rawScore));
|
|
257
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
258
|
+
const parts = [];
|
|
259
|
+
if (errors > 0)
|
|
260
|
+
parts.push(`${errors} error${errors !== 1 ? 's' : ''}`);
|
|
261
|
+
if (warnings > 0)
|
|
262
|
+
parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`);
|
|
263
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
264
|
+
if (infos > 0)
|
|
265
|
+
parts.push(`${infos} info`);
|
|
266
|
+
const summary = parts.length === 0
|
|
267
|
+
? `${declaredNames.length} dependencies checked, all clean`
|
|
268
|
+
: `${declaredNames.length} dependencies: ${parts.join(', ')}`;
|
|
269
|
+
return {
|
|
270
|
+
name: 'deps',
|
|
271
|
+
score: finalScore,
|
|
272
|
+
maxScore: 10,
|
|
273
|
+
issues,
|
|
274
|
+
summary,
|
|
275
|
+
};
|
|
276
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ import { checkConfig } from './checks/config.js';
|
|
|
9
9
|
import { checkHistory } from './checks/history.js';
|
|
10
10
|
import { checkScan } from './checks/scan.js';
|
|
11
11
|
import { checkSecrets } from './checks/secrets.js';
|
|
12
|
+
import { checkDeps } from './checks/deps.js';
|
|
13
|
+
import { checkDebt } from './checks/debt.js';
|
|
12
14
|
import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
|
|
13
15
|
import { score } from './scorer.js';
|
|
14
16
|
import { reportPretty, reportJSON } from './reporter.js';
|
|
@@ -48,6 +50,8 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
48
50
|
scan malicious patterns in agent config files
|
|
49
51
|
secrets leaked secrets in build output and .env files
|
|
50
52
|
receipt last agent session audit (informational)
|
|
53
|
+
deps phantom/hallucinated dependency detection
|
|
54
|
+
debt AI-generated technical debt patterns
|
|
51
55
|
|
|
52
56
|
${c.dim}options:${c.reset}
|
|
53
57
|
--ci CI mode (exit 1 if score < threshold)
|
|
@@ -120,7 +124,7 @@ if (isFix) {
|
|
|
120
124
|
process.exit(0);
|
|
121
125
|
}
|
|
122
126
|
async function runChecks() {
|
|
123
|
-
const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt'];
|
|
127
|
+
const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt', 'deps', 'debt'];
|
|
124
128
|
const enabledChecks = config.checks || allChecks;
|
|
125
129
|
const results = [];
|
|
126
130
|
// ready and models are async (try rich subpackages first, fallback to built-in)
|
|
@@ -140,6 +144,10 @@ async function runChecks() {
|
|
|
140
144
|
results.push(await checkSecrets(cwd));
|
|
141
145
|
if (enabledChecks.includes('receipt'))
|
|
142
146
|
results.push(await checkReceipt(cwd));
|
|
147
|
+
if (enabledChecks.includes('deps'))
|
|
148
|
+
results.push(await checkDeps(cwd));
|
|
149
|
+
if (enabledChecks.includes('debt'))
|
|
150
|
+
results.push(await checkDebt(cwd, ignore));
|
|
143
151
|
return score(cwd, results);
|
|
144
152
|
}
|
|
145
153
|
// --watch mode
|