sarj-python-lint 1.7.8__tar.gz → 1.8.2__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 (164) hide show
  1. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/PKG-INFO +2 -2
  2. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/pyproject.toml +2 -2
  3. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +124 -74
  4. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +58 -57
  5. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +7 -3
  6. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +10 -62
  7. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/.gitignore +0 -0
  8. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/LICENSE +0 -0
  9. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/README.md +0 -0
  10. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_analysis_session.py +0 -0
  13. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_file_context.py +0 -0
  14. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_filesystem.py +0 -0
  15. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_python_target.py +0 -0
  16. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  17. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/benchmark.py +0 -0
  20. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/json_boundary.py +0 -0
  21. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/py.typed +0 -0
  22. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/ratchet.py +0 -0
  23. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rule_base.py +0 -0
  24. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/__init__.py +0 -0
  25. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
  26. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  27. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_ast_position.py +0 -0
  28. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_comments.py +0 -0
  29. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_contract_annotation.py +0 -0
  30. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_dict_key_views.py +0 -0
  31. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  32. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  33. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_first_party.py +0 -0
  34. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  35. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_imports.py +0 -0
  36. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_logging.py +0 -0
  37. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_nominal_project.py +0 -0
  38. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_paths.py +0 -0
  39. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_project_index.py +0 -0
  40. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  41. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_pytest.py +0 -0
  42. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_registry.py +0 -0
  43. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_resource_provenance.py +0 -0
  44. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_set_fields.py +0 -0
  45. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_sql.py +0 -0
  46. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  47. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  48. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/_test_composition.py +0 -0
  49. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/async_cleanup_registered_synchronously.py +0 -0
  50. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  51. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/discourage_nullable_constructor_parameters.py +0 -0
  52. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  53. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  54. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  55. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  56. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
  57. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  58. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  59. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  60. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  61. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
  62. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  63. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
  64. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  65. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  66. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  67. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  68. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
  69. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  70. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  71. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  72. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  73. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  74. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  75. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +0 -0
  76. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  77. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  78. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  79. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
  80. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  81. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  82. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  83. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  84. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  85. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  86. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  87. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  88. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  89. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  90. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  91. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  92. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  93. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  94. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  95. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  96. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_swallowed_asyncio_cancellation.py +0 -0
  97. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  98. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  99. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  100. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  101. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
  102. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  103. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  104. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  105. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  106. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  107. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  108. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  109. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  110. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  111. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  112. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  113. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  114. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  115. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
  116. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  117. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  118. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  119. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_monotonic_for_elapsed_time.py +0 -0
  120. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  121. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  122. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  123. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  124. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
  125. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_regex_fullmatch.py +0 -0
  126. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  127. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  128. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  129. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  130. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  131. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  132. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  133. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  134. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  135. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  136. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  137. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  138. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  139. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  140. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  141. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  142. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/repeated_test_composition.py +0 -0
  143. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_explicit_contract_implementation.py +0 -0
  144. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_explicit_psycopg_transaction.py +0 -0
  145. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_explicit_service_contract.py +0 -0
  146. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  147. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  148. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  149. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_public_dependency_contract.py +0 -0
  150. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  151. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
  152. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  153. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  154. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/shared_mutable_pydantic_factory.py +0 -0
  155. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/stepdown.py +0 -0
  156. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  157. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  158. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/subprocess_kill_without_reap.py +0 -0
  159. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  160. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  161. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  162. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  163. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  164. {sarj_python_lint-1.7.8 → sarj_python_lint-1.8.2}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 1.7.8
3
+ Version: 1.8.2
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/
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Requires-Python: >=3.14
20
20
  Requires-Dist: packaging==26.3
21
21
  Requires-Dist: ruff>=0.16.9
22
- Requires-Dist: sarj-rule-contracts==0.1.1
22
+ Requires-Dist: sarj-rule-contracts==0.2.0
23
23
  Requires-Dist: sqlglot==30.19.0
24
24
  Requires-Dist: typer==0.27.2
25
25
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "1.7.8"
3
+ version = "1.8.2"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3.14",
16
16
  "Topic :: Software Development :: Quality Assurance",
17
17
  ]
18
- dependencies = ["sarj-rule-contracts==0.1.1", "packaging==26.3", "ruff>=0.16.9", "sqlglot==30.19.0", "typer==0.27.2"]
18
+ dependencies = ["sarj-rule-contracts==0.2.0", "packaging==26.3", "ruff>=0.16.9", "sqlglot==30.19.0", "typer==0.27.2"]
19
19
 
20
20
  [project.scripts]
21
21
  sarj-python-lint = "sarj_python_lint.__main__:main"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ from enum import Enum, auto
4
5
  from pathlib import PurePosixPath
5
6
  from types import MappingProxyType
6
7
  from typing import TYPE_CHECKING, ClassVar, Final, final, override
@@ -65,12 +66,18 @@ type _TestFunction = ast.FunctionDef | ast.AsyncFunctionDef
65
66
  type _Reference = tuple[str, ...]
66
67
 
67
68
 
69
+ class _Evidence(Enum):
70
+ POSITIVE = auto()
71
+ INSUFFICIENT = auto()
72
+ UNRESOLVED = auto()
73
+
74
+
68
75
  @final
69
76
  class AsyncMockCallWithoutAwaitAssertion(Rule):
70
77
  id = "async-mock-call-without-await-assertion"
71
78
  code = "SARJ456"
72
79
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
73
- default_level=Severity.ERROR,
80
+ default_level=Severity.WARNING,
74
81
  summary="Pair positive AsyncMock call assertions with evidence that the mock was awaited.",
75
82
  rationale=(
76
83
  "Calling an AsyncMock records a call before its coroutine is awaited. A positive call assertion can "
@@ -87,33 +94,50 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
87
94
  "Only direct statements in collected test functions and methods are inspected; fixtures, helpers, nested blocks, and generated files are excluded.",
88
95
  "Requires a fresh local unittest.mock.AsyncMock constructor with unshadowed imports; aliases, reassignment, reset, and ambiguous configuration are excluded.",
89
96
  "Only the mock itself and unchanged immediate children of unspecced, unwrapped mocks are inferred; spec children, return values, and deeper chains are excluded.",
90
- "Positive call-count and called-state assertions are covered as well as call-assertion methods. Await-state assertions must prove at least one await; argument equality and exact call/await counts are not compared.",
91
- "Boolean conjunctions combine evidence; disjunctions require evidence in every branch. Dynamic counts and call lists are not inferred.",
97
+ "Positive call evidence includes direct assertion methods and literal integer or boolean call-state checks; unresolved call expectations are excluded.",
98
+ "Await evidence excludes provably vacuous checks, including zero or nonnegative counts and empty expected await sequences; unresolved expectations are accepted without inferring their values.",
99
+ "Conjunctions need one accepted await branch; disjunctions need accepted evidence for the same mock in every branch. Argument equality and assertion strength are not compared.",
100
+ "Compound negation and custom expression semantics remain unresolved; no variable values or fixture graphs are inferred.",
92
101
  "Custom assertion helpers and intentional scheduling contracts may require a reasoned local suppression.",
93
102
  ),
94
103
  examples=(
95
104
  RuleExample(
96
- example_id="numeric-call-with-vacuous-await-check",
97
- title="A nonnegative await count does not prove execution",
105
+ example_id="call-only-async-mock",
106
+ title="A call assertion does not prove awaiting",
98
107
  outcome=ExampleOutcome.MATCH,
99
108
  files=(
100
109
  ExampleFile.python(
101
110
  "tests/test_delivery.py",
102
- "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n assert send.call_count == 2\n assert send.await_count >= 0\n",
111
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n",
103
112
  ),
104
113
  ),
105
114
  focus_path=PurePosixPath("tests/test_delivery.py"),
106
115
  expected_count=1,
107
- public=False,
116
+ public=True,
108
117
  ),
109
118
  RuleExample(
110
- example_id="call-only-async-mock",
111
- title="A call assertion does not prove awaiting",
119
+ example_id="call-and-await-async-mock",
120
+ title="Call and await assertions protect different contracts",
121
+ outcome=ExampleOutcome.NO_MATCH,
122
+ files=(
123
+ ExampleFile.python(
124
+ "tests/test_delivery.py",
125
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n send.assert_awaited_once_with('item')\n",
126
+ ),
127
+ ),
128
+ focus_path=PurePosixPath("tests/test_delivery.py"),
129
+ expected_count=0,
130
+ public=True,
131
+ ),
132
+ RuleExample(
133
+ example_id="vacuous-await-count",
134
+ scenario="await-expectation-boundaries",
135
+ title="A nonnegative await count does not prove execution",
112
136
  outcome=ExampleOutcome.MATCH,
113
137
  files=(
114
138
  ExampleFile.python(
115
139
  "tests/test_delivery.py",
116
- "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n",
140
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n assert send.call_count == 1\n assert send.await_count >= 0\n",
117
141
  ),
118
142
  ),
119
143
  focus_path=PurePosixPath("tests/test_delivery.py"),
@@ -121,13 +145,14 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
121
145
  public=True,
122
146
  ),
123
147
  RuleExample(
124
- example_id="call-and-await-async-mock",
125
- title="Call and await assertions protect different contracts",
148
+ example_id="dynamic-expected-awaits",
149
+ scenario="await-expectation-boundaries",
150
+ title="Unresolved await expectations retain their acceptance",
126
151
  outcome=ExampleOutcome.NO_MATCH,
127
152
  files=(
128
153
  ExampleFile.python(
129
154
  "tests/test_delivery.py",
130
- "from unittest.mock import AsyncMock\n\nasync def test_delivery():\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once_with('item')\n send.assert_awaited_once_with('item')\n",
155
+ "from unittest.mock import AsyncMock\n\nasync def test_delivery(expected):\n send = AsyncMock()\n await deliver(send)\n send.assert_called_once()\n send.assert_has_awaits(expected)\n",
131
156
  ),
132
157
  ),
133
158
  focus_path=PurePosixPath("tests/test_delivery.py"),
@@ -166,7 +191,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
166
191
  line=call.lineno,
167
192
  col=call.col_offset + 1,
168
193
  code=self.code,
169
- severity=Severity.ERROR,
194
+ severity=Severity.WARNING,
170
195
  message="AsyncMock call assertions do not prove awaiting; keep this assertion and add an await assertion",
171
196
  )
172
197
  for test in _collected_tests(tree.body)
@@ -231,7 +256,7 @@ def _call_oracles(statement: ast.stmt) -> Iterator[tuple[_Reference, ast.Call |
231
256
  ):
232
257
  yield _reference(receiver), call
233
258
  case ast.Assert(test=condition):
234
- for reference in sorted(_positive_states(condition, _CALL_STATE)):
259
+ for reference in sorted(_asserted_states(condition, _CALL_STATE)):
235
260
  oracle = next(
236
261
  node
237
262
  for node in walk_ast(condition)
@@ -304,49 +329,60 @@ def _await_oracles(statements: list[ast.stmt]) -> set[_Reference]:
304
329
  case ast.Expr(value=ast.Call(func=ast.Attribute(value=receiver, attr=method)) as call) if (
305
330
  method in _AWAIT_ASSERTIONS
306
331
  ):
307
- if method != "assert_has_awaits" or _has_nonempty_calls_argument(call):
332
+ if method != "assert_has_awaits" or _calls_evidence(call) is not _Evidence.INSUFFICIENT:
308
333
  covered.add(_reference(receiver))
309
334
  case ast.Assert(test=condition):
310
- covered.update(_positive_states(condition, _AWAIT_STATE))
335
+ covered.update(_asserted_states(condition, _AWAIT_STATE))
311
336
  case _:
312
337
  pass
313
338
  return covered
314
339
 
315
340
 
316
- def _has_nonempty_calls_argument(call: ast.Call) -> bool:
341
+ def _calls_evidence(call: ast.Call) -> _Evidence:
317
342
  if call.args:
318
- return _nonempty_sequence(call.args[0])
319
- return any(item.arg == "calls" and _nonempty_sequence(item.value) for item in call.keywords)
320
-
321
-
322
- def _positive_states(condition: ast.expr, states: frozenset[str]) -> set[_Reference]:
323
- if isinstance(condition, ast.UnaryOp) and isinstance(condition.op, ast.Not):
324
- operand = condition.operand
325
- if isinstance(operand, ast.Compare) and len(operand.ops) == 1:
326
- inverse = _negated_operator(operand.ops[0])
327
- if inverse is not None:
328
- return _comparison_states(operand.left, inverse, operand.comparators[0], states) | _comparison_states(
329
- operand.comparators[0], _reversed_operator(inverse), operand.left, states
330
- )
331
- return set()
332
- if isinstance(condition, ast.BoolOp):
333
- branches = [_positive_states(value, states) for value in condition.values]
334
- return (
335
- branches[0].union(*branches[1:])
336
- if isinstance(condition.op, ast.And)
337
- else branches[0].intersection(*branches[1:])
338
- )
339
- if isinstance(condition, ast.Attribute) and condition.attr in states:
340
- return {_reference(condition.value)}
341
- if isinstance(condition, ast.Compare):
342
- result: set[_Reference] = set()
343
- for left, operator, right in zip(
344
- (condition.left, *condition.comparators[:-1]), condition.ops, condition.comparators, strict=True
345
- ):
346
- result.update(_comparison_states(left, operator, right, states))
347
- result.update(_comparison_states(right, _reversed_operator(operator), left, states))
348
- return result
349
- return set()
343
+ return _sequence_evidence(call.args[0])
344
+ for keyword in call.keywords:
345
+ if keyword.arg == "calls":
346
+ return _sequence_evidence(keyword.value)
347
+ return _Evidence.UNRESOLVED
348
+
349
+
350
+ def _sequence_evidence(node: ast.expr) -> _Evidence:
351
+ if isinstance(node, (ast.List, ast.Tuple)):
352
+ if not node.elts:
353
+ return _Evidence.INSUFFICIENT
354
+ if any(not isinstance(item, ast.Starred) for item in node.elts):
355
+ return _Evidence.POSITIVE
356
+ return _Evidence.UNRESOLVED
357
+
358
+
359
+ def _asserted_states(condition: ast.expr, states: frozenset[str]) -> set[_Reference]:
360
+ match condition:
361
+ case ast.UnaryOp(op=ast.Not(), operand=ast.UnaryOp(op=ast.Not(), operand=operand)):
362
+ return _asserted_states(operand, states)
363
+ case ast.UnaryOp(op=ast.Not(), operand=ast.Compare(left=left, ops=[operator], comparators=[right])):
364
+ inverse = _negated_operator(operator)
365
+ return _comparison_states(left, inverse, right, states) if inverse is not None else set()
366
+ case ast.UnaryOp(op=ast.Not(), operand=ast.Attribute(attr=attribute)) if attribute in states:
367
+ return set()
368
+ case ast.BoolOp(op=operator, values=values):
369
+ branches = [_asserted_states(value, states) for value in values]
370
+ return (
371
+ branches[0].union(*branches[1:])
372
+ if isinstance(operator, ast.And)
373
+ else branches[0].intersection(*branches[1:])
374
+ )
375
+ case ast.Attribute(value=receiver, attr=attribute) if attribute in states:
376
+ return {_reference(receiver)}
377
+ case ast.Compare(left=left, ops=operators, comparators=rights):
378
+ result: set[_Reference] = set()
379
+ for first, operator, second in zip((left, *rights[:-1]), operators, rights, strict=True):
380
+ result.update(_comparison_states(first, operator, second, states))
381
+ return result
382
+ case _:
383
+ # Preserve acceptance of unresolved await-state assertions, such as
384
+ # len(mock.await_args_list) == expected, without value propagation.
385
+ return _unresolved_await_states(condition, states)
350
386
 
351
387
 
352
388
  def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
@@ -364,6 +400,18 @@ def _negated_operator(operator: ast.cmpop) -> ast.cmpop | None:
364
400
  return factory() if factory is not None else None
365
401
 
366
402
 
403
+ def _comparison_states(left: ast.expr, operator: ast.cmpop, right: ast.expr, states: frozenset[str]) -> set[_Reference]:
404
+ result: set[_Reference] = set()
405
+ for node, comparison, expected in ((left, operator, right), (right, _reversed_operator(operator), left)):
406
+ if isinstance(node, ast.Attribute) and node.attr in states:
407
+ evidence = _state_evidence(node.attr, comparison, expected)
408
+ if evidence is _Evidence.POSITIVE or (states == _AWAIT_STATE and evidence is _Evidence.UNRESOLVED):
409
+ result.add(_reference(node.value))
410
+ else:
411
+ result.update(_unresolved_await_states(node, states))
412
+ return result
413
+
414
+
367
415
  def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
368
416
  match operator:
369
417
  case ast.Lt():
@@ -378,32 +426,38 @@ def _reversed_operator(operator: ast.cmpop) -> ast.cmpop:
378
426
  return operator
379
427
 
380
428
 
381
- def _comparison_states(left: ast.expr, operator: ast.cmpop, right: ast.expr, states: frozenset[str]) -> set[_Reference]:
382
- if not isinstance(left, ast.Attribute):
429
+ def _unresolved_await_states(node: ast.expr, states: frozenset[str]) -> set[_Reference]:
430
+ if states != _AWAIT_STATE:
383
431
  return set()
432
+ return {
433
+ _reference(child.value) for child in walk_ast(node) if isinstance(child, ast.Attribute) and child.attr in states
434
+ }
435
+
436
+
437
+ def _state_evidence(attribute: str, operator: ast.cmpop, expected: ast.expr) -> _Evidence:
438
+ positive: bool
384
439
  if (
385
- states == _AWAIT_STATE
386
- and isinstance(left.value, ast.Attribute)
387
- and left.value.attr == "await_args"
388
- and left.attr in {"args", "kwargs"}
440
+ attribute in {"call_count", "await_count"}
441
+ and isinstance(expected, ast.Constant)
442
+ and isinstance(expected.value, int)
389
443
  ):
390
- return {_reference(left.value.value)}
391
- if left.attr not in states:
392
- return set()
393
- positive = False
394
- if left.attr in {"call_count", "await_count"} and isinstance(right, ast.Constant) and type(right.value) is int:
395
- positive = _proves_positive_count(operator, right.value)
396
- elif left.attr == "called" and isinstance(right, ast.Constant):
397
- positive = (isinstance(operator, (ast.Eq, ast.Is)) and right.value is True) or (
398
- isinstance(operator, (ast.NotEq, ast.IsNot)) and right.value is False
444
+ positive = _proves_positive_count(operator, expected.value)
445
+ elif attribute == "called" and isinstance(expected, ast.Constant) and isinstance(expected.value, bool):
446
+ positive = (isinstance(operator, (ast.Eq, ast.Is)) and expected.value) or (
447
+ isinstance(operator, (ast.NotEq, ast.IsNot)) and not expected.value
399
448
  )
400
- elif left.attr == "await_args" and isinstance(right, ast.Constant) and right.value is None:
449
+ elif attribute == "await_args" and isinstance(expected, ast.Constant) and expected.value is None:
401
450
  positive = isinstance(operator, (ast.NotEq, ast.IsNot))
402
- elif left.attr == "await_args_list":
403
- positive = (isinstance(operator, ast.Eq) and _nonempty_sequence(right)) or (
404
- isinstance(operator, ast.NotEq) and isinstance(right, ast.List) and not right.elts
451
+ elif attribute == "await_args_list" and isinstance(operator, (ast.Eq, ast.NotEq)):
452
+ sequence = _sequence_evidence(expected)
453
+ if sequence is _Evidence.UNRESOLVED:
454
+ return sequence
455
+ positive = (isinstance(operator, ast.Eq) and sequence is _Evidence.POSITIVE) or (
456
+ isinstance(operator, ast.NotEq) and isinstance(expected, ast.List) and sequence is _Evidence.INSUFFICIENT
405
457
  )
406
- return {_reference(left.value)} if positive else set()
458
+ else:
459
+ return _Evidence.UNRESOLVED
460
+ return _Evidence.POSITIVE if positive else _Evidence.INSUFFICIENT
407
461
 
408
462
 
409
463
  def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
@@ -418,10 +472,6 @@ def _proves_positive_count(operator: ast.cmpop, value: int) -> bool:
418
472
  return False
419
473
 
420
474
 
421
- def _nonempty_sequence(node: ast.expr) -> bool:
422
- return isinstance(node, (ast.List, ast.Tuple)) and any(not isinstance(item, ast.Starred) for item in node.elts)
423
-
424
-
425
475
  def _invalidates_reference(node: ast.AST, reference: _Reference, binding: ast.Name) -> bool:
426
476
  match node:
427
477
  case ast.Name(id=name, ctx=ast.Store() | ast.Del()):
@@ -243,26 +243,12 @@ def _expression_args(node: exp.Expr, key: str) -> tuple[exp.Expr, ...]:
243
243
  return tuple(child for child in node.iter_expressions() if child.arg_key == key)
244
244
 
245
245
 
246
- def _root_selects(query: exp.Query) -> tuple[exp.Select, ...]:
247
- from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
248
-
249
- if isinstance(query, exp.Select):
250
- return (query,)
251
- if not isinstance(query, exp.SetOperation):
252
- return ()
253
- branches: list[exp.Select] = []
254
- for key in ("this", "expression"):
255
- if isinstance((branch := _expression_arg(query, key)), exp.Query):
256
- branches.extend(_root_selects(branch))
257
- return tuple(branches)
258
-
259
-
260
246
  def _derived_query(node: exp.Expr | None) -> exp.Query | None:
261
247
  from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
262
248
 
263
- if isinstance(node, exp.Subquery) and isinstance((query := _expression_arg(node, "this")), exp.Query):
264
- return query
265
- return None
249
+ while isinstance(node, (exp.Lateral, exp.Paren, exp.Subquery)):
250
+ node = _expression_arg(node, "this")
251
+ return node if isinstance(node, exp.Query) else None
266
252
 
267
253
 
268
254
  @final
@@ -278,18 +264,21 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
278
264
  "cost, a bad data model, or the right datastore."
279
265
  ),
280
266
  remediation=(
281
- "Review whether the schema exposes the operational fact directly, together with production-like "
282
- "cardinality and EXPLAIN output. Simplify the query or read model when warranted, but do not mechanically "
283
- "replace database joins with application joins. Keep atomic coordination in one statement; consider a "
284
- "columnar store only for measured repeated analytical reads with an explicit freshness contract."
267
+ "Prefer ClickHouse for reporting, historical analysis, and broad aggregations, with measured performance "
268
+ "and an explicit freshness contract. For transactional work, simplify the query or document why "
269
+ "PostgreSQL is required, with query bounds and production-like EXPLAIN evidence. Review whether the "
270
+ "schema exposes the operational fact directly; preserve atomic coordination and do not mechanically "
271
+ "replace database joins with application joins. Do not add reporting-specific indexes without "
272
+ "evaluating ClickHouse."
285
273
  ),
286
274
  category=RuleCategory.ARCHITECTURE,
287
275
  autofix=AutofixPolicy.NONE,
288
276
  limitations=(
289
- "Only statically recoverable SQL passed to execute, executemany, fetch, fetchrow, fetchval, or prepare in non-test modules with explicit PostgreSQL imports is analyzed; extended derived, staged, and wide-shape review requires a recognized store module.",
277
+ "Only statically recoverable SQL passed to execute, executemany, fetch, fetchrow, fetchval, or prepare in non-test modules with explicit PostgreSQL imports is analyzed, regardless of module filename.",
290
278
  "Execution receivers must use a conventional database name; custom wrappers and dynamically obtained receivers abstain.",
291
279
  "One direct, unambiguous simple-name binding in the same lexical scope is followed; standalone constants, branches, aliases, attributes, containers, wildcard imports, and cross-scope flow abstain.",
292
- "A query is reviewed for a complex derived relation, at least four joins in one query block, at least six nested query stages, three joins plus four stages, or 25 projections plus two joins.",
280
+ "Review covers derived relations in every SELECT (including LATERAL, CTEs, predicates and writes), four explicit or comma joins, six query stages, three joins plus four stages, or 25 projections plus two joins.",
281
+ "Derived-relation review requires grouping, HAVING or windowing combined with joins or nested reads; expression density is not a separate trigger.",
293
282
  "Projection width alone is accepted, and UNION branches are measured independently; execution plans and production cardinality remain authoritative.",
294
283
  "ClickHouse and BigQuery syntax is excluded per query, including in mixed-backend modules.",
295
284
  "The rule does not infer optimizer behavior, materialization, performance, data-model quality, or datastore placement.",
@@ -405,9 +394,11 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
405
394
  code=self.code,
406
395
  severity=Severity.WARNING,
407
396
  message=(
408
- f"{signal} requires architecture review;{guidance} "
409
- "Review warning only, not a defect or cost claim; syntax does not prove a bad data model or "
410
- "an offload decision."
397
+ f"Complex PostgreSQL query: {signal}. Architecture review required;{guidance} "
398
+ "Prefer ClickHouse for reporting, historical analysis, and broad aggregations. "
399
+ "For transactional work, simplify the query or document why PostgreSQL is required, "
400
+ "with query bounds and EXPLAIN evidence. Do not add reporting-specific indexes without "
401
+ "evaluating ClickHouse. Syntax alone does not establish runtime cost or datastore placement."
411
402
  ),
412
403
  )
413
404
  )
@@ -494,45 +485,40 @@ def _query_architecture_signal(text_value: str, path: Path) -> str | None:
494
485
  or (is_store_module(path) and analytical_signal(sql_without_noise) is not None)
495
486
  ):
496
487
  return None
497
- return _parse_signal(text_value, include_extended_shapes=is_store_module(path))
488
+ return _parse_signal(text_value)
498
489
 
499
490
 
500
- def _parse_signal(sql: str, *, include_extended_shapes: bool) -> str | None:
491
+ def _parse_signal(sql: str) -> str | None:
501
492
  import sqlglot # ruff: ignore[import-outside-top-level] -- parse only SQL that passes cheap ownership and shape gates
502
493
  from sqlglot.errors import SqlglotError # ruff: ignore[import-outside-top-level] -- paired with lazy parser import
503
494
 
504
495
  normalized = _COMPOSABLE_HOLE.sub(_SQL_HOLE, sql)
505
496
  try:
506
- statements = sqlglot.parse(normalized, read="postgres")
497
+ for statement in sqlglot.parse(normalized, read="postgres"):
498
+ if statement is not None and (signal := _architecture_signal(statement)) is not None:
499
+ return signal
507
500
  except SqlglotError:
508
501
  return None
509
- for statement in statements:
510
- if (
511
- statement is not None
512
- and (signal := _architecture_signal(statement, include_extended_shapes=include_extended_shapes)) is not None
513
- ):
514
- return signal
515
502
  return None
516
503
 
517
504
 
518
- def _architecture_signal(statement: exp.Expr, *, include_extended_shapes: bool) -> str | None:
505
+ def _architecture_signal(statement: exp.Expr) -> str | None:
519
506
  from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
520
507
 
521
- if not isinstance(statement, exp.Query):
508
+ if not isinstance(statement, (exp.Query, exp.Insert, exp.Update, exp.Delete)):
522
509
  return None
523
510
  maximum_joins = _maximum_query_joins(statement)
524
511
  if maximum_joins >= _JOIN_LIMIT:
525
- return f"SELECT block has {maximum_joins} JOINs (4+ explicit JOINs)"
526
- if not include_extended_shapes:
527
- return None
528
- nested_queries = sum(1 for query in statement.walk() if isinstance(query, exp.Query)) - 1
512
+ return f"SELECT block has {maximum_joins} JOINs (4+ explicit JOINs or comma relations)"
513
+ nested_queries = sum(1 for query in statement.walk() if isinstance(query, (exp.Select, exp.SetOperation)))
514
+ nested_queries -= isinstance(statement, (exp.Select, exp.SetOperation))
529
515
  if (derived_signal := _derived_architecture_signal(statement)) is not None:
530
516
  return derived_signal
531
517
  if nested_queries >= _STAGE_LIMIT:
532
518
  return f"Query has {nested_queries} CTE/subquery stages"
533
519
  if maximum_joins >= _COMBINED_JOIN_LIMIT and nested_queries >= _COMBINED_STAGE_LIMIT:
534
520
  return f"Query combines {maximum_joins} JOINs with {nested_queries} CTE/subquery stages"
535
- for select in _root_selects(statement):
521
+ for select in statement.find_all(exp.Select):
536
522
  projections = tuple(_expression_args(select, "expressions"))
537
523
  joins = tuple(join for join in _expression_args(select, "joins") if isinstance(join, exp.Join))
538
524
  if len(projections) >= _WIDE_PROJECTION_LIMIT and len(joins) >= _WIDE_PROJECTION_JOIN_LIMIT:
@@ -540,20 +526,20 @@ def _architecture_signal(statement: exp.Expr, *, include_extended_shapes: bool)
540
526
  return None
541
527
 
542
528
 
543
- def _derived_architecture_signal(statement: exp.Query) -> str | None:
529
+ def _derived_architecture_signal(statement: exp.Expr) -> str | None:
544
530
  from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
545
531
 
546
- for select in _root_selects(statement):
532
+ for select in statement.find_all(exp.Select):
547
533
  for join in _expression_args(select, "joins"):
548
534
  if not isinstance(join, exp.Join):
549
535
  continue
550
536
  if (derived := _derived_query(_expression_arg(join, "this"))) is not None and _has_complex_body(derived):
551
- return "Complex JOIN-derived query"
537
+ return "JOIN-derived query combines grouping, HAVING or windowing with joins or nested reads"
552
538
  from_clause = _expression_arg(select, "from_")
553
539
  if not isinstance(from_clause, exp.From):
554
540
  continue
555
541
  if (derived := _derived_query(_expression_arg(from_clause, "this"))) is not None and _has_complex_body(derived):
556
- return "Complex FROM-derived query"
542
+ return "FROM-derived query combines grouping, HAVING or windowing with joins or nested reads"
557
543
  return None
558
544
 
559
545
 
@@ -561,28 +547,43 @@ def _has_complex_body(query: exp.Query) -> bool:
561
547
  from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
562
548
 
563
549
  if isinstance(query, exp.SetOperation):
564
- return True
550
+ return any(
551
+ isinstance(branch := _expression_arg(query, key), exp.Query)
552
+ and (_has_row_transform(branch) or _has_complex_body(branch))
553
+ for key in ("this", "expression")
554
+ )
565
555
  if not isinstance(query, exp.Select):
566
556
  return False
567
- if _expression_arg(query, "group") is not None or _expression_arg(query, "having") is not None:
568
- return True
569
- if any(window.parent_select is query for window in query.find_all(exp.Window)):
570
- return True
571
- from_clause = _expression_arg(query, "from_")
572
- if isinstance(from_clause, exp.From) and _derived_query(_expression_arg(from_clause, "this")) is not None:
557
+ if _has_row_transform(query) and (
558
+ _expression_args(query, "joins") or any(select is not query for select in query.find_all(exp.Select))
559
+ ):
573
560
  return True
574
- for join in _expression_args(query, "joins"):
575
- if isinstance(join, exp.Join) and _derived_query(_expression_arg(join, "this")) is not None:
561
+ for relation in query.find_all(exp.From, exp.Join):
562
+ if relation.parent is not query:
563
+ continue
564
+ child = _derived_query(_expression_arg(relation, "this"))
565
+ if child is not None and (_has_row_transform(child) or _has_complex_body(child)):
576
566
  return True
577
567
  return False
578
568
 
579
569
 
580
- def _maximum_query_joins(statement: exp.Query) -> int:
570
+ def _has_row_transform(query: exp.Query) -> bool:
571
+ from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
572
+
573
+ select = _derived_query(query)
574
+ return isinstance(select, exp.Select) and (
575
+ _expression_arg(select, "group") is not None
576
+ or _expression_arg(select, "having") is not None
577
+ or any(window.parent_select is select for window in select.find_all(exp.Window))
578
+ )
579
+
580
+
581
+ def _maximum_query_joins(statement: exp.Expr) -> int:
581
582
  from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
582
583
 
583
584
  return max(
584
585
  (
585
- sum(isinstance(join, exp.Join) and "pivots" in join.args for join in _expression_args(select, "joins"))
586
+ sum(isinstance(join, exp.Join) for join in _expression_args(select, "joins"))
586
587
  for select in statement.find_all(exp.Select)
587
588
  ),
588
589
  default=0,
@@ -159,8 +159,10 @@ class NoAnalyticalAggregationInPostgresStore(Rule):
159
159
  "consistent operational aggregates remain valid PostgreSQL work."
160
160
  ),
161
161
  remediation=(
162
- "Review the query bounds and execution plan. Move reporting scans to the repository's "
163
- "columnar store, or document why the aggregate must remain transactional."
162
+ "Review the query bounds and execution plan. Prefer ClickHouse for reporting, historical analysis, "
163
+ "and broad aggregations, with measured performance and an explicit freshness contract. "
164
+ "For PostgreSQL, document why the aggregate must remain transactional, with EXPLAIN evidence. "
165
+ "Do not add reporting-specific indexes without evaluating ClickHouse."
164
166
  ),
165
167
  category=RuleCategory.ARCHITECTURE,
166
168
  autofix=AutofixPolicy.NONE,
@@ -247,7 +249,9 @@ class NoAnalyticalAggregationInPostgresStore(Rule):
247
249
  severity=Severity.WARNING,
248
250
  message=(
249
251
  f"Possible analytical PostgreSQL query ({signal}); review its bounds and execution plan. "
250
- "Move reporting scans to the columnar store, or document why this aggregate must remain transactional."
252
+ "Prefer ClickHouse for reporting, historical analysis, and broad aggregations. "
253
+ "For PostgreSQL, document why this aggregate must remain transactional, with EXPLAIN evidence. "
254
+ "Do not add reporting-specific indexes without evaluating ClickHouse."
251
255
  ),
252
256
  )
253
257
  )