Coverage for src/ai_jury/ci.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-30 06:29 +0000

1"""Severity-gated CI exit policy (issue #4). 

2 

3A pure decision function over the consensus groups: given the configured blocking 

4severities and how to treat unverified findings, decide a process exit code. 

5""" 

6 

7from __future__ import annotations 

8 

9from .findings import SEVERITY_INPUTS, canonical_severity, flatten_inline 

10 

11 

12def resolve_fail_on(fail_on) -> tuple[set[str], list[str]]: 

13 """Split a configured fail-on list into canonical severities and unknowns. 

14 

15 Blank entries are dropped. Recognised entries come back canonicalised — so 

16 the documented ``blocker`` alias matches ``critical`` groups instead of 

17 silently never firing — and anything outside the vocabulary is returned, in 

18 the spelling the operator wrote, for a caller to refuse. 

19 """ 

20 known: set[str] = set() 

21 unknown: list[str] = [] 

22 for entry in fail_on or []: 

23 text = str(entry).strip() 

24 if not text: 

25 continue 

26 severity = canonical_severity(text) 

27 if severity is None: 

28 unknown.append(text) 

29 else: 

30 known.add(severity) 

31 return known, unknown 

32 

33 

34def _vocabulary_error(unknown: list[str], where: str) -> str: 

35 return ( 

36 f"{where} contains unknown severities {unknown!r} " 

37 f"(expected one of {', '.join(SEVERITY_INPUTS)})." 

38 ) 

39 

40 

41def fail_on_error(fail_on, where: str) -> str | None: 

42 """Message naming every unrecognised severity in ``fail_on``, else ``None``. 

43 

44 Shared by ``validate_config`` and the ``--fail-on`` flag so a typo is 

45 reported the same way whichever surface it was written on (issue #718). 

46 """ 

47 _, unknown = resolve_fail_on(fail_on) 

48 return _vocabulary_error(unknown, where) if unknown else None 

49 

50 

51def evaluate_ci(groups_with_status, fail_on, ignore_unverified: bool) -> tuple[int, str]: 

52 """Decide a CI exit code from consensus groups. 

53 

54 Returns ``(exit_code, reason)``. 

55 

56 A group fails CI when its severity is in ``fail_on`` AND it is either 

57 verified (status == "verified") or, when ``ignore_unverified`` is False, has 

58 any non-"unsupported" status. Findings the verifier marked "unsupported" 

59 never fail CI. When ``ignore_unverified`` is True, groups that were never 

60 verified (empty status) do not fail CI; only explicitly verified ones can. 

61 

62 Raises ``ValueError`` when ``fail_on`` names a severity outside the 

63 vocabulary. A misspelling matches no group, so treating it as "blocks 

64 nothing" would report a green PASS quoting the typo and disable the gate 

65 forever; the CLI and ``validate_config`` refuse one first, and this guard 

66 keeps a caller that bypasses both from reopening the hole (issue #718). 

67 """ 

68 fail_set, unknown = resolve_fail_on(fail_on) 

69 if unknown: 

70 raise ValueError(_vocabulary_error(unknown, "fail_on")) 

71 blocking = [] 

72 for g in groups_with_status: 

73 severity = getattr(g, "severity", "") 

74 status = getattr(g, "status", "") or "" 

75 if severity not in fail_set: 

76 continue 

77 if status == "unsupported": 

78 continue 

79 if ignore_unverified and status != "verified": 

80 continue 

81 blocking.append(g) 

82 

83 if blocking: 

84 bits = [] 

85 for g in blocking: 

86 rep = getattr(g, "representative", None) 

87 loc = "" 

88 if rep is not None and getattr(rep, "file", None): 

89 loc = flatten_inline(rep.file) 

90 if getattr(rep, "line", None) is not None: 

91 loc += f":{rep.line}" 

92 # Flatten the attacker-influenced file/claim: this reason line is 

93 # posted to the PR as the CI-gate section, so a multi-line claim could 

94 # otherwise forge a heading/marker in the comment (audit 2026-06-13 

95 # r7/M). This stays a pure function. 

96 claim = flatten_inline(getattr(rep, "claim", "")) if rep is not None else "" 

97 bits.append(f"[{g.severity}] {loc or '(no location)'} {claim}".strip()) 

98 reason = ( 

99 f"FAIL: {len(blocking)} blocking finding(s) at severities " 

100 f"{sorted(fail_set)}: " + "; ".join(bits) 

101 ) 

102 return 1, reason 

103 

104 reason = ( 

105 f"PASS: no blocking findings at severities {sorted(fail_set)} " 

106 f"(ignore_unverified={ignore_unverified})." 

107 ) 

108 return 0, reason