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.
- package/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -7,7 +7,13 @@ Blocks if hooks use simple 'python3 ~/.claude/...' instead of the exec(open(...)
|
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
|
+
from pathlib import Path
|
|
10
11
|
|
|
12
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
13
|
+
if _hooks_dir not in sys.path:
|
|
14
|
+
sys.path.insert(0, _hooks_dir)
|
|
15
|
+
|
|
16
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
11
17
|
|
|
12
18
|
SIMPLE_PATTERN = re.compile(
|
|
13
19
|
r'python3?\s+~/\.claude/hooks/'
|
|
@@ -56,6 +62,13 @@ def main() -> None:
|
|
|
56
62
|
"permissionDecisionReason": message
|
|
57
63
|
}
|
|
58
64
|
}
|
|
65
|
+
log_hook_block(
|
|
66
|
+
calling_hook_name="hook_format_validator.py",
|
|
67
|
+
hook_event="PreToolUse",
|
|
68
|
+
block_reason=message,
|
|
69
|
+
tool_name=tool_name,
|
|
70
|
+
offending_input_preview=file_path,
|
|
71
|
+
)
|
|
59
72
|
print(json.dumps(result))
|
|
60
73
|
sys.exit(0)
|
|
61
74
|
|
|
@@ -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,30 @@ import sys
|
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from types import ModuleType
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
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.hook_block_logger import log_hook_block # noqa: E402
|
|
39
|
+
from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
|
|
40
|
+
CACHE_FILE_ENCODING,
|
|
41
|
+
CONTENT_HASH_CACHE_PASSING_EXIT_CODE,
|
|
42
|
+
HOOK_STATE_CACHE_DIRECTORY,
|
|
43
|
+
MYPY_CONFIG_CACHE_FILENAME,
|
|
44
|
+
MYPY_CONTENT_HASH_CACHE_FILENAME,
|
|
45
|
+
SESSION_ID_ENVIRONMENT_VARIABLE,
|
|
46
|
+
UNKNOWN_SESSION_IDENTIFIER,
|
|
25
47
|
)
|
|
26
|
-
sys.path.insert(0, NOTIFICATION_UTILS_DIRECTORY)
|
|
27
48
|
|
|
28
49
|
|
|
29
50
|
def load_notification_utils() -> ModuleType | None:
|
|
@@ -69,6 +90,85 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
|
|
|
69
90
|
return False
|
|
70
91
|
|
|
71
92
|
|
|
93
|
+
_session_config_cache_by_target_directory: dict[str, str | None] = {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reset_session_config_cache() -> None:
|
|
97
|
+
"""Clear the in-process config-walk cache so the next walk runs fresh.
|
|
98
|
+
|
|
99
|
+
The cache is normally seeded once per target directory per session; tests
|
|
100
|
+
call this between scenarios so a redirected cache directory starts empty.
|
|
101
|
+
"""
|
|
102
|
+
_session_config_cache_by_target_directory.clear()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_session_identifier() -> str:
|
|
106
|
+
"""Return the current session identifier for keying per-session caches.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The ``CLAUDE_CODE_SESSION_ID`` environment value, or a fixed unknown
|
|
110
|
+
marker when the variable is unset or empty so the cache still has a
|
|
111
|
+
stable key within a single run.
|
|
112
|
+
"""
|
|
113
|
+
session_identifier = os.environ.get(SESSION_ID_ENVIRONMENT_VARIABLE, "")
|
|
114
|
+
return session_identifier or UNKNOWN_SESSION_IDENTIFIER
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _session_cache_path(cache_filename: str) -> Path:
|
|
118
|
+
session_identifier = resolve_session_identifier()
|
|
119
|
+
return Path(HOOK_STATE_CACHE_DIRECTORY) / session_identifier / cache_filename
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_cache_file(cache_path: Path) -> dict[str, object]:
|
|
123
|
+
if not cache_path.is_file():
|
|
124
|
+
return {}
|
|
125
|
+
try:
|
|
126
|
+
raw_text = cache_path.read_text(encoding=CACHE_FILE_ENCODING)
|
|
127
|
+
except OSError:
|
|
128
|
+
return {}
|
|
129
|
+
if not raw_text.strip():
|
|
130
|
+
return {}
|
|
131
|
+
try:
|
|
132
|
+
parsed_cache = json.loads(raw_text)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
return {}
|
|
135
|
+
return parsed_cache if isinstance(parsed_cache, dict) else {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _write_cache_file(cache_path: Path, cache_by_key: dict[str, object]) -> None:
|
|
139
|
+
try:
|
|
140
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
cache_path.write_text(
|
|
142
|
+
json.dumps(cache_by_key), encoding=CACHE_FILE_ENCODING
|
|
143
|
+
)
|
|
144
|
+
except OSError:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _walk_mypy_config(target_file: Path) -> Path | None:
|
|
149
|
+
discovered_config = find_pyproject_with_mypy_config(target_file)
|
|
150
|
+
return discovered_config if isinstance(discovered_config, Path) else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _config_cache_key_for(target_file: Path) -> str:
|
|
154
|
+
"""Return the cache key for one file's config walk.
|
|
155
|
+
|
|
156
|
+
The walk climbs the ancestors of the target file's own directory, so its
|
|
157
|
+
result is determined by that directory, not by the shared project root. Two
|
|
158
|
+
files in sibling subtrees under one git root each carry their own nearer
|
|
159
|
+
``[tool.mypy]`` config; keying the cache by the resolved target directory
|
|
160
|
+
keeps each file's walk distinct so the first file checked does not seed the
|
|
161
|
+
second file's config.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
target_file: The Python file mypy will check.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The resolved directory of the target file as the config-walk cache key.
|
|
168
|
+
"""
|
|
169
|
+
return str(target_file.resolve().parent)
|
|
170
|
+
|
|
171
|
+
|
|
72
172
|
def discover_mypy_config(target_file: Path) -> Path | None:
|
|
73
173
|
"""Return the nearest ancestor ``pyproject.toml`` that configures mypy.
|
|
74
174
|
|
|
@@ -76,28 +176,97 @@ def discover_mypy_config(target_file: Path) -> Path | None:
|
|
|
76
176
|
is on its invocation path; handing the discovered config to mypy lets a
|
|
77
177
|
check run from the repository root still honor the project's own import
|
|
78
178
|
resolution settings (such as ``ignore_missing_imports``) for a module that
|
|
79
|
-
imports its siblings by name.
|
|
80
|
-
|
|
179
|
+
imports its siblings by name. The discovered config is cached per target
|
|
180
|
+
directory for the session, in process and in a session cache file, so a
|
|
181
|
+
later edit of a file in the same directory reuses the result rather than
|
|
182
|
+
walking ancestors again.
|
|
81
183
|
|
|
82
184
|
Args:
|
|
83
|
-
target_file: The Python file mypy will check.
|
|
185
|
+
target_file: The Python file mypy will check; its directory keys the walk.
|
|
84
186
|
|
|
85
187
|
Returns:
|
|
86
188
|
The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
|
|
87
|
-
table, or None when none exists above the file
|
|
88
|
-
cannot be imported.
|
|
189
|
+
table, or None when none exists above the file.
|
|
89
190
|
"""
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
191
|
+
cache_key = _config_cache_key_for(target_file)
|
|
192
|
+
if cache_key in _session_config_cache_by_target_directory:
|
|
193
|
+
cached_value = _session_config_cache_by_target_directory[cache_key]
|
|
194
|
+
return Path(cached_value) if cached_value is not None else None
|
|
195
|
+
|
|
196
|
+
config_cache_path = _session_cache_path(MYPY_CONFIG_CACHE_FILENAME)
|
|
197
|
+
persisted_cache = _read_cache_file(config_cache_path)
|
|
198
|
+
if cache_key in persisted_cache:
|
|
199
|
+
persisted_value = persisted_cache[cache_key]
|
|
200
|
+
resolved_persisted = persisted_value if isinstance(persisted_value, str) else None
|
|
201
|
+
_session_config_cache_by_target_directory[cache_key] = resolved_persisted
|
|
202
|
+
return Path(resolved_persisted) if resolved_persisted is not None else None
|
|
203
|
+
|
|
204
|
+
discovered_config = _walk_mypy_config(target_file)
|
|
205
|
+
discovered_value = str(discovered_config) if discovered_config is not None else None
|
|
206
|
+
_session_config_cache_by_target_directory[cache_key] = discovered_value
|
|
207
|
+
persisted_cache[cache_key] = discovered_value
|
|
208
|
+
_write_cache_file(config_cache_path, persisted_cache)
|
|
209
|
+
return discovered_config
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _config_signature(mypy_config_file: Path | None) -> bytes:
|
|
213
|
+
"""Return a byte signature of the discovered mypy config's current contents.
|
|
214
|
+
|
|
215
|
+
The signature folds the config file's own bytes into the content-hash cache
|
|
216
|
+
key so a change to the project's ``[tool.mypy]`` settings invalidates a
|
|
217
|
+
previously recorded passing hash: when the file's bytes are restored to a
|
|
218
|
+
prior passing version under a tightened config, the composite hash differs
|
|
219
|
+
and mypy re-runs rather than returning a stale pass. An absent config
|
|
220
|
+
contributes a fixed empty signature.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
mypy_config_file: The discovered config path, or None when none exists.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The config file's bytes, or an empty signature when there is no config
|
|
227
|
+
or it cannot be read.
|
|
228
|
+
"""
|
|
229
|
+
if mypy_config_file is None:
|
|
230
|
+
return b""
|
|
95
231
|
try:
|
|
96
|
-
|
|
97
|
-
except
|
|
232
|
+
return mypy_config_file.read_bytes()
|
|
233
|
+
except OSError:
|
|
234
|
+
return b""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _composite_content_hash(target_file: str, mypy_config_file: Path | None) -> str | None:
|
|
238
|
+
"""Return a hash over the target file's bytes and its mypy config's bytes.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
target_file: The absolute path of the file to type-check.
|
|
242
|
+
mypy_config_file: The discovered mypy config path, or None.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The combined hash, or None when the target file cannot be read.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
file_bytes = Path(target_file).read_bytes()
|
|
249
|
+
except OSError:
|
|
98
250
|
return None
|
|
99
|
-
|
|
100
|
-
|
|
251
|
+
hasher = hashlib.sha256()
|
|
252
|
+
hasher.update(file_bytes)
|
|
253
|
+
hasher.update(_config_signature(mypy_config_file))
|
|
254
|
+
return hasher.hexdigest()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _read_cached_passing_hash(target_file: str) -> str | None:
|
|
258
|
+
content_hash_cache = _read_cache_file(
|
|
259
|
+
_session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
|
|
260
|
+
)
|
|
261
|
+
cached_hash = content_hash_cache.get(target_file)
|
|
262
|
+
return cached_hash if isinstance(cached_hash, str) else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _record_passing_hash(target_file: str, content_hash: str) -> None:
|
|
266
|
+
content_hash_cache_path = _session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
|
|
267
|
+
content_hash_cache = _read_cache_file(content_hash_cache_path)
|
|
268
|
+
content_hash_cache[target_file] = content_hash
|
|
269
|
+
_write_cache_file(content_hash_cache_path, content_hash_cache)
|
|
101
270
|
|
|
102
271
|
|
|
103
272
|
def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
|
|
@@ -125,9 +294,53 @@ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -
|
|
|
125
294
|
]
|
|
126
295
|
|
|
127
296
|
|
|
297
|
+
def project_relative_path(target_file: str, project_root: str) -> str:
|
|
298
|
+
"""Return *target_file* relative to *project_root*, or its absolute path.
|
|
299
|
+
|
|
300
|
+
On Windows ``os.path.relpath`` raises ``ValueError`` when the two paths sit
|
|
301
|
+
on different mounts (for example a ``Y:`` drive file against a project root
|
|
302
|
+
that resolved to its backing UNC share), so no relative path can span them.
|
|
303
|
+
The absolute target path is then handed to mypy unchanged, which accepts it.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
target_file: The absolute path of the file to type-check.
|
|
307
|
+
project_root: The directory mypy runs from.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
The path relative to *project_root* when one exists, otherwise the
|
|
311
|
+
absolute path of *target_file*.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
return os.path.relpath(target_file, project_root)
|
|
315
|
+
except ValueError:
|
|
316
|
+
return os.path.abspath(target_file)
|
|
317
|
+
|
|
318
|
+
|
|
128
319
|
def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
129
320
|
"""Run mypy on one file from the project root and return its result.
|
|
130
321
|
|
|
322
|
+
The mypy run is skipped when a composite hash over the target file's bytes
|
|
323
|
+
and its discovered mypy config's bytes matches the hash recorded the last
|
|
324
|
+
time mypy passed for that file; that recorded skip can only return a pass, so
|
|
325
|
+
a content change always re-runs mypy and a file edited to introduce a type
|
|
326
|
+
error still blocks. Folding the config bytes into the hash invalidates the
|
|
327
|
+
skip when the project's ``[tool.mypy]`` settings change, so a file whose
|
|
328
|
+
bytes are restored to a prior passing version under a tightened config
|
|
329
|
+
re-runs rather than returning a stale pass. The discovered config is reused
|
|
330
|
+
from the per-session cache keyed by the target file's own directory, so two
|
|
331
|
+
files in sibling subtrees under one project root each resolve their own
|
|
332
|
+
nearer config.
|
|
333
|
+
|
|
334
|
+
The composite hash covers the target file's own bytes and its config's
|
|
335
|
+
bytes only, so the skip is blind to a cross-file change in a dependency:
|
|
336
|
+
when a dependency is edited in a way that breaks this file's call site and
|
|
337
|
+
this file is later rewritten to its prior passing content, the cached pass
|
|
338
|
+
returns without re-running mypy. The post-write hook already type-checks only
|
|
339
|
+
the single edited file, so a dependent is never re-checked on the
|
|
340
|
+
dependency's own edit regardless of the cache; the cache adds only the
|
|
341
|
+
identical-rewrite-under-unchanged-config skip on top of that existing
|
|
342
|
+
single-file scope.
|
|
343
|
+
|
|
131
344
|
Args:
|
|
132
345
|
target_file: The absolute path of the file to type-check.
|
|
133
346
|
project_root: The directory mypy runs from.
|
|
@@ -135,8 +348,13 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
|
135
348
|
Returns:
|
|
136
349
|
The mypy exit code paired with its combined stdout and stderr text.
|
|
137
350
|
"""
|
|
138
|
-
relative_file_path =
|
|
351
|
+
relative_file_path = project_relative_path(target_file, project_root)
|
|
139
352
|
mypy_config_file = discover_mypy_config(Path(target_file))
|
|
353
|
+
|
|
354
|
+
content_hash = _composite_content_hash(target_file, mypy_config_file)
|
|
355
|
+
if content_hash is not None and content_hash == _read_cached_passing_hash(target_file):
|
|
356
|
+
return CONTENT_HASH_CACHE_PASSING_EXIT_CODE, ""
|
|
357
|
+
|
|
140
358
|
mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
|
|
141
359
|
|
|
142
360
|
completed_process = subprocess.run(
|
|
@@ -152,6 +370,9 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
|
152
370
|
stderr_output = completed_process.stderr.strip()
|
|
153
371
|
combined_output = f"{stdout_output}\n{stderr_output}".strip() if stderr_output else stdout_output
|
|
154
372
|
|
|
373
|
+
if completed_process.returncode == CONTENT_HASH_CACHE_PASSING_EXIT_CODE and content_hash is not None:
|
|
374
|
+
_record_passing_hash(target_file, content_hash)
|
|
375
|
+
|
|
155
376
|
return completed_process.returncode, combined_output
|
|
156
377
|
|
|
157
378
|
|
|
@@ -259,6 +480,12 @@ def main() -> None:
|
|
|
259
480
|
error_summary = format_error_summary(all_error_lines)
|
|
260
481
|
send_block_notification(error_summary)
|
|
261
482
|
block_response = build_block_response(error_summary)
|
|
483
|
+
log_hook_block(
|
|
484
|
+
calling_hook_name="mypy_validator.py",
|
|
485
|
+
hook_event="PostToolUse",
|
|
486
|
+
block_reason=f"[MYPY] Type errors: {error_summary}",
|
|
487
|
+
offending_input_preview=target_file_path,
|
|
488
|
+
)
|
|
262
489
|
print(json.dumps(block_response))
|
|
263
490
|
sys.exit(0)
|
|
264
491
|
|