claude-dev-env 2.25.0 → 2.27.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 (45) 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/pr-cleanup/SKILL.md +1 -0
  7. package/.agents/skills/pr-plain-language-cleanup/SKILL.md +93 -0
  8. package/.agents/skills/pr-title-description/SKILL.md +92 -0
  9. package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
  10. package/bin/ever-shipped-skills.mjs +2 -0
  11. package/bin/install.agents-home.test.mjs +199 -8
  12. package/bin/install.mjs +15 -8
  13. package/bin/install.test.mjs +82 -2
  14. package/commands/sr-loop.md +10 -1
  15. package/docs/codex-compatibility.md +19 -0
  16. package/hooks/blocking/luna_fast_mode_gate.py +160 -0
  17. package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
  18. package/hooks/hooks.json +10 -5
  19. package/hooks/hooks_constants/AGENTS.md +1 -1
  20. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
  21. package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
  22. package/hooks/hooks_constants/session_start_injector.py +11 -4
  23. package/hooks/hooks_constants/test_session_start_injector.py +4 -2
  24. package/hooks/session/AGENTS.md +2 -2
  25. package/hooks/session/issue_tracker_session_starter.py +3 -2
  26. package/hooks/session/orchestrator_auto_starter.py +3 -2
  27. package/hooks/session/task_list_loop_starter.py +10 -1
  28. package/hooks/session/test_issue_tracker_session_starter.py +3 -1
  29. package/hooks/session/test_orchestrator_auto_starter.py +5 -3
  30. package/hooks/session/test_task_list_loop_starter.py +10 -8
  31. package/hooks/session/test_untracked_repo_detector.py +7 -5
  32. package/hooks/session/test_working_style_prompt.py +5 -3
  33. package/hooks/session/untracked_repo_detector.py +10 -1
  34. package/hooks/session/working_style_prompt.py +10 -1
  35. package/hooks/validators/AGENTS.md +2 -1
  36. package/hooks/validators/conftest.py +21 -4
  37. package/hooks/validators/mypy_integration.py +77 -16
  38. package/hooks/validators/run_all_validators.py +2 -35
  39. package/hooks/validators/system_temporary_roots.py +90 -0
  40. package/hooks/validators/test_directory_exemption_constants.py +1 -1
  41. package/hooks/validators/test_mypy_integration.py +169 -0
  42. package/hooks/validators/test_system_temporary_roots.py +93 -0
  43. package/package.json +1 -1
  44. package/scripts/codex_compat_materializer.py +4 -2
  45. package/scripts/tests/test_codex_compat_materializer.py +2 -2
@@ -148,16 +148,23 @@ def inject_session_start_context(
148
148
  )
149
149
 
150
150
 
151
- def build_additional_context_payload(injection_result: InjectionResult) -> dict[str, str]:
151
+ def build_additional_context_payload(
152
+ injection_result: InjectionResult,
153
+ ) -> dict[str, object]:
152
154
  """Return the SessionStart stdout object when context was injected.
153
155
 
154
156
  Args:
155
157
  injection_result: Result from inject_session_start_context.
156
158
 
157
159
  Returns:
158
- ``{"additionalContext": ...}`` when injected, else empty dict.
160
+ ``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
161
+ when injected, else an empty dict.
159
162
  """
160
163
  if not injection_result.is_context_injected:
161
164
  return {}
162
- return {"additionalContext": injection_result.additional_context}
163
-
165
+ return {
166
+ "hookSpecificOutput": {
167
+ "hookEventName": "SessionStart",
168
+ "additionalContext": injection_result.additional_context,
169
+ }
170
+ }
@@ -143,7 +143,10 @@ def test_build_additional_context_payload_only_when_injected() -> None:
143
143
  is_context_injected=False,
144
144
  )
145
145
  assert build_additional_context_payload(injected) == {
146
- "additionalContext": "hello"
146
+ "hookSpecificOutput": {
147
+ "hookEventName": "SessionStart",
148
+ "additionalContext": "hello",
149
+ }
147
150
  }
148
151
  assert build_additional_context_payload(empty) == {}
149
152
 
@@ -165,4 +168,3 @@ def test_default_injector_configuration_reads_env(
165
168
  monkeypatch.setenv(SESSION_START_INJECTOR_ENABLED_ENV_VAR, "off")
166
169
  configuration = default_injector_configuration()
167
170
  assert configuration.is_enabled is False
168
-
@@ -1,6 +1,6 @@
1
1
  # hooks/session
2
2
 
3
- SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, detecting unregistered repositories, starting the session's task-list maintenance loop, injecting working-style guidance, and clearing PR-author swap state at shutdown. Also holds the UserPromptSubmit hook. It adds a style reminder to each message.
3
+ SessionStart and SessionEnd hooks for per-session setup and cleanup: removing stale session and plugin-data directories at startup, starting the session's task-list maintenance loop, injecting working-style guidance, and clearing PR-author swap state at shutdown. Also holds the UserPromptSubmit hook. It adds a style reminder to each message.
4
4
 
5
5
  The working-style projection uses `~/.claude/rules/asd-ste100-language.md` for
6
6
  user-facing word choice, sentence style, tone, punctuation, and prose form.
@@ -13,7 +13,7 @@ user-facing word choice, sentence style, tone, punctuation, and prose form.
13
13
  | `gh_pr_author_session_cleanup.py` | SessionEnd | Clears any PR-author swap state left over from the current session |
14
14
  | `session_edit_tracker_cleanup.py` | SessionStart, SessionEnd | Deletes the tracker file for the running Claude Code conversation from the system temp directory — at start for a clean slate and at end for a clean exit. A tracker is read only by the conversation that wrote it, so a live idle tracker is kept while a peer cleans up |
15
15
  | `plugin_data_dir_cleanup.py` | SessionStart | Removes empty plugin data directories at startup to prevent `EEXIST` when Claude Code recreates them |
16
- | `untracked_repo_detector.py` | SessionStart | Detects when the session cwd is inside a git repository that is not registered in `~/.claude/project-paths.json` and logs a warning |
16
+ | `untracked_repo_detector.py` | | Retired legacy script; no longer registered |
17
17
  | `task_list_loop_starter.py` | SessionStart | Emits an `additionalContext` directive telling Claude to keep the task list current on a 10-minute cadence, starting the `/loop` skill when one is not already running. Writes nothing and runs no tools itself. |
18
18
  | `orchestrator_auto_starter.py` | SessionStart | Opt-in (`CLAUDE_ORCHESTRATOR_AUTO_STARTER_ENABLED`) consumer of the shared SessionStart injector; emits orchestrator skill context when enabled. Manual `/orchestrator` unchanged. |
19
19
  | `issue_tracker_session_starter.py` | SessionStart | Opt-in (`CLAUDE_ISSUE_TRACKER_SESSION_STARTER_ENABLED`) and repository-gated (git root in `~/.claude/project-paths.json`) issue-tracker skill context. |
@@ -93,7 +93,7 @@ def run_issue_tracker_session_starter(
93
93
  is_enabled: bool,
94
94
  is_repository_eligible: bool,
95
95
  timeout_milliseconds: int,
96
- ) -> dict[str, str]:
96
+ ) -> dict[str, object]:
97
97
  """Return additionalContext when opt-in and repo gate both pass, else empty.
98
98
 
99
99
  Args:
@@ -103,7 +103,8 @@ def run_issue_tracker_session_starter(
103
103
  timeout_milliseconds: Injector timeout budget.
104
104
 
105
105
  Returns:
106
- ``{"additionalContext": ...}`` when injected, else ``{}``.
106
+ ``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
107
+ when injected, else ``{}``.
107
108
  """
108
109
  if not is_enabled or not is_repository_eligible:
109
110
  return {}
@@ -68,7 +68,7 @@ def run_orchestrator_auto_starter(
68
68
  payload_by_key: dict[str, object],
69
69
  is_enabled: bool,
70
70
  timeout_milliseconds: int,
71
- ) -> dict[str, str]:
71
+ ) -> dict[str, object]:
72
72
  """Return additionalContext payload when opt-in injects, else empty dict.
73
73
 
74
74
  Args:
@@ -77,7 +77,8 @@ def run_orchestrator_auto_starter(
77
77
  timeout_milliseconds: Injector timeout budget.
78
78
 
79
79
  Returns:
80
- ``{"additionalContext": ...}`` when injected, else ``{}``.
80
+ ``{"hookSpecificOutput": {"hookEventName": "SessionStart", ...}}``
81
+ when injected, else ``{}``.
81
82
  """
82
83
  if not is_enabled:
83
84
  return {}
@@ -29,7 +29,16 @@ def build_session_directive() -> str:
29
29
 
30
30
  def main() -> None:
31
31
  """Emit the task-list loop directive as SessionStart additionalContext."""
32
- print(json.dumps({"additionalContext": build_session_directive()}))
32
+ print(
33
+ json.dumps(
34
+ {
35
+ "hookSpecificOutput": {
36
+ "hookEventName": "SessionStart",
37
+ "additionalContext": build_session_directive(),
38
+ }
39
+ }
40
+ )
41
+ )
33
42
 
34
43
 
35
44
  if __name__ == "__main__":
@@ -60,7 +60,9 @@ def test_enabled_eligible_startup_emits_directive() -> None:
60
60
  payload = run_issue_tracker_session_starter(
61
61
  {"source": "startup"}, True, True, DEFAULT_TIMEOUT
62
62
  )
63
- assert payload["additionalContext"] == ISSUE_TRACKER_SESSION_START_DIRECTIVE
63
+ hook_output = payload["hookSpecificOutput"]
64
+ assert hook_output["hookEventName"] == "SessionStart"
65
+ assert hook_output["additionalContext"] == ISSUE_TRACKER_SESSION_START_DIRECTIVE
64
66
 
65
67
 
66
68
  def test_timeout_zero_emits_nothing() -> None:
@@ -51,13 +51,15 @@ def test_enabled_startup_emits_orchestrator_directive() -> None:
51
51
  payload = run_orchestrator_auto_starter(
52
52
  {"source": "startup"}, True, DEFAULT_TIMEOUT
53
53
  )
54
- assert payload["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
54
+ hook_output = payload["hookSpecificOutput"]
55
+ assert hook_output["hookEventName"] == "SessionStart"
56
+ assert hook_output["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
55
57
 
56
58
 
57
59
  @pytest.mark.parametrize("source", ("startup", "resume", "clear", "compact"))
58
60
  def test_enabled_known_sources_emit_directive(source: str) -> None:
59
61
  payload = run_orchestrator_auto_starter({"source": source}, True, DEFAULT_TIMEOUT)
60
- assert ORCHESTRATOR_SESSION_START_DIRECTIVE in payload.get("additionalContext", "")
62
+ assert ORCHESTRATOR_SESSION_START_DIRECTIVE in payload["hookSpecificOutput"]["additionalContext"]
61
63
 
62
64
 
63
65
  def test_enabled_unknown_source_emits_nothing() -> None:
@@ -81,7 +83,7 @@ def test_main_stdin_enabled_prints_json() -> None:
81
83
  )
82
84
  assert completed.returncode == 0
83
85
  parsed = json.loads(completed.stdout)
84
- assert parsed["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
86
+ assert parsed["hookSpecificOutput"]["additionalContext"] == ORCHESTRATOR_SESSION_START_DIRECTIVE
85
87
 
86
88
 
87
89
  def test_main_stdin_disabled_prints_nothing() -> None:
@@ -31,31 +31,33 @@ def _run_main() -> str:
31
31
  class TestSessionDirective:
32
32
  def test_main_emits_additional_context(self) -> None:
33
33
  emitted = json.loads(_run_main())
34
- assert "additionalContext" in emitted
34
+ hook_output = emitted["hookSpecificOutput"]
35
+ assert hook_output["hookEventName"] == "SessionStart"
36
+ assert "additionalContext" in hook_output
35
37
 
36
38
  def test_directive_carries_the_one_line_instruction(self) -> None:
37
39
  emitted = json.loads(_run_main())
38
- assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["additionalContext"]
40
+ assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["hookSpecificOutput"]["additionalContext"]
39
41
 
40
42
  def test_directive_names_the_ten_minute_cadence(self) -> None:
41
43
  emitted = json.loads(_run_main())
42
- assert "10-minute" in emitted["additionalContext"]
44
+ assert "10-minute" in emitted["hookSpecificOutput"]["additionalContext"]
43
45
 
44
46
  def test_directive_names_the_loop_command(self) -> None:
45
47
  emitted = json.loads(_run_main())
46
- assert "/loop 10m" in emitted["additionalContext"]
48
+ assert "/loop 10m" in emitted["hookSpecificOutput"]["additionalContext"]
47
49
 
48
50
  def test_directive_runs_the_instruction_immediately(self) -> None:
49
51
  emitted = json.loads(_run_main())
50
52
  assert "Run that instruction once immediately when starting the loop, then" in emitted[
51
- "additionalContext"
52
- ]
53
+ "hookSpecificOutput"
54
+ ]["additionalContext"]
53
55
 
54
56
  def test_directive_reuses_an_existing_loop(self) -> None:
55
57
  emitted = json.loads(_run_main())
56
58
  assert "Reuse the active task-list maintenance loop when available." in emitted[
57
- "additionalContext"
58
- ]
59
+ "hookSpecificOutput"
60
+ ]["additionalContext"]
59
61
 
60
62
  def test_build_session_directive_returns_the_shared_constant(self) -> None:
61
63
  assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE
@@ -99,7 +99,9 @@ class TestUntrackedRepoDetection:
99
99
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
100
100
  assert stdout.strip() != ""
101
101
  emitted = json.loads(stdout)
102
- assert "additionalContext" in emitted
102
+ hook_output = emitted["hookSpecificOutput"]
103
+ assert hook_output["hookEventName"] == "SessionStart"
104
+ assert "additionalContext" in hook_output
103
105
 
104
106
  def test_emitted_context_names_the_detected_repo_path(self, tmp_path: Path) -> None:
105
107
  repo_root = tmp_path / "new-repo"
@@ -107,7 +109,7 @@ class TestUntrackedRepoDetection:
107
109
  (repo_root / ".git").mkdir()
108
110
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
109
111
  emitted = json.loads(stdout)
110
- context_text = emitted["additionalContext"]
112
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
111
113
  assert str(repo_root) in context_text
112
114
 
113
115
  def test_emitted_context_names_the_config_file_path(self, tmp_path: Path) -> None:
@@ -116,7 +118,7 @@ class TestUntrackedRepoDetection:
116
118
  (repo_root / ".git").mkdir()
117
119
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
118
120
  emitted = json.loads(stdout)
119
- context_text = emitted["additionalContext"]
121
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
120
122
  assert "project-paths.json" in context_text
121
123
 
122
124
  def test_emitted_context_instructs_claude_to_use_ask_user_question(
@@ -127,7 +129,7 @@ class TestUntrackedRepoDetection:
127
129
  (repo_root / ".git").mkdir()
128
130
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
129
131
  emitted = json.loads(stdout)
130
- context_text = emitted["additionalContext"]
132
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
131
133
  assert "AskUserQuestion" in context_text
132
134
 
133
135
  def test_emitted_context_states_hook_has_written_nothing(
@@ -138,7 +140,7 @@ class TestUntrackedRepoDetection:
138
140
  (repo_root / ".git").mkdir()
139
141
  stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
140
142
  emitted = json.loads(stdout)
141
- context_text = emitted["additionalContext"]
143
+ context_text = emitted["hookSpecificOutput"]["additionalContext"]
142
144
  assert (
143
145
  "written nothing" in context_text.lower()
144
146
  or "has not written" in context_text.lower()
@@ -30,15 +30,17 @@ def _run_main() -> str:
30
30
  class TestWorkingStylePrompt:
31
31
  def test_main_emits_additional_context(self) -> None:
32
32
  emitted = json.loads(_run_main())
33
- assert "additionalContext" in emitted
33
+ hook_output = emitted["hookSpecificOutput"]
34
+ assert hook_output["hookEventName"] == "SessionStart"
35
+ assert "additionalContext" in hook_output
34
36
 
35
37
  def test_additional_context_matches_prompt_exactly(self) -> None:
36
38
  emitted = json.loads(_run_main())
37
- assert emitted["additionalContext"] == WORKING_STYLE_PROMPT
39
+ assert emitted["hookSpecificOutput"]["additionalContext"] == WORKING_STYLE_PROMPT
38
40
 
39
41
  def test_emitted_prompt_contains_canonical_policy_and_scope_guidance(self) -> None:
40
42
  emitted = json.loads(_run_main())
41
- prompt_text = emitted["additionalContext"]
43
+ prompt_text = emitted["hookSpecificOutput"]["additionalContext"]
42
44
  assert "Document each task in a location that remains easy to find later." in prompt_text
43
45
  assert "Deliver the requested work at its intended scope." in prompt_text
44
46
  assert "Use ELI5 for beginner framing, large visuals, minimal text" in prompt_text
@@ -64,7 +64,16 @@ def main() -> None:
64
64
  if registry_contains_path(known_registry, git_root):
65
65
  sys.exit(0)
66
66
  instruction = _build_confirm_instruction(git_root)
67
- print(json.dumps({"additionalContext": instruction}))
67
+ print(
68
+ json.dumps(
69
+ {
70
+ "hookSpecificOutput": {
71
+ "hookEventName": "SessionStart",
72
+ "additionalContext": instruction,
73
+ }
74
+ }
75
+ )
76
+ )
68
77
  except Exception as e:
69
78
  _logger.error("%s", e)
70
79
  sys.exit(0)
@@ -31,7 +31,16 @@ def build_session_directive() -> str:
31
31
 
32
32
  def main() -> None:
33
33
  """Emit the working-style prompt as SessionStart additionalContext."""
34
- print(json.dumps({"additionalContext": build_session_directive()}))
34
+ print(
35
+ json.dumps(
36
+ {
37
+ "hookSpecificOutput": {
38
+ "hookEventName": "SessionStart",
39
+ "additionalContext": build_session_directive(),
40
+ }
41
+ }
42
+ )
43
+ )
35
44
 
36
45
 
37
46
  if __name__ == "__main__":
@@ -13,6 +13,7 @@ A library of check modules used by the validation hooks. Each module focuses on
13
13
  | `run_all_validators.py` | Entry point — runs every check module and aggregates results |
14
14
  | `health_check.py` | Verifies that all validator dependencies (ruff, mypy) are reachable |
15
15
  | `pyproject_config_discovery.py` | Shared walk-up primitive that resolves a tool's pyproject.toml config from an original target path, matching the `[tool.<name>]` table the tool owns |
16
+ | `system_temporary_roots.py` | Shared membership for OS temp roots (`gettempdir` plus `TEMP` / `TMP` / `TMPDIR` / `RUNNER_TEMP`); mypy walk stop and PreToolUse staging both call it |
16
17
  | `python_style_helpers.py` | Shared source-line splitting and function-discovery helpers imported by `python_style_checks.py` |
17
18
 
18
19
  ## Check modules
@@ -25,7 +26,7 @@ A library of check modules used by the validation hooks. Each module focuses on
25
26
  | `file_structure_checks.py` | File-level structural rules (line count, module layout) |
26
27
  | `git_checks.py` | Git-state checks (untracked files, merge conflicts) |
27
28
  | `magic_value_checks.py` | Magic numbers and strings |
28
- | `mypy_integration.py` | Runs mypy and converts its output to `Violation` objects |
29
+ | `mypy_integration.py` | Runs mypy and converts its output to `Violation` objects; stops project-root walks at the system temp directory; detached gate files skip followed imports and time out as a skip (passed) |
29
30
  | `pr_reference_checks.py` | PR references in commit messages and changelogs |
30
31
  | `python_antipattern_checks.py` | Python-specific anti-patterns (bare `except`, `Any`, etc.) |
31
32
  | `python_style_checks.py` | Python style rules (naming, imports, type hints) |
@@ -6,9 +6,26 @@ from pathlib import Path
6
6
 
7
7
  VALIDATORS_DIRECTORY = Path(__file__).resolve().parent
8
8
  HOOKS_DIRECTORY = VALIDATORS_DIRECTORY.parent
9
+ VALIDATORS_DIRECTORY_STRING = str(VALIDATORS_DIRECTORY)
10
+ HOOKS_DIRECTORY_STRING = str(HOOKS_DIRECTORY)
9
11
 
10
- if str(VALIDATORS_DIRECTORY) not in sys.path:
11
- sys.path.insert(0, str(VALIDATORS_DIRECTORY))
12
+ for each_directory_string in (VALIDATORS_DIRECTORY_STRING, HOOKS_DIRECTORY_STRING):
13
+ if each_directory_string in sys.path:
14
+ sys.path.remove(each_directory_string)
15
+ sys.path.insert(0, each_directory_string)
12
16
 
13
- if str(HOOKS_DIRECTORY) not in sys.path:
14
- sys.path.insert(0, str(HOOKS_DIRECTORY))
17
+ for each_module_name in list(sys.modules):
18
+ if each_module_name != "hooks_constants" and not each_module_name.startswith(
19
+ "hooks_constants."
20
+ ):
21
+ continue
22
+ loaded_file = getattr(sys.modules[each_module_name], "__file__", None)
23
+ if loaded_file is None:
24
+ sys.modules.pop(each_module_name, None)
25
+ continue
26
+ try:
27
+ is_from_this_tree = Path(loaded_file).resolve().is_relative_to(HOOKS_DIRECTORY)
28
+ except (OSError, ValueError):
29
+ is_from_this_tree = False
30
+ if not is_from_this_tree:
31
+ sys.modules.pop(each_module_name, None)
@@ -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(