claude-dev-env 2.19.0 → 2.21.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 (53) hide show
  1. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
  2. package/.agents/skills/e-code-review/SKILL.md +12 -1
  3. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  4. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  5. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  6. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  7. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  8. package/.agents/skills/pr-cleanup/SKILL.md +109 -11
  9. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  11. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  13. package/docs/CODE_RULES.md +2 -0
  14. package/hooks/advisory/conftest.py +10 -0
  15. package/hooks/advisory/refactor_guard.py +250 -144
  16. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  17. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  18. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  19. package/hooks/blocking/block_main_commit.py +66 -33
  20. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  21. package/hooks/blocking/code_rules_enforcer.py +95 -0
  22. package/hooks/blocking/codex_apply_patch.py +238 -0
  23. package/hooks/blocking/test_block_main_commit.py +145 -0
  24. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  25. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
  26. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  28. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
  29. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  30. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
  31. package/hooks/git-hooks/AGENTS.md +1 -1
  32. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  33. package/hooks/git-hooks/post_commit.py +160 -51
  34. package/hooks/git-hooks/pre_commit.py +3 -3
  35. package/hooks/git-hooks/test_post_commit.py +203 -0
  36. package/hooks/git-hooks/test_pre_commit.py +2 -2
  37. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  38. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
  39. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  40. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  41. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  42. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  43. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  44. package/hooks/validation/mypy_validator.py +213 -80
  45. package/hooks/validation/test_mypy_validator.py +288 -13
  46. package/hooks/workflow/auto_formatter.py +225 -93
  47. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  48. package/hooks/workflow/test_auto_formatter.py +261 -12
  49. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  50. package/package.json +1 -1
  51. package/rules/failure-blast-radius.md +126 -0
  52. package/scripts/codex_compat_materializer.py +395 -17
  53. package/scripts/tests/test_codex_compat_materializer.py +17 -3
@@ -1,203 +1,309 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- Refactor guard - blocks edits that rename/restructure existing code not in the git diff.
2
+ """Warn about refactors that reach beyond the current Edit change surface."""
4
3
 
5
- Detects when an Edit tool call is modifying existing code (renaming variables,
6
- functions, restructuring) rather than writing new code or replacing wholesale.
4
+ from __future__ import annotations
7
5
 
8
- Only fires for Edit operations (not Write, which creates/replaces entire files).
9
- """
10
6
  import json
7
+ import os
11
8
  import re
12
9
  import subprocess
13
10
  import sys
11
+ from collections import Counter
14
12
  from pathlib import Path
15
- from typing import Optional
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
16
26
 
17
27
  REFACTOR_BYPASS_TOKEN_PATH = Path.home() / ".claude" / ".refactor-bypass-token"
28
+ identifier_join_separator = ", "
18
29
 
19
30
 
20
- def get_git_diff_added_lines(file_path: str) -> set[str]:
21
- """Get the set of added lines (stripped) from git diff for a file."""
22
- added_lines: set[str] = set()
23
- try:
24
- result = subprocess.run(
25
- ["git", "diff", "HEAD", "--", file_path],
26
- check=False, capture_output=True,
27
- text=True,
28
- timeout=5,
29
- )
30
- for line in result.stdout.split("\n"):
31
- if line.startswith("+") and not line.startswith("+++"):
32
- added_lines.add(line[1:].strip())
31
+ def _git_query_context(file_path: str) -> tuple[str, str]:
32
+ """Keep lexical path ownership while Git resolves repository metadata."""
33
+ target_path = Path(file_path).absolute()
34
+ return str(target_path.parent), str(target_path)
35
+
33
36
 
34
- staged_result = subprocess.run(
35
- ["git", "diff", "--staged", "--", file_path],
36
- check=False, capture_output=True,
37
+ def _git_environment() -> dict[str, str]:
38
+ return {
39
+ each_environment_name: each_environment_value
40
+ for each_environment_name, each_environment_value in os.environ.items()
41
+ if not each_environment_name.startswith("GIT_")
42
+ }
43
+
44
+
45
+ def _read_added_lines_from_git(all_git_arguments: tuple[str, ...], file_path: str) -> list[str]:
46
+ """Return added lines from one Git diff command."""
47
+ working_directory, absolute_file_path = _git_query_context(file_path)
48
+ try:
49
+ completed_process = subprocess.run(
50
+ [*all_git_arguments, "--", absolute_file_path],
51
+ check=False,
52
+ capture_output=True,
37
53
  text=True,
38
- timeout=5,
54
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
55
+ cwd=working_directory,
56
+ env=_git_environment(),
39
57
  )
40
- for line in staged_result.stdout.split("\n"):
41
- if line.startswith("+") and not line.startswith("+++"):
42
- added_lines.add(line[1:].strip())
43
58
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
44
- return set()
45
- return added_lines
59
+ return []
60
+ if completed_process.returncode != 0:
61
+ return []
62
+ return [
63
+ each_line[1:].strip()
64
+ for each_line in completed_process.stdout.splitlines()
65
+ if each_line.startswith("+") and not each_line.startswith("+++")
66
+ ]
67
+
68
+
69
+ def _get_added_line_occurrences(file_path: str) -> list[str]:
70
+ all_added_lines = _read_added_lines_from_git((GIT_EXECUTABLE_TOKEN, "diff"), file_path)
71
+ all_added_lines.extend(
72
+ _read_added_lines_from_git((GIT_EXECUTABLE_TOKEN, "diff", "--cached"), file_path)
73
+ )
74
+ return all_added_lines
75
+
76
+
77
+ def get_git_diff_added_lines(file_path: str) -> set[str]:
78
+ """Return staged and unstaged added lines for a tracked file."""
79
+ return set(_get_added_line_occurrences(file_path))
46
80
 
47
81
 
48
82
  def is_new_file(file_path: str) -> bool:
49
- """Check if file is untracked (entirely new)."""
83
+ """Return whether Git reports the file as untracked."""
84
+ working_directory, absolute_file_path = _git_query_context(file_path)
50
85
  try:
51
- result = subprocess.run(
52
- ["git", "ls-files", "--others", "--exclude-standard", "--", file_path],
53
- check=False, capture_output=True,
86
+ completed_process = subprocess.run(
87
+ [
88
+ GIT_EXECUTABLE_TOKEN,
89
+ "ls-files",
90
+ "--others",
91
+ "--exclude-standard",
92
+ "--",
93
+ absolute_file_path,
94
+ ],
95
+ check=False,
96
+ capture_output=True,
54
97
  text=True,
55
- timeout=5,
98
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
99
+ cwd=working_directory,
100
+ env=_git_environment(),
56
101
  )
57
- return bool(result.stdout.strip())
58
102
  except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
59
103
  return False
104
+ return completed_process.returncode == 0 and bool(completed_process.stdout.strip())
60
105
 
61
106
 
62
107
  def is_hook_infrastructure(file_path: str) -> bool:
63
- """Check if file is a Claude Code hook."""
64
- path_lower = file_path.lower().replace("\\", "/")
65
- return "/.claude/" in path_lower
108
+ """Return whether a path belongs to the installed Claude hook tree."""
109
+ normalized_path = file_path.lower().replace("\\", "/")
110
+ return "/.claude/" in normalized_path
66
111
 
67
112
 
68
113
  def extract_identifiers(code: str) -> set[str]:
69
- """Extract meaningful identifiers (variable/function/class names) from code."""
70
- identifier_pattern = re.compile(r'\b([a-zA-Z_][a-zA-Z0-9_]{2,})\b')
71
- identifiers = set(identifier_pattern.findall(code))
72
- python_keywords = {
73
- "def", "class", "return", "import", "from", "if", "elif", "else",
74
- "for", "while", "try", "except", "finally", "with", "as", "yield",
75
- "raise", "pass", "break", "continue", "and", "or", "not", "in",
76
- "is", "lambda", "None", "True", "False", "self", "cls", "async",
77
- "await", "global", "nonlocal", "assert", "del", "print", "len",
78
- "range", "list", "dict", "set", "str", "int", "float", "bool",
79
- "type", "isinstance", "hasattr", "getattr", "setattr", "super",
80
- "property", "staticmethod", "classmethod", "abstractmethod",
81
- "Optional", "Union", "List", "Dict", "Set", "Tuple", "Any",
82
- }
83
- return identifiers - python_keywords
84
-
85
-
86
- def is_refactor_edit(old_string: str, new_string: str) -> Optional[str]:
87
- """Detect if an edit is a rename/refactor rather than a functional change.
114
+ """Return meaningful Python identifiers from a code fragment."""
115
+ all_identifiers = set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]{2,})\b", code))
116
+ return all_identifiers - ALL_PYTHON_KEYWORDS
117
+
118
+
119
+ def _have_similar_words(old_identifier: str, new_identifier: str) -> bool:
120
+ all_old_words = set(re.findall(r"[a-z]+|[A-Z][a-z]*", old_identifier))
121
+ all_new_words = set(re.findall(r"[a-z]+|[A-Z][a-z]*", new_identifier))
122
+ return (
123
+ bool(all_old_words and all_new_words)
124
+ and len(all_old_words & all_new_words) >= len(all_old_words) * CHANGED_SURFACE_MATCH_RATIO
125
+ )
126
+
127
+
128
+ def _describe_identifier_changes(
129
+ all_removed_identifiers: set[str], all_added_identifiers: set[str]
130
+ ) -> str | None:
131
+ all_renamed_identifiers: list[str] = []
132
+ for each_old_identifier in sorted(all_removed_identifiers):
133
+ for each_new_identifier in sorted(all_added_identifiers):
134
+ is_same_spelling = each_old_identifier.lower().replace(
135
+ "_", ""
136
+ ) == each_new_identifier.lower().replace("_", "")
137
+ if is_same_spelling or _have_similar_words(each_old_identifier, each_new_identifier):
138
+ all_renamed_identifiers.append(f"{each_old_identifier} -> {each_new_identifier}")
139
+ break
140
+ if all_renamed_identifiers:
141
+ return f"Renaming detected: {identifier_join_separator.join(all_renamed_identifiers[:3])}"
142
+ if len(all_removed_identifiers) > 1 and len(all_added_identifiers) > 1:
143
+ return (
144
+ "Multiple identifiers changed with same structure: "
145
+ f"removed {sorted(all_removed_identifiers)[:3]}, "
146
+ f"added {sorted(all_added_identifiers)[:3]}"
147
+ )
148
+ return None
88
149
 
89
- Returns a description of the refactor if detected, None otherwise.
90
- """
91
- old_lines = [line.strip() for line in old_string.strip().split("\n") if line.strip()]
92
- new_lines = [line.strip() for line in new_string.strip().split("\n") if line.strip()]
93
150
 
94
- if not old_lines or not new_lines:
151
+ def is_refactor_edit(old_string: str, new_string: str) -> str | None:
152
+ """Return a description when an edit preserves structure and changes names."""
153
+ all_old_lines = [
154
+ each_line.strip() for each_line in old_string.strip().splitlines() if each_line.strip()
155
+ ]
156
+ all_new_lines = [
157
+ each_line.strip() for each_line in new_string.strip().splitlines() if each_line.strip()
158
+ ]
159
+ if not all_old_lines or not all_new_lines:
95
160
  return None
96
-
97
- if abs(len(old_lines) - len(new_lines)) > max(len(old_lines) // 2, 3):
161
+ if abs(len(all_old_lines) - len(all_new_lines)) > max(
162
+ len(all_old_lines) // REFACTOR_LINE_DELTA_DIVISOR, MAXIMUM_REFACTOR_LINE_DELTA
163
+ ):
98
164
  return None
99
-
100
- old_identifiers = extract_identifiers(old_string)
101
- new_identifiers = extract_identifiers(new_string)
102
-
103
- removed_identifiers = old_identifiers - new_identifiers
104
- added_identifiers = new_identifiers - old_identifiers
105
-
106
- if not removed_identifiers or not added_identifiers:
165
+ all_old_identifiers = extract_identifiers(old_string)
166
+ all_new_identifiers = extract_identifiers(new_string)
167
+ all_removed_identifiers = all_old_identifiers - all_new_identifiers
168
+ all_added_identifiers = all_new_identifiers - all_old_identifiers
169
+ if not all_removed_identifiers or not all_added_identifiers:
107
170
  return None
108
-
109
- old_no_ids = old_string
110
- new_no_ids = new_string
111
- for identifier in old_identifiers | new_identifiers:
112
- old_no_ids = old_no_ids.replace(identifier, "ID")
113
- new_no_ids = new_no_ids.replace(identifier, "ID")
114
-
115
- old_structure = re.sub(r'\s+', ' ', old_no_ids.strip())
116
- new_structure = re.sub(r'\s+', ' ', new_no_ids.strip())
117
-
118
- if old_structure == new_structure:
119
- renamed = []
120
- for old_id in sorted(removed_identifiers):
121
- for new_id in sorted(added_identifiers):
122
- if old_id.lower().replace("_", "") == new_id.lower().replace("_", ""):
123
- renamed.append(f"{old_id} -> {new_id}")
124
- break
125
- old_words = set(re.findall(r'[a-z]+|[A-Z][a-z]*', old_id))
126
- new_words = set(re.findall(r'[a-z]+|[A-Z][a-z]*', new_id))
127
- if old_words and new_words and len(old_words & new_words) >= len(old_words) * 0.5:
128
- renamed.append(f"{old_id} -> {new_id}")
129
- break
130
-
131
- if renamed:
132
- return f"Renaming detected: {', '.join(renamed[:3])}"
133
-
134
- if len(removed_identifiers) >= 2 and len(added_identifiers) >= 2:
135
- return f"Multiple identifiers changed with same structure: removed {sorted(removed_identifiers)[:3]}, added {sorted(added_identifiers)[:3]}"
136
-
137
- return None
171
+ all_identifiers = all_old_identifiers | all_new_identifiers
172
+ old_structure = re.sub(r"\s+", " ", _replace_identifiers(old_string, all_identifiers).strip())
173
+ new_structure = re.sub(r"\s+", " ", _replace_identifiers(new_string, all_identifiers).strip())
174
+ if old_structure != new_structure:
175
+ return None
176
+ return _describe_identifier_changes(all_removed_identifiers, all_added_identifiers)
138
177
 
139
178
 
140
- def is_bypass_approved() -> bool:
141
- """Check if user explicitly approved a refactor bypass via one-time token file.
179
+ def _replace_identifiers(code: str, all_identifiers: set[str]) -> str:
180
+ normalized_code = code
181
+ for each_identifier in all_identifiers:
182
+ normalized_code = normalized_code.replace(each_identifier, "ID")
183
+ return normalized_code
142
184
 
143
- The token file is deleted after a single use, so each refactor
144
- requires fresh explicit approval from the user.
145
- """
146
- if REFACTOR_BYPASS_TOKEN_PATH.exists():
147
- REFACTOR_BYPASS_TOKEN_PATH.unlink()
148
- return True
149
- return False
150
185
 
186
+ def _nonempty_stripped_lines(text: str) -> list[str]:
187
+ return [each_line.strip() for each_line in text.splitlines() if each_line.strip()]
151
188
 
152
- def main() -> None:
153
- try:
154
- input_data = json.load(sys.stdin)
155
- except json.JSONDecodeError:
156
- sys.exit(0)
157
189
 
158
- tool_name = input_data.get("tool_name", "")
159
- if tool_name != "Edit":
160
- sys.exit(0)
190
+ def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
191
+ """Return whether at least half of the edited lines are current additions."""
192
+ all_old_lines = _nonempty_stripped_lines(old_string)
193
+ all_added_lines = _get_added_line_occurrences(file_path)
194
+ if not all_old_lines or not all_added_lines:
195
+ return False
196
+ all_old_line_counts = Counter(all_old_lines)
197
+ all_added_line_counts = Counter(all_added_lines)
198
+ matched_occurrence_count = sum(
199
+ min(each_count, all_added_line_counts[each_line])
200
+ for each_line, each_count in all_old_line_counts.items()
201
+ )
202
+ edited_occurrence_count = sum(all_old_line_counts.values())
203
+ return matched_occurrence_count / edited_occurrence_count >= CHANGED_SURFACE_MATCH_RATIO
204
+
205
+
206
+ def _is_existing_edit_target(file_path: str) -> bool:
207
+ if not file_path or is_hook_infrastructure(file_path):
208
+ return False
209
+ return not is_new_file(file_path)
161
210
 
162
- if is_bypass_approved():
163
- sys.exit(0)
164
211
 
165
- tool_input = input_data.get("tool_input", {})
166
- file_path = tool_input.get("file_path", "")
167
- old_string = tool_input.get("old_string", "")
168
- new_string = tool_input.get("new_string", "")
212
+ def find_refactor_advisory_description(
213
+ file_path: str, old_string: str, new_string: str
214
+ ) -> str | None:
215
+ """Return an advisory description for a refactor outside changed lines."""
216
+ if not _is_existing_edit_target(file_path):
217
+ return None
218
+ refactor_description = is_refactor_edit(old_string, new_string)
219
+ if refactor_description is None:
220
+ return None
221
+ if is_edit_within_changed_surface(file_path, old_string):
222
+ return None
223
+ return refactor_description
169
224
 
170
- if not file_path or not old_string or not new_string:
171
- sys.exit(0)
172
225
 
173
- if is_hook_infrastructure(file_path):
174
- sys.exit(0)
226
+ def is_refactor_eligible(file_path: str, old_string: str, new_string: str) -> bool:
227
+ """Return whether an Edit is eligible for the refactor advisory."""
228
+ return find_refactor_advisory_description(file_path, old_string, new_string) is not None
175
229
 
176
- if is_new_file(file_path):
177
- sys.exit(0)
178
230
 
179
- refactor_description = is_refactor_edit(old_string, new_string)
180
- if not refactor_description:
181
- sys.exit(0)
231
+ def is_bypass_approved() -> bool:
232
+ """Consume the one-use bypass token when the user approved a refactor."""
233
+ if not REFACTOR_BYPASS_TOKEN_PATH.exists():
234
+ return False
235
+ try:
236
+ REFACTOR_BYPASS_TOKEN_PATH.unlink()
237
+ except OSError:
238
+ return False
239
+ return True
182
240
 
183
- diff_added_lines = get_git_diff_added_lines(file_path)
184
241
 
185
- old_lines_stripped = {line.strip() for line in old_string.split("\n") if line.strip()}
186
- old_lines_in_diff = old_lines_stripped & diff_added_lines
242
+ def build_refactor_advisory_context(refactor_description: str, file_path: str) -> str:
243
+ """Build guidance that names the Edit stage and changed-surface rule."""
244
+ return (
245
+ f"[HOOK ADVISORY] Refactor guard — {refactor_description} in {file_path}. "
246
+ "Edit-stage guidance: Only modify lines already changed in the current git diff. "
247
+ "Ask the user for explicit approval first. If the user approves, "
248
+ "create the bypass token then retry."
249
+ )
187
250
 
188
- if old_lines_in_diff and len(old_lines_in_diff) >= len(old_lines_stripped) * 0.5:
189
- sys.exit(0)
190
251
 
191
- result = {
252
+ def build_refactor_advisory_payload(refactor_description: str, file_path: str) -> dict[str, object]:
253
+ """Build the standalone allow payload used by the Edit advisory hook."""
254
+ advisory_context = build_refactor_advisory_context(refactor_description, file_path)
255
+ return {
256
+ "systemMessage": advisory_context,
192
257
  "hookSpecificOutput": {
193
258
  "hookEventName": "PreToolUse",
194
259
  "permissionDecision": "allow",
195
- "additionalContext": f"[HOOK ADVISORY] Refactor guard — {refactor_description} in {file_path}. Only modify lines already changed in the current git diff. Ask the user for explicit approval first. If the user approves, create the bypass token then retry.",
196
- }
260
+ "additionalContext": advisory_context,
261
+ },
197
262
  }
198
- print(json.dumps(result))
263
+
264
+
265
+ def _read_hook_input() -> dict[str, object] | None:
266
+ try:
267
+ parsed_input = json.load(sys.stdin)
268
+ except (json.JSONDecodeError, TypeError):
269
+ return None
270
+ if not isinstance(parsed_input, dict):
271
+ return None
272
+ return parsed_input
273
+
274
+
275
+ def _read_edit_fields(payload_by_key: dict[str, object]) -> tuple[str, str, str, str]:
276
+ raw_tool_name = payload_by_key.get("tool_name")
277
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
278
+ raw_tool_input = payload_by_key.get("tool_input")
279
+ if not isinstance(raw_tool_input, dict):
280
+ return tool_name, "", "", ""
281
+ file_path_field = raw_tool_input.get("file_path")
282
+ old_string_field = raw_tool_input.get("old_string")
283
+ new_string_field = raw_tool_input.get("new_string")
284
+ if not isinstance(file_path_field, str):
285
+ return tool_name, "", "", ""
286
+ if not isinstance(old_string_field, str):
287
+ return tool_name, "", "", ""
288
+ if not isinstance(new_string_field, str):
289
+ return tool_name, "", "", ""
290
+ return tool_name, file_path_field, old_string_field, new_string_field
291
+
292
+
293
+ def main() -> None:
294
+ """Emit an Edit-stage advisory for eligible out-of-surface refactors."""
295
+ payload_by_key = _read_hook_input()
296
+ if payload_by_key is None:
297
+ return
298
+ tool_name, file_path, old_string, new_string = _read_edit_fields(payload_by_key)
299
+ if tool_name != "Edit" or is_bypass_approved():
300
+ return
301
+ refactor_description = find_refactor_advisory_description(file_path, old_string, new_string)
302
+ if refactor_description is None:
303
+ return
304
+ advisory_payload = build_refactor_advisory_payload(refactor_description, file_path)
305
+ sys.stdout.write(json.dumps(advisory_payload))
199
306
  sys.stdout.flush()
200
- sys.exit(0)
201
307
 
202
308
 
203
309
  if __name__ == "__main__":
@@ -0,0 +1,46 @@
1
+ """Shared temporary-repository support for refactor guard tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections.abc import Generator
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+
12
+ @pytest.fixture
13
+ def git_repository(tmp_path: Path) -> Generator[Path]:
14
+ """Create a committed temporary repository for refactor guard tests."""
15
+ repository_path = tmp_path / "repository"
16
+ repository_path.mkdir()
17
+ subprocess.run(["git", "init", "-q"], cwd=repository_path, check=True)
18
+ yield repository_path
19
+
20
+
21
+ def commit_file(repository_path: Path, file_path: Path, file_content: str) -> None:
22
+ """Write and commit one file in a temporary repository."""
23
+ file_path.write_text(file_content, encoding="utf-8")
24
+ subprocess.run(["git", "add", str(file_path)], cwd=repository_path, check=True)
25
+ subprocess.run(
26
+ [
27
+ "git",
28
+ "-c",
29
+ "user.name=Refactor Guard Test",
30
+ "-c",
31
+ "user.email=refactor-guard@example.invalid",
32
+ "commit",
33
+ "-q",
34
+ "-m",
35
+ "baseline",
36
+ "--no-verify",
37
+ ],
38
+ cwd=repository_path,
39
+ check=True,
40
+ )
41
+
42
+
43
+ def stage_file(repository_path: Path, file_path: Path, file_content: str) -> None:
44
+ """Write and stage one file in a temporary repository."""
45
+ file_path.write_text(file_content, encoding="utf-8")
46
+ subprocess.run(["git", "add", str(file_path)], cwd=repository_path, check=True)
@@ -0,0 +1,171 @@
1
+ """Tests for direct and dispatched refactor guidance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ ADVISORY_DIRECTORY = Path(__file__).resolve().parent
14
+ HOOK_SCRIPT_PATH = ADVISORY_DIRECTORY / "refactor_guard.py"
15
+ DISPATCHER_SCRIPT_PATH = ADVISORY_DIRECTORY.parent / "blocking" / "pre_tool_use_dispatcher.py"
16
+ if str(ADVISORY_DIRECTORY) not in sys.path:
17
+ sys.path.insert(0, str(ADVISORY_DIRECTORY))
18
+
19
+ import refactor_guard # noqa: E402
20
+ from refactor_guard_test_support import commit_file, stage_file # noqa: E402
21
+
22
+
23
+ def _refactor_payload(file_path: Path) -> str:
24
+ return json.dumps(
25
+ {
26
+ "tool_name": "Edit",
27
+ "tool_input": {
28
+ "file_path": str(file_path),
29
+ "old_string": "def calculate_total(amount: int) -> int:\n return amount",
30
+ "new_string": "def compute_total(amount: int) -> int:\n return amount",
31
+ },
32
+ }
33
+ )
34
+
35
+
36
+ def _ordinary_edit_payload(file_path: Path) -> str:
37
+ return json.dumps(
38
+ {
39
+ "tool_name": "Edit",
40
+ "tool_input": {
41
+ "file_path": str(file_path),
42
+ "old_string": "return amount",
43
+ "new_string": "return amount + 1",
44
+ },
45
+ }
46
+ )
47
+
48
+
49
+ def _run_hook(
50
+ script_path: Path, repository_path: Path, payload_text: str
51
+ ) -> subprocess.CompletedProcess[str]:
52
+ environment_by_key = os.environ.copy()
53
+ environment_by_key["HOME"] = str(repository_path)
54
+ environment_by_key["USERPROFILE"] = str(repository_path)
55
+ return subprocess.run(
56
+ [sys.executable, str(script_path)],
57
+ cwd=repository_path,
58
+ input=payload_text,
59
+ capture_output=True,
60
+ text=True,
61
+ env=environment_by_key,
62
+ check=False,
63
+ )
64
+
65
+
66
+ def test_direct_hook_emits_edit_stage_guidance_for_eligible_refactor(
67
+ git_repository: Path,
68
+ ) -> None:
69
+ source_path = git_repository / "module.py"
70
+ commit_file(
71
+ git_repository,
72
+ source_path,
73
+ "def calculate_total(amount: int) -> int:\n return amount\n",
74
+ )
75
+ stage_file(
76
+ git_repository,
77
+ source_path,
78
+ "def calculate_total(amount: int) -> int:\n return amount + 1\n",
79
+ )
80
+
81
+ completed_hook = _run_hook(HOOK_SCRIPT_PATH, git_repository, _refactor_payload(source_path))
82
+ advisory_payload = json.loads(completed_hook.stdout)
83
+ hook_specific_output = advisory_payload["hookSpecificOutput"]
84
+
85
+ assert hook_specific_output["permissionDecision"] == "allow"
86
+ assert "Edit-stage" in advisory_payload["systemMessage"]
87
+ assert "current git diff" in hook_specific_output["additionalContext"]
88
+ assert str(source_path) in advisory_payload["systemMessage"]
89
+
90
+
91
+ def test_direct_hook_stays_silent_for_ordinary_edit(git_repository: Path) -> None:
92
+ source_path = git_repository / "module.py"
93
+ commit_file(
94
+ git_repository,
95
+ source_path,
96
+ "def calculate_total(amount: int) -> int:\n return amount\n",
97
+ )
98
+
99
+ completed_hook = _run_hook(
100
+ HOOK_SCRIPT_PATH,
101
+ git_repository,
102
+ _ordinary_edit_payload(source_path),
103
+ )
104
+
105
+ assert completed_hook.returncode == 0
106
+ assert completed_hook.stdout == ""
107
+
108
+
109
+ def test_direct_hook_stays_silent_for_write_payload(git_repository: Path) -> None:
110
+ source_path = git_repository / "module.py"
111
+ commit_file(
112
+ git_repository,
113
+ source_path,
114
+ "def calculate_total(amount: int) -> int:\n return amount\n",
115
+ )
116
+ write_payload = json.dumps(
117
+ {
118
+ "tool_name": "Write",
119
+ "tool_input": {"file_path": str(source_path), "content": "new content"},
120
+ }
121
+ )
122
+
123
+ completed_hook = _run_hook(HOOK_SCRIPT_PATH, git_repository, write_payload)
124
+
125
+ assert completed_hook.returncode == 0
126
+ assert completed_hook.stdout == ""
127
+
128
+
129
+ def test_dispatcher_preserves_edit_stage_guidance(git_repository: Path) -> None:
130
+ source_path = git_repository / "module.py"
131
+ matching_test_path = git_repository / "test_module.py"
132
+ commit_file(
133
+ git_repository,
134
+ source_path,
135
+ "def calculate_total(amount: int) -> int:\n return amount\n",
136
+ )
137
+ commit_file(
138
+ git_repository,
139
+ matching_test_path,
140
+ "def calculate_total(amount: int) -> int:\n return amount\n\n"
141
+ "def test_calculate_total() -> None:\n assert calculate_total(1) == 1\n",
142
+ )
143
+ stage_file(
144
+ git_repository,
145
+ source_path,
146
+ "def calculate_total(amount: int) -> int:\n return amount + 1\n",
147
+ )
148
+
149
+ completed_dispatch = _run_hook(
150
+ DISPATCHER_SCRIPT_PATH,
151
+ git_repository,
152
+ _refactor_payload(source_path),
153
+ )
154
+ dispatcher_payload = json.loads(completed_dispatch.stdout)
155
+ hook_specific_output = dispatcher_payload["hookSpecificOutput"]
156
+
157
+ assert hook_specific_output["permissionDecision"] == "allow", (
158
+ f"Dispatcher output: {completed_dispatch.stdout!r}; stderr: {completed_dispatch.stderr!r}"
159
+ )
160
+ assert "Edit-stage" in dispatcher_payload["systemMessage"]
161
+ assert "Refactor guard" in dispatcher_payload["systemMessage"]
162
+
163
+
164
+ def test_bypass_token_is_consumed_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
165
+ bypass_token_path = tmp_path / "refactor-bypass-token"
166
+ monkeypatch.setattr(refactor_guard, "REFACTOR_BYPASS_TOKEN_PATH", bypass_token_path)
167
+ bypass_token_path.write_text("approved", encoding="utf-8")
168
+
169
+ assert refactor_guard.is_bypass_approved()
170
+ assert not bypass_token_path.exists()
171
+ assert not refactor_guard.is_bypass_approved()