@safetnsr/vet 1.15.1 → 1.17.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/debt.js +84 -14
- package/dist/checks/deps.js +17 -0
- package/dist/checks/guard.d.ts +3 -0
- package/dist/checks/guard.js +179 -0
- package/dist/cli.js +17 -3
- package/package.json +1 -1
package/dist/checks/debt.js
CHANGED
|
@@ -8,12 +8,45 @@ function isSourceFile(f) {
|
|
|
8
8
|
return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
|
|
9
9
|
}
|
|
10
10
|
function isTestFile(f) {
|
|
11
|
-
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') ||
|
|
11
|
+
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /(?:^|[/\\])tests?[/\\]/.test(f);
|
|
12
12
|
}
|
|
13
13
|
function isEntryFile(f) {
|
|
14
14
|
const b = basename(f);
|
|
15
15
|
return /^(cli|main|index)\.[jt]sx?$/.test(b);
|
|
16
16
|
}
|
|
17
|
+
// Next.js / Remix / SvelteKit / Nuxt convention exports consumed by the framework, not via imports
|
|
18
|
+
const FRAMEWORK_CONVENTION_EXPORTS = new Set([
|
|
19
|
+
// Next.js App Router
|
|
20
|
+
'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS',
|
|
21
|
+
'metadata', 'generateMetadata', 'generateStaticParams', 'generateViewport',
|
|
22
|
+
'viewport', 'runtime', 'revalidate', 'dynamic', 'dynamicParams',
|
|
23
|
+
'fetchCache', 'preferredRegion', 'maxDuration',
|
|
24
|
+
'default', // default export in page/layout/route files
|
|
25
|
+
// Next.js Pages Router
|
|
26
|
+
'getServerSideProps', 'getStaticProps', 'getStaticPaths',
|
|
27
|
+
// Remix
|
|
28
|
+
'loader', 'action', 'meta', 'links', 'headers', 'handle',
|
|
29
|
+
'shouldRevalidate', 'ErrorBoundary', 'HydrateFallback',
|
|
30
|
+
// SvelteKit
|
|
31
|
+
'load', 'prerender', 'ssr', 'csr', 'trailingSlash',
|
|
32
|
+
// Nuxt
|
|
33
|
+
'definePageMeta', 'useHead',
|
|
34
|
+
]);
|
|
35
|
+
function isFrameworkConventionFile(file) {
|
|
36
|
+
// Next.js app router: app/**/page.tsx, layout.tsx, route.tsx, loading.tsx, error.tsx, etc.
|
|
37
|
+
if (/\/(app|pages)\//.test(file) && /\/(page|layout|route|loading|error|not-found|template|default|middleware)\.[jt]sx?$/.test(file))
|
|
38
|
+
return true;
|
|
39
|
+
// Next.js API routes
|
|
40
|
+
if (/\/api\//.test(file) && /\/route\.[jt]sx?$/.test(file))
|
|
41
|
+
return true;
|
|
42
|
+
// Remix routes
|
|
43
|
+
if (/\/routes\//.test(file) && /\.[jt]sx?$/.test(file))
|
|
44
|
+
return true;
|
|
45
|
+
// SvelteKit
|
|
46
|
+
if (/\+(page|layout|server|error)\.[jt]s/.test(file))
|
|
47
|
+
return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
17
50
|
function isBarrelFile(f) {
|
|
18
51
|
const b = basename(f);
|
|
19
52
|
return /^index\.[jt]sx?$/.test(b);
|
|
@@ -236,14 +269,14 @@ function findDuplicates(allFuncs) {
|
|
|
236
269
|
// Downgrade to info if all functions in the group are in test directories
|
|
237
270
|
// or if any function is in an examples/demo directory
|
|
238
271
|
const allInTest = group.every(f => isInTestDir(f.file));
|
|
239
|
-
const anyInExample = group.some(f => /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(f.file));
|
|
272
|
+
const anyInExample = group.some(f => /(?:^|[/\\])(?:examples?|demos?|templates?|fixtures?)[/\\]/.test(f.file));
|
|
240
273
|
issues.push({
|
|
241
274
|
severity: (allInTest || anyInExample) ? 'info' : 'warning',
|
|
242
275
|
message: `near-duplicate functions: ${locations}`,
|
|
243
276
|
file: group[0].file,
|
|
244
277
|
line: group[0].line,
|
|
245
|
-
fixable:
|
|
246
|
-
fixHint: 'extract shared logic into a single function',
|
|
278
|
+
fixable: !(allInTest || anyInExample),
|
|
279
|
+
fixHint: (allInTest || anyInExample) ? 'duplication in examples/tests is often intentional' : 'extract shared logic into a single function',
|
|
247
280
|
});
|
|
248
281
|
}
|
|
249
282
|
// Similarity check for non-exact matches — length-bucketed to avoid O(n²) explosion
|
|
@@ -275,14 +308,14 @@ function findDuplicates(allFuncs) {
|
|
|
275
308
|
// Downgrade to info if both functions are in test directories
|
|
276
309
|
// or if either is in an examples/demo directory
|
|
277
310
|
const bothInTest = isInTestDir(a.file) && isInTestDir(b.file);
|
|
278
|
-
const anyInExample = /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(a.file) || /(?:^|[/\\])(?:examples?|demos?)[/\\]/.test(b.file);
|
|
311
|
+
const anyInExample = /(?:^|[/\\])(?:examples?|demos?|templates?|fixtures?)[/\\]/.test(a.file) || /(?:^|[/\\])(?:examples?|demos?|templates?|fixtures?)[/\\]/.test(b.file);
|
|
279
312
|
issues.push({
|
|
280
313
|
severity: (bothInTest || anyInExample) ? 'info' : 'warning',
|
|
281
314
|
message: `similar functions (${Math.round(sim * 100)}%): ${a.name} (${a.file}:${a.line}) and ${b.name} (${b.file}:${b.line})`,
|
|
282
315
|
file: a.file,
|
|
283
316
|
line: a.line,
|
|
284
|
-
fixable:
|
|
285
|
-
fixHint: 'consider merging or extracting shared logic',
|
|
317
|
+
fixable: !(bothInTest || anyInExample),
|
|
318
|
+
fixHint: (bothInTest || anyInExample) ? 'duplication in examples/tests is often intentional' : 'consider merging or extracting shared logic',
|
|
286
319
|
});
|
|
287
320
|
}
|
|
288
321
|
}
|
|
@@ -373,19 +406,27 @@ function findOrphanedExports(cwd, files) {
|
|
|
373
406
|
continue;
|
|
374
407
|
const braceMatch = line.match(/^export\s*\{([^}]+)\}/);
|
|
375
408
|
if (braceMatch) {
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
if (
|
|
409
|
+
for (const part of braceMatch[1].split(',')) {
|
|
410
|
+
const trimmed = part.trim();
|
|
411
|
+
if (!trimmed)
|
|
412
|
+
continue;
|
|
413
|
+
// export { x as y } — use the alias (y) as the exported name, since that's what consumers see
|
|
414
|
+
const asParts = trimmed.split(/\s+as\s+/);
|
|
415
|
+
const exportedName = (asParts.length > 1 ? asParts[1] : asParts[0]).trim();
|
|
416
|
+
if (exportedName === 'default' || exportedName === 'type')
|
|
379
417
|
continue;
|
|
380
|
-
exports.push({ name, file, line: i + 1 });
|
|
418
|
+
exports.push({ name: exportedName, file, line: i + 1 });
|
|
381
419
|
}
|
|
382
420
|
}
|
|
383
421
|
}
|
|
384
422
|
}
|
|
385
|
-
// Scan
|
|
423
|
+
// Scan ALL files (including tests) for import names — an export consumed by a test is not orphaned
|
|
386
424
|
const importedNames = new Set();
|
|
387
425
|
const importRe = /import\s+(?:type\s+)?(?:\{([^}]+)\}|([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*,\s*\{([^}]+)\})?)\s+from\s+/g;
|
|
388
|
-
for
|
|
426
|
+
// Also scan for dynamic imports: require('x'), import('x') — to catch non-static usage
|
|
427
|
+
const dynamicImportRe = /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
428
|
+
const allSourceFiles = files.filter(f => isSourceFile(f));
|
|
429
|
+
for (const file of allSourceFiles) {
|
|
389
430
|
const content = readFile(join(cwd, file));
|
|
390
431
|
if (!content)
|
|
391
432
|
continue;
|
|
@@ -406,10 +447,39 @@ function findOrphanedExports(cwd, files) {
|
|
|
406
447
|
importedNames.add(match[2]);
|
|
407
448
|
}
|
|
408
449
|
}
|
|
450
|
+
// Build a cross-reference map: for each exported name, check if it appears in other files
|
|
451
|
+
// This catches hook returns ({ Component } = useHook()), dynamic usage, re-exports, JSX, etc.
|
|
452
|
+
// Only build refs for names we actually export (not all identifiers — too expensive)
|
|
453
|
+
const exportNames = new Set(exports.map(e => e.name));
|
|
454
|
+
const nameToFiles = new Map();
|
|
455
|
+
for (const name of exportNames)
|
|
456
|
+
nameToFiles.set(name, new Set());
|
|
457
|
+
for (const file of allSourceFiles) {
|
|
458
|
+
const content = readFile(join(cwd, file));
|
|
459
|
+
if (!content)
|
|
460
|
+
continue;
|
|
461
|
+
for (const name of exportNames) {
|
|
462
|
+
if (content.includes(name)) {
|
|
463
|
+
nameToFiles.get(name).add(file);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
409
467
|
const lib = isLibrary(cwd);
|
|
410
468
|
const mono = isMonorepo(cwd);
|
|
411
469
|
for (const exp of exports) {
|
|
412
470
|
if (!importedNames.has(exp.name)) {
|
|
471
|
+
// Cross-reference check: if the export name appears in a different file, it's likely used
|
|
472
|
+
// (catches hook returns, JSX usage, dynamic imports, re-exports)
|
|
473
|
+
const refs = nameToFiles.get(exp.name);
|
|
474
|
+
if (refs) {
|
|
475
|
+
const otherFiles = new Set(refs);
|
|
476
|
+
otherFiles.delete(exp.file);
|
|
477
|
+
if (otherFiles.size > 0)
|
|
478
|
+
continue; // referenced in another file → not orphaned
|
|
479
|
+
}
|
|
480
|
+
// Skip framework convention exports (Next.js, Remix, SvelteKit, Nuxt)
|
|
481
|
+
if (FRAMEWORK_CONVENTION_EXPORTS.has(exp.name) && isFrameworkConventionFile(exp.file))
|
|
482
|
+
continue;
|
|
413
483
|
// In monorepos, check if the export's file is inside a workspace package that is a library
|
|
414
484
|
const isLib = lib || (mono && isFileInLibraryPackage(cwd, exp.file));
|
|
415
485
|
issues.push({
|
|
@@ -417,7 +487,7 @@ function findOrphanedExports(cwd, files) {
|
|
|
417
487
|
message: `orphaned export: "${exp.name}" is exported but never imported${isLib ? ' (library detected — exports may be consumed externally)' : ''}`,
|
|
418
488
|
file: exp.file,
|
|
419
489
|
line: exp.line,
|
|
420
|
-
fixable:
|
|
490
|
+
fixable: !isLib,
|
|
421
491
|
fixHint: isLib ? 'may be public API — verify if still needed' : 'remove the export keyword or delete the function',
|
|
422
492
|
});
|
|
423
493
|
}
|
package/dist/checks/deps.js
CHANGED
|
@@ -290,10 +290,27 @@ const TOOLING_PACKAGES = new Set([
|
|
|
290
290
|
'husky', 'lint-staged', 'tsx', 'ts-node', 'concurrently', 'npm-run-all',
|
|
291
291
|
'shx', 'rimraf', 'cross-env', 'nodemon', 'jest', 'vitest', 'mocha',
|
|
292
292
|
'c8', 'nyc', 'turbo', 'lerna', 'changesets', '@changesets/cli',
|
|
293
|
+
'@changesets/changelog-github', '@changesets/changelog-git',
|
|
293
294
|
'webpack', 'webpack-cli', 'vite', 'rollup', 'esbuild', 'swc',
|
|
294
295
|
'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less',
|
|
295
296
|
'commitizen', 'cz-conventional-changelog', 'semantic-release',
|
|
296
297
|
'@typescript/native-preview',
|
|
298
|
+
// Linting configs (used via eslint extends, not imported)
|
|
299
|
+
'eslint-config-next', 'eslint-config-prettier', 'eslint-config-turbo',
|
|
300
|
+
'@next/eslint-plugin-next', 'eslint-plugin-react', 'eslint-plugin-react-hooks',
|
|
301
|
+
'eslint-plugin-import', 'eslint-plugin-jsx-a11y', 'eslint-plugin-tailwindcss',
|
|
302
|
+
// Commit/release tooling
|
|
303
|
+
'@commitlint/cli', '@commitlint/config-conventional',
|
|
304
|
+
'standard-version', 'release-it', 'np',
|
|
305
|
+
// Test utilities (used as test runner plugins/reporters)
|
|
306
|
+
'chai', 'sinon', 'supertest', 'nock', '@testing-library/react',
|
|
307
|
+
'@testing-library/jest-dom', '@testing-library/user-event',
|
|
308
|
+
'ts-jest', '@swc/jest', 'babel-jest',
|
|
309
|
+
// Build plugins (used via config files, not imported)
|
|
310
|
+
'@vitejs/plugin-react', '@sveltejs/adapter-auto', '@sveltejs/kit',
|
|
311
|
+
'del-cli', 'make-node',
|
|
312
|
+
// Type packages (consumed by TS compiler, not imported)
|
|
313
|
+
'@types/react', '@types/react-dom', '@types/jest', '@types/mocha',
|
|
297
314
|
]);
|
|
298
315
|
// ── Collect all deps declared in workspace sub-packages ──────────────────────
|
|
299
316
|
export function collectWorkspaceDeps(cwd) {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { join, extname } from 'node:path';
|
|
2
|
+
import { cachedRead } from '../file-cache.js';
|
|
3
|
+
import { walkFiles, c } from '../util.js';
|
|
4
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
5
|
+
const SCAN_EXTS = new Set(['.ts', '.js', '.mjs', '.cjs', '.sql', '.sh', '.bash', '.py', '.rb']);
|
|
6
|
+
const SQL_EXTS = new Set(['.sql', '.ts', '.js', '.mjs', '.cjs']);
|
|
7
|
+
const SHELL_EXTS = new Set(['.sh', '.bash', '.ts', '.js']);
|
|
8
|
+
const SKIP_DIRS = ['test', '__tests__'];
|
|
9
|
+
const SKIP_PATTERN = /\.(test|spec)\.[^.]+$/;
|
|
10
|
+
// SQL patterns (case-insensitive)
|
|
11
|
+
const DROP_TABLE_RE = /\bDROP\s+TABLE\b/i;
|
|
12
|
+
const DROP_DB_RE = /\bDROP\s+DATABASE\b/i;
|
|
13
|
+
const TRUNCATE_RE = /\bTRUNCATE\b(\s+TABLE\b)?/i;
|
|
14
|
+
const DELETE_FROM_RE = /\bDELETE\s+FROM\b/i;
|
|
15
|
+
const DELETE_WHERE_RE = /\bDELETE\s+FROM\b.*\bWHERE\b/i;
|
|
16
|
+
// Shell patterns
|
|
17
|
+
const RM_RF_RE = /\brm\s+-(r|rf|fr)\b/i;
|
|
18
|
+
const RMDIR_RE = /\brmdir\b/i;
|
|
19
|
+
const SHRED_RE = /\bshred\b/;
|
|
20
|
+
const TRUNCATE_CMD_RE = /\btruncate\s+--size\b/;
|
|
21
|
+
// JS exec patterns
|
|
22
|
+
const EXEC_CALL_RE = /\b(exec|execSync|spawn|spawnSync)\s*\(/;
|
|
23
|
+
// Migration path patterns
|
|
24
|
+
const MIGRATION_PATH_RE = /migrat|db[/\\]/i;
|
|
25
|
+
// Rollback function patterns
|
|
26
|
+
const ROLLBACK_RE = /\b(down|rollback|revert)\s*\(/;
|
|
27
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
28
|
+
function shouldSkip(relPath) {
|
|
29
|
+
const parts = relPath.split(/[/\\]/);
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
if (SKIP_DIRS.includes(part))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return SKIP_PATTERN.test(relPath);
|
|
35
|
+
}
|
|
36
|
+
function scanLine(line, lineNum, relPath, ext, issues) {
|
|
37
|
+
const isSqlExt = SQL_EXTS.has(ext);
|
|
38
|
+
const isShellExt = SHELL_EXTS.has(ext);
|
|
39
|
+
// Pass 1 — SQL patterns
|
|
40
|
+
if (isSqlExt) {
|
|
41
|
+
if (DROP_TABLE_RE.test(line)) {
|
|
42
|
+
issues.push({ severity: 'error', message: 'DROP TABLE without transaction', file: relPath, line: lineNum, fixable: false, fixHint: 'wrap in transaction or add rollback' });
|
|
43
|
+
}
|
|
44
|
+
if (DROP_DB_RE.test(line)) {
|
|
45
|
+
issues.push({ severity: 'error', message: 'DROP DATABASE detected', file: relPath, line: lineNum, fixable: false, fixHint: 'remove or gate behind confirmation' });
|
|
46
|
+
}
|
|
47
|
+
if (TRUNCATE_RE.test(line)) {
|
|
48
|
+
issues.push({ severity: 'error', message: 'TRUNCATE operation detected', file: relPath, line: lineNum, fixable: false, fixHint: 'use soft-delete or add rollback' });
|
|
49
|
+
}
|
|
50
|
+
if (DELETE_FROM_RE.test(line)) {
|
|
51
|
+
if (DELETE_WHERE_RE.test(line)) {
|
|
52
|
+
issues.push({ severity: 'warning', message: 'DELETE FROM with WHERE clause', file: relPath, line: lineNum, fixable: false, fixHint: 'consider soft-delete or add --dry-run check' });
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
issues.push({ severity: 'error', message: 'DELETE FROM without WHERE clause', file: relPath, line: lineNum, fixable: false, fixHint: 'add WHERE clause or use TRUNCATE with rollback' });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Pass 2 — Shell patterns
|
|
60
|
+
if (isShellExt) {
|
|
61
|
+
// Direct shell commands in .sh/.bash
|
|
62
|
+
if (ext === '.sh' || ext === '.bash') {
|
|
63
|
+
if (RM_RF_RE.test(line)) {
|
|
64
|
+
issues.push({ severity: 'error', message: 'rm -rf in shell script', file: relPath, line: lineNum, fixable: false, fixHint: 'use trash-cli or add confirmation gate' });
|
|
65
|
+
}
|
|
66
|
+
if (RMDIR_RE.test(line)) {
|
|
67
|
+
issues.push({ severity: 'error', message: 'rmdir in shell script', file: relPath, line: lineNum, fixable: false, fixHint: 'use trash-cli or add confirmation gate' });
|
|
68
|
+
}
|
|
69
|
+
if (SHRED_RE.test(line)) {
|
|
70
|
+
issues.push({ severity: 'error', message: 'shred command detected', file: relPath, line: lineNum, fixable: false, fixHint: 'remove or gate behind confirmation' });
|
|
71
|
+
}
|
|
72
|
+
if (TRUNCATE_CMD_RE.test(line)) {
|
|
73
|
+
issues.push({ severity: 'error', message: 'truncate --size command detected', file: relPath, line: lineNum, fixable: false, fixHint: 'remove or gate behind confirmation' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// JS/TS exec/spawn calls with destructive commands
|
|
77
|
+
if (ext === '.ts' || ext === '.js') {
|
|
78
|
+
if (EXEC_CALL_RE.test(line)) {
|
|
79
|
+
if (RM_RF_RE.test(line)) {
|
|
80
|
+
issues.push({ severity: 'error', message: 'rm -rf in exec call', file: relPath, line: lineNum, fixable: false, fixHint: 'use trash-cli or add confirmation gate' });
|
|
81
|
+
}
|
|
82
|
+
if (RMDIR_RE.test(line)) {
|
|
83
|
+
issues.push({ severity: 'error', message: 'rmdir in exec call', file: relPath, line: lineNum, fixable: false, fixHint: 'use trash-cli or add confirmation gate' });
|
|
84
|
+
}
|
|
85
|
+
if (SHRED_RE.test(line)) {
|
|
86
|
+
issues.push({ severity: 'error', message: 'shred in exec call', file: relPath, line: lineNum, fixable: false, fixHint: 'remove or gate behind confirmation' });
|
|
87
|
+
}
|
|
88
|
+
if (TRUNCATE_CMD_RE.test(line)) {
|
|
89
|
+
issues.push({ severity: 'error', message: 'truncate --size in exec call', file: relPath, line: lineNum, fixable: false, fixHint: 'remove or gate behind confirmation' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
96
|
+
export function checkGuard(cwd) {
|
|
97
|
+
const issues = [];
|
|
98
|
+
const files = walkFiles(cwd);
|
|
99
|
+
for (const relPath of files) {
|
|
100
|
+
const ext = extname(relPath).toLowerCase();
|
|
101
|
+
if (!SCAN_EXTS.has(ext))
|
|
102
|
+
continue;
|
|
103
|
+
if (shouldSkip(relPath))
|
|
104
|
+
continue;
|
|
105
|
+
const fullPath = join(cwd, relPath);
|
|
106
|
+
let content;
|
|
107
|
+
try {
|
|
108
|
+
content = cachedRead(fullPath);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const rel = relPath;
|
|
114
|
+
const lines = content.split('\n');
|
|
115
|
+
const fileIssuesBefore = issues.length;
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
scanLine(lines[i], i + 1, rel, ext, issues);
|
|
118
|
+
}
|
|
119
|
+
// Pass 3 — Migration check
|
|
120
|
+
if (MIGRATION_PATH_RE.test(rel)) {
|
|
121
|
+
const hasDestructive = DROP_TABLE_RE.test(content) || DROP_DB_RE.test(content) ||
|
|
122
|
+
DELETE_FROM_RE.test(content) || TRUNCATE_RE.test(content);
|
|
123
|
+
const hasRollback = ROLLBACK_RE.test(content);
|
|
124
|
+
if (hasDestructive && !hasRollback) {
|
|
125
|
+
issues.push({
|
|
126
|
+
severity: 'warning',
|
|
127
|
+
message: 'migration with destructive operation but no rollback function',
|
|
128
|
+
file: rel,
|
|
129
|
+
fixable: false,
|
|
130
|
+
fixHint: 'add down() or rollback() function',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
136
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
137
|
+
const total = errors + warnings;
|
|
138
|
+
const score = Math.max(0, 100 - (errors * 15) - (warnings * 5));
|
|
139
|
+
const summary = total === 0
|
|
140
|
+
? 'no destructive patterns found'
|
|
141
|
+
: `${total} bomb sites found (${errors} fatal, ${warnings} warning)`;
|
|
142
|
+
return { name: 'guard', score, maxScore: 100, issues, summary };
|
|
143
|
+
}
|
|
144
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
145
|
+
export async function runGuardCommand(format, cwd) {
|
|
146
|
+
const dir = cwd || process.cwd();
|
|
147
|
+
const result = checkGuard(dir);
|
|
148
|
+
if (format === 'json') {
|
|
149
|
+
console.log(JSON.stringify(result, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
console.log(`\n ${c.bold}vet guard${c.reset} — destructive operation scanner\n`);
|
|
153
|
+
const errors = result.issues.filter(i => i.severity === 'error');
|
|
154
|
+
const warnings = result.issues.filter(i => i.severity === 'warning');
|
|
155
|
+
if (errors.length > 0) {
|
|
156
|
+
console.log(` ${c.red}FATAL${c.reset}`);
|
|
157
|
+
for (const issue of errors) {
|
|
158
|
+
const loc = issue.file ? (issue.line ? `${issue.file}:${issue.line}` : issue.file) : '';
|
|
159
|
+
console.log(` ${c.red}✗${c.reset} ${issue.message}${loc ? ` (${loc})` : ''}`);
|
|
160
|
+
if (issue.fixHint)
|
|
161
|
+
console.log(` ${c.dim}→ ${issue.fixHint}${c.reset}`);
|
|
162
|
+
}
|
|
163
|
+
console.log();
|
|
164
|
+
}
|
|
165
|
+
if (warnings.length > 0) {
|
|
166
|
+
console.log(` ${c.yellow}WARN${c.reset}`);
|
|
167
|
+
for (const issue of warnings) {
|
|
168
|
+
const loc = issue.file ? (issue.line ? `${issue.file}:${issue.line}` : issue.file) : '';
|
|
169
|
+
console.log(` ${c.yellow}⚠${c.reset} ${issue.message}${loc ? ` (${loc})` : ''}`);
|
|
170
|
+
if (issue.fixHint)
|
|
171
|
+
console.log(` ${c.dim}→ ${issue.fixHint}${c.reset}`);
|
|
172
|
+
}
|
|
173
|
+
console.log();
|
|
174
|
+
}
|
|
175
|
+
if (result.issues.length === 0) {
|
|
176
|
+
console.log(` ${c.green}no destructive patterns found${c.reset}\n`);
|
|
177
|
+
}
|
|
178
|
+
console.log(` ${result.summary}\n`);
|
|
179
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { checkCompact, runCompactCommand } from './checks/compact.js';
|
|
|
23
23
|
import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
|
|
24
24
|
import { checkLoop, runLoopCommand } from './checks/loop.js';
|
|
25
25
|
import { checkBloat, runBloatCommand } from './checks/bloat.js';
|
|
26
|
+
import { checkGuard, runGuardCommand } from './checks/guard.js';
|
|
26
27
|
import { checkCompleteness } from './checks/completeness.js';
|
|
27
28
|
import { score } from './scorer.js';
|
|
28
29
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
@@ -75,6 +76,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
75
76
|
npx @safetnsr/vet subsidy [--plan tier] [--since date] show AI cost vs subscription
|
|
76
77
|
npx @safetnsr/vet loop [log] /loop session forensics — per-iteration timeline
|
|
77
78
|
npx @safetnsr/vet bloat detect agent-generated code bloat
|
|
79
|
+
npx @safetnsr/vet guard [dir] scan for destructive operation bomb sites
|
|
78
80
|
|
|
79
81
|
${c.dim}categories:${c.reset}
|
|
80
82
|
security (30%) scan, secrets, config, model usage
|
|
@@ -110,7 +112,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
110
112
|
}
|
|
111
113
|
process.exit(0);
|
|
112
114
|
}
|
|
113
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat'];
|
|
115
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard'];
|
|
114
116
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
115
117
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
116
118
|
const isCI = flags.has('--ci');
|
|
@@ -241,6 +243,17 @@ if (command === 'bloat') {
|
|
|
241
243
|
}
|
|
242
244
|
process.exit(0);
|
|
243
245
|
}
|
|
246
|
+
if (command === 'guard') {
|
|
247
|
+
try {
|
|
248
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
249
|
+
await runGuardCommand(format, cwd);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
console.error(`${c.red}guard failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
244
257
|
if (!isGitRepo(cwd)) {
|
|
245
258
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
246
259
|
process.exit(1);
|
|
@@ -292,7 +305,7 @@ async function runChecks() {
|
|
|
292
305
|
}
|
|
293
306
|
}
|
|
294
307
|
// Run ALL independent checks in parallel
|
|
295
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult,] = await Promise.all([
|
|
308
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult,] = await Promise.all([
|
|
296
309
|
withTimeout('scan', () => checkScan(cwd)),
|
|
297
310
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
298
311
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -312,6 +325,7 @@ async function runChecks() {
|
|
|
312
325
|
withTimeout('loop', () => checkLoop(cwd)),
|
|
313
326
|
withTimeout('completeness', () => checkCompleteness(cwd, ignore)),
|
|
314
327
|
withTimeout('bloat', () => checkBloat(cwd)),
|
|
328
|
+
withTimeout('guard', () => checkGuard(cwd)),
|
|
315
329
|
]);
|
|
316
330
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
317
331
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -321,7 +335,7 @@ async function runChecks() {
|
|
|
321
335
|
// Clear file cache after all checks complete
|
|
322
336
|
clearCache();
|
|
323
337
|
return score(cwd, {
|
|
324
|
-
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult],
|
|
338
|
+
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
|
|
325
339
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult],
|
|
326
340
|
debt: [readyResult, historyResult, debtResult, bloatResult],
|
|
327
341
|
deps: [depsResult],
|