@safetnsr/vet 1.7.0 → 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.
- package/dist/checks/integrity.js +44 -4
- package/dist/checks/models.js +38 -9
- package/dist/checks/ready.js +33 -3
- package/dist/checks/scan.js +24 -6
- package/dist/checks/verify.js +22 -2
- package/package.json +1 -1
package/dist/checks/integrity.js
CHANGED
|
@@ -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 -
|
|
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
|
-
//
|
|
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
|
-
|
|
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({
|
package/dist/checks/models.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { join, basename } from 'node:path';
|
|
2
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
2
3
|
import { readFile, walkFiles } from '../util.js';
|
|
3
4
|
// ── AI framework detection ───────────────────────────────────────────────────
|
|
4
|
-
const AI_NAME_KEYWORDS = ['ai', 'llm', '
|
|
5
|
+
const AI_NAME_KEYWORDS = ['ai', 'llm', 'openai', 'anthropic', 'langchain', 'provider'];
|
|
5
6
|
const AI_PKG_KEYWORDS = new Set(['ai', 'llm', 'language-model', 'openai', 'anthropic']);
|
|
6
7
|
function isAiFramework(cwd) {
|
|
8
|
+
const aiDeps = ['openai', 'anthropic', 'langchain', 'transformers', 'torch', 'tensorflow', 'llama', 'huggingface'];
|
|
7
9
|
// Check package.json
|
|
8
10
|
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
9
11
|
if (pkgRaw) {
|
|
@@ -17,17 +19,44 @@ function isAiFramework(cwd) {
|
|
|
17
19
|
}
|
|
18
20
|
catch { /* skip */ }
|
|
19
21
|
}
|
|
20
|
-
// Check pyproject.toml / setup.py
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
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)))
|
|
25
38
|
return true;
|
|
26
39
|
}
|
|
27
40
|
const setupPy = readFile(join(cwd, 'setup.py'));
|
|
28
|
-
if (setupPy)
|
|
29
|
-
|
|
30
|
-
|
|
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))
|
|
31
60
|
return true;
|
|
32
61
|
}
|
|
33
62
|
return false;
|
package/dist/checks/ready.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
126
|
+
summary,
|
|
97
127
|
};
|
|
98
128
|
}
|
|
99
129
|
export async function checkReady(cwd, ignore) {
|
package/dist/checks/scan.js
CHANGED
|
@@ -9,16 +9,34 @@ const CRITICAL_PATTERNS = [
|
|
|
9
9
|
regex: /(?:aHR0c|data:text\/html;base64)/i,
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
id: 'curl-
|
|
12
|
+
id: 'curl-pipe-shell',
|
|
13
13
|
severity: 'critical',
|
|
14
|
-
description: '
|
|
15
|
-
regex: /(?:curl|wget
|
|
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
|
|
18
|
+
id: 'pipe-to-shell',
|
|
19
19
|
severity: 'critical',
|
|
20
|
-
description: '
|
|
21
|
-
regex:
|
|
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',
|
package/dist/checks/verify.js
CHANGED
|
@@ -21,6 +21,18 @@ const CONFIG_EXTENSIONS = new Set([
|
|
|
21
21
|
const CONFIG_DIRS = ['.github/', '.husky/', '.vscode/', '.idea/'];
|
|
22
22
|
const META_FILES = new Set([
|
|
23
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',
|
|
24
36
|
]);
|
|
25
37
|
function isConfigOrMetaFile(filePath) {
|
|
26
38
|
const base = basename(filePath);
|
|
@@ -32,6 +44,9 @@ function isConfigOrMetaFile(filePath) {
|
|
|
32
44
|
// Config extensions
|
|
33
45
|
if (CONFIG_EXTENSIONS.has(ext))
|
|
34
46
|
return true;
|
|
47
|
+
// Meta/non-source extensions
|
|
48
|
+
if (META_EXTENSIONS.has(ext))
|
|
49
|
+
return true;
|
|
35
50
|
// Config directories
|
|
36
51
|
if (CONFIG_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d)))
|
|
37
52
|
return true;
|
|
@@ -40,6 +55,9 @@ function isConfigOrMetaFile(filePath) {
|
|
|
40
55
|
return true;
|
|
41
56
|
if (base.startsWith('CHANGELOG'))
|
|
42
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;
|
|
43
61
|
return false;
|
|
44
62
|
}
|
|
45
63
|
// ── Code file extensions for test detection ──────────────────────────────────
|
|
@@ -131,6 +149,8 @@ function isPythonBoilerplate(filePath) {
|
|
|
131
149
|
const base = basename(filePath);
|
|
132
150
|
if (base === '__init__.py')
|
|
133
151
|
return true;
|
|
152
|
+
if (base === 'py.typed')
|
|
153
|
+
return true;
|
|
134
154
|
if (filePath.endsWith('.pyi'))
|
|
135
155
|
return true;
|
|
136
156
|
if (filePath.replace(/\\/g, '/').includes('__pycache__/'))
|
|
@@ -220,8 +240,8 @@ export function checkVerify(cwd, since) {
|
|
|
220
240
|
}
|
|
221
241
|
const lineCount = countLines(content);
|
|
222
242
|
// 2. File must have meaningful content (>10 non-empty lines)
|
|
223
|
-
// Skip thin file check for Python boilerplate files
|
|
224
|
-
if (
|
|
243
|
+
// Skip thin file check for Python boilerplate files (always, regardless of project type)
|
|
244
|
+
if (isPythonBoilerplate(relPath)) {
|
|
225
245
|
verified++;
|
|
226
246
|
continue;
|
|
227
247
|
}
|