sarj-python-lint 0.33.0__tar.gz → 0.35.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 (121) hide show
  1. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/PKG-INFO +18 -17
  2. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/README.md +17 -16
  3. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/__main__.py +32 -9
  5. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/_ratchet_cli.py +3 -18
  6. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/_secret_names.py +2 -19
  7. sarj_python_lint-0.35.0/src/sarj_python_lint/_version.py +9 -0
  8. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/ratchet.py +10 -74
  9. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rule_base.py +43 -16
  10. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_ast_index.py +1 -16
  11. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_comments.py +5 -59
  12. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_docstrings.py +4 -36
  13. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_first_party.py +129 -28
  14. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_logging.py +0 -3
  15. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_paths.py +1 -21
  16. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_pytest.py +0 -6
  17. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_registry.py +0 -2
  18. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_sql.py +8 -19
  19. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -4
  20. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +23 -333
  21. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +94 -0
  22. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/duplicate_test_body.py +405 -0
  23. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +107 -0
  24. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +7 -64
  25. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +15 -135
  26. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/interaction_only_test.py +19 -266
  27. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +185 -0
  28. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +69 -189
  29. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/mock_without_spec.py +15 -235
  30. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +9 -62
  31. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_comment_cruft.py +19 -250
  32. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +6 -35
  33. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +10 -147
  34. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +85 -0
  35. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +8 -75
  36. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_first_party_private_import.py +171 -0
  37. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +14 -158
  38. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +89 -0
  39. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +459 -0
  40. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +8 -84
  41. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_offset_pagination.py +82 -0
  42. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -74
  43. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +4 -20
  44. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +99 -0
  45. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +150 -0
  46. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_restated_comment.py +71 -115
  47. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_secret_in_log.py +6 -60
  48. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_select_star.py +6 -36
  49. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +39 -378
  50. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_sequential_await.py +15 -138
  51. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +8 -77
  52. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +12 -101
  53. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/no_tautological_expect.py +248 -0
  54. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +8 -52
  55. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/over_mocked_test.py +614 -0
  56. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +7 -81
  57. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_class_row.py +5 -35
  58. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -95
  59. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +311 -0
  60. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_library_fake.py +18 -236
  61. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +18 -251
  62. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +289 -0
  63. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +360 -0
  64. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +28 -263
  65. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +15 -163
  66. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +4 -34
  67. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_or_pattern.py +165 -0
  68. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +17 -256
  69. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +6 -36
  70. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_str_enum.py +26 -296
  71. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +67 -0
  72. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +103 -133
  73. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +5 -20
  74. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +6 -30
  75. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +6 -31
  76. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +13 -150
  77. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/redundant_class_docstring.py +116 -0
  78. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/redundant_docstring.py +136 -0
  79. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/require_port_for_service.py +400 -0
  80. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/single_public_export.py +6 -60
  81. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +7 -55
  82. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/stepdown.py +55 -148
  83. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +86 -0
  84. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +412 -0
  85. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +7 -65
  86. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/trailing_value_narration.py +5 -57
  87. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/trivially_true_assertion.py +356 -0
  88. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/unused_mock_setup.py +18 -210
  89. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +8 -86
  90. sarj_python_lint-0.35.0/src/sarj_python_lint/rules/zero_assertion_test.py +355 -0
  91. sarj_python_lint-0.33.0/src/sarj_python_lint/_version.py +0 -20
  92. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -172
  93. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/duplicate_test_body.py +0 -711
  94. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -188
  95. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -202
  96. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -150
  97. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -185
  98. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -145
  99. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -177
  100. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_offset_pagination.py +0 -128
  101. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -801
  102. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -161
  103. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -247
  104. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/no_tautological_expect.py +0 -463
  105. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/over_mocked_test.py +0 -1055
  106. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -574
  107. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -488
  108. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -287
  109. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -370
  110. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -112
  111. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -201
  112. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/redundant_docstring.py +0 -147
  113. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/require_port_for_service.py +0 -783
  114. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -150
  115. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -704
  116. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -648
  117. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/zero_assertion_test.py +0 -354
  118. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/.gitignore +0 -0
  119. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/__init__.py +0 -0
  120. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.0}/src/sarj_python_lint/py.typed +0 -0
  121. {sarj_python_lint-0.33.0 → sarj_python_lint-0.35.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.33.0
3
+ Version: 0.35.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 bulbul.stores.task_store import _row_to_task` (real; export it) 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,7 @@ 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 noura-be), so the same baseline ratchet applies.
103
+ (105 in one first-party repo), so the same baseline ratchet applies.
104
104
 
105
105
  ### Docstring-ceremony rules (0.31.0)
106
106
 
@@ -161,10 +161,11 @@ to WARNING — usually vanish in production while looking fine locally.
161
161
  The one legitimate reason to touch stdlib logging in a loguru house is to
162
162
  *bridge* it, and the bridge cannot be written without naming both loggers, so a
163
163
  module importing loguru is exempt. Measured across two production repos that
164
- exemption is exact: all four sites that import stdlib logging
165
- (`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
166
- noura-be's `common/logging.py`) are bridges, all four import loguru, and no
167
- other module in either repo imports stdlib logging at all. Tests, `scripts/`,
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/`,
168
169
  `notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
169
170
 
170
171
  **This is a house-convention rule, not a universal one.** A *library* should log
@@ -176,7 +177,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
176
177
  (Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
177
178
  policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
178
179
  and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
179
- files (41 sites in bulbul, 14 in noura-be, all of them a primary-key `DEFAULT`).
180
+ files (41 sites in one first-party repo, 14 in another, all of them a
181
+ primary-key `DEFAULT`).
180
182
  A literal only counts when it is SQL-shaped, so prose naming the function is not
181
183
  a finding.
182
184
 
@@ -195,7 +197,7 @@ flagged — measured across five repos those are the entire population.
195
197
  The second test-quality wave. Where the 0.15.0 family asks "does this test assert
196
198
  anything?", this one asks "does it exercise anything real?" — it pushes suites off
197
199
  hand-rolled doubles and onto the real store, the real database and a maintained fake
198
- library. Measured against bulbul, noura-be, five other first-party repos and fourteen
200
+ library. Measured against seven first-party repos and fourteen
199
201
  OSS corpora; two candidates were dropped outright when the corpus showed they only
200
202
  duplicated ruff.
201
203
 
@@ -203,7 +205,6 @@ duplicated ruff.
203
205
  - id: sarj-prefer-real-store-in-tests # SARJ058
204
206
  - id: sarj-prefer-library-fake # SARJ059
205
207
  - id: sarj-tautological-mock-assertion # SARJ060
206
- - id: sarj-no-patching-system-under-test # SARJ061
207
208
  - id: sarj-over-mocked-test # SARJ062
208
209
  - id: sarj-interaction-only-test # SARJ063
209
210
  - id: sarj-trivially-true-assertion # SARJ064
@@ -255,8 +256,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
255
256
  pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
256
257
  consumer. An AST port of each was built and measured, and the boundary each
257
258
  encodes turned out to be repo-specific rather than shared: "shared fake" flagged
258
- 9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
259
- "raw connection in a test" flagged 46 sites in bulbul of which every one is
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
260
261
  already an intentional exemption (store tests asserting DB state, pool-lifecycle
261
262
  tests, retention tests where physical deletion is the subject). SARJ036
262
263
  `no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
@@ -303,11 +304,11 @@ an omitted filter.
303
304
 
304
305
  Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
305
306
  attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
306
- column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
307
- and demo-gateway. In bulbul it finds 10 sites, all genuine fail-open
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
308
309
  compositions, two of which were reachable cross-tenant reads at the time of
309
- writing (`POST /v1/calls/list` and `POST /v1/calls/batch/list`, both of which
310
- composed `WHERE 1=1` for a user whose `organization_id` was NULL).
310
+ writing (two paginated list endpoints, both of which composed `WHERE 1=1` for a
311
+ user whose `organization_id` was NULL).
311
312
 
312
313
  ### Assertions that can never fail (0.23.0)
313
314
 
@@ -343,7 +344,7 @@ ran* — as is anything inside a pytest-benchmark test.
343
344
 
344
345
  Measured before shipping: **4 findings across 28,608 files** — 26,346 of
345
346
  pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
346
- in bulbul, noura-be, kpi-hub, ai and demo-gateway. All 4 are true positives
347
+ across five first-party repos. All 4 are true positives
347
348
  (Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
348
349
  `tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
349
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 bulbul.stores.task_store import _row_to_task` (real; export it) 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,7 @@ 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 noura-be), so the same baseline ratchet applies.
85
+ (105 in one first-party repo), so the same baseline ratchet applies.
86
86
 
87
87
  ### Docstring-ceremony rules (0.31.0)
88
88
 
@@ -143,10 +143,11 @@ to WARNING — usually vanish in production while looking fine locally.
143
143
  The one legitimate reason to touch stdlib logging in a loguru house is to
144
144
  *bridge* it, and the bridge cannot be written without naming both loggers, so a
145
145
  module importing loguru is exempt. Measured across two production repos that
146
- exemption is exact: all four sites that import stdlib logging
147
- (`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
148
- noura-be's `common/logging.py`) are bridges, all four import loguru, and no
149
- other module in either repo imports stdlib logging at all. Tests, `scripts/`,
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/`,
150
151
  `notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
151
152
 
152
153
  **This is a house-convention rule, not a universal one.** A *library* should log
@@ -158,7 +159,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
158
159
  (Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
159
160
  policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
160
161
  and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
161
- files (41 sites in bulbul, 14 in noura-be, all of them a primary-key `DEFAULT`).
162
+ files (41 sites in one first-party repo, 14 in another, all of them a
163
+ primary-key `DEFAULT`).
162
164
  A literal only counts when it is SQL-shaped, so prose naming the function is not
163
165
  a finding.
164
166
 
@@ -177,7 +179,7 @@ flagged — measured across five repos those are the entire population.
177
179
  The second test-quality wave. Where the 0.15.0 family asks "does this test assert
178
180
  anything?", this one asks "does it exercise anything real?" — it pushes suites off
179
181
  hand-rolled doubles and onto the real store, the real database and a maintained fake
180
- library. Measured against bulbul, noura-be, five other first-party repos and fourteen
182
+ library. Measured against seven first-party repos and fourteen
181
183
  OSS corpora; two candidates were dropped outright when the corpus showed they only
182
184
  duplicated ruff.
183
185
 
@@ -185,7 +187,6 @@ duplicated ruff.
185
187
  - id: sarj-prefer-real-store-in-tests # SARJ058
186
188
  - id: sarj-prefer-library-fake # SARJ059
187
189
  - id: sarj-tautological-mock-assertion # SARJ060
188
- - id: sarj-no-patching-system-under-test # SARJ061
189
190
  - id: sarj-over-mocked-test # SARJ062
190
191
  - id: sarj-interaction-only-test # SARJ063
191
192
  - id: sarj-trivially-true-assertion # SARJ064
@@ -237,8 +238,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
237
238
  pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
238
239
  consumer. An AST port of each was built and measured, and the boundary each
239
240
  encodes turned out to be repo-specific rather than shared: "shared fake" flagged
240
- 9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
241
- "raw connection in a test" flagged 46 sites in bulbul of which every one is
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
242
243
  already an intentional exemption (store tests asserting DB state, pool-lifecycle
243
244
  tests, retention tests where physical deletion is the subject). SARJ036
244
245
  `no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
@@ -285,11 +286,11 @@ an omitted filter.
285
286
 
286
287
  Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
287
288
  attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
288
- column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
289
- and demo-gateway. In bulbul it finds 10 sites, all genuine fail-open
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
290
291
  compositions, two of which were reachable cross-tenant reads at the time of
291
- writing (`POST /v1/calls/list` and `POST /v1/calls/batch/list`, both of which
292
- composed `WHERE 1=1` for a user whose `organization_id` was NULL).
292
+ writing (two paginated list endpoints, both of which composed `WHERE 1=1` for a
293
+ user whose `organization_id` was NULL).
293
294
 
294
295
  ### Assertions that can never fail (0.23.0)
295
296
 
@@ -325,7 +326,7 @@ ran* — as is anything inside a pytest-benchmark test.
325
326
 
326
327
  Measured before shipping: **4 findings across 28,608 files** — 26,346 of
327
328
  pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
328
- in bulbul, noura-be, kpi-hub, ai and demo-gateway. All 4 are true positives
329
+ across five first-party repos. All 4 are true positives
329
330
  (Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
330
331
  `tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
331
332
  `providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.33.0"
3
+ version = "0.35.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" }]
@@ -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))
@@ -0,0 +1,9 @@
1
+ """Resolve the installed package version, with a source-tree fallback."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ try:
7
+ __version__ = version("sarj-python-lint")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0.dev0"
@@ -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: