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
|
@@ -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()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Tests for refactor candidate eligibility against a temporary Git repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(ADVISORY_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(ADVISORY_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
import refactor_guard # noqa: E402
|
|
15
|
+
from refactor_guard_test_support import commit_file, stage_file # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_refactor_candidate_is_eligible_when_old_lines_are_outside_changed_surface(
|
|
19
|
+
git_repository: Path,
|
|
20
|
+
) -> None:
|
|
21
|
+
source_path = git_repository / "module.py"
|
|
22
|
+
commit_file(
|
|
23
|
+
git_repository,
|
|
24
|
+
source_path,
|
|
25
|
+
"def calculate_total(amount: int) -> int:\n return amount\n",
|
|
26
|
+
)
|
|
27
|
+
stage_file(
|
|
28
|
+
git_repository,
|
|
29
|
+
source_path,
|
|
30
|
+
"def calculate_total(amount: int) -> int:\n return amount + 1\n",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
old_function = "def calculate_total(amount: int) -> int:\n return amount"
|
|
34
|
+
renamed_function = "def compute_total(amount: int) -> int:\n return amount"
|
|
35
|
+
|
|
36
|
+
assert refactor_guard.is_refactor_eligible(str(source_path), old_function, renamed_function)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_refactor_candidate_is_ineligible_when_old_lines_are_in_changed_surface(
|
|
40
|
+
git_repository: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
source_path = git_repository / "module.py"
|
|
43
|
+
commit_file(git_repository, source_path, "pass\n")
|
|
44
|
+
old_function = "def calculate_total(amount: int) -> int:\n return amount"
|
|
45
|
+
stage_file(git_repository, source_path, f"{old_function}\n")
|
|
46
|
+
renamed_function = "def compute_total(amount: int) -> int:\n return amount"
|
|
47
|
+
|
|
48
|
+
assert not refactor_guard.is_refactor_eligible(str(source_path), old_function, renamed_function)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_changed_surface_reads_staged_and_unstaged_lines(
|
|
52
|
+
git_repository: Path, monkeypatch: pytest.MonkeyPatch
|
|
53
|
+
) -> None:
|
|
54
|
+
source_path = git_repository / "module.py"
|
|
55
|
+
commit_file(git_repository, source_path, "baseline = 1\n")
|
|
56
|
+
|
|
57
|
+
stage_file(git_repository, source_path, "staged_line = 1\n")
|
|
58
|
+
source_path.write_text("unstaged_line = 1\n", encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
monkeypatch.setenv("GIT_DIR", str(git_repository / "missing-git-dir"))
|
|
61
|
+
monkeypatch.setenv("GIT_WORK_TREE", str(git_repository / "missing-work-tree"))
|
|
62
|
+
monkeypatch.setenv("GIT_INDEX_FILE", str(git_repository / "missing-index"))
|
|
63
|
+
monkeypatch.setenv("GIT_COMMON_DIR", str(git_repository / "missing-common-dir"))
|
|
64
|
+
monkeypatch.setenv("GIT_PREFIX", "adversarial-prefix")
|
|
65
|
+
scrubbed_environment = refactor_guard._git_environment()
|
|
66
|
+
assert not any(each_name.startswith("GIT_") for each_name in scrubbed_environment)
|
|
67
|
+
all_added_lines = refactor_guard.get_git_diff_added_lines(str(source_path))
|
|
68
|
+
|
|
69
|
+
assert all_added_lines == {"staged_line = 1", "unstaged_line = 1"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_duplicate_old_lines_require_duplicate_changed_occurrences(
|
|
73
|
+
git_repository: Path, monkeypatch: pytest.MonkeyPatch
|
|
74
|
+
) -> None:
|
|
75
|
+
source_path = git_repository / "module.py"
|
|
76
|
+
commit_file(git_repository, source_path, "pass\n")
|
|
77
|
+
old_function = "def calculate_total(amount: int) -> int:\n return amount\n return amount"
|
|
78
|
+
stage_file(
|
|
79
|
+
git_repository,
|
|
80
|
+
source_path,
|
|
81
|
+
"def calculate_total(amount: int) -> int:\n return amount\n",
|
|
82
|
+
)
|
|
83
|
+
renamed_function = old_function.replace("calculate_total", "compute_total")
|
|
84
|
+
monkeypatch.setattr(
|
|
85
|
+
refactor_guard,
|
|
86
|
+
"_get_added_line_occurrences",
|
|
87
|
+
lambda _file_path: ["def calculate_total(amount: int) -> int:", " return amount"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert refactor_guard.is_refactor_edit(old_function, renamed_function)
|
|
91
|
+
assert not refactor_guard.is_edit_within_changed_surface(str(source_path), old_function)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_ordinary_edit_is_not_a_refactor_candidate(git_repository: Path) -> None:
|
|
95
|
+
source_path = git_repository / "module.py"
|
|
96
|
+
commit_file(git_repository, source_path, "return_amount = 1\n")
|
|
97
|
+
|
|
98
|
+
assert not refactor_guard.is_refactor_eligible(
|
|
99
|
+
str(source_path), "return_amount = 1", "return_amount = 2"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_new_file_is_not_a_refactor_candidate(git_repository: Path) -> None:
|
|
104
|
+
source_path = git_repository / "new_module.py"
|
|
105
|
+
source_path.write_text(
|
|
106
|
+
"def calculate_total(amount: int) -> int:\n return amount\n",
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert refactor_guard.is_new_file(str(source_path))
|
|
111
|
+
assert not refactor_guard.is_refactor_eligible(
|
|
112
|
+
str(source_path),
|
|
113
|
+
"def calculate_total(amount: int) -> int:\n return amount",
|
|
114
|
+
"def compute_total(amount: int) -> int:\n return amount",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_hook_infrastructure_is_not_a_refactor_candidate(
|
|
119
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
120
|
+
) -> None:
|
|
121
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
122
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
123
|
+
hook_path = str(Path.home() / ".claude" / "settings.json")
|
|
124
|
+
|
|
125
|
+
assert refactor_guard.is_hook_infrastructure(hook_path)
|
|
126
|
+
assert not refactor_guard.is_refactor_eligible(
|
|
127
|
+
hook_path,
|
|
128
|
+
"def calculate_total(amount: int) -> int:\n return amount",
|
|
129
|
+
"def compute_total(amount: int) -> int:\n return amount",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_changed_surface_below_half_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
134
|
+
monkeypatch.setattr(
|
|
135
|
+
refactor_guard,
|
|
136
|
+
"_get_added_line_occurrences",
|
|
137
|
+
lambda _file_path: ["changed line"],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
assert not refactor_guard.is_edit_within_changed_surface(
|
|
141
|
+
"module.py", "changed line\noriginal line\noriginal line"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_changed_surface_at_half_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
146
|
+
monkeypatch.setattr(
|
|
147
|
+
refactor_guard,
|
|
148
|
+
"_get_added_line_occurrences",
|
|
149
|
+
lambda _file_path: ["changed one", "changed two"],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
assert refactor_guard.is_edit_within_changed_surface(
|
|
153
|
+
"module.py", "changed one\nchanged two\noriginal one\noriginal two"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_changed_surface_above_half_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
158
|
+
monkeypatch.setattr(
|
|
159
|
+
refactor_guard,
|
|
160
|
+
"_get_added_line_occurrences",
|
|
161
|
+
lambda _file_path: ["changed one", "changed two", "changed three"],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert refactor_guard.is_edit_within_changed_surface(
|
|
165
|
+
"module.py", "changed one\nchanged two\nchanged three\noriginal line"
|
|
166
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Atomic text replacement for hook-state files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from hooks_constants.atomic_file_writer_constants import TEXT_WRITE_MODE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _remove_orphaned_temporary_files(
|
|
11
|
+
parent_directory: Path, temporary_prefix: str, temporary_suffix: str
|
|
12
|
+
) -> None:
|
|
13
|
+
for each_temporary_path in parent_directory.glob(f"{temporary_prefix}*{temporary_suffix}"):
|
|
14
|
+
try:
|
|
15
|
+
each_temporary_path.unlink(missing_ok=True)
|
|
16
|
+
except OSError:
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_temporary_text(
|
|
21
|
+
parent_directory: Path,
|
|
22
|
+
serialized_text: str,
|
|
23
|
+
encoding: str,
|
|
24
|
+
temporary_prefix: str,
|
|
25
|
+
temporary_suffix: str,
|
|
26
|
+
) -> Path:
|
|
27
|
+
temporary_path: Path | None = None
|
|
28
|
+
try:
|
|
29
|
+
with tempfile.NamedTemporaryFile(
|
|
30
|
+
mode=TEXT_WRITE_MODE,
|
|
31
|
+
encoding=encoding,
|
|
32
|
+
dir=parent_directory,
|
|
33
|
+
prefix=temporary_prefix,
|
|
34
|
+
suffix=temporary_suffix,
|
|
35
|
+
delete=False,
|
|
36
|
+
) as writable_handle:
|
|
37
|
+
temporary_path = Path(writable_handle.name)
|
|
38
|
+
writable_handle.write(serialized_text)
|
|
39
|
+
return temporary_path
|
|
40
|
+
except (OSError, UnicodeError):
|
|
41
|
+
if temporary_path is not None:
|
|
42
|
+
temporary_path.unlink(missing_ok=True)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_text_atomically(
|
|
47
|
+
target_path: Path,
|
|
48
|
+
serialized_text: str,
|
|
49
|
+
encoding: str,
|
|
50
|
+
temporary_prefix: str,
|
|
51
|
+
temporary_suffix: str,
|
|
52
|
+
should_reap_orphans: bool,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Replace one text file through a unique sibling temporary file.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
target_path: The destination file path.
|
|
58
|
+
serialized_text: The complete replacement text.
|
|
59
|
+
encoding: The text encoding.
|
|
60
|
+
temporary_prefix: Prefix for sibling temporary files.
|
|
61
|
+
temporary_suffix: Suffix for sibling temporary files.
|
|
62
|
+
should_reap_orphans: Whether to remove same-pattern temporary files first.
|
|
63
|
+
"""
|
|
64
|
+
parent_directory = target_path.parent
|
|
65
|
+
parent_directory.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
if should_reap_orphans:
|
|
67
|
+
_remove_orphaned_temporary_files(parent_directory, temporary_prefix, temporary_suffix)
|
|
68
|
+
temporary_path = _write_temporary_text(
|
|
69
|
+
parent_directory,
|
|
70
|
+
serialized_text,
|
|
71
|
+
encoding,
|
|
72
|
+
temporary_prefix,
|
|
73
|
+
temporary_suffix,
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
os.replace(temporary_path, target_path)
|
|
77
|
+
finally:
|
|
78
|
+
try:
|
|
79
|
+
temporary_path.unlink(missing_ok=True)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
package/hooks/blocking/AGENTS.md
CHANGED
|
@@ -95,7 +95,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
95
95
|
| `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
|
|
96
96
|
| `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
|
|
97
97
|
| `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file with its native Windows app |
|
|
98
|
-
| `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
|
|
98
|
+
| `sensitive_file_protector.py` | PreToolUse (Write/Edit/MultiEdit) | Writes to sensitive credential or config files |
|
|
99
99
|
| `session_edit_stage_gate.py` | PreToolUse (Bash) | A `git commit` that would drop files edited this session because they are tracked but left unstaged |
|
|
100
100
|
| `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
|
|
101
101
|
| `shell_substitution_blocker.py` | PreToolUse (Bash) | A command carrying `$(...)`, a live backtick, or `<(...)`/`>(...)` process substitution, which the allowlist matcher cannot descend into |
|
|
@@ -114,6 +114,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
114
114
|
| File | Role |
|
|
115
115
|
|---|---|
|
|
116
116
|
| `_gh_body_arg_utils.py` | Parsing helpers for `gh_body_arg_blocker.py` |
|
|
117
|
+
| `test_hook_subprocess_support.py` | Shared subprocess runner for blocking-hook behavior tests |
|
|
117
118
|
|
|
118
119
|
## Conventions
|
|
119
120
|
|
|
@@ -28,46 +28,73 @@ PROTECTED_BRANCHES = ("main", "master")
|
|
|
28
28
|
PROTECTED_REMOTE_PATTERNS: list[str] = []
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
the git commit, and for git's own -C flag.
|
|
36
|
-
|
|
37
|
-
Returns None if the commit runs in the hook's CWD.
|
|
38
|
-
"""
|
|
39
|
-
git_c_match = re.search(
|
|
40
|
-
r"git\s+-C\s+[\"']?([^\"';&|]+?)[\"']?\s+commit",
|
|
31
|
+
def _match_git_c_commit(bash_command: str) -> re.Match[str] | None:
|
|
32
|
+
"""Return the match for a Git commit with an explicit directory."""
|
|
33
|
+
return re.search(
|
|
34
|
+
r"(?:^|(?<=[;&|]))\s*git\s+-C\s+[\"']?([^\"';&|]+?)[\"']?\s+commit(?:\s|$)",
|
|
41
35
|
bash_command,
|
|
36
|
+
flags=re.IGNORECASE,
|
|
42
37
|
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_git_commit_directory(bash_command: str) -> tuple[bool, str | None]:
|
|
41
|
+
"""Return the Git commit match state and selected working directory."""
|
|
42
|
+
git_c_match = _match_git_c_commit(bash_command)
|
|
43
43
|
if git_c_match:
|
|
44
|
-
return git_c_match.group(1).strip()
|
|
44
|
+
return True, git_c_match.group(1).strip()
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
git_commit_match = re.search(
|
|
47
|
+
r"(?:^|(?<=[;&|]))\s*git\s+commit(?:\s|$)",
|
|
48
|
+
bash_command,
|
|
49
|
+
flags=re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
if git_commit_match is None:
|
|
52
|
+
return False, None
|
|
49
53
|
|
|
50
|
-
prefix = bash_command[:
|
|
54
|
+
prefix = bash_command[:git_commit_match.start()]
|
|
51
55
|
|
|
52
56
|
cd_matches = re.findall(
|
|
53
57
|
r"(?:cd|pushd)\s+[\"']?([^\"';&|]+?)[\"']?\s*[;&|]",
|
|
54
58
|
prefix,
|
|
59
|
+
flags=re.IGNORECASE,
|
|
55
60
|
)
|
|
56
61
|
if cd_matches:
|
|
57
|
-
return cd_matches[-1].strip()
|
|
62
|
+
return True, cd_matches[-1].strip()
|
|
63
|
+
|
|
64
|
+
return True, None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_git_working_directory(bash_command: str) -> str | None:
|
|
68
|
+
"""Return the working directory selected by a Git commit command."""
|
|
69
|
+
_, working_directory = parse_git_commit_directory(bash_command)
|
|
70
|
+
return working_directory
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
|
|
73
|
+
def is_commit_command(bash_command: str) -> bool:
|
|
74
|
+
"""Return the Git commit match state for the shell command."""
|
|
75
|
+
is_commit, _ = parse_git_commit_directory(bash_command)
|
|
76
|
+
return is_commit
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_git_c_commit_command(bash_command: str) -> bool:
|
|
80
|
+
"""Return whether the command names a Git commit target with ``-C``."""
|
|
81
|
+
return _match_git_c_commit(bash_command) is not None
|
|
60
82
|
|
|
61
83
|
|
|
62
|
-
def resolve_directory(
|
|
84
|
+
def resolve_directory(
|
|
85
|
+
directory: str | None,
|
|
86
|
+
from_directory: str | None = None,
|
|
87
|
+
) -> str | None:
|
|
63
88
|
"""Resolve a directory path, expanding ~ and validating existence."""
|
|
64
|
-
if directory is None
|
|
89
|
+
selected_directory = directory if directory is not None else from_directory
|
|
90
|
+
if selected_directory is None:
|
|
65
91
|
return None
|
|
66
92
|
|
|
67
|
-
expanded = os.path.expanduser(
|
|
93
|
+
expanded = os.path.expanduser(selected_directory)
|
|
68
94
|
|
|
69
95
|
if not os.path.isabs(expanded):
|
|
70
|
-
|
|
96
|
+
base_directory = from_directory or os.getcwd()
|
|
97
|
+
expanded = os.path.abspath(os.path.join(base_directory, expanded))
|
|
71
98
|
|
|
72
99
|
if os.path.isdir(expanded):
|
|
73
100
|
return expanded
|
|
@@ -112,22 +139,25 @@ def is_protected_repo(working_dir: str | None = None) -> bool:
|
|
|
112
139
|
return False
|
|
113
140
|
|
|
114
141
|
|
|
115
|
-
def is_commit_command(bash_command: str) -> bool:
|
|
116
|
-
return "git commit" in bash_command.lower().strip()
|
|
117
|
-
|
|
118
|
-
|
|
119
142
|
def is_main_commit_confirmed(bash_command: str) -> bool:
|
|
120
143
|
"""Return True if the command includes the explicit confirmation sentinel."""
|
|
121
144
|
return "--allow-main-commit" in bash_command
|
|
122
145
|
|
|
123
146
|
|
|
124
|
-
def
|
|
147
|
+
def parse_hook_context_from_stdin() -> tuple[str, str | None]:
|
|
125
148
|
try:
|
|
126
149
|
hook_event = json.load(sys.stdin)
|
|
127
150
|
except json.JSONDecodeError:
|
|
128
|
-
return ""
|
|
151
|
+
return "", None
|
|
152
|
+
|
|
153
|
+
bash_command = hook_event.get("tool_input", {}).get("command", "")
|
|
154
|
+
return bash_command, hook_event.get("cwd")
|
|
155
|
+
|
|
129
156
|
|
|
130
|
-
|
|
157
|
+
def parse_bash_command_from_stdin() -> str:
|
|
158
|
+
"""Return the Bash command from the hook payload on standard input."""
|
|
159
|
+
bash_command, _ = parse_hook_context_from_stdin()
|
|
160
|
+
return bash_command
|
|
131
161
|
|
|
132
162
|
|
|
133
163
|
DRAFT_PR_INSTRUCTION = (
|
|
@@ -156,18 +186,21 @@ def build_denial_response(branch_name: str, repo_dir: str | None) -> dict:
|
|
|
156
186
|
|
|
157
187
|
|
|
158
188
|
def main() -> None:
|
|
159
|
-
bash_command =
|
|
189
|
+
bash_command, event_cwd = parse_hook_context_from_stdin()
|
|
190
|
+
has_commit_command, target_dir_raw = parse_git_commit_directory(bash_command)
|
|
160
191
|
|
|
161
|
-
if not
|
|
192
|
+
if not has_commit_command:
|
|
162
193
|
sys.exit(0)
|
|
163
194
|
|
|
164
195
|
if is_main_commit_confirmed(bash_command):
|
|
165
196
|
sys.exit(0)
|
|
166
197
|
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
if event_cwd is None and is_git_c_commit_command(bash_command):
|
|
199
|
+
sys.exit(0)
|
|
200
|
+
|
|
201
|
+
target_dir = resolve_directory(target_dir_raw, from_directory=event_cwd)
|
|
169
202
|
|
|
170
|
-
if target_dir_raw and not target_dir:
|
|
203
|
+
if (target_dir_raw or event_cwd) and not target_dir:
|
|
171
204
|
sys.exit(0)
|
|
172
205
|
|
|
173
206
|
current_branch = get_branch_at_directory(working_dir=target_dir)
|