Coverage for src/keel/findings.py: 100%
43 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +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
45 #: How the reviewer reproduced it — the command, the failing case, the steps. Carried
46 #: so the s9 fix brief can hand the fixer the reviewer's own reproduction instead of
47 #: asking them to invent one (#1016). Not part of the ordering or the gate decision.
48 reproduction: str | None = None
50 def __post_init__(self) -> None:
51 if self.severity not in _RANK:
52 raise FindingError(
53 f"unknown severity {self.severity!r}; valid: {', '.join(SEVERITIES)}"
54 )
57def decision_for(severity: str) -> str:
58 """Map a severity to its merge decision (``block`` / ``suggest`` / ``advisory``)."""
59 try:
60 return _DECISION[severity]
61 except KeyError:
62 raise FindingError(
63 f"unknown severity {severity!r}; valid: {', '.join(SEVERITIES)}"
64 ) from None
67def is_anchorable(finding: Finding) -> bool:
68 """True if the finding can be posted as an inline diff comment (file + line)."""
69 return finding.anchorable and finding.path is not None and finding.line is not None
72def sort_findings(findings: list[Finding]) -> list[Finding]:
73 """Deterministic order: severity, then source, path, line, message."""
74 return sorted(
75 findings,
76 key=lambda f: (_RANK[f.severity], f.source, f.path or "", f.line or 0, f.message),
77 )
80@dataclass(frozen=True)
81class Verdict:
82 """Aggregate decision over a set of findings."""
84 blocked: bool
85 counts: dict[str, int]
86 findings: tuple[Finding, ...]
89def summarize(findings: list[Finding]) -> Verdict:
90 """Aggregate findings into a :class:`Verdict` (blocked + per-severity counts)."""
91 counts = {s: 0 for s in SEVERITIES}
92 blocked = False
93 for f in findings:
94 counts[f.severity] += 1
95 if decision_for(f.severity) == "block":
96 blocked = True
97 return Verdict(blocked=blocked, counts=counts, findings=tuple(sort_findings(findings)))