claude-dev-env 8.69.0 → 8.69.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.
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
- package/.agents/skills/second-account-workers/SKILL.md +21 -3
- package/docs/rule-guides/features-start-with-an-eval.md +40 -0
- package/hooks/blocking/followup_pr_dedupe.py +55 -5
- package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
- package/hooks/blocking/pull_request_proof.py +86 -20
- package/hooks/blocking/test_artifact_look_gate.py +1 -0
- package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
- package/hooks/blocking/test_pull_request_proof.py +124 -1
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/features/write-edit-blocking.md +3 -5
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
- package/hooks/test_transcript_skill_scan.py +75 -1
- package/hooks/transcript_skill_scan.py +87 -15
- package/package.json +1 -1
- package/rules/anti-corollary-tests.md +2 -2
- package/rules/archiving-agent-config.md +2 -2
- package/rules/asd-ste100-language.md +2 -2
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/cleanup-temp-files.md +3 -3
- package/rules/code-standards.md +1 -1
- package/rules/correction-lens.md +3 -3
- package/rules/destructive-commands.md +3 -3
- package/rules/doc-inventory-integrity.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/explore-thoroughly.md +1 -1
- package/rules/failure-blast-radius.md +1 -1
- package/rules/falsify-before-green.md +2 -2
- package/rules/features-start-with-an-eval.md +9 -0
- package/rules/filesystem-search.md +3 -3
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/long-horizon-autonomy.md +2 -2
- package/rules/memory-stores-durable-facts.md +2 -2
- package/rules/no-contrast-framing.md +1 -1
- package/rules/no-cross-skill-duplicate-helpers.md +1 -1
- package/rules/orphan-css-class.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +1 -1
- package/rules/prefer-existing-tools.md +2 -2
- package/rules/proof-before-pull-request.md +1 -1
- package/rules/request-scope.md +1 -1
- package/rules/research-mode.md +2 -2
- package/rules/retired-hook-prose.md +1 -1
- package/rules/session-title.md +1 -1
- package/rules/shell-invocation.md +2 -2
- package/rules/testing.md +1 -1
- package/rules/verify-before-asking.md +3 -3
- package/rules/verify-runtime-state.md +2 -2
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/account_broker.py +10 -3
- package/scripts/account_broker_support.py +7 -3
- package/scripts/claude_account_worker.py +24 -5
- package/scripts/claude_account_worker_process.py +5 -0
- package/scripts/claude_account_worker_report.py +16 -1
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_account_broker_support.py +24 -0
- package/scripts/test_claude_account_worker.py +47 -0
- package/scripts/test_claude_account_worker_process.py +20 -0
- package/scripts/test_claude_account_worker_report.py +15 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/hooks/advisory/conftest.py +0 -10
- package/hooks/advisory/refactor_guard.py +0 -385
- package/hooks/advisory/refactor_guard_test_support.py +0 -46
- package/hooks/advisory/test_refactor_guard.py +0 -148
- package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
- package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
- package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
- package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Warn about refactors that reach beyond the current Edit change surface."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
import re
|
|
9
|
-
import subprocess
|
|
10
|
-
import sys
|
|
11
|
-
from collections import Counter
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
_subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
|
|
15
|
-
if _subprocess_window_hooks_directory not in sys.path:
|
|
16
|
-
sys.path.append(_subprocess_window_hooks_directory)
|
|
17
|
-
|
|
18
|
-
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
19
|
-
|
|
20
|
-
try:
|
|
21
|
-
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
22
|
-
if hooks_root_directory not in sys.path:
|
|
23
|
-
sys.path.insert(0, hooks_root_directory)
|
|
24
|
-
|
|
25
|
-
from hooks_constants.multi_edit_reconstruction import edits_for_tool
|
|
26
|
-
from hooks_constants.refactor_guard_constants import (
|
|
27
|
-
ALL_PYTHON_KEYWORDS,
|
|
28
|
-
CHANGED_SURFACE_MATCH_RATIO,
|
|
29
|
-
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
30
|
-
GIT_EXECUTABLE_TOKEN,
|
|
31
|
-
MAXIMUM_REFACTOR_LINE_DELTA,
|
|
32
|
-
REFACTOR_LINE_DELTA_DIVISOR,
|
|
33
|
-
)
|
|
34
|
-
except ImportError as import_error:
|
|
35
|
-
raise ImportError(
|
|
36
|
-
"refactor_guard: cannot import its sibling modules; "
|
|
37
|
-
"ensure the hooks directory is importable."
|
|
38
|
-
) from import_error
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
REFACTOR_BYPASS_TOKEN_PATH = Path.home() / ".claude" / ".refactor-bypass-token"
|
|
42
|
-
identifier_join_separator = ", "
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _git_query_context(file_path: str) -> tuple[str, str]:
|
|
46
|
-
"""Keep lexical path ownership while Git resolves repository metadata."""
|
|
47
|
-
target_path = Path(file_path).absolute()
|
|
48
|
-
return str(target_path.parent), str(target_path)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _git_environment() -> dict[str, str]:
|
|
52
|
-
return {
|
|
53
|
-
each_environment_name: each_environment_value
|
|
54
|
-
for each_environment_name, each_environment_value in os.environ.items()
|
|
55
|
-
if not each_environment_name.startswith("GIT_")
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _read_added_lines_from_git(all_git_arguments: tuple[str, ...], file_path: str) -> list[str]:
|
|
60
|
-
"""Return added lines from one Git diff command."""
|
|
61
|
-
working_directory, absolute_file_path = _git_query_context(file_path)
|
|
62
|
-
try:
|
|
63
|
-
completed_process = subprocess.run(
|
|
64
|
-
[*all_git_arguments, "--", absolute_file_path],
|
|
65
|
-
check=False,
|
|
66
|
-
capture_output=True,
|
|
67
|
-
text=True,
|
|
68
|
-
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
69
|
-
cwd=working_directory,
|
|
70
|
-
env=_git_environment(),
|
|
71
|
-
creationflags=hidden_window_creation_flags(),
|
|
72
|
-
)
|
|
73
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
74
|
-
return []
|
|
75
|
-
if completed_process.returncode != 0:
|
|
76
|
-
return []
|
|
77
|
-
return [
|
|
78
|
-
each_line[1:].strip()
|
|
79
|
-
for each_line in completed_process.stdout.splitlines()
|
|
80
|
-
if each_line.startswith("+") and not each_line.startswith("+++")
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _get_added_line_occurrences(file_path: str) -> list[str]:
|
|
85
|
-
all_added_lines = _read_added_lines_from_git((GIT_EXECUTABLE_TOKEN, "diff"), file_path)
|
|
86
|
-
all_added_lines.extend(
|
|
87
|
-
_read_added_lines_from_git((GIT_EXECUTABLE_TOKEN, "diff", "--cached"), file_path)
|
|
88
|
-
)
|
|
89
|
-
return all_added_lines
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def get_git_diff_added_lines(file_path: str) -> set[str]:
|
|
93
|
-
"""Return staged and unstaged added lines for a tracked file."""
|
|
94
|
-
return set(_get_added_line_occurrences(file_path))
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def is_new_file(file_path: str) -> bool:
|
|
98
|
-
"""Return whether Git reports the file as untracked."""
|
|
99
|
-
working_directory, absolute_file_path = _git_query_context(file_path)
|
|
100
|
-
try:
|
|
101
|
-
completed_process = subprocess.run(
|
|
102
|
-
[
|
|
103
|
-
GIT_EXECUTABLE_TOKEN,
|
|
104
|
-
"ls-files",
|
|
105
|
-
"--others",
|
|
106
|
-
"--exclude-standard",
|
|
107
|
-
"--",
|
|
108
|
-
absolute_file_path,
|
|
109
|
-
],
|
|
110
|
-
check=False,
|
|
111
|
-
capture_output=True,
|
|
112
|
-
text=True,
|
|
113
|
-
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
114
|
-
cwd=working_directory,
|
|
115
|
-
env=_git_environment(),
|
|
116
|
-
creationflags=hidden_window_creation_flags(),
|
|
117
|
-
)
|
|
118
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
119
|
-
return False
|
|
120
|
-
return completed_process.returncode == 0 and bool(completed_process.stdout.strip())
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def is_hook_infrastructure(file_path: str) -> bool:
|
|
124
|
-
"""Return whether a path belongs to the installed Claude hook tree."""
|
|
125
|
-
normalized_path = file_path.lower().replace("\\", "/")
|
|
126
|
-
return "/.claude/" in normalized_path
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def extract_identifiers(code: str) -> set[str]:
|
|
130
|
-
"""Return meaningful Python identifiers from a code fragment."""
|
|
131
|
-
all_identifiers = set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]{2,})\b", code))
|
|
132
|
-
return all_identifiers - ALL_PYTHON_KEYWORDS
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def _have_similar_words(old_identifier: str, new_identifier: str) -> bool:
|
|
136
|
-
all_old_words = set(re.findall(r"[a-z]+|[A-Z][a-z]*", old_identifier))
|
|
137
|
-
all_new_words = set(re.findall(r"[a-z]+|[A-Z][a-z]*", new_identifier))
|
|
138
|
-
return (
|
|
139
|
-
bool(all_old_words and all_new_words)
|
|
140
|
-
and len(all_old_words & all_new_words) >= len(all_old_words) * CHANGED_SURFACE_MATCH_RATIO
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _describe_identifier_changes(
|
|
145
|
-
all_removed_identifiers: set[str], all_added_identifiers: set[str]
|
|
146
|
-
) -> str | None:
|
|
147
|
-
all_renamed_identifiers: list[str] = []
|
|
148
|
-
for each_old_identifier in sorted(all_removed_identifiers):
|
|
149
|
-
for each_new_identifier in sorted(all_added_identifiers):
|
|
150
|
-
is_same_spelling = each_old_identifier.lower().replace(
|
|
151
|
-
"_", ""
|
|
152
|
-
) == each_new_identifier.lower().replace("_", "")
|
|
153
|
-
if is_same_spelling or _have_similar_words(each_old_identifier, each_new_identifier):
|
|
154
|
-
all_renamed_identifiers.append(f"{each_old_identifier} -> {each_new_identifier}")
|
|
155
|
-
break
|
|
156
|
-
if all_renamed_identifiers:
|
|
157
|
-
return f"Renaming detected: {identifier_join_separator.join(all_renamed_identifiers[:3])}"
|
|
158
|
-
if len(all_removed_identifiers) > 1 and len(all_added_identifiers) > 1:
|
|
159
|
-
return (
|
|
160
|
-
"Multiple identifiers changed with same structure: "
|
|
161
|
-
f"removed {sorted(all_removed_identifiers)[:3]}, "
|
|
162
|
-
f"added {sorted(all_added_identifiers)[:3]}"
|
|
163
|
-
)
|
|
164
|
-
return None
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def is_refactor_edit(old_string: str, new_string: str) -> str | None:
|
|
168
|
-
"""Return a description when an edit preserves structure and changes names."""
|
|
169
|
-
all_old_lines = [
|
|
170
|
-
each_line.strip() for each_line in old_string.strip().splitlines() if each_line.strip()
|
|
171
|
-
]
|
|
172
|
-
all_new_lines = [
|
|
173
|
-
each_line.strip() for each_line in new_string.strip().splitlines() if each_line.strip()
|
|
174
|
-
]
|
|
175
|
-
if not all_old_lines or not all_new_lines:
|
|
176
|
-
return None
|
|
177
|
-
if abs(len(all_old_lines) - len(all_new_lines)) > max(
|
|
178
|
-
len(all_old_lines) // REFACTOR_LINE_DELTA_DIVISOR, MAXIMUM_REFACTOR_LINE_DELTA
|
|
179
|
-
):
|
|
180
|
-
return None
|
|
181
|
-
all_old_identifiers = extract_identifiers(old_string)
|
|
182
|
-
all_new_identifiers = extract_identifiers(new_string)
|
|
183
|
-
all_removed_identifiers = all_old_identifiers - all_new_identifiers
|
|
184
|
-
all_added_identifiers = all_new_identifiers - all_old_identifiers
|
|
185
|
-
if not all_removed_identifiers or not all_added_identifiers:
|
|
186
|
-
return None
|
|
187
|
-
all_identifiers = all_old_identifiers | all_new_identifiers
|
|
188
|
-
old_structure = re.sub(r"\s+", " ", _replace_identifiers(old_string, all_identifiers).strip())
|
|
189
|
-
new_structure = re.sub(r"\s+", " ", _replace_identifiers(new_string, all_identifiers).strip())
|
|
190
|
-
if old_structure != new_structure:
|
|
191
|
-
return None
|
|
192
|
-
return _describe_identifier_changes(all_removed_identifiers, all_added_identifiers)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _replace_identifiers(code: str, all_identifiers: set[str]) -> str:
|
|
196
|
-
normalized_code = code
|
|
197
|
-
for each_identifier in all_identifiers:
|
|
198
|
-
normalized_code = normalized_code.replace(each_identifier, "ID")
|
|
199
|
-
return normalized_code
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _nonempty_stripped_lines(text: str) -> list[str]:
|
|
203
|
-
return [each_line.strip() for each_line in text.splitlines() if each_line.strip()]
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _lines_within_changed_surface(old_string: str, all_added_lines: list[str]) -> bool:
|
|
207
|
-
"""Return whether at least half of the edited lines are current additions."""
|
|
208
|
-
all_old_lines = _nonempty_stripped_lines(old_string)
|
|
209
|
-
if not all_old_lines or not all_added_lines:
|
|
210
|
-
return False
|
|
211
|
-
all_old_line_counts = Counter(all_old_lines)
|
|
212
|
-
all_added_line_counts = Counter(all_added_lines)
|
|
213
|
-
matched_occurrence_count = sum(
|
|
214
|
-
min(each_count, all_added_line_counts[each_line])
|
|
215
|
-
for each_line, each_count in all_old_line_counts.items()
|
|
216
|
-
)
|
|
217
|
-
edited_occurrence_count = sum(all_old_line_counts.values())
|
|
218
|
-
return matched_occurrence_count / edited_occurrence_count >= CHANGED_SURFACE_MATCH_RATIO
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
|
|
222
|
-
"""Return whether at least half of the edited lines are current additions."""
|
|
223
|
-
all_added_lines = _get_added_line_occurrences(file_path)
|
|
224
|
-
return _lines_within_changed_surface(old_string, all_added_lines)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _is_existing_edit_target(file_path: str) -> bool:
|
|
228
|
-
if not file_path or is_hook_infrastructure(file_path):
|
|
229
|
-
return False
|
|
230
|
-
return not is_new_file(file_path)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def find_refactor_advisory_description(
|
|
234
|
-
file_path: str, old_string: str, new_string: str
|
|
235
|
-
) -> str | None:
|
|
236
|
-
"""Return an advisory description for a refactor outside changed lines."""
|
|
237
|
-
if not _is_existing_edit_target(file_path):
|
|
238
|
-
return None
|
|
239
|
-
refactor_description = is_refactor_edit(old_string, new_string)
|
|
240
|
-
if refactor_description is None:
|
|
241
|
-
return None
|
|
242
|
-
if is_edit_within_changed_surface(file_path, old_string):
|
|
243
|
-
return None
|
|
244
|
-
return refactor_description
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def is_refactor_eligible(file_path: str, old_string: str, new_string: str) -> bool:
|
|
248
|
-
"""Return whether an Edit is eligible for the refactor advisory."""
|
|
249
|
-
return find_refactor_advisory_description(file_path, old_string, new_string) is not None
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def is_bypass_approved() -> bool:
|
|
253
|
-
"""Consume the one-use bypass token when the user approved a refactor."""
|
|
254
|
-
if not REFACTOR_BYPASS_TOKEN_PATH.exists():
|
|
255
|
-
return False
|
|
256
|
-
try:
|
|
257
|
-
REFACTOR_BYPASS_TOKEN_PATH.unlink()
|
|
258
|
-
except OSError:
|
|
259
|
-
return False
|
|
260
|
-
return True
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def build_refactor_advisory_context(refactor_description: str, file_path: str) -> str:
|
|
264
|
-
"""Build guidance that names the Edit stage and changed-surface rule."""
|
|
265
|
-
return (
|
|
266
|
-
f"[HOOK ADVISORY] Refactor guard — {refactor_description} in {file_path}. "
|
|
267
|
-
"Edit-stage guidance: This edit went through, and it changes lines outside the "
|
|
268
|
-
"current git diff. Keep further edits to lines the task already changes, and ask "
|
|
269
|
-
"the user before refactoring code outside them."
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def build_refactor_advisory_payload(refactor_description: str, file_path: str) -> dict[str, object]:
|
|
274
|
-
"""Build the standalone allow payload used by the Edit advisory hook."""
|
|
275
|
-
advisory_context = build_refactor_advisory_context(refactor_description, file_path)
|
|
276
|
-
return {
|
|
277
|
-
"systemMessage": advisory_context,
|
|
278
|
-
"hookSpecificOutput": {
|
|
279
|
-
"hookEventName": "PreToolUse",
|
|
280
|
-
"permissionDecision": "allow",
|
|
281
|
-
"additionalContext": advisory_context,
|
|
282
|
-
},
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _read_hook_input() -> dict[str, object] | None:
|
|
287
|
-
try:
|
|
288
|
-
parsed_input = json.load(sys.stdin)
|
|
289
|
-
except (json.JSONDecodeError, TypeError):
|
|
290
|
-
return None
|
|
291
|
-
if not isinstance(parsed_input, dict):
|
|
292
|
-
return None
|
|
293
|
-
return parsed_input
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def _read_edit_fields(payload_by_key: dict[str, object]) -> tuple[str, str, str, str]:
|
|
297
|
-
raw_tool_name = payload_by_key.get("tool_name")
|
|
298
|
-
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
299
|
-
raw_tool_input = payload_by_key.get("tool_input")
|
|
300
|
-
if not isinstance(raw_tool_input, dict):
|
|
301
|
-
return tool_name, "", "", ""
|
|
302
|
-
file_path_field = raw_tool_input.get("file_path")
|
|
303
|
-
old_string_field = raw_tool_input.get("old_string")
|
|
304
|
-
new_string_field = raw_tool_input.get("new_string")
|
|
305
|
-
if not isinstance(file_path_field, str):
|
|
306
|
-
return tool_name, "", "", ""
|
|
307
|
-
if not isinstance(old_string_field, str):
|
|
308
|
-
return tool_name, "", "", ""
|
|
309
|
-
if not isinstance(new_string_field, str):
|
|
310
|
-
return tool_name, "", "", ""
|
|
311
|
-
return tool_name, file_path_field, old_string_field, new_string_field
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
def _refactor_advisory_description_within_surface(
|
|
315
|
-
all_added_lines: list[str],
|
|
316
|
-
old_string: str,
|
|
317
|
-
new_string: str,
|
|
318
|
-
) -> str | None:
|
|
319
|
-
"""Return an advisory description for one edit, given the file's added lines."""
|
|
320
|
-
refactor_description = is_refactor_edit(old_string, new_string)
|
|
321
|
-
if refactor_description is None:
|
|
322
|
-
return None
|
|
323
|
-
if _lines_within_changed_surface(old_string, all_added_lines):
|
|
324
|
-
return None
|
|
325
|
-
return refactor_description
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _multi_edit_refactor_advisory_description(
|
|
329
|
-
payload_by_key: dict[str, object],
|
|
330
|
-
) -> tuple[str, str] | None:
|
|
331
|
-
"""Return the (file_path, description) pair for the first eligible edit in a MultiEdit."""
|
|
332
|
-
raw_tool_input = payload_by_key.get("tool_input")
|
|
333
|
-
if not isinstance(raw_tool_input, dict):
|
|
334
|
-
return None
|
|
335
|
-
file_path_field = raw_tool_input.get("file_path")
|
|
336
|
-
file_path = file_path_field if isinstance(file_path_field, str) else ""
|
|
337
|
-
if not file_path:
|
|
338
|
-
return None
|
|
339
|
-
if not _is_existing_edit_target(file_path):
|
|
340
|
-
return None
|
|
341
|
-
all_added_lines = _get_added_line_occurrences(file_path)
|
|
342
|
-
for each_edit in edits_for_tool("MultiEdit", raw_tool_input):
|
|
343
|
-
if not isinstance(each_edit, dict):
|
|
344
|
-
continue
|
|
345
|
-
old_string_field = each_edit.get("old_string")
|
|
346
|
-
new_string_field = each_edit.get("new_string")
|
|
347
|
-
if not isinstance(old_string_field, str) or not isinstance(new_string_field, str):
|
|
348
|
-
continue
|
|
349
|
-
refactor_description = _refactor_advisory_description_within_surface(
|
|
350
|
-
all_added_lines, old_string_field, new_string_field
|
|
351
|
-
)
|
|
352
|
-
if refactor_description is not None:
|
|
353
|
-
return file_path, refactor_description
|
|
354
|
-
return None
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def _emit_advisory(file_path: str, refactor_description: str) -> None:
|
|
358
|
-
advisory_payload = build_refactor_advisory_payload(refactor_description, file_path)
|
|
359
|
-
sys.stdout.write(json.dumps(advisory_payload))
|
|
360
|
-
sys.stdout.flush()
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
def main() -> None:
|
|
364
|
-
"""Emit an Edit-stage advisory for eligible out-of-surface refactors."""
|
|
365
|
-
payload_by_key = _read_hook_input()
|
|
366
|
-
if payload_by_key is None:
|
|
367
|
-
return
|
|
368
|
-
tool_name, file_path, old_string, new_string = _read_edit_fields(payload_by_key)
|
|
369
|
-
if is_bypass_approved():
|
|
370
|
-
return
|
|
371
|
-
if tool_name == "MultiEdit":
|
|
372
|
-
multi_edit_result = _multi_edit_refactor_advisory_description(payload_by_key)
|
|
373
|
-
if multi_edit_result is not None:
|
|
374
|
-
_emit_advisory(*multi_edit_result)
|
|
375
|
-
return
|
|
376
|
-
if tool_name != "Edit":
|
|
377
|
-
return
|
|
378
|
-
refactor_description = find_refactor_advisory_description(file_path, old_string, new_string)
|
|
379
|
-
if refactor_description is None:
|
|
380
|
-
return
|
|
381
|
-
_emit_advisory(file_path, refactor_description)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if __name__ == "__main__":
|
|
385
|
-
main()
|
|
@@ -1,46 +0,0 @@
|
|
|
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)
|
|
@@ -1,148 +0,0 @@
|
|
|
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 subprocess
|
|
13
|
-
import sys
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
import pytest
|
|
17
|
-
|
|
18
|
-
try:
|
|
19
|
-
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
20
|
-
if str(_ADVISORY_DIRECTORY) not in sys.path:
|
|
21
|
-
sys.path.insert(0, str(_ADVISORY_DIRECTORY))
|
|
22
|
-
|
|
23
|
-
import refactor_guard
|
|
24
|
-
except ImportError as import_error:
|
|
25
|
-
raise ImportError(
|
|
26
|
-
"test_refactor_guard: cannot import its sibling modules; "
|
|
27
|
-
"ensure the advisory directory is importable."
|
|
28
|
-
) from import_error
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
WINDOWS_NO_WINDOW_FLAG = 0x08000000
|
|
32
|
-
EXPECTED_HIDDEN_WINDOW_FLAGS = WINDOWS_NO_WINDOW_FLAG if sys.platform == "win32" else 0
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _multi_edit_payload(file_path: str, all_edits: list[dict[str, str]]) -> dict[str, object]:
|
|
36
|
-
return {
|
|
37
|
-
"tool_name": "MultiEdit",
|
|
38
|
-
"tool_input": {"file_path": file_path, "edits": all_edits},
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_multi_edit_reports_the_first_eligible_rename(tmp_path: Path) -> None:
|
|
43
|
-
"""A rename in the second edit is reported, even though the first edit is ordinary."""
|
|
44
|
-
target_path = str(tmp_path / "module.py")
|
|
45
|
-
payload = _multi_edit_payload(
|
|
46
|
-
target_path,
|
|
47
|
-
[
|
|
48
|
-
{"old_string": "return amount", "new_string": "return amount + 1"},
|
|
49
|
-
{
|
|
50
|
-
"old_string": "def calculate_total(amount):\n return amount",
|
|
51
|
-
"new_string": "def compute_total(amount):\n return amount",
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
result = refactor_guard._multi_edit_refactor_advisory_description(payload)
|
|
57
|
-
|
|
58
|
-
assert result is not None
|
|
59
|
-
reported_path, description = result
|
|
60
|
-
assert reported_path == target_path
|
|
61
|
-
assert "calculate_total" in description
|
|
62
|
-
assert "compute_total" in description
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_multi_edit_scans_past_two_ordinary_edits_to_the_third(tmp_path: Path) -> None:
|
|
66
|
-
"""The loop over edit pairs reaches the third pair, not only the first two."""
|
|
67
|
-
target_path = str(tmp_path / "module.py")
|
|
68
|
-
payload = _multi_edit_payload(
|
|
69
|
-
target_path,
|
|
70
|
-
[
|
|
71
|
-
{"old_string": "return amount", "new_string": "return amount + 1"},
|
|
72
|
-
{"old_string": "return amount + 1", "new_string": "return amount + 2"},
|
|
73
|
-
{
|
|
74
|
-
"old_string": "def calculate_total(amount):\n return amount + 2",
|
|
75
|
-
"new_string": "def compute_total(amount):\n return amount + 2",
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
result = refactor_guard._multi_edit_refactor_advisory_description(payload)
|
|
81
|
-
|
|
82
|
-
assert result is not None
|
|
83
|
-
_reported_path, description = result
|
|
84
|
-
assert "compute_total" in description
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def test_multi_edit_returns_none_when_no_edit_qualifies(tmp_path: Path) -> None:
|
|
88
|
-
target_path = str(tmp_path / "module.py")
|
|
89
|
-
payload = _multi_edit_payload(
|
|
90
|
-
target_path,
|
|
91
|
-
[{"old_string": "return amount", "new_string": "return amount + 1"}],
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
assert refactor_guard._multi_edit_refactor_advisory_description(payload) is None
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _record_creation_flags(
|
|
98
|
-
monkeypatch: pytest.MonkeyPatch, standard_output: str
|
|
99
|
-
) -> dict[str, object]:
|
|
100
|
-
all_recorded_keyword_arguments: dict[str, object] = {}
|
|
101
|
-
|
|
102
|
-
def _record(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
103
|
-
del args
|
|
104
|
-
all_recorded_keyword_arguments.update(kwargs)
|
|
105
|
-
return subprocess.CompletedProcess([], 0, stdout=standard_output, stderr="")
|
|
106
|
-
|
|
107
|
-
monkeypatch.setattr(refactor_guard.subprocess, "run", _record)
|
|
108
|
-
return all_recorded_keyword_arguments
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def test_git_diff_should_start_without_a_console_window(
|
|
112
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
113
|
-
) -> None:
|
|
114
|
-
all_recorded_keyword_arguments = _record_creation_flags(monkeypatch, "+added line\n")
|
|
115
|
-
|
|
116
|
-
refactor_guard.get_git_diff_added_lines(str(tmp_path / "module.py"))
|
|
117
|
-
|
|
118
|
-
assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_untracked_probe_should_start_without_a_console_window(
|
|
122
|
-
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
123
|
-
) -> None:
|
|
124
|
-
all_recorded_keyword_arguments = _record_creation_flags(monkeypatch, "module.py\n")
|
|
125
|
-
|
|
126
|
-
assert refactor_guard.is_new_file(str(tmp_path / "module.py")) is True
|
|
127
|
-
assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def test_the_git_query_carries_the_advisory_s_own_executable_and_timeout(
|
|
131
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
132
|
-
) -> None:
|
|
133
|
-
"""refactor_guard_constants supplies both names the git call is built from."""
|
|
134
|
-
all_calls: list[tuple[list[str], object]] = []
|
|
135
|
-
|
|
136
|
-
def record_call(
|
|
137
|
-
command: list[str], **keyword_arguments: object
|
|
138
|
-
) -> subprocess.CompletedProcess[str]:
|
|
139
|
-
all_calls.append((command, keyword_arguments["timeout"]))
|
|
140
|
-
return subprocess.CompletedProcess(command, 1, "", "")
|
|
141
|
-
|
|
142
|
-
monkeypatch.setattr(refactor_guard.subprocess, "run", record_call)
|
|
143
|
-
|
|
144
|
-
refactor_guard.is_new_file(str(tmp_path / "module.py"))
|
|
145
|
-
|
|
146
|
-
command, timeout = all_calls[0]
|
|
147
|
-
assert command[:2] == ["git", "ls-files"]
|
|
148
|
-
assert timeout == refactor_guard.GIT_COMMAND_TIMEOUT_SECONDS
|