claude-dev-env 4.1.0 → 4.3.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/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +149 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/filesystem-search.md +5 -5
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/es_exe_path_rewriter.py +0 -159
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
"""Unit tests for the pr-description-writer PreToolUse gate on gh pr create."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
import io
|
|
7
|
-
import json
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from unittest import mock
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
-
_HOOKS_DIRECTORY = _BLOCKING_DIRECTORY.parent
|
|
16
|
-
for each_directory in (_BLOCKING_DIRECTORY, _HOOKS_DIRECTORY):
|
|
17
|
-
if str(each_directory) not in sys.path:
|
|
18
|
-
sys.path.insert(0, str(each_directory))
|
|
19
|
-
|
|
20
|
-
_GATE_SPEC = importlib.util.spec_from_file_location(
|
|
21
|
-
"pr_description_writer_gate",
|
|
22
|
-
_BLOCKING_DIRECTORY / "pr_description_writer_gate.py",
|
|
23
|
-
)
|
|
24
|
-
assert _GATE_SPEC is not None
|
|
25
|
-
assert _GATE_SPEC.loader is not None
|
|
26
|
-
gate_module = importlib.util.module_from_spec(_GATE_SPEC)
|
|
27
|
-
_GATE_SPEC.loader.exec_module(gate_module)
|
|
28
|
-
|
|
29
|
-
_GATE_CONSTANTS = importlib.import_module(
|
|
30
|
-
"hooks_constants.pr_description_writer_gate_constants"
|
|
31
|
-
)
|
|
32
|
-
_PR_DESCRIPTION_WRITER_SUBAGENT_TYPE = _GATE_CONSTANTS.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE
|
|
33
|
-
|
|
34
|
-
_SESSION_ID = "session-abc123"
|
|
35
|
-
_PLAIN_CREATE_COMMAND = 'gh pr create --draft --title "feat: add it" --body-file body.md'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _payload(command: str, tool_name: str = "Bash") -> dict[str, object]:
|
|
39
|
-
"""Build a PreToolUse payload carrying one shell command.
|
|
40
|
-
|
|
41
|
-
Args:
|
|
42
|
-
command: The shell command the gate inspects.
|
|
43
|
-
tool_name: The tool the command runs through.
|
|
44
|
-
|
|
45
|
-
Returns:
|
|
46
|
-
The payload dictionary the gate reads from stdin.
|
|
47
|
-
"""
|
|
48
|
-
return {
|
|
49
|
-
"tool_name": tool_name,
|
|
50
|
-
"session_id": _SESSION_ID,
|
|
51
|
-
"tool_input": {"command": command},
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _printed_output_for(command: str, temp_directory: Path, tool_name: str = "Bash") -> str:
|
|
56
|
-
"""Run the gate against one command and return everything it printed.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
command: The shell command the gate inspects.
|
|
60
|
-
temp_directory: The directory standing in for the system temp root.
|
|
61
|
-
tool_name: The tool the command runs through.
|
|
62
|
-
|
|
63
|
-
Returns:
|
|
64
|
-
The gate's stripped stdout — empty on an allow.
|
|
65
|
-
"""
|
|
66
|
-
captured_stdout = io.StringIO()
|
|
67
|
-
payload_text = json.dumps(_payload(command, tool_name))
|
|
68
|
-
with (
|
|
69
|
-
mock.patch("sys.stdin", io.StringIO(payload_text)),
|
|
70
|
-
mock.patch("sys.stdout", captured_stdout),
|
|
71
|
-
mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(temp_directory)),
|
|
72
|
-
pytest.raises(SystemExit),
|
|
73
|
-
):
|
|
74
|
-
gate_module.main()
|
|
75
|
-
return captured_stdout.getvalue().strip()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _decision_for(command: str, temp_directory: Path, tool_name: str = "Bash") -> str:
|
|
79
|
-
"""Return the gate's permission decision for one command.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
command: The shell command the gate inspects.
|
|
83
|
-
temp_directory: The directory standing in for the system temp root.
|
|
84
|
-
tool_name: The tool the command runs through.
|
|
85
|
-
|
|
86
|
-
Returns:
|
|
87
|
-
The ``permissionDecision`` string, or an empty string on an allow.
|
|
88
|
-
"""
|
|
89
|
-
printed_text = _printed_output_for(command, temp_directory, tool_name)
|
|
90
|
-
if not printed_text:
|
|
91
|
-
return ""
|
|
92
|
-
hook_output = json.loads(printed_text).get("hookSpecificOutput", {})
|
|
93
|
-
return str(hook_output.get("permissionDecision", ""))
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _record_spawn(temp_directory: Path, session_id: str = _SESSION_ID) -> None:
|
|
97
|
-
"""Write the marker file that stands for a recorded agent spawn.
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
temp_directory: The directory standing in for the system temp root.
|
|
101
|
-
"""
|
|
102
|
-
marker_name = (
|
|
103
|
-
f"{_GATE_CONSTANTS.SPAWN_MARKER_FILE_PREFIX}{session_id}"
|
|
104
|
-
f"{_GATE_CONSTANTS.SPAWN_MARKER_FILE_SUFFIX}"
|
|
105
|
-
)
|
|
106
|
-
(temp_directory / marker_name).touch()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def should_deny_pr_create_with_no_recorded_spawn(tmp_path: Path) -> None:
|
|
110
|
-
"""A gh pr create with no recorded spawn is denied."""
|
|
111
|
-
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == "deny"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def should_allow_pr_create_after_a_recorded_spawn(tmp_path: Path) -> None:
|
|
115
|
-
"""A gh pr create is allowed once this session recorded a spawn."""
|
|
116
|
-
_record_spawn(tmp_path)
|
|
117
|
-
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == ""
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def should_allow_pr_create_carrying_the_bypass_marker(tmp_path: Path) -> None:
|
|
121
|
-
"""A trailing bypass comment allows the create with no recorded spawn."""
|
|
122
|
-
command = f"{_PLAIN_CREATE_COMMAND} {gate_module.SPAWN_BYPASS_MARKER}"
|
|
123
|
-
assert _decision_for(command, tmp_path) == ""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def should_allow_multiline_pr_create_carrying_the_bypass_marker(tmp_path: Path) -> None:
|
|
127
|
-
"""A final bypass comment allows a create after a continued shell line."""
|
|
128
|
-
command = f"cd . && \\\n+{_PLAIN_CREATE_COMMAND} {gate_module.SPAWN_BYPASS_MARKER}"
|
|
129
|
-
assert _decision_for(command, tmp_path) == ""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def should_deny_when_the_bypass_marker_follows_another_shell_segment(tmp_path: Path) -> None:
|
|
133
|
-
"""A final marker for echo cannot opt a preceding create out."""
|
|
134
|
-
command = f"{_PLAIN_CREATE_COMMAND} && echo {gate_module.SPAWN_BYPASS_MARKER}"
|
|
135
|
-
assert _decision_for(command, tmp_path) == "deny"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def should_deny_when_only_another_session_has_a_spawn_marker(tmp_path: Path) -> None:
|
|
139
|
-
"""A marker from another session cannot unlock this session's create."""
|
|
140
|
-
_record_spawn(tmp_path, session_id="earlier-session")
|
|
141
|
-
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == "deny"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def should_deny_when_the_marker_text_sits_inside_a_quoted_value(tmp_path: Path) -> None:
|
|
145
|
-
"""The bypass text inside a quoted title leaves the gate running."""
|
|
146
|
-
command = 'gh pr create --title "fix # pr-description-skip" --body-file body.md'
|
|
147
|
-
assert _decision_for(command, tmp_path) == "deny"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def should_deny_a_pr_create_that_follows_another_command(tmp_path: Path) -> None:
|
|
151
|
-
"""A create in a later command segment is still gated."""
|
|
152
|
-
command = f"cd /repo && {_PLAIN_CREATE_COMMAND}"
|
|
153
|
-
assert _decision_for(command, tmp_path) == "deny"
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
@pytest.mark.parametrize("shell_tool_name", ["Bash", "PowerShell"])
|
|
157
|
-
def should_gate_each_shell_tool(shell_tool_name: str, tmp_path: Path) -> None:
|
|
158
|
-
"""Both shell tools reach the gate."""
|
|
159
|
-
decision = _decision_for(_PLAIN_CREATE_COMMAND, tmp_path, tool_name=shell_tool_name)
|
|
160
|
-
assert decision == "deny"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
@pytest.mark.parametrize(
|
|
164
|
-
"unrelated_command",
|
|
165
|
-
[
|
|
166
|
-
"gh pr edit 12 --body-file body.md",
|
|
167
|
-
"gh pr comment 12 --body-file body.md",
|
|
168
|
-
"gh pr ready 12",
|
|
169
|
-
"gh issue create --title x --body-file body.md",
|
|
170
|
-
"git commit -m x",
|
|
171
|
-
"echo gh pr create",
|
|
172
|
-
],
|
|
173
|
-
)
|
|
174
|
-
def should_stay_quiet_for_a_command_that_creates_no_pull_request(
|
|
175
|
-
unrelated_command: str, tmp_path: Path
|
|
176
|
-
) -> None:
|
|
177
|
-
"""A command other than gh pr create passes untouched."""
|
|
178
|
-
assert _decision_for(unrelated_command, tmp_path) == ""
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def should_stay_quiet_for_a_tool_that_runs_no_shell(tmp_path: Path) -> None:
|
|
182
|
-
"""A non-shell tool carrying the same text passes untouched."""
|
|
183
|
-
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path, tool_name="Write") == ""
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def should_deny_when_the_payload_names_no_session(tmp_path: Path) -> None:
|
|
187
|
-
"""A payload with no session id reads as no record and denies.
|
|
188
|
-
|
|
189
|
-
A shared fallback marker would let one session's spawn silently clear a
|
|
190
|
-
later session's create, so an unusable session id denies instead.
|
|
191
|
-
"""
|
|
192
|
-
_record_spawn(tmp_path)
|
|
193
|
-
captured_stdout = io.StringIO()
|
|
194
|
-
payload = _payload(_PLAIN_CREATE_COMMAND)
|
|
195
|
-
payload["session_id"] = ""
|
|
196
|
-
with (
|
|
197
|
-
mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
|
|
198
|
-
mock.patch("sys.stdout", captured_stdout),
|
|
199
|
-
mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(tmp_path)),
|
|
200
|
-
pytest.raises(SystemExit),
|
|
201
|
-
):
|
|
202
|
-
gate_module.main()
|
|
203
|
-
hook_output = json.loads(captured_stdout.getvalue().strip())["hookSpecificOutput"]
|
|
204
|
-
assert hook_output["permissionDecision"] == "deny"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def should_allow_an_untokenizable_command(tmp_path: Path) -> None:
|
|
208
|
-
"""A command that will not tokenize fails open."""
|
|
209
|
-
assert _decision_for('gh pr create --title "unclosed', tmp_path) == ""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def should_name_both_ways_forward_in_the_denial(tmp_path: Path) -> None:
|
|
213
|
-
"""The denial text names the agent spawn and the bypass marker."""
|
|
214
|
-
printed_text = _printed_output_for(_PLAIN_CREATE_COMMAND, tmp_path)
|
|
215
|
-
denial_reason = json.loads(printed_text)["hookSpecificOutput"]["permissionDecisionReason"]
|
|
216
|
-
assert _PR_DESCRIPTION_WRITER_SUBAGENT_TYPE in denial_reason
|
|
217
|
-
assert gate_module.SPAWN_BYPASS_MARKER in denial_reason
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def should_read_the_marker_path_the_shared_builder_produces(tmp_path: Path) -> None:
|
|
221
|
-
"""The gate reads exactly the file the shared path builder names.
|
|
222
|
-
|
|
223
|
-
The tracker writes through `spawn_marker_path` and the gate reads through it,
|
|
224
|
-
so neither side owns a private copy of the marker file name.
|
|
225
|
-
"""
|
|
226
|
-
marker_file = _GATE_CONSTANTS.spawn_marker_path(_SESSION_ID, temp_directory=tmp_path)
|
|
227
|
-
assert marker_file is not None
|
|
228
|
-
marker_file.touch()
|
|
229
|
-
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == ""
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def should_deny_when_the_session_id_names_no_marker(tmp_path: Path) -> None:
|
|
233
|
-
"""A session id that sanitizes to nothing yields no path, so the gate denies."""
|
|
234
|
-
assert _GATE_CONSTANTS.spawn_marker_path("", temp_directory=tmp_path) is None
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
PreToolUse:Bash hook that validates prerequisites before running test commands.
|
|
4
|
-
|
|
5
|
-
Intercepts playwright/pytest commands and checks:
|
|
6
|
-
1. Target server is reachable and healthy
|
|
7
|
-
2. Database file exists (for Django projects)
|
|
8
|
-
3. (Playwright only) Django server has --test-db flag
|
|
9
|
-
4. (Playwright only) Frontend builds successfully before e2e tests
|
|
10
|
-
|
|
11
|
-
Blocks doomed test runs early instead of letting them hang for minutes.
|
|
12
|
-
"""
|
|
13
|
-
import json
|
|
14
|
-
import os
|
|
15
|
-
import re
|
|
16
|
-
import subprocess
|
|
17
|
-
import sys
|
|
18
|
-
from urllib.parse import urlparse
|
|
19
|
-
|
|
20
|
-
try:
|
|
21
|
-
import psutil
|
|
22
|
-
except Exception:
|
|
23
|
-
psutil = None
|
|
24
|
-
|
|
25
|
-
TEST_COMMAND_PATTERNS = [
|
|
26
|
-
re.compile(r'\bplaywright\s+test\b'),
|
|
27
|
-
re.compile(r'\bnpx\s+playwright\b'),
|
|
28
|
-
re.compile(r'\bpytest\b'),
|
|
29
|
-
re.compile(r'\bpython\s+-m\s+pytest\b'),
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
SERVER_URL_PATTERN = re.compile(r'https?://[^\s"\']+')
|
|
33
|
-
|
|
34
|
-
DEFAULT_PLAYWRIGHT_URL = "http://localhost:3000"
|
|
35
|
-
DEFAULT_DJANGO_URL = "http://localhost:8000"
|
|
36
|
-
|
|
37
|
-
CURL_TIMEOUT_SECONDS = 2
|
|
38
|
-
DJANGO_DB_FILENAME = "db.sqlite3"
|
|
39
|
-
|
|
40
|
-
BLOCKED_STATUS_CODES = {500, 502, 503, 504}
|
|
41
|
-
HEALTH_CHECK_ERROR_TEMPLATE = "BLOCKED: Server at {} is not healthy ({}). Fix the server before running tests."
|
|
42
|
-
UNREACHABLE_ERROR_TEMPLATE = "BLOCKED: Server at {} is unreachable. Start the server before running tests."
|
|
43
|
-
MISSING_DB_ERROR_TEMPLATE = "BLOCKED: No database file ({}) found in {}. Run migrations before running tests."
|
|
44
|
-
FRONTEND_BUILD_FAILED_MESSAGE = "BLOCKED: Frontend build failed. Fix build errors before running e2e tests."
|
|
45
|
-
MISSING_TEST_DB_FLAG_TEMPLATE = "BLOCKED: Django server on port {} is not running with --test-db. Restart with: python manage.py runserver --test-db 0.0.0.0:{}"
|
|
46
|
-
PORT_CONFLICT_ERROR_TEMPLATE = "BLOCKED: Multiple Django runserver processes are bound to port {} across worktrees: {}. Stop stale servers first."
|
|
47
|
-
FRONTEND_DIRECTORY_NAME = "frontend"
|
|
48
|
-
NPM_BUILD_COMMAND = "npm run build"
|
|
49
|
-
COLLECTSTATIC_COMMAND = "python manage.py collectstatic --noinput"
|
|
50
|
-
BUILD_TIMEOUT_SECONDS = 120
|
|
51
|
-
PLAYWRIGHT_COMMAND_PATTERNS = [
|
|
52
|
-
re.compile(r'\bplaywright\s+test\b'),
|
|
53
|
-
re.compile(r'\bnpx\s+playwright\b'),
|
|
54
|
-
]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def is_test_command(command: str) -> bool:
|
|
58
|
-
for each_pattern in TEST_COMMAND_PATTERNS:
|
|
59
|
-
if each_pattern.search(command):
|
|
60
|
-
return True
|
|
61
|
-
return False
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def is_playwright_command(command: str) -> bool:
|
|
65
|
-
for each_pattern in PLAYWRIGHT_COMMAND_PATTERNS:
|
|
66
|
-
if each_pattern.search(command):
|
|
67
|
-
return True
|
|
68
|
-
return False
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def extract_target_url(command: str) -> str:
|
|
72
|
-
url_match = SERVER_URL_PATTERN.search(command)
|
|
73
|
-
if url_match:
|
|
74
|
-
return url_match.group(0)
|
|
75
|
-
|
|
76
|
-
is_playwright = "playwright" in command
|
|
77
|
-
if is_playwright:
|
|
78
|
-
return DEFAULT_PLAYWRIGHT_URL
|
|
79
|
-
|
|
80
|
-
is_pytest = "pytest" in command
|
|
81
|
-
if is_pytest:
|
|
82
|
-
return DEFAULT_DJANGO_URL
|
|
83
|
-
|
|
84
|
-
return DEFAULT_PLAYWRIGHT_URL
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def check_server_health(target_url: str) -> str | None:
|
|
88
|
-
try:
|
|
89
|
-
curl_result = subprocess.run(
|
|
90
|
-
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", str(CURL_TIMEOUT_SECONDS), target_url],
|
|
91
|
-
capture_output=True,
|
|
92
|
-
text=True,
|
|
93
|
-
timeout=CURL_TIMEOUT_SECONDS + 1,
|
|
94
|
-
)
|
|
95
|
-
http_status_code = int(curl_result.stdout.strip())
|
|
96
|
-
except (subprocess.TimeoutExpired, ValueError, OSError):
|
|
97
|
-
return UNREACHABLE_ERROR_TEMPLATE.format(target_url)
|
|
98
|
-
|
|
99
|
-
if http_status_code == 0:
|
|
100
|
-
return UNREACHABLE_ERROR_TEMPLATE.format(target_url)
|
|
101
|
-
|
|
102
|
-
if http_status_code in BLOCKED_STATUS_CODES:
|
|
103
|
-
return HEALTH_CHECK_ERROR_TEMPLATE.format(target_url, f"HTTP {http_status_code}")
|
|
104
|
-
|
|
105
|
-
return None
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def find_project_root(command: str) -> str | None:
|
|
109
|
-
working_directory = os.environ.get("PWD", os.getcwd())
|
|
110
|
-
|
|
111
|
-
directory_match = re.search(r'--project[= ](\S+)', command)
|
|
112
|
-
if directory_match:
|
|
113
|
-
return os.path.abspath(directory_match.group(1))
|
|
114
|
-
|
|
115
|
-
cd_match = re.search(r'cd\s+"([^"]+)"', command) or re.search(r"cd\s+'([^']+)'", command) or re.search(r'cd\s+(\S+)', command)
|
|
116
|
-
if cd_match:
|
|
117
|
-
cd_target = cd_match.group(1)
|
|
118
|
-
if os.path.isabs(cd_target):
|
|
119
|
-
return cd_target
|
|
120
|
-
return os.path.join(working_directory, cd_target)
|
|
121
|
-
|
|
122
|
-
return working_directory
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def check_django_database(command: str) -> str | None:
|
|
126
|
-
is_django_test = "pytest" in command or "manage.py" in command
|
|
127
|
-
if not is_django_test:
|
|
128
|
-
return None
|
|
129
|
-
|
|
130
|
-
project_root = find_project_root(command)
|
|
131
|
-
if not project_root:
|
|
132
|
-
return None
|
|
133
|
-
|
|
134
|
-
manage_py_path = os.path.join(project_root, "manage.py")
|
|
135
|
-
if not os.path.exists(manage_py_path):
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
database_path = os.path.join(project_root, DJANGO_DB_FILENAME)
|
|
139
|
-
if os.path.exists(database_path):
|
|
140
|
-
return None
|
|
141
|
-
|
|
142
|
-
return MISSING_DB_ERROR_TEMPLATE.format(DJANGO_DB_FILENAME, project_root)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def find_frontend_directory(command: str) -> str | None:
|
|
146
|
-
project_root = find_project_root(command)
|
|
147
|
-
if not project_root:
|
|
148
|
-
return None
|
|
149
|
-
|
|
150
|
-
frontend_path = os.path.join(project_root, FRONTEND_DIRECTORY_NAME)
|
|
151
|
-
if os.path.isdir(frontend_path):
|
|
152
|
-
return frontend_path
|
|
153
|
-
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def build_frontend(command: str) -> str | None:
|
|
158
|
-
frontend_path = find_frontend_directory(command)
|
|
159
|
-
if not frontend_path:
|
|
160
|
-
return None
|
|
161
|
-
|
|
162
|
-
project_root = find_project_root(command)
|
|
163
|
-
|
|
164
|
-
npm_build_result = subprocess.run(
|
|
165
|
-
NPM_BUILD_COMMAND.split(),
|
|
166
|
-
cwd=frontend_path,
|
|
167
|
-
capture_output=True,
|
|
168
|
-
text=True,
|
|
169
|
-
timeout=BUILD_TIMEOUT_SECONDS,
|
|
170
|
-
)
|
|
171
|
-
if npm_build_result.returncode != 0:
|
|
172
|
-
return FRONTEND_BUILD_FAILED_MESSAGE
|
|
173
|
-
|
|
174
|
-
collectstatic_result = subprocess.run(
|
|
175
|
-
COLLECTSTATIC_COMMAND.split(),
|
|
176
|
-
cwd=project_root,
|
|
177
|
-
capture_output=True,
|
|
178
|
-
text=True,
|
|
179
|
-
timeout=BUILD_TIMEOUT_SECONDS,
|
|
180
|
-
)
|
|
181
|
-
if collectstatic_result.returncode != 0:
|
|
182
|
-
return FRONTEND_BUILD_FAILED_MESSAGE
|
|
183
|
-
|
|
184
|
-
return None
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def extract_port_from_url(target_url: str) -> str:
|
|
188
|
-
parsed_url = urlparse(target_url)
|
|
189
|
-
if parsed_url.port:
|
|
190
|
-
return str(parsed_url.port)
|
|
191
|
-
return "8000"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def check_test_db_flag(target_url: str) -> str | None:
|
|
195
|
-
port = extract_port_from_url(target_url)
|
|
196
|
-
|
|
197
|
-
try:
|
|
198
|
-
ps_result = subprocess.run(
|
|
199
|
-
["ps", "aux"],
|
|
200
|
-
capture_output=True,
|
|
201
|
-
text=True,
|
|
202
|
-
timeout=CURL_TIMEOUT_SECONDS,
|
|
203
|
-
)
|
|
204
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
205
|
-
return None
|
|
206
|
-
|
|
207
|
-
is_runserver_found = False
|
|
208
|
-
for each_line in ps_result.stdout.splitlines():
|
|
209
|
-
if "runserver" not in each_line:
|
|
210
|
-
continue
|
|
211
|
-
if "grep" in each_line:
|
|
212
|
-
continue
|
|
213
|
-
is_runserver_found = True
|
|
214
|
-
if "--test-db" in each_line:
|
|
215
|
-
return None
|
|
216
|
-
|
|
217
|
-
if not is_runserver_found:
|
|
218
|
-
return None
|
|
219
|
-
|
|
220
|
-
return MISSING_TEST_DB_FLAG_TEMPLATE.format(port, port)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def _get_runserver_processes_on_port(target_port: str) -> list[tuple[int, str]]:
|
|
224
|
-
if psutil is None:
|
|
225
|
-
return []
|
|
226
|
-
|
|
227
|
-
runserver_processes: list[tuple[int, str]] = []
|
|
228
|
-
port_token = f":{target_port}"
|
|
229
|
-
|
|
230
|
-
for each_process in psutil.process_iter(["pid", "cmdline", "cwd"]):
|
|
231
|
-
try:
|
|
232
|
-
commandline_parts = each_process.info.get("cmdline") or []
|
|
233
|
-
if len(commandline_parts) < 3:
|
|
234
|
-
continue
|
|
235
|
-
if commandline_parts[1] != "manage.py" or commandline_parts[2] != "runserver":
|
|
236
|
-
continue
|
|
237
|
-
|
|
238
|
-
full_commandline = " ".join(commandline_parts)
|
|
239
|
-
if port_token not in full_commandline:
|
|
240
|
-
continue
|
|
241
|
-
|
|
242
|
-
process_working_directory = each_process.info.get("cwd") or ""
|
|
243
|
-
runserver_processes.append((each_process.info["pid"], process_working_directory))
|
|
244
|
-
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, KeyError):
|
|
245
|
-
continue
|
|
246
|
-
|
|
247
|
-
return runserver_processes
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def check_runserver_port_conflicts(target_url: str, project_root: str | None) -> str | None:
|
|
251
|
-
parsed_target_url = urlparse(target_url)
|
|
252
|
-
target_host = parsed_target_url.hostname or ""
|
|
253
|
-
if target_host not in {"localhost", "127.0.0.1", "0.0.0.0"}:
|
|
254
|
-
return None
|
|
255
|
-
|
|
256
|
-
target_port = str(parsed_target_url.port or 8000)
|
|
257
|
-
runserver_processes = _get_runserver_processes_on_port(target_port)
|
|
258
|
-
if len(runserver_processes) <= 1:
|
|
259
|
-
return None
|
|
260
|
-
|
|
261
|
-
project_root_realpath = os.path.realpath(project_root) if project_root else None
|
|
262
|
-
unique_directories: set[str] = set()
|
|
263
|
-
for _, directory_path in runserver_processes:
|
|
264
|
-
if not directory_path:
|
|
265
|
-
continue
|
|
266
|
-
unique_directories.add(os.path.realpath(directory_path))
|
|
267
|
-
|
|
268
|
-
if len(unique_directories) <= 1:
|
|
269
|
-
return None
|
|
270
|
-
|
|
271
|
-
if project_root_realpath and project_root_realpath in unique_directories:
|
|
272
|
-
other_worktrees = sorted(path for path in unique_directories if path != project_root_realpath)
|
|
273
|
-
if not other_worktrees:
|
|
274
|
-
return None
|
|
275
|
-
return PORT_CONFLICT_ERROR_TEMPLATE.format(target_port, ", ".join(other_worktrees))
|
|
276
|
-
|
|
277
|
-
return PORT_CONFLICT_ERROR_TEMPLATE.format(target_port, ", ".join(sorted(unique_directories)))
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def build_deny_response(reason: str) -> dict:
|
|
281
|
-
return {
|
|
282
|
-
"hookSpecificOutput": {
|
|
283
|
-
"hookEventName": "PreToolUse",
|
|
284
|
-
"permissionDecision": "deny",
|
|
285
|
-
"permissionDecisionReason": reason,
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def main() -> None:
|
|
291
|
-
try:
|
|
292
|
-
hook_input = json.load(sys.stdin)
|
|
293
|
-
except json.JSONDecodeError:
|
|
294
|
-
sys.exit(0)
|
|
295
|
-
|
|
296
|
-
tool_name = hook_input.get("tool_name", "")
|
|
297
|
-
if tool_name != "Bash":
|
|
298
|
-
sys.exit(0)
|
|
299
|
-
|
|
300
|
-
command = hook_input.get("tool_input", {}).get("command", "")
|
|
301
|
-
if not is_test_command(command):
|
|
302
|
-
sys.exit(0)
|
|
303
|
-
|
|
304
|
-
project_root = find_project_root(command)
|
|
305
|
-
is_django_project = project_root and os.path.exists(os.path.join(project_root, "manage.py"))
|
|
306
|
-
|
|
307
|
-
if not is_django_project and not is_playwright_command(command):
|
|
308
|
-
sys.exit(0)
|
|
309
|
-
|
|
310
|
-
database_error = check_django_database(command)
|
|
311
|
-
if database_error:
|
|
312
|
-
print(json.dumps(build_deny_response(database_error)))
|
|
313
|
-
sys.exit(0)
|
|
314
|
-
|
|
315
|
-
target_url = extract_target_url(command)
|
|
316
|
-
|
|
317
|
-
is_e2e_test = is_playwright_command(command)
|
|
318
|
-
if is_e2e_test:
|
|
319
|
-
conflict_error = check_runserver_port_conflicts(target_url, project_root)
|
|
320
|
-
if conflict_error:
|
|
321
|
-
print(json.dumps(build_deny_response(conflict_error)))
|
|
322
|
-
sys.exit(0)
|
|
323
|
-
|
|
324
|
-
test_db_error = check_test_db_flag(target_url)
|
|
325
|
-
if test_db_error:
|
|
326
|
-
print(json.dumps(build_deny_response(test_db_error)))
|
|
327
|
-
sys.exit(0)
|
|
328
|
-
|
|
329
|
-
frontend_build_error = build_frontend(command)
|
|
330
|
-
if frontend_build_error:
|
|
331
|
-
print(json.dumps(build_deny_response(frontend_build_error)))
|
|
332
|
-
sys.exit(0)
|
|
333
|
-
|
|
334
|
-
server_error = check_server_health(target_url)
|
|
335
|
-
if server_error:
|
|
336
|
-
print(json.dumps(build_deny_response(server_error)))
|
|
337
|
-
sys.exit(0)
|
|
338
|
-
|
|
339
|
-
sys.exit(0)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if __name__ == "__main__":
|
|
343
|
-
main()
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Production-path tests for pytest-Django and Playwright preflight selection."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
-
_DISPATCHER_PATH = _BLOCKING_DIRECTORY / "bash_pre_tool_use_dispatcher.py"
|
|
11
|
-
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
12
|
-
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
13
|
-
|
|
14
|
-
from test_hook_subprocess_support import ( # noqa: E402
|
|
15
|
-
build_bash_payload,
|
|
16
|
-
read_hook_permission_decision,
|
|
17
|
-
run_hook_as_subprocess,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _run_bash_dispatch(
|
|
22
|
-
command: str, project_root: Path
|
|
23
|
-
) -> subprocess.CompletedProcess[str]:
|
|
24
|
-
return run_hook_as_subprocess(
|
|
25
|
-
hook_script_path=_DISPATCHER_PATH,
|
|
26
|
-
payload_text=build_bash_payload(command),
|
|
27
|
-
working_directory=project_root,
|
|
28
|
-
home_directory=project_root,
|
|
29
|
-
environment_updates_by_name={"PWD": str(project_root)},
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _create_django_project_fixture(tmp_path: Path) -> Path:
|
|
34
|
-
project_root = tmp_path / "django-project"
|
|
35
|
-
project_root.mkdir()
|
|
36
|
-
(project_root / "manage.py").write_text(
|
|
37
|
-
"import sys\n\nif __name__ == '__main__':\n sys.exit(0)\n",
|
|
38
|
-
encoding="utf-8",
|
|
39
|
-
)
|
|
40
|
-
return project_root
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_dispatcher_selects_pytest_django_changed_test(tmp_path: Path) -> None:
|
|
44
|
-
project_root = _create_django_project_fixture(tmp_path)
|
|
45
|
-
|
|
46
|
-
command = "python -m pytest tests/test_models.py"
|
|
47
|
-
completed_process = _run_bash_dispatch(command, project_root)
|
|
48
|
-
|
|
49
|
-
assert completed_process.returncode == 0, completed_process.stderr
|
|
50
|
-
decision, reason = read_hook_permission_decision(completed_process.stdout)
|
|
51
|
-
assert decision == "deny"
|
|
52
|
-
assert "No database file (db.sqlite3)" in reason
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def test_dispatcher_ignores_unrecognized_django_test_runner(tmp_path: Path) -> None:
|
|
56
|
-
project_root = _create_django_project_fixture(tmp_path)
|
|
57
|
-
|
|
58
|
-
command = "python -m unittest tests/test_models.py"
|
|
59
|
-
completed_process = _run_bash_dispatch(command, project_root)
|
|
60
|
-
|
|
61
|
-
assert completed_process.returncode == 0, completed_process.stderr
|
|
62
|
-
assert completed_process.stdout == ""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_dispatcher_selects_playwright_changed_spec(tmp_path: Path) -> None:
|
|
66
|
-
command = "npx playwright test tests/account.spec.ts --base-url http://127.0.0.1:1"
|
|
67
|
-
completed_process = _run_bash_dispatch(command, tmp_path)
|
|
68
|
-
|
|
69
|
-
assert completed_process.returncode == 0, completed_process.stderr
|
|
70
|
-
decision, reason = read_hook_permission_decision(completed_process.stdout)
|
|
71
|
-
assert decision == "deny"
|
|
72
|
-
assert "Server at http://127.0.0.1:1 is unreachable" in reason
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_dispatcher_ignores_non_playwright_test_runner(tmp_path: Path) -> None:
|
|
76
|
-
command = "node tests/account.spec.ts"
|
|
77
|
-
completed_process = _run_bash_dispatch(command, tmp_path)
|
|
78
|
-
|
|
79
|
-
assert completed_process.returncode == 0, completed_process.stderr
|
|
80
|
-
assert completed_process.stdout == ""
|