claude-dev-env 1.71.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 (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -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()
@@ -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,20 @@ 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
12
18
  from pathlib import Path
13
19
  from types import ModuleType
14
20
 
21
+ import pytest
22
+
15
23
  HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
16
24
 
17
25
  MODULE_WITH_SIBLING_IMPORT = (
@@ -20,6 +28,15 @@ MODULE_WITH_SIBLING_IMPORT = (
20
28
  TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
21
29
  NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n"
22
30
 
31
+ CLEAN_MODULE_SOURCE = "value: int = 1\n"
32
+ TYPE_ERROR_MODULE_SOURCE = 'value: int = "not an integer"\n'
33
+
34
+ UNTYPED_DEF_MODULE_SOURCE = "def passthrough(supplied):\n return supplied\n"
35
+ LOOSE_TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
36
+ STRICT_TOOL_MYPY_PYPROJECT = (
37
+ "[tool.mypy]\nignore_missing_imports = true\ndisallow_untyped_defs = true\n"
38
+ )
39
+
23
40
 
24
41
  def _load_validator() -> ModuleType:
25
42
  spec = importlib.util.spec_from_file_location("mypy_validator_under_test", HOOK_PATH)
@@ -29,6 +46,19 @@ def _load_validator() -> ModuleType:
29
46
  return module
30
47
 
31
48
 
49
+ @pytest.fixture(autouse=True)
50
+ def isolate_cache_directory(
51
+ tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
52
+ ) -> None:
53
+ isolated_cache_directory = tmp_path_factory.mktemp("mypy-validator-cache")
54
+ monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "isolated-test-session")
55
+ monkeypatch.setattr(
56
+ "hooks_constants.mypy_validator_cache_constants.HOOK_STATE_CACHE_DIRECTORY",
57
+ str(isolated_cache_directory),
58
+ raising=True,
59
+ )
60
+
61
+
32
62
  def test_discover_mypy_config_finds_nearest_tool_mypy_pyproject(tmp_path: Path) -> None:
33
63
  validator = _load_validator()
34
64
  (tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
@@ -92,3 +122,156 @@ def test_run_mypy_reports_import_error_without_tool_mypy_config(tmp_path: Path)
92
122
 
93
123
  assert exit_code != 0
94
124
  assert "import-not-found" in output
125
+
126
+
127
+ def test_config_walk_runs_once_per_root_across_two_edits(
128
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
129
+ ) -> None:
130
+ validator = _load_validator()
131
+ project_root = tmp_path / "project"
132
+ project_root.mkdir()
133
+ (project_root / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
134
+ first_module = project_root / "first.py"
135
+ first_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
136
+ second_module = project_root / "second.py"
137
+ second_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
138
+
139
+ walk_call_count = 0
140
+ real_walk = validator.find_pyproject_with_mypy_config
141
+
142
+ def _counting_walk(starting_file: Path) -> Path | None:
143
+ nonlocal walk_call_count
144
+ walk_call_count += 1
145
+ return real_walk(starting_file)
146
+
147
+ monkeypatch.setattr(validator, "find_pyproject_with_mypy_config", _counting_walk)
148
+
149
+ validator.run_mypy(str(first_module), str(project_root))
150
+ walk_count_after_first_edit = walk_call_count
151
+ validator.run_mypy(str(second_module), str(project_root))
152
+ walk_count_after_second_edit = walk_call_count
153
+
154
+ assert walk_count_after_first_edit == 1
155
+ assert walk_count_after_second_edit == walk_count_after_first_edit
156
+
157
+
158
+ def test_sibling_subtrees_each_resolve_their_own_nested_config(
159
+ tmp_path: Path,
160
+ ) -> None:
161
+ validator = _load_validator()
162
+ git_root = tmp_path / "monorepo"
163
+ first_subtree = git_root / "first_package"
164
+ second_subtree = git_root / "second_package"
165
+ first_subtree.mkdir(parents=True)
166
+ second_subtree.mkdir(parents=True)
167
+ first_config = first_subtree / "pyproject.toml"
168
+ second_config = second_subtree / "pyproject.toml"
169
+ first_config.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
170
+ second_config.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
171
+ first_module = first_subtree / "first.py"
172
+ second_module = second_subtree / "second.py"
173
+ first_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
174
+ second_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
175
+
176
+ first_discovered = validator.discover_mypy_config(first_module)
177
+ second_discovered = validator.discover_mypy_config(second_module)
178
+
179
+ assert first_discovered is not None
180
+ assert second_discovered is not None
181
+ assert first_discovered.resolve() == first_config.resolve()
182
+ assert second_discovered.resolve() == second_config.resolve()
183
+
184
+
185
+ def test_warm_cache_still_blocks_file_edited_to_introduce_type_error(
186
+ tmp_path: Path,
187
+ ) -> None:
188
+ validator = _load_validator()
189
+ project_root = tmp_path / "project"
190
+ project_root.mkdir()
191
+ (project_root / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
192
+ edited_module = project_root / "edited.py"
193
+ edited_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
194
+
195
+ clean_exit_code, _clean_output = validator.run_mypy(
196
+ str(edited_module), str(project_root)
197
+ )
198
+ assert clean_exit_code == 0
199
+
200
+ edited_module.write_text(TYPE_ERROR_MODULE_SOURCE, encoding="utf-8")
201
+ error_exit_code, error_output = validator.run_mypy(
202
+ str(edited_module), str(project_root)
203
+ )
204
+
205
+ assert error_exit_code != 0
206
+ assert ": error:" in error_output
207
+
208
+
209
+ def test_warm_cache_skips_mypy_run_when_content_unchanged(
210
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
211
+ ) -> None:
212
+ validator = _load_validator()
213
+ project_root = tmp_path / "project"
214
+ project_root.mkdir()
215
+ (project_root / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
216
+ unchanged_module = project_root / "unchanged.py"
217
+ unchanged_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
218
+
219
+ validator.run_mypy(str(unchanged_module), str(project_root))
220
+
221
+ subprocess_run_call_count = 0
222
+ real_subprocess_run = validator.subprocess.run
223
+
224
+ def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
225
+ nonlocal subprocess_run_call_count
226
+ subprocess_run_call_count += 1
227
+ return real_subprocess_run(*positional, **keyword)
228
+
229
+ monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
230
+
231
+ second_exit_code, _second_output = validator.run_mypy(
232
+ str(unchanged_module), str(project_root)
233
+ )
234
+
235
+ assert second_exit_code == 0
236
+ assert subprocess_run_call_count == 0
237
+
238
+
239
+ def test_content_hash_skip_invalidated_when_mypy_config_tightens(
240
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
241
+ ) -> None:
242
+ monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False)
243
+ validator = _load_validator()
244
+ project_root = tmp_path / "project"
245
+ project_root.mkdir()
246
+ config_path = project_root / "pyproject.toml"
247
+ config_path.write_text(LOOSE_TOOL_MYPY_PYPROJECT, encoding="utf-8")
248
+ checked_module = project_root / "checked.py"
249
+ checked_module.write_text(UNTYPED_DEF_MODULE_SOURCE, encoding="utf-8")
250
+
251
+ loose_exit_code, _loose_output = validator.run_mypy(
252
+ str(checked_module), str(project_root)
253
+ )
254
+ assert loose_exit_code == 0
255
+
256
+ config_path.write_text(STRICT_TOOL_MYPY_PYPROJECT, encoding="utf-8")
257
+ validator.reset_session_config_cache()
258
+
259
+ subprocess_run_call_count = 0
260
+ real_subprocess_run = validator.subprocess.run
261
+
262
+ def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
263
+ nonlocal subprocess_run_call_count
264
+ subprocess_run_call_count += 1
265
+ return real_subprocess_run(*positional, **keyword)
266
+
267
+ monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
268
+
269
+ tightened_exit_code, tightened_output = validator.run_mypy(
270
+ str(checked_module), str(project_root)
271
+ )
272
+
273
+ assert subprocess_run_call_count == 1, (
274
+ "A tightened mypy config must invalidate the content-hash skip and re-run mypy"
275
+ )
276
+ assert tightened_exit_code != 0
277
+ assert ": error:" in tightened_output