sarj-python-lint 0.85.0__tar.gz → 0.86.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 (142) hide show
  1. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/__main__.py +45 -13
  4. sarj_python_lint-0.86.0/src/sarj_python_lint/rules/_annotation_semantics.py +174 -0
  5. sarj_python_lint-0.86.0/src/sarj_python_lint/rules/_fixed_record.py +578 -0
  6. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  7. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +5 -1
  8. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +81 -20
  9. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +108 -63
  10. sarj_python_lint-0.86.0/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +306 -0
  11. sarj_python_lint-0.85.0/src/sarj_python_lint/rules/_fixed_record.py +0 -170
  12. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/.gitignore +0 -0
  13. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/LICENSE +0 -0
  14. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/README.md +0 -0
  15. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/__init__.py +0 -0
  16. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  17. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/_filesystem.py +0 -0
  18. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/_python_target.py +0 -0
  19. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  20. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/_secret_names.py +0 -0
  21. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/_version.py +0 -0
  22. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/py.typed +0 -0
  23. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/ratchet.py +0 -0
  24. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rule_base.py +0 -0
  25. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  26. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  28. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  29. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  30. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  31. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  32. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  33. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  34. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  35. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  36. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  37. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  38. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  39. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  40. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  41. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  42. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  43. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  44. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  45. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  46. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  47. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  48. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  49. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  50. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  51. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  52. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  53. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  54. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  55. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  56. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  57. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  58. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  59. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  60. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  61. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  62. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  63. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  64. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  65. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  66. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  67. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  68. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  69. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  70. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  71. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  72. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  73. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  74. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  75. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  76. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  77. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  78. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  79. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  80. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  81. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  82. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  83. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  84. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  85. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  86. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  87. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  88. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  89. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  90. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  91. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  92. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  93. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  94. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  95. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  96. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  97. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  98. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  99. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  100. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  101. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  102. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  103. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  104. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  105. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
  106. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  107. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  108. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  109. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  110. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  111. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  112. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  113. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  114. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  115. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  116. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  117. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  118. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  119. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  120. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  121. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  122. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  123. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  124. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  125. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  126. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  127. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  128. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  129. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  130. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  131. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  132. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/require_typed_http_test_response.py +0 -0
  133. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  134. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  135. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  136. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  137. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  138. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  139. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  140. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  141. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  142. {sarj_python_lint-0.85.0 → sarj_python_lint-0.86.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.85.0
3
+ Version: 0.86.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.85.0"
3
+ version = "0.86.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" }]
@@ -142,6 +142,10 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
142
142
  "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ420"}),
143
143
  "SARJ092": frozenset({"SARJ086", "SARJ087", "SARJ420"}),
144
144
  "SARJ093": frozenset({"SARJ034"}),
145
+ # Visible FastAPI operations have a route-aware contract diagnostic.
146
+ # Keep the annotation diagnostics when SARJ094 is absent, including
147
+ # when a concrete response_model makes the route contract complete.
148
+ "SARJ094": frozenset({"SARJ008", "SARJ447"}),
145
149
  "SARJ099": frozenset({"SARJ420"}),
146
150
  # Any-valued mappings are the stronger correctness failure. SARJ008
147
151
  # still owns every fixed dictionary return when selected alone, while
@@ -166,13 +170,17 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
166
170
  "SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
167
171
  )
168
172
  docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
169
- needs_signature_owners = ("SARJ093" in codes and "SARJ034" in codes) or ("SARJ447" in codes and "SARJ008" in codes)
173
+ needs_signature_owners = (
174
+ ("SARJ093" in codes and "SARJ034" in codes)
175
+ or ("SARJ447" in codes and "SARJ008" in codes)
176
+ or ("SARJ094" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ094"]))
177
+ )
170
178
  signature_owners = (
171
179
  _function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
172
180
  )
173
181
 
174
182
  def owner_location(diagnostic: Diagnostic) -> _OwnerLocation:
175
- if diagnostic.code in {"SARJ008", "SARJ034", "SARJ093", "SARJ447"}:
183
+ if diagnostic.code in {"SARJ008", "SARJ034", "SARJ093", "SARJ094", "SARJ447"}:
176
184
  line, column = signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
177
185
  else:
178
186
  line, column = docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
@@ -180,6 +188,8 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
180
188
 
181
189
  present: dict[tuple[Path, int, int], dict[str, set[Severity]]] = {}
182
190
  for diagnostic in diags:
191
+ if diagnostic.code == "SARJ094" and not diagnostic.message.startswith("[return]"):
192
+ continue
183
193
  location = owner_location(diagnostic)
184
194
  by_code = present.setdefault((diagnostic.path, location.line, location.column), {})
185
195
  by_code.setdefault(diagnostic.code, set()).add(diagnostic.severity)
@@ -213,23 +223,45 @@ def _suppressed_diagnostics(
213
223
 
214
224
 
215
225
  def _suppressed_codes(codes: dict[str, set[Severity]]) -> set[tuple[str, Severity]]:
216
- suppressed = {
217
- (generic, generic_severity)
218
- for specific, generics in _DIAGNOSTIC_PRECEDENCE.items()
219
- if specific in codes
220
- for generic in generics
221
- for generic_severity in codes.get(generic, set())
222
- if generic_severity is Severity.WARNING or Severity.ERROR in codes[specific]
223
- }
226
+ suppressed = _precedence_suppressions(codes)
227
+ # SARJ094 is deliberately staged as a warning while teams calibrate the
228
+ # FastAPI contract policy. Its route-aware return finding still owns the
229
+ # overlapping annotation failure: emitting the blocking SARJ447 twin would
230
+ # defeat warning staging and give two remediations for one return contract.
231
+ if "SARJ094" in codes:
232
+ suppressed.update(_route_contract_suppressions(codes))
224
233
  # The promoted signature-restatement rules now block. Keep their errors and
225
234
  # drop only the lower-severity typed-docstring twin at the same owner.
226
- if Severity.WARNING in codes.get("SARJ092", set()) and any(
227
- Severity.ERROR in codes.get(generic, set()) for generic in ("SARJ086", "SARJ087")
228
- ):
235
+ if _typed_docstring_warning_is_superseded(codes):
229
236
  suppressed.add(("SARJ092", Severity.WARNING))
230
237
  return suppressed
231
238
 
232
239
 
240
+ def _precedence_suppressions(codes: dict[str, set[Severity]]) -> set[tuple[str, Severity]]:
241
+ suppressed: set[tuple[str, Severity]] = set()
242
+ for specific, generics in _DIAGNOSTIC_PRECEDENCE.items():
243
+ if specific not in codes:
244
+ continue
245
+ specific_severities = codes[specific]
246
+ for generic in generics:
247
+ for generic_severity in codes.get(generic, set()):
248
+ if generic_severity is Severity.WARNING or Severity.ERROR in specific_severities:
249
+ suppressed.add((generic, generic_severity))
250
+ return suppressed
251
+
252
+
253
+ def _route_contract_suppressions(codes: dict[str, set[Severity]]) -> set[tuple[str, Severity]]:
254
+ return {
255
+ (generic, severity) for generic in _DIAGNOSTIC_PRECEDENCE["SARJ094"] for severity in codes.get(generic, set())
256
+ }
257
+
258
+
259
+ def _typed_docstring_warning_is_superseded(codes: dict[str, set[Severity]]) -> bool:
260
+ if Severity.WARNING not in codes.get("SARJ092", set()):
261
+ return False
262
+ return any(Severity.ERROR in codes.get(generic, set()) for generic in ("SARJ086", "SARJ087"))
263
+
264
+
233
265
  def _function_signature_owner_locations(source: str) -> dict[int, _OwnerLocation]:
234
266
  try:
235
267
  tree = ast.parse(source)
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from types import MappingProxyType
6
+ from typing import Self
7
+
8
+ from sarj_python_lint.rules._imports import ImportIndex
9
+
10
+
11
+ _TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
12
+ _TRANSPARENT_WRAPPERS = frozenset({"Annotated", "ClassVar", "Final", "NotRequired", "Optional", "ReadOnly", "Required"})
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class AnnotationSemantics:
17
+ imports: ImportIndex
18
+ aliases: MappingProxyType[str, ast.expr]
19
+
20
+ @classmethod
21
+ def from_tree(cls, tree: ast.Module, *, module_scope_only: bool = True) -> Self:
22
+ imports = ImportIndex.from_tree(tree, module_scope_only=module_scope_only)
23
+ candidates: dict[str, ast.expr] = {}
24
+ ambiguous: set[str] = set()
25
+ for statement in tree.body:
26
+ name: str | None = None
27
+ value: ast.expr | None = None
28
+ match statement:
29
+ case ast.TypeAlias(name=ast.Name(id=alias), value=alias_value, type_params=[]):
30
+ name, value = alias, alias_value
31
+ case ast.AnnAssign(target=ast.Name(id=alias), annotation=annotation, value=alias_value) if (
32
+ alias_value is not None
33
+ and imports.resolves(annotation, sources=_TYPING_SOURCES, symbol="TypeAlias")
34
+ ):
35
+ name, value = alias, alias_value
36
+ case _:
37
+ pass
38
+ if name is None or value is None:
39
+ continue
40
+ if name in candidates:
41
+ ambiguous.add(name)
42
+ else:
43
+ candidates[name] = value
44
+ if not candidates:
45
+ return cls(imports, MappingProxyType({}))
46
+ rebound = _module_rebindings(tree)
47
+ aliases = {name: value for name, value in candidates.items() if name not in ambiguous and name not in rebound}
48
+ return cls(imports, MappingProxyType(aliases))
49
+
50
+ def parse(self, annotation: ast.expr | None) -> ast.expr | None:
51
+ if not isinstance(annotation, ast.Constant) or not isinstance(annotation.value, str):
52
+ return annotation
53
+ try:
54
+ parsed = ast.parse(annotation.value.strip(), mode="eval").body
55
+ except SyntaxError:
56
+ return None
57
+ ast.copy_location(parsed, annotation)
58
+ return parsed
59
+
60
+ def resolve_alias(self, annotation: ast.expr | None, *, seen: frozenset[str] = frozenset()) -> ast.expr | None:
61
+ resolved = self.parse(annotation)
62
+ while isinstance(resolved, ast.Name) and resolved.id in self.aliases and resolved.id not in seen:
63
+ seen |= {resolved.id}
64
+ resolved = self.parse(self.aliases[resolved.id])
65
+ return resolved
66
+
67
+ def transparent_members(
68
+ self, annotation: ast.expr | None, *, seen: frozenset[str] = frozenset()
69
+ ) -> tuple[ast.expr, ...]:
70
+ resolved = self.parse(annotation)
71
+ if resolved is None:
72
+ return ()
73
+ if isinstance(resolved, ast.Name) and resolved.id in self.aliases:
74
+ return self._alias_members(resolved.id, seen=seen)
75
+ if isinstance(resolved, ast.BinOp) and isinstance(resolved.op, ast.BitOr):
76
+ return self.transparent_members(resolved.left, seen=seen) + self.transparent_members(
77
+ resolved.right, seen=seen
78
+ )
79
+ if isinstance(resolved, ast.Subscript):
80
+ return self._subscript_members(resolved, seen=seen)
81
+ return (resolved,)
82
+
83
+ def _alias_members(self, name: str, *, seen: frozenset[str]) -> tuple[ast.expr, ...]:
84
+ if name in seen:
85
+ return ()
86
+ return self.transparent_members(self.aliases[name], seen=seen | {name})
87
+
88
+ def _subscript_members(self, node: ast.Subscript, *, seen: frozenset[str]) -> tuple[ast.expr, ...]:
89
+ wrapper = self.imports.resolved_symbol(node.value, sources=_TYPING_SOURCES)
90
+ if wrapper == "Union":
91
+ members = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice]
92
+ return self._flatten_members(members, seen=seen)
93
+ if wrapper not in _TRANSPARENT_WRAPPERS:
94
+ return (node,)
95
+ inner = node.slice.elts[0] if isinstance(node.slice, ast.Tuple) else node.slice
96
+ return self.transparent_members(inner, seen=seen)
97
+
98
+ def _flatten_members(self, nodes: list[ast.expr], *, seen: frozenset[str]) -> tuple[ast.expr, ...]:
99
+ members: list[ast.expr] = []
100
+ for node in nodes:
101
+ members.extend(self.transparent_members(node, seen=seen))
102
+ return tuple(members)
103
+
104
+
105
+ def _module_rebindings(tree: ast.Module) -> set[str]:
106
+ counts: dict[str, int] = {}
107
+ for statement in tree.body:
108
+ for name in _bound_names(statement):
109
+ counts[name] = counts.get(name, 0) + 1
110
+ return {name for name, count in counts.items() if count > 1}
111
+
112
+
113
+ def scope_bound_names(statements: list[ast.stmt]) -> set[str]:
114
+ return {name for statement in statements for name in _bound_names(statement)}
115
+
116
+
117
+ def _bound_names(statement: ast.stmt) -> set[str]:
118
+ collector = _ScopeBindings()
119
+ collector.visit(statement)
120
+ return collector.names
121
+
122
+
123
+ def _target_names(node: ast.expr) -> set[str]:
124
+ if isinstance(node, ast.Name):
125
+ return {node.id}
126
+ if isinstance(node, (ast.List, ast.Tuple)):
127
+ return {name for item in node.elts for name in _target_names(item)}
128
+ return set()
129
+
130
+
131
+ class _ScopeBindings(ast.NodeVisitor):
132
+ def __init__(self) -> None:
133
+ self.names: set[str] = set()
134
+
135
+ def visit_Name(self, node: ast.Name) -> None:
136
+ if isinstance(node.ctx, (ast.Store, ast.Del)):
137
+ self.names.add(node.id)
138
+
139
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
140
+ self.names.add(node.name)
141
+
142
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
143
+ self.names.add(node.name)
144
+
145
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
146
+ self.names.add(node.name)
147
+
148
+ def visit_Lambda(self, node: ast.Lambda) -> None:
149
+ pass
150
+
151
+ def visit_Import(self, node: ast.Import) -> None:
152
+ self.names.update(alias.asname or alias.name.partition(".")[0] for alias in node.names)
153
+
154
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
155
+ self.names.update(alias.asname or alias.name for alias in node.names if alias.name != "*")
156
+
157
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
158
+ if node.name is not None:
159
+ self.names.add(node.name)
160
+ self.generic_visit(node)
161
+
162
+ def visit_MatchAs(self, node: ast.MatchAs) -> None:
163
+ if node.name is not None:
164
+ self.names.add(node.name)
165
+ self.generic_visit(node)
166
+
167
+ def visit_MatchStar(self, node: ast.MatchStar) -> None:
168
+ if node.name is not None:
169
+ self.names.add(node.name)
170
+
171
+ def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
172
+ if node.rest is not None:
173
+ self.names.add(node.rest)
174
+ self.generic_visit(node)