claude-dev-env 1.73.0 → 1.74.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 +3 -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 +10 -4
- 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/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- 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 +3 -3
- 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 +18 -12
- 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 +14 -8
- 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 +45 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -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 +8 -8
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -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 +4 -0
- 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/docstring_rule_gate_count_blocker_constants.py +90 -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 +1 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
- 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/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +22 -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/skills/autoconverge/SKILL.md +18 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +2 -1
|
@@ -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,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
|
+
)
|
|
@@ -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"
|
|
@@ -585,24 +585,24 @@ def test_dispatcher_write_applies_both_groups() -> None:
|
|
|
585
585
|
assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
|
|
586
586
|
"plain_language_blocker (Group B) must be in Write applicable set"
|
|
587
587
|
)
|
|
588
|
-
assert len(all_write_entries) ==
|
|
589
|
-
f"Write tool must apply to all
|
|
588
|
+
assert len(all_write_entries) == 17, (
|
|
589
|
+
f"Write tool must apply to all 17 hosted hooks, got {len(all_write_entries)}"
|
|
590
590
|
)
|
|
591
591
|
|
|
592
592
|
|
|
593
593
|
def test_dispatcher_edit_applies_both_groups() -> None:
|
|
594
594
|
"""Edit tool triggers both Group A and Group B hooks through the dispatcher."""
|
|
595
595
|
all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
|
|
596
|
-
assert len(all_edit_entries) ==
|
|
597
|
-
f"Edit tool must apply to all
|
|
596
|
+
assert len(all_edit_entries) == 17, (
|
|
597
|
+
f"Edit tool must apply to all 17 hosted hooks, got {len(all_edit_entries)}"
|
|
598
598
|
)
|
|
599
599
|
|
|
600
600
|
|
|
601
601
|
def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
602
|
-
"""MultiEdit tool triggers only Group B (
|
|
602
|
+
"""MultiEdit tool triggers only Group B (7 hooks), not Group A."""
|
|
603
603
|
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
|
|
604
|
+
assert len(all_multi_edit_entries) == 7, (
|
|
605
|
+
f"MultiEdit tool must apply to exactly 7 Group-B hooks, got {len(all_multi_edit_entries)}"
|
|
606
606
|
)
|
|
607
607
|
|
|
608
608
|
|
|
@@ -613,7 +613,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
|
|
|
613
613
|
it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
|
|
614
614
|
A Python Write payload that run_all_validators would have flagged (mypy errors, for
|
|
615
615
|
instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
|
|
616
|
-
dispatcher covers only its
|
|
616
|
+
dispatcher covers only its 17 hosted blocking hooks — none of which includes the
|
|
617
617
|
validators runner.
|
|
618
618
|
"""
|
|
619
619
|
python_content_with_type_error = (
|