Coverage for tests / unit / ai / test_sarif.py: 100%
120 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"""Tests for SARIF output format (#706)."""
3from __future__ import annotations
5import json
6from pathlib import Path
8import pytest
9from assertpy import assert_that
11from lintro.ai.models import AIFixSuggestion, AISummary
12from lintro.ai.output.sarif import (
13 SARIF_SCHEMA,
14 SARIF_VERSION,
15 _confidence_to_score,
16 _risk_to_sarif_level,
17 render_fixes_sarif,
18 to_sarif,
19 write_sarif,
20)
22# -- TestRiskToSarifLevel: Tests for risk level to SARIF level mapping. ------
25@pytest.mark.parametrize(
26 ("input_val", "expected"),
27 [
28 ("high", "error"),
29 ("critical", "error"),
30 ("medium", "warning"),
31 ("behavioral-risk", "warning"),
32 ("low", "note"),
33 ("safe-style", "note"),
34 ("", "warning"),
35 ("HIGH", "error"),
36 ],
37)
38def test_risk_to_sarif_level(input_val: str, expected: str) -> None:
39 """Map risk level to SARIF severity level."""
40 assert_that(_risk_to_sarif_level(input_val)).is_equal_to(expected)
43# -- TestConfidenceToScore: Tests for confidence label to score mapping. -----
46@pytest.mark.parametrize(
47 ("input_val", "expected"),
48 [
49 ("high", 0.9),
50 ("medium", 0.6),
51 ("low", 0.3),
52 ("unknown", 0.5),
53 ("", 0.5),
54 ],
55)
56def test_confidence_to_score(input_val: str, expected: float) -> None:
57 """Map confidence label to numeric score."""
58 assert_that(_confidence_to_score(input_val)).is_equal_to(expected)
61# -- TestToSarif: Tests for SARIF document generation. -----------------------
64def test_empty_suggestions_produces_valid_sarif() -> None:
65 """Produce valid SARIF structure with no suggestions."""
66 sarif = to_sarif([])
67 assert_that(sarif["$schema"]).is_equal_to(SARIF_SCHEMA)
68 assert_that(sarif["version"]).is_equal_to(SARIF_VERSION)
69 assert_that(sarif["runs"]).is_length(1)
70 assert_that(sarif["runs"][0]["results"]).is_empty()
73def test_single_suggestion_produces_result() -> None:
74 """Convert a single suggestion to a SARIF result."""
75 s = AIFixSuggestion(
76 file="src/main.py",
77 line=10,
78 code="B101",
79 tool_name="bandit",
80 explanation="Replace assert with if/raise",
81 confidence="high",
82 risk_level="low",
83 suggested_code="if not x:\n raise ValueError",
84 cost_estimate=0.002,
85 )
86 sarif = to_sarif([s])
88 run = sarif["runs"][0]
89 assert_that(run["tool"]["driver"]["name"]).is_equal_to("lintro-ai")
91 rules = run["tool"]["driver"]["rules"]
92 assert_that(rules).is_length(1)
93 assert_that(rules[0]["id"]).is_equal_to("bandit/B101")
94 assert_that(rules[0]["fullDescription"]["text"]).is_equal_to(
95 "Replace assert with if/raise",
96 )
98 results = run["results"]
99 assert_that(results).is_length(1)
100 result = results[0]
101 assert_that(result["ruleId"]).is_equal_to("bandit/B101")
102 assert_that(result["level"]).is_equal_to("note")
103 assert_that(result["message"]["text"]).is_equal_to(
104 "Replace assert with if/raise",
105 )
107 location = result["locations"][0]["physicalLocation"]
108 assert_that(location["artifactLocation"]["uri"]).is_equal_to("src/main.py")
109 assert_that(location["region"]["startLine"]).is_equal_to(10)
111 assert_that(result["fixes"]).is_length(1)
112 assert_that(result["properties"]["confidence"]).is_equal_to("high")
113 assert_that(result["properties"]["confidenceScore"]).is_equal_to(0.9)
116def test_multiple_suggestions_same_rule_deduplicates_rules() -> None:
117 """Deduplicate rules when multiple suggestions share the same code."""
118 suggestions = [
119 AIFixSuggestion(
120 file="a.py",
121 line=1,
122 code="B101",
123 explanation="Fix 1",
124 risk_level="low",
125 ),
126 AIFixSuggestion(
127 file="b.py",
128 line=2,
129 code="B101",
130 explanation="Fix 2",
131 risk_level="low",
132 ),
133 ]
134 sarif = to_sarif(suggestions)
135 rules = sarif["runs"][0]["tool"]["driver"]["rules"]
136 assert_that(rules).is_length(1)
137 results = sarif["runs"][0]["results"]
138 assert_that(results).is_length(2)
141def test_different_rules_create_separate_entries() -> None:
142 """Create separate rule entries for different codes."""
143 suggestions = [
144 AIFixSuggestion(code="B101", explanation="Fix 1", risk_level="low"),
145 AIFixSuggestion(code="E501", explanation="Fix 2", risk_level="medium"),
146 ]
147 sarif = to_sarif(suggestions)
148 rules = sarif["runs"][0]["tool"]["driver"]["rules"]
149 assert_that(rules).is_length(2)
152def test_risk_level_maps_correctly() -> None:
153 """Map risk levels to correct SARIF severity levels."""
154 suggestions = [
155 AIFixSuggestion(code="A", risk_level="high", explanation="x"),
156 AIFixSuggestion(code="B", risk_level="medium", explanation="y"),
157 AIFixSuggestion(code="C", risk_level="low", explanation="z"),
158 ]
159 sarif = to_sarif(suggestions)
160 levels = [r["level"] for r in sarif["runs"][0]["results"]]
161 assert_that(levels).is_equal_to(["error", "warning", "note"])
164def test_summary_attached_as_run_properties() -> None:
165 """Attach summary as SARIF run properties."""
166 summary = AISummary(
167 overview="High-level assessment",
168 key_patterns=["Missing types"],
169 priority_actions=["Add type hints"],
170 estimated_effort="2 hours",
171 )
172 sarif = to_sarif([], summary=summary)
173 props = sarif["runs"][0]["properties"]["aiSummary"]
174 assert_that(props["overview"]).is_equal_to("High-level assessment")
175 assert_that(props["keyPatterns"]).contains("Missing types")
176 assert_that(props["priorityActions"]).contains("Add type hints")
177 assert_that(props["estimatedEffort"]).is_equal_to("2 hours")
180def test_no_summary_omits_run_properties() -> None:
181 """Omit run properties when no summary is provided."""
182 sarif = to_sarif([])
183 assert_that(sarif["runs"][0]).does_not_contain_key("properties")
186def test_custom_tool_name_and_version() -> None:
187 """Use custom tool name and version in driver metadata."""
188 sarif = to_sarif([], tool_name="custom-tool", tool_version="1.2.3")
189 driver = sarif["runs"][0]["tool"]["driver"]
190 assert_that(driver["name"]).is_equal_to("custom-tool")
191 assert_that(driver["version"]).is_equal_to("1.2.3")
194def test_no_file_omits_locations() -> None:
195 """Omit locations when no file is specified."""
196 s = AIFixSuggestion(code="X", explanation="No file", risk_level="low")
197 sarif = to_sarif([s])
198 result = sarif["runs"][0]["results"][0]
199 assert_that(result).does_not_contain_key("locations")
202def test_no_suggested_code_omits_fixes() -> None:
203 """Omit fixes when no suggested code is provided."""
204 s = AIFixSuggestion(
205 file="x.py",
206 line=1,
207 code="X",
208 explanation="No fix",
209 risk_level="low",
210 )
211 sarif = to_sarif([s])
212 result = sarif["runs"][0]["results"][0]
213 assert_that(result).does_not_contain_key("fixes")
216def test_tool_name_in_rule_properties() -> None:
217 """Include tool name in rule properties."""
218 s = AIFixSuggestion(
219 code="B101",
220 tool_name="bandit",
221 explanation="Fix",
222 risk_level="low",
223 )
224 sarif = to_sarif([s])
225 rule = sarif["runs"][0]["tool"]["driver"]["rules"][0]
226 assert_that(rule["properties"]["tool"]).is_equal_to("bandit")
229# -- TestRenderFixesSarif: Tests for SARIF JSON string rendering. ------------
232def test_returns_valid_json() -> None:
233 """Return valid JSON string from suggestions."""
234 s = AIFixSuggestion(code="B101", explanation="Fix", risk_level="low")
235 result = render_fixes_sarif([s])
236 parsed = json.loads(result)
237 assert_that(parsed["version"]).is_equal_to(SARIF_VERSION)
240def test_empty_suggestions_returns_valid_json() -> None:
241 """Return valid JSON string with empty suggestions."""
242 result = render_fixes_sarif([])
243 parsed = json.loads(result)
244 assert_that(parsed["runs"][0]["results"]).is_empty()
247def test_includes_summary_when_provided() -> None:
248 """Include summary in rendered SARIF output."""
249 summary = AISummary(overview="Test overview")
250 result = render_fixes_sarif([], summary=summary)
251 parsed = json.loads(result)
252 assert_that(
253 parsed["runs"][0]["properties"]["aiSummary"]["overview"],
254 ).is_equal_to(
255 "Test overview",
256 )
259# -- TestWriteSarif: Tests for SARIF file writing. ---------------------------
262def test_writes_valid_sarif_file(tmp_path: Path) -> None:
263 """Write a valid SARIF file to disk."""
264 output = tmp_path / "results.sarif"
265 s = AIFixSuggestion(
266 file="src/main.py",
267 line=10,
268 code="B101",
269 explanation="Replace assert",
270 risk_level="low",
271 )
272 write_sarif([s], output_path=output)
274 assert_that(output.exists()).is_true()
275 parsed = json.loads(output.read_text())
276 assert_that(parsed["version"]).is_equal_to(SARIF_VERSION)
277 assert_that(parsed["runs"][0]["results"]).is_length(1)
280def test_creates_parent_directories(tmp_path: Path) -> None:
281 """Create parent directories when they do not exist."""
282 output = tmp_path / "sub" / "dir" / "results.sarif"
283 write_sarif([], output_path=output)
284 assert_that(output.exists()).is_true()
287def test_writes_with_summary(tmp_path: Path) -> None:
288 """Write SARIF file including summary properties."""
289 output = tmp_path / "results.sarif"
290 summary = AISummary(overview="Summary text")
291 write_sarif([], summary=summary, output_path=output)
293 parsed = json.loads(output.read_text())
294 assert_that(
295 parsed["runs"][0]["properties"]["aiSummary"]["overview"],
296 ).is_equal_to("Summary text")