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.
- 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/pr-cleanup/SKILL.md +1 -0
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +93 -0
- package/.agents/skills/pr-title-description/SKILL.md +92 -0
- package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
- package/bin/ever-shipped-skills.mjs +2 -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/hooks_constants/session_start_injector.py +11 -4
- package/hooks/hooks_constants/test_session_start_injector.py +4 -2
- package/hooks/session/AGENTS.md +2 -2
- package/hooks/session/issue_tracker_session_starter.py +3 -2
- package/hooks/session/orchestrator_auto_starter.py +3 -2
- package/hooks/session/task_list_loop_starter.py +10 -1
- package/hooks/session/test_issue_tracker_session_starter.py +3 -1
- package/hooks/session/test_orchestrator_auto_starter.py +5 -3
- package/hooks/session/test_task_list_loop_starter.py +10 -8
- package/hooks/session/test_untracked_repo_detector.py +7 -5
- package/hooks/session/test_working_style_prompt.py +5 -3
- package/hooks/session/untracked_repo_detector.py +10 -1
- package/hooks/session/working_style_prompt.py +10 -1
- 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 +4 -2
- 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(
|
|
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
|
-
``{"
|
|
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 {
|
|
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
|
-
"
|
|
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
|
-
|
package/hooks/session/AGENTS.md
CHANGED
|
@@ -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,
|
|
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` |
|
|
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,
|
|
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
|
-
``{"
|
|
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,
|
|
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
|
-
``{"
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
11
|
-
sys.path
|
|
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
|
-
|
|
14
|
-
|
|
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 (
|
|
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(
|