@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.
- package/dist/categories.d.ts +2 -0
- package/dist/categories.js +8 -4
- package/dist/checks/aiready.d.ts +2 -0
- package/dist/checks/aiready.js +311 -0
- package/dist/checks/architecture.d.ts +2 -0
- package/dist/checks/architecture.js +285 -0
- package/dist/checks/explain.d.ts +15 -0
- package/dist/checks/explain.js +208 -0
- package/dist/cli.js +25 -3
- package/dist/scorer.d.ts +2 -0
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/categories.d.ts
CHANGED
|
@@ -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;
|
package/dist/categories.js
CHANGED
|
@@ -17,9 +17,11 @@ export function toGrade(score) {
|
|
|
17
17
|
// ── Category weights ─────────────────────────────────────────────────────────
|
|
18
18
|
const WEIGHTS = {
|
|
19
19
|
security: 0.25,
|
|
20
|
-
integrity: 0.
|
|
21
|
-
debt: 0.
|
|
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,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,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
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[];
|