Coverage for src/keel/findings.py: 100%
42 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""Structured findings + the severity → gate-decision mapping.
3Every quality signal in keel — a reviewer finding, a gate result, a jury
4consensus item — is normalised into a :class:`Finding`. The merge decision is a
5pure function of the findings, mirroring ``ship``'s gating rules:
7* ``critical`` / ``major`` ⇒ **block** the merge,
8* ``minor`` ⇒ **suggest** (gated like a 5c suggestion),
9* ``nit`` ⇒ **advisory** (logged, never gates).
10"""
12from __future__ import annotations
14from dataclasses import dataclass
15from typing import Any
17#: Severities, most to least serious.
18SEVERITIES: tuple[str, ...] = ("critical", "major", "minor", "nit")
19_RANK: dict[str, int] = {s: i for i, s in enumerate(SEVERITIES)}
21DECISIONS: tuple[str, ...] = ("block", "suggest", "advisory")
22_DECISION: dict[str, str] = {
23 "critical": "block",
24 "major": "block",
25 "minor": "suggest",
26 "nit": "advisory",
27}
30class FindingError(ValueError):
31 """Raised on an invalid severity."""
34@dataclass(frozen=True)
35class Finding:
36 """One normalised quality signal."""
38 severity: str
39 message: str
40 source: str # gate / reviewer / jury id that produced it
41 path: str | None = None
42 line: int | None = None
43 anchorable: bool = False
44 provenance: dict[str, Any] | None = None
46 def __post_init__(self) -> None:
47 if self.severity not in _RANK:
48 raise FindingError(
49 f"unknown severity {self.severity!r}; valid: {', '.join(SEVERITIES)}"
50 )
53def decision_for(severity: str) -> str:
54 """Map a severity to its merge decision (``block`` / ``suggest`` / ``advisory``)."""
55 try:
56 return _DECISION[severity]
57 except KeyError:
58 raise FindingError(
59 f"unknown severity {severity!r}; valid: {', '.join(SEVERITIES)}"
60 ) from None
63def is_anchorable(finding: Finding) -> bool:
64 """True if the finding can be posted as an inline diff comment (file + line)."""
65 return finding.anchorable and finding.path is not None and finding.line is not None
68def sort_findings(findings: list[Finding]) -> list[Finding]:
69 """Deterministic order: severity, then source, path, line, message."""
70 return sorted(
71 findings,
72 key=lambda f: (_RANK[f.severity], f.source, f.path or "", f.line or 0, f.message),
73 )
76@dataclass(frozen=True)
77class Verdict:
78 """Aggregate decision over a set of findings."""
80 blocked: bool
81 counts: dict[str, int]
82 findings: tuple[Finding, ...]
85def summarize(findings: list[Finding]) -> Verdict:
86 """Aggregate findings into a :class:`Verdict` (blocked + per-severity counts)."""
87 counts = {s: 0 for s in SEVERITIES}
88 blocked = False
89 for f in findings:
90 counts[f.severity] += 1
91 if decision_for(f.severity) == "block":
92 blocked = True
93 return Verdict(blocked=blocked, counts=counts, findings=tuple(sort_findings(findings)))