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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
1"""Severity-gated CI exit policy (issue #4).
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
9def evaluate_ci(groups_with_status, fail_on, ignore_unverified: bool) -> tuple[int, str]:
10 """Decide a CI exit code from consensus groups.
12 Returns ``(exit_code, reason)``.
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)
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
55 reason = (
56 f"PASS: no blocking findings at severities {sorted(fail_set)} "
57 f"(ignore_unverified={ignore_unverified})."
58 )
59 return 0, reason