Coverage for src/ai_jury/formats.py: 100%

41 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 23:18 +0000

1"""Machine-readable renderers for the jury outcome. 

2 

3Markdown rendering lives in :mod:`ai_jury.report`. This module 

4adds structured outputs intended for tooling: 

5 

6* :func:`to_json` -- a structured JSON report (schema documented in the README). 

7* :func:`to_sarif` -- a SARIF 2.1.0 document for CI / code scanning upload. 

8 

9Both renderers are deterministic for a deterministic outcome (e.g. under 

10``mock=True``) and only emit legitimate finding fields -- never raw diff or 

11prompt text. 

12""" 

13 

14from __future__ import annotations 

15 

16import json 

17from typing import Any 

18 

19from . import __version__ 

20from .findings import SEVERITIES, Finding 

21from .metadata import build_run_metadata 

22 

23#: Version of the JSON report schema produced by :func:`to_json`. 

24JSON_SCHEMA_VERSION = "1.0" 

25 

26#: Canonical SARIF schema URI and version emitted by :func:`to_sarif`. 

27SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" 

28SARIF_VERSION = "2.1.0" 

29 

30TOOL_NAME = "ai-jury" 

31TOOL_URI = "https://github.com/berkayturanci/ai-jury" 

32 

33#: Mapping from finding severity to SARIF result level. 

34_SARIF_LEVEL = { 

35 "critical": "error", 

36 "major": "error", 

37 "minor": "warning", 

38 "nit": "note", 

39 "info": "note", 

40} 

41 

42 

43def severity_to_sarif_level(severity: str) -> str: 

44 """Map a jury severity to a SARIF result ``level``. 

45 

46 critical/major -> ``error``, minor -> ``warning``, nit/info -> ``note``. 

47 Unknown severities fall back to ``note``. 

48 """ 

49 return _SARIF_LEVEL.get(severity, "note") 

50 

51 

52def _finding_dict(f: Finding) -> dict[str, Any]: 

53 """Serialise a finding to a stable, ordered dict of legitimate fields.""" 

54 return { 

55 "severity": f.severity, 

56 "file": f.file, 

57 "line": f.line, 

58 "claim": f.claim, 

59 "evidence": f.evidence, 

60 "suggested_fix": f.suggested_fix, 

61 "confidence": f.confidence, 

62 "reviewer": f.reviewer, 

63 } 

64 

65 

66def _group_dict(g: Any) -> dict[str, Any]: 

67 """Serialise a consensus group to a stable, ordered dict.""" 

68 return { 

69 "representative": _finding_dict(g.representative), 

70 "agreement": len(g.reviewers), 

71 "reviewers": list(g.reviewers), 

72 "bucket": g.bucket, 

73 "verification_status": g.status or None, 

74 } 

75 

76 

77def to_json(outcome: Any, config: Any, *, decision=None, vote=None) -> str: 

78 """Render the jury outcome as a structured, pretty-printed JSON report. 

79 

80 Top-level keys: ``schema_version``, ``metadata`` (from 

81 :func:`build_run_metadata`), ``findings``, ``consensus``, ``verdicts`` and 

82 ``verdict`` (the chair synthesis text, if any). The result is deterministic 

83 for a deterministic outcome and contains only legitimate finding fields. 

84 

85 ``decision``/``vote`` are threaded into the metadata so the JSON report 

86 reflects an effective ``--decision vote`` override (issue #248); when omitted 

87 the metadata falls back to ``config.decision`` as before. 

88 """ 

89 synthesis = getattr(outcome, "synthesis", None) 

90 verdict_text = "" 

91 if synthesis is not None and getattr(synthesis, "ok", False): 

92 verdict_text = (synthesis.output or "").strip() 

93 

94 # Drop the wall-clock timestamp so the report is deterministic for a 

95 # deterministic run (matching report.py, which omits generated_at too). 

96 metadata = build_run_metadata(outcome, config, decision=decision, vote=vote) 

97 metadata.pop("generated_at", None) 

98 

99 # Surface the deterministic PR-level classification (issue #7) at the top 

100 # level for easy machine consumption (it is also embedded in ``metadata``). 

101 from .classification import classify 

102 

103 doc: dict[str, Any] = { 

104 "schema_version": JSON_SCHEMA_VERSION, 

105 "metadata": metadata, 

106 "classification": classify(outcome), 

107 "findings": [_finding_dict(f) for f in outcome.findings], 

108 "consensus": [_group_dict(g) for g in outcome.groups], 

109 "verdicts": [ 

110 { 

111 "file": v.file, 

112 "line": v.line, 

113 "claim": v.claim, 

114 "status": v.status, 

115 "reasoning": v.reasoning, 

116 } 

117 for v in outcome.verdicts 

118 ], 

119 "verdict": verdict_text, 

120 } 

121 return json.dumps(doc, indent=2, sort_keys=False, ensure_ascii=False) 

122 

123 

124def _sarif_result(f: Finding) -> dict[str, Any]: 

125 """Map a finding to a SARIF result object.""" 

126 physical: dict[str, Any] = {"artifactLocation": {"uri": f.file or ""}} 

127 # SARIF 2.1.0 requires ``region.startLine`` to be a positive (1-based) 

128 # integer. A reviewer's structured output is attacker-influenced (a finding's 

129 # ``line`` is parsed from agent JSON that can be steered by the diff), so a 

130 # forged ``"line": 0`` / negative value would emit an invalid region and make 

131 # GitHub code-scanning reject the WHOLE SARIF upload — suppressing every 

132 # finding (denial-of-evidence). Drop the region in that case so the finding 

133 # still surfaces at file level (security audit 2026-06-13 r5). 

134 if f.line is not None and f.line >= 1: 

135 physical["region"] = {"startLine": f.line} 

136 return { 

137 "ruleId": f"jury/{f.severity}", 

138 "level": severity_to_sarif_level(f.severity), 

139 "message": {"text": f.claim}, 

140 "locations": [{"physicalLocation": physical}], 

141 } 

142 

143 

144def to_sarif(outcome: Any, _config: Any) -> str: 

145 """Render the jury outcome as a SARIF 2.1.0 document. 

146 

147 Consensus group representatives are preferred as the source of results; if 

148 there are no groups the raw findings are used. Rules are derived from the 

149 severities actually present. The output is deterministic. 

150 """ 

151 if outcome.groups: 

152 findings = [g.representative for g in outcome.groups] 

153 else: 

154 findings = list(outcome.findings) 

155 

156 # Rules: one per severity actually used, in canonical severity order. 

157 used = {f.severity for f in findings} 

158 rules = [ 

159 { 

160 "id": f"jury/{sev}", 

161 "name": f"jury-{sev}", 

162 "shortDescription": {"text": f"{sev} finding reported by the review jury"}, 

163 "defaultConfiguration": {"level": severity_to_sarif_level(sev)}, 

164 } 

165 for sev in SEVERITIES 

166 if sev in used 

167 ] 

168 

169 doc = { 

170 "$schema": SARIF_SCHEMA, 

171 "version": SARIF_VERSION, 

172 "runs": [ 

173 { 

174 "tool": { 

175 "driver": { 

176 "name": TOOL_NAME, 

177 "informationUri": TOOL_URI, 

178 "version": __version__, 

179 "rules": rules, 

180 } 

181 }, 

182 "results": [_sarif_result(f) for f in findings], 

183 } 

184 ], 

185 } 

186 return json.dumps(doc, indent=2, sort_keys=False, ensure_ascii=False)