claude-dev-env 7.0.0 → 7.1.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/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +78 -0
- package/bin/install-session-continuity.test.mjs +42 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
|
@@ -1,1277 +0,0 @@
|
|
|
1
|
-
"""Tests for claude_md_orphan_file_blocker hook."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import importlib
|
|
5
|
-
import os
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
import time
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
_test_file_path = Path(__file__).resolve()
|
|
14
|
-
_blocking_directory = str(_test_file_path.parent)
|
|
15
|
-
_hooks_directory = str(_test_file_path.parent.parent)
|
|
16
|
-
_blocking_directory_key = os.path.normcase(os.path.abspath(_blocking_directory))
|
|
17
|
-
_hooks_directory_key = os.path.normcase(os.path.abspath(_hooks_directory))
|
|
18
|
-
_hooks_directory_origin_key = _hooks_directory_key
|
|
19
|
-
sys.path[:] = [_blocking_directory, _hooks_directory] + [
|
|
20
|
-
each_path
|
|
21
|
-
for each_path in sys.path
|
|
22
|
-
if not isinstance(each_path, str)
|
|
23
|
-
or os.path.normcase(os.path.abspath(each_path))
|
|
24
|
-
not in (_blocking_directory_key, _hooks_directory_key)
|
|
25
|
-
]
|
|
26
|
-
importlib.invalidate_caches()
|
|
27
|
-
|
|
28
|
-
_all_cached_module_prefixes = (
|
|
29
|
-
"claude_md_orphan_file_blocker",
|
|
30
|
-
"claude_md_orphan_file_blocker_parts",
|
|
31
|
-
"inventory_intent_records",
|
|
32
|
-
"hooks_constants",
|
|
33
|
-
"code_rules_annotations_length",
|
|
34
|
-
"code_rules_boolean_mustcheck",
|
|
35
|
-
"code_rules_naming_collection",
|
|
36
|
-
"code_rules_path_utils",
|
|
37
|
-
"code_rules_shared",
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
for each_module_name, each_module in tuple(sys.modules.items()):
|
|
42
|
-
if not any(
|
|
43
|
-
each_module_name == each_prefix or each_module_name.startswith(f"{each_prefix}.")
|
|
44
|
-
for each_prefix in _all_cached_module_prefixes
|
|
45
|
-
):
|
|
46
|
-
continue
|
|
47
|
-
module_file = getattr(each_module, "__file__", None)
|
|
48
|
-
is_cached_module_in_this_checkout = False
|
|
49
|
-
if isinstance(module_file, str):
|
|
50
|
-
module_file_key = os.path.normcase(str(Path(module_file).resolve()))
|
|
51
|
-
try:
|
|
52
|
-
is_cached_module_in_this_checkout = (
|
|
53
|
-
os.path.commonpath((_hooks_directory_origin_key, module_file_key))
|
|
54
|
-
== _hooks_directory_origin_key
|
|
55
|
-
)
|
|
56
|
-
except ValueError:
|
|
57
|
-
pass
|
|
58
|
-
if is_cached_module_in_this_checkout:
|
|
59
|
-
continue
|
|
60
|
-
raise ImportError(
|
|
61
|
-
"test_claude_md_orphan_file_blocker: cached module outside this checkout: "
|
|
62
|
-
f"{each_module_name} ({module_file!r})"
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
import claude_md_orphan_file_blocker as blocker_module
|
|
66
|
-
from claude_md_orphan_file_blocker import (
|
|
67
|
-
find_missing_filenames,
|
|
68
|
-
find_referenced_filenames,
|
|
69
|
-
find_run_command_filenames,
|
|
70
|
-
)
|
|
71
|
-
from code_rules_annotations_length import check_unused_known_pytest_fixture_parameters
|
|
72
|
-
from code_rules_naming_collection import check_collection_prefix
|
|
73
|
-
|
|
74
|
-
from hooks_constants.claude_md_orphan_file_blocker_constants import (
|
|
75
|
-
ORPHAN_FILE_ADDITIONAL_CONTEXT,
|
|
76
|
-
ORPHAN_FILE_MESSAGE_TEMPLATE,
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "claude_md_orphan_file_blocker.py")
|
|
80
|
-
|
|
81
|
-
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
82
|
-
|
|
83
|
-
ONE_SECOND = 1.0
|
|
84
|
-
|
|
85
|
-
TABLE_WITH_PRESENT_FILE = (
|
|
86
|
-
"# example\n\n| File | What it does |\n|---|---|\n| `present_module.py` | Does a thing |\n"
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
TABLE_WITH_ABSENT_FILE = (
|
|
90
|
-
"# example\n\n| File | What it does |\n|---|---|\n| `reviewer_specs.py` | Does a thing |\n"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
TABLE_WITH_ABSENT_README = (
|
|
94
|
-
"# example\n\n| Document | Purpose |\n|---|---|\n| `README.md` | Overview |\n"
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
TABLE_WITH_SLASH_COMMAND_AND_SUBDIR = (
|
|
98
|
-
"# example\n\n"
|
|
99
|
-
"| Entry | Description |\n"
|
|
100
|
-
"|---|---|\n"
|
|
101
|
-
"| `/sr-loop` | Slash command |\n"
|
|
102
|
-
"| `scripts/` | A subdirectory |\n"
|
|
103
|
-
"| Plain prose, no backticks | Not a file |\n"
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class _RunHook:
|
|
108
|
-
"""Helper to test the hook via subprocess, mirroring the sibling test style."""
|
|
109
|
-
|
|
110
|
-
def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
|
|
111
|
-
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
112
|
-
return subprocess.run(
|
|
113
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
114
|
-
input=payload,
|
|
115
|
-
capture_output=True,
|
|
116
|
-
text=True,
|
|
117
|
-
check=False,
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
_run_hook = _RunHook()
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _isolated_claude_md_path(tmp_path: Path) -> Path:
|
|
125
|
-
"""Return a CLAUDE.md path nested in a dedicated empty directory.
|
|
126
|
-
|
|
127
|
-
Nesting the CLAUDE.md inside a child of tmp_path keeps the hook's scan root
|
|
128
|
-
(the CLAUDE.md directory's parent) controlled, so a sibling test's temp
|
|
129
|
-
content never resolves a filename the case expects to be absent.
|
|
130
|
-
"""
|
|
131
|
-
isolated_directory = tmp_path / "package_directory"
|
|
132
|
-
isolated_directory.mkdir()
|
|
133
|
-
return isolated_directory / "CLAUDE.md"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _run_isolated_test_module_import(
|
|
137
|
-
driver_setup: str, driver_assertions: str
|
|
138
|
-
) -> subprocess.CompletedProcess:
|
|
139
|
-
"""Import this test module in an isolated interpreter with caller-supplied state."""
|
|
140
|
-
test_module_path = _test_file_path
|
|
141
|
-
driver = (
|
|
142
|
-
"import importlib.util\n"
|
|
143
|
-
"import os\n"
|
|
144
|
-
"import site\n"
|
|
145
|
-
"import sys\n"
|
|
146
|
-
"from pathlib import Path\n"
|
|
147
|
-
f"test_module_path = Path({str(test_module_path)!r})\n"
|
|
148
|
-
"sys.path.append(site.getusersitepackages())\n"
|
|
149
|
-
f"{driver_setup}"
|
|
150
|
-
"spec = importlib.util.spec_from_file_location('bootstrap_target_test', test_module_path)\n"
|
|
151
|
-
"module = importlib.util.module_from_spec(spec)\n"
|
|
152
|
-
"spec.loader.exec_module(module)\n"
|
|
153
|
-
f"{driver_assertions}"
|
|
154
|
-
)
|
|
155
|
-
return subprocess.run(
|
|
156
|
-
[sys.executable, "-I", "-c", driver],
|
|
157
|
-
check=False,
|
|
158
|
-
capture_output=True,
|
|
159
|
-
text=True,
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def test_import_bootstrap_prioritizes_this_checkout(tmp_path: Path) -> None:
|
|
164
|
-
foreign_hooks_directory = tmp_path / "foreign_hooks"
|
|
165
|
-
foreign_blocking_directory = foreign_hooks_directory / "blocking"
|
|
166
|
-
foreign_constants_directory = foreign_hooks_directory / "hooks_constants"
|
|
167
|
-
foreign_blocking_directory.mkdir(parents=True)
|
|
168
|
-
foreign_constants_directory.mkdir()
|
|
169
|
-
(foreign_blocking_directory / "claude_md_orphan_file_blocker.py").write_text(
|
|
170
|
-
"raise RuntimeError('foreign blocker loaded')\n", encoding="utf-8"
|
|
171
|
-
)
|
|
172
|
-
(foreign_constants_directory / "__init__.py").write_text(
|
|
173
|
-
"raise RuntimeError('foreign constants loaded')\n", encoding="utf-8"
|
|
174
|
-
)
|
|
175
|
-
target_blocking_directory = Path(__file__).resolve().parent
|
|
176
|
-
target_hooks_directory = target_blocking_directory.parent
|
|
177
|
-
driver_setup = (
|
|
178
|
-
f"foreign_blocking_directory = Path({str(foreign_blocking_directory)!r})\n"
|
|
179
|
-
f"foreign_hooks_directory = Path({str(foreign_hooks_directory)!r})\n"
|
|
180
|
-
f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
|
|
181
|
-
f"target_hooks_directory = Path({str(target_hooks_directory)!r})\n"
|
|
182
|
-
"sys.path[:0] = [\n"
|
|
183
|
-
" str(foreign_blocking_directory), str(foreign_hooks_directory),\n"
|
|
184
|
-
" str(target_blocking_directory), str(target_hooks_directory),\n"
|
|
185
|
-
"]\n"
|
|
186
|
-
)
|
|
187
|
-
driver_assertions = (
|
|
188
|
-
"import claude_md_orphan_file_blocker\n"
|
|
189
|
-
"import hooks_constants\n"
|
|
190
|
-
"assert Path(claude_md_orphan_file_blocker.__file__).resolve().parent "
|
|
191
|
-
"== target_blocking_directory\n"
|
|
192
|
-
"assert Path(hooks_constants.__file__).resolve().parent.parent "
|
|
193
|
-
"== target_hooks_directory\n"
|
|
194
|
-
"all_uncovered_module_names = [\n"
|
|
195
|
-
" each_module_name\n"
|
|
196
|
-
" for each_module_name, each_loaded_module in sys.modules.items()\n"
|
|
197
|
-
" if each_loaded_module is not module\n"
|
|
198
|
-
" and isinstance(getattr(each_loaded_module, '__file__', None), str)\n"
|
|
199
|
-
" and Path(each_loaded_module.__file__).resolve().is_relative_to(\n"
|
|
200
|
-
" target_hooks_directory\n"
|
|
201
|
-
" )\n"
|
|
202
|
-
" and not any(\n"
|
|
203
|
-
" each_module_name == each_prefix\n"
|
|
204
|
-
" or each_module_name.startswith(f'{each_prefix}.')\n"
|
|
205
|
-
" for each_prefix in module._all_cached_module_prefixes\n"
|
|
206
|
-
" )\n"
|
|
207
|
-
"]\n"
|
|
208
|
-
"assert not all_uncovered_module_names, all_uncovered_module_names\n"
|
|
209
|
-
)
|
|
210
|
-
completed = _run_isolated_test_module_import(driver_setup, driver_assertions)
|
|
211
|
-
assert completed.returncode == 0, completed.stderr
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def test_import_bootstrap_preserves_non_string_path_entries() -> None:
|
|
215
|
-
completed = _run_isolated_test_module_import(
|
|
216
|
-
"sys.path.insert(0, None)\n", "assert None in sys.path\n"
|
|
217
|
-
)
|
|
218
|
-
assert completed.returncode == 0, completed.stderr
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def test_import_bootstrap_invalidates_stale_importer_cache() -> None:
|
|
222
|
-
target_blocking_directory = Path(__file__).resolve().parent
|
|
223
|
-
driver_setup = (
|
|
224
|
-
f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
|
|
225
|
-
"sys.path_importer_cache[str(target_blocking_directory)] = None\n"
|
|
226
|
-
)
|
|
227
|
-
completed = _run_isolated_test_module_import(driver_setup, "")
|
|
228
|
-
assert completed.returncode == 0, completed.stderr
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
@pytest.mark.parametrize(
|
|
232
|
-
"cached_module_name",
|
|
233
|
-
(
|
|
234
|
-
"hooks_constants",
|
|
235
|
-
"claude_md_orphan_file_blocker",
|
|
236
|
-
"code_rules_path_utils",
|
|
237
|
-
),
|
|
238
|
-
)
|
|
239
|
-
def test_import_bootstrap_rejects_foreign_cached_modules(
|
|
240
|
-
tmp_path: Path, cached_module_name: str
|
|
241
|
-
) -> None:
|
|
242
|
-
foreign_module_path = tmp_path / "foreign_hooks" / f"{cached_module_name}.py"
|
|
243
|
-
driver_setup = (
|
|
244
|
-
"import types\n"
|
|
245
|
-
f"cached_module_name = {cached_module_name!r}\n"
|
|
246
|
-
"cached_module = types.ModuleType(cached_module_name)\n"
|
|
247
|
-
f"cached_module.__file__ = {str(foreign_module_path)!r}\n"
|
|
248
|
-
"sys.modules[cached_module_name] = cached_module\n"
|
|
249
|
-
)
|
|
250
|
-
completed = _run_isolated_test_module_import(driver_setup, "")
|
|
251
|
-
assert completed.returncode != 0
|
|
252
|
-
assert f"cached module outside this checkout: {cached_module_name}" in completed.stderr
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def test_blocks_write_naming_absent_python_file(tmp_path: Path):
|
|
256
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
257
|
-
result = _run_hook(
|
|
258
|
-
"Write",
|
|
259
|
-
{
|
|
260
|
-
"file_path": str(claude_md_path),
|
|
261
|
-
"content": TABLE_WITH_ABSENT_FILE,
|
|
262
|
-
},
|
|
263
|
-
)
|
|
264
|
-
assert result.returncode == 0
|
|
265
|
-
output = json.loads(result.stdout)
|
|
266
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
267
|
-
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def test_blocks_write_naming_absent_markdown_file(tmp_path: Path):
|
|
271
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
272
|
-
result = _run_hook(
|
|
273
|
-
"Write",
|
|
274
|
-
{
|
|
275
|
-
"file_path": str(claude_md_path),
|
|
276
|
-
"content": TABLE_WITH_ABSENT_README,
|
|
277
|
-
},
|
|
278
|
-
)
|
|
279
|
-
assert result.returncode == 0
|
|
280
|
-
output = json.loads(result.stdout)
|
|
281
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
282
|
-
assert "README.md" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def test_allows_write_when_referenced_file_present(tmp_path: Path):
|
|
286
|
-
(tmp_path / "present_module.py").write_text("x = 1\n", encoding="utf-8")
|
|
287
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
288
|
-
result = _run_hook(
|
|
289
|
-
"Write",
|
|
290
|
-
{
|
|
291
|
-
"file_path": str(claude_md_path),
|
|
292
|
-
"content": TABLE_WITH_PRESENT_FILE,
|
|
293
|
-
},
|
|
294
|
-
)
|
|
295
|
-
assert result.returncode == 0
|
|
296
|
-
assert result.stdout == ""
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def test_allows_non_claude_md_target(tmp_path: Path):
|
|
300
|
-
other_path = tmp_path / "README.md"
|
|
301
|
-
result = _run_hook(
|
|
302
|
-
"Write",
|
|
303
|
-
{
|
|
304
|
-
"file_path": str(other_path),
|
|
305
|
-
"content": TABLE_WITH_ABSENT_FILE,
|
|
306
|
-
},
|
|
307
|
-
)
|
|
308
|
-
assert result.returncode == 0
|
|
309
|
-
assert result.stdout == ""
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def test_allows_slash_commands_subdirs_and_prose(tmp_path: Path):
|
|
313
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
314
|
-
result = _run_hook(
|
|
315
|
-
"Write",
|
|
316
|
-
{
|
|
317
|
-
"file_path": str(claude_md_path),
|
|
318
|
-
"content": TABLE_WITH_SLASH_COMMAND_AND_SUBDIR,
|
|
319
|
-
},
|
|
320
|
-
)
|
|
321
|
-
assert result.returncode == 0
|
|
322
|
-
assert result.stdout == ""
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def test_blocks_via_edit_new_string(tmp_path: Path):
|
|
326
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
327
|
-
result = _run_hook(
|
|
328
|
-
"Edit",
|
|
329
|
-
{
|
|
330
|
-
"file_path": str(claude_md_path),
|
|
331
|
-
"old_string": "| `old.py` | row |",
|
|
332
|
-
"new_string": TABLE_WITH_ABSENT_FILE,
|
|
333
|
-
},
|
|
334
|
-
)
|
|
335
|
-
assert result.returncode == 0
|
|
336
|
-
output = json.loads(result.stdout)
|
|
337
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
338
|
-
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def test_blocks_via_multiedit_new_string(tmp_path: Path):
|
|
342
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
343
|
-
(claude_md_path.parent / "present_module.py").write_text("x = 1\n", encoding="utf-8")
|
|
344
|
-
result = _run_hook(
|
|
345
|
-
"MultiEdit",
|
|
346
|
-
{
|
|
347
|
-
"file_path": str(claude_md_path),
|
|
348
|
-
"edits": [
|
|
349
|
-
{"old_string": "a", "new_string": TABLE_WITH_PRESENT_FILE},
|
|
350
|
-
{"old_string": "b", "new_string": TABLE_WITH_ABSENT_FILE},
|
|
351
|
-
],
|
|
352
|
-
},
|
|
353
|
-
)
|
|
354
|
-
assert result.returncode == 0
|
|
355
|
-
output = json.loads(result.stdout)
|
|
356
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
357
|
-
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def test_block_payload_carries_directory_and_system_message(tmp_path: Path):
|
|
361
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
362
|
-
result = _run_hook(
|
|
363
|
-
"Write",
|
|
364
|
-
{
|
|
365
|
-
"file_path": str(claude_md_path),
|
|
366
|
-
"content": TABLE_WITH_ABSENT_FILE,
|
|
367
|
-
},
|
|
368
|
-
)
|
|
369
|
-
assert result.returncode == 0
|
|
370
|
-
output = json.loads(result.stdout)
|
|
371
|
-
assert output["suppressOutput"] is True
|
|
372
|
-
assert isinstance(output["systemMessage"], str)
|
|
373
|
-
assert len(output["systemMessage"]) > 0
|
|
374
|
-
assert str(claude_md_path.parent.resolve()) in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
def test_allows_file_present_in_subdirectory(tmp_path: Path):
|
|
378
|
-
workflows_dir = tmp_path / "workflows"
|
|
379
|
-
workflows_dir.mkdir()
|
|
380
|
-
(workflows_dir / "pr-check.yml").write_text("name: ci\n", encoding="utf-8")
|
|
381
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
382
|
-
content = (
|
|
383
|
-
"# example\n\n"
|
|
384
|
-
"| File | Trigger |\n"
|
|
385
|
-
"|---|---|\n"
|
|
386
|
-
"| `pr-check.yml` | PR opened |\n"
|
|
387
|
-
)
|
|
388
|
-
result = _run_hook(
|
|
389
|
-
"Write",
|
|
390
|
-
{
|
|
391
|
-
"file_path": str(claude_md_path),
|
|
392
|
-
"content": content,
|
|
393
|
-
},
|
|
394
|
-
)
|
|
395
|
-
assert result.returncode == 0
|
|
396
|
-
assert result.stdout == ""
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def test_allows_table_declaring_relative_path_source(tmp_path: Path):
|
|
400
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
401
|
-
content = (
|
|
402
|
-
"# example\n\n"
|
|
403
|
-
"## Shared artifacts (referenced by path, not copied)\n\n"
|
|
404
|
-
"The skill references shared scripts from `../_shared/pr-loop/scripts/`:\n\n"
|
|
405
|
-
"| Script | Role |\n"
|
|
406
|
-
"|---|---|\n"
|
|
407
|
-
"| `preflight.py` | Pre-flight check |\n"
|
|
408
|
-
)
|
|
409
|
-
result = _run_hook(
|
|
410
|
-
"Write",
|
|
411
|
-
{
|
|
412
|
-
"file_path": str(claude_md_path),
|
|
413
|
-
"content": content,
|
|
414
|
-
},
|
|
415
|
-
)
|
|
416
|
-
assert result.returncode == 0
|
|
417
|
-
assert result.stdout == ""
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def test_separator_row_is_skipped(tmp_path: Path):
|
|
421
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
422
|
-
content = "| File | Note |\n|---|---|\n"
|
|
423
|
-
result = _run_hook(
|
|
424
|
-
"Write",
|
|
425
|
-
{
|
|
426
|
-
"file_path": str(claude_md_path),
|
|
427
|
-
"content": content,
|
|
428
|
-
},
|
|
429
|
-
)
|
|
430
|
-
assert result.returncode == 0
|
|
431
|
-
assert result.stdout == ""
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def test_relative_path_table_does_not_exempt_sibling_local_table(tmp_path: Path):
|
|
435
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
436
|
-
content = (
|
|
437
|
-
"# example\n\n"
|
|
438
|
-
"## Local files\n\n"
|
|
439
|
-
"| File | Note |\n"
|
|
440
|
-
"|---|---|\n"
|
|
441
|
-
"| `reviewer_specs.py` | Does a thing |\n\n"
|
|
442
|
-
"## Shared artifacts\n\n"
|
|
443
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
444
|
-
"| Script | Role |\n"
|
|
445
|
-
"|---|---|\n"
|
|
446
|
-
"| `preflight.py` | Pre-flight check |\n"
|
|
447
|
-
)
|
|
448
|
-
result = _run_hook(
|
|
449
|
-
"Write",
|
|
450
|
-
{
|
|
451
|
-
"file_path": str(claude_md_path),
|
|
452
|
-
"content": content,
|
|
453
|
-
},
|
|
454
|
-
)
|
|
455
|
-
assert result.returncode == 0
|
|
456
|
-
output = json.loads(result.stdout)
|
|
457
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
458
|
-
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
459
|
-
assert "preflight.py" not in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def test_relative_path_prose_above_its_own_table_exempts_that_table(tmp_path: Path):
|
|
463
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
464
|
-
content = (
|
|
465
|
-
"# example\n\n"
|
|
466
|
-
"## Shared artifacts\n\n"
|
|
467
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
468
|
-
"| Script | Role |\n"
|
|
469
|
-
"|---|---|\n"
|
|
470
|
-
"| `preflight.py` | Pre-flight check |\n"
|
|
471
|
-
)
|
|
472
|
-
result = _run_hook(
|
|
473
|
-
"Write",
|
|
474
|
-
{
|
|
475
|
-
"file_path": str(claude_md_path),
|
|
476
|
-
"content": content,
|
|
477
|
-
},
|
|
478
|
-
)
|
|
479
|
-
assert result.returncode == 0
|
|
480
|
-
assert result.stdout == ""
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def test_edit_to_relative_path_sourced_table_is_allowed(tmp_path: Path):
|
|
484
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
485
|
-
claude_md_path.write_text(
|
|
486
|
-
"# example\n\n"
|
|
487
|
-
"## Shared artifacts\n\n"
|
|
488
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
489
|
-
"| Script | Role |\n"
|
|
490
|
-
"|---|---|\n"
|
|
491
|
-
"| `code_rules_gate.py` | Gate |\n",
|
|
492
|
-
encoding="utf-8",
|
|
493
|
-
)
|
|
494
|
-
result = _run_hook(
|
|
495
|
-
"Edit",
|
|
496
|
-
{
|
|
497
|
-
"file_path": str(claude_md_path),
|
|
498
|
-
"old_string": "| `code_rules_gate.py` | Gate |",
|
|
499
|
-
"new_string": (
|
|
500
|
-
"| `code_rules_gate.py` | Gate |\n"
|
|
501
|
-
"| `preflight.py` | Pre-flight gate that runs before the audit |"
|
|
502
|
-
),
|
|
503
|
-
},
|
|
504
|
-
)
|
|
505
|
-
assert result.returncode == 0
|
|
506
|
-
assert result.stdout == ""
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def test_edit_adding_orphan_to_non_exempt_file_is_denied(tmp_path: Path):
|
|
510
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
511
|
-
claude_md_path.write_text(
|
|
512
|
-
"# example\n\n"
|
|
513
|
-
"## Local files\n\n"
|
|
514
|
-
"| File | Note |\n"
|
|
515
|
-
"|---|---|\n"
|
|
516
|
-
"| `kept.py` | row |\n",
|
|
517
|
-
encoding="utf-8",
|
|
518
|
-
)
|
|
519
|
-
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
520
|
-
result = _run_hook(
|
|
521
|
-
"Edit",
|
|
522
|
-
{
|
|
523
|
-
"file_path": str(claude_md_path),
|
|
524
|
-
"old_string": "| `kept.py` | row |",
|
|
525
|
-
"new_string": (
|
|
526
|
-
"| `kept.py` | row |\n| `reviewer_specs.py` | Does a thing |"
|
|
527
|
-
),
|
|
528
|
-
},
|
|
529
|
-
)
|
|
530
|
-
assert result.returncode == 0
|
|
531
|
-
output = json.loads(result.stdout)
|
|
532
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
533
|
-
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def test_block_lines_yield_their_filenames_when_region_is_not_exempt():
|
|
537
|
-
content = (
|
|
538
|
-
"# example\n\n"
|
|
539
|
-
"## Local files\n\n"
|
|
540
|
-
"| File | Note |\n"
|
|
541
|
-
"|---|---|\n"
|
|
542
|
-
"| `alpha.py` | row |\n"
|
|
543
|
-
"| `beta.py` | row |\n"
|
|
544
|
-
)
|
|
545
|
-
assert find_referenced_filenames(content) == ["alpha.py", "beta.py"]
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
def test_block_lines_yield_nothing_when_region_declares_relative_source():
|
|
549
|
-
content = (
|
|
550
|
-
"# example\n\n"
|
|
551
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
552
|
-
"| Script | Role |\n"
|
|
553
|
-
"|---|---|\n"
|
|
554
|
-
"| `preflight.py` | Pre-flight check |\n"
|
|
555
|
-
)
|
|
556
|
-
assert find_referenced_filenames(content) == []
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
def test_distant_relative_prose_does_not_exempt_later_local_table():
|
|
560
|
-
content = (
|
|
561
|
-
"Intro: see ../shared/ for context.\n\n"
|
|
562
|
-
"## Local files\n\n"
|
|
563
|
-
"| File | Note |\n"
|
|
564
|
-
"|---|---|\n"
|
|
565
|
-
"| `alpha.py` | row |\n"
|
|
566
|
-
)
|
|
567
|
-
assert find_referenced_filenames(content) == ["alpha.py"]
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
def test_relative_prose_then_distant_local_table_is_denied(tmp_path: Path):
|
|
571
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
572
|
-
content = (
|
|
573
|
-
"# example\n\n"
|
|
574
|
-
"Intro: see `../_shared/scripts/` for context.\n\n"
|
|
575
|
-
"## Local files\n\n"
|
|
576
|
-
"| File | Note |\n"
|
|
577
|
-
"|---|---|\n"
|
|
578
|
-
"| `reviewer_specs.py` | Does a thing |\n"
|
|
579
|
-
)
|
|
580
|
-
result = _run_hook(
|
|
581
|
-
"Write",
|
|
582
|
-
{
|
|
583
|
-
"file_path": str(claude_md_path),
|
|
584
|
-
"content": content,
|
|
585
|
-
},
|
|
586
|
-
)
|
|
587
|
-
assert result.returncode == 0
|
|
588
|
-
output = json.loads(result.stdout)
|
|
589
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
590
|
-
assert "reviewer_specs.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
def test_corrective_message_names_siblings_under_parent():
|
|
594
|
-
assert "sibling" in ORPHAN_FILE_MESSAGE_TEMPLATE
|
|
595
|
-
assert "sibling" in ORPHAN_FILE_ADDITIONAL_CONTEXT
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
def test_every_repo_claude_md_is_not_blocked():
|
|
599
|
-
all_claude_md_paths = sorted(REPO_ROOT.rglob("CLAUDE.md"))
|
|
600
|
-
assert all_claude_md_paths, "expected the repo to contain CLAUDE.md files"
|
|
601
|
-
all_offenders: list[str] = []
|
|
602
|
-
for each_path in all_claude_md_paths:
|
|
603
|
-
content = each_path.read_text(encoding="utf-8")
|
|
604
|
-
missing_filenames = find_missing_filenames(content, each_path.parent)
|
|
605
|
-
if missing_filenames:
|
|
606
|
-
all_offenders.append(f"{each_path}: {missing_filenames}")
|
|
607
|
-
assert not all_offenders, "\n".join(all_offenders)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
def test_unrelated_edit_over_preexisting_orphan_row_is_allowed(tmp_path: Path):
|
|
611
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
612
|
-
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
613
|
-
claude_md_path.write_text(
|
|
614
|
-
"# example\n\n"
|
|
615
|
-
"A prose paragraph with a typoo to fix.\n\n"
|
|
616
|
-
"## Local files\n\n"
|
|
617
|
-
"| File | Note |\n"
|
|
618
|
-
"|---|---|\n"
|
|
619
|
-
"| `kept.py` | row |\n"
|
|
620
|
-
"| `already_orphan.py` | pre-existing orphan |\n",
|
|
621
|
-
encoding="utf-8",
|
|
622
|
-
)
|
|
623
|
-
result = _run_hook(
|
|
624
|
-
"Edit",
|
|
625
|
-
{
|
|
626
|
-
"file_path": str(claude_md_path),
|
|
627
|
-
"old_string": "A prose paragraph with a typoo to fix.",
|
|
628
|
-
"new_string": "A prose paragraph with a typo fixed.",
|
|
629
|
-
},
|
|
630
|
-
)
|
|
631
|
-
assert result.returncode == 0
|
|
632
|
-
assert result.stdout == ""
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
def test_multiedit_unrelated_change_over_preexisting_orphan_is_allowed(tmp_path: Path):
|
|
636
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
637
|
-
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
638
|
-
claude_md_path.write_text(
|
|
639
|
-
"# example\n\n"
|
|
640
|
-
"First prose paragraph.\n\n"
|
|
641
|
-
"## Local files\n\n"
|
|
642
|
-
"| File | Note |\n"
|
|
643
|
-
"|---|---|\n"
|
|
644
|
-
"| `kept.py` | row |\n"
|
|
645
|
-
"| `already_orphan.py` | pre-existing orphan |\n",
|
|
646
|
-
encoding="utf-8",
|
|
647
|
-
)
|
|
648
|
-
result = _run_hook(
|
|
649
|
-
"MultiEdit",
|
|
650
|
-
{
|
|
651
|
-
"file_path": str(claude_md_path),
|
|
652
|
-
"edits": [
|
|
653
|
-
{"old_string": "First prose paragraph.", "new_string": "Revised prose paragraph."},
|
|
654
|
-
],
|
|
655
|
-
},
|
|
656
|
-
)
|
|
657
|
-
assert result.returncode == 0
|
|
658
|
-
assert result.stdout == ""
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
def test_fenced_example_table_row_is_skipped(tmp_path: Path):
|
|
662
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
663
|
-
content = (
|
|
664
|
-
"# example\n\n"
|
|
665
|
-
"An example table you might write:\n\n"
|
|
666
|
-
"```\n"
|
|
667
|
-
"| File | Note |\n"
|
|
668
|
-
"|---|---|\n"
|
|
669
|
-
"| `ghostfile.py` | example row |\n"
|
|
670
|
-
"```\n"
|
|
671
|
-
)
|
|
672
|
-
result = _run_hook(
|
|
673
|
-
"Write",
|
|
674
|
-
{
|
|
675
|
-
"file_path": str(claude_md_path),
|
|
676
|
-
"content": content,
|
|
677
|
-
},
|
|
678
|
-
)
|
|
679
|
-
assert result.returncode == 0
|
|
680
|
-
assert result.stdout == ""
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def test_fenced_table_row_does_not_exempt_a_later_real_row():
|
|
684
|
-
content = (
|
|
685
|
-
"# example\n\n"
|
|
686
|
-
"```\n"
|
|
687
|
-
"| File | Note |\n"
|
|
688
|
-
"|---|---|\n"
|
|
689
|
-
"| `ghostfile.py` | fenced example |\n"
|
|
690
|
-
"```\n\n"
|
|
691
|
-
"## Local files\n\n"
|
|
692
|
-
"| File | Note |\n"
|
|
693
|
-
"|---|---|\n"
|
|
694
|
-
"| `reviewer_specs.py` | real row |\n"
|
|
695
|
-
)
|
|
696
|
-
assert find_referenced_filenames(content) == ["reviewer_specs.py"]
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
def test_tilde_fenced_example_table_row_is_skipped():
|
|
700
|
-
content = (
|
|
701
|
-
"# example\n\n"
|
|
702
|
-
"~~~\n"
|
|
703
|
-
"| File | Note |\n"
|
|
704
|
-
"|---|---|\n"
|
|
705
|
-
"| `ghostfile.py` | example row |\n"
|
|
706
|
-
"~~~\n"
|
|
707
|
-
)
|
|
708
|
-
assert find_referenced_filenames(content) == []
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
def test_file_present_only_past_the_scan_cap_is_not_missing(
|
|
712
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
713
|
-
):
|
|
714
|
-
monkeypatch.setattr(blocker_module, "MAX_SUBTREE_FILES_SCANNED", 5)
|
|
715
|
-
package_directory = tmp_path / "package_directory"
|
|
716
|
-
package_directory.mkdir()
|
|
717
|
-
filler_directory = tmp_path / "filler"
|
|
718
|
-
filler_directory.mkdir()
|
|
719
|
-
for each_index in range(50):
|
|
720
|
-
(filler_directory / f"filler_{each_index}.py").write_text("x = 1\n", encoding="utf-8")
|
|
721
|
-
(package_directory / "real_target.py").write_text("x = 1\n", encoding="utf-8")
|
|
722
|
-
claude_md_path = package_directory / "CLAUDE.md"
|
|
723
|
-
content = (
|
|
724
|
-
"# example\n\n"
|
|
725
|
-
"| File | Note |\n"
|
|
726
|
-
"|---|---|\n"
|
|
727
|
-
"| `real_target.py` | a file that genuinely exists |\n"
|
|
728
|
-
)
|
|
729
|
-
first_missing = find_missing_filenames(content, claude_md_path.parent)
|
|
730
|
-
second_missing = find_missing_filenames(content, claude_md_path.parent)
|
|
731
|
-
assert first_missing == []
|
|
732
|
-
assert second_missing == []
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def test_oserror_during_subtree_walk_fails_open(
|
|
736
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
737
|
-
):
|
|
738
|
-
def _raise_oserror(self, pattern):
|
|
739
|
-
raise OSError("simulated unreadable directory")
|
|
740
|
-
|
|
741
|
-
monkeypatch.setattr(Path, "rglob", _raise_oserror)
|
|
742
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
743
|
-
content = (
|
|
744
|
-
"# example\n\n"
|
|
745
|
-
"| File | Note |\n"
|
|
746
|
-
"|---|---|\n"
|
|
747
|
-
"| `reviewer_specs.py` | absent |\n"
|
|
748
|
-
)
|
|
749
|
-
missing_filenames = find_missing_filenames(content, claude_md_path.parent)
|
|
750
|
-
assert missing_filenames == []
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
def test_no_referenced_filenames_performs_no_subtree_walk(
|
|
754
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
755
|
-
):
|
|
756
|
-
def _fail_if_walked(self, pattern):
|
|
757
|
-
raise AssertionError("rglob walked the subtree with no filenames to verify")
|
|
758
|
-
|
|
759
|
-
monkeypatch.setattr(Path, "rglob", _fail_if_walked)
|
|
760
|
-
claude_md_path = tmp_path / "CLAUDE.md"
|
|
761
|
-
content = (
|
|
762
|
-
"# example\n\n"
|
|
763
|
-
"| Task | Command |\n"
|
|
764
|
-
"|---|---|\n"
|
|
765
|
-
"| Run the tests | npm test |\n"
|
|
766
|
-
)
|
|
767
|
-
missing_filenames = find_missing_filenames(content, claude_md_path.parent)
|
|
768
|
-
assert missing_filenames == []
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
def test_noise_directories_are_excluded_from_the_walk(tmp_path: Path):
|
|
772
|
-
package_directory = tmp_path / "package_directory"
|
|
773
|
-
package_directory.mkdir()
|
|
774
|
-
for each_noise_name in (".git", "__pycache__", "node_modules", ".pytest_cache", ".ruff_cache"):
|
|
775
|
-
noise_directory = tmp_path / each_noise_name
|
|
776
|
-
noise_directory.mkdir()
|
|
777
|
-
(noise_directory / "buried_target.py").write_text("x = 1\n", encoding="utf-8")
|
|
778
|
-
claude_md_path = package_directory / "CLAUDE.md"
|
|
779
|
-
content = (
|
|
780
|
-
"# example\n\n"
|
|
781
|
-
"| File | Note |\n"
|
|
782
|
-
"|---|---|\n"
|
|
783
|
-
"| `buried_target.py` | only present inside noise directories |\n"
|
|
784
|
-
)
|
|
785
|
-
missing_filenames = find_missing_filenames(content, claude_md_path.parent)
|
|
786
|
-
assert missing_filenames == ["buried_target.py"]
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def test_blocks_write_when_run_command_invokes_absent_script(tmp_path: Path):
|
|
790
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
791
|
-
content = (
|
|
792
|
-
"# example\n\n"
|
|
793
|
-
"## Running / testing\n\n"
|
|
794
|
-
"Check environment readiness before a run:\n\n"
|
|
795
|
-
"```\n"
|
|
796
|
-
"C:\\Python313\\python.exe test_verification_ready.py\n"
|
|
797
|
-
"```\n"
|
|
798
|
-
)
|
|
799
|
-
result = _run_hook(
|
|
800
|
-
"Write",
|
|
801
|
-
{
|
|
802
|
-
"file_path": str(claude_md_path),
|
|
803
|
-
"content": content,
|
|
804
|
-
},
|
|
805
|
-
)
|
|
806
|
-
assert result.returncode == 0
|
|
807
|
-
output = json.loads(result.stdout)
|
|
808
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
809
|
-
assert (
|
|
810
|
-
"test_verification_ready.py"
|
|
811
|
-
in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
812
|
-
)
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
def test_allows_run_command_invoking_present_script(tmp_path: Path):
|
|
816
|
-
package_directory = tmp_path / "package_directory"
|
|
817
|
-
package_directory.mkdir()
|
|
818
|
-
(package_directory / "verify_ready.py").write_text("x = 1\n", encoding="utf-8")
|
|
819
|
-
claude_md_path = package_directory / "CLAUDE.md"
|
|
820
|
-
content = (
|
|
821
|
-
"# example\n\n"
|
|
822
|
-
"## Running / testing\n\n"
|
|
823
|
-
"```\n"
|
|
824
|
-
"C:\\Python313\\python.exe verify_ready.py\n"
|
|
825
|
-
"```\n"
|
|
826
|
-
)
|
|
827
|
-
result = _run_hook(
|
|
828
|
-
"Write",
|
|
829
|
-
{
|
|
830
|
-
"file_path": str(claude_md_path),
|
|
831
|
-
"content": content,
|
|
832
|
-
},
|
|
833
|
-
)
|
|
834
|
-
assert result.returncode == 0
|
|
835
|
-
assert result.stdout == ""
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
def test_run_command_invoking_path_qualified_present_script_is_allowed(tmp_path: Path):
|
|
839
|
-
tools_directory = tmp_path / "tools"
|
|
840
|
-
tools_directory.mkdir()
|
|
841
|
-
(tools_directory / "verify_themes.py").write_text("x = 1\n", encoding="utf-8")
|
|
842
|
-
claude_md_path = tools_directory / "CLAUDE.md"
|
|
843
|
-
content = (
|
|
844
|
-
"# example\n\n"
|
|
845
|
-
"## Running / testing\n\n"
|
|
846
|
-
"```\n"
|
|
847
|
-
'C:\\Python313\\python.exe "tools/verify_themes.py" path/to/theme.apk\n'
|
|
848
|
-
"```\n"
|
|
849
|
-
)
|
|
850
|
-
result = _run_hook(
|
|
851
|
-
"Write",
|
|
852
|
-
{
|
|
853
|
-
"file_path": str(claude_md_path),
|
|
854
|
-
"content": content,
|
|
855
|
-
},
|
|
856
|
-
)
|
|
857
|
-
assert result.returncode == 0
|
|
858
|
-
assert result.stdout == ""
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
def test_find_run_command_filenames_reads_only_fenced_interpreter_lines():
|
|
862
|
-
content = (
|
|
863
|
-
"# example\n\n"
|
|
864
|
-
"Run `python live_module.py` inline — this prose line is not a fence.\n\n"
|
|
865
|
-
"```\n"
|
|
866
|
-
"C:\\Python313\\python.exe absent_script.py --flag value\n"
|
|
867
|
-
"node tools/build_bundle.mjs\n"
|
|
868
|
-
"echo not-a-script\n"
|
|
869
|
-
"```\n"
|
|
870
|
-
)
|
|
871
|
-
assert find_run_command_filenames(content) == [
|
|
872
|
-
"absent_script.py",
|
|
873
|
-
"build_bundle.mjs",
|
|
874
|
-
]
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
def test_run_command_outside_a_fence_is_not_inspected(tmp_path: Path):
|
|
878
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
879
|
-
content = (
|
|
880
|
-
"# example\n\n"
|
|
881
|
-
"Run `python absent_script.py` to check readiness.\n"
|
|
882
|
-
)
|
|
883
|
-
result = _run_hook(
|
|
884
|
-
"Write",
|
|
885
|
-
{
|
|
886
|
-
"file_path": str(claude_md_path),
|
|
887
|
-
"content": content,
|
|
888
|
-
},
|
|
889
|
-
)
|
|
890
|
-
assert result.returncode == 0
|
|
891
|
-
assert result.stdout == ""
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
def test_run_command_orphan_reported_via_edit(tmp_path: Path):
|
|
895
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
896
|
-
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
897
|
-
claude_md_path.write_text(
|
|
898
|
-
"# example\n\n"
|
|
899
|
-
"## Running / testing\n\n"
|
|
900
|
-
"```\n"
|
|
901
|
-
"C:\\Python313\\python.exe kept.py\n"
|
|
902
|
-
"```\n",
|
|
903
|
-
encoding="utf-8",
|
|
904
|
-
)
|
|
905
|
-
result = _run_hook(
|
|
906
|
-
"Edit",
|
|
907
|
-
{
|
|
908
|
-
"file_path": str(claude_md_path),
|
|
909
|
-
"old_string": "C:\\Python313\\python.exe kept.py",
|
|
910
|
-
"new_string": "C:\\Python313\\python.exe removed_script.py",
|
|
911
|
-
},
|
|
912
|
-
)
|
|
913
|
-
assert result.returncode == 0
|
|
914
|
-
output = json.loads(result.stdout)
|
|
915
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
916
|
-
assert (
|
|
917
|
-
"removed_script.py"
|
|
918
|
-
in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
919
|
-
)
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
def test_run_command_interpreter_substring_in_word_is_not_matched():
|
|
923
|
-
content = (
|
|
924
|
-
"# example\n\n"
|
|
925
|
-
"```\n"
|
|
926
|
-
"git stash # WIP on parse.py\n"
|
|
927
|
-
"rush build # then run verify.py\n"
|
|
928
|
-
"dash run.py\n"
|
|
929
|
-
"ssh deploy@host ./remote_install.sh\n"
|
|
930
|
-
"git stash list build.sh\n"
|
|
931
|
-
"rebash deploy.sh\n"
|
|
932
|
-
"fish shell run.sh\n"
|
|
933
|
-
"```\n"
|
|
934
|
-
)
|
|
935
|
-
assert find_run_command_filenames(content) == []
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def test_run_command_standalone_interpreter_still_matches():
|
|
939
|
-
content = (
|
|
940
|
-
"# example\n\n"
|
|
941
|
-
"```\n"
|
|
942
|
-
"python deploy.py\n"
|
|
943
|
-
"node build.mjs\n"
|
|
944
|
-
"C:\\Python313\\python.exe verify_ready.py\n"
|
|
945
|
-
"bash setup.sh\n"
|
|
946
|
-
"sh install.sh\n"
|
|
947
|
-
"node tools/build_bundle.mjs\n"
|
|
948
|
-
"```\n"
|
|
949
|
-
)
|
|
950
|
-
assert find_run_command_filenames(content) == [
|
|
951
|
-
"deploy.py",
|
|
952
|
-
"build.mjs",
|
|
953
|
-
"verify_ready.py",
|
|
954
|
-
"setup.sh",
|
|
955
|
-
"install.sh",
|
|
956
|
-
"build_bundle.mjs",
|
|
957
|
-
]
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
def test_run_command_prose_mentioning_interpreter_and_script_yields_nothing():
|
|
961
|
-
content = (
|
|
962
|
-
"# example\n\n"
|
|
963
|
-
"```\n"
|
|
964
|
-
"python entrypoint crashed inside order_handler.py during startup\n"
|
|
965
|
-
"python: see config.py for details\n"
|
|
966
|
-
"node --version # build.mjs is the entry\n"
|
|
967
|
-
"python is great; edit build.py later\n"
|
|
968
|
-
"```\n"
|
|
969
|
-
)
|
|
970
|
-
assert find_run_command_filenames(content) == []
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
def test_run_command_invokes_absent_script_is_not_blocked_when_prose(tmp_path: Path):
|
|
974
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
975
|
-
content = (
|
|
976
|
-
"# example\n\n"
|
|
977
|
-
"## Running / testing\n\n"
|
|
978
|
-
"```\n"
|
|
979
|
-
"python entrypoint crashed inside order_handler.py during startup\n"
|
|
980
|
-
"```\n"
|
|
981
|
-
)
|
|
982
|
-
result = _run_hook(
|
|
983
|
-
"Write",
|
|
984
|
-
{
|
|
985
|
-
"file_path": str(claude_md_path),
|
|
986
|
-
"content": content,
|
|
987
|
-
},
|
|
988
|
-
)
|
|
989
|
-
assert result.returncode == 0
|
|
990
|
-
assert result.stdout == ""
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
def test_run_command_with_module_flag_and_value_still_matches():
|
|
994
|
-
content = (
|
|
995
|
-
"# example\n\n"
|
|
996
|
-
"```\n"
|
|
997
|
-
"python -m pytest tests/test_foo.py\n"
|
|
998
|
-
"python script.py --flag value\n"
|
|
999
|
-
"C:\\Python313\\python.exe \"tools/verify_themes.py\" path/to/theme.apk\n"
|
|
1000
|
-
"node tools/build_bundle.mjs\n"
|
|
1001
|
-
"pwsh build.ps1\n"
|
|
1002
|
-
"```\n"
|
|
1003
|
-
)
|
|
1004
|
-
assert find_run_command_filenames(content) == [
|
|
1005
|
-
"test_foo.py",
|
|
1006
|
-
"script.py",
|
|
1007
|
-
"verify_themes.py",
|
|
1008
|
-
"build_bundle.mjs",
|
|
1009
|
-
"build.ps1",
|
|
1010
|
-
]
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
def test_run_command_single_valueless_flag_before_path_captures_full_basename():
|
|
1014
|
-
content = (
|
|
1015
|
-
"# example\n\n"
|
|
1016
|
-
"```\n"
|
|
1017
|
-
"pwsh -File scripts/check.ps1\n"
|
|
1018
|
-
"python3 -u app.py\n"
|
|
1019
|
-
"python -u verify_ready.py\n"
|
|
1020
|
-
"bash -c deploy.sh\n"
|
|
1021
|
-
"node --experimental-vm-modules tools/build.mjs\n"
|
|
1022
|
-
"```\n"
|
|
1023
|
-
)
|
|
1024
|
-
assert find_run_command_filenames(content) == [
|
|
1025
|
-
"check.ps1",
|
|
1026
|
-
"app.py",
|
|
1027
|
-
"verify_ready.py",
|
|
1028
|
-
"deploy.sh",
|
|
1029
|
-
"build.mjs",
|
|
1030
|
-
]
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
def test_allows_run_command_with_valueless_flag_invoking_present_script(tmp_path: Path):
|
|
1034
|
-
scripts_directory = tmp_path / "scripts"
|
|
1035
|
-
scripts_directory.mkdir()
|
|
1036
|
-
(scripts_directory / "check.ps1").write_text("Write-Host ok\n", encoding="utf-8")
|
|
1037
|
-
claude_md_path = scripts_directory / "CLAUDE.md"
|
|
1038
|
-
content = (
|
|
1039
|
-
"# example\n\n"
|
|
1040
|
-
"## Running / testing\n\n"
|
|
1041
|
-
"```\n"
|
|
1042
|
-
"pwsh -File scripts/check.ps1\n"
|
|
1043
|
-
"```\n"
|
|
1044
|
-
)
|
|
1045
|
-
result = _run_hook(
|
|
1046
|
-
"Write",
|
|
1047
|
-
{
|
|
1048
|
-
"file_path": str(claude_md_path),
|
|
1049
|
-
"content": content,
|
|
1050
|
-
},
|
|
1051
|
-
)
|
|
1052
|
-
assert result.returncode == 0
|
|
1053
|
-
assert result.stdout == ""
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
def test_run_command_script_valued_flag_does_not_shadow_real_script():
|
|
1057
|
-
content = (
|
|
1058
|
-
"# example\n\n"
|
|
1059
|
-
"```\n"
|
|
1060
|
-
"node -r ./reg.js app.js\n"
|
|
1061
|
-
"node --require setup.js main.js\n"
|
|
1062
|
-
"node --import ./loader.mjs entry.mjs\n"
|
|
1063
|
-
"node --loader ts-node/esm runner.ts\n"
|
|
1064
|
-
"```\n"
|
|
1065
|
-
)
|
|
1066
|
-
assert find_run_command_filenames(content) == [
|
|
1067
|
-
"app.js",
|
|
1068
|
-
"main.js",
|
|
1069
|
-
"entry.mjs",
|
|
1070
|
-
"runner.ts",
|
|
1071
|
-
]
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
def test_allows_run_command_with_script_valued_flag_invoking_present_script(tmp_path: Path):
|
|
1075
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
1076
|
-
(claude_md_path.parent / "app.js").write_text("console.log('ok');\n", encoding="utf-8")
|
|
1077
|
-
content = (
|
|
1078
|
-
"# example\n\n"
|
|
1079
|
-
"## Running / testing\n\n"
|
|
1080
|
-
"```\n"
|
|
1081
|
-
"node -r ./reg.js app.js\n"
|
|
1082
|
-
"```\n"
|
|
1083
|
-
)
|
|
1084
|
-
result = _run_hook(
|
|
1085
|
-
"Write",
|
|
1086
|
-
{
|
|
1087
|
-
"file_path": str(claude_md_path),
|
|
1088
|
-
"content": content,
|
|
1089
|
-
},
|
|
1090
|
-
)
|
|
1091
|
-
assert result.returncode == 0
|
|
1092
|
-
assert result.stdout == ""
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
def test_run_command_chained_commands_each_contribute_their_basename():
|
|
1096
|
-
content = (
|
|
1097
|
-
"# example\n\n"
|
|
1098
|
-
"```\n"
|
|
1099
|
-
"python deploy.py && node build.mjs\n"
|
|
1100
|
-
"python first.py; python second.py\n"
|
|
1101
|
-
"python upstream.py | python downstream.py\n"
|
|
1102
|
-
"```\n"
|
|
1103
|
-
)
|
|
1104
|
-
assert find_run_command_filenames(content) == [
|
|
1105
|
-
"deploy.py",
|
|
1106
|
-
"build.mjs",
|
|
1107
|
-
"first.py",
|
|
1108
|
-
"second.py",
|
|
1109
|
-
"upstream.py",
|
|
1110
|
-
"downstream.py",
|
|
1111
|
-
]
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
def test_run_command_commented_line_inside_fence_is_skipped():
|
|
1115
|
-
content = (
|
|
1116
|
-
"# example\n\n"
|
|
1117
|
-
"```\n"
|
|
1118
|
-
"# python deploy.py (commented out)\n"
|
|
1119
|
-
" # node build.mjs\n"
|
|
1120
|
-
"```\n"
|
|
1121
|
-
)
|
|
1122
|
-
assert find_run_command_filenames(content) == []
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
def test_run_command_with_relative_path_source_is_exempt():
|
|
1126
|
-
content = (
|
|
1127
|
-
"# example\n\n"
|
|
1128
|
-
"```\n"
|
|
1129
|
-
"python ../shared/preflight.py --check\n"
|
|
1130
|
-
"python ../../tools/deploy.py\n"
|
|
1131
|
-
"```\n"
|
|
1132
|
-
)
|
|
1133
|
-
assert find_run_command_filenames(content) == []
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
def test_run_command_relative_prose_region_exempts_bare_basename_command():
|
|
1137
|
-
content = (
|
|
1138
|
-
"# example\n\n"
|
|
1139
|
-
"## Shared scripts (referenced from `../_shared/scripts/`)\n\n"
|
|
1140
|
-
"```\n"
|
|
1141
|
-
"python preflight.py\n"
|
|
1142
|
-
"```\n"
|
|
1143
|
-
)
|
|
1144
|
-
assert find_run_command_filenames(content) == []
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
def test_run_command_relative_prose_region_exempts_run_command_via_write(tmp_path: Path):
|
|
1148
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
1149
|
-
content = (
|
|
1150
|
-
"# example\n\n"
|
|
1151
|
-
"## Shared scripts\n\n"
|
|
1152
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
1153
|
-
"```\n"
|
|
1154
|
-
"python preflight.py\n"
|
|
1155
|
-
"```\n"
|
|
1156
|
-
)
|
|
1157
|
-
result = _run_hook(
|
|
1158
|
-
"Write",
|
|
1159
|
-
{
|
|
1160
|
-
"file_path": str(claude_md_path),
|
|
1161
|
-
"content": content,
|
|
1162
|
-
},
|
|
1163
|
-
)
|
|
1164
|
-
assert result.returncode == 0
|
|
1165
|
-
assert result.stdout == ""
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
def test_run_command_relative_prose_does_not_exempt_distant_later_fence():
|
|
1169
|
-
content = (
|
|
1170
|
-
"# example\n\n"
|
|
1171
|
-
"## Shared scripts\n\n"
|
|
1172
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
1173
|
-
"```\n"
|
|
1174
|
-
"python preflight.py\n"
|
|
1175
|
-
"```\n\n"
|
|
1176
|
-
"## Local scripts\n\n"
|
|
1177
|
-
"```\n"
|
|
1178
|
-
"python local_orphan.py\n"
|
|
1179
|
-
"```\n"
|
|
1180
|
-
)
|
|
1181
|
-
assert find_run_command_filenames(content) == ["local_orphan.py"]
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
def test_run_command_relative_prose_does_not_exempt_second_fence_under_same_heading():
|
|
1185
|
-
content = (
|
|
1186
|
-
"# example\n\n"
|
|
1187
|
-
"## Shared scripts\n\n"
|
|
1188
|
-
"Referenced from `../_shared/scripts/`:\n\n"
|
|
1189
|
-
"```\n"
|
|
1190
|
-
"python preflight.py\n"
|
|
1191
|
-
"```\n\n"
|
|
1192
|
-
"Run the local check below:\n\n"
|
|
1193
|
-
"```\n"
|
|
1194
|
-
"python local_orphan.py\n"
|
|
1195
|
-
"```\n"
|
|
1196
|
-
)
|
|
1197
|
-
assert find_run_command_filenames(content) == ["local_orphan.py"]
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
def test_run_command_long_multi_flag_no_script_line_returns_quickly():
|
|
1201
|
-
fenced_flags = " ".join(f"-x{each_index}" for each_index in range(200))
|
|
1202
|
-
content = "# example\n\n```\n" f"python {fenced_flags} endingword" "\n```\n"
|
|
1203
|
-
started_at = time.perf_counter()
|
|
1204
|
-
found_filenames = find_run_command_filenames(content)
|
|
1205
|
-
elapsed_seconds = time.perf_counter() - started_at
|
|
1206
|
-
assert found_filenames == []
|
|
1207
|
-
assert elapsed_seconds < ONE_SECOND
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
def test_run_command_inline_trailing_comment_is_not_scanned():
|
|
1211
|
-
content = (
|
|
1212
|
-
"# example\n\n"
|
|
1213
|
-
"```\n"
|
|
1214
|
-
"python real.py # was: python old_build.py\n"
|
|
1215
|
-
"```\n"
|
|
1216
|
-
)
|
|
1217
|
-
assert find_run_command_filenames(content) == ["real.py"]
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
def test_run_command_relative_path_exempt_while_bare_orphan_still_blocks(tmp_path: Path):
|
|
1221
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
1222
|
-
content = (
|
|
1223
|
-
"# example\n\n"
|
|
1224
|
-
"## Running / testing\n\n"
|
|
1225
|
-
"```\n"
|
|
1226
|
-
"python ../shared/preflight.py --check\n"
|
|
1227
|
-
"python absent_script.py\n"
|
|
1228
|
-
"```\n"
|
|
1229
|
-
)
|
|
1230
|
-
result = _run_hook(
|
|
1231
|
-
"Write",
|
|
1232
|
-
{
|
|
1233
|
-
"file_path": str(claude_md_path),
|
|
1234
|
-
"content": content,
|
|
1235
|
-
},
|
|
1236
|
-
)
|
|
1237
|
-
assert result.returncode == 0
|
|
1238
|
-
output = json.loads(result.stdout)
|
|
1239
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
1240
|
-
assert "absent_script.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
1241
|
-
assert "preflight.py" not in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
def test_run_command_orphan_preexisting_on_untouched_line_is_allowed(tmp_path: Path):
|
|
1245
|
-
claude_md_path = _isolated_claude_md_path(tmp_path)
|
|
1246
|
-
(claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
|
|
1247
|
-
claude_md_path.write_text(
|
|
1248
|
-
"# example\n\n"
|
|
1249
|
-
"A prose paragraph with a typoo to fix.\n\n"
|
|
1250
|
-
"## Running / testing\n\n"
|
|
1251
|
-
"```\n"
|
|
1252
|
-
"C:\\Python313\\python.exe kept.py\n"
|
|
1253
|
-
"C:\\Python313\\python.exe already_orphan_script.py\n"
|
|
1254
|
-
"```\n",
|
|
1255
|
-
encoding="utf-8",
|
|
1256
|
-
)
|
|
1257
|
-
result = _run_hook(
|
|
1258
|
-
"Edit",
|
|
1259
|
-
{
|
|
1260
|
-
"file_path": str(claude_md_path),
|
|
1261
|
-
"old_string": "A prose paragraph with a typoo to fix.",
|
|
1262
|
-
"new_string": "A prose paragraph with a typo fixed.",
|
|
1263
|
-
},
|
|
1264
|
-
)
|
|
1265
|
-
assert result.returncode == 0
|
|
1266
|
-
assert result.stdout == ""
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
def test_blocker_module_has_no_collection_parameter_naming_violations():
|
|
1270
|
-
blocker_source = Path(HOOK_SCRIPT_PATH).read_text(encoding="utf-8")
|
|
1271
|
-
assert check_collection_prefix(blocker_source, HOOK_SCRIPT_PATH) == []
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
def test_test_module_has_no_unused_pytest_fixture_parameters():
|
|
1275
|
-
test_file_path = str(Path(__file__).resolve())
|
|
1276
|
-
test_source = Path(test_file_path).read_text(encoding="utf-8")
|
|
1277
|
-
assert check_unused_known_pytest_fixture_parameters(test_source, test_file_path) == []
|