claude-dev-env 1.58.0 → 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.
Files changed (52) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +100 -27
  10. package/bin/install.test.mjs +133 -1
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  21. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  22. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  24. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  25. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  26. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  28. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  29. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  30. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  31. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  32. package/hooks/hooks.json +15 -0
  33. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  34. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  35. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  36. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  37. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  38. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  39. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  40. package/package.json +1 -1
  41. package/rules/docstring-prose-matches-implementation.md +43 -0
  42. package/rules/hook-prose-matches-detector.md +26 -0
  43. package/rules/no-inline-destructive-literals.md +11 -0
  44. package/rules/workflow-substitution-slots.md +7 -0
  45. package/skills/autoconverge/SKILL.md +13 -2
  46. package/skills/autoconverge/reference/convergence.md +7 -3
  47. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  49. package/skills/autoconverge/workflow/converge.mjs +106 -36
  50. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  51. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  52. 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
- if not _is_validated_target(target_path):
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
- old_content = _read_existing_file_content(target_path) or ""
452
- all_issues = validate_content(candidate_content, target_path, old_content)
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[:10])
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[:10])
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
- deny_payload = {
636
- "hookSpecificOutput": {
637
- "hookEventName": "PreToolUse",
638
- "permissionDecision": "deny",
639
- "permissionDecisionReason": _deny_reason_for_issues(
640
- all_blocking_issues,
641
- tool_name,
642
- file_path,
643
- full_file_content_after_edit,
644
- prior_full_file_content,
645
- ),
646
- }
647
- }
648
- deny_stream.write(json.dumps(deny_payload) + "\n")
649
- deny_stream.flush()
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
- if not _is_validated_target(file_path):
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,