claude-dev-env 1.89.0 → 1.92.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 +0 -13
- package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- 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 +6 -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/test_volatile_path_in_post_blocker.py +210 -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/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +32 -2
- package/hooks/hooks_constants/CLAUDE.md +4 -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/enter_worktree_prefetch_constants.py +18 -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/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -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 +3 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/scripts/CLAUDE.md +2 -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 +2 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/team-advisor/SKILL.md +188 -0
- package/skills/team-advisor-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,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)
|
|
@@ -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
|