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

1"""Tests for SARIF output format (#706).""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7 

8import pytest 

9from assertpy import assert_that 

10 

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) 

21 

22# -- TestRiskToSarifLevel: Tests for risk level to SARIF level mapping. ------ 

23 

24 

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) 

41 

42 

43# -- TestConfidenceToScore: Tests for confidence label to score mapping. ----- 

44 

45 

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) 

59 

60 

61# -- TestToSarif: Tests for SARIF document generation. ----------------------- 

62 

63 

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() 

71 

72 

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]) 

87 

88 run = sarif["runs"][0] 

89 assert_that(run["tool"]["driver"]["name"]).is_equal_to("lintro-ai") 

90 

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 ) 

97 

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 ) 

106 

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) 

110 

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) 

114 

115 

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) 

139 

140 

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) 

150 

151 

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"]) 

162 

163 

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") 

178 

179 

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") 

184 

185 

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") 

192 

193 

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") 

200 

201 

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") 

214 

215 

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") 

227 

228 

229# -- TestRenderFixesSarif: Tests for SARIF JSON string rendering. ------------ 

230 

231 

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) 

238 

239 

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() 

245 

246 

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 ) 

257 

258 

259# -- TestWriteSarif: Tests for SARIF file writing. --------------------------- 

260 

261 

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) 

273 

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) 

278 

279 

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() 

285 

286 

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) 

292 

293 parsed = json.loads(output.read_text()) 

294 assert_that( 

295 parsed["runs"][0]["properties"]["aiSummary"]["overview"], 

296 ).is_equal_to("Summary text")