sarj-python-lint 0.77.0__tar.gz → 0.77.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 (123) hide show
  1. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/PKG-INFO +6 -6
  2. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/README.md +2 -2
  3. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/pyproject.toml +4 -4
  4. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/__main__.py +5 -1
  5. sarj_python_lint-0.77.1/src/sarj_python_lint/_analysis_session.py +11 -0
  6. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rule_base.py +6 -1
  7. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_first_party.py +80 -47
  8. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_paths.py +9 -13
  9. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_project_index.py +11 -5
  10. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_first_party_private_import.py +9 -7
  11. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +86 -23
  12. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/.gitignore +0 -0
  13. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/LICENSE +0 -0
  14. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/__init__.py +0 -0
  15. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/_filesystem.py +0 -0
  16. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  17. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/_secret_names.py +0 -0
  18. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/_version.py +0 -0
  19. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/py.typed +0 -0
  20. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/ratchet.py +0 -0
  21. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  22. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  23. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_comments.py +0 -0
  24. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  25. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  26. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  29. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_pytest.py +0 -0
  30. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  31. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  32. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  33. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  34. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  35. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  36. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  37. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  38. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  39. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  40. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  41. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  42. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/get_delegates_to_get_many.py +0 -0
  43. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  46. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  47. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  48. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  49. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  50. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  51. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  53. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  54. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  55. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  56. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  57. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  58. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  59. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  60. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  61. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  62. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  63. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  64. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  65. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  66. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  67. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  68. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  69. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  70. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  71. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  72. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  73. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  74. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  75. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  76. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  77. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  78. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  79. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  80. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  81. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  82. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  83. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  84. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  85. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  86. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  87. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  88. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  89. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  90. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  91. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -0
  92. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  93. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  94. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  95. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  96. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  97. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  98. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  99. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  100. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  101. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -0
  102. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -0
  103. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  104. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  105. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  106. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  107. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  108. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  109. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  110. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  111. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  112. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  113. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  114. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -0
  115. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  116. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  117. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -0
  118. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  119. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  120. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  121. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  122. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  123. {sarj_python_lint-0.77.0 → sarj_python_lint-0.77.1}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.77.0
3
+ Version: 0.77.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/
7
- Project-URL: Changelog, https://github.com/sarj-ai/standards/releases
8
- Project-URL: Repository, https://github.com/sarj-ai/standards
9
- Project-URL: Issues, https://github.com/sarj-ai/standards/issues
7
+ Project-URL: Changelog, https://github.com/sarj-ai/code-standards/releases
8
+ Project-URL: Repository, https://github.com/sarj-ai/code-standards
9
+ Project-URL: Issues, https://github.com/sarj-ai/code-standards/issues
10
10
  Author: sarj-ai
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
@@ -19,7 +19,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
19
19
  Requires-Python: >=3.14
20
20
  Description-Content-Type: text/markdown
21
21
 
22
- <!-- Generated by `sarj-standards maintain docs sync`; do not edit. -->
22
+ <!-- Generated by `code-standards maintain docs sync`; do not edit. -->
23
23
 
24
24
  # sarj-python-lint
25
25
 
@@ -33,4 +33,4 @@ uv tool install sarj-python-lint
33
33
  sarj-python-lint --help
34
34
  ```
35
35
 
36
- [Documentation](https://code-standards.sarj.ai/rules/python/) · [Source](https://github.com/sarj-ai/standards)
36
+ [Documentation](https://code-standards.sarj.ai/rules/python/) · [Source](https://github.com/sarj-ai/code-standards)
@@ -1,4 +1,4 @@
1
- <!-- Generated by `sarj-standards maintain docs sync`; do not edit. -->
1
+ <!-- Generated by `code-standards maintain docs sync`; do not edit. -->
2
2
 
3
3
  # sarj-python-lint
4
4
 
@@ -12,4 +12,4 @@ uv tool install sarj-python-lint
12
12
  sarj-python-lint --help
13
13
  ```
14
14
 
15
- [Documentation](https://code-standards.sarj.ai/rules/python/) · [Source](https://github.com/sarj-ai/standards)
15
+ [Documentation](https://code-standards.sarj.ai/rules/python/) · [Source](https://github.com/sarj-ai/code-standards)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.77.0"
3
+ version = "0.77.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" }]
@@ -24,9 +24,9 @@ sarj-ratchet = "sarj_python_lint._ratchet_cli:main"
24
24
  [project.urls]
25
25
  Homepage = "https://code-standards.sarj.ai/rules/python/"
26
26
  Documentation = "https://code-standards.sarj.ai/rules/python/"
27
- Changelog = "https://github.com/sarj-ai/standards/releases"
28
- Repository = "https://github.com/sarj-ai/standards"
29
- Issues = "https://github.com/sarj-ai/standards/issues"
27
+ Changelog = "https://github.com/sarj-ai/code-standards/releases"
28
+ Repository = "https://github.com/sarj-ai/code-standards"
29
+ Issues = "https://github.com/sarj-ai/code-standards/issues"
30
30
 
31
31
  [dependency-groups]
32
32
  dev = [
@@ -10,6 +10,7 @@ from types import MappingProxyType
10
10
  from typing import NamedTuple
11
11
 
12
12
  from sarj_python_lint import __version__
13
+ from sarj_python_lint._analysis_session import AnalysisSession
13
14
  from sarj_python_lint._filesystem import atomic_write_text
14
15
  from sarj_python_lint.rule_base import Diagnostic, ProjectRule, Severity, is_suppressed
15
16
  from sarj_python_lint.rules import REGISTRY
@@ -77,6 +78,9 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
77
78
  sys.stderr.write(f"available: {', '.join(sorted(REGISTRY))}\n")
78
79
  raise SystemExit(2)
79
80
  rules = [REGISTRY[rid]() for rid in rule_ids]
81
+ session = AnalysisSession()
82
+ for rule in rules:
83
+ rule.prepare_session(session)
80
84
  clear_path_caches()
81
85
  expanded = _expand_paths(paths)
82
86
  loaded: dict[Path, str] = {}
@@ -87,7 +91,7 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
87
91
  continue
88
92
  project_rules = [rule for rule in rules if isinstance(rule, ProjectRule)]
89
93
  if project_rules:
90
- indexes = ProjectIndexSet.build(expanded, loaded)
94
+ indexes = ProjectIndexSet.build(expanded, loaded, facts=session.first_party)
91
95
  for rule in project_rules:
92
96
  rule.prepare(indexes)
93
97
  diags: list[Diagnostic] = []
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import final
4
+
5
+ from sarj_python_lint.rules._first_party import FirstPartyFacts
6
+
7
+
8
+ @final
9
+ class AnalysisSession:
10
+ def __init__(self) -> None:
11
+ self.first_party = FirstPartyFacts()
@@ -12,11 +12,12 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
12
12
  if TYPE_CHECKING:
13
13
  from collections.abc import Sequence
14
14
 
15
+ from sarj_python_lint._analysis_session import AnalysisSession
15
16
  from sarj_python_lint.rules._project_index import ProjectIndexSet
16
17
 
17
18
 
18
19
  # Each rule points directly to its executable examples.
19
- REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
20
+ REPO_BLOB: Final = "https://github.com/sarj-ai/code-standards/blob/main"
20
21
  TESTS_DIR: Final = "packages/python/tests/rules"
21
22
 
22
23
 
@@ -237,6 +238,10 @@ class Rule(ABC):
237
238
  code: str
238
239
  description: str
239
240
  documentation: ClassVar[RuleDocumentation | None] = None
241
+ _analysis_session: AnalysisSession | None = None
242
+
243
+ def prepare_session(self, session: AnalysisSession) -> None:
244
+ self._analysis_session = session
240
245
 
241
246
  @abstractmethod
242
247
  def check(self, path: Path, source: str) -> list[Diagnostic]:
@@ -2,9 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from functools import lru_cache
6
5
  import sys
7
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING, final
8
7
 
9
8
 
10
9
  if TYPE_CHECKING:
@@ -53,22 +52,78 @@ _MAX_DIRS_SCANNED = 3000
53
52
  _MAX_ANCESTORS = 24
54
53
 
55
54
 
56
- def is_first_party_module(module: str, path: Path) -> bool:
55
+ @final
56
+ class FirstPartyFacts:
57
+ def __init__(self) -> None:
58
+ self._project_roots: dict[Path, Path | None] = {}
59
+ self._roots: dict[Path, frozenset[str]] = {}
60
+ self._packages: dict[Path, tuple[tuple[str, Path], ...]] = {}
61
+
62
+ def project_root(self, path: Path) -> Path | None:
63
+ if path in self._project_roots:
64
+ return self._project_roots[path]
65
+ resolved = _resolved(path)
66
+ if resolved is None:
67
+ self._project_roots[path] = None
68
+ return None
69
+ ancestors = list(resolved.parents)[:_MAX_ANCESTORS]
70
+ for ancestor in ancestors:
71
+ if (ancestor / _GIT_MARKER).exists():
72
+ self._project_roots[path] = ancestor
73
+ return ancestor
74
+ # No VCS boundary: take the OUTERMOST directory of the unbroken run of
75
+ # pyproject.toml ancestors, which is the workspace root in a uv workspace.
76
+ outermost: Path | None = None
77
+ for ancestor in ancestors:
78
+ if not (ancestor / _PROJECT_MARKER).exists():
79
+ if outermost is not None:
80
+ break
81
+ continue
82
+ outermost = ancestor
83
+ self._project_roots[path] = outermost
84
+ return outermost
85
+
86
+ def first_party_roots(self, root: Path) -> frozenset[str]:
87
+ cached = self._roots.get(root)
88
+ if cached is not None:
89
+ return cached
90
+ roots = frozenset(name for name, _ in self.first_party_packages(root))
91
+ self._roots[root] = roots
92
+ return roots
93
+
94
+ def first_party_packages(self, root: Path) -> tuple[tuple[str, Path], ...]:
95
+ cached = self._packages.get(root)
96
+ if cached is not None:
97
+ return cached
98
+ packages = _scan_first_party_packages(root)
99
+ self._packages[root] = packages
100
+ return packages
101
+
102
+
103
+ def _fresh_facts(facts: FirstPartyFacts | None) -> FirstPartyFacts:
104
+ # Compatibility helper calls are independent requests. The CLI supplies one
105
+ # explicit instance so scans are still shared within a full analysis.
106
+ return facts if facts is not None else FirstPartyFacts()
107
+
108
+
109
+ def is_first_party_module(module: str, path: Path, *, facts: FirstPartyFacts | None = None) -> bool:
110
+ facts = _fresh_facts(facts)
57
111
  top = module.partition(".")[0]
58
112
  if not top or top in sys.stdlib_module_names:
59
113
  return False
60
- root = _project_root(path)
114
+ root = facts.project_root(path)
61
115
  if root is None:
62
116
  # Unresolved ownership biases to third-party to avoid unfixable findings.
63
117
  return False
64
- return top in _first_party_roots(root)
118
+ return top in facts.first_party_roots(root)
65
119
 
66
120
 
67
- def own_top_package(path: Path) -> str | None:
121
+ def own_top_package(path: Path, *, facts: FirstPartyFacts | None = None) -> str | None:
122
+ facts = _fresh_facts(facts)
68
123
  resolved = _resolved(path)
69
124
  if resolved is None:
70
125
  return None
71
- root = _project_root(resolved)
126
+ root = facts.project_root(resolved)
72
127
  top: str | None = None
73
128
  for ancestor in list(resolved.parents)[:_MAX_ANCESTORS]:
74
129
  if (ancestor / "__init__.py").exists():
@@ -76,30 +131,33 @@ def own_top_package(path: Path) -> str | None:
76
131
  if ancestor == root:
77
132
  break
78
133
  if top is None and root is not None:
79
- roots = _first_party_roots(root)
134
+ roots = facts.first_party_roots(root)
80
135
  if len(roots) == 1:
81
136
  return next(iter(roots))
82
137
  return top
83
138
 
84
139
 
85
- def same_distribution(module: str, path: Path) -> bool:
86
- ours = distribution_root(path)
140
+ def same_distribution(module: str, path: Path, *, facts: FirstPartyFacts | None = None) -> bool:
141
+ facts = _fresh_facts(facts)
142
+ ours = distribution_root(path, facts=facts)
87
143
  if ours is None:
88
144
  return False
89
- root = _project_root(path)
145
+ root = facts.project_root(path)
90
146
  if root is None:
91
147
  return False
92
148
  top = module.partition(".")[0]
93
149
  return any(
94
- name == top and distribution_root(package_dir) == ours for name, package_dir in _first_party_packages(root)
150
+ name == top and distribution_root(package_dir, facts=facts) == ours
151
+ for name, package_dir in facts.first_party_packages(root)
95
152
  )
96
153
 
97
154
 
98
- def distribution_root(path: Path) -> Path | None:
155
+ def distribution_root(path: Path, *, facts: FirstPartyFacts | None = None) -> Path | None:
156
+ facts = _fresh_facts(facts)
99
157
  resolved = _resolved(path)
100
158
  if resolved is None:
101
159
  return None
102
- root = _project_root(resolved)
160
+ root = facts.project_root(resolved)
103
161
  for ancestor in list(resolved.parents)[:_MAX_ANCESTORS]:
104
162
  if any((ancestor / marker).exists() for marker in _DISTRIBUTION_MARKERS):
105
163
  return ancestor
@@ -108,19 +166,21 @@ def distribution_root(path: Path) -> Path | None:
108
166
  return None
109
167
 
110
168
 
111
- def has_first_party_source(module: str, path: Path) -> bool:
169
+ def has_first_party_source(module: str, path: Path, *, facts: FirstPartyFacts | None = None) -> bool:
170
+ facts = _fresh_facts(facts)
112
171
  top, _, rest = module.partition(".")
113
- root = _project_root(path)
172
+ root = facts.project_root(path)
114
173
  if root is None:
115
174
  return False
116
175
  segments = rest.split(".") if rest else []
117
176
  return any(
118
- name == top and _declares_module(package_dir, segments) for name, package_dir in _first_party_packages(root)
177
+ name == top and _declares_module(package_dir, segments)
178
+ for name, package_dir in facts.first_party_packages(root)
119
179
  )
120
180
 
121
181
 
122
- def project_root(path: Path) -> Path | None:
123
- return _project_root(path)
182
+ def project_root(path: Path, *, facts: FirstPartyFacts | None = None) -> Path | None:
183
+ return _fresh_facts(facts).project_root(path)
124
184
 
125
185
 
126
186
  def _declares_module(package_dir: Path, segments: list[str]) -> bool:
@@ -138,34 +198,7 @@ def _resolved(path: Path) -> Path | None:
138
198
  return None
139
199
 
140
200
 
141
- @lru_cache(maxsize=256)
142
- def _project_root(path: Path) -> Path | None:
143
- resolved = _resolved(path)
144
- if resolved is None:
145
- return None
146
- ancestors = list(resolved.parents)[:_MAX_ANCESTORS]
147
- for ancestor in ancestors:
148
- if (ancestor / _GIT_MARKER).exists():
149
- return ancestor
150
- # No VCS boundary: take the OUTERMOST directory of the unbroken run of
151
- # pyproject.toml ancestors, which is the workspace root in a uv workspace.
152
- outermost: Path | None = None
153
- for ancestor in ancestors:
154
- if not (ancestor / _PROJECT_MARKER).exists():
155
- if outermost is not None:
156
- break
157
- continue
158
- outermost = ancestor
159
- return outermost
160
-
161
-
162
- @lru_cache(maxsize=32)
163
- def _first_party_roots(root: Path) -> frozenset[str]:
164
- return frozenset(name for name, _ in _first_party_packages(root))
165
-
166
-
167
- @lru_cache(maxsize=32)
168
- def _first_party_packages(root: Path) -> tuple[tuple[str, Path], ...]:
201
+ def _scan_first_party_packages(root: Path) -> tuple[tuple[str, Path], ...]:
169
202
  found: list[tuple[str, Path]] = []
170
203
  queue: list[tuple[Path, int]] = [(root, 0)]
171
204
  scanned = 0
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
- from functools import lru_cache
5
4
  from io import StringIO
6
5
  import re
7
6
  import tokenize
@@ -103,16 +102,6 @@ def _leading_header_bodies(source: str) -> list[str]:
103
102
  return bodies
104
103
 
105
104
 
106
- @lru_cache(maxsize=256)
107
- def _is_codegen_root(directory: Path) -> bool:
108
- return any((directory / name).exists() for name in _CODEGEN_MARKER_NAMES)
109
-
110
-
111
- @lru_cache(maxsize=256)
112
- def _is_repo_root(directory: Path) -> bool:
113
- return any((directory / name).exists() for name in _REPO_ROOT_MARKERS)
114
-
115
-
116
105
  def is_generated_path(path: Path) -> bool:
117
106
  if any(part.lower() in _GENERATED_DIR_NAMES for part in path.parts):
118
107
  return True
@@ -128,9 +117,16 @@ def is_generated_path(path: Path) -> bool:
128
117
  return False
129
118
 
130
119
 
120
+ def _is_codegen_root(directory: Path) -> bool:
121
+ return any((directory / name).exists() for name in _CODEGEN_MARKER_NAMES)
122
+
123
+
124
+ def _is_repo_root(directory: Path) -> bool:
125
+ return any((directory / name).exists() for name in _REPO_ROOT_MARKERS)
126
+
127
+
131
128
  def clear_path_caches() -> None:
132
- _is_codegen_root.cache_clear()
133
- _is_repo_root.cache_clear()
129
+ """Compatibility no-op; path topology is intentionally observed fresh."""
134
130
 
135
131
 
136
132
  def is_generated(path: Path, source: str) -> bool:
@@ -10,7 +10,7 @@ import re
10
10
  from types import MappingProxyType
11
11
  from typing import TYPE_CHECKING, final
12
12
 
13
- from sarj_python_lint.rules._first_party import project_root
13
+ from sarj_python_lint.rules._first_party import FirstPartyFacts, project_root
14
14
 
15
15
 
16
16
  if TYPE_CHECKING:
@@ -111,8 +111,14 @@ class ProjectIndexSet:
111
111
  self._nominals = MappingProxyType({key: frozenset(value) for key, value in nominals.items()})
112
112
 
113
113
  @classmethod
114
- def build(cls, paths: Sequence[Path], loaded: Mapping[Path, str]) -> Self:
115
- roots = _project_roots(paths)
114
+ def build(
115
+ cls,
116
+ paths: Sequence[Path],
117
+ loaded: Mapping[Path, str],
118
+ *,
119
+ facts: FirstPartyFacts | None = None,
120
+ ) -> Self:
121
+ roots = _project_roots(paths, facts=facts)
116
122
  sources: dict[Path, str] = {}
117
123
  for path, source in loaded.items():
118
124
  try:
@@ -283,11 +289,11 @@ def _module_name(path: Path, roots: Sequence[Path]) -> str | None:
283
289
  return ".".join(parts)
284
290
 
285
291
 
286
- def _project_roots(paths: Sequence[Path]) -> tuple[Path, ...]:
292
+ def _project_roots(paths: Sequence[Path], *, facts: FirstPartyFacts | None = None) -> tuple[Path, ...]:
287
293
  candidates: set[Path] = set()
288
294
  for path in paths:
289
295
  try:
290
- root = project_root(path.resolve())
296
+ root = project_root(path.resolve(), facts=facts)
291
297
  except OSError:
292
298
  continue
293
299
  if root is not None:
@@ -17,6 +17,7 @@ from sarj_python_lint.rule_base import (
17
17
  )
18
18
  from sarj_python_lint.rules._ast_index import nodes
19
19
  from sarj_python_lint.rules._first_party import (
20
+ FirstPartyFacts,
20
21
  has_first_party_source,
21
22
  is_first_party_module,
22
23
  own_top_package,
@@ -84,11 +85,12 @@ class NoFirstPartyPrivateImport(Rule):
84
85
  tree = parse_or_none(path, source)
85
86
  if tree is None:
86
87
  return []
87
- own_top = own_top_package(path)
88
+ facts = self._analysis_session.first_party if self._analysis_session is not None else FirstPartyFacts()
89
+ own_top = own_top_package(path, facts=facts)
88
90
  diags = [
89
91
  Diagnostic(path=path, line=hit.line, col=hit.col, code=self.code, message=_message(hit.module, hit.name))
90
92
  for hit in _private_imports(tree)
91
- if _is_ours(hit.module, path, own_top) and not _is_our_own_internals(hit, path)
93
+ if _is_ours(hit.module, path, own_top, facts) and not _is_our_own_internals(hit, path, facts)
92
94
  ]
93
95
  diags.sort(key=lambda d: (d.line, d.col))
94
96
  return diags
@@ -106,12 +108,12 @@ class _PrivateImport:
106
108
  names_public: bool
107
109
 
108
110
 
109
- def _is_our_own_internals(hit: _PrivateImport, path: Path) -> bool:
111
+ def _is_our_own_internals(hit: _PrivateImport, path: Path, facts: FirstPartyFacts) -> bool:
110
112
  if not hit.is_segment:
111
113
  return False
112
- if not has_first_party_source(hit.module, path):
114
+ if not has_first_party_source(hit.module, path, facts=facts):
113
115
  return True
114
- return hit.names_public and same_distribution(hit.module, path)
116
+ return hit.names_public and same_distribution(hit.module, path, facts=facts)
115
117
 
116
118
 
117
119
  def _message(module: str, name: str) -> str:
@@ -122,11 +124,11 @@ def _message(module: str, name: str) -> str:
122
124
  )
123
125
 
124
126
 
125
- def _is_ours(module: str, path: Path, own_top: str | None) -> bool:
127
+ def _is_ours(module: str, path: Path, own_top: str | None, facts: FirstPartyFacts) -> bool:
126
128
  top = module.partition(".")[0]
127
129
  if own_top is not None and top == own_top:
128
130
  return False
129
- return is_first_party_module(module, path)
131
+ return is_first_party_module(module, path, facts=facts)
130
132
 
131
133
 
132
134
  def _private_imports(tree: ast.Module) -> list[_PrivateImport]:
@@ -1,8 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
- from dataclasses import dataclass
5
- from functools import lru_cache
4
+ from dataclasses import dataclass, field
6
5
  import os
7
6
  from pathlib import Path, PurePosixPath
8
7
  from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
@@ -19,13 +18,15 @@ from sarj_python_lint.rule_base import (
19
18
  parse_or_none,
20
19
  )
21
20
  from sarj_python_lint.rules._ast_index import nodes
22
- from sarj_python_lint.rules._first_party import distribution_root
21
+ from sarj_python_lint.rules._first_party import FirstPartyFacts, distribution_root
23
22
  from sarj_python_lint.rules._paths import is_generated, is_test_path
24
23
 
25
24
 
26
25
  if TYPE_CHECKING:
27
26
  from collections.abc import Iterator
28
27
 
28
+ from sarj_python_lint._analysis_session import AnalysisSession
29
+
29
30
 
30
31
  _MIGRATION_PARTS = frozenset({"alembic", "migration", "migrations", "versions"})
31
32
  _DESCRIPTOR_DECORATORS = frozenset({"classmethod", "staticmethod"})
@@ -73,8 +74,16 @@ class _CanonicalSymbol(NamedTuple):
73
74
  symbol: str
74
75
 
75
76
 
77
+ @dataclass(slots=True)
78
+ class _ConstructorFacts:
79
+ modules: dict[Path, ast.Module] = field(default_factory=dict)
80
+ composition_calls: dict[tuple[Path, str, str], bool] = field(default_factory=dict)
81
+ canonical_symbols: dict[tuple[Path, str, str], _CanonicalSymbol] = field(default_factory=dict)
82
+
83
+
76
84
  @final
77
85
  class NoHiddenConstructorFallback(Rule):
86
+ _constructor_facts: _ConstructorFacts | None = None
78
87
  id = "no-hidden-constructor-fallback"
79
88
  code = "SARJ095"
80
89
  documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
@@ -144,6 +153,11 @@ class NoHiddenConstructorFallback(Rule):
144
153
  )
145
154
  description = documentation.summary
146
155
 
156
+ @override
157
+ def prepare_session(self, session: AnalysisSession) -> None:
158
+ super().prepare_session(session)
159
+ self._constructor_facts = _ConstructorFacts()
160
+
147
161
  @override
148
162
  def check(self, path: Path, source: str) -> list[Diagnostic]:
149
163
  if (
@@ -156,7 +170,11 @@ class NoHiddenConstructorFallback(Rule):
156
170
  if tree is None:
157
171
  return []
158
172
 
159
- resolver = _RuntimeConfigResolver(path, tree)
173
+ first_party = self._analysis_session.first_party if self._analysis_session is not None else FirstPartyFacts()
174
+ facts = self._constructor_facts if self._analysis_session is not None else _ConstructorFacts()
175
+ if facts is None:
176
+ facts = _ConstructorFacts()
177
+ resolver = _RuntimeConfigResolver(path, tree, first_party, facts)
160
178
  diagnostics: list[Diagnostic] = []
161
179
  for class_node in nodes(tree, ast.ClassDef):
162
180
  init = next(
@@ -170,7 +188,7 @@ class NoHiddenConstructorFallback(Rule):
170
188
  if init is None or _is_descriptor(init):
171
189
  continue
172
190
  hidden = _hidden_parameters(init, resolver)
173
- if not hidden or not _has_composition_call(path, class_node.name):
191
+ if not hidden or not _has_composition_call(path, class_node.name, first_party, facts):
174
192
  continue
175
193
  names = ", ".join(f"`{match.parameter.arg}`" for match in hidden)
176
194
  noun = "parameter" if len(hidden) == 1 else "parameters"
@@ -343,10 +361,17 @@ def _is_name(expression: ast.expr, name: str) -> bool:
343
361
 
344
362
  @final
345
363
  class _RuntimeConfigResolver:
346
- def __init__(self, path: Path, tree: ast.Module) -> None:
364
+ def __init__(
365
+ self,
366
+ path: Path,
367
+ tree: ast.Module,
368
+ first_party: FirstPartyFacts,
369
+ facts: _ConstructorFacts,
370
+ ) -> None:
347
371
  self._path = path
348
372
  self._tree = tree
349
- self._root = distribution_root(path)
373
+ self._facts = facts
374
+ self._root = distribution_root(path, facts=first_party)
350
375
  self._module = _module_name(path, self._root)
351
376
  self._imports = _imports(tree, self._module, is_package=path.name == "__init__.py")
352
377
  self._settings_cache: dict[tuple[str, str], bool] = {}
@@ -454,7 +479,7 @@ class _RuntimeConfigResolver:
454
479
  path = _module_path(module, self._root)
455
480
  if path is None:
456
481
  return None
457
- return _LoadedModule(_read_module(path), path)
482
+ return _LoadedModule(_read_module(path, self._facts), path)
458
483
 
459
484
 
460
485
  def _imports(tree: ast.Module, current_module: str | None, *, is_package: bool = False) -> dict[str, _Binding]:
@@ -582,12 +607,16 @@ def _module_path(module: str, root: Path | None) -> Path | None:
582
607
  return next((candidate for candidate in candidates if candidate.is_file()), None)
583
608
 
584
609
 
585
- @lru_cache(maxsize=4096)
586
- def _read_module(path: Path) -> ast.Module:
610
+ def _read_module(path: Path, facts: _ConstructorFacts) -> ast.Module:
611
+ cached = facts.modules.get(path)
612
+ if cached is not None:
613
+ return cached
587
614
  try:
588
- return ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path))
615
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path))
589
616
  except OSError, SyntaxError:
590
- return ast.Module(body=[], type_ignores=[])
617
+ tree = ast.Module(body=[], type_ignores=[])
618
+ facts.modules[path] = tree
619
+ return tree
591
620
 
592
621
 
593
622
  def _tail(expression: ast.expr) -> str | None:
@@ -595,16 +624,39 @@ def _tail(expression: ast.expr) -> str | None:
595
624
  return parts[-1] if parts else None
596
625
 
597
626
 
598
- def _has_composition_call(path: Path, class_name: str) -> bool:
599
- root = distribution_root(path)
627
+ def _has_composition_call(
628
+ path: Path,
629
+ class_name: str,
630
+ first_party: FirstPartyFacts,
631
+ facts: _ConstructorFacts,
632
+ ) -> bool:
633
+ root = distribution_root(path, facts=first_party)
600
634
  module = _module_name(path, root)
601
635
  if root is None or module is None:
602
636
  return False
603
- return _distribution_calls_class(root, module, class_name)
637
+ return _distribution_calls_class(root, module, class_name, facts)
604
638
 
605
639
 
606
- @lru_cache(maxsize=1024)
607
- def _distribution_calls_class(root: Path, target_module: str, class_name: str) -> bool:
640
+ def _distribution_calls_class(
641
+ root: Path,
642
+ target_module: str,
643
+ class_name: str,
644
+ facts: _ConstructorFacts,
645
+ ) -> bool:
646
+ key = (root, target_module, class_name)
647
+ if key in facts.composition_calls:
648
+ return facts.composition_calls[key]
649
+ result = _distribution_calls_class_uncached(root, target_module, class_name, facts)
650
+ facts.composition_calls[key] = result
651
+ return result
652
+
653
+
654
+ def _distribution_calls_class_uncached(
655
+ root: Path,
656
+ target_module: str,
657
+ class_name: str,
658
+ facts: _ConstructorFacts,
659
+ ) -> bool:
608
660
  skip_directories = _SCAN_SKIP_PARTS | _MIGRATION_PARTS | {"test", "tests"}
609
661
  for directory, directory_names, file_names in os.walk(root):
610
662
  directory_names[:] = [name for name in directory_names if name.lower() not in skip_directories]
@@ -641,15 +693,25 @@ def _distribution_calls_class(root: Path, target_module: str, class_name: str) -
641
693
  continue
642
694
  if called_symbol != class_name:
643
695
  continue
644
- canonical = _canonical_symbol(root, called_module, called_symbol)
696
+ canonical = _canonical_symbol(root, called_module, called_symbol, facts)
645
697
  if canonical == _CanonicalSymbol(target_module, class_name):
646
698
  return True
647
699
  return False
648
700
 
649
701
 
650
- @lru_cache(maxsize=4096)
651
- def _canonical_symbol(root: Path, module: str, symbol: str) -> _CanonicalSymbol:
652
- return _canonical_symbol_inner(root, module, symbol, frozenset())
702
+ def _canonical_symbol(
703
+ root: Path,
704
+ module: str,
705
+ symbol: str,
706
+ facts: _ConstructorFacts,
707
+ ) -> _CanonicalSymbol:
708
+ key = (root, module, symbol)
709
+ cached = facts.canonical_symbols.get(key)
710
+ if cached is not None:
711
+ return cached
712
+ canonical = _canonical_symbol_inner(root, module, symbol, frozenset(), facts)
713
+ facts.canonical_symbols[key] = canonical
714
+ return canonical
653
715
 
654
716
 
655
717
  def _canonical_symbol_inner(
@@ -657,6 +719,7 @@ def _canonical_symbol_inner(
657
719
  module: str,
658
720
  symbol: str,
659
721
  seen: frozenset[tuple[str, str]],
722
+ facts: _ConstructorFacts,
660
723
  ) -> _CanonicalSymbol:
661
724
  key = (module, symbol)
662
725
  if key in seen:
@@ -664,10 +727,10 @@ def _canonical_symbol_inner(
664
727
  path = _module_path(module, root)
665
728
  if path is None:
666
729
  return _CanonicalSymbol(module, symbol)
667
- binding = _imports(_read_module(path), module, is_package=path.name == "__init__.py").get(symbol)
730
+ binding = _imports(_read_module(path, facts), module, is_package=path.name == "__init__.py").get(symbol)
668
731
  if binding is None or binding.symbol is None:
669
732
  return _CanonicalSymbol(module, symbol)
670
- return _canonical_symbol_inner(root, binding.module, binding.symbol, seen | {key})
733
+ return _canonical_symbol_inner(root, binding.module, binding.symbol, seen | {key}, facts)
671
734
 
672
735
 
673
736
  def _calls_with_shadowing(