claude-dev-env 2.19.0 → 2.21.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/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +109 -11
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/docs/CODE_RULES.md +2 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +95 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +3 -3
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- package/scripts/codex_compat_materializer.py +395 -17
- package/scripts/tests/test_codex_compat_materializer.py +17 -3
|
@@ -11,11 +11,13 @@ 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
|
+
|
|
14
15
|
import hashlib
|
|
15
16
|
import importlib
|
|
16
17
|
import json
|
|
17
18
|
import os
|
|
18
19
|
import platform
|
|
20
|
+
import stat
|
|
19
21
|
import subprocess
|
|
20
22
|
import sys
|
|
21
23
|
from pathlib import Path
|
|
@@ -33,9 +35,17 @@ if _validators_directory not in sys.path:
|
|
|
33
35
|
if _hooks_directory not in sys.path:
|
|
34
36
|
sys.path.insert(0, _hooks_directory)
|
|
35
37
|
|
|
36
|
-
from mypy_integration import
|
|
38
|
+
from mypy_integration import ( # noqa: E402
|
|
39
|
+
ancestor_directories,
|
|
40
|
+
find_pyproject_with_mypy_config,
|
|
41
|
+
)
|
|
37
42
|
|
|
43
|
+
from atomic_file_writer import write_text_atomically # noqa: E402
|
|
44
|
+
from hooks_constants.atomic_file_writer_constants import ( # noqa: E402
|
|
45
|
+
ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
46
|
+
)
|
|
38
47
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
48
|
+
from hooks_constants.mypy_integration_constants import PYPROJECT_FILENAME # noqa: E402
|
|
39
49
|
from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
|
|
40
50
|
CACHE_FILE_ENCODING,
|
|
41
51
|
CONTENT_HASH_CACHE_PASSING_EXIT_CODE,
|
|
@@ -45,6 +55,7 @@ from hooks_constants.mypy_validator_cache_constants import ( # noqa: E402
|
|
|
45
55
|
SESSION_ID_ENVIRONMENT_VARIABLE,
|
|
46
56
|
UNKNOWN_SESSION_IDENTIFIER,
|
|
47
57
|
)
|
|
58
|
+
from json_file_reader import read_json_object # noqa: E402
|
|
48
59
|
|
|
49
60
|
|
|
50
61
|
def load_notification_utils() -> ModuleType | None:
|
|
@@ -62,6 +73,8 @@ MAXIMUM_DISPLAYED_ERRORS = 5
|
|
|
62
73
|
|
|
63
74
|
SKIP_PATTERNS = {"test_", "_test.", "conftest", "/tests/", "\\tests\\", "fixture", "mock"}
|
|
64
75
|
|
|
76
|
+
ConfigCacheEntry = tuple[str, str | None, tuple[str, ...], str | None]
|
|
77
|
+
|
|
65
78
|
|
|
66
79
|
def discover_project_root(target_file: str) -> Path | None:
|
|
67
80
|
if env_root := os.environ.get("CLAUDE_PROJECT_ROOT"):
|
|
@@ -70,7 +83,8 @@ def discover_project_root(target_file: str) -> Path | None:
|
|
|
70
83
|
try:
|
|
71
84
|
completed_process = subprocess.run(
|
|
72
85
|
["git", "rev-parse", "--show-toplevel"],
|
|
73
|
-
check=False,
|
|
86
|
+
check=False,
|
|
87
|
+
capture_output=True,
|
|
74
88
|
text=True,
|
|
75
89
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
76
90
|
cwd=str(Path(target_file).parent),
|
|
@@ -90,7 +104,7 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
|
|
|
90
104
|
return False
|
|
91
105
|
|
|
92
106
|
|
|
93
|
-
_session_config_cache_by_target_directory: dict[str,
|
|
107
|
+
_session_config_cache_by_target_directory: dict[str, ConfigCacheEntry] = {}
|
|
94
108
|
|
|
95
109
|
|
|
96
110
|
def reset_session_config_cache() -> None:
|
|
@@ -119,35 +133,96 @@ def _session_cache_path(cache_filename: str) -> Path:
|
|
|
119
133
|
return Path(HOOK_STATE_CACHE_DIRECTORY) / session_identifier / cache_filename
|
|
120
134
|
|
|
121
135
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
def _walk_mypy_config(target_file: Path) -> Path | None:
|
|
137
|
+
discovered_config = find_pyproject_with_mypy_config(target_file)
|
|
138
|
+
return discovered_config if isinstance(discovered_config, Path) else None
|
|
136
139
|
|
|
137
140
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
144
|
-
except OSError:
|
|
145
|
-
return
|
|
141
|
+
def _config_candidate_paths(target_file: Path) -> tuple[str, ...]:
|
|
142
|
+
return tuple(
|
|
143
|
+
str(each_directory / PYPROJECT_FILENAME)
|
|
144
|
+
for each_directory in ancestor_directories(target_file)
|
|
145
|
+
)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
def
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
def _configuration_metadata_signature(all_candidate_paths: tuple[str, ...]) -> str:
|
|
149
|
+
config_search_hasher = hashlib.sha256()
|
|
150
|
+
for each_candidate_path in all_candidate_paths:
|
|
151
|
+
config_search_hasher.update(each_candidate_path.encode(CACHE_FILE_ENCODING))
|
|
152
|
+
try:
|
|
153
|
+
candidate_stat = Path(each_candidate_path).stat()
|
|
154
|
+
except OSError:
|
|
155
|
+
config_search_hasher.update(b"missing")
|
|
156
|
+
continue
|
|
157
|
+
if not stat.S_ISREG(candidate_stat.st_mode):
|
|
158
|
+
config_search_hasher.update(b"non-file")
|
|
159
|
+
continue
|
|
160
|
+
config_search_hasher.update(b"file")
|
|
161
|
+
for each_metadata_field in (
|
|
162
|
+
candidate_stat.st_ino,
|
|
163
|
+
candidate_stat.st_size,
|
|
164
|
+
candidate_stat.st_mtime_ns,
|
|
165
|
+
candidate_stat.st_ctime_ns,
|
|
166
|
+
):
|
|
167
|
+
config_search_hasher.update(str(each_metadata_field).encode(CACHE_FILE_ENCODING))
|
|
168
|
+
config_search_hasher.update(b";")
|
|
169
|
+
return config_search_hasher.hexdigest()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _configuration_content_digest(
|
|
173
|
+
config_path: str | None, all_candidate_paths: tuple[str, ...]
|
|
174
|
+
) -> str | None:
|
|
175
|
+
paths_to_fingerprint = (config_path,) if config_path is not None else all_candidate_paths
|
|
176
|
+
content_hasher = hashlib.sha256()
|
|
177
|
+
for each_config_path in paths_to_fingerprint:
|
|
178
|
+
content_hasher.update(each_config_path.encode(CACHE_FILE_ENCODING))
|
|
179
|
+
try:
|
|
180
|
+
content_hasher.update(Path(each_config_path).read_bytes())
|
|
181
|
+
except OSError:
|
|
182
|
+
content_hasher.update(b"unreadable")
|
|
183
|
+
return content_hasher.hexdigest()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_cached_config_entry(cached_entry: object) -> ConfigCacheEntry | None:
|
|
187
|
+
if not isinstance(cached_entry, dict):
|
|
188
|
+
return None
|
|
189
|
+
cached_signature = cached_entry.get("signature")
|
|
190
|
+
cached_config_path = cached_entry.get("config_path")
|
|
191
|
+
raw_candidate_paths = cached_entry.get("candidate_paths")
|
|
192
|
+
cached_content_digest = cached_entry.get("content_digest")
|
|
193
|
+
if not isinstance(cached_signature, str):
|
|
194
|
+
return None
|
|
195
|
+
if cached_config_path is not None and not isinstance(cached_config_path, str):
|
|
196
|
+
return None
|
|
197
|
+
if cached_content_digest is not None and not isinstance(cached_content_digest, str):
|
|
198
|
+
return None
|
|
199
|
+
if not isinstance(raw_candidate_paths, list):
|
|
200
|
+
return None
|
|
201
|
+
all_candidate_paths: list[str] = []
|
|
202
|
+
for each_candidate_path in raw_candidate_paths:
|
|
203
|
+
if not isinstance(each_candidate_path, str):
|
|
204
|
+
return None
|
|
205
|
+
all_candidate_paths.append(each_candidate_path)
|
|
206
|
+
if not all_candidate_paths:
|
|
207
|
+
return None
|
|
208
|
+
if cached_config_path is not None and cached_config_path not in all_candidate_paths:
|
|
209
|
+
return None
|
|
210
|
+
return (
|
|
211
|
+
cached_signature,
|
|
212
|
+
cached_config_path,
|
|
213
|
+
tuple(all_candidate_paths),
|
|
214
|
+
cached_content_digest,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _serialize_config_cache_entry(cache_entry: ConfigCacheEntry) -> dict[str, object]:
|
|
219
|
+
config_signature, config_path, all_candidate_paths, content_digest = cache_entry
|
|
220
|
+
return {
|
|
221
|
+
"signature": config_signature,
|
|
222
|
+
"config_path": config_path,
|
|
223
|
+
"candidate_paths": list(all_candidate_paths),
|
|
224
|
+
"content_digest": content_digest,
|
|
225
|
+
}
|
|
151
226
|
|
|
152
227
|
|
|
153
228
|
def _config_cache_key_for(target_file: Path) -> str:
|
|
@@ -189,49 +264,88 @@ def discover_mypy_config(target_file: Path) -> Path | None:
|
|
|
189
264
|
table, or None when none exists above the file.
|
|
190
265
|
"""
|
|
191
266
|
cache_key = _config_cache_key_for(target_file)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
267
|
+
cached_entry = _session_config_cache_by_target_directory.get(cache_key)
|
|
268
|
+
if cached_entry is not None:
|
|
269
|
+
config_search_signature = _configuration_metadata_signature(cached_entry[2])
|
|
270
|
+
current_content_digest = _configuration_content_digest(cached_entry[1], cached_entry[2])
|
|
271
|
+
is_content_unchanged = current_content_digest == cached_entry[3]
|
|
272
|
+
if config_search_signature == cached_entry[0] and is_content_unchanged:
|
|
273
|
+
cached_config_path = cached_entry[1]
|
|
274
|
+
return Path(cached_config_path) if cached_config_path is not None else None
|
|
275
|
+
all_candidate_paths = cached_entry[2]
|
|
276
|
+
config_cache_path = _session_cache_path(MYPY_CONFIG_CACHE_FILENAME)
|
|
277
|
+
persisted_cache = read_json_object(config_cache_path, encoding=CACHE_FILE_ENCODING) or {}
|
|
278
|
+
else:
|
|
279
|
+
config_cache_path = _session_cache_path(MYPY_CONFIG_CACHE_FILENAME)
|
|
280
|
+
persisted_cache = read_json_object(config_cache_path, encoding=CACHE_FILE_ENCODING) or {}
|
|
281
|
+
persisted_entry = _read_cached_config_entry(persisted_cache.get(cache_key))
|
|
282
|
+
if persisted_entry is not None:
|
|
283
|
+
config_search_signature = _configuration_metadata_signature(persisted_entry[2])
|
|
284
|
+
current_content_digest = _configuration_content_digest(
|
|
285
|
+
persisted_entry[1], persisted_entry[2]
|
|
286
|
+
)
|
|
287
|
+
is_content_unchanged = current_content_digest == persisted_entry[3]
|
|
288
|
+
if config_search_signature == persisted_entry[0] and is_content_unchanged:
|
|
289
|
+
_session_config_cache_by_target_directory[cache_key] = persisted_entry
|
|
290
|
+
cached_config_path = persisted_entry[1]
|
|
291
|
+
return Path(cached_config_path) if cached_config_path is not None else None
|
|
292
|
+
all_candidate_paths = persisted_entry[2]
|
|
293
|
+
else:
|
|
294
|
+
all_candidate_paths = _config_candidate_paths(target_file)
|
|
295
|
+
config_search_signature = _configuration_metadata_signature(all_candidate_paths)
|
|
203
296
|
|
|
204
297
|
discovered_config = _walk_mypy_config(target_file)
|
|
205
298
|
discovered_value = str(discovered_config) if discovered_config is not None else None
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
299
|
+
content_digest = _configuration_content_digest(discovered_value, all_candidate_paths)
|
|
300
|
+
refreshed_entry = (
|
|
301
|
+
config_search_signature,
|
|
302
|
+
discovered_value,
|
|
303
|
+
all_candidate_paths,
|
|
304
|
+
content_digest,
|
|
305
|
+
)
|
|
306
|
+
_session_config_cache_by_target_directory[cache_key] = refreshed_entry
|
|
307
|
+
persisted_cache[cache_key] = _serialize_config_cache_entry(refreshed_entry)
|
|
308
|
+
try:
|
|
309
|
+
write_text_atomically(
|
|
310
|
+
config_cache_path,
|
|
311
|
+
json.dumps(persisted_cache),
|
|
312
|
+
encoding=CACHE_FILE_ENCODING,
|
|
313
|
+
temporary_prefix=f".{config_cache_path.name}-",
|
|
314
|
+
temporary_suffix=ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
315
|
+
should_reap_orphans=True,
|
|
316
|
+
)
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
209
319
|
return discovered_config
|
|
210
320
|
|
|
211
321
|
|
|
212
322
|
def _config_signature(mypy_config_file: Path | None) -> bytes:
|
|
213
323
|
"""Return a byte signature of the discovered mypy config's current contents.
|
|
214
324
|
|
|
215
|
-
The signature folds the config file's
|
|
216
|
-
|
|
217
|
-
previously recorded passing hash
|
|
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
|
|
325
|
+
The signature folds the resolved config path and the config file's bytes
|
|
326
|
+
into the content-hash cache key so a configuration relocation or change
|
|
327
|
+
invalidates a previously recorded passing hash. An absent config
|
|
220
328
|
contributes a fixed empty signature.
|
|
221
329
|
|
|
222
330
|
Args:
|
|
223
331
|
mypy_config_file: The discovered config path, or None when none exists.
|
|
224
332
|
|
|
225
333
|
Returns:
|
|
226
|
-
The config
|
|
227
|
-
|
|
334
|
+
The resolved config path and bytes, or the path alone when the config
|
|
335
|
+
cannot be read.
|
|
228
336
|
"""
|
|
229
337
|
if mypy_config_file is None:
|
|
230
338
|
return b""
|
|
231
339
|
try:
|
|
232
|
-
|
|
340
|
+
resolved_config_path = str(mypy_config_file.resolve())
|
|
341
|
+
except (OSError, RuntimeError):
|
|
342
|
+
resolved_config_path = str(mypy_config_file)
|
|
343
|
+
resolved_config_path_bytes = resolved_config_path.encode(CACHE_FILE_ENCODING)
|
|
344
|
+
try:
|
|
345
|
+
config_bytes = mypy_config_file.read_bytes()
|
|
233
346
|
except OSError:
|
|
234
|
-
return
|
|
347
|
+
return resolved_config_path_bytes
|
|
348
|
+
return resolved_config_path_bytes + b"\0" + config_bytes
|
|
235
349
|
|
|
236
350
|
|
|
237
351
|
def _composite_content_hash(target_file: str, mypy_config_file: Path | None) -> str | None:
|
|
@@ -255,8 +369,12 @@ def _composite_content_hash(target_file: str, mypy_config_file: Path | None) ->
|
|
|
255
369
|
|
|
256
370
|
|
|
257
371
|
def _read_cached_passing_hash(target_file: str) -> str | None:
|
|
258
|
-
content_hash_cache =
|
|
259
|
-
|
|
372
|
+
content_hash_cache = (
|
|
373
|
+
read_json_object(
|
|
374
|
+
_session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME),
|
|
375
|
+
encoding=CACHE_FILE_ENCODING,
|
|
376
|
+
)
|
|
377
|
+
or {}
|
|
260
378
|
)
|
|
261
379
|
cached_hash = content_hash_cache.get(target_file)
|
|
262
380
|
return cached_hash if isinstance(cached_hash, str) else None
|
|
@@ -264,9 +382,21 @@ def _read_cached_passing_hash(target_file: str) -> str | None:
|
|
|
264
382
|
|
|
265
383
|
def _record_passing_hash(target_file: str, content_hash: str) -> None:
|
|
266
384
|
content_hash_cache_path = _session_cache_path(MYPY_CONTENT_HASH_CACHE_FILENAME)
|
|
267
|
-
content_hash_cache =
|
|
385
|
+
content_hash_cache = (
|
|
386
|
+
read_json_object(content_hash_cache_path, encoding=CACHE_FILE_ENCODING) or {}
|
|
387
|
+
)
|
|
268
388
|
content_hash_cache[target_file] = content_hash
|
|
269
|
-
|
|
389
|
+
try:
|
|
390
|
+
write_text_atomically(
|
|
391
|
+
content_hash_cache_path,
|
|
392
|
+
json.dumps(content_hash_cache),
|
|
393
|
+
encoding=CACHE_FILE_ENCODING,
|
|
394
|
+
temporary_prefix=f".{content_hash_cache_path.name}-",
|
|
395
|
+
temporary_suffix=ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
396
|
+
should_reap_orphans=True,
|
|
397
|
+
)
|
|
398
|
+
except OSError:
|
|
399
|
+
pass
|
|
270
400
|
|
|
271
401
|
|
|
272
402
|
def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
|
|
@@ -286,12 +416,16 @@ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -
|
|
|
286
416
|
config_arguments = (
|
|
287
417
|
["--config-file", str(mypy_config_file)] if mypy_config_file is not None else []
|
|
288
418
|
)
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
419
|
+
return (
|
|
420
|
+
base_command
|
|
421
|
+
+ config_arguments
|
|
422
|
+
+ [
|
|
423
|
+
"--no-error-summary",
|
|
424
|
+
"--show-error-codes",
|
|
425
|
+
"--no-color",
|
|
426
|
+
relative_file_path,
|
|
427
|
+
]
|
|
428
|
+
)
|
|
295
429
|
|
|
296
430
|
|
|
297
431
|
def project_relative_path(target_file: str, project_root: str) -> str:
|
|
@@ -319,20 +453,14 @@ def project_relative_path(target_file: str, project_root: str) -> str:
|
|
|
319
453
|
def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
320
454
|
"""Run mypy on one file from the project root and return its result.
|
|
321
455
|
|
|
322
|
-
The mypy run is skipped when a composite hash over the target file's bytes
|
|
323
|
-
|
|
324
|
-
time mypy passed for that file
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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:
|
|
456
|
+
The mypy run is skipped when a composite hash over the target file's bytes,
|
|
457
|
+
resolved config path, and config bytes matches the hash recorded the last
|
|
458
|
+
time mypy passed for that file. A source or configuration change then
|
|
459
|
+
re-runs mypy, while the discovered config is reused from metadata cached by
|
|
460
|
+
the target file's own directory.
|
|
461
|
+
|
|
462
|
+
The composite hash covers the target file's own bytes, resolved config path,
|
|
463
|
+
and config bytes, so the skip is blind to a cross-file change in a dependency:
|
|
336
464
|
when a dependency is edited in a way that breaks this file's call site and
|
|
337
465
|
this file is later rewritten to its prior passing content, the cached pass
|
|
338
466
|
returns without re-running mypy. The post-write hook already type-checks only
|
|
@@ -359,7 +487,8 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
|
359
487
|
|
|
360
488
|
completed_process = subprocess.run(
|
|
361
489
|
mypy_command,
|
|
362
|
-
check=False,
|
|
490
|
+
check=False,
|
|
491
|
+
capture_output=True,
|
|
363
492
|
text=True,
|
|
364
493
|
env=os.environ.copy(),
|
|
365
494
|
timeout=MYPY_TIMEOUT_SECONDS,
|
|
@@ -368,9 +497,14 @@ def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
|
368
497
|
|
|
369
498
|
stdout_output = completed_process.stdout.strip()
|
|
370
499
|
stderr_output = completed_process.stderr.strip()
|
|
371
|
-
combined_output =
|
|
500
|
+
combined_output = (
|
|
501
|
+
f"{stdout_output}\n{stderr_output}".strip() if stderr_output else stdout_output
|
|
502
|
+
)
|
|
372
503
|
|
|
373
|
-
if
|
|
504
|
+
if (
|
|
505
|
+
completed_process.returncode == CONTENT_HASH_CACHE_PASSING_EXIT_CODE
|
|
506
|
+
and content_hash is not None
|
|
507
|
+
):
|
|
374
508
|
_record_passing_hash(target_file, content_hash)
|
|
375
509
|
|
|
376
510
|
return completed_process.returncode, combined_output
|
|
@@ -435,8 +569,7 @@ def is_test_file(python_file: Path) -> bool:
|
|
|
435
569
|
path_lower = str(python_file).lower()
|
|
436
570
|
|
|
437
571
|
return any(
|
|
438
|
-
each_pattern in name_lower or each_pattern in path_lower
|
|
439
|
-
for each_pattern in SKIP_PATTERNS
|
|
572
|
+
each_pattern in name_lower or each_pattern in path_lower for each_pattern in SKIP_PATTERNS
|
|
440
573
|
)
|
|
441
574
|
|
|
442
575
|
|