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
|
@@ -183,6 +183,20 @@ def other_value() -> int:
|
|
|
183
183
|
return 42
|
|
184
184
|
'''
|
|
185
185
|
|
|
186
|
+
BROKEN_ENFORCER_SOURCE = (
|
|
187
|
+
'"""Enforcer stub that raises a non-import error at load time."""\n'
|
|
188
|
+
"\n"
|
|
189
|
+
"raise RuntimeError('deliberate enforcer load failure')\n"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
RE_ERROR_ENFORCER_SOURCE = (
|
|
193
|
+
'"""Enforcer stub whose module-scope regex compile raises re.error."""\n'
|
|
194
|
+
"\n"
|
|
195
|
+
"import re\n"
|
|
196
|
+
"\n"
|
|
197
|
+
"re.compile('(')\n"
|
|
198
|
+
)
|
|
199
|
+
|
|
186
200
|
|
|
187
201
|
def run_git(repository_root: Path, *git_arguments: str) -> str:
|
|
188
202
|
completed = subprocess.run(
|
|
@@ -240,16 +254,24 @@ def advance_origin_main_divergent(
|
|
|
240
254
|
run_git(repository_root, "checkout", "feature")
|
|
241
255
|
|
|
242
256
|
|
|
243
|
-
def
|
|
257
|
+
def _spawn_payload(tool_name: str, subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
244
258
|
return json.dumps(
|
|
245
259
|
{
|
|
246
|
-
"tool_name":
|
|
260
|
+
"tool_name": tool_name,
|
|
247
261
|
"tool_input": {"subagent_type": subagent_type, "prompt": prompt},
|
|
248
262
|
"cwd": str(cwd),
|
|
249
263
|
}
|
|
250
264
|
)
|
|
251
265
|
|
|
252
266
|
|
|
267
|
+
def write_agent_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
268
|
+
return _spawn_payload("Agent", subagent_type, prompt, cwd)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def write_task_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
272
|
+
return _spawn_payload("Task", subagent_type, prompt, cwd)
|
|
273
|
+
|
|
274
|
+
|
|
253
275
|
def run_hook(payload: str, cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
254
276
|
return subprocess.run(
|
|
255
277
|
[sys.executable, str(HOOK_PATH)],
|
|
@@ -586,3 +608,103 @@ def test_hook_imports_real_config_when_parent_holds_shadowing_config(
|
|
|
586
608
|
|
|
587
609
|
assert "ModuleNotFoundError" not in completed.stderr
|
|
588
610
|
assert completed.returncode == 0
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def test_task_tool_code_verifier_spawn_is_gated_like_agent(tmp_path: Path) -> None:
|
|
614
|
+
repository_root = tmp_path / "repo"
|
|
615
|
+
repository_root.mkdir()
|
|
616
|
+
initialize_repository(repository_root)
|
|
617
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
618
|
+
write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
|
|
619
|
+
task_payload = write_task_payload("code-verifier", "verify the change", repository_root)
|
|
620
|
+
task_result = run_hook(task_payload, repository_root)
|
|
621
|
+
assert not is_allow(task_result)
|
|
622
|
+
task_reason = deny_reason(task_result)
|
|
623
|
+
assert "fresh.py" in task_reason
|
|
624
|
+
assert "CODE_RULES violations on changed lines:" in task_reason
|
|
625
|
+
clean_coder_payload = write_task_payload("clean-coder", "write code", repository_root)
|
|
626
|
+
clean_coder_result = run_hook(clean_coder_payload, repository_root)
|
|
627
|
+
assert is_allow(clean_coder_result)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def stage_package_and_feature_repo(tmp_path: Path) -> tuple[Path, Path]:
|
|
631
|
+
"""Copy the hook package and build a clean feature repo for load-failure tests.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
tmp_path: The pytest temporary directory the staged copy lands under.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
A ``(staged_enforcer_path, repository_root)`` pair: the copied
|
|
638
|
+
``code_rules_enforcer.py`` a test renders unloadable, and the clean
|
|
639
|
+
feature repository the staged hook runs against.
|
|
640
|
+
"""
|
|
641
|
+
real_hooks_directory = HOOK_PATH.parent.parent
|
|
642
|
+
real_package_directory = real_hooks_directory.parent
|
|
643
|
+
staged_package_directory = tmp_path / "claude-dev-env"
|
|
644
|
+
shutil.copytree(real_hooks_directory, staged_package_directory / "hooks")
|
|
645
|
+
shutil.copytree(
|
|
646
|
+
real_package_directory / "_shared", staged_package_directory / "_shared"
|
|
647
|
+
)
|
|
648
|
+
enforcer_path = staged_package_directory / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
649
|
+
repository_root = tmp_path / "repo"
|
|
650
|
+
repository_root.mkdir()
|
|
651
|
+
initialize_repository(repository_root)
|
|
652
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
653
|
+
write_working_tree_file(repository_root, "feature.py", CLEAN_MODULE_SOURCE)
|
|
654
|
+
return enforcer_path, repository_root
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def run_staged_preflight_hook(
|
|
658
|
+
enforcer_path: Path, repository_root: Path
|
|
659
|
+
) -> subprocess.CompletedProcess[str]:
|
|
660
|
+
"""Run the staged preflight hook that sits beside *enforcer_path*.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
enforcer_path: The staged ``code_rules_enforcer.py`` path; the staged
|
|
664
|
+
hook is its sibling in the same ``hooks/blocking`` directory.
|
|
665
|
+
repository_root: The feature repository the hook inspects.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
The completed hook subprocess run.
|
|
669
|
+
"""
|
|
670
|
+
staged_hook = enforcer_path.parent / "code_verifier_spawn_preflight_gate.py"
|
|
671
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
672
|
+
return subprocess.run(
|
|
673
|
+
[sys.executable, str(staged_hook)],
|
|
674
|
+
check=False,
|
|
675
|
+
input=payload,
|
|
676
|
+
capture_output=True,
|
|
677
|
+
text=True,
|
|
678
|
+
cwd=str(repository_root),
|
|
679
|
+
timeout=120,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def test_engine_load_failure_denies_with_named_reason(tmp_path: Path) -> None:
|
|
684
|
+
enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
|
|
685
|
+
enforcer_path.unlink()
|
|
686
|
+
completed = run_staged_preflight_hook(enforcer_path, repository_root)
|
|
687
|
+
assert not is_allow(completed)
|
|
688
|
+
reason = deny_reason(completed)
|
|
689
|
+
assert "CODE_RULES engine failed to load" in reason
|
|
690
|
+
assert "load_validate_content" in reason
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def test_engine_import_error_denies_with_named_reason(tmp_path: Path) -> None:
|
|
694
|
+
enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
|
|
695
|
+
enforcer_path.write_text(BROKEN_ENFORCER_SOURCE, encoding="utf-8")
|
|
696
|
+
completed = run_staged_preflight_hook(enforcer_path, repository_root)
|
|
697
|
+
assert not is_allow(completed)
|
|
698
|
+
reason = deny_reason(completed)
|
|
699
|
+
assert "CODE_RULES engine failed to load" in reason
|
|
700
|
+
assert "load_validate_content" in reason
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def test_engine_re_error_denies_with_named_reason(tmp_path: Path) -> None:
|
|
704
|
+
enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
|
|
705
|
+
enforcer_path.write_text(RE_ERROR_ENFORCER_SOURCE, encoding="utf-8")
|
|
706
|
+
completed = run_staged_preflight_hook(enforcer_path, repository_root)
|
|
707
|
+
assert not is_allow(completed)
|
|
708
|
+
reason = deny_reason(completed)
|
|
709
|
+
assert "CODE_RULES engine failed to load" in reason
|
|
710
|
+
assert "load_validate_content" in reason
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Contract: code-verifier cannot message a warm session-advisor."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
|
6
|
+
_CODE_VERIFIER_AGENT_PATH = _PACKAGE_ROOT / "agents" / "code-verifier.md"
|
|
7
|
+
_FRONTMATTER_OPEN = "---\n"
|
|
8
|
+
_FRONTMATTER_CLOSE = "\n---\n"
|
|
9
|
+
_TOOLS_PREFIX = "tools:"
|
|
10
|
+
_SEND_MESSAGE_TOOL_NAME = "SendMessage"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_code_verifier_tools_exclude_sendmessage() -> None:
|
|
14
|
+
"""tools frontmatter must not list SendMessage (warm-advisor path closed)."""
|
|
15
|
+
agent_text = _CODE_VERIFIER_AGENT_PATH.read_text(encoding="utf-8")
|
|
16
|
+
assert agent_text.startswith(_FRONTMATTER_OPEN), "missing frontmatter open"
|
|
17
|
+
frontmatter_end = agent_text.find(_FRONTMATTER_CLOSE, len(_FRONTMATTER_OPEN))
|
|
18
|
+
assert frontmatter_end > 0, "missing frontmatter close"
|
|
19
|
+
frontmatter_body = agent_text[len(_FRONTMATTER_OPEN) : frontmatter_end]
|
|
20
|
+
all_tools_lines = [
|
|
21
|
+
each_line
|
|
22
|
+
for each_line in frontmatter_body.splitlines()
|
|
23
|
+
if each_line.startswith(_TOOLS_PREFIX)
|
|
24
|
+
]
|
|
25
|
+
assert len(all_tools_lines) == 1, all_tools_lines
|
|
26
|
+
tools_value = all_tools_lines[0].removeprefix(_TOOLS_PREFIX).strip()
|
|
27
|
+
all_tool_names = {each_name.strip() for each_name in tools_value.split(",")}
|
|
28
|
+
assert _SEND_MESSAGE_TOOL_NAME not in all_tool_names, all_tool_names
|
|
@@ -4,6 +4,7 @@ import importlib.util
|
|
|
4
4
|
import io
|
|
5
5
|
import json
|
|
6
6
|
import pathlib
|
|
7
|
+
import re
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
9
10
|
|
|
@@ -13,8 +14,6 @@ _HOOK_DIR = pathlib.Path(__file__).parent
|
|
|
13
14
|
if str(_HOOK_DIR) not in sys.path:
|
|
14
15
|
sys.path.insert(0, str(_HOOK_DIR))
|
|
15
16
|
|
|
16
|
-
import re
|
|
17
|
-
|
|
18
17
|
_GH_PR_READY_PATTERN = re.compile(r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)")
|
|
19
18
|
|
|
20
19
|
hook_spec = importlib.util.spec_from_file_location(
|
|
@@ -53,15 +52,44 @@ def test_does_not_match_gh_issue_close() -> None:
|
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
def test_extracts_pr_number_from_command() -> None:
|
|
56
|
-
assert _resolve_pr_number("gh pr ready 418", None) == 418
|
|
55
|
+
assert _resolve_pr_number("gh pr ready 418", None, None, None) == 418
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_extracts_pr_number_when_repo_flag_precedes_number() -> None:
|
|
59
|
+
assert (
|
|
60
|
+
_resolve_pr_number(
|
|
61
|
+
"gh pr ready --repo sample-owner/target-repo 161",
|
|
62
|
+
None,
|
|
63
|
+
"sample-owner",
|
|
64
|
+
"target-repo",
|
|
65
|
+
)
|
|
66
|
+
== 161
|
|
67
|
+
)
|
|
57
68
|
|
|
58
69
|
|
|
59
70
|
def test_extracts_pr_number_with_flags() -> None:
|
|
60
|
-
assert _resolve_pr_number("gh pr ready 99 --undo", None) == 99
|
|
71
|
+
assert _resolve_pr_number("gh pr ready 99 --undo", None, None, None) == 99
|
|
61
72
|
|
|
62
73
|
|
|
63
74
|
def test_returns_none_when_no_number_and_no_repo() -> None:
|
|
64
|
-
assert _resolve_pr_number("gh pr ready", "/nonexistent/path") is None
|
|
75
|
+
assert _resolve_pr_number("gh pr ready", "/nonexistent/path", None, None) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_number_resolution_binds_gh_view_to_named_repo(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
) -> None:
|
|
81
|
+
captured_commands: list[list[str]] = []
|
|
82
|
+
|
|
83
|
+
def fake_run(all_arguments: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
84
|
+
captured_commands.append(all_arguments)
|
|
85
|
+
return subprocess.CompletedProcess(args=all_arguments, returncode=0, stdout="500\n", stderr="")
|
|
86
|
+
|
|
87
|
+
monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
|
|
88
|
+
assert _resolve_pr_number("gh pr ready", None, "flag-owner", "flag-repo") == 500
|
|
89
|
+
assert captured_commands
|
|
90
|
+
for each_command in captured_commands:
|
|
91
|
+
assert "--repo" in each_command
|
|
92
|
+
assert "flag-owner/flag-repo" in each_command
|
|
65
93
|
|
|
66
94
|
|
|
67
95
|
def test_matches_gh_pr_ready_in_compound_command() -> None:
|
|
@@ -226,6 +254,13 @@ def test_parse_repo_flag_short_alias() -> None:
|
|
|
226
254
|
)
|
|
227
255
|
|
|
228
256
|
|
|
257
|
+
def test_parse_repo_flag_short_alias_attached_value() -> None:
|
|
258
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 -Rsample-owner/target-repo") == (
|
|
259
|
+
"sample-owner",
|
|
260
|
+
"target-repo",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
229
264
|
def test_parse_repo_flag_absent_returns_none() -> None:
|
|
230
265
|
assert hook_module._parse_repo_flag("gh pr ready 418") is None
|
|
231
266
|
|
|
@@ -249,6 +284,26 @@ def test_ready_segment_clips_at_command_separator() -> None:
|
|
|
249
284
|
)
|
|
250
285
|
|
|
251
286
|
|
|
287
|
+
def test_ready_segment_keeps_repo_flag_on_a_continued_line() -> None:
|
|
288
|
+
segment = hook_module._ready_command_segment(
|
|
289
|
+
"gh pr ready 161 \\\n --repo target-owner/target-repo"
|
|
290
|
+
)
|
|
291
|
+
assert "--repo" in segment
|
|
292
|
+
assert "target-owner/target-repo" in segment
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_continued_repo_flag_binds_the_gate_to_the_named_repo(
|
|
296
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
297
|
+
) -> None:
|
|
298
|
+
identity = _capture_convergence_identity(
|
|
299
|
+
monkeypatch,
|
|
300
|
+
tmp_path,
|
|
301
|
+
"gh pr ready 161 \\\n --repo target-owner/target-repo",
|
|
302
|
+
("cwd-owner", "cwd-repo"),
|
|
303
|
+
)
|
|
304
|
+
assert identity == ("target-owner", "target-repo", 161)
|
|
305
|
+
|
|
306
|
+
|
|
252
307
|
def test_chained_repo_flag_does_not_misbind_the_gate(
|
|
253
308
|
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
254
309
|
) -> None:
|
|
@@ -278,3 +333,96 @@ def test_ready_segment_skips_a_leading_undo_invocation() -> None:
|
|
|
278
333
|
hook_module._ready_command_segment("gh pr ready --undo && gh pr ready 161")
|
|
279
334
|
== "gh pr ready 161"
|
|
280
335
|
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_parse_repo_flag_full_url_form() -> None:
|
|
339
|
+
assert hook_module._parse_repo_flag(
|
|
340
|
+
"gh pr ready 161 --repo https://github.com/other-owner/other-repo"
|
|
341
|
+
) == ("other-owner", "other-repo")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_parse_repo_flag_short_alias_full_url_form() -> None:
|
|
345
|
+
assert hook_module._parse_repo_flag(
|
|
346
|
+
"gh pr ready 161 -R https://github.com/other-owner/other-repo"
|
|
347
|
+
) == ("other-owner", "other-repo")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_parse_repo_flag_ssh_form() -> None:
|
|
351
|
+
assert hook_module._parse_repo_flag(
|
|
352
|
+
"gh pr ready 161 --repo git@github.com:other-owner/other-repo"
|
|
353
|
+
) == ("other-owner", "other-repo")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_parse_repo_flag_ssh_form_strips_git_suffix() -> None:
|
|
357
|
+
assert hook_module._parse_repo_flag(
|
|
358
|
+
"gh pr ready 161 --repo git@github.com:other-owner/other-repo.git"
|
|
359
|
+
) == ("other-owner", "other-repo")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_parse_repo_flag_url_form_strips_git_suffix() -> None:
|
|
363
|
+
assert hook_module._parse_repo_flag(
|
|
364
|
+
"gh pr ready 161 --repo https://github.com/other-owner/other-repo.git"
|
|
365
|
+
) == ("other-owner", "other-repo")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_parse_pr_url_accepts_enterprise_host() -> None:
|
|
369
|
+
assert hook_module._parse_pr_url(
|
|
370
|
+
"gh pr ready https://github.example.com/other-owner/other-repo/pull/161"
|
|
371
|
+
) == ("other-owner", "other-repo", 161)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_background_operator_clips_chained_repo_flag(
|
|
375
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
376
|
+
) -> None:
|
|
377
|
+
identity = _capture_convergence_identity(
|
|
378
|
+
monkeypatch,
|
|
379
|
+
tmp_path,
|
|
380
|
+
"gh pr ready 161 & gh pr comment 999 --repo other-owner/other-repo",
|
|
381
|
+
("cwd-owner", "cwd-repo"),
|
|
382
|
+
)
|
|
383
|
+
assert identity == ("cwd-owner", "cwd-repo", 161)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_trailing_redirect_keeps_earlier_repo_flag(
|
|
387
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
388
|
+
) -> None:
|
|
389
|
+
identity = _capture_convergence_identity(
|
|
390
|
+
monkeypatch,
|
|
391
|
+
tmp_path,
|
|
392
|
+
"gh pr ready 161 --repo other-owner/other-repo 2>&1",
|
|
393
|
+
("cwd-owner", "cwd-repo"),
|
|
394
|
+
)
|
|
395
|
+
assert identity == ("other-owner", "other-repo", 161)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_stderr_redirect_dup_keeps_repo_flag() -> None:
|
|
399
|
+
segment = hook_module._ready_command_segment(
|
|
400
|
+
"gh pr ready 161 >&2 --repo other-owner/other-repo"
|
|
401
|
+
)
|
|
402
|
+
assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_stderr_to_stdout_redirect_keeps_repo_flag() -> None:
|
|
406
|
+
segment = hook_module._ready_command_segment(
|
|
407
|
+
"gh pr ready 2>&1 --repo other-owner/other-repo"
|
|
408
|
+
)
|
|
409
|
+
assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_append_all_output_redirect_keeps_repo_flag() -> None:
|
|
413
|
+
segment = hook_module._ready_command_segment(
|
|
414
|
+
"gh pr ready 161 &> log --repo other-owner/other-repo"
|
|
415
|
+
)
|
|
416
|
+
assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_stderr_redirect_binds_repo_end_to_end(
|
|
420
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
421
|
+
) -> None:
|
|
422
|
+
identity = _capture_convergence_identity(
|
|
423
|
+
monkeypatch,
|
|
424
|
+
tmp_path,
|
|
425
|
+
"gh pr ready 161 >&2 --repo other-owner/other-repo",
|
|
426
|
+
("cwd-owner", "cwd-repo"),
|
|
427
|
+
)
|
|
428
|
+
assert identity == ("other-owner", "other-repo", 161)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for the destructive-command-blocker deny mode.
|
|
2
|
+
|
|
3
|
+
The sandbox runs under ``--dangerously-skip-permissions``, which auto-resolves
|
|
4
|
+
an ``ask`` decision, so only a hard ``deny`` contains a destructive command.
|
|
5
|
+
Setting ``CLAUDE_DESTRUCTIVE_DENY_MODE`` turns the hook's terminal ``ask`` into
|
|
6
|
+
a ``deny`` so the sandbox can be contained.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SCRIPT_PATH = Path(__file__).parent / "destructive_command_blocker.py"
|
|
18
|
+
DENY_MODE_ENV_VAR = "CLAUDE_DESTRUCTIVE_DENY_MODE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_hook_with_environment(command: str, extra_environment: dict[str, str]) -> dict:
|
|
22
|
+
child_environment = os.environ.copy()
|
|
23
|
+
child_environment.pop(DENY_MODE_ENV_VAR, None)
|
|
24
|
+
child_environment.update(extra_environment)
|
|
25
|
+
completed_process = subprocess.run(
|
|
26
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
27
|
+
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
|
|
28
|
+
text=True,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
check=False,
|
|
31
|
+
env=child_environment,
|
|
32
|
+
)
|
|
33
|
+
return json.loads(completed_process.stdout)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_rm_rf_denies_when_deny_mode_env_is_set() -> None:
|
|
37
|
+
response = _run_hook_with_environment("rm -rf /var/log/myapp", {DENY_MODE_ENV_VAR: "1"})
|
|
38
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
39
|
+
assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_rm_rf_asks_when_deny_mode_env_is_absent() -> None:
|
|
43
|
+
response = _run_hook_with_environment("rm -rf /var/log/myapp", {})
|
|
44
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
45
|
+
assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
@@ -17,6 +17,7 @@ _HOOKS_ROOT = _HOOK_DIR.parent
|
|
|
17
17
|
if str(_HOOKS_ROOT) not in sys.path:
|
|
18
18
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
19
19
|
|
|
20
|
+
from blocking import convergence_gate_blocker as convergence_module # noqa: E402
|
|
20
21
|
from blocking import pr_description_proof_of_work as proof_module # noqa: E402
|
|
21
22
|
from blocking.pr_description_proof_of_work import ( # noqa: E402
|
|
22
23
|
audit_proof_comment_body,
|
|
@@ -70,6 +71,34 @@ def _install_fake_gh(monkeypatch, all_comment_bodies, all_diff_names, diff_text)
|
|
|
70
71
|
monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
|
|
71
72
|
|
|
72
73
|
|
|
74
|
+
def _install_recording_fake_gh(monkeypatch, all_comment_bodies, all_diff_names, diff_text):
|
|
75
|
+
all_comment_records = [{"body": each_body} for each_body in all_comment_bodies]
|
|
76
|
+
all_recorded_argument_lists: list[list[str]] = []
|
|
77
|
+
|
|
78
|
+
def _fake_run_gh_command(all_gh_arguments):
|
|
79
|
+
all_recorded_argument_lists.append(list(all_gh_arguments))
|
|
80
|
+
if all_gh_arguments and all_gh_arguments[0] == "api":
|
|
81
|
+
return json.dumps([all_comment_records])
|
|
82
|
+
if list(all_gh_arguments[:2]) == ["pr", "view"]:
|
|
83
|
+
return json.dumps({"number": 77})
|
|
84
|
+
if "--name-only" in all_gh_arguments:
|
|
85
|
+
return "\n".join(all_diff_names)
|
|
86
|
+
if list(all_gh_arguments[:2]) == ["pr", "diff"]:
|
|
87
|
+
return diff_text
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
|
|
91
|
+
return all_recorded_argument_lists
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _argument_lists_with_leading(all_recorded_argument_lists, first_token, second_token):
|
|
95
|
+
return [
|
|
96
|
+
each_arguments
|
|
97
|
+
for each_arguments in all_recorded_argument_lists
|
|
98
|
+
if list(each_arguments[:2]) == [first_token, second_token]
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
73
102
|
def test_is_proof_shaped_body_detects_verification_heading() -> None:
|
|
74
103
|
assert is_proof_shaped_body(PASSING_PROOF_COMMENT_BODY)
|
|
75
104
|
|
|
@@ -159,4 +188,126 @@ def test_ready_gate_resolves_pr_number_from_current_branch(
|
|
|
159
188
|
monkeypatch: pytest.MonkeyPatch,
|
|
160
189
|
) -> None:
|
|
161
190
|
_install_fake_gh(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], "")
|
|
191
|
+
monkeypatch.setattr(
|
|
192
|
+
convergence_module,
|
|
193
|
+
"_resolve_current_branch_pr_number",
|
|
194
|
+
lambda all_target_repo, cwd: 77,
|
|
195
|
+
)
|
|
162
196
|
assert evaluate_pr_ready_gate("gh pr ready") is None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_ready_gate_reads_comments_from_repo_named_by_flag(
|
|
200
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
201
|
+
) -> None:
|
|
202
|
+
all_recorded = _install_recording_fake_gh(
|
|
203
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
204
|
+
)
|
|
205
|
+
evaluate_pr_ready_gate("gh pr ready 42 --repo other-owner/other-repo")
|
|
206
|
+
all_api_calls = _argument_lists_with_leading(
|
|
207
|
+
all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
|
|
208
|
+
)
|
|
209
|
+
assert all_api_calls
|
|
210
|
+
for each_arguments in all_api_calls:
|
|
211
|
+
assert "{owner}" not in each_arguments[1]
|
|
212
|
+
assert "{repo}" not in each_arguments[1]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_ready_gate_resolves_pr_number_from_repo_named_by_flag(
|
|
216
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
217
|
+
) -> None:
|
|
218
|
+
_install_fake_gh(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], "")
|
|
219
|
+
all_captured_repo_pairs: list[tuple[str, str] | None] = []
|
|
220
|
+
|
|
221
|
+
def fake_resolver(all_target_repo: tuple[str, str] | None, cwd: str | None) -> int:
|
|
222
|
+
all_captured_repo_pairs.append(all_target_repo)
|
|
223
|
+
return 77
|
|
224
|
+
|
|
225
|
+
monkeypatch.setattr(convergence_module, "_resolve_current_branch_pr_number", fake_resolver)
|
|
226
|
+
evaluate_pr_ready_gate("gh pr ready --repo other-owner/other-repo")
|
|
227
|
+
assert all_captured_repo_pairs == [("other-owner", "other-repo")]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_ready_gate_reads_comments_from_repo_named_by_url(
|
|
231
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
232
|
+
) -> None:
|
|
233
|
+
all_recorded = _install_recording_fake_gh(
|
|
234
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
235
|
+
)
|
|
236
|
+
evaluate_pr_ready_gate("gh pr ready https://github.com/url-owner/url-repo/pull/99")
|
|
237
|
+
all_api_calls = _argument_lists_with_leading(
|
|
238
|
+
all_recorded, "api", "repos/url-owner/url-repo/issues/99/comments"
|
|
239
|
+
)
|
|
240
|
+
assert all_api_calls
|
|
241
|
+
all_view_calls = _argument_lists_with_leading(all_recorded, "pr", "view")
|
|
242
|
+
assert not all_view_calls
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_ready_gate_leaves_bare_command_bound_to_current_directory(
|
|
246
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
247
|
+
) -> None:
|
|
248
|
+
all_recorded = _install_recording_fake_gh(
|
|
249
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
250
|
+
)
|
|
251
|
+
evaluate_pr_ready_gate("gh pr ready 123")
|
|
252
|
+
all_api_calls = _argument_lists_with_leading(
|
|
253
|
+
all_recorded, "api", "repos/{owner}/{repo}/issues/123/comments"
|
|
254
|
+
)
|
|
255
|
+
assert all_api_calls
|
|
256
|
+
for each_arguments in all_recorded:
|
|
257
|
+
assert "--repo" not in each_arguments
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_ready_gate_ignores_repo_flag_of_chained_command(
|
|
261
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
262
|
+
) -> None:
|
|
263
|
+
all_recorded = _install_recording_fake_gh(
|
|
264
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
265
|
+
)
|
|
266
|
+
evaluate_pr_ready_gate(
|
|
267
|
+
"gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo"
|
|
268
|
+
)
|
|
269
|
+
all_api_calls = _argument_lists_with_leading(
|
|
270
|
+
all_recorded, "api", "repos/{owner}/{repo}/issues/161/comments"
|
|
271
|
+
)
|
|
272
|
+
assert all_api_calls
|
|
273
|
+
for each_arguments in all_recorded:
|
|
274
|
+
assert "other-owner/other-repo" not in each_arguments
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_ready_gate_reads_comments_from_repo_named_by_full_url_flag(
|
|
278
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
279
|
+
) -> None:
|
|
280
|
+
all_recorded = _install_recording_fake_gh(
|
|
281
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
282
|
+
)
|
|
283
|
+
evaluate_pr_ready_gate("gh pr ready 42 --repo https://github.com/other-owner/other-repo")
|
|
284
|
+
all_api_calls = _argument_lists_with_leading(
|
|
285
|
+
all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
|
|
286
|
+
)
|
|
287
|
+
assert all_api_calls
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_ready_gate_reads_comments_from_repo_named_by_short_url_flag(
|
|
291
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
292
|
+
) -> None:
|
|
293
|
+
all_recorded = _install_recording_fake_gh(
|
|
294
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
295
|
+
)
|
|
296
|
+
evaluate_pr_ready_gate("gh pr ready 42 -R https://github.com/other-owner/other-repo")
|
|
297
|
+
all_api_calls = _argument_lists_with_leading(
|
|
298
|
+
all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
|
|
299
|
+
)
|
|
300
|
+
assert all_api_calls
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_ready_gate_reads_comments_from_repo_named_by_ssh_flag(
|
|
304
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
305
|
+
) -> None:
|
|
306
|
+
all_recorded = _install_recording_fake_gh(
|
|
307
|
+
monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
|
|
308
|
+
)
|
|
309
|
+
evaluate_pr_ready_gate("gh pr ready 42 --repo git@github.com:other-owner/other-repo")
|
|
310
|
+
all_api_calls = _argument_lists_with_leading(
|
|
311
|
+
all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
|
|
312
|
+
)
|
|
313
|
+
assert all_api_calls
|
|
@@ -633,8 +633,8 @@ def test_dispatcher_write_applies_both_groups() -> None:
|
|
|
633
633
|
assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
|
|
634
634
|
"plain_language_blocker (Group B) must be in Write applicable set"
|
|
635
635
|
)
|
|
636
|
-
assert len(all_write_entries) ==
|
|
637
|
-
f"Write tool must apply to all
|
|
636
|
+
assert len(all_write_entries) == 21, (
|
|
637
|
+
f"Write tool must apply to all 21 hosted hooks, got {len(all_write_entries)}"
|
|
638
638
|
)
|
|
639
639
|
|
|
640
640
|
|
|
@@ -647,16 +647,16 @@ def test_dispatcher_edit_applies_both_groups() -> None:
|
|
|
647
647
|
assert "blocking/stale_comment_reference_blocker.py" in all_edit_script_paths, (
|
|
648
648
|
"stale_comment_reference_blocker belongs in the Edit applicable set"
|
|
649
649
|
)
|
|
650
|
-
assert len(all_edit_entries) ==
|
|
651
|
-
f"expected
|
|
650
|
+
assert len(all_edit_entries) == 22, (
|
|
651
|
+
f"expected 22 Edit entries, got {len(all_edit_entries)}"
|
|
652
652
|
)
|
|
653
653
|
|
|
654
654
|
|
|
655
655
|
def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
656
|
-
"""MultiEdit tool triggers only Group B (
|
|
656
|
+
"""MultiEdit tool triggers only Group B (10 hooks), not Group A."""
|
|
657
657
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
658
|
-
assert len(all_multi_edit_entries) ==
|
|
659
|
-
f"MultiEdit tool must apply to exactly
|
|
658
|
+
assert len(all_multi_edit_entries) == 10, (
|
|
659
|
+
f"MultiEdit tool must apply to exactly 10 Group-B hooks, got {len(all_multi_edit_entries)}"
|
|
660
660
|
)
|
|
661
661
|
|
|
662
662
|
|
|
@@ -667,7 +667,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
|
|
|
667
667
|
it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
|
|
668
668
|
A Python Write payload that run_all_validators would have flagged (mypy errors, for
|
|
669
669
|
instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
|
|
670
|
-
dispatcher covers only its
|
|
670
|
+
dispatcher covers only its 22 hosted blocking hooks — none of which includes the
|
|
671
671
|
validators runner.
|
|
672
672
|
"""
|
|
673
673
|
python_content_with_type_error = (
|