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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06: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"""
7from __future__ import annotations
9from .findings import SEVERITY_INPUTS, canonical_severity, flatten_inline
12def resolve_fail_on(fail_on) -> tuple[set[str], list[str]]:
13 """Split a configured fail-on list into canonical severities and unknowns.
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
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 )
41def fail_on_error(fail_on, where: str) -> str | None:
42 """Message naming every unrecognised severity in ``fail_on``, else ``None``.
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
51def evaluate_ci(groups_with_status, fail_on, ignore_unverified: bool) -> tuple[int, str]:
52 """Decide a CI exit code from consensus groups.
54 Returns ``(exit_code, reason)``.
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.
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)
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
104 reason = (
105 f"PASS: no blocking findings at severities {sorted(fail_set)} "
106 f"(ignore_unverified={ignore_unverified})."
107 )
108 return 0, reason