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,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse dispatcher that hosts the after-write Write/Edit hooks.
|
|
3
|
+
|
|
4
|
+
Reads the tool payload from stdin once, runs each hosted hook in-process via
|
|
5
|
+
runpy in the fixed order declared in the constants module, aggregates the
|
|
6
|
+
results, and emits one PostToolUse block decision when any hook blocked
|
|
7
|
+
(carrying every blocking reason) or exits zero to allow.
|
|
8
|
+
|
|
9
|
+
The hosted hooks keep every side effect they have today: the formatter writes
|
|
10
|
+
the reformatted file to disk, and the doc publisher uploads the gist. Running
|
|
11
|
+
them in-process preserves those side effects while collapsing three processes
|
|
12
|
+
into one. The dispatcher itself performs no file write; it runs the hooks in a
|
|
13
|
+
fixed order that reproduces the prior registration order. One hosted hook (the
|
|
14
|
+
formatter) does rewrite the edited file mid-sequence, so a later hook reads the
|
|
15
|
+
file as the formatter left it — the same order the prior separate entries ran.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import runpy
|
|
23
|
+
import sys
|
|
24
|
+
import traceback
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TextIO
|
|
28
|
+
|
|
29
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
30
|
+
if _hooks_directory not in sys.path:
|
|
31
|
+
sys.path.insert(0, _hooks_directory)
|
|
32
|
+
|
|
33
|
+
from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402
|
|
34
|
+
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
35
|
+
BLOCK_DECISION,
|
|
36
|
+
DECISION_KEY,
|
|
37
|
+
EMPTY_REASON_BLOCK_FALLBACK,
|
|
38
|
+
HOOK_EVENT_NAME,
|
|
39
|
+
PLUGIN_ROOT_PLACEHOLDER,
|
|
40
|
+
REASON_KEY,
|
|
41
|
+
PostHostedHookEntry,
|
|
42
|
+
)
|
|
43
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
44
|
+
read_hook_input_dictionary_from_stdin,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PostHostedHookResult:
|
|
50
|
+
"""Outcome of running one hosted PostToolUse hook inside the dispatcher process.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
captured_stdout: The text the hook wrote to stdout during its run.
|
|
54
|
+
did_crash: True when the hook raised a non-SystemExit exception.
|
|
55
|
+
is_blocking: True when this hook's crash surfaces a blocking signal.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
captured_stdout: str
|
|
59
|
+
did_crash: bool = field(default=False)
|
|
60
|
+
is_blocking: bool = field(default=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _log_hook_crash(hook_script_path: str, error: Exception) -> None:
|
|
64
|
+
"""Write a one-line crash summary to stderr.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
hook_script_path: The absolute path of the hook that crashed.
|
|
68
|
+
error: The exception the hook raised.
|
|
69
|
+
"""
|
|
70
|
+
formatted_traceback = traceback.format_exc().strip()
|
|
71
|
+
last_line = formatted_traceback.splitlines()[-1] if formatted_traceback else str(error)
|
|
72
|
+
error_type_name = type(error).__name__
|
|
73
|
+
sys.stderr.write(
|
|
74
|
+
f"[dispatcher] crash in {hook_script_path}: {error_type_name}: {error} | {last_line}\n"
|
|
75
|
+
)
|
|
76
|
+
sys.stderr.flush()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_hosted_hook(
|
|
80
|
+
hook_script_path: str,
|
|
81
|
+
all_hook_arguments: list[str],
|
|
82
|
+
payload_text: str,
|
|
83
|
+
is_blocking: bool,
|
|
84
|
+
) -> PostHostedHookResult:
|
|
85
|
+
"""Run one hosted PostToolUse hook in-process and return its outcome.
|
|
86
|
+
|
|
87
|
+
Sets stdin to a fresh stream over payload_text, sets argv to the hook's
|
|
88
|
+
script path plus its argument tail so a hook reading sys.argv resolves the
|
|
89
|
+
same arguments the live entry passes, captures stdout into a buffer, runs
|
|
90
|
+
the hook via runpy under __main__, catches SystemExit to absorb the hook's
|
|
91
|
+
exit without ending the dispatcher, and catches a non-SystemExit exception
|
|
92
|
+
to log the crash and classify it. Always restores stdin, stdout, and argv
|
|
93
|
+
in the finally block.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
hook_script_path: Absolute path of the hook script to run.
|
|
97
|
+
all_hook_arguments: Resolved command-line arguments the hook reads after
|
|
98
|
+
its script path.
|
|
99
|
+
payload_text: The raw payload text to replay as the hook's stdin.
|
|
100
|
+
is_blocking: Whether a crash from this hook surfaces a blocking signal.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A PostHostedHookResult carrying the captured stdout, crash flag, and
|
|
104
|
+
blocking classification.
|
|
105
|
+
"""
|
|
106
|
+
original_stdin = sys.stdin
|
|
107
|
+
original_stdout = sys.stdout
|
|
108
|
+
original_argv = sys.argv
|
|
109
|
+
captured_output = io.StringIO()
|
|
110
|
+
hook_did_crash = False
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
sys.stdin = io.StringIO(payload_text)
|
|
114
|
+
sys.stdout = captured_output
|
|
115
|
+
sys.argv = [hook_script_path, *all_hook_arguments]
|
|
116
|
+
runpy.run_path(hook_script_path, run_name="__main__")
|
|
117
|
+
except SystemExit:
|
|
118
|
+
pass
|
|
119
|
+
except Exception as error:
|
|
120
|
+
_log_hook_crash(hook_script_path, error)
|
|
121
|
+
hook_did_crash = True
|
|
122
|
+
finally:
|
|
123
|
+
sys.stdin = original_stdin
|
|
124
|
+
sys.stdout = original_stdout
|
|
125
|
+
sys.argv = original_argv
|
|
126
|
+
|
|
127
|
+
return PostHostedHookResult(
|
|
128
|
+
captured_stdout=captured_output.getvalue(),
|
|
129
|
+
did_crash=hook_did_crash,
|
|
130
|
+
is_blocking=is_blocking,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_block_from_hook_output(hook_output_text: str) -> tuple[bool, str]:
|
|
135
|
+
"""Parse one hook's stdout for a PostToolUse block decision.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
hook_output_text: The text the hook wrote to stdout.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
A (is_block, block_reason) pair. is_block is True when the hook output
|
|
142
|
+
carries a decision of block. block_reason is the reason text when
|
|
143
|
+
is_block is True.
|
|
144
|
+
"""
|
|
145
|
+
stripped_text = hook_output_text.strip()
|
|
146
|
+
if not stripped_text:
|
|
147
|
+
return False, ""
|
|
148
|
+
try:
|
|
149
|
+
parsed_output = json.loads(stripped_text)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
return False, ""
|
|
152
|
+
if not isinstance(parsed_output, dict):
|
|
153
|
+
return False, ""
|
|
154
|
+
is_block = parsed_output.get(DECISION_KEY) == BLOCK_DECISION
|
|
155
|
+
block_reason = parsed_output.get(REASON_KEY, "")
|
|
156
|
+
if not isinstance(block_reason, str):
|
|
157
|
+
block_reason = ""
|
|
158
|
+
return is_block, block_reason
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class PostDispatcherDecision:
|
|
163
|
+
"""The aggregated decision across all hosted PostToolUse hook results.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
should_block: True when at least one hosted hook blocked.
|
|
167
|
+
all_block_reasons: All block reasons from blocking hooks, in run order.
|
|
168
|
+
all_non_block_stdout: Stdout from hooks that did not emit a block
|
|
169
|
+
decision, concatenated in run order. Preserved so informational
|
|
170
|
+
output (such as the doc-gist htmlpreview URL) reaches the harness
|
|
171
|
+
on both the allow and block paths.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
should_block: bool
|
|
175
|
+
all_block_reasons: list[str]
|
|
176
|
+
all_non_block_stdout: list[str]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def aggregate_post_hosted_hook_results(
|
|
180
|
+
all_results: list[PostHostedHookResult],
|
|
181
|
+
) -> PostDispatcherDecision:
|
|
182
|
+
"""Aggregate all hosted PostToolUse hook results into one dispatcher decision.
|
|
183
|
+
|
|
184
|
+
Parses each result's stdout for a block decision. A block decision signals a
|
|
185
|
+
block regardless of its reason text; an empty-reason block draws
|
|
186
|
+
EMPTY_REASON_BLOCK_FALLBACK so the block is never downgraded to allow. A
|
|
187
|
+
non-SystemExit crash in a blocking hook also signals block. Block wins over
|
|
188
|
+
allow: when any result blocks, the aggregate blocks carrying every blocking
|
|
189
|
+
reason. A side-effect hook that exits cleanly contributes no block. Non-block
|
|
190
|
+
stdout from every hook is preserved so informational output reaches the
|
|
191
|
+
harness on both the allow and block paths.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
all_results: Outcomes from running each hosted hook.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A PostDispatcherDecision with the aggregated allow-or-block signal,
|
|
198
|
+
all block reasons, and all non-block stdout.
|
|
199
|
+
"""
|
|
200
|
+
blocking_crash_reason = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
201
|
+
all_block_reasons: list[str] = []
|
|
202
|
+
all_non_block_stdout: list[str] = []
|
|
203
|
+
|
|
204
|
+
for each_result in all_results:
|
|
205
|
+
is_block, block_reason = _parse_block_from_hook_output(each_result.captured_stdout)
|
|
206
|
+
if is_block:
|
|
207
|
+
all_block_reasons.append(block_reason if block_reason else EMPTY_REASON_BLOCK_FALLBACK)
|
|
208
|
+
elif each_result.did_crash and each_result.is_blocking:
|
|
209
|
+
all_block_reasons.append(blocking_crash_reason)
|
|
210
|
+
else:
|
|
211
|
+
non_block_text = each_result.captured_stdout.strip()
|
|
212
|
+
if non_block_text:
|
|
213
|
+
all_non_block_stdout.append(non_block_text)
|
|
214
|
+
|
|
215
|
+
return PostDispatcherDecision(
|
|
216
|
+
should_block=bool(all_block_reasons),
|
|
217
|
+
all_block_reasons=all_block_reasons,
|
|
218
|
+
all_non_block_stdout=all_non_block_stdout,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _emit_non_block_stdout(all_non_block_stdout: list[str], output_stream: TextIO) -> None:
|
|
223
|
+
"""Write each non-block hook's stdout to the given stream so the harness sees it.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
all_non_block_stdout: The informational stdout lines from non-blocking
|
|
227
|
+
hooks, in run order.
|
|
228
|
+
output_stream: The stream to write the informational lines to.
|
|
229
|
+
"""
|
|
230
|
+
for each_line in all_non_block_stdout:
|
|
231
|
+
output_stream.write(each_line + "\n")
|
|
232
|
+
if all_non_block_stdout:
|
|
233
|
+
output_stream.flush()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _emit_block_decision(decision: PostDispatcherDecision) -> None:
|
|
237
|
+
"""Write one PostToolUse block JSON object as the only stdout content.
|
|
238
|
+
|
|
239
|
+
Routes any non-block hook stdout to stderr so the harness can parse the whole
|
|
240
|
+
stdout stream as one JSON block object — informational text from a side-effect
|
|
241
|
+
hook never precedes the block JSON on stdout.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
decision: The aggregated dispatcher decision with block reasons and
|
|
245
|
+
non-block stdout from side-effect hooks.
|
|
246
|
+
"""
|
|
247
|
+
_emit_non_block_stdout(decision.all_non_block_stdout, sys.stderr)
|
|
248
|
+
combined_reason = " | ".join(decision.all_block_reasons)
|
|
249
|
+
block_payload: dict[str, object] = {
|
|
250
|
+
DECISION_KEY: BLOCK_DECISION,
|
|
251
|
+
REASON_KEY: combined_reason,
|
|
252
|
+
"hookSpecificOutput": {"hookEventName": HOOK_EVENT_NAME},
|
|
253
|
+
}
|
|
254
|
+
sys.stdout.write(json.dumps(block_payload) + "\n")
|
|
255
|
+
sys.stdout.flush()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
259
|
+
"""Resolve a hook relative path to an absolute path.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
relative_path: Hook path relative to the hooks/ directory.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The absolute path of the hook script.
|
|
266
|
+
"""
|
|
267
|
+
return str(Path(__file__).resolve().parent.parent / relative_path)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _resolve_argument_tail(each_entry: PostHostedHookEntry, plugin_root: str) -> list[str]:
|
|
271
|
+
"""Resolve a hook entry's relative argument paths into absolute argv values.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
each_entry: The hosted hook entry whose extra arguments to resolve.
|
|
275
|
+
plugin_root: The plugin root absolute path the dispatcher received.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
The resolved argument list the hook reads after its script path. The
|
|
279
|
+
plugin-root placeholder resolves to plugin_root; every other entry
|
|
280
|
+
resolves relative to it.
|
|
281
|
+
"""
|
|
282
|
+
resolved_arguments: list[str] = []
|
|
283
|
+
for each_relative_path in each_entry.extra_argument_relative_paths:
|
|
284
|
+
if each_relative_path == PLUGIN_ROOT_PLACEHOLDER:
|
|
285
|
+
resolved_arguments.append(plugin_root)
|
|
286
|
+
else:
|
|
287
|
+
resolved_arguments.append(str(Path(plugin_root) / each_relative_path))
|
|
288
|
+
return resolved_arguments
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def dispatch(payload_text: str, plugin_root: str) -> None:
|
|
292
|
+
"""Run all hosted PostToolUse hooks and emit one aggregated decision.
|
|
293
|
+
|
|
294
|
+
Runs each hosted hook in-process via run_hosted_hook in the fixed order,
|
|
295
|
+
aggregates the results, and emits a block JSON object when any hook blocked.
|
|
296
|
+
A clean run with no block emits nothing and the caller exits zero to allow.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
payload_text: The raw JSON payload text to replay to each hook.
|
|
300
|
+
plugin_root: The plugin root absolute path used to resolve hook
|
|
301
|
+
arguments.
|
|
302
|
+
"""
|
|
303
|
+
all_results: list[PostHostedHookResult] = []
|
|
304
|
+
for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES:
|
|
305
|
+
script_path = _resolve_hook_script_path(each_entry.script_relative_path)
|
|
306
|
+
argument_tail = _resolve_argument_tail(each_entry, plugin_root)
|
|
307
|
+
hook_result = run_hosted_hook(
|
|
308
|
+
script_path, argument_tail, payload_text, each_entry.is_blocking
|
|
309
|
+
)
|
|
310
|
+
all_results.append(hook_result)
|
|
311
|
+
|
|
312
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
313
|
+
if aggregated_decision.should_block:
|
|
314
|
+
_emit_block_decision(aggregated_decision)
|
|
315
|
+
else:
|
|
316
|
+
_emit_non_block_stdout(aggregated_decision.all_non_block_stdout, sys.stdout)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _resolve_plugin_root() -> str:
|
|
320
|
+
"""Return the plugin root from argv, or the dispatcher's own location.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
The plugin root absolute path. The live entry passes the plugin root as
|
|
324
|
+
the first argument; when no argument is present the dispatcher derives
|
|
325
|
+
it from its own path (the hooks directory's parent).
|
|
326
|
+
"""
|
|
327
|
+
if len(sys.argv) > 1 and sys.argv[1]:
|
|
328
|
+
return sys.argv[1]
|
|
329
|
+
return str(Path(__file__).resolve().parent.parent.parent)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main() -> None:
|
|
333
|
+
"""Read stdin once and dispatch to all hosted PostToolUse hooks."""
|
|
334
|
+
payload_dict = read_hook_input_dictionary_from_stdin()
|
|
335
|
+
if payload_dict is None:
|
|
336
|
+
sys.exit(0)
|
|
337
|
+
|
|
338
|
+
payload_text = json.dumps(payload_dict)
|
|
339
|
+
dispatch(payload_text, _resolve_plugin_root())
|
|
340
|
+
sys.exit(0)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOOK_PATH = Path(__file__).parent / "hook_format_validator.py"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_hook(
|
|
11
|
+
payload: dict[str, object],
|
|
12
|
+
extra_env: dict[str, str] | None = None,
|
|
13
|
+
) -> subprocess.CompletedProcess[str]:
|
|
14
|
+
env = {**os.environ, **(extra_env or {})}
|
|
15
|
+
return subprocess.run(
|
|
16
|
+
[sys.executable, str(HOOK_PATH)],
|
|
17
|
+
input=json.dumps(payload),
|
|
18
|
+
text=True,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
check=False,
|
|
21
|
+
env=env,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_simple_pattern_blocks_with_deny_payload(tmp_path: Path) -> None:
|
|
26
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
27
|
+
payload = {
|
|
28
|
+
"tool_name": "Edit",
|
|
29
|
+
"tool_input": {
|
|
30
|
+
"file_path": str(settings_path),
|
|
31
|
+
"new_string": "python3 ~/.claude/hooks/blocking/my-hook.py",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
hook_run = _run_hook(payload)
|
|
36
|
+
|
|
37
|
+
assert hook_run.returncode == 0
|
|
38
|
+
deny_payload = json.loads(hook_run.stdout)
|
|
39
|
+
hook_specific_output = deny_payload["hookSpecificOutput"]
|
|
40
|
+
assert hook_specific_output["hookEventName"] == "PreToolUse"
|
|
41
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_block_logs_pre_tool_use_event(tmp_path: Path) -> None:
|
|
45
|
+
fake_home = tmp_path / "home"
|
|
46
|
+
fake_home.mkdir()
|
|
47
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
48
|
+
payload = {
|
|
49
|
+
"tool_name": "Edit",
|
|
50
|
+
"tool_input": {
|
|
51
|
+
"file_path": str(settings_path),
|
|
52
|
+
"new_string": "python3 ~/.claude/hooks/blocking/my-hook.py",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
hook_run = _run_hook(
|
|
57
|
+
payload,
|
|
58
|
+
extra_env={"HOME": str(fake_home), "USERPROFILE": str(fake_home)},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert hook_run.returncode == 0
|
|
62
|
+
log_path = fake_home / ".claude" / "logs" / "hook-blocks.log"
|
|
63
|
+
logged_record = json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1])
|
|
64
|
+
assert logged_record["event"] == "PreToolUse"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Behavior tests for the mypy_validator config-discovery fix.
|
|
1
|
+
"""Behavior tests for the mypy_validator config-discovery fix and caching.
|
|
2
2
|
|
|
3
3
|
The hook runs mypy from the project root, so without handing mypy the project's
|
|
4
4
|
own ``[tool.mypy]`` config a module that imports its siblings by name draws a
|
|
@@ -6,12 +6,22 @@ spurious ``import-not-found`` error. These tests drive the real production
|
|
|
6
6
|
functions: ``discover_mypy_config`` walks up to the nearest configuring
|
|
7
7
|
``pyproject.toml`` and ``run_mypy`` passes it through so the project's
|
|
8
8
|
``ignore_missing_imports`` setting applies.
|
|
9
|
+
|
|
10
|
+
The caching tests drive the real per-session caches: the config-walk cache that
|
|
11
|
+
keeps ``discover_mypy_config`` from walking ancestors twice under one project
|
|
12
|
+
root, and the content-hash cache that lets a clean file's mypy run be skipped
|
|
13
|
+
while a changed file still re-runs. Both run through the production path with
|
|
14
|
+
the cache directory redirected to a temporary directory.
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
12
20
|
from pathlib import Path
|
|
13
21
|
from types import ModuleType
|
|
14
22
|
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
15
25
|
HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
|
|
16
26
|
|
|
17
27
|
MODULE_WITH_SIBLING_IMPORT = (
|
|
@@ -20,6 +30,15 @@ MODULE_WITH_SIBLING_IMPORT = (
|
|
|
20
30
|
TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
|
|
21
31
|
NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n"
|
|
22
32
|
|
|
33
|
+
CLEAN_MODULE_SOURCE = "value: int = 1\n"
|
|
34
|
+
TYPE_ERROR_MODULE_SOURCE = 'value: int = "not an integer"\n'
|
|
35
|
+
|
|
36
|
+
UNTYPED_DEF_MODULE_SOURCE = "def passthrough(supplied):\n return supplied\n"
|
|
37
|
+
LOOSE_TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
|
|
38
|
+
STRICT_TOOL_MYPY_PYPROJECT = (
|
|
39
|
+
"[tool.mypy]\nignore_missing_imports = true\ndisallow_untyped_defs = true\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
23
42
|
|
|
24
43
|
def _load_validator() -> ModuleType:
|
|
25
44
|
spec = importlib.util.spec_from_file_location("mypy_validator_under_test", HOOK_PATH)
|
|
@@ -29,6 +48,19 @@ def _load_validator() -> ModuleType:
|
|
|
29
48
|
return module
|
|
30
49
|
|
|
31
50
|
|
|
51
|
+
@pytest.fixture(autouse=True)
|
|
52
|
+
def isolate_cache_directory(
|
|
53
|
+
tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
|
|
54
|
+
) -> None:
|
|
55
|
+
isolated_cache_directory = tmp_path_factory.mktemp("mypy-validator-cache")
|
|
56
|
+
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "isolated-test-session")
|
|
57
|
+
monkeypatch.setattr(
|
|
58
|
+
"hooks_constants.mypy_validator_cache_constants.HOOK_STATE_CACHE_DIRECTORY",
|
|
59
|
+
str(isolated_cache_directory),
|
|
60
|
+
raising=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
32
64
|
def test_discover_mypy_config_finds_nearest_tool_mypy_pyproject(tmp_path: Path) -> None:
|
|
33
65
|
validator = _load_validator()
|
|
34
66
|
(tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
@@ -92,3 +124,176 @@ def test_run_mypy_reports_import_error_without_tool_mypy_config(tmp_path: Path)
|
|
|
92
124
|
|
|
93
125
|
assert exit_code != 0
|
|
94
126
|
assert "import-not-found" in output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_config_walk_runs_once_per_root_across_two_edits(
|
|
130
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
131
|
+
) -> None:
|
|
132
|
+
validator = _load_validator()
|
|
133
|
+
project_root = tmp_path / "project"
|
|
134
|
+
project_root.mkdir()
|
|
135
|
+
(project_root / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
136
|
+
first_module = project_root / "first.py"
|
|
137
|
+
first_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
138
|
+
second_module = project_root / "second.py"
|
|
139
|
+
second_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
140
|
+
|
|
141
|
+
walk_call_count = 0
|
|
142
|
+
real_walk = validator.find_pyproject_with_mypy_config
|
|
143
|
+
|
|
144
|
+
def _counting_walk(starting_file: Path) -> Path | None:
|
|
145
|
+
nonlocal walk_call_count
|
|
146
|
+
walk_call_count += 1
|
|
147
|
+
return real_walk(starting_file)
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr(validator, "find_pyproject_with_mypy_config", _counting_walk)
|
|
150
|
+
|
|
151
|
+
validator.run_mypy(str(first_module), str(project_root))
|
|
152
|
+
walk_count_after_first_edit = walk_call_count
|
|
153
|
+
validator.run_mypy(str(second_module), str(project_root))
|
|
154
|
+
walk_count_after_second_edit = walk_call_count
|
|
155
|
+
|
|
156
|
+
assert walk_count_after_first_edit == 1
|
|
157
|
+
assert walk_count_after_second_edit == walk_count_after_first_edit
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_sibling_subtrees_each_resolve_their_own_nested_config(
|
|
161
|
+
tmp_path: Path,
|
|
162
|
+
) -> None:
|
|
163
|
+
validator = _load_validator()
|
|
164
|
+
git_root = tmp_path / "monorepo"
|
|
165
|
+
first_subtree = git_root / "first_package"
|
|
166
|
+
second_subtree = git_root / "second_package"
|
|
167
|
+
first_subtree.mkdir(parents=True)
|
|
168
|
+
second_subtree.mkdir(parents=True)
|
|
169
|
+
first_config = first_subtree / "pyproject.toml"
|
|
170
|
+
second_config = second_subtree / "pyproject.toml"
|
|
171
|
+
first_config.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
172
|
+
second_config.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
173
|
+
first_module = first_subtree / "first.py"
|
|
174
|
+
second_module = second_subtree / "second.py"
|
|
175
|
+
first_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
176
|
+
second_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
first_discovered = validator.discover_mypy_config(first_module)
|
|
179
|
+
second_discovered = validator.discover_mypy_config(second_module)
|
|
180
|
+
|
|
181
|
+
assert first_discovered is not None
|
|
182
|
+
assert second_discovered is not None
|
|
183
|
+
assert first_discovered.resolve() == first_config.resolve()
|
|
184
|
+
assert second_discovered.resolve() == second_config.resolve()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_warm_cache_still_blocks_file_edited_to_introduce_type_error(
|
|
188
|
+
tmp_path: Path,
|
|
189
|
+
) -> None:
|
|
190
|
+
validator = _load_validator()
|
|
191
|
+
project_root = tmp_path / "project"
|
|
192
|
+
project_root.mkdir()
|
|
193
|
+
(project_root / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
194
|
+
edited_module = project_root / "edited.py"
|
|
195
|
+
edited_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
196
|
+
|
|
197
|
+
clean_exit_code, _clean_output = validator.run_mypy(
|
|
198
|
+
str(edited_module), str(project_root)
|
|
199
|
+
)
|
|
200
|
+
assert clean_exit_code == 0
|
|
201
|
+
|
|
202
|
+
edited_module.write_text(TYPE_ERROR_MODULE_SOURCE, encoding="utf-8")
|
|
203
|
+
error_exit_code, error_output = validator.run_mypy(
|
|
204
|
+
str(edited_module), str(project_root)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
assert error_exit_code != 0
|
|
208
|
+
assert ": error:" in error_output
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_warm_cache_skips_mypy_run_when_content_unchanged(
|
|
212
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
213
|
+
) -> None:
|
|
214
|
+
validator = _load_validator()
|
|
215
|
+
project_root = tmp_path / "project"
|
|
216
|
+
project_root.mkdir()
|
|
217
|
+
(project_root / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
218
|
+
unchanged_module = project_root / "unchanged.py"
|
|
219
|
+
unchanged_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
220
|
+
|
|
221
|
+
validator.run_mypy(str(unchanged_module), str(project_root))
|
|
222
|
+
|
|
223
|
+
subprocess_run_call_count = 0
|
|
224
|
+
real_subprocess_run = validator.subprocess.run
|
|
225
|
+
|
|
226
|
+
def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
|
|
227
|
+
nonlocal subprocess_run_call_count
|
|
228
|
+
subprocess_run_call_count += 1
|
|
229
|
+
return real_subprocess_run(*positional, **keyword)
|
|
230
|
+
|
|
231
|
+
monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
|
|
232
|
+
|
|
233
|
+
second_exit_code, _second_output = validator.run_mypy(
|
|
234
|
+
str(unchanged_module), str(project_root)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert second_exit_code == 0
|
|
238
|
+
assert subprocess_run_call_count == 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_content_hash_skip_invalidated_when_mypy_config_tightens(
|
|
242
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
243
|
+
) -> None:
|
|
244
|
+
monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False)
|
|
245
|
+
validator = _load_validator()
|
|
246
|
+
project_root = tmp_path / "project"
|
|
247
|
+
project_root.mkdir()
|
|
248
|
+
config_path = project_root / "pyproject.toml"
|
|
249
|
+
config_path.write_text(LOOSE_TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
250
|
+
checked_module = project_root / "checked.py"
|
|
251
|
+
checked_module.write_text(UNTYPED_DEF_MODULE_SOURCE, encoding="utf-8")
|
|
252
|
+
|
|
253
|
+
loose_exit_code, _loose_output = validator.run_mypy(
|
|
254
|
+
str(checked_module), str(project_root)
|
|
255
|
+
)
|
|
256
|
+
assert loose_exit_code == 0
|
|
257
|
+
|
|
258
|
+
config_path.write_text(STRICT_TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
259
|
+
validator.reset_session_config_cache()
|
|
260
|
+
|
|
261
|
+
subprocess_run_call_count = 0
|
|
262
|
+
real_subprocess_run = validator.subprocess.run
|
|
263
|
+
|
|
264
|
+
def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
|
|
265
|
+
nonlocal subprocess_run_call_count
|
|
266
|
+
subprocess_run_call_count += 1
|
|
267
|
+
return real_subprocess_run(*positional, **keyword)
|
|
268
|
+
|
|
269
|
+
monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
|
|
270
|
+
|
|
271
|
+
tightened_exit_code, tightened_output = validator.run_mypy(
|
|
272
|
+
str(checked_module), str(project_root)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
assert subprocess_run_call_count == 1, (
|
|
276
|
+
"A tightened mypy config must invalidate the content-hash skip and re-run mypy"
|
|
277
|
+
)
|
|
278
|
+
assert tightened_exit_code != 0
|
|
279
|
+
assert ": error:" in tightened_output
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_project_relative_path_within_root_returns_relative() -> None:
|
|
283
|
+
validator = _load_validator()
|
|
284
|
+
project_root = os.path.join("base", "project")
|
|
285
|
+
target_file = os.path.join(project_root, "package", "module.py")
|
|
286
|
+
assert validator.project_relative_path(target_file, project_root) == os.path.join(
|
|
287
|
+
"package", "module.py"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_project_relative_path_across_mounts_falls_back_to_absolute() -> None:
|
|
292
|
+
if sys.platform != "win32":
|
|
293
|
+
pytest.skip("cross-mount relpath ValueError only occurs on Windows")
|
|
294
|
+
validator = _load_validator()
|
|
295
|
+
target_file = "Y:\\repository\\package\\module.py"
|
|
296
|
+
project_root = "C:\\other\\root"
|
|
297
|
+
assert validator.project_relative_path(target_file, project_root) == os.path.abspath(
|
|
298
|
+
target_file
|
|
299
|
+
)
|