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.
- 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/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/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 +29 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +50 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +2 -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/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 +1 -1
- 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/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/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/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 +17 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +54 -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 +57 -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 +28 -5
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +10 -4
- 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/stall-assessor.ts +93 -0
- package/server/cli/headless/tool-watchdog.ts +21 -0
- 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 +54 -1
- package/server/cli/improvisation-types.ts +2 -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 +1 -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/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/settings.ts +161 -4
- package/server/services/websocket/git-branch-handlers.ts +20 -6
- package/server/services/websocket/handler.ts +59 -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 +64 -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 +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
|
-
//
|
|
34
|
+
// Score Bands & Modifier Math
|
|
33
35
|
// ============================================================================
|
|
34
36
|
/**
|
|
35
|
-
* Score
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
47
|
+
const BASE_BAND_BOTTOM = {
|
|
61
48
|
A: 90,
|
|
62
49
|
B: 80,
|
|
63
50
|
C: 70,
|
|
64
|
-
|
|
65
|
-
F: 0,
|
|
51
|
+
F: 56, // F- covers 0-55 — handled specially in scoreToGrade()
|
|
66
52
|
};
|
|
67
53
|
/**
|
|
68
|
-
*
|
|
54
|
+
* Convert a 0-100 score to the full letter grade including +/- modifier.
|
|
69
55
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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 =
|
|
76
|
-
const top =
|
|
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
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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 —
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
282
|
-
*
|
|
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 &&
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
326
|
-
|
|
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 (
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
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
|
|
571
|
+
const maintainabilityRaw = isDimensionAvailable('maintainability', true, options)
|
|
422
572
|
? gradeMaintainability(buckets.maintainability, totalLines)
|
|
423
573
|
: naDimension('maintainability');
|
|
424
|
-
|
|
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
|
-
|
|
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
|
|
444
|
-
*
|
|
445
|
-
* fail
|
|
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
|
|
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
|
|
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
|
|
453
|
-
failingConditions.push(`Reliability grade
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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) ??
|