@safetnsr/vet 1.17.1 → 1.19.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/deep.d.ts +2 -0
- package/dist/checks/deep.js +276 -0
- package/dist/checks/explain.d.ts +15 -0
- package/dist/checks/explain.js +208 -0
- package/dist/checks/semantic.d.ts +2 -0
- package/dist/checks/semantic.js +181 -0
- package/dist/cli.js +30 -3
- package/dist/scorer.d.ts +2 -0
- package/dist/types.d.ts +1 -1
- package/package.json +5 -2
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
|
+
}
|