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

41 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 20:29 +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}} 

127 if f.line is not None: 

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

129 return { 

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

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

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

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

134 } 

135 

136 

137def to_sarif(outcome: Any, config: Any) -> str: 

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

139 

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

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

142 severities actually present. The output is deterministic. 

143 """ 

144 if outcome.groups: 

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

146 else: 

147 findings = list(outcome.findings) 

148 

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

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

151 rules = [ 

152 { 

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

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

155 "shortDescription": { 

156 "text": f"{sev} finding reported by the review jury" 

157 }, 

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

159 } 

160 for sev in SEVERITIES 

161 if sev in used 

162 ] 

163 

164 doc = { 

165 "$schema": SARIF_SCHEMA, 

166 "version": SARIF_VERSION, 

167 "runs": [ 

168 { 

169 "tool": { 

170 "driver": { 

171 "name": TOOL_NAME, 

172 "informationUri": TOOL_URI, 

173 "version": __version__, 

174 "rules": rules, 

175 } 

176 }, 

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

178 } 

179 ], 

180 } 

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