tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -59,20 +59,79 @@ let _lastScanRoot = "";
|
|
|
59
59
|
|
|
60
60
|
// ── Test file detection ─────────────────────────────────────
|
|
61
61
|
|
|
62
|
+
/** Escape a string for safe embedding inside a RegExp source. */
|
|
63
|
+
function escapeRegExp(s: string): string {
|
|
64
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Top-level classes DEFINED in a source file. A test that references one of
|
|
69
|
+
* these genuinely exercises this file. Classes only (distinctive PascalCase,
|
|
70
|
+
* length > 2 — so short-but-real names like `ORM`/`Api`/`Log`/`Env` count) —
|
|
71
|
+
* module-level function names like `get`/`run`/`init` are too generic to trust
|
|
72
|
+
* as a coverage signal.
|
|
73
|
+
*/
|
|
74
|
+
function definedClasses(source: string): Set<string> {
|
|
75
|
+
const names = new Set<string>();
|
|
76
|
+
const re = /(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/g;
|
|
77
|
+
let m: RegExpExecArray | null;
|
|
78
|
+
while ((m = re.exec(source)) !== null) {
|
|
79
|
+
const name = m[1];
|
|
80
|
+
// length > 2 (NOT > 3): a 3-char class like ORM/Api/Log/Env is a genuine,
|
|
81
|
+
// distinctive coverage signal — the old > 3 gate silently dropped them so a
|
|
82
|
+
// test that references `new Api()` / `Log.error()` left the file "untested".
|
|
83
|
+
if (name && !name.startsWith("_") && name.length > 2) {
|
|
84
|
+
names.add(name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return names;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a source file has a test that ACTUALLY exercises it.
|
|
92
|
+
*
|
|
93
|
+
* PRECISE detection — a bare word-mention of the module name is NOT enough
|
|
94
|
+
* (that over-reported badly: a default DB adapter looked "tested" because some
|
|
95
|
+
* test merely said the word "sqlite"). A file counts as covered only on a real,
|
|
96
|
+
* file-specific signal:
|
|
97
|
+
*
|
|
98
|
+
* 1. Filename — a dedicated test file named for THIS exact module
|
|
99
|
+
* (`<m>.test.ts/.js`, `<m>.spec.ts/.js`, `test_<m>.*`, `<m>_test.*`,
|
|
100
|
+
* `<m>_spec.*`) — NOT the parent directory (one `database.test.ts` must
|
|
101
|
+
* not mark every file under `adapters/` tested).
|
|
102
|
+
* 2. Import — a test that actually IMPORTS this module by its path
|
|
103
|
+
* (`import … from ".../<m>.js"`, `require(".../<m>")`).
|
|
104
|
+
* 3. Class reference — a test that references a top-level class DEFINED in
|
|
105
|
+
* this file (distinctive PascalCase, length > 2 — short-but-real names
|
|
106
|
+
* like ORM/Api/Log/Env count). NO bare module-name word match and NO
|
|
107
|
+
* guessed CamelCase-from-snake_case match.
|
|
108
|
+
*
|
|
109
|
+
* Returns true only on a real signal, so the "untested" offenders surfaced by
|
|
110
|
+
* `tina4 metrics` and the dashboard "T" badge are trustworthy.
|
|
111
|
+
*/
|
|
62
112
|
function hasMatchingTest(relPath: string): boolean {
|
|
63
|
-
const parts = relPath.split(
|
|
64
|
-
const basename = parts[parts.length - 1] ||
|
|
65
|
-
const name = basename.replace(/\.(ts|js)$/,
|
|
66
|
-
|
|
113
|
+
const parts = relPath.split("/");
|
|
114
|
+
const basename = parts[parts.length - 1] || "";
|
|
115
|
+
const name = basename.replace(/\.(ts|js)$/, "");
|
|
116
|
+
|
|
117
|
+
// Classes defined in THIS file (read from the resolved on-disk file).
|
|
118
|
+
let symbols = new Set<string>();
|
|
119
|
+
const srcFile = _lastScanRoot ? path.join(_lastScanRoot, relPath) : relPath;
|
|
120
|
+
const srcText = readFileSafe(srcFile) ?? readFileSafe(relPath);
|
|
121
|
+
if (srcText !== null) {
|
|
122
|
+
symbols = definedClasses(srcText);
|
|
123
|
+
}
|
|
67
124
|
|
|
68
|
-
// Search
|
|
125
|
+
// Search CWD and (in framework-fallback mode) the repo root that owns test/.
|
|
69
126
|
const searchRoots = [process.cwd()];
|
|
70
127
|
if (_lastScanRoot && _lastScanRoot !== process.cwd()) {
|
|
71
|
-
// Go up from scan root to find the repo root (where test/ lives)
|
|
72
128
|
let repoRoot = _lastScanRoot;
|
|
73
|
-
// Walk up until we find a test/ or tests/ dir, max 5 levels
|
|
74
129
|
for (let i = 0; i < 5; i++) {
|
|
75
|
-
if (
|
|
130
|
+
if (
|
|
131
|
+
fs.existsSync(path.join(repoRoot, "test")) ||
|
|
132
|
+
fs.existsSync(path.join(repoRoot, "tests")) ||
|
|
133
|
+
fs.existsSync(path.join(repoRoot, "spec"))
|
|
134
|
+
) {
|
|
76
135
|
searchRoots.push(repoRoot);
|
|
77
136
|
break;
|
|
78
137
|
}
|
|
@@ -82,10 +141,10 @@ function hasMatchingTest(relPath: string): boolean {
|
|
|
82
141
|
}
|
|
83
142
|
}
|
|
84
143
|
|
|
85
|
-
const testDirs = [
|
|
144
|
+
const testDirs = ["test", "tests", "spec"];
|
|
86
145
|
|
|
146
|
+
// Stage 1: a dedicated test FILE named for THIS module (no parent-dir blanket).
|
|
87
147
|
for (const root of searchRoots) {
|
|
88
|
-
// Stage 1: Filename matching
|
|
89
148
|
for (const td of testDirs) {
|
|
90
149
|
const patterns = [
|
|
91
150
|
path.join(root, td, `${name}.test.ts`),
|
|
@@ -94,39 +153,54 @@ function hasMatchingTest(relPath: string): boolean {
|
|
|
94
153
|
path.join(root, td, `${name}.spec.js`),
|
|
95
154
|
path.join(root, td, `test_${name}.ts`),
|
|
96
155
|
path.join(root, td, `test_${name}.js`),
|
|
97
|
-
path.join(root, td,
|
|
98
|
-
path.join(root, td, `${name}_test.
|
|
99
|
-
path.join(root, td, `${name}_spec.
|
|
100
|
-
|
|
101
|
-
path.join(root, td, `${parentModule}.test.ts`),
|
|
102
|
-
path.join(root, td, `${parentModule}.test.js`),
|
|
103
|
-
path.join(root, td, `${parentModule}.spec.ts`),
|
|
104
|
-
] : []),
|
|
156
|
+
path.join(root, td, `${name}_test.ts`),
|
|
157
|
+
path.join(root, td, `${name}_test.js`),
|
|
158
|
+
path.join(root, td, `${name}_spec.ts`),
|
|
159
|
+
path.join(root, td, `${name}_spec.js`),
|
|
105
160
|
];
|
|
106
|
-
if (patterns.some(p => fs.existsSync(p))) return true;
|
|
161
|
+
if (patterns.some((p) => fs.existsSync(p))) return true;
|
|
107
162
|
}
|
|
163
|
+
}
|
|
108
164
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
165
|
+
// Stage 2+3: a test that actually IMPORTS this module (by path), or references
|
|
166
|
+
// a class DEFINED in it. NO bare word-of-the-module-name match.
|
|
167
|
+
// A module specifier whose final path segment is exactly this module name:
|
|
168
|
+
// "./<name>", "../a/b/<name>.js", "@pkg/<name>" — but NOT "better-<name>"
|
|
169
|
+
// (the segment boundary is the opening quote or a "/", never a hyphen/word
|
|
170
|
+
// char). Optional .ts/.js extension.
|
|
171
|
+
const spec = `["'](?:[^"']*\\/)?${escapeRegExp(name)}(?:\\.(?:ts|js))?["']`;
|
|
172
|
+
const importRes: RegExp[] = [
|
|
173
|
+
// import ... from "<spec>"
|
|
174
|
+
new RegExp(`import\\b[^;\\n]*?from\\s*${spec}`),
|
|
175
|
+
// require("<spec>")
|
|
176
|
+
new RegExp(`require\\s*\\(\\s*${spec}\\s*\\)`),
|
|
177
|
+
// dynamic / inline-type import("<spec>") — covers `await import("…/m.js")`
|
|
178
|
+
// AND the TS inline type position `import("…/m.ts").SomeType`. The full
|
|
179
|
+
// package path a test uses ("../packages/core/src/<m>.ts") matches as a
|
|
180
|
+
// SUFFIX via the leading `(?:[^"']*\/)?` in <spec>.
|
|
181
|
+
new RegExp(`import\\s*\\(\\s*${spec}\\s*\\)`),
|
|
182
|
+
// side-effect import "<spec>"
|
|
183
|
+
new RegExp(`import\\s*${spec}`),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
let classRe: RegExp | null = null;
|
|
187
|
+
if (symbols.size > 0) {
|
|
188
|
+
const alt = [...symbols].map(escapeRegExp).join("|");
|
|
189
|
+
classRe = new RegExp(`\\b(?:${alt})\\b`);
|
|
190
|
+
}
|
|
114
191
|
|
|
192
|
+
for (const root of searchRoots) {
|
|
115
193
|
for (const td of testDirs) {
|
|
116
194
|
const fullTd = path.join(root, td);
|
|
117
195
|
if (!fs.existsSync(fullTd)) continue;
|
|
118
|
-
const testFiles = walkFiles(fullTd, [
|
|
196
|
+
const testFiles = walkFiles(fullTd, [".ts", ".js"]);
|
|
119
197
|
for (const testFile of testFiles) {
|
|
198
|
+
// Never let a file count as its own test.
|
|
199
|
+
if (path.resolve(testFile) === path.resolve(srcFile)) continue;
|
|
120
200
|
const content = readFileSafe(testFile);
|
|
121
201
|
if (content === null) continue;
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
content.includes(`/${name}"`) || content.includes(`/${name}'`) ||
|
|
125
|
-
content.includes(pathWithoutExt)
|
|
126
|
-
)) return true;
|
|
127
|
-
if (className !== name && new RegExp(`\\b${className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(content)) {
|
|
128
|
-
return true;
|
|
129
|
-
}
|
|
202
|
+
if (importRes.some((re) => re.test(content))) return true;
|
|
203
|
+
if (classRe && classRe.test(content)) return true;
|
|
130
204
|
}
|
|
131
205
|
}
|
|
132
206
|
}
|
|
@@ -184,6 +258,214 @@ function countLines(source: string): LineCounts {
|
|
|
184
258
|
return { loc, blank, comment };
|
|
185
259
|
}
|
|
186
260
|
|
|
261
|
+
// ── Literal / comment stripping ──────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Replace the CONTENTS of string literals, template literals (including
|
|
265
|
+
* interpolations), regex literals, and both comment styles with neutral
|
|
266
|
+
* placeholder characters (spaces), preserving newlines so line numbers and
|
|
267
|
+
* structure stay intact. The surrounding delimiters are kept.
|
|
268
|
+
*
|
|
269
|
+
* This is the regex-based stand-in for Python's AST: the decision-point
|
|
270
|
+
* patterns and the function-extraction patterns must only ever see real code,
|
|
271
|
+
* never text that happens to live inside a string, template, regex, line
|
|
272
|
+
* comment or block comment. Without this, a string full of boolean operators
|
|
273
|
+
* or a regex inflates complexity and yields bogus "functions".
|
|
274
|
+
*
|
|
275
|
+
* Regex-vs-division is resolved conservatively: a slash only starts a regex
|
|
276
|
+
* when the previous significant token can't end an expression (an operator,
|
|
277
|
+
* keyword, open bracket, comma, semicolon, etc.). When in doubt we treat the
|
|
278
|
+
* slash as division and DON'T strip — favouring "leave code intact" over
|
|
279
|
+
* "wrongly blank out a division", per the brief.
|
|
280
|
+
*/
|
|
281
|
+
function stripLiterals(source: string): string {
|
|
282
|
+
const out: string[] = [];
|
|
283
|
+
const n = source.length;
|
|
284
|
+
let i = 0;
|
|
285
|
+
|
|
286
|
+
// The last non-whitespace, non-comment character we EMITTED as real code —
|
|
287
|
+
// used to decide whether a `/` opens a regex or is a division operator.
|
|
288
|
+
let prevSignificant = "";
|
|
289
|
+
// The last "word" token (identifier/keyword) emitted, for keyword checks.
|
|
290
|
+
let prevWord = "";
|
|
291
|
+
|
|
292
|
+
/** Keywords after which a `/` is a regex, not division. */
|
|
293
|
+
const regexKeywords = new Set([
|
|
294
|
+
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
|
|
295
|
+
"throw", "case", "do", "else", "yield", "await",
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
/** Can the previous significant token end an expression? If so, `/` = division. */
|
|
299
|
+
function prevEndsExpression(): boolean {
|
|
300
|
+
if (prevSignificant === "") return false; // start of input → regex
|
|
301
|
+
// Identifier/number ending char → could be a value → division …
|
|
302
|
+
if (/[A-Za-z0-9_$]/.test(prevSignificant)) {
|
|
303
|
+
// …unless it's a keyword like `return`/`case` that precedes a regex.
|
|
304
|
+
return !regexKeywords.has(prevWord);
|
|
305
|
+
}
|
|
306
|
+
// Closing brackets and these chars end an expression → division.
|
|
307
|
+
if (prevSignificant === ")" || prevSignificant === "]") return true;
|
|
308
|
+
// `.` (member access) ends an expression-ish context → division ( `a./` is odd, treat as div).
|
|
309
|
+
if (prevSignificant === ".") return true;
|
|
310
|
+
// Everything else (operators, `(`, `,`, `{`, `[`, `;`, `:`, `=`, `<`, `>`, `&`,
|
|
311
|
+
// `|`, `!`, `?`, `+`, `-`, `*`, `%`, `^`, `~`) → regex context.
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
while (i < n) {
|
|
316
|
+
const ch = source[i];
|
|
317
|
+
const next = i + 1 < n ? source[i + 1] : "";
|
|
318
|
+
|
|
319
|
+
// ── Line comment ──
|
|
320
|
+
if (ch === "/" && next === "/") {
|
|
321
|
+
out.push("//");
|
|
322
|
+
i += 2;
|
|
323
|
+
while (i < n && source[i] !== "\n") {
|
|
324
|
+
out.push(" ");
|
|
325
|
+
i++;
|
|
326
|
+
}
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Block comment ──
|
|
331
|
+
if (ch === "/" && next === "*") {
|
|
332
|
+
out.push("/*");
|
|
333
|
+
i += 2;
|
|
334
|
+
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) {
|
|
335
|
+
out.push(source[i] === "\n" ? "\n" : " ");
|
|
336
|
+
i++;
|
|
337
|
+
}
|
|
338
|
+
if (i < n) {
|
|
339
|
+
out.push("*/");
|
|
340
|
+
i += 2;
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── String literals ' " ──
|
|
346
|
+
if (ch === '"' || ch === "'") {
|
|
347
|
+
const quote = ch;
|
|
348
|
+
out.push(quote);
|
|
349
|
+
i++;
|
|
350
|
+
while (i < n && source[i] !== quote) {
|
|
351
|
+
if (source[i] === "\\" && i + 1 < n) {
|
|
352
|
+
out.push(" "); // blank the escape pair, stay 2 chars wide
|
|
353
|
+
i += 2;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (source[i] === "\n") {
|
|
357
|
+
out.push("\n"); // unterminated string safety
|
|
358
|
+
i++;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
out.push(" ");
|
|
362
|
+
i++;
|
|
363
|
+
}
|
|
364
|
+
if (i < n && source[i] === quote) {
|
|
365
|
+
out.push(quote);
|
|
366
|
+
i++;
|
|
367
|
+
}
|
|
368
|
+
prevSignificant = quote;
|
|
369
|
+
prevWord = "";
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Template literals ` ` (with ${ ... } interpolation, recursively code) ──
|
|
374
|
+
if (ch === "`") {
|
|
375
|
+
out.push("`");
|
|
376
|
+
i++;
|
|
377
|
+
while (i < n && source[i] !== "`") {
|
|
378
|
+
if (source[i] === "\\" && i + 1 < n) {
|
|
379
|
+
out.push(source[i + 1] === "\n" ? " \n" : " ");
|
|
380
|
+
i += 2;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// Interpolation: ${ ... } — the inside IS real code, recurse on it.
|
|
384
|
+
if (source[i] === "$" && source[i + 1] === "{") {
|
|
385
|
+
out.push("${");
|
|
386
|
+
i += 2;
|
|
387
|
+
let depth = 1;
|
|
388
|
+
const exprStart = i;
|
|
389
|
+
while (i < n && depth > 0) {
|
|
390
|
+
if (source[i] === "{") depth++;
|
|
391
|
+
else if (source[i] === "}") depth--;
|
|
392
|
+
if (depth === 0) break;
|
|
393
|
+
i++;
|
|
394
|
+
}
|
|
395
|
+
// Strip literals INSIDE the interpolation too (handles nested strings/regex).
|
|
396
|
+
out.push(stripLiterals(source.slice(exprStart, i)));
|
|
397
|
+
if (i < n && source[i] === "}") {
|
|
398
|
+
out.push("}");
|
|
399
|
+
i++;
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
out.push(source[i] === "\n" ? "\n" : " ");
|
|
404
|
+
i++;
|
|
405
|
+
}
|
|
406
|
+
if (i < n && source[i] === "`") {
|
|
407
|
+
out.push("`");
|
|
408
|
+
i++;
|
|
409
|
+
}
|
|
410
|
+
prevSignificant = "`";
|
|
411
|
+
prevWord = "";
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Regex literal / vs division ──
|
|
416
|
+
if (ch === "/" && !prevEndsExpression()) {
|
|
417
|
+
// Scan a regex literal: /.../flags, honouring escapes and [...] classes.
|
|
418
|
+
let j = i + 1;
|
|
419
|
+
let ok = false;
|
|
420
|
+
let inClass = false;
|
|
421
|
+
while (j < n) {
|
|
422
|
+
const c = source[j];
|
|
423
|
+
if (c === "\\") {
|
|
424
|
+
j += 2;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (c === "\n") break; // regex can't span a newline → not a regex
|
|
428
|
+
if (c === "[") inClass = true;
|
|
429
|
+
else if (c === "]") inClass = false;
|
|
430
|
+
else if (c === "/" && !inClass) {
|
|
431
|
+
ok = true;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
j++;
|
|
435
|
+
}
|
|
436
|
+
if (ok) {
|
|
437
|
+
out.push("/");
|
|
438
|
+
for (let k = i + 1; k < j; k++) out.push(" ");
|
|
439
|
+
out.push("/");
|
|
440
|
+
i = j + 1;
|
|
441
|
+
// consume flags
|
|
442
|
+
while (i < n && /[a-z]/i.test(source[i])) {
|
|
443
|
+
out.push(source[i]);
|
|
444
|
+
i++;
|
|
445
|
+
}
|
|
446
|
+
prevSignificant = "/"; // a regex value ends an expression-ish slot
|
|
447
|
+
prevWord = "";
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
// Not a regex — fall through, emit `/` as division.
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Ordinary code char ──
|
|
454
|
+
out.push(ch);
|
|
455
|
+
if (!/\s/.test(ch)) {
|
|
456
|
+
prevSignificant = ch;
|
|
457
|
+
if (/[A-Za-z0-9_$]/.test(ch)) {
|
|
458
|
+
prevWord = /[A-Za-z0-9_$]/.test(source[i - 1] ?? "") ? prevWord + ch : ch;
|
|
459
|
+
} else {
|
|
460
|
+
prevWord = "";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
i++;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return out.join("");
|
|
467
|
+
}
|
|
468
|
+
|
|
187
469
|
// ── Class & function counting (quick) ────────────────────────
|
|
188
470
|
|
|
189
471
|
function countClassesQuick(source: string): number {
|
|
@@ -195,21 +477,24 @@ function countClassesQuick(source: string): number {
|
|
|
195
477
|
}
|
|
196
478
|
|
|
197
479
|
function countFunctionsQuick(source: string): number {
|
|
480
|
+
// Count on cleaned source so `something(...)` inside a string/regex/comment is
|
|
481
|
+
// never mistaken for a method (the chief source of the old over-count).
|
|
482
|
+
const clean = stripLiterals(source);
|
|
198
483
|
let count = 0;
|
|
199
484
|
// function declarations: function foo(, async function foo(, export function foo(
|
|
200
|
-
const funcDecls =
|
|
485
|
+
const funcDecls = clean.match(
|
|
201
486
|
/(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+\w+\s*\(/g
|
|
202
487
|
);
|
|
203
488
|
if (funcDecls) count += funcDecls.length;
|
|
204
489
|
|
|
205
490
|
// Method declarations inside classes: name(, async name(, static name(, get name(, set name(
|
|
206
|
-
const methods =
|
|
491
|
+
const methods = clean.match(
|
|
207
492
|
/(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\w+\s*\([^)]*\)\s*(?::\s*\S+)?\s*\{/g
|
|
208
493
|
);
|
|
209
494
|
if (methods) count += methods.length;
|
|
210
495
|
|
|
211
496
|
// Arrow functions assigned to const/let/var
|
|
212
|
-
const arrows =
|
|
497
|
+
const arrows = clean.match(
|
|
213
498
|
/(?:^|\n)\s*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?\(/g
|
|
214
499
|
);
|
|
215
500
|
if (arrows) count += arrows.length;
|
|
@@ -222,8 +507,12 @@ function countFunctionsQuick(source: string): number {
|
|
|
222
507
|
function cycloMaticComplexity(funcBody: string): number {
|
|
223
508
|
let cc = 1;
|
|
224
509
|
|
|
225
|
-
//
|
|
226
|
-
//
|
|
510
|
+
// Decision points must be counted on REAL code only — strip string/template/
|
|
511
|
+
// regex literals and comments first so `&&`/`if`/`? :` inside data don't inflate
|
|
512
|
+
// the count (matches the intent of Python's AST-based analyzer).
|
|
513
|
+
const body = stripLiterals(funcBody);
|
|
514
|
+
|
|
515
|
+
// Count decision points via regex.
|
|
227
516
|
const patterns: [RegExp, number][] = [
|
|
228
517
|
[/\bif\s*\(/g, 1],
|
|
229
518
|
[/\belse\s+if\s*\(/g, 1],
|
|
@@ -240,7 +529,7 @@ function cycloMaticComplexity(funcBody: string): number {
|
|
|
240
529
|
];
|
|
241
530
|
|
|
242
531
|
for (const [pattern, weight] of patterns) {
|
|
243
|
-
const matches =
|
|
532
|
+
const matches = body.match(pattern);
|
|
244
533
|
if (matches) cc += matches.length * weight;
|
|
245
534
|
}
|
|
246
535
|
|
|
@@ -258,94 +547,129 @@ interface FunctionInfo {
|
|
|
258
547
|
file?: string;
|
|
259
548
|
}
|
|
260
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Reserved words that look like `keyword(...)` (a call/control-flow head) but are
|
|
552
|
+
* NEVER a function declaration. Guards the loose class-method pattern from
|
|
553
|
+
* extracting `if (...)`, `for (...)`, `return (...)`, `await foo()` etc. as
|
|
554
|
+
* "functions" (the bogus-name source).
|
|
555
|
+
*/
|
|
556
|
+
const NON_FUNCTION_WORDS = new Set([
|
|
557
|
+
"if", "for", "while", "switch", "catch", "return", "new", "class", "import",
|
|
558
|
+
"export", "from", "do", "else", "typeof", "instanceof", "in", "of", "void",
|
|
559
|
+
"delete", "await", "yield", "throw", "super", "this", "function", "const",
|
|
560
|
+
"let", "var", "async", "static", "public", "private", "protected", "get",
|
|
561
|
+
"set", "type", "interface", "enum", "extends", "implements", "as", "case",
|
|
562
|
+
"default", "with", "debugger",
|
|
563
|
+
]);
|
|
564
|
+
|
|
261
565
|
function extractFunctions(source: string, filePath: string, root: string = "."): FunctionInfo[] {
|
|
262
566
|
const functions: FunctionInfo[] = [];
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
567
|
+
// Detect/extract on LITERAL-STRIPPED source only — a `word(...)` or a bogus
|
|
568
|
+
// name like "name"/"if" living inside a string/template/regex/comment is now
|
|
569
|
+
// blanked out, so it can never be mistaken for a declaration. Newlines are
|
|
570
|
+
// preserved, so line numbers and the brace-matched body stay accurate.
|
|
571
|
+
const lines = stripLiterals(source).split("\n");
|
|
572
|
+
|
|
573
|
+
// Patterns — anchored at the START of the trimmed line so a mid-line call can
|
|
574
|
+
// never match. Only real declaration shapes are accepted.
|
|
575
|
+
// 1) function name(args) / async function name(args) / export …
|
|
576
|
+
const fnDecl = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*\(([^)]*)\)/;
|
|
577
|
+
// 2) Arrow assigned to a binding: const name = (args) => / = async (args) =>
|
|
578
|
+
const arrowDecl = /^(?:export\s+)?(?:default\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/;
|
|
579
|
+
// 3) A class member: optional modifiers then name(args) … { — only trusted
|
|
580
|
+
// when we're inside a class body, OR a modifier keyword is present.
|
|
581
|
+
const methodMod = /^(?:(public|private|protected)\s+)?(?:(static)\s+)?(?:(async)\s+)?(?:(get|set)\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{;]+)?\s*\{/;
|
|
274
582
|
|
|
275
583
|
// Track which class we're in
|
|
276
584
|
let currentClass: string | null = null;
|
|
277
585
|
|
|
278
586
|
for (let i = 0; i < lines.length; i++) {
|
|
279
|
-
const
|
|
280
|
-
const stripped = line.trim();
|
|
587
|
+
const stripped = lines[i].trim();
|
|
281
588
|
|
|
282
589
|
// Detect class entry
|
|
283
590
|
const classMatch = stripped.match(
|
|
284
|
-
|
|
591
|
+
/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/
|
|
285
592
|
);
|
|
286
593
|
if (classMatch) {
|
|
287
594
|
currentClass = classMatch[1];
|
|
288
595
|
}
|
|
289
596
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
597
|
+
let funcName: string | null = null;
|
|
598
|
+
let argsStr = "";
|
|
599
|
+
let isTopLevelDecl = false; // function/arrow at module/top scope (not class-qualified)
|
|
600
|
+
|
|
601
|
+
// 1) function declaration
|
|
602
|
+
let m = stripped.match(fnDecl);
|
|
603
|
+
if (m) {
|
|
604
|
+
funcName = m[1];
|
|
605
|
+
argsStr = m[2] || "";
|
|
606
|
+
isTopLevelDecl = true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// 2) arrow binding
|
|
610
|
+
if (funcName === null) {
|
|
611
|
+
m = stripped.match(arrowDecl);
|
|
612
|
+
if (m) {
|
|
613
|
+
funcName = m[1];
|
|
614
|
+
argsStr = m[2] || "";
|
|
615
|
+
isTopLevelDecl = true;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
294
618
|
|
|
295
|
-
|
|
619
|
+
// 3) class method (only with a modifier, or while inside a class)
|
|
620
|
+
if (funcName === null) {
|
|
621
|
+
m = stripped.match(methodMod);
|
|
622
|
+
if (m) {
|
|
623
|
+
const hasModifier = !!(m[1] || m[2] || m[3] || m[4]);
|
|
624
|
+
const candidate = m[5];
|
|
625
|
+
// Accept only a genuine member: needs a modifier OR an enclosing class.
|
|
626
|
+
// Never accept a reserved control-flow / declaration keyword.
|
|
296
627
|
if (
|
|
297
|
-
|
|
298
|
-
!
|
|
628
|
+
candidate &&
|
|
629
|
+
!NON_FUNCTION_WORDS.has(candidate) &&
|
|
630
|
+
(hasModifier || currentClass !== null)
|
|
299
631
|
) {
|
|
300
|
-
|
|
632
|
+
funcName = candidate;
|
|
633
|
+
argsStr = m[6] || "";
|
|
301
634
|
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
302
637
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
!stripped.startsWith("async function") &&
|
|
310
|
-
!stripped.startsWith("export async function") &&
|
|
311
|
-
!stripped.startsWith("const ") &&
|
|
312
|
-
!stripped.startsWith("let ") &&
|
|
313
|
-
!stripped.startsWith("var ") &&
|
|
314
|
-
!stripped.startsWith("export const ") &&
|
|
315
|
-
!stripped.startsWith("export let ")
|
|
638
|
+
if (funcName !== null && !NON_FUNCTION_WORDS.has(funcName)) {
|
|
639
|
+
// Constructor / class-qualified display name.
|
|
640
|
+
const displayName =
|
|
641
|
+
funcName === "constructor" && currentClass
|
|
642
|
+
? `${currentClass}.constructor`
|
|
643
|
+
: currentClass !== null && !isTopLevelDecl
|
|
316
644
|
? `${currentClass}.${funcName}`
|
|
317
645
|
: funcName;
|
|
318
646
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
break; // Only match first pattern per line
|
|
341
|
-
}
|
|
647
|
+
// Extract function body by brace matching (on cleaned lines).
|
|
648
|
+
const funcBody = extractFunctionBody(lines, i);
|
|
649
|
+
const funcLoc = funcBody.split("\n").length;
|
|
650
|
+
const complexity = cycloMaticComplexity(funcBody);
|
|
651
|
+
|
|
652
|
+
// Parse args
|
|
653
|
+
const args = argsStr
|
|
654
|
+
.split(",")
|
|
655
|
+
.map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim())
|
|
656
|
+
.filter((a) => a && a !== "this");
|
|
657
|
+
|
|
658
|
+
functions.push({
|
|
659
|
+
name: displayName,
|
|
660
|
+
line: i + 1,
|
|
661
|
+
complexity,
|
|
662
|
+
loc: funcLoc,
|
|
663
|
+
args,
|
|
664
|
+
file: relativePath(filePath, root),
|
|
665
|
+
});
|
|
342
666
|
}
|
|
343
667
|
|
|
344
668
|
// Detect class exit (simple heuristic: closing brace at column 0)
|
|
345
669
|
if (
|
|
346
670
|
currentClass &&
|
|
347
671
|
stripped === "}" &&
|
|
348
|
-
|
|
672
|
+
/^\}/.test(lines[i]) // brace at start of line
|
|
349
673
|
) {
|
|
350
674
|
currentClass = null;
|
|
351
675
|
}
|
|
@@ -871,6 +1195,141 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
|
871
1195
|
return result;
|
|
872
1196
|
}
|
|
873
1197
|
|
|
1198
|
+
// ── Top Offenders (CLI + dashboard) ──────────────────────────
|
|
1199
|
+
|
|
1200
|
+
/** Severity ranking for sorting (higher = more severe). */
|
|
1201
|
+
export const SEVERITY_RANK: Record<string, number> = { error: 2, warn: 1, info: 0 };
|
|
1202
|
+
|
|
1203
|
+
export interface Offender {
|
|
1204
|
+
file: string;
|
|
1205
|
+
line: number;
|
|
1206
|
+
kind: string;
|
|
1207
|
+
severity: "error" | "warn" | "info";
|
|
1208
|
+
score: number;
|
|
1209
|
+
detail: string;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
export interface OffendersResult {
|
|
1213
|
+
offenders: Offender[];
|
|
1214
|
+
summary: Record<string, any>;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Rank the worst code-quality issues into a single "top offenders" list.
|
|
1219
|
+
*
|
|
1220
|
+
* Reuses {@link fullAnalysis} (does NOT re-analyze — the result is mtime-cached).
|
|
1221
|
+
* Each offender is `{ file, line, kind, severity, score, detail }`.
|
|
1222
|
+
*
|
|
1223
|
+
* Rules (one offender per matching condition — SAME scoring as the master):
|
|
1224
|
+
* - function complexity > 10 → kind "complexity"
|
|
1225
|
+
* severity "error" if > 20 else "warn"; score = complexity
|
|
1226
|
+
* - file loc > 500 → kind "large_file" (warn); score = loc / 100
|
|
1227
|
+
* - file functions > 20 → kind "too_many_functions" (warn); score = functions / 4
|
|
1228
|
+
* - file maintainability < 40 → kind "low_maintainability"
|
|
1229
|
+
* severity "error" if < 20 else "warn"; score = 50 - mi
|
|
1230
|
+
* - file has_tests === false → kind "untested" (info); score = loc / 100
|
|
1231
|
+
*
|
|
1232
|
+
* Sorted by (severity rank, score) DESCENDING and truncated to `top`.
|
|
1233
|
+
*
|
|
1234
|
+
* Returns `{ offenders, summary }` where summary carries the headline numbers
|
|
1235
|
+
* the CLI prints (files_analyzed, total_functions, avg_complexity,
|
|
1236
|
+
* avg_maintainability, scan_mode, scan_root, total_offenders).
|
|
1237
|
+
*/
|
|
1238
|
+
export function offenders(root: string = "src", top: number = 20): OffendersResult {
|
|
1239
|
+
const analysis = fullAnalysis(root);
|
|
1240
|
+
if (analysis.error) {
|
|
1241
|
+
return { offenders: [], summary: { error: analysis.error } };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const items: Offender[] = [];
|
|
1245
|
+
|
|
1246
|
+
// Function-level: cyclomatic complexity.
|
|
1247
|
+
for (const fn of analysis.most_complex_functions || []) {
|
|
1248
|
+
const cc: number = fn.complexity;
|
|
1249
|
+
if (cc > 10) {
|
|
1250
|
+
items.push({
|
|
1251
|
+
file: fn.file,
|
|
1252
|
+
line: fn.line,
|
|
1253
|
+
kind: "complexity",
|
|
1254
|
+
severity: cc > 20 ? "error" : "warn",
|
|
1255
|
+
score: cc,
|
|
1256
|
+
detail: `${fn.name} — cyclomatic complexity ${cc}`,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// File-level rules.
|
|
1262
|
+
for (const fm of analysis.file_metrics || []) {
|
|
1263
|
+
const filePath: string = fm.path;
|
|
1264
|
+
const loc: number = fm.loc;
|
|
1265
|
+
const funcs: number = fm.functions;
|
|
1266
|
+
const mi: number = fm.maintainability;
|
|
1267
|
+
|
|
1268
|
+
if (loc > 500) {
|
|
1269
|
+
items.push({
|
|
1270
|
+
file: filePath,
|
|
1271
|
+
line: 1,
|
|
1272
|
+
kind: "large_file",
|
|
1273
|
+
severity: "warn",
|
|
1274
|
+
score: loc / 100,
|
|
1275
|
+
detail: `${loc} LOC (max 500)`,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (funcs > 20) {
|
|
1280
|
+
items.push({
|
|
1281
|
+
file: filePath,
|
|
1282
|
+
line: 1,
|
|
1283
|
+
kind: "too_many_functions",
|
|
1284
|
+
severity: "warn",
|
|
1285
|
+
score: funcs / 4,
|
|
1286
|
+
detail: `${funcs} functions (max 20)`,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (mi < 40) {
|
|
1291
|
+
items.push({
|
|
1292
|
+
file: filePath,
|
|
1293
|
+
line: 1,
|
|
1294
|
+
kind: "low_maintainability",
|
|
1295
|
+
severity: mi < 20 ? "error" : "warn",
|
|
1296
|
+
score: 50 - mi,
|
|
1297
|
+
detail: `maintainability index ${mi} (min 40)`,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (fm.has_tests === false) {
|
|
1302
|
+
items.push({
|
|
1303
|
+
file: filePath,
|
|
1304
|
+
line: 1,
|
|
1305
|
+
kind: "untested",
|
|
1306
|
+
severity: "info",
|
|
1307
|
+
score: loc / 100,
|
|
1308
|
+
detail: "no referencing test",
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Sort by (severity rank, score) DESCENDING.
|
|
1314
|
+
items.sort((a, b) => {
|
|
1315
|
+
const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
1316
|
+
if (sevDiff !== 0) return sevDiff;
|
|
1317
|
+
return b.score - a.score;
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
const summary = {
|
|
1321
|
+
files_analyzed: analysis.files_analyzed,
|
|
1322
|
+
total_functions: analysis.total_functions,
|
|
1323
|
+
avg_complexity: analysis.avg_complexity,
|
|
1324
|
+
avg_maintainability: analysis.avg_maintainability,
|
|
1325
|
+
scan_mode: analysis.scan_mode,
|
|
1326
|
+
scan_root: analysis.scan_root,
|
|
1327
|
+
total_offenders: items.length,
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
return { offenders: items.slice(0, top), summary };
|
|
1331
|
+
}
|
|
1332
|
+
|
|
874
1333
|
// ── File Detail ──────────────────────────────────────────────
|
|
875
1334
|
|
|
876
1335
|
export function fileDetail(filePath: string): Record<string, any> {
|