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,610 @@
|
|
|
1
|
+
"""Golden differential and behavior tests for the PostToolUse dispatcher.
|
|
2
|
+
|
|
3
|
+
The golden differential test runs a payload through every hosted PostToolUse
|
|
4
|
+
hook as its own subprocess (the production path), records each hook's block
|
|
5
|
+
decision, computes the expected aggregate, then runs the dispatcher on the same
|
|
6
|
+
payload and asserts an equal block-or-allow decision and the union of reasons.
|
|
7
|
+
|
|
8
|
+
Three focused tests pin the side-effecting behavior the dispatcher must not
|
|
9
|
+
change: the formatter formats only on a Write of a file git does not yet track
|
|
10
|
+
and never blocks; the type-checker still blocks on a real type error when run
|
|
11
|
+
through the dispatcher; and non-block stdout from side-effect hooks (such as
|
|
12
|
+
the doc-gist htmlpreview URL) survives on both the allow and block paths.
|
|
13
|
+
|
|
14
|
+
Crash and early-exit tests exercise the aggregator directly: an early hook
|
|
15
|
+
crash before mypy does not drop mypy's block, a non-blocking hook crash leaves
|
|
16
|
+
the decision allow, and a blocking hook crash surfaces a block.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from types import ModuleType
|
|
27
|
+
|
|
28
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
29
|
+
if _HOOKS_DIR not in sys.path:
|
|
30
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
31
|
+
|
|
32
|
+
_VALIDATION_DIR_STR = str(Path(__file__).resolve().parent)
|
|
33
|
+
if _VALIDATION_DIR_STR not in sys.path:
|
|
34
|
+
sys.path.insert(0, _VALIDATION_DIR_STR)
|
|
35
|
+
|
|
36
|
+
from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402, I001
|
|
37
|
+
HOOK_SUBPROCESS_TIMEOUT_SECONDS as DOC_GIST_TIMEOUT_SECONDS,
|
|
38
|
+
)
|
|
39
|
+
from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
40
|
+
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
41
|
+
BLOCK_DECISION,
|
|
42
|
+
EMPTY_REASON_BLOCK_FALLBACK,
|
|
43
|
+
PLUGIN_ROOT_PLACEHOLDER,
|
|
44
|
+
PostHostedHookEntry,
|
|
45
|
+
)
|
|
46
|
+
from post_tool_use_dispatcher import ( # noqa: E402, I001
|
|
47
|
+
PostHostedHookResult,
|
|
48
|
+
aggregate_post_hosted_hook_results,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_VALIDATION_DIR = Path(__file__).resolve().parent
|
|
52
|
+
_HOOKS_ROOT = _VALIDATION_DIR.parent
|
|
53
|
+
_PLUGIN_ROOT = _HOOKS_ROOT.parent
|
|
54
|
+
_DISPATCHER_SCRIPT = str(_VALIDATION_DIR / "post_tool_use_dispatcher.py")
|
|
55
|
+
|
|
56
|
+
_WRITE_TOOL_NAME = "Write"
|
|
57
|
+
_EDIT_TOOL_NAME = "Edit"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_module(module_filename: str, module_directory: Path) -> ModuleType:
|
|
61
|
+
"""Load a hook module by file path so its module-level constants are readable.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
module_filename: The module filename to load.
|
|
65
|
+
module_directory: The directory holding the module.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The loaded module object.
|
|
69
|
+
"""
|
|
70
|
+
module_path = module_directory / module_filename
|
|
71
|
+
spec = importlib.util.spec_from_file_location(module_path.stem, module_path)
|
|
72
|
+
assert spec is not None and spec.loader is not None
|
|
73
|
+
loaded_module = importlib.util.module_from_spec(spec)
|
|
74
|
+
spec.loader.exec_module(loaded_module)
|
|
75
|
+
return loaded_module
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_extra_arguments(each_entry: PostHostedHookEntry) -> list[str]:
|
|
79
|
+
"""Resolve a hook entry's relative argument paths to absolute argv values.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
each_entry: The hosted hook entry whose extra arguments to resolve.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The resolved argument list. The plugin-root placeholder resolves to the
|
|
86
|
+
plugin root absolute path; every other entry resolves relative to it.
|
|
87
|
+
"""
|
|
88
|
+
resolved_arguments: list[str] = []
|
|
89
|
+
for each_relative_path in each_entry.extra_argument_relative_paths:
|
|
90
|
+
if each_relative_path == PLUGIN_ROOT_PLACEHOLDER:
|
|
91
|
+
resolved_arguments.append(str(_PLUGIN_ROOT))
|
|
92
|
+
else:
|
|
93
|
+
resolved_arguments.append(str(_PLUGIN_ROOT / each_relative_path))
|
|
94
|
+
return resolved_arguments
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run_hook_subprocess(
|
|
98
|
+
each_entry: PostHostedHookEntry, payload_text: str
|
|
99
|
+
) -> subprocess.CompletedProcess[str]:
|
|
100
|
+
"""Run one hosted hook script as a subprocess, returning the completed process.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
each_entry: The hosted hook entry naming the script and its arguments.
|
|
104
|
+
payload_text: The JSON payload to send on stdin.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The completed subprocess result with stdout and stderr captured.
|
|
108
|
+
"""
|
|
109
|
+
script_path = str(_HOOKS_ROOT / each_entry.script_relative_path)
|
|
110
|
+
command = [sys.executable, script_path, *_resolve_extra_arguments(each_entry)]
|
|
111
|
+
return subprocess.run(
|
|
112
|
+
command,
|
|
113
|
+
check=False,
|
|
114
|
+
input=payload_text,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
|
|
122
|
+
"""Run the PostToolUse dispatcher as a subprocess.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
payload_text: The JSON payload to send on stdin.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The completed subprocess result with stdout and stderr captured.
|
|
129
|
+
"""
|
|
130
|
+
return subprocess.run(
|
|
131
|
+
[sys.executable, _DISPATCHER_SCRIPT, str(_PLUGIN_ROOT)],
|
|
132
|
+
check=False,
|
|
133
|
+
input=payload_text,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
encoding="utf-8",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_block_decision(completed_process: subprocess.CompletedProcess[str]) -> tuple[bool, str]:
|
|
141
|
+
"""Parse one hook's subprocess result into (is_block, reason_text).
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
completed_process: The completed subprocess from running a hook.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A (is_block, reason_text) pair where is_block is True when the hook
|
|
148
|
+
emitted a PostToolUse block decision, and reason_text carries the
|
|
149
|
+
block reason.
|
|
150
|
+
"""
|
|
151
|
+
stdout_text = completed_process.stdout.strip()
|
|
152
|
+
if not stdout_text:
|
|
153
|
+
return False, ""
|
|
154
|
+
try:
|
|
155
|
+
parsed_output = json.loads(stdout_text)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
return False, ""
|
|
158
|
+
if not isinstance(parsed_output, dict):
|
|
159
|
+
return False, ""
|
|
160
|
+
is_block = parsed_output.get("decision") == BLOCK_DECISION
|
|
161
|
+
reason_text = parsed_output.get("reason", "")
|
|
162
|
+
return is_block, reason_text if isinstance(reason_text, str) else ""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _compute_expected_aggregate(payload_text: str) -> tuple[bool, list[str]]:
|
|
166
|
+
"""Run each hosted hook individually and compute the expected aggregate.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
payload_text: The JSON payload text to send to each hook.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A (should_block, all_block_reasons) pair where should_block is True when
|
|
173
|
+
any hook blocks, and all_block_reasons collects every blocking reason.
|
|
174
|
+
"""
|
|
175
|
+
all_block_reasons: list[str] = []
|
|
176
|
+
for each_entry in ALL_POST_HOSTED_HOOK_ENTRIES:
|
|
177
|
+
completed_process = _run_hook_subprocess(each_entry, payload_text)
|
|
178
|
+
is_block, reason_text = _parse_block_decision(completed_process)
|
|
179
|
+
if is_block and reason_text:
|
|
180
|
+
all_block_reasons.append(reason_text)
|
|
181
|
+
return bool(all_block_reasons), all_block_reasons
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_payload(file_path: str, content: str) -> str:
|
|
185
|
+
"""Build a Write tool payload JSON string.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
file_path: The target file path.
|
|
189
|
+
content: The file content written.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
JSON-encoded payload string.
|
|
193
|
+
"""
|
|
194
|
+
return json.dumps(
|
|
195
|
+
{
|
|
196
|
+
"tool_name": _WRITE_TOOL_NAME,
|
|
197
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _edit_payload(file_path: str, old_string: str, new_string: str) -> str:
|
|
203
|
+
"""Build an Edit tool payload JSON string.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
file_path: The target file path.
|
|
207
|
+
old_string: The text replaced.
|
|
208
|
+
new_string: The replacement text.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
JSON-encoded payload string.
|
|
212
|
+
"""
|
|
213
|
+
return json.dumps(
|
|
214
|
+
{
|
|
215
|
+
"tool_name": _EDIT_TOOL_NAME,
|
|
216
|
+
"tool_input": {
|
|
217
|
+
"file_path": file_path,
|
|
218
|
+
"old_string": old_string,
|
|
219
|
+
"new_string": new_string,
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _assert_dispatcher_matches_individual_hooks(payload_text: str) -> None:
|
|
226
|
+
"""Assert the dispatcher's decision matches the union of individual hook decisions.
|
|
227
|
+
|
|
228
|
+
Runs each hosted hook individually, computes the expected aggregate (block
|
|
229
|
+
if any blocks, union of every blocking reason), then runs the dispatcher and
|
|
230
|
+
asserts an equal outcome.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
payload_text: The JSON payload text.
|
|
234
|
+
"""
|
|
235
|
+
expected_block, all_expected_reasons = _compute_expected_aggregate(payload_text)
|
|
236
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
237
|
+
dispatcher_is_block, dispatcher_reason = _parse_block_decision(dispatcher_result)
|
|
238
|
+
assert dispatcher_is_block == expected_block, (
|
|
239
|
+
f"dispatcher block={dispatcher_is_block} but expected block={expected_block}. "
|
|
240
|
+
f"Dispatcher reason: {dispatcher_reason!r}. "
|
|
241
|
+
f"Expected reasons: {all_expected_reasons!r}"
|
|
242
|
+
)
|
|
243
|
+
for each_expected_reason in all_expected_reasons:
|
|
244
|
+
assert each_expected_reason in dispatcher_reason, (
|
|
245
|
+
f"Missing reason in dispatcher output.\n"
|
|
246
|
+
f"Expected to find: {each_expected_reason!r}\n"
|
|
247
|
+
f"Dispatcher reason: {dispatcher_reason!r}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _post_tool_use_dispatcher_harness_timeout_seconds() -> int:
|
|
252
|
+
"""Return the PostToolUse dispatcher's harness timeout from hooks.json.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
The timeout the Write|Edit PostToolUse entry declares for the dispatcher.
|
|
256
|
+
"""
|
|
257
|
+
hooks_json_path = _HOOKS_ROOT / "hooks.json"
|
|
258
|
+
parsed_hooks = json.loads(hooks_json_path.read_text(encoding="utf-8"))
|
|
259
|
+
for each_entry in parsed_hooks["hooks"]["PostToolUse"]:
|
|
260
|
+
for each_hook in each_entry["hooks"]:
|
|
261
|
+
if "post_tool_use_dispatcher.py" in each_hook["command"]:
|
|
262
|
+
return int(each_hook["timeout"])
|
|
263
|
+
raise AssertionError("PostToolUse dispatcher entry not found in hooks.json")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _hosted_hooks_worst_case_internal_seconds() -> int:
|
|
267
|
+
"""Return the summed worst-case internal subprocess budget of the hosted hooks.
|
|
268
|
+
|
|
269
|
+
The three hosted hooks run sequentially in one dispatcher process. The
|
|
270
|
+
type-checker, the formatter (its slower JS path), and the doc-gist publisher
|
|
271
|
+
each carry their own internal subprocess timeout; their sum is the dispatcher
|
|
272
|
+
process's worst-case runtime when each hook runs to its own ceiling.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
The summed worst-case internal subprocess seconds across the hosted hooks.
|
|
276
|
+
"""
|
|
277
|
+
mypy_validator = _load_module("mypy_validator.py", _VALIDATION_DIR)
|
|
278
|
+
auto_formatter = _load_module("auto_formatter.py", _HOOKS_ROOT / "workflow")
|
|
279
|
+
slowest_formatter_seconds = max(
|
|
280
|
+
auto_formatter.PYTHON_FORMAT_TIMEOUT_SECONDS,
|
|
281
|
+
auto_formatter.JS_FORMAT_TIMEOUT_SECONDS,
|
|
282
|
+
)
|
|
283
|
+
return (
|
|
284
|
+
mypy_validator.MYPY_TIMEOUT_SECONDS
|
|
285
|
+
+ slowest_formatter_seconds
|
|
286
|
+
+ DOC_GIST_TIMEOUT_SECONDS
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_dispatcher_harness_timeout_clears_summed_hosted_hook_budgets() -> None:
|
|
291
|
+
"""The dispatcher harness timeout exceeds the summed worst-case hosted-hook budget.
|
|
292
|
+
|
|
293
|
+
The three hosted hooks run sequentially under one harness timeout. When the
|
|
294
|
+
harness timeout does not clear their summed worst-case internal budgets, a
|
|
295
|
+
near-full slow type-check run consumes the budget and the harness kills the
|
|
296
|
+
dispatcher before the formatter and doc-gist publisher get their turn. The
|
|
297
|
+
harness timeout must exceed the sum so every hosted hook runs to completion.
|
|
298
|
+
"""
|
|
299
|
+
harness_timeout_seconds = _post_tool_use_dispatcher_harness_timeout_seconds()
|
|
300
|
+
summed_internal_seconds = _hosted_hooks_worst_case_internal_seconds()
|
|
301
|
+
assert harness_timeout_seconds > summed_internal_seconds, (
|
|
302
|
+
"PostToolUse dispatcher harness timeout "
|
|
303
|
+
f"({harness_timeout_seconds}s) must exceed the summed worst-case internal "
|
|
304
|
+
f"budgets of the hosted hooks ({summed_internal_seconds}s), or a slow "
|
|
305
|
+
"type-check run starves the formatter and doc-gist publisher."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_clean_edit_of_plain_text_allows() -> None:
|
|
310
|
+
"""Dispatcher allows an Edit of a non-Python plain-text file (no hook blocks)."""
|
|
311
|
+
plain_text_path = str(_VALIDATION_DIR / "CLAUDE.md")
|
|
312
|
+
payload_text = _edit_payload(plain_text_path, "old line", "new line")
|
|
313
|
+
_assert_dispatcher_matches_individual_hooks(payload_text)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_clean_write_of_nonexistent_path_allows() -> None:
|
|
317
|
+
"""Dispatcher allows a Write whose path does not exist (mypy and doc-gist skip)."""
|
|
318
|
+
missing_path = str(_VALIDATION_DIR / "does_not_exist_dispatcher_probe.txt")
|
|
319
|
+
payload_text = _write_payload(missing_path, "hello world\n")
|
|
320
|
+
_assert_dispatcher_matches_individual_hooks(payload_text)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_edit_of_non_html_skips_doc_gist_allows() -> None:
|
|
324
|
+
"""Dispatcher allows an Edit of an existing non-HTML file with no sentinel."""
|
|
325
|
+
existing_path = str(Path(__file__).resolve())
|
|
326
|
+
payload_text = _edit_payload(existing_path, "old", "new")
|
|
327
|
+
_assert_dispatcher_matches_individual_hooks(payload_text)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_malformed_payload_allows_fail_open() -> None:
|
|
331
|
+
"""Dispatcher allows when the payload is malformed, matching fail-open posture."""
|
|
332
|
+
dispatcher_result = _run_dispatcher("not valid json {{{")
|
|
333
|
+
is_block, _reason = _parse_block_decision(dispatcher_result)
|
|
334
|
+
assert not is_block, "Dispatcher must allow on malformed payload (fail-open)"
|
|
335
|
+
assert dispatcher_result.returncode == 0, (
|
|
336
|
+
f"Dispatcher must exit 0 on malformed payload, got {dispatcher_result.returncode}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_empty_payload_allows_fail_open() -> None:
|
|
341
|
+
"""Dispatcher allows when stdin is empty, matching fail-open posture."""
|
|
342
|
+
dispatcher_result = _run_dispatcher("")
|
|
343
|
+
is_block, _reason = _parse_block_decision(dispatcher_result)
|
|
344
|
+
assert not is_block, "Dispatcher must allow on empty payload (fail-open)"
|
|
345
|
+
assert dispatcher_result.returncode == 0, (
|
|
346
|
+
f"Dispatcher must exit 0 on empty payload, got {dispatcher_result.returncode}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_type_checker_still_blocks_on_type_error_through_dispatcher() -> None:
|
|
351
|
+
"""The type-checker's block on a real type error survives through the dispatcher.
|
|
352
|
+
|
|
353
|
+
Writes a Python file with a genuine type error inside this repository so
|
|
354
|
+
mypy_validator discovers the project root and blocks, then runs the same
|
|
355
|
+
payload through the dispatcher and asserts the dispatcher emits the block.
|
|
356
|
+
"""
|
|
357
|
+
type_error_file = _VALIDATION_DIR / "dispatcher_type_error_probe.py"
|
|
358
|
+
type_error_file.write_text(
|
|
359
|
+
"def add_one(value: int) -> int:\n return value + 1\n\n\nadd_one('not an int')\n",
|
|
360
|
+
encoding="utf-8",
|
|
361
|
+
)
|
|
362
|
+
try:
|
|
363
|
+
payload_text = _write_payload(str(type_error_file), type_error_file.read_text())
|
|
364
|
+
direct_block, direct_reason = _parse_block_decision(
|
|
365
|
+
_run_hook_subprocess(ALL_POST_HOSTED_HOOK_ENTRIES[0], payload_text)
|
|
366
|
+
)
|
|
367
|
+
assert direct_block, (
|
|
368
|
+
"Precondition failed: mypy_validator did not block a real type error "
|
|
369
|
+
f"directly. Reason: {direct_reason!r}. Is mypy installed and the file "
|
|
370
|
+
"inside the git project?"
|
|
371
|
+
)
|
|
372
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
373
|
+
dispatcher_is_block, dispatcher_reason = _parse_block_decision(dispatcher_result)
|
|
374
|
+
assert dispatcher_is_block, (
|
|
375
|
+
"Dispatcher must block when the type-checker blocks on a type error. "
|
|
376
|
+
f"Dispatcher stdout: {dispatcher_result.stdout!r}"
|
|
377
|
+
)
|
|
378
|
+
assert direct_reason in dispatcher_reason, (
|
|
379
|
+
"Dispatcher block reason must carry the type-checker's reason.\n"
|
|
380
|
+
f"Expected: {direct_reason!r}\n"
|
|
381
|
+
f"Dispatcher reason: {dispatcher_reason!r}"
|
|
382
|
+
)
|
|
383
|
+
finally:
|
|
384
|
+
type_error_file.unlink(missing_ok=True)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_formatter_formats_only_untracked_write_and_never_blocks(tmp_path: Path) -> None:
|
|
388
|
+
"""The formatter acts only on an untracked-file Write and never blocks.
|
|
389
|
+
|
|
390
|
+
Writes an unformatted Python file into a git repo at tmp_path so the file is
|
|
391
|
+
untracked, runs a Write payload through the dispatcher, and asserts the file
|
|
392
|
+
is formatted on disk and the dispatcher does not block. Then runs an Edit
|
|
393
|
+
payload for the same path and asserts the formatter leaves an unformatted
|
|
394
|
+
file untouched, proving the Write-untracked gate still holds through the
|
|
395
|
+
dispatcher.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
tmp_path: Pytest temp directory hosting the throwaway git repository.
|
|
399
|
+
"""
|
|
400
|
+
subprocess.run(
|
|
401
|
+
["git", "init", str(tmp_path)],
|
|
402
|
+
check=True,
|
|
403
|
+
capture_output=True,
|
|
404
|
+
text=True,
|
|
405
|
+
)
|
|
406
|
+
unformatted_source = "x=1\ny = 2\n"
|
|
407
|
+
untracked_file = tmp_path / "untracked_module.py"
|
|
408
|
+
untracked_file.write_text(unformatted_source, encoding="utf-8")
|
|
409
|
+
|
|
410
|
+
write_payload_text = _write_payload(str(untracked_file), unformatted_source)
|
|
411
|
+
dispatcher_result = _run_dispatcher(write_payload_text)
|
|
412
|
+
is_block, _reason = _parse_block_decision(dispatcher_result)
|
|
413
|
+
assert not is_block, "Formatter must never block a Write through the dispatcher"
|
|
414
|
+
formatted_source = untracked_file.read_text(encoding="utf-8")
|
|
415
|
+
assert formatted_source != unformatted_source, (
|
|
416
|
+
"Formatter must reformat an untracked-file Write through the dispatcher.\n"
|
|
417
|
+
f"On-disk content unchanged: {formatted_source!r}"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
untracked_file.write_text(unformatted_source, encoding="utf-8")
|
|
421
|
+
edit_payload_text = _edit_payload(str(untracked_file), "x=1", "x = 1")
|
|
422
|
+
edit_dispatcher_result = _run_dispatcher(edit_payload_text)
|
|
423
|
+
edit_is_block, _edit_reason = _parse_block_decision(edit_dispatcher_result)
|
|
424
|
+
assert not edit_is_block, "Formatter must never block an Edit through the dispatcher"
|
|
425
|
+
after_edit_source = untracked_file.read_text(encoding="utf-8")
|
|
426
|
+
assert after_edit_source == unformatted_source, (
|
|
427
|
+
"Formatter must not reformat on an Edit (it acts only on an untracked Write).\n"
|
|
428
|
+
f"On-disk content changed to: {after_edit_source!r}"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_non_block_stdout_preserved_in_aggregator_allow_path() -> None:
|
|
433
|
+
"""Aggregator preserves non-block hook stdout on the allow path.
|
|
434
|
+
|
|
435
|
+
A side-effect hook (such as doc_gist_auto_publish) writes informational
|
|
436
|
+
text to stdout without emitting a block decision. The aggregator must carry
|
|
437
|
+
that text into all_non_block_stdout so the dispatcher can write it to the
|
|
438
|
+
real stdout on the allow path.
|
|
439
|
+
"""
|
|
440
|
+
informational_text = "https://htmlpreview.github.io/?https://gist.github.com/abc/123"
|
|
441
|
+
all_results = [
|
|
442
|
+
PostHostedHookResult(captured_stdout="", did_crash=False, is_blocking=True),
|
|
443
|
+
PostHostedHookResult(
|
|
444
|
+
captured_stdout=informational_text, did_crash=False, is_blocking=False
|
|
445
|
+
),
|
|
446
|
+
]
|
|
447
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
448
|
+
assert not aggregated_decision.should_block, (
|
|
449
|
+
"Aggregator must allow when no hook blocks"
|
|
450
|
+
)
|
|
451
|
+
assert informational_text in aggregated_decision.all_non_block_stdout, (
|
|
452
|
+
"Aggregator must preserve non-block hook stdout in all_non_block_stdout.\n"
|
|
453
|
+
f"Expected to find: {informational_text!r}\n"
|
|
454
|
+
f"Got: {aggregated_decision.all_non_block_stdout!r}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_non_block_stdout_preserved_in_aggregator_block_path() -> None:
|
|
459
|
+
"""Aggregator preserves non-block hook stdout even when another hook blocks.
|
|
460
|
+
|
|
461
|
+
When mypy_validator blocks and doc_gist_auto_publish wrote informational
|
|
462
|
+
text, both the block reason and the informational text survive in the
|
|
463
|
+
aggregated decision so _emit_block_decision can forward both to stdout.
|
|
464
|
+
"""
|
|
465
|
+
mypy_block_json = json.dumps({"decision": BLOCK_DECISION, "reason": "[MYPY] Type errors: x"})
|
|
466
|
+
informational_text = "https://htmlpreview.github.io/?https://gist.github.com/abc/456"
|
|
467
|
+
all_results = [
|
|
468
|
+
PostHostedHookResult(captured_stdout=mypy_block_json, did_crash=False, is_blocking=True),
|
|
469
|
+
PostHostedHookResult(
|
|
470
|
+
captured_stdout=informational_text, did_crash=False, is_blocking=False
|
|
471
|
+
),
|
|
472
|
+
]
|
|
473
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
474
|
+
assert aggregated_decision.should_block, (
|
|
475
|
+
"Aggregator must block when mypy_validator blocks"
|
|
476
|
+
)
|
|
477
|
+
assert informational_text in aggregated_decision.all_non_block_stdout, (
|
|
478
|
+
"Aggregator must preserve non-block hook stdout even on the block path.\n"
|
|
479
|
+
f"Expected to find: {informational_text!r}\n"
|
|
480
|
+
f"Got: {aggregated_decision.all_non_block_stdout!r}"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_early_hook_crash_does_not_drop_later_blocking_hook_block() -> None:
|
|
485
|
+
"""A crash in an early hook does not prevent a later blocking hook's block.
|
|
486
|
+
|
|
487
|
+
Simulates a scenario where a non-blocking hook crashes before mypy_validator
|
|
488
|
+
runs and blocks. The aggregated decision must still block, proving the
|
|
489
|
+
dispatcher continues past a crash to collect all results.
|
|
490
|
+
"""
|
|
491
|
+
mypy_block_json = json.dumps(
|
|
492
|
+
{"decision": BLOCK_DECISION, "reason": "[MYPY] Type errors: y"}
|
|
493
|
+
)
|
|
494
|
+
all_results = [
|
|
495
|
+
PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=False),
|
|
496
|
+
PostHostedHookResult(
|
|
497
|
+
captured_stdout=mypy_block_json, did_crash=False, is_blocking=True
|
|
498
|
+
),
|
|
499
|
+
]
|
|
500
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
501
|
+
assert aggregated_decision.should_block, (
|
|
502
|
+
"An early non-blocking hook crash must not prevent mypy_validator's block "
|
|
503
|
+
"from reaching the aggregated decision"
|
|
504
|
+
)
|
|
505
|
+
assert any("[MYPY]" in each_reason for each_reason in aggregated_decision.all_block_reasons), (
|
|
506
|
+
"The mypy block reason must survive in the aggregated decision.\n"
|
|
507
|
+
f"Block reasons: {aggregated_decision.all_block_reasons!r}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_non_blocking_hook_crash_leaves_decision_allow() -> None:
|
|
512
|
+
"""A crash in a non-blocking hook does not change an allow to a block.
|
|
513
|
+
|
|
514
|
+
A side-effect hook such as auto_formatter or doc_gist_auto_publish carries
|
|
515
|
+
is_blocking=False. Its crash must not surface a blocking signal — the
|
|
516
|
+
aggregated decision stays allow.
|
|
517
|
+
"""
|
|
518
|
+
all_results = [
|
|
519
|
+
PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=False),
|
|
520
|
+
]
|
|
521
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
522
|
+
assert not aggregated_decision.should_block, (
|
|
523
|
+
"A non-blocking hook crash must not change an allow to a block"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_empty_reason_block_still_blocks_with_fallback_reason() -> None:
|
|
528
|
+
"""A block decision carrying an empty reason still blocks with a fallback reason.
|
|
529
|
+
|
|
530
|
+
A blocking hook that emits decision=block with an empty reason string must
|
|
531
|
+
still block. The aggregator substitutes EMPTY_REASON_BLOCK_FALLBACK so the
|
|
532
|
+
block is not silently downgraded to allow.
|
|
533
|
+
"""
|
|
534
|
+
empty_reason_block_json = json.dumps({"decision": BLOCK_DECISION, "reason": ""})
|
|
535
|
+
all_results = [
|
|
536
|
+
PostHostedHookResult(
|
|
537
|
+
captured_stdout=empty_reason_block_json, did_crash=False, is_blocking=True
|
|
538
|
+
),
|
|
539
|
+
]
|
|
540
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
541
|
+
assert aggregated_decision.should_block, (
|
|
542
|
+
"An empty-reason block must still block, not downgrade to allow"
|
|
543
|
+
)
|
|
544
|
+
assert EMPTY_REASON_BLOCK_FALLBACK in aggregated_decision.all_block_reasons, (
|
|
545
|
+
"The aggregator must substitute a fallback reason for an empty-reason block.\n"
|
|
546
|
+
f"Got block reasons: {aggregated_decision.all_block_reasons!r}"
|
|
547
|
+
)
|
|
548
|
+
assert empty_reason_block_json not in aggregated_decision.all_non_block_stdout, (
|
|
549
|
+
"An empty-reason block's raw JSON must not leak into the informational stdout"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_block_path_emits_single_parseable_json_object_on_stdout() -> None:
|
|
554
|
+
"""On the block path the dispatcher emits a single parseable JSON object on stdout.
|
|
555
|
+
|
|
556
|
+
Writes a Python file with a real type error inside this repository so
|
|
557
|
+
mypy_validator discovers the project root and blocks, then asserts the
|
|
558
|
+
dispatcher's whole stdout parses as one JSON block object — no leading
|
|
559
|
+
informational text mixed onto the same stream.
|
|
560
|
+
"""
|
|
561
|
+
type_error_file = _VALIDATION_DIR / "dispatcher_block_stdout_probe.py"
|
|
562
|
+
type_error_file.write_text(
|
|
563
|
+
"def add_one(value: int) -> int:\n return value + 1\n\n\nadd_one('not an int')\n",
|
|
564
|
+
encoding="utf-8",
|
|
565
|
+
)
|
|
566
|
+
try:
|
|
567
|
+
payload_text = _write_payload(str(type_error_file), type_error_file.read_text())
|
|
568
|
+
direct_block, _direct_reason = _parse_block_decision(
|
|
569
|
+
_run_hook_subprocess(ALL_POST_HOSTED_HOOK_ENTRIES[0], payload_text)
|
|
570
|
+
)
|
|
571
|
+
assert direct_block, (
|
|
572
|
+
"Precondition failed: mypy_validator did not block a real type error directly. "
|
|
573
|
+
"Is mypy installed and the file inside the git project?"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
577
|
+
parsed_stdout = json.loads(dispatcher_result.stdout.strip())
|
|
578
|
+
assert isinstance(parsed_stdout, dict), (
|
|
579
|
+
"Dispatcher stdout on the block path must be a single JSON object.\n"
|
|
580
|
+
f"Got: {dispatcher_result.stdout!r}"
|
|
581
|
+
)
|
|
582
|
+
assert parsed_stdout.get("decision") == BLOCK_DECISION, (
|
|
583
|
+
"The single JSON object on stdout must carry the block decision.\n"
|
|
584
|
+
f"Got: {parsed_stdout!r}"
|
|
585
|
+
)
|
|
586
|
+
finally:
|
|
587
|
+
type_error_file.unlink(missing_ok=True)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_blocking_hook_crash_surfaces_a_block() -> None:
|
|
591
|
+
"""A crash in a blocking hook surfaces a block with a crash reason.
|
|
592
|
+
|
|
593
|
+
When a blocking hook (such as mypy_validator) crashes before emitting any
|
|
594
|
+
output, the aggregator must still block so a bad write does not silently
|
|
595
|
+
pass. The block reason must reference the dispatcher's crash signal.
|
|
596
|
+
"""
|
|
597
|
+
all_results = [
|
|
598
|
+
PostHostedHookResult(captured_stdout="", did_crash=True, is_blocking=True),
|
|
599
|
+
]
|
|
600
|
+
aggregated_decision = aggregate_post_hosted_hook_results(all_results)
|
|
601
|
+
assert aggregated_decision.should_block, (
|
|
602
|
+
"A blocking hook crash must surface a block"
|
|
603
|
+
)
|
|
604
|
+
assert aggregated_decision.all_block_reasons, (
|
|
605
|
+
"The block reasons list must be non-empty after a blocking hook crash"
|
|
606
|
+
)
|
|
607
|
+
assert "dispatcher" in aggregated_decision.all_block_reasons[0].lower(), (
|
|
608
|
+
"The block reason from a blocking hook crash must reference the dispatcher.\n"
|
|
609
|
+
f"Got: {aggregated_decision.all_block_reasons[0]!r}"
|
|
610
|
+
)
|
|
@@ -9,6 +9,7 @@ The sandbox is rooted under the user's home directory via ``tempfile.mkdtemp``
|
|
|
9
9
|
rather than the OS temp directory, matching the sibling workflow-hook tests.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import contextlib
|
|
12
13
|
import functools
|
|
13
14
|
import importlib.util
|
|
14
15
|
import json
|
|
@@ -18,8 +19,8 @@ import stat
|
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
20
21
|
import tempfile
|
|
22
|
+
from collections.abc import Generator
|
|
21
23
|
from pathlib import Path
|
|
22
|
-
from typing import Generator
|
|
23
24
|
|
|
24
25
|
import pytest
|
|
25
26
|
|
|
@@ -27,7 +28,7 @@ HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "auto_formatter.py")
|
|
|
27
28
|
HOOKS_JSON_PATH = os.path.join(
|
|
28
29
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "hooks", "hooks.json"
|
|
29
30
|
)
|
|
30
|
-
|
|
31
|
+
POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT = "validation/post_tool_use_dispatcher.py"
|
|
31
32
|
UNUSED_IMPORT_SOURCE = "import os\n\n\nVALUE = 1\n"
|
|
32
33
|
HOOK_RUN_TIMEOUT_SECONDS = 60
|
|
33
34
|
|
|
@@ -46,10 +47,8 @@ def _force_rmtree(target_path: str) -> None:
|
|
|
46
47
|
if sys.version_info >= (3, 12)
|
|
47
48
|
else {"onerror": _strip_read_only_and_retry}
|
|
48
49
|
)
|
|
49
|
-
|
|
50
|
+
with contextlib.suppress(OSError):
|
|
50
51
|
shutil.rmtree(target_path, **handler_kw)
|
|
51
|
-
except OSError:
|
|
52
|
-
pass
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
@functools.lru_cache(maxsize=1)
|
|
@@ -58,7 +57,7 @@ def _get_sandbox_parent_directory() -> str:
|
|
|
58
57
|
|
|
59
58
|
|
|
60
59
|
@pytest.fixture(scope="session", autouse=True)
|
|
61
|
-
def _cleanup_sandbox_parent_directory() -> Generator[None
|
|
60
|
+
def _cleanup_sandbox_parent_directory() -> Generator[None]:
|
|
62
61
|
yield
|
|
63
62
|
if _get_sandbox_parent_directory.cache_info().currsize:
|
|
64
63
|
_force_rmtree(_get_sandbox_parent_directory())
|
|
@@ -66,7 +65,7 @@ def _cleanup_sandbox_parent_directory() -> Generator[None, None, None]:
|
|
|
66
65
|
|
|
67
66
|
|
|
68
67
|
@pytest.fixture
|
|
69
|
-
def git_repository() -> Generator[Path
|
|
68
|
+
def git_repository() -> Generator[Path]:
|
|
70
69
|
repository_path = Path(tempfile.mkdtemp(dir=_get_sandbox_parent_directory()))
|
|
71
70
|
subprocess.run(["git", "init"], cwd=repository_path, capture_output=True, check=True)
|
|
72
71
|
yield repository_path
|
|
@@ -121,9 +120,11 @@ def _registered_auto_formatter_timeout() -> int:
|
|
|
121
120
|
for each_event in hooks_configuration["hooks"].values():
|
|
122
121
|
for each_matcher in each_event:
|
|
123
122
|
for each_hook in each_matcher["hooks"]:
|
|
124
|
-
if
|
|
123
|
+
if POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT in each_hook["command"]:
|
|
125
124
|
return int(each_hook["timeout"])
|
|
126
|
-
raise AssertionError(
|
|
125
|
+
raise AssertionError(
|
|
126
|
+
"post_tool_use_dispatcher (which hosts auto_formatter) is not registered in hooks.json"
|
|
127
|
+
)
|
|
127
128
|
|
|
128
129
|
|
|
129
130
|
class TestPythonFormatTimeoutBudget:
|