claude-dev-env 1.72.0 → 1.73.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.
Files changed (50) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  2. package/bin/install.mjs +73 -5
  3. package/bin/install.test.mjs +360 -4
  4. package/hooks/blocking/CLAUDE.md +3 -1
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  6. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  7. package/hooks/blocking/code_rules_docstrings.py +616 -0
  8. package/hooks/blocking/code_rules_enforcer.py +22 -0
  9. package/hooks/blocking/code_rules_shared.py +19 -0
  10. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  11. package/hooks/blocking/md_to_html_blocker.py +7 -8
  12. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  13. package/hooks/blocking/plain_language_blocker.py +51 -16
  14. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  15. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  16. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  17. package/hooks/blocking/state_description_blocker.py +75 -36
  18. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  22. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  25. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  26. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  27. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  28. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  29. package/hooks/hooks.json +9 -79
  30. package/hooks/hooks_constants/CLAUDE.md +3 -1
  31. package/hooks/hooks_constants/blocking_check_limits.py +61 -0
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  33. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  34. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  35. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  37. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  38. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  39. package/hooks/validation/mypy_validator.py +215 -17
  40. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  41. package/hooks/validation/test_mypy_validator.py +184 -1
  42. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  43. package/hooks/workflow/test_auto_formatter.py +10 -9
  44. package/package.json +1 -1
  45. package/rules/docstring-prose-matches-implementation.md +2 -1
  46. package/skills/autoconverge/SKILL.md +93 -0
  47. package/skills/autoconverge/workflow/converge.mjs +27 -2
  48. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  49. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  50. 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
- AUTO_FORMATTER_COMMAND_FRAGMENT = "workflow/auto_formatter.py"
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
- try:
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, None, 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, None, None]:
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 AUTO_FORMATTER_COMMAND_FRAGMENT in each_hook["command"]:
123
+ if POST_TOOL_USE_DISPATCHER_COMMAND_FRAGMENT in each_hook["command"]:
125
124
  return int(each_hook["timeout"])
126
- raise AssertionError("auto_formatter hook is not registered in hooks.json")
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.72.0",
3
+ "version": "1.73.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {