sarj-python-lint 0.81.3__tar.gz → 0.81.4__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 (126) hide show
  1. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/stepdown.py +71 -19
  4. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/typed_error_reasons.py +45 -9
  5. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/.gitignore +0 -0
  6. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/LICENSE +0 -0
  7. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/README.md +0 -0
  8. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_python_target.py +0 -0
  13. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  26. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_paths.py +0 -0
  29. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_project_index.py +0 -0
  30. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  31. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_pytest.py +0 -0
  32. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_registry.py +0 -0
  33. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  37. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  38. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  41. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  42. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  43. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  47. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  48. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  49. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  50. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  51. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  53. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  54. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  55. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  56. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  57. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  58. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  59. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  60. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  61. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  62. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  63. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  64. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  65. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  66. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  67. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  68. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  69. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  70. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  71. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  72. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  73. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  74. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  75. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  76. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  77. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  78. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  79. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  80. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  81. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  82. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  83. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  84. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  85. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  86. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  87. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  88. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  89. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  90. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  91. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  92. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  94. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  95. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  96. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  97. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  98. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  99. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  100. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  101. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  102. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  103. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  104. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  105. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  106. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  107. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  108. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  109. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  110. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  111. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  112. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  113. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  114. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  115. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  116. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  117. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  118. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  119. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  120. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  121. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  122. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  123. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  124. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  125. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  126. {sarj_python_lint-0.81.3 → sarj_python_lint-0.81.4}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.81.3
3
+ Version: 0.81.4
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.81.3"
3
+ version = "0.81.4"
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" }]
@@ -34,7 +34,7 @@ _SELF_NAMES = frozenset({"self", "cls"})
34
34
 
35
35
  # These decorators preserve an ordinary callable definition and do not register
36
36
  # it through user code at definition time. Unknown decorators are movement barriers.
37
- _ORDER_TRANSPARENT_DECORATORS = frozenset({"classmethod", "staticmethod", "final", "override"})
37
+ _ORDER_TRANSPARENT_DECORATORS = frozenset({"classmethod", "staticmethod"})
38
38
 
39
39
  #: A repeated singledispatch implementation name cannot identify one movable target.
40
40
  _DISCARD_NAME = "_"
@@ -65,6 +65,7 @@ class Stepdown(Rule):
65
65
  limitations=(
66
66
  "Generated files, tests, `__main__.py`, mutual recursion, and helpers with multiple callers are excluded.",
67
67
  "Decorated definitions and dynamic references that cannot prove a sole caller are not reported.",
68
+ "Only bare builtin classmethod/staticmethod decorators without any visible rebinding are treated as transparent; other decorators remain ordering barriers.",
68
69
  ),
69
70
  examples=(
70
71
  RuleExample(
@@ -108,11 +109,14 @@ class Stepdown(Rule):
108
109
  tree = parse_or_none(path, source)
109
110
  if tree is None:
110
111
  return []
111
- diags = _check_module_scope(path, tree, self.code)
112
+ transparent = _unshadowed_builtin_decorators(tree)
113
+ diags = _check_module_scope(path, tree, self.code, transparent)
112
114
  classes = [node for node in _walk(tree) if isinstance(node, ast.ClassDef)]
113
115
  family_external = _family_external_refs(classes)
114
116
  for cls in classes:
115
- diags.extend(_check_class_scope(path, cls, self.code, family_external.get(id(cls), frozenset())))
117
+ diags.extend(
118
+ _check_class_scope(path, cls, self.code, family_external.get(id(cls), frozenset()), transparent)
119
+ )
116
120
  diags.sort(key=lambda d: (d.line, d.col))
117
121
  return diags
118
122
 
@@ -121,7 +125,7 @@ def _last_by_name[DefT: _Def](defs: Sequence[DefT]) -> dict[str, DefT]:
121
125
  return {definition.name: definition for definition in defs}
122
126
 
123
127
 
124
- def _check_module_scope(path: Path, tree: ast.Module, code: str) -> list[Diagnostic]:
128
+ def _check_module_scope(path: Path, tree: ast.Module, code: str, transparent: frozenset[str]) -> list[Diagnostic]:
125
129
  defs = [n for n in tree.body if isinstance(n, _SCOPE_NODES)]
126
130
  counts = Counter(d.name for d in defs)
127
131
  unique_defs = {name: d for d in defs if counts[name := d.name] == 1}
@@ -156,15 +160,19 @@ def _check_module_scope(path: Path, tree: ast.Module, code: str) -> list[Diagnos
156
160
  for name, d in unique_defs.items():
157
161
  if not isinstance(d, _DEF_NODES) or not _is_private_helper_name(name):
158
162
  continue
159
- if name in pinned or name in shadowed or _has_order_sensitive_decorator(d):
163
+ if name in pinned or name in shadowed or _has_order_sensitive_decorator(d, transparent):
160
164
  continue
161
165
  diags.extend(
162
- _flag_if_above_single_caller(path, code, name, node=d, graph=graph, defs=all_defs, ref_lines=ref_lines)
166
+ _flag_if_above_single_caller(
167
+ path, code, name, node=d, graph=graph, defs=all_defs, ref_lines=ref_lines, transparent=transparent
168
+ )
163
169
  )
164
170
  return diags
165
171
 
166
172
 
167
- def _check_class_scope(path: Path, cls: ast.ClassDef, code: str, external_callers: frozenset[str]) -> list[Diagnostic]:
173
+ def _check_class_scope(
174
+ path: Path, cls: ast.ClassDef, code: str, external_callers: frozenset[str], transparent: frozenset[str]
175
+ ) -> list[Diagnostic]:
168
176
  methods = [n for n in cls.body if isinstance(n, _DEF_NODES)]
169
177
  counts = Counter(m.name for m in methods)
170
178
  unique = {name: m for m in methods if counts[name := m.name] == 1}
@@ -197,10 +205,17 @@ def _check_class_scope(path: Path, cls: ast.ClassDef, code: str, external_caller
197
205
  for name, m in unique.items():
198
206
  if not _is_private_helper_name(name):
199
207
  continue
200
- if name in pinned or name in shadowed or name in external_callers or _has_order_sensitive_decorator(m):
208
+ if (
209
+ name in pinned
210
+ or name in shadowed
211
+ or name in external_callers
212
+ or _has_order_sensitive_decorator(m, transparent)
213
+ ):
201
214
  continue
202
215
  diags.extend(
203
- _flag_if_above_single_caller(path, code, name, node=m, graph=graph, defs=all_methods, ref_lines=ref_lines)
216
+ _flag_if_above_single_caller(
217
+ path, code, name, node=m, graph=graph, defs=all_methods, ref_lines=ref_lines, transparent=transparent
218
+ )
204
219
  )
205
220
  return diags
206
221
 
@@ -214,6 +229,7 @@ def _flag_if_above_single_caller(
214
229
  graph: dict[str, set[str]],
215
230
  defs: Mapping[str, ast.stmt],
216
231
  ref_lines: dict[tuple[str, str], int],
232
+ transparent: frozenset[str],
217
233
  ) -> list[Diagnostic]:
218
234
  callers = [c for c, callees in graph.items() if name in callees]
219
235
  if len(callers) != 1:
@@ -224,7 +240,9 @@ def _flag_if_above_single_caller(
224
240
  if isinstance(defs[caller], ast.ClassDef):
225
241
  return []
226
242
  caller_node = defs[caller]
227
- if isinstance(caller_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _has_order_sensitive_decorator(caller_node):
243
+ if isinstance(caller_node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _has_order_sensitive_decorator(
244
+ caller_node, transparent
245
+ ):
228
246
  return []
229
247
  if _reaches(graph, name, caller):
230
248
  return []
@@ -338,15 +356,38 @@ def _is_private_helper_name(name: str) -> bool:
338
356
  return not (name.startswith("__") and name.endswith("__"))
339
357
 
340
358
 
341
- def _has_order_sensitive_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
342
- for decorator in node.decorator_list:
343
- target = decorator.func if isinstance(decorator, ast.Call) else decorator
344
- match target:
345
- case ast.Name(id=name) | ast.Attribute(attr=name) if name in _ORDER_TRANSPARENT_DECORATORS:
346
- continue
359
+ def _unshadowed_builtin_decorators(tree: ast.Module) -> frozenset[str]:
360
+ bound: set[str] = set()
361
+ for node in _walk(tree):
362
+ match node:
363
+ case ast.ImportFrom(names=names) if any(alias.name == "*" for alias in names):
364
+ return frozenset()
365
+ case (
366
+ ast.Name(id=name, ctx=ast.Store() | ast.Del())
367
+ | ast.arg(arg=name)
368
+ | ast.FunctionDef(name=name)
369
+ | ast.AsyncFunctionDef(name=name)
370
+ | ast.ClassDef(name=name)
371
+ ):
372
+ bound.add(name)
373
+ case ast.alias(name=name, asname=asname):
374
+ bound.add(asname or name.split(".")[0])
375
+ case (
376
+ ast.ExceptHandler(name=str() as name)
377
+ | ast.MatchAs(name=str() as name)
378
+ | ast.MatchStar(name=str() as name)
379
+ | ast.MatchMapping(rest=str() as name)
380
+ ):
381
+ bound.add(name)
347
382
  case _:
348
- return True
349
- return False
383
+ pass
384
+ return _ORDER_TRANSPARENT_DECORATORS - bound
385
+
386
+
387
+ def _has_order_sensitive_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef, transparent: frozenset[str]) -> bool:
388
+ return any(
389
+ not isinstance(decorator, ast.Name) or decorator.id not in transparent for decorator in node.decorator_list
390
+ )
350
391
 
351
392
 
352
393
  def _deferred_body(node: ast.stmt) -> list[ast.stmt]:
@@ -626,7 +667,18 @@ def _direct_scope_bindings(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[
626
667
 
627
668
 
628
669
  def _lambda_bindings(node: ast.Lambda) -> set[str]:
629
- return _argument_names(node.args)
670
+ bound = _argument_names(node.args)
671
+ stack: list[ast.AST] = [node.body]
672
+ while stack:
673
+ current = stack.pop()
674
+ if isinstance(current, ast.Lambda):
675
+ stack.extend(current.args.defaults)
676
+ stack.extend(default for default in current.args.kw_defaults if default is not None)
677
+ continue
678
+ if isinstance(current, ast.NamedExpr):
679
+ bound.update(_target_names(current.target))
680
+ stack.extend(_child_nodes(current))
681
+ return bound
630
682
 
631
683
 
632
684
  def _argument_names(args: ast.arguments) -> set[str]:
@@ -32,23 +32,54 @@ class TypedErrorReasons(Rule):
32
32
  id = "typed-error-reasons"
33
33
  code = "SARJ435"
34
34
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
35
- summary="Exception aggregates presentation strings instead of typed error reasons.",
35
+ summary="Review joined exception strings for fixed reason identities versus dynamic context.",
36
36
  rationale=(
37
- "A collection of rendered sentences has no stable identity for API clients, UI formatting, telemetry, "
38
- "or exhaustive handling; consumers must display or parse text that should be presentation-only."
37
+ "Fixed reason identities benefit from stable codes for API clients, UI formatting, telemetry, and "
38
+ "exhaustive handling. Open-ended values such as file paths remain typed context, not an enum domain."
39
39
  ),
40
40
  remediation=(
41
- "Replace `list[str]` with a nominal reason type: usually a record containing a `StrEnum` code and typed "
42
- "context. Format that structure only at the presentation boundary."
41
+ "If the strings are fixed reason identities, use a `StrEnum` code with typed context. Keep dynamic "
42
+ "paths and messages as typed context; format presentation separately."
43
43
  ),
44
44
  category=RuleCategory.ARCHITECTURE,
45
45
  autofix=AutofixPolicy.NONE,
46
46
  limitations=(
47
- "Only an Error/Exception subclass whose direct Error/Exception base is visible in the class declaration is checked.",
48
- "The constructor must have exactly one non-self parameter, annotated exactly `list[str]`, and join that same parameter inside `super().__init__(...)`.",
47
+ "The class name and a direct base name must end in Error or Exception; this naming convention does not prove exception inheritance or resolve shadowed bases.",
48
+ "The constructor must have exactly one non-self parameter and no variadic parameters, annotated exactly `list[str]`, and a literal string separator must join it inside `super().__init__(...)`.",
49
+ "This warning requests manual review; joining strings does not prove a finite reason domain, so contextual string lists may also match and must not automatically become enums.",
49
50
  "Formatting delegated to another function, legacy `typing.List`, mixed constructor context, tests, and generated files are intentionally not inferred.",
50
51
  ),
51
52
  examples=(
53
+ RuleExample(
54
+ example_id="dynamic-context-review",
55
+ title="Review a contextual list without converting paths to enum values",
56
+ outcome=ExampleOutcome.MATCH,
57
+ files=(
58
+ ExampleFile.python(
59
+ "app/errors.py",
60
+ "class MissingFilesError(Exception):\n def __init__(self, paths: list[str]) -> None:\n super().__init__(', '.join(paths))\n",
61
+ ),
62
+ ),
63
+ focus_path=PurePosixPath("app/errors.py"),
64
+ expected_count=1,
65
+ public=True,
66
+ scenario="dynamic-context",
67
+ ),
68
+ RuleExample(
69
+ example_id="dynamic-context-kept-typed",
70
+ title="Keep arbitrary paths as typed context without an enum",
71
+ outcome=ExampleOutcome.NO_MATCH,
72
+ files=(
73
+ ExampleFile.python(
74
+ "app/errors.py",
75
+ "class MissingFilesError(Exception):\n def __init__(self, paths: list[str]) -> None:\n self.paths = paths\n super().__init__('Files are missing')\n\ndef render_error(error: MissingFilesError) -> str:\n return ', '.join(error.paths)\n",
76
+ ),
77
+ ),
78
+ focus_path=PurePosixPath("app/errors.py"),
79
+ expected_count=0,
80
+ public=True,
81
+ scenario="dynamic-context",
82
+ ),
52
83
  RuleExample(
53
84
  example_id="rendered-reason-list",
54
85
  title="An exception renders raw reason strings",
@@ -135,8 +166,9 @@ class TypedErrorReasons(Rule):
135
166
  code=self.code,
136
167
  severity=Severity.WARNING,
137
168
  message=(
138
- f"`{error_class.name}` joins `{parameter.arg}: list[str]` into its message; carry nominal "
139
- "reason codes with typed context and format them at the presentation boundary"
169
+ f"`{error_class.name}` joins `{parameter.arg}: list[str]` into its message; if these are "
170
+ "fixed reason identities, carry typed codes. Keep dynamic context such as paths or messages "
171
+ "as typed context and format presentation separately"
140
172
  ),
141
173
  )
142
174
  )
@@ -156,6 +188,8 @@ def _tail(node: ast.expr) -> str:
156
188
 
157
189
 
158
190
  def _sole_string_list_parameter(function: ast.FunctionDef) -> ast.arg | None:
191
+ if function.args.vararg is not None or function.args.kwarg is not None:
192
+ return None
159
193
  parameters = [
160
194
  argument
161
195
  for argument in (
@@ -221,6 +255,8 @@ def _joins_parameter(node: ast.AST, parameter: str) -> TypeGuard[ast.Call]:
221
255
  isinstance(node, ast.Call)
222
256
  and isinstance(node.func, ast.Attribute)
223
257
  and node.func.attr == "join"
258
+ and isinstance(node.func.value, ast.Constant)
259
+ and isinstance(node.func.value.value, str)
224
260
  and len(node.args) == 1
225
261
  and not node.keywords
226
262
  and isinstance(node.args[0], ast.Name)