claude-dev-env 1.26.4 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git_hooks_installer.mjs +245 -0
- package/bin/git_hooks_installer.test.mjs +208 -0
- package/bin/install.mjs +68 -1
- package/hooks/blocking/destructive_command_blocker.py +63 -10
- package/hooks/blocking/hedging_language_blocker.py +18 -9
- package/hooks/blocking/tdd_enforcer.py +52 -6
- package/hooks/blocking/test_destructive_command_blocker.py +169 -0
- package/hooks/blocking/test_hedging_language_blocker.py +135 -0
- package/hooks/blocking/test_tdd_enforcer.py +126 -3
- package/hooks/config/__init__.py +1 -0
- package/hooks/config/messages.py +4 -0
- package/hooks/config/test_messages.py +13 -0
- package/hooks/git-hooks/config.py +48 -0
- package/hooks/git-hooks/gate_utils.py +86 -0
- package/hooks/git-hooks/pre_commit.py +61 -0
- package/hooks/git-hooks/pre_push.py +146 -0
- package/hooks/git-hooks/test_config.py +24 -0
- package/hooks/git-hooks/test_gate_utils.py +225 -0
- package/hooks/git-hooks/test_pre_commit.py +179 -0
- package/hooks/git-hooks/test_pre_push.py +316 -0
- package/hooks/hooks.json +0 -5
- package/package.json +4 -1
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +150 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +271 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import unittest.mock
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
import bugteam_code_rules_gate as gate_module
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
|
|
17
|
+
completion = subprocess.run(
|
|
18
|
+
["git", *arguments],
|
|
19
|
+
cwd=str(repository_root),
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
errors="replace",
|
|
24
|
+
check=True,
|
|
25
|
+
)
|
|
26
|
+
return completion.stdout
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def initialize_git_repository(repository_root: Path) -> None:
|
|
30
|
+
run_git_in_repository(repository_root, "init", "--initial-branch=main")
|
|
31
|
+
run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
|
|
32
|
+
run_git_in_repository(repository_root, "config", "user.name", "Test")
|
|
33
|
+
run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def commit_all_files(repository_root: Path, commit_message: str) -> None:
|
|
37
|
+
run_git_in_repository(repository_root, "add", "-A")
|
|
38
|
+
run_git_in_repository(repository_root, "commit", "-m", commit_message)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_file(file_path: Path, content: str) -> None:
|
|
42
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
file_path.write_text(content, encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def stage_file(repository_root: Path, relative_path: str) -> None:
|
|
47
|
+
run_git_in_repository(repository_root, "add", "--", relative_path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture()
|
|
51
|
+
def temporary_git_repository(tmp_path: Path) -> Path:
|
|
52
|
+
repository_root = tmp_path / "repository_under_test"
|
|
53
|
+
repository_root.mkdir()
|
|
54
|
+
initialize_git_repository(repository_root)
|
|
55
|
+
return repository_root
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_paths_from_git_staged_returns_staged_files(
|
|
59
|
+
temporary_git_repository: Path,
|
|
60
|
+
) -> None:
|
|
61
|
+
write_file(temporary_git_repository / "committed_file.py", "one = 1\n")
|
|
62
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
63
|
+
write_file(temporary_git_repository / "newly_staged_file.py", "two = 2\n")
|
|
64
|
+
write_file(temporary_git_repository / "unstaged_file.py", "three = 3\n")
|
|
65
|
+
stage_file(temporary_git_repository, "newly_staged_file.py")
|
|
66
|
+
|
|
67
|
+
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
68
|
+
|
|
69
|
+
staged_names = {path.name for path in staged_paths}
|
|
70
|
+
assert "newly_staged_file.py" in staged_names
|
|
71
|
+
assert "unstaged_file.py" not in staged_names
|
|
72
|
+
assert "committed_file.py" not in staged_names
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_added_lines_for_staged_file_reports_new_lines(
|
|
76
|
+
temporary_git_repository: Path,
|
|
77
|
+
) -> None:
|
|
78
|
+
write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
|
|
79
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
80
|
+
write_file(
|
|
81
|
+
temporary_git_repository / "target.py",
|
|
82
|
+
"first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
|
|
83
|
+
)
|
|
84
|
+
stage_file(temporary_git_repository, "target.py")
|
|
85
|
+
|
|
86
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
87
|
+
temporary_git_repository,
|
|
88
|
+
"target.py",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
assert 3 in added_line_numbers
|
|
92
|
+
assert 4 in added_line_numbers
|
|
93
|
+
assert 1 not in added_line_numbers
|
|
94
|
+
assert 2 not in added_line_numbers
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_added_lines_for_staged_file_treats_new_file_as_fully_added(
|
|
98
|
+
temporary_git_repository: Path,
|
|
99
|
+
) -> None:
|
|
100
|
+
write_file(temporary_git_repository / "existing.py", "ignored = 0\n")
|
|
101
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
102
|
+
write_file(
|
|
103
|
+
temporary_git_repository / "brand_new.py",
|
|
104
|
+
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
105
|
+
)
|
|
106
|
+
stage_file(temporary_git_repository, "brand_new.py")
|
|
107
|
+
|
|
108
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
109
|
+
temporary_git_repository,
|
|
110
|
+
"brand_new.py",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assert added_line_numbers == {1, 2, 3}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_paths_from_git_staged_uses_null_delimiter(
|
|
117
|
+
temporary_git_repository: Path,
|
|
118
|
+
) -> None:
|
|
119
|
+
write_file(temporary_git_repository / "first.py", "a = 1\n")
|
|
120
|
+
write_file(temporary_git_repository / "second.py", "b = 2\n")
|
|
121
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
122
|
+
write_file(temporary_git_repository / "first.py", "a = 10\n")
|
|
123
|
+
write_file(temporary_git_repository / "second.py", "b = 20\n")
|
|
124
|
+
stage_file(temporary_git_repository, "first.py")
|
|
125
|
+
stage_file(temporary_git_repository, "second.py")
|
|
126
|
+
|
|
127
|
+
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
128
|
+
|
|
129
|
+
staged_names = {path.name for path in staged_paths}
|
|
130
|
+
assert staged_names == {"first.py", "second.py"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_paths_from_git_staged_warns_and_skips_non_utf8_filename(
|
|
134
|
+
tmp_path: Path,
|
|
135
|
+
capsys: pytest.CaptureFixture[str],
|
|
136
|
+
) -> None:
|
|
137
|
+
non_utf8_raw = b"valid.py\x00\xff\xfe_bad.py\x00"
|
|
138
|
+
mock_completed = unittest.mock.MagicMock()
|
|
139
|
+
mock_completed.returncode = 0
|
|
140
|
+
mock_completed.stdout = non_utf8_raw
|
|
141
|
+
|
|
142
|
+
with unittest.mock.patch("subprocess.run", return_value=mock_completed):
|
|
143
|
+
result_paths = gate_module.paths_from_git_staged(tmp_path)
|
|
144
|
+
|
|
145
|
+
captured = capsys.readouterr()
|
|
146
|
+
assert "non-UTF-8" in captured.err
|
|
147
|
+
assert len(result_paths) == 1
|
|
148
|
+
assert result_paths[0].name == "valid.py"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_staged_added_lines_by_file_maps_every_staged_code_file(
|
|
152
|
+
temporary_git_repository: Path,
|
|
153
|
+
) -> None:
|
|
154
|
+
write_file(temporary_git_repository / "already_committed.py", "zero = 0\n")
|
|
155
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
156
|
+
write_file(
|
|
157
|
+
temporary_git_repository / "already_committed.py",
|
|
158
|
+
"zero = 0\nappended = 1\n",
|
|
159
|
+
)
|
|
160
|
+
write_file(temporary_git_repository / "added_file.py", "only = 1\n")
|
|
161
|
+
stage_file(temporary_git_repository, "already_committed.py")
|
|
162
|
+
stage_file(temporary_git_repository, "added_file.py")
|
|
163
|
+
|
|
164
|
+
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
165
|
+
added_lines_map = gate_module.added_lines_by_file_staged(
|
|
166
|
+
temporary_git_repository,
|
|
167
|
+
staged_paths,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
resolved_repository_root = temporary_git_repository.resolve()
|
|
171
|
+
assert added_lines_map[resolved_repository_root / "already_committed.py"] == {2}
|
|
172
|
+
assert added_lines_map[resolved_repository_root / "added_file.py"] == {1}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
|
|
176
|
+
temporary_git_repository: Path,
|
|
177
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
178
|
+
) -> None:
|
|
179
|
+
write_file(temporary_git_repository / "module.py", "first_value = 1\n")
|
|
180
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
181
|
+
staged_content_with_banned_identifier = (
|
|
182
|
+
"first_value = 1\n"
|
|
183
|
+
"def compute_total(operand):\n"
|
|
184
|
+
" result = operand + 1\n"
|
|
185
|
+
" return result\n"
|
|
186
|
+
)
|
|
187
|
+
write_file(
|
|
188
|
+
temporary_git_repository / "module.py",
|
|
189
|
+
staged_content_with_banned_identifier,
|
|
190
|
+
)
|
|
191
|
+
stage_file(temporary_git_repository, "module.py")
|
|
192
|
+
|
|
193
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
194
|
+
exit_code = gate_module.main(["--staged"])
|
|
195
|
+
|
|
196
|
+
assert exit_code == 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_main_staged_mode_passes_when_no_staged_violations(
|
|
200
|
+
temporary_git_repository: Path,
|
|
201
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
202
|
+
) -> None:
|
|
203
|
+
write_file(temporary_git_repository / "module.py", "first_value = 1\n")
|
|
204
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
205
|
+
write_file(
|
|
206
|
+
temporary_git_repository / "module.py", "first_value = 1\nsecond_value = 2\n"
|
|
207
|
+
)
|
|
208
|
+
stage_file(temporary_git_repository, "module.py")
|
|
209
|
+
|
|
210
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
211
|
+
exit_code = gate_module.main(["--staged"])
|
|
212
|
+
|
|
213
|
+
assert exit_code == 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_main_staged_mode_exits_zero_when_nothing_staged(
|
|
217
|
+
temporary_git_repository: Path,
|
|
218
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
219
|
+
) -> None:
|
|
220
|
+
write_file(temporary_git_repository / "module.py", "first_value = 1\n")
|
|
221
|
+
commit_all_files(temporary_git_repository, "initial")
|
|
222
|
+
|
|
223
|
+
monkeypatch.chdir(temporary_git_repository)
|
|
224
|
+
exit_code = gate_module.main(["--staged"])
|
|
225
|
+
|
|
226
|
+
assert exit_code == 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
|
|
230
|
+
temporary_git_repository: Path,
|
|
231
|
+
) -> None:
|
|
232
|
+
write_file(
|
|
233
|
+
temporary_git_repository / "existing.py",
|
|
234
|
+
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
235
|
+
)
|
|
236
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
237
|
+
write_file(temporary_git_repository / "existing.py", "alpha = 1\nbeta = 2\n")
|
|
238
|
+
stage_file(temporary_git_repository, "existing.py")
|
|
239
|
+
|
|
240
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
241
|
+
temporary_git_repository,
|
|
242
|
+
"existing.py",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert added_line_numbers == set()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_is_file_absent_in_index_head_does_not_exist_in_module() -> None:
|
|
249
|
+
assert not hasattr(gate_module, "is_file_absent_in_index_head")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
|
|
253
|
+
temporary_git_repository: Path,
|
|
254
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
255
|
+
) -> None:
|
|
256
|
+
write_file(
|
|
257
|
+
temporary_git_repository / "sample.py",
|
|
258
|
+
"alpha = 1\nbeta = 2\n",
|
|
259
|
+
)
|
|
260
|
+
commit_all_files(temporary_git_repository, "baseline")
|
|
261
|
+
write_file(temporary_git_repository / "sample.py", "alpha = 1\nbeta = 2\ngamma = 3\n")
|
|
262
|
+
stage_file(temporary_git_repository, "sample.py")
|
|
263
|
+
|
|
264
|
+
monkeypatch.setattr(gate_module, "parse_added_line_numbers", lambda _text: set())
|
|
265
|
+
|
|
266
|
+
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
267
|
+
temporary_git_repository,
|
|
268
|
+
"sample.py",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
assert added_line_numbers == set()
|