sarj-python-lint 0.32.0__tar.gz → 0.33.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 (95) hide show
  1. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/PKG-INFO +43 -1
  2. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/README.md +42 -0
  3. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/pyproject.toml +1 -1
  4. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/_docstrings.py +317 -0
  5. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_registry.py +10 -0
  6. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/docstring_args_restate_signature.py +172 -0
  7. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/duplicated_override_docstring.py +188 -0
  8. sarj_python_lint-0.33.0/src/sarj_python_lint/rules/redundant_class_docstring.py +201 -0
  9. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/redundant_docstring.py +25 -170
  10. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/.gitignore +0 -0
  11. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  24. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  25. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  26. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  28. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  29. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  30. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  31. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  32. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  33. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  34. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  35. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  36. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  37. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  38. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  39. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  40. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  41. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  42. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  43. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  44. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  45. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -0
  46. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  47. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  48. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  49. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
  50. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  51. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  52. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  53. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  54. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  55. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  56. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  57. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  58. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  59. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  60. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  61. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  62. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  63. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  64. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  65. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  66. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  67. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  68. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  69. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  70. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  71. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  72. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  73. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  74. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  75. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  76. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  77. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  78. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  79. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  80. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  81. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  82. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  83. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  84. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  85. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  86. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  87. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  88. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  89. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  90. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  91. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  92. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  93. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  94. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  95. {sarj_python_lint-0.32.0 → sarj_python_lint-0.33.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.32.0
3
+ Version: 0.33.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
@@ -102,6 +102,48 @@ each guard was built from are recorded in the rule module docstrings.
102
102
  `redundant-docstring` finds real volume on a codebase that has never had it
103
103
  (105 in noura-be), so the same baseline ratchet applies.
104
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.
146
+
105
147
  ### House conventions moved out of consumer repos (0.21.0)
106
148
 
107
149
  ```yaml
@@ -84,6 +84,48 @@ each guard was built from are recorded in the rule module docstrings.
84
84
  `redundant-docstring` finds real volume on a codebase that has never had it
85
85
  (105 in noura-be), so the same baseline ratchet applies.
86
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.
128
+
87
129
  ### House conventions moved out of consumer repos (0.21.0)
88
130
 
89
131
  ```yaml
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.32.0"
3
+ version = "0.33.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" }]
@@ -0,0 +1,317 @@
1
+ """Shared docstring analysis for the docstring-ceremony rules (SARJ050/084/085/086).
2
+
3
+ Four rules ask overlapping questions about a docstring — "does this text say
4
+ anything the signature does not?", "which decorators make this docstring an
5
+ artefact someone else reads?", "where does the `Args:` block start?" — and the
6
+ answers have to be identical across all four or the family contradicts itself.
7
+ SARJ050 owned all of this privately until SARJ084-086 needed the same
8
+ judgements; the definitions moved here unchanged rather than being copied.
9
+
10
+ **`restates` is a DELETION test, not a value test.** It answers only "every
11
+ content word of this text already appears in that identifier set". A False
12
+ result means the text carries a word the signature does not — that is all. It
13
+ must never be read as "this docstring is worthless"; the guards in each rule,
14
+ plus `_comments.is_protected`, are what turn a restatement into a finding.
15
+
16
+ **Section parsing is Google-style only.** `Args:` / `Returns:` / `Raises:` on
17
+ their own line. NumPy style (`Parameters` followed by a `-----` underline) is
18
+ deliberately not parsed: across 2,440 reviewable first-party files the corpus
19
+ holds **2** NumPy docstrings, which is far too little evidence to tune a second
20
+ parser against, and a half-recognised section is worse than an unrecognised one
21
+ — it would let a rule read a `Parameters` heading as prose and judge the block
22
+ on it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import ast
28
+ import re
29
+ from typing import TYPE_CHECKING
30
+
31
+ from sarj_python_lint.rules._comments import split_identifier, stem
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Iterable
36
+
37
+
38
+ # Docstring filler that says nothing about *which* thing is being described.
39
+ # `not` / `no` / `none` / `never` are deliberately ABSENT: a docstring that
40
+ # negates the obvious reading of a name is the most useful kind there is.
41
+ STOPWORDS = frozenset(
42
+ {
43
+ "a",
44
+ "all",
45
+ "an",
46
+ "and",
47
+ "are",
48
+ "as",
49
+ "at",
50
+ "based",
51
+ "be",
52
+ "been",
53
+ "being",
54
+ "by",
55
+ "class",
56
+ "current",
57
+ "do",
58
+ "does",
59
+ "false",
60
+ "for",
61
+ "from",
62
+ "function",
63
+ "get",
64
+ "gets",
65
+ "given",
66
+ "helper",
67
+ "if",
68
+ "in",
69
+ "instance",
70
+ "instances",
71
+ "into",
72
+ "is",
73
+ "it",
74
+ "its",
75
+ "method",
76
+ "new",
77
+ "object",
78
+ "objects",
79
+ "of",
80
+ "on",
81
+ "or",
82
+ "provided",
83
+ "return",
84
+ "returned",
85
+ "returns",
86
+ "s",
87
+ "set",
88
+ "sets",
89
+ "should",
90
+ "specified",
91
+ "that",
92
+ "the",
93
+ "these",
94
+ "this",
95
+ "those",
96
+ "to",
97
+ "true",
98
+ "using",
99
+ "value",
100
+ "values",
101
+ "was",
102
+ "when",
103
+ "whether",
104
+ "which",
105
+ "will",
106
+ "with",
107
+ }
108
+ )
109
+
110
+ WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9']*")
111
+
112
+ _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
113
+
114
+ # Content the signature cannot carry, so the docstring is earning its place.
115
+ VALUE_MARKER_RE = re.compile(
116
+ r"https?://|\bRFC\s?\d|:raises|\bRaises:|>>>|\bExamples?:|^\s*\.\. |"
117
+ r"\b(?:ms|msec|milliseconds?|seconds?|secs?|minutes?|hours?|days?|bytes?|kb|mb|gb|hz|khz|"
118
+ r"utc|iso.?8601|e\.?164|base64|utf-?8|px|dbfs?|db)\b|%",
119
+ re.IGNORECASE | re.MULTILINE,
120
+ )
121
+
122
+ # Decorators whose docstring is consumed by something other than a reader, so
123
+ # deleting it changes an artefact rather than tidying a file:
124
+ # - `function_tool` / `tool` hand it to a language model as the tool
125
+ # description, which is what the agent reasons over;
126
+ # - click and typer hand it to the terminal as `--help`;
127
+ # - FastAPI / Starlette / Flask routing decorators hand it to the OpenAPI
128
+ # schema as the operation description. That last one was found by the corpus
129
+ # sweep rather than predicted: a `@router.post(...)` handler's one-line
130
+ # docstring is the text an API consumer reads in the generated schema.
131
+ PROMPT_DECORATOR_MARKERS = frozenset(
132
+ {
133
+ "agent",
134
+ "api_route",
135
+ "app",
136
+ "blueprint",
137
+ "cli",
138
+ "click",
139
+ "command",
140
+ "delete",
141
+ "function_tool",
142
+ "get",
143
+ "group",
144
+ "mcp",
145
+ "option",
146
+ "patch",
147
+ "post",
148
+ "put",
149
+ "route",
150
+ "router",
151
+ "server",
152
+ "tool",
153
+ "tools",
154
+ "typer",
155
+ "websocket",
156
+ }
157
+ )
158
+
159
+ # Google-style section headers, each alone on its line. `Args`/`Returns` are the
160
+ # two the ceremony rules act on; the rest are listed so a rule can tell "this
161
+ # docstring has an `Examples:` block" from "this docstring has prose containing
162
+ # the word examples".
163
+ _SECTION_RE = re.compile(
164
+ r"^[ \t]*(?P<name>Args|Arguments|Parameters|Params|Keyword Args|Keyword Arguments|"
165
+ r"Returns|Return|Yields|Yield|Raises|Attributes|Example|Examples|Note|Notes|"
166
+ r"Warning|Warnings|Warns|See Also|References|Todo|Other Parameters|Methods)\s*:[ \t]*$",
167
+ re.MULTILINE,
168
+ )
169
+
170
+ # One `Args:` entry: `name (type): description`, with the type parenthesis
171
+ # optional. The leading indent is required — an unindented `name:` line is
172
+ # ordinary prose with a colon in it, not a parameter entry.
173
+ _ARG_ENTRY_RE = re.compile(r"^[ \t]+(?P<name>\*{0,2}[A-Za-z_]\w*)[ \t]*(?:\((?P<type>[^)]*)\))?[ \t]*:(?P<desc>.*)$")
174
+
175
+ ARG_SECTIONS = ("Args", "Arguments", "Parameters", "Params", "Keyword Args", "Keyword Arguments")
176
+
177
+
178
+ def sections(docstring: str) -> dict[str, str]:
179
+ """Split a Google-style docstring into `{"summary": ..., "<Section>": ...}`.
180
+
181
+ A docstring with no recognised header is all summary. Two blocks under the
182
+ same header (which a hand-edited docstring does produce) concatenate.
183
+
184
+ Returns:
185
+ The summary and every recognised section body, keyed by header name.
186
+
187
+ """
188
+ marks = [(match.start(), match.end(), match.group("name")) for match in _SECTION_RE.finditer(docstring)]
189
+ if not marks:
190
+ return {"summary": docstring}
191
+ out: dict[str, str] = {"summary": docstring[: marks[0][0]]}
192
+ for index, (_, header_end, name) in enumerate(marks):
193
+ body_end = marks[index + 1][0] if index + 1 < len(marks) else len(docstring)
194
+ out[name] = out.get(name, "") + docstring[header_end:body_end]
195
+ return out
196
+
197
+
198
+ def arg_section(docstring: str) -> str | None:
199
+ """Return the parameter-documentation block of `docstring`, if it has one.
200
+
201
+ Returns:
202
+ The section body, or None when the docstring documents no parameters.
203
+
204
+ """
205
+ found = sections(docstring)
206
+ for name in ARG_SECTIONS:
207
+ if name in found:
208
+ return found[name]
209
+ return None
210
+
211
+
212
+ def arg_entries(block: str) -> list[tuple[str, str, str]]:
213
+ """Parse an `Args:` block into `(name, type, description)` triples.
214
+
215
+ A line that is not an entry but follows one is that entry's wrapped
216
+ description. Folding those in is load-bearing rather than cosmetic: without
217
+ it the continuation row vanishes, and an entry whose informative half sits
218
+ on the second line reads as a bare restatement.
219
+
220
+ Returns:
221
+ One triple per documented parameter, in source order.
222
+
223
+ """
224
+ entries: list[list[str]] = []
225
+ for raw in block.splitlines():
226
+ match = _ARG_ENTRY_RE.match(raw)
227
+ if match is not None:
228
+ entries.append([match.group("name"), match.group("type") or "", match.group("desc").strip()])
229
+ elif entries and raw.strip():
230
+ entries[-1][2] += " " + raw.strip()
231
+ return [(name, type_, desc) for name, type_, desc in entries]
232
+
233
+
234
+ def identifier_stems(text: str) -> set[str]:
235
+ """Collect the stemmed word parts of every identifier in `text`.
236
+
237
+ Returns:
238
+ The stems, lowercased.
239
+
240
+ """
241
+ return {stem(part) for match in _IDENTIFIER_RE.finditer(text) for part in split_identifier(match.group(0))}
242
+
243
+
244
+ def decorator_markers(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> set[str]:
245
+ """Collect the lowercase word parts of every decorator on `node`.
246
+
247
+ Returns:
248
+ The decorator name parts, for matching against `PROMPT_DECORATOR_MARKERS`.
249
+
250
+ """
251
+ markers: set[str] = set()
252
+ for decorator in node.decorator_list:
253
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
254
+ try:
255
+ markers.update(part.lower() for part in re.split(r"\W+", ast.unparse(target)) if part)
256
+ except AttributeError, ValueError: # pragma: no cover — unparse is total for these nodes
257
+ continue
258
+ return markers
259
+
260
+
261
+ def annotation_tokens(annotation: ast.expr | None) -> list[str]:
262
+ """Split an annotation's rendered source into lowercase word parts.
263
+
264
+ Returns:
265
+ The word parts, or an empty list when there is no annotation.
266
+
267
+ """
268
+ if annotation is None:
269
+ return []
270
+ try:
271
+ rendered = ast.unparse(annotation)
272
+ except AttributeError, ValueError: # pragma: no cover
273
+ return []
274
+ return [part for token in re.split(r"\W+", rendered) if token for part in split_identifier(token)]
275
+
276
+
277
+ def signature_stems(node: ast.FunctionDef | ast.AsyncFunctionDef, class_name: str | None) -> set[str]:
278
+ """Collect every stem a reader can read off the signature.
279
+
280
+ The function name, the owning class name, every parameter name that is not
281
+ `self`/`cls`, and every annotation — parameter and return.
282
+
283
+ Returns:
284
+ The stems the signature already carries.
285
+
286
+ """
287
+ tokens = list(split_identifier(node.name))
288
+ if class_name is not None:
289
+ tokens.extend(split_identifier(class_name))
290
+ args = node.args
291
+ for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg]:
292
+ if arg is None:
293
+ continue
294
+ if arg.arg not in {"self", "cls"}:
295
+ tokens.extend(split_identifier(arg.arg))
296
+ tokens.extend(annotation_tokens(arg.annotation))
297
+ tokens.extend(annotation_tokens(node.returns))
298
+ return {stem(token) for token in tokens}
299
+
300
+
301
+ def restates(text: str, known: Iterable[str]) -> bool:
302
+ """Report whether every content word of `text` is already in `known`.
303
+
304
+ A text with no content words at all (pure stopwords, or no words) returns
305
+ False: "says nothing" and "says only what the code says" are different
306
+ findings, and only the caller knows which one it wants.
307
+
308
+ Returns:
309
+ True when `text` adds no word the identifier set does not carry.
310
+
311
+ """
312
+ known_stems = set(known)
313
+ words = [match.group(0).lower() for match in WORD_RE.finditer(text)]
314
+ content = [word for word in words if word not in STOPWORDS]
315
+ if not content:
316
+ return False
317
+ return all(stem(word) in known_stems for word in content)
@@ -3,7 +3,13 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from sarj_python_lint.rules.conditional_assertion_in_test import ConditionalAssertionInTest
6
+ from sarj_python_lint.rules.docstring_args_restate_signature import (
7
+ DocstringArgsRestateSignature,
8
+ )
6
9
  from sarj_python_lint.rules.duplicate_test_body import DuplicateTestBody
10
+ from sarj_python_lint.rules.duplicated_override_docstring import (
11
+ DuplicatedOverrideDocstring,
12
+ )
7
13
  from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
8
14
  from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
9
15
  InefficientStringConcatInLoop,
@@ -84,6 +90,7 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
84
90
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
85
91
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
86
92
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
93
+ from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
87
94
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
88
95
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
89
96
  from sarj_python_lint.rules.single_public_export import SinglePublicExport
@@ -176,6 +183,9 @@ REGISTRY: dict[str, type[Rule]] = {
176
183
  PreferWalrusComprehensionFilter.id: PreferWalrusComprehensionFilter,
177
184
  PreferWalrusStreamLoop.id: PreferWalrusStreamLoop,
178
185
  PreferSelfTypeAnnotation.id: PreferSelfTypeAnnotation,
186
+ DuplicatedOverrideDocstring.id: DuplicatedOverrideDocstring,
187
+ RedundantClassDocstring.id: RedundantClassDocstring,
188
+ DocstringArgsRestateSignature.id: DocstringArgsRestateSignature,
179
189
  }
180
190
 
181
191
  __all__ = ["REGISTRY"]
@@ -0,0 +1,172 @@
1
+ """SARJ086: an `Args:` block that only re-spells the parameter list.
2
+
3
+ def delete(self, key: str) -> None:
4
+ \"\"\"Drop the entry, and the tombstone the compactor would have read.
5
+
6
+ Args: <- everything below is ceremony
7
+ key: The key of the value to delete
8
+ \"\"\"
9
+
10
+ The summary earns its place — "the tombstone the compactor would have read" is
11
+ not readable off the signature. The `Args:` block does not: every content word
12
+ of every entry is already in the parameter's own name, its annotation, or the
13
+ function name. It is a table of contents for a list of one. (That entry is real:
14
+ `celery/celery/backends/azureblockblob.py:154`.)
15
+
16
+ **The fix is to delete the `Args:` section only**, leaving the summary. That is
17
+ safe, and it was checked against the shipped strict config rather than assumed:
18
+ ruff's D417 (`undocumented-param`) does **not** fire on a Google-style docstring
19
+ with no parameter section at all, so removing the block does not trade one
20
+ finding for another. It is also the whole reason this rule exists and the
21
+ sibling `Returns:` shape does not — deleting a `Returns:` section makes DOC201
22
+ fire, so the only compliant remedy there is deleting a docstring whose summary
23
+ may be the valuable part.
24
+
25
+ **Why SARJ050 cannot reach this.** SARJ050 tests every content word of the
26
+ docstring against the signature stems. The literal header word "args" is a
27
+ content word and no signature contains it, so the mere presence of an `Args:`
28
+ block makes a docstring permanently unflaggable by that rule, whatever the block
29
+ says. Across the first-party corpus **126** functions carry a parsed `Args:`
30
+ block; SARJ050 flags **0** of them.
31
+
32
+ **Never flagged**
33
+
34
+ - **One informative entry protects the whole block.** The test is over every
35
+ entry: a block where three entries restate and the fourth carries a default, a
36
+ unit, an example value or a constraint stays whole. Splitting a parameter
37
+ table is worse than leaving it. Relaxing this to "any entry restates" raises
38
+ the first-party count from 12 to 16 and immediately admits entries documenting
39
+ defaults.
40
+ - **An entry with no description at all** — the bare `name (type):` stub. Every
41
+ one of the 8 first-party instances came from an OpenAPI client generator whose
42
+ output carries no generated-code marker, so a content-only check cannot see
43
+ it; judging a machine-emitted stub tells the author to edit a file that will
44
+ be regenerated. Dropping them is what takes the raw 20 findings to 12.
45
+ - **An empty block, or one no entry parses out of.** Nothing to judge.
46
+ - **Prompt / CLI / route decorators.** For an agent tool the `Args:` block is
47
+ part of the description shipped to the model; for click/typer it is the
48
+ argument help text — the same hard exemption SARJ050 makes.
49
+ - **The protected class and the value markers**, evaluated over the block, so a
50
+ parameter documented with a unit, a status code, an RFC, a ticket or a causal
51
+ clause keeps its whole table.
52
+ - **NumPy-style parameter blocks** (`Parameters` under a `-----` underline).
53
+ `_docstrings` parses Google style only; the first-party corpus holds 2 NumPy
54
+ docstrings in total, which is not enough evidence to tune a second parser.
55
+
56
+ **What counts as "already in the signature".** The function's own name and its
57
+ owning class contribute stems, not just the parameter's. An entry reading
58
+ `token: JWT access token to verify` on a `JwtService.verify_access_token(token: str)`
59
+ shape is a restatement, because "JWT" is on the class the caller types. That is also the
60
+ loosest the test gets: of the 12 first-party findings, exactly one turned on a
61
+ word supplied by the function name rather than the parameter, and it was judged
62
+ borderline-true rather than false.
63
+
64
+ **Measured.** 20 raw findings across 2,440 reviewable first-party files, 8 of
65
+ them generator output that the empty-description guard removes, leaving **12**.
66
+ All 12 were read: **12 true positives, 0 false** (1 borderline, above). The
67
+ dominant shape is an ID parameter documented as its own name in title case.
68
+
69
+ Over 14 OSS repos the predicate finds **864** (langchain 257, mlflow 256,
70
+ dagster 137, litellm 107, prefect 87, celery 8, superset 8, airflow 4, and 0 in
71
+ django, fastapi, saleor, sentry-python, warehouse, zulip). 20 were sampled
72
+ across celery, superset and airflow and read: **20 true positives, 0 false**,
73
+ including `celery/celery/app/task.py:1030` ("sig (Signature): signature to
74
+ replace with."), `celery/celery/backends/cosmosdbsql.py:206`,
75
+ `superset/superset/utils/jinja_template_validator.py:38` ("template_str: The
76
+ template string to validate") and
77
+ `airflow/providers/openlineage/src/airflow/providers/openlineage/utils/spark.py:131`
78
+ ("properties: Spark properties.").
79
+
80
+ Suppress an intentional case with `# sarj-noqa: SARJ086 — <reason>`.
81
+ """
82
+
83
+ from __future__ import annotations
84
+
85
+ import ast
86
+ from typing import TYPE_CHECKING, override
87
+
88
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
89
+ from sarj_python_lint.rules._ast_index import children
90
+ from sarj_python_lint.rules._comments import is_protected
91
+ from sarj_python_lint.rules._docstrings import (
92
+ PROMPT_DECORATOR_MARKERS,
93
+ VALUE_MARKER_RE,
94
+ arg_entries,
95
+ arg_section,
96
+ decorator_markers,
97
+ identifier_stems,
98
+ restates,
99
+ signature_stems,
100
+ )
101
+ from sarj_python_lint.rules._paths import is_generated
102
+
103
+
104
+ if TYPE_CHECKING:
105
+ from pathlib import Path
106
+
107
+
108
+ class DocstringArgsRestateSignature(Rule):
109
+ """An `Args:` block whose every entry only re-spells its own parameter."""
110
+
111
+ id: str = "docstring-args-restate-signature"
112
+ code: str = "SARJ086"
113
+ description: str = (
114
+ "`Args:` block adds nothing the signature does not already say — delete "
115
+ "the section and keep the summary."
116
+ )
117
+
118
+ @override
119
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
120
+ if is_generated(path, source):
121
+ return []
122
+ tree = parse_or_none(path, source)
123
+ if tree is None:
124
+ return []
125
+ diags: list[Diagnostic] = []
126
+ self._walk(tree, None, path, diags)
127
+ return sorted(diags, key=lambda d: d.line)
128
+
129
+ def _walk(self, node: ast.AST, class_name: str | None, path: Path, diags: list[Diagnostic]) -> None:
130
+ for child in children(node):
131
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
132
+ self._check_function(child, class_name, path, diags)
133
+ self._walk(child, class_name, path, diags)
134
+ elif isinstance(child, ast.ClassDef):
135
+ self._walk(child, child.name, path, diags)
136
+ else:
137
+ self._walk(child, class_name, path, diags)
138
+
139
+ def _check_function(
140
+ self,
141
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
142
+ class_name: str | None,
143
+ path: Path,
144
+ diags: list[Diagnostic],
145
+ ) -> None:
146
+ docstring = ast.get_docstring(node, clean=True)
147
+ if not docstring:
148
+ return
149
+ block = arg_section(docstring)
150
+ if block is None or VALUE_MARKER_RE.search(block) or is_protected(block):
151
+ return
152
+ if decorator_markers(node) & PROMPT_DECORATOR_MARKERS:
153
+ return
154
+ entries = arg_entries(block)
155
+ if not entries:
156
+ return
157
+ known = signature_stems(node, class_name)
158
+ for name, annotation, description in entries:
159
+ if not description:
160
+ return # a machine-emitted `name (type):` stub — see the module docstring
161
+ if not restates(description, known | identifier_stems(name) | identifier_stems(annotation)):
162
+ return
163
+ expr = node.body[0]
164
+ diags.append(
165
+ Diagnostic(
166
+ path=path,
167
+ line=expr.lineno,
168
+ col=expr.col_offset + 1,
169
+ code=self.code,
170
+ message=self.description,
171
+ )
172
+ )