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
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
1"""Machine-readable renderers for the jury outcome.
3Markdown rendering lives in :mod:`ai_jury.report`. This module
4adds structured outputs intended for tooling:
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.
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"""
14from __future__ import annotations
16import json
17from typing import Any
19from . import __version__
20from .findings import SEVERITIES, Finding
21from .metadata import build_run_metadata
23#: Version of the JSON report schema produced by :func:`to_json`.
24JSON_SCHEMA_VERSION = "1.0"
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"
30TOOL_NAME = "ai-jury"
31TOOL_URI = "https://github.com/berkayturanci/ai-jury"
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}
43def severity_to_sarif_level(severity: str) -> str:
44 """Map a jury severity to a SARIF result ``level``.
46 critical/major -> ``error``, minor -> ``warning``, nit/info -> ``note``.
47 Unknown severities fall back to ``note``.
48 """
49 return _SARIF_LEVEL.get(severity, "note")
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 }
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 }
77def to_json(outcome: Any, config: Any, *, decision=None, vote=None) -> str:
78 """Render the jury outcome as a structured, pretty-printed JSON report.
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.
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()
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)
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
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)
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 }
144def to_sarif(outcome: Any, _config: Any) -> str:
145 """Render the jury outcome as a SARIF 2.1.0 document.
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)
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 ]
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)