Coverage for src/ai_jury/patches.py: 100%
42 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"""Suggested-patch output for verified findings (issue #10).
3The jury identifies issues; this renders a *separate*, opt-in "suggested
4patches" section that turns verified findings into concrete, inspectable fix
5suggestions. It is deliberately conservative:
7- only VERIFIED findings (a consensus group the verifier confirmed) produce a
8 suggestion — unverified or rejected findings never do;
9- suggestions are rendered as clearly-labelled blocks tied to one finding;
10- nothing is ever applied automatically (read-only by design); the output is for
11 a human to inspect, copy, or adapt.
13Pure and deterministic: given the same groups it renders the same markdown.
14"""
16from __future__ import annotations
18from dataclasses import dataclass
20from .consensus import BUCKET_REJECTED, FindingGroup
21from .findings import fence_safe, flatten_inline
24@dataclass
25class PatchSuggestion:
26 file: str
27 line: int | None
28 severity: str
29 claim: str
30 suggested_fix: str
32 def location(self) -> str:
33 loc = self.file or "?"
34 if self.line is not None:
35 loc = f"{loc}:{self.line}"
36 return loc
39def patch_suggestions(groups: list[FindingGroup]) -> list[PatchSuggestion]:
40 """Return one suggestion per VERIFIED group that carries a suggested fix.
42 A group qualifies only when the verifier marked it ``verified`` (not
43 unsupported/disputed and not merely unverified) AND its representative
44 finding has a non-empty ``suggested_fix``. Order follows the input group
45 order (already severity-sorted by the consensus pass).
46 """
47 out: list[PatchSuggestion] = []
48 for g in groups:
49 if getattr(g, "status", "") != "verified" or g.bucket == BUCKET_REJECTED:
50 continue
51 rep = g.representative
52 fix = (getattr(rep, "suggested_fix", "") or "").strip()
53 if not rep or not fix:
54 continue
55 out.append(
56 PatchSuggestion(
57 file=rep.file or "",
58 line=rep.line,
59 severity=g.severity,
60 claim=(rep.claim or "").strip(),
61 suggested_fix=fix,
62 )
63 )
64 return out
67def render_patch_suggestions(groups: list[FindingGroup]) -> str:
68 """Render the "Suggested patches" markdown section, or "" when there are none.
70 Kept separate from the default report so the standard review flow stays
71 read-only; the CLI emits this only under ``--suggest-patches``.
72 """
73 suggestions = patch_suggestions(groups)
74 if not suggestions:
75 return ""
76 lines = [
77 "## Suggested patches",
78 "",
79 "_Opt-in, read-only suggestions for **verified** findings only. Inspect "
80 "before applying — nothing here is applied automatically._",
81 "",
82 ]
83 for s in suggestions:
84 # Flatten the heading text and break any fence-closer inside the
85 # suggestion body so attacker-influenced finding text can't inject a
86 # forged verdict/heading into the posted comment (audit 2026-06-13 r3).
87 lines.append(
88 f"### {flatten_inline(s.location())} — [{s.severity}] {flatten_inline(s.claim)}"
89 )
90 lines.append("")
91 lines.append("> Verified by the jury.")
92 lines.append("")
93 lines.append("```suggestion")
94 lines.append(fence_safe(s.suggested_fix))
95 lines.append("```")
96 lines.append("")
97 return "\n".join(lines).rstrip() + "\n"