sarj-python-lint 0.32.0__tar.gz → 0.34.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.32.0 → sarj_python_lint-0.34.0}/PKG-INFO +60 -17
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/README.md +59 -16
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/__main__.py +32 -9
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/_ratchet_cli.py +3 -18
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/_secret_names.py +2 -19
- sarj_python_lint-0.34.0/src/sarj_python_lint/_version.py +9 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/ratchet.py +10 -74
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rule_base.py +43 -16
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_ast_index.py +1 -16
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_comments.py +5 -59
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/_docstrings.py +285 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_first_party.py +129 -28
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_logging.py +0 -3
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_paths.py +1 -21
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_pytest.py +0 -6
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_registry.py +10 -2
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_sql.py +8 -19
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -4
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +23 -333
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +94 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/duplicate_test_body.py +405 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +107 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +7 -64
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +15 -135
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/interaction_only_test.py +19 -266
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +185 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +69 -189
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/mock_without_spec.py +15 -235
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +9 -62
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_comment_cruft.py +19 -250
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +6 -35
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +10 -147
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +85 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +8 -75
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_first_party_private_import.py +171 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +14 -158
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +89 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +459 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +8 -84
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_offset_pagination.py +82 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -74
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +4 -20
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +99 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +150 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_restated_comment.py +71 -115
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_secret_in_log.py +6 -60
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_select_star.py +6 -36
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +39 -378
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_sequential_await.py +15 -138
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +8 -77
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +12 -101
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_tautological_expect.py +248 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +8 -52
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/over_mocked_test.py +614 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +7 -81
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_class_row.py +5 -35
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -95
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +311 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_library_fake.py +18 -236
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +18 -251
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +289 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +360 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +28 -263
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +15 -163
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +4 -34
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_or_pattern.py +165 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +17 -256
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +6 -36
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_str_enum.py +26 -296
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +67 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +103 -133
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +5 -20
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +6 -30
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +6 -31
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +13 -150
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/redundant_class_docstring.py +116 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/redundant_docstring.py +136 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/require_port_for_service.py +400 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/single_public_export.py +6 -60
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +7 -55
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/stepdown.py +55 -148
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +86 -0
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +412 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +7 -65
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/trailing_value_narration.py +5 -57
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/trivially_true_assertion.py +356 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/unused_mock_setup.py +18 -210
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +8 -86
- sarj_python_lint-0.34.0/src/sarj_python_lint/rules/zero_assertion_test.py +355 -0
- sarj_python_lint-0.32.0/src/sarj_python_lint/_version.py +0 -20
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/duplicate_test_body.py +0 -711
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -202
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -150
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -185
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -145
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -177
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_offset_pagination.py +0 -128
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -801
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -161
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -247
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_tautological_expect.py +0 -463
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/over_mocked_test.py +0 -1055
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -574
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -488
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -287
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -370
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -112
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/redundant_docstring.py +0 -292
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/require_port_for_service.py +0 -783
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -150
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -704
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -648
- sarj_python_lint-0.32.0/src/sarj_python_lint/rules/zero_assertion_test.py +0 -354
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/.gitignore +0 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.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.34.0
|
|
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
|
|
@@ -76,7 +76,7 @@ package inside your own project. Third-party privates are never flagged.
|
|
|
76
76
|
|
|
77
77
|
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
78
78
|
*same top-level package* — a different question, and one that cannot separate
|
|
79
|
-
`from
|
|
79
|
+
`from app.stores.order_store import _row_to_order` (real; export it) from
|
|
80
80
|
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
81
81
|
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
82
82
|
reason; if you take that config, turn this hook on, or you lose the check
|
|
@@ -100,7 +100,49 @@ each guard was built from are recorded in the rule module docstrings.
|
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
`redundant-docstring` finds real volume on a codebase that has never had it
|
|
103
|
-
(105 in
|
|
103
|
+
(105 in one first-party repo), so the same baseline ratchet applies.
|
|
104
|
+
|
|
105
|
+
### Docstring-ceremony rules (0.31.0)
|
|
106
|
+
|
|
107
|
+
SARJ050 tests a *function* docstring against its *own signature*. That leaves
|
|
108
|
+
three shapes it cannot reach, each now its own code so a consumer can baseline
|
|
109
|
+
them separately:
|
|
110
|
+
|
|
111
|
+
```yaml
|
|
112
|
+
- id: sarj-duplicated-override-docstring # SARJ084
|
|
113
|
+
- id: sarj-redundant-class-docstring # SARJ085
|
|
114
|
+
- id: sarj-docstring-args-restate-signature # SARJ086
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`SARJ084` flags an override whose docstring is **byte-identical** to the base
|
|
118
|
+
method's, with the base resolved by undotted name inside the same file. There is
|
|
119
|
+
no judgement call — the test is byte equality — and `inspect.getdoc`, `help()`,
|
|
120
|
+
Sphinx and editor hovers all walk the MRO, so deleting the copy changes nothing
|
|
121
|
+
a reader sees. 49 first-party findings, 49 true positives; 137 across 14 OSS
|
|
122
|
+
repos, 18 sampled and read, 0 false positives.
|
|
123
|
+
|
|
124
|
+
`SARJ085` flags a class docstring that only re-spells the class name — the case
|
|
125
|
+
SARJ050's walker structurally never inspects. Its largest guard is that anything
|
|
126
|
+
whose docstring becomes a **published schema description** (pydantic models,
|
|
127
|
+
enums, `TypedDict`s, `@strawberry.type`) is exempt: that string is emitted as
|
|
128
|
+
the JSON-Schema `description` and reaches OpenAPI documents and LLM tool
|
|
129
|
+
schemas. The exemption costs 28 of 34 first-party findings and is not
|
|
130
|
+
negotiable.
|
|
131
|
+
|
|
132
|
+
`SARJ086` flags an `Args:` block whose every entry only re-spells its own
|
|
133
|
+
parameter. It fires where SARJ050 cannot: the header word "args" is a content
|
|
134
|
+
word no signature contains, so *any* `Args:` block makes a docstring
|
|
135
|
+
permanently unflaggable by SARJ050 — 126 first-party functions carry one and
|
|
136
|
+
SARJ050 flags none of them. The remedy deletes the section and keeps the
|
|
137
|
+
summary, which was checked against the shipped strict config: ruff's D417 does
|
|
138
|
+
not fire on a docstring with no parameter section.
|
|
139
|
+
|
|
140
|
+
The sibling `Returns:` shape was measured and **rejected**: deleting a
|
|
141
|
+
`Returns:` section makes DOC201 fire, so the only compliant remedy is deleting a
|
|
142
|
+
docstring whose summary may be the valuable part. Two more were rejected on
|
|
143
|
+
volume — property docstrings restating the property name and reST/epydoc type
|
|
144
|
+
duplication (`:type x: int`, `:rtype:`) both measure **0** first-party findings
|
|
145
|
+
outside generated code.
|
|
104
146
|
|
|
105
147
|
### House conventions moved out of consumer repos (0.21.0)
|
|
106
148
|
|
|
@@ -119,10 +161,11 @@ to WARNING — usually vanish in production while looking fine locally.
|
|
|
119
161
|
The one legitimate reason to touch stdlib logging in a loguru house is to
|
|
120
162
|
*bridge* it, and the bridge cannot be written without naming both loggers, so a
|
|
121
163
|
module importing loguru is exempt. Measured across two production repos that
|
|
122
|
-
exemption is exact: all four sites that import stdlib logging
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
other module in either repo imports
|
|
164
|
+
exemption is exact: all four sites that import stdlib logging (three in the
|
|
165
|
+
first repo — its package `__init__.py`, a dedicated `configure_logging.py`, and
|
|
166
|
+
a service entrypoint `main.py` — plus a `common/logging.py` in the second) are
|
|
167
|
+
bridges, all four import loguru, and no other module in either repo imports
|
|
168
|
+
stdlib logging at all. Tests, `scripts/`,
|
|
126
169
|
`notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
|
|
127
170
|
|
|
128
171
|
**This is a house-convention rule, not a universal one.** A *library* should log
|
|
@@ -134,7 +177,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
|
|
|
134
177
|
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
|
|
135
178
|
policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
|
|
136
179
|
and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
|
|
137
|
-
files (41 sites in
|
|
180
|
+
files (41 sites in one first-party repo, 14 in another, all of them a
|
|
181
|
+
primary-key `DEFAULT`).
|
|
138
182
|
A literal only counts when it is SQL-shaped, so prose naming the function is not
|
|
139
183
|
a finding.
|
|
140
184
|
|
|
@@ -153,7 +197,7 @@ flagged — measured across five repos those are the entire population.
|
|
|
153
197
|
The second test-quality wave. Where the 0.15.0 family asks "does this test assert
|
|
154
198
|
anything?", this one asks "does it exercise anything real?" — it pushes suites off
|
|
155
199
|
hand-rolled doubles and onto the real store, the real database and a maintained fake
|
|
156
|
-
library. Measured against
|
|
200
|
+
library. Measured against seven first-party repos and fourteen
|
|
157
201
|
OSS corpora; two candidates were dropped outright when the corpus showed they only
|
|
158
202
|
duplicated ruff.
|
|
159
203
|
|
|
@@ -161,7 +205,6 @@ duplicated ruff.
|
|
|
161
205
|
- id: sarj-prefer-real-store-in-tests # SARJ058
|
|
162
206
|
- id: sarj-prefer-library-fake # SARJ059
|
|
163
207
|
- id: sarj-tautological-mock-assertion # SARJ060
|
|
164
|
-
- id: sarj-no-patching-system-under-test # SARJ061
|
|
165
208
|
- id: sarj-over-mocked-test # SARJ062
|
|
166
209
|
- id: sarj-interaction-only-test # SARJ063
|
|
167
210
|
- id: sarj-trivially-true-assertion # SARJ064
|
|
@@ -213,8 +256,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
|
|
|
213
256
|
pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
|
|
214
257
|
consumer. An AST port of each was built and measured, and the boundary each
|
|
215
258
|
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
|
|
216
|
-
9/9 single-use test doubles in
|
|
217
|
-
"raw connection in a test" flagged 46 sites in
|
|
259
|
+
9/9 single-use test doubles in one first-party repo that are idiomatic where
|
|
260
|
+
they sit, and "raw connection in a test" flagged 46 sites in another of which every one is
|
|
218
261
|
already an intentional exemption (store tests asserting DB state, pool-lifecycle
|
|
219
262
|
tests, retention tests where physical deletion is the subject). SARJ036
|
|
220
263
|
`no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
|
|
@@ -261,11 +304,11 @@ an omitted filter.
|
|
|
261
304
|
|
|
262
305
|
Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
|
|
263
306
|
attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
|
|
264
|
-
column, so the rule is silent by construction — and 0
|
|
265
|
-
|
|
307
|
+
column, so the rule is silent by construction — and 0 across four other
|
|
308
|
+
first-party repos. In the fifth it finds 10 sites, all genuine fail-open
|
|
266
309
|
compositions, two of which were reachable cross-tenant reads at the time of
|
|
267
|
-
writing (
|
|
268
|
-
|
|
310
|
+
writing (two paginated list endpoints, both of which composed `WHERE 1=1` for a
|
|
311
|
+
user whose `organization_id` was NULL).
|
|
269
312
|
|
|
270
313
|
### Assertions that can never fail (0.23.0)
|
|
271
314
|
|
|
@@ -301,7 +344,7 @@ ran* — as is anything inside a pytest-benchmark test.
|
|
|
301
344
|
|
|
302
345
|
Measured before shipping: **4 findings across 28,608 files** — 26,346 of
|
|
303
346
|
pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
|
|
304
|
-
|
|
347
|
+
across five first-party repos. All 4 are true positives
|
|
305
348
|
(Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
|
|
306
349
|
`tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
|
|
307
350
|
`providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
|
|
@@ -58,7 +58,7 @@ package inside your own project. Third-party privates are never flagged.
|
|
|
58
58
|
|
|
59
59
|
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
60
60
|
*same top-level package* — a different question, and one that cannot separate
|
|
61
|
-
`from
|
|
61
|
+
`from app.stores.order_store import _row_to_order` (real; export it) from
|
|
62
62
|
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
63
63
|
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
64
64
|
reason; if you take that config, turn this hook on, or you lose the check
|
|
@@ -82,7 +82,49 @@ each guard was built from are recorded in the rule module docstrings.
|
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
`redundant-docstring` finds real volume on a codebase that has never had it
|
|
85
|
-
(105 in
|
|
85
|
+
(105 in one first-party repo), so the same baseline ratchet applies.
|
|
86
|
+
|
|
87
|
+
### Docstring-ceremony rules (0.31.0)
|
|
88
|
+
|
|
89
|
+
SARJ050 tests a *function* docstring against its *own signature*. That leaves
|
|
90
|
+
three shapes it cannot reach, each now its own code so a consumer can baseline
|
|
91
|
+
them separately:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
- id: sarj-duplicated-override-docstring # SARJ084
|
|
95
|
+
- id: sarj-redundant-class-docstring # SARJ085
|
|
96
|
+
- id: sarj-docstring-args-restate-signature # SARJ086
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`SARJ084` flags an override whose docstring is **byte-identical** to the base
|
|
100
|
+
method's, with the base resolved by undotted name inside the same file. There is
|
|
101
|
+
no judgement call — the test is byte equality — and `inspect.getdoc`, `help()`,
|
|
102
|
+
Sphinx and editor hovers all walk the MRO, so deleting the copy changes nothing
|
|
103
|
+
a reader sees. 49 first-party findings, 49 true positives; 137 across 14 OSS
|
|
104
|
+
repos, 18 sampled and read, 0 false positives.
|
|
105
|
+
|
|
106
|
+
`SARJ085` flags a class docstring that only re-spells the class name — the case
|
|
107
|
+
SARJ050's walker structurally never inspects. Its largest guard is that anything
|
|
108
|
+
whose docstring becomes a **published schema description** (pydantic models,
|
|
109
|
+
enums, `TypedDict`s, `@strawberry.type`) is exempt: that string is emitted as
|
|
110
|
+
the JSON-Schema `description` and reaches OpenAPI documents and LLM tool
|
|
111
|
+
schemas. The exemption costs 28 of 34 first-party findings and is not
|
|
112
|
+
negotiable.
|
|
113
|
+
|
|
114
|
+
`SARJ086` flags an `Args:` block whose every entry only re-spells its own
|
|
115
|
+
parameter. It fires where SARJ050 cannot: the header word "args" is a content
|
|
116
|
+
word no signature contains, so *any* `Args:` block makes a docstring
|
|
117
|
+
permanently unflaggable by SARJ050 — 126 first-party functions carry one and
|
|
118
|
+
SARJ050 flags none of them. The remedy deletes the section and keeps the
|
|
119
|
+
summary, which was checked against the shipped strict config: ruff's D417 does
|
|
120
|
+
not fire on a docstring with no parameter section.
|
|
121
|
+
|
|
122
|
+
The sibling `Returns:` shape was measured and **rejected**: deleting a
|
|
123
|
+
`Returns:` section makes DOC201 fire, so the only compliant remedy is deleting a
|
|
124
|
+
docstring whose summary may be the valuable part. Two more were rejected on
|
|
125
|
+
volume — property docstrings restating the property name and reST/epydoc type
|
|
126
|
+
duplication (`:type x: int`, `:rtype:`) both measure **0** first-party findings
|
|
127
|
+
outside generated code.
|
|
86
128
|
|
|
87
129
|
### House conventions moved out of consumer repos (0.21.0)
|
|
88
130
|
|
|
@@ -101,10 +143,11 @@ to WARNING — usually vanish in production while looking fine locally.
|
|
|
101
143
|
The one legitimate reason to touch stdlib logging in a loguru house is to
|
|
102
144
|
*bridge* it, and the bridge cannot be written without naming both loggers, so a
|
|
103
145
|
module importing loguru is exempt. Measured across two production repos that
|
|
104
|
-
exemption is exact: all four sites that import stdlib logging
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
other module in either repo imports
|
|
146
|
+
exemption is exact: all four sites that import stdlib logging (three in the
|
|
147
|
+
first repo — its package `__init__.py`, a dedicated `configure_logging.py`, and
|
|
148
|
+
a service entrypoint `main.py` — plus a `common/logging.py` in the second) are
|
|
149
|
+
bridges, all four import loguru, and no other module in either repo imports
|
|
150
|
+
stdlib logging at all. Tests, `scripts/`,
|
|
108
151
|
`notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
|
|
109
152
|
|
|
110
153
|
**This is a house-convention rule, not a universal one.** A *library* should log
|
|
@@ -116,7 +159,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
|
|
|
116
159
|
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
|
|
117
160
|
policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
|
|
118
161
|
and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
|
|
119
|
-
files (41 sites in
|
|
162
|
+
files (41 sites in one first-party repo, 14 in another, all of them a
|
|
163
|
+
primary-key `DEFAULT`).
|
|
120
164
|
A literal only counts when it is SQL-shaped, so prose naming the function is not
|
|
121
165
|
a finding.
|
|
122
166
|
|
|
@@ -135,7 +179,7 @@ flagged — measured across five repos those are the entire population.
|
|
|
135
179
|
The second test-quality wave. Where the 0.15.0 family asks "does this test assert
|
|
136
180
|
anything?", this one asks "does it exercise anything real?" — it pushes suites off
|
|
137
181
|
hand-rolled doubles and onto the real store, the real database and a maintained fake
|
|
138
|
-
library. Measured against
|
|
182
|
+
library. Measured against seven first-party repos and fourteen
|
|
139
183
|
OSS corpora; two candidates were dropped outright when the corpus showed they only
|
|
140
184
|
duplicated ruff.
|
|
141
185
|
|
|
@@ -143,7 +187,6 @@ duplicated ruff.
|
|
|
143
187
|
- id: sarj-prefer-real-store-in-tests # SARJ058
|
|
144
188
|
- id: sarj-prefer-library-fake # SARJ059
|
|
145
189
|
- id: sarj-tautological-mock-assertion # SARJ060
|
|
146
|
-
- id: sarj-no-patching-system-under-test # SARJ061
|
|
147
190
|
- id: sarj-over-mocked-test # SARJ062
|
|
148
191
|
- id: sarj-interaction-only-test # SARJ063
|
|
149
192
|
- id: sarj-trivially-true-assertion # SARJ064
|
|
@@ -195,8 +238,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
|
|
|
195
238
|
pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
|
|
196
239
|
consumer. An AST port of each was built and measured, and the boundary each
|
|
197
240
|
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
|
|
198
|
-
9/9 single-use test doubles in
|
|
199
|
-
"raw connection in a test" flagged 46 sites in
|
|
241
|
+
9/9 single-use test doubles in one first-party repo that are idiomatic where
|
|
242
|
+
they sit, and "raw connection in a test" flagged 46 sites in another of which every one is
|
|
200
243
|
already an intentional exemption (store tests asserting DB state, pool-lifecycle
|
|
201
244
|
tests, retention tests where physical deletion is the subject). SARJ036
|
|
202
245
|
`no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
|
|
@@ -243,11 +286,11 @@ an omitted filter.
|
|
|
243
286
|
|
|
244
287
|
Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
|
|
245
288
|
attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
|
|
246
|
-
column, so the rule is silent by construction — and 0
|
|
247
|
-
|
|
289
|
+
column, so the rule is silent by construction — and 0 across four other
|
|
290
|
+
first-party repos. In the fifth it finds 10 sites, all genuine fail-open
|
|
248
291
|
compositions, two of which were reachable cross-tenant reads at the time of
|
|
249
|
-
writing (
|
|
250
|
-
|
|
292
|
+
writing (two paginated list endpoints, both of which composed `WHERE 1=1` for a
|
|
293
|
+
user whose `organization_id` was NULL).
|
|
251
294
|
|
|
252
295
|
### Assertions that can never fail (0.23.0)
|
|
253
296
|
|
|
@@ -283,7 +326,7 @@ ran* — as is anything inside a pytest-benchmark test.
|
|
|
283
326
|
|
|
284
327
|
Measured before shipping: **4 findings across 28,608 files** — 26,346 of
|
|
285
328
|
pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
|
|
286
|
-
|
|
329
|
+
across five first-party repos. All 4 are true positives
|
|
287
330
|
(Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
|
|
288
331
|
`tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
|
|
289
332
|
`providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
|
|
@@ -84,6 +84,9 @@ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
|
|
|
84
84
|
class _Args(argparse.Namespace):
|
|
85
85
|
cmd: str | None
|
|
86
86
|
rule: list[str]
|
|
87
|
+
# `explain` takes exactly one rule, so it cannot share `--rule`'s list slot
|
|
88
|
+
# without widening the type and losing the check on every `check` call site.
|
|
89
|
+
which: str
|
|
87
90
|
files: list[Path]
|
|
88
91
|
baseline: Path | None
|
|
89
92
|
update_baseline: Path | None
|
|
@@ -92,11 +95,33 @@ class _Args(argparse.Namespace):
|
|
|
92
95
|
super().__init__()
|
|
93
96
|
self.cmd = None
|
|
94
97
|
self.rule = []
|
|
98
|
+
self.which = ""
|
|
95
99
|
self.files = []
|
|
96
100
|
self.baseline = None
|
|
97
101
|
self.update_baseline = None
|
|
98
102
|
|
|
99
103
|
|
|
104
|
+
def _explain(wanted: str) -> int:
|
|
105
|
+
"""Print a rule's summary plus the derived links to its examples and evidence.
|
|
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
|
+
"""
|
|
111
|
+
key = wanted.strip()
|
|
112
|
+
cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
|
|
113
|
+
if cls is None:
|
|
114
|
+
sys.stderr.write(f"unknown rule: {wanted}\navailable: {', '.join(sorted(REGISTRY))}\n")
|
|
115
|
+
return 2
|
|
116
|
+
summary = (sys.modules[cls.__module__].__doc__ or "").strip().splitlines()
|
|
117
|
+
sys.stdout.write(
|
|
118
|
+
f"{cls.code} {cls.id}\n{summary[0] if summary else cls.description}\nexamples: {cls.examples_url()}\n"
|
|
119
|
+
)
|
|
120
|
+
if cls.has_evidence:
|
|
121
|
+
sys.stdout.write(f"evidence: {cls.evidence_url()}\n")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
100
125
|
def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
|
|
101
126
|
counts: dict[str, dict[str, int]] = {}
|
|
102
127
|
for d in diags:
|
|
@@ -112,9 +137,6 @@ def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
|
|
|
112
137
|
asserted — a hand-edited baseline should degrade to "not baselined" rather
|
|
113
138
|
than crash the run or silently suppress on a malformed entry.
|
|
114
139
|
|
|
115
|
-
Returns:
|
|
116
|
-
The baseline counts, with any entry of the wrong shape dropped.
|
|
117
|
-
|
|
118
140
|
"""
|
|
119
141
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
|
|
120
142
|
path.read_text(encoding="utf-8")
|
|
@@ -134,12 +156,7 @@ def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
|
|
|
134
156
|
|
|
135
157
|
|
|
136
158
|
def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]) -> list[Diagnostic]:
|
|
137
|
-
"""Suppress up to the baselined count per (path, code); excess diags survive.
|
|
138
|
-
|
|
139
|
-
Returns:
|
|
140
|
-
The diagnostics that exceed the baselined count for their (path, code).
|
|
141
|
-
|
|
142
|
-
"""
|
|
159
|
+
"""Suppress up to the baselined count per (path, code); excess diags survive."""
|
|
143
160
|
seen: Counter[tuple[str, str]] = Counter()
|
|
144
161
|
out: list[Diagnostic] = []
|
|
145
162
|
for d in diags:
|
|
@@ -179,6 +196,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
179
196
|
|
|
180
197
|
sub.add_parser("list-rules", help="List available rule IDs.")
|
|
181
198
|
|
|
199
|
+
explain_p = sub.add_parser("explain", help="Print one rule's summary and the links to its examples and evidence.")
|
|
200
|
+
explain_p.add_argument("which", metavar="rule", help="Rule ID or SARJ code.")
|
|
201
|
+
|
|
182
202
|
args = parser.parse_args(argv, namespace=_Args())
|
|
183
203
|
|
|
184
204
|
if args.cmd == "list-rules":
|
|
@@ -187,6 +207,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
187
207
|
sys.stdout.write(f"{inst.code:8} {rid:40} {inst.description}\n")
|
|
188
208
|
return 0
|
|
189
209
|
|
|
210
|
+
if args.cmd == "explain":
|
|
211
|
+
return _explain(args.which)
|
|
212
|
+
|
|
190
213
|
diags = _check(args.rule, args.files)
|
|
191
214
|
if args.update_baseline is not None:
|
|
192
215
|
args.update_baseline.write_text(json.dumps(_baseline_counts(diags), indent=2, sort_keys=True) + "\n")
|
|
@@ -49,12 +49,7 @@ class _Args(argparse.Namespace):
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def _build_parser() -> argparse.ArgumentParser:
|
|
52
|
-
"""Assemble the argument parser.
|
|
53
|
-
|
|
54
|
-
Returns:
|
|
55
|
-
The configured parser.
|
|
56
|
-
|
|
57
|
-
"""
|
|
52
|
+
"""Assemble the argument parser."""
|
|
58
53
|
parser = argparse.ArgumentParser(
|
|
59
54
|
prog="sarj-ratchet",
|
|
60
55
|
description=(
|
|
@@ -99,12 +94,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
99
94
|
|
|
100
95
|
|
|
101
96
|
def main(argv: list[str] | None = None) -> int:
|
|
102
|
-
"""Run the ratchet.
|
|
103
|
-
|
|
104
|
-
Returns:
|
|
105
|
-
0 when every ceiling holds (or the baseline was re-seeded), 1 otherwise.
|
|
106
|
-
|
|
107
|
-
"""
|
|
97
|
+
"""Run the ratchet."""
|
|
108
98
|
args = _build_parser().parse_args(argv, namespace=_Args())
|
|
109
99
|
root = args.root.resolve()
|
|
110
100
|
baseline_path = args.baseline if args.baseline is not None else root / _DEFAULT_BASELINE_NAME
|
|
@@ -162,12 +152,7 @@ def _update(
|
|
|
162
152
|
*,
|
|
163
153
|
allow_increase: bool,
|
|
164
154
|
) -> int:
|
|
165
|
-
"""Re-seed the baseline, refusing raises unless they were explicitly reviewed.
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
0 when the baseline was written, 1 when the re-seed was refused.
|
|
169
|
-
|
|
170
|
-
"""
|
|
155
|
+
"""Re-seed the baseline, refusing raises unless they were explicitly reviewed."""
|
|
171
156
|
would_raise = gate(measurement, baseline)
|
|
172
157
|
if would_raise and not allow_increase:
|
|
173
158
|
sys.stderr.write("REFUSED: --update would raise ceilings; pass --allow-increase if this was reviewed:\n")
|
|
@@ -130,9 +130,6 @@ def identifier_tokens(identifier: str) -> list[str]:
|
|
|
130
130
|
mixed-case single word like `ToKeN` (which camel-splitting shreds into
|
|
131
131
|
`to`/`ke`/`n`) still surfaces its intended `token` form.
|
|
132
132
|
|
|
133
|
-
Returns:
|
|
134
|
-
The ordered lowercase tokens decomposed from `identifier`.
|
|
135
|
-
|
|
136
133
|
"""
|
|
137
134
|
tokens: list[str] = []
|
|
138
135
|
for segment in _SEGMENT_RE.split(identifier):
|
|
@@ -152,10 +149,6 @@ def leading_word(identifier: str) -> str | None:
|
|
|
152
149
|
useless `hassecret` rather than `has`. Splitting the leading segment with the
|
|
153
150
|
same camel regex makes `has_secret` and `hasSecret` both yield `has`.
|
|
154
151
|
|
|
155
|
-
Returns:
|
|
156
|
-
The lowercased leading word, or None when `identifier` has no word
|
|
157
|
-
characters at all.
|
|
158
|
-
|
|
159
152
|
"""
|
|
160
153
|
for segment in _SEGMENT_RE.split(identifier):
|
|
161
154
|
if not segment:
|
|
@@ -166,12 +159,7 @@ def leading_word(identifier: str) -> str | None:
|
|
|
166
159
|
|
|
167
160
|
|
|
168
161
|
def is_secret_name(identifier: str) -> bool:
|
|
169
|
-
"""Report whether `identifier` names raw secret material (a credential, not metadata).
|
|
170
|
-
|
|
171
|
-
Returns:
|
|
172
|
-
True when `identifier` denotes a credential rather than metadata.
|
|
173
|
-
|
|
174
|
-
"""
|
|
162
|
+
"""Report whether `identifier` names raw secret material (a credential, not metadata)."""
|
|
175
163
|
tokens = identifier_tokens(identifier)
|
|
176
164
|
if tokens and tokens[-1] in _INNOCUOUS_WORDS:
|
|
177
165
|
return False
|
|
@@ -183,10 +171,5 @@ def is_secret_name(identifier: str) -> bool:
|
|
|
183
171
|
|
|
184
172
|
|
|
185
173
|
def _has_api_key(tokens: list[str]) -> bool:
|
|
186
|
-
"""Report whether `api` is immediately followed by `key` (the split form of `api_key`).
|
|
187
|
-
|
|
188
|
-
Returns:
|
|
189
|
-
True when an `api` token is directly followed by a `key` token.
|
|
190
|
-
|
|
191
|
-
"""
|
|
174
|
+
"""Report whether `api` is immediately followed by `key` (the split form of `api_key`)."""
|
|
192
175
|
return any(a == "api" and b == "key" for a, b in pairwise(tokens))
|
|
@@ -94,12 +94,7 @@ class Measurement:
|
|
|
94
94
|
|
|
95
95
|
@property
|
|
96
96
|
def total(self) -> int:
|
|
97
|
-
"""Total suppressions counted.
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
The sum over every code key.
|
|
101
|
-
|
|
102
|
-
"""
|
|
97
|
+
"""Total suppressions counted."""
|
|
103
98
|
return sum(self.codes.values())
|
|
104
99
|
|
|
105
100
|
|
|
@@ -123,12 +118,7 @@ class Failure:
|
|
|
123
118
|
actual: int
|
|
124
119
|
|
|
125
120
|
def format(self) -> str:
|
|
126
|
-
"""Render the failure with the remediation for its dimension.
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
A multi-sentence message naming the key, both numbers and the fix.
|
|
130
|
-
|
|
131
|
-
"""
|
|
121
|
+
"""Render the failure with the remediation for its dimension."""
|
|
132
122
|
head = f"FAIL[{self.dimension}] {self.key}: {self.actual} suppressions, ceiling {self.ceiling}."
|
|
133
123
|
return f"{head} {_REMEDIATION[self.dimension]}"
|
|
134
124
|
|
|
@@ -164,9 +154,6 @@ def measure(
|
|
|
164
154
|
contributes 0 rather than raising, so a baseline outliving a rename fails
|
|
165
155
|
loudly on the package's disappearance rather than crashing.
|
|
166
156
|
|
|
167
|
-
Returns:
|
|
168
|
-
The measurement.
|
|
169
|
-
|
|
170
157
|
"""
|
|
171
158
|
codes: Counter[str] = Counter()
|
|
172
159
|
package_counts: Counter[str] = Counter()
|
|
@@ -189,12 +176,7 @@ def measure(
|
|
|
189
176
|
|
|
190
177
|
|
|
191
178
|
def count_source(source: str) -> Counter[str]:
|
|
192
|
-
"""Count the suppressions in one file's text, keyed by dialect and code.
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
The per-key counts for this file.
|
|
196
|
-
|
|
197
|
-
"""
|
|
179
|
+
"""Count the suppressions in one file's text, keyed by dialect and code."""
|
|
198
180
|
counts: Counter[str] = Counter()
|
|
199
181
|
for line in source.splitlines():
|
|
200
182
|
_count_line(line, counts)
|
|
@@ -202,13 +184,7 @@ def count_source(source: str) -> Counter[str]:
|
|
|
202
184
|
|
|
203
185
|
|
|
204
186
|
def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
|
|
205
|
-
"""Compare a measurement against the baseline's three ceilings.
|
|
206
|
-
|
|
207
|
-
Returns:
|
|
208
|
-
Every exceeded ceiling, ordered code → package → file, each dimension
|
|
209
|
-
sorted by key so output is stable across runs.
|
|
210
|
-
|
|
211
|
-
"""
|
|
187
|
+
"""Compare a measurement against the baseline's three ceilings."""
|
|
212
188
|
failures = [
|
|
213
189
|
Failure(dimension="code", key=key, ceiling=c, actual=n)
|
|
214
190
|
for key, n in sorted(measurement.codes.items())
|
|
@@ -228,13 +204,7 @@ def gate(measurement: Measurement, baseline: Baseline) -> list[Failure]:
|
|
|
228
204
|
|
|
229
205
|
|
|
230
206
|
def improvements(measurement: Measurement, baseline: Baseline) -> dict[str, tuple[int, int]]:
|
|
231
|
-
"""Find the code keys now below their ceiling — the wins worth locking in.
|
|
232
|
-
|
|
233
|
-
Returns:
|
|
234
|
-
`{key: (ceiling, actual)}` for every code that shrank, including codes
|
|
235
|
-
that reached zero and are no longer present at all.
|
|
236
|
-
|
|
237
|
-
"""
|
|
207
|
+
"""Find the code keys now below their ceiling — the wins worth locking in."""
|
|
238
208
|
out: dict[str, tuple[int, int]] = {}
|
|
239
209
|
for key, ceiling in baseline.codes.items():
|
|
240
210
|
actual = measurement.codes.get(key, 0)
|
|
@@ -250,9 +220,6 @@ def seed(measurement: Measurement, baseline: Baseline) -> Baseline:
|
|
|
250
220
|
that dropped to or below the global ceiling loses its exception, so the
|
|
251
221
|
allowance cannot outlive the debt it was granted for.
|
|
252
222
|
|
|
253
|
-
Returns:
|
|
254
|
-
The re-seeded baseline.
|
|
255
|
-
|
|
256
223
|
"""
|
|
257
224
|
exceptions = {path: n for path, n in measurement.files.items() if n > baseline.per_file_ceiling}
|
|
258
225
|
return Baseline(
|
|
@@ -269,9 +236,6 @@ def load_baseline(path: Path) -> Baseline:
|
|
|
269
236
|
A hand-edited baseline degrades to "not baselined" (ceiling 0) for the
|
|
270
237
|
malformed entry rather than crashing the gate or silently passing.
|
|
271
238
|
|
|
272
|
-
Returns:
|
|
273
|
-
The parsed baseline.
|
|
274
|
-
|
|
275
239
|
"""
|
|
276
240
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
|
|
277
241
|
path.read_text(encoding="utf-8")
|
|
@@ -289,24 +253,14 @@ def load_baseline(path: Path) -> Baseline:
|
|
|
289
253
|
|
|
290
254
|
|
|
291
255
|
def _get(mapping: object, key: str) -> object:
|
|
292
|
-
"""Read one key out of a value that may or may not be a JSON object.
|
|
293
|
-
|
|
294
|
-
Returns:
|
|
295
|
-
The value at `key`, or None when `mapping` is not an object or lacks it.
|
|
296
|
-
|
|
297
|
-
"""
|
|
256
|
+
"""Read one key out of a value that may or may not be a JSON object."""
|
|
298
257
|
if not isinstance(mapping, dict):
|
|
299
258
|
return None
|
|
300
259
|
return mapping.get(key) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] — json leaves are Any
|
|
301
260
|
|
|
302
261
|
|
|
303
262
|
def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
|
|
304
|
-
"""Render a baseline as the JSON text to write.
|
|
305
|
-
|
|
306
|
-
Returns:
|
|
307
|
-
The serialized baseline, newline-terminated.
|
|
308
|
-
|
|
309
|
-
"""
|
|
263
|
+
"""Render a baseline as the JSON text to write."""
|
|
310
264
|
payload = {
|
|
311
265
|
"_comment": (
|
|
312
266
|
"Suppression ceilings, written by `sarj-ratchet --update`. Counts may "
|
|
@@ -332,9 +286,6 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
|
|
|
332
286
|
Used when neither `--package` nor a baseline names the packages, so the
|
|
333
287
|
first run needs no configuration.
|
|
334
288
|
|
|
335
|
-
Returns:
|
|
336
|
-
The package names, sorted.
|
|
337
|
-
|
|
338
289
|
"""
|
|
339
290
|
return sorted(
|
|
340
291
|
name
|
|
@@ -347,12 +298,7 @@ def discover_packages(root: Path, excluded_dir_names: frozenset[str] = DEFAULT_E
|
|
|
347
298
|
|
|
348
299
|
|
|
349
300
|
def _int_map(value: object) -> dict[str, int]:
|
|
350
|
-
"""Narrow a JSON value to `{str: int}`, dropping anything else.
|
|
351
|
-
|
|
352
|
-
Returns:
|
|
353
|
-
The well-formed entries only.
|
|
354
|
-
|
|
355
|
-
"""
|
|
301
|
+
"""Narrow a JSON value to `{str: int}`, dropping anything else."""
|
|
356
302
|
if not isinstance(value, dict):
|
|
357
303
|
return {}
|
|
358
304
|
return {
|
|
@@ -363,12 +309,7 @@ def _int_map(value: object) -> dict[str, int]:
|
|
|
363
309
|
|
|
364
310
|
|
|
365
311
|
def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Path]:
|
|
366
|
-
"""Yield every `.py` file under `root`, skipping excluded directories.
|
|
367
|
-
|
|
368
|
-
Yields:
|
|
369
|
-
Each Python file path.
|
|
370
|
-
|
|
371
|
-
"""
|
|
312
|
+
"""Yield every `.py` file under `root`, skipping excluded directories."""
|
|
372
313
|
if not root.is_dir():
|
|
373
314
|
return
|
|
374
315
|
for path in sorted(root.rglob("*.py")):
|
|
@@ -377,12 +318,7 @@ def _python_files(root: Path, excluded_dir_names: frozenset[str]) -> Iterator[Pa
|
|
|
377
318
|
|
|
378
319
|
|
|
379
320
|
def _read(path: Path) -> str:
|
|
380
|
-
"""Read a source file, treating undecodable bytes as empty.
|
|
381
|
-
|
|
382
|
-
Returns:
|
|
383
|
-
The file text, or `""` when it cannot be decoded.
|
|
384
|
-
|
|
385
|
-
"""
|
|
321
|
+
"""Read a source file, treating undecodable bytes as empty."""
|
|
386
322
|
try:
|
|
387
323
|
return path.read_text(encoding="utf-8")
|
|
388
324
|
except UnicodeDecodeError, OSError:
|