sarj-python-lint 0.72.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.
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__init__.py +0 -2
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__main__.py +27 -21
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_filesystem.py +0 -3
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_ratchet_cli.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_secret_names.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_version.py +0 -2
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/ratchet.py +10 -26
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rule_base.py +0 -34
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_ast_index.py +0 -8
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_comments.py +25 -30
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_docstrings.py +77 -13
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_fastapi.py +5 -21
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_first_party.py +0 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_imports.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_logging.py +0 -3
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_paths.py +0 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_project_index.py +62 -20
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -12
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_pytest.py +0 -4
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_registry.py +6 -0
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_sql.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -4
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicate_test_body.py +20 -40
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +11 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +18 -20
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -13
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/mock_without_spec.py +9 -28
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_comment_cruft.py +8 -24
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -9
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +10 -14
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -8
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -8
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -12
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -9
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -19
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -14
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -12
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_select_star.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -41
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -12
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -21
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_tautological_expect.py +15 -30
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -5
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/no_unnecessary_docstring.py +620 -0
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/no_vague_suppression_description.py +107 -0
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +10 -12
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/over_mocked_test.py +26 -53
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -16
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -23
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -9
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -21
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -20
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +23 -49
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -33
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +24 -34
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +5 -11
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +9 -15
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -18
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -10
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_str_enum.py +8 -42
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +5 -26
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -10
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +5 -10
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +9 -10
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +5 -24
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +4 -46
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -10
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -6
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -19
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_port_for_service.py +9 -32
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +22 -14
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_validated_row_factory.py +9 -9
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -8
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/source_coupled_test.py +12 -12
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/stepdown.py +0 -15
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -7
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -5
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +19 -36
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -8
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -22
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/.gitignore +0 -0
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/LICENSE +0 -0
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/README.md +0 -0
- {sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.72.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.
|
|
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,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
|
|
@@ -99,7 +97,11 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
|
|
|
99
97
|
diags.extend(
|
|
100
98
|
diagnostic
|
|
101
99
|
for diagnostic in deduplicate_diagnostics(
|
|
102
|
-
[
|
|
100
|
+
[
|
|
101
|
+
diagnostic
|
|
102
|
+
for diagnostic in raw
|
|
103
|
+
if diagnostic.code == "SARJ419" or not is_suppressed(source_lines, diagnostic.line, diagnostic.code)
|
|
104
|
+
],
|
|
103
105
|
source=source,
|
|
104
106
|
)
|
|
105
107
|
)
|
|
@@ -113,7 +115,6 @@ def analyze(
|
|
|
113
115
|
baseline: Path | None = None,
|
|
114
116
|
root: Path | None = None,
|
|
115
117
|
) -> list[Diagnostic]:
|
|
116
|
-
"""Return native diagnostics without rendering CLI output."""
|
|
117
118
|
diagnostics = _check(rule_ids, paths)
|
|
118
119
|
return diagnostics if baseline is None else _apply_baseline(diagnostics, _read_baseline(baseline), root=root)
|
|
119
120
|
|
|
@@ -121,13 +122,23 @@ def analyze(
|
|
|
121
122
|
_DIAGNOSTIC_PRECEDENCE = MappingProxyType(
|
|
122
123
|
{
|
|
123
124
|
"SARJ003": frozenset({"SARJ080"}),
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
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"}),
|
|
127
133
|
"SARJ093": frozenset({"SARJ034"}),
|
|
134
|
+
"SARJ099": frozenset({"SARJ420"}),
|
|
128
135
|
}
|
|
129
136
|
)
|
|
130
137
|
|
|
138
|
+
_DOCSTRING_PRECEDENCE_CODES = frozenset(
|
|
139
|
+
{"SARJ050", "SARJ084", "SARJ085", "SARJ086", "SARJ087", "SARJ088", "SARJ091", "SARJ092", "SARJ099"}
|
|
140
|
+
)
|
|
141
|
+
|
|
131
142
|
|
|
132
143
|
class _OwnerLocation(NamedTuple):
|
|
133
144
|
line: int
|
|
@@ -135,9 +146,10 @@ class _OwnerLocation(NamedTuple):
|
|
|
135
146
|
|
|
136
147
|
|
|
137
148
|
def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
|
|
138
|
-
"""Keep the most specific remediation at a source location."""
|
|
139
149
|
codes = frozenset(diagnostic.code for diagnostic in diags)
|
|
140
|
-
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
|
+
)
|
|
141
153
|
docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
|
|
142
154
|
needs_signature_owners = "SARJ093" in codes and "SARJ034" in codes
|
|
143
155
|
signature_owners = (
|
|
@@ -183,31 +195,29 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
|
|
|
183
195
|
]
|
|
184
196
|
|
|
185
197
|
|
|
186
|
-
def _function_signature_owner_locations(source: str) -> dict[int,
|
|
187
|
-
"""Map signature lines to their function opening for cross-rule precedence."""
|
|
198
|
+
def _function_signature_owner_locations(source: str) -> dict[int, _OwnerLocation]:
|
|
188
199
|
try:
|
|
189
200
|
tree = ast.parse(source)
|
|
190
201
|
except SyntaxError:
|
|
191
202
|
return {}
|
|
192
|
-
owners: dict[int,
|
|
203
|
+
owners: dict[int, _OwnerLocation] = {}
|
|
193
204
|
for node in ast.walk(tree):
|
|
194
205
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
195
206
|
continue
|
|
196
207
|
body_line = node.body[0].lineno if node.body else node.lineno + 1
|
|
197
208
|
end_line = max(node.lineno, body_line - 1)
|
|
198
|
-
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)))
|
|
199
210
|
return owners
|
|
200
211
|
|
|
201
212
|
|
|
202
|
-
def _docstring_owner_locations(source: str) -> dict[int,
|
|
203
|
-
"""Map every physical docstring line to the opening expression that owns it."""
|
|
213
|
+
def _docstring_owner_locations(source: str) -> dict[int, _OwnerLocation]:
|
|
204
214
|
try:
|
|
205
215
|
tree = ast.parse(source)
|
|
206
216
|
except SyntaxError:
|
|
207
217
|
tree = None
|
|
208
218
|
if tree is None:
|
|
209
219
|
return {}
|
|
210
|
-
owners: dict[int,
|
|
220
|
+
owners: dict[int, _OwnerLocation] = {}
|
|
211
221
|
for node in ast.walk(tree):
|
|
212
222
|
if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) or not node.body:
|
|
213
223
|
continue
|
|
@@ -218,7 +228,7 @@ def _docstring_owner_locations(source: str) -> dict[int, tuple[int, int]]:
|
|
|
218
228
|
and isinstance(expression.value.value, str)
|
|
219
229
|
):
|
|
220
230
|
continue
|
|
221
|
-
location = (expression.lineno, expression.col_offset + 1)
|
|
231
|
+
location = _OwnerLocation(expression.lineno, expression.col_offset + 1)
|
|
222
232
|
lines = range(expression.lineno, (expression.end_lineno or expression.lineno) + 1)
|
|
223
233
|
owners.update(dict.fromkeys(lines, location))
|
|
224
234
|
return owners
|
|
@@ -245,7 +255,6 @@ class _Args(argparse.Namespace):
|
|
|
245
255
|
|
|
246
256
|
|
|
247
257
|
def _explain(wanted: str) -> int:
|
|
248
|
-
"""Print a rule's description and its derived examples link."""
|
|
249
258
|
key = wanted.strip()
|
|
250
259
|
cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
|
|
251
260
|
if cls is None:
|
|
@@ -267,7 +276,6 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
|
|
|
267
276
|
|
|
268
277
|
|
|
269
278
|
def _baseline_path(path: Path, *, root: Path | None = None) -> str:
|
|
270
|
-
"""Make baselines portable when a caller supplies repository-absolute paths."""
|
|
271
279
|
try:
|
|
272
280
|
return path.resolve().relative_to((Path.cwd() if root is None else root).resolve()).as_posix()
|
|
273
281
|
except ValueError:
|
|
@@ -275,7 +283,6 @@ def _baseline_path(path: Path, *, root: Path | None = None) -> str:
|
|
|
275
283
|
|
|
276
284
|
|
|
277
285
|
def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
|
|
278
|
-
"""Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries."""
|
|
279
286
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
280
287
|
path.read_text(encoding="utf-8")
|
|
281
288
|
)
|
|
@@ -299,7 +306,6 @@ def _apply_baseline(
|
|
|
299
306
|
*,
|
|
300
307
|
root: Path | None = None,
|
|
301
308
|
) -> list[Diagnostic]:
|
|
302
|
-
"""Suppress up to the baselined count per (path, code); excess diags survive."""
|
|
303
309
|
seen: Counter[tuple[str, str]] = Counter()
|
|
304
310
|
out: list[Diagnostic] = []
|
|
305
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
|
-
"""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,
|
|
168
|
-
|
|
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
|
|
@@ -30,7 +28,6 @@ _SARJ_NOQA_RE = re.compile(
|
|
|
30
28
|
|
|
31
29
|
|
|
32
30
|
def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
|
|
33
|
-
"""Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment."""
|
|
34
31
|
if line < 1 or line > len(source_lines):
|
|
35
32
|
return False
|
|
36
33
|
text = source_lines[line - 1]
|
|
@@ -46,22 +43,16 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
|
|
|
46
43
|
|
|
47
44
|
|
|
48
45
|
class Severity(StrEnum):
|
|
49
|
-
"""Whether a diagnostic blocks the lint command."""
|
|
50
|
-
|
|
51
46
|
WARNING = "warning"
|
|
52
47
|
ERROR = "error"
|
|
53
48
|
|
|
54
49
|
|
|
55
50
|
class ColumnEncoding(StrEnum):
|
|
56
|
-
"""Coordinate system used by a native diagnostic's one-based column."""
|
|
57
|
-
|
|
58
51
|
UTF8_BYTES = "utf8-bytes"
|
|
59
52
|
CODEPOINTS = "codepoints"
|
|
60
53
|
|
|
61
54
|
|
|
62
55
|
class RuleCategory(StrEnum):
|
|
63
|
-
"""Small cross-engine taxonomy used by generated rule directories."""
|
|
64
|
-
|
|
65
56
|
ARCHITECTURE = "architecture"
|
|
66
57
|
CORRECTNESS = "correctness"
|
|
67
58
|
MAINTAINABILITY = "maintainability"
|
|
@@ -72,16 +63,12 @@ class RuleCategory(StrEnum):
|
|
|
72
63
|
|
|
73
64
|
|
|
74
65
|
class AutofixPolicy(StrEnum):
|
|
75
|
-
"""Strongest source mutation a rule can safely offer."""
|
|
76
|
-
|
|
77
66
|
NONE = "none"
|
|
78
67
|
SUGGESTION = "suggestion"
|
|
79
68
|
SAFE = "safe"
|
|
80
69
|
|
|
81
70
|
|
|
82
71
|
class ExampleOutcome(StrEnum):
|
|
83
|
-
"""Expected result when a rule checks one documentation example."""
|
|
84
|
-
|
|
85
72
|
MATCH = "match"
|
|
86
73
|
NO_MATCH = "no-match"
|
|
87
74
|
|
|
@@ -94,8 +81,6 @@ type ExamplePath = str
|
|
|
94
81
|
|
|
95
82
|
@dataclass(frozen=True, slots=True)
|
|
96
83
|
class ExampleFile:
|
|
97
|
-
"""One virtual source file in a rule example."""
|
|
98
|
-
|
|
99
84
|
path: PurePosixPath
|
|
100
85
|
source: str = field(repr=False)
|
|
101
86
|
|
|
@@ -109,14 +94,11 @@ class ExampleFile:
|
|
|
109
94
|
|
|
110
95
|
@classmethod
|
|
111
96
|
def python(cls, path: ExamplePath, source: str) -> Self:
|
|
112
|
-
"""Build a Python example file without leaking path parsing into rules."""
|
|
113
97
|
return cls(PurePosixPath(path), source)
|
|
114
98
|
|
|
115
99
|
|
|
116
100
|
@dataclass(frozen=True, slots=True)
|
|
117
101
|
class RuleExample:
|
|
118
|
-
"""A reviewed, executable example; examples are private unless opted in."""
|
|
119
|
-
|
|
120
102
|
example_id: str
|
|
121
103
|
outcome: ExampleOutcome
|
|
122
104
|
files: tuple[ExampleFile, ...]
|
|
@@ -166,8 +148,6 @@ class RuleExample:
|
|
|
166
148
|
|
|
167
149
|
@dataclass(frozen=True, slots=True)
|
|
168
150
|
class RuleDocumentation:
|
|
169
|
-
"""Source-authored rule prose and reviewed examples."""
|
|
170
|
-
|
|
171
151
|
summary: str
|
|
172
152
|
rationale: str
|
|
173
153
|
remediation: str
|
|
@@ -214,8 +194,6 @@ class RuleDocumentation:
|
|
|
214
194
|
|
|
215
195
|
@dataclass(frozen=True, slots=True)
|
|
216
196
|
class NativeRuleSpec:
|
|
217
|
-
"""Complete native rule record adapted from a rule class and its authored docs."""
|
|
218
|
-
|
|
219
197
|
engine: str
|
|
220
198
|
rule_id: str
|
|
221
199
|
code: str
|
|
@@ -241,8 +219,6 @@ class NativeRuleSpec:
|
|
|
241
219
|
|
|
242
220
|
@dataclass(frozen=True, slots=True)
|
|
243
221
|
class Diagnostic:
|
|
244
|
-
"""A single lint finding."""
|
|
245
|
-
|
|
246
222
|
path: Path
|
|
247
223
|
line: int
|
|
248
224
|
col: int
|
|
@@ -252,14 +228,11 @@ class Diagnostic:
|
|
|
252
228
|
column_encoding: ColumnEncoding = ColumnEncoding.UTF8_BYTES
|
|
253
229
|
|
|
254
230
|
def format(self) -> str:
|
|
255
|
-
"""Render the finding ruff-compatibly as `path:line:col: CODE message`."""
|
|
256
231
|
label = "warning: " if self.severity is Severity.WARNING else ""
|
|
257
232
|
return f"{self.path}:{self.line}:{self.col}: {self.code} {label}{self.message}"
|
|
258
233
|
|
|
259
234
|
|
|
260
235
|
class Rule(ABC):
|
|
261
|
-
"""Base class for a single lint rule."""
|
|
262
|
-
|
|
263
236
|
id: str
|
|
264
237
|
code: str
|
|
265
238
|
description: str
|
|
@@ -267,7 +240,6 @@ class Rule(ABC):
|
|
|
267
240
|
|
|
268
241
|
@abstractmethod
|
|
269
242
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
270
|
-
"""Inspect the given source."""
|
|
271
243
|
raise NotImplementedError
|
|
272
244
|
|
|
273
245
|
@classmethod
|
|
@@ -280,7 +252,6 @@ class Rule(ABC):
|
|
|
280
252
|
|
|
281
253
|
@classmethod
|
|
282
254
|
def native_spec(cls) -> NativeRuleSpec | None:
|
|
283
|
-
"""Adapt source-owned documentation while deriving engine, ID, and code."""
|
|
284
255
|
authored = cls.documentation
|
|
285
256
|
if authored is None:
|
|
286
257
|
return None
|
|
@@ -306,18 +277,14 @@ class Rule(ABC):
|
|
|
306
277
|
|
|
307
278
|
@classmethod
|
|
308
279
|
def public_examples(cls) -> tuple[RuleExample, ...]:
|
|
309
|
-
"""Return the rule's explicitly publishable canonical fixtures."""
|
|
310
280
|
spec = cls.native_spec()
|
|
311
281
|
return () if spec is None else spec.public_examples
|
|
312
282
|
|
|
313
283
|
|
|
314
284
|
class ProjectRule(Rule):
|
|
315
|
-
"""A rule that may resolve first-party symbols prepared once per CLI run."""
|
|
316
|
-
|
|
317
285
|
_project_indexes: ProjectIndexSet | None = None
|
|
318
286
|
|
|
319
287
|
def prepare(self, indexes: ProjectIndexSet) -> None:
|
|
320
|
-
"""Attach immutable project symbols before checking the selected files."""
|
|
321
288
|
self._project_indexes = indexes
|
|
322
289
|
|
|
323
290
|
|
|
@@ -325,7 +292,6 @@ _last_parse: tuple[str, str, ast.Module | None] | None = None
|
|
|
325
292
|
|
|
326
293
|
|
|
327
294
|
def parse_or_none(path: Path, source: str) -> ast.Module | None:
|
|
328
|
-
"""Parse `source`, memoizing the most recent file so N rules share one parse."""
|
|
329
295
|
global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
|
|
330
296
|
path_key = str(path)
|
|
331
297
|
if _last_parse is not None and _last_parse[0] == path_key and _last_parse[1] is source:
|
{sarj_python_lint-0.72.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
@@ -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))
|