claude-dev-env 1.90.0 → 1.92.1
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 +1 -52
- package/agents/clean-coder.md +2 -2
- package/agents/code-verifier.md +0 -1
- package/agents/test_agent_frontmatter.py +78 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/bin/install.mjs +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +5 -2
- package/hooks/blocking/code_rules_comments.py +2 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
- package/hooks/blocking/config/verified_commit_constants.py +2 -0
- package/hooks/blocking/conftest.py +115 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
- package/hooks/blocking/pr_description_enforcer.py +46 -22
- package/hooks/blocking/pr_description_pr_number.py +5 -3
- package/hooks/blocking/pr_description_proof_of_work.py +367 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
- package/hooks/blocking/test_verification_verdict_store.py +11 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
- package/hooks/blocking/test_verified_commit_gate.py +11 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
- package/hooks/blocking/verification_verdict_store.py +73 -5
- package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
- package/hooks/blocking/verified_commit_gate.py +6 -0
- package/hooks/blocking/verifier_verdict_minter.py +6 -0
- package/hooks/hooks.json +7 -2
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
- package/hooks/validators/run_all_validators.py +216 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +7 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/rules/testing.md +0 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/claude-chain.example.json +8 -0
- package/scripts/claude_chain_runner.py +400 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/orchestrator/SKILL.md +188 -0
- package/skills/orchestrator-refresh/SKILL.md +25 -0
- package/skills/usage-pause/SKILL.md +108 -0
- package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
- package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
- package/system-prompts/software-engineer.xml +3 -2
|
@@ -122,7 +122,10 @@ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
|
|
|
122
122
|
"""Run the shared code_rules_gate engine in staged mode.
|
|
123
123
|
|
|
124
124
|
Args:
|
|
125
|
-
repository_root: Repository root passed to the gate's --repo-root
|
|
125
|
+
repository_root: Repository root passed to the gate's --repo-root
|
|
126
|
+
and used as the gate subprocess working directory, so relative
|
|
127
|
+
paths the gate's checks resolve land in the committing worktree
|
|
128
|
+
rather than the hook's own working directory.
|
|
126
129
|
|
|
127
130
|
Returns:
|
|
128
131
|
Tuple of the gate exit code and its stderr report. A missing gate
|
|
@@ -147,6 +150,7 @@ def run_staged_gate(repository_root: Path) -> tuple[int, str]:
|
|
|
147
150
|
encoding="utf-8",
|
|
148
151
|
errors="replace",
|
|
149
152
|
timeout=GATE_TIMEOUT_SECONDS,
|
|
153
|
+
cwd=str(repository_root),
|
|
150
154
|
)
|
|
151
155
|
except subprocess.TimeoutExpired:
|
|
152
156
|
return 1, (
|
|
@@ -17,6 +17,8 @@ import sys
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from types import SimpleNamespace
|
|
19
19
|
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
20
22
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
21
23
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
22
24
|
if _BLOCKING_DIRECTORY not in sys.path:
|
|
@@ -119,11 +121,15 @@ def test_check_comment_changes_skips_removal_when_old_python_un_parseable() -> N
|
|
|
119
121
|
assert issues == []
|
|
120
122
|
|
|
121
123
|
|
|
122
|
-
def
|
|
124
|
+
def test_check_comment_changes_advises_without_blocking_on_removal_on_parseable_python(
|
|
125
|
+
capsys: pytest.CaptureFixture[str],
|
|
126
|
+
) -> None:
|
|
123
127
|
old_content = 'x = 1 # existing comment\n'
|
|
124
128
|
new_content = 'x = 1\n'
|
|
125
129
|
issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
|
|
126
|
-
assert any("Existing comment removed" in each_issue for each_issue in issues)
|
|
130
|
+
assert not any("Existing comment removed" in each_issue for each_issue in issues)
|
|
131
|
+
captured = capsys.readouterr()
|
|
132
|
+
assert "[CODE_RULES advisory] Existing comment removed" in captured.err
|
|
127
133
|
|
|
128
134
|
|
|
129
135
|
def test_python_check_should_exempt_directive_without_space_after_hash() -> None:
|
|
@@ -287,6 +287,37 @@ def make_conflict_repository(tmp_path: Path) -> Path:
|
|
|
287
287
|
return repository_root
|
|
288
288
|
|
|
289
289
|
|
|
290
|
+
def make_stale_self_upstream_conflict_repository(tmp_path: Path) -> Path:
|
|
291
|
+
repository_root = make_conflict_repository(tmp_path)
|
|
292
|
+
feature_sha = run_git(repository_root, "rev-parse", "HEAD")
|
|
293
|
+
run_git(repository_root, "remote", "add", "origin", str(repository_root))
|
|
294
|
+
run_git(repository_root, "update-ref", "refs/remotes/origin/feature", feature_sha)
|
|
295
|
+
run_git(repository_root, "config", "branch.feature.remote", "origin")
|
|
296
|
+
run_git(repository_root, "config", "branch.feature.merge", "refs/heads/feature")
|
|
297
|
+
return repository_root
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def make_merge_in_progress_repository(tmp_path: Path) -> Path:
|
|
301
|
+
repository_root = tmp_path / "repo"
|
|
302
|
+
repository_root.mkdir()
|
|
303
|
+
initialize_repository(repository_root)
|
|
304
|
+
base_sha = commit_file(repository_root, "shared.py", SHARED_BASE_SOURCE, "add shared")
|
|
305
|
+
run_git(repository_root, "update-ref", "refs/remotes/origin/main", base_sha)
|
|
306
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
307
|
+
commit_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE, "feature edit")
|
|
308
|
+
advance_origin_main_divergent(repository_root, base_sha, "shared.py", SHARED_DIVERGENT_SOURCE)
|
|
309
|
+
return repository_root
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def start_conflicting_merge(repository_root: Path) -> None:
|
|
313
|
+
subprocess.run(
|
|
314
|
+
["git", "-C", str(repository_root), "merge", "--no-edit", "origin/main"],
|
|
315
|
+
check=False,
|
|
316
|
+
capture_output=True,
|
|
317
|
+
text=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
290
321
|
def test_non_code_verifier_agent_is_no_op(tmp_path: Path) -> None:
|
|
291
322
|
repository_root = make_conflict_repository(tmp_path)
|
|
292
323
|
write_working_tree_file(repository_root, "violator.py", VIOLATING_MODULE_SOURCE)
|
|
@@ -473,6 +504,46 @@ def test_conflict_and_violation_single_deny_names_both(tmp_path: Path) -> None:
|
|
|
473
504
|
assert "violator.py" in reason
|
|
474
505
|
|
|
475
506
|
|
|
507
|
+
def test_stale_self_upstream_still_judges_default_branch(tmp_path: Path) -> None:
|
|
508
|
+
repository_root = make_stale_self_upstream_conflict_repository(tmp_path)
|
|
509
|
+
tracked_upstream = run_git(
|
|
510
|
+
repository_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
|
|
511
|
+
)
|
|
512
|
+
assert tracked_upstream == "origin/feature", "fixture must track the branch's own remote ref"
|
|
513
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
514
|
+
result = run_hook(payload, repository_root)
|
|
515
|
+
assert not is_allow(result)
|
|
516
|
+
reason = deny_reason(result)
|
|
517
|
+
assert "shared.py" in reason
|
|
518
|
+
assert "Merge conflicts vs" in reason
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_staged_resolved_merge_allows(tmp_path: Path) -> None:
|
|
522
|
+
repository_root = make_merge_in_progress_repository(tmp_path)
|
|
523
|
+
start_conflicting_merge(repository_root)
|
|
524
|
+
write_working_tree_file(repository_root, "shared.py", SHARED_FEATURE_SOURCE)
|
|
525
|
+
run_git(repository_root, "add", "shared.py")
|
|
526
|
+
remaining_unmerged = run_git(repository_root, "diff", "--name-only", "--diff-filter=U")
|
|
527
|
+
assert remaining_unmerged == "", "the staged resolution must clear every unmerged index entry"
|
|
528
|
+
merge_head_sha = run_git(repository_root, "rev-parse", "--verify", "--quiet", "MERGE_HEAD")
|
|
529
|
+
assert merge_head_sha, "a merge must still be in progress so MERGE_HEAD is present"
|
|
530
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
531
|
+
result = run_hook(payload, repository_root)
|
|
532
|
+
assert is_allow(result)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_unresolved_merge_keeps_denying(tmp_path: Path) -> None:
|
|
536
|
+
repository_root = make_merge_in_progress_repository(tmp_path)
|
|
537
|
+
start_conflicting_merge(repository_root)
|
|
538
|
+
remaining_unmerged = run_git(repository_root, "diff", "--name-only", "--diff-filter=U")
|
|
539
|
+
assert "shared.py" in remaining_unmerged, "the merge must leave shared.py unmerged"
|
|
540
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
541
|
+
result = run_hook(payload, repository_root)
|
|
542
|
+
assert not is_allow(result)
|
|
543
|
+
reason = deny_reason(result)
|
|
544
|
+
assert "shared.py" in reason
|
|
545
|
+
|
|
546
|
+
|
|
476
547
|
def test_hook_imports_real_config_when_parent_holds_shadowing_config(
|
|
477
548
|
tmp_path: Path,
|
|
478
549
|
) -> None:
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Unit tests for nas-ssh-binary-enforcer PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
8
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
10
|
+
|
|
11
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
12
|
+
"nas_ssh_binary_enforcer",
|
|
13
|
+
_HOOK_DIR / "nas_ssh_binary_enforcer.py",
|
|
14
|
+
)
|
|
15
|
+
assert hook_spec is not None
|
|
16
|
+
assert hook_spec.loader is not None
|
|
17
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
18
|
+
hook_spec.loader.exec_module(hook_module)
|
|
19
|
+
_find_nas_ssh_violation = hook_module._find_nas_ssh_violation
|
|
20
|
+
|
|
21
|
+
from hooks_constants.nas_ssh_binary_enforcer_constants import (
|
|
22
|
+
BARE_SSH_BINARY_MESSAGE as _BARE_SSH_BINARY_MESSAGE,
|
|
23
|
+
MISSING_BATCH_MODE_MESSAGE as _MISSING_BATCH_MODE_MESSAGE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_bare_ssh_to_nas_is_denied_with_binary_message() -> None:
|
|
28
|
+
assert (
|
|
29
|
+
_find_nas_ssh_violation('ssh -p 9222 jon@192.168.1.100 "ls /volume1"')
|
|
30
|
+
== _BARE_SSH_BINARY_MESSAGE
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_bare_scp_to_nas_is_denied() -> None:
|
|
35
|
+
assert (
|
|
36
|
+
_find_nas_ssh_violation("scp -P 9222 file.txt jon@192.168.1.100:/volume1/")
|
|
37
|
+
== _BARE_SSH_BINARY_MESSAGE
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_bare_sftp_to_nas_is_denied() -> None:
|
|
42
|
+
assert _find_nas_ssh_violation("sftp -P 9222 jon@192.168.1.100") == _BARE_SSH_BINARY_MESSAGE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_bare_ssh_to_nas_behind_launcher_wrapper_is_denied() -> None:
|
|
46
|
+
assert (
|
|
47
|
+
_find_nas_ssh_violation('timeout 10 ssh -p 9222 jon@192.168.1.100 "uptime"')
|
|
48
|
+
== _BARE_SSH_BINARY_MESSAGE
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_bare_ssh_to_nas_after_shell_operator_is_denied() -> None:
|
|
53
|
+
assert (
|
|
54
|
+
_find_nas_ssh_violation('cd /tmp && ssh -p 9222 jon@192.168.1.100 "uptime"')
|
|
55
|
+
== _BARE_SSH_BINARY_MESSAGE
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_bare_ssh_to_nas_after_glued_operator_is_denied() -> None:
|
|
60
|
+
assert (
|
|
61
|
+
_find_nas_ssh_violation('true;ssh -p 9222 jon@192.168.1.100 "uptime"')
|
|
62
|
+
== _BARE_SSH_BINARY_MESSAGE
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_bare_ssh_to_nas_after_pipe_ampersand_operator_is_denied() -> None:
|
|
67
|
+
assert (
|
|
68
|
+
_find_nas_ssh_violation('echo start |& ssh -p 9222 jon@192.168.1.100 "echo hi"')
|
|
69
|
+
== _BARE_SSH_BINARY_MESSAGE
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_full_openssh_binary_to_nas_without_batchmode_is_denied() -> None:
|
|
74
|
+
assert (
|
|
75
|
+
_find_nas_ssh_violation(
|
|
76
|
+
'"/c/Windows/System32/OpenSSH/ssh.exe" -o ConnectTimeout=10 '
|
|
77
|
+
'-p 9222 jon@192.168.1.100 "uptime"'
|
|
78
|
+
)
|
|
79
|
+
== _MISSING_BATCH_MODE_MESSAGE
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_full_openssh_scp_to_nas_without_batchmode_is_denied() -> None:
|
|
84
|
+
assert (
|
|
85
|
+
_find_nas_ssh_violation(
|
|
86
|
+
'"/c/Windows/System32/OpenSSH/scp.exe" -P 9222 file.txt jon@192.168.1.100:/volume1/'
|
|
87
|
+
)
|
|
88
|
+
== _MISSING_BATCH_MODE_MESSAGE
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_full_openssh_binary_to_nas_with_batchmode_is_allowed() -> None:
|
|
93
|
+
assert (
|
|
94
|
+
_find_nas_ssh_violation(
|
|
95
|
+
'"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes '
|
|
96
|
+
'-o ConnectTimeout=10 -p 9222 jon@192.168.1.100 "uptime"'
|
|
97
|
+
)
|
|
98
|
+
is None
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_full_openssh_binary_glued_batchmode_flag_is_allowed() -> None:
|
|
103
|
+
assert (
|
|
104
|
+
_find_nas_ssh_violation(
|
|
105
|
+
'"/c/Windows/System32/OpenSSH/ssh.exe" -oBatchMode=yes '
|
|
106
|
+
'-p 9222 jon@192.168.1.100 "uptime"'
|
|
107
|
+
)
|
|
108
|
+
is None
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_bare_ssh_to_other_host_is_allowed() -> None:
|
|
113
|
+
assert _find_nas_ssh_violation('ssh -p 22 jon@example.com "uptime"') is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_full_openssh_binary_to_other_host_without_batchmode_is_allowed() -> None:
|
|
117
|
+
assert (
|
|
118
|
+
_find_nas_ssh_violation(
|
|
119
|
+
'"/c/Windows/System32/OpenSSH/ssh.exe" -p 22 jon@example.com "uptime"'
|
|
120
|
+
)
|
|
121
|
+
is None
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_nas_ip_as_echoed_data_is_allowed() -> None:
|
|
126
|
+
assert _find_nas_ssh_violation('echo "connect via ssh to 192.168.1.100"') is None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_nas_ip_mentioned_without_ssh_command_word_is_allowed() -> None:
|
|
130
|
+
assert _find_nas_ssh_violation("ping -c 1 192.168.1.100") is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_ssh_family_word_only_in_remote_command_argument_is_allowed() -> None:
|
|
134
|
+
assert (
|
|
135
|
+
_find_nas_ssh_violation(
|
|
136
|
+
'"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes '
|
|
137
|
+
'-p 9222 jon@192.168.1.100 "grep ssh /etc/config"'
|
|
138
|
+
)
|
|
139
|
+
is None
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_nas_ip_in_different_segment_than_ssh_is_allowed() -> None:
|
|
144
|
+
assert _find_nas_ssh_violation('echo 192.168.1.100 && ssh -p 22 jon@example.com "id"') is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_ssh_to_other_host_with_nas_ip_in_remote_command_is_allowed() -> None:
|
|
148
|
+
assert (
|
|
149
|
+
_find_nas_ssh_violation('ssh -p 22 jon@example.com "ping -c1 192.168.1.100"') is None
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_scp_to_other_host_with_nas_ip_in_remote_path_is_allowed() -> None:
|
|
154
|
+
assert (
|
|
155
|
+
_find_nas_ssh_violation("scp -P 22 f.txt jon@example.com:/backup/192.168.1.100/") is None
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_similar_ip_prefix_is_not_matched() -> None:
|
|
160
|
+
assert _find_nas_ssh_violation('ssh -p 22 jon@192.168.1.1000 "id"') is None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_empty_command_is_allowed() -> None:
|
|
164
|
+
assert _find_nas_ssh_violation("") is None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_unbalanced_quotes_command_is_allowed() -> None:
|
|
168
|
+
assert _find_nas_ssh_violation("ssh 'unterminated 192.168.1.100") is None
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Entry-flow tests for the proof-of-work gates in pr_description_enforcer.
|
|
2
|
+
|
|
3
|
+
Each test drives hook main() with a real Bash PreToolUse payload on stdin,
|
|
4
|
+
mirroring test_pr_description_enforcer.py. The single gh subprocess boundary
|
|
5
|
+
in pr_description_proof_of_work is patched with a fake runner, so command
|
|
6
|
+
recognition, body extraction, and the proof audit run the production paths.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import pathlib
|
|
14
|
+
import sys
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
20
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
21
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
23
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
25
|
+
|
|
26
|
+
from blocking import pr_description_proof_of_work as proof_module # noqa: E402
|
|
27
|
+
from blocking import pr_description_readability as readability_module # noqa: E402
|
|
28
|
+
|
|
29
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
30
|
+
"pr_description_enforcer_proof_gate",
|
|
31
|
+
_HOOK_DIR / "pr_description_enforcer.py",
|
|
32
|
+
)
|
|
33
|
+
assert hook_spec is not None
|
|
34
|
+
assert hook_spec.loader is not None
|
|
35
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
36
|
+
hook_spec.loader.exec_module(hook_module)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture(autouse=True)
|
|
40
|
+
def _isolate_readability_state(
|
|
41
|
+
tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Redirect the three readability state files to per-test temp paths.
|
|
44
|
+
|
|
45
|
+
The enabled file is written with enabled=False so readability scoring
|
|
46
|
+
stays out of these entry-flow tests and the live state directory is
|
|
47
|
+
never touched.
|
|
48
|
+
"""
|
|
49
|
+
per_test_state_dir = tmp_path_factory.mktemp("proof_gate_readability_state")
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
readability_module, "READABILITY_STATE_FILE", per_test_state_dir / "strikes.json"
|
|
52
|
+
)
|
|
53
|
+
monkeypatch.setattr(
|
|
54
|
+
readability_module,
|
|
55
|
+
"READABILITY_THRESHOLD_OVERRIDE_FILE",
|
|
56
|
+
per_test_state_dir / "overrides.json",
|
|
57
|
+
)
|
|
58
|
+
enabled_path = per_test_state_dir / "enabled.json"
|
|
59
|
+
enabled_path.write_text(json.dumps({"enabled": False}))
|
|
60
|
+
monkeypatch.setattr(readability_module, "READABILITY_ENABLED_STATE_FILE", enabled_path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
PASSING_PROOF_COMMENT_BODY = (
|
|
64
|
+
"## Summary\n\n"
|
|
65
|
+
"Recolored the launcher icons and checked every produced asset by hand "
|
|
66
|
+
"against the palette the plan names.\n\n"
|
|
67
|
+
"## Verification\n\n"
|
|
68
|
+
"```\n"
|
|
69
|
+
"C:\\Python313\\python.exe scripts/recolor.py --input themes/dawn\n"
|
|
70
|
+
"```\n\n"
|
|
71
|
+
"| Artifact | Measured |\n"
|
|
72
|
+
"| --- | --- |\n"
|
|
73
|
+
"| icons.zip | 48 files, 412,993 bytes |\n\n"
|
|
74
|
+
"This advances phase 2 of issue #12.\n\n"
|
|
75
|
+
"Known gap: the offline proof cannot show the on-device rendering; the "
|
|
76
|
+
"store preview covers that.\n"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
ORDINARY_COMMENT_BODY = (
|
|
80
|
+
"The retry logic here mirrors what the uploader does for chunked "
|
|
81
|
+
"transfers, so the two stay in step."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_hook_main_with_command(command: str) -> str:
|
|
86
|
+
hook_input_json = json.dumps({"tool_name": "Bash", "tool_input": {"command": command}})
|
|
87
|
+
captured_stdout = io.StringIO()
|
|
88
|
+
with (
|
|
89
|
+
patch("sys.stdin", io.StringIO(hook_input_json)),
|
|
90
|
+
patch("sys.stdout", captured_stdout),
|
|
91
|
+
contextlib.suppress(SystemExit),
|
|
92
|
+
):
|
|
93
|
+
hook_module.main()
|
|
94
|
+
return captured_stdout.getvalue()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _install_fake_gh_responses(monkeypatch, all_comment_bodies, all_diff_names):
|
|
98
|
+
all_comment_records = [{"body": each_body} for each_body in all_comment_bodies]
|
|
99
|
+
|
|
100
|
+
def _fake_run_gh_command(all_gh_arguments):
|
|
101
|
+
if all_gh_arguments and all_gh_arguments[0] == "api":
|
|
102
|
+
return json.dumps([all_comment_records])
|
|
103
|
+
if "--name-only" in all_gh_arguments:
|
|
104
|
+
return "\n".join(all_diff_names)
|
|
105
|
+
if list(all_gh_arguments[:2]) == ["pr", "view"]:
|
|
106
|
+
return json.dumps({"number": 123})
|
|
107
|
+
if list(all_gh_arguments[:2]) == ["pr", "diff"]:
|
|
108
|
+
return ""
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_main_blocks_gh_pr_ready_without_proof_comment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
115
|
+
_install_fake_gh_responses(monkeypatch, [], ["src/parser.py"])
|
|
116
|
+
decision_output = _run_hook_main_with_command("gh pr ready 123")
|
|
117
|
+
assert "deny" in decision_output
|
|
118
|
+
assert "proof" in decision_output.lower()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_main_allows_gh_pr_ready_with_passing_proof_comment(
|
|
122
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
123
|
+
) -> None:
|
|
124
|
+
_install_fake_gh_responses(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"])
|
|
125
|
+
decision_output = _run_hook_main_with_command("gh pr ready 123")
|
|
126
|
+
assert "deny" not in decision_output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_main_allows_complete_proof_comment_post(
|
|
130
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
131
|
+
) -> None:
|
|
132
|
+
_install_fake_gh_responses(monkeypatch, [], ["src/parser.py"])
|
|
133
|
+
body_file = tmp_path / "proof.md"
|
|
134
|
+
body_file.write_text(PASSING_PROOF_COMMENT_BODY)
|
|
135
|
+
decision_output = _run_hook_main_with_command(f"gh pr comment 123 --body-file {body_file}")
|
|
136
|
+
assert "deny" not in decision_output
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_main_blocks_proof_comment_missing_visual_on_visual_change(
|
|
140
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
141
|
+
) -> None:
|
|
142
|
+
_install_fake_gh_responses(monkeypatch, [], ["assets/icon.png"])
|
|
143
|
+
body_file = tmp_path / "proof.md"
|
|
144
|
+
body_file.write_text(PASSING_PROOF_COMMENT_BODY)
|
|
145
|
+
decision_output = _run_hook_main_with_command(f"gh pr comment 123 --body-file {body_file}")
|
|
146
|
+
assert "deny" in decision_output
|
|
147
|
+
assert "visual" in decision_output
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_main_leaves_ordinary_comment_untouched(
|
|
151
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
152
|
+
) -> None:
|
|
153
|
+
all_recorded_calls: list[list[str]] = []
|
|
154
|
+
|
|
155
|
+
def _recording_run_gh_command(all_gh_arguments):
|
|
156
|
+
all_recorded_calls.append(list(all_gh_arguments))
|
|
157
|
+
|
|
158
|
+
monkeypatch.setattr(proof_module, "_run_gh_command", _recording_run_gh_command)
|
|
159
|
+
body_file = tmp_path / "comment.md"
|
|
160
|
+
body_file.write_text(ORDINARY_COMMENT_BODY)
|
|
161
|
+
decision_output = _run_hook_main_with_command(f"gh pr comment 123 --body-file {body_file}")
|
|
162
|
+
assert "deny" not in decision_output
|
|
163
|
+
assert all_recorded_calls == []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_main_allows_gh_pr_ready_undo_without_queries(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
167
|
+
all_recorded_calls: list[list[str]] = []
|
|
168
|
+
|
|
169
|
+
def _recording_run_gh_command(all_gh_arguments):
|
|
170
|
+
all_recorded_calls.append(list(all_gh_arguments))
|
|
171
|
+
|
|
172
|
+
monkeypatch.setattr(proof_module, "_run_gh_command", _recording_run_gh_command)
|
|
173
|
+
decision_output = _run_hook_main_with_command("gh pr ready 123 --undo")
|
|
174
|
+
assert "deny" not in decision_output
|
|
175
|
+
assert all_recorded_calls == []
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Behavior tests for the proof-of-work comment audit and gh pr ready gate.
|
|
2
|
+
|
|
3
|
+
Each test drives the public audit surface of pr_description_proof_of_work
|
|
4
|
+
with real comment bodies. The single gh subprocess boundary is patched with
|
|
5
|
+
a fake runner, so parsing, part detection, and gate decisions all run the
|
|
6
|
+
production code paths.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import pathlib
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
16
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
17
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
19
|
+
|
|
20
|
+
from blocking import pr_description_proof_of_work as proof_module # noqa: E402
|
|
21
|
+
from blocking.pr_description_proof_of_work import ( # noqa: E402
|
|
22
|
+
audit_proof_comment_body,
|
|
23
|
+
evaluate_pr_ready_gate,
|
|
24
|
+
is_pr_ready_command,
|
|
25
|
+
is_proof_shaped_body,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
PASSING_PROOF_COMMENT_BODY = (
|
|
29
|
+
"## Summary\n\n"
|
|
30
|
+
"Recolored the launcher icons and checked every produced asset by hand "
|
|
31
|
+
"against the palette the plan names.\n\n"
|
|
32
|
+
"## Verification\n\n"
|
|
33
|
+
"```\n"
|
|
34
|
+
"C:\\Python313\\python.exe scripts/recolor.py --input themes/dawn\n"
|
|
35
|
+
"```\n\n"
|
|
36
|
+
"| Artifact | Measured |\n"
|
|
37
|
+
"| --- | --- |\n"
|
|
38
|
+
"| icons.zip | 48 files, 412,993 bytes |\n\n"
|
|
39
|
+
"This advances phase 2 of issue #12.\n\n"
|
|
40
|
+
"Known gap: the offline proof cannot show the on-device rendering; the "
|
|
41
|
+
"store preview covers that.\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
VISUAL_PROOF_COMMENT_BODY = (
|
|
45
|
+
PASSING_PROOF_COMMENT_BODY + "\n\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
BARE_PROOF_COMMENT_BODY = "## Verification\n\nLooks good overall to me.\n"
|
|
49
|
+
|
|
50
|
+
ORDINARY_COMMENT_BODY = (
|
|
51
|
+
"The retry logic here mirrors what the uploader does for chunked "
|
|
52
|
+
"transfers, so the two stay in step."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _install_fake_gh(monkeypatch, all_comment_bodies, all_diff_names, diff_text):
|
|
57
|
+
all_comment_records = [{"body": each_body} for each_body in all_comment_bodies]
|
|
58
|
+
|
|
59
|
+
def _fake_run_gh_command(all_gh_arguments):
|
|
60
|
+
if all_gh_arguments and all_gh_arguments[0] == "api":
|
|
61
|
+
return json.dumps([all_comment_records])
|
|
62
|
+
if list(all_gh_arguments[:2]) == ["pr", "view"]:
|
|
63
|
+
return json.dumps({"number": 77})
|
|
64
|
+
if "--name-only" in all_gh_arguments:
|
|
65
|
+
return "\n".join(all_diff_names)
|
|
66
|
+
if list(all_gh_arguments[:2]) == ["pr", "diff"]:
|
|
67
|
+
return diff_text
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_is_proof_shaped_body_detects_verification_heading() -> None:
|
|
74
|
+
assert is_proof_shaped_body(PASSING_PROOF_COMMENT_BODY)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_is_proof_shaped_body_skips_ordinary_comment() -> None:
|
|
78
|
+
assert not is_proof_shaped_body(ORDINARY_COMMENT_BODY)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_is_pr_ready_command_matches_ready() -> None:
|
|
82
|
+
assert is_pr_ready_command("gh pr ready 123")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_is_pr_ready_command_skips_undo() -> None:
|
|
86
|
+
assert not is_pr_ready_command("gh pr ready 123 --undo")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_is_pr_ready_command_matches_after_shell_operator() -> None:
|
|
90
|
+
assert is_pr_ready_command("git fetch && gh pr ready 5")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_is_pr_ready_command_ignores_phrase_inside_commit_message() -> None:
|
|
94
|
+
assert not is_pr_ready_command(
|
|
95
|
+
'git commit -m "block gh pr ready without a proof comment"'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_is_pr_ready_command_ignores_phrase_inside_search_argument() -> None:
|
|
100
|
+
assert not is_pr_ready_command('grep -r "gh pr ready" packages/')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_audit_passes_complete_proof_on_non_visual_change(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
104
|
+
_install_fake_gh(monkeypatch, [], ["src/parser.py"], "+ plain code line")
|
|
105
|
+
assert audit_proof_comment_body(PASSING_PROOF_COMMENT_BODY, 123) == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_audit_flags_missing_visual_on_image_diff(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
109
|
+
_install_fake_gh(monkeypatch, [], ["assets/icon.png"], "")
|
|
110
|
+
all_missing_parts = audit_proof_comment_body(PASSING_PROOF_COMMENT_BODY, 123)
|
|
111
|
+
assert any("visual" in each_part for each_part in all_missing_parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_audit_accepts_image_embed_on_image_diff(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
115
|
+
_install_fake_gh(monkeypatch, [], ["assets/icon.png"], "")
|
|
116
|
+
assert audit_proof_comment_body(VISUAL_PROOF_COMMENT_BODY, 123) == []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_audit_detects_visual_change_from_hex_colors(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
120
|
+
_install_fake_gh(monkeypatch, [], ["src/theme.py"], '+ accent = "#AABBCC"')
|
|
121
|
+
all_missing_parts = audit_proof_comment_body(PASSING_PROOF_COMMENT_BODY, 123)
|
|
122
|
+
assert any("visual" in each_part for each_part in all_missing_parts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_audit_names_every_missing_part_of_bare_proof(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
126
|
+
_install_fake_gh(monkeypatch, [], ["src/parser.py"], "")
|
|
127
|
+
all_missing_parts = audit_proof_comment_body(BARE_PROOF_COMMENT_BODY, 123)
|
|
128
|
+
assert len(all_missing_parts) == 4
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_ready_gate_blocks_when_no_proof_comment_exists(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
132
|
+
_install_fake_gh(monkeypatch, [ORDINARY_COMMENT_BODY], ["src/parser.py"], "")
|
|
133
|
+
denial_reason = evaluate_pr_ready_gate("gh pr ready 123")
|
|
134
|
+
assert denial_reason is not None
|
|
135
|
+
assert "proof" in denial_reason.lower()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_ready_gate_allows_with_passing_proof_comment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
139
|
+
_install_fake_gh(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], "")
|
|
140
|
+
assert evaluate_pr_ready_gate("gh pr ready 123") is None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_ready_gate_blocks_when_proof_comment_is_incomplete(
|
|
144
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
145
|
+
) -> None:
|
|
146
|
+
_install_fake_gh(monkeypatch, [BARE_PROOF_COMMENT_BODY], ["src/parser.py"], "")
|
|
147
|
+
denial_reason = evaluate_pr_ready_gate("gh pr ready 123")
|
|
148
|
+
assert denial_reason is not None
|
|
149
|
+
assert "123" in denial_reason
|
|
150
|
+
assert "proof" in denial_reason.lower()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_ready_gate_fails_open_when_gh_is_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
154
|
+
monkeypatch.setattr(proof_module, "_run_gh_command", lambda all_gh_arguments: None)
|
|
155
|
+
assert evaluate_pr_ready_gate("gh pr ready 123") is None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_ready_gate_resolves_pr_number_from_current_branch(
|
|
159
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
160
|
+
) -> None:
|
|
161
|
+
_install_fake_gh(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], "")
|
|
162
|
+
assert evaluate_pr_ready_gate("gh pr ready") is None
|