sarj-python-lint 0.25.0__tar.gz → 0.26.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 (92) hide show
  1. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/.gitignore +1 -0
  2. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/PKG-INFO +6 -6
  3. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/README.md +5 -5
  4. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/pyproject.toml +1 -1
  5. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/ratchet.py +8 -8
  6. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rule_base.py +1 -1
  7. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_comments.py +128 -1
  8. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_first_party.py +23 -17
  9. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_registry.py +20 -28
  10. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +2 -2
  11. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/duplicate_test_body.py +9 -0
  12. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +8 -6
  13. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/mock_without_spec.py +1 -0
  14. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_comment_cruft.py +67 -15
  15. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +1 -1
  16. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +2 -2
  17. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +106 -0
  18. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +2 -2
  19. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -6
  20. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +2 -2
  21. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/over_mocked_test.py +65 -17
  22. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +33 -6
  23. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +3 -3
  24. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +72 -12
  25. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +275 -0
  26. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +3 -6
  27. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_pattern_matching.py +340 -0
  28. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_self_type_annotation.py +159 -0
  29. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_str_enum.py +1 -4
  30. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +125 -0
  31. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +143 -0
  32. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +133 -0
  33. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/primary_export_file_name.py +149 -0
  34. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/redundant_docstring.py +97 -17
  35. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/require_port_for_service.py +6 -2
  36. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/stepdown.py +4 -4
  37. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/trailing_value_narration.py +65 -14
  38. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +1 -1
  39. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/__init__.py +0 -0
  40. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/__main__.py +0 -0
  41. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  42. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/_secret_names.py +0 -0
  43. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/_version.py +0 -0
  44. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/py.typed +0 -0
  45. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  46. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  47. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  48. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  49. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  50. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  51. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  52. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  53. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  54. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  55. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  56. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  57. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  58. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  59. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  60. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  61. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  62. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  64. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  65. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  66. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  67. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  68. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  69. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  70. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  71. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  72. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  73. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  74. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  75. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  76. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  77. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  78. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  79. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  80. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  81. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  82. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  83. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  84. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  85. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  86. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  87. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  88. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  89. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  90. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  91. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  92. {sarj_python_lint-0.25.0 → sarj_python_lint-0.26.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -34,3 +34,4 @@ Thumbs.db
34
34
  # Claude Code local state (shared settings.json is tracked)
35
35
  .claude/*
36
36
  !.claude/settings.json
37
+ .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.25.0
3
+ Version: 0.26.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
@@ -240,7 +240,7 @@ disappears — and the query still runs — whenever the filter is empty:
240
240
 
241
241
  ```python
242
242
  where_conditions = []
243
- if args.organization_ids: # ← optional
243
+ if args.organization_ids: # ← optional
244
244
  where_conditions.append(SQL("organization_id = ANY(%s::uuid[])"))
245
245
  ...
246
246
  where_clause = SQL(" AND ").join(where_conditions) if where_conditions else SQL("1=1")
@@ -282,12 +282,12 @@ is the assertion whose real condition slid out of the condition slot, because it
282
282
  was a working assertion when it was typed:
283
283
 
284
284
  ```python
285
- assert { # ← braces, not parentheses
285
+ assert { # ← braces, not parentheses
286
286
  "referencing a non existing `via_device` " in caplog.text
287
- } # one-element SET, always truthy
287
+ } # one-element SET, always truthy
288
288
 
289
- assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
290
- assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
289
+ assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
290
+ assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
291
291
  ```
292
292
 
293
293
  **The narrowness is the rule.** The obvious generalisation — "flag a comparison
@@ -222,7 +222,7 @@ disappears — and the query still runs — whenever the filter is empty:
222
222
 
223
223
  ```python
224
224
  where_conditions = []
225
- if args.organization_ids: # ← optional
225
+ if args.organization_ids: # ← optional
226
226
  where_conditions.append(SQL("organization_id = ANY(%s::uuid[])"))
227
227
  ...
228
228
  where_clause = SQL(" AND ").join(where_conditions) if where_conditions else SQL("1=1")
@@ -264,12 +264,12 @@ is the assertion whose real condition slid out of the condition slot, because it
264
264
  was a working assertion when it was typed:
265
265
 
266
266
  ```python
267
- assert { # ← braces, not parentheses
267
+ assert { # ← braces, not parentheses
268
268
  "referencing a non existing `via_device` " in caplog.text
269
- } # one-element SET, always truthy
269
+ } # one-element SET, always truthy
270
270
 
271
- assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
272
- assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
271
+ assert [f"No logs found on hdfs for ti={ti}"] # the `== messages` was lost
272
+ assert True, cover_result_json[0]["success"][...] # slid into the MESSAGE slot
273
273
  ```
274
274
 
275
275
  **The narrowness is the rule.** The obvious generalisation — "flag a comparison
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.25.0"
3
+ version = "0.26.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" }]
@@ -210,14 +210,14 @@ def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
210
210
 
211
211
  """
212
212
  failures = [
213
- Failure(dimension="code", key=key, ceiling=baseline.codes.get(key, 0), actual=n)
213
+ Failure(dimension="code", key=key, ceiling=c, actual=n)
214
214
  for key, n in sorted(measurement.codes.items())
215
- if n > baseline.codes.get(key, 0)
215
+ if n > (c := baseline.codes.get(key, 0))
216
216
  ]
217
217
  failures += [
218
- Failure(dimension="package", key=key, ceiling=baseline.packages.get(key, 0), actual=n)
218
+ Failure(dimension="package", key=key, ceiling=c, actual=n)
219
219
  for key, n in sorted(measurement.packages.items())
220
- if n > baseline.packages.get(key, 0)
220
+ if n > (c := baseline.packages.get(key, 0))
221
221
  ]
222
222
  failures += [
223
223
  Failure(dimension="file", key=key, ceiling=ceiling, actual=n)
@@ -337,11 +337,11 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
337
337
 
338
338
  """
339
339
  return sorted(
340
- child.name
340
+ name
341
341
  for child in root.iterdir()
342
- if child.is_dir()
343
- and child.name not in excluded_dir_names
344
- and not child.name.startswith(".")
342
+ if (name := child.name) not in excluded_dir_names
343
+ and not name.startswith(".")
344
+ and child.is_dir()
345
345
  and any(True for _ in _python_files(child, excluded_dir_names))
346
346
  )
347
347
 
@@ -46,7 +46,7 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
46
46
  if not codes_str:
47
47
  # Bare `# sarj-noqa` suppresses every SARJ code on the line
48
48
  return True
49
- codes = {c.strip().upper() for c in codes_str.split(",") if c.strip()}
49
+ codes = {val.upper() for c in codes_str.split(",") if (val := c.strip())}
50
50
  return code.upper() in codes
51
51
 
52
52
 
@@ -180,7 +180,134 @@ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
180
180
  # the prototype's list: shrinking it costs recall, growing it costs precision by
181
181
  # letting a genuinely novel word be discounted.
182
182
  STOPWORDS: frozenset[str] = frozenset(
183
- ["a", "an", "the", "this", "that", "these", "those", "it", "its", "their", "his", "her", "our", "your", "my", "is", "are", "was", "were", "be", "been", "being", "am", "do", "does", "did", "done", "doing", "has", "have", "had", "having", "will", "would", "shall", "should", "can", "could", "may", "might", "must", "and", "or", "but", "nor", "so", "yet", "not", "no", "none", "to", "of", "for", "in", "on", "at", "by", "with", "from", "into", "onto", "out", "up", "down", "over", "under", "about", "as", "if", "then", "than", "when", "where", "which", "who", "whom", "whose", "what", "how", "why", "while", "we", "you", "they", "i", "he", "she", "them", "him", "us", "me", "also", "just", "only", "even", "still", "already", "again", "there", "here", "all", "any", "each", "every", "some", "via", "per", "etc", "eg", "ie", "vs", "need", "needs", "needed", "want", "wants", "make", "makes", "making", "let", "lets", "please", "note", "see", "above", "below"]
183
+ [
184
+ "a",
185
+ "an",
186
+ "the",
187
+ "this",
188
+ "that",
189
+ "these",
190
+ "those",
191
+ "it",
192
+ "its",
193
+ "their",
194
+ "his",
195
+ "her",
196
+ "our",
197
+ "your",
198
+ "my",
199
+ "is",
200
+ "are",
201
+ "was",
202
+ "were",
203
+ "be",
204
+ "been",
205
+ "being",
206
+ "am",
207
+ "do",
208
+ "does",
209
+ "did",
210
+ "done",
211
+ "doing",
212
+ "has",
213
+ "have",
214
+ "had",
215
+ "having",
216
+ "will",
217
+ "would",
218
+ "shall",
219
+ "should",
220
+ "can",
221
+ "could",
222
+ "may",
223
+ "might",
224
+ "must",
225
+ "and",
226
+ "or",
227
+ "but",
228
+ "nor",
229
+ "so",
230
+ "yet",
231
+ "not",
232
+ "no",
233
+ "none",
234
+ "to",
235
+ "of",
236
+ "for",
237
+ "in",
238
+ "on",
239
+ "at",
240
+ "by",
241
+ "with",
242
+ "from",
243
+ "into",
244
+ "onto",
245
+ "out",
246
+ "up",
247
+ "down",
248
+ "over",
249
+ "under",
250
+ "about",
251
+ "as",
252
+ "if",
253
+ "then",
254
+ "than",
255
+ "when",
256
+ "where",
257
+ "which",
258
+ "who",
259
+ "whom",
260
+ "whose",
261
+ "what",
262
+ "how",
263
+ "why",
264
+ "while",
265
+ "we",
266
+ "you",
267
+ "they",
268
+ "i",
269
+ "he",
270
+ "she",
271
+ "them",
272
+ "him",
273
+ "us",
274
+ "me",
275
+ "also",
276
+ "just",
277
+ "only",
278
+ "even",
279
+ "still",
280
+ "already",
281
+ "again",
282
+ "there",
283
+ "here",
284
+ "all",
285
+ "any",
286
+ "each",
287
+ "every",
288
+ "some",
289
+ "via",
290
+ "per",
291
+ "etc",
292
+ "eg",
293
+ "ie",
294
+ "vs",
295
+ "need",
296
+ "needs",
297
+ "needed",
298
+ "want",
299
+ "wants",
300
+ "make",
301
+ "makes",
302
+ "making",
303
+ "let",
304
+ "lets",
305
+ "please",
306
+ "note",
307
+ "see",
308
+ "above",
309
+ "below",
310
+ ]
184
311
  )
185
312
 
186
313
 
@@ -50,23 +50,25 @@ _PROJECT_MARKER = "pyproject.toml"
50
50
 
51
51
  # Never descend into these: a virtualenv or vendored tree holds *third-party*
52
52
  # packages, and collecting their names would classify every dependency as ours.
53
- _SKIP_DIR_NAMES = frozenset({
54
- ".git",
55
- ".mypy_cache",
56
- ".next",
57
- ".pytest_cache",
58
- ".ruff_cache",
59
- ".tox",
60
- ".turbo",
61
- ".venv",
62
- "__pycache__",
63
- "build",
64
- "coverage",
65
- "dist",
66
- "node_modules",
67
- "site-packages",
68
- "venv",
69
- })
53
+ _SKIP_DIR_NAMES = frozenset(
54
+ {
55
+ ".git",
56
+ ".mypy_cache",
57
+ ".next",
58
+ ".pytest_cache",
59
+ ".ruff_cache",
60
+ ".tox",
61
+ ".turbo",
62
+ ".venv",
63
+ "__pycache__",
64
+ "build",
65
+ "coverage",
66
+ "dist",
67
+ "node_modules",
68
+ "site-packages",
69
+ "venv",
70
+ }
71
+ )
70
72
 
71
73
  # Deep enough for a uv/pnpm-style monorepo (`<root>/packages/python/src/<pkg>`),
72
74
  # shallow enough that the scan stays a few hundred `iterdir()` calls.
@@ -119,6 +121,10 @@ def own_top_package(path: Path) -> str | None:
119
121
  top = ancestor.name
120
122
  if ancestor == root:
121
123
  break
124
+ if top is None and root is not None:
125
+ roots = _first_party_roots(root)
126
+ if len(roots) == 1:
127
+ return next(iter(roots))
122
128
  return top
123
129
 
124
130
 
@@ -27,6 +27,7 @@ from sarj_python_lint.rules.no_first_party_private_import import (
27
27
  )
28
28
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
29
29
  from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
30
+ from sarj_python_lint.rules.no_implicit_attribute_access import NoImplicitAttributeAccess
30
31
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
31
32
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
32
33
  from sarj_python_lint.rules.no_optional_tenant_predicate import (
@@ -57,6 +58,7 @@ from sarj_python_lint.rules.prefer_fstring_over_concat import PreferFstringOverC
57
58
  from sarj_python_lint.rules.prefer_library_fake import PreferLibraryFake
58
59
  from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
59
60
  from sarj_python_lint.rules.prefer_match_pattern_destructuring import PreferMatchPatternDestructuring
61
+ from sarj_python_lint.rules.prefer_match_type_dispatch import PreferMatchTypeDispatch
60
62
  from sarj_python_lint.rules.prefer_module_level_constant import (
61
63
  PreferModuleLevelConstant,
62
64
  )
@@ -67,7 +69,9 @@ from sarj_python_lint.rules.prefer_non_nullable_collection import (
67
69
  PreferNonNullableCollection,
68
70
  )
69
71
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
72
+ from sarj_python_lint.rules.prefer_pattern_matching import PreferPatternMatching
70
73
  from sarj_python_lint.rules.prefer_real_store_in_tests import PreferRealStoreInTests
74
+ from sarj_python_lint.rules.prefer_self_type_annotation import PreferSelfTypeAnnotation
71
75
  from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
72
76
  from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
73
77
  PreferStructOverNamedtuple,
@@ -75,6 +79,14 @@ from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
75
79
  from sarj_python_lint.rules.prefer_timedelta_for_durations import (
76
80
  PreferTimedeltaForDurations,
77
81
  )
82
+ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
83
+ PreferWalrusComprehensionFilter,
84
+ )
85
+ from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
86
+ from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
87
+ from sarj_python_lint.rules.primary_export_file_name import (
88
+ PrimaryExportFileName,
89
+ )
78
90
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
79
91
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
80
92
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
@@ -98,34 +110,6 @@ from sarj_python_lint.rules.zero_assertion_test import ZeroAssertionTest
98
110
  if TYPE_CHECKING:
99
111
  from sarj_python_lint.rule_base import Rule
100
112
 
101
-
102
- # Retired codes — never reuse these for new rules:
103
- # SARJ004, SARJ005 (retired before the standards merge),
104
- # SARJ027, SARJ029, SARJ030 (dropped in 0.11.1 as too noisy),
105
- # SARJ033 httpx-client-requires-timeout, SARJ035 no-import-time-settings
106
- # (dropped by user veto after the 0.13.x mined-rules review),
107
- # SARJ055 no-filler-success-adverb (built and corpus-validated, then dropped
108
- # — but not for the reason first recorded. The headline "4.7% of Airflow's
109
- # info/debug logs" is a composition artifact: 237 of those 248 hits are in
110
- # `providers/` (contributed vendor operators, heavily copy-pasted — the
111
- # template "%s completed successfully." appears 22 times verbatim). Airflow's
112
- # maintainer-owned `airflow-core/` sits at 1.15% and Home Assistant at 1.24%,
113
- # so the real external baseline is ~1.2%, not 4.7%. The rule was re-measured
114
- # and dropped on a stronger basis: the narrow variant — fire only when the
115
- # adverb is the sole content beyond a bare verb — has 12 external hits and
116
- # ZERO internal ones across all six repos, so it would govern nothing we
117
- # write. The broad rule remains opt-in house style, not a defect check.
118
- # (Noted for any future revisit: noura-be measures 7.92%, a genuine outlier
119
- # against every corpus; that, not the narrow variant, is the case to make.),
120
- # SARJ037 no-trivial-single-use-helper (prototyped and dropped for FP rate;
121
- # see the 0.13.1 inlining commit for the corpus analysis).
122
- # SARJ072 unbound-mock-assertion and SARJ073 raises-needs-specific-error
123
- # (built, corpus-measured and dropped before ever being registered, in the
124
- # wave that added SARJ058-071). SARJ072 found 0 hits across 16,130 files in
125
- # 9 repos, including 0 against 8,322 real mock-assertion API uses; ruff B018
126
- # covers its bare-statement shape. SARJ073 was a strict superset of ruff
127
- # PT011, which this standard already enables — its one real gap,
128
- # RuntimeError, is closed by `lint.flake8-pytest-style.raises-require-match-for`.
129
113
  REGISTRY: dict[str, type[Rule]] = {
130
114
  NoSequentialAwait.id: NoSequentialAwait,
131
115
  InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
@@ -133,6 +117,7 @@ REGISTRY: dict[str, type[Rule]] = {
133
117
  PreferStrEnum.id: PreferStrEnum,
134
118
  NoFatTryBlocks.id: NoFatTryBlocks,
135
119
  NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
120
+ NoImplicitAttributeAccess.id: NoImplicitAttributeAccess,
136
121
  NoOffsetPagination.id: NoOffsetPagination,
137
122
  PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
138
123
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
@@ -188,8 +173,15 @@ REGISTRY: dict[str, type[Rule]] = {
188
173
  PreferFstringOverConcat.id: PreferFstringOverConcat,
189
174
  PreferMatchPatternDestructuring.id: PreferMatchPatternDestructuring,
190
175
  PreferOrPattern.id: PreferOrPattern,
176
+ PreferPatternMatching.id: PreferPatternMatching,
191
177
  RequirePortForService.id: RequirePortForService,
192
178
  PreferNonNullableCollection.id: PreferNonNullableCollection,
179
+ PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
180
+ PrimaryExportFileName.id: PrimaryExportFileName,
181
+ PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
182
+ PreferWalrusComprehensionFilter.id: PreferWalrusComprehensionFilter,
183
+ PreferWalrusStreamLoop.id: PreferWalrusStreamLoop,
184
+ PreferSelfTypeAnnotation.id: PreferSelfTypeAnnotation,
193
185
  }
194
186
 
195
187
  __all__ = ["REGISTRY"]
@@ -901,9 +901,9 @@ def _parametrized_nonempty(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[
901
901
  def _parametrize_names(dec: ast.Call) -> list[str]:
902
902
  argnames = dec.args[0] if dec.args else None
903
903
  if isinstance(argnames, ast.Constant) and isinstance(argnames.value, str):
904
- return [part.strip() for part in argnames.value.split(",") if part.strip()]
904
+ return [p for part in argnames.value.split(",") if (p := part.strip())]
905
905
  if isinstance(argnames, (ast.List, ast.Tuple)):
906
- return [elt.value for elt in argnames.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str)]
906
+ return [val for elt in argnames.elts if isinstance(elt, ast.Constant) and isinstance(val := elt.value, str)]
907
907
  return []
908
908
 
909
909
 
@@ -294,6 +294,15 @@ class DuplicateTestBody(Rule):
294
294
  groups = _duplicate_groups(tree, source)
295
295
  except tokenize.TokenError, IndentationError, SyntaxError:
296
296
  return []
297
+ except RecursionError:
298
+ # Normalisation deep-copies each body and `ast.dump`s it, both of
299
+ # which recurse once per AST level. A left-nested `+` spine of a few
300
+ # thousand terms — generated or minified source, never hand-written;
301
+ # the deepest real test-file AST measured across 43k files is 33 —
302
+ # exhausts the stack. Returning nothing skips this one file, where
303
+ # letting it propagate would abort the whole lint run: `check()` is
304
+ # called per file by a CLI that does not catch it.
305
+ return []
297
306
 
298
307
  diags = [
299
308
  Diagnostic(
@@ -301,9 +301,9 @@ def _calls_super_same_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool
301
301
 
302
302
  """
303
303
  return any(
304
- isinstance(call.func, ast.Attribute)
305
- and call.func.attr == node.name
306
- and isinstance(inner := call.func.value, ast.Call)
304
+ isinstance(func := call.func, ast.Attribute)
305
+ and func.attr == node.name
306
+ and isinstance(inner := func.value, ast.Call)
307
307
  and isinstance(inner.func, ast.Name)
308
308
  and inner.func.id == "super"
309
309
  for call in ast.walk(node)
@@ -362,9 +362,11 @@ def _swap_prone_annotation(args: ast.arguments) -> str | None:
362
362
  if isinstance(ann := p.annotation, ast.Name) and ann.id in _PRIMITIVES:
363
363
  groups.setdefault(ann.id, []).append(p.arg)
364
364
  for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
365
- if len(arg_names) >= _MIN_SAME_TYPE and not (
366
- _is_symmetric_numbering(arg_names) or _is_conventional_order(arg_names)
367
- ) and _is_high_value_group(name, arg_names):
365
+ if (
366
+ len(arg_names) >= _MIN_SAME_TYPE
367
+ and not (_is_symmetric_numbering(arg_names) or _is_conventional_order(arg_names))
368
+ and _is_high_value_group(name, arg_names)
369
+ ):
368
370
  return name
369
371
  return None
370
372
 
@@ -126,6 +126,7 @@ _PATCHERS = frozenset({"patch"})
126
126
  # Any one of these gives the double a real contract to honour.
127
127
  _SPEC_KEYWORDS = frozenset({"spec", "spec_set", "autospec", "new", "new_callable", "wraps"})
128
128
 
129
+
129
130
  # How many positional arguments a callee must receive before the spec/new
130
131
  # parameter is filled. `Mock(spec, wraps, ...)`, `patch(target, new, ...)` and
131
132
  # `patch.object(target, attribute, new, ...)` all take it positionally, and that
@@ -178,8 +178,6 @@ _DIRECTIVE_PREFIXES = (
178
178
  "flake8:",
179
179
  "nosec",
180
180
  "nosemgrep",
181
- "todo",
182
- "fixme",
183
181
  "hack",
184
182
  "xxx:",
185
183
  "-*-",
@@ -249,7 +247,7 @@ _SENTENCE_END_RE = re.compile(r"""[.!?:;)\]}"'`]$""")
249
247
  # holds") aren't mistaken for an enumeration marker.
250
248
  _STEP_NARRATION_RE = re.compile(
251
249
  r"^(?:first(?:ly)?|second(?:ly)?|third(?:ly)?|then|next|after(?:wards| that)?"
252
- r"|finally|lastly|now)\s*[,:]\s*\S|^step\s+\d+\b",
250
+ r"|finally|lastly|now)\s*[,:]\s*\S",
253
251
  re.IGNORECASE,
254
252
  )
255
253
 
@@ -274,14 +272,49 @@ _META_COMMENTARY_RE = re.compile(
274
272
  # demo-gateway, 12 of 12 sampled were true positives. Closed vocabulary on
275
273
  # purpose: a one-word comment outside this list ("# Riyadh") is far more likely
276
274
  # to be a genuine label for a value.
277
- _SECTION_LABEL_WORDS = frozenset({
278
- "actions", "components", "config", "configuration", "constant", "constants",
279
- "enums", "exports", "fixtures", "getters", "globals", "handler", "handlers",
280
- "helper", "helpers", "hook", "hooks", "imports", "interfaces", "main",
281
- "mocks", "models", "mutations", "props", "queries", "reducers", "routes",
282
- "schemas", "selectors", "setters", "setup", "state", "styles", "teardown",
283
- "type", "types", "util", "utilities", "utils",
284
- })
275
+ _SECTION_LABEL_WORDS = frozenset(
276
+ {
277
+ "actions",
278
+ "components",
279
+ "config",
280
+ "configuration",
281
+ "constant",
282
+ "constants",
283
+ "enums",
284
+ "exports",
285
+ "fixtures",
286
+ "getters",
287
+ "globals",
288
+ "handler",
289
+ "handlers",
290
+ "helper",
291
+ "helpers",
292
+ "hook",
293
+ "hooks",
294
+ "imports",
295
+ "interfaces",
296
+ "main",
297
+ "mocks",
298
+ "models",
299
+ "mutations",
300
+ "props",
301
+ "queries",
302
+ "reducers",
303
+ "routes",
304
+ "schemas",
305
+ "selectors",
306
+ "setters",
307
+ "setup",
308
+ "state",
309
+ "styles",
310
+ "teardown",
311
+ "type",
312
+ "types",
313
+ "util",
314
+ "utilities",
315
+ "utils",
316
+ }
317
+ )
285
318
  _SECTION_LABEL_RE = re.compile(r"^([A-Za-z]+)\s*:?\s*$")
286
319
 
287
320
  # "Helper function to check if a path is active" — the opener announces the
@@ -315,7 +348,13 @@ _LETS_RE = re.compile(
315
348
  # `# Phase 2: reconcile`. Flagged only when the file carries exactly one of
316
349
  # them — a *run* of them is a documented algorithm walkthrough, which is the
317
350
  # kind of comment this rule exists to protect.
318
- _ENUMERATION_RE = re.compile(r"^(?:\d+[.)]\s+\S|phase\s+\d+\b)", re.IGNORECASE)
351
+ _ENUMERATION_RE = re.compile(r"^(?:\d+[.)]\s+\S|(?:phase|step)\s+\d+\b)", re.IGNORECASE)
352
+
353
+ # Dummy translational comments: ultra-short comments that just restate the code.
354
+ _DUMMY_TRANSLATION_RE = re.compile(
355
+ r"^(?:increment|return|returns|get|gets|set\b(?! up\b)|sets\b(?! up\b)|function to|method to)\b",
356
+ re.IGNORECASE,
357
+ )
319
358
 
320
359
 
321
360
  def _is_word_char(ch: str) -> bool:
@@ -493,6 +532,14 @@ def _is_redundant_narration(
493
532
  return True
494
533
  if _HELPER_OPENER_RE.match(c) or _LETS_RE.match(c):
495
534
  return True
535
+ words = c.split()
536
+ if len(words) <= 4 and _DUMMY_TRANSLATION_RE.match(c) and not any(ch in c for ch in "():="):
537
+ # Exclude common rationale words or test markers
538
+ lower_c = c.lower()
539
+ if not any(w in lower_c for w in ("when", "because", "if", "so that", "due to", "for", "instead of", "to prevent", "to avoid", "only")):
540
+ # Also exclude single-word labels like `# get` which are often group labels in tests
541
+ if len(words) > 1:
542
+ return True
496
543
  if not nested and _is_section_label(c):
497
544
  return True
498
545
  return isolated_enumeration and bool(_ENUMERATION_RE.match(c))
@@ -559,6 +606,9 @@ def _looks_like_code(body: str) -> bool:
559
606
  if c.startswith("@"):
560
607
  return _compiles(c + "\ndef _f():\n pass")
561
608
  if _ASSIGN_OR_CALL_RE.match(c):
609
+ # Implicit type condition documentation (e.g. under `else:`)
610
+ if c.startswith("isinstance(") or c.startswith("issubclass("):
611
+ return False
562
612
  return _is_assign_or_call(c)
563
613
  return False
564
614
 
@@ -660,6 +710,10 @@ class NoCommentCruft(Rule):
660
710
  ) -> str | None:
661
711
  if _CODE_REGEN_CALL_RE.match(body):
662
712
  return None
713
+ if re.match(r"^(?:todo|fixme)\b", body, re.IGNORECASE):
714
+ if not narration_protected:
715
+ return "Untracked TODO/FIXME marker — add an issue ticket or context link."
716
+ return None
663
717
  if _is_banner(body):
664
718
  if _is_heading_underline(body, prev_body):
665
719
  return None
@@ -670,9 +724,7 @@ class NoCommentCruft(Rule):
670
724
  return "Commented-out code — delete it; git history remembers."
671
725
  if narration_protected:
672
726
  return None
673
- if _is_redundant_narration(
674
- body, prev_body, isolated_enumeration=isolated_enumeration, nested=nested
675
- ):
727
+ if _is_redundant_narration(body, prev_body, isolated_enumeration=isolated_enumeration, nested=nested):
676
728
  return "Comment narrates the code — delete it or say why, not what. Code is self-documenting."
677
729
  return None
678
730
 
@@ -48,7 +48,7 @@ class NoCorsWildcardWithCredentials(Rule):
48
48
  for node in ast.walk(tree):
49
49
  if not isinstance(node, ast.Call):
50
50
  continue
51
- keywords = {kw.arg: kw.value for kw in node.keywords if kw.arg is not None}
51
+ keywords = {arg: kw.value for kw in node.keywords if (arg := kw.arg) is not None}
52
52
  credentials = keywords.get("allow_credentials")
53
53
  origins = keywords.get("allow_origins")
54
54
  if credentials is None or origins is None:
@@ -148,9 +148,9 @@ def _from_import_hits(node: ast.ImportFrom) -> list[tuple[int, int, str, str]]:
148
148
  if private_segment is not None:
149
149
  return [(node.lineno, node.col_offset + 1, node.module, private_segment)]
150
150
  return [
151
- (alias.lineno, alias.col_offset + 1, node.module, alias.name)
151
+ (alias.lineno, alias.col_offset + 1, node.module, name)
152
152
  for alias in node.names
153
- if _is_private_name(alias.name)
153
+ if _is_private_name(name := alias.name)
154
154
  ]
155
155
 
156
156