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

31 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 20: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""" 

6from __future__ import annotations 

7 

8 

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

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

11 

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

13 

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

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

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

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

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

19 """ 

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

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

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

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

24 if "blocker" in fail_set: 

25 fail_set.add("critical") 

26 blocking = [] 

27 for g in groups_with_status: 

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

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

30 if severity not in fail_set: 

31 continue 

32 if status == "unsupported": 

33 continue 

34 if ignore_unverified and status != "verified": 

35 continue 

36 blocking.append(g) 

37 

38 if blocking: 

39 bits = [] 

40 for g in blocking: 

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

42 loc = "" 

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

44 loc = rep.file 

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

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

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

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

49 reason = ( 

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

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

52 ) 

53 return 1, reason 

54 

55 reason = ( 

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

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

58 ) 

59 return 0, reason