claude-dev-env 1.76.0 → 1.78.0
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.
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
- package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
- package/hooks/blocking/code_rules_docstrings.py +730 -0
- package/hooks/blocking/code_rules_enforcer.py +39 -0
- package/hooks/blocking/code_rules_paired_test.py +299 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
- package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +51 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/paired-test-coverage.md +28 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Dead-conditional-branch check for a truthiness test on an always-non-empty split."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
if _hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_directory)
|
|
13
|
+
|
|
14
|
+
from code_rules_path_utils import ( # noqa: E402
|
|
15
|
+
is_config_file,
|
|
16
|
+
)
|
|
17
|
+
from code_rules_shared import ( # noqa: E402
|
|
18
|
+
_collect_annotated_arguments,
|
|
19
|
+
_walk_skipping_nested_function_defs,
|
|
20
|
+
is_test_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
24
|
+
ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES,
|
|
25
|
+
DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX,
|
|
26
|
+
MAX_DEAD_SPLIT_BRANCH_ISSUES,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_separator_split_call(call_node: ast.Call) -> bool:
|
|
31
|
+
"""Return True for an ``x.split(sep)`` / ``x.rsplit(sep)`` with a non-empty separator.
|
|
32
|
+
|
|
33
|
+
``str.split`` and ``bytes.split`` return a list holding at least one element
|
|
34
|
+
whenever a separator argument is supplied, so the result is always truthy.
|
|
35
|
+
Requiring a non-empty string or bytes literal separator keeps the guarantee
|
|
36
|
+
airtight: ``split()`` with no argument can return an empty list, and an
|
|
37
|
+
empty separator raises at runtime. The receiver is restricted to a bare
|
|
38
|
+
``Name`` or a string/bytes literal so an intermediate attribute chain such
|
|
39
|
+
as a pandas ``s.str.split(",")`` — whose result is a Series, not a list —
|
|
40
|
+
is not treated as an always-non-empty split.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
call_node: The call expression on the right-hand side of an assignment.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True when the call is a separator-bearing split that never returns an
|
|
47
|
+
empty list.
|
|
48
|
+
"""
|
|
49
|
+
function_node = call_node.func
|
|
50
|
+
if not isinstance(function_node, ast.Attribute):
|
|
51
|
+
return False
|
|
52
|
+
if function_node.attr not in ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES:
|
|
53
|
+
return False
|
|
54
|
+
receiver_node = function_node.value
|
|
55
|
+
if isinstance(receiver_node, ast.Constant):
|
|
56
|
+
if not isinstance(receiver_node.value, (str, bytes)):
|
|
57
|
+
return False
|
|
58
|
+
elif not isinstance(receiver_node, ast.Name):
|
|
59
|
+
return False
|
|
60
|
+
if not call_node.args:
|
|
61
|
+
return False
|
|
62
|
+
first_argument = call_node.args[0]
|
|
63
|
+
if not isinstance(first_argument, ast.Constant):
|
|
64
|
+
return False
|
|
65
|
+
separator_value = first_argument.value
|
|
66
|
+
if not isinstance(separator_value, (str, bytes)):
|
|
67
|
+
return False
|
|
68
|
+
return len(separator_value) > 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _separator_split_target_names(
|
|
72
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
73
|
+
) -> set[str]:
|
|
74
|
+
"""Return names bound exactly once in the function from a separator split.
|
|
75
|
+
|
|
76
|
+
A name qualifies when its single binding in the function body is an
|
|
77
|
+
assignment whose value is a separator-bearing ``split`` / ``rsplit`` call,
|
|
78
|
+
so the bound list is always truthy. A name bound more than once, or also a
|
|
79
|
+
parameter, is excluded because a later rebinding could make it falsy.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
function_node: The function whose body is inspected.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The set of always-truthy split-result names safe to reason about.
|
|
86
|
+
"""
|
|
87
|
+
all_store_counts: dict[str, int] = {}
|
|
88
|
+
all_split_names: set[str] = set()
|
|
89
|
+
for each_statement in function_node.body:
|
|
90
|
+
for each_node in _walk_skipping_nested_function_defs(each_statement):
|
|
91
|
+
if isinstance(each_node, ast.Name) and isinstance(each_node.ctx, ast.Store):
|
|
92
|
+
all_store_counts[each_node.id] = (
|
|
93
|
+
all_store_counts.get(each_node.id, 0) + 1
|
|
94
|
+
)
|
|
95
|
+
if not isinstance(each_node, ast.Assign):
|
|
96
|
+
continue
|
|
97
|
+
if len(each_node.targets) != 1:
|
|
98
|
+
continue
|
|
99
|
+
assignment_target = each_node.targets[0]
|
|
100
|
+
if not isinstance(assignment_target, ast.Name):
|
|
101
|
+
continue
|
|
102
|
+
if isinstance(each_node.value, ast.Call) and _is_separator_split_call(
|
|
103
|
+
each_node.value
|
|
104
|
+
):
|
|
105
|
+
all_split_names.add(assignment_target.id)
|
|
106
|
+
all_parameter_names = {
|
|
107
|
+
each_argument.arg
|
|
108
|
+
for each_argument in _collect_annotated_arguments(function_node)
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
each_name
|
|
112
|
+
for each_name in all_split_names
|
|
113
|
+
if all_store_counts.get(each_name, 0) == 1
|
|
114
|
+
and each_name not in all_parameter_names
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _truthiness_target(
|
|
119
|
+
test_node: ast.expr, all_candidate_names: set[str]
|
|
120
|
+
) -> str | None:
|
|
121
|
+
"""Return the candidate name a bare ``Name`` truthiness test reads, else None."""
|
|
122
|
+
if isinstance(test_node, ast.Name) and test_node.id in all_candidate_names:
|
|
123
|
+
return test_node.id
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _negated_truthiness_target(
|
|
128
|
+
test_node: ast.expr, all_candidate_names: set[str]
|
|
129
|
+
) -> str | None:
|
|
130
|
+
"""Return the candidate name a ``not Name`` test reads, else None."""
|
|
131
|
+
if not isinstance(test_node, ast.UnaryOp) or not isinstance(test_node.op, ast.Not):
|
|
132
|
+
return None
|
|
133
|
+
operand = test_node.operand
|
|
134
|
+
if isinstance(operand, ast.Name) and operand.id in all_candidate_names:
|
|
135
|
+
return operand.id
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _branch_finding_for_node(
|
|
140
|
+
node: ast.AST, all_candidate_names: set[str]
|
|
141
|
+
) -> tuple[int, str] | None:
|
|
142
|
+
"""Return ``(line, name)`` when a node carries a dead branch, else None.
|
|
143
|
+
|
|
144
|
+
A conditional expression always carries an else arm, so any truthiness test
|
|
145
|
+
on a candidate name is a dead branch. An ``if`` statement only carries a
|
|
146
|
+
dead else when it has one, while an ``if not name`` statement always has a
|
|
147
|
+
dead body.
|
|
148
|
+
"""
|
|
149
|
+
if isinstance(node, ast.IfExp):
|
|
150
|
+
truthy_name = _truthiness_target(node.test, all_candidate_names)
|
|
151
|
+
if truthy_name is not None:
|
|
152
|
+
return node.lineno, truthy_name
|
|
153
|
+
return _negated_branch_finding(node.test, node.lineno, all_candidate_names)
|
|
154
|
+
if isinstance(node, ast.If):
|
|
155
|
+
truthy_name = _truthiness_target(node.test, all_candidate_names)
|
|
156
|
+
if truthy_name is not None and node.orelse:
|
|
157
|
+
return node.lineno, truthy_name
|
|
158
|
+
return _negated_branch_finding(node.test, node.lineno, all_candidate_names)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _negated_branch_finding(
|
|
163
|
+
test_node: ast.expr, line_number: int, all_candidate_names: set[str]
|
|
164
|
+
) -> tuple[int, str] | None:
|
|
165
|
+
negated_name = _negated_truthiness_target(test_node, all_candidate_names)
|
|
166
|
+
if negated_name is not None:
|
|
167
|
+
return line_number, negated_name
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _dead_branch_findings(
|
|
172
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
173
|
+
all_candidate_names: set[str],
|
|
174
|
+
) -> list[tuple[int, str]]:
|
|
175
|
+
"""Return every ``(line, name)`` dead-branch finding in the function body."""
|
|
176
|
+
all_findings: list[tuple[int, str]] = []
|
|
177
|
+
for each_statement in function_node.body:
|
|
178
|
+
for each_node in _walk_skipping_nested_function_defs(each_statement):
|
|
179
|
+
finding = _branch_finding_for_node(each_node, all_candidate_names)
|
|
180
|
+
if finding is not None:
|
|
181
|
+
all_findings.append(finding)
|
|
182
|
+
return all_findings
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_dead_split_truthiness_branch(content: str, file_path: str) -> list[str]:
|
|
186
|
+
"""Flag a conditional whose branch is unreachable after a separator split.
|
|
187
|
+
|
|
188
|
+
``str.split(sep)`` and ``bytes.split(sep)`` with a separator always return a
|
|
189
|
+
list holding at least one element, so a value bound from such a call is
|
|
190
|
+
always truthy. A conditional that tests that value's truthiness carries a
|
|
191
|
+
dead branch: ``parts[0] if parts else fallback`` never reaches ``fallback``,
|
|
192
|
+
and ``if not parts:`` never runs its body. Both the conditional-expression
|
|
193
|
+
form and the ``if`` / ``if not`` statement form are reported, scoped to a
|
|
194
|
+
value bound exactly once in the function from a separator split. Config
|
|
195
|
+
files and test files are exempt.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
content: The source text to inspect.
|
|
199
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
One issue per dead conditional branch found, capped at the module limit.
|
|
203
|
+
"""
|
|
204
|
+
if is_test_file(file_path) or is_config_file(file_path):
|
|
205
|
+
return []
|
|
206
|
+
try:
|
|
207
|
+
tree = ast.parse(content)
|
|
208
|
+
except SyntaxError:
|
|
209
|
+
return []
|
|
210
|
+
issues: list[str] = []
|
|
211
|
+
for each_function_node in ast.walk(tree):
|
|
212
|
+
if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
+
continue
|
|
214
|
+
all_candidate_names = _separator_split_target_names(each_function_node)
|
|
215
|
+
if not all_candidate_names:
|
|
216
|
+
continue
|
|
217
|
+
for each_line, each_name in _dead_branch_findings(
|
|
218
|
+
each_function_node, all_candidate_names
|
|
219
|
+
):
|
|
220
|
+
issues.append(
|
|
221
|
+
f"Line {each_line}: {each_name!r} {DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX}"
|
|
222
|
+
)
|
|
223
|
+
if len(issues) >= MAX_DEAD_SPLIT_BRANCH_ISSUES:
|
|
224
|
+
return issues
|
|
225
|
+
return issues
|