claude-dev-env 1.73.0 → 1.75.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/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +17 -23
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +18 -26
- package/hooks/blocking/md_to_html_blocker.py +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
- package/hooks/blocking/question_to_user_enforcer.py +19 -23
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +15 -23
- package/hooks/blocking/state_description_blocker.py +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/CLAUDE.md +8 -1
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +23 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/autoconverge/SKILL.md +21 -1
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +599 -606
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Tests for package_inventory_stale_blocker hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
|
|
11
|
+
from package_inventory_stale_blocker import (
|
|
12
|
+
find_stale_inventory,
|
|
13
|
+
inventory_named_basenames,
|
|
14
|
+
is_inventoried_production_file,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from hooks_constants.package_inventory_stale_blocker_constants import (
|
|
18
|
+
STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "package_inventory_stale_blocker.py")
|
|
22
|
+
|
|
23
|
+
README_LISTING_TWO_FILES = (
|
|
24
|
+
"# Pipeline\n\n"
|
|
25
|
+
"| Path | Role |\n"
|
|
26
|
+
"|---|---|\n"
|
|
27
|
+
"| `pipeline/dialer_compose.py` | Composes a dialer strip. |\n"
|
|
28
|
+
"| `compose_dialer_cli.py` | CLI for the dialer strip. |\n"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
CLAUDE_MD_BULLET_LIST = (
|
|
32
|
+
"# package\n\n"
|
|
33
|
+
"## Key files\n\n"
|
|
34
|
+
"- `compose_dialer_cli.py` — composes one dialer strip.\n"
|
|
35
|
+
"- `compose_aod_cli.py` — composes the AOD image.\n"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
README_LISTING_ONE_FILE = "# package\n\nSome prose mentioning `single_module.py` once.\n"
|
|
39
|
+
|
|
40
|
+
README_LISTING_ONLY_GLOB_TOKENS = (
|
|
41
|
+
"# package\n\n"
|
|
42
|
+
"This directory holds files matching `*.py` and `*.mjs`.\n"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
README_LISTING_ONLY_FENCED_FILENAMES = (
|
|
46
|
+
"# package\n\n"
|
|
47
|
+
"Example inventory table:\n\n"
|
|
48
|
+
"```\n"
|
|
49
|
+
"| Path | Role |\n"
|
|
50
|
+
"|---|---|\n"
|
|
51
|
+
"| `example_alpha.py` | Sample row. |\n"
|
|
52
|
+
"| `example_beta.py` | Sample row. |\n"
|
|
53
|
+
"```\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
README_LISTING_ONLY_COMMAND_EXAMPLES = (
|
|
57
|
+
"# package\n\n"
|
|
58
|
+
"Run `parent:node_modules package.json` to find the manifest, then "
|
|
59
|
+
"`python <file>.py` and `psql $DATABASE_URL -f <query>.sql`. Import via "
|
|
60
|
+
"`from git_hooks_constants import VALUE`.\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
README_PROSE_NAMES_NON_SIBLING_FILES = (
|
|
64
|
+
"# package\n\n"
|
|
65
|
+
"This directory works alongside `install.mjs` and is documented in "
|
|
66
|
+
"`source-material-section-types.md`.\n"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _RunHook:
|
|
71
|
+
"""Helper to test the hook via subprocess, mirroring the sibling test style."""
|
|
72
|
+
|
|
73
|
+
def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
|
|
74
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
75
|
+
return subprocess.run(
|
|
76
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
77
|
+
input=payload,
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
check=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_run_hook = _RunHook()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _package_directory_with_readme(tmp_path: Path, readme_content: str) -> Path:
|
|
88
|
+
"""Return a fresh package directory holding a README.md with the given content."""
|
|
89
|
+
package_directory = tmp_path / "package_directory"
|
|
90
|
+
package_directory.mkdir()
|
|
91
|
+
(package_directory / "README.md").write_text(readme_content, encoding="utf-8")
|
|
92
|
+
return package_directory
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_sibling_files(package_directory: Path, all_basenames: list[str]) -> None:
|
|
96
|
+
"""Create each named file on disk inside *package_directory*."""
|
|
97
|
+
for each_basename in all_basenames:
|
|
98
|
+
(package_directory / each_basename).write_text("x = 1\n", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_inventory_named_basenames_strips_path_to_basename():
|
|
102
|
+
named_basenames = inventory_named_basenames(README_LISTING_TWO_FILES)
|
|
103
|
+
assert named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_inventory_named_basenames_reads_bullet_list():
|
|
107
|
+
named_basenames = inventory_named_basenames(CLAUDE_MD_BULLET_LIST)
|
|
108
|
+
assert named_basenames == {"compose_dialer_cli.py", "compose_aod_cli.py"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_inventory_named_basenames_rejects_glob_tokens():
|
|
112
|
+
named_basenames = inventory_named_basenames(README_LISTING_ONLY_GLOB_TOKENS)
|
|
113
|
+
assert named_basenames == set()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_inventory_named_basenames_skips_fenced_code_block():
|
|
117
|
+
named_basenames = inventory_named_basenames(README_LISTING_ONLY_FENCED_FILENAMES)
|
|
118
|
+
assert named_basenames == set()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_inventory_named_basenames_rejects_command_example_spans():
|
|
122
|
+
named_basenames = inventory_named_basenames(README_LISTING_ONLY_COMMAND_EXAMPLES)
|
|
123
|
+
assert named_basenames == set()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_blocks_new_production_file_absent_from_readme(tmp_path: Path):
|
|
127
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
128
|
+
_write_sibling_files(package_directory, ["dialer_compose.py", "compose_dialer_cli.py"])
|
|
129
|
+
new_file_path = package_directory / "check_dialer_seam_cli.py"
|
|
130
|
+
result = _run_hook(
|
|
131
|
+
"Write",
|
|
132
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
133
|
+
)
|
|
134
|
+
assert result.returncode == 0
|
|
135
|
+
payload = json.loads(result.stdout)
|
|
136
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
137
|
+
assert "check_dialer_seam_cli.py" in payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
138
|
+
assert payload["systemMessage"] == STALE_INVENTORY_SYSTEM_MESSAGE
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_blocks_new_file_absent_from_claude_md_bullet_list(tmp_path: Path):
|
|
142
|
+
package_directory = tmp_path / "package_directory"
|
|
143
|
+
package_directory.mkdir()
|
|
144
|
+
(package_directory / "CLAUDE.md").write_text(CLAUDE_MD_BULLET_LIST, encoding="utf-8")
|
|
145
|
+
_write_sibling_files(package_directory, ["compose_dialer_cli.py", "compose_aod_cli.py"])
|
|
146
|
+
new_file_path = package_directory / "build_dialer_aod_roster_cli.py"
|
|
147
|
+
result = _run_hook(
|
|
148
|
+
"Write",
|
|
149
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
150
|
+
)
|
|
151
|
+
assert result.returncode == 0
|
|
152
|
+
payload = json.loads(result.stdout)
|
|
153
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_allows_new_file_already_named_in_readme(tmp_path: Path):
|
|
157
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
158
|
+
new_file_path = package_directory / "compose_dialer_cli.py"
|
|
159
|
+
result = _run_hook(
|
|
160
|
+
"Write",
|
|
161
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
162
|
+
)
|
|
163
|
+
assert result.returncode == 0
|
|
164
|
+
assert result.stdout.strip() == ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_allows_new_file_named_by_path_form_in_readme(tmp_path: Path):
|
|
168
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
169
|
+
pipeline_directory = package_directory / "pipeline"
|
|
170
|
+
pipeline_directory.mkdir()
|
|
171
|
+
new_file_path = package_directory / "dialer_compose.py"
|
|
172
|
+
result = _run_hook(
|
|
173
|
+
"Write",
|
|
174
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
175
|
+
)
|
|
176
|
+
assert result.returncode == 0
|
|
177
|
+
assert result.stdout.strip() == ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_allows_directory_with_no_inventory(tmp_path: Path):
|
|
181
|
+
package_directory = tmp_path / "package_directory"
|
|
182
|
+
package_directory.mkdir()
|
|
183
|
+
new_file_path = package_directory / "lonely_module.py"
|
|
184
|
+
result = _run_hook(
|
|
185
|
+
"Write",
|
|
186
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
187
|
+
)
|
|
188
|
+
assert result.returncode == 0
|
|
189
|
+
assert result.stdout.strip() == ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_allows_directory_whose_readme_names_too_few_files(tmp_path: Path):
|
|
193
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_ONE_FILE)
|
|
194
|
+
new_file_path = package_directory / "another_module.py"
|
|
195
|
+
result = _run_hook(
|
|
196
|
+
"Write",
|
|
197
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
198
|
+
)
|
|
199
|
+
assert result.returncode == 0
|
|
200
|
+
assert result.stdout.strip() == ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_allows_new_file_when_inventory_holds_only_glob_tokens(tmp_path: Path):
|
|
204
|
+
package_directory = _package_directory_with_readme(
|
|
205
|
+
tmp_path, README_LISTING_ONLY_GLOB_TOKENS
|
|
206
|
+
)
|
|
207
|
+
new_file_path = package_directory / "new_sibling_module.py"
|
|
208
|
+
result = _run_hook(
|
|
209
|
+
"Write",
|
|
210
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
211
|
+
)
|
|
212
|
+
assert result.returncode == 0
|
|
213
|
+
assert result.stdout.strip() == ""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_allows_new_file_when_inventory_filenames_live_in_code_fence(tmp_path: Path):
|
|
217
|
+
package_directory = _package_directory_with_readme(
|
|
218
|
+
tmp_path, README_LISTING_ONLY_FENCED_FILENAMES
|
|
219
|
+
)
|
|
220
|
+
new_file_path = package_directory / "new_sibling_module.py"
|
|
221
|
+
result = _run_hook(
|
|
222
|
+
"Write",
|
|
223
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
224
|
+
)
|
|
225
|
+
assert result.returncode == 0
|
|
226
|
+
assert result.stdout.strip() == ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_allows_new_file_when_inventory_names_only_non_sibling_files(tmp_path: Path):
|
|
230
|
+
package_directory = _package_directory_with_readme(
|
|
231
|
+
tmp_path, README_PROSE_NAMES_NON_SIBLING_FILES
|
|
232
|
+
)
|
|
233
|
+
new_file_path = package_directory / "new_helper.py"
|
|
234
|
+
result = _run_hook(
|
|
235
|
+
"Write",
|
|
236
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
237
|
+
)
|
|
238
|
+
assert result.returncode == 0
|
|
239
|
+
assert result.stdout.strip() == ""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_blocks_new_file_when_inventory_names_sibling_files_on_disk(tmp_path: Path):
|
|
243
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
244
|
+
(package_directory / "dialer_compose.py").write_text("x = 1\n", encoding="utf-8")
|
|
245
|
+
(package_directory / "compose_dialer_cli.py").write_text("x = 1\n", encoding="utf-8")
|
|
246
|
+
new_file_path = package_directory / "check_dialer_seam_cli.py"
|
|
247
|
+
result = _run_hook(
|
|
248
|
+
"Write",
|
|
249
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
250
|
+
)
|
|
251
|
+
assert result.returncode == 0
|
|
252
|
+
payload = json.loads(result.stdout)
|
|
253
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
254
|
+
assert "check_dialer_seam_cli.py" in payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_allows_test_file_absent_from_inventory(tmp_path: Path):
|
|
258
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
259
|
+
new_file_path = package_directory / "test_check_dialer_seam_cli.py"
|
|
260
|
+
result = _run_hook(
|
|
261
|
+
"Write",
|
|
262
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
263
|
+
)
|
|
264
|
+
assert result.returncode == 0
|
|
265
|
+
assert result.stdout.strip() == ""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_allows_init_file_absent_from_inventory(tmp_path: Path):
|
|
269
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
270
|
+
new_file_path = package_directory / "__init__.py"
|
|
271
|
+
result = _run_hook(
|
|
272
|
+
"Write",
|
|
273
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
274
|
+
)
|
|
275
|
+
assert result.returncode == 0
|
|
276
|
+
assert result.stdout.strip() == ""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_allows_non_code_file_absent_from_inventory(tmp_path: Path):
|
|
280
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
281
|
+
new_file_path = package_directory / "notes.txt"
|
|
282
|
+
result = _run_hook(
|
|
283
|
+
"Write",
|
|
284
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
285
|
+
)
|
|
286
|
+
assert result.returncode == 0
|
|
287
|
+
assert result.stdout.strip() == ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_allows_edit_of_existing_file(tmp_path: Path):
|
|
291
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
292
|
+
existing_file_path = package_directory / "seam_continuity.py"
|
|
293
|
+
existing_file_path.write_text("x = 1\n", encoding="utf-8")
|
|
294
|
+
result = _run_hook(
|
|
295
|
+
"Write",
|
|
296
|
+
{"file_path": str(existing_file_path), "content": "x = 2\n"},
|
|
297
|
+
)
|
|
298
|
+
assert result.returncode == 0
|
|
299
|
+
assert result.stdout.strip() == ""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_is_inventoried_production_file_rejects_config_directory(tmp_path: Path):
|
|
303
|
+
config_directory = tmp_path / "config"
|
|
304
|
+
config_directory.mkdir()
|
|
305
|
+
config_file_path = config_directory / "constants.py"
|
|
306
|
+
assert is_inventoried_production_file(str(config_file_path)) is False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_is_inventoried_production_file_accepts_production_file(tmp_path: Path):
|
|
310
|
+
production_file_path = tmp_path / "dialer_compose.py"
|
|
311
|
+
assert is_inventoried_production_file(str(production_file_path)) is True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_find_stale_inventory_returns_survey_for_omission(tmp_path: Path):
|
|
315
|
+
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
316
|
+
_write_sibling_files(package_directory, ["dialer_compose.py", "compose_dialer_cli.py"])
|
|
317
|
+
new_file_path = package_directory / "seam_continuity.py"
|
|
318
|
+
survey = find_stale_inventory(str(new_file_path))
|
|
319
|
+
assert survey is not None
|
|
320
|
+
assert survey.present_inventory_names == ["README.md"]
|
|
321
|
+
assert survey.named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_find_stale_inventory_skips_prose_only_directory():
|
|
325
|
+
audit_rubrics_directory = (
|
|
326
|
+
Path(__file__).resolve().parent.parent.parent / "audit-rubrics"
|
|
327
|
+
)
|
|
328
|
+
new_file_path = audit_rubrics_directory / "new_helper.py"
|
|
329
|
+
assert find_stale_inventory(str(new_file_path)) is None
|
|
@@ -12,6 +12,7 @@ import os
|
|
|
12
12
|
import subprocess
|
|
13
13
|
import sys
|
|
14
14
|
from pathlib import Path
|
|
15
|
+
from unittest.mock import patch
|
|
15
16
|
|
|
16
17
|
HOOK_SCRIPT_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
17
18
|
_HOOKS_DIR = str(Path(__file__).resolve().parent)
|
|
@@ -37,6 +38,8 @@ find_banned_terms = hook_module.find_banned_terms
|
|
|
37
38
|
strip_non_prose_regions = hook_module.strip_non_prose_regions
|
|
38
39
|
build_block_reason = hook_module.build_block_reason
|
|
39
40
|
|
|
41
|
+
from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
|
|
42
|
+
|
|
40
43
|
|
|
41
44
|
def _run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
42
45
|
return subprocess.run(
|
|
@@ -245,3 +248,36 @@ def test_prose_slash_token_is_not_stripped_as_path() -> None:
|
|
|
245
248
|
|
|
246
249
|
def test_real_file_path_is_still_stripped() -> None:
|
|
247
250
|
assert "initiate" not in strip_non_prose_regions("Edit src/initiate.py to wire it.")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
254
|
+
"""A deny routed through the dispatcher's native path logs one record.
|
|
255
|
+
|
|
256
|
+
On the Write|Edit|MultiEdit surface this hook runs only through
|
|
257
|
+
pre_tool_use_dispatcher's native path, which calls evaluate() and
|
|
258
|
+
build_deny_payload() — never _emit_deny() or main(). The block must still
|
|
259
|
+
land in the hook-blocks log, so the log call lives on build_deny_payload,
|
|
260
|
+
the function the native path executes.
|
|
261
|
+
"""
|
|
262
|
+
deny_payload = {
|
|
263
|
+
"tool_name": "Edit",
|
|
264
|
+
"tool_input": {
|
|
265
|
+
"file_path": str(tmp_path / "notes.md"),
|
|
266
|
+
"new_string": "This guide explains how to utilize the new cache layer.",
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
native_hook = NativeHook(
|
|
270
|
+
evaluate=hook_module.evaluate,
|
|
271
|
+
build_deny_payload=hook_module.build_deny_payload,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
275
|
+
hosted_result = run_native_hook(native_hook, deny_payload, is_blocking=True)
|
|
276
|
+
|
|
277
|
+
assert hosted_result.captured_stdout
|
|
278
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
279
|
+
all_records = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
280
|
+
assert len(all_records) == 1
|
|
281
|
+
logged_record = json.loads(all_records[0])
|
|
282
|
+
assert logged_record["hook"] == "plain_language_blocker.py"
|
|
283
|
+
assert logged_record["event"] == "PreToolUse"
|
|
@@ -26,6 +26,7 @@ if _HOOKS_DIR not in sys.path:
|
|
|
26
26
|
|
|
27
27
|
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
28
28
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
29
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
29
30
|
BLOCKING_CRASH_EXIT_CODE,
|
|
30
31
|
DENY_DECISION,
|
|
31
32
|
EDIT_TOOL_NAME,
|
|
@@ -485,6 +486,52 @@ def test_aggregate_exit_code_two_signals_deny() -> None:
|
|
|
485
486
|
)
|
|
486
487
|
|
|
487
488
|
|
|
489
|
+
def test_aggregate_blocking_hook_crash_surfaces_a_deny() -> None:
|
|
490
|
+
"""A crash in a blocking hook surfaces a deny with the crash reason.
|
|
491
|
+
|
|
492
|
+
When a blocking hook raises a non-SystemExit exception before emitting any
|
|
493
|
+
output, the aggregator must still deny so a bad write does not silently
|
|
494
|
+
pass. The deny reason must be the BLOCKING_CRASH_DENY_REASON constant.
|
|
495
|
+
"""
|
|
496
|
+
all_results = [
|
|
497
|
+
HostedHookResult(
|
|
498
|
+
exit_code=0,
|
|
499
|
+
captured_stdout="",
|
|
500
|
+
did_crash=True,
|
|
501
|
+
is_blocking=True,
|
|
502
|
+
)
|
|
503
|
+
]
|
|
504
|
+
decision = aggregate_hosted_hook_results(all_results)
|
|
505
|
+
assert decision.should_deny, "a blocking hook crash must surface a deny"
|
|
506
|
+
assert decision.all_deny_reasons, (
|
|
507
|
+
"the deny reasons list must be non-empty after a blocking hook crash"
|
|
508
|
+
)
|
|
509
|
+
assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons, (
|
|
510
|
+
"the deny reason from a blocking hook crash must be BLOCKING_CRASH_DENY_REASON.\n"
|
|
511
|
+
f"Got: {decision.all_deny_reasons!r}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_aggregate_non_blocking_hook_crash_does_not_deny() -> None:
|
|
516
|
+
"""A crash in a non-blocking hook does not change an allow to a deny.
|
|
517
|
+
|
|
518
|
+
A hosted hook carrying is_blocking=False must not surface a deny when it
|
|
519
|
+
crashes — the aggregated decision stays allow.
|
|
520
|
+
"""
|
|
521
|
+
all_results = [
|
|
522
|
+
HostedHookResult(
|
|
523
|
+
exit_code=0,
|
|
524
|
+
captured_stdout="",
|
|
525
|
+
did_crash=True,
|
|
526
|
+
is_blocking=False,
|
|
527
|
+
)
|
|
528
|
+
]
|
|
529
|
+
decision = aggregate_hosted_hook_results(all_results)
|
|
530
|
+
assert not decision.should_deny, (
|
|
531
|
+
"a non-blocking hook crash must not change an allow to a deny"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
488
535
|
def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
|
|
489
536
|
"""A HostedHookResult with exit_code 0 and empty stdout signals allow.
|
|
490
537
|
|
|
@@ -585,24 +632,24 @@ def test_dispatcher_write_applies_both_groups() -> None:
|
|
|
585
632
|
assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
|
|
586
633
|
"plain_language_blocker (Group B) must be in Write applicable set"
|
|
587
634
|
)
|
|
588
|
-
assert len(all_write_entries) ==
|
|
589
|
-
f"Write tool must apply to all
|
|
635
|
+
assert len(all_write_entries) == 18, (
|
|
636
|
+
f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
|
|
590
637
|
)
|
|
591
638
|
|
|
592
639
|
|
|
593
640
|
def test_dispatcher_edit_applies_both_groups() -> None:
|
|
594
641
|
"""Edit tool triggers both Group A and Group B hooks through the dispatcher."""
|
|
595
642
|
all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
|
|
596
|
-
assert len(all_edit_entries) ==
|
|
597
|
-
f"Edit tool must apply to all
|
|
643
|
+
assert len(all_edit_entries) == 18, (
|
|
644
|
+
f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
|
|
598
645
|
)
|
|
599
646
|
|
|
600
647
|
|
|
601
648
|
def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
602
|
-
"""MultiEdit tool triggers only Group B (
|
|
649
|
+
"""MultiEdit tool triggers only Group B (7 hooks), not Group A."""
|
|
603
650
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
604
|
-
assert len(all_multi_edit_entries) ==
|
|
605
|
-
f"MultiEdit tool must apply to exactly
|
|
651
|
+
assert len(all_multi_edit_entries) == 7, (
|
|
652
|
+
f"MultiEdit tool must apply to exactly 7 Group-B hooks, got {len(all_multi_edit_entries)}"
|
|
606
653
|
)
|
|
607
654
|
|
|
608
655
|
|
|
@@ -613,7 +660,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
|
|
|
613
660
|
it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
|
|
614
661
|
A Python Write payload that run_all_validators would have flagged (mypy errors, for
|
|
615
662
|
instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
|
|
616
|
-
dispatcher covers only its
|
|
663
|
+
dispatcher covers only its 18 hosted blocking hooks — none of which includes the
|
|
617
664
|
validators runner.
|
|
618
665
|
"""
|
|
619
666
|
python_content_with_type_error = (
|
|
@@ -14,7 +14,9 @@ if _HOOKS_DIR not in sys.path:
|
|
|
14
14
|
sys.path.insert(0, _HOOKS_DIR)
|
|
15
15
|
if _HOOKS_ROOT not in sys.path:
|
|
16
16
|
sys.path.insert(0, _HOOKS_ROOT)
|
|
17
|
+
import question_to_user_enforcer
|
|
17
18
|
from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE
|
|
19
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
18
20
|
|
|
19
21
|
CLEAN_DECLARATIVE_MESSAGE = "I applied the rename across both files. The tests pass."
|
|
20
22
|
TRAILING_QUESTION_MESSAGE = (
|
|
@@ -68,6 +70,10 @@ def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess
|
|
|
68
70
|
return run_hook_with_payload({"last_assistant_message": assistant_message})
|
|
69
71
|
|
|
70
72
|
|
|
73
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
74
|
+
assert question_to_user_enforcer.strip_code_and_quotes is strip_code_and_quotes
|
|
75
|
+
|
|
76
|
+
|
|
71
77
|
def test_clean_declarative_message_passes_through():
|
|
72
78
|
completed_process = run_hook_with_message(CLEAN_DECLARATIVE_MESSAGE)
|
|
73
79
|
assert completed_process.returncode == 0
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Unit tests for the send-user-file-open-locally PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
11
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
13
|
+
|
|
14
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"send_user_file_open_locally_blocker",
|
|
16
|
+
_HOOK_DIR / "send_user_file_open_locally_blocker.py",
|
|
17
|
+
)
|
|
18
|
+
assert hook_spec is not None
|
|
19
|
+
assert hook_spec.loader is not None
|
|
20
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
21
|
+
hook_spec.loader.exec_module(hook_module)
|
|
22
|
+
|
|
23
|
+
_should_block = hook_module._should_block
|
|
24
|
+
|
|
25
|
+
from hooks_constants.send_user_file_open_locally_blocker_constants import (
|
|
26
|
+
CORRECTIVE_MESSAGE,
|
|
27
|
+
PROACTIVE_STATUS,
|
|
28
|
+
TOOL_NAME,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_blocks_normal_status() -> None:
|
|
33
|
+
assert _should_block("normal") is True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_blocks_empty_status() -> None:
|
|
37
|
+
assert _should_block("") is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_blocks_unknown_status() -> None:
|
|
41
|
+
assert _should_block("whatever") is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_allows_proactive_status() -> None:
|
|
45
|
+
assert _should_block(PROACTIVE_STATUS) is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_corrective_message_points_to_show_asset() -> None:
|
|
49
|
+
assert "Show-Asset.ps1" in CORRECTIVE_MESSAGE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_corrective_message_names_proactive_escape_hatch() -> None:
|
|
53
|
+
assert PROACTIVE_STATUS in CORRECTIVE_MESSAGE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_main_with_io(input_text: str) -> str:
|
|
57
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
58
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
|
|
59
|
+
try:
|
|
60
|
+
hook_module.main()
|
|
61
|
+
except SystemExit:
|
|
62
|
+
pass
|
|
63
|
+
return mock_stdout.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_main_blocks_normal_attach() -> None:
|
|
67
|
+
hook_input = {
|
|
68
|
+
"tool_name": TOOL_NAME,
|
|
69
|
+
"tool_input": {"files": ["report.html"], "status": "normal"},
|
|
70
|
+
}
|
|
71
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
72
|
+
output = json.loads(output_text)
|
|
73
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
74
|
+
assert "Show-Asset.ps1" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_main_allows_proactive_attach() -> None:
|
|
78
|
+
hook_input = {
|
|
79
|
+
"tool_name": TOOL_NAME,
|
|
80
|
+
"tool_input": {"files": ["report.html"], "status": "proactive"},
|
|
81
|
+
}
|
|
82
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_main_blocks_when_status_missing() -> None:
|
|
86
|
+
hook_input = {
|
|
87
|
+
"tool_name": TOOL_NAME,
|
|
88
|
+
"tool_input": {"files": ["report.html"]},
|
|
89
|
+
}
|
|
90
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
91
|
+
output = json.loads(output_text)
|
|
92
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_main_blocks_when_tool_input_is_null() -> None:
|
|
96
|
+
hook_input = {
|
|
97
|
+
"tool_name": TOOL_NAME,
|
|
98
|
+
"tool_input": None,
|
|
99
|
+
}
|
|
100
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
101
|
+
output = json.loads(output_text)
|
|
102
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_main_passes_wrong_tool_name() -> None:
|
|
106
|
+
hook_input = {
|
|
107
|
+
"tool_name": "Write",
|
|
108
|
+
"tool_input": {"files": ["report.html"], "status": "normal"},
|
|
109
|
+
}
|
|
110
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_main_passes_malformed_json() -> None:
|
|
114
|
+
assert _run_main_with_io("not valid json {{{") == ""
|
|
@@ -16,6 +16,12 @@ if _HOOKS_ROOT not in sys.path:
|
|
|
16
16
|
sys.path.insert(0, _HOOKS_ROOT)
|
|
17
17
|
import session_handoff_blocker
|
|
18
18
|
from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE
|
|
19
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
23
|
+
assert session_handoff_blocker.strip_code_and_quotes is strip_code_and_quotes
|
|
24
|
+
|
|
19
25
|
|
|
20
26
|
NEW_SESSION_PROPOSAL_MESSAGE = (
|
|
21
27
|
"I recommend we continue this in a fresh session to keep things manageable."
|
|
@@ -33,6 +33,7 @@ ALL_CONVERTED_HOOK_FILENAMES = (
|
|
|
33
33
|
"claude_md_orphan_file_blocker.py",
|
|
34
34
|
"pr_converge_bugteam_enforcer.py",
|
|
35
35
|
"verdict_directory_write_blocker.py",
|
|
36
|
+
"package_inventory_stale_blocker.py",
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
EMPTY_STDIN_PAYLOAD = ""
|
|
@@ -142,6 +143,47 @@ def test_open_questions_blocker_still_allows_plan_without_open_questions(
|
|
|
142
143
|
assert _decision_from_stdout(completed) is None
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
def test_package_inventory_blocker_still_denies_uninventoried_new_file(
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
) -> None:
|
|
149
|
+
inventory_body = "# package\n\n| File | Role |\n|---|---|\n| `alpha.py` | A |\n| `beta.py` | B |\n"
|
|
150
|
+
(tmp_path / "README.md").write_text(inventory_body, encoding="utf-8")
|
|
151
|
+
(tmp_path / "alpha.py").write_text("x = 1\n", encoding="utf-8")
|
|
152
|
+
(tmp_path / "beta.py").write_text("x = 1\n", encoding="utf-8")
|
|
153
|
+
new_file_path = tmp_path / "gamma.py"
|
|
154
|
+
payload = json.dumps(
|
|
155
|
+
{
|
|
156
|
+
"tool_name": "Write",
|
|
157
|
+
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
161
|
+
assert completed.returncode == 0
|
|
162
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_package_inventory_blocker_still_allows_inventoried_new_file(
|
|
166
|
+
tmp_path: Path,
|
|
167
|
+
) -> None:
|
|
168
|
+
inventory_body = (
|
|
169
|
+
"# package\n\n| File | Role |\n|---|---|\n"
|
|
170
|
+
"| `alpha.py` | A |\n| `beta.py` | B |\n| `gamma.py` | G |\n"
|
|
171
|
+
)
|
|
172
|
+
(tmp_path / "README.md").write_text(inventory_body, encoding="utf-8")
|
|
173
|
+
(tmp_path / "alpha.py").write_text("x = 1\n", encoding="utf-8")
|
|
174
|
+
(tmp_path / "beta.py").write_text("x = 1\n", encoding="utf-8")
|
|
175
|
+
new_file_path = tmp_path / "gamma.py"
|
|
176
|
+
payload = json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"tool_name": "Write",
|
|
179
|
+
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
183
|
+
assert completed.returncode == 0
|
|
184
|
+
assert _decision_from_stdout(completed) is None
|
|
185
|
+
|
|
186
|
+
|
|
145
187
|
def test_converted_hooks_allow_unrelated_tool_name() -> None:
|
|
146
188
|
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
|
147
189
|
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|