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.
Files changed (32) hide show
  1. package/.agents/agents/clean-coder.md +95 -33
  2. package/.agents/agents/code-quality-agent.md +85 -24
  3. package/.agents/agents/pr-description-writer.md +2 -2
  4. package/.agents/agents/test_agent_frontmatter.py +626 -0
  5. package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
  6. package/.agents/skills/hitl/SKILL.md +45 -0
  7. package/.agents/skills/pr-plain-language-cleanup/SKILL.md +17 -0
  8. package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
  9. package/bin/ever-shipped-skills.mjs +1 -0
  10. package/bin/install.agents-home.test.mjs +199 -8
  11. package/bin/install.mjs +15 -8
  12. package/bin/install.test.mjs +82 -2
  13. package/commands/sr-loop.md +10 -1
  14. package/docs/codex-compatibility.md +19 -0
  15. package/hooks/blocking/luna_fast_mode_gate.py +160 -0
  16. package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
  17. package/hooks/hooks.json +10 -5
  18. package/hooks/hooks_constants/AGENTS.md +1 -1
  19. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
  20. package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
  21. package/hooks/session/AGENTS.md +2 -2
  22. package/hooks/validators/AGENTS.md +2 -1
  23. package/hooks/validators/conftest.py +21 -4
  24. package/hooks/validators/mypy_integration.py +77 -16
  25. package/hooks/validators/run_all_validators.py +2 -35
  26. package/hooks/validators/system_temporary_roots.py +90 -0
  27. package/hooks/validators/test_directory_exemption_constants.py +1 -1
  28. package/hooks/validators/test_mypy_integration.py +169 -0
  29. package/hooks/validators/test_system_temporary_roots.py +93 -0
  30. package/package.json +1 -1
  31. package/scripts/codex_compat_materializer.py +99 -12
  32. 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 (no marker up-tree) -> None
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(all_py_files: list[str]) -> Iterator[str]:
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
- all_py_files: Absolute or relative paths of the Python files under check.
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
- with mypy_working_directory(all_py_files) as working_directory:
195
- return subprocess.run(
196
- ["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
197
- + all_py_files,
198
- check=False,
199
- capture_output=True,
200
- text=True,
201
- cwd=working_directory,
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
- try:
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.run_all_validators.tempfile.gettempdir",
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.26.0",
3
+ "version": "2.28.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {