@safetnsr/vet 1.11.0 → 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']);
@@ -232,8 +233,12 @@ function findDuplicates(allFuncs) {
232
233
  if (isSpecPattern(group))
233
234
  continue;
234
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));
235
240
  issues.push({
236
- severity: 'warning',
241
+ severity: (allInTest || anyInExample) ? 'info' : 'warning',
237
242
  message: `near-duplicate functions: ${locations}`,
238
243
  file: group[0].file,
239
244
  line: group[0].line,
@@ -267,8 +272,12 @@ function findDuplicates(allFuncs) {
267
272
  if (reported.has(key))
268
273
  continue;
269
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);
270
279
  issues.push({
271
- severity: 'warning',
280
+ severity: (bothInTest || anyInExample) ? 'info' : 'warning',
272
281
  message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
273
282
  file: a.file,
274
283
  line: a.line,
@@ -293,6 +302,46 @@ function isLibrary(cwd) {
293
302
  return false;
294
303
  }
295
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
+ }
296
345
  function findOrphanedExports(cwd, files) {
297
346
  const issues = [];
298
347
  const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
@@ -358,15 +407,18 @@ function findOrphanedExports(cwd, files) {
358
407
  }
359
408
  }
360
409
  const lib = isLibrary(cwd);
410
+ const mono = isMonorepo(cwd);
361
411
  for (const exp of exports) {
362
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));
363
415
  issues.push({
364
- severity: lib ? 'info' : 'warning',
365
- 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)' : ''}`,
366
418
  file: exp.file,
367
419
  line: exp.line,
368
420
  fixable: true,
369
- 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',
370
422
  });
371
423
  }
372
424
  }
@@ -428,6 +480,10 @@ function findNamingDrift(allFuncs) {
428
480
  return issues;
429
481
  }
430
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
+ }
431
487
  export async function checkDebt(cwd, ignore) {
432
488
  const allFiles = walkFiles(cwd, ignore);
433
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>;
@@ -82,6 +82,9 @@ export function extractPackageName(specifier) {
82
82
  // Skip relative imports
83
83
  if (specifier.startsWith('.') || specifier.startsWith('/'))
84
84
  return null;
85
+ // Skip URL imports
86
+ if (specifier.startsWith('http://') || specifier.startsWith('https://'))
87
+ return null;
85
88
  // Skip node: builtins
86
89
  if (specifier.startsWith('node:'))
87
90
  return null;
@@ -241,7 +244,7 @@ export function detectProvidedDeps(cwd) {
241
244
  if (!pkgRaw)
242
245
  return provided;
243
246
  const pkg = JSON.parse(pkgRaw);
244
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
247
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies, ...pkg.peerDependencies };
245
248
  // Obsidian plugin
246
249
  const hasObsidian = 'obsidian' in (allDeps || {});
247
250
  const manifestPath = join(cwd, 'manifest.json');
@@ -281,19 +284,111 @@ function isProvidedPackage(pkg, provided) {
281
284
  return true;
282
285
  return false;
283
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
+ }
284
377
  // ── Main check ───────────────────────────────────────────────────────────────
285
378
  export async function checkDeps(cwd) {
286
379
  try {
287
380
  const issues = [];
288
381
  // Read package.json
289
382
  let declaredDeps = {};
383
+ let devDeps = {};
290
384
  let hasPkgJson = false;
291
385
  try {
292
386
  const pkgRaw = readFile(join(cwd, 'package.json'));
293
387
  if (pkgRaw) {
294
388
  const pkg = JSON.parse(pkgRaw);
295
389
  hasPkgJson = true;
296
- declaredDeps = { ...pkg.dependencies, ...pkg.devDependencies };
390
+ devDeps = pkg.devDependencies || {};
391
+ declaredDeps = { ...pkg.dependencies, ...devDeps, ...pkg.optionalDependencies, ...pkg.peerDependencies };
297
392
  }
298
393
  }
299
394
  catch { /* skip */ }
@@ -382,8 +477,12 @@ export async function checkDeps(cwd) {
382
477
  }
383
478
  // Dead deps: declared but never imported
384
479
  const declaredSet = new Set(declaredNames);
480
+ const devDepNames = new Set(Object.keys(devDeps));
385
481
  for (const pkg of declaredNames) {
386
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;
387
486
  // Check if it's a CLI tool / plugin / type package (common false positives)
388
487
  // Still flag it, but as info
389
488
  issues.push({
@@ -397,13 +496,17 @@ export async function checkDeps(cwd) {
397
496
  }
398
497
  // Detect workspace packages and host-provided deps
399
498
  const workspacePackages = detectWorkspacePackages(cwd);
499
+ const workspaceDeps = collectWorkspaceDeps(cwd);
400
500
  const providedDeps = detectProvidedDeps(cwd);
401
501
  // Phantom imports: imported but not declared
402
502
  for (const pkg of importedPackages) {
403
503
  if (!declaredSet.has(pkg)) {
404
- // Skip workspace packages
504
+ // Skip workspace packages (local packages in the monorepo)
405
505
  if (workspacePackages.has(pkg))
406
506
  continue;
507
+ // Skip deps declared in any workspace sub-package
508
+ if (workspaceDeps.has(pkg))
509
+ continue;
407
510
  // Skip host-provided deps
408
511
  if (isProvidedPackage(pkg, providedDeps))
409
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 = [];
@@ -202,6 +202,20 @@ export function checkMemory(cwd) {
202
202
  // Skip ../ references — they point to sibling repos and can't be validated locally
203
203
  if (p.startsWith('../'))
204
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
+ }
205
219
  const resolved = p.startsWith('/') ? p : resolve(cwd, p);
206
220
  if (!existsSync(resolved)) {
207
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',
@@ -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');
@@ -170,6 +170,9 @@ function scanContent(content, relPath) {
170
170
  // Skip command-substitution checks in workflow files (shell commands are expected)
171
171
  if (pattern.id === 'command-substitution' && isWorkflow)
172
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;
173
176
  // Skip command-substitution in markdown code contexts
174
177
  if (pattern.id === 'command-substitution' && relPath.endsWith('.md') && isInCodeContext(lines, i))
175
178
  continue;
@@ -1,4 +1,4 @@
1
- import { join } from 'node:path';
1
+ import { join, basename } from 'node:path';
2
2
  import { walkFiles } from '../util.js';
3
3
  import { cachedRead } from '../file-cache.js';
4
4
  const TEST_FILE_RE = /\.(test|spec)\.(ts|js|tsx|jsx)$/;
@@ -6,6 +6,22 @@ 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',
@@ -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));
@@ -94,6 +94,31 @@ function isTestFile(filePath) {
94
94
  return true;
95
95
  return false;
96
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
+ }
97
122
  function hasAssertions(content) {
98
123
  return /\b(assert|expect\s*\(|it\s*\(|test\s*\(|describe\s*\(|should\.|toBe\(|toEqual\(|assertEqual|assertStrictEqual)\b/i.test(content);
99
124
  }
@@ -322,6 +347,32 @@ export function checkVerify(cwd, since) {
322
347
  verified++;
323
348
  continue;
324
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
+ }
325
376
  if (lineCount < 10 && lineCount > 0) {
326
377
  issues.push({
327
378
  severity: 'warning',
@@ -346,8 +397,8 @@ export function checkVerify(cwd, since) {
346
397
  failed++;
347
398
  continue;
348
399
  }
349
- // 3. Test files must have actual assertions (but not config files)
350
- 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)) {
351
402
  if (!hasAssertions(content)) {
352
403
  issues.push({
353
404
  severity: 'error',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.11.0",
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": {