claude-dev-env 1.69.1 → 1.69.2
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/hooks/blocking/code_rules_enforcer.py +5 -0
- package/hooks/blocking/code_rules_shared.py +47 -0
- package/hooks/blocking/tdd_enforcer.py +7 -2
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +383 -0
- package/hooks/blocking/test_tdd_enforcer.py +106 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/package.json +1 -1
|
@@ -108,6 +108,7 @@ from code_rules_paths_syspath import ( # noqa: E402
|
|
|
108
108
|
from code_rules_shared import ( # noqa: E402
|
|
109
109
|
changed_line_numbers,
|
|
110
110
|
get_file_extension,
|
|
111
|
+
is_ephemeral_script_path,
|
|
111
112
|
is_hook_infrastructure,
|
|
112
113
|
is_test_file,
|
|
113
114
|
)
|
|
@@ -395,6 +396,8 @@ def _is_validated_target(file_path: str) -> bool:
|
|
|
395
396
|
"""
|
|
396
397
|
if not file_path:
|
|
397
398
|
return False
|
|
399
|
+
if is_ephemeral_script_path(file_path):
|
|
400
|
+
return False
|
|
398
401
|
if is_hook_infrastructure(file_path):
|
|
399
402
|
return False
|
|
400
403
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
@@ -416,6 +419,8 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
|
416
419
|
"""
|
|
417
420
|
if not file_path:
|
|
418
421
|
return False
|
|
422
|
+
if is_ephemeral_script_path(file_path):
|
|
423
|
+
return False
|
|
419
424
|
if not is_hook_infrastructure(file_path):
|
|
420
425
|
return False
|
|
421
426
|
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import difflib
|
|
5
|
+
import os
|
|
5
6
|
import sys
|
|
6
7
|
from collections.abc import Iterator
|
|
7
8
|
from pathlib import Path
|
|
@@ -15,10 +16,16 @@ if _hooks_directory not in sys.path:
|
|
|
15
16
|
|
|
16
17
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
17
18
|
ALL_DIFF_CHANGED_OPCODE_TAGS,
|
|
19
|
+
ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES,
|
|
18
20
|
ALL_HOOK_INFRASTRUCTURE_PATTERNS,
|
|
19
21
|
ALL_MIGRATION_PATH_PATTERNS,
|
|
22
|
+
ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES,
|
|
20
23
|
ALL_TEST_PATH_PATTERNS,
|
|
21
24
|
ALL_WORKFLOW_REGISTRY_PATTERNS,
|
|
25
|
+
CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME,
|
|
26
|
+
CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY,
|
|
27
|
+
EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME,
|
|
28
|
+
LEADING_DRIVE_LETTER_PATTERN,
|
|
22
29
|
)
|
|
23
30
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
24
31
|
TYPE_CHECKING_IDENTIFIER,
|
|
@@ -201,6 +208,46 @@ def _extract_fstring_literal_parts(
|
|
|
201
208
|
return "".join(display_segments), "".join(shape_segments)
|
|
202
209
|
|
|
203
210
|
|
|
211
|
+
def is_ephemeral_script_path(file_path: str) -> bool:
|
|
212
|
+
"""Return True when the path is rooted at a throwaway scratch directory.
|
|
213
|
+
|
|
214
|
+
Checks these sources in order:
|
|
215
|
+
- ``$CLAUDE_JOB_DIR/tmp`` — only when ``CLAUDE_JOB_DIR`` is set.
|
|
216
|
+
- Root-anchored ``/tmp`` and ``/temp`` (drive-letter tolerant).
|
|
217
|
+
|
|
218
|
+
The shared OS temp directory is deliberately not a source: pytest writes
|
|
219
|
+
its sandbox fixtures there, so matching it would exempt the suite's own
|
|
220
|
+
targets. Returns False when ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT``
|
|
221
|
+
is truthy, when ``file_path`` is empty, and when no root matches. Path
|
|
222
|
+
classification is string-only; the file need not exist.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
file_path: The candidate path to classify.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True when the path is rooted at a recognized ephemeral scratch directory.
|
|
229
|
+
"""
|
|
230
|
+
if not file_path:
|
|
231
|
+
return False
|
|
232
|
+
disable_value = os.environ.get(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "").strip().lower()
|
|
233
|
+
if disable_value in ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES:
|
|
234
|
+
return False
|
|
235
|
+
normalized = LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(file_path).replace("\\", "/").lower())
|
|
236
|
+
all_temp_roots: list[str] = []
|
|
237
|
+
job_dir = os.environ.get(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME)
|
|
238
|
+
if job_dir:
|
|
239
|
+
job_dir_scratch = LEADING_DRIVE_LETTER_PATTERN.sub(
|
|
240
|
+
"", os.path.join(job_dir, CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY).replace("\\", "/").lower()
|
|
241
|
+
)
|
|
242
|
+
all_temp_roots.append(job_dir_scratch)
|
|
243
|
+
for each_root in ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES:
|
|
244
|
+
all_temp_roots.append(each_root)
|
|
245
|
+
for each_temp_root in all_temp_roots:
|
|
246
|
+
if normalized == each_temp_root or normalized.startswith(each_temp_root + "/"):
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
204
251
|
def is_migration_file(file_path: str) -> bool:
|
|
205
252
|
"""Check if file is a Django migration (must be self-contained)."""
|
|
206
253
|
path_lower = file_path.lower().replace("\\", "/")
|
|
@@ -16,10 +16,15 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
_hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
_blocking_directory_path_string = str(Path(__file__).resolve().parent)
|
|
19
20
|
if _hooks_root_path_string not in sys.path:
|
|
20
21
|
sys.path.insert(0, _hooks_root_path_string)
|
|
22
|
+
if _blocking_directory_path_string not in sys.path:
|
|
23
|
+
sys.path.insert(0, _blocking_directory_path_string)
|
|
21
24
|
|
|
22
|
-
from
|
|
25
|
+
from code_rules_shared import is_ephemeral_script_path # noqa: E402
|
|
26
|
+
|
|
27
|
+
from hooks_constants.messages import USER_FACING_TDD_NOTICE # noqa: E402
|
|
23
28
|
|
|
24
29
|
PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
|
|
25
30
|
SKIP_PATTERNS = {
|
|
@@ -581,7 +586,7 @@ def main() -> None:
|
|
|
581
586
|
if not file_path:
|
|
582
587
|
sys.exit(0)
|
|
583
588
|
|
|
584
|
-
if _is_inside_dotclaude_segment(file_path):
|
|
589
|
+
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
|
|
585
590
|
sys.exit(0)
|
|
586
591
|
|
|
587
592
|
path = Path(file_path)
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Ephemeral-path exemption tests for code_rules_enforcer and the classifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
17
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
19
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
20
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
21
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
22
|
+
|
|
23
|
+
from code_rules_enforcer import main as enforcer_main # noqa: E402
|
|
24
|
+
from code_rules_shared import is_ephemeral_script_path # noqa: E402
|
|
25
|
+
|
|
26
|
+
_ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
27
|
+
_TDD_SCRIPT = Path(__file__).resolve().parent / "tdd_enforcer.py"
|
|
28
|
+
|
|
29
|
+
_VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
|
|
30
|
+
|
|
31
|
+
code_rules_enforcer_module = SimpleNamespace(main=enforcer_main, sys=sys)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_enforcer_cli(
|
|
35
|
+
all_cli_arguments: list[str],
|
|
36
|
+
extra_env: dict[str, str],
|
|
37
|
+
) -> subprocess.CompletedProcess[str]:
|
|
38
|
+
"""Drive the enforcer script through its real argv entry point.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
all_cli_arguments: The argument vector appended after the script path.
|
|
42
|
+
extra_env: Additional environment variables merged into the subprocess env.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The completed process carrying stdout, stderr, and the exit code.
|
|
46
|
+
"""
|
|
47
|
+
subprocess_env = {**os.environ, **extra_env}
|
|
48
|
+
return subprocess.run(
|
|
49
|
+
[sys.executable, str(_ENFORCER_SCRIPT), *all_cli_arguments],
|
|
50
|
+
input="",
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=False,
|
|
54
|
+
env=subprocess_env,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_main_with_write_payload(
|
|
59
|
+
file_path: str,
|
|
60
|
+
content: str,
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
capsys: pytest.CaptureFixture[str],
|
|
63
|
+
) -> tuple[str, int]:
|
|
64
|
+
"""Drive enforcer_main through its stdin entry point for a Write payload.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
file_path: The destination path the Write targets.
|
|
68
|
+
content: The content of the Write.
|
|
69
|
+
monkeypatch: The pytest fixture used to redirect sys.stdin.
|
|
70
|
+
capsys: The pytest fixture used to capture the deny payload on stdout.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A tuple of (captured_stdout, exit_code).
|
|
74
|
+
"""
|
|
75
|
+
write_payload = json.dumps(
|
|
76
|
+
{
|
|
77
|
+
"tool_name": "Write",
|
|
78
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
monkeypatch.setattr(code_rules_enforcer_module.sys, "stdin", io.StringIO(write_payload))
|
|
82
|
+
exit_code = 0
|
|
83
|
+
try:
|
|
84
|
+
code_rules_enforcer_module.main([])
|
|
85
|
+
except SystemExit as each_exit:
|
|
86
|
+
exit_code = int(each_exit.code or 0)
|
|
87
|
+
captured = capsys.readouterr()
|
|
88
|
+
return captured.out, exit_code
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run_tdd_with_write_payload(
|
|
92
|
+
file_path: str,
|
|
93
|
+
content: str,
|
|
94
|
+
) -> subprocess.CompletedProcess[str]:
|
|
95
|
+
"""Drive the TDD enforcer through subprocess with a Write payload.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
file_path: The destination path the Write targets.
|
|
99
|
+
content: The production-looking content to write.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The completed process carrying stdout, stderr, and the exit code.
|
|
103
|
+
"""
|
|
104
|
+
write_payload = json.dumps(
|
|
105
|
+
{
|
|
106
|
+
"tool_name": "Write",
|
|
107
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return subprocess.run(
|
|
111
|
+
[sys.executable, str(_TDD_SCRIPT)],
|
|
112
|
+
input=write_payload,
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
check=False,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
120
|
+
"""Extract the permissionDecision from a hook's JSON stdout.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
completed: The completed subprocess carrying the hook's stdout.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The permissionDecision string, or None when stdout is empty.
|
|
127
|
+
"""
|
|
128
|
+
if not completed.stdout:
|
|
129
|
+
return None
|
|
130
|
+
parsed = json.loads(completed.stdout)
|
|
131
|
+
hook_output = parsed.get("hookSpecificOutput", {})
|
|
132
|
+
return hook_output.get("permissionDecision")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_return_true_for_claude_job_dir_tmp_path(
|
|
136
|
+
tmp_path: Path,
|
|
137
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""B1: classifier returns True for a path under $CLAUDE_JOB_DIR/tmp."""
|
|
140
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
141
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
142
|
+
scratch_path = str(tmp_path / "tmp" / "scratch.py")
|
|
143
|
+
assert is_ephemeral_script_path(scratch_path) is True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_return_false_for_os_tempfile_gettempdir_root(
|
|
147
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""B2: the shared OS temp directory is not an ephemeral source.
|
|
150
|
+
|
|
151
|
+
pytest sandbox fixtures live under tempfile.gettempdir(); matching it
|
|
152
|
+
would exempt the suite's own enforcer test targets.
|
|
153
|
+
"""
|
|
154
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
155
|
+
monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
|
|
156
|
+
system_temp_root = tempfile.gettempdir()
|
|
157
|
+
scratch_path = str(Path(system_temp_root) / "scratch_work.py")
|
|
158
|
+
assert is_ephemeral_script_path(scratch_path) is False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.parametrize("env_name", ["TMPDIR", "TEMP", "TMP"])
|
|
162
|
+
def test_should_return_false_for_tmpdir_temp_tmp_env_roots(
|
|
163
|
+
env_name: str,
|
|
164
|
+
tmp_path: Path,
|
|
165
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""B3: $TMPDIR / $TEMP / $TMP are not ephemeral sources.
|
|
168
|
+
|
|
169
|
+
A path under one of these env roots that is neither $CLAUDE_JOB_DIR/tmp
|
|
170
|
+
nor root-anchored /tmp must classify False.
|
|
171
|
+
"""
|
|
172
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
173
|
+
monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
|
|
174
|
+
monkeypatch.setenv(env_name, str(tmp_path / "env_root"))
|
|
175
|
+
scratch_path = str(tmp_path / "env_root" / "scratch.py")
|
|
176
|
+
assert is_ephemeral_script_path(scratch_path) is False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_should_return_false_for_pytest_tmp_path_when_job_dir_elsewhere(
|
|
180
|
+
tmp_path: Path,
|
|
181
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Regression guard: a pytest tmp_path under %TEMP% returns False.
|
|
184
|
+
|
|
185
|
+
This is the exact path class that broke the enforcer suite when the OS
|
|
186
|
+
temp directory was an ephemeral source: pytest tmp_path fixtures live
|
|
187
|
+
under tempfile.gettempdir(), so the enforcer's own test targets must not
|
|
188
|
+
classify as ephemeral. CLAUDE_JOB_DIR points elsewhere (its scratch dir
|
|
189
|
+
lives under .claude-ev/jobs, not %TEMP%).
|
|
190
|
+
"""
|
|
191
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
192
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path / "elsewhere"))
|
|
193
|
+
pytest_sandbox_target = str(tmp_path / "candidate.py")
|
|
194
|
+
assert is_ephemeral_script_path(pytest_sandbox_target) is False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize(
|
|
198
|
+
"raw_path",
|
|
199
|
+
[
|
|
200
|
+
"/tmp/scratch.py",
|
|
201
|
+
"/temp/scratch.py",
|
|
202
|
+
"C:/Temp/scratch.py",
|
|
203
|
+
"c:/tmp/scratch.py",
|
|
204
|
+
],
|
|
205
|
+
)
|
|
206
|
+
def test_should_return_true_for_root_anchored_tmp_and_temp(
|
|
207
|
+
raw_path: str,
|
|
208
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""B4: classifier returns True for root-anchored /tmp and /temp paths."""
|
|
211
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
212
|
+
assert is_ephemeral_script_path(raw_path) is True
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.parametrize(
|
|
216
|
+
"lookalike_path",
|
|
217
|
+
[
|
|
218
|
+
"/repo/tmp_helper.py",
|
|
219
|
+
"/repo/temp/foo.py",
|
|
220
|
+
"/repo/src/temperature.py",
|
|
221
|
+
"/repo/contemporary/x.py",
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
def test_should_return_false_for_lookalike_tmp_temp_paths(
|
|
225
|
+
lookalike_path: str,
|
|
226
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""B5: classifier returns False for paths that merely contain tmp/temp substrings."""
|
|
229
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
230
|
+
assert is_ephemeral_script_path(lookalike_path) is False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_should_resolve_relative_path_before_classifying(
|
|
234
|
+
tmp_path: Path,
|
|
235
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""B6: a relative path resolving under $CLAUDE_JOB_DIR/tmp returns True."""
|
|
238
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
239
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
240
|
+
(tmp_path / "tmp").mkdir(parents=True, exist_ok=True)
|
|
241
|
+
monkeypatch.chdir(tmp_path / "tmp")
|
|
242
|
+
assert is_ephemeral_script_path("scratch.py") is True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_should_return_false_when_job_dir_unset(
|
|
246
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""B7: with CLAUDE_JOB_DIR unset, a non-temp path returns False."""
|
|
249
|
+
monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
|
|
250
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
251
|
+
assert is_ephemeral_script_path("/repo/src/orders.py") is False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.parametrize("truthy_value", ["1", "true", "yes", "on"])
|
|
255
|
+
def test_should_return_false_when_disable_override_truthy(
|
|
256
|
+
truthy_value: str,
|
|
257
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""B8: override set to each truthy value returns False even for a temp path."""
|
|
260
|
+
monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", truthy_value)
|
|
261
|
+
assert is_ephemeral_script_path("/tmp/scratch.py") is False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_should_classify_nonexistent_path_without_error(
|
|
265
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""B9: a path that does not exist is classified by string, with no exception."""
|
|
268
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
269
|
+
classification_result = is_ephemeral_script_path("/tmp/does_not_exist.py")
|
|
270
|
+
assert isinstance(classification_result, bool)
|
|
271
|
+
assert classification_result is True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_should_return_false_for_empty_path() -> None:
|
|
275
|
+
"""B10: an empty string returns False."""
|
|
276
|
+
assert is_ephemeral_script_path("") is False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_should_exit_zero_for_ephemeral_pretooluse_target(
|
|
280
|
+
tmp_path: Path,
|
|
281
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
282
|
+
capsys: pytest.CaptureFixture[str],
|
|
283
|
+
) -> None:
|
|
284
|
+
"""B11: enforcer main exits 0 with no deny payload for an ephemeral file_path."""
|
|
285
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
286
|
+
ephemeral_path = str(tmp_path / "tmp" / "scratch.py")
|
|
287
|
+
captured_stdout, exit_code = _run_main_with_write_payload(
|
|
288
|
+
ephemeral_path,
|
|
289
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
290
|
+
monkeypatch,
|
|
291
|
+
capsys,
|
|
292
|
+
)
|
|
293
|
+
assert exit_code == 0
|
|
294
|
+
assert "deny" not in captured_stdout.lower()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_should_exit_zero_for_ephemeral_path_with_hooks_substring(
|
|
298
|
+
tmp_path: Path,
|
|
299
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
300
|
+
capsys: pytest.CaptureFixture[str],
|
|
301
|
+
) -> None:
|
|
302
|
+
"""B12: an ephemeral path carrying /hooks/ exits 0 (hook-infra route short-circuited)."""
|
|
303
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
304
|
+
ephemeral_hooks_path = str(tmp_path / "tmp" / "hooks" / "scratch.py")
|
|
305
|
+
captured_stdout, exit_code = _run_main_with_write_payload(
|
|
306
|
+
ephemeral_hooks_path,
|
|
307
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
308
|
+
monkeypatch,
|
|
309
|
+
capsys,
|
|
310
|
+
)
|
|
311
|
+
assert exit_code == 0
|
|
312
|
+
assert "deny" not in captured_stdout.lower()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_should_return_zero_from_precheck_for_ephemeral_target(
|
|
316
|
+
tmp_path: Path,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""B13: enforcer CLI returns 0 for an ephemeral root-anchored /tmp --as target."""
|
|
319
|
+
candidate_file = tmp_path / "candidate.py"
|
|
320
|
+
candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
|
|
321
|
+
ephemeral_target = "/tmp/target.py"
|
|
322
|
+
completed = _run_enforcer_cli(["--check", str(candidate_file), "--as", ephemeral_target], extra_env={})
|
|
323
|
+
assert completed.returncode == 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_should_run_full_suite_for_non_ephemeral_target(
|
|
327
|
+
tmp_path: Path,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""B14: a non-ephemeral violating path still produces a deny payload."""
|
|
330
|
+
candidate_file = tmp_path / "candidate.py"
|
|
331
|
+
candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
|
|
332
|
+
non_ephemeral_target = "/repo/src/orders.py"
|
|
333
|
+
completed = _run_enforcer_cli(
|
|
334
|
+
["--check", str(candidate_file), "--as", non_ephemeral_target],
|
|
335
|
+
extra_env={},
|
|
336
|
+
)
|
|
337
|
+
assert completed.returncode == 1
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_should_run_full_suite_when_override_truthy(
|
|
341
|
+
tmp_path: Path,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""B15: with override set, an ephemeral violating path produces a deny."""
|
|
344
|
+
candidate_file = tmp_path / "candidate.py"
|
|
345
|
+
candidate_file.write_text(_VIOLATING_PRODUCTION_SOURCE, encoding="utf-8")
|
|
346
|
+
ephemeral_target = "/tmp/scratch.py"
|
|
347
|
+
completed = _run_enforcer_cli(
|
|
348
|
+
["--check", str(candidate_file), "--as", ephemeral_target],
|
|
349
|
+
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
350
|
+
)
|
|
351
|
+
assert completed.returncode == 1
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_should_exempt_same_path_set_on_both_gates(
|
|
355
|
+
tmp_path: Path,
|
|
356
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
357
|
+
capsys: pytest.CaptureFixture[str],
|
|
358
|
+
) -> None:
|
|
359
|
+
"""B21: every ephemeral path exits 0 on both enforcer main and TDD enforcer main."""
|
|
360
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
361
|
+
all_ephemeral_paths = [
|
|
362
|
+
str(tmp_path / "tmp" / "scratch.py"),
|
|
363
|
+
"/tmp/scratch.py",
|
|
364
|
+
"/temp/scratch.py",
|
|
365
|
+
]
|
|
366
|
+
for each_ephemeral_path in all_ephemeral_paths:
|
|
367
|
+
captured_stdout, exit_code = _run_main_with_write_payload(
|
|
368
|
+
each_ephemeral_path,
|
|
369
|
+
_VIOLATING_PRODUCTION_SOURCE,
|
|
370
|
+
monkeypatch,
|
|
371
|
+
capsys,
|
|
372
|
+
)
|
|
373
|
+
assert exit_code == 0, (
|
|
374
|
+
f"enforcer must exit 0 for ephemeral path {each_ephemeral_path!r}, "
|
|
375
|
+
f"got exit_code={exit_code}, stdout={captured_stdout!r}"
|
|
376
|
+
)
|
|
377
|
+
assert "deny" not in captured_stdout.lower(), (
|
|
378
|
+
f"enforcer must not deny ephemeral path {each_ephemeral_path!r}"
|
|
379
|
+
)
|
|
380
|
+
completed = _run_tdd_with_write_payload(each_ephemeral_path, _VIOLATING_PRODUCTION_SOURCE)
|
|
381
|
+
assert _decision_from(completed) != "deny", (
|
|
382
|
+
f"TDD enforcer must not deny ephemeral path {each_ephemeral_path!r}"
|
|
383
|
+
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for tdd-enforcer hook (blocking behavior)."""
|
|
2
2
|
|
|
3
3
|
import importlib.util
|
|
4
|
+
import io
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import subprocess
|
|
@@ -8,6 +9,7 @@ import sys
|
|
|
8
9
|
import time
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
12
|
+
import pytest
|
|
11
13
|
|
|
12
14
|
SCRIPT_PATH = Path(__file__).parent / "tdd_enforcer.py"
|
|
13
15
|
|
|
@@ -25,13 +27,18 @@ FRESHNESS_SECONDS = _PRODUCTION_MODULE._freshness_seconds()
|
|
|
25
27
|
STALE_MTIME_OFFSET_SECONDS = FRESHNESS_SECONDS + 60
|
|
26
28
|
|
|
27
29
|
|
|
28
|
-
def _run_hook_with_payload(
|
|
30
|
+
def _run_hook_with_payload(
|
|
31
|
+
payload: dict,
|
|
32
|
+
extra_env: dict[str, str] | None = None,
|
|
33
|
+
) -> subprocess.CompletedProcess[str]:
|
|
34
|
+
subprocess_env = {**os.environ, **(extra_env or {})}
|
|
29
35
|
return subprocess.run(
|
|
30
36
|
[sys.executable, str(SCRIPT_PATH)],
|
|
31
37
|
input=json.dumps(payload),
|
|
32
38
|
text=True,
|
|
33
39
|
capture_output=True,
|
|
34
40
|
check=False,
|
|
41
|
+
env=subprocess_env,
|
|
35
42
|
)
|
|
36
43
|
|
|
37
44
|
|
|
@@ -810,6 +817,104 @@ def test_should_deny_edit_that_swaps_an_import_target(tmp_path: Path) -> None:
|
|
|
810
817
|
assert _decision_from(completed) == "deny"
|
|
811
818
|
|
|
812
819
|
|
|
820
|
+
def _run_hook_with_payload_and_env(
|
|
821
|
+
payload: dict,
|
|
822
|
+
extra_env: dict[str, str],
|
|
823
|
+
) -> subprocess.CompletedProcess[str]:
|
|
824
|
+
return subprocess.run(
|
|
825
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
826
|
+
input=json.dumps(payload),
|
|
827
|
+
text=True,
|
|
828
|
+
capture_output=True,
|
|
829
|
+
check=False,
|
|
830
|
+
env={**os.environ, **extra_env},
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
_BEHAVIOR_BEARING_CONTENT = "def fulfill_order(order: str) -> str:\n return order\n"
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def test_should_exit_zero_for_ephemeral_scratch_python() -> None:
|
|
838
|
+
"""B16: behavior-bearing scratch .py under root-anchored /tmp exits 0 with no deny."""
|
|
839
|
+
ephemeral_path = "/tmp/scratch_work.py"
|
|
840
|
+
payload = _make_write_payload(Path(ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
841
|
+
completed = _run_hook_with_payload(payload)
|
|
842
|
+
assert _decision_from(completed) != "deny", (
|
|
843
|
+
f"TDD enforcer must not deny ephemeral Python path, got: {completed.stdout!r}"
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def test_should_exit_zero_for_ephemeral_scratch_typescript() -> None:
|
|
848
|
+
"""B17: ephemeral .ts scratch file under root-anchored /tmp exits 0 with no deny."""
|
|
849
|
+
ephemeral_path = "/tmp/scratch_work.ts"
|
|
850
|
+
payload = {
|
|
851
|
+
"tool_name": "Write",
|
|
852
|
+
"tool_input": {
|
|
853
|
+
"file_path": ephemeral_path,
|
|
854
|
+
"content": "export function fulfillOrder(order: string): string { return order; }\n",
|
|
855
|
+
},
|
|
856
|
+
}
|
|
857
|
+
completed = _run_hook_with_payload(payload)
|
|
858
|
+
assert _decision_from(completed) != "deny", (
|
|
859
|
+
f"TDD enforcer must not deny ephemeral TypeScript path, got: {completed.stdout!r}"
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def test_should_still_deny_non_ephemeral_python_without_test() -> None:
|
|
864
|
+
"""B18: a non-ephemeral .py file with no matching test still receives a deny."""
|
|
865
|
+
non_ephemeral_path = "/repo/src/orders.py"
|
|
866
|
+
payload = _make_write_payload(Path(non_ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
867
|
+
completed = _run_hook_with_payload(payload)
|
|
868
|
+
assert _decision_from(completed) == "deny", (
|
|
869
|
+
f"TDD enforcer must deny non-ephemeral production file, got: {completed.stdout!r}"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def test_should_deny_ephemeral_scratch_when_override_truthy() -> None:
|
|
874
|
+
"""B19: with override set, an ephemeral scratch .py with no test still receives a deny."""
|
|
875
|
+
ephemeral_path = "/tmp/scratch_work.py"
|
|
876
|
+
payload = _make_write_payload(Path(ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
877
|
+
completed = _run_hook_with_payload_and_env(
|
|
878
|
+
payload,
|
|
879
|
+
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
880
|
+
)
|
|
881
|
+
assert _decision_from(completed) == "deny", (
|
|
882
|
+
f"TDD enforcer must deny ephemeral path when override is set, got: {completed.stdout!r}"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def test_should_exit_before_fresh_test_lookup_for_ephemeral(
|
|
887
|
+
tmp_path: Path,
|
|
888
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
889
|
+
) -> None:
|
|
890
|
+
"""B20: the ephemeral exit precedes the fresh-test candidate search.
|
|
891
|
+
|
|
892
|
+
Spies on candidate_test_paths_for and asserts it is never reached for a
|
|
893
|
+
scratch .py under $CLAUDE_JOB_DIR/tmp, proving the early exit short-circuits
|
|
894
|
+
before any test-file lookup runs.
|
|
895
|
+
"""
|
|
896
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
897
|
+
monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
|
|
898
|
+
candidate_search_call_count = {"count": 0}
|
|
899
|
+
|
|
900
|
+
def _spy_candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
901
|
+
candidate_search_call_count["count"] += 1
|
|
902
|
+
return []
|
|
903
|
+
|
|
904
|
+
monkeypatch.setattr(
|
|
905
|
+
_PRODUCTION_MODULE, "candidate_test_paths_for", _spy_candidate_test_paths_for
|
|
906
|
+
)
|
|
907
|
+
scratch_path = str(tmp_path / "tmp" / "scratch_work.py")
|
|
908
|
+
payload = _make_write_payload(Path(scratch_path), _BEHAVIOR_BEARING_CONTENT)
|
|
909
|
+
monkeypatch.setattr(_PRODUCTION_MODULE.sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
910
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
911
|
+
_PRODUCTION_MODULE.main()
|
|
912
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
913
|
+
assert candidate_search_call_count["count"] == 0, (
|
|
914
|
+
"candidate_test_paths_for must not run for an ephemeral scratch path"
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
|
|
813
918
|
def test_should_allow_edit_that_removes_an_import_statement(tmp_path: Path) -> None:
|
|
814
919
|
sandbox = _sandbox(tmp_path)
|
|
815
920
|
production_module = sandbox / "orders.py"
|
|
@@ -17,6 +17,12 @@ ALL_JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
|
17
17
|
ALL_CODE_EXTENSIONS = ALL_PYTHON_EXTENSIONS | ALL_JAVASCRIPT_EXTENSIONS
|
|
18
18
|
|
|
19
19
|
ALL_TEST_PATH_PATTERNS = {"test_", "_test.", ".test.", ".spec.", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
|
|
20
|
+
ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES: tuple[str, str] = ("/tmp", "/temp")
|
|
21
|
+
CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME: str = "CLAUDE_JOB_DIR"
|
|
22
|
+
CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY: str = "tmp"
|
|
23
|
+
EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME: str = "CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT"
|
|
24
|
+
ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
|
|
25
|
+
LEADING_DRIVE_LETTER_PATTERN: re.Pattern[str] = re.compile(r"^[a-z]:")
|
|
20
26
|
ALL_HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/", "/packages/claude-dev-env/hooks/", "\\packages\\claude-dev-env\\hooks\\"}
|
|
21
27
|
ALL_WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
|
|
22
28
|
ALL_MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
|