claude-dev-env 1.69.2 → 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/test_claude_md_orphan_file_blocker.py +587 -0
- 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/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
|
@@ -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) == []
|
package/hooks/hooks.json
CHANGED
|
@@ -70,6 +70,11 @@
|
|
|
70
70
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/workflow_substitution_slot_blocker.py",
|
|
71
71
|
"timeout": 10
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/claude_md_orphan_file_blocker.py",
|
|
76
|
+
"timeout": 10
|
|
77
|
+
},
|
|
73
78
|
{
|
|
74
79
|
"type": "command",
|
|
75
80
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
|
|
@@ -11,6 +11,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
11
11
|
| `banned_identifiers_constants.py` | The set of banned short identifiers and banned function-name prefixes |
|
|
12
12
|
| `blocking_check_limits.py` | Max issue counts and preview lengths for blocking hooks |
|
|
13
13
|
| `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
|
|
14
|
+
| `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
|
|
14
15
|
| `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
|
|
15
16
|
| `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
|
|
16
17
|
| `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Constants for the per-directory CLAUDE.md orphan-file-reference blocker.
|
|
2
|
+
|
|
3
|
+
A per-directory ``CLAUDE.md`` documents the files reachable from its own
|
|
4
|
+
directory in a markdown table whose first column names each file in backticks.
|
|
5
|
+
When a first-column cell names a bare filename that exists nowhere under the scan
|
|
6
|
+
root (the CLAUDE.md directory's parent, covering the directory, its
|
|
7
|
+
subdirectories, and its siblings), the table points a reader at a file that is
|
|
8
|
+
not there. This module holds the patterns that find those cells, the filename
|
|
9
|
+
extensions that mark a cell as a file reference, the region-boundary marker that
|
|
10
|
+
scopes a prose region to one section, the relative-path marker that exempts a
|
|
11
|
+
cross-directory table block, the subtree scan budget, and the block-message text
|
|
12
|
+
the hook emits.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CLAUDE_MD_FILENAME",
|
|
19
|
+
"TABLE_ROW_PATTERN",
|
|
20
|
+
"CODE_FENCE_PATTERN",
|
|
21
|
+
"FIRST_COLUMN_BACKTICK_PATTERN",
|
|
22
|
+
"SEPARATOR_CELL_PATTERN",
|
|
23
|
+
"REGION_BOUNDARY_PATTERN",
|
|
24
|
+
"RELATIVE_PATH_SOURCE_PATTERN",
|
|
25
|
+
"ALL_REFERENCED_FILE_EXTENSIONS",
|
|
26
|
+
"MAX_SUBTREE_FILES_SCANNED",
|
|
27
|
+
"MAX_ORPHAN_FILE_ISSUES",
|
|
28
|
+
"ORPHAN_FILE_MESSAGE_TEMPLATE",
|
|
29
|
+
"ORPHAN_FILE_SYSTEM_MESSAGE",
|
|
30
|
+
"ORPHAN_FILE_ADDITIONAL_CONTEXT",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
CLAUDE_MD_FILENAME: str = "CLAUDE.md"
|
|
34
|
+
|
|
35
|
+
TABLE_ROW_PATTERN: re.Pattern[str] = re.compile(r"^\s*\|")
|
|
36
|
+
|
|
37
|
+
CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
|
|
38
|
+
|
|
39
|
+
FIRST_COLUMN_BACKTICK_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
|
|
40
|
+
|
|
41
|
+
SEPARATOR_CELL_PATTERN: re.Pattern[str] = re.compile(r"^[\s:\-]+$")
|
|
42
|
+
|
|
43
|
+
REGION_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"^\s*#")
|
|
44
|
+
|
|
45
|
+
RELATIVE_PATH_SOURCE_PATTERN: re.Pattern[str] = re.compile(r"\.\.[\\/]")
|
|
46
|
+
|
|
47
|
+
ALL_REFERENCED_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
48
|
+
{
|
|
49
|
+
".py",
|
|
50
|
+
".md",
|
|
51
|
+
".json",
|
|
52
|
+
".mjs",
|
|
53
|
+
".js",
|
|
54
|
+
".ts",
|
|
55
|
+
".ps1",
|
|
56
|
+
".cmd",
|
|
57
|
+
".ahk",
|
|
58
|
+
".yml",
|
|
59
|
+
".yaml",
|
|
60
|
+
".sh",
|
|
61
|
+
".txt",
|
|
62
|
+
".cfg",
|
|
63
|
+
".toml",
|
|
64
|
+
".ini",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
MAX_SUBTREE_FILES_SCANNED: int = 5000
|
|
69
|
+
|
|
70
|
+
MAX_ORPHAN_FILE_ISSUES: int = 20
|
|
71
|
+
|
|
72
|
+
ORPHAN_FILE_MESSAGE_TEMPLATE: str = (
|
|
73
|
+
"CLAUDE.md table references files that exist nowhere under {directory}: "
|
|
74
|
+
"{missing}. A per-directory CLAUDE.md table names files in its own directory "
|
|
75
|
+
"subtree; a first-column cell naming a file absent from that subtree points a "
|
|
76
|
+
"reader at something that is not there. Drop the row, or correct the cell to "
|
|
77
|
+
"name a file that exists in this directory, a subdirectory of it, or a sibling "
|
|
78
|
+
"directory under its parent."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
ORPHAN_FILE_SYSTEM_MESSAGE: str = (
|
|
82
|
+
"CLAUDE.md table names a file that does not exist in its directory subtree - "
|
|
83
|
+
"drop the row or name an existing file"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
ORPHAN_FILE_ADDITIONAL_CONTEXT: str = (
|
|
87
|
+
"Each first-column table cell wrapped in backticks that ends in a known file "
|
|
88
|
+
"extension must name a file present under the scan root: this CLAUDE.md's own "
|
|
89
|
+
"directory, a subdirectory of it, or a sibling directory under its parent. "
|
|
90
|
+
"Cells holding a path with a slash, a subdirectory ending in '/', or a "
|
|
91
|
+
"slash-command are out of scope. A table whose own block names an explicit "
|
|
92
|
+
"relative-path source (a '../' token) documents files outside the subtree and "
|
|
93
|
+
"is out of scope. For each missing file:\n"
|
|
94
|
+
" - delete the table row, or\n"
|
|
95
|
+
" - rename the cell to an existing file under the scan root."
|
|
96
|
+
)
|