@safetnsr/vet 1.10.1 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checks/debt.js +182 -41
- package/dist/checks/deps.d.ts +1 -0
- package/dist/checks/deps.js +109 -5
- package/dist/checks/integrity.js +113 -10
- package/dist/checks/memory.js +17 -2
- package/dist/checks/models.js +4 -0
- package/dist/checks/owasp/asi03-identity-abuse.js +3 -1
- package/dist/checks/owasp/asi06-memory-poisoning.js +3 -2
- package/dist/checks/owasp/shared.js +3 -2
- package/dist/checks/ready.js +33 -6
- package/dist/checks/scan.js +6 -2
- package/dist/checks/secrets.js +3 -2
- package/dist/checks/tests.js +27 -3
- package/dist/checks/verify.js +56 -4
- package/dist/cli.js +27 -40
- package/dist/file-cache.d.ts +4 -0
- package/dist/file-cache.js +22 -0
- package/dist/util.js +3 -7
- package/package.json +1 -1
package/dist/checks/debt.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { join, basename } from 'node:path';
|
|
1
|
+
import { join, basename, dirname } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { walkFiles, readFile } from '../util.js';
|
|
3
4
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
5
|
const SOURCE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx']);
|
|
@@ -38,27 +39,93 @@ function simpleHash(s) {
|
|
|
38
39
|
}
|
|
39
40
|
return h.toString(36);
|
|
40
41
|
}
|
|
42
|
+
/** Levenshtein distance (optimized single-row DP, with early exit) */
|
|
43
|
+
function levenshtein(a, b, maxDist) {
|
|
44
|
+
if (a === b)
|
|
45
|
+
return 0;
|
|
46
|
+
if (a.length === 0)
|
|
47
|
+
return b.length;
|
|
48
|
+
if (b.length === 0)
|
|
49
|
+
return a.length;
|
|
50
|
+
// Ensure a is shorter for memory efficiency
|
|
51
|
+
if (a.length > b.length) {
|
|
52
|
+
const t = a;
|
|
53
|
+
a = b;
|
|
54
|
+
b = t;
|
|
55
|
+
}
|
|
56
|
+
const aLen = a.length;
|
|
57
|
+
const bLen = b.length;
|
|
58
|
+
// For very long strings, use sampled comparison instead of full DP
|
|
59
|
+
if (aLen > 500) {
|
|
60
|
+
return sampledDistance(a, b, maxDist);
|
|
61
|
+
}
|
|
62
|
+
const row = new Uint32Array(aLen + 1);
|
|
63
|
+
for (let i = 0; i <= aLen; i++)
|
|
64
|
+
row[i] = i;
|
|
65
|
+
for (let j = 1; j <= bLen; j++) {
|
|
66
|
+
let prev = row[0];
|
|
67
|
+
row[0] = j;
|
|
68
|
+
let rowMin = j;
|
|
69
|
+
for (let i = 1; i <= aLen; i++) {
|
|
70
|
+
const cur = row[i];
|
|
71
|
+
if (a[i - 1] === b[j - 1]) {
|
|
72
|
+
row[i] = prev;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
row[i] = 1 + Math.min(prev, row[i], row[i - 1]);
|
|
76
|
+
}
|
|
77
|
+
prev = cur;
|
|
78
|
+
if (row[i] < rowMin)
|
|
79
|
+
rowMin = row[i];
|
|
80
|
+
}
|
|
81
|
+
// Early exit if minimum in this row already exceeds threshold
|
|
82
|
+
if (rowMin > maxDist)
|
|
83
|
+
return rowMin;
|
|
84
|
+
}
|
|
85
|
+
return row[aLen];
|
|
86
|
+
}
|
|
87
|
+
/** Fast sampled distance for long strings — compare chunks instead of full DP */
|
|
88
|
+
function sampledDistance(a, b, maxDist) {
|
|
89
|
+
const maxLen = Math.max(a.length, b.length);
|
|
90
|
+
// Sample 5 chunks of 80 chars each from evenly spaced positions
|
|
91
|
+
const chunkSize = 80;
|
|
92
|
+
const samples = 5;
|
|
93
|
+
let totalDiff = 0;
|
|
94
|
+
let totalSampled = 0;
|
|
95
|
+
for (let s = 0; s < samples; s++) {
|
|
96
|
+
const pos = Math.floor((s / samples) * (Math.min(a.length, b.length) - chunkSize));
|
|
97
|
+
if (pos < 0)
|
|
98
|
+
continue;
|
|
99
|
+
const ca = a.substring(pos, pos + chunkSize);
|
|
100
|
+
const cb = b.substring(pos, pos + chunkSize);
|
|
101
|
+
let diff = 0;
|
|
102
|
+
for (let i = 0; i < chunkSize; i++) {
|
|
103
|
+
if (ca[i] !== cb[i])
|
|
104
|
+
diff++;
|
|
105
|
+
}
|
|
106
|
+
totalDiff += diff;
|
|
107
|
+
totalSampled += chunkSize;
|
|
108
|
+
}
|
|
109
|
+
if (totalSampled === 0)
|
|
110
|
+
return maxLen;
|
|
111
|
+
// Extrapolate
|
|
112
|
+
const estDist = Math.round((totalDiff / totalSampled) * maxLen);
|
|
113
|
+
return estDist;
|
|
114
|
+
}
|
|
41
115
|
/** Similarity ratio between two strings (0-1) */
|
|
42
116
|
function similarity(a, b) {
|
|
43
117
|
if (a === b)
|
|
44
118
|
return 1;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
if (longer.length === 0)
|
|
119
|
+
const maxLen = Math.max(a.length, b.length);
|
|
120
|
+
if (maxLen === 0)
|
|
48
121
|
return 1;
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
used[j] = true;
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return matches / longer.length;
|
|
122
|
+
// Quick reject: if length diff alone makes similarity impossible
|
|
123
|
+
const lenDiff = Math.abs(a.length - b.length);
|
|
124
|
+
if (1 - lenDiff / maxLen < 0.92)
|
|
125
|
+
return 0;
|
|
126
|
+
const maxDist = Math.floor(maxLen * 0.08); // 92% similarity = 8% max distance
|
|
127
|
+
const dist = levenshtein(a, b, maxDist);
|
|
128
|
+
return 1 - dist / maxLen;
|
|
62
129
|
}
|
|
63
130
|
/** Extract function bodies with brace matching */
|
|
64
131
|
function extractBraceBody(source, startIdx) {
|
|
@@ -166,8 +233,12 @@ function findDuplicates(allFuncs) {
|
|
|
166
233
|
if (isSpecPattern(group))
|
|
167
234
|
continue;
|
|
168
235
|
const locations = group.map(f => `${f.name} (${f.file}:${f.line})`).join(', ');
|
|
236
|
+
// Downgrade to info if all functions in the group are in test directories
|
|
237
|
+
// or if any function is in an examples/demo directory
|
|
238
|
+
const allInTest = group.every(f => isInTestDir(f.file));
|
|
239
|
+
const anyInExample = group.some(f => /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(f.file));
|
|
169
240
|
issues.push({
|
|
170
|
-
severity: 'warning',
|
|
241
|
+
severity: (allInTest || anyInExample) ? 'info' : 'warning',
|
|
171
242
|
message: `near-duplicate functions: ${locations}`,
|
|
172
243
|
file: group[0].file,
|
|
173
244
|
line: group[0].line,
|
|
@@ -175,28 +246,38 @@ function findDuplicates(allFuncs) {
|
|
|
175
246
|
fixHint: 'extract shared logic into a single function',
|
|
176
247
|
});
|
|
177
248
|
}
|
|
178
|
-
// Similarity check for non-exact matches
|
|
249
|
+
// Similarity check for non-exact matches — length-bucketed to avoid O(n²) explosion
|
|
250
|
+
// Only consider functions with substantial normalized bodies (>= 65 chars)
|
|
179
251
|
const singles = allFuncs.filter(fn => {
|
|
180
252
|
const g = groups.get(fn.hash);
|
|
181
|
-
return !g || g.length < 2;
|
|
253
|
+
return (!g || g.length < 2) && fn.normalized.length >= 65;
|
|
182
254
|
});
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
255
|
+
// Sort by normalized length so we can break early when lengths diverge
|
|
256
|
+
singles.sort((a, b) => a.normalized.length - b.normalized.length);
|
|
257
|
+
let comparisons = 0;
|
|
258
|
+
const MAX_COMPARISONS = 200_000; // safety cap
|
|
259
|
+
for (let i = 0; i < singles.length && comparisons < MAX_COMPARISONS; i++) {
|
|
260
|
+
const a = singles[i];
|
|
261
|
+
for (let j = i + 1; j < singles.length; j++) {
|
|
188
262
|
const b = singles[j];
|
|
189
|
-
//
|
|
190
|
-
if (
|
|
191
|
-
|
|
263
|
+
// If b is >25% longer than a, skip rest (sorted, so all further are longer)
|
|
264
|
+
if (b.normalized.length > a.normalized.length * 1.25)
|
|
265
|
+
break;
|
|
266
|
+
comparisons++;
|
|
267
|
+
if (comparisons > MAX_COMPARISONS)
|
|
268
|
+
break;
|
|
192
269
|
const sim = similarity(a.normalized, b.normalized);
|
|
193
270
|
if (sim > 0.92) {
|
|
194
271
|
const key = [a.file + ':' + a.name, b.file + ':' + b.name].sort().join('|');
|
|
195
272
|
if (reported.has(key))
|
|
196
273
|
continue;
|
|
197
274
|
reported.add(key);
|
|
275
|
+
// Downgrade to info if both functions are in test directories
|
|
276
|
+
// or if either is in an examples/demo directory
|
|
277
|
+
const bothInTest = isInTestDir(a.file) && isInTestDir(b.file);
|
|
278
|
+
const anyInExample = /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(a.file) || /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(b.file);
|
|
198
279
|
issues.push({
|
|
199
|
-
severity: 'warning',
|
|
280
|
+
severity: (bothInTest || anyInExample) ? 'info' : 'warning',
|
|
200
281
|
message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
|
|
201
282
|
file: a.file,
|
|
202
283
|
line: a.line,
|
|
@@ -221,6 +302,46 @@ function isLibrary(cwd) {
|
|
|
221
302
|
return false;
|
|
222
303
|
}
|
|
223
304
|
}
|
|
305
|
+
function isMonorepo(cwd) {
|
|
306
|
+
try {
|
|
307
|
+
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
308
|
+
if (pkgRaw) {
|
|
309
|
+
const pkg = JSON.parse(pkgRaw);
|
|
310
|
+
if (Array.isArray(pkg.workspaces) || pkg.workspaces?.packages)
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch { /* skip */ }
|
|
315
|
+
if (existsSync(join(cwd, 'pnpm-workspace.yaml')))
|
|
316
|
+
return true;
|
|
317
|
+
if (existsSync(join(cwd, 'lerna.json')))
|
|
318
|
+
return true;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
/** Find nearest package.json upward from a file path, check if it's a library */
|
|
322
|
+
function isFileInLibraryPackage(cwd, filePath) {
|
|
323
|
+
let dir = dirname(join(cwd, filePath));
|
|
324
|
+
const root = cwd;
|
|
325
|
+
while (dir.length >= root.length) {
|
|
326
|
+
const pkgPath = join(dir, 'package.json');
|
|
327
|
+
try {
|
|
328
|
+
const raw = readFile(pkgPath);
|
|
329
|
+
if (raw) {
|
|
330
|
+
// Don't count the root package.json — we already check that via isLibrary
|
|
331
|
+
if (dir === root)
|
|
332
|
+
return false;
|
|
333
|
+
const pkg = JSON.parse(raw);
|
|
334
|
+
return !!(pkg.main || pkg.exports || pkg.module || pkg.types || pkg.bin);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch { /* skip */ }
|
|
338
|
+
const parent = dirname(dir);
|
|
339
|
+
if (parent === dir)
|
|
340
|
+
break;
|
|
341
|
+
dir = parent;
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
224
345
|
function findOrphanedExports(cwd, files) {
|
|
225
346
|
const issues = [];
|
|
226
347
|
const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
|
|
@@ -261,27 +382,43 @@ function findOrphanedExports(cwd, files) {
|
|
|
261
382
|
}
|
|
262
383
|
}
|
|
263
384
|
}
|
|
264
|
-
// Scan all files for
|
|
265
|
-
const
|
|
385
|
+
// Scan all files for import names — collect all imported identifiers into a Set
|
|
386
|
+
const importedNames = new Set();
|
|
387
|
+
const importRe = /import\s+(?:type\s+)?(?:\{([^}]+)\}|([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*,\s*\{([^}]+)\})?)\s+from\s+/g;
|
|
266
388
|
for (const file of sourceFiles) {
|
|
267
389
|
const content = readFile(join(cwd, file));
|
|
268
|
-
if (content)
|
|
269
|
-
|
|
390
|
+
if (!content)
|
|
391
|
+
continue;
|
|
392
|
+
let match;
|
|
393
|
+
importRe.lastIndex = 0;
|
|
394
|
+
while ((match = importRe.exec(content)) !== null) {
|
|
395
|
+
// Named imports: { a, b as c }
|
|
396
|
+
const namedParts = [match[1], match[3]].filter(Boolean);
|
|
397
|
+
for (const part of namedParts) {
|
|
398
|
+
for (const name of part.split(',')) {
|
|
399
|
+
const trimmed = name.trim().split(/\s+as\s+/)[0].trim();
|
|
400
|
+
if (trimmed)
|
|
401
|
+
importedNames.add(trimmed);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Default import
|
|
405
|
+
if (match[2])
|
|
406
|
+
importedNames.add(match[2]);
|
|
407
|
+
}
|
|
270
408
|
}
|
|
271
|
-
const allText = allContent.join('\n');
|
|
272
409
|
const lib = isLibrary(cwd);
|
|
410
|
+
const mono = isMonorepo(cwd);
|
|
273
411
|
for (const exp of exports) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (!importPattern.test(allText)) {
|
|
412
|
+
if (!importedNames.has(exp.name)) {
|
|
413
|
+
// In monorepos, check if the export's file is inside a workspace package that is a library
|
|
414
|
+
const isLib = lib || (mono && isFileInLibraryPackage(cwd, exp.file));
|
|
278
415
|
issues.push({
|
|
279
|
-
severity:
|
|
280
|
-
message: `orphaned export: "${exp.name}" is exported but never imported${
|
|
416
|
+
severity: isLib ? 'info' : 'warning',
|
|
417
|
+
message: `orphaned export: "${exp.name}" is exported but never imported${isLib ? ' (library detected — exports may be consumed externally)' : ''}`,
|
|
281
418
|
file: exp.file,
|
|
282
419
|
line: exp.line,
|
|
283
420
|
fixable: true,
|
|
284
|
-
fixHint:
|
|
421
|
+
fixHint: isLib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
|
|
285
422
|
});
|
|
286
423
|
}
|
|
287
424
|
}
|
|
@@ -343,6 +480,10 @@ function findNamingDrift(allFuncs) {
|
|
|
343
480
|
return issues;
|
|
344
481
|
}
|
|
345
482
|
// ── Main check ───────────────────────────────────────────────────────────────
|
|
483
|
+
/** Check if a file path is in a test directory or is a test file */
|
|
484
|
+
function isInTestDir(file) {
|
|
485
|
+
return /(?:^|[/\\])(?:test|tests|__tests__)[/\\]/.test(file) || /\.(?:test|spec)\.[jt]sx?$/.test(file);
|
|
486
|
+
}
|
|
346
487
|
export async function checkDebt(cwd, ignore) {
|
|
347
488
|
const allFiles = walkFiles(cwd, ignore);
|
|
348
489
|
const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f));
|
package/dist/checks/deps.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ export declare function extractPackageName(specifier: string): string | null;
|
|
|
5
5
|
export declare function isBuiltin(specifier: string): boolean;
|
|
6
6
|
export declare function detectWorkspacePackages(cwd: string): Set<string>;
|
|
7
7
|
export declare function detectProvidedDeps(cwd: string): Set<string>;
|
|
8
|
+
export declare function collectWorkspaceDeps(cwd: string): Set<string>;
|
|
8
9
|
export declare function checkDeps(cwd: string): Promise<CheckResult>;
|
package/dist/checks/deps.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { walkFiles, readFile } from '../util.js';
|
|
4
|
+
import { cachedRead } from '../file-cache.js';
|
|
4
5
|
// ── Top packages list (~150 popular npm packages) ────────────────────────────
|
|
5
6
|
const TOP_PACKAGES = [
|
|
6
7
|
'react', 'react-dom', 'next', 'vue', 'angular', 'express', 'koa', 'fastify', 'hono',
|
|
@@ -81,6 +82,9 @@ export function extractPackageName(specifier) {
|
|
|
81
82
|
// Skip relative imports
|
|
82
83
|
if (specifier.startsWith('.') || specifier.startsWith('/'))
|
|
83
84
|
return null;
|
|
85
|
+
// Skip URL imports
|
|
86
|
+
if (specifier.startsWith('http://') || specifier.startsWith('https://'))
|
|
87
|
+
return null;
|
|
84
88
|
// Skip node: builtins
|
|
85
89
|
if (specifier.startsWith('node:'))
|
|
86
90
|
return null;
|
|
@@ -240,7 +244,7 @@ export function detectProvidedDeps(cwd) {
|
|
|
240
244
|
if (!pkgRaw)
|
|
241
245
|
return provided;
|
|
242
246
|
const pkg = JSON.parse(pkgRaw);
|
|
243
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
247
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies, ...pkg.peerDependencies };
|
|
244
248
|
// Obsidian plugin
|
|
245
249
|
const hasObsidian = 'obsidian' in (allDeps || {});
|
|
246
250
|
const manifestPath = join(cwd, 'manifest.json');
|
|
@@ -280,19 +284,111 @@ function isProvidedPackage(pkg, provided) {
|
|
|
280
284
|
return true;
|
|
281
285
|
return false;
|
|
282
286
|
}
|
|
287
|
+
// ── Tooling packages (CLI-only, never imported in source) ────────────────────
|
|
288
|
+
const TOOLING_PACKAGES = new Set([
|
|
289
|
+
'typescript', '@types/node', '@biomejs/biome', 'biome', 'prettier', 'eslint',
|
|
290
|
+
'husky', 'lint-staged', 'tsx', 'ts-node', 'concurrently', 'npm-run-all',
|
|
291
|
+
'shx', 'rimraf', 'cross-env', 'nodemon', 'jest', 'vitest', 'mocha',
|
|
292
|
+
'c8', 'nyc', 'turbo', 'lerna', 'changesets', '@changesets/cli',
|
|
293
|
+
'webpack', 'webpack-cli', 'vite', 'rollup', 'esbuild', 'swc',
|
|
294
|
+
'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less',
|
|
295
|
+
'commitizen', 'cz-conventional-changelog', 'semantic-release',
|
|
296
|
+
'@typescript/native-preview',
|
|
297
|
+
]);
|
|
298
|
+
// ── Collect all deps declared in workspace sub-packages ──────────────────────
|
|
299
|
+
export function collectWorkspaceDeps(cwd) {
|
|
300
|
+
const allDeps = new Set();
|
|
301
|
+
const globs = [];
|
|
302
|
+
try {
|
|
303
|
+
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
304
|
+
if (pkgRaw) {
|
|
305
|
+
const pkg = JSON.parse(pkgRaw);
|
|
306
|
+
if (Array.isArray(pkg.workspaces))
|
|
307
|
+
globs.push(...pkg.workspaces);
|
|
308
|
+
else if (pkg.workspaces?.packages)
|
|
309
|
+
globs.push(...pkg.workspaces.packages);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch { /* skip */ }
|
|
313
|
+
try {
|
|
314
|
+
const pnpmWs = readFile(join(cwd, 'pnpm-workspace.yaml'));
|
|
315
|
+
if (pnpmWs) {
|
|
316
|
+
const matches = pnpmWs.matchAll(/['"]?([^'":\n]+\*[^'":\n]*)['"]?/g);
|
|
317
|
+
for (const m of matches)
|
|
318
|
+
globs.push(m[1].trim());
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch { /* skip */ }
|
|
322
|
+
try {
|
|
323
|
+
const lernaRaw = readFile(join(cwd, 'lerna.json'));
|
|
324
|
+
if (lernaRaw) {
|
|
325
|
+
const lerna = JSON.parse(lernaRaw);
|
|
326
|
+
if (Array.isArray(lerna.packages))
|
|
327
|
+
globs.push(...lerna.packages);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch { /* skip */ }
|
|
331
|
+
function addDepsFromPkg(pkgPath) {
|
|
332
|
+
try {
|
|
333
|
+
const raw = readFile(pkgPath);
|
|
334
|
+
if (!raw)
|
|
335
|
+
return;
|
|
336
|
+
const pkg = JSON.parse(raw);
|
|
337
|
+
for (const key of Object.keys(pkg.dependencies || {}))
|
|
338
|
+
allDeps.add(key);
|
|
339
|
+
for (const key of Object.keys(pkg.devDependencies || {}))
|
|
340
|
+
allDeps.add(key);
|
|
341
|
+
for (const key of Object.keys(pkg.optionalDependencies || {}))
|
|
342
|
+
allDeps.add(key);
|
|
343
|
+
for (const key of Object.keys(pkg.peerDependencies || {}))
|
|
344
|
+
allDeps.add(key);
|
|
345
|
+
}
|
|
346
|
+
catch { /* skip */ }
|
|
347
|
+
}
|
|
348
|
+
for (const glob of globs) {
|
|
349
|
+
const parts = glob.replace(/\/$/, '').split('/');
|
|
350
|
+
const starIdx = parts.indexOf('*');
|
|
351
|
+
if (starIdx === -1) {
|
|
352
|
+
addDepsFromPkg(join(cwd, glob, 'package.json'));
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const prefix = parts.slice(0, starIdx).join('/');
|
|
356
|
+
const prefixDir = join(cwd, prefix);
|
|
357
|
+
try {
|
|
358
|
+
if (existsSync(prefixDir) && statSync(prefixDir).isDirectory()) {
|
|
359
|
+
for (const entry of readdirSync(prefixDir)) {
|
|
360
|
+
const entryDir = join(prefixDir, entry);
|
|
361
|
+
try {
|
|
362
|
+
if (!statSync(entryDir).isDirectory())
|
|
363
|
+
continue;
|
|
364
|
+
const suffix = parts.slice(starIdx + 1);
|
|
365
|
+
const pkgDir = suffix.length > 0 ? join(entryDir, ...suffix) : entryDir;
|
|
366
|
+
addDepsFromPkg(join(pkgDir, 'package.json'));
|
|
367
|
+
}
|
|
368
|
+
catch { /* skip */ }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch { /* skip */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return allDeps;
|
|
376
|
+
}
|
|
283
377
|
// ── Main check ───────────────────────────────────────────────────────────────
|
|
284
378
|
export async function checkDeps(cwd) {
|
|
285
379
|
try {
|
|
286
380
|
const issues = [];
|
|
287
381
|
// Read package.json
|
|
288
382
|
let declaredDeps = {};
|
|
383
|
+
let devDeps = {};
|
|
289
384
|
let hasPkgJson = false;
|
|
290
385
|
try {
|
|
291
386
|
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
292
387
|
if (pkgRaw) {
|
|
293
388
|
const pkg = JSON.parse(pkgRaw);
|
|
294
389
|
hasPkgJson = true;
|
|
295
|
-
|
|
390
|
+
devDeps = pkg.devDependencies || {};
|
|
391
|
+
declaredDeps = { ...pkg.dependencies, ...devDeps, ...pkg.optionalDependencies, ...pkg.peerDependencies };
|
|
296
392
|
}
|
|
297
393
|
}
|
|
298
394
|
catch { /* skip */ }
|
|
@@ -367,7 +463,7 @@ export async function checkDeps(cwd) {
|
|
|
367
463
|
const importedPackages = new Set();
|
|
368
464
|
for (const file of sourceFiles) {
|
|
369
465
|
try {
|
|
370
|
-
const content =
|
|
466
|
+
const content = cachedRead(join(cwd, file));
|
|
371
467
|
const rawImports = extractImports(content);
|
|
372
468
|
for (const imp of rawImports) {
|
|
373
469
|
if (isBuiltin(imp))
|
|
@@ -381,8 +477,12 @@ export async function checkDeps(cwd) {
|
|
|
381
477
|
}
|
|
382
478
|
// Dead deps: declared but never imported
|
|
383
479
|
const declaredSet = new Set(declaredNames);
|
|
480
|
+
const devDepNames = new Set(Object.keys(devDeps));
|
|
384
481
|
for (const pkg of declaredNames) {
|
|
385
482
|
if (!importedPackages.has(pkg)) {
|
|
483
|
+
// Skip known tooling packages that are devDependencies (used via CLI scripts, not imports)
|
|
484
|
+
if (TOOLING_PACKAGES.has(pkg) && devDepNames.has(pkg))
|
|
485
|
+
continue;
|
|
386
486
|
// Check if it's a CLI tool / plugin / type package (common false positives)
|
|
387
487
|
// Still flag it, but as info
|
|
388
488
|
issues.push({
|
|
@@ -396,13 +496,17 @@ export async function checkDeps(cwd) {
|
|
|
396
496
|
}
|
|
397
497
|
// Detect workspace packages and host-provided deps
|
|
398
498
|
const workspacePackages = detectWorkspacePackages(cwd);
|
|
499
|
+
const workspaceDeps = collectWorkspaceDeps(cwd);
|
|
399
500
|
const providedDeps = detectProvidedDeps(cwd);
|
|
400
501
|
// Phantom imports: imported but not declared
|
|
401
502
|
for (const pkg of importedPackages) {
|
|
402
503
|
if (!declaredSet.has(pkg)) {
|
|
403
|
-
// Skip workspace packages
|
|
504
|
+
// Skip workspace packages (local packages in the monorepo)
|
|
404
505
|
if (workspacePackages.has(pkg))
|
|
405
506
|
continue;
|
|
507
|
+
// Skip deps declared in any workspace sub-package
|
|
508
|
+
if (workspaceDeps.has(pkg))
|
|
509
|
+
continue;
|
|
406
510
|
// Skip host-provided deps
|
|
407
511
|
if (isProvidedPackage(pkg, providedDeps))
|
|
408
512
|
continue;
|
package/dist/checks/integrity.js
CHANGED
|
@@ -66,8 +66,22 @@ function isCommentLine(line) {
|
|
|
66
66
|
function extractRelativeImports(source) {
|
|
67
67
|
const imports = [];
|
|
68
68
|
const lines = source.split('\n');
|
|
69
|
+
let inTemplateLiteral = false;
|
|
69
70
|
for (let i = 0; i < lines.length; i++) {
|
|
70
71
|
const line = lines[i];
|
|
72
|
+
// Track template literal context — check state at start of line, then update
|
|
73
|
+
const wasInTemplate = inTemplateLiteral;
|
|
74
|
+
for (let ci = 0; ci < line.length; ci++) {
|
|
75
|
+
if (line[ci] === '\\') {
|
|
76
|
+
ci++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (line[ci] === '`')
|
|
80
|
+
inTemplateLiteral = !inTemplateLiteral;
|
|
81
|
+
}
|
|
82
|
+
// Skip lines that start inside a template literal — they contain generated code, not real imports
|
|
83
|
+
if (wasInTemplate)
|
|
84
|
+
continue;
|
|
71
85
|
// Skip comment lines
|
|
72
86
|
if (isCommentLine(line))
|
|
73
87
|
continue;
|
|
@@ -153,16 +167,19 @@ function checkEmptyCatch(cwd, files) {
|
|
|
153
167
|
// Skip test files — empty catches in tests are usually intentional (testing error paths)
|
|
154
168
|
if (isTestFile(file))
|
|
155
169
|
continue;
|
|
170
|
+
// Skip example/demo directories — example code doesn't need production error handling
|
|
171
|
+
if (/(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(file))
|
|
172
|
+
continue;
|
|
156
173
|
const content = readFile(join(cwd, file));
|
|
157
174
|
if (!content)
|
|
158
175
|
continue;
|
|
159
176
|
const lines = content.split('\n');
|
|
160
177
|
for (let i = 0; i < lines.length; i++) {
|
|
161
178
|
const line = lines[i];
|
|
162
|
-
// single-line catch with param and empty body — error
|
|
179
|
+
// single-line catch with param and empty body — warning (was error, too harsh)
|
|
163
180
|
if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line)) {
|
|
164
181
|
issues.push({
|
|
165
|
-
severity: '
|
|
182
|
+
severity: 'warning',
|
|
166
183
|
message: 'empty catch block — error silently swallowed',
|
|
167
184
|
file,
|
|
168
185
|
line: i + 1,
|
|
@@ -174,7 +191,7 @@ function checkEmptyCatch(cwd, files) {
|
|
|
174
191
|
// single-line catch without param and empty body
|
|
175
192
|
if (/catch\s*\{\s*\}/.test(line)) {
|
|
176
193
|
issues.push({
|
|
177
|
-
severity: '
|
|
194
|
+
severity: 'warning',
|
|
178
195
|
message: 'empty catch block — error silently swallowed',
|
|
179
196
|
file,
|
|
180
197
|
line: i + 1,
|
|
@@ -213,9 +230,16 @@ function checkEmptyCatch(cwd, files) {
|
|
|
213
230
|
// Check if block body is only comments
|
|
214
231
|
const bodyText = blockLines.join('\n').replace(/\}$/, '').trim();
|
|
215
232
|
if (bodyText.length > 0 && /^(\s*(\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*$/.test(bodyText)) {
|
|
233
|
+
// If the comment contains TODO/FIXME/HACK/XXX/WIP/implement, keep as warning (unfinished work)
|
|
234
|
+
// TEMP only as standalone marker (not "temporary" used as adjective)
|
|
235
|
+
// Otherwise, any comment text means someone documented why it's empty → downgrade to info
|
|
236
|
+
const unfinishedRe = /\b(TODO|FIXME|HACK|XXX|WIP|implement)\b|\bTEMP\b(?!orar)/i;
|
|
237
|
+
const sev = unfinishedRe.test(bodyText) ? 'warning' : 'info';
|
|
216
238
|
issues.push({
|
|
217
|
-
severity:
|
|
218
|
-
message:
|
|
239
|
+
severity: sev,
|
|
240
|
+
message: sev === 'info'
|
|
241
|
+
? 'catch block with intentional comment — acknowledged'
|
|
242
|
+
: 'catch block contains only comments — consider proper error handling',
|
|
219
243
|
file,
|
|
220
244
|
line: i + 1,
|
|
221
245
|
fixable: false,
|
|
@@ -337,6 +361,63 @@ function hasGlobalErrorHandling(content) {
|
|
|
337
361
|
return true;
|
|
338
362
|
return false;
|
|
339
363
|
}
|
|
364
|
+
function buildFuncScopes(lines) {
|
|
365
|
+
const scopes = [];
|
|
366
|
+
// Find function start lines
|
|
367
|
+
const funcStarts = [];
|
|
368
|
+
for (let i = 0; i < lines.length; i++) {
|
|
369
|
+
const l = lines[i];
|
|
370
|
+
// function declarations and arrow functions
|
|
371
|
+
const isFuncDecl = /(?:async\s+)?function\s+\w/.test(l) && /\{/.test(l);
|
|
372
|
+
const isArrow = /=>\s*\{/.test(l);
|
|
373
|
+
const isMethod = /^\s+(?:async\s+)?\w+\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/.test(l) && !/\b(?:if|for|while|switch|catch)\b/.test(l);
|
|
374
|
+
if (isFuncDecl || isArrow || isMethod) {
|
|
375
|
+
const isExported = /^\s*export\s/.test(l) || (i > 0 && /^\s*export\s/.test(lines[i - 1]));
|
|
376
|
+
funcStarts.push({ line: i, isExported });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const fs of funcStarts) {
|
|
380
|
+
// Find the opening brace on the start line
|
|
381
|
+
let braceIdx = lines[fs.line].indexOf('{');
|
|
382
|
+
if (braceIdx === -1)
|
|
383
|
+
continue;
|
|
384
|
+
let depth = 0;
|
|
385
|
+
let endLine = fs.line;
|
|
386
|
+
let hasTry = false;
|
|
387
|
+
for (let i = fs.line; i < lines.length; i++) {
|
|
388
|
+
const startJ = i === fs.line ? braceIdx : 0;
|
|
389
|
+
for (let j = startJ; j < lines[i].length; j++) {
|
|
390
|
+
if (lines[i][j] === '{')
|
|
391
|
+
depth++;
|
|
392
|
+
if (lines[i][j] === '}') {
|
|
393
|
+
depth--;
|
|
394
|
+
if (depth === 0) {
|
|
395
|
+
endLine = i;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (/\btry\s*\{/.test(lines[i]))
|
|
401
|
+
hasTry = true;
|
|
402
|
+
if (depth === 0)
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
scopes.push({ startLine: fs.line, endLine, hasTryCatch: hasTry, isExported: fs.isExported });
|
|
406
|
+
}
|
|
407
|
+
return scopes;
|
|
408
|
+
}
|
|
409
|
+
function findEnclosingFunc(scopes, lineIdx) {
|
|
410
|
+
// Find the tightest (smallest range) enclosing function
|
|
411
|
+
let best = null;
|
|
412
|
+
for (const s of scopes) {
|
|
413
|
+
if (lineIdx >= s.startLine && lineIdx <= s.endLine) {
|
|
414
|
+
if (!best || (s.endLine - s.startLine) < (best.endLine - best.startLine)) {
|
|
415
|
+
best = s;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return best;
|
|
420
|
+
}
|
|
340
421
|
function checkUnhandledAsync(cwd, files) {
|
|
341
422
|
const issues = [];
|
|
342
423
|
const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
@@ -349,6 +430,9 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
349
430
|
// Skip error boundary files — they ARE the error handlers
|
|
350
431
|
if (isErrorBoundaryFile(file))
|
|
351
432
|
continue;
|
|
433
|
+
// Skip example/demo directories — example code doesn't need production error handling
|
|
434
|
+
if (/(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(file))
|
|
435
|
+
continue;
|
|
352
436
|
const content = readFile(join(cwd, file));
|
|
353
437
|
if (!content)
|
|
354
438
|
continue;
|
|
@@ -383,6 +467,8 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
383
467
|
insideTry.add(i);
|
|
384
468
|
}
|
|
385
469
|
}
|
|
470
|
+
// Build function scope info for severity decisions
|
|
471
|
+
const funcScopes = buildFuncScopes(lines);
|
|
386
472
|
for (let i = 0; i < lines.length; i++) {
|
|
387
473
|
const line = lines[i];
|
|
388
474
|
// await without try/catch context — detect standalone awaits
|
|
@@ -399,11 +485,27 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
399
485
|
const hasThenError = /\.then\s*\([^,]+,\s*\w+/.test(line) || (i + 1 < lines.length && /\.then\s*\([^,]+,\s*\w+/.test(lines[i + 1]));
|
|
400
486
|
if (!hasCatch && !hasThenError) {
|
|
401
487
|
unhandledCount++;
|
|
402
|
-
//
|
|
488
|
+
// Determine severity:
|
|
489
|
+
// - 'info' for Next.js server components, or functions that have try/catch elsewhere in their body
|
|
490
|
+
// - 'warning' only for exported functions with NO try/catch anywhere
|
|
491
|
+
// - 'info' for everything else (non-exported, internal functions)
|
|
403
492
|
const isServerComp = isNextjsServerComponent(file);
|
|
493
|
+
const enclosing = findEnclosingFunc(funcScopes, i);
|
|
494
|
+
const hasFuncTryCatch = enclosing?.hasTryCatch ?? false;
|
|
495
|
+
const isExported = enclosing?.isExported ?? false;
|
|
496
|
+
let severity;
|
|
497
|
+
if (isServerComp || hasFuncTryCatch) {
|
|
498
|
+
severity = 'info';
|
|
499
|
+
}
|
|
500
|
+
else if (isExported) {
|
|
501
|
+
severity = 'warning';
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
severity = 'info';
|
|
505
|
+
}
|
|
404
506
|
if (unhandledCount <= 10) {
|
|
405
507
|
issues.push({
|
|
406
|
-
severity
|
|
508
|
+
severity,
|
|
407
509
|
message: isServerComp
|
|
408
510
|
? 'unhandled async: await without try/catch (Next.js server component — framework-managed)'
|
|
409
511
|
: 'unhandled async: await without try/catch',
|
|
@@ -436,10 +538,11 @@ export async function checkIntegrity(cwd, ignore) {
|
|
|
436
538
|
let score = 100;
|
|
437
539
|
score -= hallucinatedIssues.length * 10;
|
|
438
540
|
score -= emptyCatchIssues.filter(i => i.severity === 'error').length * 8;
|
|
541
|
+
score -= emptyCatchIssues.filter(i => i.severity === 'warning').length * 3;
|
|
439
542
|
score -= stubbedTestIssues.filter(i => i.severity === 'error').length * 5;
|
|
440
|
-
// Unhandled async capped at -
|
|
441
|
-
const
|
|
442
|
-
score -= Math.min(
|
|
543
|
+
// Unhandled async capped at -15 (only count warnings, not info-downgraded ones)
|
|
544
|
+
const unhandledWarnings = unhandledAsyncIssues.filter(i => i.severity === 'warning').length;
|
|
545
|
+
score -= Math.min(15, unhandledWarnings * 3);
|
|
443
546
|
score = Math.max(0, Math.round(score));
|
|
444
547
|
// Summary parts
|
|
445
548
|
const parts = [];
|
package/dist/checks/memory.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path';
|
|
2
|
-
import { existsSync, readdirSync, statSync
|
|
2
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { readFile } from '../util.js';
|
|
4
|
+
import { cachedRead } from '../file-cache.js';
|
|
4
5
|
import { detectWorkspacePackages } from './deps.js';
|
|
5
6
|
// ── Memory file targets ──────────────────────────────────────────────────────
|
|
6
7
|
const ROOT_FILES = ['CLAUDE.md', 'AGENTS.md', 'SOUL.md', '.cursorrules', 'codex.md'];
|
|
@@ -153,7 +154,7 @@ export function checkMemory(cwd) {
|
|
|
153
154
|
const allDeps = new Set();
|
|
154
155
|
if (existsSync(pkgPath)) {
|
|
155
156
|
try {
|
|
156
|
-
const pkg = JSON.parse(
|
|
157
|
+
const pkg = JSON.parse(cachedRead(pkgPath));
|
|
157
158
|
// Include the package's own name
|
|
158
159
|
if (pkg.name)
|
|
159
160
|
allDeps.add(pkg.name);
|
|
@@ -201,6 +202,20 @@ export function checkMemory(cwd) {
|
|
|
201
202
|
// Skip ../ references — they point to sibling repos and can't be validated locally
|
|
202
203
|
if (p.startsWith('../'))
|
|
203
204
|
continue;
|
|
205
|
+
// Skip relative paths that appear inside inline code (`...`) in markdown files
|
|
206
|
+
if (relPath.endsWith('.md') || relPath.endsWith('.mdx')) {
|
|
207
|
+
const lineText = content.split('\n')[line - 1] || '';
|
|
208
|
+
// Check if this path is inside backtick code spans (inline code examples)
|
|
209
|
+
const pathIdx = lineText.indexOf(p);
|
|
210
|
+
if (pathIdx >= 0) {
|
|
211
|
+
const before = lineText.substring(0, pathIdx);
|
|
212
|
+
const after = lineText.substring(pathIdx + p.length);
|
|
213
|
+
// Count backticks before — odd means inside inline code
|
|
214
|
+
const ticksBefore = (before.match(/`/g) || []).length;
|
|
215
|
+
if (ticksBefore % 2 === 1)
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
204
219
|
const resolved = p.startsWith('/') ? p : resolve(cwd, p);
|
|
205
220
|
if (!existsSync(resolved)) {
|
|
206
221
|
issues.push({
|
package/dist/checks/models.js
CHANGED
|
@@ -102,12 +102,16 @@ async function tryModelGraveyard(cwd) {
|
|
|
102
102
|
const aiFramework = isAiFramework(cwd);
|
|
103
103
|
// Files that define deprecated model registries should not be flagged
|
|
104
104
|
const SELF_FILES = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset', 'fix/models'];
|
|
105
|
+
const GENERATED_PATTERNS = ['.generated.', '.gen.'];
|
|
105
106
|
for (const match of report.matches) {
|
|
106
107
|
if (!match.model)
|
|
107
108
|
continue;
|
|
108
109
|
// Skip self-referencing files (model definition/fix files)
|
|
109
110
|
if (match.file && SELF_FILES.some(s => match.file.toLowerCase().includes(s)))
|
|
110
111
|
continue;
|
|
112
|
+
// Skip auto-generated model registries
|
|
113
|
+
if (match.file && GENERATED_PATTERNS.some(p => match.file.includes(p)))
|
|
114
|
+
continue;
|
|
111
115
|
if (match.model.status === 'deprecated' || match.model.status === 'eol') {
|
|
112
116
|
const inTestDocs = match.file && isTestOrDocsFile(match.file);
|
|
113
117
|
const severity = (aiFramework || inTestDocs) ? 'info' : 'error';
|
|
@@ -21,6 +21,8 @@ export function checkASI03(cwd, configFiles) {
|
|
|
21
21
|
continue;
|
|
22
22
|
const contentLower = content.toLowerCase();
|
|
23
23
|
const relPath = relative(cwd, filePath);
|
|
24
|
+
const normalizedPath = relPath.replace(/\\/g, '/');
|
|
25
|
+
const isCiFile = normalizedPath.startsWith('.github/workflows/') || normalizedPath.startsWith('.circleci/') || normalizedPath.startsWith('.gitlab-ci');
|
|
24
26
|
if (leastPrivKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
|
|
25
27
|
hasLeastPrivMention = true;
|
|
26
28
|
}
|
|
@@ -55,7 +57,7 @@ export function checkASI03(cwd, configFiles) {
|
|
|
55
57
|
deduction = Math.min(deduction + 15, 30);
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
|
-
if (sudoPattern.test(line)) {
|
|
60
|
+
if (!isCiFile && sudoPattern.test(line)) {
|
|
59
61
|
findings.push({
|
|
60
62
|
asiId: 'ASI03',
|
|
61
63
|
severity: 'warning',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
3
|
import { readTextFile } from './shared.js';
|
|
4
|
+
import { cachedRead } from '../../file-cache.js';
|
|
4
5
|
// ── ASI06 — Memory and Context Poisoning ─────────────────────────────────────
|
|
5
6
|
export function checkASI06(cwd) {
|
|
6
7
|
const findings = [];
|
|
@@ -17,7 +18,7 @@ export function checkASI06(cwd) {
|
|
|
17
18
|
const gitignorePath = join(cwd, '.gitignore');
|
|
18
19
|
let gitignoreContent = '';
|
|
19
20
|
try {
|
|
20
|
-
gitignoreContent =
|
|
21
|
+
gitignoreContent = cachedRead(gitignorePath);
|
|
21
22
|
}
|
|
22
23
|
catch { /* intentional: .gitignore may not exist */ }
|
|
23
24
|
for (const memPath of memoryPaths) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { statSync, existsSync } from 'node:fs';
|
|
3
3
|
import { isTextFile, collectDirFiles } from '../../util.js';
|
|
4
|
+
import { cachedRead } from '../../file-cache.js';
|
|
4
5
|
// ── Agent config file targets ─────────────────────────────────────────────────
|
|
5
6
|
const AGENT_CONFIG_TARGETS = [
|
|
6
7
|
'.claude',
|
|
@@ -54,7 +55,7 @@ export function readTextFile(filePath) {
|
|
|
54
55
|
if (!isTextFile(filePath))
|
|
55
56
|
return null;
|
|
56
57
|
try {
|
|
57
|
-
return
|
|
58
|
+
return cachedRead(filePath);
|
|
58
59
|
}
|
|
59
60
|
catch { /* intentional: resolver may fail on unreadable files */ }
|
|
60
61
|
return null;
|
package/dist/checks/ready.js
CHANGED
|
@@ -87,19 +87,46 @@ function builtinReady(cwd, ignore) {
|
|
|
87
87
|
issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
|
|
88
88
|
}
|
|
89
89
|
let largeFileCount = 0;
|
|
90
|
+
const generatedFileRe = /(?:\.generated\.[jt]sx?$|\.gen\.[jt]s$|\.min\.[jt]s$|\.min\.css$|(?:^|[/\\])(?:generated|vendor|__generated__)[/\\])/;
|
|
91
|
+
const exampleDirRe = /(?:^|[/\\])(?:examples?|demos?|scripts?)[/\\]/;
|
|
92
|
+
const testFileRe = /(?:\.(?:test|spec)\.[jt]sx?$|(?:^|[/\\])(?:test|tests|__tests__)[/\\])/;
|
|
93
|
+
const largeFileIssues = [];
|
|
90
94
|
for (const f of files) {
|
|
91
95
|
if (!codeExts.some(ext => f.endsWith(ext)))
|
|
92
96
|
continue;
|
|
97
|
+
// Skip generated/vendored/minified files
|
|
98
|
+
if (generatedFileRe.test(f))
|
|
99
|
+
continue;
|
|
100
|
+
// Bug 5: Skip files in examples/demo directories
|
|
101
|
+
if (exampleDirRe.test(f))
|
|
102
|
+
continue;
|
|
93
103
|
const content = readFile(join(cwd, f));
|
|
94
|
-
if (content
|
|
104
|
+
if (!content)
|
|
105
|
+
continue;
|
|
106
|
+
const lineCount = content.split('\n').length;
|
|
107
|
+
// Bug 3: Higher threshold for test files (1000 vs 500)
|
|
108
|
+
const threshold = testFileRe.test(f) ? 1000 : 500;
|
|
109
|
+
if (lineCount > threshold) {
|
|
95
110
|
largeFileCount++;
|
|
96
|
-
|
|
97
|
-
issues.push({ severity: 'warning', message: `${f} is ${content.split('\n').length} lines — split for better AI comprehension`, fixable: false });
|
|
98
|
-
}
|
|
111
|
+
largeFileIssues.push({ severity: 'warning', message: `${f} is ${lineCount} lines — split for better AI comprehension`, fixable: false });
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
// Bug 4: Cap large file penalty for monorepos
|
|
115
|
+
if (isMonorepo && largeFileIssues.length > 10) {
|
|
116
|
+
// First 10 stay as warnings, rest downgraded to info
|
|
117
|
+
for (let i = 10; i < largeFileIssues.length; i++) {
|
|
118
|
+
largeFileIssues[i].severity = 'info';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Add issues (show first 3 inline, rest as summary)
|
|
122
|
+
for (let i = 0; i < Math.min(3, largeFileIssues.length); i++) {
|
|
123
|
+
issues.push(largeFileIssues[i]);
|
|
124
|
+
}
|
|
125
|
+
if (largeFileIssues.length > 3) {
|
|
126
|
+
// Add remaining issues individually (for JSON output) but summarize in display
|
|
127
|
+
for (let i = 3; i < largeFileIssues.length; i++) {
|
|
128
|
+
issues.push(largeFileIssues[i]);
|
|
129
|
+
}
|
|
103
130
|
}
|
|
104
131
|
const hasEnv = files.some(f => f === '.env' || f === '.env.local');
|
|
105
132
|
const hasEnvExample = files.some(f => f === '.env.example' || f === '.env.template');
|
package/dist/checks/scan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, relative } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { statSync, existsSync } from 'node:fs';
|
|
3
3
|
import { isTextFile as utilIsTextFile, collectDirFiles as utilCollectDirFiles } from '../util.js';
|
|
4
|
+
import { cachedRead } from '../file-cache.js';
|
|
4
5
|
const CRITICAL_PATTERNS = [
|
|
5
6
|
{
|
|
6
7
|
id: 'base64-url',
|
|
@@ -169,6 +170,9 @@ function scanContent(content, relPath) {
|
|
|
169
170
|
// Skip command-substitution checks in workflow files (shell commands are expected)
|
|
170
171
|
if (pattern.id === 'command-substitution' && isWorkflow)
|
|
171
172
|
continue;
|
|
173
|
+
// Skip permission-escalation in CI workflow files (sudo apt-get etc. is standard)
|
|
174
|
+
if (pattern.id === 'permission-escalation' && isWorkflow)
|
|
175
|
+
continue;
|
|
172
176
|
// Skip command-substitution in markdown code contexts
|
|
173
177
|
if (pattern.id === 'command-substitution' && relPath.endsWith('.md') && isInCodeContext(lines, i))
|
|
174
178
|
continue;
|
|
@@ -215,7 +219,7 @@ export function checkScan(cwd) {
|
|
|
215
219
|
if (!isTextFile(filePath))
|
|
216
220
|
continue;
|
|
217
221
|
try {
|
|
218
|
-
const content =
|
|
222
|
+
const content = cachedRead(filePath);
|
|
219
223
|
const relPath = relative(cwd, filePath);
|
|
220
224
|
filesScanned++;
|
|
221
225
|
findings.push(...scanContent(content, relPath));
|
package/dist/checks/secrets.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, readdirSync,
|
|
1
|
+
import { existsSync, readdirSync, statSync, createReadStream } from 'node:fs';
|
|
2
2
|
import { join, relative, extname, dirname } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { collectDirFiles } from '../util.js';
|
|
7
|
+
import { cachedRead } from '../file-cache.js';
|
|
7
8
|
// ── Shannon entropy ──────────────────────────────────────────────────────────
|
|
8
9
|
function calculateEntropy(str) {
|
|
9
10
|
if (str.length === 0)
|
|
@@ -176,7 +177,7 @@ function findEnvFiles(dir, maxDepth = 3, depth = 0) {
|
|
|
176
177
|
function scanEnvFile(filePath) {
|
|
177
178
|
const findings = [];
|
|
178
179
|
try {
|
|
179
|
-
const lines =
|
|
180
|
+
const lines = cachedRead(filePath).split('\n');
|
|
180
181
|
const gitTracked = isGitTracked(filePath);
|
|
181
182
|
for (const line of lines) {
|
|
182
183
|
if (line.trimStart().startsWith('#'))
|
package/dist/checks/tests.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { join, basename } from 'node:path';
|
|
3
2
|
import { walkFiles } from '../util.js';
|
|
3
|
+
import { cachedRead } from '../file-cache.js';
|
|
4
4
|
const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$/;
|
|
5
5
|
const TEST_DIR_RE = /(?:^|[/\\])(__tests__|tests?)[/\\]/;
|
|
6
6
|
function isTestFile(relPath) {
|
|
7
7
|
return TEST_FILE_RE.test(relPath) || TEST_DIR_RE.test(relPath);
|
|
8
8
|
}
|
|
9
|
+
/** Test utility/helper file patterns — these export helpers, not actual tests */
|
|
10
|
+
const TEST_UTILITY_NAMES = /(?:^|[/\\])(?:util(?:itie)?s?|helpers?|fixtures?|mocks?|setup|factor(?:y|ies)|themes?|test-(?:utils?|helpers?|setup|fixtures?|mocks?|themes?))\.[jt]sx?$/i;
|
|
11
|
+
function isTestUtilityFile(relPath, content) {
|
|
12
|
+
const hasTestCalls = /\b(?:test|it|describe|Deno\.test)\s*\(/.test(content);
|
|
13
|
+
// Check filename pattern — but only if no test runner calls present
|
|
14
|
+
const base = basename(relPath);
|
|
15
|
+
if (TEST_UTILITY_NAMES.test(base) && !hasTestCalls)
|
|
16
|
+
return true;
|
|
17
|
+
// If in a test dir, has exports, but no test runner calls — it's a utility
|
|
18
|
+
if (TEST_DIR_RE.test(relPath)) {
|
|
19
|
+
const hasExports = /\bexport\s+(function|const|let|var|class|default|{)/.test(content);
|
|
20
|
+
if (hasExports && !hasTestCalls)
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
9
25
|
// Pattern 1: Tautological assertions
|
|
10
26
|
function findTautological(lines, file) {
|
|
11
27
|
const issues = [];
|
|
@@ -119,6 +135,11 @@ function findZeroAssertionTests(content, file) {
|
|
|
119
135
|
// Check for assertion calls
|
|
120
136
|
const assertionRe = /(?:expect\s*\(|assert\.|\.should\.|toBe\s*\(|toEqual\s*\(|toMatch\s*\(|toThrow\s*\()/;
|
|
121
137
|
if (!assertionRe.test(body)) {
|
|
138
|
+
// If every non-empty, non-comment statement is a function call, it's delegating to a helper
|
|
139
|
+
const stmts = stripped.split(/;\s*|\n/).map(s => s.trim()).filter(s => s && !s.startsWith('//'));
|
|
140
|
+
const delegatingRe = /^(await\s+)?[a-zA-Z_$][a-zA-Z0-9_$.]*\s*\(/;
|
|
141
|
+
if (stmts.length > 0 && stmts.length <= 3 && stmts.every(s => delegatingRe.test(s)))
|
|
142
|
+
continue;
|
|
122
143
|
const line = content.substring(0, m.index).split('\n').length;
|
|
123
144
|
issues.push({
|
|
124
145
|
severity: 'warning',
|
|
@@ -205,7 +226,7 @@ export function checkTests(cwd, ignore) {
|
|
|
205
226
|
for (const rel of testFiles) {
|
|
206
227
|
let content;
|
|
207
228
|
try {
|
|
208
|
-
content =
|
|
229
|
+
content = cachedRead(join(cwd, rel));
|
|
209
230
|
}
|
|
210
231
|
catch {
|
|
211
232
|
continue;
|
|
@@ -213,6 +234,9 @@ export function checkTests(cwd, ignore) {
|
|
|
213
234
|
// Skip files with vet-ignore: tests directive
|
|
214
235
|
if (hasVetIgnore(content, 'tests'))
|
|
215
236
|
continue;
|
|
237
|
+
// Skip test utility/helper files — they export helpers, not tests
|
|
238
|
+
if (isTestUtilityFile(rel, content))
|
|
239
|
+
continue;
|
|
216
240
|
const lines = content.split('\n');
|
|
217
241
|
issues.push(...findTautological(lines, rel));
|
|
218
242
|
issues.push(...findEmptyBodies(content, rel));
|
package/dist/checks/verify.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, basename, extname } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
|
+
import { cachedRead } from '../file-cache.js';
|
|
4
5
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
6
|
function safeExec(cmd, cwd) {
|
|
6
7
|
try {
|
|
@@ -93,6 +94,31 @@ function isTestFile(filePath) {
|
|
|
93
94
|
return true;
|
|
94
95
|
return false;
|
|
95
96
|
}
|
|
97
|
+
/** Test utility/helper file patterns — these export helpers, not actual tests */
|
|
98
|
+
const TEST_UTILITY_NAMES = /(?:^|[/\\])(?:util(?:itie)?s?|helpers?|fixtures?|mocks?|setup|factor(?:y|ies)|themes?|test-(?:utils?|helpers?|setup|fixtures?|mocks?|themes?))\.[jt]sx?$/i;
|
|
99
|
+
const TEST_DIR_PATTERN = /(?:^|[/\\])(__tests__|tests?)[/\\]/;
|
|
100
|
+
function isTestUtilityFile(filePath, content) {
|
|
101
|
+
const hasTestCalls = /\b(?:test|it|describe|Deno\.test)\s*\(/.test(content);
|
|
102
|
+
const base = basename(filePath);
|
|
103
|
+
if (TEST_UTILITY_NAMES.test(base) && !hasTestCalls)
|
|
104
|
+
return true;
|
|
105
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
106
|
+
if (TEST_DIR_PATTERN.test(normalized) || normalized.startsWith('test/') || normalized.startsWith('tests/')) {
|
|
107
|
+
const hasExports = /\bexport\s+(function|const|let|var|class|default|{)/.test(content);
|
|
108
|
+
// Files with exports but no test calls are utilities
|
|
109
|
+
if (hasExports && !hasTestCalls)
|
|
110
|
+
return true;
|
|
111
|
+
// Files with no exports AND no test calls but with actual code (imports, function defs)
|
|
112
|
+
// are standalone scripts (debug, examples, repros) — not test files
|
|
113
|
+
if (!hasExports && !hasTestCalls) {
|
|
114
|
+
const hasImports = /\bimport\s/.test(content);
|
|
115
|
+
const hasFunctions = /\b(?:function|class|const\s+\w+\s*=\s*(?:async\s+)?(?:\(|[a-z]))/i.test(content);
|
|
116
|
+
if (hasImports || hasFunctions)
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
96
122
|
function hasAssertions(content) {
|
|
97
123
|
return /\b(assert|expect\s*\(|it\s*\(|test\s*\(|describe\s*\(|should\.|toBe\(|toEqual\(|assertEqual|assertStrictEqual)\b/i.test(content);
|
|
98
124
|
}
|
|
@@ -288,7 +314,7 @@ export function checkVerify(cwd, since) {
|
|
|
288
314
|
verified++;
|
|
289
315
|
continue;
|
|
290
316
|
}
|
|
291
|
-
content =
|
|
317
|
+
content = cachedRead(absPath);
|
|
292
318
|
}
|
|
293
319
|
catch {
|
|
294
320
|
continue;
|
|
@@ -321,6 +347,32 @@ export function checkVerify(cwd, since) {
|
|
|
321
347
|
verified++;
|
|
322
348
|
continue;
|
|
323
349
|
}
|
|
350
|
+
// Skip vendor, minified, dist, and build files
|
|
351
|
+
if (/(?:^|[/\\])vendor[/\\]/.test(relPath) || /\.min\.(js|css|mjs)$/.test(relPath) || /(?:^|[/\\])(?:dist|build)[/\\]/.test(relPath)) {
|
|
352
|
+
verified++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// Skip re-export barrel files (all non-empty lines are re-exports or wrappers)
|
|
356
|
+
if (lineCount > 0 && lineCount < 10) {
|
|
357
|
+
const nonEmptyLines = content.split('\n').filter((l) => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('/*') && !l.trim().startsWith('*'));
|
|
358
|
+
const reExportRe = /^\s*(?:export\s+\*\s+from\s|export\s*\{[^}]*\}\s*from\s|module\.exports\s*=\s*require\s*\()/;
|
|
359
|
+
if (nonEmptyLines.length > 0 && nonEmptyLines.every((l) => reExportRe.test(l))) {
|
|
360
|
+
verified++;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// Also skip import-and-reexport wrappers: files with only imports, exports, and simple identifiers/braces
|
|
364
|
+
const wrapperRe = /^\s*(?:import\s|export\s|[a-zA-Z_$][a-zA-Z0-9_$]*\s*,?\s*$|\}\s*;?\s*$|\};?\s*$)/;
|
|
365
|
+
if (nonEmptyLines.length > 0 && nonEmptyLines.some((l) => /^\s*import\s/.test(l)) && nonEmptyLines.some((l) => /^\s*export\s/.test(l)) && nonEmptyLines.every((l) => wrapperRe.test(l))) {
|
|
366
|
+
verified++;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Skip thin file check for entry point scripts in scripts/ directory
|
|
371
|
+
const normalizedForScripts = relPath.replace(/\\/g, '/');
|
|
372
|
+
if (/(?:^|[/\\])scripts[/\\]/.test(relPath) || normalizedForScripts.startsWith('scripts/') || normalizedForScripts.includes('/scripts/')) {
|
|
373
|
+
verified++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
324
376
|
if (lineCount < 10 && lineCount > 0) {
|
|
325
377
|
issues.push({
|
|
326
378
|
severity: 'warning',
|
|
@@ -345,8 +397,8 @@ export function checkVerify(cwd, since) {
|
|
|
345
397
|
failed++;
|
|
346
398
|
continue;
|
|
347
399
|
}
|
|
348
|
-
// 3. Test files must have actual assertions (but not config files)
|
|
349
|
-
if (isTestFile(relPath) && !isConfigFile(relPath)) {
|
|
400
|
+
// 3. Test files must have actual assertions (but not config files, not utility files)
|
|
401
|
+
if (isTestFile(relPath) && !isConfigFile(relPath) && !isTestUtilityFile(relPath, content)) {
|
|
350
402
|
if (!hasAssertions(content)) {
|
|
351
403
|
issues.push({
|
|
352
404
|
severity: 'error',
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { checkPermissions } from './checks/permissions.js';
|
|
|
22
22
|
import { checkCompact, runCompactCommand } from './checks/compact.js';
|
|
23
23
|
import { score } from './scorer.js';
|
|
24
24
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
25
|
+
import { clearCache } from './file-cache.js';
|
|
25
26
|
const args = process.argv.slice(2);
|
|
26
27
|
const flags = new Set(args.filter(a => a.startsWith('-') && !a.startsWith('--since')));
|
|
27
28
|
const flagMap = new Map();
|
|
@@ -79,7 +80,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
79
80
|
--watch re-run on file changes
|
|
80
81
|
--json JSON output
|
|
81
82
|
--pretty force pretty output (even in pipes)
|
|
82
|
-
--max-files N limit file scanning (default:
|
|
83
|
+
--max-files N limit file scanning (default: unlimited)
|
|
83
84
|
-h, --help show this help
|
|
84
85
|
-v, --version show version
|
|
85
86
|
`);
|
|
@@ -105,7 +106,7 @@ const isWatch = flags.has('--watch');
|
|
|
105
106
|
const isBadge = flags.has('--badge');
|
|
106
107
|
const isJSON = flags.has('--json') || (!process.stdout.isTTY && !flags.has('--pretty') && !isBadge);
|
|
107
108
|
const since = flagMap.get('since');
|
|
108
|
-
const maxFiles =
|
|
109
|
+
const maxFiles = flagMap.has('max-files') ? (parseInt(flagMap.get('max-files'), 10) || 0) : 0;
|
|
109
110
|
// Load config
|
|
110
111
|
let config = {};
|
|
111
112
|
const configContent = readFile(resolve(cwd, '.vetrc'));
|
|
@@ -232,53 +233,39 @@ async function runChecks() {
|
|
|
232
233
|
const GLOBAL_TIMEOUT = 120_000;
|
|
233
234
|
try {
|
|
234
235
|
// Check file count and warn if large
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
236
|
+
if (maxFiles > 0) {
|
|
237
|
+
const { walkFiles: wf } = await import('./util.js');
|
|
238
|
+
const allProjectFiles = wf(cwd, [], maxFiles);
|
|
239
|
+
if (allProjectFiles.length >= maxFiles) {
|
|
240
|
+
if (!isJSON)
|
|
241
|
+
console.log(` ${c.yellow}Large project (${allProjectFiles.length}+ files) — scanning first ${maxFiles} files. Use --max-files to increase.${c.reset}\n`);
|
|
242
|
+
}
|
|
240
243
|
}
|
|
241
|
-
// Run
|
|
242
|
-
|
|
243
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult] = await Promise.all([
|
|
244
|
+
// Run ALL independent checks in parallel
|
|
245
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult,] = await Promise.all([
|
|
244
246
|
withTimeout('scan', () => checkScan(cwd)),
|
|
245
247
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
246
248
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
247
249
|
withTimeout('models', () => checkModels(cwd, ignore)),
|
|
248
250
|
withTimeout('owasp', () => checkOwasp(cwd)),
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (Date.now() - globalStart > GLOBAL_TIMEOUT) {
|
|
252
|
-
if (!isJSON)
|
|
253
|
-
console.error(` ${c.yellow}⚠ global timeout (${GLOBAL_TIMEOUT / 1000}s) reached — returning partial results${c.reset}`);
|
|
254
|
-
return score(cwd, { security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult], integrity: [], debt: [], deps: [] });
|
|
255
|
-
}
|
|
256
|
-
// Integrity: diff, integrity checks
|
|
257
|
-
const diffResult = await withTimeout('diff', () => checkDiff(cwd, { since }));
|
|
258
|
-
const integrityResult = await withTimeout('integrity', () => checkIntegrity(cwd, ignore));
|
|
259
|
-
// Debt: ready, history, debt
|
|
260
|
-
const [readyResult, debtResult] = await Promise.all([
|
|
251
|
+
withTimeout('permissions', () => checkPermissions(cwd)),
|
|
252
|
+
withTimeout('integrity', () => checkIntegrity(cwd, ignore)),
|
|
261
253
|
withTimeout('ready', () => checkReady(cwd, ignore)),
|
|
262
254
|
withTimeout('debt', () => checkDebt(cwd, ignore)),
|
|
255
|
+
withTimeout('deps', () => checkDeps(cwd)),
|
|
256
|
+
withTimeout('receipt', () => checkReceipt(cwd)),
|
|
257
|
+
withTimeout('compact', () => checkCompact(cwd)),
|
|
258
|
+
withTimeout('memory', () => checkMemory(cwd)),
|
|
259
|
+
withTimeout('verify', () => checkVerify(cwd, since)),
|
|
260
|
+
withTimeout('tests', () => checkTests(cwd, ignore)),
|
|
263
261
|
]);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const depsResult = await withTimeout('deps', () => checkDeps(cwd));
|
|
272
|
-
// Receipt is informational — fold into integrity category but keep low weight
|
|
273
|
-
const receiptResult = await withTimeout('receipt', () => checkReceipt(cwd));
|
|
274
|
-
// Compact: compaction forensics
|
|
275
|
-
const compactResult = await withTimeout('compact', () => checkCompact(cwd));
|
|
276
|
-
// Memory: stale facts in agent memory files
|
|
277
|
-
const memoryResult = await withTimeout('memory', () => checkMemory(cwd));
|
|
278
|
-
// Verify: agent claim validation
|
|
279
|
-
const verifyResult = await withTimeout('verify', () => checkVerify(cwd, since));
|
|
280
|
-
// Tests: test theater detection
|
|
281
|
-
const testsResult = await withTimeout('tests', () => checkTests(cwd, ignore));
|
|
262
|
+
// Git-dependent checks (diff + history) — parallel with each other
|
|
263
|
+
const [diffResult, historyResult] = await Promise.all([
|
|
264
|
+
withTimeout('diff', () => checkDiff(cwd, { since })),
|
|
265
|
+
withTimeout('history', () => checkHistory(cwd)),
|
|
266
|
+
]);
|
|
267
|
+
// Clear file cache after all checks complete
|
|
268
|
+
clearCache();
|
|
282
269
|
return score(cwd, {
|
|
283
270
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult],
|
|
284
271
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
// Singleton file cache — read once, share across all checks
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
export function cachedRead(path) {
|
|
5
|
+
if (cache.has(path))
|
|
6
|
+
return cache.get(path);
|
|
7
|
+
const content = readFileSync(path, 'utf-8');
|
|
8
|
+
cache.set(path, content);
|
|
9
|
+
return content;
|
|
10
|
+
}
|
|
11
|
+
/** Cached readFile that returns null on error (matches util.readFile signature) */
|
|
12
|
+
export function cachedReadFile(path) {
|
|
13
|
+
try {
|
|
14
|
+
return cachedRead(path);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function clearCache() {
|
|
21
|
+
cache.clear();
|
|
22
|
+
}
|
package/dist/util.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
|
+
import { cachedReadFile } from './file-cache.js';
|
|
4
5
|
// ANSI colors — zero deps
|
|
5
6
|
export const c = {
|
|
6
7
|
reset: '\x1b[0m',
|
|
@@ -30,18 +31,13 @@ export function isGitRepo(cwd) {
|
|
|
30
31
|
return git('rev-parse --is-inside-work-tree', cwd) === 'true';
|
|
31
32
|
}
|
|
32
33
|
export function readFile(path) {
|
|
33
|
-
|
|
34
|
-
return readFileSync(path, 'utf-8');
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
34
|
+
return cachedReadFile(path);
|
|
39
35
|
}
|
|
40
36
|
/** Returns true if the path exists (file or directory). Convenience alias for existsSync. */
|
|
41
37
|
export function fileExists(path) {
|
|
42
38
|
return existsSync(path);
|
|
43
39
|
}
|
|
44
|
-
export function walkFiles(dir, ignore = [], maxFiles =
|
|
40
|
+
export function walkFiles(dir, ignore = [], maxFiles = 0) {
|
|
45
41
|
const results = [];
|
|
46
42
|
const defaultIgnore = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'vendor', '__pycache__', '.venv', 'venv'];
|
|
47
43
|
const allIgnore = [...defaultIgnore, ...ignore];
|