sarj-python-lint 0.35.0__tar.gz → 0.36.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/PKG-INFO +22 -7
  2. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/README.md +21 -6
  3. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_registry.py +4 -0
  5. sarj_python_lint-0.36.1/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +115 -0
  6. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +32 -9
  7. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_comment_cruft.py +38 -1
  8. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +67 -1
  9. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/.gitignore +0 -0
  10. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  24. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  25. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  26. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  28. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  29. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  30. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  31. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  32. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  33. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  34. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  35. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  36. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  37. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  38. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  39. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  40. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  41. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  42. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  43. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  44. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  45. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  46. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  47. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  48. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  49. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  50. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  51. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  52. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  53. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  54. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  55. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  56. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  57. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  58. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  59. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  60. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  61. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  62. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  63. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  64. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  65. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  66. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  67. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  68. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  69. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  70. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  71. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  72. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  73. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  74. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  75. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  76. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  77. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  78. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  79. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  80. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  81. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  82. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  83. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  84. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  85. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  86. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  87. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  88. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  89. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  90. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  91. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  92. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  93. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  94. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  95. {sarj_python_lint-0.35.0 → sarj_python_lint-0.36.1}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.35.0
3
+ Version: 0.36.1
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -137,12 +137,27 @@ SARJ050 flags none of them. The remedy deletes the section and keeps the
137
137
  summary, which was checked against the shipped strict config: ruff's D417 does
138
138
  not fire on a docstring with no parameter section.
139
139
 
140
- The sibling `Returns:` shape was measured and **rejected**: deleting a
141
- `Returns:` section makes DOC201 fire, so the only compliant remedy is deleting a
142
- docstring whose summary may be the valuable part. Two more were rejected on
143
- volume — property docstrings restating the property name and reST/epydoc type
144
- duplication (`:type x: int`, `:rtype:`) both measure **0** first-party findings
145
- outside generated code.
140
+ Two more shapes were rejected on volume — property docstrings restating the
141
+ property name and reST/epydoc type duplication (`:type x: int`, `:rtype:`) both
142
+ measure **0** first-party findings outside generated code.
143
+
144
+ ### The `Returns:` half (0.36.0)
145
+
146
+ ```yaml
147
+ - id: sarj-docstring-returns-restate-signature # SARJ087
148
+ ```
149
+
150
+ `SARJ087` is the `Returns:` sibling of SARJ086, and it was **rejected once**:
151
+ deleting a `Returns:` section used to make ruff's DOC201 fire, so the only
152
+ compliant remedy was deleting the whole docstring. #164 then removed DOC201 from
153
+ `ruff.strict.toml` as a rule that DEMANDS prose, and the premise expired — under
154
+ the shipped config the section goes and the summary stays.
155
+
156
+ 756 findings over 33 OSS repos / 35,254 files; two seeded samples of 40 and 20
157
+ read against source gave **~2%** false positives, the whole of which was one
158
+ family (`Returns: A new X` — whether the value is a copy is the one thing
159
+ `-> Self` cannot say) now guarded. Three findings on this repo's own source, all
160
+ true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ087.md).
146
161
 
147
162
  ### House conventions moved out of consumer repos (0.21.0)
148
163
 
@@ -119,12 +119,27 @@ SARJ050 flags none of them. The remedy deletes the section and keeps the
119
119
  summary, which was checked against the shipped strict config: ruff's D417 does
120
120
  not fire on a docstring with no parameter section.
121
121
 
122
- The sibling `Returns:` shape was measured and **rejected**: deleting a
123
- `Returns:` section makes DOC201 fire, so the only compliant remedy is deleting a
124
- docstring whose summary may be the valuable part. Two more were rejected on
125
- volume — property docstrings restating the property name and reST/epydoc type
126
- duplication (`:type x: int`, `:rtype:`) both measure **0** first-party findings
127
- outside generated code.
122
+ Two more shapes were rejected on volume — property docstrings restating the
123
+ property name and reST/epydoc type duplication (`:type x: int`, `:rtype:`) both
124
+ measure **0** first-party findings outside generated code.
125
+
126
+ ### The `Returns:` half (0.36.0)
127
+
128
+ ```yaml
129
+ - id: sarj-docstring-returns-restate-signature # SARJ087
130
+ ```
131
+
132
+ `SARJ087` is the `Returns:` sibling of SARJ086, and it was **rejected once**:
133
+ deleting a `Returns:` section used to make ruff's DOC201 fire, so the only
134
+ compliant remedy was deleting the whole docstring. #164 then removed DOC201 from
135
+ `ruff.strict.toml` as a rule that DEMANDS prose, and the premise expired — under
136
+ the shipped config the section goes and the summary stays.
137
+
138
+ 756 findings over 33 OSS repos / 35,254 files; two seeded samples of 40 and 20
139
+ read against source gave **~2%** false positives, the whole of which was one
140
+ family (`Returns: A new X` — whether the value is a copy is the one thing
141
+ `-> Self` cannot say) now guarded. Three findings on this repo's own source, all
142
+ true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ087.md).
128
143
 
129
144
  ### House conventions moved out of consumer repos (0.21.0)
130
145
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.35.0"
3
+ version = "0.36.1"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -6,6 +6,9 @@ from sarj_python_lint.rules.conditional_assertion_in_test import ConditionalAsse
6
6
  from sarj_python_lint.rules.docstring_args_restate_signature import (
7
7
  DocstringArgsRestateSignature,
8
8
  )
9
+ from sarj_python_lint.rules.docstring_returns_restate_signature import (
10
+ DocstringReturnsRestateSignature,
11
+ )
9
12
  from sarj_python_lint.rules.duplicate_test_body import DuplicateTestBody
10
13
  from sarj_python_lint.rules.duplicated_override_docstring import (
11
14
  DuplicatedOverrideDocstring,
@@ -184,6 +187,7 @@ REGISTRY: dict[str, type[Rule]] = {
184
187
  DuplicatedOverrideDocstring.id: DuplicatedOverrideDocstring,
185
188
  RedundantClassDocstring.id: RedundantClassDocstring,
186
189
  DocstringArgsRestateSignature.id: DocstringArgsRestateSignature,
190
+ DocstringReturnsRestateSignature.id: DocstringReturnsRestateSignature,
187
191
  }
188
192
 
189
193
  __all__ = ["REGISTRY"]
@@ -0,0 +1,115 @@
1
+ """SARJ087 — A `Returns:` block that only re-spells the name and the return annotation.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_docstring_returns_restate_signature.py
4
+ Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ087.md
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+ from typing import TYPE_CHECKING, override
12
+
13
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
14
+ from sarj_python_lint.rules._ast_index import children
15
+ from sarj_python_lint.rules._comments import is_protected
16
+ from sarj_python_lint.rules._docstrings import (
17
+ PROMPT_DECORATOR_MARKERS,
18
+ VALUE_MARKER_RE,
19
+ decorator_markers,
20
+ restates,
21
+ sections,
22
+ signature_stems,
23
+ )
24
+ from sarj_python_lint.rules._paths import is_generated
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from pathlib import Path
29
+
30
+
31
+ _RETURN_SECTIONS = ("Returns", "Return", "Yields", "Yield")
32
+
33
+ # Identity semantics: whether the value handed back is a FRESH object or the
34
+ # receiver itself is the one fact `-> Self` and `-> Foo` cannot carry, and the
35
+ # words that state it (`new`, `copy`, `same`) are stopwords for the restatement
36
+ # tokenizer, so the block reads as pure ceremony without this.
37
+ _IDENTITY_RE = re.compile(
38
+ r"\b(?:new|copy|copies|copied|clone[ds]?|fresh|same|itself|self|shallow|deep|"
39
+ r"in[- ]place|unchanged|original)\b",
40
+ re.IGNORECASE,
41
+ )
42
+
43
+
44
+ def _return_block(docstring: str) -> str | None:
45
+ found = sections(docstring)
46
+ for name in _RETURN_SECTIONS:
47
+ if name in found:
48
+ return found[name]
49
+ return None
50
+
51
+
52
+ class DocstringReturnsRestateSignature(Rule):
53
+ id: str = "docstring-returns-restate-signature"
54
+ code: str = "SARJ087"
55
+ has_evidence: bool = True
56
+ description: str = (
57
+ "`Returns:` block adds nothing the name and the return annotation do not "
58
+ "already say — delete the section and keep the summary."
59
+ )
60
+
61
+ @override
62
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
63
+ if is_generated(path, source):
64
+ return []
65
+ tree = parse_or_none(path, source)
66
+ if tree is None:
67
+ return []
68
+ diags: list[Diagnostic] = []
69
+ self._walk(tree, None, path, diags)
70
+ return sorted(diags, key=lambda diag: diag.line)
71
+
72
+ def _walk(self, node: ast.AST, class_name: str | None, path: Path, diags: list[Diagnostic]) -> None:
73
+ for child in children(node):
74
+ if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef):
75
+ self._check_function(child, class_name, path, diags)
76
+ self._walk(child, class_name, path, diags)
77
+ elif isinstance(child, ast.ClassDef):
78
+ self._walk(child, child.name, path, diags)
79
+ else:
80
+ self._walk(child, class_name, path, diags)
81
+
82
+ def _check_function(
83
+ self,
84
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
85
+ class_name: str | None,
86
+ path: Path,
87
+ diags: list[Diagnostic],
88
+ ) -> None:
89
+ docstring = ast.get_docstring(node, clean=True)
90
+ if not docstring:
91
+ return
92
+ block = _return_block(docstring)
93
+ if block is None:
94
+ return
95
+ if VALUE_MARKER_RE.search(block) or is_protected(block) or _IDENTITY_RE.search(block):
96
+ return
97
+ if decorator_markers(node) & PROMPT_DECORATOR_MARKERS:
98
+ return
99
+ stems = signature_stems(node, class_name)
100
+ # The whole-docstring case is SARJ050's; reporting it here too would
101
+ # make one deletion look like two findings.
102
+ if restates(docstring, stems):
103
+ return
104
+ if not restates(block, stems):
105
+ return
106
+ expr = node.body[0]
107
+ diags.append(
108
+ Diagnostic(
109
+ path=path,
110
+ line=expr.lineno,
111
+ col=expr.col_offset + 1,
112
+ code=self.code,
113
+ message=self.description,
114
+ )
115
+ )
@@ -19,6 +19,29 @@ if TYPE_CHECKING:
19
19
  from pathlib import Path
20
20
 
21
21
 
22
+ def _src(node: ast.expr) -> str:
23
+ """`ast.unparse` for the shapes this rule compares, without `ast.unparse`'s cost.
24
+
25
+ The rule identifies an accumulator by the SOURCE TEXT of its target, so it unparses
26
+ once per Name/Attribute in every loop body. `ast.unparse` builds and runs a full
27
+ unparser on each call. For a bare `Name` the answer is the identifier, and for a
28
+ dotted chain over one it is the chain — byte-identical, far cheaper. Everything
29
+ else falls through to `ast.unparse`, so no comparison changes meaning.
30
+
31
+ This is why the rule stopped being the registry's performance outlier; see
32
+ `tests/test_perf.py`.
33
+
34
+ Returns:
35
+ The node's source text.
36
+
37
+ """
38
+ if isinstance(node, ast.Name):
39
+ return node.id
40
+ if isinstance(node, ast.Attribute) and isinstance(node.value, (ast.Name, ast.Attribute)):
41
+ return f"{_src(node.value)}.{node.attr}"
42
+ return ast.unparse(node)
43
+
44
+
22
45
  class InefficientStringConcatInLoop(Rule):
23
46
  id: str = "inefficient-string-concat-in-loop"
24
47
  code: str = "SARJ002"
@@ -99,7 +122,7 @@ class _ConcatVisitor(ast.NodeVisitor):
99
122
 
100
123
  def _is_probe_target(self, node: ast.AugAssign | ast.Assign) -> bool:
101
124
  """Report whether the concat target's intermediate values are consumed."""
102
- target_src = ast.unparse(self._accumulation_target(node))
125
+ target_src = _src(self._accumulation_target(node))
103
126
  if any(target_src in names for names in self._while_probe_names):
104
127
  return True
105
128
  # Only the INNERMOST loop's reads consume the growth per iteration; a
@@ -109,7 +132,7 @@ class _ConcatVisitor(ast.NodeVisitor):
109
132
  def _is_loop_local_target(self, node: ast.AugAssign | ast.Assign) -> bool:
110
133
  """Report whether the concat target is freshly rebound earlier this iteration."""
111
134
  target = self._accumulation_target(node)
112
- rebinds = self._loop_reassigns[-1].get(ast.unparse(target), ())
135
+ rebinds = self._loop_reassigns[-1].get(_src(target), ())
113
136
  return any(line < node.lineno for line in rebinds)
114
137
 
115
138
  def _accumulation_target(self, node: ast.AugAssign | ast.Assign) -> ast.expr:
@@ -149,7 +172,7 @@ class _ConcatVisitor(ast.NodeVisitor):
149
172
 
150
173
  def _test_names(test: ast.expr) -> set[str]:
151
174
  """Collect the source text of every Name/Attribute read in a while test."""
152
- return {ast.unparse(n) for n in walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
175
+ return {_src(n) for n in walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
153
176
 
154
177
 
155
178
  def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]:
@@ -170,7 +193,7 @@ def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]
170
193
  continue
171
194
  stack.extend(children(node))
172
195
  if isinstance(node, (ast.Name, ast.Attribute)) and isinstance(node.ctx, ast.Load):
173
- reads.add(ast.unparse(node))
196
+ reads.add(_src(node))
174
197
  return frozenset(reads)
175
198
 
176
199
 
@@ -192,13 +215,13 @@ def _collect_reassignments(node: ast.AST, reassigns: dict[str, list[int]]) -> No
192
215
  for target in node.targets:
193
216
  for bound in _iter_binding_targets(target):
194
217
  if not _is_accumulation_assign(bound, node.value):
195
- reassigns.setdefault(ast.unparse(bound), []).append(bound.lineno)
218
+ reassigns.setdefault(_src(bound), []).append(bound.lineno)
196
219
  elif (
197
220
  isinstance(node, ast.AnnAssign)
198
221
  and node.value is not None
199
222
  and not _is_accumulation_assign(node.target, node.value)
200
223
  ):
201
- reassigns.setdefault(ast.unparse(node.target), []).append(node.target.lineno)
224
+ reassigns.setdefault(_src(node.target), []).append(node.target.lineno)
202
225
  for child in children(node):
203
226
  _collect_reassignments(child, reassigns)
204
227
 
@@ -222,10 +245,10 @@ def _is_accumulation_assign(target: ast.expr, value: ast.expr) -> bool:
222
245
 
223
246
  def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
224
247
  """Return the non-target operand of `target + x` / `x + target`."""
225
- target_src = ast.unparse(target)
226
- if ast.unparse(binop.left) == target_src:
248
+ target_src = _src(target)
249
+ if _src(binop.left) == target_src:
227
250
  return binop.right
228
- if ast.unparse(binop.right) == target_src:
251
+ if _src(binop.right) == target_src:
229
252
  return binop.left
230
253
  return None
231
254
 
@@ -56,6 +56,22 @@ _LICENSE_RE = re.compile(
56
56
  re.IGNORECASE,
57
57
  )
58
58
 
59
+ # A licence header is a legally required block of text a contributor cannot
60
+ # restructure, and the `# ---------` rules that box it in are part of it. The
61
+ # rule already knows this -- `_flag_leading_preamble` returns early on
62
+ # `_LICENSE_RE` -- but the BANNER branch did not, so "structure code with
63
+ # functions, not ASCII rules" was reported against the one comment in the file
64
+ # that is not about structure at all. 831 findings across 590 corpus files sat
65
+ # on a punctuation rule in the first 8 lines of a file whose header carries a
66
+ # copyright or SPDX line: `bokeh/docs/bokeh/docserver.py:1`,
67
+ # `bokeh/release/checks.py:1`, `bokeh/release/__main__.py:1` are the shape --
68
+ # `# ---…---` immediately above `# Copyright (c) Anaconda, Inc.`.
69
+ #
70
+ # Scoped to the file HEADER, not to every copyright mention: a banner beside a
71
+ # licence reference in the middle of a file is an ordinary section rule.
72
+ _LICENSE_HEADER_MAX_LINE = 8
73
+ _LICENSE_HEADER_RADIUS = 4
74
+
59
75
  _BANNER_FULL_RE = re.compile(r"^[-=#*~_+.\s]{4,}$")
60
76
  # `[\u2500-\u257f]` is the Unicode box-drawing block. A `────────` rule is the
61
77
  # same section separator as `--------`, just prettier; 34 of them were sitting in
@@ -441,6 +457,24 @@ def _is_assign_or_call(snippet: str) -> bool:
441
457
  return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call)
442
458
 
443
459
 
460
+ def _license_header_lines(standalone: list[tuple[int, int, str]]) -> frozenset[int]:
461
+ """Collect the lines belonging to a file-header licence block.
462
+
463
+ Returns:
464
+ Every line within `_LICENSE_HEADER_RADIUS` of a licence comment that
465
+ sits in the first `_LICENSE_HEADER_MAX_LINE` lines of the file.
466
+
467
+ """
468
+ anchors = [
469
+ line for line, _, body in standalone if line <= _LICENSE_HEADER_MAX_LINE and _LICENSE_RE.search(body)
470
+ ]
471
+ return frozenset(
472
+ line
473
+ for anchor in anchors
474
+ for line in range(anchor - _LICENSE_HEADER_RADIUS, anchor + _LICENSE_HEADER_RADIUS + 1)
475
+ )
476
+
477
+
444
478
  class NoCommentCruft(Rule):
445
479
  id: str = "no-comment-cruft"
446
480
  code: str = "SARJ016"
@@ -468,6 +502,7 @@ class NoCommentCruft(Rule):
468
502
  referenced = _externally_referenced_lines(standalone)
469
503
  nested = nested_comment_lines(source)
470
504
  enumerated = [line for line, _, body in standalone if _ENUMERATION_RE.match(body)]
505
+ license_header = _license_header_lines(standalone)
471
506
  for line, col, body in standalone:
472
507
  if _is_directive(body) or _is_coding_cookie(body) or line in skip:
473
508
  continue
@@ -478,6 +513,7 @@ class NoCommentCruft(Rule):
478
513
  narration_protected=line in referenced,
479
514
  isolated_enumeration=enumerated == [line],
480
515
  nested=line in nested,
516
+ in_license_header=line in license_header,
481
517
  )
482
518
  if msg is not None:
483
519
  diags[line] = Diagnostic(path=path, line=line, col=col + 1, code=self.code, message=msg)
@@ -492,6 +528,7 @@ class NoCommentCruft(Rule):
492
528
  narration_protected: bool,
493
529
  isolated_enumeration: bool,
494
530
  nested: bool,
531
+ in_license_header: bool,
495
532
  ) -> str | None:
496
533
  if _CODE_REGEN_CALL_RE.match(body):
497
534
  return None
@@ -500,7 +537,7 @@ class NoCommentCruft(Rule):
500
537
  return "Untracked TODO/FIXME marker — add an issue ticket or context link."
501
538
  return None
502
539
  if _is_banner(body):
503
- if _is_heading_underline(body, prev_body):
540
+ if _is_heading_underline(body, prev_body) or in_license_header:
504
541
  return None
505
542
  return "Section-banner / region comment — structure code with functions, not ASCII rules."
506
543
  if _looks_like_code(body):
@@ -28,6 +28,13 @@ _EXCLUDED_BASES = {
28
28
  "config",
29
29
  "kwargs",
30
30
  "env",
31
+ # A deliberately OPEN extension bag: the framework guarantees the mapping
32
+ # exists and guarantees nothing about its keys, because third-party code is
33
+ # what puts them there. `scrapy/core/downloader/handlers/http11.py:531`
34
+ # (`request.meta.get("download_maxsize", self._maxsize)`) is the shape --
35
+ # Scrapy documents `Request.meta` as the per-request extension dict, and no
36
+ # model can enumerate keys that downstream middlewares invent.
37
+ "meta",
31
38
  "os",
32
39
  "sys",
33
40
  }
@@ -114,6 +121,7 @@ class _FileFacts:
114
121
  """The whole-file context a single subscript cannot answer for itself."""
115
122
 
116
123
  annotation_nodes: frozenset[int]
124
+ decorator_nodes: frozenset[int]
117
125
  mutation_receivers: frozenset[int]
118
126
  schema_bound_names: frozenset[str]
119
127
  constant_tables: frozenset[str]
@@ -242,6 +250,8 @@ def _is_exempt(node: ast.Call | ast.Subscript, key: str, facts: _FileFacts) -> b
242
250
  return True
243
251
  if id(node) in facts.annotation_nodes:
244
252
  return True
253
+ if id(node) in facts.decorator_nodes:
254
+ return True
245
255
  if isinstance(node, ast.Subscript) and id(node) in facts.mutation_receivers:
246
256
  return True
247
257
  receiver = _receiver(node)
@@ -278,9 +288,10 @@ def _file_facts(tree: ast.Module) -> _FileFacts:
278
288
  The four indexes, each built from the memoized per-file node index.
279
289
 
280
290
  """
281
- typed_dicts = _typed_dict_class_names(tree)
291
+ typed_dicts = _typed_dict_class_names(tree) | _declared_type_names(tree)
282
292
  return _FileFacts(
283
293
  annotation_nodes=_annotation_nodes(tree),
294
+ decorator_nodes=_decorator_nodes(tree),
284
295
  mutation_receivers=_mutation_receivers(tree),
285
296
  schema_bound_names=_schema_bound_names(tree, typed_dicts) if typed_dicts else frozenset(),
286
297
  constant_tables=_constant_tables(tree),
@@ -308,6 +319,27 @@ def _annotation_nodes(tree: ast.Module) -> frozenset[int]:
308
319
  return frozenset(id(inner) for root in roots for inner in walk(root))
309
320
 
310
321
 
322
+ def _decorator_nodes(tree: ast.Module) -> frozenset[int]:
323
+ """Collect the identity of every node inside a decorator expression.
324
+
325
+ A decorator is never a mapping lookup, so `@router.get("")` is not one --
326
+ but `_looks_like_route_or_url` only recognises a value starting with `/` or
327
+ containing `://`, and the EMPTY-STRING route (the router-root registration
328
+ FastAPI projects write) slips through both. 23 findings across airflow,
329
+ litellm and prefect were exactly `@<router>.get("")` on the decorator line.
330
+ Position answers it exactly where the argument cannot, and costs no recall:
331
+ nothing in `decorator_list` is a payload field read.
332
+
333
+ Returns:
334
+ `id()` of each node in a decorator subtree.
335
+
336
+ """
337
+ roots: list[ast.expr] = []
338
+ for node in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef):
339
+ roots.extend(node.decorator_list)
340
+ return frozenset(id(inner) for root in roots for inner in walk(root))
341
+
342
+
311
343
  def _mutation_receivers(tree: ast.Module) -> frozenset[int]:
312
344
  """Collect subscripts that are the receiver of an in-place collection method.
313
345
 
@@ -403,6 +435,40 @@ def _parse_type_text(text: str) -> ast.expr | None:
403
435
  return None
404
436
 
405
437
 
438
+ # Modules whose exports are containers and escape hatches rather than schemas.
439
+ # `from typing import Any` followed by `payload: Any` declares nothing.
440
+ _STRUCTURELESS_IMPORT_MODULES = frozenset(
441
+ {"builtins", "collections", "collections.abc", "typing", "typing_extensions"}
442
+ )
443
+
444
+
445
+ def _declared_type_names(tree: ast.Module) -> frozenset[str]:
446
+ """Collect names imported into this module that could name a declared shape.
447
+
448
+ A receiver annotated with a type this file IMPORTS has already been given a
449
+ schema by its author -- most often a TypedDict, which subscripts by design:
450
+ `AllMessageValues` in `litellm/…/prompt_templates/factory.py` is OpenAI's
451
+ message TypedDict, and `current_message["role"]` is the DECLARATIVE form,
452
+ not a substitute for one. `_typed_dict_class_names` sees only `class X
453
+ (TypedDict)` written in the same file, so every cross-module TypedDict --
454
+ which is nearly all of them -- was invisible.
455
+
456
+ This cannot resolve the import to check what it really is, so it is
457
+ deliberately generous: `typing`/`collections` exports are excluded because
458
+ `Any` and `dict` declare nothing, and everything else is taken at its word.
459
+
460
+ Returns:
461
+ The imported names usable as an annotation head.
462
+
463
+ """
464
+ declared: set[str] = set()
465
+ for node in nodes(tree, ast.Import, ast.ImportFrom):
466
+ if isinstance(node, ast.ImportFrom) and node.module in _STRUCTURELESS_IMPORT_MODULES:
467
+ continue
468
+ declared.update(alias.asname or alias.name.split(".")[0] for alias in node.names)
469
+ return frozenset(name for name in declared if name[:1].isupper())
470
+
471
+
406
472
  def _schema_bound_names(tree: ast.Module, typed_dicts: frozenset[str]) -> frozenset[str]:
407
473
  """Collect names whose declared type is one of this file's TypedDicts.
408
474