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

1"""Structured findings + the severity → gate-decision mapping. 

2 

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: 

6 

7* ``critical`` / ``major`` ⇒ **block** the merge, 

8* ``minor`` ⇒ **suggest** (gated like a 5c suggestion), 

9* ``nit`` ⇒ **advisory** (logged, never gates). 

10""" 

11 

12from __future__ import annotations 

13 

14from dataclasses import dataclass 

15from typing import Any 

16 

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)} 

20 

21DECISIONS: tuple[str, ...] = ("block", "suggest", "advisory") 

22_DECISION: dict[str, str] = { 

23 "critical": "block", 

24 "major": "block", 

25 "minor": "suggest", 

26 "nit": "advisory", 

27} 

28 

29 

30class FindingError(ValueError): 

31 """Raised on an invalid severity.""" 

32 

33 

34@dataclass(frozen=True) 

35class Finding: 

36 """One normalised quality signal.""" 

37 

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 

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 ) 

51 

52 

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 

61 

62 

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 

66 

67 

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 ) 

74 

75 

76@dataclass(frozen=True) 

77class Verdict: 

78 """Aggregate decision over a set of findings.""" 

79 

80 blocked: bool 

81 counts: dict[str, int] 

82 findings: tuple[Finding, ...] 

83 

84 

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)))