sarj-python-lint 0.19.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.
Files changed (68) hide show
  1. sarj_python_lint-0.21.0/PKG-INFO +212 -0
  2. sarj_python_lint-0.21.0/README.md +194 -0
  3. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/pyproject.toml +2 -1
  4. sarj_python_lint-0.21.0/src/sarj_python_lint/_ratchet_cli.py +185 -0
  5. sarj_python_lint-0.21.0/src/sarj_python_lint/ratchet.py +423 -0
  6. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/_comments.py +375 -0
  7. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_registry.py +12 -0
  8. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/_suppression_comments.py +78 -0
  9. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_comment_cruft.py +187 -41
  10. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +150 -0
  11. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +3 -64
  12. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +118 -0
  13. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_restated_comment.py +318 -0
  14. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/no_stdlib_logging.py +195 -0
  15. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/redundant_docstring.py +211 -0
  16. sarj_python_lint-0.21.0/src/sarj_python_lint/rules/trailing_value_narration.py +134 -0
  17. sarj_python_lint-0.19.0/PKG-INFO +0 -109
  18. sarj_python_lint-0.19.0/README.md +0 -91
  19. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/.gitignore +0 -0
  20. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/__init__.py +0 -0
  21. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/__main__.py +0 -0
  22. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/_secret_names.py +0 -0
  23. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/_version.py +0 -0
  24. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/py.typed +0 -0
  25. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rule_base.py +0 -0
  26. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  27. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  28. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  31. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  32. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  33. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  34. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  35. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  36. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  37. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  38. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  39. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  40. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  41. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  42. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  43. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  44. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  45. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  46. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  47. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  48. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  49. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  50. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  51. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  52. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  53. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  54. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  55. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  56. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  57. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  58. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  59. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  60. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  61. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  62. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  63. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  64. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  65. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  66. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  67. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  68. {sarj_python_lint-0.19.0 → sarj_python_lint-0.21.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: sarj-python-lint
3
+ Version: 0.21.0
4
+ Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
+ Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
+ Project-URL: Repository, https://github.com/sarj-ai/standards
7
+ Project-URL: Issues, https://github.com/sarj-ai/standards/issues
8
+ Author: sarj-ai
9
+ License: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.14
17
+ Description-Content-Type: text/markdown
18
+
19
+ # sarj-python-lint
20
+
21
+ Custom Python lint rules via stdlib `ast`. Designed for pre-commit. For SQL rules see [`sarj-sql-lint`](../sql/).
22
+
23
+ ```bash
24
+ uv tool install sarj-python-lint
25
+ ```
26
+
27
+ ## Pre-commit
28
+
29
+ ```yaml
30
+ - repo: https://github.com/sarj-ai/standards
31
+ rev: python-v0.2.0
32
+ hooks:
33
+ - id: sarj-no-sequential-await
34
+ - id: sarj-inefficient-string-concat-in-loop
35
+ - id: sarj-prefer-str-enum
36
+ - id: sarj-no-fat-try-blocks
37
+ - id: sarj-pydantic-at-boundaries
38
+ - id: sarj-prefer-class-row
39
+ - id: sarj-prefer-timedelta-for-durations
40
+ - id: sarj-prefer-struct-over-namedtuple
41
+ - id: sarj-no-comment-cruft
42
+ - id: sarj-no-fstring-in-log
43
+ ```
44
+
45
+ ### Test-quality rules (0.15.0)
46
+
47
+ Mined from an AST audit of ~7,500 test functions across two production repos.
48
+ Every one is scoped to test files and carries the false-positive guard that made
49
+ it shippable; the module docstring for each records the population it was
50
+ measured against.
51
+
52
+ ```yaml
53
+ - id: sarj-mock-without-spec # SARJ040
54
+ - id: sarj-test-loops-over-literal-cases # SARJ041
55
+ - id: sarj-parametrize-case-needs-id # SARJ042
56
+ - id: sarj-zero-assertion-test # SARJ043
57
+ - id: sarj-fixture-returns-bare-tuple # SARJ044
58
+ - id: sarj-kwarg-heavy-construction-in-test # SARJ045
59
+ - id: sarj-xfail-requires-strict # SARJ046
60
+ - id: sarj-sleep-with-computed-arg-in-test # SARJ047
61
+ ```
62
+
63
+ ### Private access, first-party only (0.19.0)
64
+
65
+ ```yaml
66
+ - id: sarj-no-first-party-private-import # SARJ048
67
+ ```
68
+
69
+ Reaching past a module's public surface is a design finding when the module is
70
+ ours and an unavoidable fact of life when it is not: a dependency that moves an
71
+ API private in a minor release leaves no edit that satisfies the lint.
72
+
73
+ `SARJ048` fires only when the module declaring the private name resolves to a
74
+ package inside your own project. Third-party privates are never flagged.
75
+
76
+ **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
77
+ *same top-level package* — a different question, and one that cannot separate
78
+ `from bulbul.stores.task_store import _row_to_task` (real; export it) from
79
+ `from livekit.agents.inference_runner import _InferenceRunner` (no fix exists).
80
+ `sarj-lint-configs` ≥ 0.8.0 ships `PLC2701` in its ignore list for exactly this
81
+ reason; if you take that config, turn this hook on, or you lose the check
82
+ entirely.
83
+
84
+ Attribute access (`session._stt`) is out of scope and stays with ruff's
85
+ `SLF001`, which cannot make the distinction either — see the rationale in
86
+ `ruff.strict.toml`.
87
+
88
+ ### Comment-hygiene rules (0.20.0)
89
+
90
+ From a 37,918-comment, nine-repo measurement study. All three are
91
+ deletion-class, so each was validated against pydantic / trio / attrs as well as
92
+ the maintained repos before shipping — the counts and the false-positive classes
93
+ each guard was built from are recorded in the rule module docstrings.
94
+
95
+ ```yaml
96
+ - id: sarj-no-restated-comment # SARJ049
97
+ - id: sarj-redundant-docstring # SARJ050
98
+ - id: sarj-trailing-value-narration # SARJ051
99
+ ```
100
+
101
+ `redundant-docstring` finds real volume on a codebase that has never had it
102
+ (105 in noura-be), so the same baseline ratchet applies.
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
+
191
+ Adopting these against an existing suite is easier through the baseline ratchet
192
+ than as a big-bang fix — snapshot the current counts, then let them only shrink:
193
+
194
+ ```bash
195
+ sarj-python-lint check --rule mock-without-spec --update-baseline test-quality-baseline.json python/
196
+ sarj-python-lint check --rule mock-without-spec --baseline test-quality-baseline.json python/
197
+ ```
198
+
199
+ ## CLI
200
+
201
+ ```bash
202
+ sarj-python-lint check --rule no-sequential-await path/to/file.py
203
+ sarj-python-lint list-rules
204
+ ```
205
+
206
+ Diagnostic format is `path:line:col: CODE message` — Ruff-compatible.
207
+
208
+ ## Suppression
209
+
210
+ Inline `# sarj-noqa: SARJ00X — <reason>` on the offending line.
211
+
212
+ Each rule's source under `src/sarj_python_lint/rules/` carries its own `description` and diagnostic message.
@@ -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.19.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())