sarj-python-lint 0.70.1__tar.gz → 0.72.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 (114) hide show
  1. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/__main__.py +27 -10
  4. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rule_base.py +12 -0
  5. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_comments.py +14 -8
  6. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_fastapi.py +42 -25
  7. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_first_party.py +5 -0
  8. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/_project_index.py +366 -0
  9. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_registry.py +12 -0
  10. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +10 -6
  11. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +22 -11
  12. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +24 -12
  13. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +11 -4
  14. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +53 -39
  15. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/over_mocked_test.py +18 -8
  16. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +21 -9
  17. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +25 -12
  18. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +11 -6
  19. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +190 -24
  20. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_str_enum.py +28 -10
  21. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +39 -6
  22. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +16 -7
  23. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +143 -0
  24. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/preserve_enum_types.py +136 -0
  25. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +15 -5
  26. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_docstring.py +5 -2
  27. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +31 -18
  28. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_port_for_service.py +13 -5
  29. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +164 -17
  30. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +171 -0
  31. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/require_validated_row_factory.py +159 -0
  32. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/source_coupled_test.py +6 -0
  33. sarj_python_lint-0.72.0/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +207 -0
  34. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +11 -5
  35. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +15 -7
  36. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/.gitignore +0 -0
  37. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/LICENSE +0 -0
  38. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/README.md +0 -0
  39. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/__init__.py +0 -0
  40. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_filesystem.py +0 -0
  41. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  42. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_secret_names.py +0 -0
  43. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/_version.py +0 -0
  44. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/py.typed +0 -0
  45. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/ratchet.py +0 -0
  46. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  47. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  48. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  49. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  50. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  51. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  52. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  53. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  54. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  55. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  56. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  57. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  58. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  59. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  60. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  61. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  62. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  63. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  64. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  65. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  66. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  67. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  68. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  69. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  70. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  71. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  72. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  73. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  74. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  75. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  76. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  77. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  78. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  79. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  80. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  81. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  82. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  83. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  84. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  85. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  86. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  87. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  88. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  89. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  90. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  91. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  92. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  93. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  94. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  95. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  96. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  97. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  98. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  99. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  100. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  101. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  102. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  103. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  104. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  105. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  106. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  107. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  108. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  109. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  110. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  111. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  112. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  113. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  114. {sarj_python_lint-0.70.1 → sarj_python_lint-0.72.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.70.1
3
+ Version: 0.72.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.70.1"
3
+ version = "0.72.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" }]
@@ -9,12 +9,14 @@ import json
9
9
  from pathlib import Path
10
10
  import sys
11
11
  from types import MappingProxyType
12
+ from typing import NamedTuple
12
13
 
13
14
  from sarj_python_lint import __version__
14
15
  from sarj_python_lint._filesystem import atomic_write_text
15
- from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
16
+ from sarj_python_lint.rule_base import Diagnostic, ProjectRule, Severity, is_suppressed
16
17
  from sarj_python_lint.rules import REGISTRY
17
18
  from sarj_python_lint.rules._paths import clear_path_caches
19
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
18
20
 
19
21
 
20
22
  SKIP_DIR_NAMES = frozenset(
@@ -79,12 +81,19 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
79
81
  rules = [REGISTRY[rid]() for rid in rule_ids]
80
82
  clear_path_caches()
81
83
  expanded = _expand_paths(paths)
82
- diags: list[Diagnostic] = []
83
- for p in expanded:
84
+ loaded: dict[Path, str] = {}
85
+ for path in expanded:
84
86
  try:
85
- source = p.read_text(encoding="utf-8", errors="replace")
87
+ loaded[path] = path.read_text(encoding="utf-8", errors="replace")
86
88
  except OSError:
87
89
  continue
90
+ project_rules = [rule for rule in rules if isinstance(rule, ProjectRule)]
91
+ if project_rules:
92
+ indexes = ProjectIndexSet.build(expanded, loaded)
93
+ for rule in project_rules:
94
+ rule.prepare(indexes)
95
+ diags: list[Diagnostic] = []
96
+ for p, source in loaded.items():
88
97
  source_lines = source.splitlines()
89
98
  raw = [diagnostic for rule in rules for diagnostic in rule.check(p, source)]
90
99
  diags.extend(
@@ -120,6 +129,11 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
120
129
  )
121
130
 
122
131
 
132
+ class _OwnerLocation(NamedTuple):
133
+ line: int
134
+ column: int
135
+
136
+
123
137
  def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
124
138
  """Keep the most specific remediation at a source location."""
125
139
  codes = frozenset(diagnostic.code for diagnostic in diags)
@@ -130,15 +144,17 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
130
144
  _function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
131
145
  )
132
146
 
133
- def owner_location(diagnostic: Diagnostic) -> tuple[int, int]:
147
+ def owner_location(diagnostic: Diagnostic) -> _OwnerLocation:
134
148
  if diagnostic.code in {"SARJ034", "SARJ093"}:
135
- return signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
136
- return docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
149
+ line, column = signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
150
+ else:
151
+ line, column = docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
152
+ return _OwnerLocation(line, column)
137
153
 
138
154
  present: dict[tuple[Path, int, int], dict[str, set[Severity]]] = {}
139
155
  for diagnostic in diags:
140
- line, col = owner_location(diagnostic)
141
- by_code = present.setdefault((diagnostic.path, line, col), {})
156
+ location = owner_location(diagnostic)
157
+ by_code = present.setdefault((diagnostic.path, location.line, location.column), {})
142
158
  by_code.setdefault(diagnostic.code, set()).add(diagnostic.severity)
143
159
  suppressed = {
144
160
  (location, generic, generic_severity)
@@ -156,7 +172,8 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
156
172
  (
157
173
  (
158
174
  diagnostic.path,
159
- *owner_location(diagnostic),
175
+ owner_location(diagnostic).line,
176
+ owner_location(diagnostic).column,
160
177
  ),
161
178
  diagnostic.code,
162
179
  diagnostic.severity,
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
14
14
  if TYPE_CHECKING:
15
15
  from collections.abc import Sequence
16
16
 
17
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
18
+
17
19
 
18
20
  # Each rule points directly to its executable examples.
19
21
  REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
@@ -309,6 +311,16 @@ class Rule(ABC):
309
311
  return () if spec is None else spec.public_examples
310
312
 
311
313
 
314
+ class ProjectRule(Rule):
315
+ """A rule that may resolve first-party symbols prepared once per CLI run."""
316
+
317
+ _project_indexes: ProjectIndexSet | None = None
318
+
319
+ def prepare(self, indexes: ProjectIndexSet) -> None:
320
+ """Attach immutable project symbols before checking the selected files."""
321
+ self._project_indexes = indexes
322
+
323
+
312
324
  _last_parse: tuple[str, str, ast.Module | None] | None = None
313
325
 
314
326
 
@@ -314,15 +314,21 @@ class _CommentScan[T](NamedTuple):
314
314
  first_code_line: int
315
315
 
316
316
 
317
- _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int, _Ordered]
317
+ class _Scan(NamedTuple):
318
+ standalone: list[tuple[int, int, str]]
319
+ trailing: list[tuple[int, int, str]]
320
+ nested: set[int]
321
+ first_code_line: int
322
+ ordered: _Ordered
323
+
318
324
 
319
325
  _last_scan: tuple[str, _Scan] | None = None
320
326
 
321
327
 
322
328
  def all_comments(source: str) -> _CommentScan[_Ordered]:
323
329
  """Return every comment as `(line, col0, body, standalone)`, plus the first code line."""
324
- _, _, _, first_code_line, ordered = _scan_memo(source)
325
- return _CommentScan(ordered, first_code_line)
330
+ scan = _scan_memo(source)
331
+ return _CommentScan(scan.ordered, scan.first_code_line)
326
332
 
327
333
 
328
334
  def _scan_memo(source: str) -> _Scan:
@@ -361,23 +367,23 @@ def _scan(source: str) -> _Scan:
361
367
  prev_end_row = tok.end[0]
362
368
  if tok.type not in _NON_CODE_TOKENS:
363
369
  first_code_line = min(first_code_line, tok.start[0])
364
- return standalone, trailing, nested, first_code_line, ordered
370
+ return _Scan(standalone, trailing, nested, first_code_line, ordered)
365
371
 
366
372
 
367
373
  def trailing_comments(source: str) -> list[tuple[int, int, str]]:
368
374
  """Return every comment that shares its line with code, as `(line, col, body)`."""
369
- return _scan_memo(source)[1]
375
+ return _scan_memo(source).trailing
370
376
 
371
377
 
372
378
  def nested_comment_lines(source: str) -> set[int]:
373
379
  """Return the lines of comments sitting INSIDE a bracketed expression."""
374
- return _scan_memo(source)[2]
380
+ return _scan_memo(source).nested
375
381
 
376
382
 
377
383
  def standalone_comments(source: str) -> _CommentScan[list[tuple[int, int, str]]]:
378
384
  """Return every own-line comment as `(line, col, body)`, plus the first code line."""
379
- standalone, _, _, first_code_line, _ = _scan_memo(source)
380
- return _CommentScan(standalone, first_code_line)
385
+ scan = _scan_memo(source)
386
+ return _CommentScan(scan.standalone, scan.first_code_line)
381
387
 
382
388
 
383
389
  def comment_runs(standalone: Sequence[tuple[int, int, str]]) -> list[list[tuple[int, int, str]]]:
@@ -74,6 +74,16 @@ class ParameterMarker(NamedTuple):
74
74
  call: ast.Call
75
75
 
76
76
 
77
+ class _ReceiverKey(NamedTuple):
78
+ scope: int
79
+ name: str
80
+
81
+
82
+ class _DecoratorBinding(NamedTuple):
83
+ receiver: str
84
+ method: str
85
+
86
+
77
87
  @dataclass(frozen=True, slots=True)
78
88
  class _ImportedReference:
79
89
  module: str
@@ -113,12 +123,12 @@ class FastapiIndex:
113
123
  self.annotated: set[str] = set()
114
124
  self.symbols: dict[str, str] = {}
115
125
  self.type_aliases: dict[str, ast.expr] = {}
116
- self.receivers: set[tuple[int, str]] = set()
117
- self.receiver_origins: dict[tuple[int, str], tuple[int, str]] = {}
118
- self.receiver_kinds: dict[tuple[int, str], Literal["FastAPI", "APIRouter"]] = {}
119
- self.hidden_receivers: set[tuple[int, str]] = set()
120
- self.decorators: dict[tuple[int, str], tuple[str, str]] = {}
121
- self.bound_names: set[tuple[int, str]] = set()
126
+ self.receivers: set[_ReceiverKey] = set()
127
+ self.receiver_origins: dict[_ReceiverKey, _ReceiverKey] = {}
128
+ self.receiver_kinds: dict[_ReceiverKey, Literal["FastAPI", "APIRouter"]] = {}
129
+ self.hidden_receivers: set[_ReceiverKey] = set()
130
+ self.decorators: dict[_ReceiverKey, _DecoratorBinding] = {}
131
+ self.bound_names: set[_ReceiverKey] = set()
122
132
  self._node_scopes: dict[int, int] = {}
123
133
  self._node_classes: dict[int, int | None] = {}
124
134
  self._route_scopes: dict[int, int] = {}
@@ -182,31 +192,37 @@ class FastapiIndex:
182
192
  assignments: list[tuple[int, str, ast.expr]] = []
183
193
  for node in ast.walk(tree):
184
194
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
185
- self.bound_names.add((self._node_scopes[id(node)], node.name))
195
+ self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
186
196
  arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
187
197
  if node.args.vararg is not None:
188
198
  arguments.append(node.args.vararg)
189
199
  if node.args.kwarg is not None:
190
200
  arguments.append(node.args.kwarg)
191
- self.bound_names.update((node.lineno, argument.arg) for argument in arguments)
201
+ self.bound_names.update(_ReceiverKey(node.lineno, argument.arg) for argument in arguments)
192
202
  elif isinstance(node, ast.ClassDef):
193
- self.bound_names.add((self._node_scopes[id(node)], node.name))
203
+ self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
194
204
  elif isinstance(node, (ast.Import, ast.ImportFrom)):
195
205
  self.bound_names.update(
196
- (self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0]) for alias in node.names
206
+ _ReceiverKey(self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0])
207
+ for alias in node.names
197
208
  )
198
209
  elif isinstance(node, (ast.For, ast.AsyncFor)):
199
- self.bound_names.update((self._node_scopes[id(node)], name) for name in self._target_names(node.target))
210
+ self.bound_names.update(
211
+ _ReceiverKey(self._node_scopes[id(node)], name) for name in self._target_names(node.target)
212
+ )
200
213
  elif isinstance(node, ast.With):
201
214
  for item in node.items:
202
215
  if item.optional_vars is not None:
203
216
  self.bound_names.update(
204
- (self._node_scopes[id(node)], name) for name in self._target_names(item.optional_vars)
217
+ _ReceiverKey(self._node_scopes[id(node)], name)
218
+ for name in self._target_names(item.optional_vars)
205
219
  )
206
220
  elif isinstance(node, ast.ExceptHandler) and node.name is not None:
207
- self.bound_names.add((self._node_scopes[id(node)], node.name))
221
+ self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
208
222
  elif isinstance(node, ast.NamedExpr):
209
- self.bound_names.update((self._node_scopes[id(node)], name) for name in self._target_names(node.target))
223
+ self.bound_names.update(
224
+ _ReceiverKey(self._node_scopes[id(node)], name) for name in self._target_names(node.target)
225
+ )
210
226
  if isinstance(node, ast.Assign) and len(node.targets) == 1:
211
227
  name = _binding_name(node.targets[0])
212
228
  if name:
@@ -219,12 +235,12 @@ class FastapiIndex:
219
235
  assignments.append((self._node_scopes[id(node)], node.name.id, node.value))
220
236
 
221
237
  changed = True
222
- counts = Counter((scope, name) for scope, name, _value in assignments)
238
+ counts = Counter(_ReceiverKey(scope, name) for scope, name, _value in assignments)
223
239
  self.bound_names.update(counts)
224
240
  while changed:
225
241
  changed = False
226
242
  for scope, name, value in assignments:
227
- key = (scope, name)
243
+ key = _ReceiverKey(scope, name)
228
244
  if counts[key] != 1:
229
245
  continue
230
246
  if key in self.receivers or key in self.decorators:
@@ -250,7 +266,7 @@ class FastapiIndex:
250
266
  if isinstance(value, ast.Attribute) and value.attr in HTTP_METHODS:
251
267
  receiver = _binding_name(value.value)
252
268
  if self._receiver_key(scope, receiver) is not None:
253
- self.decorators[key] = (receiver, value.attr)
269
+ self.decorators[key] = _DecoratorBinding(receiver, value.attr)
254
270
  changed = True
255
271
  continue
256
272
  if self._is_annotated(value) or (isinstance(value, ast.Name) and value.id in self.type_aliases):
@@ -268,12 +284,12 @@ class FastapiIndex:
268
284
  return self._node_classes[id(node)] or self._node_scopes[id(node)]
269
285
  return self._node_scopes[id(node)]
270
286
 
271
- def _receiver_key(self, scope: int, name: str) -> tuple[int, str] | None:
287
+ def _receiver_key(self, scope: int, name: str) -> _ReceiverKey | None:
272
288
  if not name:
273
289
  return None
274
290
  current: int | None = scope
275
291
  while current is not None:
276
- binding = (current, name)
292
+ binding = _ReceiverKey(current, name)
277
293
  if binding in self.receivers:
278
294
  return binding
279
295
  if binding in self.bound_names:
@@ -325,7 +341,8 @@ class FastapiIndex:
325
341
  if receiver_key is not None:
326
342
  method = decorator.func.attr
327
343
  elif isinstance(decorator.func, ast.Name) and (resolved := self._decorator(scope, decorator.func.id)):
328
- receiver, method = resolved
344
+ receiver = resolved.receiver
345
+ method = resolved.method
329
346
  receiver_key = self._receiver_key(scope, receiver)
330
347
  if method not in HTTP_METHODS or receiver_key is None:
331
348
  continue
@@ -336,11 +353,11 @@ class FastapiIndex:
336
353
  )
337
354
  path = path_node.value if isinstance(path_node, ast.Constant) and isinstance(path_node.value, str) else None
338
355
  route_methods = self._route_methods(decorator) if method == "api_route" else (method,)
339
- origin_scope, origin_name = self.receiver_origins[receiver_key]
356
+ origin = self.receiver_origins[receiver_key]
340
357
  routes.extend(
341
358
  Route(
342
359
  decorator=decorator,
343
- receiver=f"{origin_scope}:{origin_name}",
360
+ receiver=f"{origin.scope}:{origin.name}",
344
361
  method=route_method,
345
362
  path=path,
346
363
  receiver_kind=self.receiver_kinds[receiver_key],
@@ -350,10 +367,10 @@ class FastapiIndex:
350
367
  )
351
368
  return tuple(routes)
352
369
 
353
- def _decorator(self, scope: int, name: str) -> tuple[str, str] | None:
370
+ def _decorator(self, scope: int, name: str) -> _DecoratorBinding | None:
354
371
  current: int | None = scope
355
372
  while current is not None:
356
- binding = (current, name)
373
+ binding = _ReceiverKey(current, name)
357
374
  if binding in self.decorators:
358
375
  return self.decorators[binding]
359
376
  if binding in self.bound_names:
@@ -473,7 +490,7 @@ class FastapiIndex:
473
490
  # it, so begin with that function's parent scope.
474
491
  scope: int | None = self._scope_parents.get(node_scope)
475
492
  while scope is not None and scope != 0:
476
- if (scope, root) in self.bound_names:
493
+ if _ReceiverKey(scope, root) in self.bound_names:
477
494
  return True
478
495
  scope = self._scope_parents.get(scope)
479
496
  return False
@@ -126,6 +126,11 @@ def has_first_party_source(module: str, path: Path) -> bool:
126
126
  )
127
127
 
128
128
 
129
+ def project_root(path: Path) -> Path | None:
130
+ """Return the conservative repository/workspace root used by project-aware rules."""
131
+ return _project_root(path)
132
+
133
+
129
134
  def _declares_module(package_dir: Path, segments: list[str]) -> bool:
130
135
  target = package_dir.joinpath(*segments)
131
136
  try: