sarj-python-lint 0.69.0__tar.gz → 0.69.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +109 -26
  4. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/.gitignore +0 -0
  5. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/LICENSE +0 -0
  6. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/README.md +0 -0
  7. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/_filesystem.py +0 -0
  10. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  11. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/_secret_names.py +0 -0
  12. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/_version.py +0 -0
  13. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/py.typed +0 -0
  14. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/ratchet.py +0 -0
  15. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rule_base.py +0 -0
  16. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  17. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  18. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  19. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  20. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  21. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  22. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  23. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  24. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  25. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  26. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  27. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  28. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  29. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  30. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  31. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  32. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  33. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  34. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  35. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  36. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  37. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  38. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  39. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  40. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  41. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  42. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  43. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  44. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  45. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  46. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  47. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  48. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  49. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  50. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  51. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  52. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  53. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  54. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  55. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  56. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  57. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  58. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  59. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  60. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  61. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  62. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  63. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  64. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  65. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  66. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  67. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  68. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  69. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  70. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  71. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  72. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  73. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  74. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  75. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  76. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  77. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  78. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  79. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  80. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  81. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  82. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  83. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  84. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  85. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  86. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  87. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  88. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  89. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  90. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  91. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  92. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  93. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  94. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  95. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  96. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  97. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  98. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  99. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  100. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  101. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  102. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  103. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  104. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  105. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  106. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  107. {sarj_python_lint-0.69.0 → sarj_python_lint-0.69.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.69.0
3
+ Version: 0.69.1
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.69.0"
3
+ version = "0.69.1"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -18,6 +18,7 @@ from sarj_python_lint.rule_base import (
18
18
  RuleCategory,
19
19
  RuleDocumentation,
20
20
  RuleExample,
21
+ Severity,
21
22
  parse_or_none,
22
23
  )
23
24
  from sarj_python_lint.rules._paths import is_generated, is_test_path
@@ -29,7 +30,8 @@ if TYPE_CHECKING:
29
30
 
30
31
  _GENERIC_EXCEPTIONS = frozenset({"Exception", "BaseException"})
31
32
  _MIN_SENTINEL_COUNT = 2
32
- _MIN_TYPE_DISPATCH_BRANCHES = 3
33
+ _MIN_TYPE_DISPATCH_BRANCHES = 2
34
+ _ERROR_TYPE_DISPATCH_BRANCHES = 3
33
35
  _ISINSTANCE_ARG_COUNT = 2
34
36
  _PAIR_COUNT = 2
35
37
 
@@ -238,7 +240,7 @@ def _matchable_class_reference(
238
240
  case ast.Name(id=name):
239
241
  if name in runtime_tuple_aliases:
240
242
  return False
241
- return name in _MATCHABLE_BUILTIN_TYPES or (name[:1].isupper() and not name.isupper())
243
+ return name in _MATCHABLE_BUILTIN_TYPES or _looks_like_class_name(name)
242
244
  case ast.Attribute(value=value, attr=attribute):
243
245
  lowercase_ast_type = (
244
246
  isinstance(value, ast.Name)
@@ -246,15 +248,19 @@ def _matchable_class_reference(
246
248
  and attribute in _MATCHABLE_LOWERCASE_AST_TYPES
247
249
  )
248
250
  matchable_leaf = (
249
- attribute in _MATCHABLE_BUILTIN_TYPES
250
- or (attribute[:1].isupper() and not attribute.isupper())
251
- or lowercase_ast_type
251
+ attribute in _MATCHABLE_BUILTIN_TYPES or _looks_like_class_name(attribute) or lowercase_ast_type
252
252
  )
253
253
  return matchable_leaf and _is_dotted_name(value)
254
254
  case _:
255
255
  return False
256
256
 
257
257
 
258
+ def _looks_like_class_name(name: str) -> bool:
259
+ """Recognize ordinary and private CamelCase runtime class references."""
260
+ unprefixed = name.lstrip("_")
261
+ return bool(unprefixed) and unprefixed[:1].isupper() and not unprefixed.isupper()
262
+
263
+
258
264
  def _is_dotted_name(node: ast.expr) -> bool:
259
265
  """Report whether `node` is the prefix of a pattern-compatible dotted name."""
260
266
  if isinstance(node, ast.Name):
@@ -279,30 +285,74 @@ def _is_matchable_raise_guard(test: ast.expr, runtime_tuple_aliases: frozenset[s
279
285
  )
280
286
 
281
287
 
282
- def _isinstance_ladder(node: ast.If, runtime_tuple_aliases: frozenset[str]) -> tuple[str, int] | None:
288
+ def _isinstance_ladder(
289
+ node: ast.If,
290
+ runtime_tuple_aliases: frozenset[str],
291
+ ast_module_names: frozenset[str],
292
+ ) -> tuple[str, int] | None:
283
293
  """Recognize a complete if/elif chain dispatching one name by runtime type."""
294
+ branches = _if_ladder(node)
295
+ branch_count = len(branches)
296
+ if branch_count < _MIN_TYPE_DISPATCH_BRANCHES:
297
+ return None
298
+
284
299
  subject: str | None = None
285
- branch_count = 0
286
- current = node
287
- while True:
288
- branch_subject = _matchable_isinstance_test(current.test, runtime_tuple_aliases)
300
+ for branch in branches:
301
+ # Guarded ladders are part of the newly calibrated two-arm advisory.
302
+ # Preserve SARJ080's established 3+ ladder boundary to avoid promoting
303
+ # an uncalibrated population directly to blocking diagnostics.
304
+ branch_subject = (
305
+ _matchable_isinstance_dispatch(branch.test, runtime_tuple_aliases, frozenset())
306
+ if branch_count < _ERROR_TYPE_DISPATCH_BRANCHES
307
+ else _matchable_isinstance_test(branch.test, runtime_tuple_aliases)
308
+ )
289
309
  if branch_subject is None or (subject is not None and branch_subject != subject):
290
310
  return None
291
311
  subject = branch_subject
292
- branch_count += 1
293
312
 
294
- if not current.orelse:
295
- break
296
- if len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
297
- current = current.orelse[0]
298
- continue
299
- break
300
-
301
- if branch_count < _MIN_TYPE_DISPATCH_BRANCHES:
313
+ if branch_count < _ERROR_TYPE_DISPATCH_BRANCHES and any(
314
+ _dispatch_uses_stdlib_ast(branch.test, ast_module_names) for branch in branches
315
+ ):
316
+ return None
317
+ if subject is None:
302
318
  return None
303
319
  return subject, branch_count
304
320
 
305
321
 
322
+ def _if_ladder(node: ast.If) -> tuple[ast.If, ...]:
323
+ """Return the complete maximal if/elif chain rooted at ``node``."""
324
+ branches = [node]
325
+ current = node
326
+ while len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
327
+ current = current.orelse[0]
328
+ branches.append(current)
329
+ return tuple(branches)
330
+
331
+
332
+ def _dispatch_uses_stdlib_ast(test: ast.expr, ast_module_names: frozenset[str]) -> bool:
333
+ """Report whether a dispatch arm directly names a proven stdlib ``ast`` class."""
334
+ call = _leading_isinstance(test)
335
+ if call is None or len(call.args) != _ISINSTANCE_ARG_COUNT:
336
+ return False
337
+ return any(
338
+ isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in ast_module_names
339
+ for node in ast.walk(call.args[1])
340
+ )
341
+
342
+
343
+ def _leading_isinstance(test: ast.expr) -> ast.Call | None:
344
+ """Return the leading isinstance call from a plain or guarded dispatch arm."""
345
+ candidate = test.values[0] if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And) else test
346
+ if isinstance(candidate, ast.Call) and isinstance(candidate.func, ast.Name) and candidate.func.id == "isinstance":
347
+ return candidate
348
+ return None
349
+
350
+
351
+ def _type_dispatch_severity(branch_count: int) -> Severity:
352
+ """Keep newly expanded two-arm findings advisory while 3+ remains blocking."""
353
+ return Severity.WARNING if branch_count < _ERROR_TYPE_DISPATCH_BRANCHES else Severity.ERROR
354
+
355
+
306
356
  def _sequential_isinstance_dispatches(
307
357
  tree: ast.Module, path: Path, code: str, runtime_tuple_aliases: frozenset[str]
308
358
  ) -> list[Diagnostic]:
@@ -336,15 +386,28 @@ def _sequential_isinstance_dispatches(
336
386
  break
337
387
  end += 1
338
388
  branch_count = end - index
389
+ adjacent_if = (index > 0 and isinstance(statements[index - 1], ast.If)) or (
390
+ end < len(statements) and isinstance(statements[end], ast.If)
391
+ )
339
392
  guards = [_passthrough_guard_var(statement) for statement in statements[index:end]]
340
393
  owned_by_sentinel_arm = guards[:_MIN_SENTINEL_COUNT] == [subject] * _MIN_SENTINEL_COUNT
341
- if branch_count >= _MIN_TYPE_DISPATCH_BRANCHES and not owned_by_sentinel_arm:
394
+ exact_two_ast_dispatch = branch_count < _ERROR_TYPE_DISPATCH_BRANCHES and any(
395
+ isinstance(statement, ast.If) and _dispatch_uses_stdlib_ast(statement.test, ast_module_names)
396
+ for statement in statements[index:end]
397
+ )
398
+ if (
399
+ branch_count >= _MIN_TYPE_DISPATCH_BRANCHES
400
+ and not adjacent_if
401
+ and not owned_by_sentinel_arm
402
+ and not exact_two_ast_dispatch
403
+ ):
342
404
  findings.append(
343
405
  Diagnostic(
344
406
  path=path,
345
407
  line=first.lineno,
346
408
  col=first.col_offset + 1,
347
409
  code=code,
410
+ severity=_type_dispatch_severity(branch_count),
348
411
  message=(
349
412
  f"{branch_count}-branch terminating isinstance dispatch on '{subject}' — use "
350
413
  "match/case class patterns so the exclusive type dispatch is explicit."
@@ -396,16 +459,24 @@ def _matchable_isinstance_dispatch(
396
459
  ) -> str | None:
397
460
  """Extract a type-dispatch subject, preserving any trailing ``and`` terms as a case guard."""
398
461
  match test:
399
- case ast.BoolOp(op=ast.And(), values=[leading_isinstance, *_guard_terms]):
400
- return _matchable_isinstance_test(
462
+ case ast.BoolOp(op=ast.And(), values=[leading_isinstance, *guard_terms]):
463
+ subject = _matchable_isinstance_test(
401
464
  leading_isinstance,
402
465
  runtime_tuple_aliases,
403
466
  ast_module_names=ast_module_names,
404
467
  )
468
+ if subject is None or any(_rebinds_name(term, subject) for term in guard_terms):
469
+ return None
470
+ return subject
405
471
  case _:
406
472
  return _matchable_isinstance_test(test, runtime_tuple_aliases)
407
473
 
408
474
 
475
+ def _rebinds_name(node: ast.AST, name: str) -> bool:
476
+ """Reject guards that change the dispatch subject after ``match`` would snapshot it."""
477
+ return any(isinstance(candidate, ast.NamedExpr) and candidate.target.id == name for candidate in ast.walk(node))
478
+
479
+
409
480
  def _body_terminates(body: list[ast.stmt]) -> bool:
410
481
  """Prove a branch cannot fall through to the following sibling statement."""
411
482
  statements = [statement for statement in body if not _is_docstring(statement)]
@@ -419,6 +490,13 @@ def _body_terminates(body: list[ast.stmt]) -> bool:
419
490
  return bool(orelse) and _body_terminates(body) and _body_terminates(orelse)
420
491
  case ast.With() | ast.AsyncWith():
421
492
  return _body_terminates(last.body)
493
+ case ast.Try() | ast.TryStar():
494
+ if last.finalbody and _body_terminates(last.finalbody):
495
+ return True
496
+ normal_path_terminates = _body_terminates(last.body) or (
497
+ bool(last.orelse) and _body_terminates(last.orelse)
498
+ )
499
+ return normal_path_terminates and all(_body_terminates(handler.body) for handler in last.handlers)
422
500
  case _:
423
501
  return False
424
502
 
@@ -683,12 +761,14 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
683
761
  report_control_flow_raise: bool,
684
762
  report_isinstance_ladder: bool,
685
763
  runtime_tuple_aliases: frozenset[str],
764
+ ast_module_names: frozenset[str],
686
765
  ) -> None:
687
766
  self.path: Path = path
688
767
  self.code: str = code
689
768
  self.report_control_flow_raise: bool = report_control_flow_raise
690
769
  self.report_isinstance_ladder: bool = report_isinstance_ladder
691
770
  self.runtime_tuple_aliases: frozenset[str] = runtime_tuple_aliases
771
+ self.ast_module_names: frozenset[str] = ast_module_names
692
772
  self.diags: list[Diagnostic] = []
693
773
  self.try_stack: list[ast.Try | ast.TryStar] = []
694
774
  self.if_stack: list[ast.If] = []
@@ -700,7 +780,7 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
700
780
  while len(continuation.orelse) == 1 and isinstance(continuation.orelse[0], ast.If):
701
781
  continuation = continuation.orelse[0]
702
782
  self.ladder_continuations.add(id(continuation))
703
- ladder = _isinstance_ladder(node, self.runtime_tuple_aliases)
783
+ ladder = _isinstance_ladder(node, self.runtime_tuple_aliases, self.ast_module_names)
704
784
  if ladder is not None:
705
785
  subject, branch_count = ladder
706
786
  self.diags.append(
@@ -709,6 +789,7 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
709
789
  line=node.lineno,
710
790
  col=node.col_offset + 1,
711
791
  code=self.code,
792
+ severity=_type_dispatch_severity(branch_count),
712
793
  message=(
713
794
  f"{branch_count}-branch isinstance dispatch on '{subject}' — use match/case "
714
795
  "class patterns (combining tuple members with `|`) so the type dispatch is explicit."
@@ -815,14 +896,14 @@ class PreferMatchTypeDispatch(Rule):
815
896
  id: str = "prefer-match-type-dispatch"
816
897
  code: str = "SARJ080"
817
898
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
818
- summary="Use `match` for explicit runtime type dispatch instead of branching parser machinery.",
899
+ summary="Use `match` for explicit runtime type dispatch instead of repeated `isinstance` branches or parser machinery.",
819
900
  rationale="Pattern matching makes type cases and sentinel cases explicit without control-flow exceptions or repeated dispatch checks.",
820
901
  remediation="Replace the detected guard or exception-driven dispatch with `match` arms for each supported value shape.",
821
902
  category=RuleCategory.MAINTAINABILITY,
822
903
  autofix=AutofixPolicy.NONE,
823
904
  limitations=(
824
- "The rule targets several measured shapes, including control-flow raises, sequential guards, and repeated `isinstance` dispatch.",
825
- "Generated files, runtime tuple aliases, idiomatic None/Unset prologues, and code that shadows `isinstance` are excluded; test files omit the control-flow-raise check.",
905
+ "Safe two-branch `isinstance` dispatch is advisory; dispatch with three or more branches and the rule's established parser shapes remain blocking.",
906
+ "Generated files, runtime tuple aliases, two-arm stdlib AST visitors, subject-rebinding guards, idiomatic None/Unset prologues, and code that shadows `isinstance` are excluded; test files omit the control-flow-raise check.",
826
907
  ),
827
908
  examples=(
828
909
  RuleExample(
@@ -881,6 +962,7 @@ class PreferMatchTypeDispatch(Rule):
881
962
  if tree is None:
882
963
  return []
883
964
  runtime_tuple_aliases = _runtime_tuple_aliases(tree)
965
+ ast_module_names = _stdlib_ast_module_names(tree)
884
966
 
885
967
  visitor = _TypeDispatchVisitor(
886
968
  path,
@@ -888,6 +970,7 @@ class PreferMatchTypeDispatch(Rule):
888
970
  report_control_flow_raise=not is_test_path(path),
889
971
  report_isinstance_ladder=not _shadows_isinstance(tree),
890
972
  runtime_tuple_aliases=runtime_tuple_aliases,
973
+ ast_module_names=ast_module_names,
891
974
  )
892
975
  visitor.visit(tree)
893
976
  if not _shadows_isinstance(tree):