mstro-app 0.5.1 → 0.5.5

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 (240) 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/stall-assessor.d.ts +50 -0
  10. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.js +64 -9
  12. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  13. package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
  14. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  15. package/dist/server/cli/headless/tool-watchdog.js +19 -12
  16. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  17. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
  18. package/dist/server/cli/improvisation-history-store.js +5 -1
  19. package/dist/server/cli/improvisation-history-store.js.map +1 -1
  20. package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
  21. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
  22. package/dist/server/cli/improvisation-output-queue.js +30 -7
  23. package/dist/server/cli/improvisation-output-queue.js.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +29 -0
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +50 -1
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/cli/improvisation-types.d.ts +2 -0
  29. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-types.js.map +1 -1
  31. package/dist/server/engines/EngineEvent.d.ts +126 -0
  32. package/dist/server/engines/EngineEvent.d.ts.map +1 -0
  33. package/dist/server/engines/EngineEvent.js +11 -0
  34. package/dist/server/engines/EngineEvent.js.map +1 -0
  35. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
  36. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
  37. package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
  38. package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
  39. package/dist/server/engines/factory.d.ts +21 -0
  40. package/dist/server/engines/factory.d.ts.map +1 -0
  41. package/dist/server/engines/factory.js +152 -0
  42. package/dist/server/engines/factory.js.map +1 -0
  43. package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
  44. package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
  45. package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
  46. package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
  47. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
  48. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
  49. package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
  50. package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
  51. package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
  52. package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
  53. package/dist/server/engines/opencode/model-catalog.js +141 -0
  54. package/dist/server/engines/opencode/model-catalog.js.map +1 -0
  55. package/dist/server/engines/types.d.ts +146 -0
  56. package/dist/server/engines/types.d.ts.map +1 -0
  57. package/dist/server/engines/types.js +4 -0
  58. package/dist/server/engines/types.js.map +1 -0
  59. package/dist/server/index.js +1 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
  62. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  63. package/dist/server/mcp/bouncer-haiku.js +8 -124
  64. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  65. package/dist/server/mcp/bouncer-integration.d.ts +45 -0
  66. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  67. package/dist/server/mcp/bouncer-integration.js +69 -5
  68. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  69. package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
  70. package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
  71. package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
  72. package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
  73. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
  74. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
  75. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
  76. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
  77. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
  78. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
  79. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
  80. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
  81. package/dist/server/mcp/classifier/factory.d.ts +70 -0
  82. package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
  83. package/dist/server/mcp/classifier/factory.js +155 -0
  84. package/dist/server/mcp/classifier/factory.js.map +1 -0
  85. package/dist/server/services/plan/agent-resolver.d.ts +26 -0
  86. package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
  87. package/dist/server/services/plan/agent-resolver.js +102 -0
  88. package/dist/server/services/plan/agent-resolver.js.map +1 -0
  89. package/dist/server/services/plan/composer.d.ts.map +1 -1
  90. package/dist/server/services/plan/composer.js +59 -11
  91. package/dist/server/services/plan/composer.js.map +1 -1
  92. package/dist/server/services/plan/executor.d.ts.map +1 -1
  93. package/dist/server/services/plan/executor.js +3 -1
  94. package/dist/server/services/plan/executor.js.map +1 -1
  95. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  96. package/dist/server/services/plan/issue-prompt-builder.js +33 -1
  97. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  98. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  99. package/dist/server/services/plan/parser-core.js +1 -0
  100. package/dist/server/services/plan/parser-core.js.map +1 -1
  101. package/dist/server/services/plan/types.d.ts +1 -0
  102. package/dist/server/services/plan/types.d.ts.map +1 -1
  103. package/dist/server/services/settings.d.ts +76 -2
  104. package/dist/server/services/settings.d.ts.map +1 -1
  105. package/dist/server/services/settings.js +127 -4
  106. package/dist/server/services/settings.js.map +1 -1
  107. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  108. package/dist/server/services/websocket/git-branch-handlers.js +19 -6
  109. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  110. package/dist/server/services/websocket/handler.d.ts +17 -1
  111. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  112. package/dist/server/services/websocket/handler.js +54 -2
  113. package/dist/server/services/websocket/handler.js.map +1 -1
  114. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  115. package/dist/server/services/websocket/quality-complexity.js +78 -26
  116. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  117. package/dist/server/services/websocket/quality-eta.d.ts +47 -0
  118. package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
  119. package/dist/server/services/websocket/quality-eta.js +110 -0
  120. package/dist/server/services/websocket/quality-eta.js.map +1 -0
  121. package/dist/server/services/websocket/quality-grading.d.ts +27 -4
  122. package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
  123. package/dist/server/services/websocket/quality-grading.js +369 -201
  124. package/dist/server/services/websocket/quality-grading.js.map +1 -1
  125. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/quality-handlers.js +145 -7
  127. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/quality-operations.d.ts +34 -0
  129. package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
  130. package/dist/server/services/websocket/quality-operations.js +47 -0
  131. package/dist/server/services/websocket/quality-operations.js.map +1 -0
  132. package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
  133. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  134. package/dist/server/services/websocket/quality-persistence.js +10 -0
  135. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  136. package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
  137. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  138. package/dist/server/services/websocket/quality-review-agent.js +105 -56
  139. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  140. package/dist/server/services/websocket/quality-service.d.ts +9 -1
  141. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  142. package/dist/server/services/websocket/quality-service.js +334 -14
  143. package/dist/server/services/websocket/quality-service.js.map +1 -1
  144. package/dist/server/services/websocket/quality-tools.d.ts +21 -0
  145. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  146. package/dist/server/services/websocket/quality-tools.js +49 -0
  147. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  148. package/dist/server/services/websocket/quality-types.d.ts +35 -2
  149. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  150. package/dist/server/services/websocket/quality-types.js +1 -1
  151. package/dist/server/services/websocket/quality-types.js.map +1 -1
  152. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  153. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  154. package/dist/server/services/websocket/session-handlers.js +57 -9
  155. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  156. package/dist/server/services/websocket/session-history.js +3 -0
  157. package/dist/server/services/websocket/session-history.js.map +1 -1
  158. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  159. package/dist/server/services/websocket/session-initialization.js +158 -42
  160. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  161. package/dist/server/services/websocket/session-registry.d.ts +25 -0
  162. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  163. package/dist/server/services/websocket/session-registry.js +19 -0
  164. package/dist/server/services/websocket/session-registry.js.map +1 -1
  165. package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
  166. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/settings-handlers.js +35 -4
  168. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
  170. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
  171. package/dist/server/services/websocket/tab-broadcast.js +10 -2
  172. package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
  173. package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
  174. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
  175. package/dist/server/services/websocket/tab-event-buffer.js +138 -12
  176. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
  177. package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
  178. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
  179. package/dist/server/services/websocket/tab-event-replay.js +55 -2
  180. package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
  181. package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
  182. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  183. package/dist/server/services/websocket/tab-handlers.js +47 -2
  184. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  185. package/dist/server/services/websocket/types.d.ts +28 -5
  186. package/dist/server/services/websocket/types.d.ts.map +1 -1
  187. package/dist/server/services/websocket/types.js +10 -4
  188. package/dist/server/services/websocket/types.js.map +1 -1
  189. package/package.json +5 -3
  190. package/server/cli/eta-estimator.ts +249 -0
  191. package/server/cli/headless/stall-assessor.ts +93 -0
  192. package/server/cli/headless/tool-watchdog.ts +21 -0
  193. package/server/cli/improvisation-history-store.ts +4 -1
  194. package/server/cli/improvisation-output-queue.ts +29 -7
  195. package/server/cli/improvisation-session-manager.ts +54 -1
  196. package/server/cli/improvisation-types.ts +2 -0
  197. package/server/engines/EngineEvent.ts +156 -0
  198. package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
  199. package/server/engines/factory.ts +176 -0
  200. package/server/engines/opencode/OpenCodeEngine.ts +786 -0
  201. package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
  202. package/server/engines/opencode/model-catalog.ts +217 -0
  203. package/server/engines/types.ts +173 -0
  204. package/server/index.ts +1 -1
  205. package/server/mcp/bouncer-haiku.ts +21 -145
  206. package/server/mcp/bouncer-integration.ts +107 -5
  207. package/server/mcp/classifier/BouncerClassifier.ts +40 -0
  208. package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
  209. package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
  210. package/server/mcp/classifier/factory.ts +195 -0
  211. package/server/services/plan/agent-resolver.ts +115 -0
  212. package/server/services/plan/agents/code-review.md +38 -8
  213. package/server/services/plan/composer.ts +63 -11
  214. package/server/services/plan/executor.ts +3 -1
  215. package/server/services/plan/issue-prompt-builder.ts +39 -1
  216. package/server/services/plan/parser-core.ts +1 -0
  217. package/server/services/plan/types.ts +4 -0
  218. package/server/services/settings.ts +161 -4
  219. package/server/services/websocket/git-branch-handlers.ts +20 -6
  220. package/server/services/websocket/handler.ts +59 -2
  221. package/server/services/websocket/quality-complexity.ts +80 -26
  222. package/server/services/websocket/quality-eta.ts +155 -0
  223. package/server/services/websocket/quality-grading.ts +445 -222
  224. package/server/services/websocket/quality-handlers.ts +153 -7
  225. package/server/services/websocket/quality-operations.ts +72 -0
  226. package/server/services/websocket/quality-persistence.ts +17 -0
  227. package/server/services/websocket/quality-review-agent.ts +154 -64
  228. package/server/services/websocket/quality-service.ts +361 -13
  229. package/server/services/websocket/quality-tools.ts +51 -0
  230. package/server/services/websocket/quality-types.ts +41 -2
  231. package/server/services/websocket/session-handlers.ts +64 -10
  232. package/server/services/websocket/session-history.ts +3 -0
  233. package/server/services/websocket/session-initialization.ts +189 -46
  234. package/server/services/websocket/session-registry.ts +37 -0
  235. package/server/services/websocket/settings-handlers.ts +41 -4
  236. package/server/services/websocket/tab-broadcast.ts +10 -2
  237. package/server/services/websocket/tab-event-buffer.ts +143 -11
  238. package/server/services/websocket/tab-event-replay.ts +70 -3
  239. package/server/services/websocket/tab-handlers.ts +53 -5
  240. package/server/services/websocket/types.ts +37 -5
@@ -3,7 +3,7 @@
3
3
  // Category -> Dimension Mapping
4
4
  // ============================================================================
5
5
  const SECURITY_CATEGORIES = new Set(['security']);
6
- const RELIABILITY_CATEGORIES = new Set(['bugs', 'logic', 'performance', 'complexity']);
6
+ const RELIABILITY_CATEGORIES = new Set(['bugs', 'logic', 'performance', 'complexity', 'build']);
7
7
  const MAINTAINABILITY_CATEGORIES = new Set([
8
8
  'lint',
9
9
  'linting',
@@ -28,52 +28,102 @@ export function categoryToDimension(category) {
28
28
  return 'maintainability';
29
29
  return 'maintainability';
30
30
  }
31
+ /** Categories that represent architectural problems — used by the arch penalty. */
32
+ const ARCHITECTURE_CATEGORIES = new Set(['architecture', 'oop']);
31
33
  // ============================================================================
32
- // Legacy Fallback
34
+ // Score Bands & Modifier Math
33
35
  // ============================================================================
34
36
  /**
35
- * Score-to-grade conversion used by legacy callers that still operate on a
36
- * single 0-100 number. The new multi-dimensional path computes grades
37
- * directly from finding shape; this remains for backward compatibility.
37
+ * Score boundaries for each base grade. Note the gap between C (70+) and F+
38
+ * (≤69): the band 60-69 maps to F+ instead of D, per product spec ("60s and
39
+ * below is F").
38
40
  */
39
- export function gradeFromScore(score) {
40
- if (score >= 90)
41
- return 'A';
42
- if (score >= 80)
43
- return 'B';
44
- if (score >= 70)
45
- return 'C';
46
- if (score >= 60)
47
- return 'D';
48
- return 'F';
49
- }
50
- // ============================================================================
51
- // Score Bands
52
- // ============================================================================
53
- const BAND_TOP = {
41
+ const BASE_BAND_TOP = {
54
42
  A: 100,
55
43
  B: 89,
56
44
  C: 79,
57
- D: 69,
58
- F: 59,
45
+ F: 69, // F covers 56-69 (F+ for 65-69, F for 56-64) — F- splits off below
59
46
  };
60
- const BAND_BOTTOM = {
47
+ const BASE_BAND_BOTTOM = {
61
48
  A: 90,
62
49
  B: 80,
63
50
  C: 70,
64
- D: 60,
65
- F: 0,
51
+ F: 56, // F- covers 0-55 — handled specially in scoreToGrade()
66
52
  };
67
53
  /**
68
- * Linearly interpolate a score within a grade's band.
54
+ * Convert a 0-100 score to the full letter grade including +/- modifier.
69
55
  *
70
- * `position` is in [0, 1]: 0 means "as bad as this grade gets" (band bottom),
71
- * 1 means "as good as this grade gets" (band top, just below the next grade).
56
+ * Within an A/B/C band, the band is split into thirds:
57
+ * X- bottom third (e.g., A-: 90-92)
58
+ * X middle third (e.g., A : 93-96)
59
+ * X+ top third (e.g., A+: 97-100)
60
+ *
61
+ * The F band uses two slices instead of three because there is no academic
62
+ * "F0" anchor and the user wanted F+/F/F-:
63
+ * F- 0-55 "critically broken"
64
+ * F 56-64 "broken"
65
+ * F+ 65-69 "barely failing"
66
+ *
67
+ * Compile/critical-severity hard caps are applied separately, not by score.
68
+ */
69
+ export function scoreToGrade(score) {
70
+ if (score >= 97)
71
+ return 'A+';
72
+ if (score >= 93)
73
+ return 'A';
74
+ if (score >= 90)
75
+ return 'A-';
76
+ if (score >= 87)
77
+ return 'B+';
78
+ if (score >= 83)
79
+ return 'B';
80
+ if (score >= 80)
81
+ return 'B-';
82
+ if (score >= 77)
83
+ return 'C+';
84
+ if (score >= 73)
85
+ return 'C';
86
+ if (score >= 70)
87
+ return 'C-';
88
+ if (score >= 65)
89
+ return 'F+';
90
+ if (score >= 56)
91
+ return 'F';
92
+ return 'F-';
93
+ }
94
+ /**
95
+ * Legacy single-letter conversion. Returns the *base* grade only (no
96
+ * modifier) for compatibility with callers that pre-date the +/- rollout
97
+ * (`scoreBreakdown.categoryPenalties[].grade`, etc.). New surfaces should
98
+ * call `scoreToGrade()` instead.
99
+ */
100
+ export function gradeFromScore(score) {
101
+ const full = scoreToGrade(score);
102
+ // Strip the modifier so legacy callers still see exactly one of A/B/C/F.
103
+ return baseGradeOf(full);
104
+ }
105
+ /** Strip the +/- modifier from a letter grade. */
106
+ function baseGradeOf(g) {
107
+ if (g === 'N/A' || g === 'D')
108
+ return g;
109
+ if (g.startsWith('A'))
110
+ return 'A';
111
+ if (g.startsWith('B'))
112
+ return 'B';
113
+ if (g.startsWith('C'))
114
+ return 'C';
115
+ return 'F';
116
+ }
117
+ /**
118
+ * Linearly interpolate a score within a base band.
119
+ *
120
+ * `position` is in [0, 1]: 0 = "as bad as this grade gets" (band bottom),
121
+ * 1 = "as good as this grade gets" (band top, just below the next grade).
72
122
  */
73
123
  function scoreInBand(grade, position) {
74
124
  const clamped = Math.max(0, Math.min(1, position));
75
- const bottom = BAND_BOTTOM[grade];
76
- const top = BAND_TOP[grade];
125
+ const bottom = BASE_BAND_BOTTOM[grade];
126
+ const top = BASE_BAND_TOP[grade];
77
127
  return Math.round(bottom + (top - bottom) * clamped);
78
128
  }
79
129
  // ============================================================================
@@ -111,132 +161,111 @@ function worstSeverity(counts) {
111
161
  * security finding immediately drops the grade below B because security
112
162
  * issues can't be amortized over codebase size.
113
163
  *
114
- * Within-band score: more findings at the threshold severity -> lower score.
115
- * The interpolation favors "fewer issues is meaningfully better" so 1 medium
116
- * scores higher than 5 mediums even though both are grade C.
164
+ * A critical security issue caps at F- (the worst grade). One low-severity
165
+ * finding still earns a B- because every team has a few.
117
166
  */
118
167
  function gradeSecurity(findings) {
119
168
  const counts = countSeverities(findings);
120
169
  const worst = worstSeverity(counts);
121
170
  if (counts.total === 0) {
122
- return {
123
- name: 'security',
124
- score: 100,
125
- grade: 'A',
126
- rationale: '0 security findings',
127
- available: true,
128
- findingCount: 0,
129
- worstSeverity: null,
130
- };
171
+ return makeDimension('security', 100, '0 security findings', 0, null);
131
172
  }
132
- let grade;
133
- let position;
134
- let rationale;
135
173
  if (counts.critical > 0) {
136
- grade = 'F';
137
- // F band: fewer criticals -> higher within-band, but still F.
138
- position = 1 / (1 + counts.critical);
139
- rationale = `${counts.critical} critical-severity security ${pluralize('issue', counts.critical)}`;
174
+ // Critical security issue → F-, not just F. There's no recovering by
175
+ // averaging this away across a clean codebase.
176
+ return makeDimension('security', Math.max(0, 55 - counts.critical * 5), `${counts.critical} critical-severity security ${pluralize('issue', counts.critical)}`, counts.total, worst);
140
177
  }
141
- else if (counts.high > 0) {
142
- grade = 'D';
178
+ let baseGrade;
179
+ let position;
180
+ let rationale;
181
+ if (counts.high > 0) {
182
+ baseGrade = 'F';
143
183
  position = 1 / (1 + counts.high);
144
184
  rationale = `${counts.high} high-severity security ${pluralize('issue', counts.high)}`;
145
185
  }
146
186
  else if (counts.medium > 0) {
147
- grade = 'C';
187
+ baseGrade = 'C';
148
188
  position = 1 / (1 + counts.medium);
149
189
  rationale = `${counts.medium} medium-severity security ${pluralize('issue', counts.medium)}`;
150
190
  }
151
191
  else {
152
192
  // Only low-severity findings.
153
- grade = 'B';
154
- // 1 low -> top of B (89); more lows -> down toward 80.
193
+ baseGrade = 'B';
155
194
  position = 1 / Math.max(1, counts.low);
156
195
  rationale = `${counts.low} low-severity security ${pluralize('issue', counts.low)}`;
157
196
  }
158
- return {
159
- name: 'security',
160
- score: scoreInBand(grade, position),
161
- grade,
162
- rationale,
163
- available: true,
164
- findingCount: counts.total,
165
- worstSeverity: worst,
166
- };
197
+ const score = scoreInBand(baseGrade, position);
198
+ return makeDimension('security', score, rationale, counts.total, worst);
167
199
  }
168
- function reliabilityBandClean(counts) {
169
- const position = counts.total === 0 ? 1 : 0.5;
170
- const rationale = counts.total === 0 ? '0 reliability findings' : '1 low-severity reliability issue';
171
- return { grade: 'A', position, rationale };
200
+ function reliabilityByCount(n) {
201
+ // Stricter than Maintainability's count ladder: a couple of real bugs hurt
202
+ // more than a couple of lint warnings, but a single isolated medium bug on
203
+ // a small project shouldn't pin the codebase at C.
204
+ const label = `${n} reliability ${pluralize('issue', n)}`;
205
+ if (n <= 2)
206
+ return { grade: 'A', position: 1 - n / 2, label };
207
+ if (n <= 6)
208
+ return { grade: 'B', position: 1 - (n - 2) / 4, label };
209
+ if (n <= 15)
210
+ return { grade: 'C', position: 1 - (n - 6) / 9, label };
211
+ return { grade: 'F', position: 1 / (1 + (n - 15) / 15), label };
212
+ }
213
+ function reliabilityByDensity(n, kloc) {
214
+ // Density thresholds are tighter than Maintainability (5/10/25). A 50 KLOC
215
+ // codebase with 100 reliability bugs (density 2) is "minor cleanup", not
216
+ // pristine — but 1.4/KLOC is still A-band because real-world projects
217
+ // never get to zero. The escape hatch handles severity outliers above this.
218
+ const density = n / kloc;
219
+ const label = `${roundOne(density)} reliability ${pluralize('issue', n)} / KLOC`;
220
+ if (density < 1.5)
221
+ return { grade: 'A', position: 1 - density / 1.5, label };
222
+ if (density < 4)
223
+ return { grade: 'B', position: 1 - (density - 1.5) / 2.5, label };
224
+ if (density < 8)
225
+ return { grade: 'C', position: 1 - (density - 4) / 4, label };
226
+ return { grade: 'F', position: 1 / (1 + (density - 8) / 8), label };
172
227
  }
173
- function reliabilityBandSevere(counts) {
228
+ function reliabilityEscape(counts) {
174
229
  if (counts.critical > 0) {
175
- return {
176
- grade: 'F',
177
- position: 1 / (1 + counts.critical),
178
- rationale: `${counts.critical} critical-severity ${pluralize('bug', counts.critical)}`,
179
- };
230
+ return { grade: 'F', note: `${counts.critical} critical-severity ${pluralize('bug', counts.critical)}` };
180
231
  }
181
- if (counts.high >= 2) {
182
- return {
183
- grade: 'D',
184
- position: 1 / (1 + (counts.high - 1)),
185
- rationale: `${counts.high} high-severity ${pluralize('bug', counts.high)}`,
186
- };
232
+ if (counts.high > 0) {
233
+ return { grade: 'C', note: `${counts.high} high-severity ${pluralize('bug', counts.high)}` };
187
234
  }
188
235
  return null;
189
236
  }
190
- function reliabilityBandMid(counts) {
191
- if (counts.high >= 1) {
192
- return {
193
- grade: 'C',
194
- position: 1 / (1 + counts.high),
195
- rationale: `${counts.high} high-severity ${pluralize('bug', counts.high)}`,
196
- };
197
- }
198
- if (counts.medium >= 3) {
199
- return {
200
- grade: 'C',
201
- position: 1 / Math.max(1, counts.medium - 2),
202
- rationale: `${counts.medium} medium-severity reliability ${pluralize('issue', counts.medium)}`,
203
- };
204
- }
205
- if (counts.medium >= 1) {
206
- return {
207
- grade: 'B',
208
- position: 1 / Math.max(1, counts.medium),
209
- rationale: `${counts.medium} medium-severity reliability ${pluralize('issue', counts.medium)}`,
210
- };
211
- }
212
- // Only low-severity findings, > 1 of them.
213
- return {
214
- grade: 'B',
215
- position: 1 / Math.max(1, counts.low - 1),
216
- rationale: `${counts.low} low-severity reliability ${pluralize('issue', counts.low)}`,
217
- };
218
- }
219
237
  /**
220
- * Reliability grading — slightly more lenient than Security because not every
221
- * complexity warning is a runtime defect. A single low-severity logic issue
222
- * still earns an A; medium issues escalate gradually.
238
+ * Reliability grading — density-based with a severity escape hatch.
239
+ *
240
+ * - Empty / ≤1 low: A-band (clean by convention).
241
+ * - Density-based grade (≥5 KLOC) or count-based grade (<5 KLOC) drives
242
+ * the baseline. Both ladders mirror Maintainability's so reliability and
243
+ * maintainability remain comparable at a glance.
244
+ * - Severity escape: critical → F, high → C. This matches Maintainability and
245
+ * prevents a handful of medium-density bugs from being silently rated A
246
+ * when at least one is severe.
247
+ *
248
+ * Build/compile errors flow in via `build` category with severity `critical`
249
+ * and therefore land at F via the escape hatch — no special-case branching.
223
250
  */
224
- function gradeReliability(findings) {
251
+ function gradeReliability(findings, totalLines) {
225
252
  const counts = countSeverities(findings);
226
253
  const worst = worstSeverity(counts);
227
- const isClean = counts.total === 0 || (counts.low <= 1 && counts.medium === 0 && counts.high === 0 && counts.critical === 0);
228
- const band = isClean
229
- ? reliabilityBandClean(counts)
230
- : reliabilityBandSevere(counts) ?? reliabilityBandMid(counts);
231
- return {
232
- name: 'reliability',
233
- score: scoreInBand(band.grade, band.position),
234
- grade: band.grade,
235
- rationale: band.rationale,
236
- available: true,
237
- findingCount: counts.total,
238
- worstSeverity: worst,
239
- };
254
+ const kloc = Math.max(totalLines / 1000, 1.0);
255
+ if (counts.total === 0) {
256
+ return makeDimension('reliability', 100, '0 reliability findings', 0, null);
257
+ }
258
+ // ≤1 low and nothing else is treated as clean — every team has one.
259
+ if (counts.low <= 1 && counts.medium === 0 && counts.high === 0 && counts.critical === 0) {
260
+ return makeDimension('reliability', scoreInBand('A', 0.5), '1 low-severity reliability issue', counts.total, worst);
261
+ }
262
+ const band = kloc < 5 ? reliabilityByCount(counts.total) : reliabilityByDensity(counts.total, kloc);
263
+ const severityCap = reliabilityEscape(counts);
264
+ const useCap = severityCap && baseIsWorse(severityCap.grade, band.grade);
265
+ const finalGrade = useCap ? severityCap.grade : band.grade;
266
+ const finalPosition = useCap ? 0.5 : band.position;
267
+ const rationale = useCap ? `${band.label}, ${severityCap.note}` : band.label;
268
+ return makeDimension('reliability', scoreInBand(finalGrade, finalPosition), rationale, counts.total, worst);
240
269
  }
241
270
  function maintainabilityByCount(n) {
242
271
  const label = `${n} maintainability ${pluralize('issue', n)}`;
@@ -246,9 +275,7 @@ function maintainabilityByCount(n) {
246
275
  return { grade: 'B', position: 1 - (n - 5) / 10, label };
247
276
  if (n <= 30)
248
277
  return { grade: 'C', position: 1 - (n - 15) / 15, label };
249
- if (n <= 60)
250
- return { grade: 'D', position: 1 - (n - 30) / 30, label };
251
- return { grade: 'F', position: 1 / (1 + (n - 60) / 30), label };
278
+ return { grade: 'F', position: 1 / (1 + (n - 30) / 30), label };
252
279
  }
253
280
  function maintainabilityByDensity(n, kloc) {
254
281
  const density = n / kloc;
@@ -259,13 +286,11 @@ function maintainabilityByDensity(n, kloc) {
259
286
  return { grade: 'B', position: 1 - (density - 5) / 5, label };
260
287
  if (density < 25)
261
288
  return { grade: 'C', position: 1 - (density - 10) / 15, label };
262
- if (density < 50)
263
- return { grade: 'D', position: 1 - (density - 25) / 25, label };
264
- return { grade: 'F', position: 1 / (1 + (density - 50) / 25), label };
289
+ return { grade: 'F', position: 1 / (1 + (density - 25) / 25), label };
265
290
  }
266
291
  function maintainabilityEscape(counts) {
267
292
  if (counts.critical > 0) {
268
- return { grade: 'D', note: `${counts.critical} critical-severity ${pluralize('issue', counts.critical)}` };
293
+ return { grade: 'F', note: `${counts.critical} critical-severity ${pluralize('issue', counts.critical)}` };
269
294
  }
270
295
  if (counts.high > 0) {
271
296
  return { grade: 'C', note: `${counts.high} high-severity ${pluralize('issue', counts.high)}` };
@@ -278,57 +303,173 @@ function maintainabilityEscape(counts) {
278
303
  * (one extra lint issue moves density by 1.0+), so we fall back to absolute
279
304
  * counts — preventing tiny projects from being unfairly penalized.
280
305
  *
281
- * Severity escape hatch: a single high-severity maintainability finding
282
- * (e.g., a 1500-line file) caps the grade at C; a critical caps at D.
283
- * "Worst wins" — we take min of density-grade and severity-cap.
306
+ * Severity escape hatch: a critical maintainability finding (e.g., a 3000-
307
+ * line file with high cohesion-violation severity) caps at F; a high-severity
308
+ * one caps at C. "Worst wins" — we take min of density-grade and severity-cap.
284
309
  */
285
310
  function gradeMaintainability(findings, totalLines) {
286
311
  const counts = countSeverities(findings);
287
312
  const kloc = Math.max(totalLines / 1000, 1.0);
288
313
  if (counts.total === 0) {
289
- return {
290
- name: 'maintainability',
291
- score: 100,
292
- grade: 'A',
293
- rationale: '0 maintainability findings',
294
- available: true,
295
- findingCount: 0,
296
- worstSeverity: null,
297
- };
314
+ return makeDimension('maintainability', 100, '0 maintainability findings', 0, null);
298
315
  }
299
316
  const band = kloc < 5 ? maintainabilityByCount(counts.total) : maintainabilityByDensity(counts.total, kloc);
300
317
  const severityCap = maintainabilityEscape(counts);
301
- const useCap = severityCap && gradeIsWorse(severityCap.grade, band.grade);
318
+ const useCap = severityCap && baseIsWorse(severityCap.grade, band.grade);
302
319
  const finalGrade = useCap ? severityCap.grade : band.grade;
303
320
  const finalPosition = useCap ? 0.5 : band.position;
304
321
  const rationale = useCap ? `${band.label}, ${severityCap.note}` : band.label;
322
+ return makeDimension('maintainability', scoreInBand(finalGrade, finalPosition), rationale, counts.total, worstSeverity(counts));
323
+ }
324
+ // ============================================================================
325
+ // Architectural Penalty
326
+ // ============================================================================
327
+ /**
328
+ * Drop a dimension's grade by N letters because of architectural findings.
329
+ *
330
+ * Rationale: a high-severity architectural problem (god class, leaky
331
+ * abstraction, broken layering) is qualitatively different from a long-file
332
+ * lint warning — it pollutes every change that touches the affected code.
333
+ * The user spec calls for explicit letter-grade drops:
334
+ *
335
+ * - 1 high-severity arch issue → drop 1 letter
336
+ * - 2+ high-severity arch issues → drop 2 letters
337
+ * - any critical-severity arch issue → drop 2 letters
338
+ *
339
+ * Letters drop A → B → C → F → F-. We never go lower than F-. The drop is
340
+ * applied AFTER the dimension's normal grading so the displayed score still
341
+ * reflects the underlying finding count, but the letter grade carries the
342
+ * architectural weight that a density-based score would otherwise miss.
343
+ */
344
+ function archDropCount(archFindings) {
345
+ let highCount = 0;
346
+ let criticalCount = 0;
347
+ for (const f of archFindings) {
348
+ if (f.severity === 'critical')
349
+ criticalCount++;
350
+ else if (f.severity === 'high')
351
+ highCount++;
352
+ }
353
+ if (criticalCount >= 1)
354
+ return 2;
355
+ if (highCount >= 2)
356
+ return 2;
357
+ if (highCount >= 1)
358
+ return 1;
359
+ return 0;
360
+ }
361
+ const BASE_LETTERS = ['A', 'B', 'C', 'F'];
362
+ function gradeModifier(grade) {
363
+ if (grade.endsWith('+'))
364
+ return '+';
365
+ if (grade.endsWith('-'))
366
+ return '-';
367
+ return '';
368
+ }
369
+ function applyModifierToTargetBase(targetBase, modifier) {
370
+ // F's modifier semantics differ from A/B/C: F+ is "barely failing" while
371
+ // A+/B+/C+ are "top of band." For simplicity we map any modifier on F to
372
+ // its matching variant, and use F- (the worst) for any post-F overshoot.
373
+ if (targetBase === 'F') {
374
+ if (modifier === '+')
375
+ return 'F+';
376
+ if (modifier === '-')
377
+ return 'F-';
378
+ return 'F';
379
+ }
380
+ if (modifier === '+')
381
+ return `${targetBase}+`;
382
+ if (modifier === '-')
383
+ return `${targetBase}-`;
384
+ return targetBase;
385
+ }
386
+ /**
387
+ * Drop a grade by N "letters." A "letter" here means a full base-grade step
388
+ * (A → B → C → F → F-), preserving the modifier when possible. So A+ dropped
389
+ * by 1 becomes B+, not A. Stops at F-.
390
+ */
391
+ function dropGradeByLetters(grade, letters) {
392
+ if (letters <= 0 || grade === 'N/A' || grade === 'D')
393
+ return grade;
394
+ const baseLetter = baseGradeOf(grade);
395
+ const baseIdx = BASE_LETTERS.indexOf(baseLetter);
396
+ if (baseIdx === -1)
397
+ return grade;
398
+ const targetBaseIdx = baseIdx + letters;
399
+ // Past the F base — bottom out at F- (the absolute worst grade).
400
+ if (targetBaseIdx > 3)
401
+ return 'F-';
402
+ const targetBase = BASE_LETTERS[targetBaseIdx];
403
+ return applyModifierToTargetBase(targetBase, gradeModifier(grade));
404
+ }
405
+ function applyArchPenalty(dim, archFindings) {
406
+ const drop = archDropCount(archFindings);
407
+ if (drop === 0)
408
+ return dim;
409
+ const dropped = dropGradeByLetters(dim.grade, drop);
410
+ if (dropped === dim.grade)
411
+ return dim;
412
+ const archCount = archFindings.length;
413
+ const noun = pluralize('architectural finding', archCount);
414
+ const note = `dropped ${drop} ${pluralize('letter', drop)} by ${archCount} ${noun}`;
305
415
  return {
306
- name: 'maintainability',
307
- score: scoreInBand(finalGrade, finalPosition),
308
- grade: finalGrade,
309
- rationale,
310
- available: true,
311
- findingCount: counts.total,
312
- worstSeverity: worstSeverity(counts),
416
+ ...dim,
417
+ grade: dropped,
418
+ // Re-anchor score to the new band's midpoint so score and letter agree.
419
+ score: anchorScoreToGrade(dropped, dim.score),
420
+ rationale: dim.rationale === '0 maintainability findings' || dim.findingCount === 0
421
+ ? note
422
+ : `${dim.rationale}; ${note}`,
313
423
  };
314
424
  }
425
+ /**
426
+ * Re-snap a score to fall within the band of the given grade. Used after
427
+ * applying the architectural penalty so the displayed score never disagrees
428
+ * with the displayed letter (e.g., grade C with score 89 would be jarring).
429
+ *
430
+ * If the original score is already in-band, keep it; otherwise pick the
431
+ * band's midpoint as a sensible default.
432
+ */
433
+ function anchorScoreToGrade(grade, originalScore) {
434
+ if (grade === 'N/A' || grade === 'D')
435
+ return originalScore;
436
+ const ranges = {
437
+ 'A+': [97, 100], A: [93, 96], 'A-': [90, 92],
438
+ 'B+': [87, 89], B: [83, 86], 'B-': [80, 82],
439
+ 'C+': [77, 79], C: [73, 76], 'C-': [70, 72],
440
+ 'F+': [65, 69], F: [56, 64], 'F-': [0, 55],
441
+ };
442
+ const [lo, hi] = ranges[grade];
443
+ if (originalScore >= lo && originalScore <= hi)
444
+ return originalScore;
445
+ return Math.round((lo + hi) / 2);
446
+ }
315
447
  // ============================================================================
316
448
  // Grade Comparison Helpers
317
449
  // ============================================================================
318
- const GRADE_RANK = {
319
- F: 1,
320
- D: 2,
321
- C: 3,
322
- B: 4,
323
- A: 5,
450
+ const BASE_RANK = { F: 1, C: 2, B: 3, A: 4 };
451
+ function baseIsWorse(a, b) {
452
+ return BASE_RANK[a] < BASE_RANK[b];
453
+ }
454
+ const FULL_RANK = {
455
+ 'F-': 0, F: 1, 'F+': 2,
456
+ 'C-': 3, C: 4, 'C+': 5,
457
+ 'B-': 6, B: 7, 'B+': 8,
458
+ 'A-': 9, A: 10, 'A+': 11,
324
459
  };
325
- function gradeIsWorse(a, b) {
326
- return GRADE_RANK[a] < GRADE_RANK[b];
460
+ function gradeRank(g) {
461
+ if (g === 'N/A')
462
+ return -1;
463
+ if (g === 'D')
464
+ return 1.5; // legacy: between F+ and C-
465
+ return FULL_RANK[g];
327
466
  }
328
467
  function worstOf(grades) {
329
- let worst = 'A';
468
+ let worst = 'A+';
330
469
  for (const g of grades) {
331
- if (gradeIsWorse(g, worst))
470
+ if (g === 'N/A')
471
+ continue;
472
+ if (gradeRank(g) < gradeRank(worst))
332
473
  worst = g;
333
474
  }
334
475
  return worst;
@@ -345,6 +486,17 @@ function roundOne(n) {
345
486
  function dimensionDisplayName(name) {
346
487
  return name.charAt(0).toUpperCase() + name.slice(1);
347
488
  }
489
+ function makeDimension(name, score, rationale, findingCount, worst) {
490
+ return {
491
+ name,
492
+ score,
493
+ grade: scoreToGrade(score),
494
+ rationale,
495
+ available: true,
496
+ findingCount,
497
+ worstSeverity: worst,
498
+ };
499
+ }
348
500
  function naDimension(name) {
349
501
  return {
350
502
  name,
@@ -359,26 +511,14 @@ function naDimension(name) {
359
511
  // ============================================================================
360
512
  // Top-Level Entry Point
361
513
  // ============================================================================
362
- /**
363
- * Compute the full multi-dimensional quality rating from the merged finding
364
- * set. Callers can override availability in two ways:
365
- * - `availableDimensions`: hard whitelist — only listed dims are graded.
366
- * - `forceNA`: forces specific dims to N/A even if they would otherwise
367
- * auto-detect as available. Use this when the underlying tools didn't
368
- * run (e.g., no linter installed -> Maintainability has limited coverage).
369
- *
370
- * Default availability rules:
371
- * - maintainability is always available (lint/format/length checks always run)
372
- * - security/reliability are available iff at least one finding maps there
373
- *
374
- * Overall score uses min(avg, worst) so a single bad dimension caps the
375
- * total — you cannot earn a great overall score by averaging away a hole.
376
- */
377
514
  function bucketByDimension(findings) {
378
515
  const security = [];
379
516
  const reliability = [];
380
517
  const maintainability = [];
518
+ const architecture = [];
381
519
  for (const f of findings) {
520
+ if (ARCHITECTURE_CATEGORIES.has(f.category))
521
+ architecture.push(f);
382
522
  const dim = categoryToDimension(f.category);
383
523
  if (dim === 'security')
384
524
  security.push(f);
@@ -387,7 +527,7 @@ function bucketByDimension(findings) {
387
527
  else
388
528
  maintainability.push(f);
389
529
  }
390
- return { security, reliability, maintainability };
530
+ return { security, reliability, maintainability, architecture };
391
531
  }
392
532
  function isDimensionAvailable(dim, hasFindings, options) {
393
533
  if (options?.forceNA?.has(dim))
@@ -398,6 +538,14 @@ function isDimensionAvailable(dim, hasFindings, options) {
398
538
  // Auto-detect: maintainability always on, security/reliability iff findings exist.
399
539
  return dim === 'maintainability' ? true : hasFindings;
400
540
  }
541
+ /**
542
+ * Combine the available dimensions into a single overall grade + score.
543
+ *
544
+ * "Worst dimension wins" for the letter grade — a single failing dimension
545
+ * caps the overall score, matching how SonarQube's quality gate behaves.
546
+ * The numeric score is `min(avg, worst)` so a great Maintainability score
547
+ * can't paper over a Security failure.
548
+ */
401
549
  function computeOverall(availableDims) {
402
550
  if (availableDims.length === 0) {
403
551
  return { grade: 'N/A', score: 0 };
@@ -405,28 +553,34 @@ function computeOverall(availableDims) {
405
553
  const grades = availableDims.map((d) => d.grade);
406
554
  const scores = availableDims.map((d) => d.score);
407
555
  const avg = scores.reduce((s, n) => s + n, 0) / scores.length;
408
- return {
409
- grade: worstOf(grades),
410
- score: Math.round(Math.min(avg, Math.min(...scores))),
411
- };
556
+ const worst = worstOf(grades);
557
+ // Re-snap the displayed score so it lives in the worst dimension's band —
558
+ // otherwise we'd display a B-letter with a C-numeric score (or vice versa).
559
+ const blendedScore = Math.round(Math.min(avg, Math.min(...scores)));
560
+ return { grade: worst, score: anchorScoreToGrade(worst, blendedScore) };
412
561
  }
413
562
  export function computeQualityRating(allFindings, totalLines, options) {
414
563
  const buckets = bucketByDimension(allFindings);
564
+ // Initial dimension grades, before architectural penalty.
415
565
  const security = isDimensionAvailable('security', buckets.security.length > 0, options)
416
566
  ? gradeSecurity(buckets.security)
417
567
  : naDimension('security');
418
- const reliability = isDimensionAvailable('reliability', buckets.reliability.length > 0, options)
419
- ? gradeReliability(buckets.reliability)
568
+ const reliabilityRaw = isDimensionAvailable('reliability', buckets.reliability.length > 0, options)
569
+ ? gradeReliability(buckets.reliability, totalLines)
420
570
  : naDimension('reliability');
421
- const maintainability = isDimensionAvailable('maintainability', true, options)
571
+ const maintainabilityRaw = isDimensionAvailable('maintainability', true, options)
422
572
  ? gradeMaintainability(buckets.maintainability, totalLines)
423
573
  : naDimension('maintainability');
424
- const dimensions = [security, reliability, maintainability];
574
+ // Architectural penalty: hits whichever dimension(s) have arch findings
575
+ // bucketed into them (currently maintainability via the category map).
576
+ const archFindings = buckets.architecture;
577
+ const maintainability = maintainabilityRaw.available
578
+ ? applyArchPenalty(maintainabilityRaw, archFindings)
579
+ : maintainabilityRaw;
580
+ const dimensions = [security, reliabilityRaw, maintainability];
425
581
  const availableDims = dimensions.filter((d) => d.available);
426
582
  const overall = computeOverall(availableDims);
427
- // Quality gate.
428
- const qualityGate = computeQualityGate(security, reliability);
429
- // Grade rationale.
583
+ const qualityGate = computeQualityGate(security, reliabilityRaw, archFindings.length);
430
584
  const gradeRationale = computeGradeRationale(availableDims, overall.grade, allFindings.length);
431
585
  return {
432
586
  overall,
@@ -440,17 +594,27 @@ export function computeQualityRating(allFindings, totalLines, options) {
440
594
  // ============================================================================
441
595
  /**
442
596
  * The Quality Gate is a coarse PASS/FAIL signal layered on top of the grades.
443
- * It only fires for the most user-actionable thresholds — any medium+ security
444
- * finding, or any critical bug. N/A dimensions never trigger a fail (we don't
445
- * fail on missing data).
597
+ * It only fires for the most user-actionable thresholds — any C-or-worse
598
+ * security grade, any F-tier reliability grade, or 2+ high-severity
599
+ * architectural findings. N/A dimensions never trigger a fail (we don't fail
600
+ * on missing data).
446
601
  */
447
- function computeQualityGate(security, reliability) {
602
+ function isFTier(g) {
603
+ return g === 'F+' || g === 'F' || g === 'F-' || g === 'D';
604
+ }
605
+ function isCorWorse(g) {
606
+ return baseGradeOf(g) === 'C' || isFTier(g);
607
+ }
608
+ function computeQualityGate(security, reliability, archFindingCount) {
448
609
  const failingConditions = [];
449
- if (security.available && (security.grade === 'C' || security.grade === 'D' || security.grade === 'F')) {
610
+ if (security.available && isCorWorse(security.grade)) {
450
611
  failingConditions.push(`Security grade ${security.grade} — ${security.rationale}`);
451
612
  }
452
- if (reliability.available && reliability.grade === 'F') {
453
- failingConditions.push(`Reliability grade F — ${reliability.rationale}`);
613
+ if (reliability.available && isFTier(reliability.grade)) {
614
+ failingConditions.push(`Reliability grade ${reliability.grade} — ${reliability.rationale}`);
615
+ }
616
+ if (archFindingCount >= 2) {
617
+ failingConditions.push(`${archFindingCount} architectural findings`);
454
618
  }
455
619
  return {
456
620
  passed: failingConditions.length === 0,
@@ -467,11 +631,15 @@ function computeGradeRationale(availableDims, overallGrade, totalFindingCount) {
467
631
  if (availableDims.length === 0 || overallGrade === 'N/A') {
468
632
  return 'No dimensions available to grade';
469
633
  }
470
- // All available dimensions equal -> "consistent quality".
471
- const firstGrade = availableDims[0].grade;
472
- const allEqual = availableDims.every((d) => d.grade === firstGrade);
473
- if (allEqual) {
474
- return `All dimensions ${firstGrade} consistent quality`;
634
+ // All available dimensions share the same base letter -> "consistent
635
+ // quality". With +/- modifiers it's normal for sibling dimensions to land
636
+ // at A vs A+ depending on within-band position; calling that "inconsistent"
637
+ // would be misleading. We compare base letters so the user-facing message
638
+ // captures the high-level shape rather than every minor band difference.
639
+ const firstBase = baseGradeOf(availableDims[0].grade);
640
+ const allSameBase = availableDims.every((d) => baseGradeOf(d.grade) === firstBase);
641
+ if (allSameBase) {
642
+ return `All dimensions ${firstBase}-tier — consistent quality`;
475
643
  }
476
644
  // Find the dimension that pinned the overall grade (worst available).
477
645
  const worstDim = availableDims.find((d) => d.grade === overallGrade) ??