claude-dev-env 1.72.0 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  2. package/bin/install.mjs +73 -5
  3. package/bin/install.test.mjs +360 -4
  4. package/hooks/blocking/CLAUDE.md +3 -1
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  6. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  7. package/hooks/blocking/code_rules_docstrings.py +616 -0
  8. package/hooks/blocking/code_rules_enforcer.py +22 -0
  9. package/hooks/blocking/code_rules_shared.py +19 -0
  10. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  11. package/hooks/blocking/md_to_html_blocker.py +7 -8
  12. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  13. package/hooks/blocking/plain_language_blocker.py +51 -16
  14. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  15. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  16. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  17. package/hooks/blocking/state_description_blocker.py +75 -36
  18. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  22. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  25. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  26. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  27. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  28. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  29. package/hooks/hooks.json +9 -79
  30. package/hooks/hooks_constants/CLAUDE.md +3 -1
  31. package/hooks/hooks_constants/blocking_check_limits.py +61 -0
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  33. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  34. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  35. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  37. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  38. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  39. package/hooks/validation/mypy_validator.py +215 -17
  40. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  41. package/hooks/validation/test_mypy_validator.py +184 -1
  42. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  43. package/hooks/workflow/test_auto_formatter.py +10 -9
  44. package/package.json +1 -1
  45. package/rules/docstring-prose-matches-implementation.md +2 -1
  46. package/skills/autoconverge/SKILL.md +93 -0
  47. package/skills/autoconverge/workflow/converge.mjs +27 -2
  48. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  49. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  50. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -0,0 +1,135 @@
1
+ """Constants for the PreToolUse dispatcher that hosts Write/Edit/MultiEdit hooks.
2
+
3
+ Holds the ordered hosted-hook list with per-hook applicable-tool sets, the
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.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ __all__ = [
13
+ "DENY_DECISION",
14
+ "ALLOW_DECISION",
15
+ "HOOK_EVENT_NAME",
16
+ "BLOCKING_CRASH_EXIT_CODE",
17
+ "EXIT_CODE_TWO_DENY_REASON",
18
+ "WRITE_TOOL_NAME",
19
+ "EDIT_TOOL_NAME",
20
+ "MULTI_EDIT_TOOL_NAME",
21
+ "ALL_WRITE_AND_EDIT_TOOL_NAMES",
22
+ "ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
23
+ "STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
24
+ "PLAIN_LANGUAGE_BLOCKER_MODULE_NAME",
25
+ "HostedHookEntry",
26
+ "ALL_HOSTED_HOOK_ENTRIES",
27
+ ]
28
+
29
+ DENY_DECISION = "deny"
30
+ ALLOW_DECISION = "allow"
31
+ HOOK_EVENT_NAME = "PreToolUse"
32
+ BLOCKING_CRASH_EXIT_CODE = 2
33
+ EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
34
+
35
+ WRITE_TOOL_NAME = "Write"
36
+ EDIT_TOOL_NAME = "Edit"
37
+ MULTI_EDIT_TOOL_NAME = "MultiEdit"
38
+
39
+ ALL_WRITE_AND_EDIT_TOOL_NAMES: frozenset[str] = frozenset({WRITE_TOOL_NAME, EDIT_TOOL_NAME})
40
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
41
+ {WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
42
+ )
43
+
44
+
45
+ STATE_DESCRIPTION_BLOCKER_MODULE_NAME = "state_description_blocker"
46
+ PLAIN_LANGUAGE_BLOCKER_MODULE_NAME = "plain_language_blocker"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class HostedHookEntry:
51
+ """A single hosted hook with its applicable-tools constraint and blocking flag.
52
+
53
+ Attributes:
54
+ script_relative_path: Hook path relative to the hooks/ directory.
55
+ applicable_tool_names: Tool names this hook applies to. The dispatcher
56
+ skips the hook when the payload's tool is not in this set.
57
+ is_blocking: True when a crash surfaces a blocking signal; False when the
58
+ hook is advisory and a crash stays silent.
59
+ native_module_name: The importable module name whose evaluate function
60
+ the dispatcher calls in-process for this hook, or None when the hook
61
+ 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
+ """
65
+
66
+ script_relative_path: str
67
+ applicable_tool_names: frozenset[str]
68
+ is_blocking: bool = field(default=True)
69
+ native_module_name: str | None = field(default=None)
70
+
71
+
72
+ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
73
+ HostedHookEntry(
74
+ script_relative_path="blocking/write_existing_file_blocker.py",
75
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
76
+ ),
77
+ HostedHookEntry(
78
+ script_relative_path="blocking/sensitive_file_protector.py",
79
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
80
+ ),
81
+ HostedHookEntry(
82
+ script_relative_path="validation/hook_format_validator.py",
83
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
84
+ ),
85
+ HostedHookEntry(
86
+ script_relative_path="blocking/code_rules_enforcer.py",
87
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
88
+ ),
89
+ HostedHookEntry(
90
+ script_relative_path="blocking/tdd_enforcer.py",
91
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
92
+ ),
93
+ HostedHookEntry(
94
+ script_relative_path="blocking/windows_rmtree_blocker.py",
95
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
96
+ ),
97
+ HostedHookEntry(
98
+ script_relative_path="blocking/state_description_blocker.py",
99
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
100
+ native_module_name=STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
101
+ ),
102
+ HostedHookEntry(
103
+ script_relative_path="blocking/subprocess_budget_completeness.py",
104
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
105
+ ),
106
+ HostedHookEntry(
107
+ script_relative_path="blocking/hook_prose_detector_consistency.py",
108
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
109
+ ),
110
+ HostedHookEntry(
111
+ script_relative_path="blocking/verified_commit_message_accuracy_blocker.py",
112
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
113
+ ),
114
+ HostedHookEntry(
115
+ script_relative_path="blocking/workflow_substitution_slot_blocker.py",
116
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
117
+ ),
118
+ HostedHookEntry(
119
+ script_relative_path="blocking/claude_md_orphan_file_blocker.py",
120
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
121
+ ),
122
+ HostedHookEntry(
123
+ script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
124
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
125
+ ),
126
+ HostedHookEntry(
127
+ script_relative_path="blocking/open_questions_in_plans_blocker.py",
128
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
129
+ ),
130
+ HostedHookEntry(
131
+ script_relative_path="blocking/plain_language_blocker.py",
132
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
133
+ native_module_name=PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
134
+ ),
135
+ )
@@ -0,0 +1,79 @@
1
+ """Constants for the pytest unregistered-test-directory blocker.
2
+
3
+ A package whose ``pyproject.toml`` declares ``[tool.pytest.ini_options]`` with an
4
+ explicit ``testpaths`` list runs only the directories that list names. A
5
+ ``test_*.py`` file written into a directory that no ``testpaths`` entry covers is
6
+ collected by no default ``pytest`` run, so the test silently never executes and a
7
+ regression in the code it guards passes the standard suite undetected. This
8
+ module holds the marker filename that anchors a pytest package, the key name
9
+ that identifies an explicit ``testpaths`` allowlist, the test-file basename
10
+ pattern, the package-root entry tokens and glob metacharacters that classify a
11
+ ``testpaths`` entry, the directory names the upward search prunes, the search
12
+ budget, and the block-message text the hook emits.
13
+ """
14
+
15
+ import re
16
+
17
+ __all__ = [
18
+ "PYPROJECT_FILENAME",
19
+ "TESTPATHS_KEY",
20
+ "TEST_FILE_BASENAME_PATTERN",
21
+ "PACKAGE_ROOT_ENTRY",
22
+ "PACKAGE_ROOT_ENTRY_PREFIX",
23
+ "GLOB_METACHARACTERS",
24
+ "ALL_PRUNED_PARENT_DIRECTORY_NAMES",
25
+ "MAX_PARENT_DIRECTORIES_SEARCHED",
26
+ "UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE",
27
+ "UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE",
28
+ "UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT",
29
+ ]
30
+
31
+ PYPROJECT_FILENAME: str = "pyproject.toml"
32
+
33
+ TESTPATHS_KEY: str = "testpaths"
34
+
35
+ TEST_FILE_BASENAME_PATTERN: re.Pattern[str] = re.compile(r"^test_.+\.py$")
36
+
37
+ PACKAGE_ROOT_ENTRY: str = "."
38
+
39
+ PACKAGE_ROOT_ENTRY_PREFIX: str = "./"
40
+
41
+ GLOB_METACHARACTERS: frozenset[str] = frozenset({"*", "?", "["})
42
+
43
+ ALL_PRUNED_PARENT_DIRECTORY_NAMES: frozenset[str] = frozenset(
44
+ {
45
+ ".git",
46
+ "__pycache__",
47
+ "node_modules",
48
+ ".pytest_cache",
49
+ ".ruff_cache",
50
+ }
51
+ )
52
+
53
+ MAX_PARENT_DIRECTORIES_SEARCHED: int = 40
54
+
55
+ UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE: str = (
56
+ "Test file {test_file} lands in a directory that the pytest config at "
57
+ "{pyproject} does not collect. That pyproject declares an explicit testpaths "
58
+ "allowlist, and no entry covers {test_directory} (relative to the package "
59
+ "root). A default `pytest` run from the package root never collects this file, "
60
+ "so the test silently never runs and a regression it would catch passes the "
61
+ "suite undetected. Add the directory to the testpaths list in {pyproject} "
62
+ "(for example `{suggested_entry}`) in the same change that adds the test."
63
+ )
64
+
65
+ UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE: str = (
66
+ "test file lands outside the pytest testpaths allowlist - add its directory to "
67
+ "testpaths so the default suite collects it"
68
+ )
69
+
70
+ UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT: str = (
71
+ "When a package's pyproject.toml declares [tool.pytest.ini_options] with an "
72
+ "explicit testpaths list, that list is the complete set of directories a "
73
+ "default pytest run collects. A test_*.py file written into a directory no "
74
+ "testpaths entry covers is collected by nobody: the default run skips it and "
75
+ "the regression it guards goes unnoticed. To resolve:\n"
76
+ " - add the test file's directory (relative to the package root) to the "
77
+ "testpaths list in pyproject.toml, or\n"
78
+ " - move the test under a directory the testpaths list already covers."
79
+ )
@@ -11,6 +11,7 @@ This catches:
11
11
  Works in both WSL and Windows for any Python project with a git root.
12
12
  Project root is discovered via CLAUDE_PROJECT_ROOT env var or git rev-parse.
13
13
  """
14
+ import hashlib
14
15
  import importlib
15
16
  import json
16
17
  import os
@@ -20,10 +21,29 @@ import sys
20
21
  from pathlib import Path
21
22
  from types import ModuleType
22
23
 
23
- NOTIFICATION_UTILS_DIRECTORY = os.path.join(
24
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "notification"
24
+ _hooks_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ _notification_utils_directory = os.path.join(_hooks_directory, "notification")
27
+ sys.path.insert(0, _notification_utils_directory)
28
+
29
+ _validators_directory = os.path.join(_hooks_directory, "validators")
30
+ if _validators_directory not in sys.path:
31
+ sys.path.insert(0, _validators_directory)
32
+
33
+ if _hooks_directory not in sys.path:
34
+ sys.path.insert(0, _hooks_directory)
35
+
36
+ from mypy_integration import find_pyproject_with_mypy_config # noqa: E402
37
+
38
+ from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
39
+ CACHE_FILE_ENCODING,
40
+ CONTENT_HASH_CACHE_PASSING_EXIT_CODE,
41
+ HOOK_STATE_CACHE_DIRECTORY,
42
+ MYPY_CONFIG_CACHE_FILENAME,
43
+ MYPY_CONTENT_HASH_CACHE_FILENAME,
44
+ SESSION_ID_ENVIRONMENT_VARIABLE,
45
+ UNKNOWN_SESSION_IDENTIFIER,
25
46
  )
26
- sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
27
47
 
28
48
 
29
49
  def load_notification_utils() -> ModuleType | None:
@@ -69,6 +89,85 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
69
89
  return False
70
90
 
71
91
 
92
+ _session_config_cache_by_target_directory: dict[str, str | None] = {}
93
+
94
+
95
+ def reset_session_config_cache() -> None:
96
+ """Clear the in-process config-walk cache so the next walk runs fresh.
97
+
98
+ The cache is normally seeded once per target directory per session; tests
99
+ call this between scenarios so a redirected cache directory starts empty.
100
+ """
101
+ _session_config_cache_by_target_directory.clear()
102
+
103
+
104
+ def resolve_session_identifier() -> str:
105
+ """Return the current session identifier for keying per-session caches.
106
+
107
+ Returns:
108
+ The ``CLAUDE_CODE_SESSION_ID`` environment value, or a fixed unknown
109
+ marker when the variable is unset or empty so the cache still has a
110
+ stable key within a single run.
111
+ """
112
+ session_identifier = os.environ.get(SESSION_ID_ENVIRONMENT_VARIABLE, "")
113
+ return session_identifier or UNKNOWN_SESSION_IDENTIFIER
114
+
115
+
116
+ def _session_cache_path(cache_filename: str) -> Path:
117
+ session_identifier = resolve_session_identifier()
118
+ return Path(HOOK_STATE_CACHE_DIRECTORY) / session_identifier / cache_filename
119
+
120
+
121
+ def _read_cache_file(cache_path: Path) -> dict[str, object]:
122
+ if not cache_path.is_file():
123
+ return {}
124
+ try:
125
+ raw_text = cache_path.read_text(encoding=CACHE_FILE_ENCODING)
126
+ except OSError:
127
+ return {}
128
+ if not raw_text.strip():
129
+ return {}
130
+ try:
131
+ parsed_cache = json.loads(raw_text)
132
+ except json.JSONDecodeError:
133
+ return {}
134
+ return parsed_cache if isinstance(parsed_cache, dict) else {}
135
+
136
+
137
+ def _write_cache_file(cache_path: Path, cache_by_key: dict[str, object]) -> None:
138
+ try:
139
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
140
+ cache_path.write_text(
141
+ json.dumps(cache_by_key), encoding=CACHE_FILE_ENCODING
142
+ )
143
+ except OSError:
144
+ return
145
+
146
+
147
+ def _walk_mypy_config(target_file: Path) -> Path | None:
148
+ discovered_config = find_pyproject_with_mypy_config(target_file)
149
+ return discovered_config if isinstance(discovered_config, Path) else None
150
+
151
+
152
+ def _config_cache_key_for(target_file: Path) -> str:
153
+ """Return the cache key for one file's config walk.
154
+
155
+ The walk climbs the ancestors of the target file's own directory, so its
156
+ result is determined by that directory, not by the shared project root. Two
157
+ files in sibling subtrees under one git root each carry their own nearer
158
+ ``[tool.mypy]`` config; keying the cache by the resolved target directory
159
+ keeps each file's walk distinct so the first file checked does not seed the
160
+ second file's config.
161
+
162
+ Args:
163
+ target_file: The Python file mypy will check.
164
+
165
+ Returns:
166
+ The resolved directory of the target file as the config-walk cache key.
167
+ """
168
+ return str(target_file.resolve().parent)
169
+
170
+
72
171
  def discover_mypy_config(target_file: Path) -> Path | None:
73
172
  """Return the nearest ancestor ``pyproject.toml`` that configures mypy.
74
173
 
@@ -76,28 +175,97 @@ def discover_mypy_config(target_file: Path) -> Path | None:
76
175
  is on its invocation path; handing the discovered config to mypy lets a
77
176
  check run from the repository root still honor the project's own import
78
177
  resolution settings (such as ``ignore_missing_imports``) for a module that
79
- imports its siblings by name. Reuses the validators-package walk-up so the
80
- discovery logic lives in one place.
178
+ imports its siblings by name. The discovered config is cached per target
179
+ directory for the session, in process and in a session cache file, so a
180
+ later edit of a file in the same directory reuses the result rather than
181
+ walking ancestors again.
81
182
 
82
183
  Args:
83
- target_file: The Python file mypy will check.
184
+ target_file: The Python file mypy will check; its directory keys the walk.
84
185
 
85
186
  Returns:
86
187
  The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
87
- table, or None when none exists above the file or the walk-up helper
88
- cannot be imported.
188
+ table, or None when none exists above the file.
89
189
  """
90
- validators_directory = os.path.join(
91
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "validators"
92
- )
93
- if validators_directory not in sys.path:
94
- sys.path.insert(0, validators_directory)
190
+ cache_key = _config_cache_key_for(target_file)
191
+ if cache_key in _session_config_cache_by_target_directory:
192
+ cached_value = _session_config_cache_by_target_directory[cache_key]
193
+ return Path(cached_value) if cached_value is not None else None
194
+
195
+ config_cache_path = _session_cache_path(MYPY_CONFIG_CACHE_FILENAME)
196
+ persisted_cache = _read_cache_file(config_cache_path)
197
+ if cache_key in persisted_cache:
198
+ persisted_value = persisted_cache[cache_key]
199
+ resolved_persisted = persisted_value if isinstance(persisted_value, str) else None
200
+ _session_config_cache_by_target_directory[cache_key] = resolved_persisted
201
+ return Path(resolved_persisted) if resolved_persisted is not None else None
202
+
203
+ discovered_config = _walk_mypy_config(target_file)
204
+ discovered_value = str(discovered_config) if discovered_config is not None else None
205
+ _session_config_cache_by_target_directory[cache_key] = discovered_value
206
+ persisted_cache[cache_key] = discovered_value
207
+ _write_cache_file(config_cache_path, persisted_cache)
208
+ return discovered_config
209
+
210
+
211
+ def _config_signature(mypy_config_file: Path | None) -> bytes:
212
+ """Return a byte signature of the discovered mypy config's current contents.
213
+
214
+ The signature folds the config file's own bytes into the content-hash cache
215
+ key so a change to the project's ``[tool.mypy]`` settings invalidates a
216
+ previously recorded passing hash: when the file's bytes are restored to a
217
+ prior passing version under a tightened config, the composite hash differs
218
+ and mypy re-runs rather than returning a stale pass. An absent config
219
+ contributes a fixed empty signature.
220
+
221
+ Args:
222
+ mypy_config_file: The discovered config path, or None when none exists.
223
+
224
+ Returns:
225
+ The config file's bytes, or an empty signature when there is no config
226
+ or it cannot be read.
227
+ """
228
+ if mypy_config_file is None:
229
+ return b""
95
230
  try:
96
- integration_module = importlib.import_module("mypy_integration")
97
- except ImportError:
231
+ return mypy_config_file.read_bytes()
232
+ except OSError:
233
+ return b""
234
+
235
+
236
+ def _composite_content_hash(target_file: str, mypy_config_file: Path | None) -> str | None:
237
+ """Return a hash over the target file's bytes and its mypy config's bytes.
238
+
239
+ Args:
240
+ target_file: The absolute path of the file to type-check.
241
+ mypy_config_file: The discovered mypy config path, or None.
242
+
243
+ Returns:
244
+ The combined hash, or None when the target file cannot be read.
245
+ """
246
+ try:
247
+ file_bytes = Path(target_file).read_bytes()
248
+ except OSError:
98
249
  return None
99
- discovered_config = integration_module.find_pyproject_with_mypy_config(target_file)
100
- return discovered_config if isinstance(discovered_config, Path) else None
250
+ hasher = hashlib.sha256()
251
+ hasher.update(file_bytes)
252
+ hasher.update(_config_signature(mypy_config_file))
253
+ return hasher.hexdigest()
254
+
255
+
256
+ def _read_cached_passing_hash(target_file: str) -> str | None:
257
+ content_hash_cache = _read_cache_file(
258
+ _session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
259
+ )
260
+ cached_hash = content_hash_cache.get(target_file)
261
+ return cached_hash if isinstance(cached_hash, str) else None
262
+
263
+
264
+ def _record_passing_hash(target_file: str, content_hash: str) -> None:
265
+ content_hash_cache_path = _session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
266
+ content_hash_cache = _read_cache_file(content_hash_cache_path)
267
+ content_hash_cache[target_file] = content_hash
268
+ _write_cache_file(content_hash_cache_path, content_hash_cache)
101
269
 
102
270
 
103
271
  def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
@@ -128,6 +296,28 @@ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -
128
296
  def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
129
297
  """Run mypy on one file from the project root and return its result.
130
298
 
299
+ The mypy run is skipped when a composite hash over the target file's bytes
300
+ and its discovered mypy config's bytes matches the hash recorded the last
301
+ time mypy passed for that file; that recorded skip can only return a pass, so
302
+ a content change always re-runs mypy and a file edited to introduce a type
303
+ error still blocks. Folding the config bytes into the hash invalidates the
304
+ skip when the project's ``[tool.mypy]`` settings change, so a file whose
305
+ bytes are restored to a prior passing version under a tightened config
306
+ re-runs rather than returning a stale pass. The discovered config is reused
307
+ from the per-session cache keyed by the target file's own directory, so two
308
+ files in sibling subtrees under one project root each resolve their own
309
+ nearer config.
310
+
311
+ The composite hash covers the target file's own bytes and its config's
312
+ bytes only, so the skip is blind to a cross-file change in a dependency:
313
+ when a dependency is edited in a way that breaks this file's call site and
314
+ this file is later rewritten to its prior passing content, the cached pass
315
+ returns without re-running mypy. The post-write hook already type-checks only
316
+ the single edited file, so a dependent is never re-checked on the
317
+ dependency's own edit regardless of the cache; the cache adds only the
318
+ identical-rewrite-under-unchanged-config skip on top of that existing
319
+ single-file scope.
320
+
131
321
  Args:
132
322
  target_file: The absolute path of the file to type-check.
133
323
  project_root: The directory mypy runs from.
@@ -137,6 +327,11 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
137
327
  """
138
328
  relative_file_path = os.path.relpath(target_file, project_root)
139
329
  mypy_config_file = discover_mypy_config(Path(target_file))
330
+
331
+ content_hash = _composite_content_hash(target_file, mypy_config_file)
332
+ if content_hash is not None and content_hash == _read_cached_passing_hash(target_file):
333
+ return CONTENT_HASH_CACHE_PASSING_EXIT_CODE, ""
334
+
140
335
  mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
141
336
 
142
337
  completed_process = subprocess.run(
@@ -152,6 +347,9 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
152
347
  stderr_output = completed_process.stderr.strip()
153
348
  combined_output = f"{stdout_output}\n{stderr_output}".strip() if stderr_output else stdout_output
154
349
 
350
+ if completed_process.returncode == CONTENT_HASH_CACHE_PASSING_EXIT_CODE and content_hash is not None:
351
+ _record_passing_hash(target_file, content_hash)
352
+
155
353
  return completed_process.returncode, combined_output
156
354
 
157
355