claude-dev-env 1.72.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 +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- 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 +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- 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 +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -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 +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -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 +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -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 +21 -7
- 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 +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- 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/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- 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 +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Behavior tests for the code_verifier_spawn_preflight_gate PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Each test builds a real git repository in a temporary directory, writes real
|
|
4
|
+
files (real merge conflicts, real CODE_RULES violations), and runs the hook
|
|
5
|
+
script as a subprocess with a real Agent PreToolUse JSON payload on stdin —
|
|
6
|
+
the exact production invocation path. No mocks. The harness mirrors
|
|
7
|
+
test_precommit_code_rules_gate.py lines 1-70.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
HOOK_PATH = Path(__file__).resolve().parent / "code_verifier_spawn_preflight_gate.py"
|
|
17
|
+
|
|
18
|
+
CLEAN_MODULE_SOURCE = '''"""Increment helper used by the preflight gate tests."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def add_one(number: int) -> int:
|
|
22
|
+
"""Return *number* plus one.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
number: The integer to increment.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The incremented integer.
|
|
29
|
+
"""
|
|
30
|
+
return number + 1
|
|
31
|
+
'''
|
|
32
|
+
|
|
33
|
+
CLEAN_MODULE_SOURCE_EDITED = '''"""Increment helper used by the preflight gate tests."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_one(number: int) -> int:
|
|
37
|
+
"""Return *number* plus two.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
number: The integer to increment.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The incremented integer.
|
|
44
|
+
"""
|
|
45
|
+
return number + 2
|
|
46
|
+
'''
|
|
47
|
+
|
|
48
|
+
VIOLATING_MODULE_SOURCE = '''"""Module carrying a banned identifier for the preflight gate tests."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compute_total() -> int:
|
|
52
|
+
"""Return a fixed total.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The fixed total.
|
|
56
|
+
"""
|
|
57
|
+
result = 1
|
|
58
|
+
return result
|
|
59
|
+
'''
|
|
60
|
+
|
|
61
|
+
CLEAN_TWO_FUNCTION_SOURCE = '''"""Two-function helper used by the preflight gate tests."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_one(number: int) -> int:
|
|
65
|
+
"""Return *number* plus one.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
number: The integer to increment.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The incremented integer.
|
|
72
|
+
"""
|
|
73
|
+
return number + 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def add_two(number: int) -> int:
|
|
77
|
+
"""Return *number* plus two.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
number: The integer to increment.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The integer plus two.
|
|
84
|
+
"""
|
|
85
|
+
return number + 2
|
|
86
|
+
'''
|
|
87
|
+
|
|
88
|
+
PREEXISTING_VIOLATION_BASE_SOURCE = '''"""Module that already carries a banned identifier at the base commit."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def compute_total() -> int:
|
|
92
|
+
"""Return a fixed total.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The fixed total.
|
|
96
|
+
"""
|
|
97
|
+
result = 1
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def add_one(number: int) -> int:
|
|
102
|
+
"""Return *number* plus one.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
number: The integer to increment.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The incremented integer.
|
|
109
|
+
"""
|
|
110
|
+
return number + 1
|
|
111
|
+
'''
|
|
112
|
+
|
|
113
|
+
PREEXISTING_VIOLATION_EDITED_SOURCE = '''"""Module that already carries a banned identifier at the base commit."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compute_total() -> int:
|
|
117
|
+
"""Return a fixed total.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The fixed total.
|
|
121
|
+
"""
|
|
122
|
+
result = 1
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def add_one(number: int) -> int:
|
|
127
|
+
"""Return *number* plus two.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
number: The integer to increment.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The integer plus two.
|
|
134
|
+
"""
|
|
135
|
+
return number + 2
|
|
136
|
+
'''
|
|
137
|
+
|
|
138
|
+
SHARED_BASE_SOURCE = '''"""Shared module the conflict fixture edits on both sides."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def shared_value() -> int:
|
|
142
|
+
"""Return the shared base value.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The shared integer.
|
|
146
|
+
"""
|
|
147
|
+
return 100
|
|
148
|
+
'''
|
|
149
|
+
|
|
150
|
+
SHARED_FEATURE_SOURCE = '''"""Shared module the conflict fixture edits on both sides."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def shared_value() -> int:
|
|
154
|
+
"""Return the shared base value.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The shared integer.
|
|
158
|
+
"""
|
|
159
|
+
return 200
|
|
160
|
+
'''
|
|
161
|
+
|
|
162
|
+
SHARED_DIVERGENT_SOURCE = '''"""Shared module the conflict fixture edits on both sides."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def shared_value() -> int:
|
|
166
|
+
"""Return the shared base value.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The shared integer.
|
|
170
|
+
"""
|
|
171
|
+
return 300
|
|
172
|
+
'''
|
|
173
|
+
|
|
174
|
+
OTHER_DIVERGENT_SOURCE = '''"""Unrelated module the divergent base edits for the behind-but-clean case."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def other_value() -> int:
|
|
178
|
+
"""Return the unrelated value.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The unrelated integer.
|
|
182
|
+
"""
|
|
183
|
+
return 42
|
|
184
|
+
'''
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_git(repository_root: Path, *git_arguments: str) -> str:
|
|
188
|
+
completed = subprocess.run(
|
|
189
|
+
["git", "-C", str(repository_root), *git_arguments],
|
|
190
|
+
check=True,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
)
|
|
194
|
+
return completed.stdout.strip()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def disable_native_git_hooks(repository_root: Path) -> None:
|
|
198
|
+
empty_hooks_directory = repository_root.parent / "nohooks"
|
|
199
|
+
empty_hooks_directory.mkdir(exist_ok=True)
|
|
200
|
+
run_git(repository_root, "config", "core.hooksPath", str(empty_hooks_directory))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def initialize_repository(repository_root: Path) -> None:
|
|
204
|
+
run_git(repository_root, "init")
|
|
205
|
+
run_git(repository_root, "config", "user.email", "tests@example.com")
|
|
206
|
+
run_git(repository_root, "config", "user.name", "Preflight Tests")
|
|
207
|
+
disable_native_git_hooks(repository_root)
|
|
208
|
+
run_git(repository_root, "checkout", "-b", "main")
|
|
209
|
+
(repository_root / "base.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
210
|
+
run_git(repository_root, "add", "base.py")
|
|
211
|
+
run_git(repository_root, "commit", "-m", "initial")
|
|
212
|
+
main_sha = run_git(repository_root, "rev-parse", "HEAD")
|
|
213
|
+
run_git(repository_root, "update-ref", "refs/remotes/origin/main", main_sha)
|
|
214
|
+
resolved_base = run_git(repository_root, "merge-base", "HEAD", "origin/main")
|
|
215
|
+
assert resolved_base, "fixture must resolve a merge base against origin/main"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def commit_file(repository_root: Path, relative_name: str, source_text: str, message: str) -> str:
|
|
219
|
+
(repository_root / relative_name).write_text(source_text, encoding="utf-8")
|
|
220
|
+
run_git(repository_root, "add", relative_name)
|
|
221
|
+
run_git(repository_root, "commit", "-m", message)
|
|
222
|
+
return run_git(repository_root, "rev-parse", "HEAD")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def write_working_tree_file(repository_root: Path, relative_name: str, source_text: str) -> None:
|
|
226
|
+
(repository_root / relative_name).write_text(source_text, encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_invalid_utf8_file(repository_root: Path, relative_name: str) -> None:
|
|
230
|
+
(repository_root / relative_name).write_bytes(b"\xff\xfe invalid utf-8 bytes\n")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def advance_origin_main_divergent(
|
|
234
|
+
repository_root: Path, base_sha: str, relative_name: str, source_text: str
|
|
235
|
+
) -> None:
|
|
236
|
+
run_git(repository_root, "checkout", "-b", "divergent", base_sha)
|
|
237
|
+
commit_file(repository_root, relative_name, source_text, "divergent edit")
|
|
238
|
+
divergent_sha = run_git(repository_root, "rev-parse", "HEAD")
|
|
239
|
+
run_git(repository_root, "update-ref", "refs/remotes/origin/main", divergent_sha)
|
|
240
|
+
run_git(repository_root, "checkout", "feature")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def write_agent_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
244
|
+
return json.dumps(
|
|
245
|
+
{
|
|
246
|
+
"tool_name": "Agent",
|
|
247
|
+
"tool_input": {"subagent_type": subagent_type, "prompt": prompt},
|
|
248
|
+
"cwd": str(cwd),
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run_hook(payload: str, cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
254
|
+
return subprocess.run(
|
|
255
|
+
[sys.executable, str(HOOK_PATH)],
|
|
256
|
+
check=False,
|
|
257
|
+
input=payload,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
cwd=str(cwd),
|
|
261
|
+
timeout=120,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def is_allow(result: subprocess.CompletedProcess[str]) -> bool:
|
|
266
|
+
stdout_text = result.stdout.strip()
|
|
267
|
+
if not stdout_text:
|
|
268
|
+
return True
|
|
269
|
+
parsed = json.loads(stdout_text)
|
|
270
|
+
hook_output = parsed.get("hookSpecificOutput", {})
|
|
271
|
+
return hook_output.get("permissionDecision") != "deny"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def deny_reason(result: subprocess.CompletedProcess[str]) -> str:
|
|
275
|
+
parsed = json.loads(result.stdout.strip())
|
|
276
|
+
return parsed["hookSpecificOutput"]["permissionDecisionReason"]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def make_conflict_repository(tmp_path: Path) -> Path:
|
|
280
|
+
repository_root = tmp_path / "repo"
|
|
281
|
+
repository_root.mkdir()
|
|
282
|
+
initialize_repository(repository_root)
|
|
283
|
+
base_sha = commit_file(repository_root, "shared.py", SHARED_BASE_SOURCE, "add shared")
|
|
284
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
285
|
+
commit_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE, "feature edit")
|
|
286
|
+
advance_origin_main_divergent(repository_root, base_sha, "shared.py", SHARED_DIVERGENT_SOURCE)
|
|
287
|
+
return repository_root
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_non_code_verifier_agent_is_no_op(tmp_path: Path) -> None:
|
|
291
|
+
repository_root = make_conflict_repository(tmp_path)
|
|
292
|
+
write_working_tree_file(repository_root, "violator.py", VIOLATING_MODULE_SOURCE)
|
|
293
|
+
payload = write_agent_payload("clean-coder", "do an audit", repository_root)
|
|
294
|
+
result = run_hook(payload, repository_root)
|
|
295
|
+
assert is_allow(result)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_non_agent_tool_is_no_op(tmp_path: Path) -> None:
|
|
299
|
+
repository_root = tmp_path / "repo"
|
|
300
|
+
repository_root.mkdir()
|
|
301
|
+
initialize_repository(repository_root)
|
|
302
|
+
payload = json.dumps(
|
|
303
|
+
{"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": str(repository_root)}
|
|
304
|
+
)
|
|
305
|
+
result = run_hook(payload, repository_root)
|
|
306
|
+
assert is_allow(result)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_clean_surface_allows(tmp_path: Path) -> None:
|
|
310
|
+
repository_root = tmp_path / "repo"
|
|
311
|
+
repository_root.mkdir()
|
|
312
|
+
initialize_repository(repository_root)
|
|
313
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
314
|
+
write_working_tree_file(repository_root, "feature.py", CLEAN_MODULE_SOURCE)
|
|
315
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
316
|
+
result = run_hook(payload, repository_root)
|
|
317
|
+
assert is_allow(result)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_real_conflict_denies_naming_files(tmp_path: Path) -> None:
|
|
321
|
+
repository_root = make_conflict_repository(tmp_path)
|
|
322
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
323
|
+
result = run_hook(payload, repository_root)
|
|
324
|
+
assert not is_allow(result)
|
|
325
|
+
reason = deny_reason(result)
|
|
326
|
+
assert "shared.py" in reason
|
|
327
|
+
assert "Merge conflicts vs" in reason
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_real_code_rules_violation_on_added_line_denies(tmp_path: Path) -> None:
|
|
331
|
+
repository_root = tmp_path / "repo"
|
|
332
|
+
repository_root.mkdir()
|
|
333
|
+
initialize_repository(repository_root)
|
|
334
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
335
|
+
commit_file(repository_root, "tracked.py", CLEAN_MODULE_SOURCE, "add tracked")
|
|
336
|
+
write_working_tree_file(repository_root, "tracked.py", VIOLATING_MODULE_SOURCE)
|
|
337
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
338
|
+
result = run_hook(payload, repository_root)
|
|
339
|
+
assert not is_allow(result)
|
|
340
|
+
reason = deny_reason(result)
|
|
341
|
+
assert "tracked.py" in reason
|
|
342
|
+
assert "Line " in reason
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_preexisting_violation_on_untouched_line_allows(tmp_path: Path) -> None:
|
|
346
|
+
repository_root = tmp_path / "repo"
|
|
347
|
+
repository_root.mkdir()
|
|
348
|
+
initialize_repository(repository_root)
|
|
349
|
+
carrier_sha = commit_file(
|
|
350
|
+
repository_root, "carrier.py", PREEXISTING_VIOLATION_BASE_SOURCE, "add carrier"
|
|
351
|
+
)
|
|
352
|
+
run_git(repository_root, "update-ref", "refs/remotes/origin/main", carrier_sha)
|
|
353
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
354
|
+
write_working_tree_file(repository_root, "carrier.py", PREEXISTING_VIOLATION_EDITED_SOURCE)
|
|
355
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
356
|
+
result = run_hook(payload, repository_root)
|
|
357
|
+
assert is_allow(result)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_untracked_new_violating_file_denies(tmp_path: Path) -> None:
|
|
361
|
+
repository_root = tmp_path / "repo"
|
|
362
|
+
repository_root.mkdir()
|
|
363
|
+
initialize_repository(repository_root)
|
|
364
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
365
|
+
write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
|
|
366
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
367
|
+
result = run_hook(payload, repository_root)
|
|
368
|
+
assert not is_allow(result)
|
|
369
|
+
reason = deny_reason(result)
|
|
370
|
+
assert "fresh.py" in reason
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_tooling_scratch_file_is_ignored(tmp_path: Path) -> None:
|
|
374
|
+
repository_root = tmp_path / "repo"
|
|
375
|
+
repository_root.mkdir()
|
|
376
|
+
initialize_repository(repository_root)
|
|
377
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
378
|
+
scratch_directory = repository_root / ".claude" / "verification"
|
|
379
|
+
scratch_directory.mkdir(parents=True)
|
|
380
|
+
(scratch_directory / "x.py").write_text(VIOLATING_MODULE_SOURCE, encoding="utf-8")
|
|
381
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
382
|
+
result = run_hook(payload, repository_root)
|
|
383
|
+
assert is_allow(result)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_missing_base_ref_fails_open(tmp_path: Path) -> None:
|
|
387
|
+
repository_root = tmp_path / "repo"
|
|
388
|
+
repository_root.mkdir()
|
|
389
|
+
run_git(repository_root, "init")
|
|
390
|
+
run_git(repository_root, "config", "user.email", "tests@example.com")
|
|
391
|
+
run_git(repository_root, "config", "user.name", "Preflight Tests")
|
|
392
|
+
disable_native_git_hooks(repository_root)
|
|
393
|
+
(repository_root / "base.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
394
|
+
run_git(repository_root, "add", "base.py")
|
|
395
|
+
run_git(repository_root, "commit", "-m", "initial")
|
|
396
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
397
|
+
result = run_hook(payload, repository_root)
|
|
398
|
+
assert is_allow(result)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_non_repo_cwd_fails_open(tmp_path: Path) -> None:
|
|
402
|
+
non_repo_directory = tmp_path / "plain"
|
|
403
|
+
non_repo_directory.mkdir()
|
|
404
|
+
payload = write_agent_payload("code-verifier", "verify the change", non_repo_directory)
|
|
405
|
+
result = run_hook(payload, non_repo_directory)
|
|
406
|
+
assert is_allow(result)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_behind_but_conflict_free_allows(tmp_path: Path) -> None:
|
|
410
|
+
repository_root = tmp_path / "repo"
|
|
411
|
+
repository_root.mkdir()
|
|
412
|
+
initialize_repository(repository_root)
|
|
413
|
+
base_sha = commit_file(repository_root, "shared.py", SHARED_BASE_SOURCE, "add shared")
|
|
414
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
415
|
+
commit_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE, "feature edit")
|
|
416
|
+
advance_origin_main_divergent(repository_root, base_sha, "other.py", OTHER_DIVERGENT_SOURCE)
|
|
417
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
418
|
+
result = run_hook(payload, repository_root)
|
|
419
|
+
assert is_allow(result)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_unreadable_changed_file_alone_fails_open(tmp_path: Path) -> None:
|
|
423
|
+
repository_root = tmp_path / "repo"
|
|
424
|
+
repository_root.mkdir()
|
|
425
|
+
initialize_repository(repository_root)
|
|
426
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
427
|
+
write_invalid_utf8_file(repository_root, "binary.py")
|
|
428
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
429
|
+
result = run_hook(payload, repository_root)
|
|
430
|
+
assert is_allow(result)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_unreadable_changed_file_does_not_mask_real_violation(tmp_path: Path) -> None:
|
|
434
|
+
repository_root = tmp_path / "repo"
|
|
435
|
+
repository_root.mkdir()
|
|
436
|
+
initialize_repository(repository_root)
|
|
437
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
438
|
+
write_invalid_utf8_file(repository_root, "binary.py")
|
|
439
|
+
write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
|
|
440
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
441
|
+
result = run_hook(payload, repository_root)
|
|
442
|
+
assert not is_allow(result)
|
|
443
|
+
reason = deny_reason(result)
|
|
444
|
+
assert "fresh.py" in reason
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_conflict_and_violation_single_deny_names_both(tmp_path: Path) -> None:
|
|
448
|
+
repository_root = make_conflict_repository(tmp_path)
|
|
449
|
+
write_working_tree_file(repository_root, "violator.py", VIOLATING_MODULE_SOURCE)
|
|
450
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
451
|
+
result = run_hook(payload, repository_root)
|
|
452
|
+
assert not is_allow(result)
|
|
453
|
+
reason = deny_reason(result)
|
|
454
|
+
assert "Merge conflicts vs" in reason
|
|
455
|
+
assert "shared.py" in reason
|
|
456
|
+
assert "CODE_RULES violations on changed lines:" in reason
|
|
457
|
+
assert "violator.py" in reason
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_hook_imports_real_config_when_parent_holds_shadowing_config(
|
|
461
|
+
tmp_path: Path,
|
|
462
|
+
) -> None:
|
|
463
|
+
real_hooks_directory = HOOK_PATH.parent.parent
|
|
464
|
+
real_package_directory = real_hooks_directory.parent
|
|
465
|
+
|
|
466
|
+
staged_package_directory = tmp_path / "claude-dev-env"
|
|
467
|
+
shutil.copytree(
|
|
468
|
+
real_hooks_directory,
|
|
469
|
+
staged_package_directory / "hooks",
|
|
470
|
+
)
|
|
471
|
+
shutil.copytree(
|
|
472
|
+
real_package_directory / "_shared",
|
|
473
|
+
staged_package_directory / "_shared",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
shadowing_config_directory = staged_package_directory / "hooks" / "config"
|
|
477
|
+
shadowing_config_directory.mkdir(parents=True, exist_ok=True)
|
|
478
|
+
(shadowing_config_directory / "__init__.py").write_text("", encoding="utf-8")
|
|
479
|
+
(shadowing_config_directory / "unrelated_constants.py").write_text(
|
|
480
|
+
"UNRELATED_VALUE = 1\n", encoding="utf-8"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
staged_hook = (
|
|
484
|
+
staged_package_directory
|
|
485
|
+
/ "hooks"
|
|
486
|
+
/ "blocking"
|
|
487
|
+
/ "code_verifier_spawn_preflight_gate.py"
|
|
488
|
+
)
|
|
489
|
+
payload = write_agent_payload("general-purpose", "unrelated work", tmp_path)
|
|
490
|
+
completed = subprocess.run(
|
|
491
|
+
[sys.executable, str(staged_hook)],
|
|
492
|
+
check=False,
|
|
493
|
+
input=payload,
|
|
494
|
+
capture_output=True,
|
|
495
|
+
text=True,
|
|
496
|
+
cwd=str(tmp_path),
|
|
497
|
+
timeout=120,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
assert "ModuleNotFoundError" not in completed.stderr
|
|
501
|
+
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() == ""
|