Coverage for lintro / ai / output / sarif.py: 100%
92 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""SARIF v2.1.0 output for AI findings.
3Generates SARIF (Static Analysis Results Interchange Format) output
4from AI fix suggestions and summaries. Compatible with GitHub Code
5Scanning, VS Code SARIF Viewer, and other SARIF-consuming tools.
7Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/
8"""
10from __future__ import annotations
12import json
13from collections.abc import Sequence
14from pathlib import Path
15from typing import Any
17from lintro.ai.enums import ConfidenceLevel, RiskLevel
18from lintro.ai.models import AIFixSuggestion, AISummary
20SARIF_SCHEMA = (
21 "https://raw.githubusercontent.com/oasis-tcs/sarif-spec"
22 "/main/sarif-2.1/schema/sarif-schema-2.1.0.json"
23)
24SARIF_VERSION = "2.1.0"
26_CONFIDENCE_SCORE = {
27 ConfidenceLevel.HIGH: 0.9,
28 ConfidenceLevel.MEDIUM: 0.6,
29 ConfidenceLevel.LOW: 0.3,
30}
33def _risk_to_sarif_level(risk_level: RiskLevel | str) -> str:
34 """Map AI risk level to SARIF result level.
36 Args:
37 risk_level: Risk classification (e.g. ``"behavioral-risk"``).
39 Returns:
40 One of ``"error"``, ``"warning"``, or ``"note"``.
41 """
42 normalized = str(risk_level).lower().strip() if risk_level else ""
43 try:
44 return RiskLevel(normalized).to_severity_label(sarif=True)
45 except ValueError:
46 pass
47 if normalized in {"high", "critical"}:
48 return "error"
49 if normalized in {"medium"}:
50 return "warning"
51 if normalized in {"low"}:
52 return "note"
53 return "warning"
56def _confidence_to_score(confidence: ConfidenceLevel | str) -> float:
57 """Map confidence label to a numeric score.
59 Args:
60 confidence: Confidence level string or enum.
62 Returns:
63 Score between 0.0 and 1.0.
64 """
65 normalized = str(confidence).lower().strip() if confidence else ""
66 try:
67 return _CONFIDENCE_SCORE[ConfidenceLevel(normalized)]
68 except (ValueError, KeyError):
69 return 0.5
72def to_sarif(
73 suggestions: Sequence[AIFixSuggestion],
74 summary: AISummary | None = None,
75 *,
76 tool_name: str = "lintro-ai",
77 tool_version: str = "",
78 doc_urls: dict[str, str] | None = None,
79) -> dict[str, Any]:
80 """Convert AI findings to a SARIF v2.1.0 document.
82 Args:
83 suggestions: AI fix suggestions to include as results.
84 summary: Optional AI summary to attach as run properties.
85 tool_name: Name for the SARIF tool driver.
86 tool_version: Version string for the tool driver.
87 doc_urls: Optional mapping of rule codes to documentation URLs.
88 When provided, matching rules get a ``helpUri`` property.
90 Returns:
91 SARIF document as a dictionary.
92 """
93 rules_map: dict[str, dict[str, Any]] = {}
94 results: list[dict[str, Any]] = []
96 for s in suggestions:
97 if s.tool_name and s.code:
98 rule_id = f"{s.tool_name}/{s.code}"
99 else:
100 rule_id = s.tool_name or s.code or "unknown"
102 if rule_id not in rules_map:
103 rule: dict[str, Any] = {"id": rule_id}
104 short_desc = s.code or "AI finding"
105 rule["shortDescription"] = {"text": short_desc}
106 if s.explanation:
107 rule["fullDescription"] = {"text": s.explanation}
108 # Attach documentation URL when available
109 if doc_urls:
110 help_uri = doc_urls.get(s.code, "") or doc_urls.get(rule_id, "")
111 if help_uri:
112 rule["helpUri"] = help_uri
113 if s.tool_name:
114 rule["properties"] = {"tool": s.tool_name}
115 rules_map[rule_id] = rule
117 result: dict[str, Any] = {
118 "ruleId": rule_id,
119 "level": _risk_to_sarif_level(s.risk_level),
120 "message": {"text": s.explanation or "AI fix available"},
121 }
123 # Location
124 if s.file:
125 location: dict[str, Any] = {
126 "physicalLocation": {
127 "artifactLocation": {"uri": s.file},
128 },
129 }
130 if s.line > 0:
131 location["physicalLocation"]["region"] = {
132 "startLine": s.line,
133 }
134 result["locations"] = [location]
136 # Fix suggestion — only emit when there is a real replacement target
137 if s.suggested_code and s.file and s.line > 0:
138 deleted_region: dict[str, int] = {"startLine": s.line}
139 if s.original_code:
140 trimmed = s.original_code.rstrip("\n")
141 line_count = max(1, trimmed.count("\n") + 1)
142 deleted_region["endLine"] = s.line + line_count - 1
143 fix: dict[str, Any] = {
144 "description": {"text": s.explanation or "AI suggestion"},
145 "artifactChanges": [
146 {
147 "artifactLocation": {"uri": s.file},
148 "replacements": [
149 {
150 "deletedRegion": deleted_region,
151 "insertedContent": {
152 "text": s.suggested_code,
153 },
154 },
155 ],
156 },
157 ],
158 }
159 result["fixes"] = [fix]
161 # Properties
162 props: dict[str, Any] = {}
163 if s.confidence:
164 props["confidence"] = s.confidence
165 props["confidenceScore"] = _confidence_to_score(s.confidence)
166 if s.risk_level:
167 props["riskLevel"] = s.risk_level
168 if s.tool_name:
169 props["tool"] = s.tool_name
170 if s.cost_estimate:
171 props["costEstimate"] = s.cost_estimate
172 if props:
173 result["properties"] = props
175 results.append(result)
177 # Build driver
178 driver: dict[str, Any] = {"name": tool_name}
179 if tool_version:
180 driver["version"] = tool_version
181 if rules_map:
182 driver["rules"] = list(rules_map.values())
184 run: dict[str, Any] = {
185 "tool": {"driver": driver},
186 "results": results,
187 }
189 # Attach summary as run properties when any field is populated
190 if summary and (
191 summary.overview
192 or summary.key_patterns
193 or summary.priority_actions
194 or summary.triage_suggestions
195 or summary.estimated_effort
196 ):
197 run["properties"] = {
198 "aiSummary": {
199 "overview": summary.overview,
200 "keyPatterns": summary.key_patterns,
201 "priorityActions": summary.priority_actions,
202 "triageSuggestions": summary.triage_suggestions,
203 "estimatedEffort": summary.estimated_effort,
204 },
205 }
207 return {
208 "$schema": SARIF_SCHEMA,
209 "version": SARIF_VERSION,
210 "runs": [run],
211 }
214def render_fixes_sarif(
215 suggestions: Sequence[AIFixSuggestion],
216 summary: AISummary | None = None,
217 *,
218 tool_name: str = "lintro-ai",
219 tool_version: str = "",
220) -> str:
221 """Render AI findings as a SARIF JSON string.
223 Args:
224 suggestions: AI fix suggestions.
225 summary: Optional AI summary.
226 tool_name: Name for the SARIF tool driver.
227 tool_version: Version string for the tool driver.
229 Returns:
230 Pretty-printed SARIF JSON string.
231 """
232 sarif = to_sarif(
233 suggestions,
234 summary,
235 tool_name=tool_name,
236 tool_version=tool_version,
237 )
238 return json.dumps(sarif, indent=2)
241def write_sarif(
242 suggestions: Sequence[AIFixSuggestion],
243 summary: AISummary | None = None,
244 *,
245 output_path: Path,
246 tool_name: str = "lintro-ai",
247 tool_version: str = "",
248 doc_urls: dict[str, str] | None = None,
249) -> None:
250 """Write AI findings as a SARIF file.
252 Args:
253 suggestions: AI fix suggestions.
254 summary: Optional AI summary.
255 output_path: Path to write the SARIF file.
256 tool_name: Name for the SARIF tool driver.
257 tool_version: Version string for the tool driver.
258 doc_urls: Optional mapping of rule codes to documentation URLs.
259 """
260 sarif = to_sarif(
261 suggestions,
262 summary,
263 tool_name=tool_name,
264 tool_version=tool_version,
265 doc_urls=doc_urls,
266 )
267 output_path.parent.mkdir(parents=True, exist_ok=True)
268 output_path.write_text(
269 json.dumps(sarif, indent=2) + "\n",
270 encoding="utf-8",
271 )