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,816 @@
1
+ """Golden differential and failure-mode tests for the PreToolUse dispatcher.
2
+
3
+ Each golden differential test runs a payload through every applicable hosted
4
+ hook as its own subprocess (the production path), records each hook's
5
+ allow-or-deny and messages, computes the expected aggregate, then runs the
6
+ dispatcher on the same payload and asserts equal decision and equal message
7
+ union.
8
+
9
+ The failure-mode tests cover one row each from spec/failure-modes.md:
10
+ early-exit-then-later-deny, multi-deny, context-survival, blocking-hook crash,
11
+ fail-open malformed input.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
24
+ if _HOOKS_DIR not in sys.path:
25
+ sys.path.insert(0, _HOOKS_DIR)
26
+
27
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
28
+ ALL_HOSTED_HOOK_ENTRIES,
29
+ BLOCKING_CRASH_EXIT_CODE,
30
+ DENY_DECISION,
31
+ EDIT_TOOL_NAME,
32
+ EXIT_CODE_TWO_DENY_REASON,
33
+ MULTI_EDIT_TOOL_NAME,
34
+ WRITE_TOOL_NAME,
35
+ HostedHookEntry,
36
+ )
37
+ from pre_tool_use_dispatcher import ( # noqa: E402, I001
38
+ HostedHookResult,
39
+ aggregate_hosted_hook_results,
40
+ run_hosted_hook,
41
+ )
42
+
43
+ _BLOCKING_DIR = Path(__file__).resolve().parent
44
+ _HOOKS_ROOT = _BLOCKING_DIR.parent
45
+ _DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
46
+
47
+ _TEMP_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.txt")
48
+ _MARKDOWN_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.md")
49
+
50
+
51
+ def _run_hook_subprocess(
52
+ hook_relative_path: str, payload_text: str
53
+ ) -> subprocess.CompletedProcess[str]:
54
+ """Run one hook script as a subprocess, returning the completed process.
55
+
56
+ Args:
57
+ hook_relative_path: Path relative to the hooks/ directory.
58
+ payload_text: The JSON payload to send on stdin.
59
+
60
+ Returns:
61
+ The completed subprocess result with stdout and stderr captured.
62
+ """
63
+ script_path = str(_HOOKS_ROOT / hook_relative_path)
64
+ return subprocess.run(
65
+ [sys.executable, script_path],
66
+ check=False,
67
+ input=payload_text,
68
+ capture_output=True,
69
+ text=True,
70
+ encoding="utf-8",
71
+ )
72
+
73
+
74
+ def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
75
+ """Run the dispatcher as a subprocess.
76
+
77
+ Args:
78
+ payload_text: The JSON payload to send on stdin.
79
+
80
+ Returns:
81
+ The completed subprocess result with stdout and stderr captured.
82
+ """
83
+ return subprocess.run(
84
+ [sys.executable, _DISPATCHER_SCRIPT],
85
+ check=False,
86
+ input=payload_text,
87
+ capture_output=True,
88
+ text=True,
89
+ encoding="utf-8",
90
+ )
91
+
92
+
93
+ def _parse_hook_decision(completed_process: subprocess.CompletedProcess[str]) -> tuple[bool, str]:
94
+ """Parse one hook's subprocess result into (is_deny, reason_text).
95
+
96
+ Args:
97
+ completed_process: The completed subprocess from running a hook.
98
+
99
+ Returns:
100
+ A (is_deny, reason_text) pair where is_deny is True when the hook
101
+ denied, and reason_text carries the permissionDecisionReason.
102
+ """
103
+ stdout_text = completed_process.stdout.strip()
104
+ if not stdout_text:
105
+ return False, ""
106
+ try:
107
+ parsed_output = json.loads(stdout_text)
108
+ except json.JSONDecodeError:
109
+ return False, ""
110
+ hook_specific = parsed_output.get("hookSpecificOutput", {})
111
+ if not isinstance(hook_specific, dict):
112
+ return False, ""
113
+ is_deny = hook_specific.get("permissionDecision") == DENY_DECISION
114
+ reason_text = hook_specific.get("permissionDecisionReason", "")
115
+ return is_deny, reason_text if isinstance(reason_text, str) else ""
116
+
117
+
118
+ def _compute_expected_aggregate(
119
+ payload_text: str,
120
+ applicable_entries: list[HostedHookEntry],
121
+ ) -> tuple[bool, list[str]]:
122
+ """Run each applicable hook individually and compute the expected aggregate.
123
+
124
+ Args:
125
+ payload_text: The JSON payload text to send to each hook.
126
+ applicable_entries: The hosted hook entries applicable to this payload's tool.
127
+
128
+ Returns:
129
+ A (should_deny, all_deny_reasons) pair where should_deny is True when
130
+ any hook denies, and all_deny_reasons collects every denying reason.
131
+ """
132
+ all_deny_reasons: list[str] = []
133
+ for each_entry in applicable_entries:
134
+ completed_process = _run_hook_subprocess(each_entry.script_relative_path, payload_text)
135
+ is_deny, reason_text = _parse_hook_decision(completed_process)
136
+ if is_deny and reason_text:
137
+ all_deny_reasons.append(reason_text)
138
+ return bool(all_deny_reasons), all_deny_reasons
139
+
140
+
141
+ def _applicable_entries_for_tool(tool_name: str) -> list[HostedHookEntry]:
142
+ """Return the hosted hook entries applicable to the given tool name.
143
+
144
+ Args:
145
+ tool_name: The tool name from the PreToolUse payload.
146
+
147
+ Returns:
148
+ The ordered list of HostedHookEntry objects whose applicable_tool_names
149
+ includes tool_name.
150
+ """
151
+ return [
152
+ each_entry
153
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
154
+ if tool_name in each_entry.applicable_tool_names
155
+ ]
156
+
157
+
158
+ def _write_payload(file_path: str, content: str) -> str:
159
+ """Build a Write tool payload JSON string.
160
+
161
+ Args:
162
+ file_path: The target file path.
163
+ content: The file content to write.
164
+
165
+ Returns:
166
+ JSON-encoded payload string.
167
+ """
168
+ return json.dumps(
169
+ {
170
+ "tool_name": WRITE_TOOL_NAME,
171
+ "tool_input": {"file_path": file_path, "content": content},
172
+ }
173
+ )
174
+
175
+
176
+ def _edit_payload(file_path: str, old_string: str, new_string: str) -> str:
177
+ """Build an Edit tool payload JSON string.
178
+
179
+ Args:
180
+ file_path: The target file path.
181
+ old_string: The text to replace.
182
+ new_string: The replacement text.
183
+
184
+ Returns:
185
+ JSON-encoded payload string.
186
+ """
187
+ return json.dumps(
188
+ {
189
+ "tool_name": EDIT_TOOL_NAME,
190
+ "tool_input": {
191
+ "file_path": file_path,
192
+ "old_string": old_string,
193
+ "new_string": new_string,
194
+ },
195
+ }
196
+ )
197
+
198
+
199
+ def _multi_edit_payload(file_path: str, edits: list[dict[str, str]]) -> str:
200
+ """Build a MultiEdit tool payload JSON string.
201
+
202
+ Args:
203
+ file_path: The target file path.
204
+ edits: List of edit dicts with old_string and new_string keys.
205
+
206
+ Returns:
207
+ JSON-encoded payload string.
208
+ """
209
+ return json.dumps(
210
+ {
211
+ "tool_name": MULTI_EDIT_TOOL_NAME,
212
+ "tool_input": {"file_path": file_path, "edits": edits},
213
+ }
214
+ )
215
+
216
+
217
+ def _assert_dispatcher_matches_individual_hooks(
218
+ payload_text: str,
219
+ tool_name: str,
220
+ ) -> None:
221
+ """Assert the dispatcher's decision matches the union of individual hook decisions.
222
+
223
+ Runs each applicable hook individually, computes the expected aggregate
224
+ (deny if any denies, union of all deny reasons), then runs the dispatcher
225
+ and asserts equal outcome.
226
+
227
+ Args:
228
+ payload_text: The JSON payload text.
229
+ tool_name: The tool name, used to select applicable hooks.
230
+ """
231
+ applicable_entries = _applicable_entries_for_tool(tool_name)
232
+ expected_deny, all_expected_reasons = _compute_expected_aggregate(
233
+ payload_text, applicable_entries
234
+ )
235
+ dispatcher_result = _run_dispatcher(payload_text)
236
+ dispatcher_is_deny, dispatcher_reason = _parse_hook_decision(dispatcher_result)
237
+ assert dispatcher_is_deny == expected_deny, (
238
+ f"Tool={tool_name}: dispatcher deny={dispatcher_is_deny} "
239
+ f"but expected deny={expected_deny}. "
240
+ f"Dispatcher reason: {dispatcher_reason!r}. "
241
+ f"Expected reasons: {all_expected_reasons!r}"
242
+ )
243
+ if expected_deny and all_expected_reasons:
244
+ for each_expected_reason in all_expected_reasons:
245
+ assert each_expected_reason in dispatcher_reason, (
246
+ f"Missing reason in dispatcher output.\n"
247
+ f"Expected to find: {each_expected_reason!r}\n"
248
+ f"Dispatcher reason: {dispatcher_reason!r}"
249
+ )
250
+
251
+
252
+ def test_clean_write_allows_on_write_tool() -> None:
253
+ """Dispatcher allows a write that all hosted hooks allow on Write tool."""
254
+ payload_text = _write_payload(_TEMP_FILE_PATH, "hello world\n")
255
+ _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
256
+
257
+
258
+ def test_clean_write_allows_on_edit_tool() -> None:
259
+ """Dispatcher allows an edit that all hosted hooks allow on Edit tool."""
260
+ payload_text = _edit_payload(_TEMP_FILE_PATH, "old text", "new text")
261
+ _assert_dispatcher_matches_individual_hooks(payload_text, EDIT_TOOL_NAME)
262
+
263
+
264
+ def test_clean_write_allows_on_multi_edit_tool() -> None:
265
+ """Dispatcher allows a multi-edit that all hosted hooks allow on MultiEdit tool."""
266
+ payload_text = _multi_edit_payload(
267
+ _TEMP_FILE_PATH,
268
+ [{"old_string": "old", "new_string": "new"}],
269
+ )
270
+ _assert_dispatcher_matches_individual_hooks(payload_text, MULTI_EDIT_TOOL_NAME)
271
+
272
+
273
+ def test_plain_language_denial_on_write_of_markdown_file() -> None:
274
+ """Dispatcher denies when plain_language_blocker denies a Write of heavy prose."""
275
+ payload_text = _write_payload(
276
+ _MARKDOWN_FILE_PATH,
277
+ "# Guide\n\nPlease utilize this functionality to commence the process.\n",
278
+ )
279
+ _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
280
+
281
+
282
+ def test_plain_language_denial_on_edit_of_markdown_file() -> None:
283
+ """Dispatcher denies when plain_language_blocker denies an Edit with heavy prose."""
284
+ payload_text = _edit_payload(
285
+ _MARKDOWN_FILE_PATH,
286
+ "old line",
287
+ "Please utilize this functionality to commence the process.\n",
288
+ )
289
+ _assert_dispatcher_matches_individual_hooks(payload_text, EDIT_TOOL_NAME)
290
+
291
+
292
+ def test_plain_language_denial_on_multi_edit_of_markdown_file() -> None:
293
+ """Dispatcher denies when plain_language_blocker denies a MultiEdit with heavy prose."""
294
+ payload_text = _multi_edit_payload(
295
+ _MARKDOWN_FILE_PATH,
296
+ [{"old_string": "old", "new_string": "Please utilize this functionality to commence."}],
297
+ )
298
+ _assert_dispatcher_matches_individual_hooks(payload_text, MULTI_EDIT_TOOL_NAME)
299
+
300
+
301
+ def test_multi_edit_runs_only_group_b_hooks() -> None:
302
+ """Dispatcher invokes only Group-B hooks on MultiEdit, not Group-A hooks.
303
+
304
+ A plain_language_blocker denial on a MultiEdit proves Group B runs.
305
+ The write_existing_file_blocker (Group A only) must not run on MultiEdit,
306
+ so a MultiEdit to any path that would trip a Group-A hook must still allow.
307
+ This test proves Group A does not run on MultiEdit by asserting:
308
+ 1. Group-B (plain_language_blocker) fires on MultiEdit for a markdown file
309
+ with heavy prose.
310
+ 2. The set of applicable entries for MultiEdit contains no Group-A entries.
311
+ """
312
+ all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
313
+ all_write_only_entries = [
314
+ each_entry
315
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
316
+ if MULTI_EDIT_TOOL_NAME not in each_entry.applicable_tool_names
317
+ ]
318
+ all_multi_edit_script_paths = {
319
+ each_entry.script_relative_path for each_entry in all_multi_edit_entries
320
+ }
321
+ for each_group_a_entry in all_write_only_entries:
322
+ assert each_group_a_entry.script_relative_path not in all_multi_edit_script_paths, (
323
+ f"Group-A hook {each_group_a_entry.script_relative_path!r} "
324
+ "appears in the MultiEdit applicable set — it must not"
325
+ )
326
+ heavy_prose_payload = _multi_edit_payload(
327
+ _MARKDOWN_FILE_PATH,
328
+ [{"old_string": "old line", "new_string": "Utilize this to commence the process."}],
329
+ )
330
+ dispatcher_result = _run_dispatcher(heavy_prose_payload)
331
+ dispatcher_is_deny, _reason = _parse_hook_decision(dispatcher_result)
332
+ assert dispatcher_is_deny, (
333
+ "Dispatcher should deny a MultiEdit with heavy prose (plain_language_blocker "
334
+ "is a Group-B hook and must run on MultiEdit)"
335
+ )
336
+
337
+
338
+ def test_malformed_payload_allows_fail_open() -> None:
339
+ """Dispatcher allows when the payload is malformed, matching fail-open posture."""
340
+ dispatcher_result = _run_dispatcher("not valid json {{{")
341
+ is_deny, _reason = _parse_hook_decision(dispatcher_result)
342
+ assert not is_deny, "Dispatcher must allow on malformed payload (fail-open)"
343
+ assert dispatcher_result.returncode == 0, (
344
+ f"Dispatcher must exit 0 on malformed payload, got {dispatcher_result.returncode}"
345
+ )
346
+
347
+
348
+ def test_empty_payload_allows_fail_open() -> None:
349
+ """Dispatcher allows when stdin is empty, matching fail-open posture."""
350
+ dispatcher_result = _run_dispatcher("")
351
+ is_deny, _reason = _parse_hook_decision(dispatcher_result)
352
+ assert not is_deny, "Dispatcher must allow on empty payload (fail-open)"
353
+ assert dispatcher_result.returncode == 0, (
354
+ f"Dispatcher must exit 0 on empty payload, got {dispatcher_result.returncode}"
355
+ )
356
+
357
+
358
+ def test_sensitive_file_protector_denies_on_write_tool(
359
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
360
+ ) -> None:
361
+ """Dispatcher denies a Write targeting a sensitive path.
362
+
363
+ This proves a Group-A hook fires on Write. It exercises the golden
364
+ differential against a payload where sensitive_file_protector denies.
365
+ """
366
+ monkeypatch.setenv("HOME", str(tmp_path))
367
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
368
+ sensitive_path = str(Path.home() / ".ssh" / "id_rsa")
369
+ payload_text = _write_payload(sensitive_path, "fake key content")
370
+ _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
371
+
372
+
373
+ def test_write_existing_file_blocker_denies_on_write_tool() -> None:
374
+ """Dispatcher denies when write_existing_file_blocker fires on Write tool.
375
+
376
+ write_existing_file_blocker denies a Write to a path where a file already
377
+ exists. This exercises a real denial in the first Group-A hook position.
378
+ """
379
+ existing_path = str(Path(__file__).resolve())
380
+ payload_text = _write_payload(existing_path, "content")
381
+ _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
382
+
383
+
384
+ def test_write_existing_file_blocker_does_not_run_on_multi_edit() -> None:
385
+ """Group-A write_existing_file_blocker does not run on MultiEdit.
386
+
387
+ The dispatcher must allow a MultiEdit to an existing file path even though
388
+ write_existing_file_blocker would deny the same path on a Write.
389
+ Uses a non-markdown file so plain_language_blocker stays silent.
390
+ """
391
+ existing_file_path = str(Path(__file__).resolve())
392
+ payload_text = _multi_edit_payload(
393
+ existing_file_path,
394
+ [{"old_string": "old text", "new_string": "new text"}],
395
+ )
396
+ _assert_dispatcher_matches_individual_hooks(payload_text, MULTI_EDIT_TOOL_NAME)
397
+
398
+
399
+ def test_context_survives_alongside_deny_reason(
400
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
401
+ ) -> None:
402
+ """A non-denying hook's additional context survives in the dispatcher output.
403
+
404
+ This tests that hooks whose output is additional-context (not a deny) still
405
+ have their output preserved when another hook denies.
406
+ """
407
+ monkeypatch.setenv("HOME", str(tmp_path))
408
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
409
+ sensitive_path = str(Path.home() / ".env")
410
+ payload_text = _write_payload(sensitive_path, "SECRET=abc")
411
+ dispatcher_result = _run_dispatcher(payload_text)
412
+ is_deny, _reason = _parse_hook_decision(dispatcher_result)
413
+ assert is_deny, (
414
+ "sensitive_file_protector should deny a write to .env — "
415
+ "if it did not, check whether the path is on the sensitive list"
416
+ )
417
+ assert dispatcher_result.stdout.strip(), "Dispatcher must emit output when denying"
418
+
419
+
420
+ def test_all_deny_reasons_present_when_multiple_hooks_deny() -> None:
421
+ """When two or more hooks deny, all their reasons appear in the dispatcher output.
422
+
423
+ Uses a Write to a .md file carrying both a historical phrase ("previously")
424
+ and a heavy word ("utilize") so state_description_blocker (Group A) and
425
+ plain_language_blocker (Group B) both deny deterministically with no
426
+ real-filesystem dependency.
427
+ """
428
+ multi_deny_content = (
429
+ "# Guide\n\n"
430
+ "Previously the system utilized a different mechanism.\n"
431
+ )
432
+ payload_text = _write_payload(_MARKDOWN_FILE_PATH, multi_deny_content)
433
+ _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
434
+
435
+ dispatcher_result = _run_dispatcher(payload_text)
436
+ dispatcher_is_deny, dispatcher_reason = _parse_hook_decision(dispatcher_result)
437
+ assert dispatcher_is_deny, "Dispatcher must deny when any hook denies"
438
+
439
+ applicable_entries = _applicable_entries_for_tool(WRITE_TOOL_NAME)
440
+ all_expected_deny_reasons: list[str] = []
441
+ for each_entry in applicable_entries:
442
+ completed_process = _run_hook_subprocess(each_entry.script_relative_path, payload_text)
443
+ is_deny, reason_text = _parse_hook_decision(completed_process)
444
+ if is_deny and reason_text:
445
+ all_expected_deny_reasons.append(reason_text)
446
+
447
+ assert len(all_expected_deny_reasons) >= 2, (
448
+ f"Test payload must trip at least two hooks — got {len(all_expected_deny_reasons)}. "
449
+ "Check that 'previously' triggers state_description_blocker and 'utilized' "
450
+ "triggers plain_language_blocker on a .md Write."
451
+ )
452
+ for each_reason in all_expected_deny_reasons:
453
+ assert each_reason in dispatcher_reason, (
454
+ f"Missing deny reason in dispatcher output.\n"
455
+ f"Expected reason: {each_reason!r}\n"
456
+ f"Dispatcher reason: {dispatcher_reason!r}"
457
+ )
458
+
459
+
460
+ def test_aggregate_exit_code_two_signals_deny() -> None:
461
+ """A HostedHookResult with exit_code 2 and did_crash False signals deny.
462
+
463
+ A hosted hook that raises SystemExit(2) cleanly (not via an exception crash)
464
+ signals deny by exit code. The aggregator must treat exit_code==2 and
465
+ is_blocking==True as a deny even when captured_stdout is empty.
466
+ """
467
+ all_results = [
468
+ HostedHookResult(
469
+ exit_code=BLOCKING_CRASH_EXIT_CODE,
470
+ captured_stdout="",
471
+ did_crash=False,
472
+ is_blocking=True,
473
+ )
474
+ ]
475
+ decision = aggregate_hosted_hook_results(all_results)
476
+ assert decision.should_deny, (
477
+ "exit_code==2 with did_crash=False must signal deny"
478
+ )
479
+ assert decision.all_deny_reasons, (
480
+ "aggregator must supply a non-empty reason when exit_code==2 deny carries no JSON"
481
+ )
482
+ assert EXIT_CODE_TWO_DENY_REASON in decision.all_deny_reasons[0], (
483
+ f"deny reason must reference EXIT_CODE_TWO_DENY_REASON constant. "
484
+ f"Got: {decision.all_deny_reasons[0]!r}"
485
+ )
486
+
487
+
488
+ def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
489
+ """A HostedHookResult with exit_code 0 and empty stdout signals allow.
490
+
491
+ The aggregator must not deny on a clean allow (no JSON output, exit 0).
492
+ """
493
+ all_results = [
494
+ HostedHookResult(
495
+ exit_code=0,
496
+ captured_stdout="",
497
+ did_crash=False,
498
+ is_blocking=True,
499
+ )
500
+ ]
501
+ decision = aggregate_hosted_hook_results(all_results)
502
+ assert not decision.should_deny, (
503
+ "exit_code==0 with no output must signal allow"
504
+ )
505
+
506
+
507
+ def test_aggregate_explicit_allow_payload_signals_allow_decision() -> None:
508
+ """An explicit permissionDecision allow from a hosted hook signals an allow decision.
509
+
510
+ tdd_enforcer writes an explicit allow payload on its allow path, which
511
+ auto-approves the write standalone. The aggregator must surface that as an
512
+ explicit allow decision so the dispatcher re-emits it rather than silently
513
+ falling back to the default permission flow.
514
+ """
515
+ explicit_allow_stdout = json.dumps(
516
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}
517
+ )
518
+ all_results = [
519
+ HostedHookResult(
520
+ exit_code=0,
521
+ captured_stdout=explicit_allow_stdout,
522
+ did_crash=False,
523
+ is_blocking=True,
524
+ )
525
+ ]
526
+ decision = aggregate_hosted_hook_results(all_results)
527
+ assert not decision.should_deny, "an explicit allow must not deny"
528
+ assert decision.should_allow, (
529
+ "an explicit permissionDecision allow with no deny must signal an allow decision"
530
+ )
531
+
532
+
533
+ def test_aggregate_explicit_allow_is_overridden_by_a_deny() -> None:
534
+ """A deny wins over an explicit allow from another hook in the same run."""
535
+ explicit_allow_stdout = json.dumps(
536
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}
537
+ )
538
+ all_results = [
539
+ HostedHookResult(
540
+ exit_code=0,
541
+ captured_stdout=explicit_allow_stdout,
542
+ did_crash=False,
543
+ is_blocking=True,
544
+ ),
545
+ HostedHookResult(
546
+ exit_code=BLOCKING_CRASH_EXIT_CODE,
547
+ captured_stdout="",
548
+ did_crash=False,
549
+ is_blocking=True,
550
+ ),
551
+ ]
552
+ decision = aggregate_hosted_hook_results(all_results)
553
+ assert decision.should_deny, "a deny must win over an explicit allow"
554
+ assert not decision.should_allow, (
555
+ "should_allow must be False when any hook denies, so deny wins"
556
+ )
557
+
558
+
559
+ def test_later_hook_deny_survives_early_hook_exit() -> None:
560
+ """Dispatcher denies even when an earlier hook exits cleanly before a later hook denies.
561
+
562
+ plain_language_blocker (Group B, last in order) denies a markdown write with heavy
563
+ prose. Earlier hooks exit 0 (allow). The dispatcher must catch each hook's
564
+ SystemExit and continue, so the later denial reaches the aggregator.
565
+ """
566
+ payload_text = _write_payload(
567
+ _MARKDOWN_FILE_PATH,
568
+ "# Doc\n\nThis section attempts to facilitate the utilization of this functionality.\n",
569
+ )
570
+ _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
571
+
572
+
573
+ def test_dispatcher_write_applies_both_groups() -> None:
574
+ """Write tool triggers both Group A and Group B hooks through the dispatcher.
575
+
576
+ Verifies that the set of applicable entries for Write includes entries from
577
+ both ALL_WRITE_AND_EDIT_TOOL_NAMES (Group A) and ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES
578
+ (Group B) in the constants.
579
+ """
580
+ all_write_entries = _applicable_entries_for_tool(WRITE_TOOL_NAME)
581
+ all_write_script_paths = {each_entry.script_relative_path for each_entry in all_write_entries}
582
+ assert "blocking/write_existing_file_blocker.py" in all_write_script_paths, (
583
+ "write_existing_file_blocker (Group A) must be in Write applicable set"
584
+ )
585
+ assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
586
+ "plain_language_blocker (Group B) must be in Write applicable set"
587
+ )
588
+ assert len(all_write_entries) == 15, (
589
+ f"Write tool must apply to all 15 hosted hooks, got {len(all_write_entries)}"
590
+ )
591
+
592
+
593
+ def test_dispatcher_edit_applies_both_groups() -> None:
594
+ """Edit tool triggers both Group A and Group B hooks through the dispatcher."""
595
+ all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
596
+ assert len(all_edit_entries) == 15, (
597
+ f"Edit tool must apply to all 15 hosted hooks, got {len(all_edit_entries)}"
598
+ )
599
+
600
+
601
+ def test_dispatcher_multi_edit_applies_only_group_b() -> None:
602
+ """MultiEdit tool triggers only Group B (5 hooks), not Group A."""
603
+ all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
604
+ assert len(all_multi_edit_entries) == 5, (
605
+ f"MultiEdit tool must apply to exactly 5 Group-B hooks, got {len(all_multi_edit_entries)}"
606
+ )
607
+
608
+
609
+ def test_proceed_after_run_all_validators_removal_allows() -> None:
610
+ """The PreToolUse dispatcher allows a Python edit that the removed gate would have processed.
611
+
612
+ The inline run_all_validators runner was a PostToolUse gate removed in Stage 4;
613
+ it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
614
+ A Python Write payload that run_all_validators would have flagged (mypy errors, for
615
+ instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
616
+ dispatcher covers only its 15 hosted blocking hooks — none of which includes the
617
+ validators runner.
618
+ """
619
+ python_content_with_type_error = (
620
+ "def add_one(value: int) -> int:\n"
621
+ " return value + 1\n\n\n"
622
+ "add_one('not an int')\n"
623
+ )
624
+ payload_text = _write_payload(_TEMP_FILE_PATH.replace(".txt", ".py"), python_content_with_type_error)
625
+ dispatcher_result = _run_dispatcher(payload_text)
626
+ is_deny, _reason = _parse_hook_decision(dispatcher_result)
627
+ assert not is_deny, (
628
+ "PreToolUse dispatcher must allow a Python Write with a type error — "
629
+ "mypy validation is PostToolUse-only; the removed run_all_validators gate "
630
+ "was never a PreToolUse hook"
631
+ )
632
+ assert dispatcher_result.returncode == 0, (
633
+ f"Dispatcher must exit 0, got {dispatcher_result.returncode}"
634
+ )
635
+
636
+
637
+ def _orphan_claude_md_payload(tmp_path: Path) -> str:
638
+ """Build a Write payload for a CLAUDE.md naming a file that does not exist.
639
+
640
+ Args:
641
+ tmp_path: A directory hosting the throwaway CLAUDE.md.
642
+
643
+ Returns:
644
+ JSON-encoded Write payload that trips claude_md_orphan_file_blocker.
645
+ """
646
+ claude_md_path = str(tmp_path / "CLAUDE.md")
647
+ orphan_table = (
648
+ "# Files\n\n"
649
+ "| File | Role |\n"
650
+ "|---|---|\n"
651
+ "| `file_that_does_not_exist_anywhere.py` | a missing file |\n"
652
+ )
653
+ return _write_payload(claude_md_path, orphan_table)
654
+
655
+
656
+ def test_runpy_deny_preserves_additional_context_and_suppress_output(tmp_path: Path) -> None:
657
+ """A runpy-hosted deny carries its additionalContext and suppressOutput through the dispatcher.
658
+
659
+ claude_md_orphan_file_blocker emits hookSpecificOutput.additionalContext and a
660
+ top-level suppressOutput flag on a deny. The dispatcher must preserve both so
661
+ the dispatched denial matches the standalone hook's deny shape.
662
+
663
+ Args:
664
+ tmp_path: Pytest temp directory hosting the throwaway CLAUDE.md.
665
+ """
666
+ payload_text = _orphan_claude_md_payload(tmp_path)
667
+
668
+ standalone_result = _run_hook_subprocess(
669
+ "blocking/claude_md_orphan_file_blocker.py", payload_text
670
+ )
671
+ standalone_payload = json.loads(standalone_result.stdout.strip())
672
+ standalone_hook_specific = standalone_payload["hookSpecificOutput"]
673
+ expected_additional_context = standalone_hook_specific["additionalContext"]
674
+
675
+ dispatcher_result = _run_dispatcher(payload_text)
676
+ dispatcher_payload = json.loads(dispatcher_result.stdout.strip())
677
+ dispatcher_hook_specific = dispatcher_payload.get("hookSpecificOutput", {})
678
+ assert isinstance(dispatcher_hook_specific, dict)
679
+ assert dispatcher_hook_specific.get("additionalContext") == expected_additional_context, (
680
+ "Dispatcher must preserve the runpy hook's additionalContext.\n"
681
+ f"Expected: {expected_additional_context!r}\n"
682
+ f"Got: {dispatcher_hook_specific.get('additionalContext')!r}"
683
+ )
684
+ assert dispatcher_payload.get("suppressOutput") is True, (
685
+ "Dispatcher must preserve the runpy hook's suppressOutput flag.\n"
686
+ f"Got: {dispatcher_payload.get('suppressOutput')!r}"
687
+ )
688
+
689
+
690
+ def _parse_hook_allow(completed_process: subprocess.CompletedProcess[str]) -> bool:
691
+ """Parse one hook's subprocess result for an explicit permissionDecision allow.
692
+
693
+ Args:
694
+ completed_process: The completed subprocess from running a hook.
695
+
696
+ Returns:
697
+ True when the hook emitted an explicit allow decision.
698
+ """
699
+ stdout_text = completed_process.stdout.strip()
700
+ if not stdout_text:
701
+ return False
702
+ try:
703
+ parsed_output = json.loads(stdout_text)
704
+ except json.JSONDecodeError:
705
+ return False
706
+ hook_specific = parsed_output.get("hookSpecificOutput", {})
707
+ if not isinstance(hook_specific, dict):
708
+ return False
709
+ return hook_specific.get("permissionDecision") == "allow"
710
+
711
+
712
+ def test_dispatcher_reemits_explicit_allow_from_tdd_enforcer(tmp_path: Path) -> None:
713
+ """The dispatcher re-emits an explicit allow when tdd_enforcer's allow branch fires.
714
+
715
+ tdd_enforcer writes an explicit allow payload for a constants-only Python
716
+ Write, which auto-approves the write standalone. Run against the dispatcher,
717
+ the same payload must produce an explicit allow decision identical to the
718
+ standalone tdd_enforcer output, rather than a silent fall-back to the default
719
+ permission flow.
720
+
721
+ Args:
722
+ tmp_path: Pytest temp directory hosting the fresh config target path.
723
+ """
724
+ config_target_path = str(tmp_path / "config" / "timing.py")
725
+ constants_only_content = (
726
+ '"""Timing constants."""\n\nMAXIMUM_RETRIES = 3\nRETRY_DELAY_SECONDS = 5\n'
727
+ )
728
+ payload_text = _write_payload(config_target_path, constants_only_content)
729
+
730
+ standalone_result = _run_hook_subprocess("blocking/tdd_enforcer.py", payload_text)
731
+ assert _parse_hook_allow(standalone_result), (
732
+ "tdd_enforcer must emit an explicit allow for a constants-only Python Write — "
733
+ "if it does not, this fixture no longer exercises the allow branch"
734
+ )
735
+
736
+ dispatcher_result = _run_dispatcher(payload_text)
737
+ dispatcher_is_deny, _reason = _parse_hook_decision(dispatcher_result)
738
+ assert not dispatcher_is_deny, "dispatcher must not deny a payload tdd_enforcer allows"
739
+ assert _parse_hook_allow(dispatcher_result), (
740
+ "dispatcher must re-emit an explicit allow when a hosted hook allows explicitly "
741
+ "and no hook denies, matching the standalone tdd_enforcer behavior — "
742
+ f"got stdout {dispatcher_result.stdout.strip()!r}"
743
+ )
744
+ dispatcher_payload = json.loads(dispatcher_result.stdout.strip())
745
+ standalone_payload = json.loads(standalone_result.stdout.strip())
746
+ assert dispatcher_payload == standalone_payload, (
747
+ "dispatcher allow payload must match the standalone tdd_enforcer allow payload.\n"
748
+ f"Standalone: {standalone_payload!r}\n"
749
+ f"Dispatcher: {dispatcher_payload!r}"
750
+ )
751
+
752
+
753
+ def test_runpy_hosted_hook_sees_its_own_argv_not_the_dispatchers(tmp_path: Path) -> None:
754
+ """A runpy-hosted hook resolves its own script path as sys.argv, not the dispatcher's.
755
+
756
+ The dispatcher must set sys.argv to the hosted hook's own script path before
757
+ runpy so a hook that branches on sys.argv (such as code_rules_enforcer's
758
+ --check pre-check mode) reads the same argv it would standalone, rather than
759
+ the dispatcher's argv. The probe writes the argv it observed to a result
760
+ file, and the test asserts argv[0] is the probe's own path.
761
+
762
+ Args:
763
+ tmp_path: Pytest temp directory hosting the probe script and result file.
764
+ """
765
+ argv_result_path = tmp_path / "observed_argv.json"
766
+ probe_script_path = tmp_path / "argv_probe_hook.py"
767
+ probe_script_path.write_text(
768
+ "import json\n"
769
+ "import sys\n"
770
+ "from pathlib import Path\n\n"
771
+ f"Path({str(argv_result_path)!r}).write_text(json.dumps(sys.argv), encoding='utf-8')\n",
772
+ encoding="utf-8",
773
+ )
774
+
775
+ run_hosted_hook(str(probe_script_path), _write_payload(_TEMP_FILE_PATH, "x"), True)
776
+
777
+ observed_argv = json.loads(argv_result_path.read_text(encoding="utf-8"))
778
+ assert observed_argv == [str(probe_script_path)], (
779
+ "A runpy-hosted hook must see its own script path as sys.argv, "
780
+ f"not the dispatcher's argv. Observed: {observed_argv!r}"
781
+ )
782
+
783
+
784
+ def test_hosted_hook_set_covers_all_write_edit_blocking_hooks() -> None:
785
+ """The hosted hook set covers all previously-registered Write/Edit blocking hooks.
786
+
787
+ Verifies that removing the standalone gate entries from hooks.json did not
788
+ silently drop coverage: every script path that was registered as a blocking
789
+ PreToolUse hook for Write/Edit is present in the dispatcher's hosted set.
790
+ """
791
+ all_hosted_script_paths = frozenset(
792
+ each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
793
+ )
794
+ previously_registered_blocking_hooks: frozenset[str] = frozenset({
795
+ "blocking/write_existing_file_blocker.py",
796
+ "blocking/sensitive_file_protector.py",
797
+ "validation/hook_format_validator.py",
798
+ "blocking/code_rules_enforcer.py",
799
+ "blocking/tdd_enforcer.py",
800
+ "blocking/windows_rmtree_blocker.py",
801
+ "blocking/state_description_blocker.py",
802
+ "blocking/subprocess_budget_completeness.py",
803
+ "blocking/hook_prose_detector_consistency.py",
804
+ "blocking/verified_commit_message_accuracy_blocker.py",
805
+ "blocking/workflow_substitution_slot_blocker.py",
806
+ "blocking/claude_md_orphan_file_blocker.py",
807
+ "blocking/pytest_testpaths_orphan_blocker.py",
808
+ "blocking/open_questions_in_plans_blocker.py",
809
+ "blocking/plain_language_blocker.py",
810
+ })
811
+ for each_script_path in previously_registered_blocking_hooks:
812
+ assert each_script_path in all_hosted_script_paths, (
813
+ f"Previously-registered blocking hook {each_script_path!r} is missing "
814
+ "from the dispatcher's hosted hook set — coverage was lost when the "
815
+ "standalone entry was removed from hooks.json"
816
+ )