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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Behavioral tests for the push 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 io
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
_HOOK_DIR = Path(__file__).resolve().parent
|
|
21
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
23
|
+
|
|
24
|
+
GIT_TIMEOUT_SECONDS = 30
|
|
25
|
+
LOW_EFFORT = "low"
|
|
26
|
+
XHIGH_EFFORT = "xhigh"
|
|
27
|
+
CODE_SOURCE = "def add(a: int, b: int) -> int:\n return a + b\n"
|
|
28
|
+
CODE_CHANGE = "def add(a: int, b: int) -> int:\n return a - b\n"
|
|
29
|
+
DOCS_SOURCE = "# Notes\n\nfirst line\n"
|
|
30
|
+
DOCS_CHANGE = "# Notes\n\nsecond line\n"
|
|
31
|
+
BYPASS_MARKER = "# code-review-skip"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_module(module_name: str) -> ModuleType:
|
|
35
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
36
|
+
module_name, _HOOK_DIR / f"{module_name}.py"
|
|
37
|
+
)
|
|
38
|
+
assert module_spec is not None
|
|
39
|
+
assert module_spec.loader is not None
|
|
40
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
41
|
+
module_spec.loader.exec_module(loaded_module)
|
|
42
|
+
return loaded_module
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
gate_module = _load_module("code_review_push_gate")
|
|
46
|
+
store_module = _load_module("code_review_stamp_store")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_git(repository_directory: Path, *git_arguments: str) -> None:
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["git", "-C", str(repository_directory), *git_arguments],
|
|
52
|
+
check=True,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _init_pushed_repo(tmp_path: Path, tracked_name: str, base_text: str) -> Path:
|
|
60
|
+
origin_directory = tmp_path / "origin.git"
|
|
61
|
+
work_directory = tmp_path / "work"
|
|
62
|
+
work_directory.mkdir()
|
|
63
|
+
subprocess.run(
|
|
64
|
+
["git", "init", "--bare", "--initial-branch=main", str(origin_directory)],
|
|
65
|
+
check=True,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
69
|
+
)
|
|
70
|
+
_run_git(work_directory, "init", "--initial-branch=main")
|
|
71
|
+
_run_git(work_directory, "config", "user.email", "tests@example.com")
|
|
72
|
+
_run_git(work_directory, "config", "user.name", "Reviewer")
|
|
73
|
+
(work_directory / tracked_name).write_text(base_text, encoding="utf-8")
|
|
74
|
+
_run_git(work_directory, "add", "-A")
|
|
75
|
+
_run_git(work_directory, "commit", "-m", "base")
|
|
76
|
+
_run_git(work_directory, "remote", "add", "origin", str(origin_directory))
|
|
77
|
+
_run_git(work_directory, "push", "-u", "origin", "main")
|
|
78
|
+
return work_directory
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _make_code_surface_repo(tmp_path: Path) -> Path:
|
|
82
|
+
work_directory = _init_pushed_repo(tmp_path, "app.py", CODE_SOURCE)
|
|
83
|
+
(work_directory / "app.py").write_text(CODE_CHANGE, encoding="utf-8")
|
|
84
|
+
return work_directory
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _make_docs_surface_repo(tmp_path: Path) -> Path:
|
|
88
|
+
work_directory = _init_pushed_repo(tmp_path, "notes.md", DOCS_SOURCE)
|
|
89
|
+
(work_directory / "notes.md").write_text(DOCS_CHANGE, encoding="utf-8")
|
|
90
|
+
return work_directory
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
|
|
94
|
+
home_text = str(fake_home)
|
|
95
|
+
monkeypatch.setenv("HOME", home_text)
|
|
96
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
97
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
98
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _prepared_code_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
102
|
+
fake_home = tmp_path / "home"
|
|
103
|
+
fake_home.mkdir()
|
|
104
|
+
_isolate_home(monkeypatch, fake_home)
|
|
105
|
+
return _make_code_surface_repo(tmp_path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _record_stamp(work_directory: Path, effort: str) -> None:
|
|
109
|
+
surface_hash = store_module.live_surface_hash(str(work_directory))
|
|
110
|
+
assert surface_hash is not None
|
|
111
|
+
store_module.record_clean_stamp(str(work_directory), surface_hash, effort)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_main(monkeypatch: pytest.MonkeyPatch, command_text: str, work_directory: Path) -> str:
|
|
115
|
+
payload_text = json.dumps(
|
|
116
|
+
{
|
|
117
|
+
"tool_name": "Bash",
|
|
118
|
+
"tool_input": {"command": command_text},
|
|
119
|
+
"cwd": str(work_directory),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
|
|
123
|
+
captured_stdout = io.StringIO()
|
|
124
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
125
|
+
gate_module.main()
|
|
126
|
+
return captured_stdout.getvalue()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_push_without_low_stamp_denies(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
130
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
131
|
+
deny_reason = gate_module.deny_reason_for_directory(str(work_directory))
|
|
132
|
+
assert deny_reason is not None
|
|
133
|
+
assert "PUSH_GATE" in deny_reason
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_covering_low_stamp_allows_push(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
137
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
138
|
+
_record_stamp(work_directory, LOW_EFFORT)
|
|
139
|
+
assert gate_module.deny_reason_for_directory(str(work_directory)) is None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_covering_xhigh_stamp_allows_push(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
143
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
144
|
+
_record_stamp(work_directory, XHIGH_EFFORT)
|
|
145
|
+
assert gate_module.deny_reason_for_directory(str(work_directory)) is None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_docs_only_surface_is_exempt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
149
|
+
fake_home = tmp_path / "home"
|
|
150
|
+
fake_home.mkdir()
|
|
151
|
+
_isolate_home(monkeypatch, fake_home)
|
|
152
|
+
work_directory = _make_docs_surface_repo(tmp_path)
|
|
153
|
+
assert gate_module.deny_reason_for_directory(str(work_directory)) is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_git_push_payload_denies(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
157
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
158
|
+
emitted = _run_main(monkeypatch, "git push origin main", work_directory)
|
|
159
|
+
assert "PUSH_GATE" in emitted
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_git_push_with_leading_flags_denies(
|
|
163
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
164
|
+
) -> None:
|
|
165
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
166
|
+
emitted = _run_main(monkeypatch, "git push --force-with-lease origin main", work_directory)
|
|
167
|
+
assert "PUSH_GATE" in emitted
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_git_commit_is_not_gated_by_push_hook(
|
|
171
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
172
|
+
) -> None:
|
|
173
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
174
|
+
emitted = _run_main(monkeypatch, "git commit -m change", work_directory)
|
|
175
|
+
assert emitted == ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_git_stash_push_is_not_gated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
179
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
180
|
+
emitted = _run_main(monkeypatch, "git stash push", work_directory)
|
|
181
|
+
assert emitted == ""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_code_review_skip_marker_allows_push(
|
|
185
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
186
|
+
) -> None:
|
|
187
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
188
|
+
emitted = _run_main(monkeypatch, f"git push origin main {BYPASS_MARKER}", work_directory)
|
|
189
|
+
assert emitted == ""
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Behavioral tests for the code-review stamp-directory write blocker.
|
|
2
|
+
|
|
3
|
+
The blocker denies shell and file-tool access to
|
|
4
|
+
``~/.claude/code-review-stamps/`` and shell references to the stamp store
|
|
5
|
+
module or its mint call, while the sanctioned invoker command and unrelated
|
|
6
|
+
paths pass.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
_HOOK_DIR = Path(__file__).resolve().parent
|
|
21
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
23
|
+
|
|
24
|
+
STAMP_STORE_IMPORT_FORGE = (
|
|
25
|
+
'python -c "from code_review_stamp_store import record_clean_stamp; '
|
|
26
|
+
"record_clean_stamp(root, live_hash, 'low')\""
|
|
27
|
+
)
|
|
28
|
+
MINT_CALL_FORGE = "python -c \"record_clean_stamp(root, live_hash, 'xhigh')\""
|
|
29
|
+
PYTEST_RUN = "python -m pytest test_code_review_stamp_store.py"
|
|
30
|
+
SANCTIONED_INVOKER = "python invoke_code_review.py --record-stamp --cwd . --session-model opus low"
|
|
31
|
+
ABSOLUTE_STAMP_ACCESS = "cat ~/.claude/code-review-stamps/abc1234567890abc.json"
|
|
32
|
+
DECOY_ECHO_THEN_FORGE = (
|
|
33
|
+
'echo "invoke_code_review.py --record-stamp" >/dev/null; '
|
|
34
|
+
"echo x > ~/.claude/code-review-stamps/abc1234567890123.json"
|
|
35
|
+
)
|
|
36
|
+
DECOY_INVOKER_THEN_FORGE = (
|
|
37
|
+
"python invoke_code_review.py --record-stamp --cwd . low ; "
|
|
38
|
+
"echo pwned > ~/.claude/code-review-stamps/evil.json"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_module(module_name: str) -> ModuleType:
|
|
43
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
44
|
+
module_name, _HOOK_DIR / f"{module_name}.py"
|
|
45
|
+
)
|
|
46
|
+
assert module_spec is not None
|
|
47
|
+
assert module_spec.loader is not None
|
|
48
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
49
|
+
module_spec.loader.exec_module(loaded_module)
|
|
50
|
+
return loaded_module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
blocker_module = _load_module("code_review_stamp_directory_write_blocker")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
|
|
57
|
+
home_text = str(fake_home)
|
|
58
|
+
monkeypatch.setenv("HOME", home_text)
|
|
59
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
60
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
61
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_absolute_stamp_path_is_blocked() -> None:
|
|
65
|
+
assert blocker_module.references_stamp_directory(ABSOLUTE_STAMP_ACCESS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_store_module_import_forge_is_blocked() -> None:
|
|
69
|
+
assert blocker_module.references_stamp_directory(STAMP_STORE_IMPORT_FORGE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_mint_call_forge_is_blocked() -> None:
|
|
73
|
+
assert blocker_module.references_stamp_directory(MINT_CALL_FORGE)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_pytest_run_naming_store_test_is_not_blocked() -> None:
|
|
77
|
+
assert not blocker_module.references_stamp_directory(PYTEST_RUN)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_sanctioned_invoker_command_is_not_blocked() -> None:
|
|
81
|
+
assert not blocker_module.references_stamp_directory(SANCTIONED_INVOKER)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_decoy_echo_of_invoker_then_stamp_forge_is_blocked() -> None:
|
|
85
|
+
assert blocker_module.references_stamp_directory(DECOY_ECHO_THEN_FORGE)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_decoy_invoker_run_then_stamp_forge_is_blocked() -> None:
|
|
89
|
+
assert blocker_module.references_stamp_directory(DECOY_INVOKER_THEN_FORGE)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_write_tool_to_stamp_directory_is_blocked(
|
|
93
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
94
|
+
) -> None:
|
|
95
|
+
fake_home = tmp_path / "home"
|
|
96
|
+
fake_home.mkdir()
|
|
97
|
+
_isolate_home(monkeypatch, fake_home)
|
|
98
|
+
stamp_path = fake_home / ".claude" / "code-review-stamps" / "abc.json"
|
|
99
|
+
assert blocker_module.path_targets_stamp_directory(str(stamp_path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_write_tool_elsewhere_is_allowed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
103
|
+
fake_home = tmp_path / "home"
|
|
104
|
+
fake_home.mkdir()
|
|
105
|
+
_isolate_home(monkeypatch, fake_home)
|
|
106
|
+
other_path = tmp_path / "src" / "module.py"
|
|
107
|
+
assert not blocker_module.path_targets_stamp_directory(str(other_path))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_write_payload_to_stamp_directory_denies(
|
|
111
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
112
|
+
) -> None:
|
|
113
|
+
fake_home = tmp_path / "home"
|
|
114
|
+
fake_home.mkdir()
|
|
115
|
+
_isolate_home(monkeypatch, fake_home)
|
|
116
|
+
stamp_path = fake_home / ".claude" / "code-review-stamps" / "abc.json"
|
|
117
|
+
payload = {
|
|
118
|
+
"tool_name": "Write",
|
|
119
|
+
"tool_input": {"file_path": str(stamp_path), "content": "{}"},
|
|
120
|
+
}
|
|
121
|
+
deny_decision = blocker_module.decision_for_payload(payload)
|
|
122
|
+
assert deny_decision is not None
|
|
123
|
+
assert deny_decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_bash_forge_payload_denies() -> None:
|
|
127
|
+
payload = {
|
|
128
|
+
"tool_name": "Bash",
|
|
129
|
+
"tool_input": {"command": STAMP_STORE_IMPORT_FORGE},
|
|
130
|
+
}
|
|
131
|
+
deny_decision = blocker_module.decision_for_payload(payload)
|
|
132
|
+
assert deny_decision is not None
|
|
133
|
+
assert deny_decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_sanctioned_invoker_payload_is_not_denied() -> None:
|
|
137
|
+
payload = {
|
|
138
|
+
"tool_name": "Bash",
|
|
139
|
+
"tool_input": {"command": SANCTIONED_INVOKER},
|
|
140
|
+
}
|
|
141
|
+
assert blocker_module.decision_for_payload(payload) is None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_main_emits_deny_for_bash_forge(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
145
|
+
payload_text = json.dumps({"tool_name": "Bash", "tool_input": {"command": MINT_CALL_FORGE}})
|
|
146
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
|
|
147
|
+
captured_stdout = io.StringIO()
|
|
148
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
149
|
+
blocker_module.main()
|
|
150
|
+
assert "STAMP_DIRECTORY_GUARD" in captured_stdout.getvalue()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
STAMP_OBFUSCATION_PATH = "/.claude/code-review-stamps/a.json"
|
|
154
|
+
BENIGN_OBFUSCATION_PATH = "/tmp/x"
|
|
155
|
+
SPLIT_CD_FORGE = "cd ~/.claude && cd code-review-stamps && echo x > f.json"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _hex_forge(path_text: str) -> str:
|
|
159
|
+
return f"python -c \"open(bytes.fromhex('{path_text.encode().hex()}'),'w').write('x')\""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _chr_chain_forge(path_text: str) -> str:
|
|
163
|
+
chr_chain = "+".join(f"chr({ord(each_character)})" for each_character in path_text)
|
|
164
|
+
return f"python -c \"open({chr_chain},'w').write('x')\""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_split_directory_change_into_stamp_is_blocked() -> None:
|
|
168
|
+
assert blocker_module.references_stamp_directory(SPLIT_CD_FORGE)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_hex_obfuscated_stamp_path_is_blocked() -> None:
|
|
172
|
+
assert blocker_module.references_stamp_directory(_hex_forge(STAMP_OBFUSCATION_PATH))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_chr_chain_obfuscated_stamp_path_is_blocked() -> None:
|
|
176
|
+
assert blocker_module.references_stamp_directory(_chr_chain_forge(STAMP_OBFUSCATION_PATH))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_benign_decode_to_other_path_is_not_blocked() -> None:
|
|
180
|
+
assert not blocker_module.references_stamp_directory(_hex_forge(BENIGN_OBFUSCATION_PATH))
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Behavioral tests for the per-work-tree code-review stamp store.
|
|
2
|
+
|
|
3
|
+
Each test builds a real git repository with a real origin remote and records or
|
|
4
|
+
reads real stamp files under an isolated home, so it exercises the same store
|
|
5
|
+
surface the push and PR-create gates call: record a clean stamp, then ask
|
|
6
|
+
whether it covers the live branch surface at a given effort.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import pathlib
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
17
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
19
|
+
|
|
20
|
+
_store_spec = importlib.util.spec_from_file_location(
|
|
21
|
+
"code_review_stamp_store",
|
|
22
|
+
_HOOK_DIR / "code_review_stamp_store.py",
|
|
23
|
+
)
|
|
24
|
+
assert _store_spec is not None
|
|
25
|
+
assert _store_spec.loader is not None
|
|
26
|
+
_store_module = importlib.util.module_from_spec(_store_spec)
|
|
27
|
+
_store_spec.loader.exec_module(_store_module)
|
|
28
|
+
|
|
29
|
+
record_clean_stamp = _store_module.record_clean_stamp
|
|
30
|
+
stamp_covers_surface = _store_module.stamp_covers_surface
|
|
31
|
+
live_surface_hash = _store_module.live_surface_hash
|
|
32
|
+
stamp_path_for_repo = _store_module.stamp_path_for_repo
|
|
33
|
+
|
|
34
|
+
_verdict_store_module = sys.modules["verification_verdict_store"]
|
|
35
|
+
root_key_for_repo = _verdict_store_module.root_key_for_repo
|
|
36
|
+
|
|
37
|
+
_constants_spec = importlib.util.spec_from_file_location(
|
|
38
|
+
"code_review_enforcement_constants",
|
|
39
|
+
_HOOK_DIR / "config" / "code_review_enforcement_constants.py",
|
|
40
|
+
)
|
|
41
|
+
assert _constants_spec is not None
|
|
42
|
+
assert _constants_spec.loader is not None
|
|
43
|
+
_constants_module = importlib.util.module_from_spec(_constants_spec)
|
|
44
|
+
_constants_spec.loader.exec_module(_constants_module)
|
|
45
|
+
PUSH_REQUIRED_EFFORT = _constants_module.PUSH_REQUIRED_EFFORT
|
|
46
|
+
PR_CREATE_REQUIRED_EFFORT = _constants_module.PR_CREATE_REQUIRED_EFFORT
|
|
47
|
+
ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER = _constants_module.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
|
|
48
|
+
|
|
49
|
+
PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
50
|
+
CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
|
|
51
|
+
FURTHER_CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left * right\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_git(repo_dir: pathlib.Path, *git_arguments: str) -> None:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["git", "-C", str(repo_dir), *git_arguments],
|
|
57
|
+
check=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _make_repo_with_origin(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
64
|
+
origin_dir = tmp_path / "origin.git"
|
|
65
|
+
work_dir = tmp_path / "work"
|
|
66
|
+
work_dir.mkdir()
|
|
67
|
+
subprocess.run(
|
|
68
|
+
["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
|
|
69
|
+
check=True,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
)
|
|
73
|
+
empty_hooks_dir = tmp_path / "nohooks"
|
|
74
|
+
empty_hooks_dir.mkdir()
|
|
75
|
+
_run_git(work_dir, "init", "--initial-branch=main")
|
|
76
|
+
_run_git(work_dir, "config", "user.email", "tests@example.com")
|
|
77
|
+
_run_git(work_dir, "config", "user.name", "Stamp Store Tests")
|
|
78
|
+
_run_git(work_dir, "config", "core.hooksPath", str(empty_hooks_dir))
|
|
79
|
+
(work_dir / "src").mkdir()
|
|
80
|
+
(work_dir / "src" / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
|
|
81
|
+
_run_git(work_dir, "add", "-A")
|
|
82
|
+
_run_git(work_dir, "commit", "-m", "base")
|
|
83
|
+
_run_git(work_dir, "remote", "add", "origin", str(origin_dir))
|
|
84
|
+
_run_git(work_dir, "push", "-u", "origin", "main")
|
|
85
|
+
return work_dir
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
|
|
89
|
+
home_text = str(fake_home)
|
|
90
|
+
monkeypatch.setenv("HOME", home_text)
|
|
91
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
92
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
93
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _repo_with_change(tmp_path: pathlib.Path, changed_source: str) -> pathlib.Path:
|
|
97
|
+
work_dir = _make_repo_with_origin(tmp_path)
|
|
98
|
+
(work_dir / "src" / "app.py").write_text(changed_source, encoding="utf-8")
|
|
99
|
+
return work_dir
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_recorded_low_stamp_covers_the_push_surface(
|
|
103
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
104
|
+
) -> None:
|
|
105
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
106
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
107
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
108
|
+
assert surface_hash is not None
|
|
109
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
110
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_low_stamp_does_not_cover_the_pr_create_surface(
|
|
114
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
115
|
+
) -> None:
|
|
116
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
117
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
118
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
119
|
+
assert surface_hash is not None
|
|
120
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
121
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_xhigh_stamp_covers_both_push_and_pr_create(
|
|
125
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
126
|
+
) -> None:
|
|
127
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
128
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
129
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
130
|
+
assert surface_hash is not None
|
|
131
|
+
record_clean_stamp(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT)
|
|
132
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
|
|
133
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_stamp_bound_to_one_hash_does_not_cover_a_different_hash(
|
|
137
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
138
|
+
) -> None:
|
|
139
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
140
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
141
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
142
|
+
assert surface_hash is not None
|
|
143
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
144
|
+
other_hash = "f" * len(surface_hash)
|
|
145
|
+
assert stamp_covers_surface(str(work_dir), other_hash, PUSH_REQUIRED_EFFORT) is False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_editing_a_file_after_minting_drops_coverage(
|
|
149
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
150
|
+
) -> None:
|
|
151
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
152
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
153
|
+
minted_hash = live_surface_hash(str(work_dir))
|
|
154
|
+
assert minted_hash is not None
|
|
155
|
+
record_clean_stamp(str(work_dir), minted_hash, PUSH_REQUIRED_EFFORT)
|
|
156
|
+
(work_dir / "src" / "app.py").write_text(FURTHER_CHANGED_SOURCE, encoding="utf-8")
|
|
157
|
+
live_hash_after_edit = live_surface_hash(str(work_dir))
|
|
158
|
+
assert live_hash_after_edit is not None
|
|
159
|
+
assert live_hash_after_edit != minted_hash
|
|
160
|
+
assert stamp_covers_surface(str(work_dir), live_hash_after_edit, PUSH_REQUIRED_EFFORT) is False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_no_stamp_file_reads_as_not_covered(
|
|
164
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
165
|
+
) -> None:
|
|
166
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
167
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
168
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
169
|
+
assert surface_hash is not None
|
|
170
|
+
assert not stamp_path_for_repo(str(work_dir)).exists()
|
|
171
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_recording_a_second_effort_keeps_the_first(
|
|
175
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
176
|
+
) -> None:
|
|
177
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
178
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
179
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
180
|
+
assert surface_hash is not None
|
|
181
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
182
|
+
record_clean_stamp(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT)
|
|
183
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
|
|
184
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_live_surface_hash_is_none_when_nothing_changed(
|
|
188
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
189
|
+
) -> None:
|
|
190
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
191
|
+
work_dir = _make_repo_with_origin(tmp_path)
|
|
192
|
+
assert live_surface_hash(str(work_dir)) is None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_push_required_effort_is_the_lowest_known_token() -> None:
|
|
196
|
+
assert PUSH_REQUIRED_EFFORT == ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER[0]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_stamp_path_for_repo_stem_matches_the_shared_root_key(
|
|
200
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
201
|
+
) -> None:
|
|
202
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
203
|
+
work_dir = tmp_path / "work-tree"
|
|
204
|
+
work_dir.mkdir()
|
|
205
|
+
assert stamp_path_for_repo(str(work_dir)).stem == root_key_for_repo(str(work_dir))
|