claude-dev-env 8.99.1 → 8.100.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/hooks/blocking/merge_review_gate.py +66 -105
- package/hooks/blocking/merge_review_gate_parts/__init__.py +1 -0
- package/hooks/blocking/merge_review_gate_parts/open_request.py +163 -0
- package/hooks/blocking/merge_review_gate_parts/receipts.py +192 -0
- package/hooks/blocking/test_merge_review_gate.py +218 -0
- package/hooks/blocking/test_verify_before_commit_gate.py +56 -1
- package/hooks/blocking/tests/test_open_request.py +38 -0
- package/hooks/blocking/tests/test_receipts.py +57 -0
- package/hooks/blocking/verify_before_commit_gate.py +11 -1
- package/hooks/features/conduct-gates.md +5 -5
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/merge_review_gate_constants.py +46 -1
- package/hooks/hooks_constants/verify_before_commit_gate_constants.py +3 -1
- package/hooks/test_transcript_skill_scan.py +36 -3
- package/hooks/transcript_skill_scan.py +28 -3
- package/package.json +1 -1
- package/scripts/merge_review_receipt.py +56 -4
- package/scripts/test_merge_review_receipt.py +53 -0
|
@@ -44,6 +44,17 @@ def _named_skill_inputs(
|
|
|
44
44
|
all_entry_fields: dict[str, object],
|
|
45
45
|
skill_names: Collection[str],
|
|
46
46
|
accepts_plugin_prefix: bool = True,
|
|
47
|
+
) -> Iterator[dict[str, object]]:
|
|
48
|
+
for each_block in _named_skill_blocks(all_entry_fields, skill_names, accepts_plugin_prefix):
|
|
49
|
+
skill_input = each_block["input"]
|
|
50
|
+
if isinstance(skill_input, dict):
|
|
51
|
+
yield skill_input
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _named_skill_blocks(
|
|
55
|
+
all_entry_fields: dict[str, object],
|
|
56
|
+
skill_names: Collection[str],
|
|
57
|
+
accepts_plugin_prefix: bool = True,
|
|
47
58
|
) -> Iterator[dict[str, object]]:
|
|
48
59
|
message = all_entry_fields.get("message")
|
|
49
60
|
if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(message, dict):
|
|
@@ -61,7 +72,7 @@ def _named_skill_inputs(
|
|
|
61
72
|
invoked_name == name or (accepts_plugin_prefix and invoked_name.endswith(":" + name))
|
|
62
73
|
for name in skill_names
|
|
63
74
|
):
|
|
64
|
-
yield each_block
|
|
75
|
+
yield each_block
|
|
65
76
|
|
|
66
77
|
|
|
67
78
|
def _invokes_skill(
|
|
@@ -183,11 +194,15 @@ def is_skill_loaded_since_last_prompt(
|
|
|
183
194
|
::
|
|
184
195
|
|
|
185
196
|
prompt, Skill verify, tool result, skill body -> True
|
|
197
|
+
prompt, Skill verify, tool result with is_error -> False
|
|
198
|
+
prompt, Skill verify, no tool result yet -> False
|
|
186
199
|
prompt, Skill verify, prompt -> False
|
|
187
200
|
prompt carrying the /verify command marker -> True
|
|
188
201
|
prompt with the marker, compact boundary -> False
|
|
189
202
|
|
|
190
|
-
A
|
|
203
|
+
A Skill call counts once its tool result comes back without an error, so a
|
|
204
|
+
refused load leaves the skill unloaded. A tool result and a meta entry, such
|
|
205
|
+
as the skill body, leave the turn open.
|
|
191
206
|
|
|
192
207
|
Args:
|
|
193
208
|
all_transcript_lines: JSON transcript entries, one per line.
|
|
@@ -195,13 +210,23 @@ def is_skill_loaded_since_last_prompt(
|
|
|
195
210
|
slash_command_markers: Accepted user command tags.
|
|
196
211
|
"""
|
|
197
212
|
loaded = False
|
|
213
|
+
all_pending_ids: set[object] = set()
|
|
198
214
|
for each_line in all_transcript_lines:
|
|
199
215
|
all_entry_fields = _parsed_entry(each_line)
|
|
200
216
|
if all_entry_fields is None:
|
|
201
217
|
continue
|
|
202
218
|
if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE or _is_user_prompt(all_entry_fields):
|
|
203
219
|
loaded = False
|
|
204
|
-
|
|
220
|
+
all_pending_ids = set()
|
|
221
|
+
command_text = _user_command_text(all_entry_fields)
|
|
222
|
+
if command_text is not None:
|
|
223
|
+
loaded = loaded or any(marker in command_text for marker in slash_command_markers)
|
|
224
|
+
all_pending_ids.update(
|
|
225
|
+
each_block["id"]
|
|
226
|
+
for each_block in _named_skill_blocks(all_entry_fields, skill_names)
|
|
227
|
+
if each_block.get("id") is not None
|
|
228
|
+
)
|
|
229
|
+
loaded = loaded or bool(all_pending_ids & _succeeded_result_ids(all_entry_fields))
|
|
205
230
|
return loaded
|
|
206
231
|
|
|
207
232
|
|
package/package.json
CHANGED
|
@@ -13,8 +13,14 @@ clean once the rulings are posted and linked::
|
|
|
13
13
|
python3 merge_review_receipt.py owner/name 12 def5678 2 --ruled-out 2 --rulings <url>
|
|
14
14
|
-> CLEAN owner/name#12 def5678 (2 findings ruled out: <url>)
|
|
15
15
|
|
|
16
|
+
A pass run before the pull request exists names ``open`` in place of the
|
|
17
|
+
number, and records the head it reviewed::
|
|
18
|
+
|
|
19
|
+
python3 merge_review_receipt.py owner/name open def5678 0 -> CLEAN owner/name open def5678
|
|
20
|
+
|
|
16
21
|
``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
|
|
17
|
-
the newest pass only when that pass is clean.
|
|
22
|
+
the newest pass only when that pass is clean. It allows a pull request open
|
|
23
|
+
whose head has a clean ``open`` pass, and a merge pinned to that same head.
|
|
18
24
|
"""
|
|
19
25
|
|
|
20
26
|
from __future__ import annotations
|
|
@@ -30,12 +36,19 @@ hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
|
|
|
30
36
|
if hooks_directory not in sys.path:
|
|
31
37
|
sys.path.insert(0, hooks_directory)
|
|
32
38
|
|
|
33
|
-
from blocking.
|
|
39
|
+
from blocking.merge_review_gate_parts.receipts import (
|
|
40
|
+
is_clean_receipt,
|
|
41
|
+
open_receipt_path,
|
|
42
|
+
receipt_path,
|
|
43
|
+
)
|
|
34
44
|
from hooks_constants.merge_review_gate_constants import (
|
|
45
|
+
OPEN_RECEIPT_HEAD_PATTERN,
|
|
46
|
+
OPEN_TARGET_WORD,
|
|
35
47
|
RECEIPT_FINDINGS_KEY,
|
|
36
48
|
RECEIPT_HEAD_KEY,
|
|
37
49
|
RECEIPT_RULED_OUT_KEY,
|
|
38
50
|
RECEIPT_RULINGS_KEY,
|
|
51
|
+
REPOSITORY_SLUG_PATTERN,
|
|
39
52
|
)
|
|
40
53
|
|
|
41
54
|
|
|
@@ -64,9 +77,29 @@ def record_review(
|
|
|
64
77
|
return _write_receipt(slug, number, receipt)
|
|
65
78
|
|
|
66
79
|
|
|
80
|
+
def record_open_review(slug: str, head: str, finding_count: int) -> Path:
|
|
81
|
+
"""Write the receipt for one review pass run before the pull request opens.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
slug: Repository as ``owner/name``.
|
|
85
|
+
head: The commit the pass reviewed.
|
|
86
|
+
finding_count: How many findings the pass reported.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The receipt path written.
|
|
90
|
+
"""
|
|
91
|
+
owner, repo = slug.split("/", 1)
|
|
92
|
+
return _write_receipt_at(
|
|
93
|
+
open_receipt_path(owner, repo, head), _receipt_fields(head, finding_count, 0, "")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
67
97
|
def _write_receipt(slug: str, number: int, all_receipt_fields: dict[str, object]) -> Path:
|
|
68
98
|
owner, repo = slug.split("/", 1)
|
|
69
|
-
|
|
99
|
+
return _write_receipt_at(receipt_path(owner, repo, number), all_receipt_fields)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_receipt_at(path: Path, all_receipt_fields: dict[str, object]) -> Path:
|
|
70
103
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
104
|
path.write_text(json.dumps(all_receipt_fields) + "\n", encoding="utf-8")
|
|
72
105
|
return path
|
|
@@ -94,7 +127,9 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
|
94
127
|
"""
|
|
95
128
|
parser = argparse.ArgumentParser(prog="merge_review_receipt")
|
|
96
129
|
parser.add_argument("slug", help="Repository as owner/name.")
|
|
97
|
-
parser.add_argument(
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"number", help=f"Pull request number, or {OPEN_TARGET_WORD} before the pull request opens."
|
|
132
|
+
)
|
|
98
133
|
parser.add_argument("head", help="The head commit the pass reviewed.")
|
|
99
134
|
parser.add_argument("findings", type=int, help="Findings the pass reported.")
|
|
100
135
|
parser.add_argument(
|
|
@@ -103,6 +138,23 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
|
103
138
|
parser.add_argument("--rulings", default="", help="Link to the posted rulings.")
|
|
104
139
|
parsed = parser.parse_args(all_arguments)
|
|
105
140
|
rulings = parsed.rulings.strip()
|
|
141
|
+
if not REPOSITORY_SLUG_PATTERN.fullmatch(parsed.slug) or ".." in parsed.slug:
|
|
142
|
+
parser.error("slug must be owner/name")
|
|
143
|
+
if parsed.number == OPEN_TARGET_WORD:
|
|
144
|
+
if parsed.ruled_out or rulings:
|
|
145
|
+
parser.error(f"an {OPEN_TARGET_WORD} pass is clean only at zero findings")
|
|
146
|
+
if parsed.findings < 0:
|
|
147
|
+
parser.error("findings must be zero or more")
|
|
148
|
+
if not OPEN_RECEIPT_HEAD_PATTERN.fullmatch(parsed.head):
|
|
149
|
+
parser.error(f"an {OPEN_TARGET_WORD} pass needs the full 40 character commit")
|
|
150
|
+
record_open_review(parsed.slug, parsed.head, parsed.findings)
|
|
151
|
+
verdict = "CLEAN" if parsed.findings == 0 else "REVIEWED"
|
|
152
|
+
count_note = "" if parsed.findings == 0 else f" {parsed.findings} findings"
|
|
153
|
+
stdout.write(f"{verdict} {parsed.slug} {OPEN_TARGET_WORD} {parsed.head}{count_note}\n")
|
|
154
|
+
return 0 if parsed.findings == 0 else 1
|
|
155
|
+
if not parsed.number.isdigit():
|
|
156
|
+
parser.error(f"number must be a pull request number or {OPEN_TARGET_WORD}")
|
|
157
|
+
parsed.number = int(parsed.number)
|
|
106
158
|
if parsed.ruled_out < 0 or parsed.ruled_out > parsed.findings:
|
|
107
159
|
parser.error("--ruled-out must be between 0 and the finding count")
|
|
108
160
|
if parsed.ruled_out and not rulings:
|
|
@@ -122,3 +122,56 @@ def test_should_report_reviewed_when_the_rulings_link_points_at_another_pull_req
|
|
|
122
122
|
)
|
|
123
123
|
assert exit_code == 1
|
|
124
124
|
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 2 findings\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_record_a_clean_open_pass_under_heads(receipt_folder: Path) -> None:
|
|
128
|
+
verdict_stream = StringIO()
|
|
129
|
+
assert merge_review_receipt.main(["acme/tools", "open", HEAD, "0"], verdict_stream) == 0
|
|
130
|
+
assert verdict_stream.getvalue() == f"CLEAN acme/tools open {HEAD}\n"
|
|
131
|
+
receipt = json.loads((receipt_folder / "heads" / f"acme__tools__{HEAD}.json").read_text())
|
|
132
|
+
assert receipt == {"head": HEAD, "findings": 0}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_record_open_review_writes_the_head_receipt_in_lowercase(receipt_folder: Path) -> None:
|
|
136
|
+
path = merge_review_receipt.record_open_review("Acme/Tools", HEAD.upper(), 2)
|
|
137
|
+
assert path == receipt_folder / "heads" / f"acme__tools__{HEAD}.json"
|
|
138
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD.upper(), "findings": 2}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_should_exit_one_for_an_open_pass_with_findings() -> None:
|
|
142
|
+
verdict_stream = StringIO()
|
|
143
|
+
assert merge_review_receipt.main(["acme/tools", "open", HEAD, "2"], verdict_stream) == 1
|
|
144
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools open {HEAD} 2 findings\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize(
|
|
148
|
+
"all_arguments",
|
|
149
|
+
[
|
|
150
|
+
["acme/tools", "open", HEAD, "-1"],
|
|
151
|
+
["acme/tools", "open", HEAD, "2", "--ruled-out", "2", "--rulings", RULINGS_LINK],
|
|
152
|
+
["acme/tools", "twelve", HEAD, "0"],
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
def test_should_refuse_an_open_pass_it_cannot_record(
|
|
156
|
+
all_arguments: list[str], receipt_folder: Path
|
|
157
|
+
) -> None:
|
|
158
|
+
with pytest.raises(SystemExit):
|
|
159
|
+
merge_review_receipt.main(all_arguments, StringIO())
|
|
160
|
+
assert list(receipt_folder.iterdir()) == []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize("head", ["abc", "abc1234", "../../escape", "zzzzzzz"])
|
|
164
|
+
def test_should_refuse_an_open_pass_whose_head_is_not_a_commit(head: str, receipt_folder: Path) -> None:
|
|
165
|
+
with pytest.raises(SystemExit):
|
|
166
|
+
merge_review_receipt.main(["acme/tools", "open", head, "0"], StringIO())
|
|
167
|
+
assert list(receipt_folder.iterdir()) == []
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize("slug", ["owner-repo", "a/b/../c", "a/.."])
|
|
171
|
+
@pytest.mark.parametrize("number", ["open", "12"])
|
|
172
|
+
def test_should_refuse_a_slug_that_is_not_owner_and_name(
|
|
173
|
+
slug: str, number: str, receipt_folder: Path
|
|
174
|
+
) -> None:
|
|
175
|
+
with pytest.raises(SystemExit):
|
|
176
|
+
merge_review_receipt.main([slug, number, HEAD, "0"], StringIO())
|
|
177
|
+
assert list(receipt_folder.iterdir()) == []
|