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.
- package/PRIVACY.md +9 -9
- package/README.md +71 -28
- package/bin/commands/config.js +1 -1
- package/bin/mstro.js +55 -4
- package/dist/server/cli/eta-estimator.d.ts +55 -0
- package/dist/server/cli/eta-estimator.d.ts.map +1 -0
- package/dist/server/cli/eta-estimator.js +222 -0
- package/dist/server/cli/eta-estimator.js.map +1 -0
- package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +9 -1
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.d.ts +22 -5
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +7 -5
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +19 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +64 -9
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +19 -12
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +16 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.js +5 -1
- package/dist/server/cli/improvisation-history-store.js.map +1 -1
- package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
- package/dist/server/cli/improvisation-output-queue.js +30 -7
- package/dist/server/cli/improvisation-output-queue.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +35 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +58 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +9 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
- package/dist/server/cli/retry/retry-runner-factory.js +1 -0
- package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
- package/dist/server/engines/EngineEvent.d.ts +126 -0
- package/dist/server/engines/EngineEvent.d.ts.map +1 -0
- package/dist/server/engines/EngineEvent.js +11 -0
- package/dist/server/engines/EngineEvent.js.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
- package/dist/server/engines/factory.d.ts +21 -0
- package/dist/server/engines/factory.d.ts.map +1 -0
- package/dist/server/engines/factory.js +152 -0
- package/dist/server/engines/factory.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
- package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
- package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
- package/dist/server/engines/opencode/model-catalog.js +141 -0
- package/dist/server/engines/opencode/model-catalog.js.map +1 -0
- package/dist/server/engines/types.d.ts +146 -0
- package/dist/server/engines/types.d.ts.map +1 -0
- package/dist/server/engines/types.js +4 -0
- package/dist/server/engines/types.js.map +1 -0
- package/dist/server/index.js +9 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +8 -124
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +45 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +69 -5
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/factory.d.ts +70 -0
- package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
- package/dist/server/mcp/classifier/factory.js +155 -0
- package/dist/server/mcp/classifier/factory.js.map +1 -0
- package/dist/server/mcp/server.js +52 -0
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +1 -0
- package/dist/server/routes/index.js.map +1 -1
- package/dist/server/routes/internal.d.ts +16 -0
- package/dist/server/routes/internal.d.ts.map +1 -0
- package/dist/server/routes/internal.js +94 -0
- package/dist/server/routes/internal.js.map +1 -0
- package/dist/server/services/plan/agent-resolver.d.ts +26 -0
- package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/agent-resolver.js +102 -0
- package/dist/server/services/plan/agent-resolver.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +59 -11
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +3 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +33 -1
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/runtime-info.d.ts +3 -0
- package/dist/server/services/runtime-info.d.ts.map +1 -0
- package/dist/server/services/runtime-info.js +21 -0
- package/dist/server/services/runtime-info.js.map +1 -0
- package/dist/server/services/settings.d.ts +76 -2
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +127 -4
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
- package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
- package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
- package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +19 -6
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +25 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +84 -2
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +78 -26
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-eta.d.ts +47 -0
- package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-eta.js +110 -0
- package/dist/server/services/websocket/quality-eta.js.map +1 -0
- package/dist/server/services/websocket/quality-grading.d.ts +27 -4
- package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-grading.js +369 -201
- package/dist/server/services/websocket/quality-grading.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +145 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-operations.d.ts +34 -0
- package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-operations.js +47 -0
- package/dist/server/services/websocket/quality-operations.js.map +1 -0
- package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +10 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +105 -56
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +9 -1
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +334 -14
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts +21 -0
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +49 -0
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +35 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +3 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +60 -9
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-history.js +3 -0
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +158 -42
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +25 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +19 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/settings-handlers.js +35 -4
- package/dist/server/services/websocket/settings-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.js +10 -2
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.js +138 -12
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.js +55 -2
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +47 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +67 -7
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +12 -6
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +5 -3
- package/server/cli/eta-estimator.ts +249 -0
- package/server/cli/headless/claude-invoker-process.ts +9 -1
- package/server/cli/headless/mcp-config.ts +30 -5
- package/server/cli/headless/runner.ts +21 -0
- package/server/cli/headless/stall-assessor.ts +93 -0
- package/server/cli/headless/tool-watchdog.ts +21 -0
- package/server/cli/headless/types.ts +16 -1
- package/server/cli/improvisation-history-store.ts +4 -1
- package/server/cli/improvisation-output-queue.ts +29 -7
- package/server/cli/improvisation-session-manager.ts +63 -1
- package/server/cli/improvisation-types.ts +9 -0
- package/server/cli/retry/retry-runner-factory.ts +1 -0
- package/server/engines/EngineEvent.ts +156 -0
- package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
- package/server/engines/factory.ts +176 -0
- package/server/engines/opencode/OpenCodeEngine.ts +786 -0
- package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
- package/server/engines/opencode/model-catalog.ts +217 -0
- package/server/engines/types.ts +173 -0
- package/server/index.ts +9 -1
- package/server/mcp/bouncer-haiku.ts +21 -145
- package/server/mcp/bouncer-integration.ts +107 -5
- package/server/mcp/classifier/BouncerClassifier.ts +40 -0
- package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
- package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
- package/server/mcp/classifier/factory.ts +195 -0
- package/server/mcp/server.ts +57 -0
- package/server/routes/index.ts +1 -0
- package/server/routes/internal.ts +112 -0
- package/server/services/plan/agent-resolver.ts +115 -0
- package/server/services/plan/agents/code-review.md +38 -8
- package/server/services/plan/composer.ts +63 -11
- package/server/services/plan/executor.ts +3 -1
- package/server/services/plan/issue-prompt-builder.ts +39 -1
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/types.ts +4 -0
- package/server/services/runtime-info.ts +24 -0
- package/server/services/settings.ts +161 -4
- package/server/services/websocket/ask-user-question-bridge.ts +148 -0
- package/server/services/websocket/git-branch-handlers.ts +20 -6
- package/server/services/websocket/handler.ts +89 -2
- package/server/services/websocket/quality-complexity.ts +80 -26
- package/server/services/websocket/quality-eta.ts +155 -0
- package/server/services/websocket/quality-grading.ts +445 -222
- package/server/services/websocket/quality-handlers.ts +153 -7
- package/server/services/websocket/quality-operations.ts +72 -0
- package/server/services/websocket/quality-persistence.ts +17 -0
- package/server/services/websocket/quality-review-agent.ts +154 -64
- package/server/services/websocket/quality-service.ts +361 -13
- package/server/services/websocket/quality-tools.ts +51 -0
- package/server/services/websocket/quality-types.ts +41 -2
- package/server/services/websocket/session-handlers.ts +67 -10
- package/server/services/websocket/session-history.ts +3 -0
- package/server/services/websocket/session-initialization.ts +189 -46
- package/server/services/websocket/session-registry.ts +37 -0
- package/server/services/websocket/settings-handlers.ts +41 -4
- package/server/services/websocket/tab-broadcast.ts +10 -2
- package/server/services/websocket/tab-event-buffer.ts +143 -11
- package/server/services/websocket/tab-event-replay.ts +70 -3
- package/server/services/websocket/tab-handlers.ts +53 -5
- package/server/services/websocket/types.ts +85 -7
|
@@ -4,16 +4,29 @@
|
|
|
4
4
|
// Multi-Dimensional Quality Grading
|
|
5
5
|
// ============================================================================
|
|
6
6
|
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
113
|
+
// Score Bands & Modifier Math
|
|
82
114
|
// ============================================================================
|
|
83
115
|
|
|
84
116
|
/**
|
|
85
|
-
* Score
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
F: 0,
|
|
131
|
+
F: 56, // F- covers 0-55 — handled specially in scoreToGrade()
|
|
115
132
|
};
|
|
116
133
|
|
|
117
134
|
/**
|
|
118
|
-
*
|
|
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
|
|
121
|
-
* 1
|
|
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:
|
|
192
|
+
function scoreInBand(grade: BaseGrade, position: number): number {
|
|
124
193
|
const clamped = Math.max(0, Math.min(1, position));
|
|
125
|
-
const bottom =
|
|
126
|
-
const top =
|
|
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
|
-
*
|
|
174
|
-
*
|
|
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
|
-
|
|
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.
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
234
|
-
grade:
|
|
325
|
+
interface ReliabilityBand {
|
|
326
|
+
grade: BaseGrade;
|
|
235
327
|
position: number;
|
|
236
|
-
|
|
328
|
+
label: string;
|
|
237
329
|
}
|
|
238
330
|
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
264
|
-
if (counts.
|
|
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.
|
|
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
|
-
|
|
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 —
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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 }
|
|
379
|
+
function gradeReliability(findings: Array<{ severity: string }>, totalLines: number): DimensionScore {
|
|
299
380
|
const counts = countSeverities(findings);
|
|
300
381
|
const worst = worstSeverity(counts);
|
|
301
|
-
const
|
|
302
|
-
const band = isClean
|
|
303
|
-
? reliabilityBandClean(counts)
|
|
304
|
-
: reliabilityBandSevere(counts) ?? reliabilityBandMid(counts);
|
|
382
|
+
const kloc = Math.max(totalLines / 1000, 1.0);
|
|
305
383
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
368
|
-
*
|
|
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 &&
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
418
|
-
|
|
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:
|
|
422
|
-
let worst:
|
|
604
|
+
function worstOf(grades: Grade[]): Grade {
|
|
605
|
+
let worst: Grade = 'A+';
|
|
423
606
|
for (const g of grades) {
|
|
424
|
-
if (
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
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
|
|
733
|
+
const maintainabilityRaw = isDimensionAvailable('maintainability', true, options)
|
|
534
734
|
? gradeMaintainability(buckets.maintainability, totalLines)
|
|
535
735
|
: naDimension('maintainability');
|
|
536
736
|
|
|
537
|
-
|
|
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
|
|
562
|
-
*
|
|
563
|
-
* fail
|
|
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
|
|
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
|
|
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
|
|
572
|
-
failingConditions.push(`Reliability grade
|
|
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
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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).
|