claude-dev-env 1.26.5 → 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.
@@ -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.26.5",
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,