sarj-python-lint 0.80.6__tar.gz → 0.80.8__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 (153) hide show
  1. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/pyproject.toml +1 -1
  3. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/_fixed_record.py +151 -0
  4. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/_imports.py +181 -0
  5. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_registry.py +8 -16
  6. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_sql.py +17 -1
  7. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +76 -2
  8. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/iac_source_coupled_test.py +14 -5
  9. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +902 -0
  10. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +804 -0
  11. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +912 -0
  12. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +25 -22
  13. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/no_vague_suppression_description.py +138 -0
  14. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +298 -0
  15. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/over_mocked_test.py +216 -51
  16. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_class_row.py +432 -0
  17. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +507 -0
  18. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +246 -87
  19. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +115 -27
  20. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_library_fake.py +253 -86
  21. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_match_assert_never.py +436 -0
  22. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +449 -0
  23. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_module_level_constant.py +254 -110
  24. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +205 -0
  25. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_nominal_id_types.py +546 -0
  26. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +336 -0
  27. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_or_pattern.py +72 -34
  28. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +209 -34
  29. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_self_type_annotation.py +652 -0
  30. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_str_enum.py +301 -128
  31. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +368 -0
  32. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +265 -0
  33. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +506 -0
  34. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +249 -0
  35. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/production_derived_test_cases.py +151 -44
  36. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/pydantic_at_boundaries.py +313 -0
  37. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/redundant_class_docstring.py +209 -0
  38. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/redundant_docstring.py +269 -0
  39. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/redundant_module_docstring.py +79 -41
  40. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/repeated_static_call_cases.py +512 -0
  41. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +219 -18
  42. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +151 -51
  43. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/require_port_for_service.py +250 -51
  44. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +1208 -0
  45. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +614 -0
  46. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/restated_test_docstring.py +545 -0
  47. sarj_python_lint-0.80.8/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +315 -0
  48. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/_imports.py +0 -96
  49. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -107
  50. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -209
  51. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_class_row.py +0 -231
  52. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -332
  53. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -440
  54. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -978
  55. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -493
  56. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -475
  57. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -383
  58. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -175
  59. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -250
  60. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -131
  61. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -669
  62. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -171
  63. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -298
  64. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -149
  65. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -138
  66. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/preserve_enum_types.py +0 -131
  67. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -374
  68. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -121
  69. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/redundant_docstring.py +0 -171
  70. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -305
  71. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -601
  72. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -166
  73. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -159
  74. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/restated_test_docstring.py +0 -248
  75. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/source_coupled_test.py +0 -519
  76. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -202
  77. sarj_python_lint-0.80.6/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -148
  78. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/.gitignore +0 -0
  79. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/LICENSE +0 -0
  80. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/README.md +0 -0
  81. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/__init__.py +0 -0
  82. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/__main__.py +0 -0
  83. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_analysis_session.py +0 -0
  84. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_filesystem.py +0 -0
  85. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  86. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_secret_names.py +0 -0
  87. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/_version.py +0 -0
  88. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/py.typed +0 -0
  89. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/ratchet.py +0 -0
  90. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rule_base.py +0 -0
  91. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/__init__.py +0 -0
  92. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  93. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_comments.py +0 -0
  94. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  95. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  96. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_first_party.py +0 -0
  97. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_logging.py +0 -0
  98. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_paths.py +0 -0
  99. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_project_index.py +0 -0
  100. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  101. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_pytest.py +0 -0
  102. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  103. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  104. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  105. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  106. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  107. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  108. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  109. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  110. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  111. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  112. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  113. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  114. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  115. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  116. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  117. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  118. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  119. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  120. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  121. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  122. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  123. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  124. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  125. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  126. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  127. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  128. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  129. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  130. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  131. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  132. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  133. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  134. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  135. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  136. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  137. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  138. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  139. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  140. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  141. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  142. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  143. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  144. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  145. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  146. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/stepdown.py +0 -0
  147. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  148. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  149. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  150. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  151. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  152. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  153. {sarj_python_lint-0.80.6 → sarj_python_lint-0.80.8}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.80.6
3
+ Version: 0.80.8
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.80.6"
3
+ version = "0.80.8"
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" }]
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+
5
+ from sarj_python_lint.rules._ast_index import children
6
+
7
+
8
+ _RECORD_MUTATOR_METHODS = frozenset({"clear", "pop", "popitem", "setdefault", "update"})
9
+
10
+
11
+ def builds_fixed_record(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
12
+ returned: list[ast.expr] = []
13
+ record_names = {
14
+ target.id
15
+ for current in _owned_nodes(node)
16
+ if isinstance(current, ast.Assign) and _is_record_literal(current.value)
17
+ for target in current.targets
18
+ if isinstance(target, ast.Name)
19
+ }
20
+ record_names.update(
21
+ current.target.id
22
+ for current in _owned_nodes(node)
23
+ if isinstance(current, ast.AnnAssign)
24
+ and isinstance(current.target, ast.Name)
25
+ and current.value is not None
26
+ and _is_record_literal(current.value)
27
+ )
28
+ invalidated_names: set[str] = set()
29
+ aliases: dict[str, str] = {}
30
+ stack: list[ast.AST] = list(node.body)
31
+ while stack:
32
+ current = stack.pop()
33
+ if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
34
+ continue
35
+ if isinstance(current, ast.Return) and current.value is not None:
36
+ returned.append(current.value)
37
+ elif isinstance(current, ast.Assign) and _is_record_literal(current.value):
38
+ for target in current.targets:
39
+ if isinstance(target, ast.Subscript):
40
+ invalidated_names.update(_mutated_record_roots(target))
41
+ elif isinstance(current, ast.Assign):
42
+ if isinstance(current.value, ast.Name):
43
+ for target in current.targets:
44
+ if not isinstance(target, ast.Name):
45
+ continue
46
+ if target.id in record_names:
47
+ invalidated_names.add(target.id)
48
+ else:
49
+ aliases[target.id] = current.value.id
50
+ else:
51
+ invalidated_names.update(
52
+ target.id for target in current.targets if isinstance(target, ast.Name)
53
+ )
54
+ for target in current.targets:
55
+ if isinstance(target, ast.Subscript):
56
+ invalidated_names.update(_mutated_record_roots(target))
57
+ elif (
58
+ isinstance(current, ast.AnnAssign)
59
+ and isinstance(current.target, ast.Name)
60
+ and current.value is not None
61
+ and _is_record_literal(current.value)
62
+ ):
63
+ pass
64
+ elif (
65
+ isinstance(current, ast.AnnAssign)
66
+ and isinstance(current.target, ast.Name)
67
+ and isinstance(current.value, ast.Name)
68
+ ):
69
+ if current.target.id in record_names:
70
+ invalidated_names.add(current.target.id)
71
+ else:
72
+ aliases[current.target.id] = current.value.id
73
+ elif isinstance(current, (ast.AnnAssign, ast.AugAssign)):
74
+ invalidated_names.update(_mutated_record_roots(current.target))
75
+ elif isinstance(current, ast.Delete):
76
+ for target in current.targets:
77
+ invalidated_names.update(_mutated_record_roots(target))
78
+ elif (
79
+ isinstance(current, ast.Call)
80
+ and isinstance(current.func, ast.Attribute)
81
+ and current.func.attr in _RECORD_MUTATOR_METHODS
82
+ and isinstance(current.func.value, ast.Name)
83
+ ):
84
+ invalidated_names.add(current.func.value.id)
85
+ elif isinstance(current, ast.Call):
86
+ invalidated_names.update(
87
+ argument.id
88
+ for argument in (*current.args, *(keyword.value for keyword in current.keywords))
89
+ if isinstance(argument, ast.Name)
90
+ )
91
+ stack.extend(children(current))
92
+
93
+ changed = True
94
+ while changed:
95
+ changed = False
96
+ for alias, original in aliases.items():
97
+ if alias in invalidated_names and original not in invalidated_names:
98
+ invalidated_names.add(original)
99
+ changed = True
100
+ if original in invalidated_names and alias not in invalidated_names:
101
+ invalidated_names.add(alias)
102
+ changed = True
103
+
104
+ def original_name(name: str) -> str:
105
+ seen: set[str] = set()
106
+ while name in aliases and name not in seen:
107
+ seen.add(name)
108
+ name = aliases[name]
109
+ return name
110
+
111
+ intact_record_names = record_names - invalidated_names
112
+ if any(
113
+ isinstance(value, ast.Name) and original_name(value.id) in record_names & invalidated_names
114
+ for value in returned
115
+ ):
116
+ return False
117
+ return any(
118
+ _is_record_literal(value)
119
+ or (isinstance(value, ast.Name) and original_name(value.id) in intact_record_names)
120
+ for value in returned
121
+ )
122
+
123
+
124
+ def _owned_nodes(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]:
125
+ owned: list[ast.AST] = []
126
+ stack: list[ast.AST] = list(node.body)
127
+ while stack:
128
+ current = stack.pop()
129
+ if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
130
+ continue
131
+ owned.append(current)
132
+ stack.extend(children(current))
133
+ return owned
134
+
135
+
136
+ def _is_record_literal(node: ast.expr) -> bool:
137
+ if isinstance(node, ast.List):
138
+ return bool(node.elts) and all(_is_record_literal(element) for element in node.elts)
139
+ if isinstance(node, ast.ListComp):
140
+ return _is_record_literal(node.elt)
141
+ if not isinstance(node, ast.Dict):
142
+ return False
143
+ return bool(node.keys) and all(
144
+ isinstance(key, ast.Constant) and isinstance(key.value, str) for key in node.keys
145
+ )
146
+
147
+
148
+ def _mutated_record_roots(target: ast.AST) -> set[str]:
149
+ while isinstance(target, ast.Subscript):
150
+ target = target.value
151
+ return {target.id} if isinstance(target, ast.Name) else set()
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from types import MappingProxyType
7
+ from typing import Self
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class _ImportTarget:
12
+ module: str
13
+ symbol: str | None
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ImportIndex:
18
+ bindings: Mapping[str, _ImportTarget]
19
+ shadowed_names: frozenset[str]
20
+
21
+ @classmethod
22
+ def from_tree(cls, tree: ast.Module, *, module_scope_only: bool = False) -> Self:
23
+ candidates: dict[str, set[_ImportTarget]] = {}
24
+ statements = _module_import_statements(tree) if module_scope_only else tree.body
25
+ for statement in statements:
26
+ match statement:
27
+ case ast.Import(names=names):
28
+ for alias in names:
29
+ local = alias.asname or alias.name.partition(".")[0]
30
+ module = alias.name if alias.asname else alias.name.partition(".")[0]
31
+ candidates.setdefault(local, set()).add(_ImportTarget(module, None))
32
+ case ast.ImportFrom(module=str(module), names=names, level=0):
33
+ for alias in names:
34
+ if alias.name == "*":
35
+ continue
36
+ local = alias.asname or alias.name
37
+ candidates.setdefault(local, set()).add(_ImportTarget(module, alias.name))
38
+ case _:
39
+ pass
40
+
41
+ imported_names = frozenset(candidates)
42
+ non_import_bindings = (
43
+ _module_non_import_bindings(tree) if module_scope_only else _non_import_bindings(tree)
44
+ )
45
+ rebound = non_import_bindings & imported_names
46
+ bindings = {
47
+ local: next(iter(targets))
48
+ for local, targets in candidates.items()
49
+ if len(targets) == 1 and local not in rebound
50
+ }
51
+ return cls(MappingProxyType(bindings), frozenset(non_import_bindings))
52
+
53
+ def resolves(self, node: ast.expr, *, sources: frozenset[str], symbol: str) -> bool:
54
+ resolved = self._resolve(node)
55
+ return resolved is not None and resolved.module in sources and resolved.symbol == symbol
56
+
57
+ def resolved_symbol(self, node: ast.expr, *, sources: frozenset[str]) -> str | None:
58
+ resolved = self._resolve(node)
59
+ if resolved is None or resolved.module not in sources:
60
+ return None
61
+ return resolved.symbol
62
+
63
+ def builtin_is_unshadowed(self, name: str) -> bool:
64
+ return name not in self.shadowed_names and name not in self.bindings
65
+
66
+ def _resolve(self, node: ast.expr) -> _ImportTarget | None:
67
+ match node:
68
+ case ast.Name(id=local):
69
+ target = self.bindings.get(local)
70
+ return target if target is not None and target.symbol is not None else None
71
+ case ast.Attribute():
72
+ parts: list[str] = []
73
+ current: ast.expr = node
74
+ while isinstance(current, ast.Attribute):
75
+ parts.append(current.attr)
76
+ current = current.value
77
+ if not isinstance(current, ast.Name):
78
+ return None
79
+ target = self.bindings.get(current.id)
80
+ if target is None or target.symbol is not None or not parts:
81
+ return None
82
+ ordered = list(reversed(parts))
83
+ return _ImportTarget(".".join((target.module, *ordered[:-1])), ordered[-1])
84
+ case _:
85
+ return None
86
+
87
+
88
+ def _non_import_bindings(tree: ast.Module) -> set[str]:
89
+ names: set[str] = set()
90
+ for node in ast.walk(tree):
91
+ match node:
92
+ case ast.Name(id=name, ctx=(ast.Store() | ast.Del())) | ast.arg(arg=name):
93
+ names.add(name)
94
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
95
+ names.add(node.name)
96
+ case ast.alias():
97
+ # Imports are the candidate bindings, not shadows of themselves.
98
+ continue
99
+ case _:
100
+ pass
101
+ return names
102
+
103
+
104
+ def _module_non_import_bindings(tree: ast.Module) -> set[str]:
105
+ names: set[str] = set()
106
+ for statement in tree.body:
107
+ match statement:
108
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
109
+ names.add(statement.name)
110
+ case ast.Assign(targets=targets):
111
+ for target in targets:
112
+ names.update(_target_names(target))
113
+ case ast.AnnAssign(target=target) | ast.AugAssign(target=target):
114
+ names.update(_target_names(target))
115
+ case ast.TypeAlias(name=ast.Name(id=name)):
116
+ names.add(name)
117
+ case ast.Import() | ast.ImportFrom():
118
+ continue
119
+ case _:
120
+ # Module control flow can still bind names in the module namespace.
121
+ _collect_module_bindings(statement, names)
122
+ return names
123
+
124
+
125
+ def _target_names(node: ast.expr) -> set[str]:
126
+ if isinstance(node, ast.Name):
127
+ return {node.id}
128
+ if isinstance(node, (ast.Tuple, ast.List)):
129
+ return {name for element in node.elts for name in _target_names(element)}
130
+ return set()
131
+
132
+
133
+ def _collect_module_bindings(node: ast.AST, names: set[str]) -> None:
134
+ match node:
135
+ case ast.FunctionDef() | ast.AsyncFunctionDef() | ast.ClassDef():
136
+ names.add(node.name)
137
+ return
138
+ case ast.ListComp() | ast.SetComp() | ast.GeneratorExp():
139
+ _collect_module_bindings(node.elt, names)
140
+ _collect_comprehension_bindings(node.generators, names)
141
+ return
142
+ case ast.DictComp():
143
+ _collect_module_bindings(node.key, names)
144
+ _collect_module_bindings(node.value, names)
145
+ _collect_comprehension_bindings(node.generators, names)
146
+ return
147
+ case ast.Lambda():
148
+ return
149
+ case ast.Name(id=name, ctx=(ast.Store() | ast.Del())):
150
+ names.add(name)
151
+ case _:
152
+ pass
153
+ for child in ast.iter_child_nodes(node):
154
+ _collect_module_bindings(child, names)
155
+
156
+
157
+ def _collect_comprehension_bindings(generators: list[ast.comprehension], names: set[str]) -> None:
158
+ for generator in generators:
159
+ _collect_module_bindings(generator.iter, names)
160
+ for condition in generator.ifs:
161
+ _collect_module_bindings(condition, names)
162
+
163
+
164
+ def _module_import_statements(tree: ast.Module) -> list[ast.stmt]:
165
+ statements = list(tree.body)
166
+ for statement in tree.body:
167
+ if not isinstance(statement, ast.If) or not _is_type_checking_guard(statement.test):
168
+ continue
169
+ statements.extend(
170
+ child for child in statement.body if isinstance(child, (ast.Import, ast.ImportFrom))
171
+ )
172
+ return statements
173
+
174
+
175
+ def _is_type_checking_guard(node: ast.expr) -> bool:
176
+ return (isinstance(node, ast.Name) and node.id == "TYPE_CHECKING") or (
177
+ isinstance(node, ast.Attribute)
178
+ and isinstance(node.value, ast.Name)
179
+ and node.value.id in {"typing", "typing_extensions"}
180
+ and node.attr == "TYPE_CHECKING"
181
+ )
@@ -50,8 +50,13 @@ from sarj_python_lint.rules.no_hidden_constructor_fallback import (
50
50
  )
51
51
  from sarj_python_lint.rules.no_nested_pydantic_field_validator import NoNestedPydanticFieldValidator
52
52
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
53
+ from sarj_python_lint.rules.no_positional_psycopg_row_escape import NoPositionalPsycopgRowEscape
54
+ from sarj_python_lint.rules.no_psycopg_execution_outside_injected_owner import (
55
+ NoPsycopgExecutionOutsideInjectedOwner,
56
+ )
53
57
  from sarj_python_lint.rules.no_random_uuid_in_sql import NoRandomUuidInSql
54
58
  from sarj_python_lint.rules.no_raw_connection_in_tests import NoRawConnectionInTests
59
+ from sarj_python_lint.rules.no_raw_source_text_test_oracle import NoRawSourceTextTestOracle
55
60
  from sarj_python_lint.rules.no_redundant_literal_description import NoRedundantLiteralDescription
56
61
  from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
57
62
  from sarj_python_lint.rules.no_repeated_test_body import NoRepeatedTestBody
@@ -96,7 +101,6 @@ from sarj_python_lint.rules.prefer_nominal_id_types import PreferNominalIdTypes
96
101
  from sarj_python_lint.rules.prefer_non_nullable_collection import (
97
102
  PreferNonNullableCollection,
98
103
  )
99
- from sarj_python_lint.rules.prefer_one_for_required_row import PreferOneForRequiredRow
100
104
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
101
105
  from sarj_python_lint.rules.prefer_self_documenting_constant import (
102
106
  PreferSelfDocumentingConstant,
@@ -107,17 +111,12 @@ from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
107
111
  from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
108
112
  PreferStructOverNamedtuple,
109
113
  )
110
- from sarj_python_lint.rules.prefer_timedelta_for_durations import (
111
- PreferTimedeltaForDurations,
112
- )
113
114
  from sarj_python_lint.rules.prefer_walrus_awaited_none_guard import PreferWalrusAwaitedNoneGuard
114
115
  from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
115
116
  PreferWalrusComprehensionFilter,
116
117
  )
117
118
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
118
119
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
119
- from sarj_python_lint.rules.preserve_declared_nominal_id import PreserveDeclaredNominalId
120
- from sarj_python_lint.rules.preserve_enum_types import PreserveEnumTypes
121
120
  from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
122
121
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
123
122
  from sarj_python_lint.rules.pytest_fixture_returns_bare_tuple import PytestFixtureReturnsBareTuple
@@ -139,10 +138,7 @@ from sarj_python_lint.rules.require_pydantic_for_external_json import (
139
138
  from sarj_python_lint.rules.require_pydantic_ordinal_lower_bound import (
140
139
  RequirePydanticOrdinalLowerBound,
141
140
  )
142
- from sarj_python_lint.rules.require_validated_row_factory import RequireValidatedRowFactory
143
141
  from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
144
- from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
145
- from sarj_python_lint.rules.sql_requires_injected_pool_owner import SqlRequiresInjectedPoolOwner
146
142
  from sarj_python_lint.rules.stepdown import Stepdown
147
143
  from sarj_python_lint.rules.store_get_delegates_to_bulk_read import StoreGetDelegatesToBulkRead
148
144
  from sarj_python_lint.rules.store_insert_requires_on_conflict import (
@@ -179,12 +175,11 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
179
175
  FakesInSharedLocation.id: FakesInSharedLocation,
180
176
  PreferConstantTimeSecretCompare.id: PreferConstantTimeSecretCompare,
181
177
  NoSecretInLog.id: NoSecretInLog,
182
- PreferTimedeltaForDurations.id: PreferTimedeltaForDurations,
183
178
  PreferStructOverNamedtuple.id: PreferStructOverNamedtuple,
184
179
  NoCommentCruft.id: NoCommentCruft,
185
180
  NoConftestTestModuleImport.id: NoConftestTestModuleImport,
186
181
  StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
187
- SourceCoupledTest.id: SourceCoupledTest,
182
+ NoRawSourceTextTestOracle.id: NoRawSourceTextTestOracle,
188
183
  IacSourceCoupledTest.id: IacSourceCoupledTest,
189
184
  NoRawConnectionInTests.id: NoRawConnectionInTests,
190
185
  NoAnalyticalAggregationInPostgresStore.id: NoAnalyticalAggregationInPostgresStore,
@@ -201,7 +196,6 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
201
196
  OpaqueParametrizeCaseNeedsId.id: OpaqueParametrizeCaseNeedsId,
202
197
  PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
203
198
  StoreGetDelegatesToBulkRead.id: StoreGetDelegatesToBulkRead,
204
- PreferOneForRequiredRow.id: PreferOneForRequiredRow,
205
199
  RepeatedKwargHeavyCallInTest.id: RepeatedKwargHeavyCallInTest,
206
200
  DefectXfailRequiresExplicitStrict.id: DefectXfailRequiresExplicitStrict,
207
201
  NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
@@ -249,10 +243,8 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
249
243
  ProductionDerivedTestCases.id: ProductionDerivedTestCases,
250
244
  UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
251
245
  RepeatedStaticCallCases.id: RepeatedStaticCallCases,
252
- RequireValidatedRowFactory.id: RequireValidatedRowFactory,
253
- SqlRequiresInjectedPoolOwner.id: SqlRequiresInjectedPoolOwner,
254
- PreserveDeclaredNominalId.id: PreserveDeclaredNominalId,
255
- PreserveEnumTypes.id: PreserveEnumTypes,
246
+ NoPositionalPsycopgRowEscape.id: NoPositionalPsycopgRowEscape,
247
+ NoPsycopgExecutionOutsideInjectedOwner.id: NoPsycopgExecutionOutsideInjectedOwner,
256
248
  RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
257
249
  RequireNoDecodeForSplittingSettingsField.id: RequireNoDecodeForSplittingSettingsField,
258
250
  NoVagueSuppressionDescription.id: NoVagueSuppressionDescription,
@@ -39,12 +39,28 @@ def sql_string_value(node: ast.expr, *, interpolation_placeholder: str = " ") ->
39
39
  return None
40
40
 
41
41
 
42
- def strip_sql_noise(text: str, *, mask_dollar_quotes: bool = True) -> str:
42
+ def strip_sql_noise(
43
+ text: str,
44
+ *,
45
+ mask_dollar_quotes: bool = True,
46
+ mask_double_quotes: bool = True,
47
+ ) -> str:
43
48
  out = list(text)
44
49
  n = len(text)
45
50
  i = 0
46
51
  while i < n:
47
52
  ch = text[i]
53
+ if ch == '"' and not mask_double_quotes:
54
+ i += 1
55
+ while i < n:
56
+ if text[i] == '"':
57
+ i += 1
58
+ if i < n and text[i] == '"':
59
+ i += 1
60
+ continue
61
+ break
62
+ i += 1
63
+ continue
48
64
  if ch in {"'", '"'}:
49
65
  out[i] = " "
50
66
  i += 1
@@ -26,6 +26,7 @@ from sarj_python_lint.rules._fastapi import (
26
26
  Route,
27
27
  flat_name,
28
28
  )
29
+ from sarj_python_lint.rules._fixed_record import builds_fixed_record
29
30
  from sarj_python_lint.rules._paths import is_generated, is_test_path
30
31
 
31
32
 
@@ -152,6 +153,42 @@ class FastapiExplicitOpenapiContract(Rule):
152
153
  scenario="schema-integrity",
153
154
  public=True,
154
155
  ),
156
+ RuleExample(
157
+ example_id="unannotated-record-response",
158
+ title="Unnamed record leaves the response schema implicit",
159
+ outcome=ExampleOutcome.MATCH,
160
+ files=(
161
+ ExampleFile.python(
162
+ "api.py",
163
+ "from fastapi import APIRouter\n\nrouter = APIRouter()\n\n"
164
+ "@router.get('/health', status_code=200)\n"
165
+ "async def health():\n return {'status': 'ok'}\n",
166
+ ),
167
+ ),
168
+ focus_path=PurePosixPath("api.py"),
169
+ expected_count=1,
170
+ scenario="unannotated-record",
171
+ public=True,
172
+ ),
173
+ RuleExample(
174
+ example_id="named-record-response",
175
+ title="Named response type documents the record",
176
+ outcome=ExampleOutcome.NO_MATCH,
177
+ files=(
178
+ ExampleFile.python(
179
+ "api.py",
180
+ "from fastapi import APIRouter\nfrom pydantic import BaseModel\n\n"
181
+ "class HealthResponse(BaseModel):\n status: str\n\n"
182
+ "router = APIRouter()\n\n@router.get('/health', status_code=200)\n"
183
+ "async def health() -> HealthResponse:\n"
184
+ " return HealthResponse(status='ok')\n",
185
+ ),
186
+ ),
187
+ focus_path=PurePosixPath("api.py"),
188
+ expected_count=0,
189
+ scenario="unannotated-record",
190
+ public=True,
191
+ ),
155
192
  ),
156
193
  )
157
194
  description: str = documentation.summary
@@ -386,10 +423,27 @@ def _check_return(
386
423
  ) -> list[_Finding]:
387
424
  annotation = index.resolve_annotation(function.returns)
388
425
  if annotation is None:
426
+ if not builds_fixed_record(function):
427
+ return []
428
+ for route in _operations(routes):
429
+ if route.has_unpack:
430
+ continue
431
+ response_model = route.keywords.get("response_model")
432
+ status = _status_code(route.keywords.get("status_code"), index) or HTTPStatus.OK
433
+ if (
434
+ _concrete_response_model(response_model, index)
435
+ or any(_documents_response_model(node, status, index) for node in _route_response_nodes(route))
436
+ ):
437
+ continue
438
+ return [
439
+ _Finding(
440
+ function,
441
+ "[return] fixed-shape dictionary requires a named return type or concrete response_model.",
442
+ )
443
+ ]
389
444
  return []
390
445
  problems: set[str] = set()
391
- if _schema_erasing(annotation, index):
392
- problems.add("response annotation erases its OpenAPI schema; define an output model")
446
+ annotation_erases_schema = _schema_erasing(annotation, index)
393
447
  is_response = index.is_response(annotation)
394
448
  is_none = _is_none_annotation(annotation, index)
395
449
  for route in _operations(routes):
@@ -398,6 +452,8 @@ def _check_return(
398
452
  keywords = route.keywords
399
453
  response_model = keywords.get("response_model")
400
454
  response_model_none = response_model is not None and _literal_none(response_model)
455
+ if annotation_erases_schema and not _concrete_response_model(response_model, index):
456
+ problems.add("response annotation erases its OpenAPI schema; define an output model")
401
457
  status = _status_code(keywords.get("status_code"), index)
402
458
  response_class = keywords.get("response_class") or route.inherited_response_class
403
459
  if is_response and status not in _NO_CONTENT_STATUSES and response_class is None:
@@ -464,6 +520,24 @@ def _documents_response_content(node: ast.expr | None, status: int | None, index
464
520
  return False
465
521
 
466
522
 
523
+ def _documents_response_model(node: ast.expr | None, status: int | None, index: FastapiIndex) -> bool:
524
+ if not isinstance(node, ast.Dict):
525
+ return False
526
+ for key, value in zip(node.keys, node.values, strict=True):
527
+ if key is None or not isinstance(value, ast.Dict):
528
+ continue
529
+ if status is not None and status not in _response_codes(ast.Dict(keys=[key], values=[value]), index):
530
+ continue
531
+ for entry_key, entry_value in zip(value.keys, value.values, strict=True):
532
+ if (
533
+ isinstance(entry_key, ast.Constant)
534
+ and entry_key.value == "model"
535
+ and _concrete_response_model(entry_value, index)
536
+ ):
537
+ return True
538
+ return False
539
+
540
+
467
541
  def _route_response_nodes(route: Route) -> tuple[ast.expr, ...]:
468
542
  return tuple(node for node in (route.inherited_responses, route.keywords.get("responses")) if node is not None)
469
543
 
@@ -15,8 +15,9 @@ from sarj_python_lint.rule_base import (
15
15
  Severity,
16
16
  parse_or_none,
17
17
  )
18
+ from sarj_python_lint.rules._imports import ImportIndex
18
19
  from sarj_python_lint.rules._paths import is_generated, is_test_path
19
- from sarj_python_lint.rules.source_coupled_test import FunctionAnalyzer, top_level_test_functions
20
+ from sarj_python_lint.rules.no_raw_source_text_test_oracle import FunctionAnalyzer, top_level_test_functions
20
21
 
21
22
 
22
23
  if TYPE_CHECKING:
@@ -45,7 +46,7 @@ class IacSourceCoupledTest(Rule):
45
46
  "The rule follows local aliases, path collections, context-managed reads, and common normalization; interprocedural flows remain unreported.",
46
47
  "Files produced beneath recognized temporary-directory fixtures are generated outputs and remain unreported.",
47
48
  "The Python detector currently owns Terraform and HCL suffixes; YAML remains with the general source-coupled rule.",
48
- "Golden, packaging, formatter, and compatibility representation contracts require an exact suppression.",
49
+ "Fixture, golden, and snapshot paths are treated as deliberate representation contracts; other packaging, formatter, and compatibility contracts require an exact suppression.",
49
50
  ),
50
51
  examples=(
51
52
  RuleExample(
@@ -72,7 +73,7 @@ class IacSourceCoupledTest(Rule):
72
73
  files=(
73
74
  ExampleFile.python(
74
75
  "tests/test_policy.py",
75
- "def test_policy():\n source = Path('main.tf').read_text()\n assert 'prevent_destroy = true' in source\n",
76
+ "from pathlib import Path\n\ndef test_policy():\n source = Path('main.tf').read_text()\n assert 'prevent_destroy = true' in source\n",
76
77
  ),
77
78
  ),
78
79
  focus_path=PurePosixPath("tests/test_policy.py"),
@@ -90,10 +91,18 @@ class IacSourceCoupledTest(Rule):
90
91
  tree = parse_or_none(path, source)
91
92
  if not isinstance(tree, ast.Module):
92
93
  return []
94
+ imports = ImportIndex.from_tree(tree, module_scope_only=True)
95
+ source_lines = source.splitlines()
93
96
  assertions = [
94
97
  assertion
95
- for function, unittest_style in top_level_test_functions(tree)
96
- for assertion in FunctionAnalyzer(IAC_SOURCE_SUFFIXES, unittest_style=unittest_style).analyze(function)
98
+ for function, unittest_style in top_level_test_functions(tree, imports)
99
+ for assertion in FunctionAnalyzer(
100
+ IAC_SOURCE_SUFFIXES,
101
+ imports=imports,
102
+ suppression_code=self.code,
103
+ suppression_lines=source_lines,
104
+ unittest_style=unittest_style,
105
+ ).analyze(function)
97
106
  ]
98
107
  return [
99
108
  Diagnostic(