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.
Files changed (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /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
@@ -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 extract_git_working_directory(bash_command: str) -> str | None:
32
- """Extract the directory where git commit will actually execute.
33
-
34
- Parses the bash command for directory-changing patterns that precede
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
- commit_pos = bash_command.lower().find("git commit")
47
- if commit_pos == -1:
48
- return None
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[:commit_pos]
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
- return None
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(directory: str | None) -> str | None:
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(directory)
93
+ expanded = os.path.expanduser(selected_directory)
68
94
 
69
95
  if not os.path.isabs(expanded):
70
- expanded = os.path.abspath(expanded)
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 parse_bash_command_from_stdin() -> str:
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
- return hook_event.get("tool_input", {}).get("command", "")
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 = parse_bash_command_from_stdin()
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 is_commit_command(bash_command):
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
- target_dir_raw = extract_git_working_directory(bash_command)
168
- target_dir = resolve_directory(target_dir_raw)
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)