claude-dev-env 2.30.0 → 2.31.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/agents/pr-description-writer.md +1 -1
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +59 -36
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +62 -8
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pr-description-writer
|
|
3
|
-
description: "
|
|
3
|
+
description: "Writes factual pull request titles, descriptions, and comments in plain illustrative language from the current diff. The `pr_description_writer_gate` PreToolUse hook requires a spawn of this agent before `gh pr create`."
|
|
4
4
|
tools: Read,Grep,Glob,Bash
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -25,7 +25,7 @@ Triggers: `/e-simplify` on the current diff (or a PR/branch/path passed as an ar
|
|
|
25
25
|
|
|
26
26
|
## The process
|
|
27
27
|
|
|
28
|
-
`/simplify → 4 cleanup agents in parallel → apply the fixes`
|
|
28
|
+
`/simplify → up to 4 cleanup agents in parallel → apply the fixes`
|
|
29
29
|
|
|
30
30
|
You are improving the quality of the changed code, not hunting for bugs. Review
|
|
31
31
|
it for reuse, simplification, efficiency, and altitude issues, then fix what you
|
|
@@ -40,13 +40,25 @@ include the working-tree changes in scope — the review often runs before the
|
|
|
40
40
|
commit. If a PR number, branch name, or file path was passed as an argument,
|
|
41
41
|
review that target instead. Treat this diff as the review scope.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**Diff-size cap.** Run `git diff --shortstat` on the same range Phase 0 used
|
|
44
|
+
to get the diff, and add its insertions and deletions for the changed-line
|
|
45
|
+
count. Default ceiling: 400 changed lines for one run — a tunable default the
|
|
46
|
+
user may adjust. Above the ceiling, narrow the target first — a subdirectory,
|
|
47
|
+
a package, or one file path — and review that narrowed target. Do not launch
|
|
48
|
+
Phase 1 over a diff above the ceiling.
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
### Phase 1 — Review (up to 4 cleanup agents in parallel)
|
|
51
|
+
|
|
52
|
+
**Lens cap.** Four lenses is the ceiling, not the floor. Default split — also
|
|
53
|
+
tunable — by the `git diff --shortstat` changed-line count from Phase 0:
|
|
54
|
+
under 100 changed lines, run 2 lenses (reuse and simplification); at 100
|
|
55
|
+
changed lines or above, and under the diff-size cap, run all 4 lenses.
|
|
56
|
+
|
|
57
|
+
Launch the review agents for the lenses in scope for this run — 2 or 4 by the
|
|
58
|
+
cap above — via the Agent tool, all in a single message so they run
|
|
59
|
+
concurrently. Pass each agent the diff and one of the angles below. Each
|
|
60
|
+
returns its findings with `file`, `line`, a one-line `summary`, and the
|
|
61
|
+
concrete cost (what is duplicated, wasted, or harder to maintain).
|
|
50
62
|
|
|
51
63
|
#### Reuse
|
|
52
64
|
|
|
@@ -79,7 +91,7 @@ special cases.
|
|
|
79
91
|
|
|
80
92
|
### Phase 2 — Apply the fixes
|
|
81
93
|
|
|
82
|
-
Wait for
|
|
94
|
+
Wait for every launched agent to complete, dedup findings that point at the same
|
|
83
95
|
line or mechanism, and fix each remaining one directly. Skip any finding whose
|
|
84
96
|
fix would change intended behavior, require changes well outside the reviewed
|
|
85
97
|
diff, or that you judge to be a false positive — note the skip rather than
|
package/AGENTS.md
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
+
Use positive, present-focused prose.
|
|
1
2
|
|
|
3
|
+
State what should be done, what something does, what was done, or what needs to be done.
|
|
4
|
+
|
|
5
|
+
Include only information directly relevant to the immediate task or remaining work.
|
|
6
|
+
|
|
7
|
+
Use direct outcome-focused wording. Omit filler, failed attempts, alternatives considered, and process narration.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"""Behavioral tests for the enforcer_loading parts module."""
|
|
2
2
|
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
from code_rules_gate_parts import enforcer_loading
|
|
6
8
|
|
|
9
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[2]
|
|
10
|
+
|
|
7
11
|
|
|
8
12
|
def test_resolve_claude_dev_env_root_finds_enforcer_bearing_root() -> None:
|
|
9
13
|
resolved_root = enforcer_loading.resolve_claude_dev_env_root(Path(__file__))
|
|
@@ -15,3 +19,36 @@ def test_load_validate_content_for_full_gate_returns_callable_passing_a_clean_mo
|
|
|
15
19
|
clean_module = '"""Clean module."""\n\n\ndef ping() -> str:\n return "pong"\n'
|
|
16
20
|
issues = validate_content(clean_module, "sample.py", "")
|
|
17
21
|
assert issues == []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_load_validate_content_for_full_gate_survives_a_cold_sys_modules_cache() -> None:
|
|
25
|
+
"""This test guards sys.modules caching, not the import path itself.
|
|
26
|
+
|
|
27
|
+
A module the enforcer imports directly (not through the hooks_constants
|
|
28
|
+
package this loader already resets) can stay cached in sys.modules from
|
|
29
|
+
an earlier test in the same pytest session, so a broken import path can
|
|
30
|
+
still read as working once some earlier test already imported the
|
|
31
|
+
module successfully. A fresh interpreter carries no such cache.
|
|
32
|
+
|
|
33
|
+
This does not prove the import path resolves in every layout the gate
|
|
34
|
+
is loaded from -- a projected layout missing a dependency file is a
|
|
35
|
+
separate failure this test cannot see, because this process still runs
|
|
36
|
+
against the real repository tree where every dependency already exists
|
|
37
|
+
on disk. That coverage lives in test_bugteam_code_rules_gate.py, whose
|
|
38
|
+
fixture copies a deliberately narrower file set.
|
|
39
|
+
"""
|
|
40
|
+
probe_script = (
|
|
41
|
+
"from code_rules_gate_parts.enforcer_loading import "
|
|
42
|
+
"load_validate_content_for_full_gate\n"
|
|
43
|
+
"load_validate_content_for_full_gate()\n"
|
|
44
|
+
"print('ENFORCER_LOAD_OK')\n"
|
|
45
|
+
)
|
|
46
|
+
completed_process = subprocess.run(
|
|
47
|
+
[sys.executable, "-c", probe_script],
|
|
48
|
+
cwd=str(_SCRIPTS_DIRECTORY),
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
check=False,
|
|
52
|
+
)
|
|
53
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
54
|
+
assert "ENFORCER_LOAD_OK" in completed_process.stdout
|
|
@@ -62,7 +62,7 @@ const RETIRED_HOOK_RELATIVE_SEGMENTS = ['blocking', 'retired_gate.py'];
|
|
|
62
62
|
const USER_HOOK_COMMAND = 'python3 my_own_gate.py --user-authored';
|
|
63
63
|
const RETIRED_HOOK_EVENT_TYPE = 'PreToolUse';
|
|
64
64
|
const DROPPED_HOOK_EVENT_TYPE = 'PreCompact';
|
|
65
|
-
const
|
|
65
|
+
const DISPATCHER_HOOK_COMMAND_SEGMENT = 'pre_tool_use_dispatcher.py';
|
|
66
66
|
const UNMANAGED_SIBLING_DIRECTORY = 'my-notes';
|
|
67
67
|
const NESTED_SKILL_DIRECTORY = 'foo';
|
|
68
68
|
const NESTED_SKILL_FILE_SEGMENTS = [NESTED_SKILL_DIRECTORY, 'scripts', 'a.py'];
|
|
@@ -909,7 +909,9 @@ function seedRetiredManagedHook(sandbox) {
|
|
|
909
909
|
const lookalikeHookCommand = `python3 "${retiredHookPath.replace(/\\/g, '/')}.bak"`;
|
|
910
910
|
const settings = readSettings(sandbox.claudeDirectory);
|
|
911
911
|
const liveGroup = settings.hooks[RETIRED_HOOK_EVENT_TYPE]
|
|
912
|
-
.find(group => group.
|
|
912
|
+
.find(group => group.hooks.some(
|
|
913
|
+
hook => hook.command.includes(DISPATCHER_HOOK_COMMAND_SEGMENT),
|
|
914
|
+
));
|
|
913
915
|
liveGroup.hooks.push({ type: 'command', command: retiredHookCommand });
|
|
914
916
|
liveGroup.hooks.push({ type: 'command', command: USER_HOOK_COMMAND });
|
|
915
917
|
liveGroup.hooks.push({ type: 'command', command: lookalikeHookCommand });
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -119,3 +119,14 @@ If you already have the data, don't fetch it again.
|
|
|
119
119
|
3. **Changed-line scope filters only the report.** `defer_scope_to_caller` and the changed-line set decide which found violations block. Scope filters findings after every check in the roster already ran; it adds or removes no check.
|
|
120
120
|
|
|
121
121
|
`hooks/hooks_constants/validation_phase_constants.py` is the single source for all three axes: the phase names (`EDIT_LANE_PHASE`, `FULL_GATE_PHASE`), the full-gate-only roster (`ALL_FULL_GATE_ONLY_CHECK_NAMES`), and the hook-infrastructure edit-lane roster (`ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES`).
|
|
122
|
+
|
|
123
|
+
## 11.6 LANE ASSIGNMENT IS BY SCOPE
|
|
124
|
+
|
|
125
|
+
Scope assigns the lane. A check that reads a file other than the target runs on the full gate. Every other check runs on both lanes.
|
|
126
|
+
|
|
127
|
+
Hook-infrastructure targets run three checks in the edit lane — `check_same_file_inline_duplicate_body`, `check_zero_payload_function_alias`, `check_unanchored_command_dispatch` — and the whole roster on the full gate. `ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES` holds that set.
|
|
128
|
+
|
|
129
|
+
Two surfaces report on the roster, and each reports a specific thing:
|
|
130
|
+
|
|
131
|
+
- `hooks/validators/hook_timing_harness.py` builds a `Write` payload against a target that already holds content. `_contents_for_validation` returns `None` for that payload, so the harness times interpreter start and hook dispatch. Time an `Edit` payload against a real file to measure the checks.
|
|
132
|
+
- `~/.claude/logs/hook-blocks.log` records the denials raised by fixtures in `test_code_rules_enforcer_*.py` and by the timing harness's default target.
|
|
@@ -4,24 +4,48 @@
|
|
|
4
4
|
import json
|
|
5
5
|
import re
|
|
6
6
|
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
11
|
+
if _hooks_dir not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_dir)
|
|
13
|
+
|
|
14
|
+
from hooks_constants.migration_safety_advisor_constants import (
|
|
15
|
+
MULTI_EDIT_TOOL_NAME,
|
|
16
|
+
)
|
|
17
|
+
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
18
|
+
except ImportError as import_error:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"migration_safety_advisor: cannot import its sibling modules; "
|
|
21
|
+
"ensure the hooks directory is importable."
|
|
22
|
+
) from import_error
|
|
7
23
|
|
|
8
24
|
MIGRATION_PATH_PATTERN = re.compile(r"[/\\]migrations[/\\]\d{4}_\w+\.py$")
|
|
9
25
|
UNSAFE_OPERATIONS = ["RemoveField", "RenameField", "DeleteModel", "RenameModel"]
|
|
10
26
|
|
|
11
27
|
|
|
28
|
+
def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
|
|
29
|
+
"""Return the text a Write, Edit, or MultiEdit payload introduces."""
|
|
30
|
+
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
31
|
+
return joined_new_strings(all_tool_input)
|
|
32
|
+
return all_tool_input.get("content", "") or all_tool_input.get("new_string", "")
|
|
33
|
+
|
|
34
|
+
|
|
12
35
|
def main() -> None:
|
|
13
36
|
try:
|
|
14
37
|
hook_input = json.load(sys.stdin)
|
|
15
38
|
except json.JSONDecodeError:
|
|
16
39
|
sys.exit(0)
|
|
17
40
|
|
|
41
|
+
tool_name = hook_input.get("tool_name", "")
|
|
18
42
|
tool_input = hook_input.get("tool_input", {})
|
|
19
43
|
file_path = tool_input.get("file_path", "")
|
|
20
44
|
|
|
21
45
|
if not MIGRATION_PATH_PATTERN.search(file_path):
|
|
22
46
|
sys.exit(0)
|
|
23
47
|
|
|
24
|
-
content =
|
|
48
|
+
content = _resolve_content(tool_name, tool_input)
|
|
25
49
|
found_unsafe = [op for op in UNSAFE_OPERATIONS if op in content]
|
|
26
50
|
|
|
27
51
|
if found_unsafe:
|
|
@@ -11,18 +11,26 @@ import sys
|
|
|
11
11
|
from collections import Counter
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
sys.path
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
from hooks_constants.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
14
|
+
try:
|
|
15
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
16
|
+
if hooks_root_directory not in sys.path:
|
|
17
|
+
sys.path.insert(0, hooks_root_directory)
|
|
18
|
+
|
|
19
|
+
from hooks_constants.multi_edit_reconstruction import edits_for_tool
|
|
20
|
+
from hooks_constants.precommit_code_rules_gate_constants import GIT_COMMAND_TIMEOUT_SECONDS
|
|
21
|
+
from hooks_constants.refactor_guard_constants import (
|
|
22
|
+
ALL_PYTHON_KEYWORDS,
|
|
23
|
+
CHANGED_SURFACE_MATCH_RATIO,
|
|
24
|
+
MAXIMUM_REFACTOR_LINE_DELTA,
|
|
25
|
+
REFACTOR_LINE_DELTA_DIVISOR,
|
|
26
|
+
)
|
|
27
|
+
from hooks_constants.session_edit_stage_gate_constants import GIT_EXECUTABLE_TOKEN
|
|
28
|
+
except ImportError as import_error:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"refactor_guard: cannot import its sibling modules; "
|
|
31
|
+
"ensure the hooks directory is importable."
|
|
32
|
+
) from import_error
|
|
33
|
+
|
|
26
34
|
|
|
27
35
|
REFACTOR_BYPASS_TOKEN_PATH = Path.home() / ".claude" / ".refactor-bypass-token"
|
|
28
36
|
identifier_join_separator = ", "
|
|
@@ -187,10 +195,9 @@ def _nonempty_stripped_lines(text: str) -> list[str]:
|
|
|
187
195
|
return [each_line.strip() for each_line in text.splitlines() if each_line.strip()]
|
|
188
196
|
|
|
189
197
|
|
|
190
|
-
def
|
|
198
|
+
def _lines_within_changed_surface(old_string: str, all_added_lines: list[str]) -> bool:
|
|
191
199
|
"""Return whether at least half of the edited lines are current additions."""
|
|
192
200
|
all_old_lines = _nonempty_stripped_lines(old_string)
|
|
193
|
-
all_added_lines = _get_added_line_occurrences(file_path)
|
|
194
201
|
if not all_old_lines or not all_added_lines:
|
|
195
202
|
return False
|
|
196
203
|
all_old_line_counts = Counter(all_old_lines)
|
|
@@ -203,6 +210,12 @@ def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
|
|
|
203
210
|
return matched_occurrence_count / edited_occurrence_count >= CHANGED_SURFACE_MATCH_RATIO
|
|
204
211
|
|
|
205
212
|
|
|
213
|
+
def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
|
|
214
|
+
"""Return whether at least half of the edited lines are current additions."""
|
|
215
|
+
all_added_lines = _get_added_line_occurrences(file_path)
|
|
216
|
+
return _lines_within_changed_surface(old_string, all_added_lines)
|
|
217
|
+
|
|
218
|
+
|
|
206
219
|
def _is_existing_edit_target(file_path: str) -> bool:
|
|
207
220
|
if not file_path or is_hook_infrastructure(file_path):
|
|
208
221
|
return False
|
|
@@ -290,20 +303,74 @@ def _read_edit_fields(payload_by_key: dict[str, object]) -> tuple[str, str, str,
|
|
|
290
303
|
return tool_name, file_path_field, old_string_field, new_string_field
|
|
291
304
|
|
|
292
305
|
|
|
306
|
+
def _refactor_advisory_description_within_surface(
|
|
307
|
+
all_added_lines: list[str],
|
|
308
|
+
old_string: str,
|
|
309
|
+
new_string: str,
|
|
310
|
+
) -> str | None:
|
|
311
|
+
"""Return an advisory description for one edit, given the file's added lines."""
|
|
312
|
+
refactor_description = is_refactor_edit(old_string, new_string)
|
|
313
|
+
if refactor_description is None:
|
|
314
|
+
return None
|
|
315
|
+
if _lines_within_changed_surface(old_string, all_added_lines):
|
|
316
|
+
return None
|
|
317
|
+
return refactor_description
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _multi_edit_refactor_advisory_description(
|
|
321
|
+
payload_by_key: dict[str, object],
|
|
322
|
+
) -> tuple[str, str] | None:
|
|
323
|
+
"""Return the (file_path, description) pair for the first eligible edit in a MultiEdit."""
|
|
324
|
+
raw_tool_input = payload_by_key.get("tool_input")
|
|
325
|
+
if not isinstance(raw_tool_input, dict):
|
|
326
|
+
return None
|
|
327
|
+
file_path_field = raw_tool_input.get("file_path")
|
|
328
|
+
file_path = file_path_field if isinstance(file_path_field, str) else ""
|
|
329
|
+
if not file_path:
|
|
330
|
+
return None
|
|
331
|
+
if not _is_existing_edit_target(file_path):
|
|
332
|
+
return None
|
|
333
|
+
all_added_lines = _get_added_line_occurrences(file_path)
|
|
334
|
+
for each_edit in edits_for_tool("MultiEdit", raw_tool_input):
|
|
335
|
+
if not isinstance(each_edit, dict):
|
|
336
|
+
continue
|
|
337
|
+
old_string_field = each_edit.get("old_string")
|
|
338
|
+
new_string_field = each_edit.get("new_string")
|
|
339
|
+
if not isinstance(old_string_field, str) or not isinstance(new_string_field, str):
|
|
340
|
+
continue
|
|
341
|
+
refactor_description = _refactor_advisory_description_within_surface(
|
|
342
|
+
all_added_lines, old_string_field, new_string_field
|
|
343
|
+
)
|
|
344
|
+
if refactor_description is not None:
|
|
345
|
+
return file_path, refactor_description
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _emit_advisory(file_path: str, refactor_description: str) -> None:
|
|
350
|
+
advisory_payload = build_refactor_advisory_payload(refactor_description, file_path)
|
|
351
|
+
sys.stdout.write(json.dumps(advisory_payload))
|
|
352
|
+
sys.stdout.flush()
|
|
353
|
+
|
|
354
|
+
|
|
293
355
|
def main() -> None:
|
|
294
356
|
"""Emit an Edit-stage advisory for eligible out-of-surface refactors."""
|
|
295
357
|
payload_by_key = _read_hook_input()
|
|
296
358
|
if payload_by_key is None:
|
|
297
359
|
return
|
|
298
360
|
tool_name, file_path, old_string, new_string = _read_edit_fields(payload_by_key)
|
|
299
|
-
if
|
|
361
|
+
if is_bypass_approved():
|
|
362
|
+
return
|
|
363
|
+
if tool_name == "MultiEdit":
|
|
364
|
+
multi_edit_result = _multi_edit_refactor_advisory_description(payload_by_key)
|
|
365
|
+
if multi_edit_result is not None:
|
|
366
|
+
_emit_advisory(*multi_edit_result)
|
|
367
|
+
return
|
|
368
|
+
if tool_name != "Edit":
|
|
300
369
|
return
|
|
301
370
|
refactor_description = find_refactor_advisory_description(file_path, old_string, new_string)
|
|
302
371
|
if refactor_description is None:
|
|
303
372
|
return
|
|
304
|
-
|
|
305
|
-
sys.stdout.write(json.dumps(advisory_payload))
|
|
306
|
-
sys.stdout.flush()
|
|
373
|
+
_emit_advisory(file_path, refactor_description)
|
|
307
374
|
|
|
308
375
|
|
|
309
376
|
if __name__ == "__main__":
|
|
@@ -7,8 +7,6 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
import pytest
|
|
11
|
-
|
|
12
10
|
|
|
13
11
|
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
14
12
|
_HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
|
|
@@ -59,6 +57,7 @@ def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dic
|
|
|
59
57
|
|
|
60
58
|
|
|
61
59
|
def _prepare_fresh_test_pair(migration_path: Path) -> None:
|
|
60
|
+
"""Write a migration module and its paired test file."""
|
|
62
61
|
migration_path.parent.mkdir(parents=True)
|
|
63
62
|
migration_path.write_text("operations = []\n", encoding="utf-8")
|
|
64
63
|
test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
|
|
@@ -104,12 +103,12 @@ def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
|
|
|
104
103
|
assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
|
|
105
104
|
|
|
106
105
|
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
def test_dispatcher_keeps_migration_advisor_write_excluded(tmp_path: Path) -> None:
|
|
107
|
+
"""Write stays out of the migration advisor's roster; only Edit and MultiEdit reach it."""
|
|
109
108
|
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
110
109
|
_prepare_fresh_test_pair(migration_path)
|
|
111
110
|
payload = _build_control_payload(
|
|
112
|
-
|
|
111
|
+
"Write",
|
|
113
112
|
migration_path,
|
|
114
113
|
'operations = [migrations.RemoveField("name")]',
|
|
115
114
|
)
|
|
@@ -118,3 +117,88 @@ def test_dispatcher_keeps_migration_advisor_edit_only(tmp_path: Path, tool_name:
|
|
|
118
117
|
serialized_payload = json.dumps(dispatched_payload or {})
|
|
119
118
|
|
|
120
119
|
assert "MIGRATION SAFETY" not in serialized_payload
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_dispatcher_extends_migration_advisor_to_multi_edit(tmp_path: Path) -> None:
|
|
123
|
+
"""A MultiEdit reaches the migration advisor the same way an Edit does."""
|
|
124
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
125
|
+
_prepare_fresh_test_pair(migration_path)
|
|
126
|
+
payload = _build_control_payload(
|
|
127
|
+
"MultiEdit",
|
|
128
|
+
migration_path,
|
|
129
|
+
'operations = [migrations.RemoveField("name")]',
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
133
|
+
assert dispatched_payload is not None
|
|
134
|
+
serialized_payload = json.dumps(dispatched_payload)
|
|
135
|
+
|
|
136
|
+
assert "MIGRATION SAFETY" in serialized_payload
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_multi_edit_payload(
|
|
140
|
+
file_path: Path, all_edits: list[dict[str, str]]
|
|
141
|
+
) -> dict[str, object]:
|
|
142
|
+
return {
|
|
143
|
+
"tool_name": "MultiEdit",
|
|
144
|
+
"tool_input": {"file_path": str(file_path), "edits": all_edits},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_standalone_advisor_warns_for_unsafe_operation_in_second_multi_edit(
|
|
149
|
+
tmp_path: Path,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""An unsafe operation in the second edit of a MultiEdit is caught, not only the first.
|
|
152
|
+
|
|
153
|
+
migration_safety_advisor has no tool_name gate today: it reads
|
|
154
|
+
tool_input.get("new_string"), a key a MultiEdit payload never carries at
|
|
155
|
+
the top level. Widening its roster entry alone would silently do
|
|
156
|
+
nothing on a real MultiEdit call.
|
|
157
|
+
"""
|
|
158
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
159
|
+
payload = _build_multi_edit_payload(
|
|
160
|
+
migration_path,
|
|
161
|
+
[
|
|
162
|
+
{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"},
|
|
163
|
+
{
|
|
164
|
+
"old_string": "migrations.AddField()",
|
|
165
|
+
"new_string": 'migrations.AddField(), migrations.RemoveField("name")',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
171
|
+
assert advisory_payload is not None
|
|
172
|
+
permission_fields = _permission_fields(advisory_payload)
|
|
173
|
+
|
|
174
|
+
assert permission_fields["permissionDecision"] == "allow"
|
|
175
|
+
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_standalone_advisor_reads_a_write_payloads_content_field(tmp_path: Path) -> None:
|
|
179
|
+
"""_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
|
|
180
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
181
|
+
payload: dict[str, object] = {
|
|
182
|
+
"tool_name": "Write",
|
|
183
|
+
"tool_input": {
|
|
184
|
+
"file_path": str(migration_path),
|
|
185
|
+
"content": 'operations = [migrations.RemoveField("name")]',
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
190
|
+
assert advisory_payload is not None
|
|
191
|
+
assert "RemoveField" in _text_field(
|
|
192
|
+
_permission_fields(advisory_payload), "additionalContext"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_standalone_advisor_is_silent_for_a_clean_multi_edit(tmp_path: Path) -> None:
|
|
197
|
+
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
198
|
+
payload = _build_multi_edit_payload(
|
|
199
|
+
migration_path,
|
|
200
|
+
[{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"}],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
204
|
+
assert advisory_payload is None
|
|
@@ -0,0 +1,87 @@
|
|
|
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 sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
if str(_ADVISORY_DIRECTORY) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_ADVISORY_DIRECTORY))
|
|
19
|
+
|
|
20
|
+
import refactor_guard
|
|
21
|
+
except ImportError as import_error:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"test_refactor_guard: cannot import its sibling modules; "
|
|
24
|
+
"ensure the advisory directory is importable."
|
|
25
|
+
) from import_error
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _multi_edit_payload(file_path: str, all_edits: list[dict[str, str]]) -> dict[str, object]:
|
|
29
|
+
return {
|
|
30
|
+
"tool_name": "MultiEdit",
|
|
31
|
+
"tool_input": {"file_path": file_path, "edits": all_edits},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_multi_edit_reports_the_first_eligible_rename(tmp_path: Path) -> None:
|
|
36
|
+
"""A rename in the second edit is reported, even though the first edit is ordinary."""
|
|
37
|
+
target_path = str(tmp_path / "module.py")
|
|
38
|
+
payload = _multi_edit_payload(
|
|
39
|
+
target_path,
|
|
40
|
+
[
|
|
41
|
+
{"old_string": "return amount", "new_string": "return amount + 1"},
|
|
42
|
+
{
|
|
43
|
+
"old_string": "def calculate_total(amount):\n return amount",
|
|
44
|
+
"new_string": "def compute_total(amount):\n return amount",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
result = refactor_guard._multi_edit_refactor_advisory_description(payload)
|
|
50
|
+
|
|
51
|
+
assert result is not None
|
|
52
|
+
reported_path, description = result
|
|
53
|
+
assert reported_path == target_path
|
|
54
|
+
assert "calculate_total" in description
|
|
55
|
+
assert "compute_total" in description
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_multi_edit_scans_past_two_ordinary_edits_to_the_third(tmp_path: Path) -> None:
|
|
59
|
+
"""The loop over edit pairs reaches the third pair, not only the first two."""
|
|
60
|
+
target_path = str(tmp_path / "module.py")
|
|
61
|
+
payload = _multi_edit_payload(
|
|
62
|
+
target_path,
|
|
63
|
+
[
|
|
64
|
+
{"old_string": "return amount", "new_string": "return amount + 1"},
|
|
65
|
+
{"old_string": "return amount + 1", "new_string": "return amount + 2"},
|
|
66
|
+
{
|
|
67
|
+
"old_string": "def calculate_total(amount):\n return amount + 2",
|
|
68
|
+
"new_string": "def compute_total(amount):\n return amount + 2",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
result = refactor_guard._multi_edit_refactor_advisory_description(payload)
|
|
74
|
+
|
|
75
|
+
assert result is not None
|
|
76
|
+
_reported_path, description = result
|
|
77
|
+
assert "compute_total" in description
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_multi_edit_returns_none_when_no_edit_qualifies(tmp_path: Path) -> None:
|
|
81
|
+
target_path = str(tmp_path / "module.py")
|
|
82
|
+
payload = _multi_edit_payload(
|
|
83
|
+
target_path,
|
|
84
|
+
[{"old_string": "return amount", "new_string": "return amount + 1"}],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert refactor_guard._multi_edit_refactor_advisory_description(payload) is None
|
|
@@ -19,6 +19,8 @@ if str(ADVISORY_DIRECTORY) not in sys.path:
|
|
|
19
19
|
import refactor_guard # noqa: E402
|
|
20
20
|
from refactor_guard_test_support import commit_file, stage_file # noqa: E402
|
|
21
21
|
|
|
22
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
def _refactor_payload(file_path: Path) -> str:
|
|
24
26
|
return json.dumps(
|
|
@@ -127,6 +129,7 @@ def test_direct_hook_stays_silent_for_write_payload(git_repository: Path) -> Non
|
|
|
127
129
|
|
|
128
130
|
|
|
129
131
|
def test_dispatcher_preserves_edit_stage_guidance(git_repository: Path) -> None:
|
|
132
|
+
"""A red step (staged, uncommitted test) plus a staged refactor still allows."""
|
|
130
133
|
source_path = git_repository / "module.py"
|
|
131
134
|
matching_test_path = git_repository / "test_module.py"
|
|
132
135
|
commit_file(
|
|
@@ -134,7 +137,7 @@ def test_dispatcher_preserves_edit_stage_guidance(git_repository: Path) -> None:
|
|
|
134
137
|
source_path,
|
|
135
138
|
"def calculate_total(amount: int) -> int:\n return amount\n",
|
|
136
139
|
)
|
|
137
|
-
|
|
140
|
+
stage_file(
|
|
138
141
|
git_repository,
|
|
139
142
|
matching_test_path,
|
|
140
143
|
"def calculate_total(amount: int) -> int:\n return amount\n\n"
|
|
@@ -169,3 +172,71 @@ def test_bypass_token_is_consumed_once(tmp_path: Path, monkeypatch: pytest.Monke
|
|
|
169
172
|
assert refactor_guard.is_bypass_approved()
|
|
170
173
|
assert not bypass_token_path.exists()
|
|
171
174
|
assert not refactor_guard.is_bypass_approved()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _multi_edit_payload(file_path: Path, all_edits: list[dict[str, str]]) -> str:
|
|
178
|
+
return json.dumps(
|
|
179
|
+
{
|
|
180
|
+
"tool_name": "MultiEdit",
|
|
181
|
+
"tool_input": {"file_path": str(file_path), "edits": all_edits},
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _commit_and_stage_the_renamed_module(git_repository: Path, source_path: Path) -> None:
|
|
187
|
+
"""Commit the base module, then stage an unrelated numeric change to it."""
|
|
188
|
+
commit_file(
|
|
189
|
+
git_repository,
|
|
190
|
+
source_path,
|
|
191
|
+
"def calculate_total(amount: int) -> int:\n return amount\n",
|
|
192
|
+
)
|
|
193
|
+
stage_file(
|
|
194
|
+
git_repository,
|
|
195
|
+
source_path,
|
|
196
|
+
"def calculate_total(amount: int) -> int:\n return amount + 1\n",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_direct_hook_flags_refactor_in_second_multi_edit(git_repository: Path) -> None:
|
|
201
|
+
"""A rename buried in the second edit of a MultiEdit is caught, not only the first.
|
|
202
|
+
|
|
203
|
+
The first edit is an ordinary numeric change; only the second edit renames
|
|
204
|
+
calculate_total to compute_total outside the currently staged diff.
|
|
205
|
+
"""
|
|
206
|
+
source_path = git_repository / "module.py"
|
|
207
|
+
_commit_and_stage_the_renamed_module(git_repository, source_path)
|
|
208
|
+
payload_text = _multi_edit_payload(
|
|
209
|
+
source_path,
|
|
210
|
+
[
|
|
211
|
+
{"old_string": "return amount", "new_string": "return amount + 1"},
|
|
212
|
+
{
|
|
213
|
+
"old_string": "def calculate_total(amount: int) -> int:\n return amount",
|
|
214
|
+
"new_string": "def compute_total(amount: int) -> int:\n return amount",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
completed_hook = _run_hook(HOOK_SCRIPT_PATH, git_repository, payload_text)
|
|
220
|
+
advisory_payload = json.loads(completed_hook.stdout)
|
|
221
|
+
hook_specific_output = advisory_payload["hookSpecificOutput"]
|
|
222
|
+
|
|
223
|
+
assert hook_specific_output["permissionDecision"] == "allow"
|
|
224
|
+
assert "Edit-stage" in advisory_payload["systemMessage"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_direct_hook_stays_silent_for_ordinary_multi_edit(git_repository: Path) -> None:
|
|
228
|
+
source_path = git_repository / "module.py"
|
|
229
|
+
commit_file(
|
|
230
|
+
git_repository,
|
|
231
|
+
source_path,
|
|
232
|
+
"def calculate_total(amount: int) -> int:\n return amount\n",
|
|
233
|
+
)
|
|
234
|
+
payload_text = _multi_edit_payload(
|
|
235
|
+
source_path,
|
|
236
|
+
[{"old_string": "return amount", "new_string": "return amount + 1"}],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
completed_hook = _run_hook(HOOK_SCRIPT_PATH, git_repository, payload_text)
|
|
240
|
+
|
|
241
|
+
assert completed_hook.returncode == 0
|
|
242
|
+
assert completed_hook.stdout == ""
|