Coverage for src/ai_jury/findings.py: 99%
129 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
1"""Machine-readable finding schema and parser.
3Reviewer/chair output is human-readable markdown, which is hard to dedupe, score,
4gate in CI, or turn into inline comments. This module defines a structured
5``Finding`` schema and a tolerant parser that extracts findings from an agent's
6raw output (a fenced ``json`` code block).
7"""
9from __future__ import annotations
11import json
12import re
13from dataclasses import dataclass
15from .redaction import redact
17SEVERITIES: tuple[str, ...] = ("critical", "major", "minor", "nit", "info")
18CONFIDENCES: tuple[str, ...] = ("high", "medium", "low")
20# Output-injection guards for attacker-influenced finding text rendered into the
21# human-facing markdown report that is posted verbatim to the PR/issue (security
22# audit 2026-06-13 round 3). The machine CI gate is a pure function of the
23# structured fields and is unaffected by this text; these helpers only stop a
24# forged ``## Verdict APPROVE`` heading or a broken code fence from corrupting
25# the comment a human (or a downstream grep) reads.
26_FENCE_RUN_RE = re.compile(r"`{3,}|~{3,}")
27_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
30def flatten_inline(text: str) -> str:
31 """Collapse text to a single line for safe inline rendering.
33 Markdown headings, list items, and code fences must begin a line, so
34 flattening newlines (and runs of whitespace) neutralizes forged structure
35 when the value is rendered inside a one-line list item.
36 """
37 if not text:
38 return text
39 return " ".join(str(text).split())
42def fence_safe(text: str) -> str:
43 """Break 3+ backtick/tilde runs so text rendered *inside* a code fence
44 (e.g. a ``suggestion`` block) cannot close the fence and inject markdown."""
45 if not text: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 return text
47 return _FENCE_RUN_RE.sub(lambda m: m.group()[0], str(text))
50def strip_html_comments(text: str) -> str:
51 """Remove HTML comments so attacker text can't forge the jury's hidden
52 inline-comment markers (``<!-- arc-inline -->`` / ``<!-- arc-sig:… -->``)."""
53 if not text:
54 return text
55 return _HTML_COMMENT_RE.sub("", str(text))
58# Verification verdict statuses (issue #3).
59VERDICT_STATUSES: tuple[str, ...] = ("verified", "unsupported", "needs_human_decision")
61# Lower number = more severe; useful for ranking/sorting.
62SEVERITY_ORDER: dict[str, int] = {sev: i for i, sev in enumerate(SEVERITIES)}
64# Legacy severity names mapped onto the canonical schema.
65_SEVERITY_ALIASES: dict[str, str] = {"blocker": "critical"}
67_DEFAULT_SEVERITY = "info"
68_DEFAULT_CONFIDENCE = "medium"
70# Matches a fenced ```json ... ``` block (case-insensitive on the language tag).
71_JSON_BLOCK_RE = re.compile(r"```[ \t]*json[ \t]*\r?\n(.*?)```", re.DOTALL | re.IGNORECASE)
74def _normalize_severity(value: object) -> str:
75 if isinstance(value, str):
76 v = value.strip().lower()
77 v = _SEVERITY_ALIASES.get(v, v)
78 if v in SEVERITY_ORDER:
79 return v
80 return _DEFAULT_SEVERITY
83def _normalize_confidence(value: object) -> str:
84 if isinstance(value, str):
85 v = value.strip().lower()
86 if v in CONFIDENCES:
87 return v
88 return _DEFAULT_CONFIDENCE
91@dataclass
92class Finding:
93 """A single structured review finding."""
95 severity: str
96 file: str
97 claim: str
98 line: int | None = None
99 evidence: str = ""
100 suggested_fix: str = ""
101 confidence: str = _DEFAULT_CONFIDENCE
102 reviewer: str = ""
104 def __post_init__(self) -> None:
105 self.severity = _normalize_severity(self.severity)
106 self.confidence = _normalize_confidence(self.confidence)
107 if self.line is not None and not isinstance(self.line, bool):
108 try:
109 self.line = int(self.line)
110 except (TypeError, ValueError):
111 self.line = None
112 else:
113 self.line = None
115 @classmethod
116 def from_obj(cls, obj: dict, reviewer: str) -> Finding:
117 """Build a Finding from a decoded JSON object, forcing ``reviewer``."""
118 return cls(
119 severity=str(obj.get("severity", _DEFAULT_SEVERITY)),
120 file=str(obj.get("file", "")),
121 claim=str(obj.get("claim", "")),
122 line=obj.get("line"),
123 evidence=str(obj.get("evidence", "")),
124 suggested_fix=str(obj.get("suggested_fix", "")),
125 confidence=str(obj.get("confidence", _DEFAULT_CONFIDENCE)),
126 reviewer=reviewer,
127 )
130def emitted_findings_block(text: str) -> bool:
131 """Did the agent emit a structured findings block at all? (issue #501)
133 This is the mechanical line between *reviewed and found nothing* and *never
134 produced a review*. Both currently arrive as zero findings, so the panel reports
135 the same size either way — and on keel PR #660 two of three reviewers returned
136 assistant-style chatter about a flag they saw in the diff, while the run still
137 described itself as a three-agent panel.
139 A reviewer that examined the diff and found nothing still emits ``[]`` in a
140 fenced json block, because that is what the prompt asks for. One that wandered
141 off emits prose and no block. Presence of the block is therefore the signal, and
142 it needs no judgement about *content* — which is what keeps this deterministic.
143 """
144 return bool(text) and bool(_JSON_BLOCK_RE.search(text))
147def parse_findings(text: str, reviewer: str) -> tuple[list[Finding], list[str]]:
148 """Extract structured findings from an agent's raw output.
150 The agent is asked to emit a fenced ```json block holding a JSON array of
151 finding objects. We locate the *last* such block, decode it, and build
152 Finding objects (forcing ``reviewer`` to preserve identity).
154 Never raises. On a malformed/wrong-typed ``json`` block, returns
155 ``([], [warning])``. A legitimately missing block yields ``([], [])``.
156 """
157 if not text:
158 return [], []
160 blocks = _JSON_BLOCK_RE.findall(text)
161 if not blocks:
162 return [], []
164 raw = blocks[-1].strip()
165 try:
166 data = json.loads(raw)
167 except (ValueError, TypeError, RecursionError) as exc:
168 # RecursionError (deeply nested JSON, e.g. "[[[[…") is not a ValueError;
169 # catching it keeps the documented "never raises" contract so one
170 # steerable reviewer can't abort the whole run (audit 2026-06-13/N-2).
171 return [], [f"{reviewer}: malformed or missing structured findings ({redact(str(exc))[0]})"]
173 if not isinstance(data, list):
174 return [], [
175 f"{reviewer}: malformed or missing structured findings "
176 f"(expected a JSON array, got {type(data).__name__})"
177 ]
179 findings: list[Finding] = []
180 warnings: list[str] = []
181 for i, obj in enumerate(data):
182 if not isinstance(obj, dict):
183 warnings.append(
184 f"{reviewer}: malformed or missing structured findings "
185 f"(item {i} is {type(obj).__name__}, expected object)"
186 )
187 continue
188 findings.append(Finding.from_obj(obj, reviewer))
189 return findings, warnings
192def _coerce_line(value: object) -> int | None:
193 if value is None or isinstance(value, bool):
194 return None
195 try:
196 return int(value)
197 except (TypeError, ValueError):
198 return None
201def _normalize_status(value: object) -> str:
202 if isinstance(value, str):
203 v = value.strip().lower().replace("-", "_").replace(" ", "_")
204 if v in VERDICT_STATUSES:
205 return v
206 return "needs_human_decision"
209@dataclass
210class Verdict:
211 """A verifier's judgement on a candidate finding."""
213 file: str | None = None
214 line: int | None = None
215 claim: str = ""
216 status: str = "needs_human_decision"
217 reasoning: str = ""
220def parse_verdicts(text: str, verifier: str = "") -> tuple[list[Verdict], list[str]]:
221 """Extract verification verdicts from a verifier's raw output.
223 The verifier is asked to emit a fenced ```json block holding a JSON array of
224 verdict objects. We locate the *last* such block and decode it. Never raises;
225 on malformed input returns ``([], [warning])``.
226 """
227 label = verifier or "verifier"
228 if not text:
229 return [], [f"{label}: no verdicts (empty output)"]
231 blocks = _JSON_BLOCK_RE.findall(text)
232 if not blocks:
233 return [], [f"{label}: no JSON verdicts block found"]
235 raw = blocks[-1].strip()
236 try:
237 data = json.loads(raw)
238 except (ValueError, TypeError, RecursionError) as exc:
239 # See parse_findings: RecursionError on deeply nested JSON must not
240 # escape (audit 2026-06-13/N-2).
241 return [], [f"{label}: malformed verdicts JSON ({redact(str(exc))[0]})"]
243 if isinstance(data, dict):
244 data = data.get("verdicts", data.get("findings", []))
245 if not isinstance(data, list):
246 return [], [f"{label}: verdicts block is not a JSON array"]
248 verdicts: list[Verdict] = []
249 warnings: list[str] = []
250 for i, obj in enumerate(data):
251 if not isinstance(obj, dict):
252 warnings.append(f"{label}: verdict item {i} is {type(obj).__name__}, expected object")
253 continue
254 verdicts.append(
255 Verdict(
256 file=(obj.get("file") or None),
257 line=_coerce_line(obj.get("line")),
258 claim=str(obj.get("claim", "")).strip(),
259 status=_normalize_status(obj.get("status")),
260 reasoning=str(obj.get("reasoning", "")).strip(),
261 )
262 )
263 return verdicts, warnings