tina4-nodejs 3.13.36 → 3.13.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CLAUDE.md +51 -19
  2. package/package.json +5 -3
  3. package/packages/cli/src/bin.ts +7 -0
  4. package/packages/cli/src/commands/init.ts +1 -0
  5. package/packages/cli/src/commands/metrics.ts +154 -0
  6. package/packages/cli/src/commands/routes.ts +3 -3
  7. package/packages/core/public/js/tina4-dev-admin.js +212 -212
  8. package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
  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 +75 -26
  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 +14 -8
  18. package/packages/core/src/logger.ts +1 -1
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/messenger.ts +111 -11
  21. package/packages/core/src/metrics.ts +232 -33
  22. package/packages/core/src/middleware.ts +129 -39
  23. package/packages/core/src/plan.ts +1 -1
  24. package/packages/core/src/queue.ts +1 -1
  25. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  26. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  27. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  28. package/packages/core/src/rateLimiter.ts +1 -1
  29. package/packages/core/src/response.ts +90 -6
  30. package/packages/core/src/router.ts +2 -2
  31. package/packages/core/src/server.ts +26 -4
  32. package/packages/core/src/session.ts +130 -18
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  34. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  35. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  36. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  37. package/packages/core/src/testClient.ts +1 -1
  38. package/packages/core/src/websocket.ts +247 -33
  39. package/packages/core/src/websocketBackplane.ts +210 -10
  40. package/packages/core/src/wsdl.ts +55 -21
  41. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  42. package/packages/orm/src/adapters/postgres.ts +26 -4
  43. package/packages/orm/src/adapters/sqlite.ts +112 -13
  44. package/packages/orm/src/baseModel.ts +8 -3
  45. package/packages/orm/src/cachedDatabase.ts +15 -6
  46. package/packages/orm/src/database.ts +257 -55
  47. package/packages/orm/src/index.ts +2 -1
  48. package/packages/orm/src/migration.ts +2 -2
  49. package/packages/orm/src/seeder.ts +443 -65
  50. package/packages/swagger/src/ui.ts +1 -1
@@ -28,6 +28,19 @@ import tls from "node:tls";
28
28
  import { readFileSync } from "node:fs";
29
29
  import { basename } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
+ import { isTruthy } from "./dotenv.js";
32
+ import { Log } from "./logger.js";
33
+
34
+ /**
35
+ * TLS certificate validation defaults to SECURE (rejectUnauthorized: true).
36
+ * Set TINA4_MAIL_TLS_INSECURE=true to disable validation for dev / self-signed
37
+ * certificates ONLY — never in production. Previously this was hard-coded to
38
+ * `rejectUnauthorized: false`, silently disabling certificate validation for
39
+ * every TLS connection (a man-in-the-middle risk).
40
+ */
41
+ function tlsRejectUnauthorized(): boolean {
42
+ return !isTruthy(process.env.TINA4_MAIL_TLS_INSECURE);
43
+ }
31
44
 
32
45
  // ── Types ────────────────────────────────────────────────────
33
46
 
@@ -37,6 +50,23 @@ export interface SendResult {
37
50
  id?: string;
38
51
  }
39
52
 
53
+ /**
54
+ * Raised when an IMAP read fails to connect, authenticate, or speak the
55
+ * protocol (a `NO`/`BAD` tagged response, a refused/reset socket, a TLS or
56
+ * DNS failure). Distinct from a SUCCESSFUL fetch that simply has no messages —
57
+ * that still returns an empty result ([] / 0 / {}), NOT an error.
58
+ *
59
+ * inbox()/read()/unread()/search()/folders() LOG and then RAISE this on a
60
+ * connection/protocol failure so a dead mailbox is never silently mistaken for
61
+ * an empty one. send() is unchanged — it keeps returning { success, error }.
62
+ */
63
+ export class MessengerConnectionError extends Error {
64
+ constructor(message: string) {
65
+ super(message);
66
+ this.name = "MessengerConnectionError";
67
+ }
68
+ }
69
+
40
70
  export interface EmailMessage {
41
71
  id: string;
42
72
  type: "inbox" | "outbox";
@@ -402,7 +432,7 @@ export class Messenger {
402
432
 
403
433
  if (this.port === 465) {
404
434
  // Implicit TLS (SMTPS)
405
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false });
435
+ socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
406
436
  await new Promise<void>((resolve, reject) => {
407
437
  socket.once("secureConnect", resolve);
408
438
  socket.once("error", reject);
@@ -440,7 +470,7 @@ export class Messenger {
440
470
  // Upgrade to TLS
441
471
  const plainSocket = socket as net.Socket;
442
472
  socket = tls.connect(
443
- { socket: plainSocket, host: this.host, rejectUnauthorized: false },
473
+ { socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() },
444
474
  );
445
475
  await new Promise<void>((resolve, reject) => {
446
476
  (socket as tls.TLSSocket).once("secureConnect", resolve);
@@ -541,7 +571,7 @@ export class Messenger {
541
571
  let socket: net.Socket | tls.TLSSocket;
542
572
 
543
573
  if (this.port === 465) {
544
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false });
574
+ socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
545
575
  await new Promise<void>((resolve, reject) => {
546
576
  socket.once("secureConnect", resolve);
547
577
  socket.once("error", reject);
@@ -595,7 +625,7 @@ export class Messenger {
595
625
  || (this.imapEncryption === "" && this.imapPort === 993);
596
626
 
597
627
  if (useTls) {
598
- socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: false });
628
+ socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: tlsRejectUnauthorized() });
599
629
  await new Promise<void>((resolve, reject) => {
600
630
  socket.once("secureConnect", resolve);
601
631
  socket.once("error", reject);
@@ -638,7 +668,12 @@ export class Messenger {
638
668
  * Returns list of message summaries.
639
669
  */
640
670
  async inbox(limit: number = 20, offset: number = 0, folder: string = "INBOX"): Promise<ImapMessage[]> {
641
- const socket = await this.imapConnect();
671
+ let socket: net.Socket | tls.TLSSocket;
672
+ try {
673
+ socket = await this.imapConnect();
674
+ } catch (err) {
675
+ throw imapFail("inbox", err);
676
+ }
642
677
  try {
643
678
  // Select folder
644
679
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
@@ -660,6 +695,8 @@ export class Messenger {
660
695
  }
661
696
 
662
697
  return messages;
698
+ } catch (err) {
699
+ throw imapFail("inbox", err);
663
700
  } finally {
664
701
  await this.imapDisconnect(socket);
665
702
  }
@@ -669,15 +706,28 @@ export class Messenger {
669
706
  * Read a single message by sequence number or UID.
670
707
  */
671
708
  async read(uid: string, folder: string = "INBOX"): Promise<ImapFullMessage> {
672
- const socket = await this.imapConnect();
709
+ let socket: net.Socket | tls.TLSSocket;
710
+ try {
711
+ socket = await this.imapConnect();
712
+ } catch (err) {
713
+ throw imapFail("read", err);
714
+ }
673
715
  try {
674
716
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
675
717
  const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY[])`);
676
718
 
719
+ // A genuinely missing UID is a tagged OK with no message body literal —
720
+ // that is NOT an error: return an empty message (parity with Python's {}).
721
+ if (!/\{\d+\}/.test(fetchResp)) {
722
+ return emptyFullMessage(uid);
723
+ }
724
+
677
725
  // Mark as seen
678
726
  await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
679
727
 
680
728
  return parseFullMessage(uid, fetchResp);
729
+ } catch (err) {
730
+ throw imapFail("read", err);
681
731
  } finally {
682
732
  await this.imapDisconnect(socket);
683
733
  }
@@ -704,7 +754,12 @@ export class Messenger {
704
754
  if (unseenOnly) criteria.push("UNSEEN");
705
755
 
706
756
  const query = criteria.join(" ");
707
- const socket = await this.imapConnect();
757
+ let socket: net.Socket | tls.TLSSocket;
758
+ try {
759
+ socket = await this.imapConnect();
760
+ } catch (err) {
761
+ throw imapFail("search", err);
762
+ }
708
763
  try {
709
764
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
710
765
  const searchResp = await imapCommand(socket, `SEARCH ${query}`);
@@ -718,6 +773,8 @@ export class Messenger {
718
773
  messages.push(parseHeaderResponse(uid, fetchResp));
719
774
  }
720
775
  return messages;
776
+ } catch (err) {
777
+ throw imapFail("search", err);
721
778
  } finally {
722
779
  await this.imapDisconnect(socket);
723
780
  }
@@ -754,11 +811,18 @@ export class Messenger {
754
811
  * Count unseen messages in a folder.
755
812
  */
756
813
  async unread(folder: string = "INBOX"): Promise<number> {
757
- const socket = await this.imapConnect();
814
+ let socket: net.Socket | tls.TLSSocket;
815
+ try {
816
+ socket = await this.imapConnect();
817
+ } catch (err) {
818
+ throw imapFail("unread", err);
819
+ }
758
820
  try {
759
821
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
760
822
  const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
761
823
  return parseSearchResponse(searchResp).length;
824
+ } catch (err) {
825
+ throw imapFail("unread", err);
762
826
  } finally {
763
827
  await this.imapDisconnect(socket);
764
828
  }
@@ -768,7 +832,12 @@ export class Messenger {
768
832
  * List available IMAP folders/mailboxes.
769
833
  */
770
834
  async folders(): Promise<string[]> {
771
- const socket = await this.imapConnect();
835
+ let socket: net.Socket | tls.TLSSocket;
836
+ try {
837
+ socket = await this.imapConnect();
838
+ } catch (err) {
839
+ throw imapFail("folders", err);
840
+ }
772
841
  try {
773
842
  const resp = await imapCommand(socket, 'LIST "" "*"');
774
843
  const result: string[] = [];
@@ -778,6 +847,8 @@ export class Messenger {
778
847
  if (m) result.push(m[1]);
779
848
  }
780
849
  return result;
850
+ } catch (err) {
851
+ throw imapFail("folders", err);
781
852
  } finally {
782
853
  await this.imapDisconnect(socket);
783
854
  }
@@ -840,11 +911,20 @@ function imapCommand(socket: net.Socket | tls.TLSSocket, command: string): Promi
840
911
  let buffer = "";
841
912
  const onData = (chunk: Buffer) => {
842
913
  buffer += chunk.toString("utf-8");
843
- // Look for tagged response line indicating completion
844
- if (buffer.includes(`${tag} OK`) || buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
914
+ // Tagged OK = success.
915
+ if (buffer.includes(`${tag} OK`)) {
845
916
  socket.removeListener("data", onData);
846
917
  socket.removeListener("error", onError);
847
918
  resolve(buffer);
919
+ return;
920
+ }
921
+ // Tagged NO / BAD = protocol failure — fail loud (mirrors Python's
922
+ // non-OK status raise). A genuinely empty mailbox is a tagged OK with an
923
+ // empty SEARCH result, which still resolves above.
924
+ if (buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
925
+ socket.removeListener("data", onData);
926
+ socket.removeListener("error", onError);
927
+ reject(new MessengerConnectionError(`IMAP command failed: ${command.split(" ")[0]} → ${buffer.trim()}`));
848
928
  }
849
929
  };
850
930
 
@@ -859,6 +939,17 @@ function imapCommand(socket: net.Socket | tls.TLSSocket, command: string): Promi
859
939
  });
860
940
  }
861
941
 
942
+ /**
943
+ * Log an IMAP connection/protocol failure and return the error to throw.
944
+ * A genuinely empty mailbox is NOT an error and never reaches here.
945
+ */
946
+ function imapFail(method: string, err: unknown): MessengerConnectionError {
947
+ const e = err instanceof Error ? err : new Error(String(err));
948
+ Log.error(`Messenger IMAP ${method}() failed: ${e.name}: ${e.message}`);
949
+ if (e instanceof MessengerConnectionError) return e;
950
+ return new MessengerConnectionError(`IMAP ${method} failed: ${e.message}`);
951
+ }
952
+
862
953
  function parseSearchResponse(response: string): string[] {
863
954
  // SEARCH response: * SEARCH 1 2 3 4 5
864
955
  const match = response.match(/\* SEARCH (.+)/);
@@ -898,6 +989,15 @@ function parseHeaderResponse(uid: string, response: string): ImapMessage {
898
989
  };
899
990
  }
900
991
 
992
+ /**
993
+ * An empty full message — returned when a FETCH succeeds (tagged OK) but the
994
+ * UID does not exist, so there is no message body. Mirrors Python's {} return:
995
+ * a missing UID is NOT an error.
996
+ */
997
+ function emptyFullMessage(uid: string): ImapFullMessage {
998
+ return { uid, subject: "", from: "", to: "", cc: "", date: "", bodyText: "", bodyHtml: "", headers: {} };
999
+ }
1000
+
901
1001
  function parseFullMessage(uid: string, response: string): ImapFullMessage {
902
1002
  // Extract the raw message body from FETCH response
903
1003
  const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);
@@ -59,20 +59,74 @@ 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 > 3) — module-level function names like `get`/`run`/`init` are too
71
+ * generic to trust as a coverage signal.
72
+ */
73
+ function definedClasses(source: string): Set<string> {
74
+ const names = new Set<string>();
75
+ const re = /(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/g;
76
+ let m: RegExpExecArray | null;
77
+ while ((m = re.exec(source)) !== null) {
78
+ const name = m[1];
79
+ if (name && !name.startsWith("_") && name.length > 3) {
80
+ names.add(name);
81
+ }
82
+ }
83
+ return names;
84
+ }
85
+
86
+ /**
87
+ * Whether a source file has a test that ACTUALLY exercises it.
88
+ *
89
+ * PRECISE detection — a bare word-mention of the module name is NOT enough
90
+ * (that over-reported badly: a default DB adapter looked "tested" because some
91
+ * test merely said the word "sqlite"). A file counts as covered only on a real,
92
+ * file-specific signal:
93
+ *
94
+ * 1. Filename — a dedicated test file named for THIS exact module
95
+ * (`<m>.test.ts/.js`, `<m>.spec.ts/.js`, `test_<m>.*`, `<m>_test.*`,
96
+ * `<m>_spec.*`) — NOT the parent directory (one `database.test.ts` must
97
+ * not mark every file under `adapters/` tested).
98
+ * 2. Import — a test that actually IMPORTS this module by its path
99
+ * (`import … from ".../<m>.js"`, `require(".../<m>")`).
100
+ * 3. Class reference — a test that references a top-level class DEFINED in
101
+ * this file (distinctive PascalCase, length > 3). NO bare module-name word
102
+ * match and NO guessed CamelCase-from-snake_case match.
103
+ *
104
+ * Returns true only on a real signal, so the "untested" offenders surfaced by
105
+ * `tina4 metrics` and the dashboard "T" badge are trustworthy.
106
+ */
62
107
  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] : '';
108
+ const parts = relPath.split("/");
109
+ const basename = parts[parts.length - 1] || "";
110
+ const name = basename.replace(/\.(ts|js)$/, "");
111
+
112
+ // Classes defined in THIS file (read from the resolved on-disk file).
113
+ let symbols = new Set<string>();
114
+ const srcFile = _lastScanRoot ? path.join(_lastScanRoot, relPath) : relPath;
115
+ const srcText = readFileSafe(srcFile) ?? readFileSafe(relPath);
116
+ if (srcText !== null) {
117
+ symbols = definedClasses(srcText);
118
+ }
67
119
 
68
- // Search both CWD and the scan root (framework dir when in fallback mode)
120
+ // Search CWD and (in framework-fallback mode) the repo root that owns test/.
69
121
  const searchRoots = [process.cwd()];
70
122
  if (_lastScanRoot && _lastScanRoot !== process.cwd()) {
71
- // Go up from scan root to find the repo root (where test/ lives)
72
123
  let repoRoot = _lastScanRoot;
73
- // Walk up until we find a test/ or tests/ dir, max 5 levels
74
124
  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'))) {
125
+ if (
126
+ fs.existsSync(path.join(repoRoot, "test")) ||
127
+ fs.existsSync(path.join(repoRoot, "tests")) ||
128
+ fs.existsSync(path.join(repoRoot, "spec"))
129
+ ) {
76
130
  searchRoots.push(repoRoot);
77
131
  break;
78
132
  }
@@ -82,10 +136,10 @@ function hasMatchingTest(relPath: string): boolean {
82
136
  }
83
137
  }
84
138
 
85
- const testDirs = ['test', 'tests', 'spec'];
139
+ const testDirs = ["test", "tests", "spec"];
86
140
 
141
+ // Stage 1: a dedicated test FILE named for THIS module (no parent-dir blanket).
87
142
  for (const root of searchRoots) {
88
- // Stage 1: Filename matching
89
143
  for (const td of testDirs) {
90
144
  const patterns = [
91
145
  path.join(root, td, `${name}.test.ts`),
@@ -94,39 +148,49 @@ function hasMatchingTest(relPath: string): boolean {
94
148
  path.join(root, td, `${name}.spec.js`),
95
149
  path.join(root, td, `test_${name}.ts`),
96
150
  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
- ] : []),
151
+ path.join(root, td, `${name}_test.ts`),
152
+ path.join(root, td, `${name}_test.js`),
153
+ path.join(root, td, `${name}_spec.ts`),
154
+ path.join(root, td, `${name}_spec.js`),
105
155
  ];
106
- if (patterns.some(p => fs.existsSync(p))) return true;
156
+ if (patterns.some((p) => fs.existsSync(p))) return true;
107
157
  }
158
+ }
108
159
 
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());
160
+ // Stage 2+3: a test that actually IMPORTS this module (by path), or references
161
+ // a class DEFINED in it. NO bare word-of-the-module-name match.
162
+ // A module specifier whose final path segment is exactly this module name:
163
+ // "./<name>", "../a/b/<name>.js", "@pkg/<name>" but NOT "better-<name>"
164
+ // (the segment boundary is the opening quote or a "/", never a hyphen/word
165
+ // char). Optional .ts/.js extension.
166
+ const spec = `["'](?:[^"']*\\/)?${escapeRegExp(name)}(?:\\.(?:ts|js))?["']`;
167
+ const importRes: RegExp[] = [
168
+ // import ... from "<spec>"
169
+ new RegExp(`import\\b[^;\\n]*?from\\s*${spec}`),
170
+ // require("<spec>")
171
+ new RegExp(`require\\s*\\(\\s*${spec}\\s*\\)`),
172
+ // side-effect import "<spec>"
173
+ new RegExp(`import\\s*${spec}`),
174
+ ];
175
+
176
+ let classRe: RegExp | null = null;
177
+ if (symbols.size > 0) {
178
+ const alt = [...symbols].map(escapeRegExp).join("|");
179
+ classRe = new RegExp(`\\b(?:${alt})\\b`);
180
+ }
114
181
 
182
+ for (const root of searchRoots) {
115
183
  for (const td of testDirs) {
116
184
  const fullTd = path.join(root, td);
117
185
  if (!fs.existsSync(fullTd)) continue;
118
- const testFiles = walkFiles(fullTd, ['.ts', '.js', '.py', '.rb']);
186
+ const testFiles = walkFiles(fullTd, [".ts", ".js"]);
119
187
  for (const testFile of testFiles) {
188
+ // Never let a file count as its own test.
189
+ if (path.resolve(testFile) === path.resolve(srcFile)) continue;
120
190
  const content = readFileSafe(testFile);
121
191
  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
- }
192
+ if (importRes.some((re) => re.test(content))) return true;
193
+ if (classRe && classRe.test(content)) return true;
130
194
  }
131
195
  }
132
196
  }
@@ -871,6 +935,141 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
871
935
  return result;
872
936
  }
873
937
 
938
+ // ── Top Offenders (CLI + dashboard) ──────────────────────────
939
+
940
+ /** Severity ranking for sorting (higher = more severe). */
941
+ export const SEVERITY_RANK: Record<string, number> = { error: 2, warn: 1, info: 0 };
942
+
943
+ export interface Offender {
944
+ file: string;
945
+ line: number;
946
+ kind: string;
947
+ severity: "error" | "warn" | "info";
948
+ score: number;
949
+ detail: string;
950
+ }
951
+
952
+ export interface OffendersResult {
953
+ offenders: Offender[];
954
+ summary: Record<string, any>;
955
+ }
956
+
957
+ /**
958
+ * Rank the worst code-quality issues into a single "top offenders" list.
959
+ *
960
+ * Reuses {@link fullAnalysis} (does NOT re-analyze — the result is mtime-cached).
961
+ * Each offender is `{ file, line, kind, severity, score, detail }`.
962
+ *
963
+ * Rules (one offender per matching condition — SAME scoring as the master):
964
+ * - function complexity > 10 → kind "complexity"
965
+ * severity "error" if > 20 else "warn"; score = complexity
966
+ * - file loc > 500 → kind "large_file" (warn); score = loc / 100
967
+ * - file functions > 20 → kind "too_many_functions" (warn); score = functions / 4
968
+ * - file maintainability < 40 → kind "low_maintainability"
969
+ * severity "error" if < 20 else "warn"; score = 50 - mi
970
+ * - file has_tests === false → kind "untested" (info); score = loc / 100
971
+ *
972
+ * Sorted by (severity rank, score) DESCENDING and truncated to `top`.
973
+ *
974
+ * Returns `{ offenders, summary }` where summary carries the headline numbers
975
+ * the CLI prints (files_analyzed, total_functions, avg_complexity,
976
+ * avg_maintainability, scan_mode, scan_root, total_offenders).
977
+ */
978
+ export function offenders(root: string = "src", top: number = 20): OffendersResult {
979
+ const analysis = fullAnalysis(root);
980
+ if (analysis.error) {
981
+ return { offenders: [], summary: { error: analysis.error } };
982
+ }
983
+
984
+ const items: Offender[] = [];
985
+
986
+ // Function-level: cyclomatic complexity.
987
+ for (const fn of analysis.most_complex_functions || []) {
988
+ const cc: number = fn.complexity;
989
+ if (cc > 10) {
990
+ items.push({
991
+ file: fn.file,
992
+ line: fn.line,
993
+ kind: "complexity",
994
+ severity: cc > 20 ? "error" : "warn",
995
+ score: cc,
996
+ detail: `${fn.name} — cyclomatic complexity ${cc}`,
997
+ });
998
+ }
999
+ }
1000
+
1001
+ // File-level rules.
1002
+ for (const fm of analysis.file_metrics || []) {
1003
+ const filePath: string = fm.path;
1004
+ const loc: number = fm.loc;
1005
+ const funcs: number = fm.functions;
1006
+ const mi: number = fm.maintainability;
1007
+
1008
+ if (loc > 500) {
1009
+ items.push({
1010
+ file: filePath,
1011
+ line: 1,
1012
+ kind: "large_file",
1013
+ severity: "warn",
1014
+ score: loc / 100,
1015
+ detail: `${loc} LOC (max 500)`,
1016
+ });
1017
+ }
1018
+
1019
+ if (funcs > 20) {
1020
+ items.push({
1021
+ file: filePath,
1022
+ line: 1,
1023
+ kind: "too_many_functions",
1024
+ severity: "warn",
1025
+ score: funcs / 4,
1026
+ detail: `${funcs} functions (max 20)`,
1027
+ });
1028
+ }
1029
+
1030
+ if (mi < 40) {
1031
+ items.push({
1032
+ file: filePath,
1033
+ line: 1,
1034
+ kind: "low_maintainability",
1035
+ severity: mi < 20 ? "error" : "warn",
1036
+ score: 50 - mi,
1037
+ detail: `maintainability index ${mi} (min 40)`,
1038
+ });
1039
+ }
1040
+
1041
+ if (fm.has_tests === false) {
1042
+ items.push({
1043
+ file: filePath,
1044
+ line: 1,
1045
+ kind: "untested",
1046
+ severity: "info",
1047
+ score: loc / 100,
1048
+ detail: "no referencing test",
1049
+ });
1050
+ }
1051
+ }
1052
+
1053
+ // Sort by (severity rank, score) DESCENDING.
1054
+ items.sort((a, b) => {
1055
+ const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
1056
+ if (sevDiff !== 0) return sevDiff;
1057
+ return b.score - a.score;
1058
+ });
1059
+
1060
+ const summary = {
1061
+ files_analyzed: analysis.files_analyzed,
1062
+ total_functions: analysis.total_functions,
1063
+ avg_complexity: analysis.avg_complexity,
1064
+ avg_maintainability: analysis.avg_maintainability,
1065
+ scan_mode: analysis.scan_mode,
1066
+ scan_root: analysis.scan_root,
1067
+ total_offenders: items.length,
1068
+ };
1069
+
1070
+ return { offenders: items.slice(0, top), summary };
1071
+ }
1072
+
874
1073
  // ── File Detail ──────────────────────────────────────────────
875
1074
 
876
1075
  export function fileDetail(filePath: string): Record<string, any> {