From cb6f0df13882584aecd64558c21db083981c8753 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Jul 2026 12:48:39 +0200 Subject: [PATCH 1/8] Support Ruff 0.16 suppression directives Require explanations for Ruff line, file, and range suppression directives, including rule-name selectors and the documented spacing and trailing-comma variants. Keep range terminators neutral, extend regression coverage for the full comment grammar, document the expanded rule, and lock Ruff 0.16.0 so the compatibility contract is exercised by the project gates. --- df12_python_lints/suppressions.py | 32 ++++++++++++++------- docs/users-guide.md | 10 +++---- tests/test_suppressions.py | 46 +++++++++++++++++++++++++++++-- uv.lock | 42 ++++++++++++++-------------- 4 files changed, 92 insertions(+), 38 deletions(-) diff --git a/df12_python_lints/suppressions.py b/df12_python_lints/suppressions.py index fbb8273..b8dd560 100644 --- a/df12_python_lints/suppressions.py +++ b/df12_python_lints/suppressions.py @@ -51,7 +51,11 @@ } _LINT_DIRECTIVE: typ.Final = re.compile( - r"\bnoqa\b|\bruff\s*:\s*noqa\b|\bpylint\s*:\s*disable", + ( + r"\bnoqa\b" + r"|\bruff\s*:\s*(?:ignore|file-ignore|disable)\[" + r"|\bpylint\s*:\s*disable" + ), re.IGNORECASE, ) @@ -60,14 +64,23 @@ 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\-]+)*" _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})? + | ruff\s*:\s*(?:ignore|file-ignore|disable|enable) + \[\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,\-]*\])? @@ -89,8 +102,8 @@ def _directive_symbols(comment_text: str) -> tuple[str, ...]: Examples -------- - ``"# noqa: S101"`` maps to the lint suppression symbol; a plain - comment maps to an empty tuple. + ``"# ruff: ignore[S101]"`` maps to the lint suppression symbol; a + plain comment maps to an empty tuple. """ symbols: list[str] = [] if _LINT_DIRECTIVE.search(comment_text): @@ -180,8 +193,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 +203,4 @@ 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) + return _has_inline_explanation(preceding.text) diff --git a/docs/users-guide.md b/docs/users-guide.md index 5fd1373..7b6d325 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -123,17 +123,17 @@ 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`, `ruff: ignore`, `ruff: file-ignore`, `ruff: disable`, + and `pylint: disable`. - `typecheck-suppression-without-explanation` (C9107) covers type-check pragmas: `type: ignore`, `pyright: ignore`, `ty: ignore`, and `mypy:`. diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py index 5599c6b..e9c6f35 100644 --- a/tests/test_suppressions.py +++ b/tests/test_suppressions.py @@ -35,6 +35,36 @@ 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( + "#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: disable[E741, F841,]\nx = 1\n", + _lint_message(1), + id="ruff-disable", + ), pytest.param( "# pylint: disable=too-many-branches\nx = 1\n", _lint_message(1), @@ -66,13 +96,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 +129,18 @@ def test_ignores_plain_comments(self) -> None: with self.assertNoMessages(): self.checker.process_tokens(_tokens(code)) + 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_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]] From 1fd4b20af1cfab35146e3727b93214cf2d6146f1 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Jul 2026 01:30:42 +0200 Subject: [PATCH 2/8] Accept spaced Ruff suppression selectors Recognize Ruff suppression and range directives when whitespace separates the keyword from its selector bracket. Keep spaced `enable` terminators neutral so they cannot explain a following suppression accidentally. Add regressions for line-level, file-level and range forms accepted by Ruff 0.16.0. --- df12_python_lints/suppressions.py | 4 ++-- tests/test_suppressions.py | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/df12_python_lints/suppressions.py b/df12_python_lints/suppressions.py index b8dd560..1bdc285 100644 --- a/df12_python_lints/suppressions.py +++ b/df12_python_lints/suppressions.py @@ -53,7 +53,7 @@ _LINT_DIRECTIVE: typ.Final = re.compile( ( r"\bnoqa\b" - r"|\bruff\s*:\s*(?:ignore|file-ignore|disable)\[" + r"|\bruff\s*:\s*(?:ignore|file-ignore|disable)\s*\[" r"|\bpylint\s*:\s*disable" ), re.IGNORECASE, @@ -80,7 +80,7 @@ (?:(?:ruff|flake8)\s*:\s*)? noqa (?:\s*:\s*{_NOQA_CODE_LIST})? | ruff\s*:\s*(?:ignore|file-ignore|disable|enable) - \[\s*{_RUFF_RULE_LIST}\s*\] + \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,\-]*\])? diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py index e9c6f35..9eaac01 100644 --- a/tests/test_suppressions.py +++ b/tests/test_suppressions.py @@ -50,6 +50,11 @@ class TestSuppressionCommentChecker(testutils.CheckerTestCase): _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), @@ -60,11 +65,21 @@ class TestSuppressionCommentChecker(testutils.CheckerTestCase): _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), @@ -131,13 +146,13 @@ def test_ignores_plain_comments(self) -> None: def test_ignores_ruff_enable_directive(self) -> None: """A directive ending a suppression range needs no reason.""" - code = "# ruff: enable[E501]\n" + 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" + 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)) From f04f875e5f58d80e70b7f102346dd08d44dbb3d7 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Jul 2026 01:50:19 +0200 Subject: [PATCH 3/8] Document and verify Ruff suppression policy Describe Ruff range terminators and the complete suppression grammar in the user and developer guides. Add generated coverage for grammar variants, explanation precedence, and neutral enable directives. --- docs/developers-guide.md | 18 ++++++++ docs/users-guide.md | 8 +++- tests/test_properties.py | 90 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0bc4704..da0c68e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -87,6 +87,24 @@ 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: + +- `noqa` and the file-level `ruff: noqa` and `flake8: noqa` aliases 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: 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/users-guide.md b/docs/users-guide.md index 7b6d325..031712b 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -132,11 +132,15 @@ and assignments inside functions are not flagged. Two checkers require every suppression pragma to record a reason: - `lint-suppression-without-explanation` (C9106) covers lint pragmas: - `noqa`, `ruff: noqa`, `ruff: ignore`, `ruff: file-ignore`, `ruff: disable`, - and `pylint: disable`. + `noqa`, `ruff: noqa`, `ruff: ignore`, `ruff: file-ignore`, the paired range + directives `ruff: disable` and `ruff: enable`, and `pylint: disable`. - `typecheck-suppression-without-explanation` (C9107) covers type-check pragmas: `type: ignore`, `pyright: ignore`, `ty: ignore`, and `mypy:`. +`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..3b79f85 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,11 +1,9 @@ """Hypothesis property tests for the checkers' decision kernels. - Where the example-based suites pin a handful of sizes and shapes, these properties cover the whole bounded space: chains of any length, guard runs of any length, arbitrarily nested literals, and generated pragma comments. """ - from __future__ import annotations import io @@ -14,11 +12,11 @@ import tokenize import typing as typ -import astroid from hypothesis import given, settings from hypothesis import strategies as st from pylint import testutils from pylint.utils import ASTWalker +import astroid from df12_python_lints._chains import narrowing_prefix, repeated_subject from df12_python_lints._dataclass_analysis import LayoutAnalyzer @@ -31,7 +29,6 @@ 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) @@ -45,6 +42,33 @@ ("order", "True"), ("unsafe_hash", "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 _walk_symbols(checker_class: type[BaseChecker], code: str) -> list[str]: @@ -198,6 +222,64 @@ def test_threshold_is_nesting_invariant(self, leaves: int, split: int) -> None: 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( From dd55e9ba09be528f1e30cd627667821ec47bebc4 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 13:51:41 +0200 Subject: [PATCH 4/8] Match Ruff directive syntax precisely Treat Ruff directives as case-sensitive and require file-level and range directives to occupy standalone comments. Avoid C9106 false positives for forms that Ruff itself does not recognize as suppressions. --- df12_python_lints/suppressions.py | 29 +++++++++++++++++------------ docs/developers-guide.md | 4 +++- docs/users-guide.md | 4 ++++ tests/test_suppressions.py | 22 ++++++++++++++++++++++ 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/df12_python_lints/suppressions.py b/df12_python_lints/suppressions.py index 1bdc285..fec23e0 100644 --- a/df12_python_lints/suppressions.py +++ b/df12_python_lints/suppressions.py @@ -51,13 +51,13 @@ } _LINT_DIRECTIVE: typ.Final = re.compile( - ( - r"\bnoqa\b" - r"|\bruff\s*:\s*(?:ignore|file-ignore|disable)\s*\[" - r"|\bpylint\s*:\s*disable" - ), + r"\bnoqa\b|\bpylint\s*:\s*disable", re.IGNORECASE, ) +_RUFF_INLINE_DIRECTIVE: typ.Final = re.compile(r"\bruff\s*:\s*ignore\s*\[") +_RUFF_STANDALONE_DIRECTIVE: typ.Final = re.compile( + r"\bruff\s*:\s*(?:file-ignore|disable)\s*\[" +) _TYPE_DIRECTIVE: typ.Final = re.compile( r"\btype\s*:\s*ignore\b|\b(?:pyright|ty)\s*:\s*ignore\b|\bmypy\s*:", @@ -79,8 +79,8 @@ rf"""^\s*(?: (?:(?:ruff|flake8)\s*:\s*)? noqa (?:\s*:\s*{_NOQA_CODE_LIST})? - | ruff\s*:\s*(?:ignore|file-ignore|disable|enable) - \s*\[\s*{_RUFF_RULE_LIST}\s*\] + | (?-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,\-]*\])? @@ -97,8 +97,8 @@ 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*. +def _directive_symbols(comment: _Comment) -> tuple[str, ...]: + """Return the message symbols for pragmas present in *comment*. Examples -------- @@ -106,9 +106,14 @@ def _directive_symbols(comment_text: str) -> tuple[str, ...]: plain comment maps to an empty tuple. """ 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) @@ -160,7 +165,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): diff --git a/docs/developers-guide.md b/docs/developers-guide.md index da0c68e..f19c3a1 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -94,7 +94,9 @@ Its Ruff grammar follows Ruff 0.16.0: - `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. + 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. diff --git a/docs/users-guide.md b/docs/users-guide.md index 031712b..01ddbef 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -137,6 +137,10 @@ Two checkers require every suppression pragma to record a reason: - `typecheck-suppression-without-explanation` (C9107) covers type-check pragmas: `type: ignore`, `pyright: ignore`, `ty: ignore`, and `mypy:`. +Ruff directive keywords are 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. diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py index 9eaac01..bfa25d7 100644 --- a/tests/test_suppressions.py +++ b/tests/test_suppressions.py @@ -144,6 +144,28 @@ 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)) + def test_ignores_ruff_enable_directive(self) -> None: """A directive ending a suppression range needs no reason.""" code = "# ruff: enable [E501]\n" From 58a6c1a1ad3694445ba32f41bd1facce2c31d165 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 13:55:59 +0200 Subject: [PATCH 5/8] Keep Ruff enable comments neutral Recognize range terminators even when trailing prose follows them. Prevent the entire comment from explaining a later suppression and add a regression that distinguishes neutral handling from ignoring `enable` altogether. --- df12_python_lints/suppressions.py | 3 +++ tests/test_suppressions.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/df12_python_lints/suppressions.py b/df12_python_lints/suppressions.py index fec23e0..0aa2440 100644 --- a/df12_python_lints/suppressions.py +++ b/df12_python_lints/suppressions.py @@ -58,6 +58,7 @@ _RUFF_STANDALONE_DIRECTIVE: typ.Final = re.compile( r"\bruff\s*:\s*(?:file-ignore|disable)\s*\[" ) +_RUFF_ENABLE_DIRECTIVE: typ.Final = re.compile(r"^\s*#\s*ruff\s*:\s*enable\s*\[") _TYPE_DIRECTIVE: typ.Final = re.compile( r"\btype\s*:\s*ignore\b|\b(?:pyright|ty)\s*:\s*ignore\b|\bmypy\s*:", @@ -208,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 + if _RUFF_ENABLE_DIRECTIVE.match(preceding.text): + return False return _has_inline_explanation(preceding.text) diff --git a/tests/test_suppressions.py b/tests/test_suppressions.py index bfa25d7..4ade00a 100644 --- a/tests/test_suppressions.py +++ b/tests/test_suppressions.py @@ -178,6 +178,14 @@ def test_ruff_enable_does_not_explain_next_suppression(self) -> None: 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" From 3e84d44b1521bdc5b2c7d05ec4d6d6233c58f8ff Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 17 Aug 2026 01:52:20 +0200 Subject: [PATCH 6/8] Clarify Ruff suppression documentation --- docs/developers-guide.md | 6 ++++-- docs/users-guide.md | 13 ++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f19c3a1..1641c88 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -89,8 +89,10 @@ tree to attach a check to. Its Ruff grammar follows Ruff 0.16.0: -- `noqa` and the file-level `ruff: noqa` and `flake8: noqa` aliases accept - blanket suppression or rule-code lists. +- 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 diff --git a/docs/users-guide.md b/docs/users-guide.md index 01ddbef..5bac9fe 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -132,14 +132,17 @@ and assignments inside functions are not flagged. Two checkers require every suppression pragma to record a reason: - `lint-suppression-without-explanation` (C9106) covers lint pragmas: - `noqa`, `ruff: noqa`, `ruff: ignore`, `ruff: file-ignore`, the paired range - directives `ruff: disable` and `ruff: enable`, 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:`. -Ruff directive keywords are 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. +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 From 29c363998d575c0a692508258d46796d57a5a9ab Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 17 Aug 2026 02:07:31 +0200 Subject: [PATCH 7/8] Validate complete Ruff suppression directives Require complete selector lists before classifying Ruff suppressions, and cover malformed, case-invalid, and misplaced forms. Move the generated suppression properties into their own module so the rebased suite remains within the project's file-size limit. --- df12_python_lints/suppressions.py | 24 ++--- tests/test_properties.py | 146 +------------------------ tests/test_suppression_properties.py | 153 +++++++++++++++++++++++++++ tests/test_suppressions.py | 26 ++++- 4 files changed, 193 insertions(+), 156 deletions(-) create mode 100644 tests/test_suppression_properties.py diff --git a/df12_python_lints/suppressions.py b/df12_python_lints/suppressions.py index 0aa2440..5b84712 100644 --- a/df12_python_lints/suppressions.py +++ b/df12_python_lints/suppressions.py @@ -54,11 +54,6 @@ r"\bnoqa\b|\bpylint\s*:\s*disable", re.IGNORECASE, ) -_RUFF_INLINE_DIRECTIVE: typ.Final = re.compile(r"\bruff\s*:\s*ignore\s*\[") -_RUFF_STANDALONE_DIRECTIVE: typ.Final = re.compile( - r"\bruff\s*:\s*(?:file-ignore|disable)\s*\[" -) -_RUFF_ENABLE_DIRECTIVE: typ.Final = re.compile(r"^\s*#\s*ruff\s*:\s*enable\s*\[") _TYPE_DIRECTIVE: typ.Final = re.compile( r"\btype\s*:\s*ignore\b|\b(?:pyright|ty)\s*:\s*ignore\b|\bmypy\s*:", @@ -75,6 +70,17 @@ ) _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*(?: @@ -99,13 +105,7 @@ class _Comment(typ.NamedTuple): def _directive_symbols(comment: _Comment) -> tuple[str, ...]: - """Return the message symbols for pragmas present in *comment*. - - Examples - -------- - ``"# ruff: ignore[S101]"`` maps to the lint suppression symbol; a - plain comment maps to an empty tuple. - """ + """Return message symbols for pragmas present in *comment*.""" symbols: list[str] = [] has_lint_directive = ( _LINT_DIRECTIVE.search(comment.text) diff --git a/tests/test_properties.py b/tests/test_properties.py index 3b79f85..18688df 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,22 +1,22 @@ """Hypothesis property tests for the checkers' decision kernels. + Where the example-based suites pin a handful of sizes and shapes, these properties cover the whole bounded space: chains of any length, guard runs of any length, arbitrarily nested literals, and generated pragma comments. """ + from __future__ import annotations -import io import math import operator -import tokenize import typing as typ +import astroid from hypothesis import given, settings from hypothesis import strategies as st from pylint import testutils from pylint.utils import ASTWalker -import astroid from df12_python_lints._chains import narrowing_prefix, repeated_subject from df12_python_lints._dataclass_analysis import LayoutAnalyzer @@ -24,7 +24,6 @@ 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: @@ -42,33 +41,6 @@ ("order", "True"), ("unsafe_hash", "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 _walk_symbols(checker_class: type[BaseChecker], code: str) -> list[str]: @@ -81,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"] @@ -219,109 +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(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" - ) - - 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 4ade00a..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]: @@ -166,6 +170,26 @@ def test_ignores_invalid_ruff_directives(self, code: str) -> None: 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" From e3ce864d5df3cea6b40114e066a1c985f557ee88 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 20 Aug 2026 19:25:09 +0200 Subject: [PATCH 8/8] Add Ruff suppression migration guide Document the v0.3.0 suppression-comment requirements so projects can update newly recognised Ruff and Flake8 pragmas without losing their rationale. Explain accepted syntax and the neutral `ruff: enable[...]` range terminator. --- docs/contents.md | 3 ++ docs/migration-0.3.0.md | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 docs/migration-0.3.0.md 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/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.