sarj-python-lint 0.32.0__tar.gz → 0.34.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/PKG-INFO +60 -17
  2. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/README.md +59 -16
  3. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/__main__.py +32 -9
  5. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/_ratchet_cli.py +3 -18
  6. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/_secret_names.py +2 -19
  7. sarj_python_lint-0.34.0/src/sarj_python_lint/_version.py +9 -0
  8. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/ratchet.py +10 -74
  9. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rule_base.py +43 -16
  10. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_ast_index.py +1 -16
  11. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_comments.py +5 -59
  12. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/_docstrings.py +285 -0
  13. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_first_party.py +129 -28
  14. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_logging.py +0 -3
  15. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_paths.py +1 -21
  16. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_pytest.py +0 -6
  17. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_registry.py +10 -2
  18. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_sql.py +8 -19
  19. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -4
  20. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +23 -333
  21. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +94 -0
  22. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/duplicate_test_body.py +405 -0
  23. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +107 -0
  24. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +7 -64
  25. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +15 -135
  26. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/interaction_only_test.py +19 -266
  27. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +185 -0
  28. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +69 -189
  29. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/mock_without_spec.py +15 -235
  30. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +9 -62
  31. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_comment_cruft.py +19 -250
  32. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +6 -35
  33. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +10 -147
  34. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +85 -0
  35. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +8 -75
  36. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_first_party_private_import.py +171 -0
  37. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +14 -158
  38. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +89 -0
  39. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +459 -0
  40. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +8 -84
  41. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_offset_pagination.py +82 -0
  42. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +10 -74
  43. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +4 -20
  44. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +99 -0
  45. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +150 -0
  46. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_restated_comment.py +71 -115
  47. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_secret_in_log.py +6 -60
  48. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_select_star.py +6 -36
  49. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +39 -378
  50. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_sequential_await.py +15 -138
  51. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +8 -77
  52. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +12 -101
  53. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/no_tautological_expect.py +248 -0
  54. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +8 -52
  55. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/over_mocked_test.py +614 -0
  56. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +7 -81
  57. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_class_row.py +5 -35
  58. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -95
  59. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +311 -0
  60. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_library_fake.py +18 -236
  61. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +18 -251
  62. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +289 -0
  63. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +360 -0
  64. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +28 -263
  65. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +15 -163
  66. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +4 -34
  67. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_or_pattern.py +165 -0
  68. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +17 -256
  69. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +6 -36
  70. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_str_enum.py +26 -296
  71. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +67 -0
  72. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +103 -133
  73. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +5 -20
  74. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +6 -30
  75. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +6 -31
  76. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +13 -150
  77. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/redundant_class_docstring.py +116 -0
  78. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/redundant_docstring.py +136 -0
  79. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/require_port_for_service.py +400 -0
  80. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/single_public_export.py +6 -60
  81. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +7 -55
  82. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/stepdown.py +55 -148
  83. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +86 -0
  84. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +412 -0
  85. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +7 -65
  86. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/trailing_value_narration.py +5 -57
  87. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/trivially_true_assertion.py +356 -0
  88. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/unused_mock_setup.py +18 -210
  89. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +8 -86
  90. sarj_python_lint-0.34.0/src/sarj_python_lint/rules/zero_assertion_test.py +355 -0
  91. sarj_python_lint-0.32.0/src/sarj_python_lint/_version.py +0 -20
  92. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/duplicate_test_body.py +0 -711
  93. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -202
  94. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -150
  95. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -185
  96. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -145
  97. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -177
  98. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_offset_pagination.py +0 -128
  99. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -801
  100. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -161
  101. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -247
  102. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/no_tautological_expect.py +0 -463
  103. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/over_mocked_test.py +0 -1055
  104. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -574
  105. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -488
  106. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -287
  107. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -370
  108. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -112
  109. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/redundant_docstring.py +0 -292
  110. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/require_port_for_service.py +0 -783
  111. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -150
  112. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -704
  113. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -648
  114. sarj_python_lint-0.32.0/src/sarj_python_lint/rules/zero_assertion_test.py +0 -354
  115. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/.gitignore +0 -0
  116. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/__init__.py +0 -0
  117. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/py.typed +0 -0
  118. {sarj_python_lint-0.32.0 → sarj_python_lint-0.34.0}/src/sarj_python_lint/rules/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.32.0
3
+ Version: 0.34.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -76,7 +76,7 @@ package inside your own project. Third-party privates are never flagged.
76
76
 
77
77
  **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
78
78
  *same top-level package* — a different question, and one that cannot separate
79
- `from 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,49 @@ each guard was built from are recorded in the rule module docstrings.
100
100
  ```
101
101
 
102
102
  `redundant-docstring` finds real volume on a codebase that has never had it
103
- (105 in noura-be), so the same baseline ratchet applies.
103
+ (105 in one first-party repo), so the same baseline ratchet applies.
104
+
105
+ ### Docstring-ceremony rules (0.31.0)
106
+
107
+ SARJ050 tests a *function* docstring against its *own signature*. That leaves
108
+ three shapes it cannot reach, each now its own code so a consumer can baseline
109
+ them separately:
110
+
111
+ ```yaml
112
+ - id: sarj-duplicated-override-docstring # SARJ084
113
+ - id: sarj-redundant-class-docstring # SARJ085
114
+ - id: sarj-docstring-args-restate-signature # SARJ086
115
+ ```
116
+
117
+ `SARJ084` flags an override whose docstring is **byte-identical** to the base
118
+ method's, with the base resolved by undotted name inside the same file. There is
119
+ no judgement call — the test is byte equality — and `inspect.getdoc`, `help()`,
120
+ Sphinx and editor hovers all walk the MRO, so deleting the copy changes nothing
121
+ a reader sees. 49 first-party findings, 49 true positives; 137 across 14 OSS
122
+ repos, 18 sampled and read, 0 false positives.
123
+
124
+ `SARJ085` flags a class docstring that only re-spells the class name — the case
125
+ SARJ050's walker structurally never inspects. Its largest guard is that anything
126
+ whose docstring becomes a **published schema description** (pydantic models,
127
+ enums, `TypedDict`s, `@strawberry.type`) is exempt: that string is emitted as
128
+ the JSON-Schema `description` and reaches OpenAPI documents and LLM tool
129
+ schemas. The exemption costs 28 of 34 first-party findings and is not
130
+ negotiable.
131
+
132
+ `SARJ086` flags an `Args:` block whose every entry only re-spells its own
133
+ parameter. It fires where SARJ050 cannot: the header word "args" is a content
134
+ word no signature contains, so *any* `Args:` block makes a docstring
135
+ permanently unflaggable by SARJ050 — 126 first-party functions carry one and
136
+ SARJ050 flags none of them. The remedy deletes the section and keeps the
137
+ summary, which was checked against the shipped strict config: ruff's D417 does
138
+ not fire on a docstring with no parameter section.
139
+
140
+ The sibling `Returns:` shape was measured and **rejected**: deleting a
141
+ `Returns:` section makes DOC201 fire, so the only compliant remedy is deleting a
142
+ docstring whose summary may be the valuable part. Two more were rejected on
143
+ volume — property docstrings restating the property name and reST/epydoc type
144
+ duplication (`:type x: int`, `:rtype:`) both measure **0** first-party findings
145
+ outside generated code.
104
146
 
105
147
  ### House conventions moved out of consumer repos (0.21.0)
106
148
 
@@ -119,10 +161,11 @@ to WARNING — usually vanish in production while looking fine locally.
119
161
  The one legitimate reason to touch stdlib logging in a loguru house is to
120
162
  *bridge* it, and the bridge cannot be written without naming both loggers, so a
121
163
  module importing loguru is exempt. Measured across two production repos that
122
- exemption is exact: all four sites that import stdlib logging
123
- (`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
124
- noura-be's `common/logging.py`) are bridges, all four import loguru, and no
125
- 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/`,
126
169
  `notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
127
170
 
128
171
  **This is a house-convention rule, not a universal one.** A *library* should log
@@ -134,7 +177,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
134
177
  (Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
135
178
  policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
136
179
  and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
137
- files (41 sites in 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`).
138
182
  A literal only counts when it is SQL-shaped, so prose naming the function is not
139
183
  a finding.
140
184
 
@@ -153,7 +197,7 @@ flagged — measured across five repos those are the entire population.
153
197
  The second test-quality wave. Where the 0.15.0 family asks "does this test assert
154
198
  anything?", this one asks "does it exercise anything real?" — it pushes suites off
155
199
  hand-rolled doubles and onto the real store, the real database and a maintained fake
156
- library. Measured against bulbul, noura-be, five other first-party repos and fourteen
200
+ library. Measured against seven first-party repos and fourteen
157
201
  OSS corpora; two candidates were dropped outright when the corpus showed they only
158
202
  duplicated ruff.
159
203
 
@@ -161,7 +205,6 @@ duplicated ruff.
161
205
  - id: sarj-prefer-real-store-in-tests # SARJ058
162
206
  - id: sarj-prefer-library-fake # SARJ059
163
207
  - id: sarj-tautological-mock-assertion # SARJ060
164
- - id: sarj-no-patching-system-under-test # SARJ061
165
208
  - id: sarj-over-mocked-test # SARJ062
166
209
  - id: sarj-interaction-only-test # SARJ063
167
210
  - id: sarj-trivially-true-assertion # SARJ064
@@ -213,8 +256,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
213
256
  pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
214
257
  consumer. An AST port of each was built and measured, and the boundary each
215
258
  encodes turned out to be repo-specific rather than shared: "shared fake" flagged
216
- 9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
217
- "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
218
261
  already an intentional exemption (store tests asserting DB state, pool-lifecycle
219
262
  tests, retention tests where physical deletion is the subject). SARJ036
220
263
  `no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
@@ -261,11 +304,11 @@ an omitted filter.
261
304
 
262
305
  Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
263
306
  attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
264
- column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
265
- 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
266
309
  compositions, two of which were reachable cross-tenant reads at the time of
267
- writing (`POST /v1/calls/list` and `POST /v1/calls/batch/list`, both of which
268
- 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).
269
312
 
270
313
  ### Assertions that can never fail (0.23.0)
271
314
 
@@ -301,7 +344,7 @@ ran* — as is anything inside a pytest-benchmark test.
301
344
 
302
345
  Measured before shipping: **4 findings across 28,608 files** — 26,346 of
303
346
  pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
304
- 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
305
348
  (Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
306
349
  `tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
307
350
  `providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
@@ -58,7 +58,7 @@ package inside your own project. Third-party privates are never flagged.
58
58
 
59
59
  **It replaces ruff's `PLC2701 import-private-name`,** whose only exemption is
60
60
  *same top-level package* — a different question, and one that cannot separate
61
- `from 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,49 @@ each guard was built from are recorded in the rule module docstrings.
82
82
  ```
83
83
 
84
84
  `redundant-docstring` finds real volume on a codebase that has never had it
85
- (105 in noura-be), so the same baseline ratchet applies.
85
+ (105 in one first-party repo), so the same baseline ratchet applies.
86
+
87
+ ### Docstring-ceremony rules (0.31.0)
88
+
89
+ SARJ050 tests a *function* docstring against its *own signature*. That leaves
90
+ three shapes it cannot reach, each now its own code so a consumer can baseline
91
+ them separately:
92
+
93
+ ```yaml
94
+ - id: sarj-duplicated-override-docstring # SARJ084
95
+ - id: sarj-redundant-class-docstring # SARJ085
96
+ - id: sarj-docstring-args-restate-signature # SARJ086
97
+ ```
98
+
99
+ `SARJ084` flags an override whose docstring is **byte-identical** to the base
100
+ method's, with the base resolved by undotted name inside the same file. There is
101
+ no judgement call — the test is byte equality — and `inspect.getdoc`, `help()`,
102
+ Sphinx and editor hovers all walk the MRO, so deleting the copy changes nothing
103
+ a reader sees. 49 first-party findings, 49 true positives; 137 across 14 OSS
104
+ repos, 18 sampled and read, 0 false positives.
105
+
106
+ `SARJ085` flags a class docstring that only re-spells the class name — the case
107
+ SARJ050's walker structurally never inspects. Its largest guard is that anything
108
+ whose docstring becomes a **published schema description** (pydantic models,
109
+ enums, `TypedDict`s, `@strawberry.type`) is exempt: that string is emitted as
110
+ the JSON-Schema `description` and reaches OpenAPI documents and LLM tool
111
+ schemas. The exemption costs 28 of 34 first-party findings and is not
112
+ negotiable.
113
+
114
+ `SARJ086` flags an `Args:` block whose every entry only re-spells its own
115
+ parameter. It fires where SARJ050 cannot: the header word "args" is a content
116
+ word no signature contains, so *any* `Args:` block makes a docstring
117
+ permanently unflaggable by SARJ050 — 126 first-party functions carry one and
118
+ SARJ050 flags none of them. The remedy deletes the section and keeps the
119
+ summary, which was checked against the shipped strict config: ruff's D417 does
120
+ not fire on a docstring with no parameter section.
121
+
122
+ The sibling `Returns:` shape was measured and **rejected**: deleting a
123
+ `Returns:` section makes DOC201 fire, so the only compliant remedy is deleting a
124
+ docstring whose summary may be the valuable part. Two more were rejected on
125
+ volume — property docstrings restating the property name and reST/epydoc type
126
+ duplication (`:type x: int`, `:rtype:`) both measure **0** first-party findings
127
+ outside generated code.
86
128
 
87
129
  ### House conventions moved out of consumer repos (0.21.0)
88
130
 
@@ -101,10 +143,11 @@ to WARNING — usually vanish in production while looking fine locally.
101
143
  The one legitimate reason to touch stdlib logging in a loguru house is to
102
144
  *bridge* it, and the bridge cannot be written without naming both loggers, so a
103
145
  module importing loguru is exempt. Measured across two production repos that
104
- exemption is exact: all four sites that import stdlib logging
105
- (`bulbul/__init__.py`, `bulbul/configure_logging.py`, `agent/main.py`,
106
- noura-be's `common/logging.py`) are bridges, all four import loguru, and no
107
- 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/`,
108
151
  `notebooks/`, generated files and `if TYPE_CHECKING:` imports are also exempt.
109
152
 
110
153
  **This is a house-convention rule, not a universal one.** A *library* should log
@@ -116,7 +159,8 @@ UUIDv4 keys scatter B-tree inserts across every leaf page, where `uuidv7()`
116
159
  (Postgres 18) is time-ordered and appends. It is the embedded-SQL third of a
117
160
  policy the stack already states twice — `ruff.strict.toml` bans `uuid.uuid4`,
118
161
  and `sarj-sql-lint`'s SARJ109 `prefer-uuidv7-default` covers `.sql` migration
119
- files (41 sites in 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`).
120
164
  A literal only counts when it is SQL-shaped, so prose naming the function is not
121
165
  a finding.
122
166
 
@@ -135,7 +179,7 @@ flagged — measured across five repos those are the entire population.
135
179
  The second test-quality wave. Where the 0.15.0 family asks "does this test assert
136
180
  anything?", this one asks "does it exercise anything real?" — it pushes suites off
137
181
  hand-rolled doubles and onto the real store, the real database and a maintained fake
138
- library. Measured against bulbul, noura-be, five other first-party repos and fourteen
182
+ library. Measured against seven first-party repos and fourteen
139
183
  OSS corpora; two candidates were dropped outright when the corpus showed they only
140
184
  duplicated ruff.
141
185
 
@@ -143,7 +187,6 @@ duplicated ruff.
143
187
  - id: sarj-prefer-real-store-in-tests # SARJ058
144
188
  - id: sarj-prefer-library-fake # SARJ059
145
189
  - id: sarj-tautological-mock-assertion # SARJ060
146
- - id: sarj-no-patching-system-under-test # SARJ061
147
190
  - id: sarj-over-mocked-test # SARJ062
148
191
  - id: sarj-interaction-only-test # SARJ063
149
192
  - id: sarj-trivially-true-assertion # SARJ064
@@ -195,8 +238,8 @@ file falls back under the global cap, so an allowance cannot outlive its debt.
195
238
  pygrep hooks, not SARJ rules, and both need a `files:`/`exclude:` from the
196
239
  consumer. An AST port of each was built and measured, and the boundary each
197
240
  encodes turned out to be repo-specific rather than shared: "shared fake" flagged
198
- 9/9 single-use test doubles in noura-be that are idiomatic where they sit, and
199
- "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
200
243
  already an intentional exemption (store tests asserting DB state, pool-lifecycle
201
244
  tests, retention tests where physical deletion is the subject). SARJ036
202
245
  `no-raw-sql-in-tests` remains the corpus-validated shared rule for raw SQL in
@@ -243,11 +286,11 @@ an omitted filter.
243
286
 
244
287
  Measured before shipping: **0 findings across 26,345 files** of pydantic, trio,
245
288
  attrs, Airflow and Home Assistant — single-tenant codebases have no tenant
246
- column, so the rule is silent by construction — and 0 in noura-be, ai, kpi-hub
247
- 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
248
291
  compositions, two of which were reachable cross-tenant reads at the time of
249
- writing (`POST /v1/calls/list` and `POST /v1/calls/batch/list`, both of which
250
- 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).
251
294
 
252
295
  ### Assertions that can never fail (0.23.0)
253
296
 
@@ -283,7 +326,7 @@ ran* — as is anything inside a pytest-benchmark test.
283
326
 
284
327
  Measured before shipping: **4 findings across 28,608 files** — 26,346 of
285
328
  pydantic, trio, attrs, Airflow and Home Assistant plus 2,262 first-party files
286
- 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
287
330
  (Home Assistant `tests/helpers/test_device_registry.py:3711` and `:3777`,
288
331
  `tests/components/emulated_hue/test_hue_api.py:1078`, Airflow
289
332
  `providers/apache/hdfs/.../log/test_hdfs_task_handler.py:170`); 0 false
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.32.0"
3
+ version = "0.34.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: