tina4-nodejs 3.13.99 → 3.13.101

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.
@@ -1,689 +1,137 @@
1
- // Tina4 Code Metrics -- the native engine (ADR-0002) plus an instant file census.
2
- //
3
- // The regex-based analyzer that used to live here is gone. Everything except the
4
- // instant census now comes from `tina4 metrics --json`, so a number measured in
5
- // Node is comparable with the same number measured in Python, PHP or Ruby.
6
- // There is deliberately NO fallback: a second engine is exactly the condition
7
- // that made the four frameworks' numbers incomparable.
1
+ // Thin dev-admin adapter for the native `tina4 metrics` engine (ADR-0054).
8
2
 
9
3
  import * as fs from "node:fs";
10
4
  import * as path from "node:path";
11
- import * as crypto from "node:crypto";
12
5
  import { spawnSync } from "node:child_process";
13
6
  import { fileURLToPath } from "node:url";
14
7
 
15
- // -- Census helpers (kept verbatim: the census parses no code) ----------------
8
+ let lastScanRoot = "";
16
9
 
17
- // Where the census last resolved to, so fileDetail() can accept a path taken
18
- // straight out of file_metrics. Written by resolveRoot below.
19
- let _lastScanRoot = "";
20
-
21
-
22
- function walkFiles(
23
- dir: string,
24
- extensions: string[],
25
- exclude: string[] = ["node_modules", ".git", "dist", "build"]
26
- ): string[] {
27
- const results: string[] = [];
28
- if (!fs.existsSync(dir)) return results;
29
-
30
- const entries = fs.readdirSync(dir, { withFileTypes: true });
31
- for (const entry of entries) {
32
- const fullPath = path.join(dir, entry.name);
33
- if (entry.isDirectory()) {
34
- if (!exclude.includes(entry.name)) {
35
- results.push(...walkFiles(fullPath, extensions, exclude));
36
- }
37
- } else if (entry.isFile()) {
38
- const ext = path.extname(entry.name);
39
- if (
40
- extensions.includes(ext) &&
41
- !entry.name.endsWith(".d.ts")
42
- ) {
43
- results.push(fullPath);
44
- }
45
- }
46
- }
47
- return results;
48
- }
49
-
50
- function readFileSafe(filePath: string): string | null {
51
- try {
52
- return fs.readFileSync(filePath, "utf-8");
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- function relativePath(filePath: string, root: string = "."): string {
59
- return path.relative(root, filePath);
60
- }
61
-
62
- interface LineCounts {
63
- loc: number;
64
- blank: number;
65
- comment: number;
66
- }
67
-
68
- function countLines(source: string): LineCounts {
69
- const lines = source.split("\n");
70
- let loc = 0;
71
- let blank = 0;
72
- let comment = 0;
73
- let inBlockComment = false;
74
-
75
- for (const line of lines) {
76
- const stripped = line.trim();
77
-
78
- if (!stripped) {
79
- blank++;
80
- continue;
81
- }
82
-
83
- if (inBlockComment) {
84
- comment++;
85
- if (stripped.includes("*/")) {
86
- inBlockComment = false;
87
- }
88
- continue;
89
- }
90
-
91
- if (stripped.startsWith("/*")) {
92
- comment++;
93
- if (!stripped.includes("*/") || stripped.endsWith("/*")) {
94
- inBlockComment = true;
95
- }
96
- continue;
97
- }
98
-
99
- if (stripped.startsWith("//")) {
100
- comment++;
101
- continue;
102
- }
103
-
104
- loc++;
105
- }
106
-
107
- return { loc, blank, comment };
108
- }
109
-
110
- /**
111
- * Replace the CONTENTS of string literals, template literals (including
112
- * interpolations), regex literals, and both comment styles with neutral
113
- * placeholder characters (spaces), preserving newlines so line numbers and
114
- * structure stay intact. The surrounding delimiters are kept.
115
- *
116
- * This is the regex-based stand-in for Python's AST: the decision-point
117
- * patterns and the function-extraction patterns must only ever see real code,
118
- * never text that happens to live inside a string, template, regex, line
119
- * comment or block comment. Without this, a string full of boolean operators
120
- * or a regex inflates complexity and yields bogus "functions".
121
- *
122
- * Regex-vs-division is resolved conservatively: a slash only starts a regex
123
- * when the previous significant token can't end an expression (an operator,
124
- * keyword, open bracket, comma, semicolon, etc.). When in doubt we treat the
125
- * slash as division and DON'T strip — favouring "leave code intact" over
126
- * "wrongly blank out a division", per the brief.
127
- */
128
- function stripLiterals(source: string): string {
129
- const out: string[] = [];
130
- const n = source.length;
131
- let i = 0;
132
-
133
- // The last non-whitespace, non-comment character we EMITTED as real code —
134
- // used to decide whether a `/` opens a regex or is a division operator.
135
- let prevSignificant = "";
136
- // The last "word" token (identifier/keyword) emitted, for keyword checks.
137
- let prevWord = "";
138
-
139
- /** Keywords after which a `/` is a regex, not division. */
140
- const regexKeywords = new Set([
141
- "return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
142
- "throw", "case", "do", "else", "yield", "await",
143
- ]);
144
-
145
- /** Can the previous significant token end an expression? If so, `/` = division. */
146
- function prevEndsExpression(): boolean {
147
- if (prevSignificant === "") return false; // start of input → regex
148
- // Identifier/number ending char → could be a value → division …
149
- if (/[A-Za-z0-9_$]/.test(prevSignificant)) {
150
- // …unless it's a keyword like `return`/`case` that precedes a regex.
151
- return !regexKeywords.has(prevWord);
152
- }
153
- // Closing brackets and these chars end an expression → division.
154
- if (prevSignificant === ")" || prevSignificant === "]") return true;
155
- // `.` (member access) ends an expression-ish context → division ( `a./` is odd, treat as div).
156
- if (prevSignificant === ".") return true;
157
- // Everything else (operators, `(`, `,`, `{`, `[`, `;`, `:`, `=`, `<`, `>`, `&`,
158
- // `|`, `!`, `?`, `+`, `-`, `*`, `%`, `^`, `~`) → regex context.
159
- return false;
160
- }
161
-
162
- while (i < n) {
163
- const ch = source[i];
164
- const next = i + 1 < n ? source[i + 1] : "";
165
-
166
- // ── Line comment ──
167
- if (ch === "/" && next === "/") {
168
- out.push("//");
169
- i += 2;
170
- while (i < n && source[i] !== "\n") {
171
- out.push(" ");
172
- i++;
173
- }
174
- continue;
175
- }
176
-
177
- // ── Block comment ──
178
- if (ch === "/" && next === "*") {
179
- out.push("/*");
180
- i += 2;
181
- while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
182
- out.push(source[i] === "\n" ? "\n" : " ");
183
- i++;
184
- }
185
- if (i < n) {
186
- out.push("*/");
187
- i += 2;
188
- }
189
- continue;
190
- }
191
-
192
- // ── String literals ' " ──
193
- if (ch === '"' || ch === "'") {
194
- const quote = ch;
195
- out.push(quote);
196
- i++;
197
- while (i < n && source[i] !== quote) {
198
- if (source[i] === "\\" && i + 1 < n) {
199
- out.push(" "); // blank the escape pair, stay 2 chars wide
200
- i += 2;
201
- continue;
202
- }
203
- if (source[i] === "\n") {
204
- out.push("\n"); // unterminated string safety
205
- i++;
206
- break;
207
- }
208
- out.push(" ");
209
- i++;
210
- }
211
- if (i < n && source[i] === quote) {
212
- out.push(quote);
213
- i++;
214
- }
215
- prevSignificant = quote;
216
- prevWord = "";
217
- continue;
218
- }
219
-
220
- // ── Template literals ` ` (with ${ ... } interpolation, recursively code) ──
221
- if (ch === "`") {
222
- out.push("`");
223
- i++;
224
- while (i < n && source[i] !== "`") {
225
- if (source[i] === "\\" && i + 1 < n) {
226
- out.push(source[i + 1] === "\n" ? " \n" : " ");
227
- i += 2;
228
- continue;
229
- }
230
- // Interpolation: ${ ... } — the inside IS real code, recurse on it.
231
- if (source[i] === "$" && source[i + 1] === "{") {
232
- out.push("${");
233
- i += 2;
234
- let depth = 1;
235
- const exprStart = i;
236
- while (i < n && depth > 0) {
237
- if (source[i] === "{") depth++;
238
- else if (source[i] === "}") depth--;
239
- if (depth === 0) break;
240
- i++;
241
- }
242
- // Strip literals INSIDE the interpolation too (handles nested strings/regex).
243
- out.push(stripLiterals(source.slice(exprStart, i)));
244
- if (i < n && source[i] === "}") {
245
- out.push("}");
246
- i++;
247
- }
248
- continue;
249
- }
250
- out.push(source[i] === "\n" ? "\n" : " ");
251
- i++;
252
- }
253
- if (i < n && source[i] === "`") {
254
- out.push("`");
255
- i++;
256
- }
257
- prevSignificant = "`";
258
- prevWord = "";
259
- continue;
260
- }
261
-
262
- // ── Regex literal / vs division ──
263
- if (ch === "/" && !prevEndsExpression()) {
264
- // Scan a regex literal: /.../flags, honouring escapes and [...] classes.
265
- let j = i + 1;
266
- let ok = false;
267
- let inClass = false;
268
- while (j < n) {
269
- const c = source[j];
270
- if (c === "\\") {
271
- j += 2;
272
- continue;
273
- }
274
- if (c === "\n") break; // regex can't span a newline → not a regex
275
- if (c === "[") inClass = true;
276
- else if (c === "]") inClass = false;
277
- else if (c === "/" && !inClass) {
278
- ok = true;
279
- break;
280
- }
281
- j++;
282
- }
283
- if (ok) {
284
- out.push("/");
285
- for (let k = i + 1; k < j; k++) out.push(" ");
286
- out.push("/");
287
- i = j + 1;
288
- // consume flags
289
- while (i < n && /[a-z]/i.test(source[i])) {
290
- out.push(source[i]);
291
- i++;
292
- }
293
- prevSignificant = "/"; // a regex value ends an expression-ish slot
294
- prevWord = "";
295
- continue;
296
- }
297
- // Not a regex — fall through, emit `/` as division.
298
- }
299
-
300
- // ── Ordinary code char ──
301
- out.push(ch);
302
- if (!/\s/.test(ch)) {
303
- prevSignificant = ch;
304
- if (/[A-Za-z0-9_$]/.test(ch)) {
305
- prevWord = /[A-Za-z0-9_$]/.test(source[i - 1] ?? "") ? prevWord + ch : ch;
306
- } else {
307
- prevWord = "";
308
- }
309
- }
310
- i++;
311
- }
312
-
313
- return out.join("");
314
- }
315
-
316
- function countClassesQuick(source: string): number {
317
- // Match class declarations: class Foo, export class Foo, abstract class Foo
318
- const matches = source.match(
319
- /(?:^|\n)\s*(?:export\s+)?(?:abstract\s+)?class\s+\w+/g
320
- );
321
- return matches ? matches.length : 0;
322
- }
323
-
324
- function countFunctionsQuick(source: string): number {
325
- // Count on cleaned source so `something(...)` inside a string/regex/comment is
326
- // never mistaken for a method (the chief source of the old over-count).
327
- const clean = stripLiterals(source);
328
- let count = 0;
329
- // function declarations: function foo(, async function foo(, export function foo(
330
- const funcDecls = clean.match(
331
- /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+\w+\s*\(/g
332
- );
333
- if (funcDecls) count += funcDecls.length;
334
-
335
- // Method declarations inside classes: name(, async name(, static name(, get name(, set name(
336
- const methods = clean.match(
337
- /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\w+\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/g
338
- );
339
- if (methods) count += methods.length;
340
-
341
- // Arrow functions assigned to const/let/var
342
- const arrows = clean.match(
343
- /(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?\(/g
344
- );
345
- if (arrows) count += arrows.length;
346
-
347
- return count;
348
- }
349
-
350
- /**
351
- * Pick the right directory to scan.
352
- *
353
- * If the root dir has .ts files, scan the user's project code.
354
- * Otherwise, scan the framework itself — so the bubble chart is never empty.
355
- */
356
- function resolveRoot(root: string = "src"): string {
357
- const rootPath = path.resolve(root);
358
- if (fs.existsSync(rootPath) && walkFiles(rootPath, [".ts", ".js"]).length > 0) {
359
- _lastScanRoot = rootPath;
360
- return root;
361
- }
362
- // Fallback: scan the framework package itself
363
- const fwDir = path.resolve(path.dirname(new URL(import.meta.url).pathname));
364
- _lastScanRoot = fwDir;
365
- return fwDir;
366
- }
367
-
368
- export function quickMetrics(root: string = "src"): Record<string, any> {
369
- root = resolveRoot(root);
370
- const rootPath = path.resolve(root);
371
- if (!fs.existsSync(rootPath)) {
372
- return { error: `Directory not found: ${root}` };
373
- }
374
-
375
- const tsFiles = walkFiles(rootPath, [".ts", ".js"]);
376
- const twigFiles = walkFiles(rootPath, [".twig", ".html"]);
377
-
378
- const migrationsDir = path.resolve("migrations");
379
- const migrationFiles = [
380
- ...walkFiles(migrationsDir, [".sql"]),
381
- ...walkFiles(migrationsDir, [".ts"]),
382
- ];
383
-
384
- const scssFiles = walkFiles(rootPath, [".scss", ".css"]);
385
-
386
- let totalLoc = 0;
387
- let totalBlank = 0;
388
- let totalComment = 0;
389
- let totalClasses = 0;
390
- let totalFunctions = 0;
391
- const fileDetails: Record<string, any>[] = [];
392
-
393
- for (const f of tsFiles) {
394
- const source = readFileSafe(f);
395
- if (source === null) continue;
396
-
397
- const counts = countLines(source);
398
- const classes = countClassesQuick(source);
399
- const functions = countFunctionsQuick(source);
400
-
401
- totalLoc += counts.loc;
402
- totalBlank += counts.blank;
403
- totalComment += counts.comment;
404
- totalClasses += classes;
405
- totalFunctions += functions;
406
-
407
- fileDetails.push({
408
- path: relativePath(f, rootPath),
409
- loc: counts.loc,
410
- blank: counts.blank,
411
- comment: counts.comment,
412
- classes,
413
- functions,
414
- });
415
- }
416
-
417
- // Sort by LOC descending
418
- fileDetails.sort((a, b) => b.loc - a.loc);
419
-
420
- // Route and ORM counts (scan for decorators/patterns)
421
- let routeCount = 0;
422
- let ormCount = 0;
423
-
424
- for (const f of tsFiles) {
425
- const source = readFileSafe(f);
426
- if (source === null) continue;
427
-
428
- // Count route registrations: router.get(, router.post(, @get(, @post(, etc.
429
- const routes = source.match(
430
- /(?:router\s*\.\s*(?:get|post|put|delete|patch|any)\s*\(|@(?:get|post|put|delete|patch)\s*\()/g
431
- );
432
- if (routes) routeCount += routes.length;
433
-
434
- // Count ORM models: extends ORM, extends Model
435
- const orms = source.match(
436
- /class\s+\w+\s+extends\s+(?:ORM|Model)\b/g
437
- );
438
- if (orms) ormCount += orms.length;
439
- }
440
-
441
- const breakdown: Record<string, number> = {
442
- typescript: tsFiles.filter((f) => f.endsWith(".ts")).length,
443
- javascript: tsFiles.filter((f) => f.endsWith(".js")).length,
444
- templates: twigFiles.length,
445
- migrations: migrationFiles.length,
446
- stylesheets: scssFiles.length,
447
- };
448
-
449
- return {
450
- file_count: tsFiles.length,
451
- total_loc: totalLoc,
452
- total_blank: totalBlank,
453
- total_comment: totalComment,
454
- lloc: totalLoc,
455
- classes: totalClasses,
456
- functions: totalFunctions,
457
- route_count: routeCount,
458
- orm_count: ormCount,
459
- template_count: twigFiles.length,
460
- migration_count: migrationFiles.length,
461
- avg_file_size: tsFiles.length > 0 ? Math.round((totalLoc / tsFiles.length) * 10) / 10 : 0,
462
- largest_files: fileDetails.slice(0, 10),
463
- breakdown,
464
- };
465
- }
466
-
467
- // -- The native engine (ADR-0002) --------------------------------------------
468
-
469
- /**
470
- * The native metrics engine could not produce a payload.
471
- *
472
- * Thrown instead of falling back to a second implementation.
473
- */
474
10
  export class MetricsEngineError extends Error {
475
11
  constructor(message: string) {
476
12
  super(message);
477
13
  this.name = "MetricsEngineError";
478
14
  }
479
15
  }
480
-
481
- const TIMEOUT_MS = 60_000;
482
-
483
- const INSTALL_HINT = [
484
- "the tina4 CLI provides the metrics engine (ADR-0002). Install it with",
485
- " curl -fsSL https://tina4.com/install.sh | sh",
486
- "or see https://tina4.com/cli",
487
- ].join("\n");
488
-
489
- // Fields the dashboard renders. Checking for the DATA is honest where checking a
490
- // version string is not: a user may run any CLI build, and the payload is what
491
- // tells us what that build can actually do.
16
+ const INSTALL_HINT = "update the native tina4 CLI: https://tina4.com/cli";
492
17
  const SUMMARY_KEYS = ["files_analyzed", "total_functions", "avg_complexity", "avg_maintainability"];
493
18
  const FILE_KEYS = ["path", "loc", "avg_complexity", "maintainability", "has_tests"];
494
19
  const FUNCTION_KEYS = ["name", "file", "line", "complexity", "loc"];
495
20
 
496
- export const SEVERITY_RANK: Record<string, number> = { error: 2, warn: 1, info: 0 };
21
+ function containsTypeScript(directory: string): boolean {
22
+ if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return false;
23
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
24
+ if (["node_modules", ".git", "dist", "build"].includes(entry.name)) continue;
25
+ const target = path.join(directory, entry.name);
26
+ if (entry.isDirectory() ? containsTypeScript(target) : /\.[cm]?[jt]sx?$/.test(entry.name)) return true;
27
+ }
28
+ return false;
29
+ }
497
30
 
498
- /**
499
- * Return [directory to scan, scanMode] for any metrics producer.
500
- *
501
- * The engine is language-agnostic and cannot know which directory holds a
502
- * framework package, so root resolution and the "framework" label stay here,
503
- * shared by the census and the engine adapter so the two never disagree.
504
- */
505
- export function resolveScanTarget(root: string = "src"): [string, string] {
506
- const resolved = resolveRoot(root);
507
- const frameworkDir = path.dirname(fileURLToPath(import.meta.url));
508
- const real = path.resolve(resolved);
509
- const scanningFramework = real === frameworkDir || real.startsWith(frameworkDir);
510
- return [resolved, scanningFramework ? "framework" : "project"];
31
+ function resolveTarget(root: string = "src"): [string, string] {
32
+ const resolved = containsTypeScript(root)
33
+ ? path.resolve(root)
34
+ : path.dirname(fileURLToPath(import.meta.url));
35
+ const mode = containsTypeScript(root) ? "project" : "framework";
36
+ lastScanRoot = resolved;
37
+ return [resolved, mode];
511
38
  }
512
39
 
513
- /** Absolute path to the tina4 CLI binary, or null when it is not installed. */
514
40
  export function enginePath(): string | null {
515
- const names = process.platform === "win32" ? ["tina4.exe", "tina4.cmd", "tina4"] : ["tina4"];
516
- for (const dir of (process.env.PATH || "").split(path.delimiter)) {
517
- if (!dir) continue;
41
+ const names = process.platform === "win32" ? ["tina4.exe", "tina4"] : ["tina4"];
42
+ for (const directory of (process.env.PATH || "").split(path.delimiter)) {
518
43
  for (const name of names) {
519
- const candidate = path.join(dir, name);
44
+ const candidate = path.join(directory, name);
520
45
  try {
521
- if (!fs.statSync(candidate).isFile()) continue;
522
46
  fs.accessSync(candidate, fs.constants.X_OK);
47
+ if (!fs.statSync(candidate).isFile()) continue;
48
+ const descriptor = fs.openSync(candidate, "r");
49
+ const header = Buffer.alloc(2);
50
+ fs.readSync(descriptor, header, 0, 2, 0);
51
+ fs.closeSync(descriptor);
52
+ if (header.toString("latin1") !== "#!") return candidate;
523
53
  } catch {
524
54
  continue;
525
55
  }
526
- // Skip shebang scripts: the engine is a COMPILED binary, and npm/npx put
527
- // JS shims on PATH that would be picked up ahead of the real thing.
528
- try {
529
- const fd = fs.openSync(candidate, "r");
530
- const buf = Buffer.alloc(2);
531
- fs.readSync(fd, buf, 0, 2, 0);
532
- fs.closeSync(fd);
533
- if (buf.toString("latin1") === "#!") continue;
534
- } catch {
535
- /* unreadable header: fall through and try running it */
536
- }
537
- return candidate;
538
56
  }
539
57
  }
540
58
  return null;
541
59
  }
542
60
 
543
- /** Run `tina4 metrics --json` over a path and return the raw payload. */
544
61
  function runEngine(target: string): Record<string, any> {
545
62
  const binary = enginePath();
546
- if (binary === null) {
547
- throw new MetricsEngineError(`tina4 not found on PATH - ${INSTALL_HINT}`);
548
- }
549
-
550
- const proc = spawnSync(binary, ["metrics", "--path", target, "--json"], {
63
+ if (!binary) throw new MetricsEngineError(`tina4 not found on PATH - ${INSTALL_HINT}`);
64
+ const processResult = spawnSync(binary, ["metrics", "--path", target, "--json"], {
551
65
  encoding: "utf8",
552
- timeout: TIMEOUT_MS,
66
+ timeout: 60_000,
553
67
  maxBuffer: 64 * 1024 * 1024,
554
68
  });
555
-
556
- if (proc.error) {
557
- const err = proc.error as NodeJS.ErrnoException;
558
- if (err.code === "ETIMEDOUT") {
559
- throw new MetricsEngineError(`tina4 metrics timed out after ${TIMEOUT_MS / 1000}s on ${target}`);
560
- }
561
- throw new MetricsEngineError(`could not run ${binary}: ${err.message}`);
69
+ if (processResult.error) {
70
+ throw new MetricsEngineError(`could not run ${binary}: ${processResult.error.message}`);
562
71
  }
563
- if (proc.status !== 0) {
564
- const detail = (proc.stderr || proc.stdout || "").trim().split("\n")[0];
565
- throw new MetricsEngineError(
566
- `tina4 metrics failed on ${target}: ${detail || `exit code ${proc.status}`}`
567
- );
72
+ if (processResult.status !== 0) {
73
+ const detail = (processResult.stderr || processResult.stdout || "").trim().split("\n")[0];
74
+ throw new MetricsEngineError(`tina4 metrics failed on ${target}: ${detail || processResult.status}`);
568
75
  }
569
- if (!proc.stdout || !proc.stdout.trim()) {
570
- throw new MetricsEngineError(`tina4 metrics produced no output for ${target}`);
571
- }
572
-
573
- let payload: any;
574
76
  try {
575
- payload = JSON.parse(proc.stdout);
576
- } catch (e) {
577
- throw new MetricsEngineError(`tina4 metrics returned unreadable JSON: ${(e as Error).message}`);
578
- }
579
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
580
- throw new MetricsEngineError("tina4 metrics returned a non-object payload");
77
+ const payload = JSON.parse(processResult.stdout);
78
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
79
+ throw new Error("non-object payload");
80
+ }
81
+ return payload;
82
+ } catch (error) {
83
+ throw new MetricsEngineError(`tina4 metrics returned unreadable JSON: ${(error as Error).message}`);
581
84
  }
582
- return payload;
583
85
  }
584
86
 
585
- /** Pull a key out of the payload or throw naming what the engine is missing. */
586
- function requireKey<T>(payload: Record<string, any>, key: string, isArray: boolean): T {
587
- const value = payload[key];
588
- const ok = isArray
589
- ? Array.isArray(value)
590
- : value !== null && typeof value === "object" && !Array.isArray(value);
591
- if (!ok) {
592
- throw new MetricsEngineError(
593
- `engine payload has no usable '${key}' - the installed tina4 CLI predates a field ` +
594
- `the dashboard renders. Update it: ${INSTALL_HINT}`
595
- );
87
+ function requireArray(payload: Record<string, any>, key: string): Record<string, any>[] {
88
+ if (!Array.isArray(payload[key])) {
89
+ throw new MetricsEngineError(`engine payload has no usable '${key}' - ${INSTALL_HINT}`);
596
90
  }
597
- return value as T;
91
+ return payload[key];
598
92
  }
599
93
 
600
- /** Full code analysis from the native engine, shaped for the dashboard. */
601
94
  export function fullAnalysis(root: string = "src"): Record<string, any> {
602
- const [resolved, scanMode] = resolveScanTarget(root);
95
+ const [resolved, scanMode] = resolveTarget(root);
603
96
  const payload = runEngine(resolved);
604
-
605
- const summary = requireKey<Record<string, any>>(payload, "summary", false);
606
- const fileMetrics = requireKey<Record<string, any>[]>(payload, "file_metrics", true);
607
- const functions = requireKey<Record<string, any>[]>(payload, "most_complex_functions", true);
608
-
609
- const missing = SUMMARY_KEYS.filter((k) => !(k in summary));
610
- if (missing.length) {
611
- throw new MetricsEngineError(
612
- `engine summary is missing ${missing.join(", ")} - update the CLI: ${INSTALL_HINT}`
613
- );
97
+ const summary = payload.summary;
98
+ if (!summary || typeof summary !== "object" || Array.isArray(summary)) {
99
+ throw new MetricsEngineError(`engine payload has no usable 'summary' - ${INSTALL_HINT}`);
100
+ }
101
+ const fileMetrics = requireArray(payload, "file_metrics");
102
+ const functions = requireArray(payload, "most_complex_functions");
103
+ const missingSummary = SUMMARY_KEYS.filter((key) => !(key in summary));
104
+ if (missingSummary.length) throw new MetricsEngineError(`engine summary is missing ${missingSummary.join(", ")}`);
105
+ const missingFile = fileMetrics.length ? FILE_KEYS.filter((key) => !(key in fileMetrics[0])) : [];
106
+ if (missingFile.length) throw new MetricsEngineError(`engine file_metrics is missing ${missingFile.join(", ")}`);
107
+ const missingFunction = functions.length ? FUNCTION_KEYS.filter((key) => !(key in functions[0])) : [];
108
+ if (missingFunction.length) {
109
+ throw new MetricsEngineError(`engine function metrics are missing ${missingFunction.join(", ")}`);
614
110
  }
615
- if (fileMetrics.length) {
616
- const absent = FILE_KEYS.filter((k) => !(k in fileMetrics[0]));
617
- if (absent.length) throw new MetricsEngineError(`engine file_metrics is missing ${absent.join(", ")}`);
618
- }
619
- if (functions.length) {
620
- const absent = FUNCTION_KEYS.filter((k) => !(k in functions[0]));
621
- if (absent.length) throw new MetricsEngineError(`engine function metrics are missing ${absent.join(", ")}`);
622
- }
623
-
624
- const result: Record<string, any> = {};
625
- for (const key of SUMMARY_KEYS) result[key] = summary[key];
626
- result.file_metrics = fileMetrics;
627
- // Display cap only. offenders() reads the engine's own uncapped list, so a
628
- // 16th over-threshold function is never hidden from the gate.
629
- result.most_complex_functions = functions.slice(0, 15);
630
- result.dependency_graph = payload.dependency_graph || {};
631
- // The framework owns these two: the engine always reports "project" because
632
- // it cannot know which directory is a framework package.
633
- result.scan_mode = scanMode;
634
- result.scan_root = path.resolve(resolved);
635
- result.engine = "tina4-cli";
636
- return result;
637
- }
638
-
639
- export interface OffendersResult {
640
- offenders: Record<string, any>[];
641
- summary: Record<string, any>;
642
- }
643
-
644
- /**
645
- * Top code-health offenders from the native engine.
646
- *
647
- * The engine ranks and severity-tags them, and its own --fail-on gate reads the
648
- * same list, so the CLI and the dashboard can never disagree about what counts
649
- * as an offender.
650
- */
651
- export function offenders(root: string = "src", top: number = 20): OffendersResult {
652
- const [resolved, scanMode] = resolveScanTarget(root);
653
- const payload = runEngine(resolved);
654
-
655
- const found = requireKey<Record<string, any>[]>(payload, "offenders", true);
656
- const summary = { ...requireKey<Record<string, any>>(payload, "summary", false) };
657
- summary.scan_mode = scanMode;
658
- summary.scan_root = path.resolve(resolved);
659
- summary.engine = "tina4-cli";
660
- if (summary.total_offenders === undefined) summary.total_offenders = found.length;
661
- return { offenders: found.slice(0, top), summary };
111
+ return {
112
+ ...Object.fromEntries(SUMMARY_KEYS.map((key) => [key, summary[key]])),
113
+ file_metrics: fileMetrics,
114
+ most_complex_functions: functions.slice(0, 15),
115
+ dependency_graph: payload.dependency_graph || {},
116
+ scan_mode: scanMode,
117
+ scan_root: resolved,
118
+ engine: "tina4-cli",
119
+ };
662
120
  }
663
121
 
664
- /**
665
- * Per-file metrics from the native engine.
666
- *
667
- * The engine accepts a single file for --path, so one code path serves both the
668
- * whole-tree scan and one file.
669
- */
670
122
  export function fileDetail(filePath: string): Record<string, any> {
671
123
  if (!filePath) throw new MetricsEngineError("fileDetail needs a path");
672
-
673
124
  let target = filePath;
674
- if (!fs.existsSync(target) && _lastScanRoot) {
675
- // Try it relative to whatever the census last resolved, so the dashboard can
676
- // pass a path taken straight out of file_metrics.
677
- const candidate = path.join(_lastScanRoot, filePath);
678
- if (fs.existsSync(candidate)) target = candidate;
679
- }
125
+ if (!fs.existsSync(target) && lastScanRoot) target = path.join(lastScanRoot, filePath);
680
126
  if (!fs.existsSync(target)) throw new MetricsEngineError(`no such file: ${filePath}`);
681
127
  if (fs.statSync(target).isDirectory()) throw new MetricsEngineError(`not a file: ${filePath}`);
682
-
683
128
  const payload = runEngine(target);
684
- const fileMetrics = requireKey<Record<string, any>[]>(payload, "file_metrics", true);
685
- if (!fileMetrics.length) {
686
- throw new MetricsEngineError(`engine reported no metrics for ${filePath}`);
687
- }
688
- return { ...fileMetrics[0], engine: "tina4-cli" };
129
+ const files = requireArray(payload, "file_metrics");
130
+ if (!files.length) throw new MetricsEngineError(`engine reported no metrics for ${filePath}`);
131
+ return {
132
+ ...files[0],
133
+ function_count: files[0].functions || 0,
134
+ functions: requireArray(payload, "most_complex_functions"),
135
+ engine: "tina4-cli",
136
+ };
689
137
  }