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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: force the Windows OpenSSH binary for NAS ssh/scp/sftp commands.
|
|
3
|
+
|
|
4
|
+
Root cause: Git Bash's MSYS ssh reads ``~/.ssh/id_ed25519`` as world-readable
|
|
5
|
+
through its ACL mapping, rejects the key as "bad permissions", offers no key, and
|
|
6
|
+
falls back to an interactive password prompt that hangs unattended agent sessions.
|
|
7
|
+
The Windows OpenSSH binary under ``System32/OpenSSH`` authenticates the same key
|
|
8
|
+
without prompting.
|
|
9
|
+
|
|
10
|
+
Detection strategy: this hook fires only on the Bash tool. The command is
|
|
11
|
+
shlex-tokenized (quotes removed), glued shell control operators are exploded off
|
|
12
|
+
the tokens, and the tokens are split into simple-command segments on ``&&`` /
|
|
13
|
+
``||`` / ``;`` / ``|&`` / ``|`` / ``&``. For each segment the effective leading program is
|
|
14
|
+
found by skipping ``VAR=value`` assignments and launcher wrappers (``timeout``,
|
|
15
|
+
``nohup``, ``nice``, ``stdbuf``, ``setsid``, ``env``) with their flags and duration
|
|
16
|
+
arguments. A segment is evaluated only when its leading program's basename is
|
|
17
|
+
``ssh``/``scp``/``sftp`` (with or without a ``.exe`` suffix) AND a token in that same
|
|
18
|
+
segment holds the NAS address ``192.168.1.100`` as a connection host — bare,
|
|
19
|
+
``user@host``, or a ``host:path`` target. The address inside a remote-command
|
|
20
|
+
argument or a remote path component on another host does not count.
|
|
21
|
+
|
|
22
|
+
An evaluated segment is DENIED with the binary-swap message when its leading program
|
|
23
|
+
is a bare ssh-family word rather than a path ending in ``OpenSSH/ssh.exe`` (or
|
|
24
|
+
``scp.exe`` / ``sftp.exe``). An evaluated segment whose leading program is that full
|
|
25
|
+
OpenSSH path is DENIED with the batch-mode message when no token in the segment
|
|
26
|
+
carries ``BatchMode=yes``, so an authentication regression fails loudly rather than
|
|
27
|
+
prompting. Everything else is allowed: ssh to any other host, the full OpenSSH path
|
|
28
|
+
with ``BatchMode=yes``, non-Bash tools, a command that only mentions the address
|
|
29
|
+
without an ssh-family leading program, and a command shlex cannot tokenize.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import shlex
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
38
|
+
if _hooks_dir not in sys.path:
|
|
39
|
+
sys.path.insert(0, _hooks_dir)
|
|
40
|
+
|
|
41
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
42
|
+
from hooks_constants.nas_ssh_binary_enforcer_constants import ( # noqa: E402
|
|
43
|
+
ALL_LAUNCHER_WRAPPER_COMMANDS,
|
|
44
|
+
ALL_OPENSSH_BINARY_PATH_SUFFIXES,
|
|
45
|
+
ALL_SHELL_CONTROL_OPERATOR_TOKENS,
|
|
46
|
+
ALL_SSH_FAMILY_COMMAND_BASENAMES,
|
|
47
|
+
BARE_SSH_BINARY_MESSAGE,
|
|
48
|
+
BASH_TOOL_NAME,
|
|
49
|
+
BATCH_MODE_PATTERN,
|
|
50
|
+
CONTROL_OPERATOR_SPLIT_PATTERN,
|
|
51
|
+
LAUNCHER_DURATION_PATTERN,
|
|
52
|
+
LEADING_ASSIGNMENT_PATTERN,
|
|
53
|
+
MISSING_BATCH_MODE_MESSAGE,
|
|
54
|
+
NAS_HOST_TOKEN_PATTERN,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _token_basename(token: str) -> str:
|
|
59
|
+
return token.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_openssh_binary_path(token: str) -> bool:
|
|
63
|
+
normalized_token = token.replace("\\", "/").lower()
|
|
64
|
+
return any(
|
|
65
|
+
normalized_token.endswith(each_suffix)
|
|
66
|
+
for each_suffix in ALL_OPENSSH_BINARY_PATH_SUFFIXES
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _split_into_segments(all_command_tokens: list[str]) -> list[list[str]]:
|
|
71
|
+
all_exploded_tokens: list[str] = []
|
|
72
|
+
for each_token in all_command_tokens:
|
|
73
|
+
for each_fragment in CONTROL_OPERATOR_SPLIT_PATTERN.split(each_token):
|
|
74
|
+
if each_fragment:
|
|
75
|
+
all_exploded_tokens.append(each_fragment)
|
|
76
|
+
all_segments: list[list[str]] = []
|
|
77
|
+
current_segment: list[str] = []
|
|
78
|
+
for each_token in all_exploded_tokens:
|
|
79
|
+
if each_token in ALL_SHELL_CONTROL_OPERATOR_TOKENS:
|
|
80
|
+
all_segments.append(current_segment)
|
|
81
|
+
current_segment = []
|
|
82
|
+
continue
|
|
83
|
+
current_segment.append(each_token)
|
|
84
|
+
all_segments.append(current_segment)
|
|
85
|
+
return all_segments
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _effective_leading_program(all_segment_tokens: list[str]) -> str | None:
|
|
89
|
+
has_seen_launcher_wrapper = False
|
|
90
|
+
for each_token in all_segment_tokens:
|
|
91
|
+
if LEADING_ASSIGNMENT_PATTERN.match(each_token):
|
|
92
|
+
continue
|
|
93
|
+
if _token_basename(each_token) in ALL_LAUNCHER_WRAPPER_COMMANDS:
|
|
94
|
+
has_seen_launcher_wrapper = True
|
|
95
|
+
continue
|
|
96
|
+
if has_seen_launcher_wrapper and (
|
|
97
|
+
each_token.startswith("-") or LAUNCHER_DURATION_PATTERN.match(each_token)
|
|
98
|
+
):
|
|
99
|
+
continue
|
|
100
|
+
return each_token
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _segment_references_nas(all_segment_tokens: list[str]) -> bool:
|
|
105
|
+
return any(NAS_HOST_TOKEN_PATTERN.search(each_token) for each_token in all_segment_tokens)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _segment_carries_batch_mode(all_segment_tokens: list[str]) -> bool:
|
|
109
|
+
return any(BATCH_MODE_PATTERN.search(each_token) for each_token in all_segment_tokens)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_nas_ssh_violation(command: str) -> str | None:
|
|
113
|
+
"""Return the deny message for a NAS ssh-family command, or None to allow.
|
|
114
|
+
|
|
115
|
+
::
|
|
116
|
+
|
|
117
|
+
ssh -p 9222 jon@192.168.1.100 "ls" flag: bare-binary message
|
|
118
|
+
"…/OpenSSH/ssh.exe" -p 9222 jon@192.168… flag: missing-batch-mode message
|
|
119
|
+
"…/OpenSSH/ssh.exe" -o BatchMode=yes …NAS ok: None
|
|
120
|
+
ssh jon@example.com "ls" ok: None
|
|
121
|
+
|
|
122
|
+
Tokenizes the command, splits it into simple-command segments, and evaluates
|
|
123
|
+
each segment whose leading program is an ssh-family word AND whose tokens carry
|
|
124
|
+
the NAS address. A bare ssh-family leader returns the binary-swap message; the
|
|
125
|
+
full OpenSSH path leader without a ``BatchMode=yes`` token returns the batch-mode
|
|
126
|
+
message. Returns None when shlex cannot tokenize the command.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
command: The raw Bash command string from the tool input.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The deny message string when a segment violates the NAS ssh policy, else None.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
all_command_tokens = shlex.split(command)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
for each_segment in _split_into_segments(all_command_tokens):
|
|
139
|
+
leading_program = _effective_leading_program(each_segment)
|
|
140
|
+
if leading_program is None:
|
|
141
|
+
continue
|
|
142
|
+
if _token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
|
|
143
|
+
continue
|
|
144
|
+
if not _segment_references_nas(each_segment):
|
|
145
|
+
continue
|
|
146
|
+
if not _is_openssh_binary_path(leading_program):
|
|
147
|
+
return BARE_SSH_BINARY_MESSAGE
|
|
148
|
+
if not _segment_carries_batch_mode(each_segment):
|
|
149
|
+
return MISSING_BATCH_MODE_MESSAGE
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> None:
|
|
154
|
+
try:
|
|
155
|
+
hook_input = json.load(sys.stdin)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
sys.exit(0)
|
|
158
|
+
|
|
159
|
+
tool_name = hook_input.get("tool_name", "")
|
|
160
|
+
if tool_name != BASH_TOOL_NAME:
|
|
161
|
+
sys.exit(0)
|
|
162
|
+
|
|
163
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
164
|
+
if not command:
|
|
165
|
+
sys.exit(0)
|
|
166
|
+
|
|
167
|
+
deny_reason = _find_nas_ssh_violation(command)
|
|
168
|
+
if deny_reason is None:
|
|
169
|
+
sys.exit(0)
|
|
170
|
+
|
|
171
|
+
deny_payload = {
|
|
172
|
+
"hookSpecificOutput": {
|
|
173
|
+
"hookEventName": "PreToolUse",
|
|
174
|
+
"permissionDecision": "deny",
|
|
175
|
+
"permissionDecisionReason": deny_reason,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
log_hook_block(
|
|
179
|
+
calling_hook_name="nas_ssh_binary_enforcer.py",
|
|
180
|
+
hook_event="PreToolUse",
|
|
181
|
+
block_reason=deny_reason,
|
|
182
|
+
tool_name=tool_name,
|
|
183
|
+
offending_input_preview=command,
|
|
184
|
+
)
|
|
185
|
+
print(json.dumps(deny_payload))
|
|
186
|
+
sys.stdout.flush()
|
|
187
|
+
sys.exit(0)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""PreToolUse hook gating gh pr create/edit/comment
|
|
2
|
-
|
|
3
|
-
Reads a PreToolUse JSON payload on stdin,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
"""PreToolUse hook gating gh pr create/edit/comment bodies and gh pr ready.
|
|
2
|
+
|
|
3
|
+
Reads a PreToolUse JSON payload on stdin, audits body-carrying gh pr
|
|
4
|
+
create/edit/comment invocations against the Anthropic claude-code style
|
|
5
|
+
rules plus the proof-of-work audit for proof-shaped comment bodies, and
|
|
6
|
+
denies gh pr ready while the PR carries no passing proof comment.
|
|
7
|
+
Readability-management CLI flags short-circuit the stdin path to adjust
|
|
8
|
+
the persisted readability state.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import json
|
|
@@ -32,6 +33,12 @@ from blocking.pr_description_pr_number import ( # noqa: E402
|
|
|
32
33
|
_command_carries_body_flag,
|
|
33
34
|
_extract_pr_number_from_command,
|
|
34
35
|
)
|
|
36
|
+
from blocking.pr_description_proof_of_work import ( # noqa: E402
|
|
37
|
+
audit_proof_comment_body,
|
|
38
|
+
evaluate_pr_ready_gate,
|
|
39
|
+
is_pr_ready_command,
|
|
40
|
+
is_proof_shaped_body,
|
|
41
|
+
)
|
|
35
42
|
from blocking.pr_description_readability import ( # noqa: E402
|
|
36
43
|
_build_readability_escape_hatch_message,
|
|
37
44
|
_dispatch_cli_flag,
|
|
@@ -131,6 +138,28 @@ def validate_pr_body(body: str, pr_number: int | None = None) -> list[str]:
|
|
|
131
138
|
return violations
|
|
132
139
|
|
|
133
140
|
|
|
141
|
+
def _emit_denial(denial_reason: str) -> None:
|
|
142
|
+
"""Print the PreToolUse deny payload for a violation and log the block.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
denial_reason: The permissionDecisionReason text for the deny payload.
|
|
146
|
+
"""
|
|
147
|
+
denial_payload = {
|
|
148
|
+
"hookSpecificOutput": {
|
|
149
|
+
"hookEventName": "PreToolUse",
|
|
150
|
+
"permissionDecision": "deny",
|
|
151
|
+
"permissionDecisionReason": denial_reason,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
log_hook_block(
|
|
155
|
+
calling_hook_name="pr_description_enforcer.py",
|
|
156
|
+
hook_event="PreToolUse",
|
|
157
|
+
block_reason=denial_reason,
|
|
158
|
+
)
|
|
159
|
+
print(json.dumps(denial_payload))
|
|
160
|
+
sys.stdout.flush()
|
|
161
|
+
|
|
162
|
+
|
|
134
163
|
def main() -> None:
|
|
135
164
|
for each_argv_token in sys.argv[1:]:
|
|
136
165
|
if each_argv_token in ALL_READABILITY_CLI_FLAG_TOKENS:
|
|
@@ -154,6 +183,12 @@ def main() -> None:
|
|
|
154
183
|
if not command:
|
|
155
184
|
sys.exit(0)
|
|
156
185
|
|
|
186
|
+
if is_pr_ready_command(command):
|
|
187
|
+
ready_denial_reason = evaluate_pr_ready_gate(command)
|
|
188
|
+
if ready_denial_reason is not None:
|
|
189
|
+
_emit_denial(ready_denial_reason)
|
|
190
|
+
sys.exit(0)
|
|
191
|
+
|
|
157
192
|
has_any_body_flag = _command_carries_body_flag(command)
|
|
158
193
|
is_pr_create = "gh pr create" in command and has_any_body_flag
|
|
159
194
|
is_pr_edit = "gh pr edit" in command and has_any_body_flag
|
|
@@ -173,28 +208,17 @@ def main() -> None:
|
|
|
173
208
|
|
|
174
209
|
violations = validate_pr_body(body, pr_number=extracted_pr_number)
|
|
175
210
|
|
|
211
|
+
if is_pr_comment and is_proof_shaped_body(body):
|
|
212
|
+
violations.extend(audit_proof_comment_body(body, extracted_pr_number))
|
|
213
|
+
|
|
176
214
|
if violations:
|
|
177
215
|
violation_list = "; ".join(violations)
|
|
178
216
|
pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
|
|
179
|
-
|
|
217
|
+
_emit_denial(
|
|
180
218
|
f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
|
|
181
219
|
f"Rewrite the body yourself in Anthropic claude-code style. "
|
|
182
220
|
f"Guide:{pr_guide_reference}"
|
|
183
221
|
)
|
|
184
|
-
denial_payload = {
|
|
185
|
-
"hookSpecificOutput": {
|
|
186
|
-
"hookEventName": "PreToolUse",
|
|
187
|
-
"permissionDecision": "deny",
|
|
188
|
-
"permissionDecisionReason": denial_reason,
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
log_hook_block(
|
|
192
|
-
calling_hook_name="pr_description_enforcer.py",
|
|
193
|
-
hook_event="PreToolUse",
|
|
194
|
-
block_reason=denial_reason,
|
|
195
|
-
)
|
|
196
|
-
print(json.dumps(denial_payload))
|
|
197
|
-
sys.stdout.flush()
|
|
198
222
|
|
|
199
223
|
sys.exit(0)
|
|
200
224
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Reports whether a captured shell command carries any body or body-file flag,
|
|
4
4
|
and extracts the positional PR number (bare integer or GitHub PR URL) from a
|
|
5
|
-
gh pr edit/comment command while skipping value-taking flags and their
|
|
5
|
+
gh pr edit/comment/ready command while skipping value-taking flags and their
|
|
6
|
+
values.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import re
|
|
@@ -29,6 +30,7 @@ from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
|
29
30
|
strip_surrounding_quotes,
|
|
30
31
|
)
|
|
31
32
|
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
33
|
+
ALL_PR_NUMBER_BEARING_GH_PR_SUBCOMMANDS,
|
|
32
34
|
GH_PR_COMMAND_MIN_TOKEN_COUNT,
|
|
33
35
|
)
|
|
34
36
|
|
|
@@ -59,7 +61,7 @@ def _resolve_positional_pr_number(token: str) -> int | None:
|
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
def _extract_pr_number_from_command(command: str) -> int | None:
|
|
62
|
-
"""Return the PR number positional argument from a `gh pr edit|comment` command.
|
|
64
|
+
"""Return the PR number positional argument from a `gh pr edit|comment|ready` command.
|
|
63
65
|
|
|
64
66
|
Skips value-taking non-body flags (and their value tokens) so that ``--repo owner/r``
|
|
65
67
|
pairs do not consume the trailing PR number. Accepts both a bare integer literal
|
|
@@ -83,7 +85,7 @@ def _extract_pr_number_from_command(command: str) -> int | None:
|
|
|
83
85
|
if all_tokens[0] != "gh" or all_tokens[1] != "pr":
|
|
84
86
|
return None
|
|
85
87
|
subcommand_token = all_tokens[2]
|
|
86
|
-
if subcommand_token not in
|
|
88
|
+
if subcommand_token not in ALL_PR_NUMBER_BEARING_GH_PR_SUBCOMMANDS:
|
|
87
89
|
return None
|
|
88
90
|
all_value_taking_bare_flags: frozenset[str] = (
|
|
89
91
|
non_body_value_flags | all_body_flags | {body_file_flag, body_file_short_flag}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Audit proof-of-work PR comments and gate gh pr ready on a passing one.
|
|
2
|
+
|
|
3
|
+
A proof comment shows the work behind a PR: the command run on real data,
|
|
4
|
+
the measured outcomes, the parent issue it advances, an image when the
|
|
5
|
+
change is visual, and the gaps the offline proof cannot cover. This module
|
|
6
|
+
detects proof-shaped comment bodies, audits the five parts, and blocks
|
|
7
|
+
``gh pr ready`` while no passing proof comment exists on the PR. Every gh
|
|
8
|
+
query fails open, so a tooling failure never blocks a command.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
20
|
+
if _hooks_dir not in sys.path:
|
|
21
|
+
sys.path.insert(0, _hooks_dir)
|
|
22
|
+
|
|
23
|
+
from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
|
|
24
|
+
from blocking.pr_description_pr_number import ( # noqa: E402
|
|
25
|
+
_extract_pr_number_from_command,
|
|
26
|
+
)
|
|
27
|
+
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
28
|
+
BULLET_MARKER_PATTERN,
|
|
29
|
+
FENCED_CODE_BLOCK_PATTERN,
|
|
30
|
+
TABLE_ROW_LINE_PATTERN,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E402
|
|
33
|
+
ALL_HONEST_GAP_PHRASES,
|
|
34
|
+
ALL_PR_DIFF_SUBCOMMANDS,
|
|
35
|
+
ALL_PR_VIEW_NUMBER_ARGUMENTS,
|
|
36
|
+
ALL_PROOF_HEADING_KEYWORDS,
|
|
37
|
+
ALL_VISUAL_FILE_SUFFIXES,
|
|
38
|
+
COMMENT_BODY_JSON_FIELD,
|
|
39
|
+
DIGIT_PATTERN,
|
|
40
|
+
GH_API_SUBCOMMAND,
|
|
41
|
+
GH_COMMAND_TIMEOUT_SECONDS,
|
|
42
|
+
GH_EXECUTABLE,
|
|
43
|
+
GH_PAGINATE_FLAG,
|
|
44
|
+
GH_SLURP_FLAG,
|
|
45
|
+
HEX_COLOR_ADDED_LINE_PATTERN,
|
|
46
|
+
IMAGE_EMBED_PATTERN,
|
|
47
|
+
ISSUE_REFERENCE_PATTERN,
|
|
48
|
+
MAX_DIFF_SCAN_CHARS,
|
|
49
|
+
PLAN_LINKAGE_KEYWORD_PATTERN,
|
|
50
|
+
PR_COMMENTS_API_PATH_TEMPLATE,
|
|
51
|
+
PR_DIFF_NAME_ONLY_FLAG,
|
|
52
|
+
PR_NUMBER_JSON_FIELD,
|
|
53
|
+
PR_READY_GATE_MESSAGE_TEMPLATE,
|
|
54
|
+
PR_READY_INVOCATION_PATTERN,
|
|
55
|
+
PR_READY_UNDO_FLAG,
|
|
56
|
+
PROOF_PART_COMMAND_MESSAGE,
|
|
57
|
+
PROOF_PART_HONEST_GAPS_MESSAGE,
|
|
58
|
+
PROOF_PART_MEASURED_MESSAGE,
|
|
59
|
+
PROOF_PART_PLAN_LINKAGE_MESSAGE,
|
|
60
|
+
PROOF_PART_VISUAL_MESSAGE,
|
|
61
|
+
)
|
|
62
|
+
from hooks_constants.setup_project_paths_constants import UTF8_ENCODING # noqa: E402
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_pr_ready_command(command: str) -> bool:
|
|
68
|
+
"""Decide whether a shell command marks a PR ready for review.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
command: The raw shell command captured by the hook.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True for a ``gh pr ready`` command without ``--undo``; False for
|
|
75
|
+
every other command, including the undo form that returns a PR to
|
|
76
|
+
draft and any command that only mentions the phrase ``gh pr ready``
|
|
77
|
+
inside a quoted argument or commit message.
|
|
78
|
+
"""
|
|
79
|
+
if PR_READY_UNDO_FLAG in command:
|
|
80
|
+
return False
|
|
81
|
+
return PR_READY_INVOCATION_PATTERN.search(command) is not None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_proof_shaped_body(body: str) -> bool:
|
|
85
|
+
"""Decide whether a comment body presents itself as a proof of work.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
body: The comment body markdown text.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True when any markdown heading names proof or verification; False
|
|
92
|
+
when no heading does.
|
|
93
|
+
"""
|
|
94
|
+
for each_header in _iter_section_headers(body):
|
|
95
|
+
header_lower = each_header.lower()
|
|
96
|
+
if any(each_keyword in header_lower for each_keyword in ALL_PROOF_HEADING_KEYWORDS):
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def audit_proof_comment_body(body: str, pr_number: int | None) -> list[str]:
|
|
102
|
+
"""Audit a proof-shaped comment body for the five proof parts.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
body: The comment body markdown text.
|
|
106
|
+
pr_number: The PR number named in the command, or None when the
|
|
107
|
+
command targets the current branch's PR.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
One message per missing proof part. Empty when the body carries a
|
|
111
|
+
command block, a measured-value element, a plan-linkage sentence,
|
|
112
|
+
an honest-gaps statement, and (when the PR diff is visual) an
|
|
113
|
+
image embed.
|
|
114
|
+
"""
|
|
115
|
+
is_visual_change = _detect_visual_change(pr_number)
|
|
116
|
+
return _missing_proof_parts(body, is_visual_change)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def evaluate_pr_ready_gate(command: str) -> str | None:
|
|
120
|
+
"""Decide whether a gh pr ready command may proceed.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
command: The raw shell command captured by the hook.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A denial reason stating the five-part proof standard when the PR
|
|
127
|
+
carries no passing proof comment, or None when a passing proof
|
|
128
|
+
comment exists or any gh query fails.
|
|
129
|
+
"""
|
|
130
|
+
resolved_pr_number = _extract_pr_number_from_command(command)
|
|
131
|
+
if resolved_pr_number is None:
|
|
132
|
+
resolved_pr_number = _resolve_current_pr_number()
|
|
133
|
+
if resolved_pr_number is None:
|
|
134
|
+
return None
|
|
135
|
+
all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number)
|
|
136
|
+
if all_comment_bodies is None:
|
|
137
|
+
return None
|
|
138
|
+
is_visual_change = _detect_visual_change(resolved_pr_number)
|
|
139
|
+
for each_body in all_comment_bodies:
|
|
140
|
+
if not is_proof_shaped_body(each_body):
|
|
141
|
+
continue
|
|
142
|
+
if not _missing_proof_parts(each_body, is_visual_change):
|
|
143
|
+
return None
|
|
144
|
+
return PR_READY_GATE_MESSAGE_TEMPLATE.format(pr_number=resolved_pr_number)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _run_gh_command(all_gh_arguments: list[str]) -> str | None:
|
|
148
|
+
"""Run one gh command and return its stdout.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
all_gh_arguments: The gh arguments after the executable name.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The command stdout on a zero exit, or None when gh is missing,
|
|
155
|
+
times out, or exits non-zero — the caller fails open on None.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
completed_process = subprocess.run(
|
|
159
|
+
[GH_EXECUTABLE, *all_gh_arguments],
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
encoding=UTF8_ENCODING,
|
|
163
|
+
errors="replace",
|
|
164
|
+
timeout=GH_COMMAND_TIMEOUT_SECONDS,
|
|
165
|
+
check=False,
|
|
166
|
+
)
|
|
167
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
168
|
+
logger.warning("gh command failed to run: %s", all_gh_arguments)
|
|
169
|
+
return None
|
|
170
|
+
if completed_process.returncode != 0:
|
|
171
|
+
logger.warning("gh command exited %d: %s", completed_process.returncode, all_gh_arguments)
|
|
172
|
+
return None
|
|
173
|
+
return completed_process.stdout
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _resolve_current_pr_number() -> int | None:
|
|
177
|
+
"""Resolve the PR number of the current branch's open PR.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The PR number from ``gh pr view``, or None when no PR resolves or
|
|
181
|
+
gh fails.
|
|
182
|
+
"""
|
|
183
|
+
view_stdout = _run_gh_command(list(ALL_PR_VIEW_NUMBER_ARGUMENTS))
|
|
184
|
+
if view_stdout is None:
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
view_record = json.loads(view_stdout)
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(view_record, dict):
|
|
191
|
+
return None
|
|
192
|
+
resolved_number = view_record.get(PR_NUMBER_JSON_FIELD)
|
|
193
|
+
if isinstance(resolved_number, int):
|
|
194
|
+
return resolved_number
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
|
|
199
|
+
"""Read every comment body on a PR through the paginated issues API.
|
|
200
|
+
|
|
201
|
+
The query runs ``gh api --paginate --slurp`` so every page lands in one
|
|
202
|
+
JSON array of pages, and the flattening happens here after the full
|
|
203
|
+
pagination rather than per page.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
pr_number: The PR number whose comments to read.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The comment bodies across all pages, or None when gh fails or the
|
|
210
|
+
output is not the slurped page-array shape — the caller fails open
|
|
211
|
+
on None.
|
|
212
|
+
"""
|
|
213
|
+
comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
|
|
214
|
+
api_stdout = _run_gh_command(
|
|
215
|
+
[GH_API_SUBCOMMAND, comments_api_path, GH_PAGINATE_FLAG, GH_SLURP_FLAG]
|
|
216
|
+
)
|
|
217
|
+
if api_stdout is None:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
all_comment_pages = json.loads(api_stdout)
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
return None
|
|
223
|
+
if not isinstance(all_comment_pages, list):
|
|
224
|
+
return None
|
|
225
|
+
all_comment_bodies: list[str] = []
|
|
226
|
+
for each_page in all_comment_pages:
|
|
227
|
+
if not isinstance(each_page, list):
|
|
228
|
+
continue
|
|
229
|
+
for each_comment_record in each_page:
|
|
230
|
+
if isinstance(each_comment_record, dict):
|
|
231
|
+
all_comment_bodies.append(str(each_comment_record.get(COMMENT_BODY_JSON_FIELD, "")))
|
|
232
|
+
return all_comment_bodies
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _detect_visual_change(pr_number: int | None) -> bool:
|
|
236
|
+
"""Decide whether the PR diff carries visual work.
|
|
237
|
+
|
|
238
|
+
A diff is visual when a changed file carries an image, HTML, or
|
|
239
|
+
stylesheet suffix, or when an added line carries a six-digit hex color
|
|
240
|
+
value.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
pr_number: The PR number to diff, or None to diff the current
|
|
244
|
+
branch's PR.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
True when the diff is visual; False when it is not or when any gh
|
|
248
|
+
query fails — an unknowable diff never adds the visual requirement.
|
|
249
|
+
"""
|
|
250
|
+
all_number_arguments = [str(pr_number)] if pr_number is not None else []
|
|
251
|
+
name_only_stdout = _run_gh_command(
|
|
252
|
+
[*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, PR_DIFF_NAME_ONLY_FLAG]
|
|
253
|
+
)
|
|
254
|
+
if name_only_stdout is None:
|
|
255
|
+
return False
|
|
256
|
+
for each_changed_path in name_only_stdout.splitlines():
|
|
257
|
+
if Path(each_changed_path.strip()).suffix.lower() in ALL_VISUAL_FILE_SUFFIXES:
|
|
258
|
+
return True
|
|
259
|
+
diff_stdout = _run_gh_command([*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments])
|
|
260
|
+
if diff_stdout is None:
|
|
261
|
+
return False
|
|
262
|
+
return HEX_COLOR_ADDED_LINE_PATTERN.search(diff_stdout[:MAX_DIFF_SCAN_CHARS]) is not None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _missing_proof_parts(body: str, is_visual_change: bool) -> list[str]:
|
|
266
|
+
"""Collect the proof parts a comment body is missing.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
body: The comment body markdown text.
|
|
270
|
+
is_visual_change: Whether the PR diff carries visual work, which
|
|
271
|
+
adds the image-embed requirement.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
One message per missing part, in the standard's order: command
|
|
275
|
+
block, measured values, plan linkage, visual element, honest gaps.
|
|
276
|
+
"""
|
|
277
|
+
prose_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
|
|
278
|
+
all_missing_parts: list[str] = []
|
|
279
|
+
if not _has_command_block(body):
|
|
280
|
+
all_missing_parts.append(PROOF_PART_COMMAND_MESSAGE)
|
|
281
|
+
if not _has_measured_value(prose_without_fences):
|
|
282
|
+
all_missing_parts.append(PROOF_PART_MEASURED_MESSAGE)
|
|
283
|
+
if not _has_plan_linkage(prose_without_fences):
|
|
284
|
+
all_missing_parts.append(PROOF_PART_PLAN_LINKAGE_MESSAGE)
|
|
285
|
+
if is_visual_change and not _has_visual_element(prose_without_fences):
|
|
286
|
+
all_missing_parts.append(PROOF_PART_VISUAL_MESSAGE)
|
|
287
|
+
if not _has_honest_gaps(prose_without_fences):
|
|
288
|
+
all_missing_parts.append(PROOF_PART_HONEST_GAPS_MESSAGE)
|
|
289
|
+
return all_missing_parts
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _has_command_block(body: str) -> bool:
|
|
293
|
+
"""Report whether the body carries a fenced code block with content.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
body: The comment body markdown text.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
True when at least one fenced code block holds a non-empty line
|
|
300
|
+
between its fence lines.
|
|
301
|
+
"""
|
|
302
|
+
for each_fence_match in FENCED_CODE_BLOCK_PATTERN.finditer(body):
|
|
303
|
+
all_inner_lines = each_fence_match.group(0).splitlines()[1:-1]
|
|
304
|
+
if any(each_line.strip() for each_line in all_inner_lines):
|
|
305
|
+
return True
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _has_measured_value(prose_without_fences: str) -> bool:
|
|
310
|
+
"""Report whether the prose carries a measured-value element.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
prose_without_fences: The body text with fenced code blocks removed.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
True when a pipe-table row or a bullet line carries a digit.
|
|
317
|
+
"""
|
|
318
|
+
for each_line in prose_without_fences.splitlines():
|
|
319
|
+
if not DIGIT_PATTERN.search(each_line):
|
|
320
|
+
continue
|
|
321
|
+
if TABLE_ROW_LINE_PATTERN.match(each_line) or BULLET_MARKER_PATTERN.match(each_line):
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _has_plan_linkage(prose_without_fences: str) -> bool:
|
|
327
|
+
"""Report whether the prose links the PR to its parent plan.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
prose_without_fences: The body text with fenced code blocks removed.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
True when one line carries both an issue reference (``#123``) and a
|
|
334
|
+
linkage word (issue, phase, plan, parent, advances, milestone, or
|
|
335
|
+
part of).
|
|
336
|
+
"""
|
|
337
|
+
for each_line in prose_without_fences.splitlines():
|
|
338
|
+
has_issue_reference = ISSUE_REFERENCE_PATTERN.search(each_line) is not None
|
|
339
|
+
if has_issue_reference and PLAN_LINKAGE_KEYWORD_PATTERN.search(each_line) is not None:
|
|
340
|
+
return True
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _has_visual_element(prose_without_fences: str) -> bool:
|
|
345
|
+
"""Report whether the prose embeds an image.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
prose_without_fences: The body text with fenced code blocks removed.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
True when a markdown image embed (````) is present.
|
|
352
|
+
"""
|
|
353
|
+
return IMAGE_EMBED_PATTERN.search(prose_without_fences) is not None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _has_honest_gaps(prose_without_fences: str) -> bool:
|
|
357
|
+
"""Report whether the prose states what the proof cannot show.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
prose_without_fences: The body text with fenced code blocks removed.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
True when the prose carries a gap phrase (gap, limitation, cannot,
|
|
364
|
+
does not show, not shown, unverified, or not covered).
|
|
365
|
+
"""
|
|
366
|
+
prose_lower = prose_without_fences.lower()
|
|
367
|
+
return any(each_phrase in prose_lower for each_phrase in ALL_HONEST_GAP_PHRASES)
|