@safetnsr/vet 1.17.1 → 1.18.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.
@@ -7,5 +7,7 @@ export declare function buildCategories(checkMap: {
7
7
  integrity: CheckResult[];
8
8
  debt: CheckResult[];
9
9
  deps: CheckResult[];
10
+ architecture: CheckResult[];
11
+ aiready: CheckResult[];
10
12
  }): CategoryResult[];
11
13
  export declare function buildVetResult(project: string, categories: CategoryResult[]): VetResult;
@@ -17,9 +17,11 @@ export function toGrade(score) {
17
17
  // ── Category weights ─────────────────────────────────────────────────────────
18
18
  const WEIGHTS = {
19
19
  security: 0.25,
20
- integrity: 0.35,
21
- debt: 0.30,
20
+ integrity: 0.25,
21
+ debt: 0.20,
22
22
  deps: 0.10,
23
+ architecture: 0.10,
24
+ aiready: 0.10,
23
25
  };
24
26
  // ── Scoring floor for non-security checks ────────────────────────────────────
25
27
  const SECURITY_CHECKS = new Set(['scan', 'secrets', 'permissions', 'owasp']);
@@ -66,14 +68,16 @@ function completenessMultiplier(categories) {
66
68
  // ── Group checks into categories ─────────────────────────────────────────────
67
69
  export function buildCategories(checkMap) {
68
70
  const categories = [];
69
- for (const name of ['security', 'integrity', 'debt', 'deps']) {
71
+ for (const name of ['security', 'integrity', 'debt', 'deps', 'architecture', 'aiready']) {
70
72
  const checks = checkMap[name];
73
+ if (!checks || checks.length === 0)
74
+ continue;
71
75
  const score = averageScore(checks);
72
76
  const issues = checks.flatMap(c => c.issues);
73
77
  categories.push({
74
78
  name,
75
79
  score,
76
- weight: WEIGHTS[name],
80
+ weight: WEIGHTS[name] || 0.10,
77
81
  checks,
78
82
  issues,
79
83
  });
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkAIReady(cwd: string): CheckResult;
@@ -0,0 +1,311 @@
1
+ import { join } from 'node:path';
2
+ import { walkFiles, readFile, c } from '../util.js';
3
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
4
+ function isSourceFile(f) {
5
+ const dot = f.lastIndexOf('.');
6
+ return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
7
+ }
8
+ function isTestFile(f) {
9
+ return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /(?:^|[/\\])tests?[/\\]/.test(f);
10
+ }
11
+ function isTsFile(f) {
12
+ return /\.[mc]?tsx?$/.test(f);
13
+ }
14
+ function extractFunctions(file, content) {
15
+ const funcs = [];
16
+ const lines = content.split('\n');
17
+ const isTs = isTsFile(file);
18
+ // Match function declarations, arrow functions, methods
19
+ const funcStartRe = /^(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*:\s*[^{]+)?\s*\{/;
20
+ const arrowRe = /^(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+)?\s*=>/;
21
+ const methodRe = /^\s+(?:async\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(([^)]*)\)(?:\s*:\s*[^{]+)?\s*\{/;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ let match = line.match(funcStartRe) || line.match(arrowRe) || line.match(methodRe);
25
+ if (!match)
26
+ continue;
27
+ const name = match[1];
28
+ if (!name || name === 'if' || name === 'for' || name === 'while' || name === 'switch')
29
+ continue;
30
+ const params = match[2] || '';
31
+ const paramCount = params.trim() === '' ? 0 : params.split(',').length;
32
+ const hasReturnType = /\)\s*:\s*[^{]/.test(line);
33
+ // Count function body lines (find matching closing brace)
34
+ let depth = 0;
35
+ let started = false;
36
+ let endLine = i;
37
+ for (let j = i; j < lines.length && j < i + 500; j++) {
38
+ for (const ch of lines[j]) {
39
+ if (ch === '{') {
40
+ depth++;
41
+ started = true;
42
+ }
43
+ if (ch === '}')
44
+ depth--;
45
+ }
46
+ if (started && depth <= 0) {
47
+ endLine = j;
48
+ break;
49
+ }
50
+ }
51
+ funcs.push({
52
+ name,
53
+ file,
54
+ line: i + 1,
55
+ lineCount: endLine - i + 1,
56
+ paramCount,
57
+ hasReturnType,
58
+ isTyped: isTs,
59
+ });
60
+ }
61
+ return funcs;
62
+ }
63
+ // ── Import graph for context load ────────────────────────────────────────────
64
+ function buildImportCounts(cwd, sourceFiles) {
65
+ const importCounts = new Map(); // file → number of local imports
66
+ const importRe = /(?:import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
67
+ for (const file of sourceFiles) {
68
+ const content = readFile(join(cwd, file));
69
+ if (!content)
70
+ continue;
71
+ let count = 0;
72
+ let match;
73
+ importRe.lastIndex = 0;
74
+ while ((match = importRe.exec(content)) !== null) {
75
+ const spec = match[1] || match[2];
76
+ if (spec && spec.startsWith('.'))
77
+ count++;
78
+ }
79
+ importCounts.set(file, count);
80
+ }
81
+ return importCounts;
82
+ }
83
+ function analyzeStructure(files) {
84
+ const dirs = new Map(); // dir → file count
85
+ let totalDepth = 0;
86
+ let maxDepth = 0;
87
+ // Naming conventions
88
+ let camelCase = 0;
89
+ let kebabCase = 0;
90
+ let snakeCase = 0;
91
+ let pascalCase = 0;
92
+ for (const file of files) {
93
+ const parts = file.split(/[/\\]/);
94
+ const depth = parts.length - 1;
95
+ totalDepth += depth;
96
+ if (depth > maxDepth)
97
+ maxDepth = depth;
98
+ const dir = parts.slice(0, -1).join('/') || '.';
99
+ dirs.set(dir, (dirs.get(dir) || 0) + 1);
100
+ // Check file naming convention (without extension)
101
+ const name = parts[parts.length - 1].replace(/\.[^.]+$/, '');
102
+ if (/^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name))
103
+ camelCase++;
104
+ else if (/^[a-z][a-z0-9-]*$/.test(name) && name.includes('-'))
105
+ kebabCase++;
106
+ else if (/^[a-z][a-z0-9_]*$/.test(name) && name.includes('_'))
107
+ snakeCase++;
108
+ else if (/^[A-Z][a-zA-Z0-9]*$/.test(name))
109
+ pascalCase++;
110
+ }
111
+ const total = camelCase + kebabCase + snakeCase + pascalCase;
112
+ const dominant = Math.max(camelCase, kebabCase, snakeCase, pascalCase);
113
+ const namingConsistency = total > 0 ? dominant / total : 1;
114
+ const dirCounts = Array.from(dirs.values());
115
+ const avgFilesPerDir = dirCounts.length > 0 ? dirCounts.reduce((a, b) => a + b, 0) / dirCounts.length : 0;
116
+ return {
117
+ maxDepth,
118
+ avgDepth: files.length > 0 ? totalDepth / files.length : 0,
119
+ avgFilesPerDir,
120
+ namingConsistency,
121
+ };
122
+ }
123
+ // ── Main check ───────────────────────────────────────────────────────────────
124
+ export function checkAIReady(cwd) {
125
+ const allFiles = walkFiles(cwd);
126
+ const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f));
127
+ if (sourceFiles.length < 3) {
128
+ return {
129
+ maxScore: 100,
130
+ name: 'aiready',
131
+ score: 100,
132
+ summary: 'too few source files for AI-readiness analysis',
133
+ issues: [],
134
+ };
135
+ }
136
+ const issues = [];
137
+ // ── 1. Context load ───────────────────────────────────────────────────────
138
+ const importCounts = buildImportCounts(cwd, sourceFiles);
139
+ const counts = Array.from(importCounts.values());
140
+ const avgContextLoad = counts.length > 0 ? counts.reduce((a, b) => a + b, 0) / counts.length : 0;
141
+ // Score: <2 = great (100), 2-4 = good (80), 4-6 = okay (60), 6-8 = poor (40), >8 = bad (20)
142
+ const contextScore = avgContextLoad <= 2 ? 100
143
+ : avgContextLoad <= 4 ? 100 - (avgContextLoad - 2) * 10
144
+ : avgContextLoad <= 6 ? 80 - (avgContextLoad - 4) * 10
145
+ : avgContextLoad <= 8 ? 60 - (avgContextLoad - 6) * 10
146
+ : Math.max(20, 40 - (avgContextLoad - 8) * 5);
147
+ if (avgContextLoad > 5) {
148
+ issues.push({
149
+ severity: 'warning',
150
+ message: `high context load: files import ${avgContextLoad.toFixed(1)} local modules on average — AI agents need to load many files to understand changes`,
151
+ file: '',
152
+ fixable: true,
153
+ fixHint: 'reduce coupling between modules, use dependency injection or barrel exports',
154
+ });
155
+ }
156
+ // ── 2. Function clarity ───────────────────────────────────────────────────
157
+ const allFuncs = [];
158
+ for (const file of sourceFiles) {
159
+ const content = readFile(join(cwd, file));
160
+ if (!content)
161
+ continue;
162
+ allFuncs.push(...extractFunctions(file, content));
163
+ }
164
+ const clearFuncs = allFuncs.filter(f => f.lineCount <= 25 && f.paramCount < 5);
165
+ const clarityPct = allFuncs.length > 0 ? clearFuncs.length / allFuncs.length : 1;
166
+ const clarityScore = Math.round(clarityPct * 100);
167
+ const longFuncs = allFuncs.filter(f => f.lineCount > 50);
168
+ if (longFuncs.length > 0) {
169
+ const worst = longFuncs.sort((a, b) => b.lineCount - a.lineCount).slice(0, 3);
170
+ issues.push({
171
+ severity: 'warning',
172
+ message: `${longFuncs.length} function${longFuncs.length !== 1 ? 's' : ''} >50 lines — AI agents will struggle to modify these safely. Worst: ${worst.map(f => `${f.name} (${f.lineCount} lines)`).join(', ')}`,
173
+ file: worst[0]?.file || '',
174
+ line: worst[0]?.line,
175
+ fixable: true,
176
+ fixHint: 'break into smaller, single-responsibility functions',
177
+ });
178
+ }
179
+ const manyParams = allFuncs.filter(f => f.paramCount >= 5);
180
+ if (manyParams.length > 0) {
181
+ issues.push({
182
+ severity: 'info',
183
+ message: `${manyParams.length} function${manyParams.length !== 1 ? 's' : ''} with 5+ parameters — hard for agents to call correctly`,
184
+ file: manyParams[0]?.file || '',
185
+ fixable: true,
186
+ fixHint: 'use an options object parameter instead',
187
+ });
188
+ }
189
+ // ── 3. Type safety ────────────────────────────────────────────────────────
190
+ const tsFiles = sourceFiles.filter(f => isTsFile(f));
191
+ const tsRatio = sourceFiles.length > 0 ? tsFiles.length / sourceFiles.length : 0;
192
+ // Count `any` usage in TS files
193
+ let anyCount = 0;
194
+ let totalTsLines = 0;
195
+ for (const file of tsFiles) {
196
+ const content = readFile(join(cwd, file));
197
+ if (!content)
198
+ continue;
199
+ const lines = content.split('\n');
200
+ totalTsLines += lines.length;
201
+ for (const line of lines) {
202
+ // Match `: any`, `as any`, `<any>`, but not in comments
203
+ if (line.trimStart().startsWith('//') || line.trimStart().startsWith('*'))
204
+ continue;
205
+ const anyMatches = line.match(/(?::\s*any\b|as\s+any\b|<any>)/g);
206
+ if (anyMatches)
207
+ anyCount += anyMatches.length;
208
+ }
209
+ }
210
+ const anyDensity = totalTsLines > 0 ? anyCount / totalTsLines : 0;
211
+ const typeSafetyScore = Math.round(tsRatio * 100 * (1 - Math.min(1, anyDensity * 50)));
212
+ if (tsRatio < 0.5 && sourceFiles.length > 5) {
213
+ issues.push({
214
+ severity: 'warning',
215
+ message: `low type coverage: ${Math.round(tsRatio * 100)}% TypeScript — AI agents can't verify their changes in untyped code`,
216
+ file: '',
217
+ fixable: true,
218
+ fixHint: 'migrate JavaScript files to TypeScript incrementally',
219
+ });
220
+ }
221
+ if (anyCount > 5) {
222
+ issues.push({
223
+ severity: 'info',
224
+ message: `${anyCount} \`any\` type usages — weakens type safety that agents rely on`,
225
+ file: '',
226
+ fixable: true,
227
+ fixHint: 'replace `any` with specific types',
228
+ });
229
+ }
230
+ // ── 4. File discoverability ───────────────────────────────────────────────
231
+ const structure = analyzeStructure(sourceFiles);
232
+ let discoverScore = 100;
233
+ // Penalize deep nesting
234
+ if (structure.maxDepth > 6)
235
+ discoverScore -= Math.min(20, (structure.maxDepth - 6) * 5);
236
+ // Penalize inconsistent naming
237
+ discoverScore -= Math.round((1 - structure.namingConsistency) * 30);
238
+ // Penalize very large directories (>30 files)
239
+ if (structure.avgFilesPerDir > 20)
240
+ discoverScore -= Math.min(15, (structure.avgFilesPerDir - 20) * 2);
241
+ discoverScore = Math.max(20, discoverScore);
242
+ if (structure.namingConsistency < 0.6) {
243
+ issues.push({
244
+ severity: 'info',
245
+ message: `mixed file naming conventions — AI agents work best with consistent naming (pick camelCase or kebab-case)`,
246
+ file: '',
247
+ fixable: true,
248
+ fixHint: 'standardize file naming convention across the project',
249
+ });
250
+ }
251
+ if (structure.maxDepth > 7) {
252
+ issues.push({
253
+ severity: 'info',
254
+ message: `deeply nested file structure (max ${structure.maxDepth} levels) — agents struggle to navigate deep hierarchies`,
255
+ file: '',
256
+ fixable: true,
257
+ fixHint: 'flatten directory structure where possible',
258
+ });
259
+ }
260
+ // ── 5. Modification safety ────────────────────────────────────────────────
261
+ // % of functions that are typed (in .ts files with return type)
262
+ const typedFuncs = allFuncs.filter(f => f.isTyped && f.hasReturnType);
263
+ const modSafetyPct = allFuncs.length > 0 ? typedFuncs.length / allFuncs.length : 0;
264
+ const modSafetyScore = Math.round(modSafetyPct * 100);
265
+ // ── 6. Context window fit ─────────────────────────────────────────────────
266
+ let totalChars = 0;
267
+ for (const file of sourceFiles) {
268
+ const content = readFile(join(cwd, file));
269
+ if (content)
270
+ totalChars += content.length;
271
+ }
272
+ const estimatedTokens = totalChars / 4;
273
+ const contextWindowSize = 128_000;
274
+ const fitRatio = Math.min(1, contextWindowSize / estimatedTokens);
275
+ const fitScore = fitRatio >= 1 ? 100
276
+ : fitRatio >= 0.5 ? 70 + fitRatio * 30
277
+ : fitRatio >= 0.25 ? 40 + fitRatio * 60
278
+ : Math.max(10, Math.round(fitRatio * 160));
279
+ if (fitRatio < 0.25) {
280
+ issues.push({
281
+ severity: 'info',
282
+ message: `only ${Math.round(fitRatio * 100)}% of source fits in a 128k context window (${Math.round(estimatedTokens / 1000)}k tokens) — agents need careful file selection`,
283
+ file: '',
284
+ fixable: false,
285
+ fixHint: 'maintain clear module boundaries so agents can work on isolated sections',
286
+ });
287
+ }
288
+ // ── Weighted score ────────────────────────────────────────────────────────
289
+ const score = Math.max(25, Math.round(contextScore * 0.25 +
290
+ clarityScore * 0.20 +
291
+ typeSafetyScore * 0.20 +
292
+ discoverScore * 0.15 +
293
+ modSafetyScore * 0.10 +
294
+ fitScore * 0.10));
295
+ // ── Summary ───────────────────────────────────────────────────────────────
296
+ const parts = [];
297
+ parts.push(`context load ${avgContextLoad.toFixed(1)} files`);
298
+ parts.push(`${Math.round(clarityPct * 100)}% clear functions`);
299
+ parts.push(`${Math.round(tsRatio * 100)}% typed`);
300
+ if (fitRatio < 0.5)
301
+ parts.push(c.yellow + `${Math.round(fitRatio * 100)}% fits in 128k` + c.reset);
302
+ if (longFuncs.length > 0)
303
+ parts.push(c.yellow + `${longFuncs.length} long functions` + c.reset);
304
+ return {
305
+ maxScore: 100,
306
+ name: 'aiready',
307
+ score,
308
+ summary: parts.join(', '),
309
+ issues,
310
+ };
311
+ }
@@ -0,0 +1,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkArchitecture(cwd: string): CheckResult;
@@ -0,0 +1,285 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { walkFiles, readFile, c } from '../util.js';
3
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
4
+ function isSourceFile(f) {
5
+ const dot = f.lastIndexOf('.');
6
+ return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
7
+ }
8
+ function isTestFile(f) {
9
+ return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /(?:^|[/\\])tests?[/\\]/.test(f);
10
+ }
11
+ function resolveImport(fromFile, specifier, allFiles) {
12
+ if (!specifier.startsWith('.'))
13
+ return null; // only resolve relative imports
14
+ const dir = dirname(fromFile);
15
+ const base = join(dir, specifier).replace(/\\/g, '/');
16
+ // Try exact, then with extensions, then as directory index
17
+ const candidates = [
18
+ base,
19
+ ...Array.from(SOURCE_EXTS).map(ext => base + ext),
20
+ ...Array.from(SOURCE_EXTS).map(ext => base + '/index' + ext),
21
+ ];
22
+ for (const c of candidates) {
23
+ if (allFiles.has(c))
24
+ return c;
25
+ }
26
+ return null;
27
+ }
28
+ function buildImportGraph(cwd, files) {
29
+ const sourceFiles = files.filter(f => isSourceFile(f) && !isTestFile(f));
30
+ const fileSet = new Set(sourceFiles);
31
+ const edges = [];
32
+ const importRe = /(?:import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
33
+ for (const file of sourceFiles) {
34
+ const content = readFile(join(cwd, file));
35
+ if (!content)
36
+ continue;
37
+ let match;
38
+ importRe.lastIndex = 0;
39
+ while ((match = importRe.exec(content)) !== null) {
40
+ const specifier = match[1] || match[2];
41
+ if (!specifier || !specifier.startsWith('.'))
42
+ continue;
43
+ const resolved = resolveImport(file, specifier, fileSet);
44
+ if (resolved) {
45
+ edges.push({ from: file, to: resolved });
46
+ }
47
+ }
48
+ }
49
+ return edges;
50
+ }
51
+ // ── Module detection ─────────────────────────────────────────────────────────
52
+ function getModule(file) {
53
+ // First directory under src/, lib/, app/, or root
54
+ const parts = file.split(/[/\\]/);
55
+ // Find the "source root" (src/, lib/, app/, packages/*)
56
+ let start = 0;
57
+ for (let i = 0; i < parts.length - 1; i++) {
58
+ if (['src', 'lib', 'app', 'source'].includes(parts[i])) {
59
+ start = i + 1;
60
+ break;
61
+ }
62
+ }
63
+ if (start < parts.length - 1) {
64
+ return parts.slice(0, start + 1).join('/');
65
+ }
66
+ // Fallback: use first directory
67
+ return parts.length > 1 ? parts[0] : '(root)';
68
+ }
69
+ // ── Cycle detection (DFS) ────────────────────────────────────────────────────
70
+ function findCycles(moduleGraph) {
71
+ const cycles = [];
72
+ const visited = new Set();
73
+ const inStack = new Set();
74
+ const path = [];
75
+ function dfs(node) {
76
+ if (inStack.has(node)) {
77
+ // Found cycle — extract it
78
+ const cycleStart = path.indexOf(node);
79
+ if (cycleStart !== -1) {
80
+ cycles.push([...path.slice(cycleStart), node]);
81
+ }
82
+ return;
83
+ }
84
+ if (visited.has(node))
85
+ return;
86
+ visited.add(node);
87
+ inStack.add(node);
88
+ path.push(node);
89
+ const deps = moduleGraph.get(node);
90
+ if (deps) {
91
+ for (const dep of deps) {
92
+ if (dep !== node)
93
+ dfs(dep); // skip self-imports
94
+ }
95
+ }
96
+ path.pop();
97
+ inStack.delete(node);
98
+ }
99
+ for (const node of moduleGraph.keys()) {
100
+ dfs(node);
101
+ }
102
+ // Deduplicate cycles (same cycle can be found from different start nodes)
103
+ const seen = new Set();
104
+ return cycles.filter(cycle => {
105
+ const sorted = [...cycle.slice(0, -1)].sort().join('|');
106
+ if (seen.has(sorted))
107
+ return false;
108
+ seen.add(sorted);
109
+ return true;
110
+ });
111
+ }
112
+ // ── Main check ───────────────────────────────────────────────────────────────
113
+ export function checkArchitecture(cwd) {
114
+ const allFiles = walkFiles(cwd);
115
+ const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f));
116
+ if (sourceFiles.length < 5) {
117
+ return {
118
+ maxScore: 100,
119
+ name: 'architecture',
120
+ score: 100,
121
+ summary: 'too few source files for architecture analysis',
122
+ issues: [],
123
+ };
124
+ }
125
+ const edges = buildImportGraph(cwd, allFiles);
126
+ const issues = [];
127
+ // ── Build module-level graph ───────────────────────────────────────────────
128
+ const moduleGraph = new Map(); // module → set of imported modules
129
+ const moduleFiles = new Map(); // module → files in it
130
+ for (const file of sourceFiles) {
131
+ const mod = getModule(file);
132
+ if (!moduleFiles.has(mod))
133
+ moduleFiles.set(mod, new Set());
134
+ moduleFiles.get(mod).add(file);
135
+ if (!moduleGraph.has(mod))
136
+ moduleGraph.set(mod, new Set());
137
+ }
138
+ for (const edge of edges) {
139
+ const fromMod = getModule(edge.from);
140
+ const toMod = getModule(edge.to);
141
+ if (fromMod !== toMod) {
142
+ moduleGraph.get(fromMod)?.add(toMod);
143
+ }
144
+ }
145
+ // ── Coupling metrics ──────────────────────────────────────────────────────
146
+ const afferent = new Map(); // Ca: who depends on me
147
+ const efferent = new Map(); // Ce: who do I depend on
148
+ for (const [mod, deps] of moduleGraph) {
149
+ efferent.set(mod, deps.size);
150
+ for (const dep of deps) {
151
+ afferent.set(dep, (afferent.get(dep) || 0) + 1);
152
+ }
153
+ }
154
+ // Instability: Ce / (Ca + Ce). 0 = stable (many dependents), 1 = unstable
155
+ const instability = new Map();
156
+ for (const mod of moduleGraph.keys()) {
157
+ const ca = afferent.get(mod) || 0;
158
+ const ce = efferent.get(mod) || 0;
159
+ instability.set(mod, ca + ce > 0 ? ce / (ca + ce) : 0.5);
160
+ }
161
+ // ── God files ──────────────────────────────────────────────────────────────
162
+ const fileImporters = new Map(); // file → files that import it
163
+ for (const edge of edges) {
164
+ if (!fileImporters.has(edge.to))
165
+ fileImporters.set(edge.to, new Set());
166
+ fileImporters.get(edge.to).add(edge.from);
167
+ }
168
+ const GOD_FILE_THRESHOLD = 10;
169
+ const godFiles = [];
170
+ for (const [file, importers] of fileImporters) {
171
+ if (importers.size >= GOD_FILE_THRESHOLD) {
172
+ godFiles.push({ file, importers: importers.size });
173
+ }
174
+ }
175
+ godFiles.sort((a, b) => b.importers - a.importers);
176
+ for (const gf of godFiles) {
177
+ issues.push({
178
+ severity: 'warning',
179
+ message: `god file: ${gf.file} is imported by ${gf.importers} files — consider splitting by domain`,
180
+ file: gf.file,
181
+ fixable: true,
182
+ fixHint: 'split into smaller, domain-specific modules',
183
+ });
184
+ }
185
+ // ── Circular dependencies ─────────────────────────────────────────────────
186
+ const cycles = findCycles(moduleGraph);
187
+ for (const cycle of cycles.slice(0, 5)) { // cap at 5 reported cycles
188
+ const cycleStr = cycle.join(' → ');
189
+ issues.push({
190
+ severity: 'warning',
191
+ message: `circular dependency: ${cycleStr}`,
192
+ file: cycle[0],
193
+ fixable: true,
194
+ fixHint: 'extract shared types/interfaces into a separate module to break the cycle',
195
+ });
196
+ }
197
+ // ── Unstable modules with high dependents (worst pattern) ──────────────────
198
+ for (const [mod, inst] of instability) {
199
+ const ca = afferent.get(mod) || 0;
200
+ if (inst > 0.8 && ca >= 3) {
201
+ issues.push({
202
+ severity: 'warning',
203
+ message: `unstable dependency: ${mod} has instability ${inst.toFixed(2)} but ${ca} modules depend on it — changes here break downstream`,
204
+ file: mod,
205
+ fixable: true,
206
+ fixHint: 'reduce dependencies or add an abstraction layer',
207
+ });
208
+ }
209
+ }
210
+ // ── Orphan modules ────────────────────────────────────────────────────────
211
+ for (const mod of moduleGraph.keys()) {
212
+ const ca = afferent.get(mod) || 0;
213
+ const ce = efferent.get(mod) || 0;
214
+ const files = moduleFiles.get(mod);
215
+ if (ca === 0 && ce === 0 && files && files.size > 2) {
216
+ // Skip entry point modules
217
+ const hasEntry = Array.from(files).some(f => /(?:^|[/\\])(?:index|main|cli|app|server)\.[jt]sx?$/.test(f));
218
+ if (!hasEntry) {
219
+ issues.push({
220
+ severity: 'info',
221
+ message: `orphan module: ${mod} (${files.size} files) is not imported by any other module`,
222
+ file: mod,
223
+ fixable: false,
224
+ fixHint: 'may be dead code — verify if still needed',
225
+ });
226
+ }
227
+ }
228
+ }
229
+ // ── Boundary violations ───────────────────────────────────────────────────
230
+ // Files importing deep into another module (>2 levels) instead of from its index
231
+ let boundaryViolations = 0;
232
+ for (const edge of edges) {
233
+ const fromMod = getModule(edge.from);
234
+ const toMod = getModule(edge.to);
235
+ if (fromMod === toMod)
236
+ continue;
237
+ // Check if target file is deep inside the module (not the index)
238
+ const toRelative = edge.to.replace(toMod + '/', '');
239
+ if (toRelative.includes('/') && !/^index\.[jt]sx?$/.test(toRelative)) {
240
+ boundaryViolations++;
241
+ if (boundaryViolations <= 3) { // only report first 3
242
+ issues.push({
243
+ severity: 'info',
244
+ message: `boundary violation: ${edge.from} imports ${edge.to} directly — prefer importing from ${toMod}/index`,
245
+ file: edge.from,
246
+ fixable: true,
247
+ fixHint: `re-export from ${toMod}/index.ts and import from there`,
248
+ });
249
+ }
250
+ }
251
+ }
252
+ if (boundaryViolations > 3) {
253
+ issues.push({
254
+ severity: 'info',
255
+ message: `${boundaryViolations - 3} more boundary violations (importing deep into other modules)`,
256
+ file: '',
257
+ fixable: false,
258
+ fixHint: 'use barrel exports (index.ts) to define module boundaries',
259
+ });
260
+ }
261
+ // ── Scoring ───────────────────────────────────────────────────────────────
262
+ const moduleCount = moduleGraph.size;
263
+ const sizeScale = moduleCount <= 5 ? 1.0 : Math.max(0.3, 1.0 - Math.log10(moduleCount / 5) * 0.3);
264
+ let score = 100;
265
+ score -= Math.min(30, cycles.length * 15) * sizeScale;
266
+ score -= Math.min(20, godFiles.length * 10) * sizeScale;
267
+ score -= Math.min(15, Array.from(instability.values()).filter(i => i > 0.8).length * 5) * sizeScale;
268
+ score -= Math.min(10, Math.floor(boundaryViolations / 3) * 3) * sizeScale;
269
+ score = Math.max(25, Math.round(score));
270
+ // ── Summary ───────────────────────────────────────────────────────────────
271
+ const parts = [];
272
+ parts.push(`${moduleGraph.size} modules`);
273
+ parts.push(`${edges.length} import edges`);
274
+ if (cycles.length > 0)
275
+ parts.push(c.red + `${cycles.length} circular dep${cycles.length !== 1 ? 's' : ''}` + c.reset);
276
+ if (godFiles.length > 0)
277
+ parts.push(c.yellow + `${godFiles.length} god file${godFiles.length !== 1 ? 's' : ''}` + c.reset);
278
+ return {
279
+ maxScore: 100,
280
+ name: 'architecture',
281
+ score,
282
+ summary: parts.join(', '),
283
+ issues,
284
+ };
285
+ }
@@ -0,0 +1,15 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export type RiskTier = 'RISKY' | 'REVIEW' | 'SAFE';
3
+ interface FileRisk {
4
+ file: string;
5
+ tier: RiskTier;
6
+ reason: string;
7
+ }
8
+ export declare function classifyFile(filePath: string): {
9
+ tier: RiskTier;
10
+ reason: string;
11
+ };
12
+ export declare function analyzeFiles(cwd: string, since?: string): FileRisk[];
13
+ export declare function checkExplain(cwd: string, since?: string): CheckResult;
14
+ export declare function runExplainCommand(format: 'json' | 'ascii', cwd: string, since?: string, useAI?: boolean, verbose?: boolean): Promise<void>;
15
+ export {};
@@ -0,0 +1,208 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { c } from '../util.js';
3
+ const RISKY_PATTERNS = [
4
+ /^auth/i, /^session/i, /^payment/i, /^billing/i, /^credential/i,
5
+ /^token/i, /^jwt/i, /^password/i, /^secret/i, /^env/i,
6
+ ];
7
+ const RISKY_PATH_PATTERNS = [
8
+ /config\/prod/i, /migrations\//i, /\.env/i,
9
+ ];
10
+ const REVIEW_PATH_PATTERNS = [
11
+ /^api\//i, /^routes\//i, /^middleware\//i, /^db\//i,
12
+ /^schema/i, /^hooks\//i, /^controllers\//i,
13
+ ];
14
+ const RISKY_REMOVED_KEYWORDS = ['DELETE', 'DROP', 'destroy', 'removeAll', 'truncate', 'rm -rf', 'unlink'];
15
+ const REVIEW_ADDED_KEYWORDS = ['TODO', 'FIXME', 'HACK'];
16
+ function basename(filePath) {
17
+ const parts = filePath.split('/');
18
+ return parts[parts.length - 1];
19
+ }
20
+ export function classifyFile(filePath) {
21
+ const base = basename(filePath).toLowerCase();
22
+ const lower = filePath.toLowerCase();
23
+ for (const pat of RISKY_PATTERNS) {
24
+ if (pat.test(base))
25
+ return { tier: 'RISKY', reason: `basename matches ${pat.source}` };
26
+ }
27
+ for (const pat of RISKY_PATH_PATTERNS) {
28
+ if (pat.test(lower))
29
+ return { tier: 'RISKY', reason: `path matches ${pat.source}` };
30
+ }
31
+ for (const pat of REVIEW_PATH_PATTERNS) {
32
+ if (pat.test(lower))
33
+ return { tier: 'REVIEW', reason: `path matches ${pat.source}` };
34
+ }
35
+ return { tier: 'SAFE', reason: 'no risk patterns matched' };
36
+ }
37
+ function gitCmd(args, cwd) {
38
+ try {
39
+ return execSync(`git ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
40
+ }
41
+ catch {
42
+ return '';
43
+ }
44
+ }
45
+ function getChangedFiles(cwd, since) {
46
+ // Try git diff first
47
+ const ref = since || 'HEAD~1';
48
+ const nameStatus = gitCmd(`diff ${ref} --name-status`, cwd);
49
+ if (nameStatus) {
50
+ return nameStatus.split('\n')
51
+ .filter(l => l.trim())
52
+ .map(l => l.split('\t').slice(1).pop())
53
+ .filter(Boolean);
54
+ }
55
+ // Fallback: git status for repos with no history
56
+ const status = gitCmd('status --porcelain', cwd);
57
+ if (status) {
58
+ return status.split('\n')
59
+ .filter(l => l.trim())
60
+ .map(l => l.trim().replace(/^[A-Z?!]+\s+/, ''))
61
+ .filter(Boolean);
62
+ }
63
+ return [];
64
+ }
65
+ function getHunkKeywords(cwd, since) {
66
+ const ref = since || 'HEAD~1';
67
+ const riskyFiles = new Set();
68
+ const reviewFiles = new Set();
69
+ const diff = gitCmd(`diff ${ref} -U0`, cwd);
70
+ if (!diff)
71
+ return { riskyFiles, reviewFiles };
72
+ let currentFile = '';
73
+ for (const line of diff.split('\n')) {
74
+ if (line.startsWith('diff --git')) {
75
+ const match = line.match(/b\/(.+)$/);
76
+ if (match)
77
+ currentFile = match[1];
78
+ continue;
79
+ }
80
+ // Removed lines (start with - but not ---)
81
+ if (line.startsWith('-') && !line.startsWith('---')) {
82
+ for (const kw of RISKY_REMOVED_KEYWORDS) {
83
+ if (line.includes(kw)) {
84
+ riskyFiles.add(currentFile);
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ // Added lines (start with + but not +++)
90
+ if (line.startsWith('+') && !line.startsWith('+++')) {
91
+ for (const kw of REVIEW_ADDED_KEYWORDS) {
92
+ if (line.includes(kw)) {
93
+ reviewFiles.add(currentFile);
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return { riskyFiles, reviewFiles };
100
+ }
101
+ export function analyzeFiles(cwd, since) {
102
+ const files = getChangedFiles(cwd, since);
103
+ if (files.length === 0)
104
+ return [];
105
+ const { riskyFiles, reviewFiles } = getHunkKeywords(cwd, since);
106
+ const results = [];
107
+ for (const file of files) {
108
+ let { tier, reason } = classifyFile(file);
109
+ // Hunk-based bumps
110
+ if (riskyFiles.has(file) && tier !== 'RISKY') {
111
+ tier = 'RISKY';
112
+ reason = 'destructive keyword in removed line';
113
+ }
114
+ else if (reviewFiles.has(file) && tier === 'SAFE') {
115
+ tier = 'REVIEW';
116
+ reason = 'TODO/FIXME/HACK in added line';
117
+ }
118
+ results.push({ file, tier, reason });
119
+ }
120
+ return results;
121
+ }
122
+ // ── checkExplain (for full scan integration) ─────────────────────────────────
123
+ export function checkExplain(cwd, since) {
124
+ const files = analyzeFiles(cwd, since);
125
+ const risky = files.filter(f => f.tier === 'RISKY');
126
+ const review = files.filter(f => f.tier === 'REVIEW');
127
+ const rawScore = 100 - (risky.length * 15) - (review.length * 5);
128
+ const finalScore = Math.max(0, Math.min(100, rawScore));
129
+ const issues = [];
130
+ for (const f of risky) {
131
+ issues.push({
132
+ severity: 'error',
133
+ message: `RISKY: ${f.file} — ${f.reason}`,
134
+ file: f.file,
135
+ fixable: false,
136
+ });
137
+ }
138
+ for (const f of review) {
139
+ issues.push({
140
+ severity: 'warning',
141
+ message: `REVIEW: ${f.file} — ${f.reason}`,
142
+ file: f.file,
143
+ fixable: false,
144
+ });
145
+ }
146
+ return {
147
+ name: 'explain',
148
+ score: finalScore,
149
+ maxScore: 100,
150
+ issues,
151
+ summary: `${risky.length} risky, ${review.length} review, ${files.length - risky.length - review.length} safe`,
152
+ };
153
+ }
154
+ // ── runExplainCommand (standalone CLI) ───────────────────────────────────────
155
+ export async function runExplainCommand(format, cwd, since, useAI, verbose) {
156
+ if (useAI) {
157
+ console.log(`\n ${c.dim}LLM classification coming in v2${c.reset}\n`);
158
+ return;
159
+ }
160
+ const files = analyzeFiles(cwd, since);
161
+ const risky = files.filter(f => f.tier === 'RISKY');
162
+ const review = files.filter(f => f.tier === 'REVIEW');
163
+ const safe = files.filter(f => f.tier === 'SAFE');
164
+ if (format === 'json') {
165
+ const output = {
166
+ risky: risky.map(f => ({ file: f.file, reason: f.reason })),
167
+ review: review.map(f => ({ file: f.file, reason: f.reason })),
168
+ safe: safe.map(f => ({ file: f.file, reason: f.reason })),
169
+ summary: {
170
+ risky: risky.length,
171
+ review: review.length,
172
+ safe: safe.length,
173
+ total: files.length,
174
+ },
175
+ };
176
+ console.log(JSON.stringify(output, null, 2));
177
+ return;
178
+ }
179
+ // ASCII output
180
+ console.log(`\n ${c.bold}vet explain${c.reset} — risk-tier analysis\n`);
181
+ if (files.length === 0) {
182
+ console.log(` ${c.dim}no changed files found${c.reset}\n`);
183
+ return;
184
+ }
185
+ if (risky.length > 0) {
186
+ console.log(` ${c.red}${c.bold}RISKY${c.reset} ${c.dim}(${risky.length})${c.reset}`);
187
+ for (const f of risky) {
188
+ console.log(` ${c.red}✗${c.reset} ${f.file} ${c.dim}— ${f.reason}${c.reset}`);
189
+ }
190
+ console.log('');
191
+ }
192
+ if (review.length > 0) {
193
+ console.log(` ${c.yellow}${c.bold}REVIEW${c.reset} ${c.dim}(${review.length})${c.reset}`);
194
+ for (const f of review) {
195
+ console.log(` ${c.yellow}⚠${c.reset} ${f.file} ${c.dim}— ${f.reason}${c.reset}`);
196
+ }
197
+ console.log('');
198
+ }
199
+ if (verbose && safe.length > 0) {
200
+ console.log(` ${c.green}${c.bold}SAFE${c.reset} ${c.dim}(${safe.length})${c.reset}`);
201
+ for (const f of safe) {
202
+ console.log(` ${c.green}✓${c.reset} ${f.file}`);
203
+ }
204
+ console.log('');
205
+ }
206
+ const total = files.length;
207
+ console.log(` ${c.dim}total: ${total} files — ${risky.length} risky, ${review.length} review, ${safe.length} safe${c.reset}\n`);
208
+ }
package/dist/cli.js CHANGED
@@ -13,6 +13,8 @@ import { checkOwasp } from './checks/owasp.js';
13
13
  import { checkDeps } from './checks/deps.js';
14
14
  import { checkDebt } from './checks/debt.js';
15
15
  import { checkIntegrity } from './checks/integrity.js';
16
+ import { checkArchitecture } from './checks/architecture.js';
17
+ import { checkAIReady } from './checks/aiready.js';
16
18
  import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
17
19
  import { checkMemory } from './checks/memory.js';
18
20
  import { checkVerify } from './checks/verify.js';
@@ -24,6 +26,7 @@ import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
24
26
  import { checkLoop, runLoopCommand } from './checks/loop.js';
25
27
  import { checkBloat, runBloatCommand } from './checks/bloat.js';
26
28
  import { checkGuard, runGuardCommand } from './checks/guard.js';
29
+ import { checkExplain, runExplainCommand } from './checks/explain.js';
27
30
  import { checkCompleteness } from './checks/completeness.js';
28
31
  import { score } from './scorer.js';
29
32
  import { reportPretty, reportJSON, reportBadge } from './reporter.js';
@@ -77,6 +80,7 @@ if (flags.has('--help') || flags.has('-h')) {
77
80
  npx @safetnsr/vet loop [log] /loop session forensics — per-iteration timeline
78
81
  npx @safetnsr/vet bloat detect agent-generated code bloat
79
82
  npx @safetnsr/vet guard [dir] scan for destructive operation bomb sites
83
+ npx @safetnsr/vet explain [--since REF] [--verbose] [--json] risk-tier agent changes
80
84
 
81
85
  ${c.dim}categories:${c.reset}
82
86
  security (30%) scan, secrets, config, model usage
@@ -112,7 +116,7 @@ if (flags.has('--version') || flags.has('-v')) {
112
116
  }
113
117
  process.exit(0);
114
118
  }
115
- const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard'];
119
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain'];
116
120
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
117
121
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
118
122
  const isCI = flags.has('--ci');
@@ -254,6 +258,19 @@ if (command === 'guard') {
254
258
  }
255
259
  process.exit(0);
256
260
  }
261
+ if (command === 'explain') {
262
+ try {
263
+ const format = isJSON ? 'json' : 'ascii';
264
+ const useAI = flags.has('--ai');
265
+ const verbose = flags.has('--verbose');
266
+ await runExplainCommand(format, cwd, since, useAI, verbose);
267
+ }
268
+ catch (e) {
269
+ console.error(`${c.red}explain failed:${c.reset}`, e instanceof Error ? e.message : e);
270
+ process.exit(1);
271
+ }
272
+ process.exit(0);
273
+ }
257
274
  if (!isGitRepo(cwd)) {
258
275
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
259
276
  process.exit(1);
@@ -305,7 +322,7 @@ async function runChecks() {
305
322
  }
306
323
  }
307
324
  // Run ALL independent checks in parallel
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([
325
+ const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult,] = await Promise.all([
309
326
  withTimeout('scan', () => checkScan(cwd)),
310
327
  withTimeout('secrets', () => checkSecrets(cwd)),
311
328
  withTimeout('config', () => checkConfig(cwd, ignore)),
@@ -326,6 +343,9 @@ async function runChecks() {
326
343
  withTimeout('completeness', () => checkCompleteness(cwd, ignore)),
327
344
  withTimeout('bloat', () => checkBloat(cwd)),
328
345
  withTimeout('guard', () => checkGuard(cwd)),
346
+ withTimeout('explain', () => checkExplain(cwd, since)),
347
+ withTimeout('architecture', () => checkArchitecture(cwd)),
348
+ withTimeout('aiready', () => checkAIReady(cwd)),
329
349
  ]);
330
350
  // Git-dependent checks (diff + history) — parallel with each other
331
351
  const [diffResult, historyResult] = await Promise.all([
@@ -336,9 +356,11 @@ async function runChecks() {
336
356
  clearCache();
337
357
  return score(cwd, {
338
358
  security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
339
- integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult],
359
+ integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
340
360
  debt: [readyResult, historyResult, debtResult, bloatResult],
341
361
  deps: [depsResult],
362
+ architecture: [architectureResult],
363
+ aiready: [aireadyResult],
342
364
  });
343
365
  }
344
366
  catch (e) {
package/dist/scorer.d.ts CHANGED
@@ -4,5 +4,7 @@ export interface CheckMap {
4
4
  integrity: CheckResult[];
5
5
  debt: CheckResult[];
6
6
  deps: CheckResult[];
7
+ architecture: CheckResult[];
8
+ aiready: CheckResult[];
7
9
  }
8
10
  export declare function score(project: string, checkMap: CheckMap): VetResult;
package/dist/types.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface Issue {
14
14
  fixHint?: string;
15
15
  }
16
16
  export interface CategoryResult {
17
- name: 'security' | 'integrity' | 'debt' | 'deps';
17
+ name: 'security' | 'integrity' | 'debt' | 'deps' | 'architecture' | 'aiready';
18
18
  score: number;
19
19
  weight: number;
20
20
  checks: CheckResult[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {