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.
- 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 +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +616 -0
- package/hooks/blocking/code_rules_enforcer.py +22 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- 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_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_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -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_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +61 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -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/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -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/docstring-prose-matches-implementation.md +2 -1
- package/skills/autoconverge/SKILL.md +93 -0
- package/skills/autoconverge/workflow/converge.mjs +27 -2
- 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
|
@@ -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
|
-
|
|
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.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.
|
|
80
|
-
|
|
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
|
|
88
|
-
cannot be imported.
|
|
188
|
+
table, or None when none exists above the file.
|
|
89
189
|
"""
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
except
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|