@safetnsr/vet 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  import type { CheckResult, CategoryResult, VetResult } from './types.js';
2
2
  export declare function toGrade(score: number): string;
3
+ /** Apply a floor of 20 to non-security checks that have no security-related errors */
4
+ export declare function applyScoreFloor(check: CheckResult): number;
3
5
  export declare function buildCategories(checkMap: {
4
6
  security: CheckResult[];
5
7
  integrity: CheckResult[];
@@ -21,11 +21,20 @@ const WEIGHTS = {
21
21
  debt: 0.25,
22
22
  deps: 0.15,
23
23
  };
24
+ // ── Scoring floor for non-security checks ────────────────────────────────────
25
+ const SECURITY_CHECKS = new Set(['scan', 'secrets', 'permissions', 'owasp']);
26
+ /** Apply a floor of 20 to non-security checks that have no security-related errors */
27
+ export function applyScoreFloor(check) {
28
+ if (SECURITY_CHECKS.has(check.name))
29
+ return check.score;
30
+ // Non-security check: minimum score is 20
31
+ return Math.max(20, check.score);
32
+ }
24
33
  // ── Average scores within a category ────────────────────────────────────────
25
34
  function averageScore(checks) {
26
35
  if (checks.length === 0)
27
36
  return 100;
28
- const total = checks.reduce((sum, c) => sum + c.score, 0);
37
+ const total = checks.reduce((sum, c) => sum + applyScoreFloor(c), 0);
29
38
  return Math.round(total / checks.length);
30
39
  }
31
40
  // ── Group checks into categories ─────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import { join, resolve, dirname, extname } from 'node:path';
1
+ import { join, resolve, dirname, extname, basename } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { walkFiles, readFile } from '../util.js';
4
4
  // ── Hallucinated imports ─────────────────────────────────────────────────────
@@ -277,6 +277,38 @@ function checkStubbedTests(cwd, files) {
277
277
  return issues;
278
278
  }
279
279
  // ── Unhandled async (removed error handling) ─────────────────────────────────
280
+ /** Files that ARE error boundaries — they handle errors by design */
281
+ function isErrorBoundaryFile(file) {
282
+ const normalized = file.replace(/\\/g, '/');
283
+ const base = basename(normalized);
284
+ // Next.js error boundaries
285
+ if (/^error\.[jt]sx?$/.test(base))
286
+ return true;
287
+ if (/^global-error\.[jt]sx?$/.test(base))
288
+ return true;
289
+ // Middleware files
290
+ if (/^middleware\.[jt]sx?$/.test(base))
291
+ return true;
292
+ // Error handler files
293
+ if (/error[-_]?handler/i.test(base))
294
+ return true;
295
+ if (/error[-_]?boundary/i.test(base))
296
+ return true;
297
+ return false;
298
+ }
299
+ /** Check if a file has a top-level error handler (global catch-all) */
300
+ function hasGlobalErrorHandling(content) {
301
+ // process.on('unhandledRejection'/'uncaughtException')
302
+ if (/process\.on\s*\(\s*['"](?:unhandledRejection|uncaughtException)['"]/i.test(content))
303
+ return true;
304
+ // Express/Koa-style error middleware: (err, req, res, next) or app.use with 4 params
305
+ if (/(?:app|router)\.use\s*\(\s*(?:async\s*)?\(\s*\w+\s*,\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)/.test(content))
306
+ return true;
307
+ // window.addEventListener('error'/'unhandledrejection')
308
+ if (/addEventListener\s*\(\s*['"](?:error|unhandledrejection)['"]/i.test(content))
309
+ return true;
310
+ return false;
311
+ }
280
312
  function checkUnhandledAsync(cwd, files) {
281
313
  const issues = [];
282
314
  const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
@@ -286,9 +318,15 @@ function checkUnhandledAsync(cwd, files) {
286
318
  // Skip test files — test runners handle errors at the framework level
287
319
  if (isTestFile(file))
288
320
  continue;
321
+ // Skip error boundary files — they ARE the error handlers
322
+ if (isErrorBoundaryFile(file))
323
+ continue;
289
324
  const content = readFile(join(cwd, file));
290
325
  if (!content)
291
326
  continue;
327
+ // Skip files with global error handling
328
+ if (hasGlobalErrorHandling(content))
329
+ continue;
292
330
  const lines = content.split('\n');
293
331
  let unhandledCount = 0;
294
332
  for (let i = 0; i < lines.length; i++) {
@@ -299,7 +337,7 @@ function checkUnhandledAsync(cwd, files) {
299
337
  if (!hasAwait)
300
338
  continue;
301
339
  // Check context window — look for try { in surrounding lines
302
- const contextStart = Math.max(0, i - 15);
340
+ const contextStart = Math.max(0, i - 20);
303
341
  const contextEnd = Math.min(lines.length - 1, i + 5);
304
342
  const contextLines = lines.slice(contextStart, contextEnd + 1);
305
343
  const contextText = contextLines.join('\n');
@@ -307,9 +345,11 @@ function checkUnhandledAsync(cwd, files) {
307
345
  const tryCount = (contextText.match(/\btry\s*\{/g) || []).length;
308
346
  const catchCount = (contextText.match(/\bcatch\s*(?:\([^)]*\))?\s*\{/g) || []).length;
309
347
  if (tryCount === 0 || catchCount === 0) {
310
- // Also check for .catch() chained
348
+ // Check for .catch() chained on this or next line
311
349
  const hasCatch = /\.catch\s*\(/.test(line) || (i + 1 < lines.length && /\.catch\s*\(/.test(lines[i + 1]));
312
- if (!hasCatch) {
350
+ // Check for .then(..., errorHandler) pattern
351
+ const hasThenError = /\.then\s*\([^,]+,\s*\w+/.test(line) || (i + 1 < lines.length && /\.then\s*\([^,]+,\s*\w+/.test(lines[i + 1]));
352
+ if (!hasCatch && !hasThenError) {
313
353
  unhandledCount++;
314
354
  if (unhandledCount <= 10) {
315
355
  issues.push({
@@ -1,5 +1,79 @@
1
- import { join } from 'node:path';
1
+ import { join, basename } from 'node:path';
2
+ import { readdirSync, existsSync } from 'node:fs';
2
3
  import { readFile, walkFiles } from '../util.js';
4
+ // ── AI framework detection ───────────────────────────────────────────────────
5
+ const AI_NAME_KEYWORDS = ['ai', 'llm', 'openai', 'anthropic', 'langchain', 'provider'];
6
+ const AI_PKG_KEYWORDS = new Set(['ai', 'llm', 'language-model', 'openai', 'anthropic']);
7
+ function isAiFramework(cwd) {
8
+ const aiDeps = ['openai', 'anthropic', 'langchain', 'transformers', 'torch', 'tensorflow', 'llama', 'huggingface'];
9
+ // Check package.json
10
+ const pkgRaw = readFile(join(cwd, 'package.json'));
11
+ if (pkgRaw) {
12
+ try {
13
+ const pkg = JSON.parse(pkgRaw);
14
+ const name = (pkg.name || '').toLowerCase();
15
+ if (AI_NAME_KEYWORDS.some(k => name.includes(k)))
16
+ return true;
17
+ if (Array.isArray(pkg.keywords) && pkg.keywords.some((k) => AI_PKG_KEYWORDS.has(k.toLowerCase())))
18
+ return true;
19
+ }
20
+ catch { /* skip */ }
21
+ }
22
+ // Check pyproject.toml / setup.py in root AND subdirectories
23
+ const pyprojectPaths = [join(cwd, 'pyproject.toml')];
24
+ try {
25
+ const entries = readdirSync(cwd);
26
+ for (const entry of entries) {
27
+ if (entry.startsWith('.') || entry === 'node_modules')
28
+ continue;
29
+ const subPyproject = join(cwd, entry, 'pyproject.toml');
30
+ if (existsSync(subPyproject))
31
+ pyprojectPaths.push(subPyproject);
32
+ }
33
+ }
34
+ catch { /* skip */ }
35
+ for (const pyprojectPath of pyprojectPaths) {
36
+ const pyproject = readFile(pyprojectPath);
37
+ if (pyproject && aiDeps.some(d => pyproject.includes(d)))
38
+ return true;
39
+ }
40
+ const setupPy = readFile(join(cwd, 'setup.py'));
41
+ if (setupPy && aiDeps.some(d => setupPy.includes(d)))
42
+ return true;
43
+ // Check directory name for AI keywords (use word-boundary-like matching with separators)
44
+ const dirName = basename(cwd).toLowerCase();
45
+ const dirParts = dirName.split(/[-_./\\]/);
46
+ const DIR_AI_KEYWORDS = ['ai', 'llm', 'openai', 'anthropic', 'langchain', 'pydantic-ai', 'autogen', 'crewai'];
47
+ if (DIR_AI_KEYWORDS.some(k => dirParts.includes(k) || dirName.includes(k)))
48
+ return true;
49
+ // Check CLAUDE.md or .claude/settings.json for AI/LLM terms
50
+ const claudeMd = readFile(join(cwd, 'CLAUDE.md'));
51
+ if (claudeMd) {
52
+ const aiTerms = /\b(llm|language model|ai agent|openai|anthropic|embedding|vector|rag|prompt|fine.?tun)/i;
53
+ if (aiTerms.test(claudeMd))
54
+ return true;
55
+ }
56
+ const claudeSettings = readFile(join(cwd, '.claude', 'settings.json'));
57
+ if (claudeSettings) {
58
+ const aiTerms = /\b(llm|language model|ai|openai|anthropic|model)/i;
59
+ if (aiTerms.test(claudeSettings))
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ // ── Test/example/docs path detection ─────────────────────────────────────────
65
+ const TEST_DOCS_PATTERNS = ['test/', 'tests/', '__tests__/', 'examples/', 'docs/'];
66
+ function isTestOrDocsFile(filePath) {
67
+ const normalized = filePath.replace(/\\/g, '/');
68
+ if (TEST_DOCS_PATTERNS.some(p => normalized.includes(p) || normalized.startsWith(p)))
69
+ return true;
70
+ const base = basename(filePath);
71
+ if (/\.(test|spec)\./i.test(base))
72
+ return true;
73
+ if (base.endsWith('.md'))
74
+ return true;
75
+ return false;
76
+ }
3
77
  // Try to use @safetnsr/model-graveyard if installed (248 models, alias matching, YAML registry)
4
78
  async function tryModelGraveyard(cwd) {
5
79
  try {
@@ -8,6 +82,7 @@ async function tryModelGraveyard(cwd) {
8
82
  return null;
9
83
  const report = await mod.scan(cwd);
10
84
  const issues = [];
85
+ const aiFramework = isAiFramework(cwd);
11
86
  // Files that define deprecated model registries should not be flagged
12
87
  const SELF_FILES = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset', 'fix/models'];
13
88
  for (const match of report.matches) {
@@ -17,8 +92,10 @@ async function tryModelGraveyard(cwd) {
17
92
  if (match.file && SELF_FILES.some(s => match.file.toLowerCase().includes(s)))
18
93
  continue;
19
94
  if (match.model.status === 'deprecated' || match.model.status === 'eol') {
95
+ const inTestDocs = match.file && isTestOrDocsFile(match.file);
96
+ const severity = (aiFramework || inTestDocs) ? 'info' : 'error';
20
97
  issues.push({
21
- severity: 'error',
98
+ severity,
22
99
  message: `${match.model.status} model "${match.raw}" in ${match.file}:${match.line}${match.model.successor ? ` — use "${match.model.successor}"` : ''}`,
23
100
  file: match.file,
24
101
  line: match.line,
@@ -27,15 +104,22 @@ async function tryModelGraveyard(cwd) {
27
104
  });
28
105
  }
29
106
  }
30
- const score = Math.max(0, 100 - issues.length * 20);
107
+ const errorCount = issues.filter(i => i.severity === 'error').length;
108
+ const score = aiFramework
109
+ ? Math.max(70, 100 - errorCount * 20)
110
+ : Math.max(0, 100 - errorCount * 20);
111
+ let summary = issues.length === 0
112
+ ? `${report.filesScanned} files scanned (via model-graveyard) — all current`
113
+ : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} (via model-graveyard)`;
114
+ if (aiFramework) {
115
+ summary += ' — AI framework detected — model references are expected';
116
+ }
31
117
  return {
32
118
  name: 'models',
33
119
  score: Math.min(100, score),
34
120
  maxScore: 100,
35
121
  issues,
36
- summary: issues.length === 0
37
- ? `${report.filesScanned} files scanned (via model-graveyard) — all current`
38
- : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} (via model-graveyard)`,
122
+ summary,
39
123
  };
40
124
  }
41
125
  catch {
@@ -87,6 +171,7 @@ function builtinModels(cwd, ignore) {
87
171
  const issues = [];
88
172
  const files = walkFiles(cwd, ignore);
89
173
  const found = new Map();
174
+ const aiFramework = isAiFramework(cwd);
90
175
  for (const f of files) {
91
176
  if (!SCAN_EXTS.some(ext => f.endsWith(ext)))
92
177
  continue;
@@ -105,24 +190,42 @@ function builtinModels(cwd, ignore) {
105
190
  found.set(model, existing);
106
191
  }
107
192
  }
108
- for (const [model, files] of found) {
193
+ for (const [model, modelFiles] of found) {
109
194
  const info = SUNSET_MODELS[model];
110
- const fileList = files.length <= 2 ? files.join(', ') : `${files[0]} +${files.length - 1} more`;
195
+ const fileList = modelFiles.length <= 2 ? modelFiles.join(', ') : `${modelFiles[0]} +${modelFiles.length - 1} more`;
196
+ // Determine severity: downgrade for AI frameworks or test/docs files
197
+ const allInTestDocs = modelFiles.every(f => isTestOrDocsFile(f));
198
+ const severity = (aiFramework || allInTestDocs) ? 'info' : 'error';
111
199
  issues.push({
112
- severity: 'error',
200
+ severity,
113
201
  message: `deprecated model "${model}" in ${fileList} — use "${info.replacement}"${info.sunset ? ` (sunset ${info.sunset})` : ''}`,
114
- file: files[0],
202
+ file: modelFiles[0],
115
203
  fixable: true,
116
204
  fixHint: `replace "${model}" with "${info.replacement}"`,
117
205
  });
118
206
  }
119
- const score = Math.max(0, 100 - issues.length * 20);
207
+ let score;
208
+ if (aiFramework) {
209
+ // AI framework: models exist for compatibility, score 70+ base
210
+ const errorCount = issues.filter(i => i.severity === 'error').length;
211
+ score = Math.max(70, 100 - errorCount * 20);
212
+ }
213
+ else {
214
+ const errorCount = issues.filter(i => i.severity === 'error').length;
215
+ score = Math.max(0, 100 - errorCount * 20);
216
+ }
217
+ let summary = issues.length === 0
218
+ ? 'all model references current'
219
+ : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} found`;
220
+ if (aiFramework) {
221
+ summary += ' — AI framework detected — model references are expected';
222
+ }
120
223
  return {
121
224
  name: 'models',
122
225
  score: Math.min(100, score),
123
226
  maxScore: 100,
124
227
  issues,
125
- summary: issues.length === 0 ? 'all model references current' : `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} found`,
228
+ summary,
126
229
  };
127
230
  }
128
231
  export async function checkModels(cwd, ignore) {
@@ -48,13 +48,40 @@ function builtinReady(cwd, ignore) {
48
48
  if (!hasReadme) {
49
49
  issues.push({ severity: 'error', message: 'no README — AI agents have no project context', fixable: true, fixHint: 'create a README.md' });
50
50
  }
51
+ // Detect Python project (root or subdirs)
52
+ const pythonMarkers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
53
+ const hasPythonRoot = pythonMarkers.some(m => files.includes(m));
54
+ const hasPythonSubdir = files.some(f => pythonMarkers.some(m => f.endsWith('/' + m) || f.endsWith('\\' + m)));
55
+ const isPython = hasPythonRoot || hasPythonSubdir;
56
+ // Detect monorepo (multiple manifests in subdirs)
57
+ const subPyprojects = files.filter(f => f !== 'pyproject.toml' && f.endsWith('pyproject.toml'));
58
+ const subPackageJsons = files.filter(f => f !== 'package.json' && f.endsWith('package.json'));
59
+ const isMonorepo = subPyprojects.length > 0 || subPackageJsons.length > 1;
51
60
  const manifests = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
52
61
  const hasManifest = manifests.some(m => files.includes(m));
53
- if (!hasManifest) {
62
+ // For Python projects, any pyproject.toml in subdirs counts as a manifest
63
+ const hasManifestAnywhere = hasManifest || isPython;
64
+ if (!hasManifestAnywhere) {
54
65
  issues.push({ severity: 'error', message: 'no package manifest — agents can\'t resolve dependencies', fixable: false });
55
66
  }
56
67
  const codeExts = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php', '.cs', '.swift', '.kt'];
57
- const testFiles = files.filter(f => /\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f) || f.includes('__tests__/') || f.startsWith('tests/') || f.startsWith('test/'));
68
+ // Broader test detection: includes Python test patterns and nested test directories
69
+ const testFiles = files.filter(f => {
70
+ if (/\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f))
71
+ return true;
72
+ if (f.includes('__tests__/'))
73
+ return true;
74
+ if (f.startsWith('tests/') || f.startsWith('test/'))
75
+ return true;
76
+ if (f.includes('/tests/') || f.includes('/test/'))
77
+ return true;
78
+ // Python test file patterns: test_*.py, *_test.py
79
+ if (/(?:^|[/\\])test_[^/\\]+\.py$/.test(f))
80
+ return true;
81
+ if (/(?:^|[/\\])[^/\\]+_test\.py$/.test(f))
82
+ return true;
83
+ return false;
84
+ });
58
85
  const codeFiles = files.filter(f => codeExts.some(ext => f.endsWith(ext)));
59
86
  if (codeFiles.length > 5 && testFiles.length === 0) {
60
87
  issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
@@ -88,12 +115,15 @@ function builtinReady(cwd, ignore) {
88
115
  const warnings = issues.filter(i => i.severity === 'warning').length;
89
116
  const infos = issues.filter(i => i.severity === 'info').length;
90
117
  const score = Math.max(0, Math.min(100, 100 - errors * 30 - warnings * 15 - infos * 3));
118
+ let summary = issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`;
119
+ if (isMonorepo)
120
+ summary += ' (monorepo detected)';
91
121
  return {
92
122
  name: 'ready',
93
123
  score: Math.round(score),
94
124
  maxScore: 100,
95
125
  issues,
96
- summary: issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`,
126
+ summary,
97
127
  };
98
128
  }
99
129
  export async function checkReady(cwd, ignore) {
@@ -9,16 +9,34 @@ const CRITICAL_PATTERNS = [
9
9
  regex: /(?:aHR0c|data:text\/html;base64)/i,
10
10
  },
11
11
  {
12
- id: 'curl-wget',
12
+ id: 'curl-pipe-shell',
13
13
  severity: 'critical',
14
- description: 'Network download command in agent config potential remote payload fetch',
15
- regex: /(?:curl|wget|fetch)\s+(?:https?:\/\/|[-])/i,
14
+ description: 'Download-and-execute pattern — remote code execution',
15
+ regex: /(?:curl|wget)\s+[^\n|]*\|\s*(?:ba)?sh\b/i,
16
16
  },
17
17
  {
18
- id: 'shell-injection',
18
+ id: 'pipe-to-shell',
19
19
  severity: 'critical',
20
- description: 'Shell injection patterncommand substitution or eval/exec call',
21
- regex: /\$\(|`[^`]+`|\beval\b|\bexec\b/,
20
+ description: 'Pipe to shell interpreter potential code execution',
21
+ regex: /\|\s*(?:ba)?sh\b|\|\s*\beval\b/,
22
+ },
23
+ {
24
+ id: 'command-substitution',
25
+ severity: 'critical',
26
+ description: 'Command substitution in config — potential injection vector',
27
+ regex: /\$\([^)]+\)/,
28
+ },
29
+ {
30
+ id: 'data-exfiltration',
31
+ severity: 'critical',
32
+ description: 'Data exfiltration pattern — sending local data to external URL',
33
+ regex: /curl\s+[^\n]*(?:-[dFT]\s*@|--data-binary\s*@|--upload-file)|curl\s+[^\n]*\$(?:API_KEY|SECRET|TOKEN|PASSWORD)|nc\s+-[^\n]*\d+\.\d+/i,
34
+ },
35
+ {
36
+ id: 'base64-exec',
37
+ severity: 'critical',
38
+ description: 'Base64-decoded payload piped to execution',
39
+ regex: /base64\s+(?:-d|--decode)[^\n]*\|\s*(?:ba)?sh\b/i,
22
40
  },
23
41
  {
24
42
  id: 'powershell-download',
@@ -85,9 +103,10 @@ const CONFIG_TARGETS = [
85
103
  '.claude', 'CLAUDE.md', 'AGENTS.md',
86
104
  '.cursorrules', '.cursor',
87
105
  '.github',
88
- '.aider.conf.yml',
106
+ 'copilot-instructions.md',
107
+ '.aider.conf.yml', '.aider',
89
108
  '.continue',
90
- '.mcp',
109
+ '.mcp', 'mcp.json', '.mcp.json',
91
110
  '.roomodes', '.roo',
92
111
  ];
93
112
  // ── File helpers (delegated to util.ts) ──────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import { join, basename } from 'node:path';
1
+ import { join, basename, extname } from 'node:path';
2
2
  import { readFileSync, existsSync, statSync } from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
4
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -10,9 +10,66 @@ function safeExec(cmd, cwd) {
10
10
  return '';
11
11
  }
12
12
  }
13
+ // ── Config/meta file exclusion for thin-file checks ──────────────────────────
14
+ const CONFIG_DOTFILES = new Set([
15
+ '.gitignore', '.gitattributes', '.nvmrc', '.node-version', '.editorconfig',
16
+ '.prettierrc', '.eslintignore', '.npmrc', '.npmignore',
17
+ ]);
18
+ const CONFIG_EXTENSIONS = new Set([
19
+ '.yml', '.yaml', '.json', '.toml', '.cfg', '.ini', '.lock', '.svg', '.xml',
20
+ ]);
21
+ const CONFIG_DIRS = ['.github/', '.husky/', '.vscode/', '.idea/'];
22
+ const META_FILES = new Set([
23
+ 'FUNDING.yaml', 'CODEOWNERS', 'LICENSE',
24
+ 'py.typed', 'MANIFEST.in', 'CITATION.cff',
25
+ ]);
26
+ const META_EXTENSIONS = new Set([
27
+ '.cff', '.mdc', '.txt', '.html', '.md', '.rst', '.csv',
28
+ '.css', '.scss', '.less', '.map', '.wasm',
29
+ '.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1',
30
+ '.sql', '.graphql', '.gql', '.proto',
31
+ ]);
32
+ /** Source code extensions that should be checked for thin files */
33
+ const SOURCE_CODE_EXTS = new Set([
34
+ '.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs',
35
+ '.py', '.go', '.rs', '.java', '.rb', '.php', '.cs', '.swift', '.kt',
36
+ ]);
37
+ function isConfigOrMetaFile(filePath) {
38
+ const base = basename(filePath);
39
+ const ext = extname(filePath).toLowerCase();
40
+ const normalized = filePath.replace(/\\/g, '/');
41
+ // Dotfiles
42
+ if (CONFIG_DOTFILES.has(base))
43
+ return true;
44
+ // Config extensions
45
+ if (CONFIG_EXTENSIONS.has(ext))
46
+ return true;
47
+ // Meta/non-source extensions
48
+ if (META_EXTENSIONS.has(ext))
49
+ return true;
50
+ // Config directories
51
+ if (CONFIG_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d)))
52
+ return true;
53
+ // Meta files
54
+ if (META_FILES.has(base))
55
+ return true;
56
+ if (base.startsWith('CHANGELOG'))
57
+ return true;
58
+ // Any file that is NOT source code should not be flagged for thin content
59
+ if (!SOURCE_CODE_EXTS.has(ext))
60
+ return true;
61
+ return false;
62
+ }
63
+ // ── Code file extensions for test detection ──────────────────────────────────
64
+ const CODE_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs']);
65
+ const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|js|tsx|jsx)$/i;
13
66
  function isTestFile(filePath) {
14
67
  const base = basename(filePath);
15
- if (/\.(test|spec)\.[a-z]+$/i.test(base))
68
+ const ext = extname(filePath).toLowerCase();
69
+ // Only code files can be test files
70
+ if (!CODE_EXTENSIONS.has(ext))
71
+ return false;
72
+ if (TEST_FILE_PATTERN.test(base))
16
73
  return true;
17
74
  const normalized = filePath.replace(/\\/g, '/');
18
75
  // Match __tests__/ anywhere in path (including at root)
@@ -92,6 +149,8 @@ function isPythonBoilerplate(filePath) {
92
149
  const base = basename(filePath);
93
150
  if (base === '__init__.py')
94
151
  return true;
152
+ if (base === 'py.typed')
153
+ return true;
95
154
  if (filePath.endsWith('.pyi'))
96
155
  return true;
97
156
  if (filePath.replace(/\\/g, '/').includes('__pycache__/'))
@@ -181,8 +240,13 @@ export function checkVerify(cwd, since) {
181
240
  }
182
241
  const lineCount = countLines(content);
183
242
  // 2. File must have meaningful content (>10 non-empty lines)
184
- // Skip thin file check for Python boilerplate files
185
- if (python && isPythonBoilerplate(relPath)) {
243
+ // Skip thin file check for Python boilerplate files (always, regardless of project type)
244
+ if (isPythonBoilerplate(relPath)) {
245
+ verified++;
246
+ continue;
247
+ }
248
+ // Skip thin file check for config/meta files (they're supposed to be small)
249
+ if (isConfigOrMetaFile(relPath)) {
186
250
  verified++;
187
251
  continue;
188
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {