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

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 #: 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 

49 

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 ) 

55 

56 

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 

65 

66 

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 

70 

71 

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 ) 

78 

79 

80@dataclass(frozen=True) 

81class Verdict: 

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

83 

84 blocked: bool 

85 counts: dict[str, int] 

86 findings: tuple[Finding, ...] 

87 

88 

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