sarj-python-lint 0.25.0__tar.gz → 0.27.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 (94) hide show
  1. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/.gitignore +1 -0
  2. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/PKG-INFO +6 -6
  3. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/README.md +5 -5
  4. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/pyproject.toml +1 -1
  5. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/ratchet.py +8 -8
  6. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rule_base.py +1 -1
  7. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_ast_index.py +180 -0
  8. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_comments.py +128 -1
  9. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_first_party.py +23 -17
  10. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_logging.py +54 -0
  11. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_pytest.py +3 -1
  12. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_registry.py +20 -28
  13. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_suppression_comments.py +18 -0
  14. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +17 -18
  15. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/duplicate_test_body.py +11 -1
  16. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +4 -3
  17. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +5 -4
  18. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/interaction_only_test.py +6 -5
  19. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +2 -3
  20. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +27 -24
  21. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/mock_without_spec.py +8 -8
  22. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +3 -4
  23. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_comment_cruft.py +83 -15
  24. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +4 -5
  25. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +3 -4
  26. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +3 -3
  27. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +5 -4
  28. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +5 -4
  29. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +3 -2
  30. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +126 -0
  31. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +3 -4
  32. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_offset_pagination.py +3 -4
  33. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +5 -4
  34. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +72 -5
  35. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +3 -4
  36. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +2 -3
  37. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +4 -3
  38. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -6
  39. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_secret_in_log.py +2 -3
  40. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_select_star.py +3 -4
  41. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +14 -10
  42. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_sequential_await.py +74 -51
  43. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +2 -1
  44. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +34 -19
  45. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_tautological_expect.py +37 -5
  46. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +15 -1
  47. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/over_mocked_test.py +69 -20
  48. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +2 -7
  49. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_class_row.py +2 -3
  50. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +36 -26
  51. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +36 -8
  52. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_library_fake.py +4 -3
  53. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +10 -13
  54. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +75 -14
  55. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +275 -0
  56. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +3 -4
  57. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +4 -5
  58. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +7 -9
  59. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +3 -3
  60. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_pattern_matching.py +343 -0
  61. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +5 -4
  62. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_self_type_annotation.py +159 -0
  63. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_str_enum.py +11 -17
  64. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +3 -3
  65. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +4 -3
  66. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +125 -0
  67. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +144 -0
  68. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +129 -0
  69. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/primary_export_file_name.py +149 -0
  70. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +4 -5
  71. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/redundant_docstring.py +99 -18
  72. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/require_port_for_service.py +27 -3
  73. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/stepdown.py +6 -5
  74. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +3 -4
  75. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +183 -10
  76. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +3 -2
  77. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/trailing_value_narration.py +65 -14
  78. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +48 -172
  79. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/unused_mock_setup.py +8 -9
  80. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +4 -5
  81. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/zero_assertion_test.py +5 -5
  82. sarj_python_lint-0.25.0/src/sarj_python_lint/rules/_logging.py +0 -39
  83. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/__init__.py +0 -0
  84. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/__main__.py +0 -0
  85. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  86. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/_secret_names.py +0 -0
  87. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/_version.py +0 -0
  88. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/py.typed +0 -0
  89. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  90. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  91. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  92. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  93. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  94. {sarj_python_lint-0.25.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_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.27.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.27.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
 
@@ -0,0 +1,180 @@
1
+ """One tree traversal per file, shared by every rule that needs node lookups.
2
+
3
+ Profiling the whole registry over a 1,920-line module showed `ast.walk` and its
4
+ helpers (`iter_child_nodes` / `iter_fields`) accounting for ~60% of all rule
5
+ time. The cause is structural rather than any one rule's fault: the dominant
6
+ shape of a rule is
7
+
8
+ for node in ast.walk(tree):
9
+ if isinstance(node, ast.Something):
10
+ ...
11
+
12
+ so a file with N nodes and R rules asking that question pays R full traversals,
13
+ each one re-deriving the same partition of the tree by node type.
14
+
15
+ `nodes()` derives that partition **once per file** and answers every subsequent
16
+ type query from it. The index is memoized in a single slot keyed on tree
17
+ identity, mirroring `parse_or_none`: the CLI iterates files on the outer loop
18
+ and rules on the inner one, so one slot is all that is ever live. The slot holds
19
+ a strong reference to the tree, so a recycled `id()` can never alias a stale
20
+ index.
21
+
22
+ Two invariants make the substitution finding-preserving, not merely faster:
23
+
24
+ * **Order.** The index is built by breadth-first traversal, the same order
25
+ `ast.walk` yields, and every query preserves it. `nodes(tree, T)` is therefore
26
+ elementwise equal to `[n for n in ast.walk(tree) if isinstance(n, T)]`, so a
27
+ rule that takes the *first* match, or reports in discovery order, is
28
+ unaffected.
29
+ * **Subclasses.** Queries match by `isinstance`, not by exact type: the buckets
30
+ are keyed on exact class, and a query resolves to the set of present classes
31
+ that are subclasses of the requested types. Asking for `ast.stmt` still
32
+ returns every statement.
33
+
34
+ `walk()` is the same breadth-first order for the cases the index cannot serve —
35
+ traversing a *subtree* rather than the module, where containment is not
36
+ something a whole-module partition can answer.
37
+
38
+ No rule may mutate the tree. That was already required — every rule shares one
39
+ memoized parse, and `duplicate_test_body` deep-copies before normalizing — and
40
+ the index inherits the requirement, since it holds references into the tree it
41
+ was built from.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import ast
47
+ from typing import TYPE_CHECKING, final
48
+
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Iterator
52
+
53
+
54
+ _AST = ast.AST
55
+
56
+
57
+ def children(node: ast.AST) -> list[ast.AST]:
58
+ """`node`'s direct children, in the same order as `ast.iter_child_nodes`.
59
+
60
+ A list rather than a generator: every caller either iterates it once or
61
+ extends a stack with it, and the hand-rolled walkers in this package call
62
+ this once per node, where the generator's setup cost dominates the work.
63
+
64
+ Returns:
65
+ The child nodes, in field-declaration order.
66
+
67
+ """
68
+ out: list[ast.AST] = []
69
+ for name in node._fields:
70
+ value: object = getattr(node, name, None)
71
+ if isinstance(value, list):
72
+ out += [item for item in value if isinstance(item, _AST)] # pyright: ignore[reportUnknownVariableType] — element narrowed by isinstance
73
+ elif isinstance(value, _AST):
74
+ out.append(value)
75
+ return out
76
+
77
+
78
+ def walk(node: ast.AST) -> Iterator[ast.AST]:
79
+ """Yield `node` and every descendant, breadth-first.
80
+
81
+ Identical in order to `ast.walk`, but reads each node's fields directly
82
+ instead of routing every node through two intermediate generators.
83
+
84
+ Yields:
85
+ `node` first, then its descendants breadth-first.
86
+
87
+ """
88
+ queue: list[ast.AST] = [node]
89
+ i = 0
90
+ while i < len(queue):
91
+ current = queue[i]
92
+ i += 1
93
+ for name in current._fields:
94
+ value: object = getattr(current, name, None)
95
+ if isinstance(value, list):
96
+ queue += [item for item in value if isinstance(item, _AST)] # pyright: ignore[reportUnknownVariableType] — element narrowed by isinstance
97
+ elif isinstance(value, _AST):
98
+ queue.append(value)
99
+ yield current
100
+
101
+
102
+ @final
103
+ class _NodeIndex:
104
+ """A module's nodes partitioned by exact class, in breadth-first order."""
105
+
106
+ __slots__ = ("_buckets", "_flat", "_queries")
107
+
108
+ def __init__(self, tree: ast.AST) -> None:
109
+ buckets: dict[type[ast.AST], list[ast.AST]] = {}
110
+ flat: list[ast.AST] = [tree]
111
+ i = 0
112
+ while i < len(flat):
113
+ current = flat[i]
114
+ i += 1
115
+ cls = current.__class__
116
+ bucket = buckets.get(cls)
117
+ if bucket is None:
118
+ buckets[cls] = [current]
119
+ else:
120
+ bucket.append(current)
121
+ for name in cls._fields:
122
+ value: object = getattr(current, name, None)
123
+ if isinstance(value, list):
124
+ flat += [item for item in value if isinstance(item, _AST)] # pyright: ignore[reportUnknownVariableType] — element narrowed by isinstance
125
+ elif isinstance(value, _AST):
126
+ flat.append(value)
127
+ self._buckets: dict[type[ast.AST], list[ast.AST]] = buckets
128
+ self._flat: list[ast.AST] = flat
129
+ self._queries: dict[tuple[type[ast.AST], ...], list[ast.AST]] = {}
130
+
131
+ def query(self, types: tuple[type[ast.AST], ...]) -> list[ast.AST]:
132
+ """Return every node matching `isinstance(node, types)`, breadth-first.
133
+
134
+ Returns:
135
+ The match list, cached per `types` and shared — treat as read-only.
136
+
137
+ """
138
+ hit = self._queries.get(types)
139
+ if hit is not None:
140
+ return hit
141
+ matched = frozenset(cls for cls in self._buckets if issubclass(cls, types))
142
+ if len(matched) == 1:
143
+ # The single bucket is already the answer, in breadth-first order.
144
+ result = self._buckets[next(iter(matched))]
145
+ elif len(matched) == len(self._buckets):
146
+ # Every class present matches — e.g. `nodes(tree, ast.AST)`, asked by
147
+ # a rule whose loop body inspects every node rather than a few types.
148
+ result = self._flat
149
+ elif matched:
150
+ result = [node for node in self._flat if node.__class__ in matched]
151
+ else:
152
+ result = []
153
+ self._queries[types] = result
154
+ return result
155
+
156
+
157
+ _last_index: tuple[ast.AST, _NodeIndex] | None = None
158
+
159
+
160
+ def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT]) -> list[NodeT]:
161
+ """Every node of `tree` matching `types`, in `ast.walk` order.
162
+
163
+ A drop-in, memoized replacement for the ubiquitous
164
+ `[n for n in ast.walk(tree) if isinstance(n, types)]`. Pass the *module*
165
+ tree: the index is per-file, so calling this with a subtree builds a fresh
166
+ index and evicts the module's. Use `walk` for subtrees.
167
+
168
+ Returns:
169
+ The matching nodes, breadth-first. A fresh list per call; the partition
170
+ it is drawn from is what gets cached.
171
+
172
+ """
173
+ global _last_index # ruff: ignore[global-statement] — single-slot memo, mirroring `parse_or_none`
174
+ if _last_index is None or _last_index[0] is not tree:
175
+ _last_index = (tree, _NodeIndex(tree))
176
+ # The `isinstance` pass is what narrows `list[ast.AST]` to `list[NodeT]`
177
+ # honestly, rather than asserting it with a `cast` the house style bans. It
178
+ # runs over the matches only — never the tree — and every element passes by
179
+ # construction, so it costs one type check per node actually returned.
180
+ return [node for node in _last_index[1].query(types) if isinstance(node, types)]
@@ -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
 
@@ -0,0 +1,54 @@
1
+ """Shared logging-receiver detection for SARJ012/SARJ017.
2
+
3
+ A single resolver for "does this receiver expression evaluate to a logger?",
4
+ used by both the secret-in-log and f-string-in-log rules so they recognise the
5
+ same factory/builder forms.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+
12
+
13
+ _LOGGER_NAMES = frozenset({"logger", "log", "logging", "loguru", "_logger", "_log"})
14
+
15
+ _LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
16
+
17
+
18
+ def is_logger_expr(expr: ast.expr) -> bool:
19
+ """Report whether `expr` evaluates to a logger.
20
+
21
+ Resolves the whole receiver chain so adapter/builder/factory calls are
22
+ caught: `logger.bind(...).info(...)`, `logger.opt(lazy=True).debug(...)`,
23
+ `logging.getLogger(__name__).info(...)`, `self.logger.error(...)`, and the
24
+ bare-name factory `get_logger().info(...)`.
25
+
26
+ Returns:
27
+ True when `expr` resolves to a logger receiver.
28
+
29
+ """
30
+ if isinstance(expr, ast.Name):
31
+ return expr.id.lower() in _LOGGER_NAMES
32
+ if isinstance(expr, ast.Attribute):
33
+ if expr.attr.lower() in _LOGGER_NAMES or expr.attr.lower() in _LOGGER_FACTORIES:
34
+ return True
35
+ return is_logger_expr(expr.value)
36
+ if isinstance(expr, ast.Call):
37
+ # A factory names a logger only when it is *called*: `get_logger()` is a
38
+ # logger, the bare name `get_logger` is a function — which is why the
39
+ # factories live in their own set and not in `_LOGGER_NAMES`. Both the
40
+ # dotted spelling (`structlog.get_logger()`, `logging.getLogger()`) and
41
+ # the bare one (`from structlog import get_logger`, then `get_logger()`)
42
+ # have to be recognised right here: a bare callee is an `ast.Name`, and
43
+ # recursing on it lands in the `_LOGGER_NAMES` branch, which by that
44
+ # design does not carry the factory names. Omitting this second check is
45
+ # a silent false negative rather than a crash, so it went unnoticed —
46
+ # `get_logger().info("auth", token=token)`, structlog's own documented
47
+ # module-level idiom, was unlinted by both SARJ012 and SARJ017.
48
+ callee = expr.func
49
+ if isinstance(callee, ast.Attribute) and callee.attr.lower() in _LOGGER_FACTORIES:
50
+ return True
51
+ if isinstance(callee, ast.Name) and callee.id.lower() in _LOGGER_FACTORIES:
52
+ return True
53
+ return is_logger_expr(callee)
54
+ return False
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
 
16
+ from sarj_python_lint.rules._ast_index import walk
17
+
16
18
 
17
19
  # The pytest-benchmark fixture: the test measures time, it does not verify.
18
20
  _BENCHMARK = "benchmark"
@@ -33,7 +35,7 @@ def uses_benchmark_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool
33
35
  """
34
36
  args = node.args
35
37
  declared = any(arg.arg == _BENCHMARK for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs))
36
- return declared and any(isinstance(child, ast.Name) and child.id == _BENCHMARK for child in ast.walk(node))
38
+ return declared and any(isinstance(child, ast.Name) and child.id == _BENCHMARK for child in walk(node))
37
39
 
38
40
 
39
41
  def has_benchmark_marker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
@@ -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"]