sarj-python-lint 0.71.0__tar.gz → 0.73.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 (116) hide show
  1. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__init__.py +0 -2
  4. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__main__.py +39 -25
  5. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_filesystem.py +0 -3
  6. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_ratchet_cli.py +0 -5
  7. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_secret_names.py +0 -6
  8. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_version.py +0 -2
  9. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/ratchet.py +10 -26
  10. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rule_base.py +9 -31
  11. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_ast_index.py +0 -8
  12. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_comments.py +25 -30
  13. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_docstrings.py +77 -13
  14. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_fastapi.py +5 -21
  15. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_first_party.py +4 -10
  16. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_imports.py +0 -7
  17. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_logging.py +0 -3
  18. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_paths.py +0 -11
  19. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/_project_index.py +408 -0
  20. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -12
  21. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_pytest.py +0 -4
  22. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_registry.py +18 -0
  23. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_sql.py +0 -6
  24. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -6
  25. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -4
  26. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -5
  27. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -11
  28. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -5
  29. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -7
  30. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicate_test_body.py +20 -40
  31. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -7
  32. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +11 -11
  33. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +18 -20
  34. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -5
  35. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -5
  36. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -13
  37. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/mock_without_spec.py +9 -28
  38. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -6
  39. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -6
  40. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_comment_cruft.py +8 -24
  41. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -9
  42. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +10 -14
  43. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -8
  44. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -8
  45. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -12
  46. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -5
  47. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -7
  48. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -9
  49. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -5
  50. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -19
  51. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -6
  52. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -5
  53. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -14
  54. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -6
  55. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -7
  56. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -12
  57. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -11
  58. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_select_star.py +0 -6
  59. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -41
  60. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -12
  61. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -21
  62. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_tautological_expect.py +15 -30
  63. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -5
  64. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -5
  65. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/no_unnecessary_docstring.py +620 -0
  66. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/no_vague_suppression_description.py +107 -0
  67. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +10 -12
  68. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/over_mocked_test.py +26 -53
  69. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -5
  70. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -11
  71. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -16
  72. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -23
  73. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -9
  74. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -21
  75. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -20
  76. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +23 -49
  77. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -33
  78. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +24 -34
  79. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +5 -11
  80. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +9 -15
  81. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -18
  82. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -5
  83. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -10
  84. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_str_enum.py +8 -42
  85. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -6
  86. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +5 -26
  87. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -7
  88. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -10
  89. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -7
  90. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +138 -0
  91. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/preserve_enum_types.py +131 -0
  92. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +9 -10
  93. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +5 -24
  94. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +4 -46
  95. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -10
  96. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -5
  97. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -6
  98. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -19
  99. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_port_for_service.py +9 -32
  100. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +22 -14
  101. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +166 -0
  102. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/require_validated_row_factory.py +159 -0
  103. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -8
  104. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/source_coupled_test.py +12 -12
  105. sarj_python_lint-0.73.0/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +202 -0
  106. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/stepdown.py +0 -15
  107. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -7
  108. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -5
  109. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +19 -36
  110. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -8
  111. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -22
  112. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/.gitignore +0 -0
  113. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/LICENSE +0 -0
  114. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/README.md +0 -0
  115. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/py.typed +0 -0
  116. {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.71.0
3
+ Version: 0.73.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.71.0"
3
+ version = "0.73.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" }]
@@ -1,5 +1,3 @@
1
- """sarj-python-lint — custom Python lint rules."""
2
-
3
1
  from sarj_python_lint._ratchet_cli import main as run_ratchet
4
2
  from sarj_python_lint._version import __version__
5
3
 
@@ -1,5 +1,3 @@
1
- """CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import argparse
@@ -13,9 +11,10 @@ from typing import NamedTuple
13
11
 
14
12
  from sarj_python_lint import __version__
15
13
  from sarj_python_lint._filesystem import atomic_write_text
16
- from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
14
+ from sarj_python_lint.rule_base import Diagnostic, ProjectRule, Severity, is_suppressed
17
15
  from sarj_python_lint.rules import REGISTRY
18
16
  from sarj_python_lint.rules._paths import clear_path_caches
17
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
19
18
 
20
19
 
21
20
  SKIP_DIR_NAMES = frozenset(
@@ -80,18 +79,29 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
80
79
  rules = [REGISTRY[rid]() for rid in rule_ids]
81
80
  clear_path_caches()
82
81
  expanded = _expand_paths(paths)
83
- diags: list[Diagnostic] = []
84
- for p in expanded:
82
+ loaded: dict[Path, str] = {}
83
+ for path in expanded:
85
84
  try:
86
- source = p.read_text(encoding="utf-8", errors="replace")
85
+ loaded[path] = path.read_text(encoding="utf-8", errors="replace")
87
86
  except OSError:
88
87
  continue
88
+ project_rules = [rule for rule in rules if isinstance(rule, ProjectRule)]
89
+ if project_rules:
90
+ indexes = ProjectIndexSet.build(expanded, loaded)
91
+ for rule in project_rules:
92
+ rule.prepare(indexes)
93
+ diags: list[Diagnostic] = []
94
+ for p, source in loaded.items():
89
95
  source_lines = source.splitlines()
90
96
  raw = [diagnostic for rule in rules for diagnostic in rule.check(p, source)]
91
97
  diags.extend(
92
98
  diagnostic
93
99
  for diagnostic in deduplicate_diagnostics(
94
- [diagnostic for diagnostic in raw if not is_suppressed(source_lines, diagnostic.line, diagnostic.code)],
100
+ [
101
+ diagnostic
102
+ for diagnostic in raw
103
+ if diagnostic.code == "SARJ419" or not is_suppressed(source_lines, diagnostic.line, diagnostic.code)
104
+ ],
95
105
  source=source,
96
106
  )
97
107
  )
@@ -105,7 +115,6 @@ def analyze(
105
115
  baseline: Path | None = None,
106
116
  root: Path | None = None,
107
117
  ) -> list[Diagnostic]:
108
- """Return native diagnostics without rendering CLI output."""
109
118
  diagnostics = _check(rule_ids, paths)
110
119
  return diagnostics if baseline is None else _apply_baseline(diagnostics, _read_baseline(baseline), root=root)
111
120
 
@@ -113,13 +122,23 @@ def analyze(
113
122
  _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
114
123
  {
115
124
  "SARJ003": frozenset({"SARJ080"}),
116
- "SARJ084": frozenset({"SARJ050", "SARJ091"}),
117
- "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ091"}),
118
- "SARJ092": frozenset({"SARJ086", "SARJ087"}),
125
+ "SARJ050": frozenset({"SARJ420"}),
126
+ "SARJ084": frozenset({"SARJ050", "SARJ091", "SARJ420"}),
127
+ "SARJ085": frozenset({"SARJ420"}),
128
+ "SARJ086": frozenset({"SARJ420"}),
129
+ "SARJ087": frozenset({"SARJ420"}),
130
+ "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ091", "SARJ420"}),
131
+ "SARJ091": frozenset({"SARJ420"}),
132
+ "SARJ092": frozenset({"SARJ086", "SARJ087", "SARJ420"}),
119
133
  "SARJ093": frozenset({"SARJ034"}),
134
+ "SARJ099": frozenset({"SARJ420"}),
120
135
  }
121
136
  )
122
137
 
138
+ _DOCSTRING_PRECEDENCE_CODES = frozenset(
139
+ {"SARJ050", "SARJ084", "SARJ085", "SARJ086", "SARJ087", "SARJ088", "SARJ091", "SARJ092", "SARJ099"}
140
+ )
141
+
123
142
 
124
143
  class _OwnerLocation(NamedTuple):
125
144
  line: int
@@ -127,9 +146,10 @@ class _OwnerLocation(NamedTuple):
127
146
 
128
147
 
129
148
  def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
130
- """Keep the most specific remediation at a source location."""
131
149
  codes = frozenset(diagnostic.code for diagnostic in diags)
132
- needs_docstring_owners = "SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])
150
+ needs_docstring_owners = ("SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])) or (
151
+ "SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
152
+ )
133
153
  docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
134
154
  needs_signature_owners = "SARJ093" in codes and "SARJ034" in codes
135
155
  signature_owners = (
@@ -175,31 +195,29 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
175
195
  ]
176
196
 
177
197
 
178
- def _function_signature_owner_locations(source: str) -> dict[int, tuple[int, int]]:
179
- """Map signature lines to their function opening for cross-rule precedence."""
198
+ def _function_signature_owner_locations(source: str) -> dict[int, _OwnerLocation]:
180
199
  try:
181
200
  tree = ast.parse(source)
182
201
  except SyntaxError:
183
202
  return {}
184
- owners: dict[int, tuple[int, int]] = {}
203
+ owners: dict[int, _OwnerLocation] = {}
185
204
  for node in ast.walk(tree):
186
205
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
187
206
  continue
188
207
  body_line = node.body[0].lineno if node.body else node.lineno + 1
189
208
  end_line = max(node.lineno, body_line - 1)
190
- owners.update(dict.fromkeys(range(node.lineno, end_line + 1), (node.lineno, node.col_offset + 1)))
209
+ owners.update(dict.fromkeys(range(node.lineno, end_line + 1), _OwnerLocation(node.lineno, node.col_offset + 1)))
191
210
  return owners
192
211
 
193
212
 
194
- def _docstring_owner_locations(source: str) -> dict[int, tuple[int, int]]:
195
- """Map every physical docstring line to the opening expression that owns it."""
213
+ def _docstring_owner_locations(source: str) -> dict[int, _OwnerLocation]:
196
214
  try:
197
215
  tree = ast.parse(source)
198
216
  except SyntaxError:
199
217
  tree = None
200
218
  if tree is None:
201
219
  return {}
202
- owners: dict[int, tuple[int, int]] = {}
220
+ owners: dict[int, _OwnerLocation] = {}
203
221
  for node in ast.walk(tree):
204
222
  if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) or not node.body:
205
223
  continue
@@ -210,7 +228,7 @@ def _docstring_owner_locations(source: str) -> dict[int, tuple[int, int]]:
210
228
  and isinstance(expression.value.value, str)
211
229
  ):
212
230
  continue
213
- location = (expression.lineno, expression.col_offset + 1)
231
+ location = _OwnerLocation(expression.lineno, expression.col_offset + 1)
214
232
  lines = range(expression.lineno, (expression.end_lineno or expression.lineno) + 1)
215
233
  owners.update(dict.fromkeys(lines, location))
216
234
  return owners
@@ -237,7 +255,6 @@ class _Args(argparse.Namespace):
237
255
 
238
256
 
239
257
  def _explain(wanted: str) -> int:
240
- """Print a rule's description and its derived examples link."""
241
258
  key = wanted.strip()
242
259
  cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
243
260
  if cls is None:
@@ -259,7 +276,6 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
259
276
 
260
277
 
261
278
  def _baseline_path(path: Path, *, root: Path | None = None) -> str:
262
- """Make baselines portable when a caller supplies repository-absolute paths."""
263
279
  try:
264
280
  return path.resolve().relative_to((Path.cwd() if root is None else root).resolve()).as_posix()
265
281
  except ValueError:
@@ -267,7 +283,6 @@ def _baseline_path(path: Path, *, root: Path | None = None) -> str:
267
283
 
268
284
 
269
285
  def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
270
- """Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries."""
271
286
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
272
287
  path.read_text(encoding="utf-8")
273
288
  )
@@ -291,7 +306,6 @@ def _apply_baseline(
291
306
  *,
292
307
  root: Path | None = None,
293
308
  ) -> list[Diagnostic]:
294
- """Suppress up to the baselined count per (path, code); excess diags survive."""
295
309
  seen: Counter[tuple[str, str]] = Counter()
296
310
  out: list[Diagnostic] = []
297
311
  for d in diags:
@@ -1,5 +1,3 @@
1
- """Small safe filesystem primitives shared by command-line workflows."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import os
@@ -8,7 +6,6 @@ import tempfile
8
6
 
9
7
 
10
8
  def atomic_write_text(path: Path, contents: str) -> None:
11
- """Replace an existing-parent destination atomically."""
12
9
  if not path.parent.is_dir():
13
10
  msg = f"baseline parent does not exist: {path.parent}"
14
11
  raise OSError(msg)
@@ -1,5 +1,3 @@
1
- """CLI: `sarj-ratchet [--baseline PATH] [--package DIR]... [--update [--allow-increase]] [ROOT]`."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import argparse
@@ -50,7 +48,6 @@ class _Args(argparse.Namespace):
50
48
 
51
49
 
52
50
  def main(argv: list[str] | None = None) -> int:
53
- """Run the ratchet."""
54
51
  args = _build_parser().parse_args(argv, namespace=_Args())
55
52
  root = args.root.resolve()
56
53
  baseline_path = args.baseline if args.baseline is not None else root / _DEFAULT_BASELINE_NAME
@@ -128,7 +125,6 @@ def main(argv: list[str] | None = None) -> int:
128
125
 
129
126
 
130
127
  def _build_parser() -> argparse.ArgumentParser:
131
- """Assemble the argument parser."""
132
128
  parser = argparse.ArgumentParser(
133
129
  prog="sarj-ratchet",
134
130
  description=(
@@ -180,7 +176,6 @@ def _update(
180
176
  *,
181
177
  allow_increase: bool,
182
178
  ) -> int:
183
- """Re-seed the baseline, refusing raises unless they were explicitly reviewed."""
184
179
  would_raise = gate(measurement, baseline)
185
180
  if would_raise and not allow_increase:
186
181
  sys.stderr.write("REFUSED: --update would raise ceilings; pass --allow-increase if this was reviewed:\n")
@@ -1,5 +1,3 @@
1
- """Recognize secret-bearing identifiers for SARJ011 and SARJ012."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from itertools import pairwise
@@ -67,7 +65,6 @@ _SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
67
65
 
68
66
 
69
67
  def identifier_tokens(identifier: str) -> list[str]:
70
- """Return lowercase whole segments and their camel-case words."""
71
68
  tokens: list[str] = []
72
69
  for segment in _SEGMENT_RE.split(identifier):
73
70
  if not segment:
@@ -79,7 +76,6 @@ def identifier_tokens(identifier: str) -> list[str]:
79
76
 
80
77
 
81
78
  def leading_word(identifier: str) -> str | None:
82
- """Return the first camel- or delimiter-separated word, lowercased."""
83
79
  for segment in _SEGMENT_RE.split(identifier):
84
80
  if not segment:
85
81
  continue
@@ -89,7 +85,6 @@ def leading_word(identifier: str) -> str | None:
89
85
 
90
86
 
91
87
  def is_secret_name(identifier: str) -> bool:
92
- """Report whether `identifier` names raw secret material (a credential, not metadata)."""
93
88
  tokens = identifier_tokens(identifier)
94
89
  if tokens and tokens[-1] in _INNOCUOUS_WORDS:
95
90
  return False
@@ -101,5 +96,4 @@ def is_secret_name(identifier: str) -> bool:
101
96
 
102
97
 
103
98
  def _has_api_key(tokens: list[str]) -> bool:
104
- """Report whether `api` is immediately followed by `key` (the split form of `api_key`)."""
105
99
  return any(a == "api" and b == "key" for a, b in pairwise(tokens))
@@ -1,5 +1,3 @@
1
- """Resolve the installed package version, with a source-tree fallback."""
2
-
3
1
  from importlib.metadata import PackageNotFoundError, version
4
2
 
5
3
 
@@ -1,5 +1,3 @@
1
- """Suppression ratchet: count every escape hatch in a tree and let the count only shrink."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from collections import Counter
@@ -7,7 +5,7 @@ from dataclasses import dataclass, field
7
5
  import json
8
6
  import re
9
7
  from types import MappingProxyType
10
- from typing import TYPE_CHECKING, Final, TypeGuard
8
+ from typing import TYPE_CHECKING, Final, NamedTuple, TypeGuard
11
9
 
12
10
 
13
11
  if TYPE_CHECKING:
@@ -32,6 +30,12 @@ DEFAULT_EXCLUDED_DIR_NAMES: Final = frozenset(
32
30
  }
33
31
  )
34
32
 
33
+
34
+ class Improvement(NamedTuple):
35
+ previous: int
36
+ current: int
37
+
38
+
35
39
  DEFAULT_PER_FILE_CEILING: Final = 10
36
40
 
37
41
  _NOQA_RE: Final = re.compile(r"#\s*noqa:\s*([A-Z][A-Z0-9]+(?:\s*,\s*[A-Z][A-Z0-9]+)*)")
@@ -50,8 +54,6 @@ _BARE_TYPE_IGNORE_KEY: Final = "type-ignore"
50
54
 
51
55
  @dataclass(frozen=True, slots=True)
52
56
  class Measurement:
53
- """What one scan of the tree found."""
54
-
55
57
  codes: Counter[str]
56
58
  packages: Counter[str]
57
59
  files: dict[str, int]
@@ -64,8 +66,6 @@ class Measurement:
64
66
 
65
67
  @dataclass(frozen=True, slots=True)
66
68
  class Baseline:
67
- """Ceilings by code, package, and file so cleanup in one dimension cannot finance debt in another."""
68
-
69
69
  codes: dict[str, int] = field(default_factory=dict[str, int])
70
70
  packages: dict[str, int] = field(default_factory=dict[str, int])
71
71
  per_file_ceiling: int = DEFAULT_PER_FILE_CEILING
@@ -75,15 +75,12 @@ class Baseline:
75
75
 
76
76
  @dataclass(frozen=True, slots=True)
77
77
  class Failure:
78
- """One ceiling that the measurement exceeded."""
79
-
80
78
  dimension: str
81
79
  key: str
82
80
  ceiling: int
83
81
  actual: int
84
82
 
85
83
  def format(self) -> str:
86
- """Render the failure with the remediation for its dimension."""
87
84
  head = f"FAIL[{self.dimension}] {self.key}: {self.actual} suppressions, ceiling {self.ceiling}."
88
85
  return f"{head} {_REMEDIATION[self.dimension]}"
89
86
 
@@ -115,7 +112,6 @@ def measure(
115
112
  excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
116
113
  excluded_subtrees: Iterable[str] = (),
117
114
  ) -> Measurement:
118
- """Count every suppression under `root`, bucketed by code, package and file."""
119
115
  codes: Counter[str] = Counter()
120
116
  package_counts: Counter[str] = Counter()
121
117
  files: dict[str, int] = {}
@@ -137,7 +133,6 @@ def measure(
137
133
 
138
134
 
139
135
  def count_source(source: str) -> Counter[str]:
140
- """Count the suppressions in one file's text, keyed by dialect and code."""
141
136
  counts: Counter[str] = Counter()
142
137
  for line in source.splitlines():
143
138
  _count_line(line, counts)
@@ -145,7 +140,6 @@ def count_source(source: str) -> Counter[str]:
145
140
 
146
141
 
147
142
  def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
148
- """Compare a measurement against the baseline's three ceilings."""
149
143
  failures = [
150
144
  Failure(dimension="code", key=key, ceiling=c, actual=n)
151
145
  for key, n in sorted(measurement.codes.items())
@@ -164,18 +158,16 @@ def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
164
158
  return failures
165
159
 
166
160
 
167
- def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, tuple[int, int]]:
168
- """Find the code keys now below their ceiling — the wins worth locking in."""
169
- out: dict[str, tuple[int, int]] = {}
161
+ def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, Improvement]:
162
+ out: dict[str, Improvement] = {}
170
163
  for key, ceiling in baseline.codes.items():
171
164
  actual = measurement.codes.get(key, 0)
172
165
  if actual < ceiling:
173
- out[key] = (ceiling, actual)
166
+ out[key] = Improvement(ceiling, actual)
174
167
  return out
175
168
 
176
169
 
177
170
  def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
178
- """Build the baseline that `--update` would write from a measurement."""
179
171
  # Recompute exceptions so a file loses grandfathering as soon as it reaches the ceiling.
180
172
  exceptions = {path: n for path, n in measurement.files.items() if n > baseline.per_file_ceiling}
181
173
  return Baseline(
@@ -188,7 +180,6 @@ def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
188
180
 
189
181
 
190
182
  def load_baseline(path: Path) -> Baseline:
191
- """Read a baseline JSON file, ignoring entries of the wrong shape."""
192
183
  raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
193
184
  path.read_text(encoding="utf-8")
194
185
  )
@@ -206,14 +197,12 @@ def load_baseline(path: Path) -> Baseline:
206
197
 
207
198
 
208
199
  def _get(mapping: object, key: str) -> object:
209
- """Read one key out of a value that may or may not be a JSON object."""
210
200
  if not isinstance(mapping, dict):
211
201
  return None
212
202
  return mapping.get(key) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] — json leaves are Any
213
203
 
214
204
 
215
205
  def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
216
- """Render a baseline as the JSON text to write."""
217
206
  payload = {
218
207
  "_comment": (
219
208
  "Suppression ceilings, written by `sarj-ratchet --update`. Counts may "
@@ -235,7 +224,6 @@ def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
235
224
 
236
225
 
237
226
  def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES) -> list[str]:
238
- """List the top-level directories under `root` that contain Python files."""
239
227
  return sorted(
240
228
  name
241
229
  for child in root.iterdir()
@@ -247,7 +235,6 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
247
235
 
248
236
 
249
237
  def _int_map(value: object) -> dict[str, int]:
250
- """Narrow a JSON value to `{str: int}`, dropping anything else."""
251
238
  if not isinstance(value, dict):
252
239
  return {}
253
240
  return {
@@ -281,7 +268,6 @@ def _inside_subtree(relative: str, subtree: str) -> bool:
281
268
 
282
269
 
283
270
  def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Path]:
284
- """Yield every `.py` file under `root`, skipping excluded directories."""
285
271
  if not root.is_dir():
286
272
  return
287
273
  for path in sorted(root.rglob("*.py")):
@@ -290,7 +276,6 @@ def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Pa
290
276
 
291
277
 
292
278
  def _read(path: Path) -> str:
293
- """Read a source file, treating undecodable bytes as empty."""
294
279
  try:
295
280
  return path.read_text(encoding="utf-8")
296
281
  except UnicodeDecodeError, OSError:
@@ -298,7 +283,6 @@ def _read(path: Path) -> str:
298
283
 
299
284
 
300
285
  def _count_line(line: str, counts: Counter[str]) -> None:
301
- """Add one line's suppressions to `counts`."""
302
286
  if file_noqa := _FILE_NOQA_RE.match(line):
303
287
  listed = file_noqa.group(1)
304
288
  if listed:
@@ -1,5 +1,3 @@
1
- """Base types for sarj-python-lint rules."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from abc import ABC, abstractmethod
@@ -14,6 +12,8 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
14
12
  if TYPE_CHECKING:
15
13
  from collections.abc import Sequence
16
14
 
15
+ from sarj_python_lint.rules._project_index import ProjectIndexSet
16
+
17
17
 
18
18
  # Each rule points directly to its executable examples.
19
19
  REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
@@ -28,7 +28,6 @@ _SARJ_NOQA_RE = re.compile(
28
28
 
29
29
 
30
30
  def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
31
- """Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment."""
32
31
  if line < 1 or line > len(source_lines):
33
32
  return False
34
33
  text = source_lines[line - 1]
@@ -44,22 +43,16 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
44
43
 
45
44
 
46
45
  class Severity(StrEnum):
47
- """Whether a diagnostic blocks the lint command."""
48
-
49
46
  WARNING = "warning"
50
47
  ERROR = "error"
51
48
 
52
49
 
53
50
  class ColumnEncoding(StrEnum):
54
- """Coordinate system used by a native diagnostic's one-based column."""
55
-
56
51
  UTF8_BYTES = "utf8-bytes"
57
52
  CODEPOINTS = "codepoints"
58
53
 
59
54
 
60
55
  class RuleCategory(StrEnum):
61
- """Small cross-engine taxonomy used by generated rule directories."""
62
-
63
56
  ARCHITECTURE = "architecture"
64
57
  CORRECTNESS = "correctness"
65
58
  MAINTAINABILITY = "maintainability"
@@ -70,16 +63,12 @@ class RuleCategory(StrEnum):
70
63
 
71
64
 
72
65
  class AutofixPolicy(StrEnum):
73
- """Strongest source mutation a rule can safely offer."""
74
-
75
66
  NONE = "none"
76
67
  SUGGESTION = "suggestion"
77
68
  SAFE = "safe"
78
69
 
79
70
 
80
71
  class ExampleOutcome(StrEnum):
81
- """Expected result when a rule checks one documentation example."""
82
-
83
72
  MATCH = "match"
84
73
  NO_MATCH = "no-match"
85
74
 
@@ -92,8 +81,6 @@ type ExamplePath = str
92
81
 
93
82
  @dataclass(frozen=True, slots=True)
94
83
  class ExampleFile:
95
- """One virtual source file in a rule example."""
96
-
97
84
  path: PurePosixPath
98
85
  source: str = field(repr=False)
99
86
 
@@ -107,14 +94,11 @@ class ExampleFile:
107
94
 
108
95
  @classmethod
109
96
  def python(cls, path: ExamplePath, source: str) -> Self:
110
- """Build a Python example file without leaking path parsing into rules."""
111
97
  return cls(PurePosixPath(path), source)
112
98
 
113
99
 
114
100
  @dataclass(frozen=True, slots=True)
115
101
  class RuleExample:
116
- """A reviewed, executable example; examples are private unless opted in."""
117
-
118
102
  example_id: str
119
103
  outcome: ExampleOutcome
120
104
  files: tuple[ExampleFile, ...]
@@ -164,8 +148,6 @@ class RuleExample:
164
148
 
165
149
  @dataclass(frozen=True, slots=True)
166
150
  class RuleDocumentation:
167
- """Source-authored rule prose and reviewed examples."""
168
-
169
151
  summary: str
170
152
  rationale: str
171
153
  remediation: str
@@ -212,8 +194,6 @@ class RuleDocumentation:
212
194
 
213
195
  @dataclass(frozen=True, slots=True)
214
196
  class NativeRuleSpec:
215
- """Complete native rule record adapted from a rule class and its authored docs."""
216
-
217
197
  engine: str
218
198
  rule_id: str
219
199
  code: str
@@ -239,8 +219,6 @@ class NativeRuleSpec:
239
219
 
240
220
  @dataclass(frozen=True, slots=True)
241
221
  class Diagnostic:
242
- """A single lint finding."""
243
-
244
222
  path: Path
245
223
  line: int
246
224
  col: int
@@ -250,14 +228,11 @@ class Diagnostic:
250
228
  column_encoding: ColumnEncoding = ColumnEncoding.UTF8_BYTES
251
229
 
252
230
  def format(self) -> str:
253
- """Render the finding ruff-compatibly as `path:line:col: CODE message`."""
254
231
  label = "warning: " if self.severity is Severity.WARNING else ""
255
232
  return f"{self.path}:{self.line}:{self.col}: {self.code} {label}{self.message}"
256
233
 
257
234
 
258
235
  class Rule(ABC):
259
- """Base class for a single lint rule."""
260
-
261
236
  id: str
262
237
  code: str
263
238
  description: str
@@ -265,7 +240,6 @@ class Rule(ABC):
265
240
 
266
241
  @abstractmethod
267
242
  def check(self, path: Path, source: str) -> list[Diagnostic]:
268
- """Inspect the given source."""
269
243
  raise NotImplementedError
270
244
 
271
245
  @classmethod
@@ -278,7 +252,6 @@ class Rule(ABC):
278
252
 
279
253
  @classmethod
280
254
  def native_spec(cls) -> NativeRuleSpec | None:
281
- """Adapt source-owned documentation while deriving engine, ID, and code."""
282
255
  authored = cls.documentation
283
256
  if authored is None:
284
257
  return None
@@ -304,16 +277,21 @@ class Rule(ABC):
304
277
 
305
278
  @classmethod
306
279
  def public_examples(cls) -> tuple[RuleExample, ...]:
307
- """Return the rule's explicitly publishable canonical fixtures."""
308
280
  spec = cls.native_spec()
309
281
  return () if spec is None else spec.public_examples
310
282
 
311
283
 
284
+ class ProjectRule(Rule):
285
+ _project_indexes: ProjectIndexSet | None = None
286
+
287
+ def prepare(self, indexes: ProjectIndexSet) -> None:
288
+ self._project_indexes = indexes
289
+
290
+
312
291
  _last_parse: tuple[str, str, ast.Module | None] | None = None
313
292
 
314
293
 
315
294
  def parse_or_none(path: Path, source: str) -> ast.Module | None:
316
- """Parse `source`, memoizing the most recent file so N rules share one parse."""
317
295
  global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
318
296
  path_key = str(path)
319
297
  if _last_parse is not None and _last_parse[0] == path_key and _last_parse[1] is source:
@@ -1,5 +1,3 @@
1
- """One tree traversal per file, shared by every rule that needs node lookups."""
2
-
3
1
  # Breadth-first order and isinstance semantics intentionally match ast.walk because rules rely on first-match order.
4
2
 
5
3
  from __future__ import annotations
@@ -16,7 +14,6 @@ _AST = ast.AST
16
14
 
17
15
 
18
16
  def children(node: ast.AST) -> list[ast.AST]:
19
- """`node`'s direct children, in the same order as `ast.iter_child_nodes`."""
20
17
  out: list[ast.AST] = []
21
18
  for name in node._fields:
22
19
  value: object = getattr(node, name, None)
@@ -28,7 +25,6 @@ def children(node: ast.AST) -> list[ast.AST]:
28
25
 
29
26
 
30
27
  def walk(node: ast.AST) -> Iterator[ast.AST]:
31
- """Yield `node` and every descendant, breadth-first."""
32
28
  queue: list[ast.AST] = [node]
33
29
  i = 0
34
30
  while i < len(queue):
@@ -45,8 +41,6 @@ def walk(node: ast.AST) -> Iterator[ast.AST]:
45
41
 
46
42
  @final
47
43
  class _NodeIndex:
48
- """A module's nodes partitioned by exact class, in breadth-first order."""
49
-
50
44
  __slots__ = ("_buckets", "_flat", "_queries")
51
45
 
52
46
  def __init__(self, tree: ast.AST) -> None:
@@ -73,7 +67,6 @@ class _NodeIndex:
73
67
  self._queries: dict[tuple[type[ast.AST], ...], list[ast.AST]] = {}
74
68
 
75
69
  def query(self, types: tuple[type[ast.AST], ...]) -> list[ast.AST]:
76
- """Return every node matching `isinstance(node, types)`, breadth-first."""
77
70
  hit = self._queries.get(types)
78
71
  if hit is not None:
79
72
  return hit
@@ -98,7 +91,6 @@ _last_index: tuple[ast.AST, _NodeIndex] | None = None
98
91
 
99
92
 
100
93
  def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT]) -> list[NodeT]:
101
- """Index a whole-file tree; use `walk` for subtrees and never mutate indexed nodes."""
102
94
  global _last_index # ruff: ignore[global-statement] — single-slot memo, mirroring `parse_or_none`
103
95
  if _last_index is None or _last_index[0] is not tree:
104
96
  _last_index = (tree, _NodeIndex(tree))