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.
Files changed (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -1,18 +1,23 @@
1
- // Tina4 Code Metrics regex-based static analysis for the dev dashboard.
2
- /**
3
- * Two-tier analysis:
4
- * 1. Quick metrics (instant): LOC, file counts, class/function counts
5
- * 2. Full analysis (on-demand, cached): cyclomatic complexity, maintainability
6
- * index, coupling, Halstead metrics, violations
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
- // ── Full Analysis (cached) ───────────────────────────────────
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
- function filesHash(root: string = "src"): string {
1107
- const h = crypto.createHash("md5");
1108
- const rootPath = path.resolve(root);
1109
- if (fs.existsSync(rootPath)) {
1110
- const files = walkFiles(rootPath, [".ts", ".js"]).sort();
1111
- for (const f of files) {
1112
- try {
1113
- const stat = fs.statSync(f);
1114
- h.update(`${f}:${stat.mtimeMs}`);
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
- export function fullAnalysis(root: string = "src"): Record<string, any> {
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
- const allFunctions: FunctionInfo[] = [];
1144
- const fileMetrics: Record<string, any>[] = [];
1145
- const importGraph: Record<string, string[]> = {};
1146
- const reverseGraph: Record<string, string[]> = {};
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
- for (const f of tsFiles) {
1149
- const source = readFileSafe(f);
1150
- if (source === null) continue;
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
- const relPath = relativePath(f, rootPath);
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
- // Extract imports for coupling analysis
1159
- const imports = extractImports(source);
1160
- importGraph[relPath] = imports;
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
- for (const imp of imports) {
1163
- if (!reverseGraph[imp]) {
1164
- reverseGraph[imp] = [];
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
- reverseGraph[imp].push(relPath);
537
+ return candidate;
1167
538
  }
539
+ }
540
+ return null;
541
+ }
1168
542
 
1169
- // Analyze functions/methods
1170
- const fileFunctions = extractFunctions(source, f, rootPath);
1171
- let fileComplexity = 0;
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
- for (const func of fileFunctions) {
1174
- fileComplexity += func.complexity;
1175
- allFunctions.push(func);
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
- // Halstead
1179
- const halstead = countHalstead(source);
1180
- const n1 = halstead.uniqueOperators.size;
1181
- const n2 = halstead.uniqueOperands.size;
1182
- const N1 = halstead.operators;
1183
- const N2 = halstead.operands;
1184
- const vocabulary = n1 + n2;
1185
- const length = N1 + N2;
1186
- const volume = vocabulary > 0 ? length * Math.log2(vocabulary) : 0;
1187
-
1188
- // Maintainability index
1189
- const avgCC =
1190
- fileFunctions.length > 0
1191
- ? fileComplexity / fileFunctions.length
1192
- : 0;
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
- // Sort: functions by complexity descending, files by maintainability ascending (worst first)
1217
- allFunctions.sort((a, b) => b.complexity - a.complexity);
1218
- fileMetrics.sort((a, b) => a.maintainability - b.maintainability);
1219
-
1220
- // Violations
1221
- const violations = detectViolations(allFunctions, fileMetrics);
1222
-
1223
- // Overall averages
1224
- const totalCC = allFunctions.reduce((sum, f) => sum + f.complexity, 0);
1225
- const avgCC = allFunctions.length > 0 ? totalCC / allFunctions.length : 0;
1226
- const totalMI = fileMetrics.reduce((sum, f) => sum + f.maintainability, 0);
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
- _fullCache = { hash: currentHash, data: result, time: now };
1254
- return result;
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
- // ── Top Offenders (CLI + dashboard) ──────────────────────────
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
- /** Severity ranking for sorting (higher = more severe). */
1260
- export const SEVERITY_RANK: Record<string, number> = { error: 2, warn: 1, info: 0 };
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
- export interface Offender {
1263
- file: string;
1264
- line: number;
1265
- kind: string;
1266
- severity: "error" | "warn" | "info";
1267
- score: number;
1268
- detail: string;
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: Offender[];
640
+ offenders: Record<string, any>[];
1273
641
  summary: Record<string, any>;
1274
642
  }
1275
643
 
1276
644
  /**
1277
- * Rank the worst code-quality issues into a single "top offenders" list.
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
- * Returns `{ offenders, summary }` where summary carries the headline numbers
1294
- * the CLI prints (files_analyzed, total_functions, avg_complexity,
1295
- * avg_maintainability, scan_mode, scan_root, total_offenders).
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 analysis = fullAnalysis(root);
1299
- if (analysis.error) {
1300
- return { offenders: [], summary: { error: analysis.error } };
1301
- }
1302
-
1303
- const items: Offender[] = [];
1304
-
1305
- // Function-level: cyclomatic complexity. Use the FULL function list (not the
1306
- // display-capped most_complex_functions[:15]) so a 16th+ over-threshold
1307
- // function is never silently dropped from the offenders list or --fail-on.
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
- // ── File Detail ──────────────────────────────────────────────
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
- let resolved = path.resolve(filePath);
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
- const source = readFileSafe(resolved);
1411
- if (source === null) {
1412
- return { error: `Could not read file: ${filePath}` };
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 lines = source.split("\n");
1416
- const loc = lines.filter(
1417
- (l) => l.trim() && !l.trim().startsWith("//")
1418
- ).length;
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
  }