sarj-python-lint 0.43.0__tar.gz → 0.43.2__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.43.0 → sarj_python_lint-0.43.2}/.gitignore +1 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/PKG-INFO +4 -6
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/README.md +3 -5
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/pyproject.toml +1 -1
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/__main__.py +3 -17
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/_secret_names.py +2 -6
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/ratchet.py +8 -71
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rule_base.py +5 -22
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_ast_index.py +7 -66
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_comments.py +34 -144
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_docstrings.py +9 -74
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_first_party.py +11 -110
- sarj_python_lint-0.43.2/src/sarj_python_lint/rules/_logging.py +48 -0
- sarj_python_lint-0.43.2/src/sarj_python_lint/rules/_paths.py +93 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_prose_budget.py +47 -12
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_pytest.py +3 -25
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_registry.py +0 -2
- sarj_python_lint-0.43.2/src/sarj_python_lint/rules/_suppression_comments.py +42 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +10 -42
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/duplicate_test_body.py +61 -37
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +1 -15
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/interaction_only_test.py +3 -8
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/kwonly_same_type_params.py +8 -62
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/mock_without_spec.py +4 -13
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +8 -36
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_comment_cruft.py +19 -75
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_fat_try_blocks.py +3 -17
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +4 -8
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_file_level_suppression.py +5 -11
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_first_party_private_import.py +1 -4
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_fstring_in_log.py +2 -16
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +1 -4
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +3 -9
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_restated_comment.py +1 -3
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +2 -4
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_sequential_await.py +3 -13
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_stdlib_logging.py +2 -7
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_tautological_expect.py +1 -3
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +4 -20
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/over_mocked_test.py +12 -14
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/phase_label_comment.py +1 -4
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +7 -26
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +5 -21
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_library_fake.py +3 -10
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +11 -83
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +23 -16
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +1 -3
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +7 -9
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +18 -17
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +3 -13
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +1 -1
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_str_enum.py +4 -18
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +4 -42
- sarj_python_lint-0.43.2/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +124 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +2 -5
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +1 -6
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/require_port_for_service.py +8 -32
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/restated_test_docstring.py +54 -24
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/single_public_export.py +2 -10
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/stepdown.py +14 -29
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +2 -4
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/tautological_mock_assertion.py +4 -12
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +2 -8
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/trivially_true_assertion.py +4 -14
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/unused_mock_setup.py +3 -7
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/xfail_requires_strict.py +3 -5
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/zero_assertion_test.py +29 -32
- sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_logging.py +0 -77
- sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_paths.py +0 -153
- sarj_python_lint-0.43.0/src/sarj_python_lint/rules/_suppression_comments.py +0 -55
- sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -279
- sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -108
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.43.
|
|
3
|
+
Version: 0.43.2
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -47,8 +47,7 @@ uv tool install sarj-python-lint
|
|
|
47
47
|
|
|
48
48
|
Mined from an AST audit of ~7,500 test functions across two production repos.
|
|
49
49
|
Every one is scoped to test files and carries the false-positive guard that made
|
|
50
|
-
it shippable;
|
|
51
|
-
measured against.
|
|
50
|
+
it shippable; `sarj-python-lint explain` links each rule to its behavioral tests.
|
|
52
51
|
|
|
53
52
|
```yaml
|
|
54
53
|
- id: sarj-mock-without-spec # SARJ040
|
|
@@ -90,8 +89,8 @@ Attribute access (`session._stt`) is out of scope and stays with ruff's
|
|
|
90
89
|
|
|
91
90
|
From a 37,918-comment, nine-repo measurement study. All three are
|
|
92
91
|
deletion-class, so each was validated against pydantic / trio / attrs as well as
|
|
93
|
-
the maintained repos before shipping
|
|
94
|
-
|
|
92
|
+
the maintained repos before shipping, with each false-positive guard encoded in
|
|
93
|
+
the behavioral tests linked by `sarj-python-lint explain`.
|
|
95
94
|
|
|
96
95
|
```yaml
|
|
97
96
|
- id: sarj-no-restated-comment # SARJ049
|
|
@@ -278,7 +277,6 @@ duplicated ruff.
|
|
|
278
277
|
|
|
279
278
|
```yaml
|
|
280
279
|
- id: sarj-prefer-fstring-over-concat # SARJ068
|
|
281
|
-
- id: sarj-prefer-match-pattern-destructuring # SARJ069
|
|
282
280
|
- id: sarj-prefer-or-pattern # SARJ070
|
|
283
281
|
- id: sarj-require-port-for-service # SARJ071
|
|
284
282
|
```
|
|
@@ -29,8 +29,7 @@ uv tool install sarj-python-lint
|
|
|
29
29
|
|
|
30
30
|
Mined from an AST audit of ~7,500 test functions across two production repos.
|
|
31
31
|
Every one is scoped to test files and carries the false-positive guard that made
|
|
32
|
-
it shippable;
|
|
33
|
-
measured against.
|
|
32
|
+
it shippable; `sarj-python-lint explain` links each rule to its behavioral tests.
|
|
34
33
|
|
|
35
34
|
```yaml
|
|
36
35
|
- id: sarj-mock-without-spec # SARJ040
|
|
@@ -72,8 +71,8 @@ Attribute access (`session._stt`) is out of scope and stays with ruff's
|
|
|
72
71
|
|
|
73
72
|
From a 37,918-comment, nine-repo measurement study. All three are
|
|
74
73
|
deletion-class, so each was validated against pydantic / trio / attrs as well as
|
|
75
|
-
the maintained repos before shipping
|
|
76
|
-
|
|
74
|
+
the maintained repos before shipping, with each false-positive guard encoded in
|
|
75
|
+
the behavioral tests linked by `sarj-python-lint explain`.
|
|
77
76
|
|
|
78
77
|
```yaml
|
|
79
78
|
- id: sarj-no-restated-comment # SARJ049
|
|
@@ -260,7 +259,6 @@ duplicated ruff.
|
|
|
260
259
|
|
|
261
260
|
```yaml
|
|
262
261
|
- id: sarj-prefer-fstring-over-concat # SARJ068
|
|
263
|
-
- id: sarj-prefer-match-pattern-destructuring # SARJ069
|
|
264
262
|
- id: sarj-prefer-or-pattern # SARJ070
|
|
265
263
|
- id: sarj-require-port-for-service # SARJ071
|
|
266
264
|
```
|
|
@@ -102,21 +102,13 @@ class _Args(argparse.Namespace):
|
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
def _explain(wanted: str) -> int:
|
|
105
|
-
"""Print a rule's
|
|
106
|
-
|
|
107
|
-
This is the runtime consumer of the one-line module docstring: it is what
|
|
108
|
-
makes an inaccurate summary visible rather than merely unread.
|
|
109
|
-
|
|
110
|
-
"""
|
|
105
|
+
"""Print a rule's description and its derived examples link."""
|
|
111
106
|
key = wanted.strip()
|
|
112
107
|
cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
|
|
113
108
|
if cls is None:
|
|
114
109
|
sys.stderr.write(f"unknown rule: {wanted}\navailable: {', '.join(sorted(REGISTRY))}\n")
|
|
115
110
|
return 2
|
|
116
|
-
|
|
117
|
-
sys.stdout.write(
|
|
118
|
-
f"{cls.code} {cls.id}\n{summary[0] if summary else cls.description}\nexamples: {cls.examples_url()}\n"
|
|
119
|
-
)
|
|
111
|
+
sys.stdout.write(f"{cls.code} {cls.id}\n{cls.description}\nexamples: {cls.examples_url()}\n")
|
|
120
112
|
return 0
|
|
121
113
|
|
|
122
114
|
|
|
@@ -131,13 +123,7 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
|
|
|
131
123
|
|
|
132
124
|
|
|
133
125
|
def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
|
|
134
|
-
"""Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries.
|
|
135
|
-
|
|
136
|
-
`json.loads` returns `Any`, so the shape is narrowed here rather than
|
|
137
|
-
asserted — a hand-edited baseline should degrade to "not baselined" rather
|
|
138
|
-
than crash the run or silently suppress on a malformed entry.
|
|
139
|
-
|
|
140
|
-
"""
|
|
126
|
+
"""Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries."""
|
|
141
127
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
142
128
|
path.read_text(encoding="utf-8")
|
|
143
129
|
)
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
"""Recognize secret-bearing identifiers for SARJ011 and SARJ012.
|
|
2
|
-
|
|
3
|
-
Matching uses whole words and excludes metadata and boolean-state names.
|
|
4
|
-
"""
|
|
1
|
+
"""Recognize secret-bearing identifiers for SARJ011 and SARJ012."""
|
|
5
2
|
|
|
6
3
|
from __future__ import annotations
|
|
7
4
|
|
|
@@ -64,8 +61,7 @@ _INNOCUOUS_WORDS = frozenset(
|
|
|
64
61
|
# A leading predicate makes the identifier a boolean flag, not a credential.
|
|
65
62
|
_FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
|
|
66
63
|
|
|
67
|
-
#
|
|
68
|
-
# snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
|
|
64
|
+
# Split camel case, capitals, and digit runs within each underscore-delimited component.
|
|
69
65
|
_CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
|
70
66
|
_SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
|
|
71
67
|
|
|
@@ -1,41 +1,4 @@
|
|
|
1
|
-
"""Suppression ratchet: count every escape hatch in a tree and let the count only shrink.
|
|
2
|
-
|
|
3
|
-
A suppression comment is a decision to stop a checker from doing its job on one
|
|
4
|
-
line. Individually each is often right; collectively they are the only number
|
|
5
|
-
that says whether the codebase is getting more or less checked, and nothing in
|
|
6
|
-
review shows that number. A ratchet makes it visible and one-directional:
|
|
7
|
-
snapshot today's counts into a baseline, then fail any commit that raises one.
|
|
8
|
-
|
|
9
|
-
Three ceilings apply at once, because each catches a shape the others miss:
|
|
10
|
-
|
|
11
|
-
* **per code** — `# noqa: TID251` growing from 40 to 41 is a real regression
|
|
12
|
-
even when the repo total falls, and a code-blind total lets a cleanup
|
|
13
|
-
elsewhere pay for it.
|
|
14
|
-
* **per package** — a monorepo where one package is well-typed and another is
|
|
15
|
-
not needs separate floors, or the good package's headroom finances the bad
|
|
16
|
-
one's debt.
|
|
17
|
-
* **per file** — a global ceiling on any single file, so new suppressions
|
|
18
|
-
cannot pile into one hot spot while the package total stays flat. Files that
|
|
19
|
-
already exceeded it when the ceiling landed are grandfathered at their
|
|
20
|
-
then-current counts and may only shrink.
|
|
21
|
-
|
|
22
|
-
All four suppression dialects are counted, each under its own key prefix, so
|
|
23
|
-
migrating a suppression from one spelling to another can never hide it:
|
|
24
|
-
|
|
25
|
-
| key | comment form |
|
|
26
|
-
|-------------------------|-------------------------------------------------|
|
|
27
|
-
| `noqa:CODE` | `x = f() # noqa: E501` |
|
|
28
|
-
| `sarj-noqa:CODE` | `x = f() # sarj-noqa: SARJ016 — reason` |
|
|
29
|
-
| `pyright:CODE` | `x = f() # pyright: ignore[reportAny]` |
|
|
30
|
-
| `type-ignore:CODE` | `x = f() # type: ignore[attr-defined]` |
|
|
31
|
-
| `type-ignore` | `x = f() # type: ignore` (bare) |
|
|
32
|
-
| `file-noqa:CODE` | `# ruff: noqa: E501` at file level |
|
|
33
|
-
| `file-noqa:<blanket>` | `# ruff: noqa` at file level, no codes |
|
|
34
|
-
| `file-pyright:RULE` | `# pyright: reportAny=false` file header |
|
|
35
|
-
|
|
36
|
-
Every occurrence on a line counts, not just the first: `f() # noqa: A # noqa: B`
|
|
37
|
-
is two suppressions.
|
|
38
|
-
"""
|
|
1
|
+
"""Suppression ratchet: count every escape hatch in a tree and let the count only shrink."""
|
|
39
2
|
|
|
40
3
|
from __future__ import annotations
|
|
41
4
|
|
|
@@ -100,7 +63,7 @@ class Measurement:
|
|
|
100
63
|
|
|
101
64
|
@dataclass(frozen=True, slots=True)
|
|
102
65
|
class Baseline:
|
|
103
|
-
"""
|
|
66
|
+
"""Ceilings by code, package, and file so cleanup in one dimension cannot finance debt in another."""
|
|
104
67
|
|
|
105
68
|
codes: dict[str, int] = field(default_factory=dict[str, int])
|
|
106
69
|
packages: dict[str, int] = field(default_factory=dict[str, int])
|
|
@@ -148,13 +111,7 @@ def measure(
|
|
|
148
111
|
excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
|
|
149
112
|
excluded_subtrees: Iterable[str] = (),
|
|
150
113
|
) -> Measurement:
|
|
151
|
-
"""Count every suppression under `root`, bucketed by code, package and file.
|
|
152
|
-
|
|
153
|
-
`packages` are paths relative to `root`; a package that does not exist
|
|
154
|
-
contributes 0 rather than raising, so a baseline outliving a rename fails
|
|
155
|
-
loudly on the package's disappearance rather than crashing.
|
|
156
|
-
|
|
157
|
-
"""
|
|
114
|
+
"""Count every suppression under `root`, bucketed by code, package and file."""
|
|
158
115
|
codes: Counter[str] = Counter()
|
|
159
116
|
package_counts: Counter[str] = Counter()
|
|
160
117
|
files: dict[str, int] = {}
|
|
@@ -214,13 +171,8 @@ def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, tupl
|
|
|
214
171
|
|
|
215
172
|
|
|
216
173
|
def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
|
|
217
|
-
"""Build the baseline that `--update` would write from a measurement.
|
|
218
|
-
|
|
219
|
-
Per-file grandfathering is preserved only where it is still needed: a file
|
|
220
|
-
that dropped to or below the global ceiling loses its exception, so the
|
|
221
|
-
allowance cannot outlive the debt it was granted for.
|
|
222
|
-
|
|
223
|
-
"""
|
|
174
|
+
"""Build the baseline that `--update` would write from a measurement."""
|
|
175
|
+
# Recompute exceptions so a file loses grandfathering as soon as it reaches the ceiling.
|
|
224
176
|
exceptions = {path: n for path, n in measurement.files.items() if n > baseline.per_file_ceiling}
|
|
225
177
|
return Baseline(
|
|
226
178
|
codes=dict(sorted(measurement.codes.items())),
|
|
@@ -231,12 +183,7 @@ def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
|
|
|
231
183
|
|
|
232
184
|
|
|
233
185
|
def load_baseline(path: Path) -> Baseline:
|
|
234
|
-
"""Read a baseline JSON file, ignoring entries of the wrong shape.
|
|
235
|
-
|
|
236
|
-
A hand-edited baseline degrades to "not baselined" (ceiling 0) for the
|
|
237
|
-
malformed entry rather than crashing the gate or silently passing.
|
|
238
|
-
|
|
239
|
-
"""
|
|
186
|
+
"""Read a baseline JSON file, ignoring entries of the wrong shape."""
|
|
240
187
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
|
|
241
188
|
path.read_text(encoding="utf-8")
|
|
242
189
|
)
|
|
@@ -281,12 +228,7 @@ def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
|
|
|
281
228
|
|
|
282
229
|
|
|
283
230
|
def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES) -> list[str]:
|
|
284
|
-
"""List the top-level directories under `root` that contain Python files.
|
|
285
|
-
|
|
286
|
-
Used when neither `--package` nor a baseline names the packages, so the
|
|
287
|
-
first run needs no configuration.
|
|
288
|
-
|
|
289
|
-
"""
|
|
231
|
+
"""List the top-level directories under `root` that contain Python files."""
|
|
290
232
|
return sorted(
|
|
291
233
|
name
|
|
292
234
|
for child in root.iterdir()
|
|
@@ -326,12 +268,7 @@ def _read(path: Path) -> str:
|
|
|
326
268
|
|
|
327
269
|
|
|
328
270
|
def _count_line(line: str, counts: Counter[str]) -> None:
|
|
329
|
-
"""Add one line's suppressions to `counts`.
|
|
330
|
-
|
|
331
|
-
The two file-level forms return early: a `# ruff: noqa: X` header is not
|
|
332
|
-
also an inline `noqa`, and double-counting it would make the two key
|
|
333
|
-
prefixes overlap.
|
|
334
|
-
"""
|
|
271
|
+
"""Add one line's suppressions to `counts`."""
|
|
335
272
|
file_noqa = _FILE_NOQA_RE.match(line)
|
|
336
273
|
if file_noqa:
|
|
337
274
|
listed = file_noqa.group(1)
|
|
@@ -20,13 +20,7 @@ REPO_BLOB: Final = "https://github.com/sarj-ai/standards/blob/main"
|
|
|
20
20
|
TESTS_DIR: Final = "packages/python/tests/rules"
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
# # sarj-noqa: SARJ001 — reason
|
|
25
|
-
# # sarj-noqa: SARJ001, SARJ002 — reason
|
|
26
|
-
# We deliberately do NOT reuse ruff's own suppression comment because ruff
|
|
27
|
-
# aggressively cleans unrecognized codes (RUF100/RUF102) even with `external`
|
|
28
|
-
# set, which silently breaks suppressions across runs. A distinct prefix
|
|
29
|
-
# (sarj-noqa) shares no syntax with ruff, so the two never collide.
|
|
23
|
+
# Keep SARJ suppressions separate because Ruff removes unknown `noqa` codes.
|
|
30
24
|
_SARJ_NOQA_RE = re.compile(
|
|
31
25
|
r"#\s*sarj-noqa(?::\s*([A-Za-z0-9_, ]+))?",
|
|
32
26
|
re.IGNORECASE,
|
|
@@ -34,11 +28,7 @@ _SARJ_NOQA_RE = re.compile(
|
|
|
34
28
|
|
|
35
29
|
|
|
36
30
|
def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
|
|
37
|
-
"""Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment.
|
|
38
|
-
|
|
39
|
-
`line` is 1-based to match Diagnostic.line.
|
|
40
|
-
|
|
41
|
-
"""
|
|
31
|
+
"""Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment."""
|
|
42
32
|
if line < 1 or line > len(source_lines):
|
|
43
33
|
return False
|
|
44
34
|
text = source_lines[line - 1]
|
|
@@ -47,7 +37,7 @@ def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
|
|
|
47
37
|
return False
|
|
48
38
|
codes_str = m.group(1)
|
|
49
39
|
if not codes_str:
|
|
50
|
-
#
|
|
40
|
+
# A bare sarj-noqa intentionally suppresses every SARJ code on its line.
|
|
51
41
|
return True
|
|
52
42
|
codes = {val.upper() for c in codes_str.split(",") if (val := c.strip())}
|
|
53
43
|
return code.upper() in codes
|
|
@@ -78,14 +68,7 @@ class Diagnostic:
|
|
|
78
68
|
|
|
79
69
|
|
|
80
70
|
class Rule(ABC):
|
|
81
|
-
"""Base class for a single lint rule.
|
|
82
|
-
|
|
83
|
-
Subclasses set `id` (kebab-case) and `code` (e.g. SARJ001) as class
|
|
84
|
-
attributes and implement `check(path, source) -> list[Diagnostic]`.
|
|
85
|
-
|
|
86
|
-
A rule documents itself with a concise summary and a derived link to its
|
|
87
|
-
executable examples. The metadata tests require that paired test module.
|
|
88
|
-
"""
|
|
71
|
+
"""Base class for a single lint rule."""
|
|
89
72
|
|
|
90
73
|
id: str
|
|
91
74
|
code: str
|
|
@@ -93,7 +76,7 @@ class Rule(ABC):
|
|
|
93
76
|
|
|
94
77
|
@abstractmethod
|
|
95
78
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
96
|
-
"""Inspect the given source.
|
|
79
|
+
"""Inspect the given source."""
|
|
97
80
|
raise NotImplementedError
|
|
98
81
|
|
|
99
82
|
@classmethod
|
{sarj_python_lint-0.43.0 → sarj_python_lint-0.43.2}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
@@ -1,45 +1,6 @@
|
|
|
1
|
-
"""One tree traversal per file, shared by every rule that needs node lookups.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
helpers (`iter_child_nodes` / `iter_fields`) accounting for ~60% of all rule
|
|
5
|
-
time. The cause is structural rather than any one rule's fault: the dominant
|
|
6
|
-
shape of a rule is
|
|
7
|
-
|
|
8
|
-
for node in ast.walk(tree):
|
|
9
|
-
if isinstance(node, ast.Something):
|
|
10
|
-
...
|
|
11
|
-
|
|
12
|
-
so a file with N nodes and R rules asking that question pays R full traversals,
|
|
13
|
-
each one re-deriving the same partition of the tree by node type.
|
|
14
|
-
|
|
15
|
-
`nodes()` derives that partition **once per file** and answers every subsequent
|
|
16
|
-
type query from it. The index is memoized in a single slot keyed on tree
|
|
17
|
-
identity, mirroring `parse_or_none`: the CLI iterates files on the outer loop
|
|
18
|
-
and rules on the inner one, so one slot is all that is ever live. The slot holds
|
|
19
|
-
a strong reference to the tree, so a recycled `id()` can never alias a stale
|
|
20
|
-
index.
|
|
21
|
-
|
|
22
|
-
Two invariants make the substitution finding-preserving, not merely faster:
|
|
23
|
-
|
|
24
|
-
* **Order.** The index is built by breadth-first traversal, the same order
|
|
25
|
-
`ast.walk` yields, and every query preserves it. `nodes(tree, T)` is therefore
|
|
26
|
-
elementwise equal to `[n for n in ast.walk(tree) if isinstance(n, T)]`, so a
|
|
27
|
-
rule that takes the *first* match, or reports in discovery order, is
|
|
28
|
-
unaffected.
|
|
29
|
-
* **Subclasses.** Queries match by `isinstance`, not by exact type: the buckets
|
|
30
|
-
are keyed on exact class, and a query resolves to the set of present classes
|
|
31
|
-
that are subclasses of the requested types. Asking for `ast.stmt` still
|
|
32
|
-
returns every statement.
|
|
33
|
-
|
|
34
|
-
`walk()` is the same breadth-first order for the cases the index cannot serve —
|
|
35
|
-
traversing a *subtree* rather than the module, where containment is not
|
|
36
|
-
something a whole-module partition can answer.
|
|
37
|
-
|
|
38
|
-
No rule may mutate the tree. That was already required — every rule shares one
|
|
39
|
-
memoized parse, and `duplicate_test_body` deep-copies before normalizing — and
|
|
40
|
-
the index inherits the requirement, since it holds references into the tree it
|
|
41
|
-
was built from.
|
|
42
|
-
"""
|
|
1
|
+
"""One tree traversal per file, shared by every rule that needs node lookups."""
|
|
2
|
+
|
|
3
|
+
# Breadth-first order and isinstance semantics intentionally match ast.walk because rules rely on first-match order.
|
|
43
4
|
|
|
44
5
|
from __future__ import annotations
|
|
45
6
|
|
|
@@ -55,13 +16,7 @@ _AST = ast.AST
|
|
|
55
16
|
|
|
56
17
|
|
|
57
18
|
def children(node: ast.AST) -> list[ast.AST]:
|
|
58
|
-
"""`node`'s direct children, in the same order as `ast.iter_child_nodes`.
|
|
59
|
-
|
|
60
|
-
A list rather than a generator: every caller either iterates it once or
|
|
61
|
-
extends a stack with it, and the hand-rolled walkers in this package call
|
|
62
|
-
this once per node, where the generator's setup cost dominates the work.
|
|
63
|
-
|
|
64
|
-
"""
|
|
19
|
+
"""`node`'s direct children, in the same order as `ast.iter_child_nodes`."""
|
|
65
20
|
out: list[ast.AST] = []
|
|
66
21
|
for name in node._fields:
|
|
67
22
|
value: object = getattr(node, name, None)
|
|
@@ -73,12 +28,7 @@ def children(node: ast.AST) -> list[ast.AST]:
|
|
|
73
28
|
|
|
74
29
|
|
|
75
30
|
def walk(node: ast.AST) -> Iterator[ast.AST]:
|
|
76
|
-
"""Yield `node` and every descendant, breadth-first.
|
|
77
|
-
|
|
78
|
-
Identical in order to `ast.walk`, but reads each node's fields directly
|
|
79
|
-
instead of routing every node through two intermediate generators.
|
|
80
|
-
|
|
81
|
-
"""
|
|
31
|
+
"""Yield `node` and every descendant, breadth-first."""
|
|
82
32
|
queue: list[ast.AST] = [node]
|
|
83
33
|
i = 0
|
|
84
34
|
while i < len(queue):
|
|
@@ -144,22 +94,13 @@ class _NodeIndex:
|
|
|
144
94
|
|
|
145
95
|
|
|
146
96
|
_last_index: tuple[ast.AST, _NodeIndex] | None = None
|
|
97
|
+
# The strong tree reference prevents a recycled object id from ever reusing a stale index.
|
|
147
98
|
|
|
148
99
|
|
|
149
100
|
def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT]) -> list[NodeT]:
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
A drop-in, memoized replacement for the ubiquitous
|
|
153
|
-
`[n for n in ast.walk(tree) if isinstance(n, types)]`. Pass the *module*
|
|
154
|
-
tree: the index is per-file, so calling this with a subtree builds a fresh
|
|
155
|
-
index and evicts the module's. Use `walk` for subtrees.
|
|
156
|
-
|
|
157
|
-
"""
|
|
101
|
+
"""Index a whole-file tree; use `walk` for subtrees and never mutate indexed nodes."""
|
|
158
102
|
global _last_index # ruff: ignore[global-statement] — single-slot memo, mirroring `parse_or_none`
|
|
159
103
|
if _last_index is None or _last_index[0] is not tree:
|
|
160
104
|
_last_index = (tree, _NodeIndex(tree))
|
|
161
105
|
# The `isinstance` pass is what narrows `list[ast.AST]` to `list[NodeT]`
|
|
162
|
-
# honestly, rather than asserting it with a `cast` the house style bans. It
|
|
163
|
-
# runs over the matches only — never the tree — and every element passes by
|
|
164
|
-
# construction, so it costs one type check per node actually returned.
|
|
165
106
|
return [node for node in _last_index[1].query(types) if isinstance(node, types)]
|