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,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
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 pre_commit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_gate_script_returning(exit_code: int, target_path: Path) -> Path:
|
|
17
|
+
target_path.write_text(
|
|
18
|
+
f"import sys\nsys.exit({exit_code})\n",
|
|
19
|
+
encoding="utf-8",
|
|
20
|
+
)
|
|
21
|
+
return target_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture()
|
|
25
|
+
def fake_gate_script_blocking(tmp_path: Path) -> Path:
|
|
26
|
+
return make_gate_script_returning(1, tmp_path / "fake_gate_blocking.py")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture()
|
|
30
|
+
def fake_gate_script_passing(tmp_path: Path) -> Path:
|
|
31
|
+
return make_gate_script_returning(0, tmp_path / "fake_gate_passing.py")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_main_exits_zero_when_gate_script_missing(
|
|
35
|
+
tmp_path: Path,
|
|
36
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
37
|
+
) -> None:
|
|
38
|
+
monkeypatch.setenv(
|
|
39
|
+
"CODE_RULES_GATE_PATH",
|
|
40
|
+
str(tmp_path / "does_not_exist.py"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
exit_code = pre_commit.main()
|
|
44
|
+
|
|
45
|
+
assert exit_code == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_main_propagates_blocking_exit_code_from_gate(
|
|
49
|
+
fake_gate_script_blocking: Path,
|
|
50
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
51
|
+
) -> None:
|
|
52
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(fake_gate_script_blocking))
|
|
53
|
+
|
|
54
|
+
exit_code = pre_commit.main()
|
|
55
|
+
|
|
56
|
+
assert exit_code == 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_main_propagates_passing_exit_code_from_gate(
|
|
60
|
+
fake_gate_script_passing: Path,
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
) -> None:
|
|
63
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(fake_gate_script_passing))
|
|
64
|
+
|
|
65
|
+
exit_code = pre_commit.main()
|
|
66
|
+
|
|
67
|
+
assert exit_code == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_main_invokes_gate_with_staged_flag(
|
|
71
|
+
tmp_path: Path,
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
73
|
+
) -> None:
|
|
74
|
+
recorded_arguments_path = tmp_path / "recorded_arguments.txt"
|
|
75
|
+
recording_gate_script_path = tmp_path / "recording_gate.py"
|
|
76
|
+
recording_gate_script_path.write_text(
|
|
77
|
+
"import sys, pathlib\n"
|
|
78
|
+
f'pathlib.Path(r"{recorded_arguments_path}").write_text('
|
|
79
|
+
"'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
|
|
80
|
+
"sys.exit(0)\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(recording_gate_script_path))
|
|
84
|
+
|
|
85
|
+
exit_code = pre_commit.main()
|
|
86
|
+
|
|
87
|
+
assert exit_code == 0
|
|
88
|
+
assert recorded_arguments_path.exists(), (
|
|
89
|
+
f"recording gate did not write to {recorded_arguments_path}"
|
|
90
|
+
)
|
|
91
|
+
recorded_arguments = recorded_arguments_path.read_text(
|
|
92
|
+
encoding="utf-8"
|
|
93
|
+
).splitlines()
|
|
94
|
+
assert recorded_arguments == ["--staged"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_main_exits_two_when_invoke_gate_raises_oserror(
|
|
98
|
+
tmp_path: Path,
|
|
99
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
100
|
+
) -> None:
|
|
101
|
+
existing_gate_path = tmp_path / "gate.py"
|
|
102
|
+
existing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
103
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(existing_gate_path))
|
|
104
|
+
|
|
105
|
+
original_run = __import__("subprocess").run
|
|
106
|
+
|
|
107
|
+
def raising_run(*args: object, **kwargs: object) -> object:
|
|
108
|
+
raise OSError("no such file")
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(__import__("subprocess"), "run", raising_run)
|
|
111
|
+
|
|
112
|
+
exit_code = pre_commit.main()
|
|
113
|
+
|
|
114
|
+
assert exit_code == 2
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_main_emits_stderr_warning_when_gate_script_missing(
|
|
118
|
+
tmp_path: Path,
|
|
119
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
120
|
+
capsys: pytest.CaptureFixture[str],
|
|
121
|
+
) -> None:
|
|
122
|
+
monkeypatch.setenv(
|
|
123
|
+
"CODE_RULES_GATE_PATH",
|
|
124
|
+
str(tmp_path / "does_not_exist.py"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
exit_code = pre_commit.main()
|
|
128
|
+
|
|
129
|
+
assert exit_code == 0
|
|
130
|
+
captured = capsys.readouterr()
|
|
131
|
+
assert "gate script not found" in captured.err
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_invoke_gate_returns_infrastructure_failure_when_strict_resolve_raises(
|
|
135
|
+
tmp_path: Path,
|
|
136
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
137
|
+
) -> None:
|
|
138
|
+
missing_gate_path = tmp_path / "missing_gate.py"
|
|
139
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(missing_gate_path))
|
|
140
|
+
missing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
141
|
+
|
|
142
|
+
original_resolve = Path.resolve
|
|
143
|
+
|
|
144
|
+
def raising_resolve(self: Path, strict: bool = False) -> Path:
|
|
145
|
+
if strict and self == missing_gate_path.resolve():
|
|
146
|
+
raise FileNotFoundError("not found")
|
|
147
|
+
return original_resolve(self, strict=strict)
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr(Path, "resolve", raising_resolve)
|
|
150
|
+
|
|
151
|
+
exit_code = pre_commit.invoke_gate(missing_gate_path)
|
|
152
|
+
|
|
153
|
+
assert exit_code == 2
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_invoke_gate_uses_resolved_path(
|
|
157
|
+
tmp_path: Path,
|
|
158
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
159
|
+
) -> None:
|
|
160
|
+
real_gate_dir = tmp_path / "real"
|
|
161
|
+
real_gate_dir.mkdir()
|
|
162
|
+
real_gate_path = real_gate_dir / "gate.py"
|
|
163
|
+
recorded_path_file = tmp_path / "recorded_path.txt"
|
|
164
|
+
real_gate_path.write_text(
|
|
165
|
+
"import sys, pathlib\n"
|
|
166
|
+
f'pathlib.Path(r"{recorded_path_file}").write_text(sys.argv[0], encoding="utf-8")\n'
|
|
167
|
+
"sys.exit(0)\n",
|
|
168
|
+
encoding="utf-8",
|
|
169
|
+
)
|
|
170
|
+
symlink_gate_path = tmp_path / "link_gate.py"
|
|
171
|
+
symlink_gate_path.symlink_to(real_gate_path)
|
|
172
|
+
resolved_path = symlink_gate_path.resolve()
|
|
173
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(symlink_gate_path))
|
|
174
|
+
|
|
175
|
+
exit_code = pre_commit.main()
|
|
176
|
+
|
|
177
|
+
assert exit_code == 0
|
|
178
|
+
executed_path = recorded_path_file.read_text(encoding="utf-8")
|
|
179
|
+
assert executed_path == str(resolved_path)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
import pre_push
|
|
15
|
+
import config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ALL_ZEROS_OBJECT_NAME: str = "0" * 40
|
|
19
|
+
NON_ZERO_LOCAL_SHA: str = "a" * 40
|
|
20
|
+
NON_ZERO_REMOTE_SHA_ONE: str = "1" * 40
|
|
21
|
+
NON_ZERO_REMOTE_SHA_TWO: str = "2" * 40
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_resolve_base_reference_uses_remote_object_when_non_zero() -> None:
|
|
25
|
+
stdin_text = (
|
|
26
|
+
f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
base_reference = pre_push.resolve_base_reference_from_stdin(stdin_text)
|
|
30
|
+
|
|
31
|
+
assert base_reference == NON_ZERO_REMOTE_SHA_ONE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_resolve_base_reference_falls_back_when_remote_is_all_zeros() -> None:
|
|
35
|
+
stdin_text = f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {ALL_ZEROS_OBJECT_NAME}\n"
|
|
36
|
+
|
|
37
|
+
base_reference = pre_push.resolve_base_reference_from_stdin(stdin_text)
|
|
38
|
+
|
|
39
|
+
assert base_reference == pre_push.DEFAULT_REMOTE_BASE_REFERENCE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_resolve_base_reference_falls_back_when_stdin_empty() -> None:
|
|
43
|
+
base_reference = pre_push.resolve_base_reference_from_stdin("")
|
|
44
|
+
|
|
45
|
+
assert base_reference == pre_push.DEFAULT_REMOTE_BASE_REFERENCE
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_resolve_base_reference_prefers_first_non_zero_remote_object_among_many() -> (
|
|
49
|
+
None
|
|
50
|
+
):
|
|
51
|
+
stdin_text = (
|
|
52
|
+
f"refs/heads/new_branch {ALL_ZEROS_OBJECT_NAME} refs/heads/new_branch {ALL_ZEROS_OBJECT_NAME}\n"
|
|
53
|
+
f"refs/heads/existing {NON_ZERO_LOCAL_SHA} refs/heads/existing {NON_ZERO_REMOTE_SHA_TWO}\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
base_reference = pre_push.resolve_base_reference_from_stdin(stdin_text)
|
|
57
|
+
|
|
58
|
+
assert base_reference == NON_ZERO_REMOTE_SHA_TWO
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_main_exits_zero_when_gate_script_missing(
|
|
62
|
+
tmp_path: Path,
|
|
63
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
64
|
+
) -> None:
|
|
65
|
+
monkeypatch.setenv(
|
|
66
|
+
"CODE_RULES_GATE_PATH",
|
|
67
|
+
str(tmp_path / "does_not_exist.py"),
|
|
68
|
+
)
|
|
69
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
70
|
+
|
|
71
|
+
exit_code = pre_push.main()
|
|
72
|
+
|
|
73
|
+
assert exit_code == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_main_invokes_gate_with_resolved_base_reference(
|
|
77
|
+
tmp_path: Path,
|
|
78
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
79
|
+
) -> None:
|
|
80
|
+
recorded_arguments_path = tmp_path / "recorded_arguments.txt"
|
|
81
|
+
recording_gate_script_path = tmp_path / "recording_gate.py"
|
|
82
|
+
recording_gate_script_path.write_text(
|
|
83
|
+
"import sys, pathlib\n"
|
|
84
|
+
f'pathlib.Path(r"{recorded_arguments_path}").write_text('
|
|
85
|
+
"'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
|
|
86
|
+
"sys.exit(0)\n",
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(recording_gate_script_path))
|
|
90
|
+
remote_sha = "9" * 40
|
|
91
|
+
monkeypatch.setattr(
|
|
92
|
+
sys,
|
|
93
|
+
"stdin",
|
|
94
|
+
io.StringIO(f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {remote_sha}\n"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
exit_code = pre_push.main()
|
|
98
|
+
|
|
99
|
+
assert exit_code == 0
|
|
100
|
+
assert recorded_arguments_path.exists(), (
|
|
101
|
+
f"recording gate did not write to {recorded_arguments_path}"
|
|
102
|
+
)
|
|
103
|
+
recorded_arguments = recorded_arguments_path.read_text(
|
|
104
|
+
encoding="utf-8"
|
|
105
|
+
).splitlines()
|
|
106
|
+
assert recorded_arguments == ["--base", remote_sha]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_main_propagates_blocking_exit_code_from_gate(
|
|
110
|
+
tmp_path: Path,
|
|
111
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
112
|
+
) -> None:
|
|
113
|
+
blocking_gate_script_path = tmp_path / "blocking_gate.py"
|
|
114
|
+
blocking_gate_script_path.write_text(
|
|
115
|
+
"import sys\nsys.exit(1)\n",
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
118
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(blocking_gate_script_path))
|
|
119
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
120
|
+
|
|
121
|
+
exit_code = pre_push.main()
|
|
122
|
+
|
|
123
|
+
assert exit_code == 1
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_main_propagates_infrastructure_failure_exit_code_from_gate(
|
|
127
|
+
tmp_path: Path,
|
|
128
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
129
|
+
) -> None:
|
|
130
|
+
infrastructure_failure_gate_path = tmp_path / "infrastructure_failure_gate.py"
|
|
131
|
+
infrastructure_failure_gate_path.write_text(
|
|
132
|
+
"import sys\nsys.exit(2)\n",
|
|
133
|
+
encoding="utf-8",
|
|
134
|
+
)
|
|
135
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(infrastructure_failure_gate_path))
|
|
136
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
137
|
+
|
|
138
|
+
exit_code = pre_push.main()
|
|
139
|
+
|
|
140
|
+
assert exit_code == 2
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_main_exits_two_when_stdin_raises_ioerror(
|
|
144
|
+
tmp_path: Path,
|
|
145
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
146
|
+
) -> None:
|
|
147
|
+
gate_path = tmp_path / "gate.py"
|
|
148
|
+
gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
149
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
150
|
+
|
|
151
|
+
class RaisingStdin:
|
|
152
|
+
def read(self) -> str:
|
|
153
|
+
raise IOError("broken pipe")
|
|
154
|
+
|
|
155
|
+
monkeypatch.setattr(sys, "stdin", RaisingStdin())
|
|
156
|
+
|
|
157
|
+
exit_code = pre_push.main()
|
|
158
|
+
|
|
159
|
+
assert exit_code == config.GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_main_exits_two_when_invoke_gate_raises_oserror(
|
|
163
|
+
tmp_path: Path,
|
|
164
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
165
|
+
) -> None:
|
|
166
|
+
gate_path = tmp_path / "gate.py"
|
|
167
|
+
gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
168
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
169
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
170
|
+
|
|
171
|
+
def raising_run(*args: object, **kwargs: object) -> object:
|
|
172
|
+
raise OSError("no such file")
|
|
173
|
+
|
|
174
|
+
monkeypatch.setattr(__import__("subprocess"), "run", raising_run)
|
|
175
|
+
|
|
176
|
+
exit_code = pre_push.main()
|
|
177
|
+
|
|
178
|
+
assert exit_code == config.GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_resolve_base_reference_emits_warning_for_malformed_line(
|
|
182
|
+
capsys: pytest.CaptureFixture[str],
|
|
183
|
+
) -> None:
|
|
184
|
+
malformed_stdin_text = "only_one_field\n"
|
|
185
|
+
|
|
186
|
+
pre_push.resolve_base_reference_from_stdin(malformed_stdin_text)
|
|
187
|
+
|
|
188
|
+
captured = capsys.readouterr()
|
|
189
|
+
assert "malformed" in captured.err
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_resolve_base_reference_returns_none_when_local_sha_is_all_zeros() -> None:
|
|
193
|
+
stdin_text = f"refs/heads/feature {ALL_ZEROS_OBJECT_NAME} refs/heads/feature {ALL_ZEROS_OBJECT_NAME}\n"
|
|
194
|
+
|
|
195
|
+
base_reference = pre_push.resolve_base_reference_from_stdin(stdin_text)
|
|
196
|
+
|
|
197
|
+
assert base_reference is None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_main_exits_zero_immediately_when_push_is_deletion(
|
|
201
|
+
tmp_path: Path,
|
|
202
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
203
|
+
) -> None:
|
|
204
|
+
gate_path = tmp_path / "gate.py"
|
|
205
|
+
gate_path.write_text("import sys\nsys.exit(1)\n", encoding="utf-8")
|
|
206
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
207
|
+
deletion_stdin = f"refs/heads/feature {ALL_ZEROS_OBJECT_NAME} refs/heads/feature {ALL_ZEROS_OBJECT_NAME}\n"
|
|
208
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(deletion_stdin))
|
|
209
|
+
|
|
210
|
+
exit_code = pre_push.main()
|
|
211
|
+
|
|
212
|
+
assert exit_code == 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_resolve_base_reference_returns_sentinel_when_only_malformed_lines_present(
|
|
216
|
+
capsys: pytest.CaptureFixture[str],
|
|
217
|
+
) -> None:
|
|
218
|
+
malformed_only_stdin = "one_field_only\nalso_malformed\n"
|
|
219
|
+
|
|
220
|
+
base_reference = pre_push.resolve_base_reference_from_stdin(malformed_only_stdin)
|
|
221
|
+
|
|
222
|
+
assert base_reference == config.NO_PARSEABLE_STDIN_LINES_SENTINEL
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_main_prints_stderr_when_gate_script_missing(
|
|
226
|
+
tmp_path: Path,
|
|
227
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
228
|
+
capsys: pytest.CaptureFixture[str],
|
|
229
|
+
) -> None:
|
|
230
|
+
monkeypatch.setenv(
|
|
231
|
+
"CODE_RULES_GATE_PATH",
|
|
232
|
+
str(tmp_path / "does_not_exist.py"),
|
|
233
|
+
)
|
|
234
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
235
|
+
|
|
236
|
+
pre_push.main()
|
|
237
|
+
|
|
238
|
+
captured = capsys.readouterr()
|
|
239
|
+
assert captured.err != ""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_resolve_base_reference_exits_two_when_only_malformed_lines_and_no_valid_lines(
|
|
243
|
+
capsys: pytest.CaptureFixture[str],
|
|
244
|
+
) -> None:
|
|
245
|
+
malformed_only_stdin = "one_field_only\nalso_malformed\n"
|
|
246
|
+
|
|
247
|
+
result = pre_push.resolve_base_reference_from_stdin(malformed_only_stdin)
|
|
248
|
+
|
|
249
|
+
assert result == config.NO_PARSEABLE_STDIN_LINES_SENTINEL
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_main_exits_two_when_all_stdin_lines_are_malformed(
|
|
253
|
+
tmp_path: Path,
|
|
254
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
255
|
+
capsys: pytest.CaptureFixture[str],
|
|
256
|
+
) -> None:
|
|
257
|
+
gate_path = tmp_path / "gate.py"
|
|
258
|
+
gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
259
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
260
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("one_field_only\nalso_malformed\n"))
|
|
261
|
+
|
|
262
|
+
exit_code = pre_push.main()
|
|
263
|
+
|
|
264
|
+
assert exit_code == config.GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
265
|
+
captured = capsys.readouterr()
|
|
266
|
+
assert "no parseable stdin lines" in captured.err
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_invoke_gate_returns_infrastructure_failure_when_strict_resolve_raises(
|
|
270
|
+
tmp_path: Path,
|
|
271
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
272
|
+
) -> None:
|
|
273
|
+
gate_path = tmp_path / "gate.py"
|
|
274
|
+
gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
275
|
+
|
|
276
|
+
original_resolve = Path.resolve
|
|
277
|
+
|
|
278
|
+
def raising_resolve(self: Path, strict: bool = False) -> Path:
|
|
279
|
+
if strict and self == gate_path.resolve():
|
|
280
|
+
raise FileNotFoundError("not found")
|
|
281
|
+
return original_resolve(self, strict=strict)
|
|
282
|
+
|
|
283
|
+
monkeypatch.setattr(Path, "resolve", raising_resolve)
|
|
284
|
+
|
|
285
|
+
exit_code = pre_push.invoke_gate(gate_path, "origin/main")
|
|
286
|
+
|
|
287
|
+
assert exit_code == 2
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_invoke_gate_uses_resolved_path(
|
|
291
|
+
tmp_path: Path,
|
|
292
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
293
|
+
) -> None:
|
|
294
|
+
real_gate_dir = tmp_path / "real"
|
|
295
|
+
real_gate_dir.mkdir()
|
|
296
|
+
real_gate_path = real_gate_dir / "gate.py"
|
|
297
|
+
recorded_path_file = tmp_path / "recorded_path.txt"
|
|
298
|
+
real_gate_path.write_text(
|
|
299
|
+
"import sys, pathlib\n"
|
|
300
|
+
f'pathlib.Path(r"{recorded_path_file}").write_text(sys.argv[0], encoding="utf-8")\n'
|
|
301
|
+
"sys.exit(0)\n",
|
|
302
|
+
encoding="utf-8",
|
|
303
|
+
)
|
|
304
|
+
symlink_gate_path = tmp_path / "link_gate.py"
|
|
305
|
+
symlink_gate_path.symlink_to(real_gate_path)
|
|
306
|
+
resolved_path = symlink_gate_path.resolve()
|
|
307
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(symlink_gate_path))
|
|
308
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(
|
|
309
|
+
f"refs/heads/feature {NON_ZERO_LOCAL_SHA} refs/heads/feature {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
310
|
+
))
|
|
311
|
+
|
|
312
|
+
exit_code = pre_push.main()
|
|
313
|
+
|
|
314
|
+
assert exit_code == 0
|
|
315
|
+
executed_path = recorded_path_file.read_text(encoding="utf-8")
|
|
316
|
+
assert executed_path == str(resolved_path)
|
package/hooks/hooks.json
CHANGED
|
@@ -35,11 +35,6 @@
|
|
|
35
35
|
"command": "python3 -c \"import sys; sys.path.insert(0, r'${CLAUDE_PLUGIN_ROOT}/hooks'); from validators.run_all_validators import main; sys.exit(main())\"",
|
|
36
36
|
"timeout": 15
|
|
37
37
|
},
|
|
38
|
-
{
|
|
39
|
-
"type": "command",
|
|
40
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
|
|
41
|
-
"timeout": 15
|
|
42
|
-
},
|
|
43
38
|
{
|
|
44
39
|
"type": "command",
|
|
45
40
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/tdd_enforcer.py",
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-dev-env": "bin/install.mjs"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test bin/*.test.mjs"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin/",
|
|
11
14
|
"rules/",
|
|
@@ -89,6 +89,130 @@ def filter_paths_under_prefixes(
|
|
|
89
89
|
return filtered
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def paths_from_git_staged(repository_root: Path) -> list[Path]:
|
|
93
|
+
name_result = subprocess.run(
|
|
94
|
+
["git", "diff", "--cached", "--name-only", "-z"],
|
|
95
|
+
cwd=str(repository_root),
|
|
96
|
+
capture_output=True,
|
|
97
|
+
check=False,
|
|
98
|
+
)
|
|
99
|
+
if name_result.returncode != 0:
|
|
100
|
+
stderr_text = name_result.stderr.decode("utf-8", errors="replace")
|
|
101
|
+
print(
|
|
102
|
+
f"bugteam_code_rules_gate: git diff --cached --name-only -z failed:\n{stderr_text}",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
raise SystemExit(2)
|
|
106
|
+
raw_paths = name_result.stdout.split(b"\x00")
|
|
107
|
+
resolved_paths = []
|
|
108
|
+
for each_raw_path in raw_paths:
|
|
109
|
+
if not each_raw_path:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
relative_path = each_raw_path.decode("utf-8")
|
|
113
|
+
except UnicodeDecodeError:
|
|
114
|
+
print(
|
|
115
|
+
f"bugteam_code_rules_gate: skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
|
|
116
|
+
file=sys.stderr,
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
resolved_paths.append(repository_root / relative_path)
|
|
120
|
+
return resolved_paths
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def staged_file_line_count(
|
|
124
|
+
repository_root: Path,
|
|
125
|
+
relative_path_posix: str,
|
|
126
|
+
) -> int:
|
|
127
|
+
show_result = subprocess.run(
|
|
128
|
+
["git", "show", f":{relative_path_posix}"],
|
|
129
|
+
cwd=str(repository_root),
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
errors="replace",
|
|
134
|
+
check=False,
|
|
135
|
+
)
|
|
136
|
+
if show_result.returncode != 0:
|
|
137
|
+
return 0
|
|
138
|
+
staged_content = show_result.stdout
|
|
139
|
+
if not staged_content:
|
|
140
|
+
return 0
|
|
141
|
+
return len(staged_content.splitlines())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_staged_file_newly_added(
|
|
145
|
+
repository_root: Path,
|
|
146
|
+
relative_path_posix: str,
|
|
147
|
+
) -> bool:
|
|
148
|
+
status_result = subprocess.run(
|
|
149
|
+
["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
|
|
150
|
+
cwd=str(repository_root),
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
errors="replace",
|
|
155
|
+
check=False,
|
|
156
|
+
)
|
|
157
|
+
if status_result.returncode != 0:
|
|
158
|
+
return False
|
|
159
|
+
for each_line in status_result.stdout.splitlines():
|
|
160
|
+
stripped_line = each_line.strip()
|
|
161
|
+
if stripped_line:
|
|
162
|
+
return stripped_line.startswith("A")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def added_lines_for_staged_file(
|
|
167
|
+
repository_root: Path,
|
|
168
|
+
relative_path_posix: str,
|
|
169
|
+
) -> set[int]:
|
|
170
|
+
diff_result = subprocess.run(
|
|
171
|
+
["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
|
|
172
|
+
cwd=str(repository_root),
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
encoding="utf-8",
|
|
176
|
+
errors="replace",
|
|
177
|
+
check=False,
|
|
178
|
+
)
|
|
179
|
+
if diff_result.returncode != 0:
|
|
180
|
+
print(
|
|
181
|
+
f"bugteam_code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}:\n"
|
|
182
|
+
f"{diff_result.stderr}",
|
|
183
|
+
file=sys.stderr,
|
|
184
|
+
)
|
|
185
|
+
raise SystemExit(2)
|
|
186
|
+
if diff_result.stdout.strip():
|
|
187
|
+
return parse_added_line_numbers(diff_result.stdout)
|
|
188
|
+
if is_staged_file_newly_added(repository_root, relative_path_posix):
|
|
189
|
+
total_lines = staged_file_line_count(repository_root, relative_path_posix)
|
|
190
|
+
if total_lines > 0:
|
|
191
|
+
return set(range(1, total_lines + 1))
|
|
192
|
+
return set()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def added_lines_by_file_staged(
|
|
196
|
+
repository_root: Path,
|
|
197
|
+
file_paths: list[Path],
|
|
198
|
+
) -> dict[Path, set[int]]:
|
|
199
|
+
resolved_root = repository_root.resolve()
|
|
200
|
+
added_by_path: dict[Path, set[int]] = {}
|
|
201
|
+
for each_path in file_paths:
|
|
202
|
+
try:
|
|
203
|
+
resolved = each_path.resolve()
|
|
204
|
+
except OSError:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
relative = resolved.relative_to(resolved_root)
|
|
208
|
+
except ValueError:
|
|
209
|
+
continue
|
|
210
|
+
relative_posix = str(relative).replace("\\", "/")
|
|
211
|
+
added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
|
|
212
|
+
added_by_path[resolved] = added_numbers
|
|
213
|
+
return added_by_path
|
|
214
|
+
|
|
215
|
+
|
|
92
216
|
def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
|
|
93
217
|
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
94
218
|
name_result = subprocess.run(
|
|
@@ -337,6 +461,16 @@ def parse_arguments(argv: list[str]) -> argparse.Namespace:
|
|
|
337
461
|
default="origin/main",
|
|
338
462
|
help="Merge-base ref for git diff (default: origin/main).",
|
|
339
463
|
)
|
|
464
|
+
parser.add_argument(
|
|
465
|
+
"--staged",
|
|
466
|
+
action="store_true",
|
|
467
|
+
default=False,
|
|
468
|
+
help=(
|
|
469
|
+
"Scope to staged changes only (git diff --cached). "
|
|
470
|
+
"Blocks on violations introduced on staged-added lines; "
|
|
471
|
+
"reports pre-existing violations in touched files as advisory."
|
|
472
|
+
),
|
|
473
|
+
)
|
|
340
474
|
parser.add_argument(
|
|
341
475
|
"--only-under",
|
|
342
476
|
action="append",
|
|
@@ -368,6 +502,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
368
502
|
if arguments.paths:
|
|
369
503
|
file_paths = [repository_root / path for path in arguments.paths]
|
|
370
504
|
return run_gate(validate_content, file_paths, repository_root, added_lines_map=None)
|
|
505
|
+
if arguments.staged:
|
|
506
|
+
staged_file_paths = paths_from_git_staged(repository_root)
|
|
507
|
+
staged_file_paths = filter_paths_under_prefixes(
|
|
508
|
+
staged_file_paths,
|
|
509
|
+
repository_root,
|
|
510
|
+
arguments.only_under,
|
|
511
|
+
)
|
|
512
|
+
if not staged_file_paths:
|
|
513
|
+
return 0
|
|
514
|
+
staged_added_lines = added_lines_by_file_staged(repository_root, staged_file_paths)
|
|
515
|
+
return run_gate(
|
|
516
|
+
validate_content,
|
|
517
|
+
staged_file_paths,
|
|
518
|
+
repository_root,
|
|
519
|
+
added_lines_map=staged_added_lines,
|
|
520
|
+
)
|
|
371
521
|
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
372
522
|
file_paths = filter_paths_under_prefixes(
|
|
373
523
|
file_paths,
|