sarj-python-lint 0.81.18__tar.gz → 0.82.1__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.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +98 -15
  6. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/.gitignore +0 -0
  7. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/LICENSE +0 -0
  8. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/README.md +0 -0
  9. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/_analysis_session.py +0 -0
  12. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  27. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  28. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_project_index.py +0 -0
  31. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  34. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  35. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  36. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  37. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  38. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  41. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  42. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  43. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  47. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  48. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  49. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  50. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  51. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  52. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  53. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  54. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  55. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  56. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  57. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  58. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  59. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  60. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  61. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  62. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  63. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  64. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  65. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  66. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  67. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  68. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  69. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  70. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  71. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  72. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  73. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/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.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  76. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  77. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  79. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  80. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/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.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  83. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  84. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  85. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  86. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  88. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  89. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  90. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  91. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  92. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  93. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  94. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  96. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  97. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  98. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  99. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  100. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  101. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  102. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  103. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  105. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  106. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  107. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  108. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  109. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  111. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  112. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  113. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  115. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/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.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  118. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/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.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  121. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  122. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1}/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.1}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  125. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  126. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  127. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  128. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  129. {sarj_python_lint-0.81.18 → sarj_python_lint-0.82.1}/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.1
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.1"
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(
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import ast
4
4
  from pathlib import PurePosixPath
5
+ import re
5
6
  from typing import TYPE_CHECKING, ClassVar, final, override
6
7
 
7
8
  from sarj_python_lint.rule_base import (
@@ -24,6 +25,7 @@ if TYPE_CHECKING:
24
25
 
25
26
 
26
27
  _SYS_SOURCES = frozenset({"sys"})
28
+ _PRIVATE_ALIAS_CANDIDATE_RE = re.compile(r"(?m)^(?![\d_])\w+(?:\s*:[^=\n]+)?(?:\s*=\s*(?!_)\w+)*\s*=\s*_(?!_)\w")
27
29
 
28
30
 
29
31
  @final
@@ -31,25 +33,67 @@ class NoRedundantModuleAliasExports(Rule):
31
33
  id: str = "no-redundant-module-alias-exports"
32
34
  code: str = "SARJ440"
33
35
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
34
- summary="Do not replace the current module through `sys.modules`.",
36
+ summary="Do not manufacture public APIs from private names or replace the current module.",
35
37
  rationale=(
36
- "Replacing the current module mutates import identity at runtime in a way static tools cannot model. "
37
- "It makes introspection, reloads, circular imports, state, and monkeypatch behavior surprising."
38
+ "Binding a public name to a private implementation creates two names for one API and disguises the "
39
+ "intended visibility boundary. Replacing the current module similarly mutates import identity at runtime "
40
+ "in a way static tools cannot model. Both forms make navigation, introspection, and compatibility ownership "
41
+ "surprising."
38
42
  ),
39
43
  remediation=(
40
- "Import the canonical path inside maintained code. If a compatibility module is still required, expose "
41
- "its supported names with explicit same-name imports grouped by source module."
44
+ "Define maintained APIs under their public names and update local callers directly. Import the canonical "
45
+ "path inside maintained code. If a compatibility module is still required, expose its supported names with "
46
+ "explicit same-name imports grouped by source module."
42
47
  ),
43
48
  category=RuleCategory.MAINTAINABILITY,
44
49
  autofix=AutofixPolicy.NONE,
45
50
  limitations=(
46
- "Only module-scope assignments through an unconditional, unshadowed stdlib `sys` import are checked.",
47
- "Assignments inside functions, classes, and `TYPE_CHECKING` branches are excluded.",
51
+ (
52
+ "Public-to-private name aliases are checked only as direct module-body assignments; assignments "
53
+ "through an unconditional, unshadowed stdlib `sys` import include module-level control flow."
54
+ ),
55
+ "Aliases inside module control flow, functions, classes, and `TYPE_CHECKING` branches are excluded.",
56
+ (
57
+ "Private targets, dunder sources, attribute values, destructuring, imports, and same-visibility aliases "
58
+ "are excluded."
59
+ ),
48
60
  "Literal-key registrations, child-module registrations, method calls, reads, and arbitrary registries are excluded.",
49
61
  "Generated source, malformed source, and stub files (`.pyi`) are outside this rule's scope.",
50
62
  "No autofix is offered because the intended compatibility surface and identity requirements need review.",
51
63
  ),
52
64
  examples=(
65
+ RuleExample(
66
+ example_id="public-alias-for-private-helper",
67
+ title="Define a public helper under its public name",
68
+ outcome=ExampleOutcome.MATCH,
69
+ files=(
70
+ ExampleFile.python(
71
+ "pagination.py",
72
+ "def _encode_cursor(value: str) -> str:\n"
73
+ " return value\n\n\n"
74
+ "encode_phone_number_cursor = _encode_cursor\n",
75
+ ),
76
+ ),
77
+ focus_path=PurePosixPath("pagination.py"),
78
+ expected_count=1,
79
+ public=True,
80
+ scenario="public-private-alias",
81
+ ),
82
+ RuleExample(
83
+ example_id="public-helper-definition",
84
+ title="Give the implementation its intended public name",
85
+ outcome=ExampleOutcome.NO_MATCH,
86
+ files=(
87
+ ExampleFile.python(
88
+ "pagination.py",
89
+ "def encode_phone_number_cursor(value: str) -> str:\n return value\n",
90
+ ),
91
+ ),
92
+ focus_path=PurePosixPath("pagination.py"),
93
+ expected_count=0,
94
+ public=True,
95
+ scenario="public-private-alias",
96
+ ),
53
97
  RuleExample(
54
98
  example_id="current-module-replacement",
55
99
  title="Do not replace a compatibility module at runtime",
@@ -65,6 +109,7 @@ class NoRedundantModuleAliasExports(Rule):
65
109
  focus_path=PurePosixPath("legacy/settings.py"),
66
110
  expected_count=1,
67
111
  public=True,
112
+ scenario="module-replacement",
68
113
  ),
69
114
  RuleExample(
70
115
  example_id="explicit-compatibility-exports",
@@ -79,6 +124,7 @@ class NoRedundantModuleAliasExports(Rule):
79
124
  focus_path=PurePosixPath("legacy/settings.py"),
80
125
  expected_count=0,
81
126
  public=True,
127
+ scenario="module-replacement",
82
128
  ),
83
129
  ),
84
130
  )
@@ -86,14 +132,17 @@ class NoRedundantModuleAliasExports(Rule):
86
132
 
87
133
  @override
88
134
  def check(self, path: Path, source: str) -> list[Diagnostic]:
89
- if path.suffix != ".py" or "modules" not in source or "__name__" not in source or is_generated(path, source):
135
+ has_module_replacement_candidate = "modules" in source and "__name__" in source
136
+ if (
137
+ path.suffix != ".py"
138
+ or (not has_module_replacement_candidate and _PRIVATE_ALIAS_CANDIDATE_RE.search(source) is None)
139
+ or is_generated(path, source)
140
+ ):
90
141
  return []
91
142
  tree = parse_or_none(path, source)
92
143
  if tree is None:
93
144
  return []
94
145
  imports = ImportIndex.from_tree(tree, module_scope_only=True)
95
- if not imports.builtin_is_unshadowed("__name__"):
96
- return []
97
146
  direct_sys_bindings = _direct_sys_bindings(tree)
98
147
  findings = [
99
148
  Diagnostic(
@@ -102,17 +151,51 @@ class NoRedundantModuleAliasExports(Rule):
102
151
  col=target.col_offset + 1,
103
152
  code=self.code,
104
153
  message=(
105
- "Do not replace the current module through `sys.modules[__name__]`; expose compatibility names "
106
- "with explicit imports and update maintained callers to the canonical module."
154
+ f"Do not expose private `{source_name}` as public `{target.id}`; define the implementation under "
155
+ "its public name and update local callers directly."
107
156
  ),
108
157
  )
109
- for statement in _module_statements(tree.body)
110
- for target in _assignment_targets(statement)
111
- if _is_current_module_target(target, imports, direct_sys_bindings)
158
+ for statement in tree.body
159
+ for target, source_name in _public_private_aliases(statement)
112
160
  ]
161
+ if imports.builtin_is_unshadowed("__name__"):
162
+ findings.extend(
163
+ Diagnostic(
164
+ path=path,
165
+ line=target.lineno,
166
+ col=target.col_offset + 1,
167
+ code=self.code,
168
+ message=(
169
+ "Do not replace the current module through `sys.modules[__name__]`; expose compatibility names "
170
+ "with explicit imports and update maintained callers to the canonical module."
171
+ ),
172
+ )
173
+ for statement in _module_statements(tree.body)
174
+ for target in _assignment_targets(statement)
175
+ if _is_current_module_target(target, imports, direct_sys_bindings)
176
+ )
113
177
  return sorted(findings, key=lambda finding: (finding.line, finding.col, finding.code))
114
178
 
115
179
 
180
+ def _public_private_aliases(statement: ast.stmt) -> list[tuple[ast.Name, str]]:
181
+ match statement:
182
+ case ast.Assign(targets=targets, value=ast.Name(id=source_name)):
183
+ pass
184
+ case ast.AnnAssign(target=target, value=ast.Name(id=source_name)):
185
+ targets = [target]
186
+ case _:
187
+ return []
188
+ if not _is_private_name(source_name):
189
+ return []
190
+ return [
191
+ (target, source_name) for target in targets if isinstance(target, ast.Name) and not target.id.startswith("_")
192
+ ]
193
+
194
+
195
+ def _is_private_name(name: str) -> bool:
196
+ return len(name) > 1 and name.startswith("_") and not name.startswith("__")
197
+
198
+
116
199
  def _direct_sys_bindings(tree: ast.Module) -> frozenset[str]:
117
200
  bindings: set[str] = set()
118
201
  for statement in tree.body: