sarj-python-lint 0.84.2__tar.gz → 0.85.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 (141) hide show
  1. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/__main__.py +6 -2
  4. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_fastapi.py +3 -0
  5. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_imports.py +6 -0
  6. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_registry.py +10 -6
  7. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +27 -4
  8. sarj_python_lint-0.84.2/src/sarj_python_lint/rules/pydantic_at_boundaries.py → sarj_python_lint-0.85.0/src/sarj_python_lint/rules/named_record_at_boundaries.py +8 -9
  9. sarj_python_lint-0.85.0/src/sarj_python_lint/rules/no_any_mapping_types.py +257 -0
  10. sarj_python_lint-0.85.0/src/sarj_python_lint/rules/no_delete_statement.py +104 -0
  11. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -1
  12. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +5 -5
  13. sarj_python_lint-0.85.0/src/sarj_python_lint/rules/no_unused_value_marker.py +141 -0
  14. sarj_python_lint-0.85.0/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +420 -0
  15. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +59 -8
  16. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +65 -3
  17. sarj_python_lint-0.84.2/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -257
  18. sarj_python_lint-0.84.2/src/sarj_python_lint/rules/no_vague_annotations.py +0 -173
  19. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/.gitignore +0 -0
  20. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/LICENSE +0 -0
  21. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/README.md +0 -0
  22. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/__init__.py +0 -0
  23. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  24. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_filesystem.py +0 -0
  25. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_python_target.py +0 -0
  26. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  27. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_secret_names.py +0 -0
  28. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/_version.py +0 -0
  29. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/py.typed +0 -0
  30. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/ratchet.py +0 -0
  31. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rule_base.py +0 -0
  32. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  33. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  34. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  35. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  36. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  37. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  38. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  39. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  40. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  41. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  42. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  43. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  44. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  45. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  46. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  47. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  48. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  49. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  50. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  51. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  52. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  53. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  54. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  55. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  56. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  57. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  58. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  59. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  60. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  61. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  62. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  63. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  64. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  65. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  66. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  67. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  68. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  69. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  70. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  71. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  72. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  73. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  74. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  75. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  76. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  77. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  78. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  79. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  80. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  81. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  82. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  83. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  84. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  85. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  86. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  87. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  88. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  89. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  90. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  91. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  92. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  93. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  94. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  95. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  96. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  97. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  98. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  99. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  100. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  101. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  102. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  103. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  104. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  105. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  106. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  107. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  108. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  109. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  110. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  111. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  112. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  113. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  114. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  115. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  116. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  117. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  118. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  119. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  120. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  121. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  122. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  123. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  124. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  125. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  126. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  127. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  128. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  129. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  130. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  131. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/require_typed_http_test_response.py +0 -0
  132. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  133. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  134. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  135. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  136. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  137. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  138. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  139. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  140. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  141. {sarj_python_lint-0.84.2 → sarj_python_lint-0.85.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: 0.84.2
3
+ Version: 0.85.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 = "0.84.2"
3
+ version = "0.85.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" }]
@@ -143,6 +143,10 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
143
143
  "SARJ092": frozenset({"SARJ086", "SARJ087", "SARJ420"}),
144
144
  "SARJ093": frozenset({"SARJ034"}),
145
145
  "SARJ099": frozenset({"SARJ420"}),
146
+ # Any-valued mappings are the stronger correctness failure. SARJ008
147
+ # still owns every fixed dictionary return when selected alone, while
148
+ # an all-rules run emits one actionable diagnostic for this overlap.
149
+ "SARJ447": frozenset({"SARJ008"}),
146
150
  }
147
151
  )
148
152
 
@@ -162,13 +166,13 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
162
166
  "SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
163
167
  )
164
168
  docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
165
- needs_signature_owners = "SARJ093" in codes and "SARJ034" in codes
169
+ needs_signature_owners = ("SARJ093" in codes and "SARJ034" in codes) or ("SARJ447" in codes and "SARJ008" in codes)
166
170
  signature_owners = (
167
171
  _function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
168
172
  )
169
173
 
170
174
  def owner_location(diagnostic: Diagnostic) -> _OwnerLocation:
171
- if diagnostic.code in {"SARJ034", "SARJ093"}:
175
+ if diagnostic.code in {"SARJ008", "SARJ034", "SARJ093", "SARJ447"}:
172
176
  line, column = signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
173
177
  else:
174
178
  line, column = docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
@@ -5,6 +5,8 @@ from collections import Counter
5
5
  from dataclasses import dataclass
6
6
  from typing import TYPE_CHECKING, Literal, NamedTuple
7
7
 
8
+ from sarj_python_lint.rules._imports import ImportIndex
9
+
8
10
 
9
11
  if TYPE_CHECKING:
10
12
  from pathlib import Path
@@ -107,6 +109,7 @@ class FastapiIndex:
107
109
  def __init__(self, tree: ast.Module, *, path: Path | None = None) -> None:
108
110
  self.tree: ast.Module = tree
109
111
  self.path: Path | None = path
112
+ self.imports: ImportIndex = ImportIndex.from_tree(tree, module_scope_only=True)
110
113
  self._imported_dependency_cache: dict[_ImportedReference, bool] = {}
111
114
  self._imported_module_cache: dict[Path, ast.Module | None] = {}
112
115
  self.modules: set[str] = set()
@@ -44,6 +44,12 @@ class ImportIndex:
44
44
  return None
45
45
  return resolved.symbol
46
46
 
47
+ def resolved_qualified_name(self, node: ast.expr) -> str | None:
48
+ resolved = self._resolve(node)
49
+ if resolved is None or resolved.symbol is None:
50
+ return None
51
+ return f"{resolved.module}.{resolved.symbol}"
52
+
47
53
  def builtin_is_unshadowed(self, name: str) -> bool:
48
54
  return name not in self.shadowed_names and name not in self.bindings
49
55
 
@@ -22,19 +22,21 @@ from sarj_python_lint.rules.invalid_pydantic_field_default import (
22
22
  InvalidPydanticFieldDefault,
23
23
  )
24
24
  from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
25
+ from sarj_python_lint.rules.named_record_at_boundaries import NamedRecordAtBoundaries
25
26
  from sarj_python_lint.rules.negative_only_http_status_assertion import (
26
27
  NegativeOnlyHttpStatusAssertion,
27
28
  )
28
29
  from sarj_python_lint.rules.no_analytical_aggregation_in_postgres_store import (
29
30
  NoAnalyticalAggregationInPostgresStore,
30
31
  )
32
+ from sarj_python_lint.rules.no_any_mapping_types import NoAnyMappingTypes
31
33
  from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
32
34
  from sarj_python_lint.rules.no_conftest_test_module_import import NoConftestTestModuleImport
33
35
  from sarj_python_lint.rules.no_copied_inherited_docstring import NoCopiedInheritedDocstring
34
36
  from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
35
37
  NoCorsWildcardWithCredentials,
36
38
  )
37
- from sarj_python_lint.rules.no_deleted_only_override_parameter import NoDeletedOnlyOverrideParameter
39
+ from sarj_python_lint.rules.no_delete_statement import NoDeleteStatement
38
40
  from sarj_python_lint.rules.no_dunder_all import NoDunderAll
39
41
  from sarj_python_lint.rules.no_duplicate_dunder_all_entry import NoDuplicateDunderAllEntry
40
42
  from sarj_python_lint.rules.no_excessive_cognitive_complexity import NoExcessiveCognitiveComplexity
@@ -77,7 +79,7 @@ from sarj_python_lint.rules.no_unique_violation_message_match import (
77
79
  NoUniqueViolationMessageMatch,
78
80
  )
79
81
  from sarj_python_lint.rules.no_unnecessary_docstring import NoUnnecessaryDocstring
80
- from sarj_python_lint.rules.no_vague_annotations import NoVagueAnnotations
82
+ from sarj_python_lint.rules.no_unused_value_marker import NoUnusedValueMarker
81
83
  from sarj_python_lint.rules.no_vague_suppression_description import (
82
84
  NoVagueSuppressionDescription,
83
85
  )
@@ -101,6 +103,7 @@ from sarj_python_lint.rules.prefer_injected_dependency_over_monkeypatch import (
101
103
  )
102
104
  from sarj_python_lint.rules.prefer_library_fake import PreferLibraryFake
103
105
  from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
106
+ from sarj_python_lint.rules.prefer_match_exception_dispatch import PreferMatchExceptionDispatch
104
107
  from sarj_python_lint.rules.prefer_match_type_dispatch import PreferMatchTypeDispatch
105
108
  from sarj_python_lint.rules.prefer_match_value_dispatch import PreferMatchValueDispatch
106
109
  from sarj_python_lint.rules.prefer_module_level_constant import (
@@ -133,7 +136,6 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
133
136
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
134
137
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
135
138
  from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
136
- from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
137
139
  from sarj_python_lint.rules.pytest_fixture_returns_bare_tuple import PytestFixtureReturnsBareTuple
138
140
  from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
139
141
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
@@ -188,7 +190,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
188
190
  NoExcessiveCognitiveComplexity.id: NoExcessiveCognitiveComplexity,
189
191
  PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
190
192
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
191
- PydanticAtBoundaries.id: PydanticAtBoundaries,
193
+ NamedRecordAtBoundaries.id: NamedRecordAtBoundaries,
192
194
  FastapiExplicitOpenapiContract.id: FastapiExplicitOpenapiContract,
193
195
  FastapiClassRouterContract.id: FastapiClassRouterContract,
194
196
  FakesInSharedLocation.id: FakesInSharedLocation,
@@ -242,6 +244,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
242
244
  PreferOrPattern.id: PreferOrPattern,
243
245
  RequirePortForService.id: RequirePortForService,
244
246
  PreferNonNullableCollection.id: PreferNonNullableCollection,
247
+ PreferMatchExceptionDispatch.id: PreferMatchExceptionDispatch,
245
248
  PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
246
249
  PreferMatchValueDispatch.id: PreferMatchValueDispatch,
247
250
  PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
@@ -252,7 +255,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
252
255
  PreferSelfDocumentingConstant.id: PreferSelfDocumentingConstant,
253
256
  NoDuplicateDunderAllEntry.id: NoDuplicateDunderAllEntry,
254
257
  NoDunderAll.id: NoDunderAll,
255
- NoDeletedOnlyOverrideParameter.id: NoDeletedOnlyOverrideParameter,
258
+ NoDeleteStatement.id: NoDeleteStatement,
256
259
  NoCopiedInheritedDocstring.id: NoCopiedInheritedDocstring,
257
260
  RedundantClassDocstring.id: RedundantClassDocstring,
258
261
  RedundantModuleDocstring.id: RedundantModuleDocstring,
@@ -264,8 +267,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
264
267
  NoRedundantLiteralDescription.id: NoRedundantLiteralDescription,
265
268
  NoTypedDocSections.id: NoTypedDocSections,
266
269
  NoUnnecessaryDocstring.id: NoUnnecessaryDocstring,
270
+ NoUnusedValueMarker.id: NoUnusedValueMarker,
267
271
  PreferNominalIdTypes.id: PreferNominalIdTypes,
268
- NoVagueAnnotations.id: NoVagueAnnotations,
272
+ NoAnyMappingTypes.id: NoAnyMappingTypes,
269
273
  NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
270
274
  NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
271
275
  ProductionDerivedTestCases.id: ProductionDerivedTestCases,
@@ -425,18 +425,41 @@ def _schema_erasing(node: ast.expr, index: FastapiIndex) -> bool:
425
425
  return _schema_erasing(parts[0], index)
426
426
  if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.BitOr):
427
427
  return _schema_erasing(resolved.left, index) or _schema_erasing(resolved.right, index)
428
- name = flat_name(resolved.value) if isinstance(resolved, ast.Subscript) else flat_name(resolved)
429
- if name in {"Any", "object"}:
428
+ target = resolved.value if isinstance(resolved, ast.Subscript) else resolved
429
+ if _is_typing_type(target, index, "Any") or _is_builtin_type(target, index, "object"):
430
430
  return True
431
- if name in _RAW_MAPPINGS or name in _CONTAINERS:
431
+ if index.imports.resolves(target, sources=frozenset({"pydantic"}), symbol="JsonValue"):
432
+ return True
433
+ if _is_container_type(target, index, _RAW_MAPPINGS | _CONTAINERS):
432
434
  if not isinstance(resolved, ast.Subscript):
433
435
  return True
434
436
  return any(_schema_erasing(item, index) for item in _slice_items(resolved.slice))
435
- if isinstance(resolved, ast.Subscript) and name in {"Optional", "Union"}:
437
+ if isinstance(resolved, ast.Subscript) and any(
438
+ _is_typing_type(target, index, wrapper) for wrapper in ("Optional", "Union")
439
+ ):
436
440
  return any(_schema_erasing(item, index) for item in _slice_items(resolved.slice))
437
441
  return False
438
442
 
439
443
 
444
+ def _is_builtin_type(node: ast.expr, index: FastapiIndex, name: str) -> bool:
445
+ return (
446
+ isinstance(node, ast.Name) and node.id == name and index.imports.builtin_is_unshadowed(name)
447
+ ) or index.imports.resolves(node, sources=frozenset({"builtins"}), symbol=name)
448
+
449
+
450
+ def _is_typing_type(node: ast.expr, index: FastapiIndex, name: str) -> bool:
451
+ return index.imports.resolves(node, sources=frozenset({"typing", "typing_extensions"}), symbol=name)
452
+
453
+
454
+ def _is_container_type(node: ast.expr, index: FastapiIndex, names: frozenset[str]) -> bool:
455
+ builtin_names = {"dict", "list", "set", "tuple"}
456
+ return any(
457
+ (name in builtin_names and _is_builtin_type(node, index, name))
458
+ or index.imports.resolves(node, sources=frozenset({"collections.abc", "typing"}), symbol=name)
459
+ for name in names
460
+ )
461
+
462
+
440
463
  def _slice_items(node: ast.expr) -> tuple[ast.expr, ...]:
441
464
  return tuple(node.elts) if isinstance(node, ast.Tuple) else (node,)
442
465
 
@@ -36,15 +36,15 @@ _DICT_ARG_COUNT = 2
36
36
  _DOCUMENTATION_DIR_NAMES = frozenset({"docs", "docs_src"})
37
37
 
38
38
 
39
- class PydanticAtBoundaries(Rule):
40
- id: str = "pydantic-at-boundaries"
39
+ class NamedRecordAtBoundaries(Rule):
40
+ id: str = "named-record-at-boundaries"
41
41
  code: str = "SARJ008"
42
42
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
43
43
  summary="Public Python API returns an unnamed fixed-shape record.",
44
44
  rationale="A named record makes field types and required keys explicit to callers and static tooling.",
45
45
  remediation="Define and return a `TypedDict`, Pydantic model, or frozen dataclass for the fixed record shape.",
46
46
  category=RuleCategory.ARCHITECTURE,
47
- aliases=("named-fixed-record-return",),
47
+ aliases=("pydantic-at-boundaries", "named-fixed-record-return"),
48
48
  limitations=(
49
49
  "Visible FastAPI routes are owned by SARJ094. Private functions and classes, closures, tests, generated files, documentation examples, recognized framework hooks, and dictionary conversion methods are excluded.",
50
50
  "Only returned record literals and locally built fixed-shape dictionaries are recognized.",
@@ -57,7 +57,7 @@ class PydanticAtBoundaries(Rule):
57
57
  files=(
58
58
  ExampleFile.python(
59
59
  "service.py",
60
- "from typing import Any\n\ndef build_payload(call) -> dict[str, Any]:\n return {'id': call.id}\n",
60
+ "def build_payload(call) -> dict[str, object]:\n return {'id': call.id}\n",
61
61
  ),
62
62
  ),
63
63
  focus_path=PurePosixPath("service.py"),
@@ -250,24 +250,23 @@ def _classify_return(node: ast.expr, imports: ImportIndex) -> str | None:
250
250
  if _is_typing_type(node.value, imports, "Union"):
251
251
  return _classify_union_return(node, imports)
252
252
  if _is_type(node.value, imports, builtin="list", typing_symbol="List"):
253
- # Only list-of-untyped-dict is flagged (e.g. `list[dict[str, Any]]`).
253
+ # Fixed lists of unnamed records need one named element contract.
254
254
  inner = _classify_return(node.slice, imports)
255
255
  return "dict" if inner == "dict" else None
256
256
  if _is_type(node.value, imports, builtin="dict", typing_symbol="Dict"):
257
- return "dict" if _is_untyped_dict_args(node.slice, imports) else None
257
+ return "dict" if _is_named_record_dict_args(node.slice, imports) else None
258
258
  # Heterogeneous tuple returns are NOT flagged — multiple return values are
259
259
  # idiomatic Python, not a missing data contract.
260
260
  return None
261
261
 
262
262
 
263
- def _is_untyped_dict_args(slice_node: ast.expr, imports: ImportIndex) -> bool:
263
+ def _is_named_record_dict_args(slice_node: ast.expr, imports: ImportIndex) -> bool:
264
264
  if not isinstance(slice_node, ast.Tuple) or len(slice_node.elts) != _DICT_ARG_COUNT:
265
265
  return False
266
266
  key = _resolve_annotation(slice_node.elts[0])
267
267
  if key is None or not _is_builtin(key, imports, "str"):
268
268
  return False
269
- value = _resolve_annotation(slice_node.elts[1])
270
- return value is not None and (_is_builtin(value, imports, "object") or _is_typing_type(value, imports, "Any"))
269
+ return _resolve_annotation(slice_node.elts[1]) is not None
271
270
 
272
271
 
273
272
  def _is_type(node: ast.expr, imports: ImportIndex, *, builtin: str, typing_symbol: str) -> bool:
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, 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
+ is_suppressed,
17
+ )
18
+ from sarj_python_lint.rules._imports import ImportIndex
19
+ from sarj_python_lint.rules._paths import is_generated
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+
25
+
26
+ _TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
27
+ _PYDANTIC_SOURCES = frozenset({"pydantic"})
28
+ _MAPPING_SOURCES = frozenset({"collections.abc", "typing"})
29
+ _MAPPING_NAMES = frozenset({"dict", "Dict", "Mapping", "MutableMapping"})
30
+ _MAPPING_ARGUMENT_COUNT = 2
31
+
32
+
33
+ @final
34
+ class NoAnyMappingTypes(Rule):
35
+ id = "no-any-mapping-types"
36
+ code = "SARJ447"
37
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
38
+ summary="String-keyed mapping types must not erase their values with `Any`.",
39
+ rationale=(
40
+ "An `Any`-valued mapping disables checking transitively and conceals whether the value is a fixed record, "
41
+ "an arbitrary JSON tree, or a genuinely opaque Python object."
42
+ ),
43
+ remediation=(
44
+ "Use `TypedDict` (and `Unpack[TypedDict]` for closed keyword arguments) for fixed records, a Pydantic model "
45
+ "for untrusted runtime input, `pydantic.JsonValue` for arbitrary JSON, or `object` plus explicit narrowing "
46
+ "for a genuinely opaque Python value."
47
+ ),
48
+ category=RuleCategory.CORRECTNESS,
49
+ autofix=AutofixPolicy.NONE,
50
+ aliases=("no-vague-annotations",),
51
+ limitations=(
52
+ (
53
+ "Only proven built-in or standard-library string-keyed mappings whose value recursively contains "
54
+ "`typing.Any` are reported; `object` is intentionally not treated as `Any`."
55
+ ),
56
+ "Generated and vendored sources are excluded; exact local suppressions remain auditable.",
57
+ ),
58
+ examples=(
59
+ RuleExample(
60
+ example_id="any-valued-mapping-annotation",
61
+ title="An Any-valued mapping erases the record schema",
62
+ outcome=ExampleOutcome.MATCH,
63
+ files=(
64
+ ExampleFile.python(
65
+ "app/offers.py", "from typing import Any\ndef details() -> dict[str, Any]: ...\n"
66
+ ),
67
+ ),
68
+ focus_path=PurePosixPath("app/offers.py"),
69
+ expected_count=1,
70
+ public=True,
71
+ ),
72
+ RuleExample(
73
+ example_id="typed-dictionary-annotation",
74
+ title="A TypedDict preserves the record schema",
75
+ outcome=ExampleOutcome.NO_MATCH,
76
+ files=(
77
+ ExampleFile.python(
78
+ "app/offers.py",
79
+ "from typing import TypedDict\nclass OfferDetails(TypedDict):\n id: str\ndef details() -> OfferDetails: ...\n",
80
+ ),
81
+ ),
82
+ focus_path=PurePosixPath("app/offers.py"),
83
+ expected_count=0,
84
+ public=True,
85
+ ),
86
+ ),
87
+ )
88
+ description = documentation.summary
89
+
90
+ @override
91
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
92
+ if is_generated(path, source) or _is_vendor_path(path):
93
+ return []
94
+ try:
95
+ tree = ast.parse(source, filename=str(path), type_comments=True)
96
+ except SyntaxError:
97
+ return []
98
+ imports = ImportIndex.from_tree(tree, module_scope_only=True)
99
+ aliases = _type_aliases(tree, imports)
100
+ lines = source.splitlines()
101
+ findings: list[Diagnostic] = []
102
+ seen: set[tuple[int, int]] = set()
103
+ for expression in _type_expressions(tree, imports):
104
+ for mapping in _any_mappings(expression, imports, aliases):
105
+ location = expression if isinstance(expression, ast.Constant) else mapping
106
+ key = (location.lineno, location.col_offset)
107
+ if key in seen or is_suppressed(lines, location.lineno, self.code):
108
+ continue
109
+ seen.add(key)
110
+ findings.append(
111
+ Diagnostic(
112
+ path=path,
113
+ line=location.lineno,
114
+ col=location.col_offset + 1,
115
+ code=self.code,
116
+ message=(
117
+ f"mapping type `{ast.unparse(mapping)}` contains `Any`; use `TypedDict` for a fixed "
118
+ "record, `pydantic.JsonValue` for arbitrary JSON, or `object` with narrowing for opaque values"
119
+ ),
120
+ )
121
+ )
122
+ return sorted(findings, key=lambda finding: (finding.line, finding.col))
123
+
124
+
125
+ def _type_expressions(tree: ast.Module, imports: ImportIndex) -> list[ast.expr]:
126
+ expressions = [annotation for node in ast.walk(tree) if (annotation := _node_annotation(node, imports)) is not None]
127
+ for node in ast.walk(tree):
128
+ if not isinstance(node, ast.Call) or not node.args:
129
+ continue
130
+ if imports.resolves(node.func, sources=_TYPING_SOURCES, symbol="cast") or imports.resolves(
131
+ node.func, sources=_PYDANTIC_SOURCES, symbol="TypeAdapter"
132
+ ):
133
+ expressions.append(node.args[0])
134
+ return expressions
135
+
136
+
137
+ def _node_annotation(node: ast.AST, imports: ImportIndex) -> ast.expr | None:
138
+ match node:
139
+ case (
140
+ ast.arg(annotation=annotation)
141
+ | ast.FunctionDef(returns=annotation)
142
+ | ast.AsyncFunctionDef(returns=annotation)
143
+ ):
144
+ return annotation
145
+ case ast.AnnAssign(annotation=annotation, value=value) if value is not None:
146
+ if imports.resolves(annotation, sources=_TYPING_SOURCES, symbol="TypeAlias"):
147
+ return value
148
+ return annotation
149
+ case ast.AnnAssign(annotation=annotation):
150
+ return annotation
151
+ case ast.TypeAlias(value=value):
152
+ return value
153
+ case (
154
+ ast.Assign(type_comment=str(comment))
155
+ | ast.For(type_comment=str(comment))
156
+ | ast.AsyncFor(type_comment=str(comment))
157
+ | ast.With(type_comment=str(comment))
158
+ | ast.AsyncWith(type_comment=str(comment))
159
+ ):
160
+ return _type_comment(comment, node)
161
+ case _:
162
+ return None
163
+
164
+
165
+ def _type_comment(comment: str, owner: ast.AST) -> ast.expr | None:
166
+ try:
167
+ expression = ast.parse(comment, mode="eval").body
168
+ except SyntaxError:
169
+ return None
170
+ ast.copy_location(expression, owner)
171
+ return expression
172
+
173
+
174
+ def _parse_string_annotation(annotation: ast.expr) -> ast.expr:
175
+ if not isinstance(annotation, ast.Constant) or not isinstance(annotation.value, str):
176
+ return annotation
177
+ try:
178
+ parsed = ast.parse(annotation.value, mode="eval").body
179
+ except SyntaxError:
180
+ return annotation
181
+ ast.copy_location(parsed, annotation)
182
+ return parsed
183
+
184
+
185
+ def _any_mappings(annotation: ast.expr, imports: ImportIndex, aliases: dict[str, ast.expr]) -> list[ast.Subscript]:
186
+ resolved = _parse_string_annotation(annotation)
187
+ return [
188
+ node
189
+ for node in ast.walk(resolved)
190
+ if isinstance(node, ast.Subscript) and _is_any_mapping(node, imports, aliases)
191
+ ]
192
+
193
+
194
+ def _is_any_mapping(node: ast.Subscript, imports: ImportIndex, aliases: dict[str, ast.expr]) -> bool:
195
+ if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != _MAPPING_ARGUMENT_COUNT:
196
+ return False
197
+ target = node.value
198
+ mapping = (isinstance(target, ast.Name) and target.id == "dict" and imports.builtin_is_unshadowed("dict")) or any(
199
+ imports.resolves(target, sources=_MAPPING_SOURCES, symbol=name) for name in _MAPPING_NAMES
200
+ )
201
+ if not mapping:
202
+ return False
203
+ key, value = node.slice.elts
204
+ return _is_builtin_str(key, imports) and _contains_any(value, imports, aliases, seen=frozenset())
205
+
206
+
207
+ def _is_builtin_str(node: ast.expr, imports: ImportIndex) -> bool:
208
+ return isinstance(node, ast.Name) and node.id == "str" and imports.builtin_is_unshadowed("str")
209
+
210
+
211
+ def _contains_any(
212
+ node: ast.expr,
213
+ imports: ImportIndex,
214
+ aliases: dict[str, ast.expr],
215
+ *,
216
+ seen: frozenset[str],
217
+ ) -> bool:
218
+ node = _parse_string_annotation(node)
219
+ if imports.resolves(node, sources=_TYPING_SOURCES, symbol="Any"):
220
+ return True
221
+ if isinstance(node, ast.Name) and node.id in aliases and node.id not in seen:
222
+ return _contains_any(aliases[node.id], imports, aliases, seen=seen | {node.id})
223
+ return any(
224
+ _contains_any(child, imports, aliases, seen=seen)
225
+ for child in ast.iter_child_nodes(node)
226
+ if isinstance(child, ast.expr)
227
+ )
228
+
229
+
230
+ def _type_aliases(tree: ast.Module, imports: ImportIndex) -> dict[str, ast.expr]:
231
+ aliases: dict[str, ast.expr] = {}
232
+ ambiguous: set[str] = set()
233
+ for statement in tree.body:
234
+ name: str | None = None
235
+ value: ast.expr | None = None
236
+ match statement:
237
+ case ast.TypeAlias(name=ast.Name(id=alias), value=alias_value, type_params=[]):
238
+ name, value = alias, alias_value
239
+ case ast.AnnAssign(target=ast.Name(id=alias), annotation=annotation, value=alias_value) if (
240
+ alias_value is not None and imports.resolves(annotation, sources=_TYPING_SOURCES, symbol="TypeAlias")
241
+ ):
242
+ name, value = alias, alias_value
243
+ case _:
244
+ pass
245
+ if name is None or value is None:
246
+ continue
247
+ if name in aliases:
248
+ ambiguous.add(name)
249
+ else:
250
+ aliases[name] = value
251
+ for name in ambiguous:
252
+ aliases.pop(name, None)
253
+ return aliases
254
+
255
+
256
+ def _is_vendor_path(path: Path) -> bool:
257
+ return any(part.casefold() in {"vendor", "vendored", "third_party"} for part in path.parts)
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, ClassVar, final
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
+ is_suppressed,
18
+ parse_or_none,
19
+ )
20
+ from sarj_python_lint.rules._paths import is_generated
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ @final
28
+ class NoDeleteStatement(Rule):
29
+ id = "no-delete-statement"
30
+ code = "SARJ442"
31
+ documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
32
+ summary="Avoid `del` statements; prefer constructing an immutable replacement value.",
33
+ rationale=(
34
+ "Deleting a name, attribute, or collection entry introduces lifetime or in-place mutation semantics "
35
+ "that are harder to follow than explicit value construction."
36
+ ),
37
+ remediation=(
38
+ "Remove unused-value deletion markers. Rebuild collections with comprehensions, copies, or immutable "
39
+ "value helpers such as dataclasses.replace. When an external API or deliberate lifetime boundary "
40
+ "requires deletion, add an exact SARJ442 suppression on the statement and explain why."
41
+ ),
42
+ category=RuleCategory.MAINTAINABILITY,
43
+ autofix=AutofixPolicy.NONE,
44
+ aliases=("no-deleted-only-override-parameter",),
45
+ limitations=(
46
+ "Every authored Python del statement is reported once, regardless of how many targets it contains.",
47
+ "The rule does not automatically rewrite deletion because the correct immutable replacement depends on ownership and value semantics.",
48
+ "Generated and vendored files are excluded.",
49
+ ),
50
+ examples=(
51
+ RuleExample(
52
+ example_id="delete-mapping-entry",
53
+ title="Build a mapping without the removed entry",
54
+ outcome=ExampleOutcome.MATCH,
55
+ files=(
56
+ ExampleFile.python(
57
+ "app/request.py",
58
+ "payload = dict(raw_payload)\ndel payload['secret']\n",
59
+ ),
60
+ ),
61
+ focus_path=PurePosixPath("app/request.py"),
62
+ expected_count=1,
63
+ public=True,
64
+ ),
65
+ RuleExample(
66
+ example_id="immutable-mapping-filter",
67
+ title="Construct the desired mapping directly",
68
+ outcome=ExampleOutcome.NO_MATCH,
69
+ files=(
70
+ ExampleFile.python(
71
+ "app/request.py",
72
+ "payload = {key: value for key, value in raw_payload.items() if key != 'secret'}\n",
73
+ ),
74
+ ),
75
+ focus_path=PurePosixPath("app/request.py"),
76
+ expected_count=0,
77
+ public=True,
78
+ ),
79
+ ),
80
+ )
81
+ description = documentation.summary
82
+
83
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
84
+ if "del" not in source or is_generated(path, source):
85
+ return []
86
+ tree = parse_or_none(path, source)
87
+ if tree is None:
88
+ return []
89
+ source_lines = source.splitlines()
90
+ return [
91
+ Diagnostic(
92
+ path=path,
93
+ line=node.lineno,
94
+ col=node.col_offset + 1,
95
+ code=self.code,
96
+ severity=Severity.WARNING,
97
+ message=(
98
+ "`del` introduces mutation or hidden lifetime behavior — construct the desired value "
99
+ "immutably, or add an exact SARJ442 suppression explaining why deletion is required."
100
+ ),
101
+ )
102
+ for node in ast.walk(tree)
103
+ if isinstance(node, ast.Delete) and not is_suppressed(source_lines, node.lineno, self.code)
104
+ ]
@@ -442,7 +442,6 @@ class _RuntimeConfigResolver:
442
442
  and binding.symbol is not None
443
443
  and self._is_settings_symbol(binding.module, binding.symbol, seen | {key})
444
444
  )
445
- del module_path
446
445
  self._settings_cache[key] = result
447
446
  return result
448
447
 
@@ -404,7 +404,7 @@ def _function_scopes(tree: ast.Module) -> list[_FunctionScope]:
404
404
 
405
405
  @override
406
406
  def visit_Lambda(self, node: ast.Lambda) -> None:
407
- del node
407
+ return
408
408
 
409
409
  Collector().visit(tree)
410
410
  return scopes
@@ -623,19 +623,19 @@ class _FunctionAnalyzer:
623
623
 
624
624
  @override
625
625
  def visit_Lambda(self, node: ast.Lambda) -> None:
626
- del node
626
+ return
627
627
 
628
628
  @override
629
629
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
630
- del node
630
+ return
631
631
 
632
632
  @override
633
633
  def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
634
- del node
634
+ return
635
635
 
636
636
  @override
637
637
  def visit_ClassDef(self, node: ast.ClassDef) -> None:
638
- del node
638
+ return
639
639
 
640
640
  Visitor().visit(node)
641
641