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.
Files changed (89) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/everything-search/SKILL.md +18 -3
  6. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  7. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  8. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  9. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  10. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  11. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  12. package/.agents/skills/pull-request/SKILL.md +218 -0
  13. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  14. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  15. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  16. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  17. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  18. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  19. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  20. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  21. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  22. package/.agents/skills/test-runner/SKILL.md +69 -0
  23. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  24. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  25. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  26. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  27. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  28. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  29. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  30. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  31. package/bin/ever-shipped-skills.mjs +2 -0
  32. package/bin/ever-shipped-skills.test.mjs +8 -0
  33. package/bin/install.mjs +9 -1
  34. package/bin/install.prune.test.mjs +149 -0
  35. package/bin/install.test.mjs +24 -7
  36. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  37. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  40. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  41. package/hooks/gh_post_body_texts.py +195 -0
  42. package/hooks/hooks.json +0 -15
  43. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
  45. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  46. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  47. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
  48. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  49. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  50. package/hooks/observability/test_failure_recorder.py +1 -1
  51. package/hooks/pyproject.toml +1 -1
  52. package/hooks/test_gh_post_body_texts.py +79 -0
  53. package/package.json +1 -1
  54. package/rules/durable-post-artifacts.md +31 -20
  55. package/rules/filesystem-search.md +5 -5
  56. package/rules/gh-cli-conventions.md +23 -13
  57. package/rules/git-workflow.md +21 -8
  58. package/scripts/policy_lint/adapter_configuration.py +53 -12
  59. package/scripts/repository_checks/config/constants.py +0 -5
  60. package/scripts/tests/test_adapter_configuration.py +54 -0
  61. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  62. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  63. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  64. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  65. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  66. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  67. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  68. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  69. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  70. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  71. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  72. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  73. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  74. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  75. package/hooks/blocking/test_preflight_check.py +0 -343
  76. package/hooks/blocking/test_test_preflight_check.py +0 -80
  77. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  78. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  79. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  80. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  81. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  82. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  83. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
  84. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  85. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  86. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  87. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  88. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  89. 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 == ""