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.
- package/CLAUDE.md +51 -19
- 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/public/js/tina4-dev-admin.js +212 -212
- package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +75 -26
- 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 +14 -8
- package/packages/core/src/logger.ts +1 -1
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +232 -33
- package/packages/core/src/middleware.ts +129 -39
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -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 +2 -2
- package/packages/core/src/server.ts +26 -4
- 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/websocket.ts +247 -33
- package/packages/core/src/websocketBackplane.ts +210 -10
- 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 +8 -3
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +2 -1
- package/packages/orm/src/migration.ts +2 -2
- package/packages/orm/src/seeder.ts +443 -65
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
844
|
-
if (buffer.includes(`${tag} OK`)
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 = [
|
|
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,
|
|
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
|
-
] : []),
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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, [
|
|
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 (
|
|
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
|
-
}
|
|
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> {
|