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.
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__init__.py +0 -2
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/__main__.py +39 -25
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_filesystem.py +0 -3
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_ratchet_cli.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_secret_names.py +0 -6
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/_version.py +0 -2
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/ratchet.py +10 -26
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rule_base.py +9 -31
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_ast_index.py +0 -8
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_comments.py +25 -30
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_docstrings.py +77 -13
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_fastapi.py +5 -21
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_first_party.py +4 -10
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_imports.py +0 -7
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_logging.py +0 -3
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_paths.py +0 -11
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/_project_index.py +408 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -12
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_pytest.py +0 -4
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_registry.py +18 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_sql.py +0 -6
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -6
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -4
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -11
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -7
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicate_test_body.py +20 -40
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -7
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +11 -11
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +18 -20
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -5
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/mock_without_spec.py +9 -28
- {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
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_comment_cruft.py +8 -24
- {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
- {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
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -8
- {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
- {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
- {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
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -19
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -6
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -14
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -7
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_restated_comment.py +2 -12
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -11
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_select_star.py +0 -6
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -12
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_tautological_expect.py +15 -30
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -5
- {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
- 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.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +10 -12
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/over_mocked_test.py +26 -53
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -11
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -23
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -9
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -21
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -20
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +23 -49
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -33
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +5 -11
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +9 -15
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -18
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -10
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_str_enum.py +8 -42
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -6
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +5 -26
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -7
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -10
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -7
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +138 -0
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/preserve_enum_types.py +131 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +9 -10
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +5 -24
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +4 -46
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -10
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -6
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/require_port_for_service.py +9 -32
- {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
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +166 -0
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/require_validated_row_factory.py +159 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -8
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/source_coupled_test.py +12 -12
- sarj_python_lint-0.73.0/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +202 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/stepdown.py +0 -15
- {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
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -5
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +19 -36
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -8
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -22
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/.gitignore +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/LICENSE +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/README.md +0 -0
- {sarj_python_lint-0.71.0 → sarj_python_lint-0.73.0}/src/sarj_python_lint/py.typed +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
84
|
-
for
|
|
82
|
+
loaded: dict[Path, str] = {}
|
|
83
|
+
for path in expanded:
|
|
85
84
|
try:
|
|
86
|
-
|
|
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
|
-
[
|
|
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
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
"""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
|
|
@@ -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:
|
{sarj_python_lint-0.71.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))
|