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
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +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"""
7from __future__ import annotations
9from .findings import flatten_inline
12def evaluate_ci(groups_with_status, fail_on, ignore_unverified: bool) -> tuple[int, str]:
13 """Decide a CI exit code from consensus groups.
15 Returns ``(exit_code, reason)``.
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)
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
62 reason = (
63 f"PASS: no blocking findings at severities {sorted(fail_set)} "
64 f"(ignore_unverified={ignore_unverified})."
65 )
66 return 0, reason