@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.
@@ -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,2 @@
1
+ import type { CheckResult } from '../types.js';
2
+ export declare function checkDeep(cwd: string): Promise<CheckResult>;