claude-dev-env 1.73.0 → 1.75.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 (105) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +4 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  14. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  15. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  16. package/hooks/blocking/hedging_language_blocker.py +17 -23
  17. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  18. package/hooks/blocking/intent_only_ending_blocker.py +18 -26
  19. package/hooks/blocking/md_to_html_blocker.py +10 -2
  20. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  21. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  22. package/hooks/blocking/plain_language_blocker.py +6 -0
  23. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  24. package/hooks/blocking/pr_description_enforcer.py +6 -0
  25. package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
  26. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  27. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  28. package/hooks/blocking/question_to_user_enforcer.py +19 -23
  29. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  30. package/hooks/blocking/sensitive_file_protector.py +15 -1
  31. package/hooks/blocking/session_handoff_blocker.py +15 -23
  32. package/hooks/blocking/state_description_blocker.py +6 -0
  33. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  34. package/hooks/blocking/tdd_enforcer.py +6 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  36. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  37. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
  38. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  39. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  40. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  41. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  42. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  43. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  44. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  45. package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
  46. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  47. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  48. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  49. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  50. package/hooks/blocking/test_state_description_blocker.py +41 -0
  51. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  52. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  53. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  54. package/hooks/blocking/verified_commit_gate.py +11 -0
  55. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  56. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  57. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  58. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  59. package/hooks/hooks.json +10 -0
  60. package/hooks/hooks_constants/CLAUDE.md +8 -1
  61. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  62. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  63. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  64. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  65. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  66. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  67. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  68. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  69. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
  70. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
  71. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  72. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  73. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  74. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  75. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  76. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  77. package/hooks/hooks_constants/text_stripping.py +36 -0
  78. package/hooks/lifecycle/config_change_guard.py +12 -0
  79. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  80. package/hooks/validation/CLAUDE.md +1 -0
  81. package/hooks/validation/hook_format_validator.py +13 -0
  82. package/hooks/validation/mypy_validator.py +30 -1
  83. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  84. package/hooks/validation/test_hook_format_validator.py +64 -0
  85. package/hooks/validation/test_mypy_validator.py +23 -1
  86. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  87. package/hooks/workflow/auto_formatter.py +8 -5
  88. package/hooks/workflow/test_auto_formatter.py +33 -0
  89. package/package.json +1 -1
  90. package/rules/CLAUDE.md +1 -0
  91. package/rules/docstring-prose-matches-implementation.md +2 -1
  92. package/rules/package-inventory-stale-entry.md +24 -0
  93. package/rules/windows-filesystem-safe.md +2 -0
  94. package/skills/autoconverge/SKILL.md +21 -1
  95. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  96. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
  98. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  99. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  100. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  101. package/skills/autoconverge/workflow/converge.mjs +599 -606
  102. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  103. package/skills/autoconverge/workflow/render_report.py +2 -6
  104. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  105. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -0,0 +1,56 @@
1
+ """Shared helpers that reconstruct the post-edit content of an Edit or MultiEdit.
2
+
3
+ Several PreToolUse blockers judge the content a write would leave on disk rather
4
+ than the raw payload fragment, so an edit on a line the blocker watches still
5
+ participates even when an untouched line elsewhere supplies the context. Both the
6
+ edit-replacement applier and the edit-list extractor are identical across those
7
+ blockers, so they live here once and are imported from each.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __all__ = [
13
+ "apply_edits",
14
+ "edits_for_tool",
15
+ ]
16
+
17
+
18
+ def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
19
+ """Return *existing_content* with each Edit/MultiEdit replacement applied in order.
20
+
21
+ Args:
22
+ existing_content: The current on-disk file content.
23
+ all_edits: The Edit payload (as a single-element list) or MultiEdit
24
+ ``edits`` list, each a mapping with an ``old_string`` and a
25
+ ``new_string``.
26
+
27
+ Returns:
28
+ The content after replacing the first occurrence of each edit's
29
+ ``old_string`` with its ``new_string``, in list order.
30
+ """
31
+ edited_content = existing_content
32
+ for each_edit in all_edits:
33
+ if not isinstance(each_edit, dict):
34
+ continue
35
+ old_string = each_edit.get("old_string", "")
36
+ new_string = each_edit.get("new_string", "")
37
+ if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
38
+ edited_content = edited_content.replace(old_string, new_string, 1)
39
+ return edited_content
40
+
41
+
42
+ def edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
43
+ """Return the edit mappings an Edit or MultiEdit payload carries.
44
+
45
+ Args:
46
+ tool_name: The intercepted tool — ``Edit`` or ``MultiEdit``.
47
+ tool_input: The tool's input payload.
48
+
49
+ Returns:
50
+ A single-element list holding the Edit payload, or the MultiEdit
51
+ ``edits`` list when it is present as a list; an empty list otherwise.
52
+ """
53
+ if tool_name == "Edit":
54
+ return [tool_input]
55
+ all_edits = tool_input.get("edits", [])
56
+ return all_edits if isinstance(all_edits, list) else []
@@ -0,0 +1,111 @@
1
+ """Constants for the package-inventory stale-entry blocker.
2
+
3
+ A package directory documents its own files in a sibling inventory document —
4
+ a ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list — whose entries
5
+ name each file in backticks. When a new production code file lands in that
6
+ directory and the inventory carries no entry naming it, the inventory disagrees
7
+ with the directory on the package's file set, and a reader trusting the
8
+ inventory to map the directory misses the new file. This module holds the
9
+ inventory document names, the production code extensions that earn an inventory
10
+ entry, the backtick pattern that finds an inventory's named files, the code-fence
11
+ pattern that marks lines to skip, the glob-metacharacter pattern that rejects
12
+ pattern tokens, the non-filename pattern that rejects command-example and
13
+ path-bearing prose spans, the minimum inventory size that marks a document as a
14
+ maintained inventory, the filenames exempt from an entry, the scan budget, and
15
+ the block-message text the hook emits.
16
+ """
17
+
18
+ import re
19
+
20
+ __all__ = [
21
+ "ALL_INVENTORY_DOCUMENT_NAMES",
22
+ "ALL_PRODUCTION_CODE_EXTENSIONS",
23
+ "PYTHON_FILE_EXTENSION",
24
+ "ALL_TEST_FILE_MARKERS",
25
+ "BACKTICK_TOKEN_PATTERN",
26
+ "CODE_FENCE_PATTERN",
27
+ "GLOB_METACHARACTER_PATTERN",
28
+ "NON_FILENAME_TOKEN_PATTERN",
29
+ "MINIMUM_INVENTORY_ENTRY_COUNT",
30
+ "ALL_EXEMPT_BASENAMES",
31
+ "ALL_EXEMPT_DIRECTORY_NAMES",
32
+ "MAX_INVENTORY_FILE_BYTES",
33
+ "STALE_INVENTORY_MESSAGE_TEMPLATE",
34
+ "STALE_INVENTORY_SYSTEM_MESSAGE",
35
+ "STALE_INVENTORY_ADDITIONAL_CONTEXT",
36
+ ]
37
+
38
+ ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset({"README.md", "CLAUDE.md"})
39
+
40
+ PYTHON_FILE_EXTENSION: str = ".py"
41
+
42
+ ALL_TEST_FILE_MARKERS: tuple[str, ...] = (".spec.", ".test.")
43
+
44
+ ALL_PRODUCTION_CODE_EXTENSIONS: frozenset[str] = frozenset(
45
+ {
46
+ ".py",
47
+ ".mjs",
48
+ ".js",
49
+ ".ts",
50
+ ".ps1",
51
+ ".sh",
52
+ }
53
+ )
54
+
55
+ BACKTICK_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
56
+
57
+ CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
58
+
59
+ GLOB_METACHARACTER_PATTERN: re.Pattern[str] = re.compile(r"[*?{}\[\]]")
60
+
61
+ NON_FILENAME_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"[\s:$<>]")
62
+
63
+ MINIMUM_INVENTORY_ENTRY_COUNT: int = 2
64
+
65
+ ALL_EXEMPT_BASENAMES: frozenset[str] = frozenset(
66
+ {
67
+ "__init__.py",
68
+ "conftest.py",
69
+ "setup.py",
70
+ "_path_setup.py",
71
+ }
72
+ )
73
+
74
+ ALL_EXEMPT_DIRECTORY_NAMES: frozenset[str] = frozenset(
75
+ {
76
+ "config",
77
+ "tests",
78
+ "__pycache__",
79
+ ".git",
80
+ "node_modules",
81
+ ".pytest_cache",
82
+ ".ruff_cache",
83
+ }
84
+ )
85
+
86
+ MAX_INVENTORY_FILE_BYTES: int = 200_000
87
+
88
+ STALE_INVENTORY_MESSAGE_TEMPLATE: str = (
89
+ "New production file `{filename}` lands in {directory}, whose inventory "
90
+ "document(s) ({inventories}) name {entry_count} sibling files but no entry "
91
+ "for `{filename}`. A package inventory names every production file in its "
92
+ "directory; a new file the inventory omits leaves the inventory and the "
93
+ "directory disagreeing on the package's file set. Add an entry naming "
94
+ "`{filename}` to the inventory in this same change."
95
+ )
96
+
97
+ STALE_INVENTORY_SYSTEM_MESSAGE: str = (
98
+ "New production file is absent from its package inventory (README.md / "
99
+ "CLAUDE.md) - add the inventory entry in this same change"
100
+ )
101
+
102
+ STALE_INVENTORY_ADDITIONAL_CONTEXT: str = (
103
+ "A package directory whose README.md or CLAUDE.md lists its files in "
104
+ "backticks is a maintained inventory of the package's file set. A new "
105
+ "production code file (.py, .mjs, .js, .ts, .ps1, .sh) in that directory "
106
+ "carries one inventory entry naming it. Add a row to the README.md table or "
107
+ "a bullet to the CLAUDE.md list naming this file, describing what it does, "
108
+ "in the same change that creates the file. Exempt files (no entry needed): "
109
+ "__init__.py, conftest.py, setup.py, _path_setup.py, files under config/ or "
110
+ "tests/, and test files (test_*.py, *_test.py, *.spec.*, *.test.*)."
111
+ )
@@ -2,8 +2,7 @@
2
2
 
3
3
  Holds the ordered hosted-hook list with each hook's extra command-line
4
4
  arguments and blocking flag, the PostToolUse block-decision string and key,
5
- and the hook-event name. The dispatcher imports these; no literals appear
6
- inline in the dispatcher script.
5
+ and the hook-event name. The dispatcher imports each of these by name.
7
6
  """
8
7
 
9
8
  from __future__ import annotations
@@ -16,6 +15,7 @@ __all__ = [
16
15
  "REASON_KEY",
17
16
  "HOOK_EVENT_NAME",
18
17
  "EMPTY_REASON_BLOCK_FALLBACK",
18
+ "BLOCKING_CRASH_DENY_REASON",
19
19
  "PLUGIN_ROOT_PLACEHOLDER",
20
20
  "PostHostedHookEntry",
21
21
  "ALL_POST_HOSTED_HOOK_ENTRIES",
@@ -26,6 +26,7 @@ DECISION_KEY = "decision"
26
26
  REASON_KEY = "reason"
27
27
  HOOK_EVENT_NAME = "PostToolUse"
28
28
  EMPTY_REASON_BLOCK_FALLBACK = "[dispatcher] hook blocked with no reason — write blocked"
29
+ BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
29
30
 
30
31
  PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
31
32
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Holds the ordered hosted-hook list with per-hook applicable-tool sets, the
4
4
  special exit codes, the deny decision string, and the hook-event name. The
5
- dispatcher imports these; no literals appear inline in the dispatcher script.
5
+ dispatcher imports each of these by name.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -15,6 +15,7 @@ __all__ = [
15
15
  "HOOK_EVENT_NAME",
16
16
  "BLOCKING_CRASH_EXIT_CODE",
17
17
  "EXIT_CODE_TWO_DENY_REASON",
18
+ "BLOCKING_CRASH_DENY_REASON",
18
19
  "WRITE_TOOL_NAME",
19
20
  "EDIT_TOOL_NAME",
20
21
  "MULTI_EDIT_TOOL_NAME",
@@ -31,6 +32,7 @@ ALLOW_DECISION = "allow"
31
32
  HOOK_EVENT_NAME = "PreToolUse"
32
33
  BLOCKING_CRASH_EXIT_CODE = 2
33
34
  EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
35
+ BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
34
36
 
35
37
  WRITE_TOOL_NAME = "Write"
36
38
  EDIT_TOOL_NAME = "Edit"
@@ -59,8 +61,8 @@ class HostedHookEntry:
59
61
  native_module_name: The importable module name whose evaluate function
60
62
  the dispatcher calls in-process for this hook, or None when the hook
61
63
  runs via runpy under __main__. The named module exposes a function
62
- named NATIVE_EVALUATE_FUNCTION_NAME taking the payload dict and
63
- returning a deny-reason string or None.
64
+ named `evaluate` taking the payload dict and returning a deny-reason
65
+ string or None.
64
66
  """
65
67
 
66
68
  script_relative_path: str
@@ -94,6 +96,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
94
96
  script_relative_path="blocking/windows_rmtree_blocker.py",
95
97
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
96
98
  ),
99
+ HostedHookEntry(
100
+ script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
101
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
102
+ ),
97
103
  HostedHookEntry(
98
104
  script_relative_path="blocking/state_description_blocker.py",
99
105
  applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
@@ -119,6 +125,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
119
125
  script_relative_path="blocking/claude_md_orphan_file_blocker.py",
120
126
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
121
127
  ),
128
+ HostedHookEntry(
129
+ script_relative_path="blocking/package_inventory_stale_blocker.py",
130
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
131
+ ),
122
132
  HostedHookEntry(
123
133
  script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
124
134
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
@@ -127,6 +137,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
127
137
  script_relative_path="blocking/open_questions_in_plans_blocker.py",
128
138
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
129
139
  ),
140
+ HostedHookEntry(
141
+ script_relative_path="blocking/docstring_rule_gate_count_blocker.py",
142
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
143
+ ),
130
144
  HostedHookEntry(
131
145
  script_relative_path="blocking/plain_language_blocker.py",
132
146
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
@@ -0,0 +1,18 @@
1
+ """Configuration constants for the send_user_file_open_locally_blocker PreToolUse hook."""
2
+
3
+ TOOL_NAME: str = "SendUserFile"
4
+
5
+ PROACTIVE_STATUS: str = "proactive"
6
+
7
+ CORRECTIVE_MESSAGE: str = (
8
+ "BLOCKED [open-locally]: SendUserFile attaches a file to the session, which "
9
+ "does not let the user see it while they are at the terminal. Open the file on "
10
+ "screen in its own viewer:\n"
11
+ " Start-Process pwsh -WindowStyle Hidden -ArgumentList "
12
+ "'-NoProfile','-File',\"$HOME\\.claude\\scripts\\Show-Asset.ps1\","
13
+ "'<path 1>','<path 2>'\n"
14
+ "Show-Asset.ps1 sizes each image window to the image and opens every other file "
15
+ "type in its default app. Pass every path the user named.\n"
16
+ "The one allowed attach is a phone push: when the user has stepped away and you "
17
+ 'want the file to reach their phone, call SendUserFile with status "proactive".'
18
+ )
@@ -0,0 +1,44 @@
1
+ """Regression tests proving the dispatcher constants docstrings clear the O6 gate.
2
+
3
+ The PreToolUse and PostToolUse dispatcher constants modules each carry a module
4
+ docstring stating what the module centralizes. Neither docstring may assert that
5
+ no literals appear inline in its companion dispatcher script — that completeness
6
+ claim about a sibling file is the deterministic Category O6 drift the enforcer's
7
+ check_docstring_no_inline_literal_claim blocks. These tests load the real
8
+ modules' source and assert the check returns no issues for either.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+
17
+
18
+ def _load_enforcer_module() -> ModuleType:
19
+ enforcer_path = Path(__file__).resolve().parent.parent / "blocking" / "code_rules_enforcer.py"
20
+ enforcer_spec = importlib.util.spec_from_file_location("code_rules_enforcer", enforcer_path)
21
+ assert enforcer_spec is not None
22
+ assert enforcer_spec.loader is not None
23
+ enforcer_module = importlib.util.module_from_spec(enforcer_spec)
24
+ enforcer_spec.loader.exec_module(enforcer_module)
25
+ return enforcer_module
26
+
27
+
28
+ code_rules_enforcer = _load_enforcer_module()
29
+
30
+
31
+ def _check_issues_for(module_filename: str) -> list[str]:
32
+ module_path = Path(__file__).resolve().parent / module_filename
33
+ module_source = module_path.read_text(encoding="utf-8")
34
+ return code_rules_enforcer.check_docstring_no_inline_literal_claim(
35
+ module_source, str(module_path)
36
+ )
37
+
38
+
39
+ def test_pre_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
40
+ assert _check_issues_for("pre_tool_use_dispatcher_constants.py") == []
41
+
42
+
43
+ def test_post_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
44
+ assert _check_issues_for("post_tool_use_dispatcher_constants.py") == []
@@ -0,0 +1,159 @@
1
+ """Tests for the shared hook block logger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ import stat
8
+ import sys
9
+ from pathlib import Path
10
+ from unittest.mock import patch
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
+
16
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
17
+
18
+
19
+ def test_log_hook_block_writes_parseable_json_line(tmp_path: Path) -> None:
20
+ with patch.object(Path, "home", return_value=tmp_path):
21
+ log_hook_block(
22
+ calling_hook_name="test_hook.py",
23
+ hook_event="PreToolUse",
24
+ block_reason="test block reason",
25
+ tool_name="Bash",
26
+ offending_input_preview="echo hello",
27
+ )
28
+
29
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
30
+ assert log_path.exists()
31
+ line = log_path.read_text(encoding="utf-8").strip()
32
+ parsed = json.loads(line)
33
+
34
+ assert "timestamp" in parsed
35
+ assert parsed["hook"] == "test_hook.py"
36
+ assert parsed["event"] == "PreToolUse"
37
+ assert parsed["tool"] == "Bash"
38
+ assert parsed["reason"] == "test block reason"
39
+ assert parsed["preview"] == "echo hello"
40
+
41
+
42
+ def test_log_hook_block_creates_logs_directory(tmp_path: Path) -> None:
43
+ with patch.object(Path, "home", return_value=tmp_path):
44
+ log_hook_block(
45
+ calling_hook_name="some_hook.py",
46
+ hook_event="Stop",
47
+ block_reason="stop reason",
48
+ )
49
+
50
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
51
+ assert log_path.exists()
52
+
53
+
54
+ def test_log_hook_block_omits_none_fields(tmp_path: Path) -> None:
55
+ with patch.object(Path, "home", return_value=tmp_path):
56
+ log_hook_block(
57
+ calling_hook_name="minimal_hook.py",
58
+ hook_event="PreToolUse",
59
+ block_reason="some reason",
60
+ )
61
+
62
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
63
+ line = log_path.read_text(encoding="utf-8").strip()
64
+ parsed = json.loads(line)
65
+ assert "tool" not in parsed
66
+ assert "preview" not in parsed
67
+
68
+
69
+ def test_log_hook_block_appends_multiple_records(tmp_path: Path) -> None:
70
+ with patch.object(Path, "home", return_value=tmp_path):
71
+ log_hook_block(
72
+ calling_hook_name="hook_a.py",
73
+ hook_event="PreToolUse",
74
+ block_reason="first",
75
+ )
76
+ log_hook_block(
77
+ calling_hook_name="hook_b.py",
78
+ hook_event="Stop",
79
+ block_reason="second",
80
+ )
81
+
82
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
83
+ all_lines = log_path.read_text(encoding="utf-8").strip().splitlines()
84
+ assert len(all_lines) == 2
85
+ first_parsed = json.loads(all_lines[0])
86
+ second_parsed = json.loads(all_lines[1])
87
+ assert first_parsed["hook"] == "hook_a.py"
88
+ assert second_parsed["hook"] == "hook_b.py"
89
+
90
+
91
+ def test_log_hook_block_swallows_io_error_on_unwritable_log(tmp_path: Path) -> None:
92
+ logs_dir = tmp_path / ".claude" / "logs"
93
+ logs_dir.mkdir(parents=True, exist_ok=True)
94
+ log_path = logs_dir / "hook-blocks.log"
95
+ log_path.write_text("", encoding="utf-8")
96
+ log_path.chmod(stat.S_IREAD)
97
+
98
+ try:
99
+ with patch.object(Path, "home", return_value=tmp_path):
100
+ log_hook_block(
101
+ calling_hook_name="any_hook.py",
102
+ hook_event="PreToolUse",
103
+ block_reason="reason",
104
+ )
105
+ except OSError:
106
+ pytest.fail("log_hook_block raised OSError on unwritable log file")
107
+ finally:
108
+ log_path.chmod(stat.S_IREAD | stat.S_IWRITE)
109
+
110
+
111
+ def test_log_hook_block_swallows_runtime_error_when_home_unresolvable() -> None:
112
+ def raise_home_resolution_failure() -> Path:
113
+ raise RuntimeError("Could not determine home directory.")
114
+
115
+ with patch.object(Path, "home", side_effect=raise_home_resolution_failure):
116
+ try:
117
+ returned_nothing = log_hook_block(
118
+ calling_hook_name="any_hook.py",
119
+ hook_event="PreToolUse",
120
+ block_reason="reason",
121
+ )
122
+ except RuntimeError:
123
+ pytest.fail("log_hook_block raised RuntimeError when home was unresolvable")
124
+
125
+ assert returned_nothing is None
126
+
127
+
128
+ def test_log_hook_block_truncates_long_preview(tmp_path: Path) -> None:
129
+ long_input = "x" * 600
130
+
131
+ with patch.object(Path, "home", return_value=tmp_path):
132
+ log_hook_block(
133
+ calling_hook_name="hook.py",
134
+ hook_event="PreToolUse",
135
+ block_reason="reason",
136
+ offending_input_preview=long_input,
137
+ )
138
+
139
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
140
+ line = log_path.read_text(encoding="utf-8").strip()
141
+ parsed = json.loads(line)
142
+ assert len(parsed["preview"]) <= 500
143
+
144
+
145
+ def test_log_hook_block_timestamp_is_iso8601(tmp_path: Path) -> None:
146
+ before = datetime.datetime.now()
147
+ with patch.object(Path, "home", return_value=tmp_path):
148
+ log_hook_block(
149
+ calling_hook_name="ts_hook.py",
150
+ hook_event="PreToolUse",
151
+ block_reason="ts test",
152
+ )
153
+ after = datetime.datetime.now()
154
+
155
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
156
+ line = log_path.read_text(encoding="utf-8").strip()
157
+ parsed = json.loads(line)
158
+ parsed_timestamp = datetime.datetime.fromisoformat(parsed["timestamp"])
159
+ assert before <= parsed_timestamp <= after
@@ -0,0 +1,43 @@
1
+ """Tests for the PostToolUse dispatcher constants module."""
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ _VALIDATION_DIR = _HOOKS_ROOT / "validation"
11
+ if str(_VALIDATION_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_VALIDATION_DIR))
13
+
14
+ from post_tool_use_dispatcher import (
15
+ PostHostedHookResult,
16
+ aggregate_post_hosted_hook_results,
17
+ )
18
+
19
+ from hooks_constants.post_tool_use_dispatcher_constants import (
20
+ BLOCKING_CRASH_DENY_REASON,
21
+ )
22
+
23
+
24
+ def test_blocking_hook_crash_block_reason_surfaces_the_constant() -> None:
25
+ crash_result = PostHostedHookResult(
26
+ captured_stdout="",
27
+ did_crash=True,
28
+ is_blocking=True,
29
+ )
30
+ decision = aggregate_post_hosted_hook_results([crash_result])
31
+ assert decision.should_block
32
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_block_reasons
33
+
34
+
35
+ def test_non_blocking_hook_crash_does_not_surface_the_constant() -> None:
36
+ crash_result = PostHostedHookResult(
37
+ captured_stdout="",
38
+ did_crash=True,
39
+ is_blocking=False,
40
+ )
41
+ decision = aggregate_post_hosted_hook_results([crash_result])
42
+ assert not decision.should_block
43
+ assert BLOCKING_CRASH_DENY_REASON not in decision.all_block_reasons
@@ -0,0 +1,99 @@
1
+ """Tests for the PreToolUse dispatcher hosted-hook roster."""
2
+
3
+ import importlib
4
+ import pathlib
5
+ import sys
6
+
7
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
8
+ if str(_HOOKS_ROOT) not in sys.path:
9
+ sys.path.insert(0, str(_HOOKS_ROOT))
10
+
11
+ _BLOCKING_DIR = _HOOKS_ROOT / "blocking"
12
+ if str(_BLOCKING_DIR) not in sys.path:
13
+ sys.path.insert(0, str(_BLOCKING_DIR))
14
+
15
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
16
+ ALL_HOSTED_HOOK_ENTRIES,
17
+ ALL_WRITE_AND_EDIT_TOOL_NAMES,
18
+ BLOCKING_CRASH_DENY_REASON,
19
+ EDIT_TOOL_NAME,
20
+ WRITE_TOOL_NAME,
21
+ )
22
+ from pre_tool_use_dispatcher import (
23
+ HostedHookResult,
24
+ aggregate_hosted_hook_results,
25
+ )
26
+
27
+
28
+ def _entry_for(script_relative_path: str):
29
+ matching_entries = [
30
+ each_entry
31
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
32
+ if each_entry.script_relative_path == script_relative_path
33
+ ]
34
+ return matching_entries[0] if matching_entries else None
35
+
36
+
37
+ def test_roster_includes_duplicate_rmtree_helper_blocker_script_path() -> None:
38
+ all_registered_script_paths = [
39
+ each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
40
+ ]
41
+ assert "blocking/duplicate_rmtree_helper_blocker.py" in all_registered_script_paths, (
42
+ "duplicate_rmtree_helper_blocker must be hosted by the dispatcher so a local "
43
+ "re-definition of the Windows-safe rmtree helper trio is blocked at Write time"
44
+ )
45
+
46
+
47
+ def test_duplicate_rmtree_helper_blocker_applies_to_write_and_edit() -> None:
48
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
49
+ assert entry is not None
50
+ assert WRITE_TOOL_NAME in entry.applicable_tool_names
51
+ assert EDIT_TOOL_NAME in entry.applicable_tool_names
52
+
53
+
54
+ def test_duplicate_rmtree_helper_blocker_is_blocking() -> None:
55
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
56
+ assert entry is not None
57
+ assert entry.is_blocking is True
58
+
59
+
60
+ def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
61
+ entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
62
+ assert entry is not None
63
+ assert entry.native_module_name is None
64
+
65
+
66
+ def test_windows_rmtree_blocker_still_registered() -> None:
67
+ entry = _entry_for("blocking/windows_rmtree_blocker.py")
68
+ assert entry is not None
69
+ assert entry.applicable_tool_names == ALL_WRITE_AND_EDIT_TOOL_NAMES
70
+
71
+
72
+ def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
73
+ crash_result = HostedHookResult(
74
+ exit_code=0,
75
+ captured_stdout="",
76
+ did_crash=True,
77
+ is_blocking=True,
78
+ )
79
+ decision = aggregate_hosted_hook_results([crash_result])
80
+ assert decision.should_deny
81
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
82
+
83
+
84
+ def test_every_native_module_exposes_a_callable_evaluate() -> None:
85
+ nativized_entries = [
86
+ each_entry
87
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
88
+ if each_entry.native_module_name is not None
89
+ ]
90
+ assert nativized_entries, (
91
+ "the roster must carry at least one nativized hook for this test to lock the contract"
92
+ )
93
+ for each_entry in nativized_entries:
94
+ native_module = importlib.import_module(each_entry.native_module_name)
95
+ evaluate_function = getattr(native_module, "evaluate", None)
96
+ assert callable(evaluate_function), (
97
+ f"{each_entry.native_module_name} must expose a callable named evaluate, "
98
+ "matching the native_module_name docstring contract"
99
+ )
@@ -0,0 +1,39 @@
1
+ """Tests for the shared strip_code_and_quotes helper."""
2
+
3
+ import pathlib
4
+ import sys
5
+
6
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ from hooks_constants.text_stripping import strip_code_and_quotes
11
+
12
+
13
+ def test_removes_fenced_code_block() -> None:
14
+ text = "before\n```python\nshould I run this?\n```\nafter"
15
+ stripped = strip_code_and_quotes(text)
16
+ assert "should I run this?" not in stripped
17
+ assert "before" in stripped
18
+ assert "after" in stripped
19
+
20
+
21
+ def test_removes_inline_code_span() -> None:
22
+ text = "the function `would you like` is named oddly"
23
+ stripped = strip_code_and_quotes(text)
24
+ assert "would you like" not in stripped
25
+ assert "the function" in stripped
26
+ assert "is named oddly" in stripped
27
+
28
+
29
+ def test_removes_leading_blockquote_lines() -> None:
30
+ text = "real line\n> should I proceed?\nfinal line"
31
+ stripped = strip_code_and_quotes(text)
32
+ assert "should I proceed?" not in stripped
33
+ assert "real line" in stripped
34
+ assert "final line" in stripped
35
+
36
+
37
+ def test_leaves_plain_prose_unchanged() -> None:
38
+ text = "This sentence carries no code or quotes."
39
+ assert strip_code_and_quotes(text) == text