claude-dev-env 1.69.1 → 1.70.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/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +600 -0
- 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_claude_md_orphan_file_blocker.py +587 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +383 -0
- package/hooks/blocking/test_tdd_enforcer.py +106 -1
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +96 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/claude-md-orphan-file.md +24 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +36 -5
- package/skills/autoconverge/workflow/render_report.py +43 -5
- package/skills/autoconverge/workflow/test_render_report.py +43 -0
|
@@ -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,587 @@
|
|
|
1
|
+
"""Tests for claude_md_orphan_file_blocker hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
12
|
+
|
|
13
|
+
import claude_md_orphan_file_blocker as blocker_module
|
|
14
|
+
from claude_md_orphan_file_blocker import (
|
|
15
|
+
find_missing_filenames,
|
|
16
|
+
find_referenced_filenames,
|
|
17
|
+
)
|
|
18
|
+
from code_rules_annotations_length import check_unused_known_pytest_fixture_parameters
|
|
19
|
+
from code_rules_naming_collection import check_collection_prefix
|
|
20
|
+
|
|
21
|
+
from hooks_constants.claude_md_orphan_file_blocker_constants import (
|
|
22
|
+
ORPHAN_FILE_ADDITIONAL_CONTEXT,
|
|
23
|
+
ORPHAN_FILE_MESSAGE_TEMPLATE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "claude_md_orphan_file_blocker.py")
|
|
27
|
+
|
|
28
|
+
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
29
|
+
|
|
30
|
+
TABLE_WITH_PRESENT_FILE = (
|
|
31
|
+
"# example\n\n| File | What it does |\n|---|---|\n| `present_module.py` | Does a thing |\n"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
TABLE_WITH_ABSENT_FILE = (
|
|
35
|
+
"# example\n\n| File | What it does |\n|---|---|\n| `reviewer_specs.py` | Does a thing |\n"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
TABLE_WITH_ABSENT_README = (
|
|
39
|
+
"# example\n\n| Document | Purpose |\n|---|---|\n| `README.md` | Overview |\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
TABLE_WITH_SLASH_COMMAND_AND_SUBDIR = (
|
|
43
|
+
"# example\n\n"
|
|
44
|
+
"| Entry | Description |\n"
|
|
45
|
+
"|---|---|\n"
|
|
46
|
+
"| `/commit` | Slash command |\n"
|
|
47
|
+
"| `scripts/` | A subdirectory |\n"
|
|
48
|
+
"| Plain prose, no backticks | Not a file |\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _RunHook:
|
|
53
|
+
"""Helper to test the hook via subprocess, mirroring the sibling test style."""
|
|
54
|
+
|
|
55
|
+
def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
|
|
56
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
57
|
+
return subprocess.run(
|
|
58
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
59
|
+
input=payload,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_run_hook = _RunHook()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _isolated_claude_md_path(tmp_path: Path) -> Path:
|
|
70
|
+
"""Return a CLAUDE.md path nested in a dedicated empty directory.
|
|
71
|
+
|
|
72
|
+
Nesting the CLAUDE.md inside a child of tmp_path keeps the hook's scan root
|
|
73
|
+
(the CLAUDE.md directory's parent) controlled, so a sibling test's temp
|
|
74
|
+
content never resolves a filename the case expects to be absent.
|
|
75
|
+
"""
|
|
76
|
+
isolated_directory = tmp_path / "package_directory"
|
|
77
|
+
isolated_directory.mkdir()
|
|
78
|
+
return isolated_directory / "CLAUDE.md"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_blocks_write_naming_absent_python_file(tmp_path: Path):
|
|
82
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
83
|
+
result = _run_hook(
|
|
84
|
+
"Write",
|
|
85
|
+
{
|
|
86
|
+
"file_path": str(claude_md_path),
|
|
87
|
+
"content": TABLE_WITH_ABSENT_FILE,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
assert result.returncode == 0
|
|
91
|
+
output = json.loads(result.stdout)
|
|
92
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
93
|
+
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_blocks_write_naming_absent_markdown_file(tmp_path: Path):
|
|
97
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
98
|
+
result = _run_hook(
|
|
99
|
+
"Write",
|
|
100
|
+
{
|
|
101
|
+
"file_path": str(claude_md_path),
|
|
102
|
+
"content": TABLE_WITH_ABSENT_README,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
assert result.returncode == 0
|
|
106
|
+
output = json.loads(result.stdout)
|
|
107
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
108
|
+
assert "README.md" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_allows_write_when_referenced_file_present(tmp_path: Path):
|
|
112
|
+
(tmp_path / "present_module.py").write_text("x = 1\n", encoding="utf-8")
|
|
113
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
114
|
+
result = _run_hook(
|
|
115
|
+
"Write",
|
|
116
|
+
{
|
|
117
|
+
"file_path": str(claude_md_path),
|
|
118
|
+
"content": TABLE_WITH_PRESENT_FILE,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
assert result.returncode == 0
|
|
122
|
+
assert result.stdout == ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_allows_non_claude_md_target(tmp_path: Path):
|
|
126
|
+
other_path = tmp_path / "README.md"
|
|
127
|
+
result = _run_hook(
|
|
128
|
+
"Write",
|
|
129
|
+
{
|
|
130
|
+
"file_path": str(other_path),
|
|
131
|
+
"content": TABLE_WITH_ABSENT_FILE,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
assert result.returncode == 0
|
|
135
|
+
assert result.stdout == ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_allows_slash_commands_subdirs_and_prose(tmp_path: Path):
|
|
139
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
140
|
+
result = _run_hook(
|
|
141
|
+
"Write",
|
|
142
|
+
{
|
|
143
|
+
"file_path": str(claude_md_path),
|
|
144
|
+
"content": TABLE_WITH_SLASH_COMMAND_AND_SUBDIR,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
assert result.returncode == 0
|
|
148
|
+
assert result.stdout == ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_blocks_via_edit_new_string(tmp_path: Path):
|
|
152
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
153
|
+
result = _run_hook(
|
|
154
|
+
"Edit",
|
|
155
|
+
{
|
|
156
|
+
"file_path": str(claude_md_path),
|
|
157
|
+
"old_string": "| `old.py` | row |",
|
|
158
|
+
"new_string": TABLE_WITH_ABSENT_FILE,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
assert result.returncode == 0
|
|
162
|
+
output = json.loads(result.stdout)
|
|
163
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
164
|
+
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_blocks_via_multiedit_new_string(tmp_path: Path):
|
|
168
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
169
|
+
(claude_md_path.parent / "present_module.py").write_text("x = 1\n", encoding="utf-8")
|
|
170
|
+
result = _run_hook(
|
|
171
|
+
"MultiEdit",
|
|
172
|
+
{
|
|
173
|
+
"file_path": str(claude_md_path),
|
|
174
|
+
"edits": [
|
|
175
|
+
{"old_string": "a", "new_string": TABLE_WITH_PRESENT_FILE},
|
|
176
|
+
{"old_string": "b", "new_string": TABLE_WITH_ABSENT_FILE},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
assert result.returncode == 0
|
|
181
|
+
output = json.loads(result.stdout)
|
|
182
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
183
|
+
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_block_payload_carries_directory_and_system_message(tmp_path: Path):
|
|
187
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
188
|
+
result = _run_hook(
|
|
189
|
+
"Write",
|
|
190
|
+
{
|
|
191
|
+
"file_path": str(claude_md_path),
|
|
192
|
+
"content": TABLE_WITH_ABSENT_FILE,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
assert result.returncode == 0
|
|
196
|
+
output = json.loads(result.stdout)
|
|
197
|
+
assert output["suppressOutput"] is True
|
|
198
|
+
assert isinstance(output["systemMessage"], str)
|
|
199
|
+
assert len(output["systemMessage"]) > 0
|
|
200
|
+
assert str(claude_md_path.parent.resolve()) in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_allows_file_present_in_subdirectory(tmp_path: Path):
|
|
204
|
+
workflows_dir = tmp_path / "workflows"
|
|
205
|
+
workflows_dir.mkdir()
|
|
206
|
+
(workflows_dir / "pr-check.yml").write_text("name: ci\n", encoding="utf-8")
|
|
207
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
208
|
+
content = (
|
|
209
|
+
"# example\n\n"
|
|
210
|
+
"| File | Trigger |\n"
|
|
211
|
+
"|---|---|\n"
|
|
212
|
+
"| `pr-check.yml` | PR opened |\n"
|
|
213
|
+
)
|
|
214
|
+
result = _run_hook(
|
|
215
|
+
"Write",
|
|
216
|
+
{
|
|
217
|
+
"file_path": str(claude_md_path),
|
|
218
|
+
"content": content,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
assert result.returncode == 0
|
|
222
|
+
assert result.stdout == ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_allows_table_declaring_relative_path_source(tmp_path: Path):
|
|
226
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
227
|
+
content = (
|
|
228
|
+
"# example\n\n"
|
|
229
|
+
"## Shared artifacts (referenced by path, not copied)\n\n"
|
|
230
|
+
"The skill references shared scripts from `../_shared/pr-loop/scripts/`:\n\n"
|
|
231
|
+
"| Script | Role |\n"
|
|
232
|
+
"|---|---|\n"
|
|
233
|
+
"| `preflight.py` | Pre-flight check |\n"
|
|
234
|
+
)
|
|
235
|
+
result = _run_hook(
|
|
236
|
+
"Write",
|
|
237
|
+
{
|
|
238
|
+
"file_path": str(claude_md_path),
|
|
239
|
+
"content": content,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
assert result.returncode == 0
|
|
243
|
+
assert result.stdout == ""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_separator_row_is_skipped(tmp_path: Path):
|
|
247
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
248
|
+
content = "| File | Note |\n|---|---|\n"
|
|
249
|
+
result = _run_hook(
|
|
250
|
+
"Write",
|
|
251
|
+
{
|
|
252
|
+
"file_path": str(claude_md_path),
|
|
253
|
+
"content": content,
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
assert result.returncode == 0
|
|
257
|
+
assert result.stdout == ""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_relative_path_table_does_not_exempt_sibling_local_table(tmp_path: Path):
|
|
261
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
262
|
+
content = (
|
|
263
|
+
"# example\n\n"
|
|
264
|
+
"## Local files\n\n"
|
|
265
|
+
"| File | Note |\n"
|
|
266
|
+
"|---|---|\n"
|
|
267
|
+
"| `reviewer_specs.py` | Does a thing |\n\n"
|
|
268
|
+
"## Shared artifacts\n\n"
|
|
269
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
270
|
+
"| Script | Role |\n"
|
|
271
|
+
"|---|---|\n"
|
|
272
|
+
"| `preflight.py` | Pre-flight check |\n"
|
|
273
|
+
)
|
|
274
|
+
result = _run_hook(
|
|
275
|
+
"Write",
|
|
276
|
+
{
|
|
277
|
+
"file_path": str(claude_md_path),
|
|
278
|
+
"content": content,
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
assert result.returncode == 0
|
|
282
|
+
output = json.loads(result.stdout)
|
|
283
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
284
|
+
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
285
|
+
assert "preflight.py" not in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_relative_path_prose_above_its_own_table_exempts_that_table(tmp_path: Path):
|
|
289
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
290
|
+
content = (
|
|
291
|
+
"# example\n\n"
|
|
292
|
+
"## Shared artifacts\n\n"
|
|
293
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
294
|
+
"| Script | Role |\n"
|
|
295
|
+
"|---|---|\n"
|
|
296
|
+
"| `preflight.py` | Pre-flight check |\n"
|
|
297
|
+
)
|
|
298
|
+
result = _run_hook(
|
|
299
|
+
"Write",
|
|
300
|
+
{
|
|
301
|
+
"file_path": str(claude_md_path),
|
|
302
|
+
"content": content,
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
assert result.returncode == 0
|
|
306
|
+
assert result.stdout == ""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_edit_to_relative_path_sourced_table_is_allowed(tmp_path: Path):
|
|
310
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
311
|
+
claude_md_path.write_text(
|
|
312
|
+
"# example\n\n"
|
|
313
|
+
"## Shared artifacts\n\n"
|
|
314
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
315
|
+
"| Script | Role |\n"
|
|
316
|
+
"|---|---|\n"
|
|
317
|
+
"| `code_rules_gate.py` | Gate |\n",
|
|
318
|
+
encoding="utf-8",
|
|
319
|
+
)
|
|
320
|
+
result = _run_hook(
|
|
321
|
+
"Edit",
|
|
322
|
+
{
|
|
323
|
+
"file_path": str(claude_md_path),
|
|
324
|
+
"old_string": "| `code_rules_gate.py` | Gate |",
|
|
325
|
+
"new_string": (
|
|
326
|
+
"| `code_rules_gate.py` | Gate |\n"
|
|
327
|
+
"| `preflight.py` | Pre-flight gate that runs before the audit |"
|
|
328
|
+
),
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
assert result.returncode == 0
|
|
332
|
+
assert result.stdout == ""
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_edit_adding_orphan_to_non_exempt_file_is_denied(tmp_path: Path):
|
|
336
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
337
|
+
claude_md_path.write_text(
|
|
338
|
+
"# example\n\n"
|
|
339
|
+
"## Local files\n\n"
|
|
340
|
+
"| File | Note |\n"
|
|
341
|
+
"|---|---|\n"
|
|
342
|
+
"| `kept.py` | row |\n",
|
|
343
|
+
encoding="utf-8",
|
|
344
|
+
)
|
|
345
|
+
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
346
|
+
result = _run_hook(
|
|
347
|
+
"Edit",
|
|
348
|
+
{
|
|
349
|
+
"file_path": str(claude_md_path),
|
|
350
|
+
"old_string": "| `kept.py` | row |",
|
|
351
|
+
"new_string": (
|
|
352
|
+
"| `kept.py` | row |\n| `reviewer_specs.py` | Does a thing |"
|
|
353
|
+
),
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
assert result.returncode == 0
|
|
357
|
+
output = json.loads(result.stdout)
|
|
358
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
359
|
+
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_block_lines_yield_their_filenames_when_region_is_not_exempt():
|
|
363
|
+
content = (
|
|
364
|
+
"# example\n\n"
|
|
365
|
+
"## Local files\n\n"
|
|
366
|
+
"| File | Note |\n"
|
|
367
|
+
"|---|---|\n"
|
|
368
|
+
"| `alpha.py` | row |\n"
|
|
369
|
+
"| `beta.py` | row |\n"
|
|
370
|
+
)
|
|
371
|
+
assert find_referenced_filenames(content) == ["alpha.py", "beta.py"]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_block_lines_yield_nothing_when_region_declares_relative_source():
|
|
375
|
+
content = (
|
|
376
|
+
"# example\n\n"
|
|
377
|
+
"Referenced from `../_shared/scripts/`:\n\n"
|
|
378
|
+
"| Script | Role |\n"
|
|
379
|
+
"|---|---|\n"
|
|
380
|
+
"| `preflight.py` | Pre-flight check |\n"
|
|
381
|
+
)
|
|
382
|
+
assert find_referenced_filenames(content) == []
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_distant_relative_prose_does_not_exempt_later_local_table():
|
|
386
|
+
content = (
|
|
387
|
+
"Intro: see ../shared/ for context.\n\n"
|
|
388
|
+
"## Local files\n\n"
|
|
389
|
+
"| File | Note |\n"
|
|
390
|
+
"|---|---|\n"
|
|
391
|
+
"| `alpha.py` | row |\n"
|
|
392
|
+
)
|
|
393
|
+
assert find_referenced_filenames(content) == ["alpha.py"]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_relative_prose_then_distant_local_table_is_denied(tmp_path: Path):
|
|
397
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
398
|
+
content = (
|
|
399
|
+
"# example\n\n"
|
|
400
|
+
"Intro: see `../_shared/scripts/` for context.\n\n"
|
|
401
|
+
"## Local files\n\n"
|
|
402
|
+
"| File | Note |\n"
|
|
403
|
+
"|---|---|\n"
|
|
404
|
+
"| `reviewer_specs.py` | Does a thing |\n"
|
|
405
|
+
)
|
|
406
|
+
result = _run_hook(
|
|
407
|
+
"Write",
|
|
408
|
+
{
|
|
409
|
+
"file_path": str(claude_md_path),
|
|
410
|
+
"content": content,
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
assert result.returncode == 0
|
|
414
|
+
output = json.loads(result.stdout)
|
|
415
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
416
|
+
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_corrective_message_names_siblings_under_parent():
|
|
420
|
+
assert "sibling" in ORPHAN_FILE_MESSAGE_TEMPLATE
|
|
421
|
+
assert "sibling" in ORPHAN_FILE_ADDITIONAL_CONTEXT
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_every_repo_claude_md_is_not_blocked():
|
|
425
|
+
all_claude_md_paths = sorted(REPO_ROOT.rglob("CLAUDE.md"))
|
|
426
|
+
assert all_claude_md_paths, "expected the repo to contain CLAUDE.md files"
|
|
427
|
+
all_offenders: list[str] = []
|
|
428
|
+
for each_path in all_claude_md_paths:
|
|
429
|
+
content = each_path.read_text(encoding="utf-8")
|
|
430
|
+
missing_filenames = find_missing_filenames(content, each_path.parent)
|
|
431
|
+
if missing_filenames:
|
|
432
|
+
all_offenders.append(f"{each_path}: {missing_filenames}")
|
|
433
|
+
assert not all_offenders, "\n".join(all_offenders)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_unrelated_edit_over_preexisting_orphan_row_is_allowed(tmp_path: Path):
|
|
437
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
438
|
+
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
439
|
+
claude_md_path.write_text(
|
|
440
|
+
"# example\n\n"
|
|
441
|
+
"A prose paragraph with a typoo to fix.\n\n"
|
|
442
|
+
"## Local files\n\n"
|
|
443
|
+
"| File | Note |\n"
|
|
444
|
+
"|---|---|\n"
|
|
445
|
+
"| `kept.py` | row |\n"
|
|
446
|
+
"| `already_orphan.py` | pre-existing orphan |\n",
|
|
447
|
+
encoding="utf-8",
|
|
448
|
+
)
|
|
449
|
+
result = _run_hook(
|
|
450
|
+
"Edit",
|
|
451
|
+
{
|
|
452
|
+
"file_path": str(claude_md_path),
|
|
453
|
+
"old_string": "A prose paragraph with a typoo to fix.",
|
|
454
|
+
"new_string": "A prose paragraph with a typo fixed.",
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
assert result.returncode == 0
|
|
458
|
+
assert result.stdout == ""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_multiedit_unrelated_change_over_preexisting_orphan_is_allowed(tmp_path: Path):
|
|
462
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
463
|
+
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
464
|
+
claude_md_path.write_text(
|
|
465
|
+
"# example\n\n"
|
|
466
|
+
"First prose paragraph.\n\n"
|
|
467
|
+
"## Local files\n\n"
|
|
468
|
+
"| File | Note |\n"
|
|
469
|
+
"|---|---|\n"
|
|
470
|
+
"| `kept.py` | row |\n"
|
|
471
|
+
"| `already_orphan.py` | pre-existing orphan |\n",
|
|
472
|
+
encoding="utf-8",
|
|
473
|
+
)
|
|
474
|
+
result = _run_hook(
|
|
475
|
+
"MultiEdit",
|
|
476
|
+
{
|
|
477
|
+
"file_path": str(claude_md_path),
|
|
478
|
+
"edits": [
|
|
479
|
+
{"old_string": "First prose paragraph.", "new_string": "Revised prose paragraph."},
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
assert result.returncode == 0
|
|
484
|
+
assert result.stdout == ""
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_fenced_example_table_row_is_skipped(tmp_path: Path):
|
|
488
|
+
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
489
|
+
content = (
|
|
490
|
+
"# example\n\n"
|
|
491
|
+
"An example table you might write:\n\n"
|
|
492
|
+
"```\n"
|
|
493
|
+
"| File | Note |\n"
|
|
494
|
+
"|---|---|\n"
|
|
495
|
+
"| `ghostfile.py` | example row |\n"
|
|
496
|
+
"```\n"
|
|
497
|
+
)
|
|
498
|
+
result = _run_hook(
|
|
499
|
+
"Write",
|
|
500
|
+
{
|
|
501
|
+
"file_path": str(claude_md_path),
|
|
502
|
+
"content": content,
|
|
503
|
+
},
|
|
504
|
+
)
|
|
505
|
+
assert result.returncode == 0
|
|
506
|
+
assert result.stdout == ""
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def test_fenced_table_row_does_not_exempt_a_later_real_row():
|
|
510
|
+
content = (
|
|
511
|
+
"# example\n\n"
|
|
512
|
+
"```\n"
|
|
513
|
+
"| File | Note |\n"
|
|
514
|
+
"|---|---|\n"
|
|
515
|
+
"| `ghostfile.py` | fenced example |\n"
|
|
516
|
+
"```\n\n"
|
|
517
|
+
"## Local files\n\n"
|
|
518
|
+
"| File | Note |\n"
|
|
519
|
+
"|---|---|\n"
|
|
520
|
+
"| `reviewer_specs.py` | real row |\n"
|
|
521
|
+
)
|
|
522
|
+
assert find_referenced_filenames(content) == ["reviewer_specs.py"]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_tilde_fenced_example_table_row_is_skipped():
|
|
526
|
+
content = (
|
|
527
|
+
"# example\n\n"
|
|
528
|
+
"~~~\n"
|
|
529
|
+
"| File | Note |\n"
|
|
530
|
+
"|---|---|\n"
|
|
531
|
+
"| `ghostfile.py` | example row |\n"
|
|
532
|
+
"~~~\n"
|
|
533
|
+
)
|
|
534
|
+
assert find_referenced_filenames(content) == []
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_file_present_only_past_the_scan_cap_is_not_missing(
|
|
538
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
539
|
+
):
|
|
540
|
+
monkeypatch.setattr(blocker_module, "MAX_SUBTREE_FILES_SCANNED", 5)
|
|
541
|
+
package_directory = tmp_path / "package_directory"
|
|
542
|
+
package_directory.mkdir()
|
|
543
|
+
filler_directory = tmp_path / "filler"
|
|
544
|
+
filler_directory.mkdir()
|
|
545
|
+
for each_index in range(50):
|
|
546
|
+
(filler_directory / f"filler_{each_index}.py").write_text("x = 1\n", encoding="utf-8")
|
|
547
|
+
(package_directory / "real_target.py").write_text("x = 1\n", encoding="utf-8")
|
|
548
|
+
claude_md_path = package_directory / "CLAUDE.md"
|
|
549
|
+
content = (
|
|
550
|
+
"# example\n\n"
|
|
551
|
+
"| File | Note |\n"
|
|
552
|
+
"|---|---|\n"
|
|
553
|
+
"| `real_target.py` | a file that genuinely exists |\n"
|
|
554
|
+
)
|
|
555
|
+
first_missing = find_missing_filenames(content, claude_md_path.parent)
|
|
556
|
+
second_missing = find_missing_filenames(content, claude_md_path.parent)
|
|
557
|
+
assert first_missing == []
|
|
558
|
+
assert second_missing == []
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def test_oserror_during_subtree_walk_fails_open(
|
|
562
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
563
|
+
):
|
|
564
|
+
def _raise_oserror(self, pattern):
|
|
565
|
+
raise OSError("simulated unreadable directory")
|
|
566
|
+
|
|
567
|
+
monkeypatch.setattr(Path, "rglob", _raise_oserror)
|
|
568
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
569
|
+
content = (
|
|
570
|
+
"# example\n\n"
|
|
571
|
+
"| File | Note |\n"
|
|
572
|
+
"|---|---|\n"
|
|
573
|
+
"| `reviewer_specs.py` | absent |\n"
|
|
574
|
+
)
|
|
575
|
+
missing_filenames = find_missing_filenames(content, claude_md_path.parent)
|
|
576
|
+
assert missing_filenames == []
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_blocker_module_has_no_collection_parameter_naming_violations():
|
|
580
|
+
blocker_source = Path(HOOK_SCRIPT_PATH).read_text(encoding="utf-8")
|
|
581
|
+
assert check_collection_prefix(blocker_source, HOOK_SCRIPT_PATH) == []
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def test_test_module_has_no_unused_pytest_fixture_parameters():
|
|
585
|
+
test_file_path = str(Path(__file__).resolve())
|
|
586
|
+
test_source = Path(test_file_path).read_text(encoding="utf-8")
|
|
587
|
+
assert check_unused_known_pytest_fixture_parameters(test_source, test_file_path) == []
|