@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.
@@ -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 longer = a.length >= b.length ? a : b;
46
- const shorter = a.length >= b.length ? b : a;
47
- if (longer.length === 0)
119
+ const maxLen = Math.max(a.length, b.length);
120
+ if (maxLen === 0)
48
121
  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;
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 (capped to avoid O(n²) explosion on large repos)
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
- // Cap at 500 functions for similarity O(n²) with levenshtein is too expensive above that
184
- const singlesCapped = singles.length > 500 ? singles.slice(0, 500) : singles;
185
- for (let i = 0; i < singlesCapped.length; i++) {
186
- for (let j = i + 1; j < singlesCapped.length; j++) {
187
- const a = singles[i];
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
- // Skip very short normalized bodies
190
- if (a.normalized.length < 30 || b.normalized.length < 30)
191
- continue;
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 imports of each name
265
- const allContent = [];
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
- allContent.push(content);
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
- // Check if name appears in import statements across all files
275
- // import { name } from or import { x, name } from or import { name as y }
276
- const importPattern = new RegExp(`import\\s+[^;]*\\b${exp.name}\\b[^;]*from\\s+`, 'm');
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: lib ? 'info' : 'warning',
280
- message: `orphaned export: "${exp.name}" is exported but never imported${lib ? ' (library detected — exports may be consumed externally)' : ''}`,
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: lib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
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));
@@ -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>;
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
- import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
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
- declaredDeps = { ...pkg.dependencies, ...pkg.devDependencies };
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 = readFileSync(join(cwd, file), 'utf-8');
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;
@@ -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 silently swallowed
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: 'error',
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: 'error',
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: 'warning',
218
- message: 'catch block contains only comments — consider proper error handling',
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
- // Downgrade Next.js server components to info (framework handles errors)
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: isServerComp ? 'info' : 'warning',
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 -30 (only count warnings, not info-downgraded ones)
441
- const unhandledErrors = unhandledAsyncIssues.filter(i => i.severity === 'warning').length;
442
- score -= Math.min(30, unhandledErrors * 3);
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 = [];
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from 'node:path';
2
- import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
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(readFileSync(pkgPath, 'utf-8'));
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({
@@ -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 { readFileSync, existsSync } from 'node:fs';
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 = readFileSync(gitignorePath, 'utf-8');
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 { readFileSync, statSync, existsSync } from 'node:fs';
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 readFileSync(filePath, 'utf-8');
58
+ return cachedRead(filePath);
58
59
  }
59
60
  catch { /* intentional: resolver may fail on unreadable files */ }
60
61
  return null;
@@ -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 && content.split('\n').length > 500) {
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
- if (largeFileCount <= 3) {
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
- if (largeFileCount > 3) {
102
- issues.push({ severity: 'warning', message: `...and ${largeFileCount - 3} more large files`, fixable: false });
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');
@@ -1,6 +1,7 @@
1
1
  import { join, relative } from 'node:path';
2
- import { readFileSync, statSync, existsSync } from 'node:fs';
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 = readFileSync(filePath, 'utf-8');
222
+ const content = cachedRead(filePath);
219
223
  const relPath = relative(cwd, filePath);
220
224
  filesScanned++;
221
225
  findings.push(...scanContent(content, relPath));
@@ -1,9 +1,10 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, createReadStream } from 'node:fs';
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 = readFileSync(filePath, 'utf-8').split('\n');
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('#'))
@@ -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 = readFileSync(join(cwd, rel), 'utf-8');
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));
@@ -1,6 +1,7 @@
1
1
  import { join, basename, extname } from 'node:path';
2
- import { readFileSync, existsSync, statSync } from 'node:fs';
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 = readFileSync(absPath, 'utf-8');
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: 2000)
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 = parseInt(flagMap.get('max-files') || '2000', 10) || 2000;
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
- const { walkFiles: wf } = await import('./util.js');
236
- const allProjectFiles = wf(cwd, [], maxFiles);
237
- if (allProjectFiles.length >= maxFiles) {
238
- if (!isJSON)
239
- console.log(` ${c.yellow}Large project (${allProjectFiles.length}+ files) — scanning first ${maxFiles} files. Use --max-files to increase.${c.reset}\n`);
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 all checks, grouped into categories
242
- // Security: scan, secrets, config, models, owasp, permissions
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
- const permissionsResult = await withTimeout('permissions', () => checkPermissions(cwd));
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
- const historyResult = await withTimeout('history', () => checkHistory(cwd));
265
- if (Date.now() - globalStart > GLOBAL_TIMEOUT) {
266
- if (!isJSON)
267
- console.error(` ${c.yellow}⚠ global timeout (${GLOBAL_TIMEOUT / 1000}s) reached — returning partial results${c.reset}`);
268
- return score(cwd, { security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult], integrity: [diffResult, integrityResult], debt: [readyResult, historyResult, debtResult], deps: [] });
269
- }
270
- // Deps: deps
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,4 @@
1
+ export declare function cachedRead(path: string): string;
2
+ /** Cached readFile that returns null on error (matches util.readFile signature) */
3
+ export declare function cachedReadFile(path: string): string | null;
4
+ export declare function clearCache(): void;
@@ -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
- try {
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 = 2000) {
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];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.10.1",
3
+ "version": "1.11.1",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {