Health Score¶
Overview¶
The health score is a 0–100 integer summarizing every enabled analysis (complexity, dead code, duplication, coupling, cohesion, dependencies, architecture) into a single value. The calculation is pure and deterministic — given the same AnalyzeSummary inputs, CalculateHealthScore() always produces the same score. It is designed to be stored per commit in CI so changes are tracked over time.
Grade¶
The score maps to a letter grade using strict ≥ thresholds:
| Grade | Score range | Threshold constant |
|---|---|---|
| A | 90–100 | GradeAThreshold = 90 |
| B | 75–89 | GradeBThreshold = 75 |
| C | 60–74 | GradeCThreshold = 60 |
| D | 45–59 | GradeDThreshold = 45 |
| F | 0–44 | (below D) |
Defined in domain/analyze.go:90-93. A project is considered "healthy" at HealthScore ≥ 70 (HealthyThreshold, domain/analyze.go:103).
Formula¶
score = 100
- complexityPenalty (0–20)
- deadCodePenalty (0–20)
- duplicationPenalty (0–20)
- couplingPenalty (0–20)
- cohesionPenalty (0–20)
- dependencyPenalty (0–16)
- architecturePenalty (0–12)
HealthScore = max(0, score)
Each penalty is capped at its individual maximum:
| Category | Max penalty | Constant |
|---|---|---|
| Complexity | 20 | literal 20.0 in formula |
| Dead Code | 20 | MaxDeadCodePenalty = 20 |
| Duplication | 20 | literal 20.0 in formula |
| Coupling | 20 | literal 20.0 in formula |
| Cohesion | 20 | literal 20.0 in formula |
| Dependencies | 16 | MaxDependencyPenalty = 10+3+3 |
| Architecture | 12 | MaxArchitecturePenalty = 12 |
The score floor is MinimumScore = 0 (domain/analyze.go:102), applied after penalty summation.
Penalty specifications¶
Complexity¶
Inputs. AverageComplexity (float64).
Formula.
if AverageComplexity <= 2.0:
penalty = 0
else:
penalty = min(20, round((AverageComplexity - 2.0) / 13.0 * 20.0))
Constants.
| Name | Value | Meaning |
|---|---|---|
| baseline | 2.0 | Average complexity below which penalty = 0 |
| range | 13.0 | Denominator — max penalty reached at avg = 15 |
| max | 20.0 | Penalty cap |
Saturation. Reaches 20 at AverageComplexity >= 15.0.
Edge cases. AverageComplexity = 0 or any value ≤ 2.0 yields penalty 0. Validate() rejects negative values.
Source: domain/analyze.go:266-279.
Dead Code¶
Inputs. CriticalDeadCode, WarningDeadCode, InfoDeadCode (int). TotalFiles (int) is used to derive the normalization factor in CalculateHealthScore().
Formula.
weighted = CriticalDeadCode * 1.0
+ WarningDeadCode * 0.5
+ InfoDeadCode * 0.2
if TotalFiles <= 10:
normalization = 1.0
else:
normalization = 1.0 + log10(TotalFiles / 10.0)
if weighted <= 0:
penalty = 0
else:
penalty = int(min(20.0, weighted / normalization))
Constants.
| Name | Value | Meaning |
|---|---|---|
| critical weight | 1.0 | Full weight for Critical findings |
| warning weight | 0.5 | Half weight for Warning findings |
| info weight | 0.2 | Minimal weight for Info findings |
| normalization threshold | 10 | Files below which norm factor = 1 |
MaxDeadCodePenalty |
20 | Penalty cap |
Saturation. Reaches 20 when weighted / normalization >= 20.0.
Edge cases. Zero findings yields penalty 0. The truncation is int() (toward zero), not math.Round — so a computed value of 1.99 becomes 1.
Source: domain/analyze.go:283-296. Normalization factor derived in CalculateHealthScore() at domain/analyze.go:474-477.
Duplication¶
Inputs. CodeDuplication (float64). This field is a pre-computed duplication percentage, capped at 10 by the upstream calculation.
Formula.
Constants.
| Name | Value | Meaning |
|---|---|---|
DuplicationThresholdLow |
0.0 | 0% duplication = 0 penalty |
DuplicationThresholdHigh |
10.0 | 10% duplication = max penalty |
| max | 20.0 | Penalty cap |
Upstream computation. CodeDuplication itself is computed in app/analyze_usecase.go:570-583:
lines_in_thousands = max(GroupDensityMinLines, total_lines / GroupDensityLinesUnit)
group_density = clone_groups / lines_in_thousands
CodeDuplication = min(DuplicationThresholdHigh, group_density * GroupDensityCoefficient)
Where GroupDensityLinesUnit = 1000.0, GroupDensityMinLines = 1.0, GroupDensityCoefficient = 20.0, DuplicationThresholdHigh = 10.0 (domain/analyze.go:52, 62-64).
Saturation. Reaches 20 when CodeDuplication >= 10.0. Since the upstream caps at 10, saturation is reached at exactly 0.5 clone groups per 1000 analyzed lines.
Edge cases. No clone groups or no analyzed lines → CodeDuplication = 0 → penalty 0. Validate() rejects values outside [0, 100].
Source: domain/analyze.go:300-314.
Coupling (CBO)¶
Inputs. CBOClasses, HighCouplingClasses (CBO > 7), MediumCouplingClasses (3 < CBO ≤ 7).
Formula.
if CBOClasses == 0:
penalty = 0
else:
weighted = HighCouplingClasses + 0.5 * MediumCouplingClasses
ratio = weighted / CBOClasses
penalty = min(20, round(ratio / 0.25 * 20.0))
Constants.
| Name | Value | Meaning |
|---|---|---|
| high weight | 1.0 | Full weight for high-coupling classes |
| medium weight | 0.5 | Half weight for medium-coupling classes |
| saturation ratio | 0.25 | 25% weighted-problematic classes → max penalty |
| max | 20.0 | Penalty cap |
Saturation. Reaches 20 when the weighted ratio is ≥ 0.25.
Edge cases. CBOClasses = 0 → penalty 0. Validate() ensures high + medium ≤ total.
Source: domain/analyze.go:318-336.
Cohesion (LCOM)¶
Inputs. LCOMClasses, HighLCOMClasses (LCOM4 > 5), MediumLCOMClasses (2 < LCOM4 ≤ 5).
Formula.
if LCOMClasses == 0:
penalty = 0
else:
weighted = HighLCOMClasses + 0.5 * MediumLCOMClasses
ratio = weighted / LCOMClasses
penalty = min(20, round(ratio / 0.30 * 20.0))
Constants.
| Name | Value | Meaning |
|---|---|---|
| high weight | 1.0 | Full weight for high-LCOM classes |
| medium weight | 0.5 | Half weight for medium-LCOM classes |
| saturation ratio | 0.30 | 30% weighted-problematic classes → max penalty |
| max | 20.0 | Penalty cap |
Saturation. Reaches 20 when the weighted ratio is ≥ 0.30.
Edge cases. LCOMClasses = 0 → penalty 0. Validate() ensures high + medium ≤ total.
Source: domain/analyze.go:340-358.
Dependencies¶
Inputs. DepsEnabled (bool), DepsTotalModules, DepsModulesInCycles, DepsMaxDepth (int), DepsMainSequenceDeviation (float64, 0–1).
Formula. Three sub-penalties summed:
if not DepsEnabled:
return 0
# Cycles sub-penalty (max 10)
if DepsTotalModules > 0:
ratio = clamp(0, 1, DepsModulesInCycles / DepsTotalModules)
cycles = round(10 * ratio)
else:
cycles = 0
# Depth sub-penalty (max 3)
if DepsTotalModules > 0:
expected = max(3, ceil(log2(DepsTotalModules + 1)) + 1)
depth = clamp(0, 3, DepsMaxDepth - expected)
else:
depth = 0
# Main Sequence Deviation sub-penalty (max 3)
if DepsMainSequenceDeviation > 0:
msd = round(3 * clamp(0, 1, DepsMainSequenceDeviation))
else:
msd = 0
penalty = cycles + depth + msd # max 16
Constants.
| Name | Value | Meaning |
|---|---|---|
MaxCyclesPenalty |
10 | Cap for cycles sub-penalty |
MaxDepthPenalty |
3 | Cap for depth sub-penalty |
MaxMSDPenalty |
3 | Cap for MSD sub-penalty |
MaxDependencyPenalty |
16 | Sum of the three caps |
Saturation. Reaches 16 when every module is in a cycle, depth exceeds expected by ≥ 3, and MSD ≥ 1.
Edge cases. DepsEnabled = false → penalty 0. DepsTotalModules = 0 → cycles and depth contribute 0; only MSD can fire. Validate() enforces MSD ∈ [0, 1] and DepsModulesInCycles ≤ DepsTotalModules.
Source: domain/analyze.go:361-406.
Architecture¶
Inputs. ArchEnabled (bool), ArchCompliance (float64, 0–1).
Formula.
Constants.
| Name | Value | Meaning |
|---|---|---|
MaxArchPenalty |
12 | Penalty cap |
MaxArchitecturePenalty |
12 | Alias for the cap |
Saturation. Reaches 12 at ArchCompliance = 0.0.
Edge cases. ArchEnabled = false → penalty 0. Validate() enforces ArchCompliance ∈ [0, 1] when enabled.
Source: domain/analyze.go:409-422.
Category scores¶
Each category exposes a 0–100 score in the report. The conversion depends on the category's penalty scale.
Most categories (Complexity, Dead Code, Duplication, Coupling, Cohesion). These all have penalty cap 20 (MaxScoreBase = 20). The score is computed by penaltyToScore(penalty, 20):
Effectively each unit of penalty costs 5 score points. A penalty of 0 yields 100; a penalty of 20 yields 0.
Dependencies. Penalty cap is 16, so the score is normalized to the 20-point scale first via normalizeToScoreBase:
normalized = round(dependencyPenalty / 16 * 20)
DependencyScore = 100 - round(normalized * 100 / 20) = 100 - normalized * 5
Architecture. Special case — the score is taken directly from compliance:
So ArchCompliance = 0.98 yields ArchitectureScore = 98, regardless of the penalty rounding that feeds the overall score.
Source: domain/analyze.go:426-453, domain/analyze.go:483-510.
Worked example¶
Inputs:
| Field | Value |
|---|---|
TotalFiles |
50 |
AverageComplexity |
8.0 |
CriticalDeadCode |
2 |
WarningDeadCode |
1 |
InfoDeadCode |
0 |
CodeDuplication |
7.5 |
CBOClasses |
20 |
HighCouplingClasses |
3 |
MediumCouplingClasses |
2 |
LCOMClasses |
20 |
HighLCOMClasses |
1 |
MediumLCOMClasses |
3 |
DepsEnabled |
true |
DepsTotalModules |
8 |
DepsModulesInCycles |
1 |
DepsMaxDepth |
5 |
DepsMainSequenceDeviation |
0.2 |
ArchEnabled |
true |
ArchCompliance |
0.85 |
Complexity penalty. (8.0 − 2.0) / 13.0 × 20.0 = 9.2308 → round → 9.
Dead code penalty. weighted = 2×1.0 + 1×0.5 + 0×0.2 = 2.5. normalization = 1.0 + log10(50/10) = 1.0 + log10(5) ≈ 1.6990. 2.5 / 1.6990 ≈ 1.4715. int(min(20.0, 1.4715)) = 1.
Duplication penalty. 7.5 / 10.0 × 20.0 = 15.0 → round → 15.
Coupling penalty. weighted = 3 + 0.5×2 = 4. ratio = 4/20 = 0.20. 0.20 / 0.25 × 20.0 = 16.0 → round → 16.
Cohesion penalty. weighted = 1 + 0.5×3 = 2.5. ratio = 2.5/20 = 0.125. 0.125 / 0.30 × 20.0 ≈ 8.333 → round → 8.
Dependency penalty.
- Cycles: ratio = 1/8 = 0.125. round(10 × 0.125) = round(1.25) = 1.
- Depth: expected = max(3, ceil(log2(9)) + 1) = max(3, 4 + 1) = 5. Excess = 5 − 5 = 0.
- MSD: round(3 × 0.2) = round(0.6) = 1.
- Total: 1 + 0 + 1 = 2.
Architecture penalty. round(12 × (1 − 0.85)) = round(1.8) = 2.
Sum. 9 + 1 + 15 + 16 + 8 + 2 + 2 = 53.
HealthScore. max(0, 100 − 53) = 47. Grade: 47 ≥ 45 → D.
Category scores (as reported).
| Category | Penalty | Score |
|---|---|---|
| Complexity | 9 | 100 − 9×5 = 55 |
| Dead Code | 1 | 100 − 1×5 = 95 |
| Duplication | 15 | 100 − 15×5 = 25 |
| Coupling | 16 | 100 − 16×5 = 20 |
| Cohesion | 8 | 100 − 8×5 = 60 |
| Dependencies | 2 | normalized = round(2/16 × 20) = 3; 100 − 3×5 = 85 |
| Architecture | — | round(0.85 × 100) = 85 |
Fallback score¶
CalculateHealthScore() first calls Validate() on the summary. If validation fails — e.g. AverageComplexity < 0, CodeDuplication outside [0, 100], ArchCompliance outside [0, 1] when enabled, DepsMainSequenceDeviation outside [0, 1] when enabled, or the sum of high + medium classes exceeding the total for LCOM or CBO — the summary's scores are zeroed, the grade is set to "N/A", and an error is returned. The caller may then invoke CalculateFallbackScore() as a degraded path: starting from 100, it subtracts FallbackComplexityThreshold = 10 if AverageComplexity > 10, and FallbackPenalty = 5 each for DeadCodeCount > 0, HighComplexityCount > 0, and HighLCOMClasses > 0, flooring at MinimumScore = 0.
Source: domain/analyze.go:200-262 (Validate), domain/analyze.go:456-470 (validation branch in CalculateHealthScore), domain/analyze.go:538-566 (CalculateFallbackScore).
Rounding¶
All non-integer intermediates are reduced to integers using Go's math.Round, which applies banker's rounding for exact .5 values (round-half-away-from-zero for positive values in Go's implementation). The final health score is clamped to [0, 100] via MinimumScore. Category scores are clamped to [0, 100] inside penaltyToScore (domain/analyze.go:441-453). The one exception to math.Round is the dead-code penalty, which uses int() truncation after math.Min (domain/analyze.go:294).
Tracking over time¶
The formula is deterministic — identical inputs always produce identical scores across runs and platforms. To track health over time, persist summary.health_score from the JSON output per commit and compare in CI. The per-category scores (complexity_score, dead_code_score, etc.) are likewise stable and can be tracked individually.
References¶
All line numbers refer to domain/analyze.go:
CalculateHealthScore()— lines 456–534calculateComplexityPenalty()— lines 266–279calculateDeadCodePenalty(normalizationFactor)— lines 283–296calculateDuplicationPenalty()— lines 300–314calculateCouplingPenalty()— lines 318–336calculateCohesionPenalty()— lines 340–358calculateDependencyPenalty()— lines 361–406calculateArchitecturePenalty()— lines 409–422CalculateFallbackScore()— lines 538–566Validate()— lines 200–262penaltyToScore()— lines 441–453normalizeToScoreBase()— lines 426–438GetGradeFromScore()— lines 569–582
Upstream CodeDuplication computation: app/analyze_usecase.go:570-583.