tina4-nodejs 3.10.32 → 3.10.38
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/package.json +1 -1
- package/packages/cli/src/bin.ts +7 -26
- package/packages/cli/src/commands/serve.ts +2 -1
- package/packages/core/src/ai.ts +241 -247
- package/packages/core/src/devAdmin.ts +339 -6
- package/packages/core/src/index.ts +3 -3
- package/packages/core/src/metrics.ts +800 -0
- package/packages/core/src/response.ts +98 -40
- package/packages/core/src/server.ts +3 -8
- package/packages/core/src/types.ts +2 -2
- package/packages/frond/src/engine.ts +17 -3
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
// Tina4 Code Metrics — regex-based static analysis for the dev dashboard.
|
|
2
|
+
/**
|
|
3
|
+
* Two-tier analysis:
|
|
4
|
+
* 1. Quick metrics (instant): LOC, file counts, class/function counts
|
|
5
|
+
* 2. Full analysis (on-demand, cached): cyclomatic complexity, maintainability
|
|
6
|
+
* index, coupling, Halstead metrics, violations
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies — uses only Node.js built-in modules.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as crypto from "node:crypto";
|
|
14
|
+
|
|
15
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function walkFiles(
|
|
18
|
+
dir: string,
|
|
19
|
+
extensions: string[],
|
|
20
|
+
exclude: string[] = ["node_modules", ".git", "dist", "build"]
|
|
21
|
+
): string[] {
|
|
22
|
+
const results: string[] = [];
|
|
23
|
+
if (!fs.existsSync(dir)) return results;
|
|
24
|
+
|
|
25
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const fullPath = path.join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
if (!exclude.includes(entry.name)) {
|
|
30
|
+
results.push(...walkFiles(fullPath, extensions, exclude));
|
|
31
|
+
}
|
|
32
|
+
} else if (entry.isFile()) {
|
|
33
|
+
const ext = path.extname(entry.name);
|
|
34
|
+
if (
|
|
35
|
+
extensions.includes(ext) &&
|
|
36
|
+
!entry.name.endsWith(".d.ts")
|
|
37
|
+
) {
|
|
38
|
+
results.push(fullPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readFileSafe(filePath: string): string | null {
|
|
46
|
+
try {
|
|
47
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function relativePath(filePath: string): string {
|
|
54
|
+
return path.relative(".", filePath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Line counting ────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface LineCounts {
|
|
60
|
+
loc: number;
|
|
61
|
+
blank: number;
|
|
62
|
+
comment: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function countLines(source: string): LineCounts {
|
|
66
|
+
const lines = source.split("\n");
|
|
67
|
+
let loc = 0;
|
|
68
|
+
let blank = 0;
|
|
69
|
+
let comment = 0;
|
|
70
|
+
let inBlockComment = false;
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const stripped = line.trim();
|
|
74
|
+
|
|
75
|
+
if (!stripped) {
|
|
76
|
+
blank++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (inBlockComment) {
|
|
81
|
+
comment++;
|
|
82
|
+
if (stripped.includes("*/")) {
|
|
83
|
+
inBlockComment = false;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (stripped.startsWith("/*")) {
|
|
89
|
+
comment++;
|
|
90
|
+
if (!stripped.includes("*/") || stripped.endsWith("/*")) {
|
|
91
|
+
inBlockComment = true;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (stripped.startsWith("//")) {
|
|
97
|
+
comment++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
loc++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { loc, blank, comment };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Class & function counting (quick) ────────────────────────
|
|
108
|
+
|
|
109
|
+
function countClassesQuick(source: string): number {
|
|
110
|
+
// Match class declarations: class Foo, export class Foo, abstract class Foo
|
|
111
|
+
const matches = source.match(
|
|
112
|
+
/(?:^|\n)\s*(?:export\s+)?(?:abstract\s+)?class\s+\w+/g
|
|
113
|
+
);
|
|
114
|
+
return matches ? matches.length : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function countFunctionsQuick(source: string): number {
|
|
118
|
+
let count = 0;
|
|
119
|
+
// function declarations: function foo(, async function foo(, export function foo(
|
|
120
|
+
const funcDecls = source.match(
|
|
121
|
+
/(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+\w+\s*\(/g
|
|
122
|
+
);
|
|
123
|
+
if (funcDecls) count += funcDecls.length;
|
|
124
|
+
|
|
125
|
+
// Method declarations inside classes: name(, async name(, static name(, get name(, set name(
|
|
126
|
+
const methods = source.match(
|
|
127
|
+
/(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\w+\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/g
|
|
128
|
+
);
|
|
129
|
+
if (methods) count += methods.length;
|
|
130
|
+
|
|
131
|
+
// Arrow functions assigned to const/let/var
|
|
132
|
+
const arrows = source.match(
|
|
133
|
+
/(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?\(/g
|
|
134
|
+
);
|
|
135
|
+
if (arrows) count += arrows.length;
|
|
136
|
+
|
|
137
|
+
return count;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Cyclomatic complexity ────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function cycloMaticComplexity(funcBody: string): number {
|
|
143
|
+
let cc = 1;
|
|
144
|
+
|
|
145
|
+
// Count decision points via regex
|
|
146
|
+
// if statements (not inside strings ideally, but regex-based is approximate)
|
|
147
|
+
const patterns: [RegExp, number][] = [
|
|
148
|
+
[/\bif\s*\(/g, 1],
|
|
149
|
+
[/\belse\s+if\s*\(/g, 1],
|
|
150
|
+
[/\bcase\s+/g, 1],
|
|
151
|
+
[/\bfor\s*\(/g, 1],
|
|
152
|
+
[/\bwhile\s*\(/g, 1],
|
|
153
|
+
[/\bdo\s*\{/g, 1],
|
|
154
|
+
[/\bcatch\s*\(/g, 1],
|
|
155
|
+
[/&&/g, 1],
|
|
156
|
+
[/\|\|/g, 1],
|
|
157
|
+
[/\?\?/g, 1],
|
|
158
|
+
// Ternary ? — but not ?. (optional chaining) and not ?: in type annotations
|
|
159
|
+
[/[^?]\?[^?.:\s]/g, 1],
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const [pattern, weight] of patterns) {
|
|
163
|
+
const matches = funcBody.match(pattern);
|
|
164
|
+
if (matches) cc += matches.length * weight;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return cc;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Function extraction (regex-based) ─────────────────────────
|
|
171
|
+
|
|
172
|
+
interface FunctionInfo {
|
|
173
|
+
name: string;
|
|
174
|
+
line: number;
|
|
175
|
+
complexity: number;
|
|
176
|
+
loc: number;
|
|
177
|
+
args: string[];
|
|
178
|
+
file?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extractFunctions(source: string, filePath: string): FunctionInfo[] {
|
|
182
|
+
const functions: FunctionInfo[] = [];
|
|
183
|
+
const lines = source.split("\n");
|
|
184
|
+
|
|
185
|
+
// Patterns to match function/method declarations
|
|
186
|
+
const patterns = [
|
|
187
|
+
// function name(args) or async function name(args)
|
|
188
|
+
/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
|
|
189
|
+
// Class method: name(args) { or async name(args) {
|
|
190
|
+
/(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/,
|
|
191
|
+
// Arrow: const name = (args) => or const name = async (args) =>
|
|
192
|
+
/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/,
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// Track which class we're in
|
|
196
|
+
let currentClass: string | null = null;
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
const line = lines[i];
|
|
200
|
+
const stripped = line.trim();
|
|
201
|
+
|
|
202
|
+
// Detect class entry
|
|
203
|
+
const classMatch = stripped.match(
|
|
204
|
+
/(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/
|
|
205
|
+
);
|
|
206
|
+
if (classMatch) {
|
|
207
|
+
currentClass = classMatch[1];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const pattern of patterns) {
|
|
211
|
+
const match = stripped.match(pattern);
|
|
212
|
+
if (match && match[1]) {
|
|
213
|
+
const funcName = match[1];
|
|
214
|
+
|
|
215
|
+
// Skip keywords that look like function calls
|
|
216
|
+
if (
|
|
217
|
+
["if", "for", "while", "switch", "catch", "return", "new", "class", "import", "export", "from", "constructor"].includes(funcName) &&
|
|
218
|
+
!stripped.includes("constructor")
|
|
219
|
+
) {
|
|
220
|
+
if (funcName !== "constructor") continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Handle constructor specifically
|
|
224
|
+
const displayName =
|
|
225
|
+
funcName === "constructor" && currentClass
|
|
226
|
+
? `${currentClass}.constructor`
|
|
227
|
+
: currentClass && !stripped.startsWith("function") &&
|
|
228
|
+
!stripped.startsWith("export function") &&
|
|
229
|
+
!stripped.startsWith("async function") &&
|
|
230
|
+
!stripped.startsWith("export async function") &&
|
|
231
|
+
!stripped.startsWith("const ") &&
|
|
232
|
+
!stripped.startsWith("let ") &&
|
|
233
|
+
!stripped.startsWith("var ") &&
|
|
234
|
+
!stripped.startsWith("export const ") &&
|
|
235
|
+
!stripped.startsWith("export let ")
|
|
236
|
+
? `${currentClass}.${funcName}`
|
|
237
|
+
: funcName;
|
|
238
|
+
|
|
239
|
+
// Extract function body by brace matching
|
|
240
|
+
const funcBody = extractFunctionBody(lines, i);
|
|
241
|
+
const funcLoc = funcBody.split("\n").length;
|
|
242
|
+
const complexity = cycloMaticComplexity(funcBody);
|
|
243
|
+
|
|
244
|
+
// Parse args
|
|
245
|
+
const argsStr = match[2] || "";
|
|
246
|
+
const args = argsStr
|
|
247
|
+
.split(",")
|
|
248
|
+
.map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim())
|
|
249
|
+
.filter((a) => a && a !== "this");
|
|
250
|
+
|
|
251
|
+
functions.push({
|
|
252
|
+
name: displayName,
|
|
253
|
+
line: i + 1,
|
|
254
|
+
complexity,
|
|
255
|
+
loc: funcLoc,
|
|
256
|
+
args,
|
|
257
|
+
file: relativePath(filePath),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
break; // Only match first pattern per line
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Detect class exit (simple heuristic: closing brace at column 0)
|
|
265
|
+
if (
|
|
266
|
+
currentClass &&
|
|
267
|
+
stripped === "}" &&
|
|
268
|
+
line.match(/^\}/) // brace at start of line
|
|
269
|
+
) {
|
|
270
|
+
currentClass = null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return functions;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function extractFunctionBody(lines: string[], startLine: number): string {
|
|
278
|
+
let braceCount = 0;
|
|
279
|
+
let started = false;
|
|
280
|
+
const bodyLines: string[] = [];
|
|
281
|
+
|
|
282
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
283
|
+
const line = lines[i];
|
|
284
|
+
bodyLines.push(line);
|
|
285
|
+
|
|
286
|
+
for (const ch of line) {
|
|
287
|
+
if (ch === "{") {
|
|
288
|
+
braceCount++;
|
|
289
|
+
started = true;
|
|
290
|
+
} else if (ch === "}") {
|
|
291
|
+
braceCount--;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (started && braceCount <= 0) {
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Safety: limit to 1000 lines
|
|
300
|
+
if (bodyLines.length > 1000) break;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// For arrow functions without braces, just take the line
|
|
304
|
+
if (!started) {
|
|
305
|
+
return bodyLines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return bodyLines.join("\n");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Import extraction ────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
function extractImports(source: string): string[] {
|
|
314
|
+
const imports: string[] = [];
|
|
315
|
+
|
|
316
|
+
// import ... from "module"
|
|
317
|
+
const esImports = source.matchAll(
|
|
318
|
+
/import\s+(?:[\s\S]*?)\s+from\s+["']([^"']+)["']/g
|
|
319
|
+
);
|
|
320
|
+
for (const match of esImports) {
|
|
321
|
+
imports.push(match[1]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// import "module" (side-effect)
|
|
325
|
+
const sideEffects = source.matchAll(/import\s+["']([^"']+)["']/g);
|
|
326
|
+
for (const match of sideEffects) {
|
|
327
|
+
imports.push(match[1]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// require("module")
|
|
331
|
+
const requires = source.matchAll(/require\s*\(\s*["']([^"']+)["']\s*\)/g);
|
|
332
|
+
for (const match of requires) {
|
|
333
|
+
imports.push(match[1]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Deduplicate
|
|
337
|
+
return [...new Set(imports)];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Halstead metrics ─────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
interface HalsteadStats {
|
|
343
|
+
operators: number;
|
|
344
|
+
operands: number;
|
|
345
|
+
uniqueOperators: Set<string>;
|
|
346
|
+
uniqueOperands: Set<string>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function countHalstead(source: string): HalsteadStats {
|
|
350
|
+
const stats: HalsteadStats = {
|
|
351
|
+
operators: 0,
|
|
352
|
+
operands: 0,
|
|
353
|
+
uniqueOperators: new Set(),
|
|
354
|
+
uniqueOperands: new Set(),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Operators
|
|
358
|
+
const operatorPatterns = [
|
|
359
|
+
/[+\-*/%]=?/g,
|
|
360
|
+
/[<>!=]=?=?/g,
|
|
361
|
+
/&&/g,
|
|
362
|
+
/\|\|/g,
|
|
363
|
+
/\?\?/g,
|
|
364
|
+
/\.\.\./g,
|
|
365
|
+
/\b(typeof|instanceof|void|delete|in|of|new|yield|await)\b/g,
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
for (const pat of operatorPatterns) {
|
|
369
|
+
const matches = source.match(pat);
|
|
370
|
+
if (matches) {
|
|
371
|
+
for (const m of matches) {
|
|
372
|
+
stats.operators++;
|
|
373
|
+
stats.uniqueOperators.add(m);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Operands: identifiers and literals
|
|
379
|
+
const identifiers = source.match(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g);
|
|
380
|
+
if (identifiers) {
|
|
381
|
+
const keywords = new Set([
|
|
382
|
+
"if", "else", "for", "while", "do", "switch", "case", "break",
|
|
383
|
+
"continue", "return", "function", "class", "const", "let", "var",
|
|
384
|
+
"import", "export", "from", "default", "try", "catch", "finally",
|
|
385
|
+
"throw", "new", "delete", "typeof", "instanceof", "void", "in",
|
|
386
|
+
"of", "async", "await", "yield", "this", "super", "true", "false",
|
|
387
|
+
"null", "undefined", "extends", "implements", "interface", "type",
|
|
388
|
+
"enum", "public", "private", "protected", "static", "abstract",
|
|
389
|
+
"readonly", "as", "is", "keyof", "infer", "never", "unknown",
|
|
390
|
+
"any", "string", "number", "boolean", "symbol", "bigint", "object",
|
|
391
|
+
]);
|
|
392
|
+
for (const id of identifiers) {
|
|
393
|
+
if (!keywords.has(id)) {
|
|
394
|
+
stats.operands++;
|
|
395
|
+
stats.uniqueOperands.add(id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Number literals
|
|
401
|
+
const numbers = source.match(/\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/g);
|
|
402
|
+
if (numbers) {
|
|
403
|
+
for (const n of numbers) {
|
|
404
|
+
stats.operands++;
|
|
405
|
+
stats.uniqueOperands.add(n);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// String literals
|
|
410
|
+
const strings = source.match(
|
|
411
|
+
/(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g
|
|
412
|
+
);
|
|
413
|
+
if (strings) {
|
|
414
|
+
for (const s of strings) {
|
|
415
|
+
stats.operands++;
|
|
416
|
+
stats.uniqueOperands.add(s.substring(0, 50));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return stats;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Maintainability Index ────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
function maintainabilityIndex(
|
|
426
|
+
halsteadVolume: number,
|
|
427
|
+
avgCC: number,
|
|
428
|
+
loc: number
|
|
429
|
+
): number {
|
|
430
|
+
if (loc <= 0) return 100.0;
|
|
431
|
+
const v = Math.max(halsteadVolume, 1);
|
|
432
|
+
const mi =
|
|
433
|
+
171 - 5.2 * Math.log(v) - 0.23 * avgCC - 16.2 * Math.log(loc);
|
|
434
|
+
return Math.max(0, Math.min(100, (mi * 100) / 171));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Violations ───────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
interface Violation {
|
|
440
|
+
type: "error" | "warning";
|
|
441
|
+
rule: string;
|
|
442
|
+
message: string;
|
|
443
|
+
file: string;
|
|
444
|
+
line: number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function detectViolations(
|
|
448
|
+
functions: FunctionInfo[],
|
|
449
|
+
fileMetrics: Record<string, any>[]
|
|
450
|
+
): Violation[] {
|
|
451
|
+
const violations: Violation[] = [];
|
|
452
|
+
|
|
453
|
+
for (const f of functions) {
|
|
454
|
+
if (f.complexity > 20) {
|
|
455
|
+
violations.push({
|
|
456
|
+
type: "error",
|
|
457
|
+
rule: "high_complexity",
|
|
458
|
+
message: `${f.name} has cyclomatic complexity ${f.complexity} (max 20)`,
|
|
459
|
+
file: f.file || "",
|
|
460
|
+
line: f.line,
|
|
461
|
+
});
|
|
462
|
+
} else if (f.complexity > 10) {
|
|
463
|
+
violations.push({
|
|
464
|
+
type: "warning",
|
|
465
|
+
rule: "moderate_complexity",
|
|
466
|
+
message: `${f.name} has cyclomatic complexity ${f.complexity} (recommended max 10)`,
|
|
467
|
+
file: f.file || "",
|
|
468
|
+
line: f.line,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
for (const fm of fileMetrics) {
|
|
474
|
+
if (fm.loc > 500) {
|
|
475
|
+
violations.push({
|
|
476
|
+
type: "warning",
|
|
477
|
+
rule: "large_file",
|
|
478
|
+
message: `${fm.path} has ${fm.loc} LOC (recommended max 500)`,
|
|
479
|
+
file: fm.path,
|
|
480
|
+
line: 1,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (fm.functions > 20) {
|
|
484
|
+
violations.push({
|
|
485
|
+
type: "warning",
|
|
486
|
+
rule: "too_many_functions",
|
|
487
|
+
message: `${fm.path} has ${fm.functions} functions (recommended max 20)`,
|
|
488
|
+
file: fm.path,
|
|
489
|
+
line: 1,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (fm.maintainability < 20) {
|
|
493
|
+
violations.push({
|
|
494
|
+
type: "error",
|
|
495
|
+
rule: "low_maintainability",
|
|
496
|
+
message: `${fm.path} has maintainability index ${fm.maintainability} (min 20)`,
|
|
497
|
+
file: fm.path,
|
|
498
|
+
line: 1,
|
|
499
|
+
});
|
|
500
|
+
} else if (fm.maintainability < 40) {
|
|
501
|
+
violations.push({
|
|
502
|
+
type: "warning",
|
|
503
|
+
rule: "moderate_maintainability",
|
|
504
|
+
message: `${fm.path} has maintainability index ${fm.maintainability} (recommended min 40)`,
|
|
505
|
+
file: fm.path,
|
|
506
|
+
line: 1,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
violations.sort((a, b) => {
|
|
512
|
+
const typeDiff = (a.type === "error" ? 0 : 1) - (b.type === "error" ? 0 : 1);
|
|
513
|
+
if (typeDiff !== 0) return typeDiff;
|
|
514
|
+
return a.file.localeCompare(b.file);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
return violations;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Quick Metrics ────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
export function quickMetrics(root: string = "src"): Record<string, any> {
|
|
523
|
+
const rootPath = path.resolve(root);
|
|
524
|
+
if (!fs.existsSync(rootPath)) {
|
|
525
|
+
return { error: `Directory not found: ${root}` };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const tsFiles = walkFiles(rootPath, [".ts", ".js"]);
|
|
529
|
+
const twigFiles = walkFiles(rootPath, [".twig", ".html"]);
|
|
530
|
+
|
|
531
|
+
const migrationsDir = path.resolve("migrations");
|
|
532
|
+
const migrationFiles = [
|
|
533
|
+
...walkFiles(migrationsDir, [".sql"]),
|
|
534
|
+
...walkFiles(migrationsDir, [".ts"]),
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
const scssFiles = walkFiles(rootPath, [".scss", ".css"]);
|
|
538
|
+
|
|
539
|
+
let totalLoc = 0;
|
|
540
|
+
let totalBlank = 0;
|
|
541
|
+
let totalComment = 0;
|
|
542
|
+
let totalClasses = 0;
|
|
543
|
+
let totalFunctions = 0;
|
|
544
|
+
const fileDetails: Record<string, any>[] = [];
|
|
545
|
+
|
|
546
|
+
for (const f of tsFiles) {
|
|
547
|
+
const source = readFileSafe(f);
|
|
548
|
+
if (source === null) continue;
|
|
549
|
+
|
|
550
|
+
const counts = countLines(source);
|
|
551
|
+
const classes = countClassesQuick(source);
|
|
552
|
+
const functions = countFunctionsQuick(source);
|
|
553
|
+
|
|
554
|
+
totalLoc += counts.loc;
|
|
555
|
+
totalBlank += counts.blank;
|
|
556
|
+
totalComment += counts.comment;
|
|
557
|
+
totalClasses += classes;
|
|
558
|
+
totalFunctions += functions;
|
|
559
|
+
|
|
560
|
+
fileDetails.push({
|
|
561
|
+
path: relativePath(f),
|
|
562
|
+
loc: counts.loc,
|
|
563
|
+
blank: counts.blank,
|
|
564
|
+
comment: counts.comment,
|
|
565
|
+
classes,
|
|
566
|
+
functions,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Sort by LOC descending
|
|
571
|
+
fileDetails.sort((a, b) => b.loc - a.loc);
|
|
572
|
+
|
|
573
|
+
// Route and ORM counts (scan for decorators/patterns)
|
|
574
|
+
let routeCount = 0;
|
|
575
|
+
let ormCount = 0;
|
|
576
|
+
|
|
577
|
+
for (const f of tsFiles) {
|
|
578
|
+
const source = readFileSafe(f);
|
|
579
|
+
if (source === null) continue;
|
|
580
|
+
|
|
581
|
+
// Count route registrations: router.get(, router.post(, @get(, @post(, etc.
|
|
582
|
+
const routes = source.match(
|
|
583
|
+
/(?:router\s*\.\s*(?:get|post|put|delete|patch|any)\s*\(|@(?:get|post|put|delete|patch)\s*\()/g
|
|
584
|
+
);
|
|
585
|
+
if (routes) routeCount += routes.length;
|
|
586
|
+
|
|
587
|
+
// Count ORM models: extends ORM, extends Model
|
|
588
|
+
const orms = source.match(
|
|
589
|
+
/class\s+\w+\s+extends\s+(?:ORM|Model)\b/g
|
|
590
|
+
);
|
|
591
|
+
if (orms) ormCount += orms.length;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const breakdown: Record<string, number> = {
|
|
595
|
+
typescript: tsFiles.filter((f) => f.endsWith(".ts")).length,
|
|
596
|
+
javascript: tsFiles.filter((f) => f.endsWith(".js")).length,
|
|
597
|
+
templates: twigFiles.length,
|
|
598
|
+
migrations: migrationFiles.length,
|
|
599
|
+
stylesheets: scssFiles.length,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
file_count: tsFiles.length,
|
|
604
|
+
total_loc: totalLoc,
|
|
605
|
+
total_blank: totalBlank,
|
|
606
|
+
total_comment: totalComment,
|
|
607
|
+
lloc: totalLoc,
|
|
608
|
+
classes: totalClasses,
|
|
609
|
+
functions: totalFunctions,
|
|
610
|
+
route_count: routeCount,
|
|
611
|
+
orm_count: ormCount,
|
|
612
|
+
template_count: twigFiles.length,
|
|
613
|
+
migration_count: migrationFiles.length,
|
|
614
|
+
avg_file_size: tsFiles.length > 0 ? Math.round((totalLoc / tsFiles.length) * 10) / 10 : 0,
|
|
615
|
+
largest_files: fileDetails.slice(0, 10),
|
|
616
|
+
breakdown,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ── Full Analysis (cached) ───────────────────────────────────
|
|
621
|
+
|
|
622
|
+
let _fullCache: { hash: string; data: Record<string, any> | null; time: number } = {
|
|
623
|
+
hash: "",
|
|
624
|
+
data: null,
|
|
625
|
+
time: 0,
|
|
626
|
+
};
|
|
627
|
+
const _CACHE_TTL = 60; // seconds
|
|
628
|
+
|
|
629
|
+
function filesHash(root: string = "src"): string {
|
|
630
|
+
const h = crypto.createHash("md5");
|
|
631
|
+
const rootPath = path.resolve(root);
|
|
632
|
+
if (fs.existsSync(rootPath)) {
|
|
633
|
+
const files = walkFiles(rootPath, [".ts", ".js"]).sort();
|
|
634
|
+
for (const f of files) {
|
|
635
|
+
try {
|
|
636
|
+
const stat = fs.statSync(f);
|
|
637
|
+
h.update(`${f}:${stat.mtimeMs}`);
|
|
638
|
+
} catch {
|
|
639
|
+
// skip
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return h.digest("hex");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
647
|
+
const currentHash = filesHash(root);
|
|
648
|
+
const now = Date.now() / 1000;
|
|
649
|
+
|
|
650
|
+
if (
|
|
651
|
+
_fullCache.hash === currentHash &&
|
|
652
|
+
_fullCache.data !== null &&
|
|
653
|
+
now - _fullCache.time < _CACHE_TTL
|
|
654
|
+
) {
|
|
655
|
+
return _fullCache.data;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const rootPath = path.resolve(root);
|
|
659
|
+
if (!fs.existsSync(rootPath)) {
|
|
660
|
+
return { error: `Directory not found: ${root}` };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const tsFiles = walkFiles(rootPath, [".ts", ".js"]);
|
|
664
|
+
|
|
665
|
+
const allFunctions: FunctionInfo[] = [];
|
|
666
|
+
const fileMetrics: Record<string, any>[] = [];
|
|
667
|
+
const importGraph: Record<string, string[]> = {};
|
|
668
|
+
const reverseGraph: Record<string, string[]> = {};
|
|
669
|
+
|
|
670
|
+
for (const f of tsFiles) {
|
|
671
|
+
const source = readFileSafe(f);
|
|
672
|
+
if (source === null) continue;
|
|
673
|
+
|
|
674
|
+
const relPath = relativePath(f);
|
|
675
|
+
const lines = source.split("\n");
|
|
676
|
+
const loc = lines.filter(
|
|
677
|
+
(l) => l.trim() && !l.trim().startsWith("//")
|
|
678
|
+
).length;
|
|
679
|
+
|
|
680
|
+
// Extract imports for coupling analysis
|
|
681
|
+
const imports = extractImports(source);
|
|
682
|
+
importGraph[relPath] = imports;
|
|
683
|
+
|
|
684
|
+
for (const imp of imports) {
|
|
685
|
+
if (!reverseGraph[imp]) {
|
|
686
|
+
reverseGraph[imp] = [];
|
|
687
|
+
}
|
|
688
|
+
reverseGraph[imp].push(relPath);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Analyze functions/methods
|
|
692
|
+
const fileFunctions = extractFunctions(source, f);
|
|
693
|
+
let fileComplexity = 0;
|
|
694
|
+
|
|
695
|
+
for (const func of fileFunctions) {
|
|
696
|
+
fileComplexity += func.complexity;
|
|
697
|
+
allFunctions.push(func);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Halstead
|
|
701
|
+
const halstead = countHalstead(source);
|
|
702
|
+
const n1 = halstead.uniqueOperators.size;
|
|
703
|
+
const n2 = halstead.uniqueOperands.size;
|
|
704
|
+
const N1 = halstead.operators;
|
|
705
|
+
const N2 = halstead.operands;
|
|
706
|
+
const vocabulary = n1 + n2;
|
|
707
|
+
const length = N1 + N2;
|
|
708
|
+
const volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0;
|
|
709
|
+
|
|
710
|
+
// Maintainability index
|
|
711
|
+
const avgCC =
|
|
712
|
+
fileFunctions.length > 0
|
|
713
|
+
? fileComplexity / fileFunctions.length
|
|
714
|
+
: 0;
|
|
715
|
+
const mi = maintainabilityIndex(volume, avgCC, loc);
|
|
716
|
+
|
|
717
|
+
// Coupling
|
|
718
|
+
const ce = imports.length; // efferent
|
|
719
|
+
const ca = (reverseGraph[relPath] || []).length; // afferent
|
|
720
|
+
const instability = ca + ce > 0 ? ce / (ca + ce) : 0.0;
|
|
721
|
+
|
|
722
|
+
fileMetrics.push({
|
|
723
|
+
path: relPath,
|
|
724
|
+
loc,
|
|
725
|
+
complexity: fileComplexity,
|
|
726
|
+
avg_complexity: Math.round(avgCC * 100) / 100,
|
|
727
|
+
functions: fileFunctions.length,
|
|
728
|
+
maintainability: Math.round(mi * 10) / 10,
|
|
729
|
+
halstead_volume: Math.round(volume * 10) / 10,
|
|
730
|
+
coupling_afferent: ca,
|
|
731
|
+
coupling_efferent: ce,
|
|
732
|
+
instability: Math.round(instability * 1000) / 1000,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Sort: functions by complexity descending, files by maintainability ascending (worst first)
|
|
737
|
+
allFunctions.sort((a, b) => b.complexity - a.complexity);
|
|
738
|
+
fileMetrics.sort((a, b) => a.maintainability - b.maintainability);
|
|
739
|
+
|
|
740
|
+
// Violations
|
|
741
|
+
const violations = detectViolations(allFunctions, fileMetrics);
|
|
742
|
+
|
|
743
|
+
// Overall averages
|
|
744
|
+
const totalCC = allFunctions.reduce((sum, f) => sum + f.complexity, 0);
|
|
745
|
+
const avgCC = allFunctions.length > 0 ? totalCC / allFunctions.length : 0;
|
|
746
|
+
const totalMI = fileMetrics.reduce((sum, f) => sum + f.maintainability, 0);
|
|
747
|
+
const avgMI = fileMetrics.length > 0 ? totalMI / fileMetrics.length : 0;
|
|
748
|
+
|
|
749
|
+
const result: Record<string, any> = {
|
|
750
|
+
files_analyzed: fileMetrics.length,
|
|
751
|
+
total_functions: allFunctions.length,
|
|
752
|
+
avg_complexity: Math.round(avgCC * 100) / 100,
|
|
753
|
+
avg_maintainability: Math.round(avgMI * 10) / 10,
|
|
754
|
+
most_complex_functions: allFunctions.slice(0, 15),
|
|
755
|
+
file_metrics: fileMetrics,
|
|
756
|
+
violations,
|
|
757
|
+
dependency_graph: importGraph,
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
_fullCache = { hash: currentHash, data: result, time: now };
|
|
761
|
+
return result;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ── File Detail ──────────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
export function fileDetail(filePath: string): Record<string, any> {
|
|
767
|
+
const resolved = path.resolve(filePath);
|
|
768
|
+
if (!fs.existsSync(resolved)) {
|
|
769
|
+
return { error: `File not found: ${filePath}` };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const source = readFileSafe(resolved);
|
|
773
|
+
if (source === null) {
|
|
774
|
+
return { error: `Could not read file: ${filePath}` };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const lines = source.split("\n");
|
|
778
|
+
const loc = lines.filter(
|
|
779
|
+
(l) => l.trim() && !l.trim().startsWith("//")
|
|
780
|
+
).length;
|
|
781
|
+
|
|
782
|
+
const classes = countClassesQuick(source);
|
|
783
|
+
const functions = extractFunctions(source, resolved);
|
|
784
|
+
const imports = extractImports(source);
|
|
785
|
+
|
|
786
|
+
// Sort functions by complexity descending
|
|
787
|
+
functions.sort((a, b) => b.complexity - a.complexity);
|
|
788
|
+
|
|
789
|
+
// Remove file field from function info for single-file detail
|
|
790
|
+
const cleanFunctions = functions.map(({ file, ...rest }) => rest);
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
path: filePath,
|
|
794
|
+
loc,
|
|
795
|
+
total_lines: lines.length,
|
|
796
|
+
classes,
|
|
797
|
+
functions: cleanFunctions,
|
|
798
|
+
imports,
|
|
799
|
+
};
|
|
800
|
+
}
|