diff --git a/df12_python_lints/suppressions.py b/df12_python_lints/suppressions.py index fbb8273..5b84712 100644 --- a/df12_python_lints/suppressions.py +++ b/df12_python_lints/suppressions.py @@ -51,7 +51,7 @@ } _LINT_DIRECTIVE: typ.Final = re.compile( - r"\bnoqa\b|\bruff\s*:\s*noqa\b|\bpylint\s*:\s*disable", + r"\bnoqa\b|\bpylint\s*:\s*disable", re.IGNORECASE, ) @@ -60,14 +60,34 @@ re.IGNORECASE, ) -# Comma-separated code lists; a space-separated word after the codes is -# prose, so these deliberately do not admit bare spaces between items. -_CODE_LIST: typ.Final = r"[A-Za-z0-9]+(?:\s*,\s*[A-Za-z0-9]+)*" +# Inline `noqa` directives accept comma- or whitespace-separated rule codes. +# Restricting each item to Ruff's letter-plus-digit shape leaves trailing prose +# distinguishable. +_NOQA_CODE_LIST: typ.Final = ( + r"[A-Za-z]+[0-9]+" + r"(?:(?:\s*,\s*|\s+)[A-Za-z]+[0-9]+)*" + r"\s*,?" +) +_RUFF_RULE_LIST: typ.Final = r"[\w\-]+(?:\s*,\s*[\w\-]+)*\s*,?" _NAME_LIST: typ.Final = r"[\w\-]+(?:\s*,\s*[\w\-]+)*" +_RUFF_INLINE_DIRECTIVE: typ.Final = re.compile( + rf"\bruff\s*:\s*ignore\s*\[\s*{_RUFF_RULE_LIST}\s*\](?=\s|$)" +) +_RUFF_STANDALONE_DIRECTIVE: typ.Final = re.compile( + rf"\bruff\s*:\s*(?:file-ignore|disable)" + rf"\s*\[\s*{_RUFF_RULE_LIST}\s*\](?=\s|$)" +) +_RUFF_ENABLE_DIRECTIVE: typ.Final = re.compile( + rf"^\s*#\s*ruff\s*:\s*enable" + rf"\s*\[\s*{_RUFF_RULE_LIST}\s*\](?=\s|$)" +) _DIRECTIVE_ONLY_SEGMENT: typ.Final = re.compile( rf"""^\s*(?: - (?:ruff\s*:\s*)? noqa (?:\s*:\s*{_CODE_LIST})? + (?:(?:ruff|flake8)\s*:\s*)? noqa + (?:\s*:\s*{_NOQA_CODE_LIST})? + | (?-i:ruff\s*:\s*(?:ignore|file-ignore|disable|enable) + \s*\[\s*{_RUFF_RULE_LIST}\s*\]) | pylint\s*:\s*disable(?:-next|-line)?\s*=\s*{_NAME_LIST} | type\s*:\s*ignore (?:\[[\w\s,\-]*\])? | (?:pyright|ty)\s*:\s*ignore (?:\[[\w\s,\-]*\])? @@ -84,18 +104,17 @@ class _Comment(typ.NamedTuple): is_standalone: bool -def _directive_symbols(comment_text: str) -> tuple[str, ...]: - """Return the message symbols for pragmas present in *comment_text*. - - Examples - -------- - ``"# noqa: S101"`` maps to the lint suppression symbol; a plain - comment maps to an empty tuple. - """ +def _directive_symbols(comment: _Comment) -> tuple[str, ...]: + """Return message symbols for pragmas present in *comment*.""" symbols: list[str] = [] - if _LINT_DIRECTIVE.search(comment_text): + has_lint_directive = ( + _LINT_DIRECTIVE.search(comment.text) + or _RUFF_INLINE_DIRECTIVE.search(comment.text) + or (comment.is_standalone and _RUFF_STANDALONE_DIRECTIVE.search(comment.text)) + ) + if has_lint_directive: symbols.append("lint-suppression-without-explanation") - if _TYPE_DIRECTIVE.search(comment_text): + if _TYPE_DIRECTIVE.search(comment.text): symbols.append("typecheck-suppression-without-explanation") return tuple(symbols) @@ -147,7 +166,7 @@ def process_tokens(self, tokens: list[tokenize.TokenInfo]) -> None: """ comments = _collect_comments(tokens) for row, comment in sorted(comments.items()): - symbols = _directive_symbols(comment.text) + symbols = _directive_symbols(comment) if not symbols or _has_inline_explanation(comment.text): continue if _has_preceding_explanation(comments, row): @@ -180,8 +199,7 @@ def _collect_comments( def _has_preceding_explanation(comments: dict[int, _Comment], row: int) -> bool: """Return whether the line above *row* holds an explanatory comment. - Only a standalone comment that is not itself a suppression pragma - counts. + Only a standalone comment containing prose beyond any directives counts. Examples -------- @@ -191,4 +209,6 @@ def _has_preceding_explanation(comments: dict[int, _Comment], row: int) -> bool: preceding = comments.get(row - 1) if preceding is None or not preceding.is_standalone: return False - return not _directive_symbols(preceding.text) + if _RUFF_ENABLE_DIRECTIVE.match(preceding.text): + return False + return _has_inline_explanation(preceding.text) diff --git a/docs/contents.md b/docs/contents.md index bd41a9f..0e75420 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -15,6 +15,9 @@ documentation set. - [Version 0.2.0 migration guide](migration-0.2.0.md) explains the new dataclass-slots rule, the reassigned message identifiers, and the required pylint configuration changes. +- [Version 0.3.0 migration guide](migration-0.3.0.md) explains the expanded + Ruff suppression grammar, explanation requirements, and neutral range + terminators. - [ADR 001](adr-001-conservative-dataclass-layout-analysis.md) records the conservative, cached layout analysis and supported Pylint range for R9111. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0bc4704..1641c88 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -87,6 +87,28 @@ comment tokens to find suppression pragmas and the explanations that may accompany them, because a bare pragma carries no node in the abstract syntax tree to attach a check to. +Its Ruff grammar follows Ruff 0.16.0: + +- Bare `noqa` is case-insensitive, including when it follows code on the same + line. The file-level `ruff: noqa` and `flake8: noqa` aliases are + case-sensitive and must occupy standalone comments; they accept blanket + suppression or rule-code lists. +- `ruff: ignore[...]`, `ruff: file-ignore[...]`, and `ruff: disable[...]` + accept rule codes or preview rule names. Whitespace around the colon, before + the opening bracket, and around comma separators is permitted, as is a + trailing comma. Ruff keywords are case-sensitive; `file-ignore`, `disable`, + and `enable` are recognized only in standalone comments, while `ignore` may + follow code on the same line. +- `ruff: enable[...]` is a range terminator, not a suppression opener. It + emits no C9106 diagnostic and is classified as a directive rather than + explanatory prose. + +A suppression opener is explained by non-directive prose in the same comment +segment, prose after a second `#`, or a standalone prose comment immediately +above it. Another pragma on the preceding line never explains it. This includes +`ruff: enable[...]`: the terminator is neutral, so it neither requires an +explanation nor supplies one for the next suppression. + The `ambrleaks` subpackage is a separate, standalone scanner exposed as its own console script, split into four modules: `rules.py` pairs each detection pattern with an optional entropy floor and allowlists, `scanner.py` walks diff --git a/docs/migration-0.3.0.md b/docs/migration-0.3.0.md new file mode 100644 index 0000000..90d91e8 --- /dev/null +++ b/docs/migration-0.3.0.md @@ -0,0 +1,73 @@ +# Migrate to version 0.3.0 + +Version 0.3.0 expands suppression-comment checking to recognize the valid Ruff +and Flake8 forms accepted by the project. Suppression directives must record a +reason, while a Ruff range terminator remains neutral. + +## Explain valid suppression directives + +Review existing suppression comments after upgrading. The checker reports an +unexplained valid directive for `ruff: ignore[...]`, standalone +`ruff: file-ignore[...]`, standalone `ruff: disable[...]`, and standalone +`ruff: noqa` or `flake8: noqa`, in addition to the other lint and type-check +suppressions already covered by C9106 and C9107. + +An explanation can be non-directive prose in the same comment segment, prose +after a second `#`, or a standalone prose comment immediately above the +directive: + +```python +value = eval(text) # ruff: ignore[S307] # input is a vetted config literal + +# Generated URLs cannot be wrapped. +# ruff: file-ignore [E501,] + +# ruff: disable[F841,] # generated fixture intentionally binds this name + +# ruff: noqa: F401 # generated package exports imported names + +# flake8: noqa: F401 # generated module exports imported names +``` + +A pragma on the preceding line is not an explanation. The prose comment above +must explain the following directive; another pragma, including a Ruff range +directive, does not. + +## Preserve Ruff syntax constraints + +Ruff keywords are case-sensitive. Bare `noqa` is the exception: it is +case-insensitive and may follow code on the same line. The file-level +`ruff: noqa` and `flake8: noqa` aliases are case-sensitive and must be +standalone comments. Among bracketed Ruff directives, only `ruff: ignore[...]` +may follow code; `ruff: file-ignore[...]`, `ruff: disable[...]`, and +`ruff: enable[...]` must be standalone comments. + +Whitespace before the selector bracket is valid, as are spaces around comma +separators and a trailing comma. Selectors may be rule codes or preview rule +names. These forms are therefore equivalent for suppression detection: + +```python +value = eval(text) # ruff: ignore[S307,] +value = eval(text) # ruff: ignore [S307,] + +# ruff: file-ignore [F401, ARG001,] +# ruff: disable[E741, F841,] +``` + +## Keep `ruff: enable[...]` neutral + +`ruff: enable[...]` ends a suppression range; it does not suppress a +diagnostic. It needs no explanation and cannot explain a later suppression: + +```python +# ruff: enable [E501,] +value = 1 # ruff: ignore [F841] # retained for generated fixture parity +``` + +The `enable` directive itself is not reported as C9106, and trailing prose on +that directive does not satisfy the explanation requirement for the next +suppression. + +After updating existing comments, run the normal lint targets and resolve any +new C9106 or C9107 diagnostics. Keep explanations close to the directive so the +compatibility reason remains reviewable when the suppression is revisited. diff --git a/docs/users-guide.md b/docs/users-guide.md index 5fd1373..5bac9fe 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -123,20 +123,31 @@ import os.path join = os.path.join # flagged ``` -Use `from os.path import join` instead, so -importers and type checkers see a real import binding. Call results, aliases of -names defined in the same module, and assignments inside functions are not -flagged. +Use `from os.path import join` instead, so importers and type checkers see a +real import binding. Call results, aliases of names defined in the same module, +and assignments inside functions are not flagged. ### Suppressions without explanations (C9106, C9107) Two checkers require every suppression pragma to record a reason: - `lint-suppression-without-explanation` (C9106) covers lint pragmas: - `noqa`, `ruff: noqa`, and `pylint: disable`. + `noqa`, `ruff: noqa`, `flake8: noqa`, `ruff: ignore`, `ruff: file-ignore`, + the range directive `ruff: disable`, and `pylint: disable`. - `typecheck-suppression-without-explanation` (C9107) covers type-check pragmas: `type: ignore`, `pyright: ignore`, `ty: ignore`, and `mypy:`. +Bare `noqa`, including inline `noqa` after code, is case-insensitive. The +`ruff: noqa` and `flake8: noqa` file-level aliases have case-sensitive prefixes +and must occupy standalone comments. Other Ruff directive keywords are also +case-sensitive: `ruff: file-ignore`, `ruff: disable`, and `ruff: enable` must +occupy a standalone comment; only `ruff: ignore` may follow code on the same +line. + +`ruff: enable[...]` ends a suppression range rather than suppressing a +diagnostic itself. It therefore needs no explanation and does not count as an +explanation for a suppression on the next line. + An explanation may sit after a second `#` in the same comment, as trailing prose in the pragma segment, or as a standalone comment on the line above: diff --git a/tests/test_properties.py b/tests/test_properties.py index 87b693f..18688df 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -8,10 +8,8 @@ from __future__ import annotations -import io import math import operator -import tokenize import typing as typ import astroid @@ -26,12 +24,10 @@ from df12_python_lints.constant_chain import ConstantChainChecker from df12_python_lints.match_dispatch import MatchDispatchChecker from df12_python_lints.snapshot_asserts import SnapshotAssertionChecker -from df12_python_lints.suppressions import SuppressionCommentChecker from tests.dataclass_slots_support import module_classes, parse_module if typ.TYPE_CHECKING: from pylint.checkers import BaseChecker - # Constructed identifiers: a fixed prefix guarantees the name is never a # Python keyword, avoiding the filtering trap. _SUBJECTS = st.from_regex(r"v_[a-z]{1,6}", fullmatch=True) @@ -57,15 +53,6 @@ def _walk_symbols(checker_class: type[BaseChecker], code: str) -> list[str]: return [message.msg_id for message in linter.release_messages()] -def _token_symbols(code: str) -> list[str]: - """Collect the suppression checker's symbols over *code*.""" - linter = testutils.UnittestLinter() - checker = SuppressionCommentChecker(linter) - tokens = list(tokenize.generate_tokens(io.StringIO(code).readline)) - checker.process_tokens(tokens) - return [message.msg_id for message in linter.release_messages()] - - def _constant_chain(subject: str, constants: list[int]) -> str: """Render an if/elif chain comparing *subject* with *constants*.""" branches = [f" if {subject} == {constants[0]}:\n return 0\n"] @@ -195,51 +182,6 @@ def test_threshold_is_nesting_invariant(self, leaves: int, split: int) -> None: assert symbols == expected, "firing must depend only on the total leaf count" -class TestSuppressionProperties: - """Generated pragmas are classified uniformly.""" - - @settings(deadline=None) - @given( - codes=st.lists( - st.from_regex(r"[A-Z]{1,3}[0-9]{2,4}", fullmatch=True), - min_size=1, - max_size=3, - ) - ) - def test_bare_noqa_always_fires(self, codes: list[str]) -> None: - """A noqa pragma with any code list and no prose is reported.""" - code = f"x = 1 # noqa: {', '.join(codes)}\n" - assert _token_symbols(code) == ["lint-suppression-without-explanation"], ( - "a bare noqa must be reported whatever its code list" - ) - - @settings(deadline=None) - @given( - codes=st.lists( - st.from_regex(r"[A-Z][0-9]{3}", fullmatch=True), min_size=1, max_size=3 - ), - first=_WORDS, - second=_WORDS, - ) - def test_prose_always_explains( - self, codes: list[str], first: str, second: str - ) -> None: - """Two-word prose after a second hash explains any pragma.""" - code = f"x = 1 # noqa: {', '.join(codes)} # {first} {second}\n" - assert _token_symbols(code) == [], ( - "prose after a second hash must count as an explanation" - ) - - @settings(deadline=None) - @given(names=st.lists(_WORDS, min_size=1, max_size=3)) - def test_bare_pylint_disable_always_fires(self, names: list[str]) -> None: - """A pylint disable pragma with any name list is reported.""" - code = f"x = 1 # pylint: disable={','.join(names)}\n" - assert _token_symbols(code) == ["lint-suppression-without-explanation"], ( - "a bare pylint disable must be reported whatever its names" - ) - - class TestPureKernelProperties: """The extracted selection kernels honour their contracts.""" diff --git a/tests/test_suppression_properties.py b/tests/test_suppression_properties.py new file mode 100644 index 0000000..c435c91 --- /dev/null +++ b/tests/test_suppression_properties.py @@ -0,0 +1,153 @@ +"""Hypothesis property tests for suppression pragma classification.""" + +from __future__ import annotations + +import io +import tokenize + +from hypothesis import given, settings +from hypothesis import strategies as st +from pylint import testutils + +from df12_python_lints.suppressions import SuppressionCommentChecker + +_WORDS = st.from_regex(r"[a-z]{2,8}", fullmatch=True) +_RUFF_DIRECTIVE_KINDS = st.sampled_from(("ignore", "file-ignore", "disable")) +_RUFF_SELECTORS = st.lists( + st.one_of( + st.from_regex(r"[A-Z]{1,3}[0-9]{2,4}", fullmatch=True), + st.from_regex(r"[a-z]{2,8}(?:-[a-z]{2,8})?", fullmatch=True), + ), + min_size=1, + max_size=3, +) +_RUFF_WHITESPACE = st.sampled_from(("", " ", " ", "\t")) +_RUFF_SEPARATOR = st.sampled_from((",", ", ", " , ")) +_RUFF_BARE_CASES = st.tuples( + _RUFF_DIRECTIVE_KINDS, + _RUFF_SELECTORS, + _RUFF_WHITESPACE, + _RUFF_WHITESPACE, + _RUFF_SEPARATOR, + st.booleans(), +) +_RUFF_EXPLAINED_CASES = st.tuples( + _RUFF_DIRECTIVE_KINDS, + _RUFF_SELECTORS, + _RUFF_WHITESPACE, + st.sampled_from(("inline", "preceding")), + _WORDS, + _WORDS, +) + + +def _token_symbols(code: str) -> list[str]: + """Collect the suppression checker's symbols over *code*.""" + linter = testutils.UnittestLinter() + checker = SuppressionCommentChecker(linter) + tokens = list(tokenize.generate_tokens(io.StringIO(code).readline)) + checker.process_tokens(tokens) + return [message.msg_id for message in linter.release_messages()] + + +class TestSuppressionProperties: + """Generated pragmas are classified uniformly.""" + + @settings(deadline=None) + @given(case=_RUFF_BARE_CASES) + def test_bare_ruff_suppressions_always_fire( + self, + case: tuple[str, list[str], str, str, str, bool], + ) -> None: + """Every valid Ruff suppression opener without prose is reported.""" + kind, selectors, after_colon, before_bracket, separator, has_trailing_comma = ( + case + ) + selector_list = separator.join(selectors) + trailing_comma = "," if has_trailing_comma else "" + directive = ( + f"# ruff:{after_colon}{kind}{before_bracket}" + f"[{selector_list}{trailing_comma}]" + ) + assert _token_symbols(f"{directive}\nx = 1\n") == [ + "lint-suppression-without-explanation" + ], "every bare Ruff suppression grammar variant must be reported" + + @settings(deadline=None) + @given(case=_RUFF_EXPLAINED_CASES) + def test_prose_explains_every_ruff_suppression( + self, + case: tuple[str, list[str], str, str, str, str], + ) -> None: + """Inline or preceding prose explains every Ruff suppression form.""" + kind, selectors, before_bracket, explanation_placement, first, second = case + directive = f"# ruff: {kind}{before_bracket}[{', '.join(selectors)}]" + explanation = f"# {first} {second}" + code = ( + f"{directive} {explanation}\nx = 1\n" + if explanation_placement == "inline" + else f"{explanation}\n{directive}\nx = 1\n" + ) + assert _token_symbols(code) == [], ( + "valid explanatory prose must take precedence over the directive" + ) + + @settings(deadline=None) + @given( + selectors=_RUFF_SELECTORS, + before_bracket=_RUFF_WHITESPACE, + ) + def test_ruff_enable_is_always_neutral( + self, selectors: list[str], before_bracket: str + ) -> None: + """A Ruff range terminator neither fires nor explains a suppression.""" + selector_list = ", ".join(selectors) + enable = f"# ruff: enable{before_bracket}[{selector_list}]" + suppression = f"# ruff: ignore{before_bracket}[{selector_list}]" + assert _token_symbols(f"{enable}\n") == [], ( + "a range terminator must not require an explanation" + ) + assert _token_symbols(f"{enable}\n{suppression}\nx = 1\n") == [ + "lint-suppression-without-explanation" + ], "a range terminator must not explain the next suppression" + + @settings(deadline=None) + @given( + codes=st.lists( + st.from_regex(r"[A-Z]{1,3}[0-9]{2,4}", fullmatch=True), + min_size=1, + max_size=3, + ) + ) + def test_bare_noqa_always_fires(self, codes: list[str]) -> None: + """A noqa pragma with any code list and no prose is reported.""" + code = f"x = 1 # noqa: {', '.join(codes)}\n" + assert _token_symbols(code) == ["lint-suppression-without-explanation"], ( + "a bare noqa must be reported whatever its code list" + ) + + @settings(deadline=None) + @given( + codes=st.lists( + st.from_regex(r"[A-Z][0-9]{3}", fullmatch=True), min_size=1, max_size=3 + ), + first=_WORDS, + second=_WORDS, + ) + def test_prose_always_explains( + self, codes: list[str], first: str, second: str + ) -> None: + """Two-word prose after a second hash explains any pragma.""" + code = f"x = 1 # noqa: {', '.join(codes)} # {first} {second}\n" + assert _token_symbols(code) == [], ( + "prose after a second hash must count as an explanation" + ) + + @settings(deadline=None) + @given(names=st.lists(_WORDS, min_size=1, max_size=3)) + def test_bare_pylint_disable_always_fires(self, names: list[str]) -> None: + """A pylint disable pragma with any name list is reported.""" + code = f"x = 1 # pylint: disable={','.join(names)}\n" + assert _token_symbols(code) == ["lint-suppression-without-explanation"], ( + "a bare pylint disable must be reported whatever its names" + ) diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py index 5599c6b..ecbaca4 100644 --- a/tests/test_suppressions.py +++ b/tests/test_suppressions.py @@ -8,7 +8,11 @@ import pytest from pylint import testutils -from df12_python_lints.suppressions import SuppressionCommentChecker +from df12_python_lints.suppressions import ( + SuppressionCommentChecker, + _Comment, + _directive_symbols, +) def _tokens(code: str) -> list[tokenize.TokenInfo]: @@ -35,6 +39,51 @@ class TestSuppressionCommentChecker(testutils.CheckerTestCase): ("code", "message"), [ pytest.param("x = 1 # noqa: E501\n", _lint_message(1), id="bare-noqa"), + pytest.param( + "x = 1 # noqa: E501 F841,\n", + _lint_message(1), + id="space-separated-noqa-with-trailing-comma", + ), + pytest.param( + "# flake8: noqa: F401\n", + _lint_message(1), + id="flake8-file-noqa", + ), + pytest.param( + "x = 1 # ruff: ignore[E501]\n", + _lint_message(1), + id="inline-ruff-ignore", + ), + pytest.param( + "x = 1 # ruff: ignore [E501]\n", + _lint_message(1), + id="spaced-inline-ruff-ignore", + ), + pytest.param( + "#ruff: ignore[unused-variable,]\nx = 1\n", + _lint_message(1), + id="preceding-ruff-ignore-with-rule-name", + ), + pytest.param( + "# ruff: file-ignore[F401, ARG001,]\n", + _lint_message(1), + id="ruff-file-ignore", + ), + pytest.param( + "# ruff: file-ignore [F401, ARG001,]\n", + _lint_message(1), + id="spaced-ruff-file-ignore", + ), + pytest.param( + "# ruff: disable[E741, F841,]\nx = 1\n", + _lint_message(1), + id="ruff-disable", + ), + pytest.param( + "# ruff: disable [E741, F841,]\nx = 1\n", + _lint_message(1), + id="spaced-ruff-disable", + ), pytest.param( "# pylint: disable=too-many-branches\nx = 1\n", _lint_message(1), @@ -66,13 +115,13 @@ def test_flags_bare_pragma_without_explanation( def test_accepts_second_hash_explanation(self) -> None: """Prose after a second hash explains the pragma.""" - code = "x = eval(s) # noqa: S307 # input is a vetted literal\n" + code = "x = eval(s) # ruff: ignore[S307] # input is a vetted literal\n" with self.assertNoMessages(): self.checker.process_tokens(_tokens(code)) def test_accepts_trailing_prose_in_pragma_segment(self) -> None: """Prose in the same segment as the pragma explains it.""" - code = "x = 1 # noqa: E501 the URL cannot be wrapped\n" + code = "# ruff: file-ignore[E501] generated URLs cannot be wrapped\n" with self.assertNoMessages(): self.checker.process_tokens(_tokens(code)) @@ -99,6 +148,68 @@ def test_ignores_plain_comments(self) -> None: with self.assertNoMessages(): self.checker.process_tokens(_tokens(code)) + @pytest.mark.parametrize( + "code", + [ + pytest.param( + "x = 1 # RUFF: ignore[F841]\n", + id="uppercase-ruff", + ), + pytest.param( + "x = 1 # ruff: file-ignore[F841]\n", + id="inline-file-ignore", + ), + pytest.param( + "x = 1 # ruff: disable[F841]\n", + id="inline-disable", + ), + ], + ) + def test_ignores_invalid_ruff_directives(self, code: str) -> None: + """Text that Ruff does not treat as a suppression is not reported.""" + with self.assertNoMessages(): + self.checker.process_tokens(_tokens(code)) + + @pytest.mark.parametrize( + ("comment_text", "is_standalone"), + [ + pytest.param("# ruff: ignore [", True, id="missing-selector"), + pytest.param("# ruff: ignore[]", True, id="empty-selector"), + pytest.param("# ruff: ignore [F401", True, id="missing-bracket"), + pytest.param("# ruff: ignore[F401,,E501]", True, id="bad-separator"), + pytest.param("# ruff: IGNORE[F401]", True, id="uppercase-ignore"), + pytest.param("# ruff: FILE-IGNORE[F401]", True, id="uppercase-file-ignore"), + pytest.param("# ruff: DISABLE[F401]", True, id="uppercase-disable"), + pytest.param("# ruff: file-ignore[F401]", False, id="inline-file-ignore"), + pytest.param("# ruff: disable[F401]", False, id="inline-disable"), + ], + ) + def test_does_not_classify_invalid_ruff_directives( + self, comment_text: str, *, is_standalone: bool + ) -> None: + """Malformed, case-invalid, and inline-only forms are not pragmas.""" + assert not _directive_symbols(_Comment(comment_text, is_standalone)) + + def test_ignores_ruff_enable_directive(self) -> None: + """A directive ending a suppression range needs no reason.""" + code = "# ruff: enable [E501]\n" + with self.assertNoMessages(): + self.checker.process_tokens(_tokens(code)) + + def test_ruff_enable_does_not_explain_next_suppression(self) -> None: + """A range terminator is not prose explaining the next pragma.""" + code = "# ruff: enable [E501]\nx = 1 # ruff: ignore [F841]\n" + with self.assertAddsMessages(_lint_message(2), ignore_position=True): + self.checker.process_tokens(_tokens(code)) + + def test_ruff_enable_with_prose_does_not_explain_next_suppression(self) -> None: + """Text trailing a range terminator does not explain a later pragma.""" + code = ( + "# ruff: enable [E501] linting resumes here\nx = 1 # ruff: ignore [F841]\n" + ) + with self.assertAddsMessages(_lint_message(2), ignore_position=True): + self.checker.process_tokens(_tokens(code)) + def test_flags_both_kinds_in_one_comment(self) -> None: """A comment mixing lint and type pragmas reports both.""" code = "y = obj.attr # noqa: A001 # type: ignore[attr-defined]\n" diff --git a/uv.lock b/uv.lock index f7f5a4a..bb3dda8 100644 --- a/uv.lock +++ b/uv.lock @@ -764,27 +764,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]]