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.
@@ -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
+ }