claude-dev-env 2.18.0 → 2.20.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 (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -1,7 +1,9 @@
1
1
  """Banned identifier, banned noun-word, and banned function-prefix naming checks."""
2
2
 
3
3
  import ast
4
+ import io
4
5
  import sys
6
+ import tokenize
5
7
  from pathlib import Path
6
8
 
7
9
  _blocking_directory = str(Path(__file__).resolve().parent)
@@ -30,6 +32,7 @@ from hooks_constants.banned_identifiers_constants import ( # noqa: E402
30
32
  ALL_BANNED_NOUN_WORDS,
31
33
  BANNED_IDENTIFIER_MESSAGE_SUFFIX,
32
34
  BANNED_IDENTIFIER_SKIP_ADVISORY,
35
+ BANNED_NOUN_COMPATIBILITY_COMMENT,
33
36
  BANNED_NOUN_WORD_MESSAGE_SUFFIX,
34
37
  CAMEL_CASE_WORD_PATTERN,
35
38
  MAX_BANNED_IDENTIFIER_ISSUES,
@@ -235,8 +238,31 @@ def _is_dunder_name(identifier: str) -> bool:
235
238
  return identifier.startswith("__") and identifier.endswith("__")
236
239
 
237
240
 
241
+ def _collect_annotated_public_definition_lines(
242
+ content: str, parsed_tree: ast.AST
243
+ ) -> frozenset[int]:
244
+ all_annotation_lines: set[int] = set()
245
+ try:
246
+ for each_token in tokenize.generate_tokens(io.StringIO(content).readline):
247
+ if each_token.type != tokenize.COMMENT:
248
+ continue
249
+ if each_token.string.strip() == BANNED_NOUN_COMPATIBILITY_COMMENT:
250
+ all_annotation_lines.add(each_token.start[0])
251
+ except (IndentationError, SyntaxError, tokenize.TokenError):
252
+ return frozenset()
253
+
254
+ return frozenset(
255
+ each_node.lineno
256
+ for each_node in ast.walk(parsed_tree)
257
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
258
+ and not each_node.name.startswith("_")
259
+ and each_node.lineno in all_annotation_lines
260
+ )
261
+
262
+
238
263
  def _collect_banned_noun_word_bindings(
239
264
  parsed_tree: ast.AST,
265
+ all_annotated_public_definition_lines: frozenset[int],
240
266
  ) -> list[tuple[str, int, int, str]]:
241
267
  """Yield ``(identifier, lineno, col_offset, banned_word)`` for each binding.
242
268
 
@@ -248,7 +274,14 @@ def _collect_banned_noun_word_bindings(
248
274
  flagged_bindings: list[tuple[str, int, int, str]] = []
249
275
  seen_keys: set[tuple[str, int, int]] = set()
250
276
 
251
- def record(name: str, lineno: int, col_offset: int) -> None:
277
+ def record(
278
+ name: str,
279
+ lineno: int,
280
+ col_offset: int,
281
+ is_annotated_public_definition: bool = False,
282
+ ) -> None:
283
+ if is_annotated_public_definition:
284
+ return
252
285
  if name in ALL_BANNED_IDENTIFIERS:
253
286
  return
254
287
  if _is_dunder_name(name):
@@ -282,11 +315,24 @@ def _collect_banned_noun_word_bindings(
282
315
  for each_name_node in _collect_target_names(each_node.optional_vars):
283
316
  record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
284
317
  elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
285
- record(each_node.name, each_node.lineno, each_node.col_offset)
318
+ is_annotated_public_definition = (
319
+ each_node.lineno in all_annotated_public_definition_lines
320
+ )
321
+ record(
322
+ each_node.name,
323
+ each_node.lineno,
324
+ each_node.col_offset,
325
+ is_annotated_public_definition,
326
+ )
286
327
  for each_arg in _collect_annotated_arguments(each_node):
287
328
  if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
288
329
  continue
289
- record(each_arg.arg, each_arg.lineno, each_arg.col_offset)
330
+ record(
331
+ each_arg.arg,
332
+ each_arg.lineno,
333
+ each_arg.col_offset,
334
+ is_annotated_public_definition,
335
+ )
290
336
  elif isinstance(each_node, ast.ClassDef):
291
337
  record(each_node.name, each_node.lineno, each_node.col_offset)
292
338
  elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
@@ -368,9 +414,14 @@ def check_banned_noun_word_boundary(
368
414
  except SyntaxError:
369
415
  return []
370
416
 
417
+ all_annotated_public_definition_lines = _collect_annotated_public_definition_lines(
418
+ content, parsed_tree
419
+ )
371
420
  single_line_span = 1
372
421
  all_violations_in_walk_order: list[tuple[range, str]] = []
373
- for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(parsed_tree):
422
+ for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(
423
+ parsed_tree, all_annotated_public_definition_lines
424
+ ):
374
425
  span_range = range(each_lineno, each_lineno + single_line_span)
375
426
  span_fragment = BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE.format(
376
427
  definition_line=each_lineno, line_span=single_line_span
@@ -0,0 +1,194 @@
1
+ """Blast-radius check: a raise inside per-item work must name what it stops."""
2
+
3
+ import ast
4
+ import sys
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+ _blocking_directory = str(Path(__file__).resolve().parent)
9
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
10
+ if _blocking_directory not in sys.path:
11
+ sys.path.insert(0, _blocking_directory)
12
+ if _hooks_directory not in sys.path:
13
+ sys.path.insert(0, _hooks_directory)
14
+
15
+ from code_rules_shared import ( # noqa: E402
16
+ is_hook_infrastructure,
17
+ is_test_file,
18
+ )
19
+
20
+ from hooks_constants.blast_radius_constants import ( # noqa: E402
21
+ ALL_BLAST_RADIUS_SUFFIXES,
22
+ BLAST_RADIUS_MESSAGE_SUFFIX,
23
+ MAX_BLAST_RADIUS_ISSUES,
24
+ )
25
+
26
+
27
+ def _raised_type_name(raise_node: ast.Raise) -> str | None:
28
+ """Return the name of the exception type a raise statement constructs.
29
+
30
+ Args:
31
+ raise_node: The raise statement to read.
32
+
33
+ Returns:
34
+ The exception type name. A ``None`` return classifies bare re-raises and
35
+ alternate AST forms.
36
+ """
37
+ raised = raise_node.exc
38
+ if raised is None:
39
+ return None
40
+ if isinstance(raised, ast.Call):
41
+ raised = raised.func
42
+ if isinstance(raised, ast.Name):
43
+ return raised.id
44
+ if isinstance(raised, ast.Attribute):
45
+ return raised.attr
46
+ return None
47
+
48
+
49
+ def _declares_blast_radius(type_name: str) -> bool:
50
+ """Report whether an exception type name states what its failure stops.
51
+
52
+ Args:
53
+ type_name: The exception type name to inspect.
54
+
55
+ Returns:
56
+ ``True`` when the name ends in a recognized blast-radius suffix.
57
+ """
58
+ return any(type_name.endswith(each_suffix) for each_suffix in ALL_BLAST_RADIUS_SUFFIXES)
59
+
60
+
61
+ def _handler_type_names(handler: ast.ExceptHandler) -> list[str]:
62
+ """Return named exception types caught by one except clause."""
63
+ caught = handler.type
64
+ if caught is None:
65
+ return []
66
+ all_caught = caught.elts if isinstance(caught, ast.Tuple) else [caught]
67
+ return [
68
+ each_caught.id
69
+ for each_caught in all_caught
70
+ if isinstance(each_caught, ast.Name)
71
+ ] + [
72
+ each_caught.attr
73
+ for each_caught in all_caught
74
+ if isinstance(each_caught, ast.Attribute)
75
+ ]
76
+
77
+
78
+ def _handler_names_blast_radius_type(handler: ast.ExceptHandler) -> bool:
79
+ """Report whether an except clause catches a blast-radius-declaring type.
80
+
81
+ Args:
82
+ handler: The except clause to inspect.
83
+
84
+ Returns:
85
+ ``True`` when any caught type name carries a blast-radius suffix.
86
+ """
87
+ return any(_declares_blast_radius(each_name) for each_name in _handler_type_names(handler))
88
+
89
+
90
+ def _handler_matches_raise(handler: ast.ExceptHandler, type_name: str | None) -> bool:
91
+ """Report whether a handler corresponds to the raise it encloses."""
92
+ all_handler_names = _handler_type_names(handler)
93
+ if type_name is None:
94
+ return _handler_names_blast_radius_type(handler)
95
+ return type_name in all_handler_names
96
+
97
+
98
+ def _walk_loop_body(node: ast.AST) -> Iterator[ast.AST]:
99
+ """Walk loop statements without entering nested definition scopes."""
100
+ for each_child in ast.iter_child_nodes(node):
101
+ if isinstance(
102
+ each_child,
103
+ (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef, ast.Lambda),
104
+ ):
105
+ continue
106
+ yield each_child
107
+ yield from _walk_loop_body(each_child)
108
+
109
+
110
+ def _boundary_guarded_raise_lines(tree: ast.Module) -> set[int]:
111
+ """Collect raise lines already sitting inside a blast-radius boundary.
112
+
113
+ Args:
114
+ tree: The parsed module to walk.
115
+
116
+ Returns:
117
+ The line numbers of raise statements enclosed by a try whose handlers
118
+ name a blast-radius-declaring type.
119
+ """
120
+ all_guarded: set[int] = set()
121
+ for each_node in ast.walk(tree):
122
+ if not isinstance(each_node, ast.Try):
123
+ continue
124
+ for each_body_node in each_node.body:
125
+ for each_inner in ast.walk(each_body_node):
126
+ if isinstance(each_inner, ast.Raise) and any(
127
+ _handler_matches_raise(each_handler, _raised_type_name(each_inner))
128
+ for each_handler in each_node.handlers
129
+ ):
130
+ all_guarded.add(each_inner.lineno)
131
+ return all_guarded
132
+
133
+
134
+ def _per_item_raise_nodes(tree: ast.Module) -> list[ast.Raise]:
135
+ """Collect raise statements that sit inside a loop body.
136
+
137
+ A raise reached through per-item iteration ends every remaining item by
138
+ default. A declared radius identifies the intended scope, so each raise
139
+ in this path benefits from an explicit name.
140
+
141
+ Args:
142
+ tree: The parsed module to walk.
143
+
144
+ Returns:
145
+ Every raise statement found under a for or while loop body.
146
+ """
147
+ all_raises: list[ast.Raise] = []
148
+ for each_node in ast.walk(tree):
149
+ if not isinstance(each_node, (ast.For, ast.AsyncFor, ast.While)):
150
+ continue
151
+ for each_inner in _walk_loop_body(each_node):
152
+ if isinstance(each_inner, ast.Raise):
153
+ all_raises.append(each_inner)
154
+ return all_raises
155
+
156
+
157
+ def check_blast_radius_declared(content: str, file_path: str) -> list[str]:
158
+ """Check that raises inside per-item work name their stopping scope.
159
+
160
+ A raise reached through a loop body ends the whole batch by default, so a
161
+ one-item defect discards every item that already succeeded. Naming the type
162
+ ``*RunFatal`` or ``*ItemBlocked`` states the intent, and a boundary catching
163
+ a declared type already handles it.
164
+
165
+ Args:
166
+ content: The file body to inspect.
167
+ file_path: The path the body will be written to.
168
+
169
+ Returns:
170
+ One advisory line per raise that needs a declared blast radius, capped
171
+ at the configured maximum.
172
+ """
173
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
174
+ return []
175
+
176
+ try:
177
+ parsed_tree = ast.parse(content)
178
+ except SyntaxError:
179
+ return []
180
+
181
+ all_guarded_lines = _boundary_guarded_raise_lines(parsed_tree)
182
+ all_issues: list[str] = []
183
+ all_reported_lines: set[int] = set()
184
+ for each_raise in _per_item_raise_nodes(parsed_tree):
185
+ if each_raise.lineno in all_guarded_lines or each_raise.lineno in all_reported_lines:
186
+ continue
187
+ type_name = _raised_type_name(each_raise)
188
+ if type_name is None or _declares_blast_radius(type_name):
189
+ continue
190
+ all_reported_lines.add(each_raise.lineno)
191
+ all_issues.append(f"Line {each_raise.lineno}: {type_name} {BLAST_RADIUS_MESSAGE_SUFFIX}")
192
+ if len(all_issues) >= MAX_BLAST_RADIUS_ISSUES:
193
+ break
194
+ return all_issues
@@ -41,6 +41,9 @@ from code_rules_banned_identifiers import ( # noqa: E402
41
41
  check_banned_noun_word_boundary,
42
42
  check_banned_prefixes,
43
43
  )
44
+ from code_rules_blast_radius import ( # noqa: E402
45
+ check_blast_radius_declared,
46
+ )
44
47
  from code_rules_boolean_mustcheck import ( # noqa: E402
45
48
  check_boolean_naming,
46
49
  check_ignored_must_check_return,
@@ -295,6 +298,15 @@ def validate_content(
295
298
  defer_scope_to_caller,
296
299
  )
297
300
  )
301
+ all_issues.extend(
302
+ _fragment_or_deferred_check(
303
+ check_blast_radius_declared,
304
+ old_content,
305
+ content,
306
+ file_path,
307
+ defer_scope_to_caller,
308
+ )
309
+ )
298
310
  all_issues.extend(check_fstring_structural_literals(content, file_path))
299
311
  all_issues.extend(check_constants_outside_config(content, file_path))
300
312
  all_issues.extend(check_config_duplicate_path_anchor(content, file_path))
@@ -0,0 +1,92 @@
1
+ """Shared entrypoint helpers for code-rules enforcer behavior tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+
14
+ _ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
15
+
16
+
17
+ def build_write_payload(file_path: str, content: str) -> str:
18
+ """Build the JSON payload sent by a Write tool call."""
19
+ return json.dumps(
20
+ {
21
+ "tool_name": "Write",
22
+ "tool_input": {"file_path": file_path, "content": content},
23
+ }
24
+ )
25
+
26
+
27
+ def run_serialized_payload_entrypoint(
28
+ entrypoint: Callable[[list[str]], None],
29
+ serialized_payload: str,
30
+ ) -> tuple[str, int | None]:
31
+ """Run a callable hook entrypoint with a serialized stdin payload."""
32
+ input_stream = io.StringIO(serialized_payload)
33
+ captured_stream = io.StringIO()
34
+ original_stdin = sys.stdin
35
+ original_stdout = sys.stdout
36
+ exit_code: int | None = None
37
+ try:
38
+ sys.stdin = input_stream
39
+ sys.stdout = captured_stream
40
+ entrypoint([])
41
+ except SystemExit as each_exit:
42
+ raw_code = each_exit.code
43
+ exit_code = raw_code if isinstance(raw_code, int) else None
44
+ finally:
45
+ sys.stdin = original_stdin
46
+ sys.stdout = original_stdout
47
+ return captured_stream.getvalue(), exit_code
48
+
49
+
50
+ def run_write_entrypoint(
51
+ entrypoint: Callable[[list[str]], None],
52
+ file_path: str,
53
+ content: str,
54
+ ) -> tuple[str, int]:
55
+ """Drive a real hook entrypoint with a Write payload and capture stdout."""
56
+ captured_stdout, exit_code = run_serialized_payload_entrypoint(
57
+ entrypoint, build_write_payload(file_path, content)
58
+ )
59
+ return captured_stdout, int(exit_code or 0)
60
+
61
+
62
+ def run_enforcer_cli(
63
+ script_path: Path,
64
+ all_arguments: list[str],
65
+ extra_environment_by_name: dict[str, str] | None,
66
+ ) -> subprocess.CompletedProcess[str]:
67
+ """Run an enforcer script through its real command-line entrypoint."""
68
+ subprocess_environment: dict[str, str] | None = None
69
+ if extra_environment_by_name is not None:
70
+ subprocess_environment = os.environ.copy()
71
+ subprocess_environment.update(extra_environment_by_name)
72
+ return subprocess.run(
73
+ [sys.executable, str(script_path), *all_arguments],
74
+ stdin=subprocess.DEVNULL,
75
+ capture_output=True,
76
+ text=True,
77
+ check=False,
78
+ env=subprocess_environment,
79
+ )
80
+
81
+
82
+ def run_precheck(
83
+ candidate_path: Path,
84
+ target_path: str,
85
+ extra_environment_by_name: dict[str, str] | None,
86
+ ) -> subprocess.CompletedProcess[str]:
87
+ """Run an enforcer script through its real precheck command."""
88
+ return run_enforcer_cli(
89
+ _ENFORCER_SCRIPT_PATH,
90
+ ["--check", str(candidate_path), "--as", target_path],
91
+ extra_environment_by_name,
92
+ )
@@ -20,12 +20,16 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
20
20
  CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
21
21
  )
22
22
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
23
- from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
24
- ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
25
- ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
23
+ from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
26
24
  ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
27
25
  ALL_TRUTHY_ENV_VALUES,
28
26
  DESTRUCTIVE_DENY_MODE_ENV_VAR,
27
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
28
+ GH_REDIRECT_ACTIVE_ENV_VAR,
29
+ )
30
+ from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
31
+ ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
32
+ ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
29
33
  ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
30
34
  ALL_FIND_EXEC_ACTION_FLAGS,
31
35
  ALL_FIND_EXEC_ACTION_TERMINATORS,
@@ -57,7 +61,6 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
57
61
  )
58
62
 
59
63
  CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
60
- GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
61
64
 
62
65
 
63
66
  def gh_redirect_is_active() -> bool:
@@ -87,9 +90,7 @@ def directory_is_ephemeral(directory_path: str) -> bool:
87
90
  Returns:
88
91
  True when the directory belongs to the ephemeral auto-allow namespace.
89
92
  """
90
- ephemeral_auto_allow_disabled_env_var = "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
91
- truthy_string_values = frozenset({"1", "true", "yes", "on"})
92
- if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
93
+ if os.environ.get(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "").strip().lower() in ALL_TRUTHY_ENV_VALUES:
93
94
  return False
94
95
  forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
95
96
  all_worktree_path_segments = ("/worktrees/", "/worktree/")
@@ -486,16 +486,20 @@ def _emit_allow_decision(decision: DispatcherDecision) -> None:
486
486
 
487
487
  Matches the shape a standalone hosted hook emits when it auto-approves the
488
488
  write, so a write a hosted hook allows explicitly is auto-approved under the
489
- dispatcher rather than falling back to the default permission flow. When a
490
- hook also carried an advisory systemMessage,
491
- that notice rides on the allow payload without denying.
489
+ dispatcher rather than falling back to the default permission flow. Advisory
490
+ systemMessage and additionalContext fields ride on the allow payload.
492
491
  """
492
+ allow_hook_specific: dict[str, object] = {
493
+ "hookEventName": HOOK_EVENT_NAME,
494
+ "permissionDecision": ALLOW_DECISION,
495
+ }
493
496
  allow_payload: dict[str, object] = {
494
- "hookSpecificOutput": {
495
- "hookEventName": HOOK_EVENT_NAME,
496
- "permissionDecision": ALLOW_DECISION,
497
- }
497
+ "hookSpecificOutput": allow_hook_specific,
498
498
  }
499
+ if decision.all_additional_context:
500
+ allow_hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
501
+ decision.all_additional_context
502
+ )
499
503
  if decision.all_system_messages:
500
504
  allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
501
505
  decision.all_system_messages
@@ -1,40 +1,26 @@
1
- """PreToolUse hook that runs the staged CODE_RULES gate before git commit.
1
+ """PreToolUse hook that passes Git commit validation to Git hooks.
2
2
 
3
3
  Intercepts Bash `git commit` invocations (including `git -C <path> commit`),
4
- resolves the repository root, and runs the shared code_rules_gate engine in
5
- ``--staged`` mode over the staged files. A commit that would introduce
6
- CODE_RULES violations is denied with the gate's file:line report so the
7
- violations surface before the commit instead of stalling converge loops at
8
- commit time. Non-commit commands, repositories with no staged Python files,
9
- and clean staged changes pass through silently. A gate-engine failure denies
10
- the commit with the failure detail — the gate never fails open.
4
+ emits neutral evidence, and returns control to Git. Configured Git hooks
5
+ receive the commit at the commit boundary.
11
6
  """
12
7
 
13
- import json
14
8
  import re
15
9
  import subprocess
16
10
  import sys
17
11
  from pathlib import Path
18
12
 
19
- _blocking_dir = str(Path(__file__).resolve().parent)
20
- if _blocking_dir not in sys.path:
21
- sys.path.insert(0, _blocking_dir)
22
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
23
- if _hooks_dir not in sys.path:
24
- sys.path.insert(0, _hooks_dir)
13
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
14
+ if _hooks_directory not in sys.path:
15
+ sys.path.insert(0, _hooks_directory)
25
16
 
26
17
  from block_main_commit import ( # noqa: E402
27
- extract_git_working_directory,
28
18
  is_commit_command,
29
19
  parse_bash_command_from_stdin,
30
- resolve_directory,
31
20
  )
32
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
21
+
33
22
  from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
34
23
  ALL_GIT_REPOSITORY_ROOT_COMMAND,
35
- ALL_STAGED_PYTHON_FILES_COMMAND,
36
- GATE_RELATIVE_PATH,
37
- GATE_TIMEOUT_SECONDS,
38
24
  GIT_COMMAND_TIMEOUT_SECONDS,
39
25
  GIT_DASH_C_COMMIT_PATTERN,
40
26
  )
@@ -59,20 +45,19 @@ def is_git_commit_invocation(bash_command: str) -> bool:
59
45
 
60
46
 
61
47
  def resolve_repository_root(working_directory: str | None) -> Path | None:
62
- """Resolve the git repository root for the commit's working directory.
48
+ """Resolve the Git repository root for a directory.
63
49
 
64
50
  Args:
65
- working_directory: Directory the commit runs in, or None for the
66
- hook's current working directory.
51
+ working_directory: Directory inside the repository.
67
52
 
68
53
  Returns:
69
- The repository root path, or None when the directory is not inside
70
- a git repository or git is unavailable.
54
+ Repository root. Returns None when Git cannot resolve the directory.
71
55
  """
72
56
  try:
73
57
  completed_process = subprocess.run(
74
58
  list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
75
- check=False, capture_output=True,
59
+ check=False,
60
+ capture_output=True,
76
61
  text=True,
77
62
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
78
63
  cwd=working_directory,
@@ -87,122 +72,12 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
87
72
  return Path(top_level_text)
88
73
 
89
74
 
90
- def list_staged_python_files(repository_root: Path) -> list[str]:
91
- """List repository-relative paths of staged Python files.
92
-
93
- Args:
94
- repository_root: Repository root used as the git working directory.
95
-
96
- Returns:
97
- Repository-relative paths of Python files staged for add, copy,
98
- modify, or rename. Empty when the listing command fails — the
99
- caller then allows the commit because git itself will surface the
100
- repository problem.
101
- """
102
- try:
103
- completed_process = subprocess.run(
104
- list(ALL_STAGED_PYTHON_FILES_COMMAND),
105
- check=False, capture_output=True,
106
- text=True,
107
- timeout=GIT_COMMAND_TIMEOUT_SECONDS,
108
- cwd=str(repository_root),
109
- )
110
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
111
- return []
112
- if completed_process.returncode != 0:
113
- return []
114
- return [
115
- each_line.strip()
116
- for each_line in completed_process.stdout.splitlines()
117
- if each_line.strip()
118
- ]
119
-
120
-
121
- def run_staged_gate(repository_root: Path) -> tuple[int, str]:
122
- """Run the shared code_rules_gate engine in staged mode.
123
-
124
- Args:
125
- repository_root: Repository root passed to the gate's --repo-root
126
- and used as the gate subprocess working directory, so relative
127
- paths the gate's checks resolve land in the committing worktree
128
- rather than the hook's own working directory.
129
-
130
- Returns:
131
- Tuple of the gate exit code and its stderr report. A missing gate
132
- script or a gate timeout returns a non-zero code with an
133
- explanatory message so the commit is denied rather than waved
134
- through on infrastructure failure.
135
- """
136
- gate_path = Path(__file__).resolve().parents[2] / GATE_RELATIVE_PATH
137
- if not gate_path.is_file():
138
- return 1, f"precommit_code_rules_gate: gate engine missing at {gate_path}"
139
- try:
140
- completed_process = subprocess.run(
141
- [
142
- sys.executable,
143
- str(gate_path),
144
- "--repo-root",
145
- str(repository_root),
146
- "--staged",
147
- ],
148
- check=False, capture_output=True,
149
- text=True,
150
- encoding="utf-8",
151
- errors="replace",
152
- timeout=GATE_TIMEOUT_SECONDS,
153
- cwd=str(repository_root),
154
- )
155
- except subprocess.TimeoutExpired:
156
- return 1, (
157
- f"precommit_code_rules_gate: gate engine timed out after {GATE_TIMEOUT_SECONDS}s"
158
- )
159
- return completed_process.returncode, completed_process.stderr
160
-
161
-
162
- def build_denial_response(gate_report: str) -> dict:
163
- """Build the PreToolUse deny payload carrying the gate report.
164
-
165
- Args:
166
- gate_report: The gate's stderr report listing file:line violations.
167
-
168
- Returns:
169
- The hookSpecificOutput deny mapping for the PreToolUse protocol.
170
- """
171
- denial_reason = (
172
- f"BLOCKED: staged files violate CODE_RULES; fix before committing.\n{gate_report.strip()}"
173
- )
174
- return {
175
- "hookSpecificOutput": {
176
- "hookEventName": "PreToolUse",
177
- "permissionDecision": "deny",
178
- "permissionDecisionReason": denial_reason,
179
- }
180
- }
181
-
182
-
183
75
  def main() -> None:
184
- """Gate git commits on the staged CODE_RULES report."""
76
+ """Pass Git commit validation through to configured Git hooks."""
185
77
  bash_command = parse_bash_command_from_stdin()
186
78
  if not is_git_commit_invocation(bash_command):
187
79
  sys.exit(0)
188
- working_directory = resolve_directory(extract_git_working_directory(bash_command))
189
- repository_root = resolve_repository_root(working_directory)
190
- if repository_root is None:
191
- sys.exit(0)
192
- if not list_staged_python_files(repository_root):
193
- sys.exit(0)
194
- gate_exit_code, gate_report = run_staged_gate(repository_root)
195
- if gate_exit_code == 0:
196
- sys.exit(0)
197
- denial = build_denial_response(gate_report)
198
- log_hook_block(
199
- calling_hook_name="precommit_code_rules_gate.py",
200
- hook_event="PreToolUse",
201
- block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
202
- tool_name="Bash",
203
- offending_input_preview=bash_command,
204
- )
205
- print(json.dumps(denial))
80
+ sys.stderr.write("Git commit proceeds to configured Git hooks.\n")
206
81
  sys.exit(0)
207
82
 
208
83