claude-dev-env 1.71.0 → 1.73.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 (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
@@ -0,0 +1,456 @@
1
+ """Behavior tests for the code_verifier_spawn_preflight_gate PreToolUse hook.
2
+
3
+ Each test builds a real git repository in a temporary directory, writes real
4
+ files (real merge conflicts, real CODE_RULES violations), and runs the hook
5
+ script as a subprocess with a real Agent PreToolUse JSON payload on stdin —
6
+ the exact production invocation path. No mocks. The harness mirrors
7
+ test_precommit_code_rules_gate.py lines 1-70.
8
+ """
9
+
10
+ import json
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ HOOK_PATH = Path(__file__).resolve().parent / "code_verifier_spawn_preflight_gate.py"
16
+
17
+ CLEAN_MODULE_SOURCE = '''"""Increment helper used by the preflight gate tests."""
18
+
19
+
20
+ def add_one(number: int) -> int:
21
+ """Return *number* plus one.
22
+
23
+ Args:
24
+ number: The integer to increment.
25
+
26
+ Returns:
27
+ The incremented integer.
28
+ """
29
+ return number + 1
30
+ '''
31
+
32
+ CLEAN_MODULE_SOURCE_EDITED = '''"""Increment helper used by the preflight gate tests."""
33
+
34
+
35
+ def add_one(number: int) -> int:
36
+ """Return *number* plus two.
37
+
38
+ Args:
39
+ number: The integer to increment.
40
+
41
+ Returns:
42
+ The incremented integer.
43
+ """
44
+ return number + 2
45
+ '''
46
+
47
+ VIOLATING_MODULE_SOURCE = '''"""Module carrying a banned identifier for the preflight gate tests."""
48
+
49
+
50
+ def compute_total() -> int:
51
+ """Return a fixed total.
52
+
53
+ Returns:
54
+ The fixed total.
55
+ """
56
+ result = 1
57
+ return result
58
+ '''
59
+
60
+ CLEAN_TWO_FUNCTION_SOURCE = '''"""Two-function helper used by the preflight gate tests."""
61
+
62
+
63
+ def add_one(number: int) -> int:
64
+ """Return *number* plus one.
65
+
66
+ Args:
67
+ number: The integer to increment.
68
+
69
+ Returns:
70
+ The incremented integer.
71
+ """
72
+ return number + 1
73
+
74
+
75
+ def add_two(number: int) -> int:
76
+ """Return *number* plus two.
77
+
78
+ Args:
79
+ number: The integer to increment.
80
+
81
+ Returns:
82
+ The integer plus two.
83
+ """
84
+ return number + 2
85
+ '''
86
+
87
+ PREEXISTING_VIOLATION_BASE_SOURCE = '''"""Module that already carries a banned identifier at the base commit."""
88
+
89
+
90
+ def compute_total() -> int:
91
+ """Return a fixed total.
92
+
93
+ Returns:
94
+ The fixed total.
95
+ """
96
+ result = 1
97
+ return result
98
+
99
+
100
+ def add_one(number: int) -> int:
101
+ """Return *number* plus one.
102
+
103
+ Args:
104
+ number: The integer to increment.
105
+
106
+ Returns:
107
+ The incremented integer.
108
+ """
109
+ return number + 1
110
+ '''
111
+
112
+ PREEXISTING_VIOLATION_EDITED_SOURCE = '''"""Module that already carries a banned identifier at the base commit."""
113
+
114
+
115
+ def compute_total() -> int:
116
+ """Return a fixed total.
117
+
118
+ Returns:
119
+ The fixed total.
120
+ """
121
+ result = 1
122
+ return result
123
+
124
+
125
+ def add_one(number: int) -> int:
126
+ """Return *number* plus two.
127
+
128
+ Args:
129
+ number: The integer to increment.
130
+
131
+ Returns:
132
+ The integer plus two.
133
+ """
134
+ return number + 2
135
+ '''
136
+
137
+ SHARED_BASE_SOURCE = '''"""Shared module the conflict fixture edits on both sides."""
138
+
139
+
140
+ def shared_value() -> int:
141
+ """Return the shared base value.
142
+
143
+ Returns:
144
+ The shared integer.
145
+ """
146
+ return 100
147
+ '''
148
+
149
+ SHARED_FEATURE_SOURCE = '''"""Shared module the conflict fixture edits on both sides."""
150
+
151
+
152
+ def shared_value() -> int:
153
+ """Return the shared base value.
154
+
155
+ Returns:
156
+ The shared integer.
157
+ """
158
+ return 200
159
+ '''
160
+
161
+ SHARED_DIVERGENT_SOURCE = '''"""Shared module the conflict fixture edits on both sides."""
162
+
163
+
164
+ def shared_value() -> int:
165
+ """Return the shared base value.
166
+
167
+ Returns:
168
+ The shared integer.
169
+ """
170
+ return 300
171
+ '''
172
+
173
+ OTHER_DIVERGENT_SOURCE = '''"""Unrelated module the divergent base edits for the behind-but-clean case."""
174
+
175
+
176
+ def other_value() -> int:
177
+ """Return the unrelated value.
178
+
179
+ Returns:
180
+ The unrelated integer.
181
+ """
182
+ return 42
183
+ '''
184
+
185
+
186
+ def run_git(repository_root: Path, *git_arguments: str) -> str:
187
+ completed = subprocess.run(
188
+ ["git", "-C", str(repository_root), *git_arguments],
189
+ check=True,
190
+ capture_output=True,
191
+ text=True,
192
+ )
193
+ return completed.stdout.strip()
194
+
195
+
196
+ def disable_native_git_hooks(repository_root: Path) -> None:
197
+ empty_hooks_directory = repository_root.parent / "nohooks"
198
+ empty_hooks_directory.mkdir(exist_ok=True)
199
+ run_git(repository_root, "config", "core.hooksPath", str(empty_hooks_directory))
200
+
201
+
202
+ def initialize_repository(repository_root: Path) -> None:
203
+ run_git(repository_root, "init")
204
+ run_git(repository_root, "config", "user.email", "tests@example.com")
205
+ run_git(repository_root, "config", "user.name", "Preflight Tests")
206
+ disable_native_git_hooks(repository_root)
207
+ run_git(repository_root, "checkout", "-b", "main")
208
+ (repository_root / "base.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
209
+ run_git(repository_root, "add", "base.py")
210
+ run_git(repository_root, "commit", "-m", "initial")
211
+ main_sha = run_git(repository_root, "rev-parse", "HEAD")
212
+ run_git(repository_root, "update-ref", "refs/remotes/origin/main", main_sha)
213
+ resolved_base = run_git(repository_root, "merge-base", "HEAD", "origin/main")
214
+ assert resolved_base, "fixture must resolve a merge base against origin/main"
215
+
216
+
217
+ def commit_file(repository_root: Path, relative_name: str, source_text: str, message: str) -> str:
218
+ (repository_root / relative_name).write_text(source_text, encoding="utf-8")
219
+ run_git(repository_root, "add", relative_name)
220
+ run_git(repository_root, "commit", "-m", message)
221
+ return run_git(repository_root, "rev-parse", "HEAD")
222
+
223
+
224
+ def write_working_tree_file(repository_root: Path, relative_name: str, source_text: str) -> None:
225
+ (repository_root / relative_name).write_text(source_text, encoding="utf-8")
226
+
227
+
228
+ def write_invalid_utf8_file(repository_root: Path, relative_name: str) -> None:
229
+ (repository_root / relative_name).write_bytes(b"\xff\xfe invalid utf-8 bytes\n")
230
+
231
+
232
+ def advance_origin_main_divergent(
233
+ repository_root: Path, base_sha: str, relative_name: str, source_text: str
234
+ ) -> None:
235
+ run_git(repository_root, "checkout", "-b", "divergent", base_sha)
236
+ commit_file(repository_root, relative_name, source_text, "divergent edit")
237
+ divergent_sha = run_git(repository_root, "rev-parse", "HEAD")
238
+ run_git(repository_root, "update-ref", "refs/remotes/origin/main", divergent_sha)
239
+ run_git(repository_root, "checkout", "feature")
240
+
241
+
242
+ def write_agent_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
243
+ return json.dumps(
244
+ {
245
+ "tool_name": "Agent",
246
+ "tool_input": {"subagent_type": subagent_type, "prompt": prompt},
247
+ "cwd": str(cwd),
248
+ }
249
+ )
250
+
251
+
252
+ def run_hook(payload: str, cwd: Path) -> subprocess.CompletedProcess[str]:
253
+ return subprocess.run(
254
+ [sys.executable, str(HOOK_PATH)],
255
+ check=False,
256
+ input=payload,
257
+ capture_output=True,
258
+ text=True,
259
+ cwd=str(cwd),
260
+ timeout=120,
261
+ )
262
+
263
+
264
+ def is_allow(result: subprocess.CompletedProcess[str]) -> bool:
265
+ stdout_text = result.stdout.strip()
266
+ if not stdout_text:
267
+ return True
268
+ parsed = json.loads(stdout_text)
269
+ hook_output = parsed.get("hookSpecificOutput", {})
270
+ return hook_output.get("permissionDecision") != "deny"
271
+
272
+
273
+ def deny_reason(result: subprocess.CompletedProcess[str]) -> str:
274
+ parsed = json.loads(result.stdout.strip())
275
+ return parsed["hookSpecificOutput"]["permissionDecisionReason"]
276
+
277
+
278
+ def make_conflict_repository(tmp_path: Path) -> Path:
279
+ repository_root = tmp_path / "repo"
280
+ repository_root.mkdir()
281
+ initialize_repository(repository_root)
282
+ base_sha = commit_file(repository_root, "shared.py", SHARED_BASE_SOURCE, "add shared")
283
+ run_git(repository_root, "checkout", "-b", "feature")
284
+ commit_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE, "feature edit")
285
+ advance_origin_main_divergent(repository_root, base_sha, "shared.py", SHARED_DIVERGENT_SOURCE)
286
+ return repository_root
287
+
288
+
289
+ def test_non_code_verifier_agent_is_no_op(tmp_path: Path) -> None:
290
+ repository_root = make_conflict_repository(tmp_path)
291
+ write_working_tree_file(repository_root, "violator.py", VIOLATING_MODULE_SOURCE)
292
+ payload = write_agent_payload("clean-coder", "do an audit", repository_root)
293
+ result = run_hook(payload, repository_root)
294
+ assert is_allow(result)
295
+
296
+
297
+ def test_non_agent_tool_is_no_op(tmp_path: Path) -> None:
298
+ repository_root = tmp_path / "repo"
299
+ repository_root.mkdir()
300
+ initialize_repository(repository_root)
301
+ payload = json.dumps(
302
+ {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": str(repository_root)}
303
+ )
304
+ result = run_hook(payload, repository_root)
305
+ assert is_allow(result)
306
+
307
+
308
+ def test_clean_surface_allows(tmp_path: Path) -> None:
309
+ repository_root = tmp_path / "repo"
310
+ repository_root.mkdir()
311
+ initialize_repository(repository_root)
312
+ run_git(repository_root, "checkout", "-b", "feature")
313
+ write_working_tree_file(repository_root, "feature.py", CLEAN_MODULE_SOURCE)
314
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
315
+ result = run_hook(payload, repository_root)
316
+ assert is_allow(result)
317
+
318
+
319
+ def test_real_conflict_denies_naming_files(tmp_path: Path) -> None:
320
+ repository_root = make_conflict_repository(tmp_path)
321
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
322
+ result = run_hook(payload, repository_root)
323
+ assert not is_allow(result)
324
+ reason = deny_reason(result)
325
+ assert "shared.py" in reason
326
+ assert "Merge conflicts vs" in reason
327
+
328
+
329
+ def test_real_code_rules_violation_on_added_line_denies(tmp_path: Path) -> None:
330
+ repository_root = tmp_path / "repo"
331
+ repository_root.mkdir()
332
+ initialize_repository(repository_root)
333
+ run_git(repository_root, "checkout", "-b", "feature")
334
+ commit_file(repository_root, "tracked.py", CLEAN_MODULE_SOURCE, "add tracked")
335
+ write_working_tree_file(repository_root, "tracked.py", VIOLATING_MODULE_SOURCE)
336
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
337
+ result = run_hook(payload, repository_root)
338
+ assert not is_allow(result)
339
+ reason = deny_reason(result)
340
+ assert "tracked.py" in reason
341
+ assert "Line " in reason
342
+
343
+
344
+ def test_preexisting_violation_on_untouched_line_allows(tmp_path: Path) -> None:
345
+ repository_root = tmp_path / "repo"
346
+ repository_root.mkdir()
347
+ initialize_repository(repository_root)
348
+ carrier_sha = commit_file(
349
+ repository_root, "carrier.py", PREEXISTING_VIOLATION_BASE_SOURCE, "add carrier"
350
+ )
351
+ run_git(repository_root, "update-ref", "refs/remotes/origin/main", carrier_sha)
352
+ run_git(repository_root, "checkout", "-b", "feature")
353
+ write_working_tree_file(repository_root, "carrier.py", PREEXISTING_VIOLATION_EDITED_SOURCE)
354
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
355
+ result = run_hook(payload, repository_root)
356
+ assert is_allow(result)
357
+
358
+
359
+ def test_untracked_new_violating_file_denies(tmp_path: Path) -> None:
360
+ repository_root = tmp_path / "repo"
361
+ repository_root.mkdir()
362
+ initialize_repository(repository_root)
363
+ run_git(repository_root, "checkout", "-b", "feature")
364
+ write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
365
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
366
+ result = run_hook(payload, repository_root)
367
+ assert not is_allow(result)
368
+ reason = deny_reason(result)
369
+ assert "fresh.py" in reason
370
+
371
+
372
+ def test_tooling_scratch_file_is_ignored(tmp_path: Path) -> None:
373
+ repository_root = tmp_path / "repo"
374
+ repository_root.mkdir()
375
+ initialize_repository(repository_root)
376
+ run_git(repository_root, "checkout", "-b", "feature")
377
+ scratch_directory = repository_root / ".claude" / "verification"
378
+ scratch_directory.mkdir(parents=True)
379
+ (scratch_directory / "x.py").write_text(VIOLATING_MODULE_SOURCE, encoding="utf-8")
380
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
381
+ result = run_hook(payload, repository_root)
382
+ assert is_allow(result)
383
+
384
+
385
+ def test_missing_base_ref_fails_open(tmp_path: Path) -> None:
386
+ repository_root = tmp_path / "repo"
387
+ repository_root.mkdir()
388
+ run_git(repository_root, "init")
389
+ run_git(repository_root, "config", "user.email", "tests@example.com")
390
+ run_git(repository_root, "config", "user.name", "Preflight Tests")
391
+ disable_native_git_hooks(repository_root)
392
+ (repository_root / "base.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
393
+ run_git(repository_root, "add", "base.py")
394
+ run_git(repository_root, "commit", "-m", "initial")
395
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
396
+ result = run_hook(payload, repository_root)
397
+ assert is_allow(result)
398
+
399
+
400
+ def test_non_repo_cwd_fails_open(tmp_path: Path) -> None:
401
+ non_repo_directory = tmp_path / "plain"
402
+ non_repo_directory.mkdir()
403
+ payload = write_agent_payload("code-verifier", "verify the change", non_repo_directory)
404
+ result = run_hook(payload, non_repo_directory)
405
+ assert is_allow(result)
406
+
407
+
408
+ def test_behind_but_conflict_free_allows(tmp_path: Path) -> None:
409
+ repository_root = tmp_path / "repo"
410
+ repository_root.mkdir()
411
+ initialize_repository(repository_root)
412
+ base_sha = commit_file(repository_root, "shared.py", SHARED_BASE_SOURCE, "add shared")
413
+ run_git(repository_root, "checkout", "-b", "feature")
414
+ commit_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE, "feature edit")
415
+ advance_origin_main_divergent(repository_root, base_sha, "other.py", OTHER_DIVERGENT_SOURCE)
416
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
417
+ result = run_hook(payload, repository_root)
418
+ assert is_allow(result)
419
+
420
+
421
+ def test_unreadable_changed_file_alone_fails_open(tmp_path: Path) -> None:
422
+ repository_root = tmp_path / "repo"
423
+ repository_root.mkdir()
424
+ initialize_repository(repository_root)
425
+ run_git(repository_root, "checkout", "-b", "feature")
426
+ write_invalid_utf8_file(repository_root, "binary.py")
427
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
428
+ result = run_hook(payload, repository_root)
429
+ assert is_allow(result)
430
+
431
+
432
+ def test_unreadable_changed_file_does_not_mask_real_violation(tmp_path: Path) -> None:
433
+ repository_root = tmp_path / "repo"
434
+ repository_root.mkdir()
435
+ initialize_repository(repository_root)
436
+ run_git(repository_root, "checkout", "-b", "feature")
437
+ write_invalid_utf8_file(repository_root, "binary.py")
438
+ write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
439
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
440
+ result = run_hook(payload, repository_root)
441
+ assert not is_allow(result)
442
+ reason = deny_reason(result)
443
+ assert "fresh.py" in reason
444
+
445
+
446
+ def test_conflict_and_violation_single_deny_names_both(tmp_path: Path) -> None:
447
+ repository_root = make_conflict_repository(tmp_path)
448
+ write_working_tree_file(repository_root, "violator.py", VIOLATING_MODULE_SOURCE)
449
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
450
+ result = run_hook(payload, repository_root)
451
+ assert not is_allow(result)
452
+ reason = deny_reason(result)
453
+ assert "Merge conflicts vs" in reason
454
+ assert "shared.py" in reason
455
+ assert "CODE_RULES violations on changed lines:" in reason
456
+ assert "violator.py" in reason