sarj-python-lint 0.81.18__tar.gz → 0.82.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +101 -30
  4. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +61 -3
  5. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/.gitignore +0 -0
  6. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/LICENSE +0 -0
  7. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/README.md +0 -0
  8. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/_python_target.py +0 -0
  13. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  26. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  29. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  30. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  31. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  32. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  33. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  37. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  40. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  41. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  46. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  47. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  48. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  49. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  50. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  51. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  52. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  53. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  54. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  55. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  56. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  57. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  58. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  59. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  60. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  61. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  62. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  63. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  64. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  65. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  66. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  67. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  68. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  69. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  70. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  71. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  72. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  73. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  74. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  75. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  76. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  77. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  78. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  79. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  80. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  81. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  82. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  83. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  84. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  85. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  86. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  87. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  88. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  89. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  90. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  91. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  92. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  94. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  95. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  96. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  97. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  98. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  99. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  100. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  101. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  102. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  103. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  104. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  105. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  106. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  107. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  108. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  109. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  110. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  111. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  112. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  113. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  114. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  115. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  116. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  117. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  118. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  119. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  120. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  121. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  122. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  123. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  124. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  125. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  126. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  127. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  128. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  129. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.81.18
3
+ Version: 0.82.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.81.18"
3
+ version = "0.82.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -18,8 +18,9 @@ from sarj_python_lint.rule_base import (
18
18
  parse_or_none,
19
19
  )
20
20
  from sarj_python_lint.rules._ast_index import nodes, walk
21
- from sarj_python_lint.rules._paths import is_generated
21
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
22
22
  from sarj_python_lint.rules._sql import is_store_module, sql_string_value, strip_sql_noise
23
+ from sarj_python_lint.rules.no_analytical_aggregation_in_postgres_store import analytical_signal
23
24
 
24
25
 
25
26
  if TYPE_CHECKING:
@@ -29,7 +30,6 @@ if TYPE_CHECKING:
29
30
 
30
31
 
31
32
  _QUERY_SHAPE = re.compile(r"\bSELECT\b[\s\S]*?\bFROM\b", re.IGNORECASE)
32
- _DERIVED_RELATION_GATE = re.compile(r"\b(?:FROM|JOIN)\s*\(", re.IGNORECASE)
33
33
  _POSTGRES_IMPORT_PREFIXES = (
34
34
  "asyncpg",
35
35
  "psycopg",
@@ -37,13 +37,7 @@ _POSTGRES_IMPORT_PREFIXES = (
37
37
  "psycopg_pool",
38
38
  "sqlalchemy.dialects.postgresql",
39
39
  )
40
- _COMPETING_IMPORT_PREFIXES = (
41
- "aiosqlite",
42
- "clickhouse_connect",
43
- "clickhouse_driver",
44
- "google.cloud.bigquery",
45
- "sqlite3",
46
- )
40
+ _AMBIGUOUS_RELATIONAL_IMPORT_PREFIXES = ("aiosqlite", "sqlite3")
47
41
  _QUERY_SINKS = frozenset({"execute", "executemany", "fetch", "fetchrow", "fetchval", "prepare"})
48
42
  _QUERY_KEYWORDS = frozenset({"command", "operation", "query", "statement"})
49
43
  _QUERY_RECEIVER_TOKENS = frozenset(
@@ -61,6 +55,12 @@ _BIGQUERY_SQL = re.compile(
61
55
  )
62
56
  _COMPOSABLE_HOLE = re.compile(r"(?<!\{)\{(?:[A-Za-z_]\w*|\d*)\}(?!\})")
63
57
  _SQL_HOLE = "__sarj_sql_hole__"
58
+ _JOIN_LIMIT = 4
59
+ _STAGE_LIMIT = 6
60
+ _COMBINED_JOIN_LIMIT = 3
61
+ _COMBINED_STAGE_LIMIT = 4
62
+ _WIDE_PROJECTION_LIMIT = 25
63
+ _WIDE_PROJECTION_JOIN_LIMIT = 2
64
64
 
65
65
 
66
66
  class _ConstructorImport(NamedTuple):
@@ -308,7 +308,7 @@ def _expression_args(node: exp.Expr, key: str) -> tuple[exp.Expr, ...]:
308
308
  return tuple(child for child in node.iter_expressions() if child.arg_key == key)
309
309
 
310
310
 
311
- def _parse_signal(sql: str) -> str | None:
311
+ def _parse_signal(sql: str, *, include_extended_shapes: bool) -> str | None:
312
312
  import sqlglot # ruff: ignore[import-outside-top-level] -- parse only SQL that passes cheap ownership and shape gates
313
313
  from sqlglot.errors import SqlglotError # ruff: ignore[import-outside-top-level] -- paired with lazy parser import
314
314
 
@@ -318,16 +318,31 @@ def _parse_signal(sql: str) -> str | None:
318
318
  except SqlglotError:
319
319
  return None
320
320
  for statement in statements:
321
- if statement is not None and (signal := _architecture_signal(statement)) is not None:
321
+ if (
322
+ statement is not None
323
+ and (signal := _architecture_signal(statement, include_extended_shapes=include_extended_shapes)) is not None
324
+ ):
322
325
  return signal
323
326
  return None
324
327
 
325
328
 
326
- def _architecture_signal(statement: exp.Expr) -> str | None:
329
+ def _architecture_signal(statement: exp.Expr, *, include_extended_shapes: bool) -> str | None:
327
330
  from sqlglot import exp # ruff: ignore[import-outside-top-level] -- keep parser startup off unrelated lint runs
328
331
 
329
332
  if not isinstance(statement, exp.Query):
330
333
  return None
334
+ maximum_joins = max(
335
+ (
336
+ sum(isinstance(join, exp.Join) and "pivots" in join.args for join in _expression_args(select, "joins"))
337
+ for select in statement.find_all(exp.Select)
338
+ ),
339
+ default=0,
340
+ )
341
+ if maximum_joins >= _JOIN_LIMIT:
342
+ return f"SELECT block has {maximum_joins} JOINs (4+ explicit JOINs)"
343
+ if not include_extended_shapes:
344
+ return None
345
+ nested_queries = sum(1 for query in statement.walk() if isinstance(query, exp.Query)) - 1
331
346
  for select in _root_selects(statement):
332
347
  for join in _expression_args(select, "joins"):
333
348
  if not isinstance(join, exp.Join):
@@ -339,6 +354,15 @@ def _architecture_signal(statement: exp.Expr) -> str | None:
339
354
  continue
340
355
  if (derived := _derived_query(_expression_arg(from_clause, "this"))) is not None and _has_complex_body(derived):
341
356
  return "Complex FROM-derived query"
357
+ if nested_queries >= _STAGE_LIMIT:
358
+ return f"Query has {nested_queries} CTE/subquery stages"
359
+ if maximum_joins >= _COMBINED_JOIN_LIMIT and nested_queries >= _COMBINED_STAGE_LIMIT:
360
+ return f"Query combines {maximum_joins} JOINs with {nested_queries} CTE/subquery stages"
361
+ for select in _root_selects(statement):
362
+ projections = tuple(_expression_args(select, "expressions"))
363
+ joins = tuple(join for join in _expression_args(select, "joins") if isinstance(join, exp.Join))
364
+ if len(projections) >= _WIDE_PROJECTION_LIMIT and len(joins) >= _WIDE_PROJECTION_JOIN_LIMIT:
365
+ return f"Joined query block has {len(projections)} projected expressions"
342
366
  return None
343
367
 
344
368
 
@@ -389,29 +413,64 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
389
413
  id: str = "complex-postgres-query-requires-architecture-review"
390
414
  code: str = "SARJ437"
391
415
  documentation = RuleDocumentation(
392
- summary="Complex anonymous PostgreSQL relation stages merit readability review.",
416
+ summary="Complex executable PostgreSQL query shapes require architecture review.",
393
417
  rationale=(
394
- "Anonymous derived relations with nested or transformational bodies can obscure query stages and make "
395
- "bounds, ordering, and locking semantics harder to review. Syntax alone does not establish runtime cost "
396
- "or justify materialization or offload."
418
+ "Join-heavy, deeply staged, or wide joined reads can obscure cardinality, bounds, ordering, and locking "
419
+ "semantics and can signal repeated read-time reconstruction. Syntax alone does not establish runtime "
420
+ "cost, a bad data model, or the right datastore."
397
421
  ),
398
422
  remediation=(
399
- "First verify bounds, ordering, and lock semantics. Use a semantics-preserving CTE when naming improves "
400
- "reviewability; it is not a performance optimization. Consider a view only for a reusable stable "
401
- "interface, a materialized view only for measured repeated reads with an explicit freshness contract, "
402
- "and a columnar store only for measured analytical workloads with an explicit synchronization contract."
423
+ "Review whether the schema exposes the operational fact directly, together with production-like "
424
+ "cardinality and EXPLAIN output. Simplify the query or read model when warranted, but do not mechanically "
425
+ "replace database joins with application joins. Keep atomic coordination in one statement; consider a "
426
+ "columnar store only for measured repeated analytical reads with an explicit freshness contract."
403
427
  ),
404
428
  category=RuleCategory.ARCHITECTURE,
405
429
  autofix=AutofixPolicy.NONE,
406
430
  limitations=(
407
- "Only statically recoverable SQL passed to execute, executemany, fetch, fetchrow, fetchval, or prepare in recognized PostgreSQL store modules is analyzed.",
431
+ "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.",
408
432
  "Execution receivers must use a conventional database name; custom wrappers and dynamically obtained receivers abstain.",
409
433
  "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.",
410
- "A derived relation is reported only when it contains nesting, a window, grouping, HAVING, or a set operation.",
411
- "Scalar, EXISTS, IN, and LATERAL subqueries are excluded; execution plans and production cardinality remain authoritative.",
412
- "A CTE is suggested only as a naming refactor; the rule does not infer optimizer behavior, materialization, performance, or datastore placement.",
434
+ "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.",
435
+ "Projection width alone is accepted, and UNION branches are measured independently; execution plans and production cardinality remain authoritative.",
436
+ "ClickHouse and BigQuery syntax is excluded per query, including in mixed-backend modules.",
437
+ "The rule does not infer optimizer behavior, materialization, performance, data-model quality, or datastore placement.",
413
438
  ),
414
439
  examples=(
440
+ RuleExample(
441
+ example_id="multi-join-root-discovery",
442
+ title="A read repeatedly discovers a derived root through multiple joins",
443
+ outcome=ExampleOutcome.MATCH,
444
+ files=(
445
+ ExampleFile.python(
446
+ "app/call_store.py",
447
+ 'import psycopg\ncursor.execute("SELECT call.id FROM simulated_batch '
448
+ "JOIN batch_call ON batch_call.batch_id = simulated_batch.batch_id "
449
+ "JOIN call ON call.batch_call_id = batch_call.id "
450
+ "JOIN campaign_call ON campaign_call.call_id = call.id "
451
+ 'JOIN campaign ON campaign.id = campaign_call.campaign_id WHERE simulated_batch.batch_id = %s")\n',
452
+ ),
453
+ ),
454
+ focus_path=PurePosixPath("app/call_store.py"),
455
+ expected_count=1,
456
+ public=True,
457
+ scenario="multi-join-review",
458
+ ),
459
+ RuleExample(
460
+ example_id="stored-root-lookup",
461
+ title="A read uses a root resolved and stored by its owning write workflow",
462
+ outcome=ExampleOutcome.NO_MATCH,
463
+ files=(
464
+ ExampleFile.python(
465
+ "app/call_store.py",
466
+ 'import psycopg\ncursor.execute("SELECT root_call_id FROM canary_run WHERE id = %s")\n',
467
+ ),
468
+ ),
469
+ focus_path=PurePosixPath("app/call_store.py"),
470
+ expected_count=0,
471
+ public=True,
472
+ scenario="multi-join-review",
473
+ ),
415
474
  RuleExample(
416
475
  example_id="ranked-queue-claim",
417
476
  title="A queue claim hides ranking inside nested derived relations",
@@ -453,14 +512,14 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
453
512
 
454
513
  @override
455
514
  def check(self, path: Path, source: str) -> list[Diagnostic]:
456
- if not is_store_module(path) or is_generated(path, source):
515
+ if is_test_path(path) or is_generated(path, source):
457
516
  return []
458
517
  tree = parse_or_none(path, source)
459
518
  if tree is None:
460
519
  return []
461
520
  imports = _import_names(tree)
462
521
  if not _has_import_prefix(imports, _POSTGRES_IMPORT_PREFIXES) or _has_import_prefix(
463
- imports, _COMPETING_IMPORT_PREFIXES
522
+ imports, _AMBIGUOUS_RELATIONAL_IMPORT_PREFIXES
464
523
  ):
465
524
  return []
466
525
 
@@ -481,13 +540,24 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
481
540
  sql_without_noise = strip_sql_noise(text_value)
482
541
  if (
483
542
  _QUERY_SHAPE.search(sql_without_noise) is None
484
- or _DERIVED_RELATION_GATE.search(sql_without_noise) is None
485
543
  or _CLICKHOUSE_SQL.search(sql_without_noise)
486
544
  or _BIGQUERY_SQL.search(sql_without_noise)
545
+ or (is_store_module(path) and analytical_signal(sql_without_noise) is not None)
487
546
  ):
488
547
  continue
489
- if (signal := _parse_signal(text_value)) is None:
548
+ if (signal := _parse_signal(text_value, include_extended_shapes=is_store_module(path))) is None:
490
549
  continue
550
+ coordination = re.search(
551
+ r"\b(?:FOR\s+(?:NO\s+KEY\s+)?UPDATE|FOR\s+SHARE|SKIP\s+LOCKED)\b", text_value, re.IGNORECASE
552
+ )
553
+ guidance = (
554
+ " preserve the atomic statement; review bounds, ordering, and locks, then document its cardinality, "
555
+ "supporting indexes, and production-like plan."
556
+ if coordination is not None
557
+ else " review bounds, ordering, and locks; review cardinality and a production-like query plan, then "
558
+ "review whether repeated read-time reconstruction should become a stable derived fact maintained at "
559
+ "write-time, or otherwise simplify the query or read model when warranted."
560
+ )
491
561
  diagnostics.append(
492
562
  Diagnostic(
493
563
  path=path,
@@ -496,8 +566,9 @@ class ComplexPostgresQueryRequiresArchitectureReview(Rule):
496
566
  code=self.code,
497
567
  severity=Severity.WARNING,
498
568
  message=(
499
- f"{signal} hides a relational stage — review bounds, ordering, and locks; name it with a "
500
- "semantics-preserving CTE if clearer. Reviewability warning only; no cost or offload claim."
569
+ f"{signal} requires architecture review;{guidance} "
570
+ "Review warning only, not a defect or cost claim; syntax does not prove a bad data model or "
571
+ "an offload decision."
501
572
  ),
502
573
  )
503
574
  )
@@ -25,6 +25,8 @@ from sarj_python_lint.rules._sql import is_store_module, sql_string_value, strip
25
25
  if TYPE_CHECKING:
26
26
  from pathlib import Path
27
27
 
28
+ from sqlglot import exp
29
+
28
30
 
29
31
  _QUERY_SHAPE = re.compile(r"\bSELECT\b[\s\S]*?\bFROM\b", re.IGNORECASE)
30
32
  _POSTGRES_OWNER = re.compile(
@@ -49,6 +51,11 @@ _COMMON_AGGREGATE = re.compile(
49
51
  r"\b(?:COUNT|SUM|AVG|MIN|MAX|ARRAY_AGG|STRING_AGG|JSONB?_AGG|BOOL_AND|BOOL_OR|EVERY)\s*\(",
50
52
  re.IGNORECASE,
51
53
  )
54
+ _LOCKING_READ = re.compile(r"\b(?:FOR\s+(?:NO\s+KEY\s+)?UPDATE|FOR\s+SHARE|SKIP\s+LOCKED)\b", re.IGNORECASE)
55
+ _PLACEHOLDER = re.compile(r"%(?:\([^)]+\))?s")
56
+ _GROUP_KEYS_LIMIT = 2
57
+ _GROUP_AGGREGATES_LIMIT = 2
58
+ _SINGLE_GROUP_AGGREGATES_LIMIT = 3
52
59
  _STRONG_ANALYTICAL: tuple[tuple[str, re.Pattern[str]], ...] = (
53
60
  ("STDDEV", re.compile(r"\bSTDDEV(?:_POP|_SAMP)?\s*\(", re.IGNORECASE)),
54
61
  ("VARIANCE", re.compile(r"\b(?:VARIANCE|VAR_POP|VAR_SAMP)\s*\(", re.IGNORECASE)),
@@ -71,15 +78,66 @@ def _docstring_node_ids(tree: ast.AST) -> set[int]:
71
78
  return result
72
79
 
73
80
 
74
- def _analytical_signal(sql: str) -> str | None:
81
+ def analytical_signal(sql: str) -> str | None:
82
+ if _LOCKING_READ.search(sql) is not None:
83
+ return None
75
84
  for label, pattern in _STRONG_ANALYTICAL:
76
85
  if pattern.search(sql):
77
86
  return label
78
87
  if _TIME_BUCKET.search(sql) and _GROUP_BY.search(sql) and _COMMON_AGGREGATE.search(sql):
79
88
  return "time-bucketed GROUP BY"
89
+ return _grouped_reporting_signal(sql)
90
+
91
+
92
+ def _grouped_reporting_signal(sql: str) -> str | None:
93
+ import sqlglot # ruff: ignore[import-outside-top-level] -- parse only aggregate-shaped SQL after cheap gates
94
+ from sqlglot import exp # ruff: ignore[import-outside-top-level] -- paired with lazy parser import
95
+ from sqlglot.errors import SqlglotError # ruff: ignore[import-outside-top-level] -- paired with lazy parser import
96
+
97
+ if _GROUP_BY.search(sql) is None or _COMMON_AGGREGATE.search(sql) is None:
98
+ return None
99
+ try:
100
+ statements = sqlglot.parse(_PLACEHOLDER.sub("?", sql), read="postgres")
101
+ except SqlglotError:
102
+ return None
103
+ for statement in statements:
104
+ if not isinstance(statement, exp.Query):
105
+ continue
106
+ for select in statement.find_all(exp.Select):
107
+ if _is_identity_bounded(select):
108
+ continue
109
+ group = select.args.get("group")
110
+ group_count = len(group.expressions) if isinstance(group, exp.Group) else 0
111
+ aggregate_count = sum(1 for aggregate in select.find_all(exp.AggFunc) if aggregate.parent_select is select)
112
+ if (
113
+ group_count >= _GROUP_KEYS_LIMIT and aggregate_count >= _GROUP_AGGREGATES_LIMIT
114
+ ) or aggregate_count >= _SINGLE_GROUP_AGGREGATES_LIMIT:
115
+ return "grouped reporting shape"
80
116
  return None
81
117
 
82
118
 
119
+ def _is_identity_bounded(select: object) -> bool:
120
+ from sqlglot import exp # ruff: ignore[import-outside-top-level] -- shared with lazy parser path
121
+
122
+ if not isinstance(select, exp.Select):
123
+ return False
124
+ where = select.args.get("where")
125
+ if not isinstance(where, exp.Where):
126
+ return False
127
+ equality = _expression_arg(where, "this")
128
+ if not isinstance(equality, exp.EQ):
129
+ return False
130
+ left = _expression_arg(equality, "this")
131
+ right = _expression_arg(equality, "expression")
132
+ return (isinstance(left, exp.Column) and left.name.casefold() == "id" and not isinstance(right, exp.Column)) or (
133
+ isinstance(right, exp.Column) and right.name.casefold() == "id" and not isinstance(left, exp.Column)
134
+ )
135
+
136
+
137
+ def _expression_arg(node: exp.Expr, key: str) -> exp.Expr | None:
138
+ return next((child for child in node.iter_expressions() if child.arg_key == key), None)
139
+
140
+
83
141
  @final
84
142
  class NoAnalyticalAggregationInPostgresStore(Rule):
85
143
  id: str = "no-analytical-aggregation-in-postgres-store"
@@ -100,7 +158,7 @@ class NoAnalyticalAggregationInPostgresStore(Rule):
100
158
  aliases=("no-aggregation-in-store-query",),
101
159
  limitations=(
102
160
  "Only SQL string literals in recognized store modules with positive PostgreSQL ownership evidence are analyzed.",
103
- "Only statistical aggregates and time-bucketed grouped rollups are reported; ordinary operational aggregates are intentionally excluded.",
161
+ "Statistical aggregates, time-bucketed rollups, and broad grouped reporting shapes are reported; locked coordination reads and identity-bounded aggregates are excluded.",
104
162
  "ClickHouse- and BigQuery-specific query syntax is excluded per query, including in mixed-backend modules.",
105
163
  ),
106
164
  examples=(
@@ -166,7 +224,7 @@ class NoAnalyticalAggregationInPostgresStore(Rule):
166
224
  or _BIGQUERY_SQL.search(sql)
167
225
  ):
168
226
  continue
169
- signal = _analytical_signal(sql)
227
+ signal = analytical_signal(sql)
170
228
  if signal is None:
171
229
  continue
172
230
  diagnostics.append(