sarj-python-lint 0.20.0__tar.gz → 0.21.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.20.0 → sarj_python_lint-0.21.0}/PKG-INFO +88 -1
- sarj_python_lint-0.21.0/README.md +194 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/pyproject.toml +2 -1
- sarj_python_lint-0.21.0/src/sarj_python_lint/_ratchet_cli.py +185 -0
- sarj_python_lint-0.21.0/src/sarj_python_lint/ratchet.py +423 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_registry.py +6 -0
- sarj_python_lint-0.21.0/src/sarj_python_lint/rules/_suppression_comments.py +78 -0
- sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +150 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +3 -64
- sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +118 -0
- sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_stdlib_logging.py +195 -0
- sarj_python_lint-0.20.0/README.md +0 -107
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/.gitignore +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.20.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/zero_assertion_test.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.21.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
|
|
@@ -101,6 +101,93 @@ each guard was built from are recorded in the rule module docstrings.
|
|
|
101
101
|
`redundant-docstring` finds real volume on a codebase that has never had it
|
|
102
102
|
(105 in noura-be), so the same baseline ratchet applies.
|
|
103
103
|
|
|
104
|
+
### House conventions moved out of consumer repos (0.21.0)
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
- id: sarj-no-stdlib-logging # SARJ052
|
|
108
|
+
- id: sarj-no-gen-random-uuid-in-sql # SARJ053
|
|
109
|
+
- id: sarj-no-file-level-escape-hatch-noqa # SARJ054
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`SARJ052` bans importing stdlib `logging` in application code, because the
|
|
113
|
+
house logger is loguru and two logger hierarchies mean two handler chains: the
|
|
114
|
+
records written to the one nobody configured skip the JSON formatter, the
|
|
115
|
+
redaction patcher and the error reporter, and — since the stdlib root defaults
|
|
116
|
+
to WARNING — usually vanish in production while looking fine locally.
|
|
117
|
+
|
|
118
|
+
The one legitimate reason to touch stdlib logging in a loguru house is to
|
|
119
|
+
*bridge* it, and the bridge cannot be written without naming both loggers, so a
|
|
120
|
+
module importing loguru is exempt. Measured across two production repos that
|
|
121
|
+
exemption is exact: all four sites that import stdlib logging
|
|
122
|
+
(`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
|
|
123
|
+
noura-be's `common/logging.py`) are bridges, all four import loguru, and no
|
|
124
|
+
other module in either repo imports stdlib logging at all. Tests, `scripts/`,
|
|
125
|
+
`notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
|
|
126
|
+
|
|
127
|
+
**This is a house-convention rule, not a universal one.** A *library* should log
|
|
128
|
+
through stdlib `logging` precisely so it does not impose a sink on its callers —
|
|
129
|
+
trio's three sites are correct for trio. Enable it in applications only.
|
|
130
|
+
|
|
131
|
+
`SARJ053` flags `gen_random_uuid()` in SQL embedded in a Python string literal:
|
|
132
|
+
UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
|
|
133
|
+
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
|
|
134
|
+
policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
|
|
135
|
+
and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
|
|
136
|
+
files (41 sites in bulbul, 14 in noura-be, all of them a primary-key `DEFAULT`).
|
|
137
|
+
A literal only counts when it is SQL-shaped, so prose naming the function is not
|
|
138
|
+
a finding.
|
|
139
|
+
|
|
140
|
+
`SARJ054` is SARJ038's scoped sibling. SARJ038 bans the unscoped blanket
|
|
141
|
+
(`# ruff: noqa`); this bans a *scoped* file-level exemption that names an
|
|
142
|
+
escape-hatch code — a code whose remediation `ruff.strict.toml` spells as an
|
|
143
|
+
inline `# noqa: CODE — <reason>`, which today is `TID251` alone, ruff's only
|
|
144
|
+
banned-API code. Hoisting that to the top of a file turns N reviewed per-site
|
|
145
|
+
decisions into one unreviewable one and pre-authorizes every mock added later.
|
|
146
|
+
Scoped exemptions for mechanical codes (`E501`, `F401`, `UP035`) are never
|
|
147
|
+
flagged — measured across five repos those are the entire population.
|
|
148
|
+
|
|
149
|
+
### Suppression ratchet (`sarj-ratchet`, 0.21.0)
|
|
150
|
+
|
|
151
|
+
```yaml
|
|
152
|
+
- id: sarj-suppression-ratchet
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
One tool replacing the per-repo ratchet scripts. It counts every escape hatch in
|
|
156
|
+
the tree and enforces three ceilings that may only shrink:
|
|
157
|
+
|
|
158
|
+
* **per code** — `noqa:TID251` going 40 → 41 is a regression even if the total falls
|
|
159
|
+
* **per package** — one package's headroom must not finance another's debt
|
|
160
|
+
* **per file** — a global cap so new suppressions cannot pile into one hot spot;
|
|
161
|
+
pre-existing hot spots are grandfathered at their then-current counts
|
|
162
|
+
|
|
163
|
+
All four dialects are counted under distinct key prefixes, so moving a
|
|
164
|
+
suppression between spellings can never hide it: `noqa:CODE`,
|
|
165
|
+
`sarj-noqa:CODE`, `pyright:CODE`, `type-ignore:CODE` / bare `type-ignore`, plus
|
|
166
|
+
the file-level `file-noqa:CODE` / `file-noqa:<blanket>` and `file-pyright:RULE`.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
sarj-ratchet --update python/ # seed (or lock in a drop)
|
|
170
|
+
sarj-ratchet python/ # gate
|
|
171
|
+
sarj-ratchet --update --allow-increase python/ # a reviewed ceiling raise
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`--update` **refuses** to raise a ceiling unless `--allow-increase` says the
|
|
175
|
+
raise was reviewed, and it drops a per-file grandfather clause as soon as the
|
|
176
|
+
file falls back under the global cap, so an allowance cannot outlive its debt.
|
|
177
|
+
|
|
178
|
+
### Two conventions that stayed pygrep
|
|
179
|
+
|
|
180
|
+
`sarj-fakes-in-shared-location` and `sarj-no-raw-connection-in-tests` ship as
|
|
181
|
+
pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
|
|
182
|
+
consumer. An AST port of each was built and measured, and the boundary each
|
|
183
|
+
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
|
|
184
|
+
9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
|
|
185
|
+
"raw connection in a test" flagged 46 sites in bulbul of which every one is
|
|
186
|
+
already an intentional exemption (store tests asserting DB state, pool-lifecycle
|
|
187
|
+
tests, retention tests where physical deletion is the subject). SARJ036
|
|
188
|
+
`no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
|
|
189
|
+
tests.
|
|
190
|
+
|
|
104
191
|
Adopting these against an existing suite is easier through the baseline ratchet
|
|
105
192
|
than as a big-bang fix — snapshot the current counts, then let them only shrink:
|
|
106
193
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# sarj-python-lint
|
|
2
|
+
|
|
3
|
+
Custom Python lint rules via stdlib `ast`. Designed for pre-commit. For SQL rules see [`sarj-sql-lint`](../sql/).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv tool install sarj-python-lint
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Pre-commit
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
- repo: https://github.com/sarj-ai/standards
|
|
13
|
+
rev: python-v0.2.0
|
|
14
|
+
hooks:
|
|
15
|
+
- id: sarj-no-sequential-await
|
|
16
|
+
- id: sarj-inefficient-string-concat-in-loop
|
|
17
|
+
- id: sarj-prefer-str-enum
|
|
18
|
+
- id: sarj-no-fat-try-blocks
|
|
19
|
+
- id: sarj-pydantic-at-boundaries
|
|
20
|
+
- id: sarj-prefer-class-row
|
|
21
|
+
- id: sarj-prefer-timedelta-for-durations
|
|
22
|
+
- id: sarj-prefer-struct-over-namedtuple
|
|
23
|
+
- id: sarj-no-comment-cruft
|
|
24
|
+
- id: sarj-no-fstring-in-log
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Test-quality rules (0.15.0)
|
|
28
|
+
|
|
29
|
+
Mined from an AST audit of ~7,500 test functions across two production repos.
|
|
30
|
+
Every one is scoped to test files and carries the false-positive guard that made
|
|
31
|
+
it shippable; the module docstring for each records the population it was
|
|
32
|
+
measured against.
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- id: sarj-mock-without-spec # SARJ040
|
|
36
|
+
- id: sarj-test-loops-over-literal-cases # SARJ041
|
|
37
|
+
- id: sarj-parametrize-case-needs-id # SARJ042
|
|
38
|
+
- id: sarj-zero-assertion-test # SARJ043
|
|
39
|
+
- id: sarj-fixture-returns-bare-tuple # SARJ044
|
|
40
|
+
- id: sarj-kwarg-heavy-construction-in-test # SARJ045
|
|
41
|
+
- id: sarj-xfail-requires-strict # SARJ046
|
|
42
|
+
- id: sarj-sleep-with-computed-arg-in-test # SARJ047
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Private access, first-party only (0.19.0)
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
- id: sarj-no-first-party-private-import # SARJ048
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Reaching past a module's public surface is a design finding when the module is
|
|
52
|
+
ours and an unavoidable fact of life when it is not: a dependency that moves an
|
|
53
|
+
API private in a minor release leaves no edit that satisfies the lint.
|
|
54
|
+
|
|
55
|
+
`SARJ048` fires only when the module declaring the private name resolves to a
|
|
56
|
+
package inside your own project. Third-party privates are never flagged.
|
|
57
|
+
|
|
58
|
+
**It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
|
|
59
|
+
*same top-level package* — a different question, and one that cannot separate
|
|
60
|
+
`from bulbul.stores.task_store import _row_to_task` (real; export it) from
|
|
61
|
+
`from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
|
|
62
|
+
`sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
|
|
63
|
+
reason; if you take that config, turn this hook on, or you lose the check
|
|
64
|
+
entirely.
|
|
65
|
+
|
|
66
|
+
Attribute access (`session._stt`) is out of scope and stays with ruff's
|
|
67
|
+
`SLF001`, which cannot make the distinction either — see the rationale in
|
|
68
|
+
`ruff.strict.toml`.
|
|
69
|
+
|
|
70
|
+
### Comment-hygiene rules (0.20.0)
|
|
71
|
+
|
|
72
|
+
From a 37,918-comment, nine-repo measurement study. All three are
|
|
73
|
+
deletion-class, so each was validated against pydantic / trio / attrs as well as
|
|
74
|
+
the maintained repos before shipping — the counts and the false-positive classes
|
|
75
|
+
each guard was built from are recorded in the rule module docstrings.
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- id: sarj-no-restated-comment # SARJ049
|
|
79
|
+
- id: sarj-redundant-docstring # SARJ050
|
|
80
|
+
- id: sarj-trailing-value-narration # SARJ051
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`redundant-docstring` finds real volume on a codebase that has never had it
|
|
84
|
+
(105 in noura-be), so the same baseline ratchet applies.
|
|
85
|
+
|
|
86
|
+
### House conventions moved out of consumer repos (0.21.0)
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
- id: sarj-no-stdlib-logging # SARJ052
|
|
90
|
+
- id: sarj-no-gen-random-uuid-in-sql # SARJ053
|
|
91
|
+
- id: sarj-no-file-level-escape-hatch-noqa # SARJ054
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`SARJ052` bans importing stdlib `logging` in application code, because the
|
|
95
|
+
house logger is loguru and two logger hierarchies mean two handler chains: the
|
|
96
|
+
records written to the one nobody configured skip the JSON formatter, the
|
|
97
|
+
redaction patcher and the error reporter, and — since the stdlib root defaults
|
|
98
|
+
to WARNING — usually vanish in production while looking fine locally.
|
|
99
|
+
|
|
100
|
+
The one legitimate reason to touch stdlib logging in a loguru house is to
|
|
101
|
+
*bridge* it, and the bridge cannot be written without naming both loggers, so a
|
|
102
|
+
module importing loguru is exempt. Measured across two production repos that
|
|
103
|
+
exemption is exact: all four sites that import stdlib logging
|
|
104
|
+
(`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
|
|
105
|
+
noura-be's `common/logging.py`) are bridges, all four import loguru, and no
|
|
106
|
+
other module in either repo imports stdlib logging at all. Tests, `scripts/`,
|
|
107
|
+
`notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
|
|
108
|
+
|
|
109
|
+
**This is a house-convention rule, not a universal one.** A *library* should log
|
|
110
|
+
through stdlib `logging` precisely so it does not impose a sink on its callers —
|
|
111
|
+
trio's three sites are correct for trio. Enable it in applications only.
|
|
112
|
+
|
|
113
|
+
`SARJ053` flags `gen_random_uuid()` in SQL embedded in a Python string literal:
|
|
114
|
+
UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
|
|
115
|
+
(Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
|
|
116
|
+
policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
|
|
117
|
+
and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
|
|
118
|
+
files (41 sites in bulbul, 14 in noura-be, all of them a primary-key `DEFAULT`).
|
|
119
|
+
A literal only counts when it is SQL-shaped, so prose naming the function is not
|
|
120
|
+
a finding.
|
|
121
|
+
|
|
122
|
+
`SARJ054` is SARJ038's scoped sibling. SARJ038 bans the unscoped blanket
|
|
123
|
+
(`# ruff: noqa`); this bans a *scoped* file-level exemption that names an
|
|
124
|
+
escape-hatch code — a code whose remediation `ruff.strict.toml` spells as an
|
|
125
|
+
inline `# noqa: CODE — <reason>`, which today is `TID251` alone, ruff's only
|
|
126
|
+
banned-API code. Hoisting that to the top of a file turns N reviewed per-site
|
|
127
|
+
decisions into one unreviewable one and pre-authorizes every mock added later.
|
|
128
|
+
Scoped exemptions for mechanical codes (`E501`, `F401`, `UP035`) are never
|
|
129
|
+
flagged — measured across five repos those are the entire population.
|
|
130
|
+
|
|
131
|
+
### Suppression ratchet (`sarj-ratchet`, 0.21.0)
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
- id: sarj-suppression-ratchet
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
One tool replacing the per-repo ratchet scripts. It counts every escape hatch in
|
|
138
|
+
the tree and enforces three ceilings that may only shrink:
|
|
139
|
+
|
|
140
|
+
* **per code** — `noqa:TID251` going 40 → 41 is a regression even if the total falls
|
|
141
|
+
* **per package** — one package's headroom must not finance another's debt
|
|
142
|
+
* **per file** — a global cap so new suppressions cannot pile into one hot spot;
|
|
143
|
+
pre-existing hot spots are grandfathered at their then-current counts
|
|
144
|
+
|
|
145
|
+
All four dialects are counted under distinct key prefixes, so moving a
|
|
146
|
+
suppression between spellings can never hide it: `noqa:CODE`,
|
|
147
|
+
`sarj-noqa:CODE`, `pyright:CODE`, `type-ignore:CODE` / bare `type-ignore`, plus
|
|
148
|
+
the file-level `file-noqa:CODE` / `file-noqa:<blanket>` and `file-pyright:RULE`.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
sarj-ratchet --update python/ # seed (or lock in a drop)
|
|
152
|
+
sarj-ratchet python/ # gate
|
|
153
|
+
sarj-ratchet --update --allow-increase python/ # a reviewed ceiling raise
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`--update` **refuses** to raise a ceiling unless `--allow-increase` says the
|
|
157
|
+
raise was reviewed, and it drops a per-file grandfather clause as soon as the
|
|
158
|
+
file falls back under the global cap, so an allowance cannot outlive its debt.
|
|
159
|
+
|
|
160
|
+
### Two conventions that stayed pygrep
|
|
161
|
+
|
|
162
|
+
`sarj-fakes-in-shared-location` and `sarj-no-raw-connection-in-tests` ship as
|
|
163
|
+
pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
|
|
164
|
+
consumer. An AST port of each was built and measured, and the boundary each
|
|
165
|
+
encodes turned out to be repo-specific rather than shared: "shared fake" flagged
|
|
166
|
+
9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
|
|
167
|
+
"raw connection in a test" flagged 46 sites in bulbul of which every one is
|
|
168
|
+
already an intentional exemption (store tests asserting DB state, pool-lifecycle
|
|
169
|
+
tests, retention tests where physical deletion is the subject). SARJ036
|
|
170
|
+
`no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
|
|
171
|
+
tests.
|
|
172
|
+
|
|
173
|
+
Adopting these against an existing suite is easier through the baseline ratchet
|
|
174
|
+
than as a big-bang fix — snapshot the current counts, then let them only shrink:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
|
|
178
|
+
sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## CLI
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
sarj-python-lint check --rule no-sequential-await path/to/file.py
|
|
185
|
+
sarj-python-lint list-rules
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
|
|
189
|
+
|
|
190
|
+
## Suppression
|
|
191
|
+
|
|
192
|
+
Inline `# sarj-noqa: SARJ00X — <reason>` on the offending line.
|
|
193
|
+
|
|
194
|
+
Each rule's source under `src/sarj_python_lint/rules/` carries its own `description` and diagnostic message.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sarj-python-lint"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.21.0"
|
|
4
4
|
description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "sarj-ai" }]
|
|
@@ -18,6 +18,7 @@ dependencies = []
|
|
|
18
18
|
|
|
19
19
|
[project.scripts]
|
|
20
20
|
sarj-python-lint = "sarj_python_lint.__main__:main"
|
|
21
|
+
sarj-ratchet = "sarj_python_lint._ratchet_cli:main"
|
|
21
22
|
|
|
22
23
|
[project.urls]
|
|
23
24
|
Homepage = "https://github.com/sarj-ai/standards/tree/main/packages/python"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""CLI: `sarj-ratchet [--baseline PATH] [--package DIR]... [--update [--allow-increase]] [ROOT]`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from sarj_python_lint import __version__
|
|
11
|
+
from sarj_python_lint.ratchet import (
|
|
12
|
+
DEFAULT_PER_FILE_CEILING,
|
|
13
|
+
Baseline,
|
|
14
|
+
discover_packages,
|
|
15
|
+
dump_baseline,
|
|
16
|
+
gate,
|
|
17
|
+
improvements,
|
|
18
|
+
load_baseline,
|
|
19
|
+
measure,
|
|
20
|
+
seed,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from sarj_python_lint.ratchet import Measurement
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_DEFAULT_BASELINE_NAME = "suppression-baseline.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Args(argparse.Namespace):
|
|
32
|
+
root: Path
|
|
33
|
+
baseline: Path | None
|
|
34
|
+
package: list[str]
|
|
35
|
+
exclude_subtree: list[str]
|
|
36
|
+
per_file_ceiling: int | None
|
|
37
|
+
update: bool
|
|
38
|
+
allow_increase: bool
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.root = Path()
|
|
43
|
+
self.baseline = None
|
|
44
|
+
self.package = []
|
|
45
|
+
self.exclude_subtree = []
|
|
46
|
+
self.per_file_ceiling = None
|
|
47
|
+
self.update = False
|
|
48
|
+
self.allow_increase = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
"""Assemble the argument parser.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The configured parser.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="sarj-ratchet",
|
|
60
|
+
description=(
|
|
61
|
+
"Ratchet on lint/type suppressions: per-code, per-package and per-file ceilings that may only shrink."
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
65
|
+
parser.add_argument("root", nargs="?", type=Path, default=Path(), help="tree to scan (default: cwd)")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--baseline",
|
|
68
|
+
type=Path,
|
|
69
|
+
help=f"baseline JSON (default: <root>/{_DEFAULT_BASELINE_NAME})",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--package",
|
|
73
|
+
action="append",
|
|
74
|
+
default=[],
|
|
75
|
+
help="package directory relative to root (repeatable; default: the baseline's, else auto-discovered)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--exclude-subtree",
|
|
79
|
+
action="append",
|
|
80
|
+
default=[],
|
|
81
|
+
help="root-relative path prefix to skip, e.g. generated client output (repeatable)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--per-file-ceiling",
|
|
85
|
+
type=int,
|
|
86
|
+
help=f"max suppressions in any one file (default: the baseline's, else {DEFAULT_PER_FILE_CEILING})",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--update",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="rewrite the baseline from current counts (refuses to raise a ceiling)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--allow-increase",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="with --update: permit ceilings to rise (requires explicit review)",
|
|
97
|
+
)
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
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
|
+
"""
|
|
108
|
+
args = _build_parser().parse_args(argv, namespace=_Args())
|
|
109
|
+
root = args.root.resolve()
|
|
110
|
+
baseline_path = args.baseline if args.baseline is not None else root / _DEFAULT_BASELINE_NAME
|
|
111
|
+
|
|
112
|
+
baseline = load_baseline(baseline_path) if baseline_path.exists() else Baseline()
|
|
113
|
+
if args.per_file_ceiling is not None:
|
|
114
|
+
baseline = Baseline(
|
|
115
|
+
codes=baseline.codes,
|
|
116
|
+
packages=baseline.packages,
|
|
117
|
+
per_file_ceiling=args.per_file_ceiling,
|
|
118
|
+
file_exceptions=baseline.file_exceptions,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
packages = args.package or sorted(baseline.packages) or discover_packages(root)
|
|
122
|
+
if not packages:
|
|
123
|
+
sys.stderr.write(f"sarj-ratchet: no Python packages found under {root}\n")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
measurement = measure(root, packages, excluded_subtrees=args.exclude_subtree)
|
|
127
|
+
|
|
128
|
+
if args.update:
|
|
129
|
+
# A first seed has nothing to raise: every ceiling is 0 only because no
|
|
130
|
+
# baseline exists yet, and refusing that would make the tool unusable
|
|
131
|
+
# on the run that adopts it.
|
|
132
|
+
return _update(
|
|
133
|
+
measurement,
|
|
134
|
+
baseline,
|
|
135
|
+
baseline_path,
|
|
136
|
+
packages,
|
|
137
|
+
allow_increase=args.allow_increase or not baseline_path.exists(),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
sys.stdout.write(
|
|
141
|
+
f"{measurement.total} suppressions across {len(measurement.codes)} codes "
|
|
142
|
+
f"in {len(packages)} package(s) (baseline total {sum(baseline.codes.values())})\n"
|
|
143
|
+
)
|
|
144
|
+
failures = gate(measurement, baseline)
|
|
145
|
+
for failure in failures:
|
|
146
|
+
sys.stdout.write(f"{failure.format()}\n")
|
|
147
|
+
if failures:
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
won = improvements(measurement, baseline)
|
|
151
|
+
if won:
|
|
152
|
+
shrunk = ", ".join(f"{key} {ceiling}->{actual}" for key, (ceiling, actual) in sorted(won.items()))
|
|
153
|
+
sys.stdout.write(f"Counts dropped below baseline ({shrunk}) — lock it in: sarj-ratchet --update\n")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _update(
|
|
158
|
+
measurement: Measurement,
|
|
159
|
+
baseline: Baseline,
|
|
160
|
+
baseline_path: Path,
|
|
161
|
+
packages: list[str],
|
|
162
|
+
*,
|
|
163
|
+
allow_increase: bool,
|
|
164
|
+
) -> 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
|
+
"""
|
|
171
|
+
would_raise = gate(measurement, baseline)
|
|
172
|
+
if would_raise and not allow_increase:
|
|
173
|
+
sys.stderr.write("REFUSED: --update would raise ceilings; pass --allow-increase if this was reviewed:\n")
|
|
174
|
+
for failure in would_raise:
|
|
175
|
+
sys.stderr.write(f" [{failure.dimension}] {failure.key}: {failure.ceiling} -> {failure.actual}\n")
|
|
176
|
+
return 1
|
|
177
|
+
baseline_path.write_text(dump_baseline(seed(measurement, baseline), packages), encoding="utf-8")
|
|
178
|
+
sys.stdout.write(
|
|
179
|
+
f"Baseline updated: {measurement.total} suppressions across {len(measurement.codes)} codes -> {baseline_path}\n"
|
|
180
|
+
)
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
sys.exit(main())
|