sarj-python-lint 1.1.0__tar.gz → 1.2.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 (148) hide show
  1. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/__main__.py +42 -11
  4. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_file_context.py +5 -0
  5. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_ast_index.py +15 -2
  6. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  7. sarj_python_lint-1.2.0/src/sarj_python_lint/rules/_test_composition.py +286 -0
  8. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +2 -2
  9. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +15 -15
  10. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +6 -9
  11. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/mock_without_spec.py +2 -2
  12. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +2 -2
  13. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +1 -1
  14. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +6 -10
  15. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_select_star.py +5 -3
  16. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +9 -11
  17. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +6 -6
  18. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +3 -5
  19. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +6 -4
  20. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +8 -10
  21. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +6 -6
  22. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +8 -6
  23. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +157 -33
  24. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +9 -8
  25. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +5 -4
  26. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +5 -4
  27. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +2 -2
  28. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +4 -4
  29. sarj_python_lint-1.2.0/src/sarj_python_lint/rules/repeated_test_composition.py +286 -0
  30. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +7 -5
  31. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/.gitignore +0 -0
  32. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/LICENSE +0 -0
  33. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/README.md +0 -0
  34. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/__init__.py +0 -0
  35. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  36. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_filesystem.py +0 -0
  37. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_python_target.py +0 -0
  38. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  39. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_secret_names.py +0 -0
  40. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_version.py +0 -0
  41. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/benchmark.py +0 -0
  42. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/json_boundary.py +0 -0
  43. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/py.typed +0 -0
  44. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/ratchet.py +0 -0
  45. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rule_base.py +0 -0
  46. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  47. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  48. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  49. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  50. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  51. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  52. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  53. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  54. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  55. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  56. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  57. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  58. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  59. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  60. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  61. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  62. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  63. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  64. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  65. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  66. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  67. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  68. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  69. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  70. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  71. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  72. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  73. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  74. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  75. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  76. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  77. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  78. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  79. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  80. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  81. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  82. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  83. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  84. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  85. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  86. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  87. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  88. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  89. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  90. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  91. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  92. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  93. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  94. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  95. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  96. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  97. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  98. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  99. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  100. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  101. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  102. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  103. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  104. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  105. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  106. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  107. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  108. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  109. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  110. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  111. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  112. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  113. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  114. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  115. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  116. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  117. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  118. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  119. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  120. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  121. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  122. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  123. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  124. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  125. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  126. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  127. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  128. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  129. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  130. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  131. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  132. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  133. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  134. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  135. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  136. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  137. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  138. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  139. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  140. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  141. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  142. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  143. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  144. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  145. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  146. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  147. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  148. {sarj_python_lint-1.1.0 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 1.1.0
3
+ Version: 1.2.0
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 = "1.1.0"
3
+ version = "1.2.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" }]
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import ast
4
4
  from collections import Counter
5
5
  from dataclasses import dataclass
6
+ from fnmatch import fnmatch
6
7
  import json
7
8
  from pathlib import Path
8
9
  import sys
@@ -66,19 +67,21 @@ def expand_paths(paths: list[Path]) -> list[Path]:
66
67
 
67
68
 
68
69
  def _python_files(p: Path) -> list[Path]:
70
+ if not SKIP_DIR_NAMES.isdisjoint(p.parts):
71
+ return []
69
72
  out: list[Path] = []
70
- for child in p.rglob("*.py"):
71
- if not child.is_file():
72
- continue
73
- if any(part in SKIP_DIR_NAMES for part in child.parts):
74
- continue
75
- try:
76
- if child.stat().st_size > _MAX_FILE_BYTES:
73
+ for directory, directories, filenames in p.walk():
74
+ directories[:] = [name for name in directories if name not in SKIP_DIR_NAMES]
75
+ for name in filenames:
76
+ if not fnmatch(name, "*.py"):
77
77
  continue
78
- except OSError:
79
- continue
80
- out.append(child)
81
- return out
78
+ child = directory / name
79
+ try:
80
+ if child.is_file() and child.stat().st_size <= _MAX_FILE_BYTES:
81
+ out.append(child)
82
+ except OSError:
83
+ continue
84
+ return sorted(out)
82
85
 
83
86
 
84
87
  def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
@@ -157,6 +160,7 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
157
160
  # still owns every fixed dictionary return when selected alone, while
158
161
  # an all-rules run emits one actionable diagnostic for this overlap.
159
162
  "SARJ447": frozenset({"SARJ008"}),
163
+ "SARJ457": frozenset({"SARJ045"}),
160
164
  }
161
165
  )
162
166
 
@@ -171,6 +175,7 @@ class _OwnerLocation(NamedTuple):
171
175
 
172
176
 
173
177
  def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
178
+ diags = _deduplicate_test_composition(diags, source)
174
179
  codes = frozenset(diagnostic.code for diagnostic in diags)
175
180
  needs_docstring_owners = ("SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])) or (
176
181
  "SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
@@ -218,6 +223,32 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
218
223
  ]
219
224
 
220
225
 
226
+ def _deduplicate_test_composition(diags: list[Diagnostic], source: str | None) -> list[Diagnostic]:
227
+ codes = {diagnostic.code for diagnostic in diags}
228
+ if source is None or not {"SARJ066", "SARJ457"} <= codes:
229
+ return diags
230
+ try:
231
+ tree = ast.parse(source)
232
+ except SyntaxError:
233
+ return diags
234
+ whole_tests = {
235
+ (diagnostic.path, diagnostic.line): diagnostic.severity for diagnostic in diags if diagnostic.code == "SARJ066"
236
+ }
237
+ functions = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
238
+
239
+ def redundant(diagnostic: Diagnostic) -> bool:
240
+ if diagnostic.code != "SARJ457":
241
+ return False
242
+ owners = [node for node in functions if node.lineno <= diagnostic.line <= (node.end_lineno or node.lineno)]
243
+ if not owners:
244
+ return False
245
+ owner = max(owners, key=lambda node: node.lineno)
246
+ severity = whole_tests.get((diagnostic.path, owner.lineno))
247
+ return severity is not None and (severity is Severity.ERROR or diagnostic.severity is Severity.WARNING)
248
+
249
+ return [diagnostic for diagnostic in diags if not redundant(diagnostic)]
250
+
251
+
221
252
  def _suppressed_diagnostics(
222
253
  present: dict[tuple[Path, int, int], dict[str, set[Severity]]],
223
254
  ) -> set[tuple[tuple[Path, int, int], str, Severity]]:
@@ -12,6 +12,7 @@ from sarj_python_lint.rules._paths import is_generated
12
12
 
13
13
 
14
14
  if TYPE_CHECKING:
15
+ from collections.abc import Mapping
15
16
  from pathlib import Path
16
17
 
17
18
 
@@ -48,6 +49,10 @@ class PythonFileContext:
48
49
  def nodes[NodeT: ast.AST](self, *types: type[NodeT]) -> list[NodeT]:
49
50
  return [node for node in self.node_index.query(types) if isinstance(node, types)]
50
51
 
52
+ @property
53
+ def parents(self) -> Mapping[ast.AST, ast.AST]:
54
+ return self.node_index.parents
55
+
51
56
  @cached_property
52
57
  def imports(self) -> ImportIndex:
53
58
  return ImportIndex.from_tree(self._valid_tree())
@@ -3,11 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import ast
6
+ from types import MappingProxyType
6
7
  from typing import TYPE_CHECKING, TypeIs, final
7
8
 
8
9
 
9
10
  if TYPE_CHECKING:
10
- from collections.abc import Iterator
11
+ from collections.abc import Iterator, Mapping
11
12
 
12
13
 
13
14
  _AST = ast.AST
@@ -43,7 +44,7 @@ def walk(node: ast.AST, *, index: NodeIndex | None = None) -> Iterator[ast.AST]:
43
44
 
44
45
  @final
45
46
  class NodeIndex:
46
- __slots__ = ("_buckets", "_flat", "_queries", "tree")
47
+ __slots__ = ("_buckets", "_flat", "_parents", "_queries", "tree")
47
48
 
48
49
  def __init__(self, tree: ast.AST) -> None:
49
50
  self.tree = tree
@@ -62,8 +63,15 @@ class NodeIndex:
62
63
  flat += children(current)
63
64
  self._buckets: dict[type[ast.AST], list[ast.AST]] = buckets
64
65
  self._flat: list[ast.AST] = flat
66
+ self._parents: Mapping[ast.AST, ast.AST] | None = None
65
67
  self._queries: dict[tuple[type[ast.AST], ...], list[ast.AST]] = {}
66
68
 
69
+ @property
70
+ def parents(self) -> Mapping[ast.AST, ast.AST]:
71
+ if self._parents is None:
72
+ self._parents = MappingProxyType({child: parent for parent in self._flat for child in children(parent)})
73
+ return self._parents
74
+
67
75
  def query(self, types: tuple[type[ast.AST], ...]) -> list[ast.AST]:
68
76
  hit = self._queries.get(types)
69
77
  if hit is not None:
@@ -87,3 +95,8 @@ class NodeIndex:
87
95
  def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT], index: NodeIndex | None = None) -> list[NodeT]:
88
96
  selected = index if index is not None and index.tree is tree else NodeIndex(tree)
89
97
  return [node for node in selected.query(types) if isinstance(node, types)]
98
+
99
+
100
+ def parent_map(tree: ast.AST, *, index: NodeIndex | None = None) -> Mapping[ast.AST, ast.AST]:
101
+ selected = index if index is not None and index.tree is tree else NodeIndex(tree)
102
+ return selected.parents
@@ -145,6 +145,7 @@ from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
145
145
  from sarj_python_lint.rules.redundant_module_docstring import RedundantModuleDocstring
146
146
  from sarj_python_lint.rules.repeated_kwarg_heavy_call_in_test import RepeatedKwargHeavyCallInTest
147
147
  from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCallCases
148
+ from sarj_python_lint.rules.repeated_test_composition import RepeatedTestComposition
148
149
  from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
149
150
  RequireKeywordOnlySwapProneParams,
150
151
  )
@@ -225,6 +226,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
225
226
  PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
226
227
  StoreGetDelegatesToBulkRead.id: StoreGetDelegatesToBulkRead,
227
228
  RepeatedKwargHeavyCallInTest.id: RepeatedKwargHeavyCallInTest,
229
+ RepeatedTestComposition.id: RepeatedTestComposition,
228
230
  DefectXfailRequiresExplicitStrict.id: DefectXfailRequiresExplicitStrict,
229
231
  NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
230
232
  NoRestatedComment.id: NoRestatedComment,
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections import Counter
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sarj_python_lint.rules._project_index import ProjectIndexSet, SourceUnit, SymbolRef
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterator
13
+
14
+ from sarj_python_lint._file_context import PythonFileContext
15
+
16
+
17
+ type Function = ast.FunctionDef | ast.AsyncFunctionDef
18
+ _PORT_BASES = frozenset({"abc.ABC", "typing.Protocol", "typing_extensions.Protocol"})
19
+ _MIN_COLLABORATORS = 2
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Constructor:
24
+ symbol: SymbolRef
25
+ positional: tuple[str, ...]
26
+ collaborators: frozenset[str]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class WiringScope:
31
+ parameters: frozenset[str]
32
+ aliases: dict[str, ast.expr]
33
+ blocked: frozenset[str]
34
+
35
+
36
+ def direct_nodes(node: ast.AST) -> Iterator[ast.AST]:
37
+ for child in ast.iter_child_nodes(node):
38
+ yield child
39
+ if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
40
+ yield from direct_nodes(child)
41
+
42
+
43
+ def _class_owner(
44
+ context: PythonFileContext, expression: ast.expr, project: ProjectIndexSet
45
+ ) -> tuple[SourceUnit, ast.ClassDef] | None:
46
+ qualified = context.module_imports.resolved_qualified_name(expression)
47
+ unit = project.unit(context.path)
48
+ if qualified is not None:
49
+ module, _, name = qualified.rpartition(".")
50
+ unit = project.source_unit(module)
51
+ elif isinstance(expression, ast.Name):
52
+ name = expression.id
53
+ else:
54
+ return None
55
+ if unit is None or unit.tree is None or unit.module is None:
56
+ return None
57
+ owner = next((node for node in unit.tree.body if isinstance(node, ast.ClassDef) and node.name == name), None)
58
+ if owner is None:
59
+ return None
60
+ if any(
61
+ isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, (ast.Store, ast.Del))
62
+ for node in direct_nodes(unit.tree)
63
+ ):
64
+ return None
65
+ return unit, owner
66
+
67
+
68
+ def constructor(context: PythonFileContext, expression: ast.expr, project: ProjectIndexSet) -> Constructor | None:
69
+ resolved = _class_owner(context, expression, project)
70
+ if resolved is None:
71
+ return None
72
+ unit, owner = resolved
73
+ if unit.module is None:
74
+ return None
75
+ initializer = next(
76
+ (node for node in owner.body if isinstance(node, ast.FunctionDef) and node.name == "__init__"), None
77
+ )
78
+ if initializer is None:
79
+ return None
80
+ parameters = (*initializer.args.posonlyargs, *initializer.args.args, *initializer.args.kwonlyargs)
81
+ invoked = _invoked_fields(owner)
82
+ stored = _stored_parameters(initializer)
83
+ collaborators = frozenset(
84
+ parameter.arg
85
+ for parameter in parameters
86
+ if parameter.annotation is not None
87
+ and _is_port(project, unit, parameter.annotation)
88
+ and stored.get(parameter.arg) in invoked
89
+ )
90
+ if len(collaborators) < _MIN_COLLABORATORS:
91
+ return None
92
+ return Constructor(
93
+ SymbolRef(unit.module, owner.name),
94
+ tuple(arg.arg for arg in (*initializer.args.posonlyargs, *initializer.args.args)[1:]),
95
+ collaborators,
96
+ )
97
+
98
+
99
+ def _is_port(project: ProjectIndexSet, unit: SourceUnit, annotation: ast.expr) -> bool:
100
+ if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
101
+ try:
102
+ annotation = ast.parse(annotation.value, mode="eval").body
103
+ except SyntaxError:
104
+ return False
105
+ summary = project.class_for(unit, annotation)
106
+ if summary is None:
107
+ return False
108
+ owner = project.source_unit(summary.symbol.module)
109
+ return owner is not None and project.class_inherits_from(owner, summary.symbol.name, _PORT_BASES)
110
+
111
+
112
+ def _stored_parameters(initializer: Function) -> dict[str, str]:
113
+ stored: dict[str, str] = {}
114
+ writes = Counter(
115
+ node.attr
116
+ for node in direct_nodes(initializer)
117
+ if isinstance(node, ast.Attribute) and _is_self(node.value) and isinstance(node.ctx, (ast.Store, ast.Del))
118
+ )
119
+ for statement in initializer.body:
120
+ if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
121
+ target, value = statement.targets[0], statement.value
122
+ elif isinstance(statement, ast.AnnAssign):
123
+ target, value = statement.target, statement.value
124
+ else:
125
+ continue
126
+ if (
127
+ isinstance(value, ast.Name)
128
+ and isinstance(target, ast.Attribute)
129
+ and _is_self(target.value)
130
+ and writes[target.attr] == 1
131
+ ):
132
+ stored[value.id] = target.attr
133
+ return stored
134
+
135
+
136
+ def _is_self(node: ast.expr) -> bool:
137
+ return isinstance(node, ast.Name) and node.id == "self"
138
+
139
+
140
+ def _invoked_fields(owner: ast.ClassDef) -> frozenset[str]:
141
+ invoked: set[str] = set()
142
+ for method in owner.body:
143
+ if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) and method.name != "__init__":
144
+ invoked.update(_method_fields(method))
145
+ return frozenset(invoked)
146
+
147
+
148
+ def _method_fields(method: Function) -> Iterator[str]:
149
+ for node in direct_nodes(method):
150
+ if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
151
+ continue
152
+ target = node.func.value
153
+ if isinstance(target, ast.Attribute) and _is_self(target.value):
154
+ yield target.attr
155
+
156
+
157
+ def bindings(function: Function) -> dict[str, ast.expr]:
158
+ candidates: dict[str, list[ast.expr]] = {}
159
+ for statement in function.body:
160
+ if (
161
+ isinstance(statement, ast.Assign)
162
+ and len(statement.targets) == 1
163
+ and isinstance(statement.targets[0], ast.Name)
164
+ ):
165
+ candidates.setdefault(statement.targets[0].id, []).append(statement.value)
166
+ elif (
167
+ isinstance(statement, ast.AnnAssign)
168
+ and isinstance(statement.target, ast.Name)
169
+ and statement.value is not None
170
+ ):
171
+ candidates.setdefault(statement.target.id, []).append(statement.value)
172
+ writes = [
173
+ node.id
174
+ for node in direct_nodes(function)
175
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del))
176
+ ]
177
+ return {name: values[0] for name, values in candidates.items() if len(values) == 1 and writes.count(name) == 1}
178
+
179
+
180
+ def local_names(function: Function) -> set[str]:
181
+ names: set[str] = set()
182
+ for node in direct_nodes(function):
183
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
184
+ names.add(node.id)
185
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
186
+ names.add(node.name)
187
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
188
+ names.update(alias.asname or alias.name.partition(".")[0] for alias in node.names)
189
+ return names
190
+
191
+
192
+ def changed_before(function: Function, call: ast.Call) -> set[str]:
193
+ changed: set[str] = set()
194
+ for node in direct_nodes(function):
195
+ if not isinstance(node, (ast.Call, ast.Attribute)) or node.lineno >= call.lineno:
196
+ continue
197
+ target = node.func.value if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) else None
198
+ if isinstance(node, ast.Attribute) and isinstance(node.ctx, (ast.Store, ast.Del)):
199
+ target = node.value
200
+ if isinstance(target, ast.Name):
201
+ changed.add(target.id)
202
+ return changed
203
+
204
+
205
+ def unalias(node: ast.expr, aliases: dict[str, ast.expr]) -> ast.expr:
206
+ seen: set[str] = set()
207
+ while isinstance(node, ast.Name) and node.id in aliases and node.id not in seen:
208
+ seen.add(node.id)
209
+ node = aliases[node.id]
210
+ return node
211
+
212
+
213
+ def shadowed(node: ast.expr, names: frozenset[str]) -> bool:
214
+ while isinstance(node, ast.Attribute):
215
+ node = node.value
216
+ return isinstance(node, ast.Name) and node.id in names
217
+
218
+
219
+ def wiring(
220
+ context: PythonFileContext,
221
+ project: ProjectIndexSet,
222
+ call: ast.Call,
223
+ spec: Constructor,
224
+ scope: WiringScope,
225
+ ) -> tuple[tuple[str, str], ...] | None:
226
+ if any(isinstance(arg, ast.Starred) for arg in call.args) or any(keyword.arg is None for keyword in call.keywords):
227
+ return None
228
+ if len(call.args) > len(spec.positional):
229
+ return None
230
+ values = dict(zip(spec.positional, call.args, strict=False))
231
+ for keyword in call.keywords:
232
+ if keyword.arg is None or keyword.arg in values:
233
+ return None
234
+ values[keyword.arg] = keyword.value
235
+ result: list[tuple[str, str]] = []
236
+ for name in sorted(spec.collaborators):
237
+ value = values.get(name)
238
+ if value is None or not isinstance(unalias(value, scope.aliases), (ast.Name, ast.Call)):
239
+ return None
240
+ identity = _value_key(context, project, value, scope)
241
+ if identity is None:
242
+ return None
243
+ result.append((name, identity))
244
+ return tuple(result)
245
+
246
+
247
+ def _value_key(
248
+ context: PythonFileContext,
249
+ project: ProjectIndexSet,
250
+ value: ast.expr,
251
+ scope: WiringScope,
252
+ seen: frozenset[str] = frozenset(),
253
+ ) -> str | None:
254
+ if isinstance(value, ast.Name):
255
+ if value.id in seen or value.id in scope.blocked:
256
+ return None
257
+ if value.id in scope.aliases:
258
+ return _value_key(
259
+ context,
260
+ project,
261
+ scope.aliases[value.id],
262
+ scope,
263
+ seen=seen | {value.id},
264
+ )
265
+ return f"fixture:{value.id}" if value.id in scope.parameters else None
266
+ if isinstance(value, ast.Constant):
267
+ return ast.dump(value)
268
+ if not isinstance(value, ast.Call):
269
+ return None
270
+ callee = unalias(value.func, scope.aliases)
271
+ if shadowed(callee, scope.parameters | scope.blocked):
272
+ return None
273
+ owner = _class_owner(context, callee, project)
274
+ if owner is None or any(keyword.arg is None for keyword in value.keywords):
275
+ return None
276
+ positional = [_value_key(context, project, argument, scope, seen=seen) for argument in value.args]
277
+ keywords = [
278
+ (
279
+ keyword.arg,
280
+ _value_key(context, project, keyword.value, scope, seen=seen),
281
+ )
282
+ for keyword in value.keywords
283
+ ]
284
+ if any(key is None for key in positional) or any(key is None for _, key in keywords):
285
+ return None
286
+ return f"{owner[0].module}.{owner[1].name}:{positional!r}:{sorted(keywords)!r}"
@@ -69,7 +69,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
69
69
  id = "async-mock-call-without-await-assertion"
70
70
  code = "SARJ456"
71
71
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
72
- default_level=Severity.WARNING,
72
+ default_level=Severity.ERROR,
73
73
  summary="Pair positive AsyncMock call assertions with evidence that the mock was awaited.",
74
74
  rationale=(
75
75
  "Calling an AsyncMock records a call before its coroutine is awaited. A positive call assertion can "
@@ -150,7 +150,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
150
150
  line=call.lineno,
151
151
  col=call.col_offset + 1,
152
152
  code=self.code,
153
- severity=Severity.WARNING,
153
+ severity=Severity.ERROR,
154
154
  message="AsyncMock call assertions do not prove awaiting; keep this assertion and add an await assertion",
155
155
  )
156
156
  for test in _collected_tests(tree.body)
@@ -23,7 +23,7 @@ from sarj_python_lint.rules.no_analytical_aggregation_in_postgres_store import a
23
23
 
24
24
 
25
25
  if TYPE_CHECKING:
26
- from collections.abc import Callable
26
+ from collections.abc import Callable, Mapping
27
27
  from pathlib import Path
28
28
 
29
29
  from sqlglot import exp
@@ -83,18 +83,18 @@ def _docstring_node_ids(tree: ast.AST, *, node_index: NodeIndex | None = None) -
83
83
  return result
84
84
 
85
85
 
86
- def _is_query_context(node: ast.expr, parents: dict[int, ast.AST], roots: frozenset[int]) -> bool:
86
+ def _is_query_context(node: ast.expr, parents: Mapping[ast.AST, ast.AST], roots: frozenset[int]) -> bool:
87
87
  current: ast.AST = node
88
88
  while True:
89
89
  if id(current) in roots:
90
90
  return True
91
- if (parent := parents.get(id(current))) is None:
91
+ if (parent := parents.get(current)) is None:
92
92
  return False
93
93
  current = parent
94
94
 
95
95
 
96
96
  def _query_roots(
97
- tree: ast.Module, parents: dict[int, ast.AST], *, node_index: NodeIndex | None = None
97
+ tree: ast.Module, parents: Mapping[ast.AST, ast.AST], *, node_index: NodeIndex | None = None
98
98
  ) -> frozenset[int]:
99
99
  binding_counts: dict[tuple[int, str], int] = {}
100
100
  binding_values: dict[tuple[int, str], ast.expr] = {}
@@ -133,10 +133,10 @@ def _query_roots(
133
133
  return frozenset(roots)
134
134
 
135
135
 
136
- def _scope(node: ast.AST, parents: dict[int, ast.AST]) -> ast.AST:
136
+ def _scope(node: ast.AST, parents: Mapping[ast.AST, ast.AST]) -> ast.AST:
137
137
  current = node
138
138
  while not isinstance(current, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
139
- parent = parents.get(id(current))
139
+ parent = parents.get(current)
140
140
  if parent is None:
141
141
  return current
142
142
  current = parent
@@ -169,7 +169,7 @@ def _call_name(function: ast.expr) -> str:
169
169
 
170
170
 
171
171
  def _postgres_sql_constructors(
172
- tree: ast.Module, parents: dict[int, ast.AST], *, node_index: NodeIndex | None = None
172
+ tree: ast.Module, parents: Mapping[ast.AST, ast.AST], *, node_index: NodeIndex | None = None
173
173
  ) -> frozenset[_ConstructorImport]:
174
174
  constructors: set[_ConstructorImport] = set()
175
175
  for node in nodes(tree, ast.Import, ast.ImportFrom, index=node_index):
@@ -204,7 +204,7 @@ def _qualified_name(node: ast.expr) -> str:
204
204
  def _visible_constructors(
205
205
  owner: ast.AST,
206
206
  imports: frozenset[_ConstructorImport],
207
- parents: dict[int, ast.AST],
207
+ parents: Mapping[ast.AST, ast.AST],
208
208
  binding_counts: dict[tuple[int, str], int],
209
209
  wildcard_scopes: set[int],
210
210
  ) -> frozenset[str]:
@@ -212,7 +212,7 @@ def _visible_constructors(
212
212
  current = owner
213
213
  while True:
214
214
  scope_chain.append(id(current))
215
- parent = parents.get(id(current))
215
+ parent = parents.get(current)
216
216
  if parent is None:
217
217
  break
218
218
  current = _scope(parent, parents)
@@ -380,7 +380,7 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
380
380
  return []
381
381
 
382
382
  docstrings = _docstring_node_ids(tree, node_index=context.node_index)
383
- parents = {id(child): parent for parent in context.nodes(ast.AST) for child in ast.iter_child_nodes(parent)}
383
+ parents = context.parents
384
384
  query_roots = _query_roots(tree, parents, node_index=context.node_index)
385
385
  diagnostics: list[Diagnostic] = []
386
386
  consumed: set[int] = set()
@@ -417,7 +417,7 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
417
417
 
418
418
  def _count_query_bindings(
419
419
  tree: ast.Module,
420
- parents: dict[int, ast.AST],
420
+ parents: Mapping[ast.AST, ast.AST],
421
421
  count: Callable[[ast.AST, str], None],
422
422
  wildcard_scopes: set[int],
423
423
  *,
@@ -430,7 +430,7 @@ def _count_query_bindings(
430
430
  count(_scope(argument, parents), argument.arg)
431
431
  _count_import_bindings(tree, parents, count, wildcard_scopes, node_index=node_index)
432
432
  for definition in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, index=node_index):
433
- parent = parents.get(id(definition))
433
+ parent = parents.get(definition)
434
434
  if parent is not None:
435
435
  count(_scope(parent, parents), definition.name)
436
436
  for handler in nodes(tree, ast.ExceptHandler, index=node_index):
@@ -447,7 +447,7 @@ def _count_query_bindings(
447
447
 
448
448
  def _query_binding_values(
449
449
  tree: ast.Module,
450
- parents: dict[int, ast.AST],
450
+ parents: Mapping[ast.AST, ast.AST],
451
451
  binding_values: dict[tuple[int, str], ast.expr],
452
452
  *,
453
453
  node_index: NodeIndex | None = None,
@@ -461,7 +461,7 @@ def _query_binding_values(
461
461
  if not isinstance(target, ast.Name) or assignment.value is None:
462
462
  continue
463
463
  owner = _scope(assignment, parents)
464
- if parents.get(id(assignment)) is owner:
464
+ if parents.get(assignment) is owner:
465
465
  binding_values[id(owner), target.id] = assignment.value
466
466
 
467
467
 
@@ -591,7 +591,7 @@ def _maximum_query_joins(statement: exp.Query) -> int:
591
591
 
592
592
  def _count_import_bindings(
593
593
  tree: ast.Module,
594
- parents: dict[int, ast.AST],
594
+ parents: Mapping[ast.AST, ast.AST],
595
595
  count: Callable[[ast.AST, str], None],
596
596
  wildcard_scopes: set[int],
597
597
  *,
@@ -20,6 +20,7 @@ from sarj_python_lint.rules._paths import is_test_path
20
20
 
21
21
 
22
22
  if TYPE_CHECKING:
23
+ from collections.abc import Mapping
23
24
  from pathlib import Path
24
25
 
25
26
  from sarj_python_lint._file_context import PythonFileContext
@@ -92,7 +93,7 @@ class FastapiClassRouterContract(Rule):
92
93
  if tree is None:
93
94
  return []
94
95
  index = context.fastapi
95
- parents = _parents(tree, node_index=context.node_index)
96
+ parents = context.parents
96
97
  findings = _router_construction_findings(path, tree, index, parents, node_index=context.node_index)
97
98
  for function in (
98
99
  node for node in context.nodes(ast.AST) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
@@ -109,7 +110,7 @@ def _router_construction_findings(
109
110
  path: Path,
110
111
  tree: ast.Module,
111
112
  index: FastapiIndex,
112
- parents: dict[int, ast.AST],
113
+ parents: Mapping[ast.AST, ast.AST],
113
114
  *,
114
115
  node_index: NodeIndex | None = None,
115
116
  ) -> list[Diagnostic]:
@@ -187,16 +188,12 @@ def _is_collection_or_scalar(annotation: ast.expr) -> bool:
187
188
  return flat_name(target) in _TOP_LEVEL_COLLECTIONS | _SCALARS
188
189
 
189
190
 
190
- def _parents(tree: ast.Module, *, node_index: NodeIndex | None = None) -> dict[int, ast.AST]:
191
- return {id(child): parent for parent in walk_ast(tree, index=node_index) for child in ast.iter_child_nodes(parent)}
192
-
193
-
194
191
  def _ancestor(
195
- node: ast.AST, parents: dict[int, ast.AST], kind: type[ast.AST] | tuple[type[ast.AST], ...]
192
+ node: ast.AST, parents: Mapping[ast.AST, ast.AST], kind: type[ast.AST] | tuple[type[ast.AST], ...]
196
193
  ) -> ast.AST | None:
197
- current = parents.get(id(node))
194
+ current = parents.get(node)
198
195
  while current is not None:
199
196
  if isinstance(current, kind):
200
197
  return current
201
- current = parents.get(id(current))
198
+ current = parents.get(current)
202
199
  return None
@@ -121,7 +121,7 @@ class MockWithoutSpec(Rule):
121
121
  id: str = "mock-without-spec"
122
122
  code: str = "SARJ040"
123
123
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
124
- default_level=Severity.WARNING,
124
+ default_level=Severity.ERROR,
125
125
  summary="Unrestricted mock permits attributes outside the collaborator contract.",
126
126
  rationale=(
127
127
  "A mock without a spec permits removed or misspelled attributes; a callable mock without autospec may "
@@ -234,7 +234,7 @@ class MockWithoutSpec(Rule):
234
234
  col=node.col_offset + 1,
235
235
  code=self.code,
236
236
  message=_diagnostic_message(label),
237
- severity=Severity.WARNING,
237
+ severity=Severity.ERROR,
238
238
  )
239
239
  for node, label in _unspecced_calls(
240
240
  tree, names, pytest_mocker, _FileFacts.from_tree(tree), node_index=context.node_index