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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +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}}
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 }
137def to_sarif(outcome: Any, config: Any) -> str:
138 """Render the jury outcome as a SARIF 2.1.0 document.
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)
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 ]
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)