claude-dev-env 2.2.1 → 2.4.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 (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -20,10 +20,11 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
20
20
  if _hooks_dir not in sys.path:
21
21
  sys.path.insert(0, _hooks_dir)
22
22
 
23
- from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
24
- from blocking.pr_description_pr_number import ( # noqa: E402
25
- _extract_pr_number_from_command,
23
+ from blocking.convergence_gate_blocker import ( # noqa: E402
24
+ _repo_flag_arguments,
25
+ _resolve_named_identity,
26
26
  )
27
+ from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
27
28
  from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
28
29
  BULLET_MARKER_PATTERN,
29
30
  FENCED_CODE_BLOCK_PATTERN,
@@ -32,7 +33,6 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
32
33
  from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E402
33
34
  ALL_HONEST_GAP_PHRASES,
34
35
  ALL_PR_DIFF_SUBCOMMANDS,
35
- ALL_PR_VIEW_NUMBER_ARGUMENTS,
36
36
  ALL_PROOF_HEADING_KEYWORDS,
37
37
  ALL_VISUAL_FILE_SUFFIXES,
38
38
  COMMENT_BODY_JSON_FIELD,
@@ -49,7 +49,6 @@ from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E4
49
49
  PLAN_LINKAGE_KEYWORD_PATTERN,
50
50
  PR_COMMENTS_API_PATH_TEMPLATE,
51
51
  PR_DIFF_NAME_ONLY_FLAG,
52
- PR_NUMBER_JSON_FIELD,
53
52
  PR_READY_GATE_MESSAGE_TEMPLATE,
54
53
  PR_READY_INVOCATION_PATTERN,
55
54
  PR_READY_UNDO_FLAG,
@@ -112,13 +111,18 @@ def audit_proof_comment_body(body: str, pr_number: int | None) -> list[str]:
112
111
  an honest-gaps statement, and (when the PR diff is visual) an
113
112
  image embed.
114
113
  """
115
- is_visual_change = _detect_visual_change(pr_number)
114
+ is_visual_change = _detect_visual_change(pr_number, None)
116
115
  return _missing_proof_parts(body, is_visual_change)
117
116
 
118
117
 
119
118
  def evaluate_pr_ready_gate(command: str) -> str | None:
120
119
  """Decide whether a gh pr ready command may proceed.
121
120
 
121
+ Every gh read the gate runs binds to the repository the command names —
122
+ a full PR URL or a ``--repo``/``-R`` flag — so a command targeting one
123
+ repository from a working tree checked out to another audits the named
124
+ repository's PR, not the working tree's.
125
+
122
126
  Args:
123
127
  command: The raw shell command captured by the hook.
124
128
 
@@ -127,15 +131,14 @@ def evaluate_pr_ready_gate(command: str) -> str | None:
127
131
  carries no passing proof comment, or None when a passing proof
128
132
  comment exists or any gh query fails.
129
133
  """
130
- resolved_pr_number = _extract_pr_number_from_command(command)
131
- if resolved_pr_number is None:
132
- resolved_pr_number = _resolve_current_pr_number()
133
- if resolved_pr_number is None:
134
+ target_identity = _resolve_named_identity(command, None)
135
+ if target_identity is None:
134
136
  return None
135
- all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number)
137
+ all_target_repo, resolved_pr_number = target_identity
138
+ all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number, all_target_repo)
136
139
  if all_comment_bodies is None:
137
140
  return None
138
- is_visual_change = _detect_visual_change(resolved_pr_number)
141
+ is_visual_change = _detect_visual_change(resolved_pr_number, all_target_repo)
139
142
  for each_body in all_comment_bodies:
140
143
  if not is_proof_shaped_body(each_body):
141
144
  continue
@@ -173,29 +176,30 @@ def _run_gh_command(all_gh_arguments: list[str]) -> str | None:
173
176
  return completed_process.stdout
174
177
 
175
178
 
176
- def _resolve_current_pr_number() -> int | None:
177
- """Resolve the PR number of the current branch's open PR.
179
+ def _build_comments_api_path(pr_number: int, all_target_repo: tuple[str, str] | None) -> str:
180
+ """Build the issues-comments API path for the named repository's PR.
181
+
182
+ Args:
183
+ pr_number: The PR number whose comments to read.
184
+ all_target_repo: The (owner, repo) pair the command names, or None to
185
+ leave gh's ``{owner}``/``{repo}`` placeholders for gh to expand
186
+ from the current directory's repository.
178
187
 
179
188
  Returns:
180
- The PR number from ``gh pr view``, or None when no PR resolves or
181
- gh fails.
189
+ The ``repos/<owner>/<repo>/issues/<pr_number>/comments`` path with
190
+ the named owner and repo filled in, or the placeholder form when no
191
+ repository is named.
182
192
  """
183
- view_stdout = _run_gh_command(list(ALL_PR_VIEW_NUMBER_ARGUMENTS))
184
- if view_stdout is None:
185
- return None
186
- try:
187
- view_record = json.loads(view_stdout)
188
- except json.JSONDecodeError:
189
- return None
190
- if not isinstance(view_record, dict):
191
- return None
192
- resolved_number = view_record.get(PR_NUMBER_JSON_FIELD)
193
- if isinstance(resolved_number, int):
194
- return resolved_number
195
- return None
193
+ comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
194
+ if all_target_repo is None:
195
+ return comments_api_path
196
+ owner, repo = all_target_repo
197
+ return comments_api_path.format(owner=owner, repo=repo)
196
198
 
197
199
 
198
- def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
200
+ def _fetch_pr_comment_bodies(
201
+ pr_number: int, all_target_repo: tuple[str, str] | None
202
+ ) -> list[str] | None:
199
203
  """Read every comment body on a PR through the paginated issues API.
200
204
 
201
205
  The query runs ``gh api --paginate --slurp`` so every page lands in one
@@ -204,13 +208,15 @@ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
204
208
 
205
209
  Args:
206
210
  pr_number: The PR number whose comments to read.
211
+ all_target_repo: The (owner, repo) pair the command names, or None to
212
+ read the current directory's repository.
207
213
 
208
214
  Returns:
209
215
  The comment bodies across all pages, or None when gh fails or the
210
216
  output is not the slurped page-array shape — the caller fails open
211
217
  on None.
212
218
  """
213
- comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
219
+ comments_api_path = _build_comments_api_path(pr_number, all_target_repo)
214
220
  api_stdout = _run_gh_command(
215
221
  [GH_API_SUBCOMMAND, comments_api_path, GH_PAGINATE_FLAG, GH_SLURP_FLAG]
216
222
  )
@@ -232,7 +238,9 @@ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
232
238
  return all_comment_bodies
233
239
 
234
240
 
235
- def _detect_visual_change(pr_number: int | None) -> bool:
241
+ def _detect_visual_change(
242
+ pr_number: int | None, all_target_repo: tuple[str, str] | None
243
+ ) -> bool:
236
244
  """Decide whether the PR diff carries visual work.
237
245
 
238
246
  A diff is visual when a changed file carries an image, HTML, or
@@ -242,21 +250,31 @@ def _detect_visual_change(pr_number: int | None) -> bool:
242
250
  Args:
243
251
  pr_number: The PR number to diff, or None to diff the current
244
252
  branch's PR.
253
+ all_target_repo: The (owner, repo) pair the command names, or None to
254
+ diff the current directory's repository.
245
255
 
246
256
  Returns:
247
257
  True when the diff is visual; False when it is not or when any gh
248
258
  query fails — an unknowable diff never adds the visual requirement.
249
259
  """
250
260
  all_number_arguments = [str(pr_number)] if pr_number is not None else []
261
+ all_repo_arguments = _repo_flag_arguments(all_target_repo)
251
262
  name_only_stdout = _run_gh_command(
252
- [*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, PR_DIFF_NAME_ONLY_FLAG]
263
+ [
264
+ *ALL_PR_DIFF_SUBCOMMANDS,
265
+ *all_number_arguments,
266
+ *all_repo_arguments,
267
+ PR_DIFF_NAME_ONLY_FLAG,
268
+ ]
253
269
  )
254
270
  if name_only_stdout is None:
255
271
  return False
256
272
  for each_changed_path in name_only_stdout.splitlines():
257
273
  if Path(each_changed_path.strip()).suffix.lower() in ALL_VISUAL_FILE_SUFFIXES:
258
274
  return True
259
- diff_stdout = _run_gh_command([*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments])
275
+ diff_stdout = _run_gh_command(
276
+ [*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, *all_repo_arguments]
277
+ )
260
278
  if diff_stdout is None:
261
279
  return False
262
280
  return HEX_COLOR_ADDED_LINE_PATTERN.search(diff_stdout[:MAX_DIFF_SCAN_CHARS]) is not None
@@ -213,7 +213,7 @@ def test_additional_context_is_collected_from_deciding_hooks() -> None:
213
213
 
214
214
 
215
215
  def test_powershell_selects_the_three_shared_hooks() -> None:
216
- """Selecting for PowerShell yields the three shared hooks in registration order."""
216
+ """Selecting for PowerShell yields the shared hooks in registration order."""
217
217
  powershell_paths = [
218
218
  each_entry.script_relative_path
219
219
  for each_entry in select_applicable_entries(POWERSHELL_TOOL_NAME)
@@ -222,6 +222,9 @@ def test_powershell_selects_the_three_shared_hooks() -> None:
222
222
  "blocking/pii_prevention_blocker.py",
223
223
  "blocking/verified_commit_gate.py",
224
224
  "blocking/verdict_directory_write_blocker.py",
225
+ "blocking/code_review_push_gate.py",
226
+ "blocking/code_review_pr_create_gate.py",
227
+ "blocking/code_review_stamp_directory_write_blocker.py",
225
228
  ]
226
229
 
227
230
 
@@ -0,0 +1,62 @@
1
+ """Regression tests for the code-review enforcement constants bootstrap.
2
+
3
+ These prove the shared constants module resolves to the sibling
4
+ ``config/code_review_enforcement_constants.py`` file even when a foreign
5
+ ``config`` package sits ahead of ``blocking`` on ``sys.path``, and that a
6
+ registration a caller placed first is left untouched.
7
+ """
8
+
9
+ import subprocess
10
+ from collections.abc import Callable
11
+
12
+ RunUnderConfigShadow = Callable[[str], subprocess.CompletedProcess[str]]
13
+
14
+
15
+ def test_register_binds_dotted_name_to_sibling_constants_file(
16
+ run_under_config_shadow: RunUnderConfigShadow,
17
+ ) -> None:
18
+ completed_probe = run_under_config_shadow(
19
+ "import code_review_enforcement_config_bootstrap as bootstrap\n"
20
+ "bootstrap.register_code_review_enforcement_constants()\n"
21
+ "import config.code_review_enforcement_constants as loaded\n"
22
+ "print(loaded.__file__)\n"
23
+ "print(loaded.STAMP_DIRECTORY_NAME)\n"
24
+ )
25
+ assert completed_probe.returncode == 0, completed_probe.stderr
26
+ assert "IS_DECOY_CONFIG" not in completed_probe.stdout
27
+ resolved_file_line, stamp_directory_line = completed_probe.stdout.splitlines()[:2]
28
+ assert resolved_file_line.replace("\\", "/").endswith(
29
+ "blocking/config/code_review_enforcement_constants.py"
30
+ )
31
+ assert stamp_directory_line == "code-review-stamps"
32
+
33
+
34
+ def test_register_binds_push_required_effort(
35
+ run_under_config_shadow: RunUnderConfigShadow,
36
+ ) -> None:
37
+ completed_probe = run_under_config_shadow(
38
+ "import code_review_enforcement_config_bootstrap as bootstrap\n"
39
+ "bootstrap.register_code_review_enforcement_constants()\n"
40
+ "import config.code_review_enforcement_constants as loaded\n"
41
+ "print(loaded.PUSH_REQUIRED_EFFORT)\n"
42
+ )
43
+ assert completed_probe.returncode == 0, completed_probe.stderr
44
+ assert completed_probe.stdout.strip().splitlines()[-1] == "low"
45
+
46
+
47
+ def test_register_leaves_an_already_cached_module_untouched(
48
+ run_under_config_shadow: RunUnderConfigShadow,
49
+ ) -> None:
50
+ completed_probe = run_under_config_shadow(
51
+ "import sys\n"
52
+ "import types\n"
53
+ "sentinel = types.ModuleType('config.code_review_enforcement_constants')\n"
54
+ "sentinel.ORIGIN = 'caller-seeded'\n"
55
+ "sys.modules['config.code_review_enforcement_constants'] = sentinel\n"
56
+ "import code_review_enforcement_config_bootstrap as bootstrap\n"
57
+ "bootstrap.register_code_review_enforcement_constants()\n"
58
+ "import config.code_review_enforcement_constants as loaded\n"
59
+ "print(getattr(loaded, 'ORIGIN', 'displaced'))\n"
60
+ )
61
+ assert completed_probe.returncode == 0, completed_probe.stderr
62
+ assert completed_probe.stdout.strip().splitlines()[-1] == "caller-seeded"
@@ -0,0 +1,54 @@
1
+ """Behavioral tests for the shared code-review gate deny scaffold.
2
+
3
+ The tests drive the two shared helpers directly: the payload builder's shape and
4
+ the log-and-emit helper's stdout, so both code-review gates inherit one verified
5
+ deny path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ import pytest
17
+
18
+ _HOOK_DIR = Path(__file__).resolve().parent
19
+ if str(_HOOK_DIR) not in sys.path:
20
+ sys.path.insert(0, str(_HOOK_DIR))
21
+
22
+
23
+ def _load_module(module_name: str) -> ModuleType:
24
+ module_spec = importlib.util.spec_from_file_location(
25
+ module_name, _HOOK_DIR / f"{module_name}.py"
26
+ )
27
+ assert module_spec is not None
28
+ assert module_spec.loader is not None
29
+ loaded_module = importlib.util.module_from_spec(module_spec)
30
+ module_spec.loader.exec_module(loaded_module)
31
+ return loaded_module
32
+
33
+
34
+ deny_module = _load_module("code_review_gate_deny")
35
+
36
+
37
+ def test_build_payload_carries_reason_and_deny_decision() -> None:
38
+ deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
39
+ hook_output = deny_payload["hookSpecificOutput"]
40
+ assert hook_output["permissionDecision"] == "deny"
41
+ assert hook_output["permissionDecisionReason"] == "blocked reason"
42
+
43
+
44
+ def test_log_and_emit_code_review_deny_writes_payload_to_stdout(
45
+ capsys: pytest.CaptureFixture[str],
46
+ monkeypatch: pytest.MonkeyPatch,
47
+ tmp_path: Path,
48
+ ) -> None:
49
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
50
+ deny_module.log_and_emit_code_review_deny("blocked reason", "Bash", "code_review_push_gate")
51
+ emitted_payload = json.loads(capsys.readouterr().out)
52
+ hook_output = emitted_payload["hookSpecificOutput"]
53
+ assert hook_output["permissionDecision"] == "deny"
54
+ assert hook_output["permissionDecisionReason"] == "blocked reason"
@@ -0,0 +1,185 @@
1
+ """Behavioral tests for the PR-create code-review gate.
2
+
3
+ Each test drives the gate against a real git work tree with a change surface
4
+ and a real stamp store under an isolated home, so the deny/allow decision runs
5
+ the same hash-and-coverage path the hook runs in production.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ import pytest
17
+
18
+ _HOOK_DIR = Path(__file__).resolve().parent
19
+ if str(_HOOK_DIR) not in sys.path:
20
+ sys.path.insert(0, str(_HOOK_DIR))
21
+
22
+ GIT_TIMEOUT_SECONDS = 30
23
+ XHIGH_EFFORT = "xhigh"
24
+ LOW_EFFORT = "low"
25
+ SURFACE_SOURCE = "def add(a: int, b: int) -> int:\n return a + b\n"
26
+ SURFACE_CHANGE = "def add(a: int, b: int) -> int:\n return a - b\n"
27
+ MCP_CREATE_PR_TOOL = "mcp__plugin_github_github__create_pull_request"
28
+
29
+
30
+ def _load_module(module_name: str) -> ModuleType:
31
+ module_spec = importlib.util.spec_from_file_location(
32
+ module_name, _HOOK_DIR / f"{module_name}.py"
33
+ )
34
+ assert module_spec is not None
35
+ assert module_spec.loader is not None
36
+ loaded_module = importlib.util.module_from_spec(module_spec)
37
+ module_spec.loader.exec_module(loaded_module)
38
+ return loaded_module
39
+
40
+
41
+ gate_module = _load_module("code_review_pr_create_gate")
42
+ store_module = _load_module("code_review_stamp_store")
43
+ deny_module = _load_module("code_review_gate_deny")
44
+
45
+
46
+ def _run_git(repository_directory: Path, *git_arguments: str) -> None:
47
+ subprocess.run(
48
+ ["git", "-C", str(repository_directory), *git_arguments],
49
+ check=True,
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=GIT_TIMEOUT_SECONDS,
53
+ )
54
+
55
+
56
+ def _make_repo_with_change_surface(tmp_path: Path) -> Path:
57
+ origin_directory = tmp_path / "origin.git"
58
+ work_directory = tmp_path / "work"
59
+ work_directory.mkdir()
60
+ subprocess.run(
61
+ ["git", "init", "--bare", "--initial-branch=main", str(origin_directory)],
62
+ check=True,
63
+ capture_output=True,
64
+ text=True,
65
+ timeout=GIT_TIMEOUT_SECONDS,
66
+ )
67
+ _run_git(work_directory, "init", "--initial-branch=main")
68
+ _run_git(work_directory, "config", "user.email", "tests@example.com")
69
+ _run_git(work_directory, "config", "user.name", "Reviewer")
70
+ (work_directory / "app.py").write_text(SURFACE_SOURCE, encoding="utf-8")
71
+ _run_git(work_directory, "add", "-A")
72
+ _run_git(work_directory, "commit", "-m", "base")
73
+ _run_git(work_directory, "remote", "add", "origin", str(origin_directory))
74
+ _run_git(work_directory, "push", "-u", "origin", "main")
75
+ (work_directory / "app.py").write_text(SURFACE_CHANGE, encoding="utf-8")
76
+ return work_directory
77
+
78
+
79
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
80
+ home_text = str(fake_home)
81
+ monkeypatch.setenv("HOME", home_text)
82
+ monkeypatch.setenv("USERPROFILE", home_text)
83
+ monkeypatch.delenv("HOMEDRIVE", raising=False)
84
+ monkeypatch.delenv("HOMEPATH", raising=False)
85
+
86
+
87
+ def _prepared_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
88
+ fake_home = tmp_path / "home"
89
+ fake_home.mkdir()
90
+ _isolate_home(monkeypatch, fake_home)
91
+ return _make_repo_with_change_surface(tmp_path)
92
+
93
+
94
+ def _record_stamp(work_directory: Path, effort: str) -> None:
95
+ surface_hash = store_module.live_surface_hash(str(work_directory))
96
+ assert surface_hash is not None
97
+ store_module.record_clean_stamp(str(work_directory), surface_hash, effort)
98
+
99
+
100
+ def test_command_invokes_gh_pr_create_detects_real_invocation() -> None:
101
+ assert gate_module.command_invokes_gh_pr_create("gh pr create --title T --body-file b.md")
102
+
103
+
104
+ def test_gh_pr_edit_is_not_a_create() -> None:
105
+ assert not gate_module.command_invokes_gh_pr_create("gh pr edit 1 --title T")
106
+
107
+
108
+ def test_echoed_prose_is_not_a_create() -> None:
109
+ assert not gate_module.command_invokes_gh_pr_create('echo "gh pr create"')
110
+
111
+
112
+ def test_is_mcp_create_pull_request_tool_matches_configured_name() -> None:
113
+ assert gate_module.is_mcp_create_pull_request_tool(MCP_CREATE_PR_TOOL)
114
+ assert not gate_module.is_mcp_create_pull_request_tool("Bash")
115
+
116
+
117
+ def test_uncovered_surface_denies_pr_create(
118
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
119
+ ) -> None:
120
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
121
+ deny_reason = gate_module.deny_reason_for_directory(str(work_directory))
122
+ assert deny_reason is not None
123
+ assert "PR_CREATE_GATE" in deny_reason
124
+
125
+
126
+ def test_covering_xhigh_stamp_allows_pr_create(
127
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
128
+ ) -> None:
129
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
130
+ _record_stamp(work_directory, XHIGH_EFFORT)
131
+ assert gate_module.deny_reason_for_directory(str(work_directory)) is None
132
+
133
+
134
+ def test_low_only_stamp_does_not_satisfy_xhigh(
135
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
136
+ ) -> None:
137
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
138
+ _record_stamp(work_directory, LOW_EFFORT)
139
+ deny_reason = gate_module.deny_reason_for_directory(str(work_directory))
140
+ assert deny_reason is not None
141
+ assert "PR_CREATE_GATE" in deny_reason
142
+
143
+
144
+ def test_shell_gh_pr_create_payload_denies_without_stamp(
145
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
146
+ ) -> None:
147
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
148
+ payload = {
149
+ "tool_name": "Bash",
150
+ "tool_input": {"command": "gh pr create --title T --body-file b.md"},
151
+ "cwd": str(work_directory),
152
+ }
153
+ deny_decision = gate_module.decision_for_payload(payload)
154
+ assert deny_decision is not None
155
+ assert deny_decision["hookSpecificOutput"]["permissionDecision"] == "deny"
156
+
157
+
158
+ def test_mcp_create_pull_request_payload_denies_without_stamp(
159
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
160
+ ) -> None:
161
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
162
+ payload = {
163
+ "tool_name": MCP_CREATE_PR_TOOL,
164
+ "tool_input": {"owner": "o", "repo": "r"},
165
+ "cwd": str(work_directory),
166
+ }
167
+ deny_decision = gate_module.decision_for_payload(payload)
168
+ assert deny_decision is not None
169
+ assert deny_decision["hookSpecificOutput"]["permissionDecision"] == "deny"
170
+
171
+
172
+ def test_gh_pr_edit_payload_is_not_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
173
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
174
+ payload = {
175
+ "tool_name": "Bash",
176
+ "tool_input": {"command": "gh pr edit 1 --title T"},
177
+ "cwd": str(work_directory),
178
+ }
179
+ assert gate_module.decision_for_payload(payload) is None
180
+
181
+
182
+ def test_build_deny_payload_shape() -> None:
183
+ deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
184
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
185
+ assert deny_payload["hookSpecificOutput"]["permissionDecisionReason"] == "blocked reason"