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.
Files changed (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. 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
- const parentModule = parts.length > 1 ? parts[parts.length - 2] : '';
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 both CWD and the scan root (framework dir when in fallback mode)
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 (fs.existsSync(path.join(repoRoot, 'test')) || fs.existsSync(path.join(repoRoot, 'tests')) || fs.existsSync(path.join(repoRoot, 'spec'))) {
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 = ['test', 'tests', 'spec'];
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, `test_${name}.py`),
98
- path.join(root, td, `${name}_test.rb`),
99
- path.join(root, td, `${name}_spec.rb`),
100
- ...(parentModule && parentModule !== name ? [
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
- // Stage 2+3: Content scan
110
- const pathWithoutExt = relPath.replace(/\.(ts|js|py|rb|php)$/, '');
111
- const className = name
112
- .replace(/[-_](.)/g, (_: string, c: string) => c.toUpperCase())
113
- .replace(/^(.)/, (_: string, c: string) => c.toUpperCase());
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, ['.ts', '.js', '.py', '.rb']);
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 (content.includes(name) && (
123
- content.includes(`"${name}"`) || content.includes(`'${name}'`) ||
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 = source.match(
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 = source.match(
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 = source.match(
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
- // Count decision points via regex
226
- // if statements (not inside strings ideally, but regex-based is approximate)
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 = funcBody.match(pattern);
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
- const lines = source.split("\n");
264
-
265
- // Patterns to match function/method declarations
266
- const patterns = [
267
- // function name(args) or async function name(args)
268
- /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
269
- // Class method: name(args) { or async name(args) {
270
- /(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/,
271
- // Arrow: const name = (args) => or const name = async (args) =>
272
- /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/,
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 line = lines[i];
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
- /(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/
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
- for (const pattern of patterns) {
291
- const match = stripped.match(pattern);
292
- if (match && match[1]) {
293
- const funcName = match[1];
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
- // Skip keywords that look like function calls
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
- ["if", "for", "while", "switch", "catch", "return", "new", "class", "import", "export", "from", "constructor"].includes(funcName) &&
298
- !stripped.includes("constructor")
628
+ candidate &&
629
+ !NON_FUNCTION_WORDS.has(candidate) &&
630
+ (hasModifier || currentClass !== null)
299
631
  ) {
300
- if (funcName !== "constructor") continue;
632
+ funcName = candidate;
633
+ argsStr = m[6] || "";
301
634
  }
635
+ }
636
+ }
302
637
 
303
- // Handle constructor specifically
304
- const displayName =
305
- funcName === "constructor" && currentClass
306
- ? `${currentClass}.constructor`
307
- : currentClass && !stripped.startsWith("function") &&
308
- !stripped.startsWith("export function") &&
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
- // Extract function body by brace matching
320
- const funcBody = extractFunctionBody(lines, i);
321
- const funcLoc = funcBody.split("\n").length;
322
- const complexity = cycloMaticComplexity(funcBody);
323
-
324
- // Parse args
325
- const argsStr = match[2] || "";
326
- const args = argsStr
327
- .split(",")
328
- .map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim())
329
- .filter((a) => a && a !== "this");
330
-
331
- functions.push({
332
- name: displayName,
333
- line: i + 1,
334
- complexity,
335
- loc: funcLoc,
336
- args,
337
- file: relativePath(filePath, root),
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
- line.match(/^\}/) // brace at start of line
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> {