claude-dev-env 1.85.0 → 1.86.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/_shared/pr-loop/scripts/code_rules_gate.py +18 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +142 -7
- package/_shared/pr-loop/scripts/terminology_sweep.py +288 -92
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +89 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +180 -91
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +7 -4
- package/hooks/blocking/session_edit_stage_gate.py +654 -0
- package/hooks/blocking/test_session_edit_stage_gate.py +537 -0
- package/hooks/hooks.json +30 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +92 -0
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +18 -0
- package/hooks/lifecycle/CLAUDE.md +1 -1
- package/hooks/observability/CLAUDE.md +2 -0
- package/hooks/observability/session_file_edit_tracker.py +224 -0
- package/hooks/observability/test_session_file_edit_tracker.py +174 -0
- package/hooks/session/CLAUDE.md +6 -2
- package/hooks/session/session_edit_tracker_cleanup.py +120 -0
- package/hooks/session/task_list_loop_starter.py +36 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +157 -0
- package/hooks/session/test_task_list_loop_starter.py +53 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/re-stage-before-commit.md +31 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""Behavior tests for the session_edit_stage_gate PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Each test builds a real git repository in a temporary directory, records a
|
|
4
|
+
session-edit set in a redirected temp directory, and runs the hook script as a
|
|
5
|
+
subprocess with a PreToolUse JSON payload on stdin — the exact production
|
|
6
|
+
invocation path. Environment overrides point ``tempfile.gettempdir`` at the
|
|
7
|
+
per-test temp directory (so the hook reads the test's session-edit file) and
|
|
8
|
+
point the home directory at a per-test directory (so the block log stays out of
|
|
9
|
+
the real home).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_HOOK_DIR = Path(__file__).resolve().parent
|
|
21
|
+
_HOOKS_TREE = _HOOK_DIR.parent
|
|
22
|
+
for each_path in (str(_HOOKS_TREE),):
|
|
23
|
+
if each_path not in sys.path:
|
|
24
|
+
sys.path.insert(0, each_path)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
27
|
+
ALL_EDITED_FILE_PATHS_KEY,
|
|
28
|
+
SESSION_EDIT_FILE_PREFIX,
|
|
29
|
+
SESSION_EDIT_FILE_SUFFIX,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
HOOK_PATH = _HOOK_DIR / "session_edit_stage_gate.py"
|
|
33
|
+
_SESSION_ID = "gate-session-1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clean_git_environment() -> dict[str, str]:
|
|
37
|
+
return {
|
|
38
|
+
each_key: each_value
|
|
39
|
+
for each_key, each_value in os.environ.items()
|
|
40
|
+
if not each_key.startswith("GIT_")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_git(repository_root: Path, *git_arguments: str) -> None:
|
|
45
|
+
subprocess.run(
|
|
46
|
+
["git", "-C", str(repository_root), *git_arguments],
|
|
47
|
+
check=True,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
env=_clean_git_environment(),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def initialize_repository(repository_root: Path) -> None:
|
|
54
|
+
run_git(repository_root, "init")
|
|
55
|
+
run_git(repository_root, "config", "user.email", "tests@example.com")
|
|
56
|
+
run_git(repository_root, "config", "user.name", "Gate Tests")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def prepare_repository_with_unstaged_edit(repository_root: Path) -> Path:
|
|
60
|
+
initialize_repository(repository_root)
|
|
61
|
+
tracked_file = repository_root / "widget.py"
|
|
62
|
+
tracked_file.write_text("x = 1\n", encoding="utf-8")
|
|
63
|
+
run_git(repository_root, "add", "widget.py")
|
|
64
|
+
run_git(repository_root, "commit", "-m", "add widget")
|
|
65
|
+
tracked_file.write_text("x = 2\n", encoding="utf-8")
|
|
66
|
+
return tracked_file
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_session_edits(temp_directory: Path, session_id: str, absolute_paths: list[Path]) -> None:
|
|
70
|
+
edit_file = temp_directory / f"{SESSION_EDIT_FILE_PREFIX}{session_id}{SESSION_EDIT_FILE_SUFFIX}"
|
|
71
|
+
payload = {ALL_EDITED_FILE_PATHS_KEY: [str(each_path) for each_path in absolute_paths]}
|
|
72
|
+
edit_file.write_text(json.dumps(payload), encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_hook(
|
|
76
|
+
command: str,
|
|
77
|
+
session_id: str,
|
|
78
|
+
cwd: Path,
|
|
79
|
+
temp_directory: Path,
|
|
80
|
+
home_directory: Path,
|
|
81
|
+
) -> subprocess.CompletedProcess[str]:
|
|
82
|
+
environment = _clean_git_environment()
|
|
83
|
+
environment["TMP"] = str(temp_directory)
|
|
84
|
+
environment["TEMP"] = str(temp_directory)
|
|
85
|
+
environment["TMPDIR"] = str(temp_directory)
|
|
86
|
+
environment["USERPROFILE"] = str(home_directory)
|
|
87
|
+
environment["HOME"] = str(home_directory)
|
|
88
|
+
payload = json.dumps({"session_id": session_id, "tool_input": {"command": command}})
|
|
89
|
+
return subprocess.run(
|
|
90
|
+
[sys.executable, str(HOOK_PATH)],
|
|
91
|
+
check=False,
|
|
92
|
+
input=payload,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
cwd=str(cwd),
|
|
96
|
+
env=environment,
|
|
97
|
+
timeout=60,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _make_directories(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|
102
|
+
repository_root = tmp_path / "repo"
|
|
103
|
+
repository_root.mkdir()
|
|
104
|
+
temp_directory = tmp_path / "tmp"
|
|
105
|
+
temp_directory.mkdir()
|
|
106
|
+
home_directory = tmp_path / "home"
|
|
107
|
+
home_directory.mkdir()
|
|
108
|
+
return repository_root, temp_directory, home_directory
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_denial(hook_stdout: str) -> dict:
|
|
112
|
+
return json.loads(hook_stdout)["hookSpecificOutput"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_denies_commit_dropping_session_edited_unstaged_file(tmp_path: Path) -> None:
|
|
116
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
117
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
118
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
119
|
+
completed_hook = run_hook(
|
|
120
|
+
"git commit -m update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
121
|
+
)
|
|
122
|
+
assert completed_hook.returncode == 0
|
|
123
|
+
denial = parse_denial(completed_hook.stdout)
|
|
124
|
+
assert denial["permissionDecision"] == "deny"
|
|
125
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_deny_writes_hook_block_log(tmp_path: Path) -> None:
|
|
129
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
130
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
131
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
132
|
+
run_hook("git commit -m update", _SESSION_ID, repository_root, temp_directory, home_directory)
|
|
133
|
+
logs_directory = home_directory / ".claude" / "logs"
|
|
134
|
+
all_log_files = list(logs_directory.glob("*.log"))
|
|
135
|
+
assert all_log_files
|
|
136
|
+
all_log_text = "".join(each_file.read_text(encoding="utf-8") for each_file in all_log_files)
|
|
137
|
+
assert "session_edit_stage_gate.py" in all_log_text
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_allows_commit_when_session_edited_file_is_staged(tmp_path: Path) -> None:
|
|
141
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
142
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
143
|
+
run_git(repository_root, "add", "widget.py")
|
|
144
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
145
|
+
completed_hook = run_hook(
|
|
146
|
+
"git commit -m update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
147
|
+
)
|
|
148
|
+
assert completed_hook.returncode == 0
|
|
149
|
+
assert completed_hook.stdout.strip() == ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_allows_commit_all_flag(tmp_path: Path) -> None:
|
|
153
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
154
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
155
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
156
|
+
completed_hook = run_hook(
|
|
157
|
+
"git commit -a -m update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
158
|
+
)
|
|
159
|
+
assert completed_hook.returncode == 0
|
|
160
|
+
assert completed_hook.stdout.strip() == ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_allows_non_commit_command_containing_git_commit_substring(tmp_path: Path) -> None:
|
|
164
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
165
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
166
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
167
|
+
completed_hook = run_hook(
|
|
168
|
+
'grep -rn "git commit" .',
|
|
169
|
+
_SESSION_ID,
|
|
170
|
+
repository_root,
|
|
171
|
+
temp_directory,
|
|
172
|
+
home_directory,
|
|
173
|
+
)
|
|
174
|
+
assert completed_hook.returncode == 0
|
|
175
|
+
assert completed_hook.stdout.strip() == ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_allows_all_flag_commit_after_bare_commit_token_segment(tmp_path: Path) -> None:
|
|
179
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
180
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
181
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
182
|
+
completed_hook = run_hook(
|
|
183
|
+
"echo commit && git commit -a -m update",
|
|
184
|
+
_SESSION_ID,
|
|
185
|
+
repository_root,
|
|
186
|
+
temp_directory,
|
|
187
|
+
home_directory,
|
|
188
|
+
)
|
|
189
|
+
assert completed_hook.returncode == 0
|
|
190
|
+
assert completed_hook.stdout.strip() == ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_allows_non_git_command_with_git_and_commit_as_arguments(tmp_path: Path) -> None:
|
|
194
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
195
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
196
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
197
|
+
completed_hook = run_hook(
|
|
198
|
+
"echo git commit",
|
|
199
|
+
_SESSION_ID,
|
|
200
|
+
repository_root,
|
|
201
|
+
temp_directory,
|
|
202
|
+
home_directory,
|
|
203
|
+
)
|
|
204
|
+
assert completed_hook.returncode == 0
|
|
205
|
+
assert completed_hook.stdout.strip() == ""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_denies_commit_with_leading_environment_assignment(tmp_path: Path) -> None:
|
|
209
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
210
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
211
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
212
|
+
completed_hook = run_hook(
|
|
213
|
+
"GIT_AUTHOR_NAME=x git commit -m update",
|
|
214
|
+
_SESSION_ID,
|
|
215
|
+
repository_root,
|
|
216
|
+
temp_directory,
|
|
217
|
+
home_directory,
|
|
218
|
+
)
|
|
219
|
+
assert completed_hook.returncode == 0
|
|
220
|
+
denial = parse_denial(completed_hook.stdout)
|
|
221
|
+
assert denial["permissionDecision"] == "deny"
|
|
222
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_denies_commit_with_no_space_message_containing_letter_a(tmp_path: Path) -> None:
|
|
226
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
227
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
228
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
229
|
+
completed_hook = run_hook(
|
|
230
|
+
'git commit -m"add feature"',
|
|
231
|
+
_SESSION_ID,
|
|
232
|
+
repository_root,
|
|
233
|
+
temp_directory,
|
|
234
|
+
home_directory,
|
|
235
|
+
)
|
|
236
|
+
assert completed_hook.returncode == 0
|
|
237
|
+
denial = parse_denial(completed_hook.stdout)
|
|
238
|
+
assert denial["permissionDecision"] == "deny"
|
|
239
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_denies_commit_with_no_space_message_option_form(tmp_path: Path) -> None:
|
|
243
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
244
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
245
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
246
|
+
completed_hook = run_hook(
|
|
247
|
+
"git commit -mupdate", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
248
|
+
)
|
|
249
|
+
assert completed_hook.returncode == 0
|
|
250
|
+
denial = parse_denial(completed_hook.stdout)
|
|
251
|
+
assert denial["permissionDecision"] == "deny"
|
|
252
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_allows_clustered_all_and_message_flags(tmp_path: Path) -> None:
|
|
256
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
257
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
258
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
259
|
+
completed_hook = run_hook(
|
|
260
|
+
"git commit -am update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
261
|
+
)
|
|
262
|
+
assert completed_hook.returncode == 0
|
|
263
|
+
assert completed_hook.stdout.strip() == ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_allows_commit_when_add_stages_in_same_command(tmp_path: Path) -> None:
|
|
267
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
268
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
269
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
270
|
+
completed_hook = run_hook(
|
|
271
|
+
"git add widget.py && git commit -m update",
|
|
272
|
+
_SESSION_ID,
|
|
273
|
+
repository_root,
|
|
274
|
+
temp_directory,
|
|
275
|
+
home_directory,
|
|
276
|
+
)
|
|
277
|
+
assert completed_hook.returncode == 0
|
|
278
|
+
assert completed_hook.stdout.strip() == ""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_allows_commit_when_git_dash_c_add_stages_in_same_command(tmp_path: Path) -> None:
|
|
282
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
283
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
284
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
285
|
+
completed_hook = run_hook(
|
|
286
|
+
f'git -C "{repository_root}" add widget.py && git commit -m update',
|
|
287
|
+
_SESSION_ID,
|
|
288
|
+
repository_root,
|
|
289
|
+
temp_directory,
|
|
290
|
+
home_directory,
|
|
291
|
+
)
|
|
292
|
+
assert completed_hook.returncode == 0
|
|
293
|
+
assert completed_hook.stdout.strip() == ""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_denies_commit_when_specific_add_leaves_session_edit_unstaged(tmp_path: Path) -> None:
|
|
297
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
298
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
299
|
+
readme_file = repository_root / "README.md"
|
|
300
|
+
readme_file.write_text("readme\n", encoding="utf-8")
|
|
301
|
+
run_git(repository_root, "add", "README.md")
|
|
302
|
+
run_git(repository_root, "commit", "-m", "add readme")
|
|
303
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
304
|
+
completed_hook = run_hook(
|
|
305
|
+
"git add README.md && git commit -m update",
|
|
306
|
+
_SESSION_ID,
|
|
307
|
+
repository_root,
|
|
308
|
+
temp_directory,
|
|
309
|
+
home_directory,
|
|
310
|
+
)
|
|
311
|
+
assert completed_hook.returncode == 0
|
|
312
|
+
denial = parse_denial(completed_hook.stdout)
|
|
313
|
+
assert denial["permissionDecision"] == "deny"
|
|
314
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_allows_commit_when_stage_all_add_precedes(tmp_path: Path) -> None:
|
|
318
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
319
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
320
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
321
|
+
completed_hook = run_hook(
|
|
322
|
+
"git add -A && git commit -m update",
|
|
323
|
+
_SESSION_ID,
|
|
324
|
+
repository_root,
|
|
325
|
+
temp_directory,
|
|
326
|
+
home_directory,
|
|
327
|
+
)
|
|
328
|
+
assert completed_hook.returncode == 0
|
|
329
|
+
assert completed_hook.stdout.strip() == ""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_allows_commit_when_dash_named_path_staged_after_end_of_options(tmp_path: Path) -> None:
|
|
333
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
334
|
+
initialize_repository(repository_root)
|
|
335
|
+
tracked_file = repository_root / "-weird.py"
|
|
336
|
+
tracked_file.write_text("x = 1\n", encoding="utf-8")
|
|
337
|
+
run_git(repository_root, "add", "--", "-weird.py")
|
|
338
|
+
run_git(repository_root, "commit", "-m", "add weird")
|
|
339
|
+
tracked_file.write_text("x = 2\n", encoding="utf-8")
|
|
340
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
341
|
+
completed_hook = run_hook(
|
|
342
|
+
"git add -- -weird.py && git commit -m update",
|
|
343
|
+
_SESSION_ID,
|
|
344
|
+
repository_root,
|
|
345
|
+
temp_directory,
|
|
346
|
+
home_directory,
|
|
347
|
+
)
|
|
348
|
+
assert completed_hook.returncode == 0
|
|
349
|
+
assert completed_hook.stdout.strip() == ""
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_denies_when_dash_a_filename_staged_after_end_of_options(tmp_path: Path) -> None:
|
|
353
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
354
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
355
|
+
dash_a_file = repository_root / "-A"
|
|
356
|
+
dash_a_file.write_text("a\n", encoding="utf-8")
|
|
357
|
+
run_git(repository_root, "add", "--", "-A")
|
|
358
|
+
run_git(repository_root, "commit", "-m", "add dash-a file")
|
|
359
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
360
|
+
completed_hook = run_hook(
|
|
361
|
+
"git add -- -A && git commit -m update",
|
|
362
|
+
_SESSION_ID,
|
|
363
|
+
repository_root,
|
|
364
|
+
temp_directory,
|
|
365
|
+
home_directory,
|
|
366
|
+
)
|
|
367
|
+
assert completed_hook.returncode == 0
|
|
368
|
+
denial = parse_denial(completed_hook.stdout)
|
|
369
|
+
assert denial["permissionDecision"] == "deny"
|
|
370
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_denies_commit_when_add_runs_after_commit_segment(tmp_path: Path) -> None:
|
|
374
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
375
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
376
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
377
|
+
completed_hook = run_hook(
|
|
378
|
+
"git commit -m update ; git add widget.py",
|
|
379
|
+
_SESSION_ID,
|
|
380
|
+
repository_root,
|
|
381
|
+
temp_directory,
|
|
382
|
+
home_directory,
|
|
383
|
+
)
|
|
384
|
+
assert completed_hook.returncode == 0
|
|
385
|
+
denial = parse_denial(completed_hook.stdout)
|
|
386
|
+
assert denial["permissionDecision"] == "deny"
|
|
387
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_allows_pathspec_commit(tmp_path: Path) -> None:
|
|
391
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
392
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
393
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
394
|
+
completed_hook = run_hook(
|
|
395
|
+
"git commit -m update widget.py",
|
|
396
|
+
_SESSION_ID,
|
|
397
|
+
repository_root,
|
|
398
|
+
temp_directory,
|
|
399
|
+
home_directory,
|
|
400
|
+
)
|
|
401
|
+
assert completed_hook.returncode == 0
|
|
402
|
+
assert completed_hook.stdout.strip() == ""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_allows_partial_commit_marker(tmp_path: Path) -> None:
|
|
406
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
407
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
408
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
409
|
+
completed_hook = run_hook(
|
|
410
|
+
"git commit -m update # partial-commit",
|
|
411
|
+
_SESSION_ID,
|
|
412
|
+
repository_root,
|
|
413
|
+
temp_directory,
|
|
414
|
+
home_directory,
|
|
415
|
+
)
|
|
416
|
+
assert completed_hook.returncode == 0
|
|
417
|
+
assert completed_hook.stdout.strip() == ""
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_denies_when_marker_not_trailing(tmp_path: Path) -> None:
|
|
421
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
422
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
423
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
424
|
+
completed_hook = run_hook(
|
|
425
|
+
"echo # partial-commit && git commit -m update",
|
|
426
|
+
_SESSION_ID,
|
|
427
|
+
repository_root,
|
|
428
|
+
temp_directory,
|
|
429
|
+
home_directory,
|
|
430
|
+
)
|
|
431
|
+
assert completed_hook.returncode == 0
|
|
432
|
+
denial = parse_denial(completed_hook.stdout)
|
|
433
|
+
assert denial["permissionDecision"] == "deny"
|
|
434
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_denies_when_marker_only_inside_commit_message(tmp_path: Path) -> None:
|
|
438
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
439
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
440
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
441
|
+
completed_hook = run_hook(
|
|
442
|
+
'git commit -m "fix # partial-commit substring handling"',
|
|
443
|
+
_SESSION_ID,
|
|
444
|
+
repository_root,
|
|
445
|
+
temp_directory,
|
|
446
|
+
home_directory,
|
|
447
|
+
)
|
|
448
|
+
assert completed_hook.returncode == 0
|
|
449
|
+
denial = parse_denial(completed_hook.stdout)
|
|
450
|
+
assert denial["permissionDecision"] == "deny"
|
|
451
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_denies_commit_dropping_non_ascii_named_file(tmp_path: Path) -> None:
|
|
455
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
456
|
+
initialize_repository(repository_root)
|
|
457
|
+
tracked_file = repository_root / "café.py"
|
|
458
|
+
tracked_file.write_text("x = 1\n", encoding="utf-8")
|
|
459
|
+
run_git(repository_root, "add", "café.py")
|
|
460
|
+
run_git(repository_root, "commit", "-m", "add cafe")
|
|
461
|
+
tracked_file.write_text("x = 2\n", encoding="utf-8")
|
|
462
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
463
|
+
completed_hook = run_hook(
|
|
464
|
+
"git commit -m update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
465
|
+
)
|
|
466
|
+
assert completed_hook.returncode == 0
|
|
467
|
+
denial = parse_denial(completed_hook.stdout)
|
|
468
|
+
assert denial["permissionDecision"] == "deny"
|
|
469
|
+
assert "café.py" in denial["permissionDecisionReason"]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_allows_amend_still_gates(tmp_path: Path) -> None:
|
|
473
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
474
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
475
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
476
|
+
completed_hook = run_hook(
|
|
477
|
+
"git commit --amend -m update",
|
|
478
|
+
_SESSION_ID,
|
|
479
|
+
repository_root,
|
|
480
|
+
temp_directory,
|
|
481
|
+
home_directory,
|
|
482
|
+
)
|
|
483
|
+
assert completed_hook.returncode == 0
|
|
484
|
+
denial = parse_denial(completed_hook.stdout)
|
|
485
|
+
assert denial["permissionDecision"] == "deny"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_allows_when_no_tracker_file(tmp_path: Path) -> None:
|
|
489
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
490
|
+
prepare_repository_with_unstaged_edit(repository_root)
|
|
491
|
+
completed_hook = run_hook(
|
|
492
|
+
"git commit -m update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
493
|
+
)
|
|
494
|
+
assert completed_hook.returncode == 0
|
|
495
|
+
assert completed_hook.stdout.strip() == ""
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_allows_non_commit_command(tmp_path: Path) -> None:
|
|
499
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
500
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
501
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
502
|
+
completed_hook = run_hook(
|
|
503
|
+
"git status", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
504
|
+
)
|
|
505
|
+
assert completed_hook.returncode == 0
|
|
506
|
+
assert completed_hook.stdout.strip() == ""
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def test_allows_when_unstaged_file_not_session_edited(tmp_path: Path) -> None:
|
|
510
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
511
|
+
prepare_repository_with_unstaged_edit(repository_root)
|
|
512
|
+
unrelated_path = repository_root / "unrelated.py"
|
|
513
|
+
write_session_edits(temp_directory, _SESSION_ID, [unrelated_path.resolve()])
|
|
514
|
+
completed_hook = run_hook(
|
|
515
|
+
"git commit -m update", _SESSION_ID, repository_root, temp_directory, home_directory
|
|
516
|
+
)
|
|
517
|
+
assert completed_hook.returncode == 0
|
|
518
|
+
assert completed_hook.stdout.strip() == ""
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_git_dash_c_form_resolves_repository(tmp_path: Path) -> None:
|
|
522
|
+
repository_root, temp_directory, home_directory = _make_directories(tmp_path)
|
|
523
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
524
|
+
write_session_edits(temp_directory, _SESSION_ID, [tracked_file.resolve()])
|
|
525
|
+
elsewhere = tmp_path / "elsewhere"
|
|
526
|
+
elsewhere.mkdir()
|
|
527
|
+
completed_hook = run_hook(
|
|
528
|
+
f'git -C "{repository_root}" commit -m update',
|
|
529
|
+
_SESSION_ID,
|
|
530
|
+
elsewhere,
|
|
531
|
+
temp_directory,
|
|
532
|
+
home_directory,
|
|
533
|
+
)
|
|
534
|
+
assert completed_hook.returncode == 0
|
|
535
|
+
denial = parse_denial(completed_hook.stdout)
|
|
536
|
+
assert denial["permissionDecision"] == "deny"
|
|
537
|
+
assert "widget.py" in denial["permissionDecisionReason"]
|
package/hooks/hooks.json
CHANGED
|
@@ -75,6 +75,11 @@
|
|
|
75
75
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/precommit_code_rules_gate.py",
|
|
76
76
|
"timeout": 150
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"type": "command",
|
|
80
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_edit_stage_gate.py",
|
|
81
|
+
"timeout": 15
|
|
82
|
+
},
|
|
78
83
|
{
|
|
79
84
|
"type": "command",
|
|
80
85
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_description_enforcer.py",
|
|
@@ -206,6 +211,16 @@
|
|
|
206
211
|
"type": "command",
|
|
207
212
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/gh_pr_author_session_cleanup.py",
|
|
208
213
|
"timeout": 30
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"type": "command",
|
|
217
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
|
|
218
|
+
"timeout": 10
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"type": "command",
|
|
222
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_list_loop_starter.py",
|
|
223
|
+
"timeout": 10
|
|
209
224
|
}
|
|
210
225
|
]
|
|
211
226
|
}
|
|
@@ -262,6 +277,11 @@
|
|
|
262
277
|
"type": "command",
|
|
263
278
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/session_end_cleanup.py",
|
|
264
279
|
"timeout": 15
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
"type": "command",
|
|
283
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
|
|
284
|
+
"timeout": 10
|
|
265
285
|
}
|
|
266
286
|
]
|
|
267
287
|
}
|
|
@@ -289,6 +309,16 @@
|
|
|
289
309
|
}
|
|
290
310
|
]
|
|
291
311
|
},
|
|
312
|
+
{
|
|
313
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
314
|
+
"hooks": [
|
|
315
|
+
{
|
|
316
|
+
"type": "command",
|
|
317
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/session_file_edit_tracker.py",
|
|
318
|
+
"timeout": 10
|
|
319
|
+
}
|
|
320
|
+
]
|
|
321
|
+
},
|
|
292
322
|
{
|
|
293
323
|
"matcher": "Agent|Task|TeamCreate",
|
|
294
324
|
"hooks": [
|
|
@@ -52,6 +52,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
52
52
|
| `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
|
|
53
53
|
| `reviewer_spawn_gate_constants.py` | Bash tool name, the sentinel marker, the Copilot and Bugbot trigger markers, the availability-script relative path and override env-var name, and the deny-message template for the reviewer-spawn gate |
|
|
54
54
|
| `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
|
|
55
|
+
| `session_edit_stage_gate_constants.py` | Tracker filename prefix/suffix, JSON payload key, edit tool name set, session-id sanitize pattern, lock filename suffix and lock-acquire timing, git diff command, commit flag escapes, and deny-message template shared by the session edit stage gate trio |
|
|
55
56
|
| `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
|
|
56
57
|
| `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
|
|
57
58
|
| `setup_project_paths_constants.py` | Encoding policy, BOM marker, and registry meta-key used across multiple hooks |
|
|
@@ -61,6 +62,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
61
62
|
| `stuttering_import_binding_constants.py` | Import-binding patterns for the stuttering check |
|
|
62
63
|
| `subprocess_budget_completeness_constants.py` | Required argument names for the subprocess-budget completeness check |
|
|
63
64
|
| `sys_path_insert_constants.py` | Patterns for detecting unguarded `sys.path.insert` calls |
|
|
65
|
+
| `task_list_loop_starter_constants.py` | The one-line task-list instruction and the full session-start directive text for the task-list loop starter hook |
|
|
64
66
|
| `text_stripping.py` | `strip_code_and_quotes()` — shared helper that removes fenced code blocks, inline code, and blockquotes from prose, imported by the Stop-hook prose blockers |
|
|
65
67
|
| `unused_module_import_constants.py` | Patterns for detecting unused module-level imports |
|
|
66
68
|
| `windows_rmtree_blocker_constants.py` | The unsafe `shutil.rmtree` pattern and the safe replacement pattern |
|