claude-dev-env 1.59.0 → 1.60.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 (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -54,11 +54,15 @@ from code_rules_constants_config import ( # noqa: E402
54
54
  from code_rules_dead_dataclass_field import ( # noqa: E402
55
55
  check_dead_dataclass_fields,
56
56
  )
57
+ from code_rules_dead_module_constant import ( # noqa: E402
58
+ check_dead_module_constants,
59
+ )
57
60
  from code_rules_docstrings import ( # noqa: E402
58
61
  check_docstring_args_match_signature,
59
62
  check_docstring_format,
60
63
  )
61
64
  from code_rules_duplicate_body import ( # noqa: E402
65
+ advise_cross_skill_duplicate_helper,
62
66
  check_duplicate_function_body_across_files,
63
67
  )
64
68
  from code_rules_imports_logging import ( # noqa: E402
@@ -120,6 +124,7 @@ from code_rules_typeddict_stub import ( # noqa: E402
120
124
  check_stub_implementations,
121
125
  check_thin_wrapper_files,
122
126
  check_typed_dict_encode_decode,
127
+ check_zero_payload_function_alias,
123
128
  )
124
129
  from code_rules_unused_imports import ( # noqa: E402
125
130
  check_unused_module_level_imports,
@@ -228,6 +233,7 @@ def validate_content(
228
233
  all_issues.extend(check_test_branching_in_production(effective_content, file_path))
229
234
  all_issues.extend(check_bare_except(effective_content, file_path))
230
235
  all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
236
+ all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
231
237
  all_issues.extend(check_boundary_types(effective_content, file_path))
232
238
  all_issues.extend(check_docstring_format(effective_content, file_path))
233
239
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
@@ -269,6 +275,9 @@ def validate_content(
269
275
  all_issues.extend(
270
276
  check_dead_dataclass_fields(content, file_path, full_file_content)
271
277
  )
278
+ all_issues.extend(
279
+ check_dead_module_constants(content, file_path, full_file_content)
280
+ )
272
281
  all_issues.extend(check_library_print(content, file_path))
273
282
  all_issues.extend(check_parameter_annotations(content, file_path))
274
283
  all_issues.extend(check_known_pytest_fixture_annotations(content, file_path))
@@ -287,6 +296,7 @@ def validate_content(
287
296
  all_issues.extend(check_string_literal_magic(content, file_path))
288
297
  check_incomplete_mocks(content, file_path)
289
298
  check_duplicated_format_patterns(content, file_path)
299
+ advise_cross_skill_duplicate_helper(effective_content, file_path)
290
300
 
291
301
  elif extension in ALL_JAVASCRIPT_EXTENSIONS:
292
302
  if not is_test_file(file_path):
@@ -383,18 +393,20 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
383
393
  return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
384
394
 
385
395
 
386
- def _hook_infrastructure_duplicate_body_issues(
396
+ def _hook_infrastructure_blocking_issues(
387
397
  content: str,
388
398
  file_path: str,
389
399
  full_file_content: str | None = None,
390
400
  prior_full_file_content: str = "",
391
401
  ) -> list[str]:
392
- """Run only the cross-file duplicate-body check for a hook Python target.
402
+ """Run the checks that still guard a hook Python target.
393
403
 
394
404
  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.
405
+ runs the two checks that must still guard them: the cross-file duplicate-body
406
+ check, span-scoped to the lines an edit touched exactly as ``validate_content``
407
+ scopes it for production code; and the zero-payload alias check, whose
408
+ docstring names hook modules as its motivating case, run over the whole
409
+ post-edit file.
398
410
 
399
411
  Args:
400
412
  content: The fragment or whole-file body under validation.
@@ -405,7 +417,8 @@ def _hook_infrastructure_duplicate_body_issues(
405
417
  recover the changed lines on an Edit.
406
418
 
407
419
  Returns:
408
- The in-scope duplicate-body violations for the target.
420
+ The in-scope duplicate-body violations and the zero-payload alias
421
+ violations for the target.
409
422
  """
410
423
  effective_content = content if full_file_content is None else full_file_content
411
424
  all_changed_lines = (
@@ -413,11 +426,13 @@ def _hook_infrastructure_duplicate_body_issues(
413
426
  if full_file_content is not None
414
427
  else None
415
428
  )
416
- return check_duplicate_function_body_across_files(
429
+ all_issues = check_duplicate_function_body_across_files(
417
430
  effective_content,
418
431
  file_path,
419
432
  all_changed_lines,
420
433
  )
434
+ all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
435
+ return all_issues
421
436
 
422
437
 
423
438
  def _without_line_prefix(violation_text: str) -> str:
@@ -540,7 +555,7 @@ def _run_precheck(
540
555
  old_content = _read_existing_file_content(target_path) or ""
541
556
  all_issues = validate_content(candidate_content, target_path, old_content)
542
557
  else:
543
- all_issues = _hook_infrastructure_duplicate_body_issues(
558
+ all_issues = _hook_infrastructure_blocking_issues(
544
559
  candidate_content, target_path
545
560
  )
546
561
  for each_issue in all_issues:
@@ -755,18 +770,18 @@ def _report_blocking_violations(
755
770
  )
756
771
 
757
772
 
758
- def _report_hook_duplicate_body(
773
+ def _report_hook_blocking_issues(
759
774
  content: str,
760
775
  file_path: str,
761
776
  full_file_content_after_edit: str | None,
762
777
  prior_full_file_content: str,
763
778
  deny_stream: TextIO,
764
779
  ) -> None:
765
- """Write a deny payload when a hook target copies a sibling function body.
780
+ """Write a deny payload when a hook target trips a check that still guards it.
766
781
 
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.
782
+ The full code-rules verdict stays off hook-infrastructure files; this runs the
783
+ two checks that must still guard them the cross-file duplicate-body check and
784
+ the zero-payload alias check — and emits the deny payload when either fires.
770
785
 
771
786
  Args:
772
787
  content: The fragment or whole-file body under validation.
@@ -776,7 +791,7 @@ def _report_hook_duplicate_body(
776
791
  prior_full_file_content: The on-disk content before the edit.
777
792
  deny_stream: The stream the JSON deny payload is written to.
778
793
  """
779
- all_blocking_issues = _hook_infrastructure_duplicate_body_issues(
794
+ all_blocking_issues = _hook_infrastructure_blocking_issues(
780
795
  content,
781
796
  file_path,
782
797
  full_file_content_after_edit,
@@ -835,7 +850,7 @@ def main(all_arguments: list[str]) -> None:
835
850
  sys.exit(0)
836
851
 
837
852
  if not runs_full_verdict:
838
- _report_hook_duplicate_body(
853
+ _report_hook_blocking_issues(
839
854
  content,
840
855
  file_path,
841
856
  full_file_content_after_edit,
@@ -26,6 +26,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
26
26
  MAX_STUB_IMPLEMENTATION_ISSUES,
27
27
  MAX_THIN_WRAPPER_ISSUES,
28
28
  MAX_TYPED_DICT_PAIR_ISSUES,
29
+ MAX_ZERO_PAYLOAD_ALIAS_ISSUES,
29
30
  )
30
31
 
31
32
  def _pascal_to_snake_case(pascal_name: str) -> str:
@@ -58,6 +59,16 @@ def _collect_module_function_names(parsed_tree: ast.AST) -> set[str]:
58
59
  return module_function_names
59
60
 
60
61
 
62
+ def _collect_module_function_nodes_by_name(
63
+ parsed_tree: ast.AST,
64
+ ) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]:
65
+ function_node_by_name: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
66
+ for each_statement in parsed_tree.body:
67
+ if isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
68
+ function_node_by_name[each_statement.name] = each_statement
69
+ return function_node_by_name
70
+
71
+
61
72
  def _is_init_file(file_path: str) -> bool:
62
73
  return file_path.replace("\\", "/").rsplit("/", 1)[-1] == "__init__.py"
63
74
 
@@ -126,6 +137,167 @@ def check_thin_wrapper_files(content: str, file_path: str) -> list[str]:
126
137
  return issues[:MAX_THIN_WRAPPER_ISSUES]
127
138
 
128
139
 
140
+ def _function_parameter_names_in_order(
141
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
142
+ ) -> list[str]:
143
+ arguments = function_node.args
144
+ positional_arguments = [*arguments.posonlyargs, *arguments.args]
145
+ return [each_argument.arg for each_argument in positional_arguments]
146
+
147
+
148
+ def _has_only_positional_parameters(
149
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
150
+ ) -> bool:
151
+ arguments = function_node.args
152
+ has_no_parameter_defaults = not arguments.defaults and not arguments.kw_defaults
153
+ return (
154
+ not arguments.kwonlyargs
155
+ and arguments.vararg is None
156
+ and arguments.kwarg is None
157
+ and has_no_parameter_defaults
158
+ )
159
+
160
+
161
+ def _single_return_call(
162
+ function_node: ast.FunctionDef | ast.AsyncFunctionDef,
163
+ ) -> ast.Call | None:
164
+ body_statements = function_node.body
165
+ statements_after_docstring = (
166
+ body_statements[1:]
167
+ if body_statements and _statement_is_docstring(body_statements[0])
168
+ else body_statements
169
+ )
170
+ if len(statements_after_docstring) != 1:
171
+ return None
172
+ only_statement = statements_after_docstring[0]
173
+ if not isinstance(only_statement, ast.Return):
174
+ return None
175
+ return only_statement.value if isinstance(only_statement.value, ast.Call) else None
176
+
177
+
178
+ def _forwards_parameters_unchanged(call_node: ast.Call, all_parameter_names: list[str]) -> bool:
179
+ if call_node.keywords:
180
+ return False
181
+ if len(call_node.args) != len(all_parameter_names):
182
+ return False
183
+ for each_argument, each_parameter_name in zip(call_node.args, all_parameter_names):
184
+ if not isinstance(each_argument, ast.Name) or each_argument.id != each_parameter_name:
185
+ return False
186
+ return True
187
+
188
+
189
+ def _function_is_async(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
190
+ return isinstance(function_node, ast.AsyncFunctionDef)
191
+
192
+
193
+ def _alias_target_name(call_node: ast.Call, all_sibling_function_names: set[str]) -> str:
194
+ callee = call_node.func
195
+ if not isinstance(callee, ast.Name):
196
+ return ""
197
+ return callee.id if callee.id in all_sibling_function_names else ""
198
+
199
+
200
+ def _module_string_literal_values(parsed_tree: ast.AST) -> set[str]:
201
+ string_literal_values: set[str] = set()
202
+ for each_node in ast.walk(parsed_tree):
203
+ if isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
204
+ string_literal_values.add(each_node.value)
205
+ return string_literal_values
206
+
207
+
208
+ def _target_accepts_forwarded_positional_call(
209
+ target_node: ast.FunctionDef | ast.AsyncFunctionDef,
210
+ forwarded_argument_count: int,
211
+ ) -> bool:
212
+ arguments = target_node.args
213
+ if any(default is None for default in arguments.kw_defaults):
214
+ return False
215
+ positional_parameters = [*arguments.posonlyargs, *arguments.args]
216
+ total_positional_count = len(positional_parameters)
217
+ required_positional_count = total_positional_count - len(arguments.defaults)
218
+ if arguments.vararg is not None:
219
+ return forwarded_argument_count >= required_positional_count
220
+ return required_positional_count <= forwarded_argument_count <= total_positional_count
221
+
222
+
223
+ def check_zero_payload_function_alias(content: str, file_path: str) -> list[str]:
224
+ """Flag a module-level function that only forwards its parameters to a sibling.
225
+
226
+ A function whose entire body (after an optional docstring) is a single
227
+ `return sibling_function(first_param, second_param, ...)` that forwards its
228
+ own parameters unchanged to another function defined in the same module is a
229
+ second name for one behavior — indirection without payload, which CODE_RULES
230
+ discourages. Callers should invoke the sibling directly. Both `def` and
231
+ `async def` forwarders are inspected.
232
+
233
+ A forwarder is left unflagged when any of these makes a direct call to the
234
+ target not equivalent to the alias: a decorator (caching, `@property`, route
235
+ registration); a parameter carrying a default value the target rejects; a
236
+ keyword-only / `*args` / `**kwargs` parameter on the alias; an awaitability
237
+ mismatch where one of the alias and target is `async def` and the other is
238
+ not; a forwarded positional call the live target's signature rejects (the
239
+ target has a keyword-only parameter without a default, or its positional
240
+ arity does not admit the forwarded argument count); or a name dispatched by a
241
+ string literal elsewhere in the module, where the named handler must exist for
242
+ a registry to resolve it.
243
+
244
+ Hook infrastructure is intentionally NOT exempt — pass-through aliases inside
245
+ hook modules are the motivating case. Test files and config files are exempt
246
+ because re-binding aliases are legitimate scaffolding there.
247
+
248
+ Args:
249
+ content: The source under inspection.
250
+ file_path: Path to the file, used for the test and config exemptions.
251
+
252
+ Returns:
253
+ One issue string per pass-through alias, capped at
254
+ MAX_ZERO_PAYLOAD_ALIAS_ISSUES.
255
+ """
256
+ if is_test_file(file_path) or is_config_file(file_path):
257
+ return []
258
+
259
+ try:
260
+ parsed_tree = ast.parse(content)
261
+ except SyntaxError:
262
+ return []
263
+
264
+ function_node_by_name = _collect_module_function_nodes_by_name(parsed_tree)
265
+ all_sibling_function_names = set(function_node_by_name)
266
+ all_string_literal_values = _module_string_literal_values(parsed_tree)
267
+ issues: list[str] = []
268
+ for each_statement in parsed_tree.body:
269
+ if not isinstance(each_statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
270
+ continue
271
+ if each_statement.decorator_list:
272
+ continue
273
+ if each_statement.name in all_string_literal_values:
274
+ continue
275
+ if not _has_only_positional_parameters(each_statement):
276
+ continue
277
+ call_node = _single_return_call(each_statement)
278
+ if call_node is None:
279
+ continue
280
+ target_name = _alias_target_name(call_node, all_sibling_function_names)
281
+ if not target_name or target_name == each_statement.name:
282
+ continue
283
+ target_node = function_node_by_name[target_name]
284
+ if _function_is_async(each_statement) != _function_is_async(target_node):
285
+ continue
286
+ forwarded_parameter_names = _function_parameter_names_in_order(each_statement)
287
+ if not _forwards_parameters_unchanged(call_node, forwarded_parameter_names):
288
+ continue
289
+ if not _target_accepts_forwarded_positional_call(
290
+ target_node, len(forwarded_parameter_names)
291
+ ):
292
+ continue
293
+ issues.append(
294
+ f"Line {each_statement.lineno}: {file_path}: zero-payload alias — "
295
+ f"{each_statement.name} only forwards its parameters to {target_name}; "
296
+ f"callers should call {target_name} directly (indirection without payload)"
297
+ )
298
+ return issues[:MAX_ZERO_PAYLOAD_ALIAS_ISSUES]
299
+
300
+
129
301
  def check_typed_dict_encode_decode(content: str, file_path: str) -> list[str]:
130
302
  """Flag TypedDict declarations missing companion `_encode_*` / `_decode_*` functions."""
131
303
  if (
@@ -0,0 +1,5 @@
1
+ """Configuration package for the blocking hooks.
2
+
3
+ A regular package (not a namespace package) so it resolves ahead of any
4
+ same-named package later on ``sys.path``.
5
+ """
@@ -0,0 +1,106 @@
1
+ """Constants for the verified-commit gate hook family.
2
+
3
+ Shared by ``verification_verdict_store.py``, ``verified_commit_gate.py``,
4
+ and ``verifier_verdict_minter.py`` so every tunable lives in one place.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ GIT_TIMEOUT_SECONDS = 30
10
+ ROOT_KEY_HEX_LENGTH = 16
11
+ VERDICT_JSON_INDENT = 2
12
+ CLAUDE_HOME_DIRECTORY_NAME = ".claude"
13
+ VERDICT_DIRECTORY_NAME = "verification"
14
+ VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN = r"['\"\\/,\s]+"
15
+ VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN = r"(?=['\"]*[\\/,])"
16
+ RELATIVE_VERDICT_DIRECTORY_PATTERN = r"(?:^|(?<=[\s;&|(='\"]))verification[\\/]"
17
+ VERDICT_PATH_GLUE_PATTERN = r"['\"+\\/\s]*[\\/]['\"+\\/\s]*"
18
+ VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN = r"[ \t]+['\"]?verification[\\/]?['\"]?(?=[\s;&|]|$)"
19
+ VERDICT_PATH_JOINED_VARIABLE_PATTERN = r"\$\{?(\w+)\}?[\\/]|[\\/]\$\{?(\w+)\}?"
20
+ VERDICT_PATH_VARIABLE_ASSIGNMENT_PATTERN = r"(?:^|(?<=[\s;&|(]))%s=(\S+)"
21
+ VERDICT_FILE_RELATIVE_REFERENCE_PATTERN = (
22
+ rf"(?:^|(?<=[\s;&|(='\"\\/]))verification[\\/][0-9a-f]{{{ROOT_KEY_HEX_LENGTH}}}\.json"
23
+ )
24
+ PATH_OBFUSCATION_PRIMITIVE_PATTERN = (
25
+ r"chr\s*\(|bytes\.fromhex\s*\(|b64decode\s*\(|codecs\.decode\s*\("
26
+ r"|(?:bytes|bytearray)\s*\(\s*\[|\[char\[?\]?\]"
27
+ )
28
+ ALL_VERDICT_PATH_SEGMENT_NAMES = (".claude", "verification")
29
+ ALL_VERDICT_PATH_SEGMENT_BODIES = ("claude", "verification")
30
+ HEX_TOKEN_PATTERN = r"(?<![0-9a-fx])([0-9a-f]{6,})(?![0-9a-f])"
31
+ BASE64_TOKEN_PATTERN = r"[A-Za-z0-9+/]{8,}={0,2}"
32
+ CHARACTER_CODE_SEQUENCE_PATTERN = r"\d{1,3}(?:\s*,\s*\d{1,3})+"
33
+ CHR_CALL_CHAIN_PATTERN = r"chr\(\s*\d{1,3}\s*\)(?:\s*\+\s*chr\(\s*\d{1,3}\s*\))+"
34
+ CHR_CALL_CODE_PATTERN = r"chr\(\s*(\d{1,3})\s*\)"
35
+ HEX_DIGITS_PER_BYTE = 2
36
+ FILE_WRITE_PRIMITIVE_PATTERN = (
37
+ r"\bopen\s*\(|\.write_text\s*\(|\.write_bytes\s*\("
38
+ r"|Out-File|Set-Content|Add-Content|\btee\b|>"
39
+ )
40
+ NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN = (
41
+ r"\bopen\s*\(|\.write_text\s*\(|\.write_bytes\s*\("
42
+ r"|Out-File|Set-Content|Add-Content|\btee\b"
43
+ )
44
+ WRITE_CALL_REGION_PATTERN = (
45
+ r"(?:\bopen\s*\(|\.write_text\s*\(|\.write_bytes\s*\("
46
+ r"|Out-File|Set-Content|Add-Content|\btee\b)[^;&|\n]*"
47
+ )
48
+ VERDICT_KEY_ALL_PASS = "all_pass"
49
+ VERDICT_KEY_MANIFEST_SHA256 = "manifest_sha256"
50
+ DOCS_ONLY_EXTENSIONS = frozenset(
51
+ {".md", ".txt", ".rst", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
52
+ )
53
+ PYTHON_EXTENSION = ".py"
54
+ TEST_FILE_PREFIX = "test_"
55
+ TEST_FILE_SUFFIX = "_test.py"
56
+ CONFTEST_FILE_NAME = "conftest.py"
57
+ MINIMUM_STATUS_FIELD_COUNT = 2
58
+ ALL_FALLBACK_BASE_REFERENCES = ("origin/main", "origin/master")
59
+ ALL_TOOLING_STATE_PREFIXES = (
60
+ ".claude/verification/",
61
+ ".claude/worktrees/",
62
+ ".claude/daemon/",
63
+ ".claude/teams/",
64
+ ".claude/sessions/",
65
+ ".cursor/worktrees/",
66
+ )
67
+ GATED_GIT_SUBCOMMANDS = frozenset({"commit", "push"})
68
+ ALL_GIT_BINARY_NAMES = frozenset({"git", "git.exe"})
69
+ VALUE_TAKING_GIT_OPTIONS = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace"})
70
+ REPO_DIRECTORY_OPTION = "-C"
71
+ WORK_TREE_OPTION = "--work-tree"
72
+ DIRECTORY_CHANGE_VERBS = frozenset({"cd", "pushd", "set-location", "sl"})
73
+ DIRECTORY_CHANGE_PATH_OPTIONS = frozenset({"-path", "-literalpath"})
74
+ DIRECTORY_CHANGE_OPTION_TERMINATOR = "--"
75
+ DIRECTORY_CHANGE_PATTERN_PREFIX = r"(?:^|(?<=[\s;&|(]))(?:"
76
+ DIRECTORY_CHANGE_PATTERN_SUFFIX = r")(?=\s|$)"
77
+ DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN = r"(?:[ \t]+(?:%s)(?=\s|$))*"
78
+ DIRECTORY_CHANGE_TARGET_PATTERN = r"[ \t]+['\"]?\S*"
79
+ CLAUDE_HOME_TARGET_BOUNDARY_PATTERN = r"[\\/]?['\"]?(?=[\s;&|]|$)"
80
+ VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN = r"[\\/]?['\"]?(?=[\s;&|]|$)"
81
+ COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN = r"[;&|\n][\s]*\S"
82
+ OPTION_WITH_VALUE_STEP = 2
83
+ ALL_GATED_TOOL_NAMES = ("Bash", "PowerShell")
84
+ HASH_PREVIEW_LENGTH = 16
85
+ MINTING_AGENT_TYPE = "code-verifier"
86
+ SPAWN_LOOKUP_ATTEMPT_COUNT = 3
87
+ SPAWN_LOOKUP_RETRY_DELAY_SECONDS = 0.1
88
+ VERDICT_DIRECTORY_GUARD_MESSAGE = (
89
+ "BLOCKED: [VERDICT_DIRECTORY_GUARD] Shell access to the verification "
90
+ "verdict directory (~/.claude/verification/) is denied. Only the "
91
+ "verifier_verdict_minter.py SubagentStop hook writes verdict files; a "
92
+ "shell write here would forge a passing verdict and defeat the "
93
+ "verified-commit gate. Spawn the code-verifier agent to earn a verdict "
94
+ "instead of writing one."
95
+ )
96
+ CORRECTIVE_MESSAGE = (
97
+ "BLOCKED: [VERIFIED_COMMIT_GATE] This branch surface has no passing "
98
+ "verification verdict. Spawn the code-verifier agent (Agent tool, "
99
+ "subagent_type 'code-verifier') with the task texts, the diff scope, "
100
+ "and recorded baselines; when it finishes with a clean verdict the "
101
+ "SubagentStop hook mints the verdict and this command will pass. Any "
102
+ "file change after verification invalidates the verdict, so verify "
103
+ "last. Exempt automatically: docs/image files, pytest test files, and "
104
+ "Python files whose docstring- and comment-stripped AST is unchanged "
105
+ "(comment-only edits in non-Python files are not exempt)."
106
+ )
@@ -0,0 +1,146 @@
1
+ """Tests for the cross-skill duplicate-helper advisory.
2
+
3
+ PR #233 on JonEcho/python-automation copied a Chrome-launch helper from the
4
+ ``stp-recolor`` skill's ``scripts`` directory into the ``iconize-and-recolor-stp``
5
+ skill's ``scripts`` directory. The two skills install on their own, so a shared
6
+ module would couple them and break independent install; the copy is a defensible
7
+ skill-isolation tradeoff. ``advise_cross_skill_duplicate_helper`` surfaces that
8
+ copy as a non-blocking ``[CODE_RULES advisory]`` on stderr so a reviewer confirms
9
+ the copy was intentional, without denying the write.
10
+
11
+ The tests build a real ``skills/<name>/scripts`` layout on disk and run the
12
+ advisory against it, so they exercise the on-disk cross-skill scan rather than a
13
+ stubbed view of the filesystem.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.util
19
+ import pathlib
20
+ import shutil
21
+ import sys
22
+ import tempfile
23
+ from collections.abc import Iterator
24
+
25
+ import pytest
26
+
27
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
28
+ if str(_HOOK_DIRECTORY) not in sys.path:
29
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
30
+
31
+ _hook_spec = importlib.util.spec_from_file_location(
32
+ "code_rules_duplicate_body",
33
+ _HOOK_DIRECTORY / "code_rules_duplicate_body.py",
34
+ )
35
+ assert _hook_spec is not None
36
+ assert _hook_spec.loader is not None
37
+ _hook_module = importlib.util.module_from_spec(_hook_spec)
38
+ _hook_spec.loader.exec_module(_hook_module)
39
+ advise_cross_skill_duplicate_helper = _hook_module.advise_cross_skill_duplicate_helper
40
+
41
+
42
+ CHROME_HELPER_SOURCE = (
43
+ "import winreg\n"
44
+ "from pathlib import Path\n"
45
+ "\n"
46
+ "chrome_app_paths_key = r'SOFTWARE\\Microsoft\\App Paths\\chrome.exe'\n"
47
+ "chrome_fallback_paths = ('C:/chrome.exe',)\n"
48
+ "\n"
49
+ "def _chrome_executable() -> Path | None:\n"
50
+ " for each_root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):\n"
51
+ " try:\n"
52
+ " registered = winreg.QueryValue(each_root, chrome_app_paths_key)\n"
53
+ " except OSError:\n"
54
+ " continue\n"
55
+ " registered_path = Path(registered)\n"
56
+ " if registered_path.exists():\n"
57
+ " return registered_path\n"
58
+ " for each_fallback in chrome_fallback_paths:\n"
59
+ " fallback_path = Path(each_fallback)\n"
60
+ " if fallback_path.exists():\n"
61
+ " return fallback_path\n"
62
+ " return None\n"
63
+ )
64
+
65
+
66
+ @pytest.fixture
67
+ def skills_root() -> Iterator[pathlib.Path]:
68
+ base_directory = pathlib.Path(tempfile.mkdtemp())
69
+ skills_directory = base_directory / "skills"
70
+ skills_directory.mkdir()
71
+ try:
72
+ yield skills_directory
73
+ finally:
74
+ shutil.rmtree(base_directory, ignore_errors=False)
75
+
76
+
77
+ def _make_skill_scripts(skills_directory: pathlib.Path, skill_name: str) -> pathlib.Path:
78
+ scripts_directory = skills_directory / skill_name / "scripts"
79
+ scripts_directory.mkdir(parents=True)
80
+ return scripts_directory
81
+
82
+
83
+ def test_advises_when_helper_copied_from_another_skill(
84
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
85
+ ) -> None:
86
+ source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
87
+ (source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
88
+ target_scripts = _make_skill_scripts(skills_root, "iconize-and-recolor-stp")
89
+ target_file = target_scripts / "combine_report.py"
90
+
91
+ advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
92
+
93
+ captured = capsys.readouterr()
94
+ assert "[CODE_RULES advisory]" in captured.err, (
95
+ f"Expected a cross-skill advisory on stderr, got: {captured.err!r}"
96
+ )
97
+ assert "_chrome_executable" in captured.err, (
98
+ f"Expected the duplicated function named, got: {captured.err!r}"
99
+ )
100
+ assert "stp-recolor" in captured.err, f"Expected the source skill named, got: {captured.err!r}"
101
+
102
+
103
+ def test_advisory_does_not_block(
104
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
105
+ ) -> None:
106
+ source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
107
+ (source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
108
+ target_scripts = _make_skill_scripts(skills_root, "iconize-and-recolor-stp")
109
+ target_file = target_scripts / "combine_report.py"
110
+
111
+ returned = advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
112
+
113
+ assert returned is None, "The advisory returns nothing so it never enters the deny path"
114
+
115
+
116
+ def test_no_advisory_within_one_skill(
117
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
118
+ ) -> None:
119
+ scripts_directory = _make_skill_scripts(skills_root, "stp-recolor")
120
+ (scripts_directory / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
121
+ sibling_file = scripts_directory / "combine_report.py"
122
+
123
+ advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(sibling_file))
124
+
125
+ captured = capsys.readouterr()
126
+ assert captured.err == "", (
127
+ "Within one skill the blocking gate covers the copy; the cross-skill "
128
+ f"advisory must stay silent, got: {captured.err!r}"
129
+ )
130
+
131
+
132
+ def test_no_advisory_outside_a_skill_scripts_directory(
133
+ skills_root: pathlib.Path, capsys: pytest.CaptureFixture[str]
134
+ ) -> None:
135
+ source_scripts = _make_skill_scripts(skills_root, "stp-recolor")
136
+ (source_scripts / "palette_board.py").write_text(CHROME_HELPER_SOURCE, encoding="utf-8")
137
+ non_skill_directory = skills_root.parent / "elsewhere"
138
+ non_skill_directory.mkdir()
139
+ target_file = non_skill_directory / "combine_report.py"
140
+
141
+ advise_cross_skill_duplicate_helper(CHROME_HELPER_SOURCE, str(target_file))
142
+
143
+ captured = capsys.readouterr()
144
+ assert captured.err == "", (
145
+ f"A file outside a skill scripts directory draws no advisory, got: {captured.err!r}"
146
+ )