claude-dev-env 1.30.1 → 1.32.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/clean-coder.md +275 -111
- package/agents/code-quality-agent.md +196 -209
- package/bin/install.mjs +81 -0
- package/bin/install.test.mjs +158 -0
- package/bin/install_mypy_ini.mjs +51 -0
- package/bin/install_mypy_ini.test.mjs +121 -0
- package/commands/hook-log-extract.md +70 -0
- package/commands/hook-log-init.md +76 -0
- package/hooks/blocking/code_rules_enforcer.py +5 -3
- package/hooks/blocking/destructive_command_blocker.py +187 -0
- package/hooks/blocking/question_to_user_enforcer.py +140 -0
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
- package/hooks/blocking/test_destructive_command_blocker.py +397 -0
- package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +148 -0
- package/hooks/blocking/windows_rmtree_blocker.py +106 -0
- package/hooks/config/hook_log_extractor_constants.py +234 -0
- package/hooks/config/messages.py +3 -0
- package/hooks/config/session_env_cleanup_constants.py +18 -0
- package/hooks/config/test_hook_log_extractor_constants.py +123 -0
- package/hooks/config/test_messages.py +5 -0
- package/hooks/config/test_session_env_cleanup_constants.py +55 -0
- package/hooks/diagnostic/hook_log_extractor.py +907 -0
- package/hooks/diagnostic/hook_log_init.py +202 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +172 -0
- package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
- package/hooks/diagnostic/migrations/README.md +77 -0
- package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
- package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
- package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
- package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
- package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
- package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
- package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
- package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
- package/hooks/diagnostic/schema.sql +51 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
- package/hooks/diagnostic/test_hook_log_init.py +227 -0
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +345 -0
- package/hooks/hooks.json +25 -0
- package/hooks/session/session_env_cleanup.py +129 -0
- package/hooks/session/test_session_env_cleanup.py +278 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +44 -0
- package/rules/windows-filesystem-safe.md +93 -0
- package/scripts/config/test_spec_implementer_prompt.py +0 -4
- package/scripts/test_groq_bugteam_spec.py +0 -8
- package/skills/bugteam/SKILL.md +15 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +238 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
- package/skills/logifix/SKILL.md +69 -0
- package/skills/logifix/scripts/logifix.ps1 +205 -0
- package/skills/rebase/SKILL.md +157 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Failing-first tests for hook_log_init.
|
|
2
|
+
|
|
3
|
+
Covers: environment variable verification, idempotent DDL apply, sentinel
|
|
4
|
+
insert/select/delete round-trip, and success report format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
|
+
|
|
19
|
+
from diagnostic import hook_log_init
|
|
20
|
+
from config import hook_log_extractor_constants
|
|
21
|
+
from config.hook_log_extractor_constants import (
|
|
22
|
+
EXIT_CODE_ENVIRONMENT_MISSING,
|
|
23
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
|
|
24
|
+
OUTCOME_INIT_PROBE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_main_exits_1_when_neon_database_url_missing(
|
|
29
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
30
|
+
capsys: pytest.CaptureFixture[str],
|
|
31
|
+
) -> None:
|
|
32
|
+
monkeypatch.delenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, raising=False)
|
|
33
|
+
|
|
34
|
+
exit_code = hook_log_init.main()
|
|
35
|
+
|
|
36
|
+
assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
|
|
37
|
+
captured = capsys.readouterr()
|
|
38
|
+
assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_main_exits_1_when_neon_database_url_is_empty_string(
|
|
42
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
43
|
+
capsys: pytest.CaptureFixture[str],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Empty NEON_HOOK_LOGS_DATABASE_URL must fire missing-env branch."""
|
|
46
|
+
monkeypatch.setenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, "")
|
|
47
|
+
|
|
48
|
+
exit_code = hook_log_init.main()
|
|
49
|
+
|
|
50
|
+
assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
|
|
51
|
+
captured = capsys.readouterr()
|
|
52
|
+
assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_main_exits_1_when_neon_database_url_is_whitespace_only(
|
|
56
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
57
|
+
capsys: pytest.CaptureFixture[str],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Whitespace-only NEON_HOOK_LOGS_DATABASE_URL must fire missing-env branch."""
|
|
60
|
+
monkeypatch.setenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, " ")
|
|
61
|
+
|
|
62
|
+
exit_code = hook_log_init.main()
|
|
63
|
+
|
|
64
|
+
assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
|
|
65
|
+
captured = capsys.readouterr()
|
|
66
|
+
assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_apply_schema_executes_ddl_from_schema_sql() -> None:
|
|
70
|
+
fake_cursor = MagicMock()
|
|
71
|
+
fake_connection = MagicMock()
|
|
72
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
73
|
+
|
|
74
|
+
hook_log_init.apply_schema(fake_connection)
|
|
75
|
+
|
|
76
|
+
all_executed_statements = [
|
|
77
|
+
each_call.args[0] for each_call in fake_cursor.execute.call_args_list
|
|
78
|
+
]
|
|
79
|
+
joined_schema_text = "\n".join(all_executed_statements)
|
|
80
|
+
assert "hook_events" in joined_schema_text
|
|
81
|
+
assert "CREATE TABLE IF NOT EXISTS" in joined_schema_text
|
|
82
|
+
assert "blocked_commands" in joined_schema_text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_run_sentinel_round_trip_inserts_selects_and_deletes() -> None:
|
|
86
|
+
fake_cursor = MagicMock()
|
|
87
|
+
fake_cursor.fetchone.side_effect = [(1,), (1,)]
|
|
88
|
+
fake_connection = MagicMock()
|
|
89
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
90
|
+
|
|
91
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
92
|
+
|
|
93
|
+
all_executed_statements = [
|
|
94
|
+
each_call.args[0].upper() for each_call in fake_cursor.execute.call_args_list
|
|
95
|
+
]
|
|
96
|
+
assert any("INSERT" in each_statement for each_statement in all_executed_statements)
|
|
97
|
+
assert any("SELECT" in each_statement for each_statement in all_executed_statements)
|
|
98
|
+
assert any("DELETE" in each_statement for each_statement in all_executed_statements)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_run_sentinel_round_trip_uses_init_probe_outcome() -> None:
|
|
102
|
+
fake_cursor = MagicMock()
|
|
103
|
+
fake_cursor.fetchone.side_effect = [(1,), (1,)]
|
|
104
|
+
fake_connection = MagicMock()
|
|
105
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
106
|
+
|
|
107
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
108
|
+
|
|
109
|
+
all_call_parameters = [
|
|
110
|
+
each_call.args for each_call in fake_cursor.execute.call_args_list
|
|
111
|
+
]
|
|
112
|
+
joined_parameter_text = " ".join(
|
|
113
|
+
str(each_parameter_tuple) for each_parameter_tuple in all_call_parameters
|
|
114
|
+
)
|
|
115
|
+
assert OUTCOME_INIT_PROBE in joined_parameter_text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_print_success_report_includes_host_table_and_rowcount(
|
|
119
|
+
capsys: pytest.CaptureFixture[str],
|
|
120
|
+
) -> None:
|
|
121
|
+
hook_log_init.print_success_report(
|
|
122
|
+
neon_host="ep-fake-neon.aws.neon.tech",
|
|
123
|
+
table_name="hook_events",
|
|
124
|
+
row_count=42,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
captured = capsys.readouterr()
|
|
128
|
+
assert "ep-fake-neon.aws.neon.tech" in captured.out
|
|
129
|
+
assert "hook_events" in captured.out
|
|
130
|
+
assert "42" in captured.out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_main_happy_path_returns_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
134
|
+
monkeypatch.setenv(
|
|
135
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
|
|
136
|
+
"postgres://u:p@ep-fake-host.aws.neon.tech/db",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
fake_cursor = MagicMock()
|
|
140
|
+
fake_cursor.fetchone.side_effect = [(1,), (1,), (5,)]
|
|
141
|
+
fake_connection = MagicMock()
|
|
142
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
143
|
+
|
|
144
|
+
with patch.object(hook_log_init, "connect_to_neon", return_value=fake_connection):
|
|
145
|
+
exit_code = hook_log_init.main()
|
|
146
|
+
|
|
147
|
+
assert exit_code == 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_connect_to_neon_raises_when_psycopg_missing(
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
152
|
+
) -> None:
|
|
153
|
+
monkeypatch.setenv(
|
|
154
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
|
|
155
|
+
"postgres://u:p@ep-fake-host.aws.neon.tech/db",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
with patch.object(hook_log_init, "psycopg", None):
|
|
159
|
+
with pytest.raises(hook_log_init.MissingPsycopgDependencyError):
|
|
160
|
+
hook_log_init.connect_to_neon()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_run_sentinel_round_trip_raises_when_select_returns_nothing() -> None:
|
|
164
|
+
fake_cursor = MagicMock()
|
|
165
|
+
fake_cursor.fetchone.side_effect = [(1,), None]
|
|
166
|
+
fake_connection = MagicMock()
|
|
167
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
168
|
+
|
|
169
|
+
with pytest.raises(RuntimeError):
|
|
170
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_run_sentinel_round_trip_raises_when_select_returns_wrong_id() -> None:
|
|
174
|
+
fake_cursor = MagicMock()
|
|
175
|
+
fake_cursor.fetchone.side_effect = [(1,), (999,)]
|
|
176
|
+
fake_connection = MagicMock()
|
|
177
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
178
|
+
|
|
179
|
+
with pytest.raises(RuntimeError):
|
|
180
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_run_sentinel_round_trip_raises_when_insert_returns_no_row() -> None:
|
|
184
|
+
fake_cursor = MagicMock()
|
|
185
|
+
fake_cursor.fetchone.return_value = None
|
|
186
|
+
fake_connection = MagicMock()
|
|
187
|
+
fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
|
|
188
|
+
|
|
189
|
+
with pytest.raises(RuntimeError):
|
|
190
|
+
hook_log_init.run_sentinel_round_trip(fake_connection)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_claude_home_resolver_falls_back_to_home_when_env_var_is_empty(
|
|
194
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Empty CLAUDE_HOME must fall back to ~/.claude (not process CWD)."""
|
|
197
|
+
monkeypatch.setenv("CLAUDE_HOME", "")
|
|
198
|
+
|
|
199
|
+
resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
|
|
200
|
+
|
|
201
|
+
expected_home = Path.home() / ".claude"
|
|
202
|
+
assert resolved_home == expected_home
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_claude_home_resolver_falls_back_to_home_when_env_var_is_whitespace(
|
|
206
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Whitespace-only CLAUDE_HOME must fall back to ~/.claude."""
|
|
209
|
+
monkeypatch.setenv("CLAUDE_HOME", " ")
|
|
210
|
+
|
|
211
|
+
resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
|
|
212
|
+
|
|
213
|
+
expected_home = Path.home() / ".claude"
|
|
214
|
+
assert resolved_home == expected_home
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_claude_home_resolver_honors_explicit_path(
|
|
218
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
219
|
+
tmp_path: Path,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Explicit CLAUDE_HOME path must win over the home fallback."""
|
|
222
|
+
explicit_claude_home = tmp_path / "explicit-claude-home"
|
|
223
|
+
monkeypatch.setenv("CLAUDE_HOME", str(explicit_claude_home))
|
|
224
|
+
|
|
225
|
+
resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
|
|
226
|
+
|
|
227
|
+
assert resolved_home == explicit_claude_home
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Tests for hook_log_stop_wrapper -- debounced, fire-and-forget Stop hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
14
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
16
|
+
|
|
17
|
+
from diagnostic import hook_log_stop_wrapper
|
|
18
|
+
from config.hook_log_extractor_constants import (
|
|
19
|
+
BWS_ACCESS_TOKEN_ENV_VAR,
|
|
20
|
+
BWS_EXECUTABLE_NAME,
|
|
21
|
+
FLAG_INCREMENTAL,
|
|
22
|
+
STOP_WRAPPER_DEBOUNCE_SECONDS,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _redirect_timestamp_path(
|
|
27
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
28
|
+
) -> Path:
|
|
29
|
+
timestamp_file = tmp_path / "stop_wrapper_last_run.txt"
|
|
30
|
+
monkeypatch.setattr(
|
|
31
|
+
hook_log_stop_wrapper,
|
|
32
|
+
"_last_run_timestamp_path",
|
|
33
|
+
lambda: timestamp_file,
|
|
34
|
+
)
|
|
35
|
+
return timestamp_file
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_main_returns_zero_when_extractor_spawn_raises(
|
|
39
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
40
|
+
tmp_path: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
43
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
44
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
45
|
+
|
|
46
|
+
def _raise(*_args: object, **_kwargs: object) -> None:
|
|
47
|
+
raise RuntimeError("boom")
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
|
|
50
|
+
|
|
51
|
+
assert hook_log_stop_wrapper.main() == 0
|
|
52
|
+
assert not timestamp_file.exists()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_main_writes_timestamp_before_spawn_to_narrow_toctou_window(
|
|
56
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
57
|
+
tmp_path: Path,
|
|
58
|
+
) -> None:
|
|
59
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
60
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
61
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
62
|
+
|
|
63
|
+
timestamp_file_existed_at_spawn_time: list[bool] = []
|
|
64
|
+
|
|
65
|
+
def _capture_timestamp_state(*_args: object, **_kwargs: object) -> object:
|
|
66
|
+
timestamp_file_existed_at_spawn_time.append(timestamp_file.exists())
|
|
67
|
+
return object()
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(
|
|
70
|
+
hook_log_stop_wrapper.subprocess, "Popen", _capture_timestamp_state
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
hook_log_stop_wrapper.main()
|
|
74
|
+
|
|
75
|
+
assert timestamp_file_existed_at_spawn_time == [True]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_main_removes_timestamp_when_spawn_fails_to_allow_retry(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
tmp_path: Path,
|
|
81
|
+
) -> None:
|
|
82
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
83
|
+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
stale_timestamp = time.time() - STOP_WRAPPER_DEBOUNCE_SECONDS - 1
|
|
85
|
+
timestamp_file.write_text(str(stale_timestamp))
|
|
86
|
+
|
|
87
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
88
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
89
|
+
|
|
90
|
+
def _raise(*_args: object, **_kwargs: object) -> None:
|
|
91
|
+
raise RuntimeError("boom")
|
|
92
|
+
|
|
93
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
|
|
94
|
+
|
|
95
|
+
assert hook_log_stop_wrapper.main() == 0
|
|
96
|
+
assert not timestamp_file.exists()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_main_uses_bws_when_token_and_binary_present(
|
|
100
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
101
|
+
tmp_path: Path,
|
|
102
|
+
) -> None:
|
|
103
|
+
_redirect_timestamp_path(monkeypatch, tmp_path)
|
|
104
|
+
monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
|
|
105
|
+
monkeypatch.setattr(
|
|
106
|
+
hook_log_stop_wrapper.shutil,
|
|
107
|
+
"which",
|
|
108
|
+
lambda _name: "/usr/local/bin/bws",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
captured_commands: list[list[str]] = []
|
|
112
|
+
|
|
113
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
114
|
+
captured_commands.append(list(command_list))
|
|
115
|
+
return object()
|
|
116
|
+
|
|
117
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
118
|
+
|
|
119
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
120
|
+
|
|
121
|
+
assert exit_code == 0
|
|
122
|
+
assert len(captured_commands) == 1
|
|
123
|
+
assert captured_commands[0][0] == BWS_EXECUTABLE_NAME
|
|
124
|
+
assert FLAG_INCREMENTAL in captured_commands[0]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_main_skips_bws_when_token_missing(
|
|
128
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
_redirect_timestamp_path(monkeypatch, tmp_path)
|
|
132
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
133
|
+
monkeypatch.setattr(
|
|
134
|
+
hook_log_stop_wrapper.shutil,
|
|
135
|
+
"which",
|
|
136
|
+
lambda _name: "/usr/local/bin/bws",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
captured_commands: list[list[str]] = []
|
|
140
|
+
|
|
141
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
142
|
+
captured_commands.append(list(command_list))
|
|
143
|
+
return object()
|
|
144
|
+
|
|
145
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
146
|
+
|
|
147
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
148
|
+
|
|
149
|
+
assert exit_code == 0
|
|
150
|
+
assert len(captured_commands) == 1
|
|
151
|
+
assert BWS_EXECUTABLE_NAME not in captured_commands[0]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_main_skips_bws_when_binary_missing(
|
|
155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
156
|
+
tmp_path: Path,
|
|
157
|
+
) -> None:
|
|
158
|
+
_redirect_timestamp_path(monkeypatch, tmp_path)
|
|
159
|
+
monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
|
|
160
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
161
|
+
|
|
162
|
+
captured_commands: list[list[str]] = []
|
|
163
|
+
|
|
164
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
165
|
+
captured_commands.append(list(command_list))
|
|
166
|
+
return object()
|
|
167
|
+
|
|
168
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
169
|
+
|
|
170
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
171
|
+
|
|
172
|
+
assert exit_code == 0
|
|
173
|
+
assert len(captured_commands) == 1
|
|
174
|
+
assert BWS_EXECUTABLE_NAME not in captured_commands[0]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_main_skips_spawn_when_recent_timestamp_within_debounce_window(
|
|
178
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
179
|
+
tmp_path: Path,
|
|
180
|
+
) -> None:
|
|
181
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
182
|
+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
timestamp_file.write_text(str(time.time()))
|
|
184
|
+
|
|
185
|
+
captured_commands: list[list[str]] = []
|
|
186
|
+
|
|
187
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
188
|
+
captured_commands.append(list(command_list))
|
|
189
|
+
return object()
|
|
190
|
+
|
|
191
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
192
|
+
|
|
193
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
194
|
+
|
|
195
|
+
assert exit_code == 0
|
|
196
|
+
assert len(captured_commands) == 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_main_spawns_when_timestamp_older_than_debounce_window(
|
|
200
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
201
|
+
tmp_path: Path,
|
|
202
|
+
) -> None:
|
|
203
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
204
|
+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
stale_timestamp = time.time() - STOP_WRAPPER_DEBOUNCE_SECONDS - 1
|
|
206
|
+
timestamp_file.write_text(str(stale_timestamp))
|
|
207
|
+
|
|
208
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
209
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
210
|
+
|
|
211
|
+
captured_commands: list[list[str]] = []
|
|
212
|
+
|
|
213
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
214
|
+
captured_commands.append(list(command_list))
|
|
215
|
+
return object()
|
|
216
|
+
|
|
217
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
218
|
+
|
|
219
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
220
|
+
|
|
221
|
+
assert exit_code == 0
|
|
222
|
+
assert len(captured_commands) == 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_main_writes_current_timestamp_to_file(
|
|
226
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
227
|
+
tmp_path: Path,
|
|
228
|
+
) -> None:
|
|
229
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
230
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
231
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
232
|
+
monkeypatch.setattr(
|
|
233
|
+
hook_log_stop_wrapper.subprocess,
|
|
234
|
+
"Popen",
|
|
235
|
+
lambda *_args, **_kwargs: object(),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
timestamp_before_call = time.time()
|
|
239
|
+
hook_log_stop_wrapper.main()
|
|
240
|
+
timestamp_after_call = time.time()
|
|
241
|
+
|
|
242
|
+
assert timestamp_file.exists()
|
|
243
|
+
written_timestamp = float(timestamp_file.read_text().strip())
|
|
244
|
+
assert timestamp_before_call <= written_timestamp <= timestamp_after_call
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_main_passes_devnull_streams_to_detached_spawn(
|
|
248
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
249
|
+
tmp_path: Path,
|
|
250
|
+
) -> None:
|
|
251
|
+
_redirect_timestamp_path(monkeypatch, tmp_path)
|
|
252
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
253
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
254
|
+
|
|
255
|
+
captured_kwargs: dict[str, object] = {}
|
|
256
|
+
|
|
257
|
+
def _fake_popen(command_list: list[str], **kwargs: object) -> object:
|
|
258
|
+
captured_kwargs.update(kwargs)
|
|
259
|
+
return object()
|
|
260
|
+
|
|
261
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
262
|
+
|
|
263
|
+
hook_log_stop_wrapper.main()
|
|
264
|
+
|
|
265
|
+
assert captured_kwargs.get("stdin") is hook_log_stop_wrapper.subprocess.DEVNULL
|
|
266
|
+
assert captured_kwargs.get("stdout") is hook_log_stop_wrapper.subprocess.DEVNULL
|
|
267
|
+
assert captured_kwargs.get("stderr") is hook_log_stop_wrapper.subprocess.DEVNULL
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_main_recovers_when_timestamp_file_is_corrupted(
|
|
271
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
272
|
+
tmp_path: Path,
|
|
273
|
+
) -> None:
|
|
274
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
275
|
+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
timestamp_file.write_text("not-a-float")
|
|
277
|
+
|
|
278
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
279
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
280
|
+
|
|
281
|
+
captured_commands: list[list[str]] = []
|
|
282
|
+
|
|
283
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
284
|
+
captured_commands.append(list(command_list))
|
|
285
|
+
return object()
|
|
286
|
+
|
|
287
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
288
|
+
|
|
289
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
290
|
+
|
|
291
|
+
assert exit_code == 0
|
|
292
|
+
assert len(captured_commands) == 1
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_main_treats_future_timestamp_as_not_debounced(
|
|
296
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
297
|
+
tmp_path: Path,
|
|
298
|
+
) -> None:
|
|
299
|
+
timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
|
|
300
|
+
timestamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
far_future_timestamp = time.time() + 3600
|
|
302
|
+
timestamp_file.write_text(str(far_future_timestamp))
|
|
303
|
+
|
|
304
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
305
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
306
|
+
|
|
307
|
+
captured_commands: list[list[str]] = []
|
|
308
|
+
|
|
309
|
+
def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
|
|
310
|
+
captured_commands.append(list(command_list))
|
|
311
|
+
return object()
|
|
312
|
+
|
|
313
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
314
|
+
|
|
315
|
+
exit_code = hook_log_stop_wrapper.main()
|
|
316
|
+
|
|
317
|
+
assert exit_code == 0
|
|
318
|
+
assert len(captured_commands) == 1
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_main_passes_hidden_startupinfo_on_windows(
|
|
322
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
323
|
+
tmp_path: Path,
|
|
324
|
+
) -> None:
|
|
325
|
+
_redirect_timestamp_path(monkeypatch, tmp_path)
|
|
326
|
+
monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
|
|
327
|
+
monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
|
|
328
|
+
|
|
329
|
+
captured_kwargs: dict[str, object] = {}
|
|
330
|
+
|
|
331
|
+
def _fake_popen(command_list: list[str], **kwargs: object) -> object:
|
|
332
|
+
captured_kwargs.update(kwargs)
|
|
333
|
+
return object()
|
|
334
|
+
|
|
335
|
+
monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
|
|
336
|
+
|
|
337
|
+
hook_log_stop_wrapper.main()
|
|
338
|
+
|
|
339
|
+
if os.name == "nt":
|
|
340
|
+
startup_info = captured_kwargs.get("startupinfo")
|
|
341
|
+
assert startup_info is not None
|
|
342
|
+
assert startup_info.dwFlags & subprocess.STARTF_USESHOWWINDOW
|
|
343
|
+
assert startup_info.wShowWindow == subprocess.SW_HIDE
|
|
344
|
+
else:
|
|
345
|
+
assert captured_kwargs.get("start_new_session") is True
|
package/hooks/hooks.json
CHANGED
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
"type": "command",
|
|
40
40
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/tdd_enforcer.py",
|
|
41
41
|
"timeout": 10
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"type": "command",
|
|
45
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
46
|
+
"timeout": 10
|
|
42
47
|
}
|
|
43
48
|
]
|
|
44
49
|
},
|
|
@@ -89,6 +94,11 @@
|
|
|
89
94
|
"type": "command",
|
|
90
95
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
91
96
|
"timeout": 10
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"type": "command",
|
|
100
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
101
|
+
"timeout": 10
|
|
92
102
|
}
|
|
93
103
|
]
|
|
94
104
|
},
|
|
@@ -112,6 +122,11 @@
|
|
|
112
122
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_data_dir_cleanup.py",
|
|
113
123
|
"timeout": 10
|
|
114
124
|
},
|
|
125
|
+
{
|
|
126
|
+
"type": "command",
|
|
127
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py",
|
|
128
|
+
"timeout": 10
|
|
129
|
+
},
|
|
115
130
|
{
|
|
116
131
|
"type": "command",
|
|
117
132
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py",
|
|
@@ -133,6 +148,16 @@
|
|
|
133
148
|
"type": "command",
|
|
134
149
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging_language_blocker.py",
|
|
135
150
|
"timeout": 10
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"type": "command",
|
|
154
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/question_to_user_enforcer.py",
|
|
155
|
+
"timeout": 10
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"type": "command",
|
|
159
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/diagnostic/hook_log_stop_wrapper.py",
|
|
160
|
+
"timeout": 30
|
|
136
161
|
}
|
|
137
162
|
]
|
|
138
163
|
}
|