tina4-nodejs 3.13.92 → 3.13.95
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 +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
// Tina4 Code Metrics
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* Zero dependencies — uses only Node.js built-in modules.
|
|
9
|
-
*/
|
|
1
|
+
// Tina4 Code Metrics -- the native engine (ADR-0002) plus an instant file census.
|
|
2
|
+
//
|
|
3
|
+
// The regex-based analyzer that used to live here is gone. Everything except the
|
|
4
|
+
// instant census now comes from `tina4 metrics --json`, so a number measured in
|
|
5
|
+
// Node is comparable with the same number measured in Python, PHP or Ruby.
|
|
6
|
+
// There is deliberately NO fallback: a second engine is exactly the condition
|
|
7
|
+
// that made the four frameworks' numbers incomparable.
|
|
10
8
|
|
|
11
9
|
import * as fs from "node:fs";
|
|
12
10
|
import * as path from "node:path";
|
|
13
11
|
import * as crypto from "node:crypto";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
// -- Census helpers (kept verbatim: the census parses no code) ----------------
|
|
16
|
+
|
|
17
|
+
// Where the census last resolved to, so fileDetail() can accept a path taken
|
|
18
|
+
// straight out of file_metrics. Written by resolveRoot below.
|
|
19
|
+
let _lastScanRoot = "";
|
|
14
20
|
|
|
15
|
-
// ── Helpers ──────────────────────────────────────────────────
|
|
16
21
|
|
|
17
22
|
function walkFiles(
|
|
18
23
|
dir: string,
|
|
@@ -54,162 +59,6 @@ function relativePath(filePath: string, root: string = "."): string {
|
|
|
54
59
|
return path.relative(root, filePath);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
// Stores the resolved scan root so fileDetail() can locate framework files.
|
|
58
|
-
let _lastScanRoot = "";
|
|
59
|
-
|
|
60
|
-
// ── Test file detection ─────────────────────────────────────
|
|
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
|
-
*/
|
|
112
|
-
function hasMatchingTest(relPath: string): boolean {
|
|
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
|
-
}
|
|
124
|
-
|
|
125
|
-
// Search CWD and (in framework-fallback mode) the repo root that owns test/.
|
|
126
|
-
const searchRoots = [process.cwd()];
|
|
127
|
-
if (_lastScanRoot && _lastScanRoot !== process.cwd()) {
|
|
128
|
-
let repoRoot = _lastScanRoot;
|
|
129
|
-
for (let i = 0; i < 5; i++) {
|
|
130
|
-
if (
|
|
131
|
-
fs.existsSync(path.join(repoRoot, "test")) ||
|
|
132
|
-
fs.existsSync(path.join(repoRoot, "tests")) ||
|
|
133
|
-
fs.existsSync(path.join(repoRoot, "spec"))
|
|
134
|
-
) {
|
|
135
|
-
searchRoots.push(repoRoot);
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
const parent = path.dirname(repoRoot);
|
|
139
|
-
if (parent === repoRoot) break;
|
|
140
|
-
repoRoot = parent;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const testDirs = ["test", "tests", "spec"];
|
|
145
|
-
|
|
146
|
-
// Stage 1: a dedicated test FILE named for THIS module (no parent-dir blanket).
|
|
147
|
-
for (const root of searchRoots) {
|
|
148
|
-
for (const td of testDirs) {
|
|
149
|
-
const patterns = [
|
|
150
|
-
path.join(root, td, `${name}.test.ts`),
|
|
151
|
-
path.join(root, td, `${name}.test.js`),
|
|
152
|
-
path.join(root, td, `${name}.spec.ts`),
|
|
153
|
-
path.join(root, td, `${name}.spec.js`),
|
|
154
|
-
path.join(root, td, `test_${name}.ts`),
|
|
155
|
-
path.join(root, td, `test_${name}.js`),
|
|
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`),
|
|
160
|
-
];
|
|
161
|
-
if (patterns.some((p) => fs.existsSync(p))) return true;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
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
|
-
}
|
|
191
|
-
|
|
192
|
-
for (const root of searchRoots) {
|
|
193
|
-
for (const td of testDirs) {
|
|
194
|
-
const fullTd = path.join(root, td);
|
|
195
|
-
if (!fs.existsSync(fullTd)) continue;
|
|
196
|
-
const testFiles = walkFiles(fullTd, [".ts", ".js"]);
|
|
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;
|
|
200
|
-
const content = readFileSafe(testFile);
|
|
201
|
-
if (content === null) continue;
|
|
202
|
-
if (importRes.some((re) => re.test(content))) return true;
|
|
203
|
-
if (classRe && classRe.test(content)) return true;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// ── Line counting ────────────────────────────────────────────
|
|
212
|
-
|
|
213
62
|
interface LineCounts {
|
|
214
63
|
loc: number;
|
|
215
64
|
blank: number;
|
|
@@ -258,8 +107,6 @@ function countLines(source: string): LineCounts {
|
|
|
258
107
|
return { loc, blank, comment };
|
|
259
108
|
}
|
|
260
109
|
|
|
261
|
-
// ── Literal / comment stripping ──────────────────────────────
|
|
262
|
-
|
|
263
110
|
/**
|
|
264
111
|
* Replace the CONTENTS of string literals, template literals (including
|
|
265
112
|
* interpolations), regex literals, and both comment styles with neutral
|
|
@@ -466,8 +313,6 @@ function stripLiterals(source: string): string {
|
|
|
466
313
|
return out.join("");
|
|
467
314
|
}
|
|
468
315
|
|
|
469
|
-
// ── Class & function counting (quick) ────────────────────────
|
|
470
|
-
|
|
471
316
|
function countClassesQuick(source: string): number {
|
|
472
317
|
// Match class declarations: class Foo, export class Foo, abstract class Foo
|
|
473
318
|
const matches = source.match(
|
|
@@ -502,479 +347,6 @@ function countFunctionsQuick(source: string): number {
|
|
|
502
347
|
return count;
|
|
503
348
|
}
|
|
504
349
|
|
|
505
|
-
// ── Cyclomatic complexity ────────────────────────────────────
|
|
506
|
-
|
|
507
|
-
function cycloMaticComplexity(funcBody: string): number {
|
|
508
|
-
let cc = 1;
|
|
509
|
-
|
|
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.
|
|
516
|
-
const patterns: [RegExp, number][] = [
|
|
517
|
-
[/\bif\s*\(/g, 1],
|
|
518
|
-
[/\belse\s+if\s*\(/g, 1],
|
|
519
|
-
[/\bcase\s+/g, 1],
|
|
520
|
-
[/\bfor\s*\(/g, 1],
|
|
521
|
-
[/\bwhile\s*\(/g, 1],
|
|
522
|
-
[/\bdo\s*\{/g, 1],
|
|
523
|
-
[/\bcatch\s*\(/g, 1],
|
|
524
|
-
[/&&/g, 1],
|
|
525
|
-
[/\|\|/g, 1],
|
|
526
|
-
[/\?\?/g, 1],
|
|
527
|
-
// Ternary ? — but not ?. (optional chaining) and not ?: in type annotations
|
|
528
|
-
[/[^?]\?[^?.:\s]/g, 1],
|
|
529
|
-
];
|
|
530
|
-
|
|
531
|
-
for (const [pattern, weight] of patterns) {
|
|
532
|
-
const matches = body.match(pattern);
|
|
533
|
-
if (matches) cc += matches.length * weight;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
return cc;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// ── Function extraction (regex-based) ─────────────────────────
|
|
540
|
-
|
|
541
|
-
interface FunctionInfo {
|
|
542
|
-
name: string;
|
|
543
|
-
line: number;
|
|
544
|
-
complexity: number;
|
|
545
|
-
loc: number;
|
|
546
|
-
args: string[];
|
|
547
|
-
file?: string;
|
|
548
|
-
}
|
|
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
|
-
|
|
565
|
-
function extractFunctions(source: string, filePath: string, root: string = "."): FunctionInfo[] {
|
|
566
|
-
const functions: FunctionInfo[] = [];
|
|
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*\{/;
|
|
582
|
-
|
|
583
|
-
// Track which class we're in
|
|
584
|
-
let currentClass: string | null = null;
|
|
585
|
-
|
|
586
|
-
for (let i = 0; i < lines.length; i++) {
|
|
587
|
-
const stripped = lines[i].trim();
|
|
588
|
-
|
|
589
|
-
// Detect class entry
|
|
590
|
-
const classMatch = stripped.match(
|
|
591
|
-
/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)/
|
|
592
|
-
);
|
|
593
|
-
if (classMatch) {
|
|
594
|
-
currentClass = classMatch[1];
|
|
595
|
-
}
|
|
596
|
-
|
|
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
|
-
}
|
|
618
|
-
|
|
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.
|
|
627
|
-
if (
|
|
628
|
-
candidate &&
|
|
629
|
-
!NON_FUNCTION_WORDS.has(candidate) &&
|
|
630
|
-
(hasModifier || currentClass !== null)
|
|
631
|
-
) {
|
|
632
|
-
funcName = candidate;
|
|
633
|
-
argsStr = m[6] || "";
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
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
|
|
644
|
-
? `${currentClass}.${funcName}`
|
|
645
|
-
: funcName;
|
|
646
|
-
|
|
647
|
-
// Extract function body by brace matching (on cleaned lines).
|
|
648
|
-
const funcBody = extractFunctionBody(lines, i);
|
|
649
|
-
// Code lines, by the exact same counter the file level uses. This was
|
|
650
|
-
// `funcBody.split("\n").length` - a raw line span - while file LOC excluded
|
|
651
|
-
// blanks and comments, so `loc` meant two different things in one payload
|
|
652
|
-
// and the dashboard sized bubbles in one unit while printing the function
|
|
653
|
-
// table in the other. Floor of 1: a one-line body must never report 0.
|
|
654
|
-
const funcLoc = Math.max(1, countLines(funcBody).loc);
|
|
655
|
-
const complexity = cycloMaticComplexity(funcBody);
|
|
656
|
-
|
|
657
|
-
// Parse args
|
|
658
|
-
const args = argsStr
|
|
659
|
-
.split(",")
|
|
660
|
-
.map((a) => a.trim().split(":")[0].split("=")[0].replace("?", "").trim())
|
|
661
|
-
.filter((a) => a && a !== "this");
|
|
662
|
-
|
|
663
|
-
functions.push({
|
|
664
|
-
name: displayName,
|
|
665
|
-
line: i + 1,
|
|
666
|
-
complexity,
|
|
667
|
-
loc: funcLoc,
|
|
668
|
-
args,
|
|
669
|
-
file: relativePath(filePath, root),
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Detect class exit (simple heuristic: closing brace at column 0)
|
|
674
|
-
if (
|
|
675
|
-
currentClass &&
|
|
676
|
-
stripped === "}" &&
|
|
677
|
-
/^\}/.test(lines[i]) // brace at start of line
|
|
678
|
-
) {
|
|
679
|
-
currentClass = null;
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
return chargeNestedComplexityToTheNestedFunction(functions);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/**
|
|
687
|
-
* Stop a function being charged for the complexity of the functions nested
|
|
688
|
-
* inside it.
|
|
689
|
-
*
|
|
690
|
-
* Each function's raw score is measured over its whole span, so a branch inside
|
|
691
|
-
* a nested function landed on BOTH that function and every function enclosing
|
|
692
|
-
* it. The over-count compounded with depth: an IIFE wrapper or a registrar
|
|
693
|
-
* defining twenty inner handlers absorbed the entire file's complexity and
|
|
694
|
-
* topped the offenders list, hiding the genuine hot spots.
|
|
695
|
-
*
|
|
696
|
-
* The correction is exact. A raw score is 1 + every decision in the span, so
|
|
697
|
-
* (raw - 1) is the total decision count of a function's whole subtree.
|
|
698
|
-
* Subtracting that for each DIRECT child leaves the function's own branches:
|
|
699
|
-
*
|
|
700
|
-
* own(F) = raw(F) - sum over direct children C of (raw(C) - 1)
|
|
701
|
-
*
|
|
702
|
-
* Anything the extractor does NOT list is deliberately unaffected: nothing
|
|
703
|
-
* subtracts it, so its decisions stay with the function that contains it -
|
|
704
|
-
* moved, never lost.
|
|
705
|
-
*/
|
|
706
|
-
export function chargeNestedComplexityToTheNestedFunction(
|
|
707
|
-
functions: FunctionInfo[],
|
|
708
|
-
): FunctionInfo[] {
|
|
709
|
-
if (functions.length < 2) return functions;
|
|
710
|
-
|
|
711
|
-
const lastLine = (f: FunctionInfo) => f.line + Math.max(1, f.loc) - 1;
|
|
712
|
-
const contains = (outer: FunctionInfo, inner: FunctionInfo) =>
|
|
713
|
-
inner.line > outer.line && lastLine(inner) <= lastLine(outer);
|
|
714
|
-
|
|
715
|
-
const raw = functions.map((f) => f.complexity);
|
|
716
|
-
functions.forEach((outer, i) => {
|
|
717
|
-
let subtract = 0;
|
|
718
|
-
functions.forEach((inner, j) => {
|
|
719
|
-
if (i === j || !contains(outer, inner)) return;
|
|
720
|
-
// Direct child only: skip it if another function sits between the two,
|
|
721
|
-
// or its complexity would be subtracted twice.
|
|
722
|
-
const nestedDeeper = functions.some(
|
|
723
|
-
(mid, k) => k !== i && k !== j && contains(outer, mid) && contains(mid, inner),
|
|
724
|
-
);
|
|
725
|
-
if (!nestedDeeper) subtract += raw[j] - 1;
|
|
726
|
-
});
|
|
727
|
-
outer.complexity = Math.max(1, raw[i] - subtract);
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
return functions;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
function extractFunctionBody(lines: string[], startLine: number): string {
|
|
734
|
-
let braceCount = 0;
|
|
735
|
-
let started = false;
|
|
736
|
-
const bodyLines: string[] = [];
|
|
737
|
-
|
|
738
|
-
for (let i = startLine; i < lines.length; i++) {
|
|
739
|
-
const line = lines[i];
|
|
740
|
-
bodyLines.push(line);
|
|
741
|
-
|
|
742
|
-
for (const ch of line) {
|
|
743
|
-
if (ch === "{") {
|
|
744
|
-
braceCount++;
|
|
745
|
-
started = true;
|
|
746
|
-
} else if (ch === "}") {
|
|
747
|
-
braceCount--;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
if (started && braceCount <= 0) {
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Safety: limit to 1000 lines
|
|
756
|
-
if (bodyLines.length > 1000) break;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// For arrow functions without braces, just take the line
|
|
760
|
-
if (!started) {
|
|
761
|
-
return bodyLines.join("\n");
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
return bodyLines.join("\n");
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// ── Import extraction ────────────────────────────────────────
|
|
768
|
-
|
|
769
|
-
function extractImports(source: string): string[] {
|
|
770
|
-
const imports: string[] = [];
|
|
771
|
-
|
|
772
|
-
// import ... from "module"
|
|
773
|
-
const esImports = source.matchAll(
|
|
774
|
-
/import\s+(?:[\s\S]*?)\s+from\s+["']([^"']+)["']/g
|
|
775
|
-
);
|
|
776
|
-
for (const match of esImports) {
|
|
777
|
-
imports.push(match[1]);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// import "module" (side-effect)
|
|
781
|
-
const sideEffects = source.matchAll(/import\s+["']([^"']+)["']/g);
|
|
782
|
-
for (const match of sideEffects) {
|
|
783
|
-
imports.push(match[1]);
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// require("module")
|
|
787
|
-
const requires = source.matchAll(/require\s*\(\s*["']([^"']+)["']\s*\)/g);
|
|
788
|
-
for (const match of requires) {
|
|
789
|
-
imports.push(match[1]);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// Deduplicate
|
|
793
|
-
return [...new Set(imports)];
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// ── Halstead metrics ─────────────────────────────────────────
|
|
797
|
-
|
|
798
|
-
interface HalsteadStats {
|
|
799
|
-
operators: number;
|
|
800
|
-
operands: number;
|
|
801
|
-
uniqueOperators: Set<string>;
|
|
802
|
-
uniqueOperands: Set<string>;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
function countHalstead(source: string): HalsteadStats {
|
|
806
|
-
const stats: HalsteadStats = {
|
|
807
|
-
operators: 0,
|
|
808
|
-
operands: 0,
|
|
809
|
-
uniqueOperators: new Set(),
|
|
810
|
-
uniqueOperands: new Set(),
|
|
811
|
-
};
|
|
812
|
-
|
|
813
|
-
// Operators
|
|
814
|
-
const operatorPatterns = [
|
|
815
|
-
/[+\-*/%]=?/g,
|
|
816
|
-
/[<>!=]=?=?/g,
|
|
817
|
-
/&&/g,
|
|
818
|
-
/\|\|/g,
|
|
819
|
-
/\?\?/g,
|
|
820
|
-
/\.\.\./g,
|
|
821
|
-
/\b(typeof|instanceof|void|delete|in|of|new|yield|await)\b/g,
|
|
822
|
-
];
|
|
823
|
-
|
|
824
|
-
for (const pat of operatorPatterns) {
|
|
825
|
-
const matches = source.match(pat);
|
|
826
|
-
if (matches) {
|
|
827
|
-
for (const m of matches) {
|
|
828
|
-
stats.operators++;
|
|
829
|
-
stats.uniqueOperators.add(m);
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// Operands: identifiers and literals
|
|
835
|
-
const identifiers = source.match(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g);
|
|
836
|
-
if (identifiers) {
|
|
837
|
-
const keywords = new Set([
|
|
838
|
-
"if", "else", "for", "while", "do", "switch", "case", "break",
|
|
839
|
-
"continue", "return", "function", "class", "const", "let", "var",
|
|
840
|
-
"import", "export", "from", "default", "try", "catch", "finally",
|
|
841
|
-
"throw", "new", "delete", "typeof", "instanceof", "void", "in",
|
|
842
|
-
"of", "async", "await", "yield", "this", "super", "true", "false",
|
|
843
|
-
"null", "undefined", "extends", "implements", "interface", "type",
|
|
844
|
-
"enum", "public", "private", "protected", "static", "abstract",
|
|
845
|
-
"readonly", "as", "is", "keyof", "infer", "never", "unknown",
|
|
846
|
-
"any", "string", "number", "boolean", "symbol", "bigint", "object",
|
|
847
|
-
]);
|
|
848
|
-
for (const id of identifiers) {
|
|
849
|
-
if (!keywords.has(id)) {
|
|
850
|
-
stats.operands++;
|
|
851
|
-
stats.uniqueOperands.add(id);
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Number literals
|
|
857
|
-
const numbers = source.match(/\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/g);
|
|
858
|
-
if (numbers) {
|
|
859
|
-
for (const n of numbers) {
|
|
860
|
-
stats.operands++;
|
|
861
|
-
stats.uniqueOperands.add(n);
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// String literals
|
|
866
|
-
const strings = source.match(
|
|
867
|
-
/(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g
|
|
868
|
-
);
|
|
869
|
-
if (strings) {
|
|
870
|
-
for (const s of strings) {
|
|
871
|
-
stats.operands++;
|
|
872
|
-
stats.uniqueOperands.add(s.substring(0, 50));
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
return stats;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// ── Maintainability Index ────────────────────────────────────
|
|
880
|
-
|
|
881
|
-
function maintainabilityIndex(
|
|
882
|
-
halsteadVolume: number,
|
|
883
|
-
avgCC: number,
|
|
884
|
-
loc: number
|
|
885
|
-
): number {
|
|
886
|
-
if (loc <= 0) return 100.0;
|
|
887
|
-
const v = Math.max(halsteadVolume, 1);
|
|
888
|
-
const mi =
|
|
889
|
-
171 - 5.2 * Math.log(v) - 0.23 * avgCC - 16.2 * Math.log(loc);
|
|
890
|
-
return Math.max(0, Math.min(100, (mi * 100) / 171));
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// ── Violations ───────────────────────────────────────────────
|
|
894
|
-
|
|
895
|
-
interface Violation {
|
|
896
|
-
type: "error" | "warning";
|
|
897
|
-
rule: string;
|
|
898
|
-
message: string;
|
|
899
|
-
file: string;
|
|
900
|
-
line: number;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
function detectViolations(
|
|
904
|
-
functions: FunctionInfo[],
|
|
905
|
-
fileMetrics: Record<string, any>[]
|
|
906
|
-
): Violation[] {
|
|
907
|
-
const violations: Violation[] = [];
|
|
908
|
-
|
|
909
|
-
for (const f of functions) {
|
|
910
|
-
if (f.complexity > 20) {
|
|
911
|
-
violations.push({
|
|
912
|
-
type: "error",
|
|
913
|
-
rule: "high_complexity",
|
|
914
|
-
message: `${f.name} has cyclomatic complexity ${f.complexity} (max 20)`,
|
|
915
|
-
file: f.file || "",
|
|
916
|
-
line: f.line,
|
|
917
|
-
});
|
|
918
|
-
} else if (f.complexity > 10) {
|
|
919
|
-
violations.push({
|
|
920
|
-
type: "warning",
|
|
921
|
-
rule: "moderate_complexity",
|
|
922
|
-
message: `${f.name} has cyclomatic complexity ${f.complexity} (recommended max 10)`,
|
|
923
|
-
file: f.file || "",
|
|
924
|
-
line: f.line,
|
|
925
|
-
});
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
for (const fm of fileMetrics) {
|
|
930
|
-
if (fm.loc > 500) {
|
|
931
|
-
violations.push({
|
|
932
|
-
type: "warning",
|
|
933
|
-
rule: "large_file",
|
|
934
|
-
message: `${fm.path} has ${fm.loc} LOC (recommended max 500)`,
|
|
935
|
-
file: fm.path,
|
|
936
|
-
line: 1,
|
|
937
|
-
});
|
|
938
|
-
}
|
|
939
|
-
if (fm.functions > 20) {
|
|
940
|
-
violations.push({
|
|
941
|
-
type: "warning",
|
|
942
|
-
rule: "too_many_functions",
|
|
943
|
-
message: `${fm.path} has ${fm.functions} functions (recommended max 20)`,
|
|
944
|
-
file: fm.path,
|
|
945
|
-
line: 1,
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
if (fm.maintainability < 20) {
|
|
949
|
-
violations.push({
|
|
950
|
-
type: "error",
|
|
951
|
-
rule: "low_maintainability",
|
|
952
|
-
message: `${fm.path} has maintainability index ${fm.maintainability} (min 20)`,
|
|
953
|
-
file: fm.path,
|
|
954
|
-
line: 1,
|
|
955
|
-
});
|
|
956
|
-
} else if (fm.maintainability < 40) {
|
|
957
|
-
violations.push({
|
|
958
|
-
type: "warning",
|
|
959
|
-
rule: "moderate_maintainability",
|
|
960
|
-
message: `${fm.path} has maintainability index ${fm.maintainability} (recommended min 40)`,
|
|
961
|
-
file: fm.path,
|
|
962
|
-
line: 1,
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
violations.sort((a, b) => {
|
|
968
|
-
const typeDiff = (a.type === "error" ? 0 : 1) - (b.type === "error" ? 0 : 1);
|
|
969
|
-
if (typeDiff !== 0) return typeDiff;
|
|
970
|
-
return a.file.localeCompare(b.file);
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
return violations;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// ── Root Resolution ──────────────────────────────────────────
|
|
977
|
-
|
|
978
350
|
/**
|
|
979
351
|
* Pick the right directory to scan.
|
|
980
352
|
*
|
|
@@ -993,8 +365,6 @@ function resolveRoot(root: string = "src"): string {
|
|
|
993
365
|
return fwDir;
|
|
994
366
|
}
|
|
995
367
|
|
|
996
|
-
// ── Quick Metrics ────────────────────────────────────────────
|
|
997
|
-
|
|
998
368
|
export function quickMetrics(root: string = "src"): Record<string, any> {
|
|
999
369
|
root = resolveRoot(root);
|
|
1000
370
|
const rootPath = path.resolve(root);
|
|
@@ -1094,358 +464,226 @@ export function quickMetrics(root: string = "src"): Record<string, any> {
|
|
|
1094
464
|
};
|
|
1095
465
|
}
|
|
1096
466
|
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
let _fullCache: { hash: string; data: Record<string, any> | null; time: number } = {
|
|
1100
|
-
hash: "",
|
|
1101
|
-
data: null,
|
|
1102
|
-
time: 0,
|
|
1103
|
-
};
|
|
1104
|
-
const _CACHE_TTL = 60; // seconds
|
|
467
|
+
// -- The native engine (ADR-0002) --------------------------------------------
|
|
1105
468
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
} catch {
|
|
1116
|
-
// skip
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
469
|
+
/**
|
|
470
|
+
* The native metrics engine could not produce a payload.
|
|
471
|
+
*
|
|
472
|
+
* Thrown instead of falling back to a second implementation.
|
|
473
|
+
*/
|
|
474
|
+
export class MetricsEngineError extends Error {
|
|
475
|
+
constructor(message: string) {
|
|
476
|
+
super(message);
|
|
477
|
+
this.name = "MetricsEngineError";
|
|
1119
478
|
}
|
|
1120
|
-
return h.digest("hex");
|
|
1121
479
|
}
|
|
1122
480
|
|
|
1123
|
-
|
|
1124
|
-
root = resolveRoot(root);
|
|
1125
|
-
const currentHash = filesHash(root);
|
|
1126
|
-
const now = Date.now() / 1000;
|
|
1127
|
-
|
|
1128
|
-
if (
|
|
1129
|
-
_fullCache.hash === currentHash &&
|
|
1130
|
-
_fullCache.data !== null &&
|
|
1131
|
-
now - _fullCache.time < _CACHE_TTL
|
|
1132
|
-
) {
|
|
1133
|
-
return _fullCache.data;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
const rootPath = path.resolve(root);
|
|
1137
|
-
if (!fs.existsSync(rootPath)) {
|
|
1138
|
-
return { error: `Directory not found: ${root}` };
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
const tsFiles = walkFiles(rootPath, [".ts", ".js"]);
|
|
481
|
+
const TIMEOUT_MS = 60_000;
|
|
1142
482
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
483
|
+
const INSTALL_HINT = [
|
|
484
|
+
"the tina4 CLI provides the metrics engine (ADR-0002). Install it with",
|
|
485
|
+
" curl -fsSL https://tina4.com/install.sh | sh",
|
|
486
|
+
"or see https://tina4.com/cli",
|
|
487
|
+
].join("\n");
|
|
1147
488
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
489
|
+
// Fields the dashboard renders. Checking for the DATA is honest where checking a
|
|
490
|
+
// version string is not: a user may run any CLI build, and the payload is what
|
|
491
|
+
// tells us what that build can actually do.
|
|
492
|
+
const SUMMARY_KEYS = ["files_analyzed", "total_functions", "avg_complexity", "avg_maintainability"];
|
|
493
|
+
const FILE_KEYS = ["path", "loc", "avg_complexity", "maintainability", "has_tests"];
|
|
494
|
+
const FUNCTION_KEYS = ["name", "file", "line", "complexity", "loc"];
|
|
1151
495
|
|
|
1152
|
-
|
|
1153
|
-
const lines = source.split("\n");
|
|
1154
|
-
const loc = lines.filter(
|
|
1155
|
-
(l) => l.trim() && !l.trim().startsWith("//")
|
|
1156
|
-
).length;
|
|
496
|
+
export const SEVERITY_RANK: Record<string, number> = { error: 2, warn: 1, info: 0 };
|
|
1157
497
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Return [directory to scan, scanMode] for any metrics producer.
|
|
500
|
+
*
|
|
501
|
+
* The engine is language-agnostic and cannot know which directory holds a
|
|
502
|
+
* framework package, so root resolution and the "framework" label stay here,
|
|
503
|
+
* shared by the census and the engine adapter so the two never disagree.
|
|
504
|
+
*/
|
|
505
|
+
export function resolveScanTarget(root: string = "src"): [string, string] {
|
|
506
|
+
const resolved = resolveRoot(root);
|
|
507
|
+
const frameworkDir = path.dirname(fileURLToPath(import.meta.url));
|
|
508
|
+
const real = path.resolve(resolved);
|
|
509
|
+
const scanningFramework = real === frameworkDir || real.startsWith(frameworkDir);
|
|
510
|
+
return [resolved, scanningFramework ? "framework" : "project"];
|
|
511
|
+
}
|
|
1161
512
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
513
|
+
/** Absolute path to the tina4 CLI binary, or null when it is not installed. */
|
|
514
|
+
export function enginePath(): string | null {
|
|
515
|
+
const names = process.platform === "win32" ? ["tina4.exe", "tina4.cmd", "tina4"] : ["tina4"];
|
|
516
|
+
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
|
517
|
+
if (!dir) continue;
|
|
518
|
+
for (const name of names) {
|
|
519
|
+
const candidate = path.join(dir, name);
|
|
520
|
+
try {
|
|
521
|
+
if (!fs.statSync(candidate).isFile()) continue;
|
|
522
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
523
|
+
} catch {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
// Skip shebang scripts: the engine is a COMPILED binary, and npm/npx put
|
|
527
|
+
// JS shims on PATH that would be picked up ahead of the real thing.
|
|
528
|
+
try {
|
|
529
|
+
const fd = fs.openSync(candidate, "r");
|
|
530
|
+
const buf = Buffer.alloc(2);
|
|
531
|
+
fs.readSync(fd, buf, 0, 2, 0);
|
|
532
|
+
fs.closeSync(fd);
|
|
533
|
+
if (buf.toString("latin1") === "#!") continue;
|
|
534
|
+
} catch {
|
|
535
|
+
/* unreadable header: fall through and try running it */
|
|
1165
536
|
}
|
|
1166
|
-
|
|
537
|
+
return candidate;
|
|
1167
538
|
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
1168
542
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
543
|
+
/** Run `tina4 metrics --json` over a path and return the raw payload. */
|
|
544
|
+
function runEngine(target: string): Record<string, any> {
|
|
545
|
+
const binary = enginePath();
|
|
546
|
+
if (binary === null) {
|
|
547
|
+
throw new MetricsEngineError(`tina4 not found on PATH - ${INSTALL_HINT}`);
|
|
548
|
+
}
|
|
1172
549
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
550
|
+
const proc = spawnSync(binary, ["metrics", "--path", target, "--json"], {
|
|
551
|
+
encoding: "utf8",
|
|
552
|
+
timeout: TIMEOUT_MS,
|
|
553
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
554
|
+
});
|
|
1177
555
|
|
|
1178
|
-
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
const mi = maintainabilityIndex(volume, avgCC, loc);
|
|
1194
|
-
|
|
1195
|
-
// Coupling
|
|
1196
|
-
const ce = imports.length; // efferent
|
|
1197
|
-
const ca = (reverseGraph[relPath] || []).length; // afferent
|
|
1198
|
-
const instability = ca + ce > 0 ? ce / (ca + ce) : 0.0;
|
|
1199
|
-
|
|
1200
|
-
fileMetrics.push({
|
|
1201
|
-
path: relPath,
|
|
1202
|
-
loc,
|
|
1203
|
-
complexity: fileComplexity,
|
|
1204
|
-
avg_complexity: Math.round(avgCC * 100) / 100,
|
|
1205
|
-
functions: fileFunctions.length,
|
|
1206
|
-
maintainability: Math.round(mi * 10) / 10,
|
|
1207
|
-
halstead_volume: Math.round(volume * 10) / 10,
|
|
1208
|
-
coupling_afferent: ca,
|
|
1209
|
-
coupling_efferent: ce,
|
|
1210
|
-
instability: Math.round(instability * 1000) / 1000,
|
|
1211
|
-
has_tests: hasMatchingTest(relPath),
|
|
1212
|
-
dep_count: ce,
|
|
1213
|
-
});
|
|
556
|
+
if (proc.error) {
|
|
557
|
+
const err = proc.error as NodeJS.ErrnoException;
|
|
558
|
+
if (err.code === "ETIMEDOUT") {
|
|
559
|
+
throw new MetricsEngineError(`tina4 metrics timed out after ${TIMEOUT_MS / 1000}s on ${target}`);
|
|
560
|
+
}
|
|
561
|
+
throw new MetricsEngineError(`could not run ${binary}: ${err.message}`);
|
|
562
|
+
}
|
|
563
|
+
if (proc.status !== 0) {
|
|
564
|
+
const detail = (proc.stderr || proc.stdout || "").trim().split("\n")[0];
|
|
565
|
+
throw new MetricsEngineError(
|
|
566
|
+
`tina4 metrics failed on ${target}: ${detail || `exit code ${proc.status}`}`
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (!proc.stdout || !proc.stdout.trim()) {
|
|
570
|
+
throw new MetricsEngineError(`tina4 metrics produced no output for ${target}`);
|
|
1214
571
|
}
|
|
1215
572
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const avgMI = fileMetrics.length > 0 ? totalMI / fileMetrics.length : 0;
|
|
1228
|
-
|
|
1229
|
-
// Detect if we're scanning framework or project
|
|
1230
|
-
const frameworkDir = path.resolve(path.dirname(new URL(import.meta.url).pathname));
|
|
1231
|
-
const scanningFramework = rootPath === frameworkDir || rootPath.startsWith(frameworkDir + path.sep);
|
|
1232
|
-
|
|
1233
|
-
const result: Record<string, any> = {
|
|
1234
|
-
files_analyzed: fileMetrics.length,
|
|
1235
|
-
total_functions: allFunctions.length,
|
|
1236
|
-
avg_complexity: Math.round(avgCC * 100) / 100,
|
|
1237
|
-
avg_maintainability: Math.round(avgMI * 10) / 10,
|
|
1238
|
-
// Display-only: the top-15 for the "most complex functions" report.
|
|
1239
|
-
// Do NOT source offenders / --fail-on from this — capping here silently
|
|
1240
|
-
// hides the 16th+ over-threshold function from the gate. offenders()
|
|
1241
|
-
// reads "all_functions" (below) instead.
|
|
1242
|
-
most_complex_functions: allFunctions.slice(0, 15),
|
|
1243
|
-
// Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
|
|
1244
|
-
// so no function over the complexity threshold ever escapes the gate.
|
|
1245
|
-
all_functions: allFunctions,
|
|
1246
|
-
file_metrics: fileMetrics,
|
|
1247
|
-
violations,
|
|
1248
|
-
dependency_graph: importGraph,
|
|
1249
|
-
scan_mode: scanningFramework ? "framework" : "project",
|
|
1250
|
-
scan_root: rootPath,
|
|
1251
|
-
};
|
|
573
|
+
let payload: any;
|
|
574
|
+
try {
|
|
575
|
+
payload = JSON.parse(proc.stdout);
|
|
576
|
+
} catch (e) {
|
|
577
|
+
throw new MetricsEngineError(`tina4 metrics returned unreadable JSON: ${(e as Error).message}`);
|
|
578
|
+
}
|
|
579
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
580
|
+
throw new MetricsEngineError("tina4 metrics returned a non-object payload");
|
|
581
|
+
}
|
|
582
|
+
return payload;
|
|
583
|
+
}
|
|
1252
584
|
|
|
1253
|
-
|
|
1254
|
-
|
|
585
|
+
/** Pull a key out of the payload or throw naming what the engine is missing. */
|
|
586
|
+
function requireKey<T>(payload: Record<string, any>, key: string, isArray: boolean): T {
|
|
587
|
+
const value = payload[key];
|
|
588
|
+
const ok = isArray
|
|
589
|
+
? Array.isArray(value)
|
|
590
|
+
: value !== null && typeof value === "object" && !Array.isArray(value);
|
|
591
|
+
if (!ok) {
|
|
592
|
+
throw new MetricsEngineError(
|
|
593
|
+
`engine payload has no usable '${key}' - the installed tina4 CLI predates a field ` +
|
|
594
|
+
`the dashboard renders. Update it: ${INSTALL_HINT}`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return value as T;
|
|
1255
598
|
}
|
|
1256
599
|
|
|
1257
|
-
|
|
600
|
+
/** Full code analysis from the native engine, shaped for the dashboard. */
|
|
601
|
+
export function fullAnalysis(root: string = "src"): Record<string, any> {
|
|
602
|
+
const [resolved, scanMode] = resolveScanTarget(root);
|
|
603
|
+
const payload = runEngine(resolved);
|
|
1258
604
|
|
|
1259
|
-
|
|
1260
|
-
|
|
605
|
+
const summary = requireKey<Record<string, any>>(payload, "summary", false);
|
|
606
|
+
const fileMetrics = requireKey<Record<string, any>[]>(payload, "file_metrics", true);
|
|
607
|
+
const functions = requireKey<Record<string, any>[]>(payload, "most_complex_functions", true);
|
|
1261
608
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
609
|
+
const missing = SUMMARY_KEYS.filter((k) => !(k in summary));
|
|
610
|
+
if (missing.length) {
|
|
611
|
+
throw new MetricsEngineError(
|
|
612
|
+
`engine summary is missing ${missing.join(", ")} - update the CLI: ${INSTALL_HINT}`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (fileMetrics.length) {
|
|
616
|
+
const absent = FILE_KEYS.filter((k) => !(k in fileMetrics[0]));
|
|
617
|
+
if (absent.length) throw new MetricsEngineError(`engine file_metrics is missing ${absent.join(", ")}`);
|
|
618
|
+
}
|
|
619
|
+
if (functions.length) {
|
|
620
|
+
const absent = FUNCTION_KEYS.filter((k) => !(k in functions[0]));
|
|
621
|
+
if (absent.length) throw new MetricsEngineError(`engine function metrics are missing ${absent.join(", ")}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const result: Record<string, any> = {};
|
|
625
|
+
for (const key of SUMMARY_KEYS) result[key] = summary[key];
|
|
626
|
+
result.file_metrics = fileMetrics;
|
|
627
|
+
// Display cap only. offenders() reads the engine's own uncapped list, so a
|
|
628
|
+
// 16th over-threshold function is never hidden from the gate.
|
|
629
|
+
result.most_complex_functions = functions.slice(0, 15);
|
|
630
|
+
result.dependency_graph = payload.dependency_graph || {};
|
|
631
|
+
// The framework owns these two: the engine always reports "project" because
|
|
632
|
+
// it cannot know which directory is a framework package.
|
|
633
|
+
result.scan_mode = scanMode;
|
|
634
|
+
result.scan_root = path.resolve(resolved);
|
|
635
|
+
result.engine = "tina4-cli";
|
|
636
|
+
return result;
|
|
1269
637
|
}
|
|
1270
638
|
|
|
1271
639
|
export interface OffendersResult {
|
|
1272
|
-
offenders:
|
|
640
|
+
offenders: Record<string, any>[];
|
|
1273
641
|
summary: Record<string, any>;
|
|
1274
642
|
}
|
|
1275
643
|
|
|
1276
644
|
/**
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
* Reuses {@link fullAnalysis} (does NOT re-analyze — the result is mtime-cached).
|
|
1280
|
-
* Each offender is `{ file, line, kind, severity, score, detail }`.
|
|
1281
|
-
*
|
|
1282
|
-
* Rules (one offender per matching condition — SAME scoring as the master):
|
|
1283
|
-
* - function complexity > 10 → kind "complexity"
|
|
1284
|
-
* severity "error" if > 20 else "warn"; score = complexity
|
|
1285
|
-
* - file loc > 500 → kind "large_file" (warn); score = loc / 100
|
|
1286
|
-
* - file functions > 20 → kind "too_many_functions" (warn); score = functions / 4
|
|
1287
|
-
* - file maintainability < 40 → kind "low_maintainability"
|
|
1288
|
-
* severity "error" if < 20 else "warn"; score = 50 - mi
|
|
1289
|
-
* - file has_tests === false → kind "untested" (info); score = loc / 100
|
|
1290
|
-
*
|
|
1291
|
-
* Sorted by (severity rank, score) DESCENDING and truncated to `top`.
|
|
645
|
+
* Top code-health offenders from the native engine.
|
|
1292
646
|
*
|
|
1293
|
-
*
|
|
1294
|
-
* the CLI
|
|
1295
|
-
*
|
|
647
|
+
* The engine ranks and severity-tags them, and its own --fail-on gate reads the
|
|
648
|
+
* same list, so the CLI and the dashboard can never disagree about what counts
|
|
649
|
+
* as an offender.
|
|
1296
650
|
*/
|
|
1297
651
|
export function offenders(root: string = "src", top: number = 20): OffendersResult {
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
for (const fn of analysis.all_functions || analysis.most_complex_functions || []) {
|
|
1309
|
-
const cc: number = fn.complexity;
|
|
1310
|
-
if (cc > 10) {
|
|
1311
|
-
items.push({
|
|
1312
|
-
file: fn.file,
|
|
1313
|
-
line: fn.line,
|
|
1314
|
-
kind: "complexity",
|
|
1315
|
-
severity: cc > 20 ? "error" : "warn",
|
|
1316
|
-
score: cc,
|
|
1317
|
-
detail: `${fn.name} — cyclomatic complexity ${cc}`,
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// File-level rules.
|
|
1323
|
-
for (const fm of analysis.file_metrics || []) {
|
|
1324
|
-
const filePath: string = fm.path;
|
|
1325
|
-
const loc: number = fm.loc;
|
|
1326
|
-
const funcs: number = fm.functions;
|
|
1327
|
-
const mi: number = fm.maintainability;
|
|
1328
|
-
|
|
1329
|
-
if (loc > 500) {
|
|
1330
|
-
items.push({
|
|
1331
|
-
file: filePath,
|
|
1332
|
-
line: 1,
|
|
1333
|
-
kind: "large_file",
|
|
1334
|
-
severity: "warn",
|
|
1335
|
-
score: loc / 100,
|
|
1336
|
-
detail: `${loc} LOC (max 500)`,
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
if (funcs > 20) {
|
|
1341
|
-
items.push({
|
|
1342
|
-
file: filePath,
|
|
1343
|
-
line: 1,
|
|
1344
|
-
kind: "too_many_functions",
|
|
1345
|
-
severity: "warn",
|
|
1346
|
-
score: funcs / 4,
|
|
1347
|
-
detail: `${funcs} functions (max 20)`,
|
|
1348
|
-
});
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
if (mi < 40) {
|
|
1352
|
-
items.push({
|
|
1353
|
-
file: filePath,
|
|
1354
|
-
line: 1,
|
|
1355
|
-
kind: "low_maintainability",
|
|
1356
|
-
severity: mi < 20 ? "error" : "warn",
|
|
1357
|
-
score: 50 - mi,
|
|
1358
|
-
detail: `maintainability index ${mi} (min 40)`,
|
|
1359
|
-
});
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
if (fm.has_tests === false) {
|
|
1363
|
-
items.push({
|
|
1364
|
-
file: filePath,
|
|
1365
|
-
line: 1,
|
|
1366
|
-
kind: "untested",
|
|
1367
|
-
severity: "info",
|
|
1368
|
-
score: loc / 100,
|
|
1369
|
-
detail: "no referencing test",
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
// Sort by (severity rank, score) DESCENDING.
|
|
1375
|
-
items.sort((a, b) => {
|
|
1376
|
-
const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
1377
|
-
if (sevDiff !== 0) return sevDiff;
|
|
1378
|
-
return b.score - a.score;
|
|
1379
|
-
});
|
|
1380
|
-
|
|
1381
|
-
const summary = {
|
|
1382
|
-
files_analyzed: analysis.files_analyzed,
|
|
1383
|
-
total_functions: analysis.total_functions,
|
|
1384
|
-
avg_complexity: analysis.avg_complexity,
|
|
1385
|
-
avg_maintainability: analysis.avg_maintainability,
|
|
1386
|
-
scan_mode: analysis.scan_mode,
|
|
1387
|
-
scan_root: analysis.scan_root,
|
|
1388
|
-
total_offenders: items.length,
|
|
1389
|
-
};
|
|
1390
|
-
|
|
1391
|
-
return { offenders: items.slice(0, top), summary };
|
|
652
|
+
const [resolved, scanMode] = resolveScanTarget(root);
|
|
653
|
+
const payload = runEngine(resolved);
|
|
654
|
+
|
|
655
|
+
const found = requireKey<Record<string, any>[]>(payload, "offenders", true);
|
|
656
|
+
const summary = { ...requireKey<Record<string, any>>(payload, "summary", false) };
|
|
657
|
+
summary.scan_mode = scanMode;
|
|
658
|
+
summary.scan_root = path.resolve(resolved);
|
|
659
|
+
summary.engine = "tina4-cli";
|
|
660
|
+
if (summary.total_offenders === undefined) summary.total_offenders = found.length;
|
|
661
|
+
return { offenders: found.slice(0, top), summary };
|
|
1392
662
|
}
|
|
1393
663
|
|
|
1394
|
-
|
|
1395
|
-
|
|
664
|
+
/**
|
|
665
|
+
* Per-file metrics from the native engine.
|
|
666
|
+
*
|
|
667
|
+
* The engine accepts a single file for --path, so one code path serves both the
|
|
668
|
+
* whole-tree scan and one file.
|
|
669
|
+
*/
|
|
1396
670
|
export function fileDetail(filePath: string): Record<string, any> {
|
|
1397
|
-
|
|
1398
|
-
if (!fs.existsSync(resolved) && _lastScanRoot) {
|
|
1399
|
-
// Try resolving relative to the last scan root (framework mode)
|
|
1400
|
-
const candidate = path.resolve(_lastScanRoot, filePath);
|
|
1401
|
-
if (fs.existsSync(candidate)) {
|
|
1402
|
-
resolved = candidate;
|
|
1403
|
-
filePath = candidate;
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
if (!fs.existsSync(resolved)) {
|
|
1407
|
-
return { error: `File not found: ${filePath}` };
|
|
1408
|
-
}
|
|
671
|
+
if (!filePath) throw new MetricsEngineError("fileDetail needs a path");
|
|
1409
672
|
|
|
1410
|
-
|
|
1411
|
-
if (
|
|
1412
|
-
|
|
673
|
+
let target = filePath;
|
|
674
|
+
if (!fs.existsSync(target) && _lastScanRoot) {
|
|
675
|
+
// Try it relative to whatever the census last resolved, so the dashboard can
|
|
676
|
+
// pass a path taken straight out of file_metrics.
|
|
677
|
+
const candidate = path.join(_lastScanRoot, filePath);
|
|
678
|
+
if (fs.existsSync(candidate)) target = candidate;
|
|
1413
679
|
}
|
|
680
|
+
if (!fs.existsSync(target)) throw new MetricsEngineError(`no such file: ${filePath}`);
|
|
681
|
+
if (fs.statSync(target).isDirectory()) throw new MetricsEngineError(`not a file: ${filePath}`);
|
|
1414
682
|
|
|
1415
|
-
const
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const classes = countClassesQuick(source);
|
|
1421
|
-
const functions = extractFunctions(source, resolved);
|
|
1422
|
-
const imports = extractImports(source);
|
|
1423
|
-
|
|
1424
|
-
// Sort functions by complexity descending
|
|
1425
|
-
functions.sort((a, b) => b.complexity - a.complexity);
|
|
1426
|
-
|
|
1427
|
-
// Remove file field from function info for single-file detail
|
|
1428
|
-
const cleanFunctions = functions.map(({ file, ...rest }) => rest);
|
|
1429
|
-
|
|
1430
|
-
// Detect empty methods/functions (loc <= 1 means only a brace or pass-through)
|
|
1431
|
-
const warnings: { type: string; message: string; line: number }[] = [];
|
|
1432
|
-
for (const fn of cleanFunctions) {
|
|
1433
|
-
if (fn.loc <= 1) {
|
|
1434
|
-
warnings.push({
|
|
1435
|
-
type: "empty_method",
|
|
1436
|
-
message: `Method '${fn.name}' appears to be empty`,
|
|
1437
|
-
line: fn.line,
|
|
1438
|
-
});
|
|
1439
|
-
}
|
|
683
|
+
const payload = runEngine(target);
|
|
684
|
+
const fileMetrics = requireKey<Record<string, any>[]>(payload, "file_metrics", true);
|
|
685
|
+
if (!fileMetrics.length) {
|
|
686
|
+
throw new MetricsEngineError(`engine reported no metrics for ${filePath}`);
|
|
1440
687
|
}
|
|
1441
|
-
|
|
1442
|
-
return {
|
|
1443
|
-
path: filePath,
|
|
1444
|
-
loc,
|
|
1445
|
-
total_lines: lines.length,
|
|
1446
|
-
classes,
|
|
1447
|
-
functions: cleanFunctions,
|
|
1448
|
-
imports,
|
|
1449
|
-
warnings,
|
|
1450
|
-
};
|
|
688
|
+
return { ...fileMetrics[0], engine: "tina4-cli" };
|
|
1451
689
|
}
|