mstro-app 0.5.1 → 0.5.6

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 (283) hide show
  1. package/PRIVACY.md +9 -9
  2. package/README.md +71 -28
  3. package/bin/commands/config.js +1 -1
  4. package/bin/mstro.js +55 -4
  5. package/dist/server/cli/eta-estimator.d.ts +55 -0
  6. package/dist/server/cli/eta-estimator.d.ts.map +1 -0
  7. package/dist/server/cli/eta-estimator.js +222 -0
  8. package/dist/server/cli/eta-estimator.js.map +1 -0
  9. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  10. package/dist/server/cli/headless/claude-invoker-process.js +9 -1
  11. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  12. package/dist/server/cli/headless/mcp-config.d.ts +22 -5
  13. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  14. package/dist/server/cli/headless/mcp-config.js +7 -5
  15. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  16. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  17. package/dist/server/cli/headless/runner.js +19 -0
  18. package/dist/server/cli/headless/runner.js.map +1 -1
  19. package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
  20. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  21. package/dist/server/cli/headless/stall-assessor.js +64 -9
  22. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  23. package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
  24. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  25. package/dist/server/cli/headless/tool-watchdog.js +19 -12
  26. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  27. package/dist/server/cli/headless/types.d.ts +16 -1
  28. package/dist/server/cli/headless/types.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-history-store.js +5 -1
  31. package/dist/server/cli/improvisation-history-store.js.map +1 -1
  32. package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
  33. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-output-queue.js +30 -7
  35. package/dist/server/cli/improvisation-output-queue.js.map +1 -1
  36. package/dist/server/cli/improvisation-session-manager.d.ts +35 -0
  37. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  38. package/dist/server/cli/improvisation-session-manager.js +58 -1
  39. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  40. package/dist/server/cli/improvisation-types.d.ts +9 -0
  41. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  42. package/dist/server/cli/improvisation-types.js.map +1 -1
  43. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
  44. package/dist/server/cli/retry/retry-runner-factory.js +1 -0
  45. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
  46. package/dist/server/engines/EngineEvent.d.ts +126 -0
  47. package/dist/server/engines/EngineEvent.d.ts.map +1 -0
  48. package/dist/server/engines/EngineEvent.js +11 -0
  49. package/dist/server/engines/EngineEvent.js.map +1 -0
  50. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
  51. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
  52. package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
  53. package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
  54. package/dist/server/engines/factory.d.ts +21 -0
  55. package/dist/server/engines/factory.d.ts.map +1 -0
  56. package/dist/server/engines/factory.js +152 -0
  57. package/dist/server/engines/factory.js.map +1 -0
  58. package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
  59. package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
  60. package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
  61. package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
  62. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
  63. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
  64. package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
  65. package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
  66. package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
  67. package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
  68. package/dist/server/engines/opencode/model-catalog.js +141 -0
  69. package/dist/server/engines/opencode/model-catalog.js.map +1 -0
  70. package/dist/server/engines/types.d.ts +146 -0
  71. package/dist/server/engines/types.d.ts.map +1 -0
  72. package/dist/server/engines/types.js +4 -0
  73. package/dist/server/engines/types.js.map +1 -0
  74. package/dist/server/index.js +9 -2
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
  77. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  78. package/dist/server/mcp/bouncer-haiku.js +8 -124
  79. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  80. package/dist/server/mcp/bouncer-integration.d.ts +45 -0
  81. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  82. package/dist/server/mcp/bouncer-integration.js +69 -5
  83. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  84. package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
  85. package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
  86. package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
  87. package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
  88. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
  89. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
  90. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
  91. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
  92. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
  93. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
  94. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
  95. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
  96. package/dist/server/mcp/classifier/factory.d.ts +70 -0
  97. package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
  98. package/dist/server/mcp/classifier/factory.js +155 -0
  99. package/dist/server/mcp/classifier/factory.js.map +1 -0
  100. package/dist/server/mcp/server.js +52 -0
  101. package/dist/server/mcp/server.js.map +1 -1
  102. package/dist/server/routes/index.d.ts +1 -0
  103. package/dist/server/routes/index.d.ts.map +1 -1
  104. package/dist/server/routes/index.js +1 -0
  105. package/dist/server/routes/index.js.map +1 -1
  106. package/dist/server/routes/internal.d.ts +16 -0
  107. package/dist/server/routes/internal.d.ts.map +1 -0
  108. package/dist/server/routes/internal.js +94 -0
  109. package/dist/server/routes/internal.js.map +1 -0
  110. package/dist/server/services/plan/agent-resolver.d.ts +26 -0
  111. package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
  112. package/dist/server/services/plan/agent-resolver.js +102 -0
  113. package/dist/server/services/plan/agent-resolver.js.map +1 -0
  114. package/dist/server/services/plan/composer.d.ts.map +1 -1
  115. package/dist/server/services/plan/composer.js +59 -11
  116. package/dist/server/services/plan/composer.js.map +1 -1
  117. package/dist/server/services/plan/executor.d.ts.map +1 -1
  118. package/dist/server/services/plan/executor.js +3 -1
  119. package/dist/server/services/plan/executor.js.map +1 -1
  120. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  121. package/dist/server/services/plan/issue-prompt-builder.js +33 -1
  122. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  123. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  124. package/dist/server/services/plan/parser-core.js +1 -0
  125. package/dist/server/services/plan/parser-core.js.map +1 -1
  126. package/dist/server/services/plan/types.d.ts +1 -0
  127. package/dist/server/services/plan/types.d.ts.map +1 -1
  128. package/dist/server/services/runtime-info.d.ts +3 -0
  129. package/dist/server/services/runtime-info.d.ts.map +1 -0
  130. package/dist/server/services/runtime-info.js +21 -0
  131. package/dist/server/services/runtime-info.js.map +1 -0
  132. package/dist/server/services/settings.d.ts +76 -2
  133. package/dist/server/services/settings.d.ts.map +1 -1
  134. package/dist/server/services/settings.js +127 -4
  135. package/dist/server/services/settings.js.map +1 -1
  136. package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
  137. package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
  138. package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
  139. package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
  140. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  141. package/dist/server/services/websocket/git-branch-handlers.js +19 -6
  142. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  143. package/dist/server/services/websocket/handler.d.ts +25 -1
  144. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  145. package/dist/server/services/websocket/handler.js +84 -2
  146. package/dist/server/services/websocket/handler.js.map +1 -1
  147. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  148. package/dist/server/services/websocket/quality-complexity.js +78 -26
  149. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  150. package/dist/server/services/websocket/quality-eta.d.ts +47 -0
  151. package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
  152. package/dist/server/services/websocket/quality-eta.js +110 -0
  153. package/dist/server/services/websocket/quality-eta.js.map +1 -0
  154. package/dist/server/services/websocket/quality-grading.d.ts +27 -4
  155. package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
  156. package/dist/server/services/websocket/quality-grading.js +369 -201
  157. package/dist/server/services/websocket/quality-grading.js.map +1 -1
  158. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  159. package/dist/server/services/websocket/quality-handlers.js +145 -7
  160. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  161. package/dist/server/services/websocket/quality-operations.d.ts +34 -0
  162. package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
  163. package/dist/server/services/websocket/quality-operations.js +47 -0
  164. package/dist/server/services/websocket/quality-operations.js.map +1 -0
  165. package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
  166. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  167. package/dist/server/services/websocket/quality-persistence.js +10 -0
  168. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  169. package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
  170. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  171. package/dist/server/services/websocket/quality-review-agent.js +105 -56
  172. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  173. package/dist/server/services/websocket/quality-service.d.ts +9 -1
  174. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  175. package/dist/server/services/websocket/quality-service.js +334 -14
  176. package/dist/server/services/websocket/quality-service.js.map +1 -1
  177. package/dist/server/services/websocket/quality-tools.d.ts +21 -0
  178. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  179. package/dist/server/services/websocket/quality-tools.js +49 -0
  180. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  181. package/dist/server/services/websocket/quality-types.d.ts +35 -2
  182. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  183. package/dist/server/services/websocket/quality-types.js +1 -1
  184. package/dist/server/services/websocket/quality-types.js.map +1 -1
  185. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  186. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  187. package/dist/server/services/websocket/session-handlers.js +60 -9
  188. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  189. package/dist/server/services/websocket/session-history.js +3 -0
  190. package/dist/server/services/websocket/session-history.js.map +1 -1
  191. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  192. package/dist/server/services/websocket/session-initialization.js +158 -42
  193. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  194. package/dist/server/services/websocket/session-registry.d.ts +25 -0
  195. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  196. package/dist/server/services/websocket/session-registry.js +19 -0
  197. package/dist/server/services/websocket/session-registry.js.map +1 -1
  198. package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
  199. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  200. package/dist/server/services/websocket/settings-handlers.js +35 -4
  201. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  202. package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
  203. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
  204. package/dist/server/services/websocket/tab-broadcast.js +10 -2
  205. package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
  206. package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
  207. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
  208. package/dist/server/services/websocket/tab-event-buffer.js +138 -12
  209. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
  210. package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
  211. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
  212. package/dist/server/services/websocket/tab-event-replay.js +55 -2
  213. package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
  214. package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
  215. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  216. package/dist/server/services/websocket/tab-handlers.js +47 -2
  217. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  218. package/dist/server/services/websocket/types.d.ts +67 -7
  219. package/dist/server/services/websocket/types.d.ts.map +1 -1
  220. package/dist/server/services/websocket/types.js +12 -6
  221. package/dist/server/services/websocket/types.js.map +1 -1
  222. package/package.json +5 -3
  223. package/server/cli/eta-estimator.ts +249 -0
  224. package/server/cli/headless/claude-invoker-process.ts +9 -1
  225. package/server/cli/headless/mcp-config.ts +30 -5
  226. package/server/cli/headless/runner.ts +21 -0
  227. package/server/cli/headless/stall-assessor.ts +93 -0
  228. package/server/cli/headless/tool-watchdog.ts +21 -0
  229. package/server/cli/headless/types.ts +16 -1
  230. package/server/cli/improvisation-history-store.ts +4 -1
  231. package/server/cli/improvisation-output-queue.ts +29 -7
  232. package/server/cli/improvisation-session-manager.ts +63 -1
  233. package/server/cli/improvisation-types.ts +9 -0
  234. package/server/cli/retry/retry-runner-factory.ts +1 -0
  235. package/server/engines/EngineEvent.ts +156 -0
  236. package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
  237. package/server/engines/factory.ts +176 -0
  238. package/server/engines/opencode/OpenCodeEngine.ts +786 -0
  239. package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
  240. package/server/engines/opencode/model-catalog.ts +217 -0
  241. package/server/engines/types.ts +173 -0
  242. package/server/index.ts +9 -1
  243. package/server/mcp/bouncer-haiku.ts +21 -145
  244. package/server/mcp/bouncer-integration.ts +107 -5
  245. package/server/mcp/classifier/BouncerClassifier.ts +40 -0
  246. package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
  247. package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
  248. package/server/mcp/classifier/factory.ts +195 -0
  249. package/server/mcp/server.ts +57 -0
  250. package/server/routes/index.ts +1 -0
  251. package/server/routes/internal.ts +112 -0
  252. package/server/services/plan/agent-resolver.ts +115 -0
  253. package/server/services/plan/agents/code-review.md +38 -8
  254. package/server/services/plan/composer.ts +63 -11
  255. package/server/services/plan/executor.ts +3 -1
  256. package/server/services/plan/issue-prompt-builder.ts +39 -1
  257. package/server/services/plan/parser-core.ts +1 -0
  258. package/server/services/plan/types.ts +4 -0
  259. package/server/services/runtime-info.ts +24 -0
  260. package/server/services/settings.ts +161 -4
  261. package/server/services/websocket/ask-user-question-bridge.ts +148 -0
  262. package/server/services/websocket/git-branch-handlers.ts +20 -6
  263. package/server/services/websocket/handler.ts +89 -2
  264. package/server/services/websocket/quality-complexity.ts +80 -26
  265. package/server/services/websocket/quality-eta.ts +155 -0
  266. package/server/services/websocket/quality-grading.ts +445 -222
  267. package/server/services/websocket/quality-handlers.ts +153 -7
  268. package/server/services/websocket/quality-operations.ts +72 -0
  269. package/server/services/websocket/quality-persistence.ts +17 -0
  270. package/server/services/websocket/quality-review-agent.ts +154 -64
  271. package/server/services/websocket/quality-service.ts +361 -13
  272. package/server/services/websocket/quality-tools.ts +51 -0
  273. package/server/services/websocket/quality-types.ts +41 -2
  274. package/server/services/websocket/session-handlers.ts +67 -10
  275. package/server/services/websocket/session-history.ts +3 -0
  276. package/server/services/websocket/session-initialization.ts +189 -46
  277. package/server/services/websocket/session-registry.ts +37 -0
  278. package/server/services/websocket/settings-handlers.ts +41 -4
  279. package/server/services/websocket/tab-broadcast.ts +10 -2
  280. package/server/services/websocket/tab-event-buffer.ts +143 -11
  281. package/server/services/websocket/tab-event-replay.ts +70 -3
  282. package/server/services/websocket/tab-handlers.ts +53 -5
  283. package/server/services/websocket/types.ts +85 -7
@@ -4,16 +4,29 @@
4
4
  // Multi-Dimensional Quality Grading
5
5
  // ============================================================================
6
6
  //
7
- // Pure logic for the three-dimension grading model:
8
- // - Security — severity-threshold (worst severity issue determines grade)
9
- // - Reliability — severity-threshold, slightly more lenient than Security
10
- // - Maintainability — density-based (issues / KLOC) with a severity escape hatch
7
+ // Three independent dimensions, severity-driven where it matters most:
11
8
  //
12
- // Rationale (why these specific bands):
13
- // The previous single-score exponential-decay model conflated security holes
14
- // with prettier complaints. Industry tools (SonarQube, Code Climate, CodeScene)
15
- // all separate severity-driven dimensions from aggregate metrics so that a
16
- // critical issue can never be "averaged away" by a clean lint score.
9
+ // - Security — strictest; any medium-or-worse issue capped below B
10
+ // - Reliability — slightly lenient (not every complexity warning is a bug)
11
+ // - Maintainability density-based (issues / KLOC) with a severity cap
12
+ //
13
+ // Letter grade scale (no `D` band F covers 56-69, F- below 55):
14
+ //
15
+ // A+ 97-100 A 93-96 A- 90-92 "ship it"
16
+ // B+ 87-89 B 83-86 B- 80-82 "minor cleanup"
17
+ // C+ 77-79 C 73-76 C- 70-72 "needs work"
18
+ // F+ 65-69 F 56-64 F- 0-55 "broken"
19
+ //
20
+ // Three "auto-fail" rules layer on top of the dimension grades:
21
+ //
22
+ // 1. Build/compile errors → caps Reliability at F-
23
+ // 2. Critical security issue → caps Security at F-
24
+ // 3. Architectural findings → drop the affected dim's grade by 1-2 letters
25
+ //
26
+ // Industry alignment: SonarQube uses severity-driven A-E grades; Code Climate
27
+ // uses density-driven A-F. This module borrows the strictness of the former
28
+ // for Security/Reliability and the density model of the latter for
29
+ // Maintainability — matching the two metrics where each works best.
17
30
  //
18
31
  // All functions in this module are pure: same inputs -> same outputs, no I/O.
19
32
  // ============================================================================
@@ -23,7 +36,23 @@
23
36
  // ============================================================================
24
37
 
25
38
  export type DimensionName = 'security' | 'reliability' | 'maintainability';
26
- export type Grade = 'A' | 'B' | 'C' | 'D' | 'F' | 'N/A';
39
+
40
+ /**
41
+ * Letter grades. New code emits all variants except `'D'` (kept only for
42
+ * legacy reports persisted before the +/- rollout). See quality-types.ts for
43
+ * the score-band reference.
44
+ */
45
+ export type Grade =
46
+ | 'A+' | 'A' | 'A-'
47
+ | 'B+' | 'B' | 'B-'
48
+ | 'C+' | 'C' | 'C-'
49
+ | 'D'
50
+ | 'F+' | 'F' | 'F-'
51
+ | 'N/A';
52
+
53
+ /** Letter grades excluding modifiers — used internally for band logic. */
54
+ type BaseGrade = 'A' | 'B' | 'C' | 'F';
55
+
27
56
  type Severity = 'critical' | 'high' | 'medium' | 'low';
28
57
 
29
58
  export interface DimensionScore {
@@ -53,7 +82,7 @@ export interface QualityRating {
53
82
  // ============================================================================
54
83
 
55
84
  const SECURITY_CATEGORIES = new Set<string>(['security']);
56
- const RELIABILITY_CATEGORIES = new Set<string>(['bugs', 'logic', 'performance', 'complexity']);
85
+ const RELIABILITY_CATEGORIES = new Set<string>(['bugs', 'logic', 'performance', 'complexity', 'build']);
57
86
  const MAINTAINABILITY_CATEGORIES = new Set<string>([
58
87
  'lint',
59
88
  'linting',
@@ -77,53 +106,93 @@ export function categoryToDimension(category: string): DimensionName {
77
106
  return 'maintainability';
78
107
  }
79
108
 
109
+ /** Categories that represent architectural problems — used by the arch penalty. */
110
+ const ARCHITECTURE_CATEGORIES = new Set<string>(['architecture', 'oop']);
111
+
80
112
  // ============================================================================
81
- // Legacy Fallback
113
+ // Score Bands & Modifier Math
82
114
  // ============================================================================
83
115
 
84
116
  /**
85
- * Score-to-grade conversion used by legacy callers that still operate on a
86
- * single 0-100 number. The new multi-dimensional path computes grades
87
- * directly from finding shape; this remains for backward compatibility.
117
+ * Score boundaries for each base grade. Note the gap between C (70+) and F+
118
+ * (≤69): the band 60-69 maps to F+ instead of D, per product spec ("60s and
119
+ * below is F").
88
120
  */
89
- export function gradeFromScore(score: number): Grade {
90
- if (score >= 90) return 'A';
91
- if (score >= 80) return 'B';
92
- if (score >= 70) return 'C';
93
- if (score >= 60) return 'D';
94
- return 'F';
95
- }
96
-
97
- // ============================================================================
98
- // Score Bands
99
- // ============================================================================
100
-
101
- const BAND_TOP: Record<Exclude<Grade, 'N/A'>, number> = {
121
+ const BASE_BAND_TOP: Record<BaseGrade, number> = {
102
122
  A: 100,
103
123
  B: 89,
104
124
  C: 79,
105
- D: 69,
106
- F: 59,
125
+ F: 69, // F covers 56-69 (F+ for 65-69, F for 56-64) — F- splits off below
107
126
  };
108
-
109
- const BAND_BOTTOM: Record<Exclude<Grade, 'N/A'>, number> = {
127
+ const BASE_BAND_BOTTOM: Record<BaseGrade, number> = {
110
128
  A: 90,
111
129
  B: 80,
112
130
  C: 70,
113
- D: 60,
114
- F: 0,
131
+ F: 56, // F- covers 0-55 — handled specially in scoreToGrade()
115
132
  };
116
133
 
117
134
  /**
118
- * Linearly interpolate a score within a grade's band.
135
+ * Convert a 0-100 score to the full letter grade including +/- modifier.
136
+ *
137
+ * Within an A/B/C band, the band is split into thirds:
138
+ * X- bottom third (e.g., A-: 90-92)
139
+ * X middle third (e.g., A : 93-96)
140
+ * X+ top third (e.g., A+: 97-100)
141
+ *
142
+ * The F band uses two slices instead of three because there is no academic
143
+ * "F0" anchor and the user wanted F+/F/F-:
144
+ * F- 0-55 "critically broken"
145
+ * F 56-64 "broken"
146
+ * F+ 65-69 "barely failing"
147
+ *
148
+ * Compile/critical-severity hard caps are applied separately, not by score.
149
+ */
150
+ export function scoreToGrade(score: number): Grade {
151
+ if (score >= 97) return 'A+';
152
+ if (score >= 93) return 'A';
153
+ if (score >= 90) return 'A-';
154
+ if (score >= 87) return 'B+';
155
+ if (score >= 83) return 'B';
156
+ if (score >= 80) return 'B-';
157
+ if (score >= 77) return 'C+';
158
+ if (score >= 73) return 'C';
159
+ if (score >= 70) return 'C-';
160
+ if (score >= 65) return 'F+';
161
+ if (score >= 56) return 'F';
162
+ return 'F-';
163
+ }
164
+
165
+ /**
166
+ * Legacy single-letter conversion. Returns the *base* grade only (no
167
+ * modifier) for compatibility with callers that pre-date the +/- rollout
168
+ * (`scoreBreakdown.categoryPenalties[].grade`, etc.). New surfaces should
169
+ * call `scoreToGrade()` instead.
170
+ */
171
+ export function gradeFromScore(score: number): Grade {
172
+ const full = scoreToGrade(score);
173
+ // Strip the modifier so legacy callers still see exactly one of A/B/C/F.
174
+ return baseGradeOf(full);
175
+ }
176
+
177
+ /** Strip the +/- modifier from a letter grade. */
178
+ function baseGradeOf(g: Grade): Grade {
179
+ if (g === 'N/A' || g === 'D') return g;
180
+ if (g.startsWith('A')) return 'A';
181
+ if (g.startsWith('B')) return 'B';
182
+ if (g.startsWith('C')) return 'C';
183
+ return 'F';
184
+ }
185
+
186
+ /**
187
+ * Linearly interpolate a score within a base band.
119
188
  *
120
- * `position` is in [0, 1]: 0 means "as bad as this grade gets" (band bottom),
121
- * 1 means "as good as this grade gets" (band top, just below the next grade).
189
+ * `position` is in [0, 1]: 0 = "as bad as this grade gets" (band bottom),
190
+ * 1 = "as good as this grade gets" (band top, just below the next grade).
122
191
  */
123
- function scoreInBand(grade: Exclude<Grade, 'N/A'>, position: number): number {
192
+ function scoreInBand(grade: BaseGrade, position: number): number {
124
193
  const clamped = Math.max(0, Math.min(1, position));
125
- const bottom = BAND_BOTTOM[grade];
126
- const top = BAND_TOP[grade];
194
+ const bottom = BASE_BAND_BOTTOM[grade];
195
+ const top = BASE_BAND_TOP[grade];
127
196
  return Math.round(bottom + (top - bottom) * clamped);
128
197
  }
129
198
 
@@ -161,6 +230,19 @@ function worstSeverity(counts: SeverityCounts): Severity | null {
161
230
  return null;
162
231
  }
163
232
 
233
+ /**
234
+ * Result of consulting a dimension's severity escape hatch — used by both
235
+ * Reliability and Maintainability to short-circuit a forgiving density grade
236
+ * when the underlying findings include a critical or high. The grade is the
237
+ * worst the dimension may receive after applying the escape; the consumer
238
+ * still picks `min(severityEscape.grade, band.grade)` so an even worse
239
+ * density-derived grade isn't paved over.
240
+ */
241
+ interface SeverityEscape {
242
+ grade: BaseGrade;
243
+ note: string;
244
+ }
245
+
164
246
  // ============================================================================
165
247
  // Security Dimension
166
248
  // ============================================================================
@@ -170,148 +252,152 @@ function worstSeverity(counts: SeverityCounts): Severity | null {
170
252
  * security finding immediately drops the grade below B because security
171
253
  * issues can't be amortized over codebase size.
172
254
  *
173
- * Within-band score: more findings at the threshold severity -> lower score.
174
- * The interpolation favors "fewer issues is meaningfully better" so 1 medium
175
- * scores higher than 5 mediums even though both are grade C.
255
+ * A critical security issue caps at F- (the worst grade). One low-severity
256
+ * finding still earns a B- because every team has a few.
176
257
  */
177
258
  function gradeSecurity(findings: Array<{ severity: string }>): DimensionScore {
178
259
  const counts = countSeverities(findings);
179
260
  const worst = worstSeverity(counts);
180
261
 
181
262
  if (counts.total === 0) {
182
- return {
183
- name: 'security',
184
- score: 100,
185
- grade: 'A',
186
- rationale: '0 security findings',
187
- available: true,
188
- findingCount: 0,
189
- worstSeverity: null,
190
- };
263
+ return makeDimension('security', 100, '0 security findings', 0, null);
191
264
  }
192
265
 
193
- let grade: Exclude<Grade, 'N/A'>;
266
+ if (counts.critical > 0) {
267
+ // Critical security issue → F-, not just F. There's no recovering by
268
+ // averaging this away across a clean codebase.
269
+ return makeDimension(
270
+ 'security',
271
+ Math.max(0, 55 - counts.critical * 5),
272
+ `${counts.critical} critical-severity security ${pluralize('issue', counts.critical)}`,
273
+ counts.total,
274
+ worst,
275
+ );
276
+ }
277
+
278
+ let baseGrade: BaseGrade;
194
279
  let position: number;
195
280
  let rationale: string;
196
281
 
197
- if (counts.critical > 0) {
198
- grade = 'F';
199
- // F band: fewer criticals -> higher within-band, but still F.
200
- position = 1 / (1 + counts.critical);
201
- rationale = `${counts.critical} critical-severity security ${pluralize('issue', counts.critical)}`;
202
- } else if (counts.high > 0) {
203
- grade = 'D';
282
+ if (counts.high > 0) {
283
+ baseGrade = 'F';
204
284
  position = 1 / (1 + counts.high);
205
285
  rationale = `${counts.high} high-severity security ${pluralize('issue', counts.high)}`;
206
286
  } else if (counts.medium > 0) {
207
- grade = 'C';
287
+ baseGrade = 'C';
208
288
  position = 1 / (1 + counts.medium);
209
289
  rationale = `${counts.medium} medium-severity security ${pluralize('issue', counts.medium)}`;
210
290
  } else {
211
291
  // Only low-severity findings.
212
- grade = 'B';
213
- // 1 low -> top of B (89); more lows -> down toward 80.
292
+ baseGrade = 'B';
214
293
  position = 1 / Math.max(1, counts.low);
215
294
  rationale = `${counts.low} low-severity security ${pluralize('issue', counts.low)}`;
216
295
  }
217
296
 
218
- return {
219
- name: 'security',
220
- score: scoreInBand(grade, position),
221
- grade,
222
- rationale,
223
- available: true,
224
- findingCount: counts.total,
225
- worstSeverity: worst,
226
- };
297
+ const score = scoreInBand(baseGrade, position);
298
+ return makeDimension('security', score, rationale, counts.total, worst);
227
299
  }
228
300
 
229
301
  // ============================================================================
230
302
  // Reliability Dimension
231
303
  // ============================================================================
304
+ //
305
+ // Reliability uses the same density-based model as Maintainability, with a
306
+ // severity escape hatch — softer than the previous "2+ high → F" rule, which
307
+ // over-penalised codebases that had a handful of edge-case bugs flagged on
308
+ // rarely-executed paths. The new model:
309
+ //
310
+ // - Density ladder (issues per KLOC) at ≥5 KLOC
311
+ // - Absolute-count ladder at <5 KLOC (small projects shouldn't be density-
312
+ // rated; one extra finding moves the needle by 1.0+/KLOC)
313
+ // - Severity escape: 1 critical caps at F, any high caps at C
314
+ //
315
+ // Rationale: a real-world 10 KLOC service with 4 plausibly-improbable HIGH
316
+ // bugs (race conditions on degraded paths, edge-case fly-replay leaks)
317
+ // previously landed at F, dragging the entire app to F. Under this model
318
+ // it lands at C — "needs work" — which matches how a senior engineer would
319
+ // triage it on a code review. Critical bugs and compile errors still hit
320
+ // the F-tier through the escape hatch.
321
+ //
322
+ // Build/compile errors enter via the `build` category with severity `critical`,
323
+ // so they trip the escape hatch and hit F regardless of density.
232
324
 
233
- interface BandResult {
234
- grade: Exclude<Grade, 'N/A'>;
325
+ interface ReliabilityBand {
326
+ grade: BaseGrade;
235
327
  position: number;
236
- rationale: string;
328
+ label: string;
237
329
  }
238
330
 
239
- function reliabilityBandClean(counts: SeverityCounts): BandResult {
240
- const position = counts.total === 0 ? 1 : 0.5;
241
- const rationale = counts.total === 0 ? '0 reliability findings' : '1 low-severity reliability issue';
242
- return { grade: 'A', position, rationale };
331
+ function reliabilityByCount(n: number): ReliabilityBand {
332
+ // Stricter than Maintainability's count ladder: a couple of real bugs hurt
333
+ // more than a couple of lint warnings, but a single isolated medium bug on
334
+ // a small project shouldn't pin the codebase at C.
335
+ const label = `${n} reliability ${pluralize('issue', n)}`;
336
+ if (n <= 2) return { grade: 'A', position: 1 - n / 2, label };
337
+ if (n <= 6) return { grade: 'B', position: 1 - (n - 2) / 4, label };
338
+ if (n <= 15) return { grade: 'C', position: 1 - (n - 6) / 9, label };
339
+ return { grade: 'F', position: 1 / (1 + (n - 15) / 15), label };
243
340
  }
244
341
 
245
- function reliabilityBandSevere(counts: SeverityCounts): BandResult | null {
246
- if (counts.critical > 0) {
247
- return {
248
- grade: 'F',
249
- position: 1 / (1 + counts.critical),
250
- rationale: `${counts.critical} critical-severity ${pluralize('bug', counts.critical)}`,
251
- };
252
- }
253
- if (counts.high >= 2) {
254
- return {
255
- grade: 'D',
256
- position: 1 / (1 + (counts.high - 1)),
257
- rationale: `${counts.high} high-severity ${pluralize('bug', counts.high)}`,
258
- };
259
- }
260
- return null;
342
+ function reliabilityByDensity(n: number, kloc: number): ReliabilityBand {
343
+ // Density thresholds are tighter than Maintainability (5/10/25). A 50 KLOC
344
+ // codebase with 100 reliability bugs (density 2) is "minor cleanup", not
345
+ // pristine — but 1.4/KLOC is still A-band because real-world projects
346
+ // never get to zero. The escape hatch handles severity outliers above this.
347
+ const density = n / kloc;
348
+ const label = `${roundOne(density)} reliability ${pluralize('issue', n)} / KLOC`;
349
+ if (density < 1.5) return { grade: 'A', position: 1 - density / 1.5, label };
350
+ if (density < 4) return { grade: 'B', position: 1 - (density - 1.5) / 2.5, label };
351
+ if (density < 8) return { grade: 'C', position: 1 - (density - 4) / 4, label };
352
+ return { grade: 'F', position: 1 / (1 + (density - 8) / 8), label };
261
353
  }
262
354
 
263
- function reliabilityBandMid(counts: SeverityCounts): BandResult {
264
- if (counts.high >= 1) {
265
- return {
266
- grade: 'C',
267
- position: 1 / (1 + counts.high),
268
- rationale: `${counts.high} high-severity ${pluralize('bug', counts.high)}`,
269
- };
270
- }
271
- if (counts.medium >= 3) {
272
- return {
273
- grade: 'C',
274
- position: 1 / Math.max(1, counts.medium - 2),
275
- rationale: `${counts.medium} medium-severity reliability ${pluralize('issue', counts.medium)}`,
276
- };
355
+ function reliabilityEscape(counts: SeverityCounts): SeverityEscape | null {
356
+ if (counts.critical > 0) {
357
+ return { grade: 'F', note: `${counts.critical} critical-severity ${pluralize('bug', counts.critical)}` };
277
358
  }
278
- if (counts.medium >= 1) {
279
- return {
280
- grade: 'B',
281
- position: 1 / Math.max(1, counts.medium),
282
- rationale: `${counts.medium} medium-severity reliability ${pluralize('issue', counts.medium)}`,
283
- };
359
+ if (counts.high > 0) {
360
+ return { grade: 'C', note: `${counts.high} high-severity ${pluralize('bug', counts.high)}` };
284
361
  }
285
- // Only low-severity findings, > 1 of them.
286
- return {
287
- grade: 'B',
288
- position: 1 / Math.max(1, counts.low - 1),
289
- rationale: `${counts.low} low-severity reliability ${pluralize('issue', counts.low)}`,
290
- };
362
+ return null;
291
363
  }
292
364
 
293
365
  /**
294
- * Reliability grading — slightly more lenient than Security because not every
295
- * complexity warning is a runtime defect. A single low-severity logic issue
296
- * still earns an A; medium issues escalate gradually.
366
+ * Reliability grading — density-based with a severity escape hatch.
367
+ *
368
+ * - Empty / ≤1 low: A-band (clean by convention).
369
+ * - Density-based grade (≥5 KLOC) or count-based grade (<5 KLOC) drives
370
+ * the baseline. Both ladders mirror Maintainability's so reliability and
371
+ * maintainability remain comparable at a glance.
372
+ * - Severity escape: critical → F, high → C. This matches Maintainability and
373
+ * prevents a handful of medium-density bugs from being silently rated A
374
+ * when at least one is severe.
375
+ *
376
+ * Build/compile errors flow in via `build` category with severity `critical`
377
+ * and therefore land at F via the escape hatch — no special-case branching.
297
378
  */
298
- function gradeReliability(findings: Array<{ severity: string }>): DimensionScore {
379
+ function gradeReliability(findings: Array<{ severity: string }>, totalLines: number): DimensionScore {
299
380
  const counts = countSeverities(findings);
300
381
  const worst = worstSeverity(counts);
301
- const isClean = counts.total === 0 || (counts.low <= 1 && counts.medium === 0 && counts.high === 0 && counts.critical === 0);
302
- const band = isClean
303
- ? reliabilityBandClean(counts)
304
- : reliabilityBandSevere(counts) ?? reliabilityBandMid(counts);
382
+ const kloc = Math.max(totalLines / 1000, 1.0);
305
383
 
306
- return {
307
- name: 'reliability',
308
- score: scoreInBand(band.grade, band.position),
309
- grade: band.grade,
310
- rationale: band.rationale,
311
- available: true,
312
- findingCount: counts.total,
313
- worstSeverity: worst,
314
- };
384
+ if (counts.total === 0) {
385
+ return makeDimension('reliability', 100, '0 reliability findings', 0, null);
386
+ }
387
+
388
+ // ≤1 low and nothing else is treated as clean — every team has one.
389
+ if (counts.low <= 1 && counts.medium === 0 && counts.high === 0 && counts.critical === 0) {
390
+ return makeDimension('reliability', scoreInBand('A', 0.5), '1 low-severity reliability issue', counts.total, worst);
391
+ }
392
+
393
+ const band = kloc < 5 ? reliabilityByCount(counts.total) : reliabilityByDensity(counts.total, kloc);
394
+ const severityCap = reliabilityEscape(counts);
395
+ const useCap = severityCap && baseIsWorse(severityCap.grade, band.grade);
396
+ const finalGrade = useCap ? severityCap.grade : band.grade;
397
+ const finalPosition = useCap ? 0.5 : band.position;
398
+ const rationale = useCap ? `${band.label}, ${severityCap.note}` : band.label;
399
+
400
+ return makeDimension('reliability', scoreInBand(finalGrade, finalPosition), rationale, counts.total, worst);
315
401
  }
316
402
 
317
403
  // ============================================================================
@@ -319,7 +405,7 @@ function gradeReliability(findings: Array<{ severity: string }>): DimensionScore
319
405
  // ============================================================================
320
406
 
321
407
  interface MaintainabilityBand {
322
- grade: Exclude<Grade, 'N/A'>;
408
+ grade: BaseGrade;
323
409
  position: number;
324
410
  label: string;
325
411
  }
@@ -329,8 +415,7 @@ function maintainabilityByCount(n: number): MaintainabilityBand {
329
415
  if (n <= 5) return { grade: 'A', position: 1 - n / 5, label };
330
416
  if (n <= 15) return { grade: 'B', position: 1 - (n - 5) / 10, label };
331
417
  if (n <= 30) return { grade: 'C', position: 1 - (n - 15) / 15, label };
332
- if (n <= 60) return { grade: 'D', position: 1 - (n - 30) / 30, label };
333
- return { grade: 'F', position: 1 / (1 + (n - 60) / 30), label };
418
+ return { grade: 'F', position: 1 / (1 + (n - 30) / 30), label };
334
419
  }
335
420
 
336
421
  function maintainabilityByDensity(n: number, kloc: number): MaintainabilityBand {
@@ -339,18 +424,12 @@ function maintainabilityByDensity(n: number, kloc: number): MaintainabilityBand
339
424
  if (density < 5) return { grade: 'A', position: 1 - density / 5, label };
340
425
  if (density < 10) return { grade: 'B', position: 1 - (density - 5) / 5, label };
341
426
  if (density < 25) return { grade: 'C', position: 1 - (density - 10) / 15, label };
342
- if (density < 50) return { grade: 'D', position: 1 - (density - 25) / 25, label };
343
- return { grade: 'F', position: 1 / (1 + (density - 50) / 25), label };
344
- }
345
-
346
- interface SeverityEscape {
347
- grade: Exclude<Grade, 'N/A'>;
348
- note: string;
427
+ return { grade: 'F', position: 1 / (1 + (density - 25) / 25), label };
349
428
  }
350
429
 
351
430
  function maintainabilityEscape(counts: SeverityCounts): SeverityEscape | null {
352
431
  if (counts.critical > 0) {
353
- return { grade: 'D', note: `${counts.critical} critical-severity ${pluralize('issue', counts.critical)}` };
432
+ return { grade: 'F', note: `${counts.critical} critical-severity ${pluralize('issue', counts.critical)}` };
354
433
  }
355
434
  if (counts.high > 0) {
356
435
  return { grade: 'C', note: `${counts.high} high-severity ${pluralize('issue', counts.high)}` };
@@ -364,64 +443,169 @@ function maintainabilityEscape(counts: SeverityCounts): SeverityEscape | null {
364
443
  * (one extra lint issue moves density by 1.0+), so we fall back to absolute
365
444
  * counts — preventing tiny projects from being unfairly penalized.
366
445
  *
367
- * Severity escape hatch: a single high-severity maintainability finding
368
- * (e.g., a 1500-line file) caps the grade at C; a critical caps at D.
369
- * "Worst wins" — we take min of density-grade and severity-cap.
446
+ * Severity escape hatch: a critical maintainability finding (e.g., a 3000-
447
+ * line file with high cohesion-violation severity) caps at F; a high-severity
448
+ * one caps at C. "Worst wins" — we take min of density-grade and severity-cap.
370
449
  */
371
450
  function gradeMaintainability(findings: Array<{ severity: string }>, totalLines: number): DimensionScore {
372
451
  const counts = countSeverities(findings);
373
452
  const kloc = Math.max(totalLines / 1000, 1.0);
374
453
 
375
454
  if (counts.total === 0) {
376
- return {
377
- name: 'maintainability',
378
- score: 100,
379
- grade: 'A',
380
- rationale: '0 maintainability findings',
381
- available: true,
382
- findingCount: 0,
383
- worstSeverity: null,
384
- };
455
+ return makeDimension('maintainability', 100, '0 maintainability findings', 0, null);
385
456
  }
386
457
 
387
458
  const band = kloc < 5 ? maintainabilityByCount(counts.total) : maintainabilityByDensity(counts.total, kloc);
388
459
  const severityCap = maintainabilityEscape(counts);
389
- const useCap = severityCap && gradeIsWorse(severityCap.grade, band.grade);
460
+ const useCap = severityCap && baseIsWorse(severityCap.grade, band.grade);
390
461
  const finalGrade = useCap ? severityCap.grade : band.grade;
391
462
  const finalPosition = useCap ? 0.5 : band.position;
392
463
  const rationale = useCap ? `${band.label}, ${severityCap.note}` : band.label;
393
464
 
465
+ return makeDimension('maintainability', scoreInBand(finalGrade, finalPosition), rationale, counts.total, worstSeverity(counts));
466
+ }
467
+
468
+ // ============================================================================
469
+ // Architectural Penalty
470
+ // ============================================================================
471
+
472
+ /**
473
+ * Drop a dimension's grade by N letters because of architectural findings.
474
+ *
475
+ * Rationale: a high-severity architectural problem (god class, leaky
476
+ * abstraction, broken layering) is qualitatively different from a long-file
477
+ * lint warning — it pollutes every change that touches the affected code.
478
+ * The user spec calls for explicit letter-grade drops:
479
+ *
480
+ * - 1 high-severity arch issue → drop 1 letter
481
+ * - 2+ high-severity arch issues → drop 2 letters
482
+ * - any critical-severity arch issue → drop 2 letters
483
+ *
484
+ * Letters drop A → B → C → F → F-. We never go lower than F-. The drop is
485
+ * applied AFTER the dimension's normal grading so the displayed score still
486
+ * reflects the underlying finding count, but the letter grade carries the
487
+ * architectural weight that a density-based score would otherwise miss.
488
+ */
489
+ function archDropCount(archFindings: Array<{ severity: string }>): number {
490
+ let highCount = 0;
491
+ let criticalCount = 0;
492
+ for (const f of archFindings) {
493
+ if (f.severity === 'critical') criticalCount++;
494
+ else if (f.severity === 'high') highCount++;
495
+ }
496
+ if (criticalCount >= 1) return 2;
497
+ if (highCount >= 2) return 2;
498
+ if (highCount >= 1) return 1;
499
+ return 0;
500
+ }
501
+
502
+ const BASE_LETTERS: BaseGrade[] = ['A', 'B', 'C', 'F'];
503
+
504
+ function gradeModifier(grade: Grade): '' | '+' | '-' {
505
+ if (grade.endsWith('+')) return '+';
506
+ if (grade.endsWith('-')) return '-';
507
+ return '';
508
+ }
509
+
510
+ function applyModifierToTargetBase(targetBase: BaseGrade, modifier: '' | '+' | '-'): Grade {
511
+ // F's modifier semantics differ from A/B/C: F+ is "barely failing" while
512
+ // A+/B+/C+ are "top of band." For simplicity we map any modifier on F to
513
+ // its matching variant, and use F- (the worst) for any post-F overshoot.
514
+ if (targetBase === 'F') {
515
+ if (modifier === '+') return 'F+';
516
+ if (modifier === '-') return 'F-';
517
+ return 'F';
518
+ }
519
+ if (modifier === '+') return `${targetBase}+` as Grade;
520
+ if (modifier === '-') return `${targetBase}-` as Grade;
521
+ return targetBase as Grade;
522
+ }
523
+
524
+ /**
525
+ * Drop a grade by N "letters." A "letter" here means a full base-grade step
526
+ * (A → B → C → F → F-), preserving the modifier when possible. So A+ dropped
527
+ * by 1 becomes B+, not A. Stops at F-.
528
+ */
529
+ function dropGradeByLetters(grade: Grade, letters: number): Grade {
530
+ if (letters <= 0 || grade === 'N/A' || grade === 'D') return grade;
531
+ const baseLetter = baseGradeOf(grade);
532
+ const baseIdx = BASE_LETTERS.indexOf(baseLetter as BaseGrade);
533
+ if (baseIdx === -1) return grade;
534
+ const targetBaseIdx = baseIdx + letters;
535
+ // Past the F base — bottom out at F- (the absolute worst grade).
536
+ if (targetBaseIdx > 3) return 'F-';
537
+ const targetBase = BASE_LETTERS[targetBaseIdx];
538
+ return applyModifierToTargetBase(targetBase, gradeModifier(grade));
539
+ }
540
+
541
+ function applyArchPenalty(dim: DimensionScore, archFindings: Array<{ severity: string }>): DimensionScore {
542
+ const drop = archDropCount(archFindings);
543
+ if (drop === 0) return dim;
544
+ const dropped = dropGradeByLetters(dim.grade, drop);
545
+ if (dropped === dim.grade) return dim;
546
+ const archCount = archFindings.length;
547
+ const noun = pluralize('architectural finding', archCount);
548
+ const note = `dropped ${drop} ${pluralize('letter', drop)} by ${archCount} ${noun}`;
394
549
  return {
395
- name: 'maintainability',
396
- score: scoreInBand(finalGrade, finalPosition),
397
- grade: finalGrade,
398
- rationale,
399
- available: true,
400
- findingCount: counts.total,
401
- worstSeverity: worstSeverity(counts),
550
+ ...dim,
551
+ grade: dropped,
552
+ // Re-anchor score to the new band's midpoint so score and letter agree.
553
+ score: anchorScoreToGrade(dropped, dim.score),
554
+ rationale: dim.rationale === '0 maintainability findings' || dim.findingCount === 0
555
+ ? note
556
+ : `${dim.rationale}; ${note}`,
402
557
  };
403
558
  }
404
559
 
560
+ /**
561
+ * Re-snap a score to fall within the band of the given grade. Used after
562
+ * applying the architectural penalty so the displayed score never disagrees
563
+ * with the displayed letter (e.g., grade C with score 89 would be jarring).
564
+ *
565
+ * If the original score is already in-band, keep it; otherwise pick the
566
+ * band's midpoint as a sensible default.
567
+ */
568
+ function anchorScoreToGrade(grade: Grade, originalScore: number): number {
569
+ if (grade === 'N/A' || grade === 'D') return originalScore;
570
+ const ranges: Record<Exclude<Grade, 'N/A' | 'D'>, [number, number]> = {
571
+ 'A+': [97, 100], A: [93, 96], 'A-': [90, 92],
572
+ 'B+': [87, 89], B: [83, 86], 'B-': [80, 82],
573
+ 'C+': [77, 79], C: [73, 76], 'C-': [70, 72],
574
+ 'F+': [65, 69], F: [56, 64], 'F-': [0, 55],
575
+ };
576
+ const [lo, hi] = ranges[grade as Exclude<Grade, 'N/A' | 'D'>];
577
+ if (originalScore >= lo && originalScore <= hi) return originalScore;
578
+ return Math.round((lo + hi) / 2);
579
+ }
580
+
405
581
  // ============================================================================
406
582
  // Grade Comparison Helpers
407
583
  // ============================================================================
408
584
 
409
- const GRADE_RANK: Record<Exclude<Grade, 'N/A'>, number> = {
410
- F: 1,
411
- D: 2,
412
- C: 3,
413
- B: 4,
414
- A: 5,
585
+ const BASE_RANK: Record<BaseGrade, number> = { F: 1, C: 2, B: 3, A: 4 };
586
+
587
+ function baseIsWorse(a: BaseGrade, b: BaseGrade): boolean {
588
+ return BASE_RANK[a] < BASE_RANK[b];
589
+ }
590
+
591
+ const FULL_RANK: Record<Exclude<Grade, 'N/A' | 'D'>, number> = {
592
+ 'F-': 0, F: 1, 'F+': 2,
593
+ 'C-': 3, C: 4, 'C+': 5,
594
+ 'B-': 6, B: 7, 'B+': 8,
595
+ 'A-': 9, A: 10, 'A+': 11,
415
596
  };
416
597
 
417
- function gradeIsWorse(a: Exclude<Grade, 'N/A'>, b: Exclude<Grade, 'N/A'>): boolean {
418
- return GRADE_RANK[a] < GRADE_RANK[b];
598
+ function gradeRank(g: Grade): number {
599
+ if (g === 'N/A') return -1;
600
+ if (g === 'D') return 1.5; // legacy: between F+ and C-
601
+ return FULL_RANK[g as Exclude<Grade, 'N/A' | 'D'>];
419
602
  }
420
603
 
421
- function worstOf(grades: Array<Exclude<Grade, 'N/A'>>): Exclude<Grade, 'N/A'> {
422
- let worst: Exclude<Grade, 'N/A'> = 'A';
604
+ function worstOf(grades: Grade[]): Grade {
605
+ let worst: Grade = 'A+';
423
606
  for (const g of grades) {
424
- if (gradeIsWorse(g, worst)) worst = g;
607
+ if (g === 'N/A') continue;
608
+ if (gradeRank(g) < gradeRank(worst)) worst = g;
425
609
  }
426
610
  return worst;
427
611
  }
@@ -442,6 +626,24 @@ function dimensionDisplayName(name: DimensionName): string {
442
626
  return name.charAt(0).toUpperCase() + name.slice(1);
443
627
  }
444
628
 
629
+ function makeDimension(
630
+ name: DimensionName,
631
+ score: number,
632
+ rationale: string,
633
+ findingCount: number,
634
+ worst: Severity | null,
635
+ ): DimensionScore {
636
+ return {
637
+ name,
638
+ score,
639
+ grade: scoreToGrade(score),
640
+ rationale,
641
+ available: true,
642
+ findingCount,
643
+ worstSeverity: worst,
644
+ };
645
+ }
646
+
445
647
  function naDimension(name: DimensionName): DimensionScore {
446
648
  return {
447
649
  name,
@@ -458,38 +660,26 @@ function naDimension(name: DimensionName): DimensionScore {
458
660
  // Top-Level Entry Point
459
661
  // ============================================================================
460
662
 
461
- /**
462
- * Compute the full multi-dimensional quality rating from the merged finding
463
- * set. Callers can override availability in two ways:
464
- * - `availableDimensions`: hard whitelist — only listed dims are graded.
465
- * - `forceNA`: forces specific dims to N/A even if they would otherwise
466
- * auto-detect as available. Use this when the underlying tools didn't
467
- * run (e.g., no linter installed -> Maintainability has limited coverage).
468
- *
469
- * Default availability rules:
470
- * - maintainability is always available (lint/format/length checks always run)
471
- * - security/reliability are available iff at least one finding maps there
472
- *
473
- * Overall score uses min(avg, worst) so a single bad dimension caps the
474
- * total — you cannot earn a great overall score by averaging away a hole.
475
- */
476
663
  function bucketByDimension(
477
664
  findings: Array<{ severity: string; category: string }>,
478
665
  ): {
479
666
  security: Array<{ severity: string; category: string }>;
480
667
  reliability: Array<{ severity: string; category: string }>;
481
668
  maintainability: Array<{ severity: string; category: string }>;
669
+ architecture: Array<{ severity: string; category: string }>;
482
670
  } {
483
671
  const security: Array<{ severity: string; category: string }> = [];
484
672
  const reliability: Array<{ severity: string; category: string }> = [];
485
673
  const maintainability: Array<{ severity: string; category: string }> = [];
674
+ const architecture: Array<{ severity: string; category: string }> = [];
486
675
  for (const f of findings) {
676
+ if (ARCHITECTURE_CATEGORIES.has(f.category)) architecture.push(f);
487
677
  const dim = categoryToDimension(f.category);
488
678
  if (dim === 'security') security.push(f);
489
679
  else if (dim === 'reliability') reliability.push(f);
490
680
  else maintainability.push(f);
491
681
  }
492
- return { security, reliability, maintainability };
682
+ return { security, reliability, maintainability, architecture };
493
683
  }
494
684
 
495
685
  function isDimensionAvailable(
@@ -504,17 +694,26 @@ function isDimensionAvailable(
504
694
  return dim === 'maintainability' ? true : hasFindings;
505
695
  }
506
696
 
697
+ /**
698
+ * Combine the available dimensions into a single overall grade + score.
699
+ *
700
+ * "Worst dimension wins" for the letter grade — a single failing dimension
701
+ * caps the overall score, matching how SonarQube's quality gate behaves.
702
+ * The numeric score is `min(avg, worst)` so a great Maintainability score
703
+ * can't paper over a Security failure.
704
+ */
507
705
  function computeOverall(availableDims: DimensionScore[]): { grade: Grade; score: number } {
508
706
  if (availableDims.length === 0) {
509
707
  return { grade: 'N/A', score: 0 };
510
708
  }
511
- const grades = availableDims.map((d) => d.grade as Exclude<Grade, 'N/A'>);
709
+ const grades = availableDims.map((d) => d.grade);
512
710
  const scores = availableDims.map((d) => d.score);
513
711
  const avg = scores.reduce((s, n) => s + n, 0) / scores.length;
514
- return {
515
- grade: worstOf(grades),
516
- score: Math.round(Math.min(avg, Math.min(...scores))),
517
- };
712
+ const worst = worstOf(grades);
713
+ // Re-snap the displayed score so it lives in the worst dimension's band —
714
+ // otherwise we'd display a B-letter with a C-numeric score (or vice versa).
715
+ const blendedScore = Math.round(Math.min(avg, Math.min(...scores)));
716
+ return { grade: worst, score: anchorScoreToGrade(worst, blendedScore) };
518
717
  }
519
718
 
520
719
  export function computeQualityRating(
@@ -524,24 +723,28 @@ export function computeQualityRating(
524
723
  ): QualityRating {
525
724
  const buckets = bucketByDimension(allFindings);
526
725
 
726
+ // Initial dimension grades, before architectural penalty.
527
727
  const security = isDimensionAvailable('security', buckets.security.length > 0, options)
528
728
  ? gradeSecurity(buckets.security)
529
729
  : naDimension('security');
530
- const reliability = isDimensionAvailable('reliability', buckets.reliability.length > 0, options)
531
- ? gradeReliability(buckets.reliability)
730
+ const reliabilityRaw = isDimensionAvailable('reliability', buckets.reliability.length > 0, options)
731
+ ? gradeReliability(buckets.reliability, totalLines)
532
732
  : naDimension('reliability');
533
- const maintainability = isDimensionAvailable('maintainability', true, options)
733
+ const maintainabilityRaw = isDimensionAvailable('maintainability', true, options)
534
734
  ? gradeMaintainability(buckets.maintainability, totalLines)
535
735
  : naDimension('maintainability');
536
736
 
537
- const dimensions: DimensionScore[] = [security, reliability, maintainability];
737
+ // Architectural penalty: hits whichever dimension(s) have arch findings
738
+ // bucketed into them (currently maintainability via the category map).
739
+ const archFindings = buckets.architecture;
740
+ const maintainability = maintainabilityRaw.available
741
+ ? applyArchPenalty(maintainabilityRaw, archFindings)
742
+ : maintainabilityRaw;
743
+
744
+ const dimensions: DimensionScore[] = [security, reliabilityRaw, maintainability];
538
745
  const availableDims = dimensions.filter((d) => d.available);
539
746
  const overall = computeOverall(availableDims);
540
-
541
- // Quality gate.
542
- const qualityGate = computeQualityGate(security, reliability);
543
-
544
- // Grade rationale.
747
+ const qualityGate = computeQualityGate(security, reliabilityRaw, archFindings.length);
545
748
  const gradeRationale = computeGradeRationale(availableDims, overall.grade, allFindings.length);
546
749
 
547
750
  return {
@@ -558,18 +761,34 @@ export function computeQualityRating(
558
761
 
559
762
  /**
560
763
  * The Quality Gate is a coarse PASS/FAIL signal layered on top of the grades.
561
- * It only fires for the most user-actionable thresholds — any medium+ security
562
- * finding, or any critical bug. N/A dimensions never trigger a fail (we don't
563
- * fail on missing data).
764
+ * It only fires for the most user-actionable thresholds — any C-or-worse
765
+ * security grade, any F-tier reliability grade, or 2+ high-severity
766
+ * architectural findings. N/A dimensions never trigger a fail (we don't fail
767
+ * on missing data).
564
768
  */
565
- function computeQualityGate(security: DimensionScore, reliability: DimensionScore): QualityGate {
769
+ function isFTier(g: Grade): boolean {
770
+ return g === 'F+' || g === 'F' || g === 'F-' || g === 'D';
771
+ }
772
+
773
+ function isCorWorse(g: Grade): boolean {
774
+ return baseGradeOf(g) === 'C' || isFTier(g);
775
+ }
776
+
777
+ function computeQualityGate(
778
+ security: DimensionScore,
779
+ reliability: DimensionScore,
780
+ archFindingCount: number,
781
+ ): QualityGate {
566
782
  const failingConditions: string[] = [];
567
783
 
568
- if (security.available && (security.grade === 'C' || security.grade === 'D' || security.grade === 'F')) {
784
+ if (security.available && isCorWorse(security.grade)) {
569
785
  failingConditions.push(`Security grade ${security.grade} — ${security.rationale}`);
570
786
  }
571
- if (reliability.available && reliability.grade === 'F') {
572
- failingConditions.push(`Reliability grade F — ${reliability.rationale}`);
787
+ if (reliability.available && isFTier(reliability.grade)) {
788
+ failingConditions.push(`Reliability grade ${reliability.grade} — ${reliability.rationale}`);
789
+ }
790
+ if (archFindingCount >= 2) {
791
+ failingConditions.push(`${archFindingCount} architectural findings`);
573
792
  }
574
793
 
575
794
  return {
@@ -594,11 +813,15 @@ function computeGradeRationale(
594
813
  return 'No dimensions available to grade';
595
814
  }
596
815
 
597
- // All available dimensions equal -> "consistent quality".
598
- const firstGrade = availableDims[0].grade;
599
- const allEqual = availableDims.every((d) => d.grade === firstGrade);
600
- if (allEqual) {
601
- return `All dimensions ${firstGrade} consistent quality`;
816
+ // All available dimensions share the same base letter -> "consistent
817
+ // quality". With +/- modifiers it's normal for sibling dimensions to land
818
+ // at A vs A+ depending on within-band position; calling that "inconsistent"
819
+ // would be misleading. We compare base letters so the user-facing message
820
+ // captures the high-level shape rather than every minor band difference.
821
+ const firstBase = baseGradeOf(availableDims[0].grade);
822
+ const allSameBase = availableDims.every((d) => baseGradeOf(d.grade) === firstBase);
823
+ if (allSameBase) {
824
+ return `All dimensions ${firstBase}-tier — consistent quality`;
602
825
  }
603
826
 
604
827
  // Find the dimension that pinned the overall grade (worst available).