claude-dev-env 2.26.0 → 2.28.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/agents/clean-coder.md +95 -33
- package/.agents/agents/code-quality-agent.md +85 -24
- package/.agents/agents/pr-description-writer.md +2 -2
- package/.agents/agents/test_agent_frontmatter.py +626 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
- package/.agents/skills/hitl/SKILL.md +45 -0
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +17 -0
- package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install.agents-home.test.mjs +199 -8
- package/bin/install.mjs +15 -8
- package/bin/install.test.mjs +82 -2
- package/commands/sr-loop.md +10 -1
- package/docs/codex-compatibility.md +19 -0
- package/hooks/blocking/luna_fast_mode_gate.py +160 -0
- package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
- package/hooks/hooks.json +10 -5
- package/hooks/hooks_constants/AGENTS.md +1 -1
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
- package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
- package/hooks/session/AGENTS.md +2 -2
- package/hooks/validators/AGENTS.md +2 -1
- package/hooks/validators/conftest.py +21 -4
- package/hooks/validators/mypy_integration.py +77 -16
- package/hooks/validators/run_all_validators.py +2 -35
- package/hooks/validators/system_temporary_roots.py +90 -0
- package/hooks/validators/test_directory_exemption_constants.py +1 -1
- package/hooks/validators/test_mypy_integration.py +169 -0
- package/hooks/validators/test_system_temporary_roots.py +93 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +99 -12
- package/scripts/tests/test_codex_compat_materializer.py +70 -19
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Mypy integration for static type checking."""
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
+
import logging
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import tempfile
|
|
@@ -16,6 +17,7 @@ try:
|
|
|
16
17
|
ancestor_directories,
|
|
17
18
|
find_pyproject_configuring_tool,
|
|
18
19
|
)
|
|
20
|
+
from system_temporary_roots import enclosing_system_temporary_root
|
|
19
21
|
except ModuleNotFoundError:
|
|
20
22
|
if _validators_directory not in sys.path:
|
|
21
23
|
sys.path.insert(0, _validators_directory)
|
|
@@ -23,10 +25,15 @@ except ModuleNotFoundError:
|
|
|
23
25
|
ancestor_directories,
|
|
24
26
|
find_pyproject_configuring_tool,
|
|
25
27
|
)
|
|
28
|
+
from system_temporary_roots import enclosing_system_temporary_root
|
|
26
29
|
|
|
27
30
|
try:
|
|
28
31
|
from hooks_constants.mypy_integration_constants import (
|
|
32
|
+
FOLLOW_IMPORTS_FLAG,
|
|
33
|
+
FOLLOW_IMPORTS_SKIP_VALUE,
|
|
29
34
|
GIT_DIRECTORY_NAME,
|
|
35
|
+
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS,
|
|
36
|
+
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE,
|
|
30
37
|
PYPROJECT_FILENAME,
|
|
31
38
|
PYTHON_SOURCE_SUFFIX,
|
|
32
39
|
)
|
|
@@ -35,12 +42,18 @@ except ModuleNotFoundError:
|
|
|
35
42
|
if _hooks_directory not in sys.path:
|
|
36
43
|
sys.path.insert(0, _hooks_directory)
|
|
37
44
|
from hooks_constants.mypy_integration_constants import (
|
|
45
|
+
FOLLOW_IMPORTS_FLAG,
|
|
46
|
+
FOLLOW_IMPORTS_SKIP_VALUE,
|
|
38
47
|
GIT_DIRECTORY_NAME,
|
|
48
|
+
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS,
|
|
49
|
+
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE,
|
|
39
50
|
PYPROJECT_FILENAME,
|
|
40
51
|
PYTHON_SOURCE_SUFFIX,
|
|
41
52
|
)
|
|
42
53
|
from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
|
|
43
54
|
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
44
57
|
|
|
45
58
|
@dataclass
|
|
46
59
|
class MypyResult:
|
|
@@ -91,25 +104,33 @@ def find_module_resolution_root(starting_file: Path) -> Path | None:
|
|
|
91
104
|
directory, so anchoring there binds ``config.*`` to the target file's own
|
|
92
105
|
project and keeps a foreign ``config`` in the caller's directory out of scope.
|
|
93
106
|
|
|
107
|
+
The walk does not climb out of the system temp directory. A PreToolUse
|
|
108
|
+
staging copy lives under ``%TEMP%``, and a ``.git`` in the user home above
|
|
109
|
+
that temp root is not this file's project.
|
|
110
|
+
|
|
94
111
|
::
|
|
95
112
|
|
|
96
113
|
target_repo/.git + target_repo/tools/x.py -> target_repo
|
|
97
|
-
/tmp/detached/x.py (
|
|
114
|
+
/tmp/detached/x.py (home .git above temp) -> None
|
|
115
|
+
flag: walk past %TEMP% into ~/.git -> mypy cwd=home, hook timeout
|
|
98
116
|
|
|
99
117
|
Args:
|
|
100
118
|
starting_file: The file (or directory) the walk begins from.
|
|
101
119
|
|
|
102
120
|
Returns:
|
|
103
121
|
The nearest ancestor Path that holds ``.git`` or ``pyproject.toml``,
|
|
104
|
-
or ``None`` when no such ancestor exists.
|
|
122
|
+
or ``None`` when no such ancestor exists inside the walk limit.
|
|
105
123
|
"""
|
|
106
124
|
git_entry_name = GIT_DIRECTORY_NAME
|
|
107
125
|
pyproject_filename = PYPROJECT_FILENAME
|
|
126
|
+
enclosing_temporary_root = enclosing_system_temporary_root(starting_file)
|
|
108
127
|
for each_candidate_directory in ancestor_directories(starting_file):
|
|
109
128
|
has_git_entry = (each_candidate_directory / git_entry_name).exists()
|
|
110
129
|
has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
|
|
111
130
|
if has_git_entry or has_pyproject:
|
|
112
131
|
return each_candidate_directory
|
|
132
|
+
if each_candidate_directory == enclosing_temporary_root:
|
|
133
|
+
return None
|
|
113
134
|
return None
|
|
114
135
|
|
|
115
136
|
|
|
@@ -123,7 +144,7 @@ def _first_module_resolution_root(all_py_files: list[str]) -> Path | None:
|
|
|
123
144
|
|
|
124
145
|
|
|
125
146
|
@contextlib.contextmanager
|
|
126
|
-
def mypy_working_directory(
|
|
147
|
+
def mypy_working_directory(resolution_root: Path | None) -> Iterator[str]:
|
|
127
148
|
"""Yield the working directory mypy resolves first-party imports from.
|
|
128
149
|
|
|
129
150
|
::
|
|
@@ -136,12 +157,12 @@ def mypy_working_directory(all_py_files: list[str]) -> Iterator[str]:
|
|
|
136
157
|
no foreign top-level package leaks in.
|
|
137
158
|
|
|
138
159
|
Args:
|
|
139
|
-
|
|
160
|
+
resolution_root: The first rooted target's project root, or ``None``
|
|
161
|
+
when every target is detached.
|
|
140
162
|
|
|
141
163
|
Yields:
|
|
142
164
|
A directory path string mypy should use as its working directory.
|
|
143
165
|
"""
|
|
144
|
-
resolution_root = _first_module_resolution_root(all_py_files)
|
|
145
166
|
if resolution_root is not None:
|
|
146
167
|
yield str(resolution_root)
|
|
147
168
|
return
|
|
@@ -189,17 +210,55 @@ def _mypy_config_argument(
|
|
|
189
210
|
def _run_mypy_subprocess(
|
|
190
211
|
all_py_files: list[str], config_source_path: Path | None
|
|
191
212
|
) -> subprocess.CompletedProcess[str]:
|
|
192
|
-
"""Run mypy over *all_py_files* from each file's own project root.
|
|
213
|
+
"""Run mypy over *all_py_files* from each file's own project root.
|
|
214
|
+
|
|
215
|
+
A detached file (no ``.git`` or ``pyproject.toml`` ancestor) is a PreToolUse
|
|
216
|
+
staging copy. Following imports there loads site-packages and sibling
|
|
217
|
+
modules and blows the 30-second hook budget, so that path skips followed
|
|
218
|
+
imports and bounds the subprocess.
|
|
219
|
+
|
|
220
|
+
::
|
|
221
|
+
|
|
222
|
+
ok: temp/vae_compile.py (no project root) -> --follow-imports skip
|
|
223
|
+
ok: repo/tools/serialize_tool.py -> default follow-imports
|
|
224
|
+
flag: temp/vae_compile.py follow=normal -> torch stubs, hook timeout
|
|
225
|
+
"""
|
|
226
|
+
follow_imports_flag = FOLLOW_IMPORTS_FLAG
|
|
227
|
+
follow_imports_skip_value = FOLLOW_IMPORTS_SKIP_VALUE
|
|
228
|
+
detached_timeout_seconds = MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS
|
|
229
|
+
detached_timeout_skip_message = MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE
|
|
193
230
|
config_argument = _mypy_config_argument(all_py_files, config_source_path)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
231
|
+
resolution_root = _first_module_resolution_root(all_py_files)
|
|
232
|
+
is_detached_target = resolution_root is None
|
|
233
|
+
follow_imports_arguments = (
|
|
234
|
+
[follow_imports_flag, follow_imports_skip_value] if is_detached_target else []
|
|
235
|
+
)
|
|
236
|
+
timeout_seconds = detached_timeout_seconds if is_detached_target else None
|
|
237
|
+
with mypy_working_directory(resolution_root) as working_directory:
|
|
238
|
+
try:
|
|
239
|
+
return subprocess.run(
|
|
240
|
+
[
|
|
241
|
+
"mypy",
|
|
242
|
+
*config_argument,
|
|
243
|
+
*follow_imports_arguments,
|
|
244
|
+
"--ignore-missing-imports",
|
|
245
|
+
"--no-error-summary",
|
|
246
|
+
*all_py_files,
|
|
247
|
+
],
|
|
248
|
+
check=False,
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
cwd=working_directory,
|
|
252
|
+
timeout=timeout_seconds,
|
|
253
|
+
)
|
|
254
|
+
except subprocess.TimeoutExpired:
|
|
255
|
+
logger.warning(detached_timeout_skip_message)
|
|
256
|
+
return subprocess.CompletedProcess(
|
|
257
|
+
args=["mypy"],
|
|
258
|
+
returncode=0,
|
|
259
|
+
stdout="",
|
|
260
|
+
stderr=detached_timeout_skip_message,
|
|
261
|
+
)
|
|
203
262
|
|
|
204
263
|
|
|
205
264
|
def run_mypy_check(
|
|
@@ -208,7 +267,9 @@ def run_mypy_check(
|
|
|
208
267
|
"""Run mypy on files, resolving config from *config_source_path* when given.
|
|
209
268
|
|
|
210
269
|
A given ``config_source_path`` walks ``--config-file`` up from the original
|
|
211
|
-
target rather than the staged copy's own ancestors.
|
|
270
|
+
target rather than the staged copy's own ancestors. A detached staging
|
|
271
|
+
file that hits the subprocess timeout returns passed, same as when mypy
|
|
272
|
+
is not installed, so the 30-second PreToolUse hook can still return.
|
|
212
273
|
"""
|
|
213
274
|
if not all_files:
|
|
214
275
|
return MypyResult(passed=True, output="No files to check", error_count=0)
|
|
@@ -23,10 +23,10 @@ from ._path_setup import hooks_directory_on_path # noqa: F401
|
|
|
23
23
|
from .config.directory_exemption_constants import (
|
|
24
24
|
ALL_DIRECTORY_EXEMPTION_SEGMENT_NAMES,
|
|
25
25
|
ALL_DIRECTORY_EXEMPTION_SUBSTRING_PATTERNS,
|
|
26
|
-
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES,
|
|
27
26
|
)
|
|
28
27
|
from .health_check import get_system_health, get_validator_version, print_health_report
|
|
29
28
|
from .mypy_integration import check_mypy_available, run_mypy_check
|
|
29
|
+
from .system_temporary_roots import enclosing_system_temporary_root
|
|
30
30
|
from .output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
|
|
31
31
|
from .python_style_checks import fix_file
|
|
32
32
|
from .ruff_integration import check_ruff_available, run_ruff_check
|
|
@@ -788,32 +788,6 @@ def _escapes_temporary_root(path_part: str) -> bool:
|
|
|
788
788
|
return part_as_path.is_absolute() or bool(part_as_path.anchor)
|
|
789
789
|
|
|
790
790
|
|
|
791
|
-
def _all_system_temporary_roots() -> tuple[Path, ...]:
|
|
792
|
-
"""Return resolved roots that count as system temporary directories.
|
|
793
|
-
|
|
794
|
-
::
|
|
795
|
-
|
|
796
|
-
gettempdir() plus TEMP / TMP / TMPDIR / RUNNER_TEMP when set.
|
|
797
|
-
|
|
798
|
-
GitHub Actions puts pytest basetemp under ``RUNNER_TEMP``
|
|
799
|
-
(``/home/runner/work/_temp``) while ``tempfile.gettempdir()`` is ``/tmp``.
|
|
800
|
-
Both must count so pytest-shaped ``test_*`` parents disable substring
|
|
801
|
-
exemption matching during staging.
|
|
802
|
-
"""
|
|
803
|
-
all_candidate_roots: list[str] = [tempfile.gettempdir()]
|
|
804
|
-
for each_environment_name in ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES:
|
|
805
|
-
environment_value = os.environ.get(each_environment_name)
|
|
806
|
-
if environment_value:
|
|
807
|
-
all_candidate_roots.append(environment_value)
|
|
808
|
-
all_resolved_roots: list[Path] = []
|
|
809
|
-
for each_candidate in all_candidate_roots:
|
|
810
|
-
try:
|
|
811
|
-
all_resolved_roots.append(Path(each_candidate).resolve())
|
|
812
|
-
except OSError:
|
|
813
|
-
continue
|
|
814
|
-
return tuple(all_resolved_roots)
|
|
815
|
-
|
|
816
|
-
|
|
817
791
|
def _is_absolute_path_under_system_temporary_directory(file_path: str) -> bool:
|
|
818
792
|
"""Return True when *file_path* is absolute and resolves under an OS temp root.
|
|
819
793
|
|
|
@@ -840,14 +814,7 @@ def _is_absolute_path_under_system_temporary_directory(file_path: str) -> bool:
|
|
|
840
814
|
destination_path = Path(file_path)
|
|
841
815
|
if not destination_path.is_absolute():
|
|
842
816
|
return False
|
|
843
|
-
|
|
844
|
-
resolved_destination = destination_path.resolve()
|
|
845
|
-
except OSError:
|
|
846
|
-
return False
|
|
847
|
-
return any(
|
|
848
|
-
resolved_destination.is_relative_to(each_temporary_root)
|
|
849
|
-
for each_temporary_root in _all_system_temporary_roots()
|
|
850
|
-
)
|
|
817
|
+
return enclosing_system_temporary_root(destination_path) is not None
|
|
851
818
|
|
|
852
819
|
|
|
853
820
|
def _directory_segment_signals_exemption(
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Resolve the system temp root that contains a path, if any.
|
|
2
|
+
|
|
3
|
+
Staging copies and pytest basetemp live under ``tempfile.gettempdir()`` or
|
|
4
|
+
under ``TEMP`` / ``TMP`` / ``TMPDIR`` / ``RUNNER_TEMP``. A walk that needs to
|
|
5
|
+
stop at that boundary asks here instead of re-coding membership in each caller.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
%TEMP%/pytest-123/x.py -> %TEMP%
|
|
10
|
+
RUNNER_TEMP/detached/x.py (GHA) -> RUNNER_TEMP
|
|
11
|
+
C:/repo/pkg/x.py -> None
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
22
|
+
if _hooks_directory not in sys.path:
|
|
23
|
+
sys.path.insert(0, _hooks_directory)
|
|
24
|
+
|
|
25
|
+
from validators.config.directory_exemption_constants import ( # noqa: E402
|
|
26
|
+
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def all_system_temporary_roots() -> tuple[Path, ...]:
|
|
31
|
+
"""Return resolved roots that count as system temporary directories.
|
|
32
|
+
|
|
33
|
+
::
|
|
34
|
+
|
|
35
|
+
gettempdir() plus TEMP / TMP / TMPDIR / RUNNER_TEMP when set.
|
|
36
|
+
|
|
37
|
+
GitHub Actions puts pytest basetemp under ``RUNNER_TEMP``
|
|
38
|
+
(``/home/runner/work/_temp``) while ``tempfile.gettempdir()`` is ``/tmp``.
|
|
39
|
+
Both count so a staging walk and a path-exemption check agree.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Unique resolved directory Paths. Unresolvable candidates are dropped.
|
|
43
|
+
"""
|
|
44
|
+
all_candidate_roots: list[str] = [tempfile.gettempdir()]
|
|
45
|
+
for each_environment_name in ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES:
|
|
46
|
+
environment_value = os.environ.get(each_environment_name)
|
|
47
|
+
if environment_value:
|
|
48
|
+
all_candidate_roots.append(environment_value)
|
|
49
|
+
all_resolved_roots: list[Path] = []
|
|
50
|
+
all_seen_roots: set[Path] = set()
|
|
51
|
+
for each_candidate in all_candidate_roots:
|
|
52
|
+
try:
|
|
53
|
+
resolved_root = Path(each_candidate).resolve()
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
if resolved_root in all_seen_roots:
|
|
57
|
+
continue
|
|
58
|
+
all_seen_roots.add(resolved_root)
|
|
59
|
+
all_resolved_roots.append(resolved_root)
|
|
60
|
+
return tuple(all_resolved_roots)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def enclosing_system_temporary_root(starting_file: Path) -> Path | None:
|
|
64
|
+
"""Return the innermost system temp root that contains *starting_file*.
|
|
65
|
+
|
|
66
|
+
::
|
|
67
|
+
|
|
68
|
+
%TEMP%/pytest-123/x.py -> %TEMP%
|
|
69
|
+
C:/repo/pkg/x.py -> None
|
|
70
|
+
|
|
71
|
+
When more than one listed root contains the file, the first ancestor that
|
|
72
|
+
is already in the root set wins, so a nested ``RUNNER_TEMP`` stops the walk
|
|
73
|
+
before a broader ``gettempdir()`` ancestor.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
starting_file: The file or directory whose ancestors are bounded.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The innermost matching root Path, or ``None`` when no listed root
|
|
80
|
+
contains *starting_file*.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
resolved_start = starting_file.resolve()
|
|
84
|
+
except OSError:
|
|
85
|
+
return None
|
|
86
|
+
all_root_set = set(all_system_temporary_roots())
|
|
87
|
+
for each_candidate_directory in (resolved_start, *resolved_start.parents):
|
|
88
|
+
if each_candidate_directory in all_root_set:
|
|
89
|
+
return each_candidate_directory
|
|
90
|
+
return None
|
|
@@ -147,7 +147,7 @@ def test_runner_temp_pytest_shaped_path_stages_flat_basename_when_gettempdir_dif
|
|
|
147
147
|
os_gettemp.mkdir()
|
|
148
148
|
monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
|
|
149
149
|
monkeypatch.setattr(
|
|
150
|
-
"validators.
|
|
150
|
+
"validators.system_temporary_roots.tempfile.gettempdir",
|
|
151
151
|
lambda: str(os_gettemp),
|
|
152
152
|
)
|
|
153
153
|
pytest_shaped_target = (
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Tests for mypy integration module."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from unittest.mock import patch
|
|
5
7
|
|
|
6
8
|
import pytest
|
|
7
9
|
|
|
10
|
+
from . import mypy_integration as mypy_integration_module
|
|
8
11
|
from .mypy_integration import (
|
|
9
12
|
MypyResult,
|
|
10
13
|
check_mypy_available,
|
|
@@ -30,6 +33,63 @@ def _write_config_importer(script_path: Path) -> None:
|
|
|
30
33
|
)
|
|
31
34
|
|
|
32
35
|
|
|
36
|
+
def test_find_module_resolution_root_ignores_git_above_system_temp(
|
|
37
|
+
tmp_path: Path,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""A temp staging file does not inherit ``.git`` from a parent of ``%TEMP%``.
|
|
40
|
+
|
|
41
|
+
::
|
|
42
|
+
|
|
43
|
+
%TEMP%/pytest-.../detached/module.py
|
|
44
|
+
C:/Users/<you>/.git exists above %TEMP%
|
|
45
|
+
flag: walk returns the home repo -> mypy cwd=home, torch stubs, 30s timeout
|
|
46
|
+
ok: walk stops at the temp root -> None
|
|
47
|
+
"""
|
|
48
|
+
nested_file = tmp_path / "detached" / "module.py"
|
|
49
|
+
nested_file.parent.mkdir()
|
|
50
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
51
|
+
assert find_module_resolution_root(nested_file) is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_find_module_resolution_root_still_sees_git_inside_temp(tmp_path: Path) -> None:
|
|
55
|
+
"""A repo created inside the temp tree is still a project root."""
|
|
56
|
+
nested_repo = tmp_path / "inner_repo"
|
|
57
|
+
nested_file = nested_repo / "pkg" / "module.py"
|
|
58
|
+
nested_file.parent.mkdir(parents=True)
|
|
59
|
+
(nested_repo / ".git").mkdir()
|
|
60
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
61
|
+
assert find_module_resolution_root(nested_file) == nested_repo
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_find_module_resolution_root_stops_at_runner_temp_when_gettempdir_differs(
|
|
65
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
66
|
+
) -> None:
|
|
67
|
+
"""A RUNNER_TEMP staging file does not inherit ``.git`` above that root.
|
|
68
|
+
|
|
69
|
+
::
|
|
70
|
+
|
|
71
|
+
RUNNER_TEMP/detached/module.py, gettempdir -> sibling os_gettemp
|
|
72
|
+
tmp_path/.git sits above RUNNER_TEMP
|
|
73
|
+
flag: gettempdir-only walk returns tmp_path
|
|
74
|
+
ok: walk stops at RUNNER_TEMP -> None
|
|
75
|
+
"""
|
|
76
|
+
runner_temp_root = tmp_path / "runner_temp"
|
|
77
|
+
os_gettemp = tmp_path / "os_gettemp"
|
|
78
|
+
runner_temp_root.mkdir()
|
|
79
|
+
os_gettemp.mkdir()
|
|
80
|
+
(tmp_path / ".git").mkdir()
|
|
81
|
+
monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
|
|
82
|
+
monkeypatch.setattr(
|
|
83
|
+
"validators.system_temporary_roots.tempfile.gettempdir",
|
|
84
|
+
lambda: str(os_gettemp),
|
|
85
|
+
)
|
|
86
|
+
nested_file = runner_temp_root / "detached" / "module.py"
|
|
87
|
+
nested_file.parent.mkdir()
|
|
88
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
assert find_module_resolution_root(nested_file) is None
|
|
91
|
+
|
|
92
|
+
|
|
33
93
|
def test_find_module_resolution_root_returns_git_marked_ancestor(tmp_path: Path) -> None:
|
|
34
94
|
target_repo = tmp_path / "git_repo"
|
|
35
95
|
nested_directory = target_repo / "src" / "deep"
|
|
@@ -165,6 +225,115 @@ def test_run_mypy_check_accepts_relative_path_under_nested_root(
|
|
|
165
225
|
assert mypy_result.passed, mypy_result.output
|
|
166
226
|
|
|
167
227
|
|
|
228
|
+
def test_run_mypy_check_does_not_report_imported_sibling_on_detached_file(
|
|
229
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
230
|
+
) -> None:
|
|
231
|
+
"""A detached gate file does not inherit type errors from an imported sibling.
|
|
232
|
+
|
|
233
|
+
::
|
|
234
|
+
|
|
235
|
+
detached/importer.py -> from sibling import broken_value
|
|
236
|
+
detached/sibling.py -> broken_value: int = "not an integer"
|
|
237
|
+
flag: follow-imports=normal reports sibling.py and fails the gate file
|
|
238
|
+
ok: follow-imports=skip grades importer.py only -> passed
|
|
239
|
+
|
|
240
|
+
The PreToolUse gate stages one file under a temp directory with no project
|
|
241
|
+
root. Following imports there loads site-packages (torch stubs) and sibling
|
|
242
|
+
modules, and the 30s hook budget expires.
|
|
243
|
+
"""
|
|
244
|
+
if not check_mypy_available():
|
|
245
|
+
pytest.skip("mypy is not installed")
|
|
246
|
+
|
|
247
|
+
detached_directory = tmp_path / "detached_gate_dir"
|
|
248
|
+
detached_directory.mkdir()
|
|
249
|
+
(detached_directory / "sibling.py").write_text(
|
|
250
|
+
"broken_value: int = 'not an integer'\n",
|
|
251
|
+
encoding="utf-8",
|
|
252
|
+
)
|
|
253
|
+
importer_file = detached_directory / "importer.py"
|
|
254
|
+
importer_file.write_text(
|
|
255
|
+
"from sibling import broken_value\n\n"
|
|
256
|
+
"copied_value: int = broken_value\n",
|
|
257
|
+
encoding="utf-8",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
monkeypatch.chdir(tmp_path)
|
|
261
|
+
mypy_result = run_mypy_check([importer_file])
|
|
262
|
+
|
|
263
|
+
assert mypy_result.passed, mypy_result.output
|
|
264
|
+
assert "sibling.py" not in mypy_result.output
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_run_mypy_check_reports_imported_sibling_on_rooted_file(
|
|
268
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
269
|
+
) -> None:
|
|
270
|
+
"""A rooted file still follows first-party imports and reports sibling errors.
|
|
271
|
+
|
|
272
|
+
::
|
|
273
|
+
|
|
274
|
+
rooted_repo/.git + importer.py -> from sibling import broken_value
|
|
275
|
+
rooted_repo/sibling.py -> broken_value: int = "not an integer"
|
|
276
|
+
ok: follow-imports=normal reports sibling.py -> failed
|
|
277
|
+
flag: --follow-imports=skip on rooted files hides sibling.py -> passed
|
|
278
|
+
|
|
279
|
+
Skip applies only to detached staging copies. Applying it to a repo file
|
|
280
|
+
would drop first-party type errors the in-repo path must still see.
|
|
281
|
+
"""
|
|
282
|
+
if not check_mypy_available():
|
|
283
|
+
pytest.skip("mypy is not installed")
|
|
284
|
+
|
|
285
|
+
rooted_repo = tmp_path / "rooted_repo"
|
|
286
|
+
rooted_repo.mkdir()
|
|
287
|
+
(rooted_repo / ".git").mkdir()
|
|
288
|
+
(rooted_repo / "sibling.py").write_text(
|
|
289
|
+
"broken_value: int = 'not an integer'\n",
|
|
290
|
+
encoding="utf-8",
|
|
291
|
+
)
|
|
292
|
+
importer_file = rooted_repo / "importer.py"
|
|
293
|
+
importer_file.write_text(
|
|
294
|
+
"from sibling import broken_value\n\n"
|
|
295
|
+
"copied_value: int = broken_value\n",
|
|
296
|
+
encoding="utf-8",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
monkeypatch.chdir(tmp_path)
|
|
300
|
+
mypy_result = run_mypy_check([importer_file])
|
|
301
|
+
|
|
302
|
+
assert not mypy_result.passed
|
|
303
|
+
assert "sibling.py" in mypy_result.output
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_run_mypy_check_skips_when_detached_mypy_times_out(
|
|
307
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
308
|
+
) -> None:
|
|
309
|
+
"""A detached mypy that exceeds its timeout does not fail the file.
|
|
310
|
+
|
|
311
|
+
::
|
|
312
|
+
|
|
313
|
+
detached/module.py, subprocess.run raises TimeoutExpired
|
|
314
|
+
ok: passed=True and the skip message is in the output
|
|
315
|
+
flag: TimeoutExpired propagates and the PreToolUse hook dies
|
|
316
|
+
"""
|
|
317
|
+
detached_file = tmp_path / "detached" / "module.py"
|
|
318
|
+
detached_file.parent.mkdir()
|
|
319
|
+
detached_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
320
|
+
|
|
321
|
+
monkeypatch.setattr(
|
|
322
|
+
mypy_integration_module, "check_mypy_available", lambda: True
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def raise_timeout(*_args: object, **_kwargs: object) -> None:
|
|
326
|
+
raise subprocess.TimeoutExpired(cmd="mypy", timeout=1)
|
|
327
|
+
|
|
328
|
+
monkeypatch.setattr(mypy_integration_module.subprocess, "run", raise_timeout)
|
|
329
|
+
with caplog.at_level(logging.WARNING):
|
|
330
|
+
mypy_result = run_mypy_check([detached_file])
|
|
331
|
+
|
|
332
|
+
assert mypy_result.passed
|
|
333
|
+
assert "timed out on a detached file" in mypy_result.output
|
|
334
|
+
assert "timed out on a detached file" in caplog.text
|
|
335
|
+
|
|
336
|
+
|
|
168
337
|
def test_run_mypy_check_applies_config_resolved_from_config_source_path(
|
|
169
338
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
170
339
|
) -> None:
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tests for the shared system-temp root membership helper."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from .system_temporary_roots import (
|
|
8
|
+
all_system_temporary_roots,
|
|
9
|
+
enclosing_system_temporary_root,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_all_system_temporary_roots_includes_runner_temp_when_set(
|
|
14
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
15
|
+
) -> None:
|
|
16
|
+
"""RUNNER_TEMP joins gettempdir so GHA basetemp counts as system temp."""
|
|
17
|
+
runner_temp_root = tmp_path / "runner_temp"
|
|
18
|
+
os_gettemp = tmp_path / "os_gettemp"
|
|
19
|
+
runner_temp_root.mkdir()
|
|
20
|
+
os_gettemp.mkdir()
|
|
21
|
+
monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
|
|
22
|
+
monkeypatch.setattr(
|
|
23
|
+
"validators.system_temporary_roots.tempfile.gettempdir",
|
|
24
|
+
lambda: str(os_gettemp),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
all_roots = all_system_temporary_roots()
|
|
28
|
+
|
|
29
|
+
assert os_gettemp.resolve() in all_roots
|
|
30
|
+
assert runner_temp_root.resolve() in all_roots
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_enclosing_system_temporary_root_returns_gettempdir_for_file_under_it(
|
|
34
|
+
tmp_path: Path,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""A pytest tmp_path file sits under the process temp root."""
|
|
37
|
+
nested_file = tmp_path / "detached" / "module.py"
|
|
38
|
+
nested_file.parent.mkdir()
|
|
39
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
enclosing_root = enclosing_system_temporary_root(nested_file)
|
|
42
|
+
|
|
43
|
+
assert enclosing_root is not None
|
|
44
|
+
assert nested_file.resolve().is_relative_to(enclosing_root)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_enclosing_system_temporary_root_uses_runner_temp_when_gettempdir_differs(
|
|
48
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
49
|
+
) -> None:
|
|
50
|
+
"""GHA basetemp lives under RUNNER_TEMP while gettempdir is often another root.
|
|
51
|
+
|
|
52
|
+
::
|
|
53
|
+
|
|
54
|
+
RUNNER_TEMP/detached/module.py, gettempdir -> sibling os_gettemp
|
|
55
|
+
ok: enclosing root is RUNNER_TEMP
|
|
56
|
+
flag: gettempdir-only helper returns None and the mypy walk climbs out
|
|
57
|
+
"""
|
|
58
|
+
runner_temp_root = tmp_path / "runner_temp"
|
|
59
|
+
os_gettemp = tmp_path / "os_gettemp"
|
|
60
|
+
runner_temp_root.mkdir()
|
|
61
|
+
os_gettemp.mkdir()
|
|
62
|
+
monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
|
|
63
|
+
monkeypatch.setattr(
|
|
64
|
+
"validators.system_temporary_roots.tempfile.gettempdir",
|
|
65
|
+
lambda: str(os_gettemp),
|
|
66
|
+
)
|
|
67
|
+
nested_file = runner_temp_root / "detached" / "module.py"
|
|
68
|
+
nested_file.parent.mkdir()
|
|
69
|
+
nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
assert enclosing_system_temporary_root(nested_file) == runner_temp_root.resolve()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_enclosing_system_temporary_root_returns_none_outside_every_temp_root(
|
|
75
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
76
|
+
) -> None:
|
|
77
|
+
"""A path outside gettempdir and the temp env vars is not a staging copy."""
|
|
78
|
+
os_gettemp = tmp_path / "os_gettemp"
|
|
79
|
+
project_root = tmp_path / "project"
|
|
80
|
+
os_gettemp.mkdir()
|
|
81
|
+
project_root.mkdir()
|
|
82
|
+
monkeypatch.delenv("RUNNER_TEMP", raising=False)
|
|
83
|
+
monkeypatch.delenv("TEMP", raising=False)
|
|
84
|
+
monkeypatch.delenv("TMP", raising=False)
|
|
85
|
+
monkeypatch.delenv("TMPDIR", raising=False)
|
|
86
|
+
monkeypatch.setattr(
|
|
87
|
+
"validators.system_temporary_roots.tempfile.gettempdir",
|
|
88
|
+
lambda: str(os_gettemp),
|
|
89
|
+
)
|
|
90
|
+
project_file = project_root / "module.py"
|
|
91
|
+
project_file.write_text("sample_number: int = 1\n", encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
assert enclosing_system_temporary_root(project_file) is None
|