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.
- package/CLAUDE.md +3 -2
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +4 -4
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +24 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -810
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +118 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- 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.
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
131
|
-
if
|
|
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
|
-
|
|
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
|
|
177
|
-
"""
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
if
|
|
185
|
-
return
|
|
186
|
-
|
|
187
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
[
|
|
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(
|
|
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
|
|
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"
|