sarj-python-lint 0.27.0__tar.gz → 0.28.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.27.0 → sarj_python_lint-0.28.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_comments.py +35 -5
  4. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_registry.py +0 -6
  5. sarj_python_lint-0.28.0/src/sarj_python_lint/rules/_suppression_comments.py +69 -0
  6. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +29 -0
  7. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +35 -109
  8. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_suppression_comments.py +0 -96
  9. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/prefer_pattern_matching.py +0 -343
  10. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/primary_export_file_name.py +0 -149
  11. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/.gitignore +0 -0
  12. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/README.md +0 -0
  13. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/__init__.py +0 -0
  14. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/__main__.py +0 -0
  15. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  16. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_secret_names.py +0 -0
  17. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_version.py +0 -0
  18. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/py.typed +0 -0
  19. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/ratchet.py +0 -0
  20. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rule_base.py +0 -0
  21. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  23. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  25. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  26. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  27. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  28. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  29. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  30. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  31. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  32. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  33. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  34. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  35. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  36. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  37. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  38. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  39. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  40. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  41. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  42. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  43. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  44. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
  45. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  46. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  47. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  48. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
  49. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  50. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  51. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  52. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  53. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  54. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  55. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  56. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  57. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  58. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  59. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  60. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  61. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  62. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  63. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  64. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  65. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  66. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  67. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  68. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  69. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  70. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  71. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  72. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  73. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  74. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  75. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  76. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  77. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  78. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  79. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  80. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  81. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  82. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  83. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  84. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  85. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  86. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  87. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  88. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  89. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  90. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  91. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  92. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  93. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  94. {sarj_python_lint-0.27.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.27.0
3
+ Version: 0.28.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.27.0"
3
+ version = "0.28.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" }]
@@ -397,7 +397,12 @@ def restates(comment_tokens: Sequence[str], code: Iterable[str]) -> bool:
397
397
  _LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
398
398
  _NON_CODE_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
399
399
 
400
- _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int]
400
+ # `(line, col0, body, standalone)` for every comment, in source order. This is
401
+ # what `_suppression_comments` needs, and it is a by-product of the pass below
402
+ # rather than a reason to run a second one — see `all_comments`.
403
+ _Ordered = list[tuple[int, int, str, bool]]
404
+
405
+ _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int, _Ordered]
401
406
 
402
407
  _last_scan: tuple[str, _Scan] | None = None
403
408
 
@@ -405,6 +410,7 @@ _last_scan: tuple[str, _Scan] | None = None
405
410
  def _scan(source: str) -> _Scan:
406
411
  standalone: list[tuple[int, int, str]] = []
407
412
  trailing: list[tuple[int, int, str]] = []
413
+ ordered: _Ordered = []
408
414
  nested: set[int] = set()
409
415
  first_code_line = 1 << 30
410
416
  prev_end_row = 0
@@ -412,8 +418,11 @@ def _scan(source: str) -> _Scan:
412
418
  readline = io.StringIO(source).readline
413
419
  for tok in tokenize.generate_tokens(readline):
414
420
  if tok.type == tokenize.COMMENT:
415
- entry = (tok.start[0], tok.start[1], tok.string.lstrip("#").strip())
416
- (trailing if tok.start[0] == prev_end_row else standalone).append(entry)
421
+ body = tok.string.lstrip("#").strip()
422
+ entry = (tok.start[0], tok.start[1], body)
423
+ is_standalone = tok.start[0] != prev_end_row
424
+ (standalone if is_standalone else trailing).append(entry)
425
+ ordered.append((tok.start[0], tok.start[1], body, is_standalone))
417
426
  if depth > 0:
418
427
  nested.add(tok.start[0])
419
428
  elif tok.type == tokenize.OP:
@@ -425,7 +434,28 @@ def _scan(source: str) -> _Scan:
425
434
  prev_end_row = tok.end[0]
426
435
  if tok.type not in _NON_CODE_TOKENS:
427
436
  first_code_line = min(first_code_line, tok.start[0])
428
- return standalone, trailing, nested, first_code_line
437
+ return standalone, trailing, nested, first_code_line, ordered
438
+
439
+
440
+ def all_comments(source: str) -> tuple[_Ordered, int]:
441
+ """Return every comment as `(line, col0, body, standalone)`, plus the first code line.
442
+
443
+ Exists so the suppression rules (SARJ038/054) can share this module's
444
+ tokenize pass instead of running a second one. Both scanners computed the
445
+ same three facts — comment text, whether it stands alone on its line, and
446
+ where the first real code token is — from identical token-class sets, so the
447
+ second pass was pure duplicated work: SARJ038 alone spent ~4% of total rule
448
+ time on it.
449
+
450
+ `col0` is 0-based, matching this module's other accessors; the suppression
451
+ layer adds one for its 1-based `Comment.col`.
452
+
453
+ Returns:
454
+ The ordered comments and the first code line's row.
455
+
456
+ """
457
+ _, _, _, first_code_line, ordered = _scan_memo(source)
458
+ return ordered, first_code_line
429
459
 
430
460
 
431
461
  def _scan_memo(source: str) -> _Scan:
@@ -482,7 +512,7 @@ def standalone_comments(source: str) -> tuple[list[tuple[int, int, str]], int]:
482
512
  The standalone comments and the first code line's row.
483
513
 
484
514
  """
485
- standalone, _, _, first_code_line = _scan_memo(source)
515
+ standalone, _, _, first_code_line, _ = _scan_memo(source)
486
516
  return standalone, first_code_line
487
517
 
488
518
 
@@ -69,7 +69,6 @@ from sarj_python_lint.rules.prefer_non_nullable_collection import (
69
69
  PreferNonNullableCollection,
70
70
  )
71
71
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
72
- from sarj_python_lint.rules.prefer_pattern_matching import PreferPatternMatching
73
72
  from sarj_python_lint.rules.prefer_real_store_in_tests import PreferRealStoreInTests
74
73
  from sarj_python_lint.rules.prefer_self_type_annotation import PreferSelfTypeAnnotation
75
74
  from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
@@ -84,9 +83,6 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
84
83
  )
85
84
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
86
85
  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
- )
90
86
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
91
87
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
92
88
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
@@ -173,11 +169,9 @@ REGISTRY: dict[str, type[Rule]] = {
173
169
  PreferFstringOverConcat.id: PreferFstringOverConcat,
174
170
  PreferMatchPatternDestructuring.id: PreferMatchPatternDestructuring,
175
171
  PreferOrPattern.id: PreferOrPattern,
176
- PreferPatternMatching.id: PreferPatternMatching,
177
172
  RequirePortForService.id: RequirePortForService,
178
173
  PreferNonNullableCollection.id: PreferNonNullableCollection,
179
174
  PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
180
- PrimaryExportFileName.id: PrimaryExportFileName,
181
175
  PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
182
176
  PreferWalrusComprehensionFilter.id: PreferWalrusComprehensionFilter,
183
177
  PreferWalrusStreamLoop.id: PreferWalrusStreamLoop,
@@ -0,0 +1,69 @@
1
+ """Shared comment tokenizer for the suppression-directive rules (SARJ038, SARJ054).
2
+
3
+ Comments do not survive `ast.parse`, so any rule that judges a suppression
4
+ *comment* has to lex the file. Both suppression rules need the same three facts
5
+ about every comment — its text, whether it stands alone on its line, and whether
6
+ it precedes the module's first statement — so they share one scanner rather than
7
+ drifting apart on what "file-level" means.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from sarj_python_lint.rules._comments import all_comments
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Comment:
19
+ """One comment token, with the context that decides whether it is file-level."""
20
+
21
+ line: int
22
+ col: int
23
+ body: str
24
+ standalone: bool
25
+ before_first_statement: bool = False
26
+
27
+
28
+ def scan_comments(source: str) -> list[Comment]:
29
+ """Describe every comment in `source`, in source order.
30
+
31
+ A comment is standalone when no token ended on its line before it, and
32
+ precedes the first statement when it sits above the first non-comment,
33
+ non-layout token.
34
+
35
+ Anything `tokenize` cannot lex propagates as `tokenize.TokenError`,
36
+ `IndentationError` or `SyntaxError`; callers treat that as "no diagnostics".
37
+
38
+ Built from `_comments.all_comments`, which is the same tokenize pass the
39
+ comment-hygiene rules already run. This module used to lex the file a second
40
+ time to compute exactly the same three facts from identical token-class
41
+ sets, which cost SARJ038 ~4% of total rule time for no additional
42
+ information. The memo now lives in one place rather than two.
43
+
44
+ Returns:
45
+ Every comment, in source order. `Comment` is frozen and the list is
46
+ read-only to callers.
47
+
48
+ """
49
+ ordered, first_statement_line = all_comments(source)
50
+ return [
51
+ Comment(
52
+ line=line,
53
+ col=col + 1,
54
+ body=body,
55
+ standalone=standalone,
56
+ before_first_statement=line < first_statement_line,
57
+ )
58
+ for line, col, body, standalone in ordered
59
+ ]
60
+
61
+
62
+ def comment_body(raw: str) -> str:
63
+ """Strip a comment token down to its directive text.
64
+
65
+ Returns:
66
+ The comment text without its leading `#` markers or surrounding space.
67
+
68
+ """
69
+ return raw.lstrip("#").strip()
@@ -66,6 +66,11 @@ if TYPE_CHECKING:
66
66
 
67
67
  _GEN_RANDOM_UUID_RE = re.compile(r"\bgen_random_uuid\s*\(", re.IGNORECASE)
68
68
 
69
+ # The whole-source gate in `check`. Deliberately weaker than the pattern above —
70
+ # the identifier alone, no paren — so it cannot exclude a file whose masked SQL
71
+ # would have matched. See the comment at its use site.
72
+ _NAMES_GEN_RANDOM_UUID_RE = re.compile(r"gen_random_uuid", re.IGNORECASE)
73
+
69
74
  # A string only counts as SQL when it carries a structural keyword. Without this
70
75
  # the rule fires on prose that names the function — including this module's own
71
76
  # docstring.
@@ -111,6 +116,30 @@ class NoGenRandomUuidInSql(Rule):
111
116
  The diagnostics, sorted by (line, col).
112
117
 
113
118
  """
119
+ # Naming the function is a NECESSARY condition for any finding: the
120
+ # match runs against `strip_sql_noise(literal)`, and that only ever
121
+ # removes characters, so text absent from the file cannot appear in the
122
+ # masked literal. Checking the raw source first skips the parse and the
123
+ # per-literal SQL masking for the ~100% of files that never mention it.
124
+ #
125
+ # This rule is not store-gated and so masked every string literal in
126
+ # every file, which made it the worst cost-per-finding in the registry
127
+ # (2 findings across 21 corpora, at ~125 ms each). The gate removes that
128
+ # for no loss of coverage — verified by measuring the corpus before and
129
+ # after, not by inspection.
130
+ #
131
+ # The gate deliberately tests only the identifier, NOT the full
132
+ # `_GEN_RANDOM_UUID_RE`, which also requires the open paren. Masking can
133
+ # delete characters *between* the name and its paren — `gen_random_uuid
134
+ # /* c */ ()` masks to `gen_random_uuid ()` and matches — so gating on
135
+ # the stricter pattern would drop a finding the rule would otherwise
136
+ # report. A gate must be strictly weaker than the predicate it guards.
137
+ #
138
+ # It is case-insensitive for the same reason `_GEN_RANDOM_UUID_RE` is: a
139
+ # `DEFAULT GEN_RANDOM_UUID()` in upper-case SQL is a real finding, and a
140
+ # plain `"gen_random_uuid" in source` test would silently drop it.
141
+ if not _NAMES_GEN_RANDOM_UUID_RE.search(source):
142
+ return []
114
143
  if is_generated_source(source):
115
144
  return []
116
145
  tree = parse_or_none(path, source)
@@ -1,12 +1,38 @@
1
- """SARJ080: prefer match/case over control-flow try/raise and sequential type guards.
1
+ """SARJ080: prefer match/case over sequential sentinel/type guards.
2
2
 
3
3
  Parsers and field deserializers often contain hideous type-dispatch idioms:
4
- sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards,
5
- followed by a `try` block containing `if not isinstance(x, T): raise TypeError()`
6
- to artificially jump control flow into an `except (TypeError, ...): pass` block.
7
-
8
- Raising an exception inside a `try` block solely to trigger that block's `except`
9
- handler is using `raise` as a goto (control flow via exceptions).
4
+ sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards
5
+ walking a value through one shape at a time, where a single `match` states the
6
+ whole dispatch at once.
7
+
8
+ DELIBERATELY NOT FLAGGED: `raise` inside a `try` block
9
+ ------------------------------------------------------
10
+
11
+ This rule used to carry a second detector for the other half of that idiom —
12
+ raising inside a `try` purely to jump into its own `except` handler, i.e. using
13
+ `raise` as a goto. That detector was removed because ruff already reports it and
14
+ our shipped config already enables it.
15
+
16
+ `ruff.strict.toml` selects `ALL` and does not ignore `TRY`, so `TRY301`
17
+ (`raise-within-try`, "Abstract `raise` to an inner function") is live in every
18
+ consumer. Measured across 21 corpora, the removed arm produced 1,756 findings of
19
+ which 1,649 (93.9%) sat on a line ruff already flagged, and construction
20
+ confirms the columns match exactly, not merely the line:
21
+
22
+ try:
23
+ if not isinstance(x, str):
24
+ raise TypeError # ruff TRY301 at 13:13, old SARJ080 at 13:13
25
+
26
+ The 107 positions TRY301 structurally misses are `raise`s inside an `except`
27
+ body caught by an *outer* `try`. That is a real gap, but not one worth 1,649
28
+ double reports — and 5 of those positions already carry an explicit
29
+ `# noqa: TRY301`, a decision the team made that a second code would quietly
30
+ re-open.
31
+
32
+ The surviving sequential-guard detector has NO ruff counterpart: the same sweep
33
+ found 0 of its 476 positions shared with TRY301, and `RET505` is the closest
34
+ thing ruff has, firing only on `elif`-after-`return` rather than on separate
35
+ `if ...: return` statements.
10
36
 
11
37
  Preferred Python 3.10+ match/case patterns:
12
38
  - For `None`: `case None:`
@@ -42,59 +68,9 @@ if TYPE_CHECKING:
42
68
  from pathlib import Path
43
69
 
44
70
 
45
- _GENERIC_EXCEPTIONS = frozenset({"Exception", "BaseException"})
46
71
  _MIN_SENTINEL_COUNT = 2
47
72
 
48
73
 
49
- def _get_caught_exception_names(handlers: list[ast.ExceptHandler]) -> set[str] | None:
50
- """Extract caught exception class names.
51
-
52
- Returns:
53
- A set of exception class names, or None if a catch-all handler (bare `except:`) is present.
54
-
55
- """
56
- caught: set[str] = set()
57
- for h in handlers:
58
- if h.type is None:
59
- return None
60
- if isinstance(h.type, ast.Name):
61
- caught.add(h.type.id)
62
- elif isinstance(h.type, ast.Tuple):
63
- for elt in h.type.elts:
64
- if isinstance(elt, ast.Name):
65
- caught.add(elt.id)
66
- elif isinstance(elt, ast.Attribute):
67
- caught.add(elt.attr)
68
- caught.add(ast.unparse(elt))
69
- elif isinstance(h.type, ast.Attribute):
70
- caught.add(h.type.attr)
71
- caught.add(ast.unparse(h.type))
72
- return caught
73
-
74
-
75
- def _raised_exception_name(raise_node: ast.Raise) -> str | None:
76
- """Extract exception class name from `raise Exc()` or `raise Exc`.
77
-
78
- Returns:
79
- The exception class name, or None if no exception is specified.
80
-
81
- """
82
- exc = raise_node.exc
83
- if exc is None:
84
- return None
85
- if isinstance(exc, ast.Call):
86
- func = exc.func
87
- if isinstance(func, ast.Name):
88
- return func.id
89
- if isinstance(func, ast.Attribute):
90
- return func.attr
91
- elif isinstance(exc, ast.Name):
92
- return exc.id
93
- elif isinstance(exc, ast.Attribute):
94
- return exc.attr
95
- return None
96
-
97
-
98
74
  def _guard_target_var_name(stmt: ast.stmt) -> str | None:
99
75
  """Extract target variable name if statement is `if x is None: return ...` or `if isinstance(x, ...): return ...`.
100
76
 
@@ -191,75 +167,25 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
191
167
  self.path: Path = path
192
168
  self.code: str = code
193
169
  self.diags: list[Diagnostic] = []
194
- self.try_stack: list[set[str] | None] = []
195
-
196
- def visit_Try(self, node: ast.Try | ast.TryStar) -> None:
197
- caught = _get_caught_exception_names(node.handlers)
198
- self.try_stack.append(caught)
199
- for stmt in node.body:
200
- self.visit(stmt)
201
- self.try_stack.pop()
202
- for handler in node.handlers:
203
- self.visit(handler)
204
- for stmt in node.orelse:
205
- self.visit(stmt)
206
- for stmt in node.finalbody:
207
- self.visit(stmt)
208
-
209
- @override
210
- def visit_TryStar(self, node: ast.TryStar) -> None:
211
- self.visit_Try(node)
212
-
213
- def visit_Raise(self, node: ast.Raise) -> None:
214
- if self.try_stack:
215
- exc_name = _raised_exception_name(node)
216
- if exc_name is not None:
217
- # Search outward through active try scopes
218
- for caught in reversed(self.try_stack):
219
- is_caught = caught is None or exc_name in caught or bool(caught & _GENERIC_EXCEPTIONS)
220
- if is_caught:
221
- self.diags.append(
222
- Diagnostic(
223
- path=self.path,
224
- line=node.lineno,
225
- col=node.col_offset + 1,
226
- code=self.code,
227
- message=(
228
- f"Control-flow raise in try block — 'raise {exc_name}()' "
229
- f"jumps directly to local except handler. Refactor to 'match/case' "
230
- f"(e.g., 'case str():') to handle types directly."
231
- ),
232
- )
233
- )
234
- break
235
- self.generic_visit(node)
236
170
 
237
171
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
238
172
  self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
239
- saved_stack = self.try_stack
240
- self.try_stack = []
241
173
  self.generic_visit(node)
242
- self.try_stack = saved_stack
243
174
 
244
175
  def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
245
176
  self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
246
- saved_stack = self.try_stack
247
- self.try_stack = []
248
177
  self.generic_visit(node)
249
- self.try_stack = saved_stack
250
178
 
251
179
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
252
180
  self.generic_visit(node)
253
181
 
254
182
 
255
183
  class PreferMatchTypeDispatch(Rule):
256
- """Prefer match/case over try/raise control flow and sequential type guards."""
184
+ """Prefer match/case over sequential sentinel/type guards."""
257
185
 
258
186
  id: str = "prefer-match-type-dispatch"
259
187
  code: str = "SARJ080"
260
- description: str = (
261
- "Control-flow raise in try block or sequential type guards — prefer Python 3.10+ match/case pattern matching."
262
- )
188
+ description: str = "Sequential sentinel/type guards — prefer Python 3.10+ match/case pattern matching."
263
189
 
264
190
  @override
265
191
  def check(self, path: Path, source: str) -> list[Diagnostic]:
@@ -1,96 +0,0 @@
1
- """Shared comment tokenizer for the suppression-directive rules (SARJ038, SARJ054).
2
-
3
- Comments do not survive `ast.parse`, so any rule that judges a suppression
4
- *comment* has to lex the file. Both suppression rules need the same three facts
5
- about every comment — its text, whether it stands alone on its line, and whether
6
- it precedes the module's first statement — so they share one scanner rather than
7
- drifting apart on what "file-level" means.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from dataclasses import dataclass, replace
13
- import io
14
- import tokenize
15
-
16
-
17
- # Sentinel row for "this file has no statement at all" (empty / comments-only):
18
- # every comment then counts as preceding the first statement.
19
- _NO_STATEMENT_LINE = 1 << 30
20
-
21
- _LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
22
- _NON_STATEMENT_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
23
-
24
-
25
- @dataclass(frozen=True, slots=True)
26
- class Comment:
27
- """One comment token, with the context that decides whether it is file-level."""
28
-
29
- line: int
30
- col: int
31
- body: str
32
- standalone: bool
33
- before_first_statement: bool = False
34
-
35
-
36
- _last_scan: tuple[str, list[Comment]] | None = None
37
-
38
-
39
- def scan_comments(source: str) -> list[Comment]:
40
- """Tokenize `source` and describe every comment in it.
41
-
42
- A comment is standalone when no token ended on its line before it, and
43
- precedes the first statement when it sits above the first non-comment,
44
- non-layout token.
45
-
46
- Anything `tokenize` cannot lex propagates as `tokenize.TokenError`,
47
- `IndentationError` or `SyntaxError`; callers treat that as "no diagnostics".
48
-
49
- Memoized in a single slot, like `_comments._scan_memo` and
50
- `rule_base.parse_or_none`: both suppression rules ask the same question about
51
- the same file, and the CLI runs rules per file, so one slot removes the
52
- second tokenize pass. `Comment` is frozen and the returned list is read-only
53
- to callers.
54
-
55
- Returns:
56
- Every comment, in source order.
57
-
58
- """
59
- global _last_scan # ruff: ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
60
- if _last_scan is not None and _last_scan[0] is source:
61
- return _last_scan[1]
62
- result = _scan(source)
63
- _last_scan = (source, result)
64
- return result
65
-
66
-
67
- def _scan(source: str) -> list[Comment]:
68
- comments: list[Comment] = []
69
- first_statement_line = _NO_STATEMENT_LINE
70
- prev_end_row = 0
71
- readline = io.StringIO(source).readline
72
- for tok in tokenize.generate_tokens(readline):
73
- if tok.type == tokenize.COMMENT:
74
- comments.append(
75
- Comment(
76
- line=tok.start[0],
77
- col=tok.start[1] + 1,
78
- body=comment_body(tok.string),
79
- standalone=tok.start[0] != prev_end_row,
80
- )
81
- )
82
- if tok.type not in _LAYOUT_TOKENS:
83
- prev_end_row = tok.end[0]
84
- if tok.type not in _NON_STATEMENT_TOKENS:
85
- first_statement_line = min(first_statement_line, tok.start[0])
86
- return [replace(c, before_first_statement=c.line < first_statement_line) for c in comments]
87
-
88
-
89
- def comment_body(raw: str) -> str:
90
- """Strip a comment token down to its directive text.
91
-
92
- Returns:
93
- The comment text without its leading `#` markers or surrounding space.
94
-
95
- """
96
- return raw.lstrip("#").strip()