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

32 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 23:18 +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 flatten_inline 

10 

11 

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

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

14 

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

16 

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

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

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

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

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

22 """ 

23 fail_set = {str(s).strip().lower() for s in (fail_on or [])} 

24 # `blocker` is a documented alias for `critical` (group severities are only 

25 # ever critical/major/minor/nit/info), so a `--fail-on blocker` gate must 

26 # match `critical` groups instead of silently never firing. 

27 if "blocker" in fail_set: 

28 fail_set.add("critical") 

29 blocking = [] 

30 for g in groups_with_status: 

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

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

33 if severity not in fail_set: 

34 continue 

35 if status == "unsupported": 

36 continue 

37 if ignore_unverified and status != "verified": 

38 continue 

39 blocking.append(g) 

40 

41 if blocking: 

42 bits = [] 

43 for g in blocking: 

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

45 loc = "" 

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

47 loc = flatten_inline(rep.file) 

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

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

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

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

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

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

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

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

56 reason = ( 

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

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

59 ) 

60 return 1, reason 

61 

62 reason = ( 

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

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

65 ) 

66 return 0, reason