@safetnsr/vet 1.19.1 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checks/architecture.js +139 -1
- package/dist/checks/clones.d.ts +2 -0
- package/dist/checks/clones.js +172 -0
- package/dist/checks/deep.js +138 -6
- package/dist/cli.js +4 -2
- package/package.json +1 -1
|
@@ -109,6 +109,116 @@ function findCycles(moduleGraph) {
|
|
|
109
109
|
return true;
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
|
+
function louvainCommunities(graph) {
|
|
113
|
+
const nodes = Array.from(graph.keys());
|
|
114
|
+
if (nodes.length < 2) {
|
|
115
|
+
return { modularity: 0, communities: new Map(), communityCount: 0 };
|
|
116
|
+
}
|
|
117
|
+
// Build undirected weighted adjacency (edge count = weight)
|
|
118
|
+
const adj = new Map();
|
|
119
|
+
for (const n of nodes) {
|
|
120
|
+
adj.set(n, new Map());
|
|
121
|
+
}
|
|
122
|
+
let totalEdges = 0;
|
|
123
|
+
for (const [from, deps] of graph) {
|
|
124
|
+
for (const to of deps) {
|
|
125
|
+
if (!adj.has(to)) {
|
|
126
|
+
adj.set(to, new Map());
|
|
127
|
+
}
|
|
128
|
+
// Undirected: add both directions
|
|
129
|
+
const w = (adj.get(from).get(to) || 0) + 1;
|
|
130
|
+
adj.get(from).set(to, w);
|
|
131
|
+
adj.get(to).set(from, w);
|
|
132
|
+
totalEdges++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (totalEdges === 0) {
|
|
136
|
+
return { modularity: 0, communities: new Map(), communityCount: 0 };
|
|
137
|
+
}
|
|
138
|
+
const m = totalEdges; // total edge weight
|
|
139
|
+
const m2 = 2 * m;
|
|
140
|
+
// Degree of each node (sum of edge weights)
|
|
141
|
+
const degree = new Map();
|
|
142
|
+
for (const [n, neighbors] of adj) {
|
|
143
|
+
let d = 0;
|
|
144
|
+
for (const w of neighbors.values())
|
|
145
|
+
d += w;
|
|
146
|
+
degree.set(n, d);
|
|
147
|
+
}
|
|
148
|
+
// Initialize: each node in its own community
|
|
149
|
+
const community = new Map();
|
|
150
|
+
nodes.forEach((n, i) => community.set(n, i));
|
|
151
|
+
// Community totals: sum of degrees in each community
|
|
152
|
+
const commDegree = new Map();
|
|
153
|
+
const commInternal = new Map(); // sum of internal edges × 2
|
|
154
|
+
for (const [n, c] of community) {
|
|
155
|
+
commDegree.set(c, degree.get(n) || 0);
|
|
156
|
+
commInternal.set(c, 0);
|
|
157
|
+
}
|
|
158
|
+
// Iterative optimization (1 pass — good enough for code graphs)
|
|
159
|
+
let moved = true;
|
|
160
|
+
let iterations = 0;
|
|
161
|
+
while (moved && iterations < 10) {
|
|
162
|
+
moved = false;
|
|
163
|
+
iterations++;
|
|
164
|
+
for (const node of nodes) {
|
|
165
|
+
const nodeDeg = degree.get(node) || 0;
|
|
166
|
+
const currentComm = community.get(node);
|
|
167
|
+
// Calculate edges to each neighboring community
|
|
168
|
+
const commEdges = new Map();
|
|
169
|
+
const neighbors = adj.get(node) || new Map();
|
|
170
|
+
for (const [neighbor, weight] of neighbors) {
|
|
171
|
+
const nc = community.get(neighbor);
|
|
172
|
+
if (nc !== undefined) {
|
|
173
|
+
commEdges.set(nc, (commEdges.get(nc) || 0) + weight);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Modularity gain for moving node to community c:
|
|
177
|
+
// ΔQ = [Σin + 2*ki,in] / 2m - [(Σtot + ki) / 2m]² - [Σin/2m - (Σtot/2m)² - (ki/2m)²]
|
|
178
|
+
let bestComm = currentComm;
|
|
179
|
+
let bestGain = 0;
|
|
180
|
+
// Remove node from current community
|
|
181
|
+
const edgesToCurrent = commEdges.get(currentComm) || 0;
|
|
182
|
+
for (const [targetComm, edgesToTarget] of commEdges) {
|
|
183
|
+
if (targetComm === currentComm)
|
|
184
|
+
continue;
|
|
185
|
+
const sigmaTot = commDegree.get(targetComm) || 0;
|
|
186
|
+
const sigmaIn = commInternal.get(targetComm) || 0;
|
|
187
|
+
// Simplified modularity gain
|
|
188
|
+
const gain = (edgesToTarget / m) - (sigmaTot * nodeDeg) / (m2 * m);
|
|
189
|
+
if (gain > bestGain) {
|
|
190
|
+
bestGain = gain;
|
|
191
|
+
bestComm = targetComm;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (bestComm !== currentComm) {
|
|
195
|
+
// Move node
|
|
196
|
+
commDegree.set(currentComm, (commDegree.get(currentComm) || 0) - nodeDeg);
|
|
197
|
+
commInternal.set(currentComm, (commInternal.get(currentComm) || 0) - 2 * edgesToCurrent);
|
|
198
|
+
commDegree.set(bestComm, (commDegree.get(bestComm) || 0) + nodeDeg);
|
|
199
|
+
const edgesToBest = commEdges.get(bestComm) || 0;
|
|
200
|
+
commInternal.set(bestComm, (commInternal.get(bestComm) || 0) + 2 * edgesToBest);
|
|
201
|
+
community.set(node, bestComm);
|
|
202
|
+
moved = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Calculate final modularity Q
|
|
207
|
+
let Q = 0;
|
|
208
|
+
for (const [n1, neighbors] of adj) {
|
|
209
|
+
for (const [n2, w] of neighbors) {
|
|
210
|
+
if (community.get(n1) === community.get(n2)) {
|
|
211
|
+
const d1 = degree.get(n1) || 0;
|
|
212
|
+
const d2 = degree.get(n2) || 0;
|
|
213
|
+
Q += w - (d1 * d2) / m2;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
Q /= m2;
|
|
218
|
+
// Count unique communities
|
|
219
|
+
const uniqueComms = new Set(community.values());
|
|
220
|
+
return { modularity: Q, communities: community, communityCount: uniqueComms.size };
|
|
221
|
+
}
|
|
112
222
|
// ── Main check ───────────────────────────────────────────────────────────────
|
|
113
223
|
export function checkArchitecture(cwd) {
|
|
114
224
|
const allFiles = walkFiles(cwd);
|
|
@@ -258,6 +368,28 @@ export function checkArchitecture(cwd) {
|
|
|
258
368
|
fixHint: 'use barrel exports (index.ts) to define module boundaries',
|
|
259
369
|
});
|
|
260
370
|
}
|
|
371
|
+
// ── Louvain modularity ─────────────────────────────────────────────────
|
|
372
|
+
const louvain = louvainCommunities(moduleGraph);
|
|
373
|
+
if (moduleGraph.size >= 3) {
|
|
374
|
+
if (louvain.modularity < 0.3 && louvain.communityCount > 1) {
|
|
375
|
+
issues.push({
|
|
376
|
+
severity: 'warning',
|
|
377
|
+
message: `low modularity: Q=${louvain.modularity.toFixed(2)} (${louvain.communityCount} communities detected) — modules are too interconnected`,
|
|
378
|
+
file: '',
|
|
379
|
+
fixable: true,
|
|
380
|
+
fixHint: 'reduce cross-module dependencies, group related files into cohesive modules',
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
else if (louvain.communityCount <= 1 && moduleGraph.size > 5) {
|
|
384
|
+
issues.push({
|
|
385
|
+
severity: 'info',
|
|
386
|
+
message: `monolithic structure: all ${moduleGraph.size} modules form a single community — no clear architectural boundaries`,
|
|
387
|
+
file: '',
|
|
388
|
+
fixable: true,
|
|
389
|
+
fixHint: 'introduce module boundaries with clear interfaces (barrel exports)',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
261
393
|
// ── Scoring ───────────────────────────────────────────────────────────────
|
|
262
394
|
const moduleCount = moduleGraph.size;
|
|
263
395
|
const sizeScale = moduleCount <= 5 ? 1.0 : Math.max(0.3, 1.0 - Math.log10(moduleCount / 5) * 0.3);
|
|
@@ -266,11 +398,17 @@ export function checkArchitecture(cwd) {
|
|
|
266
398
|
score -= Math.min(20, godFiles.length * 10) * sizeScale;
|
|
267
399
|
score -= Math.min(15, Array.from(instability.values()).filter(i => i > 0.8).length * 5) * sizeScale;
|
|
268
400
|
score -= Math.min(10, Math.floor(boundaryViolations / 3) * 3) * sizeScale;
|
|
401
|
+
// Modularity penalty: low Q on non-trivial graphs
|
|
402
|
+
if (moduleGraph.size >= 5 && louvain.modularity < 0.3) {
|
|
403
|
+
score -= Math.min(15, Math.round((0.3 - louvain.modularity) * 50));
|
|
404
|
+
}
|
|
269
405
|
score = Math.max(25, Math.round(score));
|
|
270
406
|
// ── Summary ───────────────────────────────────────────────────────────────
|
|
271
407
|
const parts = [];
|
|
272
408
|
parts.push(`${moduleGraph.size} modules`);
|
|
273
|
-
parts.push(`${edges.length}
|
|
409
|
+
parts.push(`${edges.length} edges`);
|
|
410
|
+
if (louvain.communityCount > 0)
|
|
411
|
+
parts.push(`Q=${louvain.modularity.toFixed(2)} (${louvain.communityCount} communities)`);
|
|
274
412
|
if (cycles.length > 0)
|
|
275
413
|
parts.push(c.red + `${cycles.length} circular dep${cycles.length !== 1 ? 's' : ''}` + c.reset);
|
|
276
414
|
if (godFiles.length > 0)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { walkFiles, readFile, c } from '../util.js';
|
|
4
|
+
const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
5
|
+
function isSourceFile(f) {
|
|
6
|
+
const dot = f.lastIndexOf('.');
|
|
7
|
+
return dot !== -1 && SOURCE_EXTS.has(f.substring(dot));
|
|
8
|
+
}
|
|
9
|
+
function isTestFile(f) {
|
|
10
|
+
return /\.(test|spec)\.[jt]sx?$/.test(f) || f.includes('__tests__') || /(?:^|[/\\])tests?[/\\]/.test(f);
|
|
11
|
+
}
|
|
12
|
+
function isExampleFile(f) {
|
|
13
|
+
return /(?:^|[/\\])(?:examples?|templates?|fixtures?|demos?)[/\\]/.test(f);
|
|
14
|
+
}
|
|
15
|
+
// ── Token normalization ─────────────────────────────────────────────────────
|
|
16
|
+
// Strip comments, normalize whitespace, replace identifiers with placeholders
|
|
17
|
+
// This makes structurally identical code match even with different variable names
|
|
18
|
+
function normalizeTokens(code) {
|
|
19
|
+
// Remove single-line comments
|
|
20
|
+
let normalized = code.replace(/\/\/.*$/gm, '');
|
|
21
|
+
// Remove multi-line comments
|
|
22
|
+
normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
23
|
+
// Remove string literals (replace with placeholder)
|
|
24
|
+
normalized = normalized.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, '"S"');
|
|
25
|
+
// Normalize whitespace
|
|
26
|
+
normalized = normalized.replace(/\s+/g, ' ').trim();
|
|
27
|
+
// Normalize numbers (replace with placeholder)
|
|
28
|
+
normalized = normalized.replace(/\b\d+\.?\d*\b/g, '0');
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
const MIN_CHUNK_LINES = 6; // minimum lines for a clone to matter
|
|
32
|
+
function extractChunks(file, content, windowSize) {
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
const chunks = [];
|
|
35
|
+
for (let i = 0; i <= lines.length - windowSize; i++) {
|
|
36
|
+
const rawSlice = lines.slice(i, i + windowSize);
|
|
37
|
+
// Skip chunks that are mostly empty or imports
|
|
38
|
+
const meaningful = rawSlice.filter(l => {
|
|
39
|
+
const t = l.trim();
|
|
40
|
+
return t && !t.startsWith('import ') && !t.startsWith('export ') && t !== '{' && t !== '}' && t !== ');';
|
|
41
|
+
});
|
|
42
|
+
if (meaningful.length < windowSize * 0.5)
|
|
43
|
+
continue;
|
|
44
|
+
const normalized = normalizeTokens(rawSlice.join('\n'));
|
|
45
|
+
if (normalized.length < 40)
|
|
46
|
+
continue; // too short after normalization
|
|
47
|
+
const hash = createHash('md5').update(normalized).digest('hex');
|
|
48
|
+
chunks.push({
|
|
49
|
+
file,
|
|
50
|
+
startLine: i + 1,
|
|
51
|
+
endLine: i + windowSize,
|
|
52
|
+
hash,
|
|
53
|
+
raw: rawSlice.join('\n'),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return chunks;
|
|
57
|
+
}
|
|
58
|
+
export async function checkClones(cwd) {
|
|
59
|
+
const allFiles = walkFiles(cwd);
|
|
60
|
+
const sourceFiles = allFiles.filter(f => isSourceFile(f) && !isTestFile(f) && !isExampleFile(f));
|
|
61
|
+
if (sourceFiles.length < 2) {
|
|
62
|
+
return { name: 'clones', score: 100, maxScore: 100, summary: 'too few files', issues: [] };
|
|
63
|
+
}
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
const issues = [];
|
|
66
|
+
// Single window size — use the largest to reduce noise
|
|
67
|
+
const WINDOW_SIZE = 10;
|
|
68
|
+
const hashMap = new Map();
|
|
69
|
+
for (const file of sourceFiles) {
|
|
70
|
+
const content = readFile(join(cwd, file));
|
|
71
|
+
if (!content)
|
|
72
|
+
continue;
|
|
73
|
+
const chunks = extractChunks(file, content, WINDOW_SIZE);
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
if (!hashMap.has(chunk.hash))
|
|
76
|
+
hashMap.set(chunk.hash, []);
|
|
77
|
+
hashMap.get(chunk.hash).push(chunk);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Find cross-file duplicates, pick ONE representative per file per clone
|
|
81
|
+
const allCloneGroups = [];
|
|
82
|
+
for (const [hash, chunks] of hashMap) {
|
|
83
|
+
if (chunks.length < 2)
|
|
84
|
+
continue;
|
|
85
|
+
// Group by file, pick earliest occurrence per file
|
|
86
|
+
const byFile = new Map();
|
|
87
|
+
for (const chunk of chunks) {
|
|
88
|
+
const existing = byFile.get(chunk.file);
|
|
89
|
+
if (!existing || chunk.startLine < existing.startLine) {
|
|
90
|
+
byFile.set(chunk.file, chunk);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Cross-file only
|
|
94
|
+
if (byFile.size < 2)
|
|
95
|
+
continue;
|
|
96
|
+
const reps = [...byFile.values()];
|
|
97
|
+
allCloneGroups.push({
|
|
98
|
+
hash,
|
|
99
|
+
locations: reps.map(r => ({ file: r.file, startLine: r.startLine, endLine: r.endLine })),
|
|
100
|
+
lineCount: WINDOW_SIZE,
|
|
101
|
+
sample: reps[0].raw.slice(0, 200),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Deduplicate overlapping clones: group by file-set, merge overlapping line ranges
|
|
105
|
+
// Sort by number of files (more widespread clones first), then by earliest line
|
|
106
|
+
allCloneGroups.sort((a, b) => b.locations.length - a.locations.length || a.locations[0].startLine - b.locations[0].startLine);
|
|
107
|
+
const coveredRanges = new Map();
|
|
108
|
+
const filteredGroups = [];
|
|
109
|
+
for (const group of allCloneGroups) {
|
|
110
|
+
// Check if the first location is already substantially covered
|
|
111
|
+
const firstLoc = group.locations[0];
|
|
112
|
+
const covered = coveredRanges.get(firstLoc.file);
|
|
113
|
+
if (covered) {
|
|
114
|
+
let overlapCount = 0;
|
|
115
|
+
for (let line = firstLoc.startLine; line <= firstLoc.endLine; line++) {
|
|
116
|
+
if (covered.has(line))
|
|
117
|
+
overlapCount++;
|
|
118
|
+
}
|
|
119
|
+
// Skip if >50% of lines already reported
|
|
120
|
+
if (overlapCount > group.lineCount * 0.5)
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
filteredGroups.push(group);
|
|
124
|
+
// Mark all locations as covered
|
|
125
|
+
for (const loc of group.locations) {
|
|
126
|
+
if (!coveredRanges.has(loc.file))
|
|
127
|
+
coveredRanges.set(loc.file, new Set());
|
|
128
|
+
const set = coveredRanges.get(loc.file);
|
|
129
|
+
for (let line = loc.startLine; line <= loc.endLine; line++) {
|
|
130
|
+
set.add(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Report top clones
|
|
135
|
+
const topClones = filteredGroups.slice(0, 10);
|
|
136
|
+
for (const clone of topClones) {
|
|
137
|
+
const locs = clone.locations.slice(0, 3);
|
|
138
|
+
const locStr = locs.map(l => `${l.file}:${l.startLine}`).join(', ');
|
|
139
|
+
issues.push({
|
|
140
|
+
severity: clone.lineCount >= 15 ? 'warning' : 'info',
|
|
141
|
+
message: `duplicated ${clone.lineCount}-line block across ${clone.locations.length} files: ${locStr}`,
|
|
142
|
+
file: clone.locations[0].file,
|
|
143
|
+
line: clone.locations[0].startLine,
|
|
144
|
+
fixable: true,
|
|
145
|
+
fixHint: 'extract into a shared function or module',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const elapsed = Date.now() - t0;
|
|
149
|
+
// ── Scoring ───────────────────────────────────────────────────────────────
|
|
150
|
+
// Total duplicated lines as % of codebase
|
|
151
|
+
const totalSourceLines = sourceFiles.reduce((sum, f) => {
|
|
152
|
+
const content = readFile(join(cwd, f));
|
|
153
|
+
return sum + (content ? content.split('\n').length : 0);
|
|
154
|
+
}, 0);
|
|
155
|
+
// Count unique duplicated lines from covered ranges
|
|
156
|
+
let duplicatedLines = 0;
|
|
157
|
+
for (const lines of coveredRanges.values()) {
|
|
158
|
+
duplicatedLines += lines.size;
|
|
159
|
+
}
|
|
160
|
+
const duplicationRate = totalSourceLines > 0 ? duplicatedLines / totalSourceLines : 0;
|
|
161
|
+
const score = Math.max(25, Math.round(100 - duplicationRate * 400));
|
|
162
|
+
const parts = [];
|
|
163
|
+
parts.push(`${sourceFiles.length} files scanned in ${elapsed}ms`);
|
|
164
|
+
if (filteredGroups.length > 0) {
|
|
165
|
+
parts.push(c.yellow + `${filteredGroups.length} clone groups` + c.reset);
|
|
166
|
+
parts.push(`${duplicatedLines} duplicated lines (${(duplicationRate * 100).toFixed(1)}%)`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
parts.push('no cross-file clones detected');
|
|
170
|
+
}
|
|
171
|
+
return { name: 'clones', score, maxScore: 100, summary: parts.join(', '), issues };
|
|
172
|
+
}
|
package/dist/checks/deep.js
CHANGED
|
@@ -110,6 +110,98 @@ function analyzeFunction(node, file, src) {
|
|
|
110
110
|
cognitiveComplexity: cognitive,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
// ── Halstead metrics + Maintainability Index ────────────────────────────────
|
|
114
|
+
const OPERATOR_KINDS = new Set([
|
|
115
|
+
ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken, ts.SyntaxKind.AsteriskToken,
|
|
116
|
+
ts.SyntaxKind.SlashToken, ts.SyntaxKind.PercentToken, ts.SyntaxKind.EqualsToken,
|
|
117
|
+
ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken,
|
|
118
|
+
ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.SlashEqualsToken,
|
|
119
|
+
ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
120
|
+
ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
121
|
+
ts.SyntaxKind.LessThanToken, ts.SyntaxKind.GreaterThanToken,
|
|
122
|
+
ts.SyntaxKind.LessThanEqualsToken, ts.SyntaxKind.GreaterThanEqualsToken,
|
|
123
|
+
ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken,
|
|
124
|
+
ts.SyntaxKind.ExclamationToken, ts.SyntaxKind.QuestionQuestionToken,
|
|
125
|
+
ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken,
|
|
126
|
+
ts.SyntaxKind.AmpersandToken, ts.SyntaxKind.BarToken,
|
|
127
|
+
ts.SyntaxKind.CaretToken, ts.SyntaxKind.TildeToken,
|
|
128
|
+
ts.SyntaxKind.DotDotDotToken,
|
|
129
|
+
]);
|
|
130
|
+
const KEYWORD_OPERATOR_KINDS = new Set([
|
|
131
|
+
ts.SyntaxKind.IfStatement, ts.SyntaxKind.ElseKeyword,
|
|
132
|
+
ts.SyntaxKind.ForStatement, ts.SyntaxKind.ForInStatement, ts.SyntaxKind.ForOfStatement,
|
|
133
|
+
ts.SyntaxKind.WhileStatement, ts.SyntaxKind.DoStatement,
|
|
134
|
+
ts.SyntaxKind.SwitchStatement, ts.SyntaxKind.CaseClause,
|
|
135
|
+
ts.SyntaxKind.ReturnStatement, ts.SyntaxKind.ThrowStatement,
|
|
136
|
+
ts.SyntaxKind.TryStatement, ts.SyntaxKind.CatchClause,
|
|
137
|
+
ts.SyntaxKind.NewExpression, ts.SyntaxKind.DeleteExpression,
|
|
138
|
+
ts.SyntaxKind.TypeOfExpression, ts.SyntaxKind.VoidExpression,
|
|
139
|
+
ts.SyntaxKind.AwaitExpression, ts.SyntaxKind.YieldExpression,
|
|
140
|
+
]);
|
|
141
|
+
function computeHalstead(src) {
|
|
142
|
+
const operators = new Map();
|
|
143
|
+
const operands = new Map();
|
|
144
|
+
function countToken(map, key) {
|
|
145
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
function walk(node) {
|
|
148
|
+
// Operators: binary/unary/assignment operators + keyword operators
|
|
149
|
+
if (ts.isBinaryExpression(node)) {
|
|
150
|
+
countToken(operators, ts.SyntaxKind[node.operatorToken.kind]);
|
|
151
|
+
}
|
|
152
|
+
else if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
|
|
153
|
+
countToken(operators, ts.SyntaxKind[node.operator]);
|
|
154
|
+
}
|
|
155
|
+
else if (KEYWORD_OPERATOR_KINDS.has(node.kind)) {
|
|
156
|
+
countToken(operators, ts.SyntaxKind[node.kind]);
|
|
157
|
+
}
|
|
158
|
+
else if (ts.isCallExpression(node)) {
|
|
159
|
+
countToken(operators, 'Call');
|
|
160
|
+
}
|
|
161
|
+
else if (ts.isPropertyAccessExpression(node)) {
|
|
162
|
+
countToken(operators, 'PropertyAccess');
|
|
163
|
+
}
|
|
164
|
+
// Operands: identifiers, literals
|
|
165
|
+
if (ts.isIdentifier(node)) {
|
|
166
|
+
countToken(operands, node.text);
|
|
167
|
+
}
|
|
168
|
+
else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
169
|
+
countToken(operands, `"${node.text.slice(0, 20)}"`);
|
|
170
|
+
}
|
|
171
|
+
else if (ts.isNumericLiteral(node)) {
|
|
172
|
+
countToken(operands, node.text);
|
|
173
|
+
}
|
|
174
|
+
ts.forEachChild(node, walk);
|
|
175
|
+
}
|
|
176
|
+
walk(src);
|
|
177
|
+
const n1 = operators.size;
|
|
178
|
+
const n2 = operands.size;
|
|
179
|
+
const N1 = Array.from(operators.values()).reduce((a, b) => a + b, 0);
|
|
180
|
+
const N2 = Array.from(operands.values()).reduce((a, b) => a + b, 0);
|
|
181
|
+
const n = n1 + n2;
|
|
182
|
+
const N = N1 + N2;
|
|
183
|
+
const volume = n > 0 ? N * Math.log2(n) : 0;
|
|
184
|
+
const difficulty = n2 > 0 ? (n1 / 2) * (N2 / n2) : 0;
|
|
185
|
+
const effort = difficulty * volume;
|
|
186
|
+
return {
|
|
187
|
+
operators: N1, operands: N2,
|
|
188
|
+
uniqueOperators: n1, uniqueOperands: n2,
|
|
189
|
+
vocabulary: n, length: N,
|
|
190
|
+
volume, difficulty, effort,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function computeMI(halsteadVolume, cyclomatic, sloc) {
|
|
194
|
+
// Standard Maintainability Index formula (SEI, 1992)
|
|
195
|
+
// MI = 171 - 5.2 × ln(V) - 0.23 × CC - 16.2 × ln(SLOC)
|
|
196
|
+
// Clamped to [0, 100]
|
|
197
|
+
if (halsteadVolume <= 0 || sloc <= 0)
|
|
198
|
+
return 100;
|
|
199
|
+
const mi = 171
|
|
200
|
+
- 5.2 * Math.log(halsteadVolume)
|
|
201
|
+
- 0.23 * cyclomatic
|
|
202
|
+
- 16.2 * Math.log(sloc);
|
|
203
|
+
return Math.max(0, Math.min(100, mi));
|
|
204
|
+
}
|
|
113
205
|
// ── Naming analysis (heuristic, no ML) ──────────────────────────────────────
|
|
114
206
|
function isDescriptiveName(name) {
|
|
115
207
|
if (name.startsWith('('))
|
|
@@ -130,6 +222,7 @@ export async function checkDeep(cwd) {
|
|
|
130
222
|
}
|
|
131
223
|
const issues = [];
|
|
132
224
|
const allMetrics = [];
|
|
225
|
+
const allFileMetrics = [];
|
|
133
226
|
const t0 = Date.now();
|
|
134
227
|
for (const file of sourceFiles) {
|
|
135
228
|
const content = readFile(join(cwd, file));
|
|
@@ -137,16 +230,25 @@ export async function checkDeep(cwd) {
|
|
|
137
230
|
continue;
|
|
138
231
|
try {
|
|
139
232
|
const src = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
|
|
233
|
+
// Per-function analysis
|
|
234
|
+
let fileCyclomatic = 1; // base complexity
|
|
140
235
|
function visit(node) {
|
|
141
236
|
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) ||
|
|
142
237
|
ts.isMethodDeclaration(node) || ts.isArrowFunction(node)) {
|
|
143
238
|
const metrics = analyzeFunction(node, file, src);
|
|
144
|
-
if (metrics)
|
|
239
|
+
if (metrics) {
|
|
145
240
|
allMetrics.push(metrics);
|
|
241
|
+
fileCyclomatic += metrics.cyclomatic - 1; // add function's complexity
|
|
242
|
+
}
|
|
146
243
|
}
|
|
147
244
|
ts.forEachChild(node, visit);
|
|
148
245
|
}
|
|
149
246
|
visit(src);
|
|
247
|
+
// Per-file: Halstead + MI
|
|
248
|
+
const halstead = computeHalstead(src);
|
|
249
|
+
const sloc = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//')).length;
|
|
250
|
+
const mi = computeMI(halstead.volume, fileCyclomatic, sloc);
|
|
251
|
+
allFileMetrics.push({ file, sloc, cyclomatic: fileCyclomatic, halstead, maintainabilityIndex: mi });
|
|
150
252
|
}
|
|
151
253
|
catch {
|
|
152
254
|
// Skip files that can't be parsed
|
|
@@ -243,6 +345,27 @@ export async function checkDeep(cwd) {
|
|
|
243
345
|
fixHint: 'use descriptive verb+noun function names (e.g., calculateTotal, validateUser)',
|
|
244
346
|
});
|
|
245
347
|
}
|
|
348
|
+
// ── Maintainability Index ──────────────────────────────────────────────────
|
|
349
|
+
const lowMI = allFileMetrics.filter(f => f.maintainabilityIndex < 20).sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
|
|
350
|
+
const mediumMI = allFileMetrics.filter(f => f.maintainabilityIndex >= 20 && f.maintainabilityIndex < 40);
|
|
351
|
+
for (const fm of lowMI.slice(0, 3)) {
|
|
352
|
+
issues.push({
|
|
353
|
+
severity: 'warning',
|
|
354
|
+
message: `low maintainability: ${fm.file} has MI=${fm.maintainabilityIndex.toFixed(0)} (halstead volume ${fm.halstead.volume.toFixed(0)}, CC ${fm.cyclomatic}, ${fm.sloc} SLOC)`,
|
|
355
|
+
file: fm.file,
|
|
356
|
+
fixable: true,
|
|
357
|
+
fixHint: 'reduce complexity and file size — split into smaller modules',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (mediumMI.length > 5) {
|
|
361
|
+
issues.push({
|
|
362
|
+
severity: 'info',
|
|
363
|
+
message: `${mediumMI.length} files with moderate maintainability (MI 20-40)`,
|
|
364
|
+
file: mediumMI[0].file,
|
|
365
|
+
fixable: true,
|
|
366
|
+
fixHint: 'consider refactoring the most complex files',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
246
369
|
// ── Scoring ───────────────────────────────────────────────────────────────
|
|
247
370
|
const total = allMetrics.length;
|
|
248
371
|
// Complexity score: % of functions below threshold
|
|
@@ -257,13 +380,22 @@ export async function checkDeep(cwd) {
|
|
|
257
380
|
// Naming score
|
|
258
381
|
const namingOk = allMetrics.filter(f => isDescriptiveName(f.name) === 'good').length;
|
|
259
382
|
const namingScore = Math.round((namingOk / total) * 100);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
383
|
+
// Maintainability Index score: average MI across files, normalized to 0-100
|
|
384
|
+
const avgMI = allFileMetrics.length > 0
|
|
385
|
+
? allFileMetrics.reduce((sum, f) => sum + f.maintainabilityIndex, 0) / allFileMetrics.length
|
|
386
|
+
: 100;
|
|
387
|
+
const miScore = Math.round(avgMI);
|
|
388
|
+
const score = Math.max(25, Math.round(complexityScore * 0.25 +
|
|
389
|
+
nestingScore * 0.20 +
|
|
390
|
+
errorScore * 0.20 +
|
|
391
|
+
namingScore * 0.10 +
|
|
392
|
+
miScore * 0.25));
|
|
264
393
|
// ── Summary ───────────────────────────────────────────────────────────────
|
|
265
394
|
const parts = [];
|
|
266
|
-
parts.push(`${total} functions
|
|
395
|
+
parts.push(`${total} functions, ${allFileMetrics.length} files in ${elapsed}ms`);
|
|
396
|
+
parts.push(`avg MI=${avgMI.toFixed(0)}`);
|
|
397
|
+
if (lowMI.length > 0)
|
|
398
|
+
parts.push(c.red + `${lowMI.length} unmaintainable` + c.reset);
|
|
267
399
|
if (highComplexity.length > 0)
|
|
268
400
|
parts.push(c.yellow + `${highComplexity.length} complex` + c.reset);
|
|
269
401
|
if (deeplyNested.length > 0)
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { checkAIReady } from './checks/aiready.js';
|
|
|
18
18
|
import { checkDeep } from './checks/deep.js';
|
|
19
19
|
import { checkSemantic } from './checks/semantic.js';
|
|
20
20
|
import { checkHotspots } from './checks/hotspots.js';
|
|
21
|
+
import { checkClones } from './checks/clones.js';
|
|
21
22
|
import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
|
|
22
23
|
import { checkMemory } from './checks/memory.js';
|
|
23
24
|
import { checkVerify } from './checks/verify.js';
|
|
@@ -326,7 +327,7 @@ async function runChecks() {
|
|
|
326
327
|
}
|
|
327
328
|
}
|
|
328
329
|
// Run ALL independent checks in parallel
|
|
329
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult,] = await Promise.all([
|
|
330
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult,] = await Promise.all([
|
|
330
331
|
withTimeout('scan', () => checkScan(cwd)),
|
|
331
332
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
332
333
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -353,6 +354,7 @@ async function runChecks() {
|
|
|
353
354
|
withTimeout('deep', () => checkDeep(cwd), 60_000),
|
|
354
355
|
withTimeout('semantic', () => checkSemantic(cwd), 60_000),
|
|
355
356
|
withTimeout('hotspots', () => checkHotspots(cwd), 30_000),
|
|
357
|
+
withTimeout('clones', () => checkClones(cwd), 60_000),
|
|
356
358
|
]);
|
|
357
359
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
358
360
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -364,7 +366,7 @@ async function runChecks() {
|
|
|
364
366
|
return score(cwd, {
|
|
365
367
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult, guardResult],
|
|
366
368
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, explainResult],
|
|
367
|
-
debt: [readyResult, historyResult, debtResult, bloatResult],
|
|
369
|
+
debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult],
|
|
368
370
|
deps: [depsResult],
|
|
369
371
|
architecture: [architectureResult],
|
|
370
372
|
aiready: [aireadyResult, deepResult, semanticResult],
|