@safetnsr/vet 1.19.2 → 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.
@@ -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} import edges`);
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)
@@ -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
- const score = Math.max(25, Math.round(complexityScore * 0.35 +
261
- nestingScore * 0.25 +
262
- errorScore * 0.25 +
263
- namingScore * 0.15));
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 analyzed in ${elapsed}ms`);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.19.2",
3
+ "version": "1.20.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {