claude-dev-env 8.10.0 → 8.11.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_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
- package/bin/codex-skill-load-block.mjs +45 -0
- package/bin/codex-skill-load-block.test.mjs +72 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +53 -31
- package/bin/install.test.mjs +37 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -1,1506 +0,0 @@
|
|
|
1
|
-
"""Tests for tdd-enforcer hook (blocking behavior).
|
|
2
|
-
|
|
3
|
-
Includes the recorded-test-failure evidence path: a genuinely recorded
|
|
4
|
-
failure unlocks a production write, and never unlocks it for a test file
|
|
5
|
-
the failing command did not name.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import importlib.util
|
|
9
|
-
import io
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
import time
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
import pytest
|
|
18
|
-
from tdd_enforcer_parts import content_hash_store
|
|
19
|
-
|
|
20
|
-
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
21
|
-
|
|
22
|
-
SCRIPT_PATH = Path(__file__).parent / "tdd_enforcer.py"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _load_production_module():
|
|
26
|
-
module_spec = importlib.util.spec_from_file_location("tdd_enforcer_under_test", SCRIPT_PATH)
|
|
27
|
-
assert module_spec is not None and module_spec.loader is not None
|
|
28
|
-
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
29
|
-
module_spec.loader.exec_module(loaded_module)
|
|
30
|
-
return loaded_module
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
_PRODUCTION_MODULE = _load_production_module()
|
|
34
|
-
FRESHNESS_SECONDS = _PRODUCTION_MODULE._freshness_seconds()
|
|
35
|
-
STALE_MTIME_OFFSET_SECONDS = FRESHNESS_SECONDS + 60
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _run_hook_with_payload(
|
|
39
|
-
payload: dict,
|
|
40
|
-
extra_env: dict[str, str] | None = None,
|
|
41
|
-
) -> subprocess.CompletedProcess[str]:
|
|
42
|
-
subprocess_env = {**os.environ, **(extra_env or {})}
|
|
43
|
-
return subprocess.run(
|
|
44
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
45
|
-
input=json.dumps(payload),
|
|
46
|
-
text=True,
|
|
47
|
-
capture_output=True,
|
|
48
|
-
check=False,
|
|
49
|
-
env=subprocess_env,
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _make_write_payload(file_path: Path, content: str = "") -> dict:
|
|
54
|
-
return {
|
|
55
|
-
"tool_name": "Write",
|
|
56
|
-
"tool_input": {"file_path": str(file_path), "content": content},
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _make_apply_patch_payload(working_directory: Path, patch_command: str) -> dict:
|
|
61
|
-
return {
|
|
62
|
-
"tool_name": "apply_patch",
|
|
63
|
-
"cwd": str(working_directory),
|
|
64
|
-
"tool_input": {"command": patch_command},
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
69
|
-
if not completed.stdout:
|
|
70
|
-
return None
|
|
71
|
-
parsed = json.loads(completed.stdout)
|
|
72
|
-
hook_output = parsed.get("hookSpecificOutput", {})
|
|
73
|
-
return hook_output.get("permissionDecision")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _sandbox(tmp_path: Path) -> Path:
|
|
77
|
-
isolated_root = tmp_path / "sandbox"
|
|
78
|
-
isolated_root.mkdir()
|
|
79
|
-
(isolated_root / ".git").mkdir()
|
|
80
|
-
return isolated_root
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_should_allow_when_sibling_test_file_exists_and_recent(tmp_path: Path) -> None:
|
|
84
|
-
sandbox = _sandbox(tmp_path)
|
|
85
|
-
production_file = sandbox / "orders.py"
|
|
86
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
87
|
-
sibling_test = sandbox / "test_orders.py"
|
|
88
|
-
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
89
|
-
|
|
90
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
91
|
-
|
|
92
|
-
assert _decision_from(completed) == "allow"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def test_should_deny_apply_patch_add_with_no_fresh_test(tmp_path: Path) -> None:
|
|
96
|
-
sandbox = _sandbox(tmp_path)
|
|
97
|
-
patch_command = (
|
|
98
|
-
"*** Begin Patch\n"
|
|
99
|
-
"*** Add File: orders.py\n"
|
|
100
|
-
"+def fulfill(): pass\n"
|
|
101
|
-
"*** End Patch"
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
completed = _run_hook_with_payload(
|
|
105
|
-
_make_apply_patch_payload(sandbox, patch_command),
|
|
106
|
-
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
assert _decision_from(completed) == "deny"
|
|
110
|
-
parsed = json.loads(completed.stdout)
|
|
111
|
-
reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
|
|
112
|
-
assert "test_orders.py" in reason
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_should_allow_apply_patch_update_with_fresh_sibling_test(tmp_path: Path) -> None:
|
|
116
|
-
sandbox = _sandbox(tmp_path)
|
|
117
|
-
production_file = sandbox / "orders.py"
|
|
118
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
119
|
-
sibling_test = sandbox / "test_orders.py"
|
|
120
|
-
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
121
|
-
patch_command = (
|
|
122
|
-
"*** Begin Patch\n"
|
|
123
|
-
"*** Update File: orders.py\n"
|
|
124
|
-
"@@\n"
|
|
125
|
-
"-def fulfill(): pass\n"
|
|
126
|
-
"+def fulfill(): return True\n"
|
|
127
|
-
"*** End Patch"
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
completed = _run_hook_with_payload(
|
|
131
|
-
_make_apply_patch_payload(sandbox, patch_command),
|
|
132
|
-
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
assert _decision_from(completed) == "allow"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def test_should_deny_when_no_test_file_exists(tmp_path: Path) -> None:
|
|
139
|
-
sandbox = _sandbox(tmp_path)
|
|
140
|
-
production_file = sandbox / "orders.py"
|
|
141
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
142
|
-
|
|
143
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
144
|
-
|
|
145
|
-
assert _decision_from(completed) == "deny"
|
|
146
|
-
parsed = json.loads(completed.stdout)
|
|
147
|
-
reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
|
|
148
|
-
assert "test_orders.py" in reason
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def test_should_deny_when_test_file_exists_but_is_stale(tmp_path: Path) -> None:
|
|
152
|
-
sandbox = _sandbox(tmp_path)
|
|
153
|
-
production_file = sandbox / "orders.py"
|
|
154
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
155
|
-
sibling_test = sandbox / "test_orders.py"
|
|
156
|
-
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
157
|
-
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
158
|
-
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
159
|
-
|
|
160
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
161
|
-
|
|
162
|
-
assert _decision_from(completed) == "deny"
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def test_should_deny_when_pragma_no_tdd_gate_sentinel_is_present_without_test(tmp_path: Path) -> None:
|
|
166
|
-
sandbox = _sandbox(tmp_path)
|
|
167
|
-
production_file = sandbox / "orders.py"
|
|
168
|
-
content_with_sentinel = "# pragma: no-tdd-gate\ndef fulfill(): pass\n"
|
|
169
|
-
|
|
170
|
-
completed = _run_hook_with_payload(
|
|
171
|
-
_make_write_payload(production_file, content_with_sentinel)
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
assert _decision_from(completed) == "deny"
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def test_should_skip_markdown_files_entirely(tmp_path: Path) -> None:
|
|
178
|
-
markdown_file = tmp_path / "notes.md"
|
|
179
|
-
|
|
180
|
-
completed = _run_hook_with_payload(_make_write_payload(markdown_file, "# Notes\n"))
|
|
181
|
-
|
|
182
|
-
assert completed.returncode == 0
|
|
183
|
-
assert completed.stdout.strip() == ""
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def test_should_skip_test_files_entirely(tmp_path: Path) -> None:
|
|
187
|
-
test_file = tmp_path / "test_orders.py"
|
|
188
|
-
|
|
189
|
-
completed = _run_hook_with_payload(
|
|
190
|
-
_make_write_payload(test_file, "def test_fulfill(): pass\n")
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
assert completed.returncode == 0
|
|
194
|
-
assert completed.stdout.strip() == ""
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def test_should_allow_when_tests_directory_sibling_has_fresh_test(
|
|
198
|
-
tmp_path: Path,
|
|
199
|
-
) -> None:
|
|
200
|
-
sandbox = _sandbox(tmp_path)
|
|
201
|
-
package_dir = sandbox / "source"
|
|
202
|
-
package_dir.mkdir()
|
|
203
|
-
tests_dir = sandbox / "tests"
|
|
204
|
-
tests_dir.mkdir()
|
|
205
|
-
production_file = package_dir / "orders.py"
|
|
206
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
207
|
-
matching_test = tests_dir / "test_orders.py"
|
|
208
|
-
matching_test.write_text("def test_fulfill(): pass\n")
|
|
209
|
-
|
|
210
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
211
|
-
|
|
212
|
-
assert _decision_from(completed) == "allow"
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def test_should_allow_tsx_when_dot_test_sibling_exists(tmp_path: Path) -> None:
|
|
216
|
-
sandbox = _sandbox(tmp_path)
|
|
217
|
-
production_file = sandbox / "Button.tsx"
|
|
218
|
-
production_file.write_text("export const Button = () => null;\n")
|
|
219
|
-
sibling_test = sandbox / "Button.test.tsx"
|
|
220
|
-
sibling_test.write_text("test('renders', () => {});\n")
|
|
221
|
-
|
|
222
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
223
|
-
|
|
224
|
-
assert _decision_from(completed) == "allow"
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def test_should_deny_when_test_file_has_no_test_evidence(tmp_path: Path) -> None:
|
|
228
|
-
sandbox = _sandbox(tmp_path)
|
|
229
|
-
production_file = sandbox / "orders.py"
|
|
230
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
231
|
-
sibling_test = sandbox / "test_orders.py"
|
|
232
|
-
sibling_test.write_text("x = 1\n")
|
|
233
|
-
|
|
234
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
235
|
-
|
|
236
|
-
assert _decision_from(completed) == "deny"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def test_should_deny_edit_when_pragma_sentinel_present_in_new_string_without_test(
|
|
240
|
-
tmp_path: Path,
|
|
241
|
-
) -> None:
|
|
242
|
-
sandbox = _sandbox(tmp_path)
|
|
243
|
-
production_file = sandbox / "orders.py"
|
|
244
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
245
|
-
payload = {
|
|
246
|
-
"tool_name": "Edit",
|
|
247
|
-
"tool_input": {
|
|
248
|
-
"file_path": str(production_file),
|
|
249
|
-
"old_string": "pass",
|
|
250
|
-
"new_string": "# pragma: no-tdd-gate\npass",
|
|
251
|
-
},
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
completed = _run_hook_with_payload(payload)
|
|
255
|
-
|
|
256
|
-
assert _decision_from(completed) == "deny"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def _make_edit_payload(file_path: Path, old_string: str, new_string: str) -> dict:
|
|
260
|
-
return {
|
|
261
|
-
"tool_name": "Edit",
|
|
262
|
-
"tool_input": {
|
|
263
|
-
"file_path": str(file_path),
|
|
264
|
-
"old_string": old_string,
|
|
265
|
-
"new_string": new_string,
|
|
266
|
-
},
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def test_should_allow_python_file_with_only_module_level_constants(tmp_path: Path) -> None:
|
|
271
|
-
sandbox = _sandbox(tmp_path)
|
|
272
|
-
constants_file = sandbox / "constants.py"
|
|
273
|
-
constants_only_content = (
|
|
274
|
-
'"""Module-level constants for the widget subsystem."""\n'
|
|
275
|
-
"import re\n"
|
|
276
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
277
|
-
"DEFAULT_TIMEOUT_SECONDS: float = 30.0\n"
|
|
278
|
-
'BANNED_WORDS: tuple[str, ...] = ("foo", "bar")\n'
|
|
279
|
-
)
|
|
280
|
-
constants_file.write_text(constants_only_content)
|
|
281
|
-
|
|
282
|
-
completed = _run_hook_with_payload(
|
|
283
|
-
_make_write_payload(constants_file, constants_only_content)
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
assert _decision_from(completed) == "allow"
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def test_should_allow_edit_to_change_constant_value_in_constants_only_file(
|
|
290
|
-
tmp_path: Path,
|
|
291
|
-
) -> None:
|
|
292
|
-
sandbox = _sandbox(tmp_path)
|
|
293
|
-
constants_file = sandbox / "constants.py"
|
|
294
|
-
constants_file.write_text(
|
|
295
|
-
'"""Module-level constants."""\n'
|
|
296
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
297
|
-
"DEFAULT_TIMEOUT_SECONDS: float = 30.0\n"
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
completed = _run_hook_with_payload(
|
|
301
|
-
_make_edit_payload(
|
|
302
|
-
constants_file,
|
|
303
|
-
old_string="MAXIMUM_RETRIES: int = 3",
|
|
304
|
-
new_string="MAXIMUM_RETRIES: int = 5",
|
|
305
|
-
)
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
assert _decision_from(completed) == "allow"
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _make_multiedit_payload(file_path: Path, edits: list[dict]) -> dict:
|
|
312
|
-
return {
|
|
313
|
-
"tool_name": "MultiEdit",
|
|
314
|
-
"tool_input": {
|
|
315
|
-
"file_path": str(file_path),
|
|
316
|
-
"edits": edits,
|
|
317
|
-
},
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def test_should_allow_multiedit_to_change_constant_value_in_constants_only_file(
|
|
322
|
-
tmp_path: Path,
|
|
323
|
-
) -> None:
|
|
324
|
-
sandbox = _sandbox(tmp_path)
|
|
325
|
-
constants_file = sandbox / "constants.py"
|
|
326
|
-
constants_file.write_text(
|
|
327
|
-
'"""Module-level constants."""\n'
|
|
328
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
329
|
-
"DEFAULT_TIMEOUT_SECONDS: float = 30.0\n"
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
completed = _run_hook_with_payload(
|
|
333
|
-
_make_multiedit_payload(
|
|
334
|
-
constants_file,
|
|
335
|
-
edits=[
|
|
336
|
-
{
|
|
337
|
-
"old_string": "MAXIMUM_RETRIES: int = 3",
|
|
338
|
-
"new_string": "MAXIMUM_RETRIES: int = 5",
|
|
339
|
-
},
|
|
340
|
-
],
|
|
341
|
-
)
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
assert _decision_from(completed) == "allow"
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def test_should_deny_multiedit_that_adds_function_to_constants_only_file(
|
|
348
|
-
tmp_path: Path,
|
|
349
|
-
) -> None:
|
|
350
|
-
sandbox = _sandbox(tmp_path)
|
|
351
|
-
constants_file = sandbox / "constants.py"
|
|
352
|
-
constants_file.write_text(
|
|
353
|
-
'"""Module-level constants."""\n'
|
|
354
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
completed = _run_hook_with_payload(
|
|
358
|
-
_make_multiedit_payload(
|
|
359
|
-
constants_file,
|
|
360
|
-
edits=[
|
|
361
|
-
{
|
|
362
|
-
"old_string": "MAXIMUM_RETRIES: int = 3",
|
|
363
|
-
"new_string": "MAXIMUM_RETRIES: int = 3\n\ndef reset() -> None:\n return None",
|
|
364
|
-
},
|
|
365
|
-
],
|
|
366
|
-
)
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
assert _decision_from(completed) == "deny"
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def test_should_deny_edit_that_adds_function_to_constants_only_file(
|
|
373
|
-
tmp_path: Path,
|
|
374
|
-
) -> None:
|
|
375
|
-
sandbox = _sandbox(tmp_path)
|
|
376
|
-
constants_file = sandbox / "constants.py"
|
|
377
|
-
constants_file.write_text(
|
|
378
|
-
'"""Module-level constants."""\n'
|
|
379
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
completed = _run_hook_with_payload(
|
|
383
|
-
_make_edit_payload(
|
|
384
|
-
constants_file,
|
|
385
|
-
old_string="MAXIMUM_RETRIES: int = 3",
|
|
386
|
-
new_string="MAXIMUM_RETRIES: int = 3\n\ndef reset() -> None:\n return None",
|
|
387
|
-
)
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
assert _decision_from(completed) == "deny"
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def test_should_deny_python_file_with_assignment_calling_undefined_function(
|
|
394
|
-
tmp_path: Path,
|
|
395
|
-
) -> None:
|
|
396
|
-
sandbox = _sandbox(tmp_path)
|
|
397
|
-
unsafe_file = sandbox / "unsafe.py"
|
|
398
|
-
unsafe_content = (
|
|
399
|
-
'"""Config with unsafe call."""\n'
|
|
400
|
-
"VALUE: str = compute()\n"
|
|
401
|
-
)
|
|
402
|
-
unsafe_file.write_text(unsafe_content)
|
|
403
|
-
|
|
404
|
-
completed = _run_hook_with_payload(
|
|
405
|
-
_make_write_payload(unsafe_file, unsafe_content)
|
|
406
|
-
)
|
|
407
|
-
|
|
408
|
-
assert _decision_from(completed) == "deny"
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def test_should_allow_python_file_with_assignment_calling_imported_function(
|
|
412
|
-
tmp_path: Path,
|
|
413
|
-
) -> None:
|
|
414
|
-
sandbox = _sandbox(tmp_path)
|
|
415
|
-
safe_file = sandbox / "safe.py"
|
|
416
|
-
safe_content = (
|
|
417
|
-
'"""Config with imported call."""\n'
|
|
418
|
-
"from pathlib import Path\n"
|
|
419
|
-
"BASE_PATH = Path(r'C:\\\\data')\n"
|
|
420
|
-
)
|
|
421
|
-
safe_file.write_text(safe_content)
|
|
422
|
-
|
|
423
|
-
completed = _run_hook_with_payload(
|
|
424
|
-
_make_write_payload(safe_file, safe_content)
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
assert _decision_from(completed) == "allow"
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def test_should_deny_python_file_when_any_function_definition_is_present(tmp_path: Path) -> None:
|
|
431
|
-
sandbox = _sandbox(tmp_path)
|
|
432
|
-
mixed_file = sandbox / "mixed.py"
|
|
433
|
-
mixed_content = (
|
|
434
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
435
|
-
"def do_something() -> None:\n"
|
|
436
|
-
" return None\n"
|
|
437
|
-
)
|
|
438
|
-
mixed_file.write_text(mixed_content)
|
|
439
|
-
|
|
440
|
-
completed = _run_hook_with_payload(
|
|
441
|
-
_make_write_payload(mixed_file, mixed_content)
|
|
442
|
-
)
|
|
443
|
-
|
|
444
|
-
assert _decision_from(completed) == "deny"
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def test_should_deny_python_file_when_any_class_definition_is_present(tmp_path: Path) -> None:
|
|
448
|
-
sandbox = _sandbox(tmp_path)
|
|
449
|
-
class_file = sandbox / "klass.py"
|
|
450
|
-
class_content = (
|
|
451
|
-
"class Widget:\n"
|
|
452
|
-
" size: int = 3\n"
|
|
453
|
-
)
|
|
454
|
-
class_file.write_text(class_content)
|
|
455
|
-
|
|
456
|
-
completed = _run_hook_with_payload(
|
|
457
|
-
_make_write_payload(class_file, class_content)
|
|
458
|
-
)
|
|
459
|
-
|
|
460
|
-
assert _decision_from(completed) == "deny"
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def test_deny_response_places_system_message_and_suppress_output_at_top_level(
|
|
464
|
-
tmp_path: Path,
|
|
465
|
-
) -> None:
|
|
466
|
-
sandbox = _sandbox(tmp_path)
|
|
467
|
-
production_file = sandbox / "orders.py"
|
|
468
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
469
|
-
|
|
470
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
471
|
-
|
|
472
|
-
parsed = json.loads(completed.stdout)
|
|
473
|
-
hook_output = parsed["hookSpecificOutput"]
|
|
474
|
-
assert hook_output["permissionDecision"] == "deny"
|
|
475
|
-
assert parsed.get("suppressOutput") is True
|
|
476
|
-
assert isinstance(parsed.get("systemMessage"), str)
|
|
477
|
-
assert parsed["systemMessage"]
|
|
478
|
-
assert "suppressOutput" not in hook_output
|
|
479
|
-
assert "systemMessage" not in hook_output
|
|
480
|
-
verbose_reason = hook_output["permissionDecisionReason"]
|
|
481
|
-
assert "propose" in verbose_reason.lower() or "enhancement" in verbose_reason.lower()
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def test_should_deny_python_file_that_calls_function_at_module_level(tmp_path: Path) -> None:
|
|
485
|
-
sandbox = _sandbox(tmp_path)
|
|
486
|
-
side_effect_file = sandbox / "side_effects.py"
|
|
487
|
-
side_effect_content = (
|
|
488
|
-
"import sys\n"
|
|
489
|
-
"sys.exit(1)\n"
|
|
490
|
-
)
|
|
491
|
-
side_effect_file.write_text(side_effect_content)
|
|
492
|
-
|
|
493
|
-
completed = _run_hook_with_payload(
|
|
494
|
-
_make_write_payload(side_effect_file, side_effect_content)
|
|
495
|
-
)
|
|
496
|
-
|
|
497
|
-
assert _decision_from(completed) == "deny"
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
def test_should_allow_python_file_with_module_docstring_plus_constants(tmp_path: Path) -> None:
|
|
501
|
-
sandbox = _sandbox(tmp_path)
|
|
502
|
-
constants_file = sandbox / "constants.py"
|
|
503
|
-
constants_content = (
|
|
504
|
-
'"""Module-level constants for the widget subsystem."""\n'
|
|
505
|
-
"import re\n"
|
|
506
|
-
"MAXIMUM_RETRIES: int = 3\n"
|
|
507
|
-
)
|
|
508
|
-
constants_file.write_text(constants_content)
|
|
509
|
-
|
|
510
|
-
completed = _run_hook_with_payload(
|
|
511
|
-
_make_write_payload(constants_file, constants_content)
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
assert _decision_from(completed) == "allow"
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def test_should_deny_python_file_that_mutates_module_state_via_aug_assign(tmp_path: Path) -> None:
|
|
518
|
-
sandbox = _sandbox(tmp_path)
|
|
519
|
-
mutation_file = sandbox / "mutation.py"
|
|
520
|
-
mutation_content = (
|
|
521
|
-
"COUNTER: int = 0\n"
|
|
522
|
-
"COUNTER += 1\n"
|
|
523
|
-
)
|
|
524
|
-
mutation_file.write_text(mutation_content)
|
|
525
|
-
|
|
526
|
-
completed = _run_hook_with_payload(
|
|
527
|
-
_make_write_payload(mutation_file, mutation_content)
|
|
528
|
-
)
|
|
529
|
-
|
|
530
|
-
assert _decision_from(completed) == "deny"
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
def test_should_deny_production_file_inside_directory_containing_skip_substring(
|
|
534
|
-
tmp_path: Path,
|
|
535
|
-
) -> None:
|
|
536
|
-
sandbox = _sandbox(tmp_path)
|
|
537
|
-
mockingbird_directory = sandbox / "mockingbird"
|
|
538
|
-
mockingbird_directory.mkdir()
|
|
539
|
-
production_file = mockingbird_directory / "orders.py"
|
|
540
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
541
|
-
|
|
542
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
543
|
-
|
|
544
|
-
assert _decision_from(completed) == "deny"
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def test_should_deny_when_test_file_contains_only_production_stem_without_test_function(
|
|
548
|
-
tmp_path: Path,
|
|
549
|
-
) -> None:
|
|
550
|
-
sandbox = _sandbox(tmp_path)
|
|
551
|
-
production_file = sandbox / "orders.py"
|
|
552
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
553
|
-
sibling_test = sandbox / "test_orders.py"
|
|
554
|
-
sibling_test.write_text("orders = 'mentioned but no test function'\n")
|
|
555
|
-
|
|
556
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
557
|
-
|
|
558
|
-
assert _decision_from(completed) == "deny"
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
def test_should_allow_when_large_test_file_has_valid_test_function_past_first_chunk(
|
|
562
|
-
tmp_path: Path,
|
|
563
|
-
) -> None:
|
|
564
|
-
sandbox = _sandbox(tmp_path)
|
|
565
|
-
production_file = sandbox / "orders.py"
|
|
566
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
567
|
-
sibling_test = sandbox / "test_orders.py"
|
|
568
|
-
padding_byte_count = 250_000
|
|
569
|
-
padding_comment = "# " + ("x" * padding_byte_count) + "\n"
|
|
570
|
-
sibling_test.write_text(padding_comment + "def test_fulfill(): pass\n")
|
|
571
|
-
|
|
572
|
-
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
573
|
-
|
|
574
|
-
assert _decision_from(completed) == "allow"
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
def test_directory_skip_components_exactly_matches_pre_fc61a8b_hardcoded_set() -> None:
|
|
578
|
-
expected_directory_skip_components = frozenset({
|
|
579
|
-
"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
|
|
580
|
-
})
|
|
581
|
-
|
|
582
|
-
actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
|
|
583
|
-
|
|
584
|
-
assert actual_directory_skip_components == expected_directory_skip_components
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
def test_directory_skip_components_excludes_pluralized_conftest() -> None:
|
|
588
|
-
actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
|
|
589
|
-
|
|
590
|
-
assert "conftests" not in actual_directory_skip_components
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
def test_should_skip_silently_when_posix_path_has_dotclaude_segment(tmp_path: Path) -> None:
|
|
594
|
-
dotclaude_production_file = tmp_path / ".claude" / "agents" / "reviewer.py"
|
|
595
|
-
|
|
596
|
-
completed = _run_hook_with_payload(
|
|
597
|
-
_make_write_payload(dotclaude_production_file, "def review(): pass\n")
|
|
598
|
-
)
|
|
599
|
-
|
|
600
|
-
assert completed.returncode == 0
|
|
601
|
-
assert completed.stdout.strip() == ""
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
def test_should_skip_silently_when_windows_backslash_path_has_dotclaude_segment() -> None:
|
|
605
|
-
windows_style_path = "C:\\Users\\dev\\.claude\\agents\\reviewer.py"
|
|
606
|
-
|
|
607
|
-
completed = _run_hook_with_payload({
|
|
608
|
-
"tool_name": "Write",
|
|
609
|
-
"tool_input": {"file_path": windows_style_path, "content": "def review(): pass\n"},
|
|
610
|
-
})
|
|
611
|
-
|
|
612
|
-
assert completed.returncode == 0
|
|
613
|
-
assert completed.stdout.strip() == ""
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
def test_should_skip_silently_when_mixed_separator_path_has_dotclaude_segment() -> None:
|
|
617
|
-
mixed_separator_path = "C:/Users/dev\\.claude/agents\\reviewer.py"
|
|
618
|
-
|
|
619
|
-
completed = _run_hook_with_payload({
|
|
620
|
-
"tool_name": "Write",
|
|
621
|
-
"tool_input": {"file_path": mixed_separator_path, "content": "def review(): pass\n"},
|
|
622
|
-
})
|
|
623
|
-
|
|
624
|
-
assert completed.returncode == 0
|
|
625
|
-
assert completed.stdout.strip() == ""
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
def test_should_not_skip_when_dotclaude_is_only_a_filename_substring(tmp_path: Path) -> None:
|
|
629
|
-
sandbox = _sandbox(tmp_path)
|
|
630
|
-
substring_production_file = sandbox / "my.claude.helpers.py"
|
|
631
|
-
substring_production_file.write_text("def help(): pass\n")
|
|
632
|
-
|
|
633
|
-
completed = _run_hook_with_payload(_make_write_payload(substring_production_file))
|
|
634
|
-
|
|
635
|
-
assert _decision_from(completed) == "deny"
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
def test_is_inside_dotclaude_segment_helper_matches_only_exact_segments() -> None:
|
|
639
|
-
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/home/user/.claude/agent.py") is True
|
|
640
|
-
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("C:\\Users\\dev\\.claude\\agent.py") is True
|
|
641
|
-
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/my.claude.helpers.py") is False
|
|
642
|
-
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/app/service.py") is False
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
def test_should_offer_split_family_test_files_as_candidates_for_code_rules_module(
|
|
646
|
-
tmp_path: Path,
|
|
647
|
-
) -> None:
|
|
648
|
-
sandbox = _sandbox(tmp_path)
|
|
649
|
-
production_module = sandbox / "code_rules_magic_values.py"
|
|
650
|
-
string_magic_family_test = sandbox / "test_code_rules_enforcer_split_string_magic.py"
|
|
651
|
-
string_magic_family_test.write_text("def test_string_magic(): pass\n")
|
|
652
|
-
banned_family_test = sandbox / "test_code_rules_enforcer_split_banned.py"
|
|
653
|
-
banned_family_test.write_text("def test_banned(): pass\n")
|
|
654
|
-
|
|
655
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
656
|
-
|
|
657
|
-
assert string_magic_family_test in all_candidates
|
|
658
|
-
assert banned_family_test in all_candidates
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
def test_should_keep_plain_stem_candidates_first_for_code_rules_module(
|
|
662
|
-
tmp_path: Path,
|
|
663
|
-
) -> None:
|
|
664
|
-
sandbox = _sandbox(tmp_path)
|
|
665
|
-
production_module = sandbox / "code_rules_magic_values.py"
|
|
666
|
-
family_test = sandbox / "test_code_rules_enforcer_split_string_magic.py"
|
|
667
|
-
family_test.write_text("def test_string_magic(): pass\n")
|
|
668
|
-
|
|
669
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
670
|
-
|
|
671
|
-
assert all_candidates[0] == sandbox / "test_code_rules_magic_values.py"
|
|
672
|
-
assert all_candidates[1] == sandbox / "code_rules_magic_values_test.py"
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def test_should_not_offer_split_family_candidates_for_non_code_rules_module(
|
|
676
|
-
tmp_path: Path,
|
|
677
|
-
) -> None:
|
|
678
|
-
sandbox = _sandbox(tmp_path)
|
|
679
|
-
production_module = sandbox / "orders.py"
|
|
680
|
-
family_test = sandbox / "test_code_rules_enforcer_split_string_magic.py"
|
|
681
|
-
family_test.write_text("def test_string_magic(): pass\n")
|
|
682
|
-
|
|
683
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
684
|
-
|
|
685
|
-
assert family_test not in all_candidates
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
def test_should_add_no_split_family_candidates_when_directory_has_none(
|
|
689
|
-
tmp_path: Path,
|
|
690
|
-
) -> None:
|
|
691
|
-
sandbox = _sandbox(tmp_path)
|
|
692
|
-
production_module = sandbox / "code_rules_magic_values.py"
|
|
693
|
-
|
|
694
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
695
|
-
|
|
696
|
-
expected_stem_candidates = [
|
|
697
|
-
sandbox / "test_code_rules_magic_values.py",
|
|
698
|
-
sandbox / "code_rules_magic_values_test.py",
|
|
699
|
-
]
|
|
700
|
-
assert all_candidates == expected_stem_candidates
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
def test_should_not_offer_family_candidates_for_code_ruleset_stem(
|
|
704
|
-
tmp_path: Path,
|
|
705
|
-
) -> None:
|
|
706
|
-
sandbox = _sandbox(tmp_path)
|
|
707
|
-
production_module = sandbox / "code_ruleset.py"
|
|
708
|
-
family_test = sandbox / "test_code_rules_enforcer_split_example.py"
|
|
709
|
-
family_test.write_text("def test_detects_example(): pass\n")
|
|
710
|
-
|
|
711
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
712
|
-
|
|
713
|
-
expected_stem_candidates = [
|
|
714
|
-
sandbox / "test_code_ruleset.py",
|
|
715
|
-
sandbox / "code_ruleset_test.py",
|
|
716
|
-
]
|
|
717
|
-
assert all_candidates == expected_stem_candidates
|
|
718
|
-
assert family_test not in all_candidates
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
def test_should_allow_code_rules_edit_when_fresh_split_family_sibling_exists(
|
|
722
|
-
tmp_path: Path,
|
|
723
|
-
) -> None:
|
|
724
|
-
sandbox = _sandbox(tmp_path)
|
|
725
|
-
production_module = sandbox / "code_rules_example.py"
|
|
726
|
-
production_module.write_text("def detect() -> None:\n return None\n")
|
|
727
|
-
family_test = sandbox / "test_code_rules_enforcer_split_example_concern.py"
|
|
728
|
-
family_test.write_text("def test_detects_example(): pass\n")
|
|
729
|
-
|
|
730
|
-
payload = _make_edit_payload(
|
|
731
|
-
production_module,
|
|
732
|
-
old_string="return None",
|
|
733
|
-
new_string="return None # adjusted",
|
|
734
|
-
)
|
|
735
|
-
completed = _run_hook_with_payload(payload)
|
|
736
|
-
|
|
737
|
-
assert _decision_from(completed) == "allow"
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def test_should_deny_code_rules_edit_when_split_family_sibling_is_stale(
|
|
741
|
-
tmp_path: Path,
|
|
742
|
-
) -> None:
|
|
743
|
-
sandbox = _sandbox(tmp_path)
|
|
744
|
-
production_module = sandbox / "code_rules_example.py"
|
|
745
|
-
production_module.write_text("def detect() -> None:\n return None\n")
|
|
746
|
-
family_test = sandbox / "test_code_rules_enforcer_split_example_concern.py"
|
|
747
|
-
family_test.write_text("def test_detects_example(): pass\n")
|
|
748
|
-
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
749
|
-
os.utime(family_test, (stale_timestamp, stale_timestamp))
|
|
750
|
-
|
|
751
|
-
payload = _make_edit_payload(
|
|
752
|
-
production_module,
|
|
753
|
-
old_string="return None",
|
|
754
|
-
new_string="return None # adjusted",
|
|
755
|
-
)
|
|
756
|
-
completed = _run_hook_with_payload(payload)
|
|
757
|
-
|
|
758
|
-
assert _decision_from(completed) == "deny"
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def test_should_offer_nested_package_mirroring_candidate_for_subpackage_module(
|
|
762
|
-
tmp_path: Path,
|
|
763
|
-
) -> None:
|
|
764
|
-
sandbox = _sandbox(tmp_path)
|
|
765
|
-
package_root = sandbox / "pkg"
|
|
766
|
-
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
767
|
-
subpackage_directory.mkdir(parents=True)
|
|
768
|
-
(package_root / "tests").mkdir()
|
|
769
|
-
production_module = subpackage_directory / "tremor.py"
|
|
770
|
-
|
|
771
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
772
|
-
|
|
773
|
-
assert package_root / "tests" / "services" / "test_tremor.py" in all_candidates
|
|
774
|
-
assert (
|
|
775
|
-
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
776
|
-
in all_candidates
|
|
777
|
-
)
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
def test_should_offer_flat_candidate_alongside_nested_candidates(
|
|
781
|
-
tmp_path: Path,
|
|
782
|
-
) -> None:
|
|
783
|
-
sandbox = _sandbox(tmp_path)
|
|
784
|
-
package_root = sandbox / "pkg"
|
|
785
|
-
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
786
|
-
subpackage_directory.mkdir(parents=True)
|
|
787
|
-
(package_root / "tests").mkdir()
|
|
788
|
-
production_module = subpackage_directory / "tremor.py"
|
|
789
|
-
|
|
790
|
-
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
791
|
-
|
|
792
|
-
assert package_root / "tests" / "test_tremor.py" in all_candidates
|
|
793
|
-
assert (
|
|
794
|
-
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
795
|
-
in all_candidates
|
|
796
|
-
)
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
def test_should_allow_write_when_nested_package_mirroring_test_is_fresh(
|
|
800
|
-
tmp_path: Path,
|
|
801
|
-
) -> None:
|
|
802
|
-
sandbox = _sandbox(tmp_path)
|
|
803
|
-
package_root = sandbox / "pkg"
|
|
804
|
-
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
805
|
-
subpackage_directory.mkdir(parents=True)
|
|
806
|
-
nested_tests_directory = package_root / "tests" / "services" / "mouse_movement"
|
|
807
|
-
nested_tests_directory.mkdir(parents=True)
|
|
808
|
-
production_module = subpackage_directory / "tremor.py"
|
|
809
|
-
production_module.write_text("def jitter(): pass\n")
|
|
810
|
-
nested_test = nested_tests_directory / "test_tremor.py"
|
|
811
|
-
nested_test.write_text("def test_jitter(): pass\n")
|
|
812
|
-
|
|
813
|
-
completed = _run_hook_with_payload(_make_write_payload(production_module))
|
|
814
|
-
|
|
815
|
-
assert _decision_from(completed) == "allow"
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
def test_should_collect_tests_directories_from_every_ancestor_up_to_repo_boundary(
|
|
819
|
-
tmp_path: Path,
|
|
820
|
-
) -> None:
|
|
821
|
-
sandbox = _sandbox(tmp_path)
|
|
822
|
-
package_root = sandbox / "pkg"
|
|
823
|
-
subpackage_directory = package_root / "services"
|
|
824
|
-
subpackage_directory.mkdir(parents=True)
|
|
825
|
-
(package_root / "tests").mkdir()
|
|
826
|
-
(subpackage_directory / "tests").mkdir()
|
|
827
|
-
|
|
828
|
-
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(subpackage_directory)
|
|
829
|
-
|
|
830
|
-
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
831
|
-
assert subpackage_directory / "tests" in collected_tests_directories
|
|
832
|
-
assert package_root / "tests" in collected_tests_directories
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
def test_ancestor_tests_walk_stops_at_repo_boundary(tmp_path: Path) -> None:
|
|
836
|
-
outer_tests_directory = tmp_path / "tests"
|
|
837
|
-
outer_tests_directory.mkdir()
|
|
838
|
-
sandbox = _sandbox(tmp_path)
|
|
839
|
-
package_directory = sandbox / "pkg"
|
|
840
|
-
package_directory.mkdir()
|
|
841
|
-
|
|
842
|
-
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(package_directory)
|
|
843
|
-
|
|
844
|
-
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
845
|
-
assert outer_tests_directory not in collected_tests_directories
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
def test_ancestor_tests_walk_honors_parent_walk_limit(tmp_path: Path) -> None:
|
|
849
|
-
walk_limit = _PRODUCTION_MODULE._parent_walk_limit()
|
|
850
|
-
deep_directory = tmp_path
|
|
851
|
-
for each_level_index in range(walk_limit + 2):
|
|
852
|
-
deep_directory = deep_directory / f"level_{each_level_index}"
|
|
853
|
-
deep_directory.mkdir(parents=True)
|
|
854
|
-
top_tests_directory = tmp_path / "tests"
|
|
855
|
-
top_tests_directory.mkdir()
|
|
856
|
-
|
|
857
|
-
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(deep_directory)
|
|
858
|
-
|
|
859
|
-
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
860
|
-
assert top_tests_directory not in collected_tests_directories
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
def test_should_deny_edit_that_swaps_an_import_target(tmp_path: Path) -> None:
|
|
864
|
-
sandbox = _sandbox(tmp_path)
|
|
865
|
-
production_module = sandbox / "orders.py"
|
|
866
|
-
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
867
|
-
|
|
868
|
-
completed = _run_hook_with_payload(
|
|
869
|
-
_make_edit_payload(
|
|
870
|
-
production_module,
|
|
871
|
-
old_string="import os",
|
|
872
|
-
new_string="import sys",
|
|
873
|
-
)
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
assert _decision_from(completed) == "deny"
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
def _run_hook_with_payload_and_env(
|
|
880
|
-
payload: dict,
|
|
881
|
-
extra_env: dict[str, str],
|
|
882
|
-
) -> subprocess.CompletedProcess[str]:
|
|
883
|
-
return subprocess.run(
|
|
884
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
885
|
-
input=json.dumps(payload),
|
|
886
|
-
text=True,
|
|
887
|
-
capture_output=True,
|
|
888
|
-
check=False,
|
|
889
|
-
env={**os.environ, **extra_env},
|
|
890
|
-
)
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
_BEHAVIOR_BEARING_CONTENT = "def fulfill_order(order: str) -> str:\n return order\n"
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
def test_should_exit_zero_for_ephemeral_scratch_python(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
897
|
-
"""B16: behavior-bearing scratch .py under root-anchored /tmp exits 0 with no deny."""
|
|
898
|
-
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
899
|
-
ephemeral_path = "/tmp/scratch_work.py"
|
|
900
|
-
payload = _make_write_payload(Path(ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
901
|
-
completed = _run_hook_with_payload(payload)
|
|
902
|
-
assert _decision_from(completed) != "deny", (
|
|
903
|
-
f"TDD enforcer must not deny ephemeral Python path, got: {completed.stdout!r}"
|
|
904
|
-
)
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
def test_should_exit_zero_for_ephemeral_scratch_typescript(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
908
|
-
"""B17: ephemeral .ts scratch file under root-anchored /tmp exits 0 with no deny."""
|
|
909
|
-
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
910
|
-
ephemeral_path = "/tmp/scratch_work.ts"
|
|
911
|
-
payload = {
|
|
912
|
-
"tool_name": "Write",
|
|
913
|
-
"tool_input": {
|
|
914
|
-
"file_path": ephemeral_path,
|
|
915
|
-
"content": "export function fulfillOrder(order: string): string { return order; }\n",
|
|
916
|
-
},
|
|
917
|
-
}
|
|
918
|
-
completed = _run_hook_with_payload(payload)
|
|
919
|
-
assert _decision_from(completed) != "deny", (
|
|
920
|
-
f"TDD enforcer must not deny ephemeral TypeScript path, got: {completed.stdout!r}"
|
|
921
|
-
)
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
def test_should_still_deny_non_ephemeral_python_without_test() -> None:
|
|
925
|
-
"""B18: a non-ephemeral .py file with no matching test still receives a deny."""
|
|
926
|
-
non_ephemeral_path = "/repo/src/orders.py"
|
|
927
|
-
payload = _make_write_payload(Path(non_ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
928
|
-
completed = _run_hook_with_payload(payload)
|
|
929
|
-
assert _decision_from(completed) == "deny", (
|
|
930
|
-
f"TDD enforcer must deny non-ephemeral production file, got: {completed.stdout!r}"
|
|
931
|
-
)
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
def test_should_deny_ephemeral_scratch_when_override_truthy() -> None:
|
|
935
|
-
"""B19: with override set, an ephemeral scratch .py with no test still receives a deny."""
|
|
936
|
-
ephemeral_path = "/tmp/scratch_work.py"
|
|
937
|
-
payload = _make_write_payload(Path(ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
938
|
-
completed = _run_hook_with_payload_and_env(
|
|
939
|
-
payload,
|
|
940
|
-
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
941
|
-
)
|
|
942
|
-
assert _decision_from(completed) == "deny", (
|
|
943
|
-
f"TDD enforcer must deny ephemeral path when override is set, got: {completed.stdout!r}"
|
|
944
|
-
)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
def test_should_exit_before_fresh_test_lookup_for_ephemeral(
|
|
948
|
-
tmp_path: Path,
|
|
949
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
950
|
-
) -> None:
|
|
951
|
-
"""B20: the ephemeral exit precedes the fresh-test candidate search.
|
|
952
|
-
|
|
953
|
-
Spies on candidate_test_paths_for and asserts it is never reached for a
|
|
954
|
-
scratch .py under $CLAUDE_JOB_DIR/tmp, proving the early exit short-circuits
|
|
955
|
-
before any test-file lookup runs.
|
|
956
|
-
"""
|
|
957
|
-
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
958
|
-
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
959
|
-
candidate_search_call_count = {"count": 0}
|
|
960
|
-
|
|
961
|
-
def _spy_candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
962
|
-
candidate_search_call_count["count"] += 1
|
|
963
|
-
return []
|
|
964
|
-
|
|
965
|
-
monkeypatch.setattr(
|
|
966
|
-
_PRODUCTION_MODULE, "candidate_test_paths_for", _spy_candidate_test_paths_for
|
|
967
|
-
)
|
|
968
|
-
scratch_path = str(tmp_path / "tmp" / "scratch_work.py")
|
|
969
|
-
payload = _make_write_payload(Path(scratch_path), _BEHAVIOR_BEARING_CONTENT)
|
|
970
|
-
monkeypatch.setattr(_PRODUCTION_MODULE.sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
971
|
-
with pytest.raises(SystemExit) as raised_exit:
|
|
972
|
-
_PRODUCTION_MODULE.main()
|
|
973
|
-
assert int(raised_exit.value.code or 0) == 0
|
|
974
|
-
assert candidate_search_call_count["count"] == 0, (
|
|
975
|
-
"candidate_test_paths_for must not run for an ephemeral scratch path"
|
|
976
|
-
)
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
def test_should_allow_edit_that_removes_an_import_statement(tmp_path: Path) -> None:
|
|
980
|
-
sandbox = _sandbox(tmp_path)
|
|
981
|
-
production_module = sandbox / "orders.py"
|
|
982
|
-
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
983
|
-
|
|
984
|
-
completed = _run_hook_with_payload(
|
|
985
|
-
_make_edit_payload(
|
|
986
|
-
production_module,
|
|
987
|
-
old_string="import os\n",
|
|
988
|
-
new_string="",
|
|
989
|
-
)
|
|
990
|
-
)
|
|
991
|
-
|
|
992
|
-
assert _decision_from(completed) == "allow"
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
def test_should_allow_multiedit_when_every_pair_removes_an_import(tmp_path: Path) -> None:
|
|
996
|
-
sandbox = _sandbox(tmp_path)
|
|
997
|
-
production_module = sandbox / "orders.py"
|
|
998
|
-
production_module.write_text("import os\nimport json\nimport time\n\ndef fulfill(): pass\n")
|
|
999
|
-
|
|
1000
|
-
completed = _run_hook_with_payload(
|
|
1001
|
-
_make_multiedit_payload(
|
|
1002
|
-
production_module,
|
|
1003
|
-
edits=[
|
|
1004
|
-
{"old_string": "import os\n", "new_string": ""},
|
|
1005
|
-
{"old_string": "import json\n", "new_string": ""},
|
|
1006
|
-
],
|
|
1007
|
-
)
|
|
1008
|
-
)
|
|
1009
|
-
|
|
1010
|
-
assert _decision_from(completed) == "allow"
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
def test_should_deny_multiedit_when_one_pair_is_not_import_only(tmp_path: Path) -> None:
|
|
1014
|
-
sandbox = _sandbox(tmp_path)
|
|
1015
|
-
production_module = sandbox / "orders.py"
|
|
1016
|
-
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1017
|
-
|
|
1018
|
-
completed = _run_hook_with_payload(
|
|
1019
|
-
_make_multiedit_payload(
|
|
1020
|
-
production_module,
|
|
1021
|
-
edits=[
|
|
1022
|
-
{"old_string": "import os", "new_string": "import sys"},
|
|
1023
|
-
{
|
|
1024
|
-
"old_string": "def fulfill(): pass",
|
|
1025
|
-
"new_string": "def fulfill(): return 1",
|
|
1026
|
-
},
|
|
1027
|
-
],
|
|
1028
|
-
)
|
|
1029
|
-
)
|
|
1030
|
-
|
|
1031
|
-
assert _decision_from(completed) == "deny"
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
def test_should_not_exempt_write_with_behavior_under_import_only_rule(
|
|
1035
|
-
tmp_path: Path,
|
|
1036
|
-
) -> None:
|
|
1037
|
-
sandbox = _sandbox(tmp_path)
|
|
1038
|
-
production_module = sandbox / "orders.py"
|
|
1039
|
-
write_content_with_behavior = "import os\n\ndef fulfill(): return os.getpid()\n"
|
|
1040
|
-
|
|
1041
|
-
completed = _run_hook_with_payload(
|
|
1042
|
-
_make_write_payload(production_module, write_content_with_behavior)
|
|
1043
|
-
)
|
|
1044
|
-
|
|
1045
|
-
assert _decision_from(completed) == "deny"
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
def test_should_deny_behavior_edit_without_a_fresh_candidate_test(tmp_path: Path) -> None:
|
|
1049
|
-
sandbox = _sandbox(tmp_path)
|
|
1050
|
-
production_module = sandbox / "orders.py"
|
|
1051
|
-
production_module.write_text("def fulfill(): pass\n")
|
|
1052
|
-
|
|
1053
|
-
completed = _run_hook_with_payload(
|
|
1054
|
-
_make_edit_payload(
|
|
1055
|
-
production_module,
|
|
1056
|
-
old_string="def fulfill(): pass",
|
|
1057
|
-
new_string="def fulfill(): return 1",
|
|
1058
|
-
)
|
|
1059
|
-
)
|
|
1060
|
-
|
|
1061
|
-
assert _decision_from(completed) == "deny"
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
def test_should_deny_edit_of_import_text_inside_a_string_literal(tmp_path: Path) -> None:
|
|
1065
|
-
sandbox = _sandbox(tmp_path)
|
|
1066
|
-
production_module = sandbox / "orders.py"
|
|
1067
|
-
production_module.write_text('banner = "import os is great"\n\ndef fulfill(): return banner\n')
|
|
1068
|
-
|
|
1069
|
-
completed = _run_hook_with_payload(
|
|
1070
|
-
_make_edit_payload(
|
|
1071
|
-
production_module,
|
|
1072
|
-
old_string="import os",
|
|
1073
|
-
new_string="import sys",
|
|
1074
|
-
)
|
|
1075
|
-
)
|
|
1076
|
-
|
|
1077
|
-
assert _decision_from(completed) == "deny"
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
def test_should_allow_import_reorder_when_old_string_carries_context_lines(
|
|
1081
|
-
tmp_path: Path,
|
|
1082
|
-
) -> None:
|
|
1083
|
-
sandbox = _sandbox(tmp_path)
|
|
1084
|
-
production_module = sandbox / "orders.py"
|
|
1085
|
-
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
1086
|
-
|
|
1087
|
-
completed = _run_hook_with_payload(
|
|
1088
|
-
_make_edit_payload(
|
|
1089
|
-
production_module,
|
|
1090
|
-
old_string="import os\nimport sys\n\ndef fulfill(): pass",
|
|
1091
|
-
new_string="import sys\nimport os\n\ndef fulfill(): pass",
|
|
1092
|
-
)
|
|
1093
|
-
)
|
|
1094
|
-
|
|
1095
|
-
assert _decision_from(completed) == "allow"
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
def test_should_allow_import_only_edit_after_a_constants_assignment(tmp_path: Path) -> None:
|
|
1099
|
-
sandbox = _sandbox(tmp_path)
|
|
1100
|
-
production_module = sandbox / "orders.py"
|
|
1101
|
-
production_module.write_text("import os\nimport sys\n\nMAX_ORDERS = 5\n\ndef fulfill(): pass\n")
|
|
1102
|
-
|
|
1103
|
-
completed = _run_hook_with_payload(
|
|
1104
|
-
_make_edit_payload(
|
|
1105
|
-
production_module,
|
|
1106
|
-
old_string="import os\n",
|
|
1107
|
-
new_string="",
|
|
1108
|
-
)
|
|
1109
|
-
)
|
|
1110
|
-
|
|
1111
|
-
assert _decision_from(completed) == "allow"
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
def test_should_deny_replace_all_edit_that_rewrites_call_sites(tmp_path: Path) -> None:
|
|
1115
|
-
sandbox = _sandbox(tmp_path)
|
|
1116
|
-
production_module = sandbox / "orders.py"
|
|
1117
|
-
production_module.write_text("import json\n\ndef parse(line): return json.loads(line)\n")
|
|
1118
|
-
|
|
1119
|
-
completed = _run_hook_with_payload(
|
|
1120
|
-
{
|
|
1121
|
-
"tool_name": "Edit",
|
|
1122
|
-
"tool_input": {
|
|
1123
|
-
"file_path": str(production_module),
|
|
1124
|
-
"old_string": "json",
|
|
1125
|
-
"new_string": "pickle",
|
|
1126
|
-
"replace_all": True,
|
|
1127
|
-
},
|
|
1128
|
-
}
|
|
1129
|
-
)
|
|
1130
|
-
|
|
1131
|
-
assert _decision_from(completed) == "deny"
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
def test_should_allow_edit_that_reorders_import_statements(tmp_path: Path) -> None:
|
|
1135
|
-
sandbox = _sandbox(tmp_path)
|
|
1136
|
-
production_module = sandbox / "orders.py"
|
|
1137
|
-
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
1138
|
-
|
|
1139
|
-
completed = _run_hook_with_payload(
|
|
1140
|
-
_make_edit_payload(
|
|
1141
|
-
production_module,
|
|
1142
|
-
old_string="import os\nimport sys",
|
|
1143
|
-
new_string="import sys\nimport os",
|
|
1144
|
-
)
|
|
1145
|
-
)
|
|
1146
|
-
|
|
1147
|
-
assert _decision_from(completed) == "allow"
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
def test_should_deny_edit_that_retargets_an_import_source(tmp_path: Path) -> None:
|
|
1151
|
-
sandbox = _sandbox(tmp_path)
|
|
1152
|
-
production_module = sandbox / "orders.py"
|
|
1153
|
-
production_module.write_text("from fast import compute\n\ndef run(): return compute()\n")
|
|
1154
|
-
|
|
1155
|
-
completed = _run_hook_with_payload(
|
|
1156
|
-
_make_edit_payload(
|
|
1157
|
-
production_module,
|
|
1158
|
-
old_string="from fast import compute",
|
|
1159
|
-
new_string="from slow import compute",
|
|
1160
|
-
)
|
|
1161
|
-
)
|
|
1162
|
-
|
|
1163
|
-
assert _decision_from(completed) == "deny"
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
def test_should_deny_edit_that_adds_a_future_import(tmp_path: Path) -> None:
|
|
1167
|
-
sandbox = _sandbox(tmp_path)
|
|
1168
|
-
production_module = sandbox / "orders.py"
|
|
1169
|
-
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1170
|
-
|
|
1171
|
-
completed = _run_hook_with_payload(
|
|
1172
|
-
_make_edit_payload(
|
|
1173
|
-
production_module,
|
|
1174
|
-
old_string="import os",
|
|
1175
|
-
new_string="from __future__ import annotations\nimport os",
|
|
1176
|
-
)
|
|
1177
|
-
)
|
|
1178
|
-
|
|
1179
|
-
assert _decision_from(completed) == "deny"
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
def test_should_deny_multiedit_with_an_empty_edits_list(tmp_path: Path) -> None:
|
|
1183
|
-
sandbox = _sandbox(tmp_path)
|
|
1184
|
-
production_module = sandbox / "orders.py"
|
|
1185
|
-
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1186
|
-
|
|
1187
|
-
completed = _run_hook_with_payload(
|
|
1188
|
-
_make_multiedit_payload(production_module, edits=[])
|
|
1189
|
-
)
|
|
1190
|
-
|
|
1191
|
-
assert _decision_from(completed) == "deny"
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
def test_should_deny_edit_that_removes_a_future_import(tmp_path: Path) -> None:
|
|
1195
|
-
sandbox = _sandbox(tmp_path)
|
|
1196
|
-
production_module = sandbox / "orders.py"
|
|
1197
|
-
production_module.write_text(
|
|
1198
|
-
"from __future__ import annotations\nimport os\n\ndef fulfill(): return os.getpid()\n"
|
|
1199
|
-
)
|
|
1200
|
-
|
|
1201
|
-
completed = _run_hook_with_payload(
|
|
1202
|
-
_make_edit_payload(
|
|
1203
|
-
production_module,
|
|
1204
|
-
old_string="from __future__ import annotations\n",
|
|
1205
|
-
new_string="",
|
|
1206
|
-
)
|
|
1207
|
-
)
|
|
1208
|
-
|
|
1209
|
-
assert _decision_from(completed) == "deny"
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
def test_should_deny_edit_that_adds_an_import(tmp_path: Path) -> None:
|
|
1213
|
-
sandbox = _sandbox(tmp_path)
|
|
1214
|
-
production_module = sandbox / "orders.py"
|
|
1215
|
-
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1216
|
-
|
|
1217
|
-
completed = _run_hook_with_payload(
|
|
1218
|
-
_make_edit_payload(
|
|
1219
|
-
production_module,
|
|
1220
|
-
old_string="import os",
|
|
1221
|
-
new_string="import os\nimport sys",
|
|
1222
|
-
)
|
|
1223
|
-
)
|
|
1224
|
-
|
|
1225
|
-
assert _decision_from(completed) == "deny"
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
def test_should_deny_edit_that_duplicates_an_import(tmp_path: Path) -> None:
|
|
1229
|
-
sandbox = _sandbox(tmp_path)
|
|
1230
|
-
production_module = sandbox / "orders.py"
|
|
1231
|
-
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1232
|
-
|
|
1233
|
-
completed = _run_hook_with_payload(
|
|
1234
|
-
_make_edit_payload(
|
|
1235
|
-
production_module,
|
|
1236
|
-
old_string="import os",
|
|
1237
|
-
new_string="import os\nimport os",
|
|
1238
|
-
)
|
|
1239
|
-
)
|
|
1240
|
-
|
|
1241
|
-
assert _decision_from(completed) == "deny"
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
def test_should_deny_changing_a_future_import_on_a_constants_only_file(tmp_path: Path) -> None:
|
|
1245
|
-
sandbox = _sandbox(tmp_path)
|
|
1246
|
-
production_module = sandbox / "orders.py"
|
|
1247
|
-
production_module.write_text("from __future__ import annotations\nMAX_ORDERS = 5\n")
|
|
1248
|
-
|
|
1249
|
-
completed = _run_hook_with_payload(
|
|
1250
|
-
_make_edit_payload(
|
|
1251
|
-
production_module,
|
|
1252
|
-
old_string="from __future__ import annotations",
|
|
1253
|
-
new_string="from __future__ import division",
|
|
1254
|
-
)
|
|
1255
|
-
)
|
|
1256
|
-
|
|
1257
|
-
assert _decision_from(completed) == "deny"
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
def _make_write_payload_for_session(
|
|
1261
|
-
file_path: Path, content: str, session_id: str, repository_root: Path
|
|
1262
|
-
) -> dict:
|
|
1263
|
-
return {
|
|
1264
|
-
"session_id": session_id,
|
|
1265
|
-
"cwd": str(repository_root),
|
|
1266
|
-
"tool_name": "Write",
|
|
1267
|
-
"tool_input": {"file_path": str(file_path), "content": content},
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
def _seed_stale_recorded_hash(
|
|
1272
|
-
candidate: Path, session_id: str, repository_root: Path, seconds_past_window: int
|
|
1273
|
-
) -> None:
|
|
1274
|
-
"""Plant a session record as if the gate had genuinely seen *candidate* long ago."""
|
|
1275
|
-
state_file = content_hash_store._state_file_path(session_id, str(repository_root))
|
|
1276
|
-
candidate_key = content_hash_store._state_key_for(candidate)
|
|
1277
|
-
recorded_hash = content_hash_store._content_hash(candidate.read_text())
|
|
1278
|
-
stale_changed_at = time.time() - FRESHNESS_SECONDS - seconds_past_window
|
|
1279
|
-
state = {
|
|
1280
|
-
candidate_key: {
|
|
1281
|
-
content_hash_store.STORED_CONTENT_HASH_KEY: recorded_hash,
|
|
1282
|
-
content_hash_store.STORED_CHANGED_AT_KEY: stale_changed_at,
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
content_hash_store._atomic_write_state(state_file, state)
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
def test_should_deny_when_test_file_was_recorded_then_only_touched_after_window(
|
|
1289
|
-
tmp_path: Path,
|
|
1290
|
-
) -> None:
|
|
1291
|
-
"""The exact loophole: a session-recorded test, later only touched, stays denied."""
|
|
1292
|
-
sandbox = _sandbox(tmp_path)
|
|
1293
|
-
production_file = sandbox / "orders.py"
|
|
1294
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
1295
|
-
sibling_test = sandbox / "test_orders.py"
|
|
1296
|
-
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
1297
|
-
session_id = "touch-loophole-session"
|
|
1298
|
-
_seed_stale_recorded_hash(sibling_test, session_id, sandbox, seconds_past_window=60)
|
|
1299
|
-
|
|
1300
|
-
sibling_test.touch()
|
|
1301
|
-
|
|
1302
|
-
completed = _run_hook_with_payload(
|
|
1303
|
-
_make_write_payload_for_session(
|
|
1304
|
-
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1305
|
-
)
|
|
1306
|
-
)
|
|
1307
|
-
|
|
1308
|
-
assert _decision_from(completed) == "deny"
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
def test_should_allow_when_test_file_content_actually_changed_after_window(
|
|
1312
|
-
tmp_path: Path,
|
|
1313
|
-
) -> None:
|
|
1314
|
-
sandbox = _sandbox(tmp_path)
|
|
1315
|
-
production_file = sandbox / "orders.py"
|
|
1316
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
1317
|
-
sibling_test = sandbox / "test_orders.py"
|
|
1318
|
-
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
1319
|
-
session_id = "touch-loophole-real-edit-session"
|
|
1320
|
-
_seed_stale_recorded_hash(sibling_test, session_id, sandbox, seconds_past_window=60)
|
|
1321
|
-
|
|
1322
|
-
sibling_test.write_text("def test_fulfill(): assert True\n")
|
|
1323
|
-
|
|
1324
|
-
completed = _run_hook_with_payload(
|
|
1325
|
-
_make_write_payload_for_session(
|
|
1326
|
-
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1327
|
-
)
|
|
1328
|
-
)
|
|
1329
|
-
|
|
1330
|
-
assert _decision_from(completed) == "allow"
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
def test_should_allow_repeat_production_write_within_window_without_further_test_change(
|
|
1334
|
-
tmp_path: Path,
|
|
1335
|
-
) -> None:
|
|
1336
|
-
"""GREEN/REFACTOR needs several production writes after one real test edit."""
|
|
1337
|
-
sandbox = _sandbox(tmp_path)
|
|
1338
|
-
production_file = sandbox / "orders.py"
|
|
1339
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
1340
|
-
sibling_test = sandbox / "test_orders.py"
|
|
1341
|
-
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
1342
|
-
session_id = "green-refactor-session"
|
|
1343
|
-
|
|
1344
|
-
first_write = _run_hook_with_payload(
|
|
1345
|
-
_make_write_payload_for_session(
|
|
1346
|
-
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1347
|
-
)
|
|
1348
|
-
)
|
|
1349
|
-
assert _decision_from(first_write) == "allow"
|
|
1350
|
-
|
|
1351
|
-
second_write = _run_hook_with_payload(
|
|
1352
|
-
_make_write_payload_for_session(
|
|
1353
|
-
production_file, "def fulfill(): return 2\n", session_id, sandbox
|
|
1354
|
-
)
|
|
1355
|
-
)
|
|
1356
|
-
|
|
1357
|
-
assert _decision_from(second_write) == "allow"
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
def _seed_recorded_test_failure(
|
|
1361
|
-
test_file: Path, session_id: str, repository_root: Path, command: str, exit_status: int
|
|
1362
|
-
) -> None:
|
|
1363
|
-
"""Plant a failure record as if a PostToolUse hook had genuinely seen it fail."""
|
|
1364
|
-
content_hash_store.record_test_command_failure(
|
|
1365
|
-
[test_file], command, exit_status, session_id, str(repository_root)
|
|
1366
|
-
)
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
def test_should_allow_production_write_when_recorded_test_failure_matches_content(
|
|
1370
|
-
tmp_path: Path,
|
|
1371
|
-
) -> None:
|
|
1372
|
-
"""RED evidence outranks a stale mtime: a genuinely recorded failure unlocks GREEN."""
|
|
1373
|
-
sandbox = _sandbox(tmp_path)
|
|
1374
|
-
production_file = sandbox / "orders.py"
|
|
1375
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
1376
|
-
sibling_test = sandbox / "test_orders.py"
|
|
1377
|
-
sibling_test.write_text("def test_fulfill(): assert fulfill() == 1\n")
|
|
1378
|
-
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
1379
|
-
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
1380
|
-
session_id = "recorded-failure-session"
|
|
1381
|
-
_seed_recorded_test_failure(
|
|
1382
|
-
sibling_test, session_id, sandbox, "pytest test_orders.py", 1
|
|
1383
|
-
)
|
|
1384
|
-
|
|
1385
|
-
completed = _run_hook_with_payload(
|
|
1386
|
-
_make_write_payload_for_session(
|
|
1387
|
-
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1388
|
-
)
|
|
1389
|
-
)
|
|
1390
|
-
|
|
1391
|
-
assert _decision_from(completed) == "allow"
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
def test_should_deny_production_write_when_recorded_failure_names_a_different_file(
|
|
1395
|
-
tmp_path: Path,
|
|
1396
|
-
) -> None:
|
|
1397
|
-
"""Anti-bypass: a failure recorded for one test file must not unlock a sibling's gate."""
|
|
1398
|
-
sandbox = _sandbox(tmp_path)
|
|
1399
|
-
production_file = sandbox / "orders.py"
|
|
1400
|
-
production_file.write_text("def fulfill(): pass\n")
|
|
1401
|
-
sibling_test = sandbox / "test_orders.py"
|
|
1402
|
-
sibling_test.write_text("def test_fulfill(): assert fulfill() == 1\n")
|
|
1403
|
-
stale_timestamp = time.time() - STALE_MTIME_OFFSET_SECONDS
|
|
1404
|
-
os.utime(sibling_test, (stale_timestamp, stale_timestamp))
|
|
1405
|
-
unrelated_test = sandbox / "test_unrelated.py"
|
|
1406
|
-
unrelated_test.write_text("def test_other(): pass\n")
|
|
1407
|
-
session_id = "recorded-failure-unrelated-session"
|
|
1408
|
-
_seed_recorded_test_failure(
|
|
1409
|
-
unrelated_test, session_id, sandbox, "pytest test_unrelated.py", 1
|
|
1410
|
-
)
|
|
1411
|
-
|
|
1412
|
-
completed = _run_hook_with_payload(
|
|
1413
|
-
_make_write_payload_for_session(
|
|
1414
|
-
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1415
|
-
)
|
|
1416
|
-
)
|
|
1417
|
-
|
|
1418
|
-
assert _decision_from(completed) == "deny"
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_transaction(
|
|
1422
|
-
tmp_path: Path,
|
|
1423
|
-
) -> None:
|
|
1424
|
-
"""One patch adding both files denies: the test is not yet on disk when read.
|
|
1425
|
-
|
|
1426
|
-
``apply_patch`` is a PreToolUse hook; it sees the patch text before the
|
|
1427
|
-
patch runs, so a same-transaction test file is not readable yet even
|
|
1428
|
-
though it names a real, matching test.
|
|
1429
|
-
"""
|
|
1430
|
-
sandbox = _sandbox(tmp_path)
|
|
1431
|
-
patch_command = (
|
|
1432
|
-
"*** Begin Patch\n"
|
|
1433
|
-
"*** Add File: orders.py\n"
|
|
1434
|
-
"+def fulfill(): pass\n"
|
|
1435
|
-
"*** Add File: test_orders.py\n"
|
|
1436
|
-
"+def test_fulfill(): pass\n"
|
|
1437
|
-
"*** End Patch"
|
|
1438
|
-
)
|
|
1439
|
-
|
|
1440
|
-
completed = _run_hook_with_payload(_make_apply_patch_payload(sandbox, patch_command))
|
|
1441
|
-
|
|
1442
|
-
assert _decision_from(completed) == "deny"
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
def test_should_stay_exempt_for_scratch_outside_repository_root(
|
|
1446
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
1447
|
-
) -> None:
|
|
1448
|
-
"""Repo-root fix: a genuine scratch file outside the repository stays exempt."""
|
|
1449
|
-
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1450
|
-
repository_root = "/tmp/pr-alpha"
|
|
1451
|
-
payload = {
|
|
1452
|
-
"tool_name": "Write",
|
|
1453
|
-
"tool_input": {
|
|
1454
|
-
"file_path": "/tmp/scratch/one_off.py",
|
|
1455
|
-
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1456
|
-
},
|
|
1457
|
-
"cwd": repository_root,
|
|
1458
|
-
}
|
|
1459
|
-
completed = _run_hook_with_payload(payload)
|
|
1460
|
-
assert _decision_from(completed) != "deny", (
|
|
1461
|
-
f"TDD enforcer must not deny scratch outside the repository, got: {completed.stdout!r}"
|
|
1462
|
-
)
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
def test_should_stay_exempt_for_unrelated_worktree_sibling_under_tmp(
|
|
1466
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
1467
|
-
) -> None:
|
|
1468
|
-
"""Repo-root fix: a sibling sharing the root's name prefix is not inside it.
|
|
1469
|
-
|
|
1470
|
-
Containment is judged by path segment, so /tmp/pr-alpha-sibling is outside
|
|
1471
|
-
/tmp/pr-alpha however much of the name the two share.
|
|
1472
|
-
"""
|
|
1473
|
-
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1474
|
-
repository_root = "/tmp/pr-alpha"
|
|
1475
|
-
payload = {
|
|
1476
|
-
"tool_name": "Write",
|
|
1477
|
-
"tool_input": {
|
|
1478
|
-
"file_path": "/tmp/pr-alpha-sibling/hooks/orders.py",
|
|
1479
|
-
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1480
|
-
},
|
|
1481
|
-
"cwd": repository_root,
|
|
1482
|
-
}
|
|
1483
|
-
completed = _run_hook_with_payload(payload)
|
|
1484
|
-
assert _decision_from(completed) != "deny", (
|
|
1485
|
-
f"TDD enforcer must not deny an unrelated sibling worktree, got: {completed.stdout!r}"
|
|
1486
|
-
)
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
def test_should_deny_production_file_inside_repository_root_under_tmp(
|
|
1490
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
1491
|
-
) -> None:
|
|
1492
|
-
"""Repo-root fix: a production file inside a repository under /tmp still needs a test."""
|
|
1493
|
-
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1494
|
-
repository_root = "/tmp/pr-alpha"
|
|
1495
|
-
payload = {
|
|
1496
|
-
"tool_name": "Write",
|
|
1497
|
-
"tool_input": {
|
|
1498
|
-
"file_path": f"{repository_root}/hooks/blocking/orders.py",
|
|
1499
|
-
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1500
|
-
},
|
|
1501
|
-
"cwd": repository_root,
|
|
1502
|
-
}
|
|
1503
|
-
completed = _run_hook_with_payload(payload)
|
|
1504
|
-
assert _decision_from(completed) == "deny", (
|
|
1505
|
-
f"TDD enforcer must gate a repository file under /tmp, got: {completed.stdout!r}"
|
|
1506
|
-
)
|