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
|
@@ -8,6 +8,7 @@ test_precommit_code_rules_gate.py lines 1-70.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import shutil
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
@@ -341,6 +342,22 @@ def test_real_code_rules_violation_on_added_line_denies(tmp_path: Path) -> None:
|
|
|
341
342
|
assert "Line " in reason
|
|
342
343
|
|
|
343
344
|
|
|
345
|
+
def test_committed_on_branch_violation_with_clean_working_tree_denies(tmp_path: Path) -> None:
|
|
346
|
+
repository_root = tmp_path / "repo"
|
|
347
|
+
repository_root.mkdir()
|
|
348
|
+
initialize_repository(repository_root)
|
|
349
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
350
|
+
commit_file(repository_root, "committed.py", VIOLATING_MODULE_SOURCE, "commit violation")
|
|
351
|
+
status_output = run_git(repository_root, "status", "--porcelain")
|
|
352
|
+
assert status_output == "", "working tree must be clean so the deny comes from the committed line"
|
|
353
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
354
|
+
result = run_hook(payload, repository_root)
|
|
355
|
+
assert not is_allow(result)
|
|
356
|
+
reason = deny_reason(result)
|
|
357
|
+
assert "committed.py" in reason
|
|
358
|
+
assert "Line " in reason
|
|
359
|
+
|
|
360
|
+
|
|
344
361
|
def test_preexisting_violation_on_untouched_line_allows(tmp_path: Path) -> None:
|
|
345
362
|
repository_root = tmp_path / "repo"
|
|
346
363
|
repository_root.mkdir()
|
|
@@ -454,3 +471,47 @@ def test_conflict_and_violation_single_deny_names_both(tmp_path: Path) -> None:
|
|
|
454
471
|
assert "shared.py" in reason
|
|
455
472
|
assert "CODE_RULES violations on changed lines:" in reason
|
|
456
473
|
assert "violator.py" in reason
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_hook_imports_real_config_when_parent_holds_shadowing_config(
|
|
477
|
+
tmp_path: Path,
|
|
478
|
+
) -> None:
|
|
479
|
+
real_hooks_directory = HOOK_PATH.parent.parent
|
|
480
|
+
real_package_directory = real_hooks_directory.parent
|
|
481
|
+
|
|
482
|
+
staged_package_directory = tmp_path / "claude-dev-env"
|
|
483
|
+
shutil.copytree(
|
|
484
|
+
real_hooks_directory,
|
|
485
|
+
staged_package_directory / "hooks",
|
|
486
|
+
)
|
|
487
|
+
shutil.copytree(
|
|
488
|
+
real_package_directory / "_shared",
|
|
489
|
+
staged_package_directory / "_shared",
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
shadowing_config_directory = staged_package_directory / "hooks" / "config"
|
|
493
|
+
shadowing_config_directory.mkdir(parents=True, exist_ok=True)
|
|
494
|
+
(shadowing_config_directory / "__init__.py").write_text("", encoding="utf-8")
|
|
495
|
+
(shadowing_config_directory / "unrelated_constants.py").write_text(
|
|
496
|
+
"UNRELATED_VALUE = 1\n", encoding="utf-8"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
staged_hook = (
|
|
500
|
+
staged_package_directory
|
|
501
|
+
/ "hooks"
|
|
502
|
+
/ "blocking"
|
|
503
|
+
/ "code_verifier_spawn_preflight_gate.py"
|
|
504
|
+
)
|
|
505
|
+
payload = write_agent_payload("general-purpose", "unrelated work", tmp_path)
|
|
506
|
+
completed = subprocess.run(
|
|
507
|
+
[sys.executable, str(staged_hook)],
|
|
508
|
+
check=False,
|
|
509
|
+
input=payload,
|
|
510
|
+
capture_output=True,
|
|
511
|
+
text=True,
|
|
512
|
+
cwd=str(tmp_path),
|
|
513
|
+
timeout=120,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
assert "ModuleNotFoundError" not in completed.stderr
|
|
517
|
+
assert completed.returncode == 0
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Tests for docstring_rule_gate_count_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 docstring_rule_gate_count_blocker import (
|
|
12
|
+
find_gate_count_drift,
|
|
13
|
+
is_target_rule_file,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from hooks_constants.docstring_rule_gate_count_blocker_constants import (
|
|
17
|
+
GATE_COUNT_SYSTEM_MESSAGE,
|
|
18
|
+
TARGET_RULE_BASENAME,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "docstring_rule_gate_count_blocker.py")
|
|
22
|
+
|
|
23
|
+
IN_STEP_RULE_TEXT = (
|
|
24
|
+
"The gate validator `check_docstring_args_match_signature` covers the Args "
|
|
25
|
+
"section parameter names. Four more gate validators each cover one "
|
|
26
|
+
"deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` "
|
|
27
|
+
"covers a fallback. `check_class_docstring_names_public_methods` covers a "
|
|
28
|
+
"class. `check_docstring_no_consumer_claim` covers a producer. "
|
|
29
|
+
"`check_docstring_unguarded_malformed_payload_claim` covers a malformed "
|
|
30
|
+
"payload. The audit lane covers everything outside the five gated slices.\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
STALE_FREE_FORM_COUNT_RULE_TEXT = (
|
|
34
|
+
"The gate validator `check_docstring_args_match_signature` covers the Args "
|
|
35
|
+
"section parameter names. Three more gate validators each cover one "
|
|
36
|
+
"deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` "
|
|
37
|
+
"covers a fallback. `check_class_docstring_names_public_methods` covers a "
|
|
38
|
+
"class. `check_docstring_no_consumer_claim` covers a producer. "
|
|
39
|
+
"`check_docstring_unguarded_malformed_payload_claim` covers a malformed "
|
|
40
|
+
"payload. The audit lane covers everything outside the four gated slices.\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
THREE_VALIDATORS_IN_STEP_RULE_TEXT = (
|
|
44
|
+
"The gate validator `check_docstring_args_match_signature` covers the Args "
|
|
45
|
+
"section parameter names. Three more gate validators each cover one "
|
|
46
|
+
"deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` "
|
|
47
|
+
"covers a fallback. `check_class_docstring_names_public_methods` covers a "
|
|
48
|
+
"class. `check_docstring_no_consumer_claim` covers a producer. The audit lane "
|
|
49
|
+
"covers everything outside the four gated slices.\n"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
FENCED_COUNT_CLAUSE_RULE_TEXT = (
|
|
53
|
+
"This rule names no live count outside a fence.\n\n"
|
|
54
|
+
"```\n"
|
|
55
|
+
"Three more gate validators each cover a slice: "
|
|
56
|
+
"`check_docstring_fallback_branch_coverage`, "
|
|
57
|
+
"`check_class_docstring_names_public_methods`. The four gated slices.\n"
|
|
58
|
+
"```\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
NO_COUNT_CLAUSE_RULE_TEXT = (
|
|
62
|
+
"This rule prose names `check_docstring_fallback_branch_coverage` and "
|
|
63
|
+
"`check_class_docstring_names_public_methods` but states no spelled-out "
|
|
64
|
+
"gate count or gated-slice total.\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
OUT_OF_WINDOW_VALIDATOR_RULE_TEXT = (
|
|
68
|
+
"The gate validator `check_docstring_args_match_signature` covers the Args "
|
|
69
|
+
"section parameter names. Three more gate validators each cover one "
|
|
70
|
+
"deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` "
|
|
71
|
+
"covers a fallback. `check_class_docstring_names_public_methods` covers a "
|
|
72
|
+
"class. `check_docstring_no_consumer_claim` covers a producer. The audit lane "
|
|
73
|
+
"covers everything outside the four gated slices. The worked example below "
|
|
74
|
+
"also names `check_docstring_step_enumeration_dispatch_coverage`, which the "
|
|
75
|
+
"enforcement section discusses but the count clause does not enumerate.\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
REVERSED_COUNT_CLAUSE_ORDER_RULE_TEXT = (
|
|
79
|
+
"The audit lane covers everything outside the five gated slices. "
|
|
80
|
+
"`check_docstring_fallback_branch_coverage` covers a fallback. "
|
|
81
|
+
"`check_class_docstring_names_public_methods` covers a class. Four more gate "
|
|
82
|
+
"validators each cover one deterministic slice of the free-form prose.\n"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _RunHook:
|
|
87
|
+
"""Helper to drive the hook via subprocess, mirroring the sibling test style."""
|
|
88
|
+
|
|
89
|
+
def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
|
|
90
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
91
|
+
return subprocess.run(
|
|
92
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
93
|
+
input=payload,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_run_hook = _RunHook()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _target_rule_path(tmp_path: Path) -> Path:
|
|
104
|
+
"""Return a path inside *tmp_path* named after the guarded rule basename."""
|
|
105
|
+
return tmp_path / TARGET_RULE_BASENAME
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def should_flag_target_rule_basename() -> None:
|
|
109
|
+
assert is_target_rule_file("/somewhere/" + TARGET_RULE_BASENAME) is True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def should_ignore_unrelated_markdown_file() -> None:
|
|
113
|
+
assert is_target_rule_file("/somewhere/other-rule.md") is False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def should_report_no_drift_when_counts_match_named_validators() -> None:
|
|
117
|
+
assert find_gate_count_drift(IN_STEP_RULE_TEXT) == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def should_report_no_drift_for_three_validators_in_step() -> None:
|
|
121
|
+
assert find_gate_count_drift(THREE_VALIDATORS_IN_STEP_RULE_TEXT) == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def should_flag_stale_free_form_count_after_a_validator_is_added() -> None:
|
|
125
|
+
issues = find_gate_count_drift(STALE_FREE_FORM_COUNT_RULE_TEXT)
|
|
126
|
+
assert len(issues) == 2
|
|
127
|
+
assert any("Three more gate validators" in each_issue for each_issue in issues)
|
|
128
|
+
assert any("four gated slices" in each_issue for each_issue in issues)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def should_ignore_count_clauses_inside_a_code_fence() -> None:
|
|
132
|
+
assert find_gate_count_drift(FENCED_COUNT_CLAUSE_RULE_TEXT) == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def should_report_no_drift_when_no_count_clause_is_present() -> None:
|
|
136
|
+
assert find_gate_count_drift(NO_COUNT_CLAUSE_RULE_TEXT) == []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def should_exclude_validators_named_outside_the_enumeration_window() -> None:
|
|
140
|
+
assert find_gate_count_drift(OUT_OF_WINDOW_VALIDATOR_RULE_TEXT) == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def should_report_no_drift_when_count_clauses_appear_out_of_order() -> None:
|
|
144
|
+
assert find_gate_count_drift(REVERSED_COUNT_CLAUSE_ORDER_RULE_TEXT) == []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def should_deny_a_write_with_a_stale_gate_count() -> None:
|
|
148
|
+
completed = _run_hook(
|
|
149
|
+
"Write",
|
|
150
|
+
{
|
|
151
|
+
"file_path": "/anywhere/" + TARGET_RULE_BASENAME,
|
|
152
|
+
"content": STALE_FREE_FORM_COUNT_RULE_TEXT,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
parsed_output = json.loads(completed.stdout)
|
|
156
|
+
hook_specific = parsed_output["hookSpecificOutput"]
|
|
157
|
+
assert hook_specific["permissionDecision"] == "deny"
|
|
158
|
+
assert parsed_output["systemMessage"] == GATE_COUNT_SYSTEM_MESSAGE
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def should_allow_a_write_with_in_step_counts() -> None:
|
|
162
|
+
completed = _run_hook(
|
|
163
|
+
"Write",
|
|
164
|
+
{"file_path": "/anywhere/" + TARGET_RULE_BASENAME, "content": IN_STEP_RULE_TEXT},
|
|
165
|
+
)
|
|
166
|
+
assert completed.stdout.strip() == ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def should_allow_a_write_to_an_unrelated_markdown_file() -> None:
|
|
170
|
+
completed = _run_hook(
|
|
171
|
+
"Write",
|
|
172
|
+
{"file_path": "/anywhere/other-rule.md", "content": STALE_FREE_FORM_COUNT_RULE_TEXT},
|
|
173
|
+
)
|
|
174
|
+
assert completed.stdout.strip() == ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def should_deny_an_edit_that_makes_the_count_stale(tmp_path: Path) -> None:
|
|
178
|
+
rule_path = _target_rule_path(tmp_path)
|
|
179
|
+
rule_path.write_text(IN_STEP_RULE_TEXT, encoding="utf-8")
|
|
180
|
+
completed = _run_hook(
|
|
181
|
+
"Edit",
|
|
182
|
+
{
|
|
183
|
+
"file_path": str(rule_path),
|
|
184
|
+
"old_string": "Four more gate validators",
|
|
185
|
+
"new_string": "Three more gate validators",
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
parsed_output = json.loads(completed.stdout)
|
|
189
|
+
assert parsed_output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def should_allow_an_edit_that_keeps_the_count_in_step(tmp_path: Path) -> None:
|
|
193
|
+
rule_path = _target_rule_path(tmp_path)
|
|
194
|
+
rule_path.write_text(IN_STEP_RULE_TEXT, encoding="utf-8")
|
|
195
|
+
completed = _run_hook(
|
|
196
|
+
"Edit",
|
|
197
|
+
{
|
|
198
|
+
"file_path": str(rule_path),
|
|
199
|
+
"old_string": "covers a malformed payload.",
|
|
200
|
+
"new_string": "covers a malformed payload case.",
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
assert completed.stdout.strip() == ""
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Unit tests for duplicate_rmtree_helper_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
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
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
14
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
16
|
+
|
|
17
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
18
|
+
"duplicate_rmtree_helper_blocker",
|
|
19
|
+
_HOOK_DIR / "duplicate_rmtree_helper_blocker.py",
|
|
20
|
+
)
|
|
21
|
+
assert hook_spec is not None
|
|
22
|
+
assert hook_spec.loader is not None
|
|
23
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
24
|
+
hook_spec.loader.exec_module(hook_module)
|
|
25
|
+
|
|
26
|
+
payload_defines_sanctioned_helper = hook_module.payload_defines_sanctioned_helper
|
|
27
|
+
path_is_exempt = hook_module.path_is_exempt
|
|
28
|
+
extract_payload_text = hook_module.extract_payload_text
|
|
29
|
+
|
|
30
|
+
COPIED_TRIO = (
|
|
31
|
+
"def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
|
|
32
|
+
" try:\n"
|
|
33
|
+
" os.chmod(target_path, stat.S_IWRITE)\n"
|
|
34
|
+
" removal_function(target_path)\n"
|
|
35
|
+
" except OSError:\n"
|
|
36
|
+
" pass\n\n\n"
|
|
37
|
+
"_rmtree_supports_onexc = 'onexc' in inspect.signature(shutil.rmtree).parameters\n\n\n"
|
|
38
|
+
"def _force_remove_tree(target_path):\n"
|
|
39
|
+
" if _rmtree_supports_onexc:\n"
|
|
40
|
+
" shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_detects_strip_read_only_definition() -> None:
|
|
45
|
+
assert payload_defines_sanctioned_helper(
|
|
46
|
+
"def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n pass"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_detects_force_remove_tree_definition() -> None:
|
|
51
|
+
assert payload_defines_sanctioned_helper(
|
|
52
|
+
"def _force_remove_tree(target_path: Path) -> None:\n pass"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_detects_force_rmtree_definition() -> None:
|
|
57
|
+
assert payload_defines_sanctioned_helper(
|
|
58
|
+
"def force_rmtree(target_path: str) -> None:\n pass"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_detects_indented_method_definition() -> None:
|
|
63
|
+
assert payload_defines_sanctioned_helper(
|
|
64
|
+
"class FileTools:\n def _strip_read_only_and_retry(self, fn, path):\n pass"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_detects_copied_trio_block() -> None:
|
|
69
|
+
assert payload_defines_sanctioned_helper(COPIED_TRIO)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_allows_import_of_shared_helper() -> None:
|
|
73
|
+
assert not payload_defines_sanctioned_helper(
|
|
74
|
+
"from shared_utils.web_automation.utils.windows_filesystem import force_rmtree"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_allows_call_site_without_definition() -> None:
|
|
79
|
+
assert not payload_defines_sanctioned_helper("force_rmtree(staging_directory)")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_allows_helper_name_inside_string_literal() -> None:
|
|
83
|
+
corrective_message = (
|
|
84
|
+
' " def _strip_read_only_and_retry(removal_function, target_path):\\n"'
|
|
85
|
+
)
|
|
86
|
+
assert not payload_defines_sanctioned_helper(corrective_message)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_allows_helper_definition_inside_triple_quoted_string() -> None:
|
|
90
|
+
documentation_snippet = (
|
|
91
|
+
'EXAMPLE = """\\\n'
|
|
92
|
+
'def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n'
|
|
93
|
+
' pass\n'
|
|
94
|
+
'"""\n'
|
|
95
|
+
)
|
|
96
|
+
assert not payload_defines_sanctioned_helper(documentation_snippet)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_allows_force_rmtree_definition_inside_triple_quoted_string() -> None:
|
|
100
|
+
documentation_snippet = (
|
|
101
|
+
"snippet = '''\n"
|
|
102
|
+
"def force_rmtree(target_path: str) -> None:\n"
|
|
103
|
+
" pass\n"
|
|
104
|
+
"'''\n"
|
|
105
|
+
)
|
|
106
|
+
assert not payload_defines_sanctioned_helper(documentation_snippet)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_detects_real_definition_following_triple_quoted_docstring() -> None:
|
|
110
|
+
module_text = (
|
|
111
|
+
'"""Module docstring."""\n'
|
|
112
|
+
'def force_rmtree(target_path: str) -> None:\n'
|
|
113
|
+
' pass\n'
|
|
114
|
+
)
|
|
115
|
+
assert payload_defines_sanctioned_helper(module_text)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_detects_real_definition_between_two_triple_quoted_strings() -> None:
|
|
119
|
+
module_text = (
|
|
120
|
+
'"""Leading docstring."""\n'
|
|
121
|
+
'def _force_remove_tree(target_path: str) -> None:\n'
|
|
122
|
+
' pass\n'
|
|
123
|
+
'"""Trailing docstring."""\n'
|
|
124
|
+
)
|
|
125
|
+
assert payload_defines_sanctioned_helper(module_text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_allows_unrelated_definition() -> None:
|
|
129
|
+
assert not payload_defines_sanctioned_helper("def categorize_and_move(theme_folder):\n pass")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_path_exempts_blocker_source() -> None:
|
|
133
|
+
assert path_is_exempt("packages/x/hooks/blocking/windows_rmtree_blocker.py")
|
|
134
|
+
assert path_is_exempt("packages/x/hooks/blocking/duplicate_rmtree_helper_blocker.py")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_path_exempts_shared_helper_module() -> None:
|
|
138
|
+
assert path_is_exempt("shared_utils/web_automation/utils/windows_filesystem.py")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_path_exempts_existing_session_env_cleanup_definition_site() -> None:
|
|
142
|
+
assert path_is_exempt("packages/claude-dev-env/hooks/session/session_env_cleanup.py")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_path_exempts_existing_md_to_html_test_support_definition_site() -> None:
|
|
146
|
+
assert path_is_exempt(
|
|
147
|
+
"packages/claude-dev-env/hooks/blocking/_md_to_html_blocker_test_support.py"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_path_exempts_existing_teardown_worktrees_definition_site() -> None:
|
|
152
|
+
assert path_is_exempt(
|
|
153
|
+
"packages/claude-dev-env/skills/_shared/pr-loop/scripts/teardown_worktrees.py"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_main_allows_full_file_write_of_existing_definition_site() -> None:
|
|
158
|
+
stdout_text, exit_code = _run_hook(
|
|
159
|
+
{
|
|
160
|
+
"tool_name": "Write",
|
|
161
|
+
"tool_input": {
|
|
162
|
+
"file_path": "packages/claude-dev-env/hooks/session/session_env_cleanup.py",
|
|
163
|
+
"content": COPIED_TRIO,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
assert exit_code == 0
|
|
168
|
+
assert stdout_text == ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_path_exempts_test_file_prefix() -> None:
|
|
172
|
+
assert path_is_exempt("hooks/blocking/test_duplicate_rmtree_helper_blocker.py")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_path_exempts_test_file_suffix() -> None:
|
|
176
|
+
assert path_is_exempt("shared_utils/something_test.py")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_path_does_not_exempt_production_module() -> None:
|
|
180
|
+
assert not path_is_exempt(
|
|
181
|
+
"shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_path_does_not_exempt_filename_containing_exempt_fragment() -> None:
|
|
186
|
+
assert not path_is_exempt("packages/x/not_windows_filesystem.py")
|
|
187
|
+
assert not path_is_exempt("packages/x/my_windows_filesystem.py")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_path_does_not_exempt_backslash_path_with_containing_fragment() -> None:
|
|
191
|
+
assert not path_is_exempt("packages\\x\\not_windows_filesystem.py")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_extract_payload_text_reads_write_content() -> None:
|
|
195
|
+
extracted = extract_payload_text("Write", {"file_path": "foo.py", "content": "abc"})
|
|
196
|
+
assert extracted == ("foo.py", "abc")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_extract_payload_text_reads_edit_new_string() -> None:
|
|
200
|
+
extracted = extract_payload_text("Edit", {"file_path": "foo.py", "new_string": "abc"})
|
|
201
|
+
assert extracted == ("foo.py", "abc")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_extract_payload_text_returns_empty_for_non_python_file() -> None:
|
|
205
|
+
extracted = extract_payload_text("Write", {"file_path": "notes.md", "content": COPIED_TRIO})
|
|
206
|
+
assert extracted == ("notes.md", "")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_extract_payload_text_returns_empty_for_unknown_tool() -> None:
|
|
210
|
+
extracted = extract_payload_text("Read", {"file_path": "foo.py"})
|
|
211
|
+
assert extracted == ("", "")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
|
|
215
|
+
captured_stdout = io.StringIO()
|
|
216
|
+
captured_stderr = io.StringIO()
|
|
217
|
+
exit_code = 0
|
|
218
|
+
sys.stdin = io.StringIO(stdin_text)
|
|
219
|
+
try:
|
|
220
|
+
with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
|
|
221
|
+
try:
|
|
222
|
+
hook_module.main()
|
|
223
|
+
except SystemExit as exit_signal:
|
|
224
|
+
raw_exit_code = exit_signal.code
|
|
225
|
+
exit_code = raw_exit_code if isinstance(raw_exit_code, int) else 0
|
|
226
|
+
finally:
|
|
227
|
+
sys.stdin = sys.__stdin__
|
|
228
|
+
return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _run_hook(hook_input: dict) -> tuple[str, int]:
|
|
232
|
+
stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(json.dumps(hook_input))
|
|
233
|
+
return stdout_text, exit_code
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_main_blocks_local_trio_copy_in_production_module() -> None:
|
|
237
|
+
stdout_text, exit_code = _run_hook(
|
|
238
|
+
{
|
|
239
|
+
"tool_name": "Write",
|
|
240
|
+
"tool_input": {
|
|
241
|
+
"file_path": (
|
|
242
|
+
"shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
|
|
243
|
+
),
|
|
244
|
+
"content": COPIED_TRIO,
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
assert exit_code == 0
|
|
249
|
+
response_payload = json.loads(stdout_text)
|
|
250
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
251
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
252
|
+
assert "duplicate-rmtree-helper" in decision_block["permissionDecisionReason"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_main_allows_import_of_shared_helper() -> None:
|
|
256
|
+
stdout_text, exit_code = _run_hook(
|
|
257
|
+
{
|
|
258
|
+
"tool_name": "Write",
|
|
259
|
+
"tool_input": {
|
|
260
|
+
"file_path": "shared_utils/samsung_utils/cleanup.py",
|
|
261
|
+
"content": (
|
|
262
|
+
"from shared_utils.web_automation.utils.windows_filesystem import "
|
|
263
|
+
"force_rmtree\n\nforce_rmtree(path)\n"
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
assert exit_code == 0
|
|
269
|
+
assert stdout_text == ""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_main_allows_definition_in_shared_helper_module() -> None:
|
|
273
|
+
stdout_text, exit_code = _run_hook(
|
|
274
|
+
{
|
|
275
|
+
"tool_name": "Write",
|
|
276
|
+
"tool_input": {
|
|
277
|
+
"file_path": "shared_utils/web_automation/utils/windows_filesystem.py",
|
|
278
|
+
"content": COPIED_TRIO,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
assert exit_code == 0
|
|
283
|
+
assert stdout_text == ""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_main_allows_definition_in_test_file() -> None:
|
|
287
|
+
stdout_text, exit_code = _run_hook(
|
|
288
|
+
{
|
|
289
|
+
"tool_name": "Write",
|
|
290
|
+
"tool_input": {
|
|
291
|
+
"file_path": "shared_utils/test_windows_filesystem.py",
|
|
292
|
+
"content": COPIED_TRIO,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
assert exit_code == 0
|
|
297
|
+
assert stdout_text == ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_main_passes_through_non_python_file() -> None:
|
|
301
|
+
stdout_text, exit_code = _run_hook(
|
|
302
|
+
{
|
|
303
|
+
"tool_name": "Write",
|
|
304
|
+
"tool_input": {"file_path": "docs/cleanup.md", "content": COPIED_TRIO},
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
assert exit_code == 0
|
|
308
|
+
assert stdout_text == ""
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_main_passes_through_unrelated_tool() -> None:
|
|
312
|
+
stdout_text, exit_code = _run_hook({"tool_name": "Read", "tool_input": {"file_path": "foo.py"}})
|
|
313
|
+
assert exit_code == 0
|
|
314
|
+
assert stdout_text == ""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_main_with_empty_stdin_exits_silently() -> None:
|
|
318
|
+
stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
|
|
319
|
+
assert exit_code == 0
|
|
320
|
+
assert stdout_text == ""
|
|
321
|
+
assert stderr_text == ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_main_with_invalid_json_stdin_exits_silently() -> None:
|
|
325
|
+
stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
|
|
326
|
+
assert exit_code == 0
|
|
327
|
+
assert stdout_text == ""
|
|
328
|
+
assert stderr_text == ""
|
|
@@ -17,6 +17,12 @@ if _HOOKS_ROOT not in sys.path:
|
|
|
17
17
|
sys.path.insert(0, _HOOKS_ROOT)
|
|
18
18
|
import hedging_language_blocker
|
|
19
19
|
from hooks_constants.messages import USER_FACING_NOTICE
|
|
20
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
24
|
+
assert hedging_language_blocker.strip_code_and_quotes is strip_code_and_quotes
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
RESEARCH_MODE_SKILL_BODY_MARKER = "Three anti-hallucination constraints are ALWAYS active."
|
|
22
28
|
HEDGING_MESSAGE = "This is likely correct."
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Meta-test: every blocking hook must call log_hook_block at its block site.
|
|
2
|
+
|
|
3
|
+
Discovers every .py under hooks/ whose source contains a block-emit pattern
|
|
4
|
+
(``"permissionDecision": "deny"`` or ``"decision": "block"``), excludes test
|
|
5
|
+
files and the logger module itself, then asserts each one imports and calls
|
|
6
|
+
``log_hook_block(``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
|
|
16
|
+
_DENY_PATTERN = re.compile(r'"permissionDecision":\s*"deny"')
|
|
17
|
+
_BLOCK_PATTERN = re.compile(r'"decision":\s*"block"')
|
|
18
|
+
_LOG_CALL_PATTERN = re.compile(r"\blog_hook_block\(")
|
|
19
|
+
|
|
20
|
+
_LOGGER_MODULE_NAME = "hook_block_logger.py"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_test_file(path: Path) -> bool:
|
|
24
|
+
return path.name.startswith("test_") or path.name.endswith("_test.py")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _discover_blocking_hook_paths() -> list[Path]:
|
|
28
|
+
all_blocking_hook_paths: list[Path] = []
|
|
29
|
+
for each_py_file in _HOOKS_ROOT.rglob("*.py"):
|
|
30
|
+
if _is_test_file(each_py_file):
|
|
31
|
+
continue
|
|
32
|
+
if each_py_file.name == _LOGGER_MODULE_NAME:
|
|
33
|
+
continue
|
|
34
|
+
source = each_py_file.read_text(encoding="utf-8", errors="replace")
|
|
35
|
+
if _DENY_PATTERN.search(source) or _BLOCK_PATTERN.search(source):
|
|
36
|
+
all_blocking_hook_paths.append(each_py_file)
|
|
37
|
+
return sorted(all_blocking_hook_paths)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_every_blocking_hook_calls_log_hook_block() -> None:
|
|
41
|
+
all_blocking_hooks = _discover_blocking_hook_paths()
|
|
42
|
+
assert all_blocking_hooks, "No blocking hooks discovered — check _HOOKS_ROOT path"
|
|
43
|
+
|
|
44
|
+
all_uninstrumented_hooks: list[str] = []
|
|
45
|
+
for each_hook_path in all_blocking_hooks:
|
|
46
|
+
source = each_hook_path.read_text(encoding="utf-8", errors="replace")
|
|
47
|
+
if not _LOG_CALL_PATTERN.search(source):
|
|
48
|
+
all_uninstrumented_hooks.append(str(each_hook_path.relative_to(_HOOKS_ROOT)))
|
|
49
|
+
|
|
50
|
+
assert not all_uninstrumented_hooks, (
|
|
51
|
+
f"{len(all_uninstrumented_hooks)} blocking hook(s) missing log_hook_block call:\n"
|
|
52
|
+
+ "\n".join(f" - {each_path}" for each_path in all_uninstrumented_hooks)
|
|
53
|
+
)
|
|
@@ -16,6 +16,11 @@ if _HOOKS_ROOT not in sys.path:
|
|
|
16
16
|
sys.path.insert(0, _HOOKS_ROOT)
|
|
17
17
|
import intent_only_ending_blocker
|
|
18
18
|
from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE
|
|
19
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
20
|
+
|
|
21
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
22
|
+
assert intent_only_ending_blocker.strip_code_and_quotes is strip_code_and_quotes
|
|
23
|
+
|
|
19
24
|
|
|
20
25
|
INTENT_ENDING_MESSAGE = "I'll now run the test suite and fix any failures that come up."
|
|
21
26
|
NEXT_STEPS_MESSAGE = "Next steps:"
|