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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tests for tool doc_url() methods.
3Verifies that each tool plugin returns the correct documentation URL for
4a given rule code, and returns None for empty or invalid codes.
5"""
7from __future__ import annotations
9import json
10import subprocess
11from unittest.mock import MagicMock, patch
13import pytest
14from assertpy import assert_that
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
37# =============================================================================
38# Simple URL-pattern tools (no subprocess needed)
39# =============================================================================
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.
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)
179# =============================================================================
180# Hadolint — DL/SC routing
181# =============================================================================
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 )
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 )
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 )
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()
214def test_hadolint_empty_code_returns_none() -> None:
215 """Empty codes return None."""
216 plugin = HadolintPlugin()
217 assert_that(plugin.doc_url("")).is_none()
220# =============================================================================
221# Oxlint — category/rule format
222# =============================================================================
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 )
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()
241def test_oxlint_empty_code_returns_none() -> None:
242 """Empty codes return None."""
243 plugin = OxlintPlugin()
244 assert_that(plugin.doc_url("")).is_none()
247# =============================================================================
248# TSC — TS prefix stripping and numeric validation
249# =============================================================================
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 )
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 )
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()
274def test_tsc_empty_code_returns_none() -> None:
275 """Empty codes return None."""
276 plugin = TscPlugin()
277 assert_that(plugin.doc_url("")).is_none()
280# =============================================================================
281# Vue-tsc — TS prefix stripping (same logic as TSC)
282# =============================================================================
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 )
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 )
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()
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()
313# =============================================================================
314# Semgrep — registry vs. custom rule detection
315# =============================================================================
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 )
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()
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()
340def test_semgrep_empty_code_returns_none() -> None:
341 """Empty codes return None."""
342 plugin = SemgrepPlugin()
343 assert_that(plugin.doc_url("")).is_none()
346# =============================================================================
347# Ruff — subprocess-based rule name resolution with caching
348# =============================================================================
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.
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()
364 result = plugin.doc_url("E501")
366 assert_that(result).is_equal_to(
367 "https://docs.astral.sh/ruff/rules/line-too-long/",
368 )
371@patch("subprocess.run")
372def test_ruff_caches_resolved_name(mock_run: MagicMock) -> None:
373 """Second call for same code uses cache, not subprocess.
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()
384 plugin.doc_url("E501")
385 plugin.doc_url("E501")
387 assert_that(mock_run.call_count).is_equal_to(1)
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.
394 Args:
395 mock_run: Mocked subprocess.run.
396 """
397 mock_run.side_effect = subprocess.TimeoutExpired(cmd="ruff", timeout=5)
398 plugin = RuffPlugin()
400 result1 = plugin.doc_url("E501")
401 result2 = plugin.doc_url("E501")
403 assert_that(result1).is_none()
404 assert_that(result2).is_none()
405 assert_that(mock_run.call_count).is_equal_to(1)
408@patch("subprocess.run")
409def test_ruff_json_error_returns_none(mock_run: MagicMock) -> None:
410 """Malformed JSON from subprocess returns None.
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()
421 result = plugin.doc_url("E501")
423 assert_that(result).is_none()
426@patch("subprocess.run")
427def test_ruff_nonzero_exit_returns_none(mock_run: MagicMock) -> None:
428 """Non-zero exit code from subprocess returns None.
430 Args:
431 mock_run: Mocked subprocess.run.
432 """
433 mock_run.return_value = MagicMock(
434 returncode=1,
435 stdout="",
436 )
437 plugin = RuffPlugin()
439 result = plugin.doc_url("UNKNOWN")
441 assert_that(result).is_none()
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()