claude-dev-env 1.49.1 → 1.50.1
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/audit-rubrics/category_rubrics/category-a-api-contracts.md +17 -3
- package/audit-rubrics/prompts/category-a-api-contracts.md +17 -2
- package/docs/CODE_RULES.md +6 -1
- package/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_enforcer.py +386 -32
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/md_to_html_blocker.py +2 -2
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/test_code_rules_enforcer.py +65 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +136 -5
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +256 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +137 -1
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/hooks_constants/blocking_check_limits.py +2 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +15 -1
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +1 -1
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +11 -4
- package/package.json +1 -1
- package/hooks/blocking/test_md_to_html_blocker.py +0 -772
|
@@ -26,7 +26,7 @@ TEST_FILE_PATH = "src/app/test_feature.py"
|
|
|
26
26
|
CONFIG_FILE_PATH = "src/config/settings.py"
|
|
27
27
|
WORKFLOW_FILE_PATH = "src/workflow/orders_tab.py"
|
|
28
28
|
HOOK_FILE_PATH = "/home/user/.claude/hooks/blocking/my_hook.py"
|
|
29
|
-
EXPECTED_PREFIX_GUIDANCE = "prefix with is_/has_/should_/can_"
|
|
29
|
+
EXPECTED_PREFIX_GUIDANCE = "prefix with is_/has_/should_/can_/was_/did_"
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _assert_flags_name(issues: list[str], name: str, line_number: int) -> None:
|
|
@@ -210,3 +210,139 @@ def test_should_allow_is_prefix_at_start_when_compound_word_follows() -> None:
|
|
|
210
210
|
assert issues == [], (
|
|
211
211
|
f"is_left_upper_snake has prefix at position 0, must pass, got: {issues}"
|
|
212
212
|
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
PARAMETER_PREFIX_GUIDANCE = "prefix with is_/has_/should_/can_/was_/did_"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _assert_flags_parameter(issues: list[str], name: str, line_number: int) -> None:
|
|
219
|
+
expected = f"Line {line_number}: Boolean parameter {name} - {PARAMETER_PREFIX_GUIDANCE}"
|
|
220
|
+
assert expected in issues, f"expected {expected!r} in {issues!r}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_should_flag_bool_annotated_parameter_without_prefix() -> None:
|
|
224
|
+
source = "def run(dry_run: bool) -> None:\n print(dry_run)\n"
|
|
225
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
226
|
+
_assert_flags_parameter(issues, "dry_run", 1)
|
|
227
|
+
assert len(issues) == 1
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_should_flag_bool_default_parameter_without_annotation() -> None:
|
|
231
|
+
source = "def run(apply_historical_weight=False) -> None:\n print(apply_historical_weight)\n"
|
|
232
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
233
|
+
_assert_flags_parameter(issues, "apply_historical_weight", 1)
|
|
234
|
+
assert len(issues) == 1
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_should_flag_keyword_only_bool_parameter_without_prefix() -> None:
|
|
238
|
+
source = "def run(*, click_succeeded: bool = True) -> None:\n print(click_succeeded)\n"
|
|
239
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
240
|
+
_assert_flags_parameter(issues, "click_succeeded", 1)
|
|
241
|
+
assert len(issues) == 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_should_allow_is_prefixed_bool_parameter() -> None:
|
|
245
|
+
source = "def run(is_dry_run: bool) -> None:\n print(is_dry_run)\n"
|
|
246
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
247
|
+
assert issues == []
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_should_allow_was_prefixed_bool_parameter() -> None:
|
|
251
|
+
source = "def run(was_clicked: bool = False) -> None:\n print(was_clicked)\n"
|
|
252
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
253
|
+
assert issues == []
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_should_allow_did_prefixed_bool_parameter() -> None:
|
|
257
|
+
source = "def run(did_succeed: bool) -> None:\n print(did_succeed)\n"
|
|
258
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
259
|
+
assert issues == []
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_should_allow_was_prefixed_bool_assignment() -> None:
|
|
263
|
+
source = "def f() -> None:\n was_clicked = True\n"
|
|
264
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
265
|
+
assert issues == []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_should_allow_did_prefixed_bool_assignment() -> None:
|
|
269
|
+
source = "def f() -> None:\n did_run = False\n"
|
|
270
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
271
|
+
assert issues == []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_should_skip_single_letter_bool_parameter() -> None:
|
|
275
|
+
source = "def run(x: bool) -> None:\n print(x)\n"
|
|
276
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
277
|
+
assert issues == []
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_should_skip_self_parameter_in_method() -> None:
|
|
281
|
+
source = (
|
|
282
|
+
"class Runner:\n"
|
|
283
|
+
" def run(self, enabled: bool) -> None:\n"
|
|
284
|
+
" print(self, enabled)\n"
|
|
285
|
+
)
|
|
286
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
287
|
+
_assert_flags_parameter(issues, "enabled", 2)
|
|
288
|
+
assert len(issues) == 1
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_should_not_flag_non_bool_parameter() -> None:
|
|
292
|
+
source = "def run(retries: int) -> None:\n print(retries)\n"
|
|
293
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
294
|
+
assert issues == []
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_should_skip_bool_parameter_in_test_file() -> None:
|
|
298
|
+
source = "def run(dry_run: bool) -> None:\n print(dry_run)\n"
|
|
299
|
+
issues = check_boolean_naming(source, TEST_FILE_PATH)
|
|
300
|
+
assert issues == []
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_should_pair_positional_defaults_right_aligned() -> None:
|
|
304
|
+
source = (
|
|
305
|
+
"def run(name: str, verbose: bool = False) -> None:\n"
|
|
306
|
+
" print(name, verbose)\n"
|
|
307
|
+
)
|
|
308
|
+
issues = check_boolean_naming(source, PRODUCTION_FILE_PATH)
|
|
309
|
+
_assert_flags_parameter(issues, "verbose", 1)
|
|
310
|
+
assert len(issues) == 1
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
FULL_MODULE_WITH_TWO_UNPREFIXED_BOOL_PARAMETERS = (
|
|
314
|
+
"def pre_existing(verbose: bool) -> None:\n"
|
|
315
|
+
" print(verbose)\n"
|
|
316
|
+
"\n\n"
|
|
317
|
+
"def edited(detailed: bool) -> None:\n"
|
|
318
|
+
" print(detailed)\n"
|
|
319
|
+
)
|
|
320
|
+
PRE_EXISTING_BOOL_PARAMETER_LINE_NUMBER = 1
|
|
321
|
+
EDITED_BOOL_PARAMETER_LINE_NUMBER = 5
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_should_flag_bool_parameter_on_changed_line() -> None:
|
|
325
|
+
issues = check_boolean_naming(
|
|
326
|
+
FULL_MODULE_WITH_TWO_UNPREFIXED_BOOL_PARAMETERS,
|
|
327
|
+
PRODUCTION_FILE_PATH,
|
|
328
|
+
{EDITED_BOOL_PARAMETER_LINE_NUMBER},
|
|
329
|
+
False,
|
|
330
|
+
)
|
|
331
|
+
_assert_flags_parameter(issues, "detailed", EDITED_BOOL_PARAMETER_LINE_NUMBER)
|
|
332
|
+
assert len(issues) == 1, (
|
|
333
|
+
"Only the bool parameter on the changed line must be flagged, got: "
|
|
334
|
+
f"{issues!r}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_should_not_flag_pre_existing_bool_parameter_on_unchanged_line() -> None:
|
|
339
|
+
issues = check_boolean_naming(
|
|
340
|
+
FULL_MODULE_WITH_TWO_UNPREFIXED_BOOL_PARAMETERS,
|
|
341
|
+
PRODUCTION_FILE_PATH,
|
|
342
|
+
{EDITED_BOOL_PARAMETER_LINE_NUMBER},
|
|
343
|
+
False,
|
|
344
|
+
)
|
|
345
|
+
assert not any("verbose" in each_issue for each_issue in issues), (
|
|
346
|
+
"A pre-existing unprefixed bool parameter on an unedited line must not block "
|
|
347
|
+
f"the edit, got: {issues!r}"
|
|
348
|
+
)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Tests for md_to_html_blocker directory and filename exemptions.
|
|
2
|
+
|
|
3
|
+
Covers which directory trees (`.claude/`, `.claude-plugin/`, source subtrees
|
|
4
|
+
under `packages/claude-dev-env/`, `agents/`, `skills/`, `commands/`) and which
|
|
5
|
+
root-level filenames (`README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`,
|
|
6
|
+
`SKILL.md`) are exempt from the `.md` block, and the segment-anchored matching
|
|
7
|
+
that prevents nested look-alike paths from bypassing the block.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
_BLOCKING_DIRECTORY = os.path.dirname(__file__)
|
|
15
|
+
|
|
16
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
17
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
18
|
+
|
|
19
|
+
from _md_to_html_blocker_test_support import ( # noqa: E402
|
|
20
|
+
_run_hook,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_blocks_nested_packages_claude_dev_env_path():
|
|
25
|
+
"""`packages/claude-dev-env/` exemption is anchored to top-level use only;
|
|
26
|
+
a nested directory like `notes/packages/claude-dev-env/docs/...` is NOT a
|
|
27
|
+
Claude Code source path and must still be blocked. Substring matching let
|
|
28
|
+
this bypass through; segment-anchored matching prevents it."""
|
|
29
|
+
result = _run_hook(
|
|
30
|
+
"Write",
|
|
31
|
+
{"file_path": "notes/packages/claude-dev-env/docs/guide.md", "content": "# Hello"},
|
|
32
|
+
)
|
|
33
|
+
assert result.returncode == 0
|
|
34
|
+
output = json.loads(result.stdout)
|
|
35
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny", (
|
|
36
|
+
f"Nested fake claude-dev-env path must still be blocked; got {output!r}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_passes_claude_dir():
|
|
41
|
+
result = _run_hook(
|
|
42
|
+
"Write",
|
|
43
|
+
{"file_path": ".claude/rules/foo.md", "content": "# Rule"},
|
|
44
|
+
)
|
|
45
|
+
assert result.returncode == 0
|
|
46
|
+
assert result.stdout == ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_passes_nested_claude_dir():
|
|
50
|
+
result = _run_hook(
|
|
51
|
+
"Write",
|
|
52
|
+
{"file_path": "notes/.claude/plans/plan.md", "content": "# Plan"},
|
|
53
|
+
)
|
|
54
|
+
assert result.returncode == 0
|
|
55
|
+
assert result.stdout == ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_passes_readme_at_root():
|
|
59
|
+
result = _run_hook(
|
|
60
|
+
"Write",
|
|
61
|
+
{"file_path": "README.md", "content": "# README"},
|
|
62
|
+
)
|
|
63
|
+
assert result.returncode == 0
|
|
64
|
+
assert result.stdout == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_passes_changelog_at_root():
|
|
68
|
+
result = _run_hook(
|
|
69
|
+
"Write",
|
|
70
|
+
{"file_path": "CHANGELOG.md", "content": "# Changelog"},
|
|
71
|
+
)
|
|
72
|
+
assert result.returncode == 0
|
|
73
|
+
assert result.stdout == ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_blocks_readme_not_at_root():
|
|
77
|
+
result = _run_hook(
|
|
78
|
+
"Write",
|
|
79
|
+
{"file_path": "docs/README.md", "content": "# README"},
|
|
80
|
+
)
|
|
81
|
+
assert result.returncode == 0
|
|
82
|
+
output = json.loads(result.stdout)
|
|
83
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_blocks_changelog_not_at_root():
|
|
87
|
+
result = _run_hook(
|
|
88
|
+
"Write",
|
|
89
|
+
{"file_path": "sub/CHANGELOG.md", "content": "# Log"},
|
|
90
|
+
)
|
|
91
|
+
assert result.returncode == 0
|
|
92
|
+
output = json.loads(result.stdout)
|
|
93
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_passes_claude_md_at_root():
|
|
97
|
+
result = _run_hook(
|
|
98
|
+
"Write",
|
|
99
|
+
{"file_path": "CLAUDE.md", "content": "# CLAUDE"},
|
|
100
|
+
)
|
|
101
|
+
assert result.returncode == 0
|
|
102
|
+
assert result.stdout == ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_passes_agents_md_at_root():
|
|
106
|
+
result = _run_hook(
|
|
107
|
+
"Write",
|
|
108
|
+
{"file_path": "AGENTS.md", "content": "# AGENTS"},
|
|
109
|
+
)
|
|
110
|
+
assert result.returncode == 0
|
|
111
|
+
assert result.stdout == ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_blocks_claude_md_not_at_root():
|
|
115
|
+
result = _run_hook(
|
|
116
|
+
"Write",
|
|
117
|
+
{"file_path": "docs/CLAUDE.md", "content": "# CLAUDE"},
|
|
118
|
+
)
|
|
119
|
+
assert result.returncode == 0
|
|
120
|
+
output = json.loads(result.stdout)
|
|
121
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_blocks_agents_md_not_at_root():
|
|
125
|
+
result = _run_hook(
|
|
126
|
+
"Write",
|
|
127
|
+
{"file_path": "sub/AGENTS.md", "content": "# AGENTS"},
|
|
128
|
+
)
|
|
129
|
+
assert result.returncode == 0
|
|
130
|
+
output = json.loads(result.stdout)
|
|
131
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_passes_claude_md_file():
|
|
135
|
+
result = _run_hook(
|
|
136
|
+
"Write",
|
|
137
|
+
{"file_path": ".claude/CLAUDE.md", "content": "# CLAUDE.md"},
|
|
138
|
+
)
|
|
139
|
+
assert result.returncode == 0
|
|
140
|
+
assert result.stdout == ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_passes_windows_path_claude_exempt():
|
|
144
|
+
result = _run_hook(
|
|
145
|
+
"Write",
|
|
146
|
+
{"file_path": "project\\.claude\\rules\\foo.md", "content": "# Rule"},
|
|
147
|
+
)
|
|
148
|
+
assert result.returncode == 0
|
|
149
|
+
assert result.stdout == ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_passes_claude_dir_case_insensitive():
|
|
153
|
+
result = _run_hook(
|
|
154
|
+
"Write",
|
|
155
|
+
{"file_path": ".Claude/rules/foo.md", "content": "# Rule"},
|
|
156
|
+
)
|
|
157
|
+
assert result.returncode == 0
|
|
158
|
+
assert result.stdout == ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_passes_readme_lowercase_at_root():
|
|
162
|
+
result = _run_hook(
|
|
163
|
+
"Write",
|
|
164
|
+
{"file_path": "readme.md", "content": "# readme"},
|
|
165
|
+
)
|
|
166
|
+
assert result.returncode == 0
|
|
167
|
+
assert result.stdout == ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_blocks_claude_path_traversal_bypass():
|
|
171
|
+
result = _run_hook(
|
|
172
|
+
"Write",
|
|
173
|
+
{"file_path": ".claude/../docs/guide.md", "content": "# Bypass"},
|
|
174
|
+
)
|
|
175
|
+
assert result.returncode == 0
|
|
176
|
+
output = json.loads(result.stdout)
|
|
177
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_passes_dot_claude_plugin_directory():
|
|
181
|
+
result = _run_hook(
|
|
182
|
+
"Write",
|
|
183
|
+
{"file_path": ".claude-plugin/manifest.md", "content": "# Manifest"},
|
|
184
|
+
)
|
|
185
|
+
assert result.returncode == 0
|
|
186
|
+
assert result.stdout == ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_passes_nested_dot_claude_plugin_directory():
|
|
190
|
+
result = _run_hook(
|
|
191
|
+
"Write",
|
|
192
|
+
{
|
|
193
|
+
"file_path": "Y:/repo/.claude-plugin/skills/foo/SKILL.md",
|
|
194
|
+
"content": "# Skill",
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
assert result.returncode == 0
|
|
198
|
+
assert result.stdout == ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_passes_skill_md_at_any_depth():
|
|
202
|
+
result = _run_hook(
|
|
203
|
+
"Write",
|
|
204
|
+
{
|
|
205
|
+
"file_path": "packages/dev-env/skills/pr-converge/SKILL.md",
|
|
206
|
+
"content": "# Skill",
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
assert result.returncode == 0
|
|
210
|
+
assert result.stdout == ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_passes_skill_md_uppercase():
|
|
214
|
+
result = _run_hook(
|
|
215
|
+
"Write",
|
|
216
|
+
{"file_path": "any/path/SKILL.MD", "content": "# Skill"},
|
|
217
|
+
)
|
|
218
|
+
assert result.returncode == 0
|
|
219
|
+
assert result.stdout == ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_passes_agents_directory_anywhere():
|
|
223
|
+
result = _run_hook(
|
|
224
|
+
"Write",
|
|
225
|
+
{
|
|
226
|
+
"file_path": "packages/dev-env/agents/pr-description-writer.md",
|
|
227
|
+
"content": "# Agent",
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
assert result.returncode == 0
|
|
231
|
+
assert result.stdout == ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_passes_skills_reference_directory():
|
|
235
|
+
result = _run_hook(
|
|
236
|
+
"Write",
|
|
237
|
+
{
|
|
238
|
+
"file_path": "packages/dev-env/skills/pr-converge/reference/per-tick.md",
|
|
239
|
+
"content": "# Reference",
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
assert result.returncode == 0
|
|
243
|
+
assert result.stdout == ""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_passes_commands_directory_anywhere():
|
|
247
|
+
result = _run_hook(
|
|
248
|
+
"Write",
|
|
249
|
+
{"file_path": "commands/pyguide-health.md", "content": "# Command"},
|
|
250
|
+
)
|
|
251
|
+
assert result.returncode == 0
|
|
252
|
+
assert result.stdout == ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_passes_claude_dev_env_docs_dir():
|
|
256
|
+
"""A .md file under ``packages/claude-dev-env/docs/`` is exempt. The
|
|
257
|
+
segment-anywhere rule does not list ``docs``; this exemption fires only
|
|
258
|
+
via the anchored helper."""
|
|
259
|
+
result = _run_hook(
|
|
260
|
+
"Write",
|
|
261
|
+
{
|
|
262
|
+
"file_path": "packages/claude-dev-env/docs/PR_DESCRIPTION_GUIDE.md",
|
|
263
|
+
"content": "# Guide",
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
assert result.returncode == 0
|
|
267
|
+
assert result.stdout == ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_passes_claude_dev_env_rules_dir():
|
|
271
|
+
"""A .md file under ``packages/claude-dev-env/rules/`` is exempt. The
|
|
272
|
+
segment-anywhere rule does not list ``rules``; the anchored helper is
|
|
273
|
+
the only path to this exemption."""
|
|
274
|
+
result = _run_hook(
|
|
275
|
+
"Write",
|
|
276
|
+
{
|
|
277
|
+
"file_path": "packages/claude-dev-env/rules/my-rule.md",
|
|
278
|
+
"content": "# Rule",
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
assert result.returncode == 0
|
|
282
|
+
assert result.stdout == ""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_passes_claude_dev_env_system_prompts_dir():
|
|
286
|
+
"""A .md file under ``packages/claude-dev-env/system-prompts/`` is
|
|
287
|
+
exempt via the anchored helper."""
|
|
288
|
+
result = _run_hook(
|
|
289
|
+
"Write",
|
|
290
|
+
{
|
|
291
|
+
"file_path": "packages/claude-dev-env/system-prompts/new-prompt.md",
|
|
292
|
+
"content": "# Prompt",
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
assert result.returncode == 0
|
|
296
|
+
assert result.stdout == ""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_passes_claude_dev_env_windows_backslash_path():
|
|
300
|
+
"""A Windows-style backslash relative path under
|
|
301
|
+
``packages\\claude-dev-env\\<dir>\\`` is exempt."""
|
|
302
|
+
result = _run_hook(
|
|
303
|
+
"Write",
|
|
304
|
+
{
|
|
305
|
+
"file_path": "packages\\claude-dev-env\\docs\\windows-style.md",
|
|
306
|
+
"content": "# Guide",
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
assert result.returncode == 0
|
|
310
|
+
assert result.stdout == ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_passes_claude_dev_env_absolute_drive_letter_path():
|
|
314
|
+
"""A Windows absolute drive-letter path containing the anchored
|
|
315
|
+
``packages\\claude-dev-env\\<dir>\\`` indicator at any depth is exempt."""
|
|
316
|
+
result = _run_hook(
|
|
317
|
+
"Write",
|
|
318
|
+
{
|
|
319
|
+
"file_path": "Y:\\repo\\packages\\claude-dev-env\\docs\\drive-letter.md",
|
|
320
|
+
"content": "# Guide",
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
assert result.returncode == 0
|
|
324
|
+
assert result.stdout == ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_blocks_md_under_packages_but_not_in_anchored_source_subdir():
|
|
328
|
+
"""A .md file inside the package but under a non-source subtree (e.g.
|
|
329
|
+
``packages/claude-dev-env/hooks/blocking/``) is blocked. The anchored
|
|
330
|
+
helper accepts only the named source subdirectories (agents, docs,
|
|
331
|
+
skills, rules, system-prompts, commands)."""
|
|
332
|
+
result = _run_hook(
|
|
333
|
+
"Write",
|
|
334
|
+
{
|
|
335
|
+
"file_path": "packages/claude-dev-env/hooks/blocking/notes.md",
|
|
336
|
+
"content": "# Notes",
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
assert result.returncode == 0
|
|
340
|
+
output = json.loads(result.stdout)
|
|
341
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_blocks_nested_claude_dev_env_substring_does_not_bypass():
|
|
345
|
+
"""A path that contains the anchored prefix as a non-leading substring
|
|
346
|
+
(e.g. ``notes/packages/claude-dev-env/docs/foo.md``) is blocked. The
|
|
347
|
+
anchored helper matches only at the start of the path (relative) or at
|
|
348
|
+
the root of an absolute path."""
|
|
349
|
+
result = _run_hook(
|
|
350
|
+
"Write",
|
|
351
|
+
{
|
|
352
|
+
"file_path": "notes/packages/claude-dev-env/docs/foo.md",
|
|
353
|
+
"content": "# Notes",
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
assert result.returncode == 0
|
|
357
|
+
output = json.loads(result.stdout)
|
|
358
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_blocks_ordinary_docs_md_file():
|
|
362
|
+
result = _run_hook(
|
|
363
|
+
"Write",
|
|
364
|
+
{"file_path": "docs/intro.md", "content": "# Intro"},
|
|
365
|
+
)
|
|
366
|
+
assert result.returncode == 0
|
|
367
|
+
output = json.loads(result.stdout)
|
|
368
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tests for md_to_html_blocker extension matching and malformed-input handling.
|
|
2
|
+
|
|
3
|
+
Covers the core decision: which file extensions and tool names trigger a deny,
|
|
4
|
+
which pass through, and how the hook degrades on malformed or non-dict stdin.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
_BLOCKING_DIRECTORY = os.path.dirname(__file__)
|
|
13
|
+
|
|
14
|
+
if _BLOCKING_DIRECTORY not in sys.path:
|
|
15
|
+
sys.path.insert(0, _BLOCKING_DIRECTORY)
|
|
16
|
+
|
|
17
|
+
from _md_to_html_blocker_test_support import ( # noqa: E402
|
|
18
|
+
HOOK_SCRIPT_PATH,
|
|
19
|
+
_run_hook,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_blocks_write_md_file():
|
|
24
|
+
result = _run_hook(
|
|
25
|
+
"Write",
|
|
26
|
+
{"file_path": "docs/guide.md", "content": "# Hello"},
|
|
27
|
+
)
|
|
28
|
+
assert result.returncode == 0
|
|
29
|
+
output = json.loads(result.stdout)
|
|
30
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_blocks_edit_md_file():
|
|
34
|
+
result = _run_hook(
|
|
35
|
+
"Edit",
|
|
36
|
+
{"file_path": "docs/guide.md", "old_string": "a", "new_string": "b"},
|
|
37
|
+
)
|
|
38
|
+
assert result.returncode == 0
|
|
39
|
+
output = json.loads(result.stdout)
|
|
40
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_blocks_uppercase_md_extension():
|
|
44
|
+
result = _run_hook(
|
|
45
|
+
"Write",
|
|
46
|
+
{"file_path": "DOCS/GUIDE.MD", "content": "# Hello"},
|
|
47
|
+
)
|
|
48
|
+
assert result.returncode == 0
|
|
49
|
+
output = json.loads(result.stdout)
|
|
50
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_passes_html_file():
|
|
54
|
+
result = _run_hook(
|
|
55
|
+
"Write",
|
|
56
|
+
{"file_path": "docs/guide.html", "content": "<h1>Hello</h1>"},
|
|
57
|
+
)
|
|
58
|
+
assert result.returncode == 0
|
|
59
|
+
assert result.stdout == ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_passes_non_markdown_extension():
|
|
63
|
+
result = _run_hook(
|
|
64
|
+
"Write",
|
|
65
|
+
{"file_path": "src/main.py", "content": "x = 1"},
|
|
66
|
+
)
|
|
67
|
+
assert result.returncode == 0
|
|
68
|
+
assert result.stdout == ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_unknown_tool_passes():
|
|
72
|
+
result = _run_hook(
|
|
73
|
+
"Grep",
|
|
74
|
+
{"pattern": "foo", "path": "."},
|
|
75
|
+
)
|
|
76
|
+
assert result.returncode == 0
|
|
77
|
+
assert result.stdout == ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_empty_file_path_passes():
|
|
81
|
+
result = _run_hook(
|
|
82
|
+
"Write",
|
|
83
|
+
{"file_path": "", "content": "# Hello"},
|
|
84
|
+
)
|
|
85
|
+
assert result.returncode == 0
|
|
86
|
+
assert result.stdout == ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_non_dict_stdin_passes():
|
|
90
|
+
payload = json.dumps(["not", "a", "dict"])
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
93
|
+
input=payload,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
assert result.returncode == 0
|
|
99
|
+
assert result.stdout == ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_non_string_tool_name_passes():
|
|
103
|
+
payload = json.dumps({"tool_name": 123, "tool_input": {"file_path": "docs/guide.md"}})
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
106
|
+
input=payload,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
assert result.returncode == 0
|
|
112
|
+
assert result.stdout == ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_non_dict_tool_input_passes():
|
|
116
|
+
payload = json.dumps({"tool_name": "Write", "tool_input": "not_a_dict"})
|
|
117
|
+
result = subprocess.run(
|
|
118
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
119
|
+
input=payload,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
check=False,
|
|
123
|
+
)
|
|
124
|
+
assert result.returncode == 0
|
|
125
|
+
assert result.stdout == ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_json_decode_error_passes():
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
131
|
+
input="not json",
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
check=False,
|
|
135
|
+
)
|
|
136
|
+
assert result.returncode == 0
|
|
137
|
+
assert result.stdout == ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_blocks_md_with_curly_braces_in_path():
|
|
141
|
+
result = _run_hook(
|
|
142
|
+
"Write",
|
|
143
|
+
{"file_path": "docs/{template}.md", "content": "# Template"},
|
|
144
|
+
)
|
|
145
|
+
assert result.returncode == 0
|
|
146
|
+
output = json.loads(result.stdout)
|
|
147
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_blocks_windows_path_with_backslash():
|
|
151
|
+
result = _run_hook(
|
|
152
|
+
"Write",
|
|
153
|
+
{"file_path": "docs\\guide.md", "content": "# Hello"},
|
|
154
|
+
)
|
|
155
|
+
assert result.returncode == 0
|
|
156
|
+
output = json.loads(result.stdout)
|
|
157
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|