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.
@@ -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 hooks_constants.messages import USER_FACING_TDD_NOTICE
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) == []