sarj-python-lint 0.38.0__tar.gz → 0.40.0__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 (96) hide show
  1. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/PKG-INFO +47 -1
  2. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/README.md +46 -0
  3. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_docstrings.py +45 -1
  5. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  6. sarj_python_lint-0.40.0/src/sarj_python_lint/rules/phase_label_comment.py +57 -0
  7. sarj_python_lint-0.40.0/src/sarj_python_lint/rules/restated_test_docstring.py +175 -0
  8. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/zero_assertion_test.py +53 -1
  9. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/.gitignore +0 -0
  10. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  22. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  23. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  24. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  25. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  26. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  27. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  28. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  29. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  30. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  31. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  32. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  33. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  34. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  35. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  36. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  37. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  38. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  39. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  40. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  41. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  42. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  43. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  44. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  45. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  46. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  47. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  48. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  49. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  50. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  51. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  52. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  53. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  54. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  55. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  56. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  57. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  58. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  59. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  60. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  61. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  62. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  63. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  64. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  65. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  66. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  67. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  68. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  69. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  70. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  71. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  72. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  73. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  74. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  75. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  76. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  77. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  78. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  79. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  80. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  81. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  82. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  83. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  84. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  85. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  86. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  87. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  88. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  89. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  90. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  91. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  92. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  93. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  94. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  95. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  96. {sarj_python_lint-0.38.0 → sarj_python_lint-0.40.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.38.0
3
+ Version: 0.40.0
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
@@ -159,6 +159,52 @@ family (`Returns: A new X` — whether the value is a copy is the one thing
159
159
  `-> Self` cannot say) now guarded. Three findings on this repo's own source, all
160
160
  true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ087.md).
161
161
 
162
+ ### Test ceremony, and the census it was chosen from (0.38.0)
163
+
164
+ ```yaml
165
+ - id: sarj-restated-test-docstring # SARJ088
166
+ - id: sarj-test-phase-label-comment # SARJ089
167
+ ```
168
+
169
+ Every comment GROUP in 19 repositories / 45,900 Python files was collected with
170
+ its adjacent code and classified: **451,482 groups, 1,293,022 lines**, of which
171
+ the seven comment/docstring rules that predate this release reached **4.9%**.
172
+ The full census table is in [docs/rules/SARJ088.md](../../docs/rules/SARJ088.md).
173
+
174
+ The largest precisely-detectable class left in it is the **test docstring**:
175
+ 52,894 of them, 10.1% of every comment group, and SARJ050 reached 4.7%. It
176
+ reached so few because SARJ050 measures a docstring against its *signature*, and
177
+ a test's specification is its BODY. `SARJ088` measures it against the signature,
178
+ the identifiers in the test's own body, and the vocabulary a test docstring
179
+ spends on being a test. **5,382 findings; 98 read at source, 0 false positives
180
+ on the shipped predicate.**
181
+
182
+ `SARJ089` deletes the bare `# given` / `# when` / `# then` / `# Arrange` /
183
+ `# Act` / `# Assert` phase label. 27,714 findings, 36 read, 0 false positives —
184
+ but **94.5% come from one OSS suite**, and none from any first-party repo. It is
185
+ a fence against the convention arriving, not a cleanup; adopt it behind the
186
+ baseline ratchet.
187
+
188
+ Shipped with them, `_docstrings.STOPWORDS` gained `FILLER_QUALIFIERS`: 31
189
+ qualifiers that narrow nothing (`specific`, `appropriate`, `entire`, `overall`).
190
+ One of these was the commonest single reason a pure restatement survived the
191
+ whole family — `"""Get a specific account by ID."""` over
192
+ `get_account(self, account_id: str)`. **+683 findings across SARJ050/085/086/087,
193
+ -0; 58 of the delta read, ~3.4% false positives.** `main` was tried and rejected:
194
+ as filler it makes `"""Main function."""` content-free, hence unflaggable.
195
+
196
+ **Three shapes measured on the same census and rejected**, each on a seeded
197
+ 12-finding read at source:
198
+
199
+ | shape | findings | true | why it fails |
200
+ | --- | ---: | ---: | --- |
201
+ | comment restates the `if` / `for` / `with` header below it | 225 | 3/12 | the population is BRANCH LABELS naming a case (`# PIL.Image` over `if isinstance(item, PILImage.Image)`), not narration |
202
+ | comment restates a plain assignment (no call on the RHS) | 308 | 2/12 | the population heads a multi-line literal or a 3-statement block — a section label, which is SARJ016's subject |
203
+ | multi-line comment run restating the block it heads | 119 | 1-2/12 | banners, Sphinx `#:` attribute docs, and worked calculations dominate |
204
+
205
+ Those three are why SARJ049 still excludes block openers, plain assignments and
206
+ multi-line runs. The exclusions are load-bearing, not unfinished work.
207
+
162
208
  ### House conventions moved out of consumer repos (0.21.0)
163
209
 
164
210
  ```yaml
@@ -141,6 +141,52 @@ family (`Returns: A new X` — whether the value is a copy is the one thing
141
141
  `-> Self` cannot say) now guarded. Three findings on this repo's own source, all
142
142
  true, all deleted. Measurements: [docs/rules/SARJ087.md](../../docs/rules/SARJ087.md).
143
143
 
144
+ ### Test ceremony, and the census it was chosen from (0.38.0)
145
+
146
+ ```yaml
147
+ - id: sarj-restated-test-docstring # SARJ088
148
+ - id: sarj-test-phase-label-comment # SARJ089
149
+ ```
150
+
151
+ Every comment GROUP in 19 repositories / 45,900 Python files was collected with
152
+ its adjacent code and classified: **451,482 groups, 1,293,022 lines**, of which
153
+ the seven comment/docstring rules that predate this release reached **4.9%**.
154
+ The full census table is in [docs/rules/SARJ088.md](../../docs/rules/SARJ088.md).
155
+
156
+ The largest precisely-detectable class left in it is the **test docstring**:
157
+ 52,894 of them, 10.1% of every comment group, and SARJ050 reached 4.7%. It
158
+ reached so few because SARJ050 measures a docstring against its *signature*, and
159
+ a test's specification is its BODY. `SARJ088` measures it against the signature,
160
+ the identifiers in the test's own body, and the vocabulary a test docstring
161
+ spends on being a test. **5,382 findings; 98 read at source, 0 false positives
162
+ on the shipped predicate.**
163
+
164
+ `SARJ089` deletes the bare `# given` / `# when` / `# then` / `# Arrange` /
165
+ `# Act` / `# Assert` phase label. 27,714 findings, 36 read, 0 false positives —
166
+ but **94.5% come from one OSS suite**, and none from any first-party repo. It is
167
+ a fence against the convention arriving, not a cleanup; adopt it behind the
168
+ baseline ratchet.
169
+
170
+ Shipped with them, `_docstrings.STOPWORDS` gained `FILLER_QUALIFIERS`: 31
171
+ qualifiers that narrow nothing (`specific`, `appropriate`, `entire`, `overall`).
172
+ One of these was the commonest single reason a pure restatement survived the
173
+ whole family — `"""Get a specific account by ID."""` over
174
+ `get_account(self, account_id: str)`. **+683 findings across SARJ050/085/086/087,
175
+ -0; 58 of the delta read, ~3.4% false positives.** `main` was tried and rejected:
176
+ as filler it makes `"""Main function."""` content-free, hence unflaggable.
177
+
178
+ **Three shapes measured on the same census and rejected**, each on a seeded
179
+ 12-finding read at source:
180
+
181
+ | shape | findings | true | why it fails |
182
+ | --- | ---: | ---: | --- |
183
+ | comment restates the `if` / `for` / `with` header below it | 225 | 3/12 | the population is BRANCH LABELS naming a case (`# PIL.Image` over `if isinstance(item, PILImage.Image)`), not narration |
184
+ | comment restates a plain assignment (no call on the RHS) | 308 | 2/12 | the population heads a multi-line literal or a 3-statement block — a section label, which is SARJ016's subject |
185
+ | multi-line comment run restating the block it heads | 119 | 1-2/12 | banners, Sphinx `#:` attribute docs, and worked calculations dominate |
186
+
187
+ Those three are why SARJ049 still excludes block openers, plain assignments and
188
+ multi-line runs. The exclusions are load-bearing, not unfinished work.
189
+
144
190
  ### House conventions moved out of consumer repos (0.21.0)
145
191
 
146
192
  ```yaml
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.38.0"
3
+ version = "0.40.0"
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" }]
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
38
38
  # Docstring filler that says nothing about *which* thing is being described.
39
39
  # `not` / `no` / `none` / `never` are deliberately ABSENT: a docstring that
40
40
  # negates the obvious reading of a name is the most useful kind there is.
41
- STOPWORDS = frozenset(
41
+ _BASE_STOPWORDS = frozenset(
42
42
  {
43
43
  "a",
44
44
  "all",
@@ -107,6 +107,50 @@ STOPWORDS = frozenset(
107
107
  }
108
108
  )
109
109
 
110
+ # Qualifiers that NARROW NOTHING. "a specific account", "the appropriate
111
+ # config", "the entire widget" — strip the adjective and the sentence means
112
+ # exactly what it meant. Kept as its own name because a single one of these
113
+ # was the commonest reason a pure restatement survived the whole family; see
114
+ # docs/rules/SARJ088.md for the measurement. `main`, `new`, `same` and `copy`
115
+ # are NOT here: they name the thing or its identity, which is content.
116
+ FILLER_QUALIFIERS = frozenset(
117
+ {
118
+ "actual",
119
+ "already",
120
+ "appropriate",
121
+ "associated",
122
+ "available",
123
+ "basic",
124
+ "correct",
125
+ "correctly",
126
+ "corresponding",
127
+ "default",
128
+ "desired",
129
+ "entire",
130
+ "existing",
131
+ "full",
132
+ "general",
133
+ "necessary",
134
+ "optional",
135
+ "overall",
136
+ "particular",
137
+ "properly",
138
+ "relevant",
139
+ "required",
140
+ "simple",
141
+ "single",
142
+ "specific",
143
+ "standard",
144
+ "successfully",
145
+ "suitable",
146
+ "supported",
147
+ "various",
148
+ "whole",
149
+ }
150
+ )
151
+
152
+ STOPWORDS = _BASE_STOPWORDS | FILLER_QUALIFIERS
153
+
110
154
  WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9']*")
111
155
 
112
156
  _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@@ -57,6 +57,7 @@ from sarj_python_lint.rules.no_unreachable_after_terminal import (
57
57
  )
58
58
  from sarj_python_lint.rules.over_mocked_test import OverMockedTest
59
59
  from sarj_python_lint.rules.parametrize_case_needs_id import ParametrizeCaseNeedsId
60
+ from sarj_python_lint.rules.phase_label_comment import TestPhaseLabelComment
60
61
  from sarj_python_lint.rules.prefer_class_row import PreferClassRow
61
62
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
62
63
  PreferConstantTimeSecretCompare,
@@ -94,6 +95,7 @@ from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
94
95
  from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
95
96
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
96
97
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
98
+ from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
97
99
  from sarj_python_lint.rules.single_public_export import SinglePublicExport
98
100
  from sarj_python_lint.rules.sleep_with_computed_arg_in_test import SleepWithComputedArgInTest
99
101
  from sarj_python_lint.rules.stepdown import Stepdown
@@ -186,6 +188,8 @@ REGISTRY: dict[str, type[Rule]] = {
186
188
  RedundantClassDocstring.id: RedundantClassDocstring,
187
189
  DocstringArgsRestateSignature.id: DocstringArgsRestateSignature,
188
190
  DocstringReturnsRestateSignature.id: DocstringReturnsRestateSignature,
191
+ RestatedTestDocstring.id: RestatedTestDocstring,
192
+ TestPhaseLabelComment.id: TestPhaseLabelComment,
189
193
  }
190
194
 
191
195
  __all__ = ["REGISTRY"]
@@ -0,0 +1,57 @@
1
+ """SARJ089 — A bare Arrange/Act/Assert (or Given/When/Then) phase label in a test.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_phase_label_comment.py
4
+ Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ089.md
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import tokenize
11
+ from typing import TYPE_CHECKING, override
12
+
13
+ from sarj_python_lint.rule_base import Diagnostic, Rule
14
+ from sarj_python_lint.rules._comments import nested_comment_lines, standalone_comments
15
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path
20
+
21
+
22
+ # The phase vocabulary, as a whole-body match. `setup` and `teardown` are
23
+ # deliberately ABSENT: SARJ016 already owns them through its section-label
24
+ # vocabulary, and reporting one deletion twice makes the finding look bigger
25
+ # than it is.
26
+ _PHASE_RE = re.compile(
27
+ r"^(?:arrange|act|assert(?:ion)?s?|given|when|then|exercise|execute|verif(?:y|ication)|"
28
+ r"cleanup|prepare|sanity(?:\s+check)?|arrange\s*[/&+]\s*act|act\s*[/&+]\s*assert|"
29
+ r"given\s*[/&+]\s*when|when\s*[/&+]\s*then)"
30
+ r"\s*[.:;!\-–—]*\s*$",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+
35
+ class TestPhaseLabelComment(Rule):
36
+ id: str = "test-phase-label-comment"
37
+ code: str = "SARJ089"
38
+ has_evidence: bool = True
39
+ description: str = (
40
+ "Bare test-phase label — delete it; a test whose phases need signposting "
41
+ "wants a named helper or a smaller test, not a comment."
42
+ )
43
+
44
+ @override
45
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
46
+ if is_generated(path, source) or not is_test_path(path):
47
+ return []
48
+ try:
49
+ standalone, _ = standalone_comments(source)
50
+ nested = nested_comment_lines(source)
51
+ except tokenize.TokenError, IndentationError, SyntaxError:
52
+ return []
53
+ return [
54
+ Diagnostic(path=path, line=line, col=col + 1, code=self.code, message=self.description)
55
+ for line, col, body in standalone
56
+ if line not in nested and _PHASE_RE.match(body)
57
+ ]
@@ -0,0 +1,175 @@
1
+ """SARJ088 — A test docstring that only re-spells the test's own name and body.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_restated_test_docstring.py
4
+ Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ088.md
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from typing import TYPE_CHECKING, override
11
+
12
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
13
+ from sarj_python_lint.rules._ast_index import children
14
+ from sarj_python_lint.rules._comments import is_protected, split_identifier, stem
15
+ from sarj_python_lint.rules._docstrings import (
16
+ VALUE_MARKER_RE,
17
+ restates,
18
+ sections,
19
+ signature_stems,
20
+ )
21
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from pathlib import Path
26
+
27
+
28
+ # The vocabulary a test docstring spends on *being a test*. Folded into the
29
+ # known-stem set rather than into `_docstrings.STOPWORDS`, so it widens this
30
+ # rule alone and cannot silently move SARJ050/085/086/087.
31
+ _TEST_CEREMONY = (
32
+ "assert", "asserted", "asserts", "behavior", "behaviour", "case", "cases", "check", "checked",
33
+ "checking", "checks", "confirm", "confirmed", "confirms", "correctly", "coverage", "covered", "covers",
34
+ "ensure", "ensured", "ensures", "ensuring", "exercise", "exercises", "expect", "expected", "expects",
35
+ "happy", "integration", "path", "properly", "regression", "scenario", "scenarios", "successful",
36
+ "successfully", "test", "tested", "testing", "tests", "unit", "validate", "validated", "validates",
37
+ "verified", "verifies", "verify", "verifying"
38
+ )
39
+
40
+ _CEREMONY_STEMS = frozenset(stem(word) for word in _TEST_CEREMONY)
41
+
42
+ # Sections other than the summary are SARJ086/087's subject. A test docstring
43
+ # carrying one is a different artefact and is left whole.
44
+ _SUMMARY_ONLY = frozenset({"summary"})
45
+
46
+
47
+ # The keyword singletons. `assert x is None` puts the word "None" on the screen
48
+ # as surely as an identifier does, and `... is None` is the single most common
49
+ # thing a test docstring re-spells.
50
+ _SINGLETONS: dict[object, str] = {None: "none", True: "true", False: "false"}
51
+
52
+
53
+ def _body_stems(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
54
+ """Collect the stemmed word parts of every IDENTIFIER in the test body.
55
+
56
+ Identifiers and keyword singletons only — never string literals. A test body
57
+ is full of prose in strings ("user not found"), and letting those count as
58
+ "the code already says it" is what would turn an explanatory docstring into
59
+ a finding.
60
+
61
+ """
62
+ tokens: list[str] = []
63
+ for child in ast.walk(node):
64
+ match child:
65
+ case ast.Name():
66
+ tokens.extend(split_identifier(child.id))
67
+ case ast.Attribute():
68
+ tokens.extend(split_identifier(child.attr))
69
+ case ast.keyword() if child.arg is not None:
70
+ tokens.extend(split_identifier(child.arg))
71
+ case ast.arg():
72
+ tokens.extend(split_identifier(child.arg))
73
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
74
+ tokens.extend(split_identifier(child.name))
75
+ case ast.Constant():
76
+ word = next((w for key, w in _SINGLETONS.items() if child.value is key), None)
77
+ if word is not None:
78
+ tokens.append(word)
79
+ case _:
80
+ continue
81
+ return {stem(token) for token in tokens}
82
+
83
+
84
+ def _is_test(node: ast.FunctionDef | ast.AsyncFunctionDef, class_name: str | None) -> bool:
85
+ if node.name.startswith("test_") or node.name == "test":
86
+ return True
87
+ return class_name is not None and class_name.startswith("Test") and node.name.startswith("test")
88
+
89
+
90
+ class RestatedTestDocstring(Rule):
91
+ id: str = "restated-test-docstring"
92
+ code: str = "SARJ088"
93
+ has_evidence: bool = True
94
+ description: str = (
95
+ "Test docstring only re-spells the test's own name and body — delete it; "
96
+ "rename the test if the name does not already say it."
97
+ )
98
+
99
+ @override
100
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
101
+ if is_generated(path, source) or not is_test_path(path):
102
+ return []
103
+ tree = parse_or_none(path, source)
104
+ if tree is None:
105
+ return []
106
+ diags: list[Diagnostic] = []
107
+ self._walk(tree, None, path, diags)
108
+ return sorted(diags, key=lambda diag: diag.line)
109
+
110
+ def _walk(self, node: ast.AST, class_name: str | None, path: Path, diags: list[Diagnostic]) -> None:
111
+ for child in children(node):
112
+ if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef):
113
+ self._check_function(child, class_name, path, diags)
114
+ elif isinstance(child, ast.ClassDef):
115
+ self._check_class(child, path, diags)
116
+ self._walk(child, child.name, path, diags)
117
+ else:
118
+ self._walk(child, class_name, path, diags)
119
+
120
+ def _check_class(self, node: ast.ClassDef, path: Path, diags: list[Diagnostic]) -> None:
121
+ """Flag a `Test*` class whose docstring only re-spells its name and method names."""
122
+ if not node.name.startswith("Test") or node.bases or node.keywords:
123
+ return
124
+ docstring = ast.get_docstring(node, clean=True)
125
+ if not docstring or not self._is_plain_summary(docstring):
126
+ return
127
+ known = {stem(part) for part in split_identifier(node.name)} | _CEREMONY_STEMS
128
+ for child in node.body:
129
+ if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef):
130
+ known |= signature_stems(child, node.name)
131
+ if not restates(docstring, known):
132
+ return
133
+ expr = node.body[0]
134
+ diags.append(
135
+ Diagnostic(
136
+ path=path,
137
+ line=expr.lineno,
138
+ col=expr.col_offset + 1,
139
+ code=self.code,
140
+ message=self.description,
141
+ )
142
+ )
143
+
144
+ @staticmethod
145
+ def _is_plain_summary(docstring: str) -> bool:
146
+ """Report whether the docstring is a bare summary with nothing protected in it."""
147
+ if frozenset(sections(docstring)) != _SUMMARY_ONLY:
148
+ return False
149
+ return not is_protected(docstring) and not VALUE_MARKER_RE.search(docstring)
150
+
151
+ def _check_function(
152
+ self,
153
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
154
+ class_name: str | None,
155
+ path: Path,
156
+ diags: list[Diagnostic],
157
+ ) -> None:
158
+ if not _is_test(node, class_name):
159
+ return
160
+ docstring = ast.get_docstring(node, clean=True)
161
+ if not docstring or not self._is_plain_summary(docstring):
162
+ return
163
+ known = signature_stems(node, class_name) | _body_stems(node) | _CEREMONY_STEMS
164
+ if not restates(docstring, known):
165
+ return
166
+ expr = node.body[0]
167
+ diags.append(
168
+ Diagnostic(
169
+ path=path,
170
+ line=expr.lineno,
171
+ col=expr.col_offset + 1,
172
+ code=self.code,
173
+ message=self.description,
174
+ )
175
+ )
@@ -32,6 +32,56 @@ _RAISES_TOKEN_RE = re.compile(r"(^|_)(raises|warns|deprecated_call)", re.IGNOREC
32
32
 
33
33
  _RAISES_NAMES = frozenset({"raises", "warns", "fail"})
34
34
 
35
+ # Library assertion helpers whose names contain none of the four tokens above.
36
+ # The name heuristic is a good default and it has a blind spot: a widely used
37
+ # assertion API is free to spell itself without the word "assert".
38
+ #
39
+ # MEASURED, 2026-07-31, over 39,893 content-deduplicated `.py` files: 4,386 of
40
+ # this rule's 7,352 findings — 59.7% — were on test functions whose body calls
41
+ # one of these, i.e. tests that assert perfectly well through a helper this rule
42
+ # could not see. Three read at source (`test_run_without_stepwise`,
43
+ # `test_repr_params_unknown_list`, `test_terminal_report_failedfirst`) are
44
+ # assertion-complete; the flag was wrong in each.
45
+ #
46
+ # Two vocabularies, both exact names rather than a pattern, because the shapes
47
+ # are too generic to match loosely:
48
+ #
49
+ # * pytest's own `LineMatcher` (`_pytest.pytester`). `result.stdout
50
+ # .fnmatch_lines([...])` RAISES `Failed` on a mismatch — it is the canonical
51
+ # way to assert on CLI output, and any repo that tests a console script with
52
+ # the `pytester` fixture uses it. 438 findings.
53
+ # * `sqlalchemy.testing.assertions`, whose trailing underscore exists to dodge
54
+ # Python keywords (`is_`, `in_`). 3,948 findings.
55
+ #
56
+ # Kept as a closed set: a wildcard for "ends in an underscore" or "starts with
57
+ # eq" would swallow unrelated user functions, and the whole point of this rule
58
+ # is that a test which really does assert nothing stays flagged.
59
+ _LIBRARY_ASSERTION_NAMES = frozenset({
60
+ # _pytest.pytester.LineMatcher
61
+ "fnmatch_lines",
62
+ "fnmatch_lines_random",
63
+ "no_fnmatch_line",
64
+ "no_re_match_line",
65
+ "re_match_lines",
66
+ "re_match_lines_random",
67
+ # sqlalchemy.testing.assertions
68
+ "eq_",
69
+ "eq_ignore_whitespace",
70
+ "eq_regex",
71
+ "in_",
72
+ "is_",
73
+ "is_false",
74
+ "is_instance_of",
75
+ "is_none",
76
+ "is_not",
77
+ "is_not_",
78
+ "is_not_none",
79
+ "is_true",
80
+ "ne_",
81
+ "not_in",
82
+ "not_in_",
83
+ })
84
+
35
85
  _TEST_PREFIX = "test_"
36
86
 
37
87
  # Fluent verification DSLs reached through an attribute rather than a call name.
@@ -307,7 +357,9 @@ def _names_verification(func: ast.expr) -> bool:
307
357
 
308
358
 
309
359
  def _reads_as_verification(name: str) -> bool:
310
- return bool(_ASSERTION_NAME_RE.search(name) or _RAISES_TOKEN_RE.search(name))
360
+ return name in _LIBRARY_ASSERTION_NAMES or bool(
361
+ _ASSERTION_NAME_RE.search(name) or _RAISES_TOKEN_RE.search(name)
362
+ )
311
363
 
312
364
 
313
365
  def _chain_has_fluent_marker(node: ast.expr) -> bool: