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

1"""Suggested-patch output for verified findings (issue #10). 

2 

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: 

6 

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. 

12 

13Pure and deterministic: given the same groups it renders the same markdown. 

14""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass 

19 

20from .consensus import BUCKET_REJECTED, FindingGroup 

21from .findings import fence_safe, flatten_inline 

22 

23 

24@dataclass 

25class PatchSuggestion: 

26 file: str 

27 line: int | None 

28 severity: str 

29 claim: str 

30 suggested_fix: str 

31 

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 

37 

38 

39def patch_suggestions(groups: list[FindingGroup]) -> list[PatchSuggestion]: 

40 """Return one suggestion per VERIFIED group that carries a suggested fix. 

41 

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 

65 

66 

67def render_patch_suggestions(groups: list[FindingGroup]) -> str: 

68 """Render the "Suggested patches" markdown section, or "" when there are none. 

69 

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"