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.
- package/CLAUDE.md +3 -3
- package/README.md +16 -0
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +585 -692
- package/packages/cli/src/bin.ts +0 -6
- package/packages/core/dist/index.js +573 -550
- package/packages/core/src/ai.ts +15 -6
- package/packages/core/src/aiClient.ts +288 -0
- package/packages/core/src/devAdmin.ts +1 -2
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/metrics.ts +79 -631
- package/packages/frond/dist/index.js +118 -36
- package/packages/frond/src/engine.ts +195 -45
- package/packages/orm/dist/index.js +574 -557
- package/types/core/src/ai.d.ts +29 -0
- package/types/core/src/aiClient.d.ts +66 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/metrics.d.ts +0 -35
- package/types/frond/src/engine.d.ts +50 -8
- package/packages/cli/src/commands/metrics.ts +0 -160
- package/types/cli/src/commands/metrics.d.ts +0 -6
|
@@ -1,689 +1,137 @@
|
|
|
1
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
516
|
-
for (const
|
|
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(
|
|
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
|
|
547
|
-
|
|
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:
|
|
66
|
+
timeout: 60_000,
|
|
553
67
|
maxBuffer: 64 * 1024 * 1024,
|
|
554
68
|
});
|
|
555
|
-
|
|
556
|
-
|
|
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 (
|
|
564
|
-
const detail = (
|
|
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(
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
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] =
|
|
95
|
+
const [resolved, scanMode] = resolveTarget(root);
|
|
603
96
|
const payload = runEngine(resolved);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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) &&
|
|
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
|
|
685
|
-
if (!
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
}
|