Coverage for tests / unit / tools / test_doc_url.py: 100%

127 statements  

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

1"""Tests for tool doc_url() methods. 

2 

3Verifies that each tool plugin returns the correct documentation URL for 

4a given rule code, and returns None for empty or invalid codes. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import subprocess 

11from unittest.mock import MagicMock, patch 

12 

13import pytest 

14from assertpy import assert_that 

15 

16from lintro.tools.definitions.actionlint import ActionlintPlugin 

17from lintro.tools.definitions.astro_check import AstroCheckPlugin 

18from lintro.tools.definitions.bandit import BanditPlugin 

19from lintro.tools.definitions.cargo_audit import CargoAuditPlugin 

20from lintro.tools.definitions.cargo_deny import CargoDenyPlugin 

21from lintro.tools.definitions.clippy import ClippyPlugin 

22from lintro.tools.definitions.hadolint import HadolintPlugin 

23from lintro.tools.definitions.markdownlint import MarkdownlintPlugin 

24from lintro.tools.definitions.mypy import MypyPlugin 

25from lintro.tools.definitions.osv_scanner import OsvScannerPlugin 

26from lintro.tools.definitions.oxlint import OxlintPlugin 

27from lintro.tools.definitions.pydoclint import PydoclintPlugin 

28from lintro.tools.definitions.ruff import RuffPlugin 

29from lintro.tools.definitions.semgrep import SemgrepPlugin 

30from lintro.tools.definitions.shellcheck import ShellcheckPlugin 

31from lintro.tools.definitions.sqlfluff import SqlfluffPlugin 

32from lintro.tools.definitions.taplo import TaploPlugin 

33from lintro.tools.definitions.tsc import TscPlugin 

34from lintro.tools.definitions.vue_tsc import VueTscPlugin 

35from lintro.tools.definitions.yamllint import YamllintPlugin 

36 

37# ============================================================================= 

38# Simple URL-pattern tools (no subprocess needed) 

39# ============================================================================= 

40 

41 

42@pytest.mark.parametrize( 

43 ("plugin_cls", "code", "expected_url"), 

44 [ 

45 # actionlint — single-page docs 

46 ( 

47 ActionlintPlugin, 

48 "syntax", 

49 "https://github.com/rhysd/actionlint/blob/main/docs/checks.md", 

50 ), 

51 (ActionlintPlugin, "", None), 

52 # astro-check — single-page docs 

53 ( 

54 AstroCheckPlugin, 

55 "TS2322", 

56 "https://docs.astro.build/en/guides/typescript/", 

57 ), 

58 (AstroCheckPlugin, "", None), 

59 # bandit — plugins index 

60 ( 

61 BanditPlugin, 

62 "B101", 

63 "https://bandit.readthedocs.io/en/latest/plugins/index.html", 

64 ), 

65 (BanditPlugin, "", None), 

66 # cargo-audit — per-advisory URL 

67 ( 

68 CargoAuditPlugin, 

69 "RUSTSEC-2021-0124", 

70 "https://rustsec.org/advisories/RUSTSEC-2021-0124", 

71 ), 

72 (CargoAuditPlugin, "", None), 

73 # cargo-deny — single-page docs 

74 ( 

75 CargoDenyPlugin, 

76 "L001", 

77 "https://embarkstudios.github.io/cargo-deny/", 

78 ), 

79 (CargoDenyPlugin, "", None), 

80 # clippy — fragment anchor 

81 ( 

82 ClippyPlugin, 

83 "needless_return", 

84 "https://rust-lang.github.io/rust-clippy/master/index.html#needless_return", 

85 ), 

86 (ClippyPlugin, "", None), 

87 # markdownlint — per-rule doc page (lowercased) 

88 ( 

89 MarkdownlintPlugin, 

90 "MD013", 

91 "https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md", 

92 ), 

93 (MarkdownlintPlugin, "", None), 

94 # mypy — error code list page 

95 ( 

96 MypyPlugin, 

97 "import-untyped", 

98 "https://mypy.readthedocs.io/en/stable/error_code_list.html", 

99 ), 

100 (MypyPlugin, "", None), 

101 # osv-scanner — per-vulnerability URL 

102 ( 

103 OsvScannerPlugin, 

104 "GHSA-c3g4-w6cv-6v7h", 

105 "https://osv.dev/vulnerability/GHSA-c3g4-w6cv-6v7h", 

106 ), 

107 (OsvScannerPlugin, "", None), 

108 # pydoclint — single config page 

109 ( 

110 PydoclintPlugin, 

111 "DOC301", 

112 "https://jsh9.github.io/pydoclint/how_to_config.html", 

113 ), 

114 (PydoclintPlugin, "", None), 

115 # shellcheck — wiki page 

116 (ShellcheckPlugin, "SC2086", "https://www.shellcheck.net/wiki/SC2086"), 

117 (ShellcheckPlugin, "", None), 

118 # sqlfluff — rules anchor 

119 (SqlfluffPlugin, "LT01", "https://docs.sqlfluff.com/en/stable/rules.html#LT01"), 

120 (SqlfluffPlugin, "", None), 

121 # taplo — single-page docs 

122 (TaploPlugin, "invalid_value", "https://taplo.tamasfe.dev/"), 

123 (TaploPlugin, "", None), 

124 # yamllint — rules anchor 

125 ( 

126 YamllintPlugin, 

127 "line-length", 

128 "https://yamllint.readthedocs.io/en/stable/rules.html#line-length", 

129 ), 

130 (YamllintPlugin, "", None), 

131 ], 

132 ids=[ 

133 "actionlint-valid", 

134 "actionlint-empty", 

135 "astro-check-valid", 

136 "astro-check-empty", 

137 "bandit-valid", 

138 "bandit-empty", 

139 "cargo-audit-valid", 

140 "cargo-audit-empty", 

141 "cargo-deny-valid", 

142 "cargo-deny-empty", 

143 "clippy-valid", 

144 "clippy-empty", 

145 "markdownlint-valid", 

146 "markdownlint-empty", 

147 "mypy-valid", 

148 "mypy-empty", 

149 "osv-scanner-valid", 

150 "osv-scanner-empty", 

151 "pydoclint-valid", 

152 "pydoclint-empty", 

153 "shellcheck-valid", 

154 "shellcheck-empty", 

155 "sqlfluff-valid", 

156 "sqlfluff-empty", 

157 "taplo-valid", 

158 "taplo-empty", 

159 "yamllint-valid", 

160 "yamllint-empty", 

161 ], 

162) 

163def test_simple_doc_url( 

164 plugin_cls: type, 

165 code: str, 

166 expected_url: str | None, 

167) -> None: 

168 """Verify simple URL-pattern tools return correct doc URLs. 

169 

170 Args: 

171 plugin_cls: Plugin class to instantiate. 

172 code: Rule code to look up. 

173 expected_url: Expected documentation URL or None. 

174 """ 

175 plugin = plugin_cls() 

176 assert_that(plugin.doc_url(code)).is_equal_to(expected_url) 

177 

178 

179# ============================================================================= 

180# Hadolint — DL/SC routing 

181# ============================================================================= 

182 

183 

184def test_hadolint_dl_code_returns_hadolint_wiki() -> None: 

185 """DL-prefixed codes route to hadolint GitHub wiki.""" 

186 plugin = HadolintPlugin() 

187 assert_that(plugin.doc_url("DL3008")).is_equal_to( 

188 "https://github.com/hadolint/hadolint/wiki/DL3008", 

189 ) 

190 

191 

192def test_hadolint_sc_code_returns_shellcheck_wiki() -> None: 

193 """SC-prefixed codes route to shellcheck.net wiki.""" 

194 plugin = HadolintPlugin() 

195 assert_that(plugin.doc_url("SC2046")).is_equal_to( 

196 "https://www.shellcheck.net/wiki/SC2046", 

197 ) 

198 

199 

200def test_hadolint_lowercase_dl_code_uppercased() -> None: 

201 """Lowercase DL codes are uppercased in the URL.""" 

202 plugin = HadolintPlugin() 

203 assert_that(plugin.doc_url("dl3008")).is_equal_to( 

204 "https://github.com/hadolint/hadolint/wiki/DL3008", 

205 ) 

206 

207 

208def test_hadolint_unknown_prefix_returns_none() -> None: 

209 """Codes with unknown prefixes return None.""" 

210 plugin = HadolintPlugin() 

211 assert_that(plugin.doc_url("XX123")).is_none() 

212 

213 

214def test_hadolint_empty_code_returns_none() -> None: 

215 """Empty codes return None.""" 

216 plugin = HadolintPlugin() 

217 assert_that(plugin.doc_url("")).is_none() 

218 

219 

220# ============================================================================= 

221# Oxlint — category/rule format 

222# ============================================================================= 

223 

224 

225def test_oxlint_category_rule_format() -> None: 

226 """Codes with category/rule format return oxc.rs URL.""" 

227 plugin = OxlintPlugin() 

228 assert_that( 

229 plugin.doc_url("deepscan/bad-comparison-sequence"), 

230 ).is_equal_to( 

231 "https://oxc.rs/docs/guide/usage/linter/rules/deepscan/bad-comparison-sequence", 

232 ) 

233 

234 

235def test_oxlint_no_slash_returns_none() -> None: 

236 """Codes without a slash return None.""" 

237 plugin = OxlintPlugin() 

238 assert_that(plugin.doc_url("no-unused-vars")).is_none() 

239 

240 

241def test_oxlint_empty_code_returns_none() -> None: 

242 """Empty codes return None.""" 

243 plugin = OxlintPlugin() 

244 assert_that(plugin.doc_url("")).is_none() 

245 

246 

247# ============================================================================= 

248# TSC — TS prefix stripping and numeric validation 

249# ============================================================================= 

250 

251 

252def test_tsc_ts_prefixed_code() -> None: 

253 """TS-prefixed codes return typescript.tv URL.""" 

254 plugin = TscPlugin() 

255 assert_that(plugin.doc_url("TS2307")).is_equal_to( 

256 "https://typescript.tv/errors/#ts2307", 

257 ) 

258 

259 

260def test_tsc_numeric_only_code() -> None: 

261 """Numeric-only codes work without TS prefix.""" 

262 plugin = TscPlugin() 

263 assert_that(plugin.doc_url("2307")).is_equal_to( 

264 "https://typescript.tv/errors/#ts2307", 

265 ) 

266 

267 

268def test_tsc_non_numeric_code_returns_none() -> None: 

269 """Non-numeric codes return None.""" 

270 plugin = TscPlugin() 

271 assert_that(plugin.doc_url("TSfoo")).is_none() 

272 

273 

274def test_tsc_empty_code_returns_none() -> None: 

275 """Empty codes return None.""" 

276 plugin = TscPlugin() 

277 assert_that(plugin.doc_url("")).is_none() 

278 

279 

280# ============================================================================= 

281# Vue-tsc — TS prefix stripping (same logic as TSC) 

282# ============================================================================= 

283 

284 

285def test_vue_tsc_ts_prefixed_code() -> None: 

286 """TS-prefixed codes return typescript.tv URL.""" 

287 plugin = VueTscPlugin() 

288 assert_that(plugin.doc_url("TS2322")).is_equal_to( 

289 "https://typescript.tv/errors/#ts2322", 

290 ) 

291 

292 

293def test_vue_tsc_numeric_only_code() -> None: 

294 """Numeric-only codes work without TS prefix.""" 

295 plugin = VueTscPlugin() 

296 assert_that(plugin.doc_url("2322")).is_equal_to( 

297 "https://typescript.tv/errors/#ts2322", 

298 ) 

299 

300 

301def test_vue_tsc_non_numeric_code_returns_none() -> None: 

302 """Non-numeric codes return None.""" 

303 plugin = VueTscPlugin() 

304 assert_that(plugin.doc_url("TSfoo")).is_none() 

305 

306 

307def test_vue_tsc_empty_code_returns_none() -> None: 

308 """Empty codes return None.""" 

309 plugin = VueTscPlugin() 

310 assert_that(plugin.doc_url("")).is_none() 

311 

312 

313# ============================================================================= 

314# Semgrep — registry vs. custom rule detection 

315# ============================================================================= 

316 

317 

318def test_semgrep_registry_rule_id() -> None: 

319 """Dotted registry rule IDs return semgrep.dev URL.""" 

320 plugin = SemgrepPlugin() 

321 assert_that( 

322 plugin.doc_url("python.lang.security.insecure-random"), 

323 ).is_equal_to( 

324 "https://semgrep.dev/r/python.lang.security.insecure-random", 

325 ) 

326 

327 

328def test_semgrep_local_rule_with_slash_returns_none() -> None: 

329 """Custom rules with path separators return None.""" 

330 plugin = SemgrepPlugin() 

331 assert_that(plugin.doc_url("rules/custom-rule")).is_none() 

332 

333 

334def test_semgrep_simple_name_without_dot_returns_none() -> None: 

335 """Simple names without dots return None (likely local rules).""" 

336 plugin = SemgrepPlugin() 

337 assert_that(plugin.doc_url("my-custom-rule")).is_none() 

338 

339 

340def test_semgrep_empty_code_returns_none() -> None: 

341 """Empty codes return None.""" 

342 plugin = SemgrepPlugin() 

343 assert_that(plugin.doc_url("")).is_none() 

344 

345 

346# ============================================================================= 

347# Ruff — subprocess-based rule name resolution with caching 

348# ============================================================================= 

349 

350 

351@patch("subprocess.run") 

352def test_ruff_resolves_rule_name_to_url(mock_run: MagicMock) -> None: 

353 """Valid codes resolve to ruff docs URL via subprocess. 

354 

355 Args: 

356 mock_run: Mocked subprocess.run. 

357 """ 

358 mock_run.return_value = MagicMock( 

359 returncode=0, 

360 stdout=json.dumps({"name": "line-too-long"}), 

361 ) 

362 plugin = RuffPlugin() 

363 

364 result = plugin.doc_url("E501") 

365 

366 assert_that(result).is_equal_to( 

367 "https://docs.astral.sh/ruff/rules/line-too-long/", 

368 ) 

369 

370 

371@patch("subprocess.run") 

372def test_ruff_caches_resolved_name(mock_run: MagicMock) -> None: 

373 """Second call for same code uses cache, not subprocess. 

374 

375 Args: 

376 mock_run: Mocked subprocess.run. 

377 """ 

378 mock_run.return_value = MagicMock( 

379 returncode=0, 

380 stdout=json.dumps({"name": "line-too-long"}), 

381 ) 

382 plugin = RuffPlugin() 

383 

384 plugin.doc_url("E501") 

385 plugin.doc_url("E501") 

386 

387 assert_that(mock_run.call_count).is_equal_to(1) 

388 

389 

390@patch("subprocess.run") 

391def test_ruff_timeout_returns_none_and_caches(mock_run: MagicMock) -> None: 

392 """Subprocess timeout returns None and caches the failure. 

393 

394 Args: 

395 mock_run: Mocked subprocess.run. 

396 """ 

397 mock_run.side_effect = subprocess.TimeoutExpired(cmd="ruff", timeout=5) 

398 plugin = RuffPlugin() 

399 

400 result1 = plugin.doc_url("E501") 

401 result2 = plugin.doc_url("E501") 

402 

403 assert_that(result1).is_none() 

404 assert_that(result2).is_none() 

405 assert_that(mock_run.call_count).is_equal_to(1) 

406 

407 

408@patch("subprocess.run") 

409def test_ruff_json_error_returns_none(mock_run: MagicMock) -> None: 

410 """Malformed JSON from subprocess returns None. 

411 

412 Args: 

413 mock_run: Mocked subprocess.run. 

414 """ 

415 mock_run.return_value = MagicMock( 

416 returncode=0, 

417 stdout="not json", 

418 ) 

419 plugin = RuffPlugin() 

420 

421 result = plugin.doc_url("E501") 

422 

423 assert_that(result).is_none() 

424 

425 

426@patch("subprocess.run") 

427def test_ruff_nonzero_exit_returns_none(mock_run: MagicMock) -> None: 

428 """Non-zero exit code from subprocess returns None. 

429 

430 Args: 

431 mock_run: Mocked subprocess.run. 

432 """ 

433 mock_run.return_value = MagicMock( 

434 returncode=1, 

435 stdout="", 

436 ) 

437 plugin = RuffPlugin() 

438 

439 result = plugin.doc_url("UNKNOWN") 

440 

441 assert_that(result).is_none() 

442 

443 

444def test_ruff_empty_code_returns_none() -> None: 

445 """Empty codes return None without calling subprocess.""" 

446 plugin = RuffPlugin() 

447 assert_that(plugin.doc_url("")).is_none()