claude-dev-env 2.30.1 → 2.31.1

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 (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pr-description-writer
3
- description: "Optional agent that writes factual pull request titles, descriptions, and comments in plain illustrative language from the current diff."
3
+ description: "Writes factual pull request titles, descriptions, and comments in plain illustrative language from the current diff. The `pr_description_writer_gate` PreToolUse hook requires a spawn of this agent before `gh pr create`."
4
4
  tools: Read,Grep,Glob,Bash
5
5
  ---
6
6
 
package/AGENTS.md CHANGED
@@ -1 +1,7 @@
1
+ Use positive, present-focused prose.
1
2
 
3
+ State what should be done, what something does, what was done, or what needs to be done.
4
+
5
+ Include only information directly relevant to the immediate task or remaining work.
6
+
7
+ Use direct outcome-focused wording. Omit filler, failed attempts, alternatives considered, and process narration.
@@ -1,9 +1,13 @@
1
1
  """Behavioral tests for the enforcer_loading parts module."""
2
2
 
3
+ import subprocess
4
+ import sys
3
5
  from pathlib import Path
4
6
 
5
7
  from code_rules_gate_parts import enforcer_loading
6
8
 
9
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[2]
10
+
7
11
 
8
12
  def test_resolve_claude_dev_env_root_finds_enforcer_bearing_root() -> None:
9
13
  resolved_root = enforcer_loading.resolve_claude_dev_env_root(Path(__file__))
@@ -15,3 +19,36 @@ def test_load_validate_content_for_full_gate_returns_callable_passing_a_clean_mo
15
19
  clean_module = '"""Clean module."""\n\n\ndef ping() -> str:\n return "pong"\n'
16
20
  issues = validate_content(clean_module, "sample.py", "")
17
21
  assert issues == []
22
+
23
+
24
+ def test_load_validate_content_for_full_gate_survives_a_cold_sys_modules_cache() -> None:
25
+ """This test guards sys.modules caching, not the import path itself.
26
+
27
+ A module the enforcer imports directly (not through the hooks_constants
28
+ package this loader already resets) can stay cached in sys.modules from
29
+ an earlier test in the same pytest session, so a broken import path can
30
+ still read as working once some earlier test already imported the
31
+ module successfully. A fresh interpreter carries no such cache.
32
+
33
+ This does not prove the import path resolves in every layout the gate
34
+ is loaded from -- a projected layout missing a dependency file is a
35
+ separate failure this test cannot see, because this process still runs
36
+ against the real repository tree where every dependency already exists
37
+ on disk. That coverage lives in test_bugteam_code_rules_gate.py, whose
38
+ fixture copies a deliberately narrower file set.
39
+ """
40
+ probe_script = (
41
+ "from code_rules_gate_parts.enforcer_loading import "
42
+ "load_validate_content_for_full_gate\n"
43
+ "load_validate_content_for_full_gate()\n"
44
+ "print('ENFORCER_LOAD_OK')\n"
45
+ )
46
+ completed_process = subprocess.run(
47
+ [sys.executable, "-c", probe_script],
48
+ cwd=str(_SCRIPTS_DIRECTORY),
49
+ capture_output=True,
50
+ text=True,
51
+ check=False,
52
+ )
53
+ assert completed_process.returncode == 0, completed_process.stderr
54
+ assert "ENFORCER_LOAD_OK" in completed_process.stdout
@@ -62,7 +62,7 @@ const RETIRED_HOOK_RELATIVE_SEGMENTS = ['blocking', 'retired_gate.py'];
62
62
  const USER_HOOK_COMMAND = 'python3 my_own_gate.py --user-authored';
63
63
  const RETIRED_HOOK_EVENT_TYPE = 'PreToolUse';
64
64
  const DROPPED_HOOK_EVENT_TYPE = 'PreCompact';
65
- const RETIRED_HOOK_MATCHER = 'Write|Edit|MultiEdit';
65
+ const DISPATCHER_HOOK_COMMAND_SEGMENT = 'pre_tool_use_dispatcher.py';
66
66
  const UNMANAGED_SIBLING_DIRECTORY = 'my-notes';
67
67
  const NESTED_SKILL_DIRECTORY = 'foo';
68
68
  const NESTED_SKILL_FILE_SEGMENTS = [NESTED_SKILL_DIRECTORY, 'scripts', 'a.py'];
@@ -909,7 +909,9 @@ function seedRetiredManagedHook(sandbox) {
909
909
  const lookalikeHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}.bak"`;
910
910
  const settings = readSettings(sandbox.claudeDirectory);
911
911
  const liveGroup = settings.hooks[RETIRED_HOOK_EVENT_TYPE]
912
- .find(group => group.matcher === RETIRED_HOOK_MATCHER);
912
+ .find(group => group.hooks.some(
913
+ hook => hook.command.includes(DISPATCHER_HOOK_COMMAND_SEGMENT),
914
+ ));
913
915
  liveGroup.hooks.push({ type: 'command', command: retiredHookCommand });
914
916
  liveGroup.hooks.push({ type: 'command', command: USER_HOOK_COMMAND });
915
917
  liveGroup.hooks.push({ type: 'command', command: lookalikeHookCommand });
@@ -119,3 +119,15 @@ If you already have the data, don't fetch it again.
119
119
  3. **Changed-line scope filters only the report.** `defer_scope_to_caller` and the changed-line set decide which found violations block. Scope filters findings after every check in the roster already ran; it adds or removes no check.
120
120
 
121
121
  `hooks/hooks_constants/validation_phase_constants.py` is the single source for all three axes: the phase names (`EDIT_LANE_PHASE`, `FULL_GATE_PHASE`), the full-gate-only roster (`ALL_FULL_GATE_ONLY_CHECK_NAMES`), and the hook-infrastructure edit-lane roster (`ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES`).
122
+
123
+ ## 11.6 LANE ASSIGNMENT IS BY SCOPE
124
+
125
+ Scope assigns the lane. A check that reads a file other than the target runs on the full gate. Every other check runs on both lanes.
126
+
127
+ Hook-infrastructure targets run three checks in the edit lane — `check_same_file_inline_duplicate_body`, `check_zero_payload_function_alias`, `check_unanchored_command_dispatch` — and the whole roster on the full gate. `ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES` holds that set.
128
+
129
+ Three surfaces report on the roster, and each reports a specific thing:
130
+
131
+ - `hooks/validators/hook_timing_harness.py` builds a `Write` payload against a target that already holds content. `_contents_for_validation` returns `None` for that payload, so the harness times interpreter start and hook dispatch. Time an `Edit` payload against a real file to measure the checks.
132
+ - `~/.claude/logs/hook-blocks.log` records the denials raised by fixtures in `test_code_rules_enforcer_*.py` and by the timing harness's default target.
133
+ - `hooks/validators/run_all_validators.py` suppresses exemption-directory substring matching for a target under a system temporary root, so pytest's `test_<name>0` parents stay scaffolding. A pytest `--basetemp` outside those roots restores the matching, and the staged copy takes a test-file exemption. Run this suite under the default temporary root.
@@ -4,24 +4,48 @@
4
4
  import json
5
5
  import re
6
6
  import sys
7
+ from pathlib import Path
8
+
9
+ try:
10
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
11
+ if _hooks_dir not in sys.path:
12
+ sys.path.insert(0, _hooks_dir)
13
+
14
+ from hooks_constants.migration_safety_advisor_constants import (
15
+ MULTI_EDIT_TOOL_NAME,
16
+ )
17
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
18
+ except ImportError as import_error:
19
+ raise ImportError(
20
+ "migration_safety_advisor: cannot import its sibling modules; "
21
+ "ensure the hooks directory is importable."
22
+ ) from import_error
7
23
 
8
24
  MIGRATION_PATH_PATTERN = re.compile(r"[/\\]migrations[/\\]\d{4}_\w+\.py$")
9
25
  UNSAFE_OPERATIONS = ["RemoveField", "RenameField", "DeleteModel", "RenameModel"]
10
26
 
11
27
 
28
+ def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
29
+ """Return the text a Write, Edit, or MultiEdit payload introduces."""
30
+ if tool_name == MULTI_EDIT_TOOL_NAME:
31
+ return joined_new_strings(all_tool_input)
32
+ return all_tool_input.get("content", "") or all_tool_input.get("new_string", "")
33
+
34
+
12
35
  def main() -> None:
13
36
  try:
14
37
  hook_input = json.load(sys.stdin)
15
38
  except json.JSONDecodeError:
16
39
  sys.exit(0)
17
40
 
41
+ tool_name = hook_input.get("tool_name", "")
18
42
  tool_input = hook_input.get("tool_input", {})
19
43
  file_path = tool_input.get("file_path", "")
20
44
 
21
45
  if not MIGRATION_PATH_PATTERN.search(file_path):
22
46
  sys.exit(0)
23
47
 
24
- content = tool_input.get("content", "") or tool_input.get("new_string", "")
48
+ content = _resolve_content(tool_name, tool_input)
25
49
  found_unsafe = [op for op in UNSAFE_OPERATIONS if op in content]
26
50
 
27
51
  if found_unsafe:
@@ -11,18 +11,26 @@ import sys
11
11
  from collections import Counter
12
12
  from pathlib import Path
13
13
 
14
- hooks_root_directory = str(Path(__file__).resolve().parent.parent)
15
- if hooks_root_directory not in sys.path:
16
- sys.path.insert(0, hooks_root_directory)
17
-
18
- from hooks_constants.precommit_code_rules_gate_constants import GIT_COMMAND_TIMEOUT_SECONDS
19
- from hooks_constants.refactor_guard_constants import (
20
- ALL_PYTHON_KEYWORDS,
21
- CHANGED_SURFACE_MATCH_RATIO,
22
- MAXIMUM_REFACTOR_LINE_DELTA,
23
- REFACTOR_LINE_DELTA_DIVISOR,
24
- )
25
- from hooks_constants.session_edit_stage_gate_constants import GIT_EXECUTABLE_TOKEN
14
+ try:
15
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
16
+ if hooks_root_directory not in sys.path:
17
+ sys.path.insert(0, hooks_root_directory)
18
+
19
+ from hooks_constants.multi_edit_reconstruction import edits_for_tool
20
+ from hooks_constants.precommit_code_rules_gate_constants import GIT_COMMAND_TIMEOUT_SECONDS
21
+ from hooks_constants.refactor_guard_constants import (
22
+ ALL_PYTHON_KEYWORDS,
23
+ CHANGED_SURFACE_MATCH_RATIO,
24
+ MAXIMUM_REFACTOR_LINE_DELTA,
25
+ REFACTOR_LINE_DELTA_DIVISOR,
26
+ )
27
+ from hooks_constants.session_edit_stage_gate_constants import GIT_EXECUTABLE_TOKEN
28
+ except ImportError as import_error:
29
+ raise ImportError(
30
+ "refactor_guard: cannot import its sibling modules; "
31
+ "ensure the hooks directory is importable."
32
+ ) from import_error
33
+
26
34
 
27
35
  REFACTOR_BYPASS_TOKEN_PATH = Path.home() / ".claude" / ".refactor-bypass-token"
28
36
  identifier_join_separator = ", "
@@ -187,10 +195,9 @@ def _nonempty_stripped_lines(text: str) -> list[str]:
187
195
  return [each_line.strip() for each_line in text.splitlines() if each_line.strip()]
188
196
 
189
197
 
190
- def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
198
+ def _lines_within_changed_surface(old_string: str, all_added_lines: list[str]) -> bool:
191
199
  """Return whether at least half of the edited lines are current additions."""
192
200
  all_old_lines = _nonempty_stripped_lines(old_string)
193
- all_added_lines = _get_added_line_occurrences(file_path)
194
201
  if not all_old_lines or not all_added_lines:
195
202
  return False
196
203
  all_old_line_counts = Counter(all_old_lines)
@@ -203,6 +210,12 @@ def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
203
210
  return matched_occurrence_count / edited_occurrence_count >= CHANGED_SURFACE_MATCH_RATIO
204
211
 
205
212
 
213
+ def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
214
+ """Return whether at least half of the edited lines are current additions."""
215
+ all_added_lines = _get_added_line_occurrences(file_path)
216
+ return _lines_within_changed_surface(old_string, all_added_lines)
217
+
218
+
206
219
  def _is_existing_edit_target(file_path: str) -> bool:
207
220
  if not file_path or is_hook_infrastructure(file_path):
208
221
  return False
@@ -290,20 +303,74 @@ def _read_edit_fields(payload_by_key: dict[str, object]) -> tuple[str, str, str,
290
303
  return tool_name, file_path_field, old_string_field, new_string_field
291
304
 
292
305
 
306
+ def _refactor_advisory_description_within_surface(
307
+ all_added_lines: list[str],
308
+ old_string: str,
309
+ new_string: str,
310
+ ) -> str | None:
311
+ """Return an advisory description for one edit, given the file's added lines."""
312
+ refactor_description = is_refactor_edit(old_string, new_string)
313
+ if refactor_description is None:
314
+ return None
315
+ if _lines_within_changed_surface(old_string, all_added_lines):
316
+ return None
317
+ return refactor_description
318
+
319
+
320
+ def _multi_edit_refactor_advisory_description(
321
+ payload_by_key: dict[str, object],
322
+ ) -> tuple[str, str] | None:
323
+ """Return the (file_path, description) pair for the first eligible edit in a MultiEdit."""
324
+ raw_tool_input = payload_by_key.get("tool_input")
325
+ if not isinstance(raw_tool_input, dict):
326
+ return None
327
+ file_path_field = raw_tool_input.get("file_path")
328
+ file_path = file_path_field if isinstance(file_path_field, str) else ""
329
+ if not file_path:
330
+ return None
331
+ if not _is_existing_edit_target(file_path):
332
+ return None
333
+ all_added_lines = _get_added_line_occurrences(file_path)
334
+ for each_edit in edits_for_tool("MultiEdit", raw_tool_input):
335
+ if not isinstance(each_edit, dict):
336
+ continue
337
+ old_string_field = each_edit.get("old_string")
338
+ new_string_field = each_edit.get("new_string")
339
+ if not isinstance(old_string_field, str) or not isinstance(new_string_field, str):
340
+ continue
341
+ refactor_description = _refactor_advisory_description_within_surface(
342
+ all_added_lines, old_string_field, new_string_field
343
+ )
344
+ if refactor_description is not None:
345
+ return file_path, refactor_description
346
+ return None
347
+
348
+
349
+ def _emit_advisory(file_path: str, refactor_description: str) -> None:
350
+ advisory_payload = build_refactor_advisory_payload(refactor_description, file_path)
351
+ sys.stdout.write(json.dumps(advisory_payload))
352
+ sys.stdout.flush()
353
+
354
+
293
355
  def main() -> None:
294
356
  """Emit an Edit-stage advisory for eligible out-of-surface refactors."""
295
357
  payload_by_key = _read_hook_input()
296
358
  if payload_by_key is None:
297
359
  return
298
360
  tool_name, file_path, old_string, new_string = _read_edit_fields(payload_by_key)
299
- if tool_name != "Edit" or is_bypass_approved():
361
+ if is_bypass_approved():
362
+ return
363
+ if tool_name == "MultiEdit":
364
+ multi_edit_result = _multi_edit_refactor_advisory_description(payload_by_key)
365
+ if multi_edit_result is not None:
366
+ _emit_advisory(*multi_edit_result)
367
+ return
368
+ if tool_name != "Edit":
300
369
  return
301
370
  refactor_description = find_refactor_advisory_description(file_path, old_string, new_string)
302
371
  if refactor_description is None:
303
372
  return
304
- advisory_payload = build_refactor_advisory_payload(refactor_description, file_path)
305
- sys.stdout.write(json.dumps(advisory_payload))
306
- sys.stdout.flush()
373
+ _emit_advisory(file_path, refactor_description)
307
374
 
308
375
 
309
376
  if __name__ == "__main__":
@@ -7,8 +7,6 @@ import subprocess
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
- import pytest
11
-
12
10
 
13
11
  _ADVISORY_DIRECTORY = Path(__file__).resolve().parent
14
12
  _HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
@@ -59,6 +57,7 @@ def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dic
59
57
 
60
58
 
61
59
  def _prepare_fresh_test_pair(migration_path: Path) -> None:
60
+ """Write a migration module and its paired test file."""
62
61
  migration_path.parent.mkdir(parents=True)
63
62
  migration_path.write_text("operations = []\n", encoding="utf-8")
64
63
  test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
@@ -104,12 +103,12 @@ def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
104
103
  assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
105
104
 
106
105
 
107
- @pytest.mark.parametrize("tool_name", ("Write", "MultiEdit"))
108
- def test_dispatcher_keeps_migration_advisor_edit_only(tmp_path: Path, tool_name: str) -> None:
106
+ def test_dispatcher_keeps_migration_advisor_write_excluded(tmp_path: Path) -> None:
107
+ """Write stays out of the migration advisor's roster; only Edit and MultiEdit reach it."""
109
108
  migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
110
109
  _prepare_fresh_test_pair(migration_path)
111
110
  payload = _build_control_payload(
112
- tool_name,
111
+ "Write",
113
112
  migration_path,
114
113
  'operations = [migrations.RemoveField("name")]',
115
114
  )
@@ -118,3 +117,88 @@ def test_dispatcher_keeps_migration_advisor_edit_only(tmp_path: Path, tool_name:
118
117
  serialized_payload = json.dumps(dispatched_payload or {})
119
118
 
120
119
  assert "MIGRATION SAFETY" not in serialized_payload
120
+
121
+
122
+ def test_dispatcher_extends_migration_advisor_to_multi_edit(tmp_path: Path) -> None:
123
+ """A MultiEdit reaches the migration advisor the same way an Edit does."""
124
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
125
+ _prepare_fresh_test_pair(migration_path)
126
+ payload = _build_control_payload(
127
+ "MultiEdit",
128
+ migration_path,
129
+ 'operations = [migrations.RemoveField("name")]',
130
+ )
131
+
132
+ dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
133
+ assert dispatched_payload is not None
134
+ serialized_payload = json.dumps(dispatched_payload)
135
+
136
+ assert "MIGRATION SAFETY" in serialized_payload
137
+
138
+
139
+ def _build_multi_edit_payload(
140
+ file_path: Path, all_edits: list[dict[str, str]]
141
+ ) -> dict[str, object]:
142
+ return {
143
+ "tool_name": "MultiEdit",
144
+ "tool_input": {"file_path": str(file_path), "edits": all_edits},
145
+ }
146
+
147
+
148
+ def test_standalone_advisor_warns_for_unsafe_operation_in_second_multi_edit(
149
+ tmp_path: Path,
150
+ ) -> None:
151
+ """An unsafe operation in the second edit of a MultiEdit is caught, not only the first.
152
+
153
+ migration_safety_advisor has no tool_name gate today: it reads
154
+ tool_input.get("new_string"), a key a MultiEdit payload never carries at
155
+ the top level. Widening its roster entry alone would silently do
156
+ nothing on a real MultiEdit call.
157
+ """
158
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
159
+ payload = _build_multi_edit_payload(
160
+ migration_path,
161
+ [
162
+ {"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"},
163
+ {
164
+ "old_string": "migrations.AddField()",
165
+ "new_string": 'migrations.AddField(), migrations.RemoveField("name")',
166
+ },
167
+ ],
168
+ )
169
+
170
+ advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
171
+ assert advisory_payload is not None
172
+ permission_fields = _permission_fields(advisory_payload)
173
+
174
+ assert permission_fields["permissionDecision"] == "allow"
175
+ assert "RemoveField" in _text_field(permission_fields, "additionalContext")
176
+
177
+
178
+ def test_standalone_advisor_reads_a_write_payloads_content_field(tmp_path: Path) -> None:
179
+ """_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
180
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
181
+ payload: dict[str, object] = {
182
+ "tool_name": "Write",
183
+ "tool_input": {
184
+ "file_path": str(migration_path),
185
+ "content": 'operations = [migrations.RemoveField("name")]',
186
+ },
187
+ }
188
+
189
+ advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
190
+ assert advisory_payload is not None
191
+ assert "RemoveField" in _text_field(
192
+ _permission_fields(advisory_payload), "additionalContext"
193
+ )
194
+
195
+
196
+ def test_standalone_advisor_is_silent_for_a_clean_multi_edit(tmp_path: Path) -> None:
197
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
198
+ payload = _build_multi_edit_payload(
199
+ migration_path,
200
+ [{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"}],
201
+ )
202
+
203
+ advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
204
+ assert advisory_payload is None
@@ -0,0 +1,87 @@
1
+ """Unit tests for refactor_guard's MultiEdit reconstruction, run in-process.
2
+
3
+ test_refactor_guard_advisory.py and test_refactor_guard_eligibility.py already
4
+ cover this hook end to end through real git fixtures; these tests exercise
5
+ ``_multi_edit_refactor_advisory_description`` directly against a target path
6
+ outside any git repository, where the underlying git commands fail closed and
7
+ return no added lines, so a qualifying rename is eligible without fixture setup.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ try:
16
+ _ADVISORY_DIRECTORY = Path(__file__).resolve().parent
17
+ if str(_ADVISORY_DIRECTORY) not in sys.path:
18
+ sys.path.insert(0, str(_ADVISORY_DIRECTORY))
19
+
20
+ import refactor_guard
21
+ except ImportError as import_error:
22
+ raise ImportError(
23
+ "test_refactor_guard: cannot import its sibling modules; "
24
+ "ensure the advisory directory is importable."
25
+ ) from import_error
26
+
27
+
28
+ def _multi_edit_payload(file_path: str, all_edits: list[dict[str, str]]) -> dict[str, object]:
29
+ return {
30
+ "tool_name": "MultiEdit",
31
+ "tool_input": {"file_path": file_path, "edits": all_edits},
32
+ }
33
+
34
+
35
+ def test_multi_edit_reports_the_first_eligible_rename(tmp_path: Path) -> None:
36
+ """A rename in the second edit is reported, even though the first edit is ordinary."""
37
+ target_path = str(tmp_path / "module.py")
38
+ payload = _multi_edit_payload(
39
+ target_path,
40
+ [
41
+ {"old_string": "return amount", "new_string": "return amount + 1"},
42
+ {
43
+ "old_string": "def calculate_total(amount):\n return amount",
44
+ "new_string": "def compute_total(amount):\n return amount",
45
+ },
46
+ ],
47
+ )
48
+
49
+ result = refactor_guard._multi_edit_refactor_advisory_description(payload)
50
+
51
+ assert result is not None
52
+ reported_path, description = result
53
+ assert reported_path == target_path
54
+ assert "calculate_total" in description
55
+ assert "compute_total" in description
56
+
57
+
58
+ def test_multi_edit_scans_past_two_ordinary_edits_to_the_third(tmp_path: Path) -> None:
59
+ """The loop over edit pairs reaches the third pair, not only the first two."""
60
+ target_path = str(tmp_path / "module.py")
61
+ payload = _multi_edit_payload(
62
+ target_path,
63
+ [
64
+ {"old_string": "return amount", "new_string": "return amount + 1"},
65
+ {"old_string": "return amount + 1", "new_string": "return amount + 2"},
66
+ {
67
+ "old_string": "def calculate_total(amount):\n return amount + 2",
68
+ "new_string": "def compute_total(amount):\n return amount + 2",
69
+ },
70
+ ],
71
+ )
72
+
73
+ result = refactor_guard._multi_edit_refactor_advisory_description(payload)
74
+
75
+ assert result is not None
76
+ _reported_path, description = result
77
+ assert "compute_total" in description
78
+
79
+
80
+ def test_multi_edit_returns_none_when_no_edit_qualifies(tmp_path: Path) -> None:
81
+ target_path = str(tmp_path / "module.py")
82
+ payload = _multi_edit_payload(
83
+ target_path,
84
+ [{"old_string": "return amount", "new_string": "return amount + 1"}],
85
+ )
86
+
87
+ assert refactor_guard._multi_edit_refactor_advisory_description(payload) is None
@@ -19,6 +19,8 @@ if str(ADVISORY_DIRECTORY) not in sys.path:
19
19
  import refactor_guard # noqa: E402
20
20
  from refactor_guard_test_support import commit_file, stage_file # noqa: E402
21
21
 
22
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
23
+
22
24
 
23
25
  def _refactor_payload(file_path: Path) -> str:
24
26
  return json.dumps(
@@ -127,6 +129,7 @@ def test_direct_hook_stays_silent_for_write_payload(git_repository: Path) -> Non
127
129
 
128
130
 
129
131
  def test_dispatcher_preserves_edit_stage_guidance(git_repository: Path) -> None:
132
+ """A red step (staged, uncommitted test) plus a staged refactor still allows."""
130
133
  source_path = git_repository / "module.py"
131
134
  matching_test_path = git_repository / "test_module.py"
132
135
  commit_file(
@@ -134,7 +137,7 @@ def test_dispatcher_preserves_edit_stage_guidance(git_repository: Path) -> None:
134
137
  source_path,
135
138
  "def calculate_total(amount: int) -> int:\n return amount\n",
136
139
  )
137
- commit_file(
140
+ stage_file(
138
141
  git_repository,
139
142
  matching_test_path,
140
143
  "def calculate_total(amount: int) -> int:\n return amount\n\n"
@@ -169,3 +172,71 @@ def test_bypass_token_is_consumed_once(tmp_path: Path, monkeypatch: pytest.Monke
169
172
  assert refactor_guard.is_bypass_approved()
170
173
  assert not bypass_token_path.exists()
171
174
  assert not refactor_guard.is_bypass_approved()
175
+
176
+
177
+ def _multi_edit_payload(file_path: Path, all_edits: list[dict[str, str]]) -> str:
178
+ return json.dumps(
179
+ {
180
+ "tool_name": "MultiEdit",
181
+ "tool_input": {"file_path": str(file_path), "edits": all_edits},
182
+ }
183
+ )
184
+
185
+
186
+ def _commit_and_stage_the_renamed_module(git_repository: Path, source_path: Path) -> None:
187
+ """Commit the base module, then stage an unrelated numeric change to it."""
188
+ commit_file(
189
+ git_repository,
190
+ source_path,
191
+ "def calculate_total(amount: int) -> int:\n return amount\n",
192
+ )
193
+ stage_file(
194
+ git_repository,
195
+ source_path,
196
+ "def calculate_total(amount: int) -> int:\n return amount + 1\n",
197
+ )
198
+
199
+
200
+ def test_direct_hook_flags_refactor_in_second_multi_edit(git_repository: Path) -> None:
201
+ """A rename buried in the second edit of a MultiEdit is caught, not only the first.
202
+
203
+ The first edit is an ordinary numeric change; only the second edit renames
204
+ calculate_total to compute_total outside the currently staged diff.
205
+ """
206
+ source_path = git_repository / "module.py"
207
+ _commit_and_stage_the_renamed_module(git_repository, source_path)
208
+ payload_text = _multi_edit_payload(
209
+ source_path,
210
+ [
211
+ {"old_string": "return amount", "new_string": "return amount + 1"},
212
+ {
213
+ "old_string": "def calculate_total(amount: int) -> int:\n return amount",
214
+ "new_string": "def compute_total(amount: int) -> int:\n return amount",
215
+ },
216
+ ],
217
+ )
218
+
219
+ completed_hook = _run_hook(HOOK_SCRIPT_PATH, git_repository, payload_text)
220
+ advisory_payload = json.loads(completed_hook.stdout)
221
+ hook_specific_output = advisory_payload["hookSpecificOutput"]
222
+
223
+ assert hook_specific_output["permissionDecision"] == "allow"
224
+ assert "Edit-stage" in advisory_payload["systemMessage"]
225
+
226
+
227
+ def test_direct_hook_stays_silent_for_ordinary_multi_edit(git_repository: Path) -> None:
228
+ source_path = git_repository / "module.py"
229
+ commit_file(
230
+ git_repository,
231
+ source_path,
232
+ "def calculate_total(amount: int) -> int:\n return amount\n",
233
+ )
234
+ payload_text = _multi_edit_payload(
235
+ source_path,
236
+ [{"old_string": "return amount", "new_string": "return amount + 1"}],
237
+ )
238
+
239
+ completed_hook = _run_hook(HOOK_SCRIPT_PATH, git_repository, payload_text)
240
+
241
+ assert completed_hook.returncode == 0
242
+ assert completed_hook.stdout == ""
@@ -149,18 +149,19 @@ def _scan_function_body_constants(content: str) -> list[str]:
149
149
  return advisory_issues
150
150
 
151
151
 
152
- def check_constants_outside_config_advisory(content: str, file_path: str) -> list[str]:
153
- """Return advisory entries for UPPER_SNAKE assignments inside function bodies.
152
+ def check_constants_outside_config_advisory(content: str, file_path: str) -> None:
153
+ """Emit stderr advisories for UPPER_SNAKE assignments inside function bodies.
154
154
 
155
155
  Module-level UPPER_SNAKE outside config/ is blocking (see
156
156
  check_constants_outside_config). Function-local UPPER_SNAKE is a softer
157
157
  smell — it belongs in config/ but does not block the write. This function
158
- surfaces those as advisory so callers can route them to stderr rather than
159
- to the blocking deny payload.
158
+ writes each finding straight to stderr, matching the other advisory-only
159
+ checks, so the write proceeds regardless of what it finds.
160
160
  """
161
161
  if _is_exempt_for_advisory_scan(file_path):
162
- return []
163
- return _scan_function_body_constants(content)
162
+ return
163
+ for each_issue in _scan_function_body_constants(content):
164
+ sys.stderr.write(f"[CODE_RULES advisory] {file_path}: {each_issue}\n")
164
165
 
165
166
 
166
167
  def _is_upper_snake_constant_name(name: str) -> bool: