Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/skillspector/nodes/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,26 @@ def _compute_risk_score(
a quarter. Occurrences beyond the third are ignored for scoring purposes.
This prevents repeated pattern matches from inflating the score unboundedly.

Each finding's contribution is also scaled by its confidence value (clamped
to [0, 1]). Findings with confidence <= 0 are skipped entirely — they do not
contribute to the score but remain in the reported findings list.

Within each rule_id bucket, findings are processed in severity-descending
order so that the highest-severity occurrence always receives the full weight.

Base points per severity: CRITICAL=50, HIGH=25, MEDIUM=10, LOW=5.
Multiplier: 1.3x if has_executable_scripts.
"""
severity_rank = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
sorted_findings = sorted(
findings,
key=lambda f: (f.rule_id or "UNKNOWN", severity_rank.get((f.severity or "LOW").upper(), 4)),
)

rule_occurrence_count: dict[str, int] = {}
score = 0.0

for f in findings:
for f in sorted_findings:
confidence = max(0.0, min(1.0, f.confidence))
if confidence <= 0.0:
continue
Expand Down
15 changes: 15 additions & 0 deletions tests/nodes/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ def test_same_rule_mixed_severities(self) -> None:
# First TM1: 50*1.0, second TM1: 5*0.5 = 2.5 -> total 52.5 -> 52
assert score == 52

def test_same_rule_low_before_critical_sorted_correctly(self) -> None:
"""LOW before CRITICAL in input order must still score as if CRITICAL came first.

Without severity sorting, LOW gets the full weight (5*1.0=5) and CRITICAL
gets the diminished weight (50*0.5=25), yielding 30. With sorting, CRITICAL
gets full weight (50*1.0=50) and LOW gets diminished (5*0.5=2.5), yielding 52.
"""
findings = [
_finding("TM1", "LOW", confidence=1.0),
_finding("TM1", "CRITICAL", confidence=1.0),
]
score, _, _ = _compute_risk_score(findings, False)
# Sorted: CRITICAL first (50*1.0) + LOW second (5*0.5=2.5) = 52.5 -> 52
assert score == 52

def test_exact_band_boundary_21_is_medium(self) -> None:
findings = [
_finding("R1", "MEDIUM", confidence=1.0),
Expand Down