claude-dev-env 2.18.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- 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 +105 -75
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- 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/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- 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/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +6 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- 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 +9 -5
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/json_file_reader.py +22 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/pending_sidecars.py +33 -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/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- 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/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
|
@@ -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,120 @@
|
|
|
1
|
+
"""Behavior tests for migration_safety_advisor through its production entry paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
|
|
15
|
+
_ADVISOR_SCRIPT = _ADVISORY_DIRECTORY / "migration_safety_advisor.py"
|
|
16
|
+
_DISPATCHER_SCRIPT = _HOOKS_DIRECTORY / "blocking" / "pre_tool_use_dispatcher.py"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_hook(script_path: Path, payload: dict[str, object]) -> dict[str, object] | None:
|
|
20
|
+
completed_process = subprocess.run(
|
|
21
|
+
[sys.executable, str(script_path)],
|
|
22
|
+
check=False,
|
|
23
|
+
input=json.dumps(payload),
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
assert completed_process.returncode == 0
|
|
29
|
+
if not completed_process.stdout.strip():
|
|
30
|
+
return None
|
|
31
|
+
parsed_payload = json.loads(completed_process.stdout)
|
|
32
|
+
assert isinstance(parsed_payload, dict)
|
|
33
|
+
return parsed_payload
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_edit_payload(file_path: Path, new_string: str) -> dict[str, object]:
|
|
37
|
+
return {
|
|
38
|
+
"tool_name": "Edit",
|
|
39
|
+
"tool_input": {
|
|
40
|
+
"file_path": str(file_path),
|
|
41
|
+
"old_string": "operations = []",
|
|
42
|
+
"new_string": new_string,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dict[str, object]:
|
|
48
|
+
if tool_name == "Write":
|
|
49
|
+
tool_input: dict[str, object] = {
|
|
50
|
+
"file_path": str(file_path),
|
|
51
|
+
"content": content,
|
|
52
|
+
}
|
|
53
|
+
else:
|
|
54
|
+
tool_input = {
|
|
55
|
+
"file_path": str(file_path),
|
|
56
|
+
"edits": [{"old_string": "operations = []", "new_string": content}],
|
|
57
|
+
}
|
|
58
|
+
return {"tool_name": tool_name, "tool_input": tool_input}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _prepare_fresh_test_pair(migration_path: Path) -> None:
|
|
62
|
+
migration_path.parent.mkdir(parents=True)
|
|
63
|
+
migration_path.write_text("operations = []\n", encoding="utf-8")
|
|
64
|
+
test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
|
|
65
|
+
test_path.write_text("def test_migration_contract():\n assert True\n", encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _permission_fields(parsed_payload: dict[str, object]) -> dict[str, object]:
|
|
69
|
+
hook_specific = parsed_payload.get("hookSpecificOutput", {})
|
|
70
|
+
assert isinstance(hook_specific, dict)
|
|
71
|
+
return hook_specific
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _text_field(field_by_key: dict[str, object], field_name: str) -> str:
|
|
75
|
+
field_value = field_by_key.get(field_name)
|
|
76
|
+
assert isinstance(field_value, str)
|
|
77
|
+
return field_value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_standalone_advisor_warns_for_unsafe_migration_edit(tmp_path: Path) -> None:
|
|
81
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
82
|
+
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
83
|
+
|
|
84
|
+
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
85
|
+
assert advisory_payload is not None
|
|
86
|
+
permission_fields = _permission_fields(advisory_payload)
|
|
87
|
+
|
|
88
|
+
assert permission_fields["permissionDecision"] == "allow"
|
|
89
|
+
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
90
|
+
assert "MIGRATION SAFETY" in _text_field(advisory_payload, "systemMessage")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
|
|
94
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
95
|
+
_prepare_fresh_test_pair(migration_path)
|
|
96
|
+
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
97
|
+
|
|
98
|
+
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
99
|
+
assert dispatched_payload is not None
|
|
100
|
+
permission_fields = _permission_fields(dispatched_payload)
|
|
101
|
+
|
|
102
|
+
assert permission_fields["permissionDecision"] == "allow"
|
|
103
|
+
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
104
|
+
assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("tool_name", ("Write", "MultiEdit"))
|
|
108
|
+
def test_dispatcher_keeps_migration_advisor_edit_only(tmp_path: Path, tool_name: str) -> None:
|
|
109
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
110
|
+
_prepare_fresh_test_pair(migration_path)
|
|
111
|
+
payload = _build_control_payload(
|
|
112
|
+
tool_name,
|
|
113
|
+
migration_path,
|
|
114
|
+
'operations = [migrations.RemoveField("name")]',
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
118
|
+
serialized_payload = json.dumps(dispatched_payload or {})
|
|
119
|
+
|
|
120
|
+
assert "MIGRATION SAFETY" not in serialized_payload
|