claude-dev-env 1.57.2 → 1.59.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/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +317 -54
- package/bin/install.test.mjs +478 -3
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +68 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -38
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/update/SKILL.md +37 -5
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Cross-file duplicate top-level function body detection.
|
|
2
|
+
|
|
3
|
+
The check flags a top-level function in the file being written whose body is
|
|
4
|
+
structurally identical to a top-level function already defined in a sibling
|
|
5
|
+
``.py`` module in the same directory. This catches the Reuse-before-create / DRY
|
|
6
|
+
violation where a helper is copy-pasted across several modules instead of being
|
|
7
|
+
imported from one shared home.
|
|
8
|
+
|
|
9
|
+
The scan is deliberately conservative to keep false positives near zero:
|
|
10
|
+
|
|
11
|
+
- Only module-scope ``def`` / ``async def`` bodies are compared (the copied-helper
|
|
12
|
+
case), never methods nested in a class.
|
|
13
|
+
- Bodies are compared by their normalized AST structure with the leading
|
|
14
|
+
docstring dropped, so reformatting and comment differences do not hide a copy.
|
|
15
|
+
The comparison keeps identifier names, so a match requires the body statements,
|
|
16
|
+
including local variable names, to be structurally identical; it does not
|
|
17
|
+
consider the parameter list, decorators, or whether the function is ``async``.
|
|
18
|
+
- A body must contain at least ``MINIMUM_DUPLICATE_BODY_STATEMENTS`` statements;
|
|
19
|
+
trivial one- or two-line helpers (``return None``, a single delegation) are too
|
|
20
|
+
common to flag.
|
|
21
|
+
- Test files and ``__init__.py`` re-export surfaces never participate, on either
|
|
22
|
+
the writing side or the sibling side.
|
|
23
|
+
|
|
24
|
+
Unlike most code-rules checks, this one runs on hook-infrastructure files: the
|
|
25
|
+
copied-helper violation it targets appears most often in the ``blocking/`` hook
|
|
26
|
+
directory itself, so gating it behind the hook-infrastructure exemption would
|
|
27
|
+
leave the exact violation class unguarded. The enforcer entry points route a
|
|
28
|
+
hook ``.py`` target to this single check even though the full code-rules verdict
|
|
29
|
+
stays off hook infrastructure, so a Write or pre-check against a file under the
|
|
30
|
+
``blocking/`` directory still blocks a copied sibling helper.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
38
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
39
|
+
if _blocking_directory not in sys.path:
|
|
40
|
+
sys.path.insert(0, _blocking_directory)
|
|
41
|
+
if _hooks_directory not in sys.path:
|
|
42
|
+
sys.path.insert(0, _hooks_directory)
|
|
43
|
+
|
|
44
|
+
from code_rules_shared import ( # noqa: E402
|
|
45
|
+
_scope_violations_to_changed_lines,
|
|
46
|
+
is_test_file,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from hooks_constants.duplicate_function_body_constants import ( # noqa: E402
|
|
50
|
+
DUNDER_INIT_FILENAME,
|
|
51
|
+
DUPLICATE_BODY_GUIDANCE,
|
|
52
|
+
MAX_DUPLICATE_BODY_ISSUES,
|
|
53
|
+
MINIMUM_DUPLICATE_BODY_STATEMENTS,
|
|
54
|
+
PYTHON_SOURCE_SUFFIX,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _normalized_body_signature(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> str | None:
|
|
59
|
+
"""Return a position-independent structural fingerprint of the function body.
|
|
60
|
+
|
|
61
|
+
The docstring statement, when present, is dropped so two copies that differ
|
|
62
|
+
only in their docstring still collide. Returns None when the remaining body
|
|
63
|
+
is shorter than the minimum statement count, which signals the caller to skip
|
|
64
|
+
this function as too trivial to be a meaningful duplicate.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
function_node: The module-scope function definition to fingerprint.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A normalized AST dump of the body statements, or None when the body is
|
|
71
|
+
too small to compare.
|
|
72
|
+
"""
|
|
73
|
+
body_statements = list(function_node.body)
|
|
74
|
+
if body_statements and isinstance(body_statements[0], ast.Expr):
|
|
75
|
+
first_value = body_statements[0].value
|
|
76
|
+
if isinstance(first_value, ast.Constant) and isinstance(first_value.value, str):
|
|
77
|
+
body_statements = body_statements[1:]
|
|
78
|
+
if len(body_statements) < MINIMUM_DUPLICATE_BODY_STATEMENTS:
|
|
79
|
+
return None
|
|
80
|
+
return "\n".join(
|
|
81
|
+
ast.dump(each_statement, annotate_fields=False) for each_statement in body_statements
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _top_level_function_signatures(tree: ast.Module) -> dict[str, str]:
|
|
86
|
+
"""Map each module-scope function name to its normalized body signature.
|
|
87
|
+
|
|
88
|
+
Functions whose body is too trivial to compare are omitted.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
tree: The parsed module.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A name-to-signature mapping for the comparable top-level functions.
|
|
95
|
+
"""
|
|
96
|
+
signature_by_name: dict[str, str] = {}
|
|
97
|
+
for each_node in tree.body:
|
|
98
|
+
if isinstance(each_node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
99
|
+
body_signature = _normalized_body_signature(each_node)
|
|
100
|
+
if body_signature is not None:
|
|
101
|
+
signature_by_name[each_node.name] = body_signature
|
|
102
|
+
return signature_by_name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _function_definition_span(
|
|
106
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
107
|
+
) -> range:
|
|
108
|
+
"""Return the inclusive 1-indexed source-line span of a function definition.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
function_node: The module-scope function definition.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
A range covering the signature line through the last body line, so a
|
|
115
|
+
changed-line set intersects the span when an edit touches any line of the
|
|
116
|
+
function — mirroring the span scoping the sibling whole-file checks use.
|
|
117
|
+
"""
|
|
118
|
+
last_line = function_node.end_lineno or function_node.lineno
|
|
119
|
+
return range(function_node.lineno, last_line + 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _top_level_function_signature_spans(
|
|
123
|
+
tree: ast.Module,
|
|
124
|
+
) -> dict[str, tuple[str, range]]:
|
|
125
|
+
"""Map each comparable module-scope function to its signature and source span.
|
|
126
|
+
|
|
127
|
+
Functions whose body is too trivial to compare are omitted.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
tree: The parsed module being written.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A name-to-``(signature, span)`` mapping for the comparable top-level
|
|
134
|
+
functions, where the span covers the function's source lines.
|
|
135
|
+
"""
|
|
136
|
+
signature_span_by_name: dict[str, tuple[str, range]] = {}
|
|
137
|
+
for each_node in tree.body:
|
|
138
|
+
if isinstance(each_node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
139
|
+
body_signature = _normalized_body_signature(each_node)
|
|
140
|
+
if body_signature is not None:
|
|
141
|
+
signature_span_by_name[each_node.name] = (
|
|
142
|
+
body_signature,
|
|
143
|
+
_function_definition_span(each_node),
|
|
144
|
+
)
|
|
145
|
+
return signature_span_by_name
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_comparable_sibling(sibling_path: Path, written_file_name: str) -> bool:
|
|
149
|
+
"""Return whether a directory entry is a sibling module worth comparing against.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
sibling_path: A candidate path from the written file's directory.
|
|
153
|
+
written_file_name: The base name of the file being written.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True for a Python source file other than the written file itself,
|
|
157
|
+
excluding ``__init__.py`` and test modules.
|
|
158
|
+
"""
|
|
159
|
+
if not sibling_path.is_file():
|
|
160
|
+
return False
|
|
161
|
+
if sibling_path.suffix != PYTHON_SOURCE_SUFFIX:
|
|
162
|
+
return False
|
|
163
|
+
if sibling_path.name == written_file_name:
|
|
164
|
+
return False
|
|
165
|
+
if sibling_path.name == DUNDER_INIT_FILENAME:
|
|
166
|
+
return False
|
|
167
|
+
return not is_test_file(sibling_path.name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sibling_signatures(
|
|
171
|
+
file_path: str,
|
|
172
|
+
sibling_directory: Path | None = None,
|
|
173
|
+
) -> dict[str, list[str]]:
|
|
174
|
+
"""Collect normalized body signatures from every comparable sibling module.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
file_path: The path of the file being written.
|
|
178
|
+
sibling_directory: An absolute directory to scan for sibling modules.
|
|
179
|
+
When None, the directory is derived from ``file_path``'s parent,
|
|
180
|
+
which resolves against the process CWD for a relative ``file_path``.
|
|
181
|
+
The commit/push gate passes the resolved file's parent so sibling
|
|
182
|
+
resolution stays anchored to the repository regardless of the gate
|
|
183
|
+
process's working directory.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
A signature-to-source-names mapping, where the value lists the
|
|
187
|
+
``module.py::function`` locations carrying that body.
|
|
188
|
+
"""
|
|
189
|
+
written_path = Path(file_path)
|
|
190
|
+
directory = written_path.parent if sibling_directory is None else sibling_directory
|
|
191
|
+
source_names_by_signature: dict[str, list[str]] = {}
|
|
192
|
+
try:
|
|
193
|
+
all_entries = sorted(directory.iterdir())
|
|
194
|
+
except OSError:
|
|
195
|
+
return {}
|
|
196
|
+
for each_entry in all_entries:
|
|
197
|
+
if not _is_comparable_sibling(each_entry, written_path.name):
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
sibling_source = each_entry.read_text(encoding="utf-8")
|
|
201
|
+
sibling_tree = ast.parse(sibling_source)
|
|
202
|
+
except (OSError, UnicodeDecodeError, SyntaxError):
|
|
203
|
+
continue
|
|
204
|
+
for each_name, each_signature in _top_level_function_signatures(sibling_tree).items():
|
|
205
|
+
location = f"{each_entry.name}::{each_name}"
|
|
206
|
+
source_names_by_signature.setdefault(each_signature, []).append(location)
|
|
207
|
+
return source_names_by_signature
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_duplicate_function_body_across_files(
|
|
211
|
+
content: str,
|
|
212
|
+
file_path: str,
|
|
213
|
+
all_changed_lines: set[int] | None = None,
|
|
214
|
+
defer_scope_to_caller: bool = False,
|
|
215
|
+
sibling_directory: Path | None = None,
|
|
216
|
+
) -> list[str]:
|
|
217
|
+
"""Flag top-level functions copied byte-for-structure from a sibling module.
|
|
218
|
+
|
|
219
|
+
Compares each module-scope function in the post-edit content against the
|
|
220
|
+
top-level functions of every comparable ``.py`` sibling in the same
|
|
221
|
+
directory, and reports any whose normalized body matches. Test files and
|
|
222
|
+
``__init__.py`` are skipped on both sides.
|
|
223
|
+
|
|
224
|
+
Violations are scoped to the lines an edit touched the same way the sibling
|
|
225
|
+
whole-file checks scope theirs: an Edit blocks only on a duplicated function
|
|
226
|
+
whose source span intersects the changed lines, so an unrelated edit to a
|
|
227
|
+
file that already carries a byte-identical entrypoint shim in a sibling
|
|
228
|
+
module does not block, while a Write that newly copies a sibling helper still
|
|
229
|
+
flags because every line is in scope.
|
|
230
|
+
|
|
231
|
+
Unlike the sibling whole-file checks, this check carries no
|
|
232
|
+
``is_hook_infrastructure`` exemption: the copied-helper violation it targets
|
|
233
|
+
appears most often in the ``blocking/`` hook directory itself.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
content: The full post-edit file content being written.
|
|
237
|
+
file_path: The destination path of the write.
|
|
238
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
239
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
240
|
+
blocks only when the duplicated function's source span intersects the
|
|
241
|
+
changed lines.
|
|
242
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
243
|
+
commit/push gate's ``split_violations_by_scope`` can scope by added
|
|
244
|
+
line.
|
|
245
|
+
sibling_directory: An absolute directory to scan for sibling modules.
|
|
246
|
+
When None, the directory is derived from ``file_path``'s parent. The
|
|
247
|
+
PreToolUse path leaves this None because its ``file_path`` is already
|
|
248
|
+
absolute; the commit/push gate passes the resolved file's parent so
|
|
249
|
+
the sibling scan stays anchored to the repository regardless of the
|
|
250
|
+
gate process's working directory.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Human-readable violation strings, one per duplicated function, scoped to
|
|
254
|
+
the changed lines unless *defer_scope_to_caller* is True or
|
|
255
|
+
*all_changed_lines* is None.
|
|
256
|
+
"""
|
|
257
|
+
written_name = Path(file_path).name
|
|
258
|
+
if written_name == DUNDER_INIT_FILENAME:
|
|
259
|
+
return []
|
|
260
|
+
if is_test_file(file_path):
|
|
261
|
+
return []
|
|
262
|
+
try:
|
|
263
|
+
written_tree = ast.parse(content)
|
|
264
|
+
except SyntaxError:
|
|
265
|
+
return []
|
|
266
|
+
written_signature_spans = _top_level_function_signature_spans(written_tree)
|
|
267
|
+
if not written_signature_spans:
|
|
268
|
+
return []
|
|
269
|
+
source_names_by_signature = _sibling_signatures(file_path, sibling_directory)
|
|
270
|
+
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
271
|
+
for each_name, (each_signature, each_span) in written_signature_spans.items():
|
|
272
|
+
matching_locations = source_names_by_signature.get(each_signature)
|
|
273
|
+
if not matching_locations:
|
|
274
|
+
continue
|
|
275
|
+
first_location = matching_locations[0]
|
|
276
|
+
message = (
|
|
277
|
+
f"Function {each_name!r} duplicates {first_location} — {DUPLICATE_BODY_GUIDANCE} "
|
|
278
|
+
f"(duplicate body span at line {each_span.start}, spanning {len(each_span)} lines)"
|
|
279
|
+
)
|
|
280
|
+
all_violations_in_walk_order.append((each_span, message))
|
|
281
|
+
if len(all_violations_in_walk_order) >= MAX_DUPLICATE_BODY_ISSUES:
|
|
282
|
+
break
|
|
283
|
+
return _scope_violations_to_changed_lines(
|
|
284
|
+
all_violations_in_walk_order,
|
|
285
|
+
all_changed_lines,
|
|
286
|
+
defer_scope_to_caller,
|
|
287
|
+
)
|
|
@@ -30,6 +30,7 @@ if _HOOKS_DIRECTORY not in sys.path:
|
|
|
30
30
|
|
|
31
31
|
from code_rules_annotations_length import ( # noqa: E402
|
|
32
32
|
check_function_length,
|
|
33
|
+
check_known_pytest_fixture_annotations,
|
|
33
34
|
check_parameter_annotations,
|
|
34
35
|
check_return_annotations,
|
|
35
36
|
)
|
|
@@ -50,10 +51,16 @@ from code_rules_constants_config import ( # noqa: E402
|
|
|
50
51
|
check_constants_outside_config_advisory,
|
|
51
52
|
check_file_global_constants_use_count,
|
|
52
53
|
)
|
|
54
|
+
from code_rules_dead_dataclass_field import ( # noqa: E402
|
|
55
|
+
check_dead_dataclass_fields,
|
|
56
|
+
)
|
|
53
57
|
from code_rules_docstrings import ( # noqa: E402
|
|
54
58
|
check_docstring_args_match_signature,
|
|
55
59
|
check_docstring_format,
|
|
56
60
|
)
|
|
61
|
+
from code_rules_duplicate_body import ( # noqa: E402
|
|
62
|
+
check_duplicate_function_body_across_files,
|
|
63
|
+
)
|
|
57
64
|
from code_rules_imports_logging import ( # noqa: E402
|
|
58
65
|
advise_file_line_count,
|
|
59
66
|
check_e2e_test_naming,
|
|
@@ -122,6 +129,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
122
129
|
ALL_CODE_EXTENSIONS,
|
|
123
130
|
ALL_JAVASCRIPT_EXTENSIONS,
|
|
124
131
|
ALL_PYTHON_EXTENSIONS,
|
|
132
|
+
DENY_REASON_ISSUE_PREVIEW_COUNT,
|
|
125
133
|
PRECHECK_USAGE_EXIT_CODE,
|
|
126
134
|
PRECHECK_USAGE_MESSAGE,
|
|
127
135
|
)
|
|
@@ -137,6 +145,7 @@ def validate_content(
|
|
|
137
145
|
full_file_content: str | None = None,
|
|
138
146
|
prior_full_file_content: str = "",
|
|
139
147
|
defer_scope_to_caller: bool = False,
|
|
148
|
+
sibling_directory: Path | None = None,
|
|
140
149
|
) -> list[str]:
|
|
141
150
|
"""Run all applicable validators on content.
|
|
142
151
|
|
|
@@ -167,6 +176,12 @@ def validate_content(
|
|
|
167
176
|
checks return their violations unscoped for the gate to classify.
|
|
168
177
|
PreToolUse new-file or full-file writes leave this False: this
|
|
169
178
|
enforcer is terminal, so it marks every violation in scope.
|
|
179
|
+
sibling_directory: The absolute directory the cross-file duplicate-body
|
|
180
|
+
check scans for sibling modules. The commit/push gate passes the
|
|
181
|
+
resolved file's parent so the on-disk sibling scan stays anchored to
|
|
182
|
+
the repository regardless of the gate process's working directory.
|
|
183
|
+
None (the PreToolUse default) derives the directory from
|
|
184
|
+
``file_path``'s parent, which is already absolute on that path.
|
|
170
185
|
"""
|
|
171
186
|
extension = get_file_extension(file_path)
|
|
172
187
|
all_issues = []
|
|
@@ -188,6 +203,15 @@ def validate_content(
|
|
|
188
203
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
189
204
|
all_issues.extend(check_constants_outside_config_advisory(content, file_path))
|
|
190
205
|
all_issues.extend(check_file_global_constants_use_count(content, file_path))
|
|
206
|
+
all_issues.extend(
|
|
207
|
+
check_duplicate_function_body_across_files(
|
|
208
|
+
effective_content,
|
|
209
|
+
file_path,
|
|
210
|
+
all_changed_lines,
|
|
211
|
+
defer_scope_to_caller,
|
|
212
|
+
sibling_directory,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
191
215
|
all_issues.extend(check_type_escape_hatches(effective_content, file_path))
|
|
192
216
|
all_issues.extend(check_banned_identifiers(content, file_path))
|
|
193
217
|
all_issues.extend(
|
|
@@ -242,8 +266,12 @@ def validate_content(
|
|
|
242
266
|
all_issues.extend(
|
|
243
267
|
check_unused_module_level_imports(content, file_path, full_file_content)
|
|
244
268
|
)
|
|
269
|
+
all_issues.extend(
|
|
270
|
+
check_dead_dataclass_fields(content, file_path, full_file_content)
|
|
271
|
+
)
|
|
245
272
|
all_issues.extend(check_library_print(content, file_path))
|
|
246
273
|
all_issues.extend(check_parameter_annotations(content, file_path))
|
|
274
|
+
all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
|
|
247
275
|
all_issues.extend(check_return_annotations(content, file_path))
|
|
248
276
|
all_issues.extend(
|
|
249
277
|
check_function_length(
|
|
@@ -334,6 +362,64 @@ def _is_validated_target(file_path: str) -> bool:
|
|
|
334
362
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
335
363
|
|
|
336
364
|
|
|
365
|
+
def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
366
|
+
"""Return whether the path is a hook-infrastructure Python file.
|
|
367
|
+
|
|
368
|
+
The full code-rules suite exempts hook-infrastructure files, but the
|
|
369
|
+
cross-file duplicate-body check must still guard them: a helper copied
|
|
370
|
+
across sibling hook modules is the exact violation it targets. This
|
|
371
|
+
predicate selects the hook ``.py`` files that route to that single check.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
file_path: The destination path of the write, edit, or pre-check target.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
True when the path names a Python file inside hook infrastructure.
|
|
378
|
+
"""
|
|
379
|
+
if not file_path:
|
|
380
|
+
return False
|
|
381
|
+
if not is_hook_infrastructure(file_path):
|
|
382
|
+
return False
|
|
383
|
+
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _hook_infrastructure_duplicate_body_issues(
|
|
387
|
+
content: str,
|
|
388
|
+
file_path: str,
|
|
389
|
+
full_file_content: str | None = None,
|
|
390
|
+
prior_full_file_content: str = "",
|
|
391
|
+
) -> list[str]:
|
|
392
|
+
"""Run only the cross-file duplicate-body check for a hook Python target.
|
|
393
|
+
|
|
394
|
+
The whole code-rules verdict stays off hook-infrastructure files, so this
|
|
395
|
+
runs the single check that must still guard them, span-scoped to the lines
|
|
396
|
+
an edit touched exactly as ``validate_content`` scopes it for production
|
|
397
|
+
code.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
content: The fragment or whole-file body under validation.
|
|
401
|
+
file_path: The hook-infrastructure destination path.
|
|
402
|
+
full_file_content: The reconstructed post-edit file body on an Edit, or
|
|
403
|
+
None for a whole-file Write.
|
|
404
|
+
prior_full_file_content: The file content before the edit applied, used to
|
|
405
|
+
recover the changed lines on an Edit.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
The in-scope duplicate-body violations for the target.
|
|
409
|
+
"""
|
|
410
|
+
effective_content = content if full_file_content is None else full_file_content
|
|
411
|
+
all_changed_lines = (
|
|
412
|
+
changed_line_numbers(prior_full_file_content, full_file_content)
|
|
413
|
+
if full_file_content is not None
|
|
414
|
+
else None
|
|
415
|
+
)
|
|
416
|
+
return check_duplicate_function_body_across_files(
|
|
417
|
+
effective_content,
|
|
418
|
+
file_path,
|
|
419
|
+
all_changed_lines,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
337
423
|
def _without_line_prefix(violation_text: str) -> str:
|
|
338
424
|
"""Return the violation message body with its ``Line <n>: `` locator removed.
|
|
339
425
|
|
|
@@ -441,15 +527,22 @@ def _run_precheck(
|
|
|
441
527
|
Exit code 1 when any violation exists or the candidate cannot be read,
|
|
442
528
|
and 0 when the candidate is clean or the target is exempt.
|
|
443
529
|
"""
|
|
444
|
-
|
|
530
|
+
runs_full_verdict = _is_validated_target(target_path)
|
|
531
|
+
runs_hook_duplicate_body = _is_hook_infrastructure_python_target(target_path)
|
|
532
|
+
if not runs_full_verdict and not runs_hook_duplicate_body:
|
|
445
533
|
return 0
|
|
446
534
|
candidate_content = _read_existing_file_content(candidate_path)
|
|
447
535
|
if candidate_content is None:
|
|
448
536
|
error_stream.write(f"error: cannot read candidate file: {candidate_path}\n")
|
|
449
537
|
return 1
|
|
450
538
|
candidate_content = candidate_content.lstrip(UTF8_BYTE_ORDER_MARK)
|
|
451
|
-
|
|
452
|
-
|
|
539
|
+
if runs_full_verdict:
|
|
540
|
+
old_content = _read_existing_file_content(target_path) or ""
|
|
541
|
+
all_issues = validate_content(candidate_content, target_path, old_content)
|
|
542
|
+
else:
|
|
543
|
+
all_issues = _hook_infrastructure_duplicate_body_issues(
|
|
544
|
+
candidate_content, target_path
|
|
545
|
+
)
|
|
453
546
|
for each_issue in all_issues:
|
|
454
547
|
violation_stream.write(f"{each_issue}\n")
|
|
455
548
|
return 1 if all_issues else 0
|
|
@@ -576,7 +669,7 @@ def _deny_reason_for_issues(
|
|
|
576
669
|
Returns:
|
|
577
670
|
The complete ``permissionDecisionReason`` text.
|
|
578
671
|
"""
|
|
579
|
-
issue_list = "; ".join(all_blocking_issues[:
|
|
672
|
+
issue_list = "; ".join(all_blocking_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
580
673
|
deny_reason = (
|
|
581
674
|
f"BLOCKED: [CODE_RULES] {len(all_blocking_issues)} violation(s): {issue_list}"
|
|
582
675
|
)
|
|
@@ -593,7 +686,7 @@ def _deny_reason_for_issues(
|
|
|
593
686
|
all_blocking_issues=all_blocking_issues,
|
|
594
687
|
)
|
|
595
688
|
if forecast_issues:
|
|
596
|
-
forecast_list = "; ".join(forecast_issues[:
|
|
689
|
+
forecast_list = "; ".join(forecast_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
597
690
|
deny_reason += (
|
|
598
691
|
f"; FULL-FILE FORECAST — {len(forecast_issues)} additional "
|
|
599
692
|
"violation(s) elsewhere in this file will block future edits "
|
|
@@ -602,6 +695,24 @@ def _deny_reason_for_issues(
|
|
|
602
695
|
return deny_reason + _precheck_hint()
|
|
603
696
|
|
|
604
697
|
|
|
698
|
+
def _write_deny_payload(deny_reason: str, deny_stream: TextIO) -> None:
|
|
699
|
+
"""Write a PreToolUse deny payload carrying the given reason.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
deny_reason: The composed ``permissionDecisionReason`` text.
|
|
703
|
+
deny_stream: The stream the JSON deny payload is written to.
|
|
704
|
+
"""
|
|
705
|
+
deny_payload = {
|
|
706
|
+
"hookSpecificOutput": {
|
|
707
|
+
"hookEventName": "PreToolUse",
|
|
708
|
+
"permissionDecision": "deny",
|
|
709
|
+
"permissionDecisionReason": deny_reason,
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
deny_stream.write(json.dumps(deny_payload) + "\n")
|
|
713
|
+
deny_stream.flush()
|
|
714
|
+
|
|
715
|
+
|
|
605
716
|
def _report_blocking_violations(
|
|
606
717
|
content: str,
|
|
607
718
|
tool_name: str,
|
|
@@ -632,21 +743,53 @@ def _report_blocking_violations(
|
|
|
632
743
|
)
|
|
633
744
|
if not all_blocking_issues:
|
|
634
745
|
return
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
746
|
+
_write_deny_payload(
|
|
747
|
+
_deny_reason_for_issues(
|
|
748
|
+
all_blocking_issues,
|
|
749
|
+
tool_name,
|
|
750
|
+
file_path,
|
|
751
|
+
full_file_content_after_edit,
|
|
752
|
+
prior_full_file_content,
|
|
753
|
+
),
|
|
754
|
+
deny_stream,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _report_hook_duplicate_body(
|
|
759
|
+
content: str,
|
|
760
|
+
file_path: str,
|
|
761
|
+
full_file_content_after_edit: str | None,
|
|
762
|
+
prior_full_file_content: str,
|
|
763
|
+
deny_stream: TextIO,
|
|
764
|
+
) -> None:
|
|
765
|
+
"""Write a deny payload when a hook target copies a sibling function body.
|
|
766
|
+
|
|
767
|
+
The full code-rules verdict stays off hook-infrastructure files; this runs
|
|
768
|
+
the single duplicate-body check that must still guard them and emits the deny
|
|
769
|
+
payload when it fires.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
content: The fragment or whole-file body under validation.
|
|
773
|
+
file_path: The hook-infrastructure destination path.
|
|
774
|
+
full_file_content_after_edit: The reconstructed post-edit file body,
|
|
775
|
+
or None when the payload is not an Edit.
|
|
776
|
+
prior_full_file_content: The on-disk content before the edit.
|
|
777
|
+
deny_stream: The stream the JSON deny payload is written to.
|
|
778
|
+
"""
|
|
779
|
+
all_blocking_issues = _hook_infrastructure_duplicate_body_issues(
|
|
780
|
+
content,
|
|
781
|
+
file_path,
|
|
782
|
+
full_file_content_after_edit,
|
|
783
|
+
prior_full_file_content,
|
|
784
|
+
)
|
|
785
|
+
if not all_blocking_issues:
|
|
786
|
+
return
|
|
787
|
+
issue_list = "; ".join(all_blocking_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
788
|
+
deny_reason = (
|
|
789
|
+
f"BLOCKED: [CODE_RULES] {len(all_blocking_issues)} violation(s): {issue_list}"
|
|
790
|
+
+ _precheck_hint()
|
|
791
|
+
)
|
|
792
|
+
_write_deny_payload(deny_reason, deny_stream)
|
|
650
793
|
|
|
651
794
|
|
|
652
795
|
def main(all_arguments: list[str]) -> None:
|
|
@@ -671,7 +814,8 @@ def main(all_arguments: list[str]) -> None:
|
|
|
671
814
|
tool_input = pretooluse_payload.get("tool_input", {})
|
|
672
815
|
file_path = tool_input.get("file_path", "")
|
|
673
816
|
|
|
674
|
-
|
|
817
|
+
runs_full_verdict = _is_validated_target(file_path)
|
|
818
|
+
if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
|
|
675
819
|
sys.exit(0)
|
|
676
820
|
|
|
677
821
|
validation_contents = _contents_for_validation(
|
|
@@ -690,6 +834,16 @@ def main(all_arguments: list[str]) -> None:
|
|
|
690
834
|
if not content:
|
|
691
835
|
sys.exit(0)
|
|
692
836
|
|
|
837
|
+
if not runs_full_verdict:
|
|
838
|
+
_report_hook_duplicate_body(
|
|
839
|
+
content,
|
|
840
|
+
file_path,
|
|
841
|
+
full_file_content_after_edit,
|
|
842
|
+
prior_full_file_content,
|
|
843
|
+
sys.stdout,
|
|
844
|
+
)
|
|
845
|
+
sys.exit(0)
|
|
846
|
+
|
|
693
847
|
_report_blocking_violations(
|
|
694
848
|
content,
|
|
695
849
|
tool_name,
|