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.
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +109 -11
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/docs/CODE_RULES.md +2 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +95 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +3 -3
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- package/scripts/codex_compat_materializer.py +395 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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=
|
|
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
|
|
45
|
-
|
|
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
|
-
"""
|
|
83
|
+
"""Return whether Git reports the file as untracked."""
|
|
84
|
+
working_directory, absolute_file_path = _git_query_context(file_path)
|
|
50
85
|
try:
|
|
51
|
-
|
|
52
|
-
[
|
|
53
|
-
|
|
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=
|
|
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
|
-
"""
|
|
64
|
-
|
|
65
|
-
return "/.claude/" in
|
|
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
|
-
"""
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
141
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
file_path
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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":
|
|
196
|
-
}
|
|
260
|
+
"additionalContext": advisory_context,
|
|
261
|
+
},
|
|
197
262
|
}
|
|
198
|
-
|
|
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()
|