claude-dev-env 1.72.0 → 1.74.0

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