sarj-python-lint 0.80.3__tar.gz → 0.80.5__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 (130) hide show
  1. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/PKG-INFO +2 -2
  2. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/pyproject.toml +3 -3
  3. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/_ratchet_cli.py +7 -0
  4. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_registry.py +4 -0
  5. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/excessive_commentary.py +218 -0
  6. sarj_python_lint-0.80.5/src/sarj_python_lint/rules/typed_error_reasons.py +228 -0
  7. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/.gitignore +0 -0
  8. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/LICENSE +0 -0
  9. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/README.md +0 -0
  10. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/_analysis_session.py +0 -0
  13. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_imports.py +0 -0
  26. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_logging.py +0 -0
  27. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_paths.py +0 -0
  28. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_project_index.py +0 -0
  29. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  30. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_pytest.py +0 -0
  31. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  35. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  36. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  37. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  38. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  39. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  40. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  41. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  42. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  43. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  44. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  45. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  46. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  47. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  48. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  49. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  50. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  51. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  52. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  53. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  54. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  55. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  56. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  57. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  58. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  59. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  60. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  61. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  62. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  63. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  64. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  65. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  66. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  67. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  68. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  69. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  70. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  71. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  72. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  73. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  74. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  75. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  76. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  77. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  78. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  79. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  80. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  81. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  82. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  83. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  84. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  85. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  86. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  87. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  88. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  89. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  90. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  91. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  92. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -0
  93. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  94. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  95. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  96. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  97. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  98. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  99. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  100. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  101. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  102. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  103. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  104. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -0
  105. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -0
  106. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  107. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  108. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  109. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  110. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  111. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  112. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  113. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  114. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  115. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  116. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  117. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  118. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  119. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -0
  120. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  121. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  122. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -0
  123. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/stepdown.py +0 -0
  124. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  125. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  126. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  127. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  128. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  129. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  130. {sarj_python_lint-0.80.3 → sarj_python_lint-0.80.5}/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.80.3
3
+ Version: 0.80.5
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/
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Requires-Python: >=3.14
20
- Requires-Dist: ruff>=0.16.3
20
+ Requires-Dist: ruff>=0.16.5
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
  <!-- Generated by `code-standards maintain docs sync`; do not edit. -->
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.80.3"
3
+ version = "0.80.5"
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" }]
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3.14",
16
16
  "Topic :: Software Development :: Quality Assurance",
17
17
  ]
18
- dependencies = ["ruff>=0.16.3"]
18
+ dependencies = ["ruff>=0.16.5"]
19
19
 
20
20
  [project.scripts]
21
21
  sarj-python-lint = "sarj_python_lint.__main__:main"
@@ -31,7 +31,7 @@ Issues = "https://github.com/sarj-ai/code-standards/issues"
31
31
  [dependency-groups]
32
32
  dev = [
33
33
  "pytest>=9.1.1",
34
- "ruff>=0.16.3",
34
+ "ruff>=0.16.5",
35
35
  "basedpyright>=1.39.10",
36
36
  ]
37
37
 
@@ -155,6 +155,13 @@ def _ruff_selector_aliases() -> dict[str, str]:
155
155
  raise TypeError(msg)
156
156
  code = raw_item.get("code")
157
157
  name = raw_item.get("name")
158
+ if code is None and isinstance(name, str):
159
+ # Ruff accepts the name as the stable selector until it allocates
160
+ # a code, so preserve that suppression identity rather than
161
+ # rejecting a valid Ruff directive.
162
+ canonical = name.lower()
163
+ aliases[canonical] = canonical
164
+ continue
158
165
  if not isinstance(code, str) or not isinstance(name, str):
159
166
  msg = "Ruff rule catalog entry lacks a string code or name"
160
167
  raise TypeError(msg)
@@ -10,6 +10,7 @@ from sarj_python_lint.rules.docstring_args_restate_signature import (
10
10
  from sarj_python_lint.rules.docstring_returns_restate_signature import (
11
11
  DocstringReturnsRestateSignature,
12
12
  )
13
+ from sarj_python_lint.rules.excessive_commentary import ExcessiveCommentary
13
14
  from sarj_python_lint.rules.fakes_in_shared_location import FakesInSharedLocation
14
15
  from sarj_python_lint.rules.fastapi_explicit_openapi_contract import FastapiExplicitOpenapiContract
15
16
  from sarj_python_lint.rules.iac_source_coupled_test import IacSourceCoupledTest
@@ -152,6 +153,7 @@ from sarj_python_lint.rules.store_insert_requires_on_conflict import (
152
153
  from sarj_python_lint.rules.timestamp_order_requires_tiebreaker import TimestampOrderRequiresTiebreaker
153
154
  from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarration
154
155
  from sarj_python_lint.rules.trivially_true_assertion import TriviallyTrueAssertion
156
+ from sarj_python_lint.rules.typed_error_reasons import TypedErrorReasons
155
157
  from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
156
158
  from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
157
159
 
@@ -163,6 +165,7 @@ if TYPE_CHECKING:
163
165
 
164
166
  REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
165
167
  {
168
+ ExcessiveCommentary.id: ExcessiveCommentary,
166
169
  PreferWalrusAwaitedNoneGuard.id: PreferWalrusAwaitedNoneGuard,
167
170
  TimestampOrderRequiresTiebreaker.id: TimestampOrderRequiresTiebreaker,
168
171
  NoStringConcatInLoop.id: NoStringConcatInLoop,
@@ -260,6 +263,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
260
263
  RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
261
264
  RequireNoDecodeForSplittingSettingsField.id: RequireNoDecodeForSplittingSettingsField,
262
265
  NoVagueSuppressionDescription.id: NoVagueSuppressionDescription,
266
+ TypedErrorReasons.id: TypedErrorReasons,
263
267
  }
264
268
  )
265
269
 
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ import re
6
+ from typing import TYPE_CHECKING, final, override
7
+
8
+ from sarj_python_lint.rule_base import (
9
+ AutofixPolicy,
10
+ Diagnostic,
11
+ ExampleFile,
12
+ ExampleOutcome,
13
+ Rule,
14
+ RuleCategory,
15
+ RuleDocumentation,
16
+ RuleExample,
17
+ parse_or_none,
18
+ )
19
+ from sarj_python_lint.rules._prose_budget import (
20
+ groups,
21
+ has_technical_anchor,
22
+ )
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from pathlib import Path
27
+
28
+
29
+ _MIN_LINES = 4
30
+ _MIN_WORDS = 28
31
+ _RATIONALE_RE = re.compile(
32
+ r"\b(?:because|otherwise|therefore|must|never|cannot|can't|required?|invariant|"
33
+ r"compatibility|security|race|atomic|deadlock|rollback|lock|data loss)\b",
34
+ re.IGNORECASE,
35
+ )
36
+ _RATIONALE_SO_RE = re.compile(r"\bso\s+(?:that|a|an|the|this|it|we|they)\b", re.IGNORECASE)
37
+ _BULLET_RE = re.compile(r"^\s*(?:[-*+] |\d+[.)] )", re.MULTILINE)
38
+ _ACCUMULATOR_METHODS = frozenset({"add", "append", "extend", "update"})
39
+ _MIN_ACCUMULATOR_CALLS = 2
40
+
41
+
42
+ @final
43
+ class ExcessiveCommentary(Rule):
44
+ id = "excessive-commentary"
45
+ code = "SARJ434"
46
+ documentation = RuleDocumentation(
47
+ summary="Long standalone implementation commentary — make the code self-documenting and retain only durable constraints.",
48
+ rationale=(
49
+ "A paragraph that narrates nearby implementation behavior competes with the code and can drift independently "
50
+ "from it."
51
+ ),
52
+ remediation=(
53
+ "Delete the narration and clarify names, types, or structure. Keep concise comments that record a durable "
54
+ "constraint or externally owned contract."
55
+ ),
56
+ category=RuleCategory.MAINTAINABILITY,
57
+ autofix=AutofixPolicy.NONE,
58
+ limitations=(
59
+ "Only contiguous standalone line-comment blocks with at least four non-empty lines and 28 words are inspected.",
60
+ "The paragraph must immediately precede an empty local collection that is then populated by at least two consecutive accumulator calls.",
61
+ "Generated files, directives, licenses, structured lists, docstrings, inline comments, rationale markers, and concrete technical anchors are excluded.",
62
+ ),
63
+ examples=(
64
+ RuleExample(
65
+ example_id="activation-narration",
66
+ title="Implementation paragraph narrates a validation helper",
67
+ outcome=ExampleOutcome.MATCH,
68
+ files=(
69
+ ExampleFile.python(
70
+ "app.py",
71
+ "def activation_reasons():\n"
72
+ " # Everything standing between this integration and being usable.\n"
73
+ " # Returns all the reasons rather than the first failure.\n"
74
+ " # Someone activating a half-built integration wants the complete list.\n"
75
+ " # That avoids discovering one problem per round trip.\n"
76
+ " reasons = []\n"
77
+ " reasons.extend(integration_reasons())\n"
78
+ " reasons.extend(endpoint_reasons())\n"
79
+ " return reasons\n",
80
+ ),
81
+ ),
82
+ focus_path=PurePosixPath("app.py"),
83
+ expected_count=1,
84
+ public=True,
85
+ ),
86
+ RuleExample(
87
+ example_id="durable-constraint",
88
+ title="A concrete compatibility constraint remains local",
89
+ outcome=ExampleOutcome.NO_MATCH,
90
+ files=(
91
+ ExampleFile.python(
92
+ "app.py",
93
+ "# Legacy clients send `execution_phase` until API-812 is retired.\n"
94
+ "# Keep the adapter at this boundary so internal models stay camelCase.\n"
95
+ 'phase = payload["execution_phase"]\n',
96
+ ),
97
+ ),
98
+ focus_path=PurePosixPath("app.py"),
99
+ expected_count=0,
100
+ public=True,
101
+ ),
102
+ ),
103
+ )
104
+ description = documentation.summary
105
+
106
+ @override
107
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
108
+ tree = parse_or_none(path, source)
109
+ if tree is None:
110
+ return []
111
+ accumulator_lines = _narrated_accumulator_lines(tree)
112
+ findings: list[Diagnostic] = []
113
+ for group in groups(path, source):
114
+ lines = tuple(stripped for line in group.text.splitlines() if (stripped := line.strip()))
115
+ if group.kind != "comment" or len(lines) < _MIN_LINES or len(group.text.split()) < _MIN_WORDS:
116
+ continue
117
+ comment_end_line = group.line + len(group.text.splitlines()) - 1
118
+ if comment_end_line + 1 not in accumulator_lines:
119
+ continue
120
+ if any(
121
+ (
122
+ _BULLET_RE.search(group.text),
123
+ has_technical_anchor(group.text),
124
+ _RATIONALE_RE.search(group.text),
125
+ _RATIONALE_SO_RE.search(group.text),
126
+ )
127
+ ):
128
+ continue
129
+ findings.append(
130
+ Diagnostic(
131
+ path,
132
+ group.line,
133
+ group.col,
134
+ self.code,
135
+ self.description,
136
+ column_encoding=group.column_encoding,
137
+ )
138
+ )
139
+ return findings
140
+
141
+
142
+ def _narrated_accumulator_lines(tree: ast.Module) -> set[int]:
143
+ lines: set[int] = set()
144
+ for function in ast.walk(tree):
145
+ if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)):
146
+ continue
147
+ _collect_accumulators(function.body, lines)
148
+ return lines
149
+
150
+
151
+ def _collect_accumulators(statements: list[ast.stmt], lines: set[int]) -> None:
152
+ for index, statement in enumerate(statements):
153
+ name = _empty_collection_name(statement)
154
+ if name is not None:
155
+ mutations = 0
156
+ for following in statements[index + 1 :]:
157
+ if not _is_accumulator_call(following, name):
158
+ break
159
+ mutations += 1
160
+ if mutations >= _MIN_ACCUMULATOR_CALLS:
161
+ lines.add(statement.lineno)
162
+ for child_statements in _nested_statement_lists(statement):
163
+ _collect_accumulators(child_statements, lines)
164
+
165
+
166
+ def _empty_collection_name(statement: ast.stmt) -> str | None:
167
+ target: ast.expr | None = None
168
+ value: ast.expr | None = None
169
+ if isinstance(statement, ast.AnnAssign):
170
+ target, value = statement.target, statement.value
171
+ elif isinstance(statement, ast.Assign) and len(statement.targets) == 1:
172
+ target, value = statement.targets[0], statement.value
173
+ if not isinstance(target, ast.Name) or value is None:
174
+ return None
175
+ if isinstance(value, (ast.List, ast.Set)) and not value.elts:
176
+ return target.id
177
+ if isinstance(value, ast.Dict) and not value.keys:
178
+ return target.id
179
+ if (
180
+ isinstance(value, ast.Call)
181
+ and isinstance(value.func, ast.Name)
182
+ and value.func.id in {"dict", "list", "set"}
183
+ and not value.args
184
+ and not value.keywords
185
+ ):
186
+ return target.id
187
+ return None
188
+
189
+
190
+ def _is_accumulator_call(statement: ast.stmt, name: str) -> bool:
191
+ if not isinstance(statement, ast.Expr) or not isinstance(statement.value, ast.Call):
192
+ return False
193
+ function = statement.value.func
194
+ return (
195
+ isinstance(function, ast.Attribute)
196
+ and function.attr in _ACCUMULATOR_METHODS
197
+ and isinstance(function.value, ast.Name)
198
+ and function.value.id == name
199
+ )
200
+
201
+
202
+ def _nested_statement_lists(statement: ast.stmt) -> list[list[ast.stmt]]:
203
+ match statement:
204
+ case ast.For() | ast.AsyncFor() | ast.While() | ast.If():
205
+ return [statement.body, statement.orelse]
206
+ case ast.With() | ast.AsyncWith():
207
+ return [statement.body]
208
+ case ast.Try() | ast.TryStar():
209
+ return [
210
+ statement.body,
211
+ statement.orelse,
212
+ statement.finalbody,
213
+ *(handler.body for handler in statement.handlers),
214
+ ]
215
+ case ast.Match():
216
+ return [case.body for case in statement.cases]
217
+ case _:
218
+ return []
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, TypeGuard, final, override
6
+
7
+ from sarj_python_lint.rule_base import (
8
+ AutofixPolicy,
9
+ Diagnostic,
10
+ ExampleFile,
11
+ ExampleOutcome,
12
+ Rule,
13
+ RuleCategory,
14
+ RuleDocumentation,
15
+ RuleExample,
16
+ Severity,
17
+ parse_or_none,
18
+ )
19
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterator
24
+ from pathlib import Path
25
+
26
+
27
+ _ERROR_SUFFIXES = ("Error", "Exception")
28
+
29
+
30
+ @final
31
+ class TypedErrorReasons(Rule):
32
+ id = "typed-error-reasons"
33
+ code = "SARJ435"
34
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
35
+ summary="Exception aggregates presentation strings instead of typed error reasons.",
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."
39
+ ),
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."
43
+ ),
44
+ category=RuleCategory.ARCHITECTURE,
45
+ autofix=AutofixPolicy.NONE,
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__(...)`.",
49
+ "Formatting delegated to another function, legacy `typing.List`, mixed constructor context, tests, and generated files are intentionally not inferred.",
50
+ ),
51
+ examples=(
52
+ RuleExample(
53
+ example_id="rendered-reason-list",
54
+ title="An exception renders raw reason strings",
55
+ outcome=ExampleOutcome.MATCH,
56
+ files=(
57
+ ExampleFile.python(
58
+ "app/errors.py",
59
+ "class DomainError(Exception): ...\n\n"
60
+ "class IncompleteError(DomainError):\n"
61
+ " def __init__(self, reasons: list[str]) -> None:\n"
62
+ " self.reasons = reasons\n"
63
+ " super().__init__(f\"Incomplete: {'; '.join(reasons)}\")\n",
64
+ ),
65
+ ),
66
+ focus_path=PurePosixPath("app/errors.py"),
67
+ expected_count=1,
68
+ public=True,
69
+ ),
70
+ RuleExample(
71
+ example_id="typed-reason-list",
72
+ title="An exception carries nominal reason records",
73
+ outcome=ExampleOutcome.NO_MATCH,
74
+ files=(
75
+ ExampleFile.python(
76
+ "app/errors.py",
77
+ "from dataclasses import dataclass\n"
78
+ "from enum import StrEnum\n\n"
79
+ "class ReasonCode(StrEnum):\n"
80
+ " MISSING_URL = 'missing_url'\n\n"
81
+ "@dataclass(frozen=True)\n"
82
+ "class Reason:\n"
83
+ " code: ReasonCode\n"
84
+ " field: str | None = None\n\n"
85
+ "class DomainError(Exception): ...\n\n"
86
+ "class IncompleteError(DomainError):\n"
87
+ " def __init__(self, reasons: list[Reason]) -> None:\n"
88
+ " self.reasons = reasons\n"
89
+ " super().__init__('Integration is incomplete')\n",
90
+ ),
91
+ ),
92
+ focus_path=PurePosixPath("app/errors.py"),
93
+ expected_count=0,
94
+ public=True,
95
+ ),
96
+ ),
97
+ )
98
+ description = documentation.summary
99
+
100
+ @override
101
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
102
+ if path.suffix != ".py" or is_test_path(path) or is_generated(path, source):
103
+ return []
104
+ if "list[str]" not in source or ".join(" not in source or "super(" not in source:
105
+ return []
106
+ tree = parse_or_none(path, source)
107
+ if tree is None:
108
+ return []
109
+
110
+ diagnostics: list[Diagnostic] = []
111
+ for error_class in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)):
112
+ if not _is_conventional_exception(error_class):
113
+ continue
114
+ constructor = next(
115
+ (
116
+ member
117
+ for member in error_class.body
118
+ if isinstance(member, ast.FunctionDef) and member.name == "__init__"
119
+ ),
120
+ None,
121
+ )
122
+ if constructor is None:
123
+ continue
124
+ parameter = _sole_string_list_parameter(constructor)
125
+ if parameter is None:
126
+ continue
127
+ joined = _joined_in_super_message(constructor, parameter.arg)
128
+ if joined is None:
129
+ continue
130
+ diagnostics.append(
131
+ Diagnostic(
132
+ path=path,
133
+ line=joined.lineno,
134
+ col=joined.col_offset + 1,
135
+ code=self.code,
136
+ severity=Severity.WARNING,
137
+ 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"
140
+ ),
141
+ )
142
+ )
143
+ return sorted(diagnostics, key=lambda diagnostic: (diagnostic.line, diagnostic.col))
144
+
145
+
146
+ def _is_conventional_exception(node: ast.ClassDef) -> bool:
147
+ return node.name.endswith(_ERROR_SUFFIXES) and any(_tail(base).endswith(_ERROR_SUFFIXES) for base in node.bases)
148
+
149
+
150
+ def _tail(node: ast.expr) -> str:
151
+ match node:
152
+ case ast.Name(id=name) | ast.Attribute(attr=name):
153
+ return name
154
+ case _:
155
+ return ""
156
+
157
+
158
+ def _sole_string_list_parameter(function: ast.FunctionDef) -> ast.arg | None:
159
+ parameters = [
160
+ argument
161
+ for argument in (
162
+ *function.args.posonlyargs,
163
+ *function.args.args,
164
+ *function.args.kwonlyargs,
165
+ )
166
+ if argument.arg not in {"self", "cls"}
167
+ ]
168
+ if len(parameters) != 1:
169
+ return None
170
+ parameter = parameters[0]
171
+ return parameter if _is_string_list(parameter.annotation) else None
172
+
173
+
174
+ def _is_string_list(annotation: ast.expr | None) -> bool:
175
+ return (
176
+ isinstance(annotation, ast.Subscript)
177
+ and isinstance(annotation.value, ast.Name)
178
+ and annotation.value.id == "list"
179
+ and isinstance(annotation.slice, ast.Name)
180
+ and annotation.slice.id == "str"
181
+ )
182
+
183
+
184
+ def _joined_in_super_message(function: ast.FunctionDef, parameter: str) -> ast.Call | None:
185
+ for node in _own_scope_nodes(function):
186
+ if not _is_super_init_call(node):
187
+ continue
188
+ for argument in node.args:
189
+ joined = next(
190
+ (candidate for candidate in ast.walk(argument) if _joins_parameter(candidate, parameter)),
191
+ None,
192
+ )
193
+ if joined is not None:
194
+ return joined
195
+ return None
196
+
197
+
198
+ def _own_scope_nodes(function: ast.FunctionDef) -> Iterator[ast.AST]:
199
+ stack: list[ast.AST] = list(function.body)
200
+ while stack:
201
+ node = stack.pop()
202
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
203
+ continue
204
+ yield node
205
+ stack.extend(ast.iter_child_nodes(node))
206
+
207
+
208
+ def _is_super_init_call(node: ast.AST) -> TypeGuard[ast.Call]:
209
+ return (
210
+ isinstance(node, ast.Call)
211
+ and isinstance(node.func, ast.Attribute)
212
+ and node.func.attr == "__init__"
213
+ and isinstance(node.func.value, ast.Call)
214
+ and isinstance(node.func.value.func, ast.Name)
215
+ and node.func.value.func.id == "super"
216
+ )
217
+
218
+
219
+ def _joins_parameter(node: ast.AST, parameter: str) -> TypeGuard[ast.Call]:
220
+ return (
221
+ isinstance(node, ast.Call)
222
+ and isinstance(node.func, ast.Attribute)
223
+ and node.func.attr == "join"
224
+ and len(node.args) == 1
225
+ and not node.keywords
226
+ and isinstance(node.args[0], ast.Name)
227
+ and node.args[0].id == parameter
228
+ )