Coverage for tests / unit / ai / test_fix_generation_basic.py: 96%

139 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Tests for basic fix generation. 

2 

3Covers generate_fixes single-issue paths, prompt construction, 

4path resolution, and ToolResult.cwd field. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import os 

11 

12import pytest 

13from assertpy import assert_that 

14 

15from lintro.ai.fix import ( 

16 generate_fixes, 

17) 

18from lintro.ai.providers.base import AIResponse 

19from lintro.models.core.tool_result import ToolResult 

20from tests.unit.ai.conftest import MockAIProvider, MockIssue 

21 

22# --------------------------------------------------------------------------- 

23# Fixtures 

24# --------------------------------------------------------------------------- 

25 

26 

27@pytest.fixture 

28def source_file(tmp_path): 

29 """Create a minimal Python source file and return its path.""" 

30 f = tmp_path / "test.py" 

31 f.write_text("x = 1\n") 

32 return f 

33 

34 

35@pytest.fixture 

36def single_issue(source_file): 

37 """Return a single MockIssue pointing at the source file.""" 

38 return MockIssue( 

39 file=str(source_file), 

40 line=1, 

41 code="B101", 

42 message="test", 

43 ) 

44 

45 

46# --------------------------------------------------------------------------- 

47# generate_fixes 

48# --------------------------------------------------------------------------- 

49 

50 

51def test_generate_fixes_empty_issues(mock_provider): 

52 """Verify that an empty issue list returns an empty result.""" 

53 result = generate_fixes( 

54 [], 

55 mock_provider, 

56 tool_name="ruff", 

57 ) 

58 assert_that(result).is_empty() 

59 

60 

61def test_generate_fixes_generates_fixes_for_unfixable(tmp_path): 

62 """Unfixable issues are sent to the AI and produce suggestions.""" 

63 source = tmp_path / "test.py" 

64 source.write_text("assert x > 0\nprint('hello')\n") 

65 

66 issue = MockIssue( 

67 file=str(source), 

68 line=1, 

69 code="B101", 

70 message="Use of assert", 

71 fixable=False, 

72 ) 

73 

74 response = AIResponse( 

75 content=json.dumps( 

76 { 

77 "original_code": "assert x > 0", 

78 "suggested_code": "if x <= 0:\n raise ValueError", 

79 "explanation": "Replace assert", 

80 "confidence": "high", 

81 }, 

82 ), 

83 model="mock", 

84 input_tokens=100, 

85 output_tokens=50, 

86 cost_estimate=0.001, 

87 provider="mock", 

88 ) 

89 provider = MockAIProvider(responses=[response]) 

90 

91 result = generate_fixes( 

92 [issue], 

93 provider, 

94 tool_name="bandit", 

95 workspace_root=tmp_path, 

96 ) 

97 

98 assert_that(result).is_length(1) 

99 assert_that(result[0].code).is_equal_to("B101") 

100 assert_that(result[0].diff).is_not_empty() 

101 

102 

103def test_generate_fixes_processes_fixable_issues(tmp_path): 

104 """AI should attempt fixes for ALL issues, including fixable ones.""" 

105 source = tmp_path / "test.py" 

106 source.write_text("x = 1\n") 

107 

108 issue = MockIssue( 

109 file=str(source), 

110 line=1, 

111 code="E501", 

112 message="Line too long", 

113 fixable=True, 

114 ) 

115 

116 provider = MockAIProvider() 

117 generate_fixes( 

118 [issue], 

119 provider, 

120 tool_name="ruff", 

121 workspace_root=tmp_path, 

122 ) 

123 

124 assert_that(provider.calls).is_not_empty() 

125 

126 

127def test_generate_fixes_skips_issues_without_file(mock_provider): 

128 """Verify that issues without a file path are skipped.""" 

129 issue = MockIssue(line=1, code="B101", message="test") 

130 result = generate_fixes( 

131 [issue], 

132 mock_provider, 

133 tool_name="ruff", 

134 ) 

135 assert_that(result).is_empty() 

136 

137 

138def test_generate_fixes_respects_max_issues(tmp_path): 

139 """Verify that the max_issues parameter limits the number of provider calls.""" 

140 # Use separate files so batching does not group them 

141 sources = [] 

142 for i in range(1, 6): 

143 f = tmp_path / f"test{i}.py" 

144 f.write_text("x = 1\n" * 50) 

145 sources.append(f) 

146 

147 issues = [ 

148 MockIssue( 

149 file=str(sources[i - 1]), 

150 line=1, 

151 code="B101", 

152 message="test", 

153 ) 

154 for i in range(1, 6) 

155 ] 

156 

157 provider = MockAIProvider() 

158 generate_fixes( 

159 issues, 

160 provider, 

161 tool_name="ruff", 

162 max_issues=2, 

163 workspace_root=tmp_path, 

164 ) 

165 

166 assert_that(provider.calls).is_length(2) 

167 

168 

169def test_generate_fixes_provider_prompt_uses_workspace_relative_path(tmp_path): 

170 """Provider prompt contains workspace-relative paths, not absolute.""" 

171 source = tmp_path / "src" / "service.py" 

172 source.parent.mkdir(parents=True) 

173 source.write_text("assert ready\n", encoding="utf-8") 

174 

175 issue = MockIssue( 

176 file=str(source), 

177 line=1, 

178 code="B101", 

179 message="Use of assert", 

180 ) 

181 

182 response = AIResponse( 

183 content=json.dumps( 

184 { 

185 "original_code": "assert ready", 

186 "suggested_code": "if not ready:\n raise ValueError", 

187 "explanation": "Replace assert", 

188 "confidence": "high", 

189 }, 

190 ), 

191 model="mock", 

192 input_tokens=10, 

193 output_tokens=10, 

194 cost_estimate=0.001, 

195 provider="mock", 

196 ) 

197 provider = MockAIProvider(responses=[response]) 

198 

199 generate_fixes( 

200 [issue], 

201 provider, 

202 tool_name="ruff", 

203 workspace_root=tmp_path, 

204 max_tokens=333, 

205 ) 

206 

207 assert_that(provider.calls).is_length(1) 

208 prompt = provider.calls[0]["prompt"] 

209 assert_that(prompt).contains("File: src/service.py") 

210 assert_that(prompt).does_not_contain(str(source)) 

211 assert_that(provider.calls[0]["max_tokens"]).is_equal_to(333) 

212 

213 

214def test_generate_fixes_skips_issue_outside_workspace_root(tmp_path): 

215 """Verify that issues with files outside the workspace root are skipped.""" 

216 outside = tmp_path.parent / "outside.py" 

217 outside.write_text("assert x\n", encoding="utf-8") 

218 

219 issue = MockIssue( 

220 file=str(outside), 

221 line=1, 

222 code="B101", 

223 message="Use of assert", 

224 ) 

225 

226 provider = MockAIProvider() 

227 result = generate_fixes( 

228 [issue], 

229 provider, 

230 tool_name="ruff", 

231 workspace_root=tmp_path, 

232 ) 

233 

234 assert_that(provider.calls).is_empty() 

235 assert_that(result).is_empty() 

236 

237 

238# --------------------------------------------------------------------------- 

239# Timeout propagation 

240# --------------------------------------------------------------------------- 

241 

242 

243def test_timeout_reaches_provider(tmp_path): 

244 """Custom timeout value is passed through to provider.complete().""" 

245 source = tmp_path / "test.py" 

246 source.write_text("x = 1\n") 

247 

248 issue = MockIssue( 

249 file=str(source), 

250 line=1, 

251 code="B101", 

252 message="test", 

253 ) 

254 

255 provider = MockAIProvider() 

256 generate_fixes( 

257 [issue], 

258 provider, 

259 tool_name="ruff", 

260 workspace_root=tmp_path, 

261 timeout=120.0, 

262 ) 

263 

264 assert_that(provider.calls).is_length(1) 

265 assert_that(provider.calls[0]["timeout"]).is_equal_to(120.0) 

266 

267 

268def test_default_timeout_is_60(tmp_path): 

269 """Default timeout (60s) is used when no custom value is provided.""" 

270 source = tmp_path / "test.py" 

271 source.write_text("x = 1\n") 

272 

273 issue = MockIssue( 

274 file=str(source), 

275 line=1, 

276 code="B101", 

277 message="test", 

278 ) 

279 

280 provider = MockAIProvider() 

281 generate_fixes( 

282 [issue], 

283 provider, 

284 tool_name="ruff", 

285 workspace_root=tmp_path, 

286 ) 

287 

288 assert_that(provider.calls).is_length(1) 

289 assert_that(provider.calls[0]["timeout"]).is_equal_to(60.0) 

290 

291 

292# --------------------------------------------------------------------------- 

293# ToolResult.cwd field 

294# --------------------------------------------------------------------------- 

295 

296 

297def test_tool_result_cwd_defaults_to_none(): 

298 """Verify that ToolResult.cwd defaults to None when not specified.""" 

299 result = ToolResult(name="test", success=True) 

300 assert_that(result.cwd).is_none() 

301 

302 

303def test_tool_result_cwd_preserves_value(): 

304 """Verify that ToolResult.cwd preserves the value passed at construction.""" 

305 result = ToolResult(name="test", success=True, cwd="/some/path") 

306 assert_that(result.cwd).is_equal_to("/some/path") 

307 

308 

309# --------------------------------------------------------------------------- 

310# Relative path resolution (ToolResult.cwd) 

311# --------------------------------------------------------------------------- 

312 

313 

314def test_resolves_relative_paths_with_cwd(tmp_path): 

315 """Issues with relative paths should be resolved using result.cwd.""" 

316 tool_cwd = tmp_path / "test_samples" / "tools" 

317 js_dir = tool_cwd / "javascript" / "oxlint" 

318 js_dir.mkdir(parents=True) 

319 source = js_dir / "violations.js" 

320 source.write_text("var x = 1;\n") 

321 

322 issue = MockIssue( 

323 file="javascript/oxlint/violations.js", 

324 line=1, 

325 code="no-var", 

326 message="Unexpected var", 

327 ) 

328 

329 cwd = str(tool_cwd) 

330 if not os.path.isabs(issue.file): 

331 issue.file = os.path.join(cwd, issue.file) 

332 

333 provider = MockAIProvider() 

334 generate_fixes( 

335 [issue], 

336 provider, 

337 tool_name="oxlint", 

338 workspace_root=tmp_path, 

339 ) 

340 

341 assert_that(provider.calls).is_length(1) 

342 

343 

344def test_absolute_paths_unchanged_by_resolution(): 

345 """Absolute paths should not be modified by path resolution.""" 

346 issue = MockIssue( 

347 file="/absolute/path/to/file.py", 

348 line=1, 

349 code="B101", 

350 message="test", 

351 ) 

352 

353 cwd = "/some/other/dir" 

354 if not os.path.isabs(issue.file): 

355 issue.file = os.path.join(cwd, issue.file) 

356 

357 assert_that(issue.file).is_equal_to("/absolute/path/to/file.py") 

358 

359 

360def test_no_resolution_when_cwd_is_none(): 

361 """When cwd is None, relative paths should remain unchanged.""" 

362 issue = MockIssue( 

363 file="relative/path/file.js", 

364 line=1, 

365 code="no-var", 

366 message="test", 

367 ) 

368 

369 cwd = None 

370 if cwd and not os.path.isabs(issue.file): 

371 issue.file = os.path.join(cwd, issue.file) 

372 

373 assert_that(issue.file).is_equal_to("relative/path/file.js") 

374 

375 

376def test_generate_fixes_skips_issues_with_unreadable_relative_paths(tmp_path): 

377 """Relative paths not resolvable from CWD are silently skipped.""" 

378 subdir = tmp_path / "tools" / "js" 

379 subdir.mkdir(parents=True) 

380 source = subdir / "test.js" 

381 source.write_text("var x = 1;\n") 

382 

383 issue = MockIssue( 

384 file="js/test.js", 

385 line=1, 

386 code="no-var", 

387 message="Unexpected var", 

388 ) 

389 

390 provider = MockAIProvider() 

391 result = generate_fixes( 

392 [issue], 

393 provider, 

394 tool_name="oxlint", 

395 ) 

396 

397 assert_that(provider.calls).is_empty() 

398 assert_that(result).is_empty() 

399 

400 

401def test_single_issue_file_not_batched(tmp_path): 

402 """A file with only 1 issue should use the single-issue path.""" 

403 source = tmp_path / "single.py" 

404 source.write_text("x = 1\n") 

405 

406 issue = MockIssue( 

407 file=str(source), 

408 line=1, 

409 code="B101", 

410 message="test", 

411 ) 

412 

413 provider = MockAIProvider() 

414 generate_fixes( 

415 [issue], 

416 provider, 

417 tool_name="ruff", 

418 workspace_root=tmp_path, 

419 ) 

420 

421 assert_that(provider.calls).is_length(1) 

422 prompt = provider.calls[0]["prompt"] 

423 # Single-issue prompt, not batch 

424 assert_that(prompt).does_not_contain("JSON array") 

425 assert_that(prompt).contains("Error code: B101")