claude-dev-env 8.98.4 → 8.98.7
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/.agents/skills/pr-lifecycle/SKILL.md +4 -4
- package/hooks/blocking/open_tasks_stop_gate.py +62 -9
- package/hooks/blocking/open_tasks_transcript.py +71 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +36 -10
- package/hooks/blocking/reply_length_gate.py +11 -2
- package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +135 -2
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +5 -0
- package/hooks/blocking/test_reply_length_gate.py +11 -0
- package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +24 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +10 -1
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/session/artifact_template_pointer.py +26 -3
- package/hooks/session/test_artifact_template_pointer.py +16 -0
- package/hooks/test_transcript_skill_scan.py +30 -0
- package/hooks/transcript_skill_scan.py +72 -1
- package/package.json +1 -1
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +6 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_durable_post_lint.py +176 -3
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -9,10 +9,12 @@ if str(HOOKS_DIRECTORY) not in sys.path:
|
|
|
9
9
|
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
10
|
|
|
11
11
|
from transcript_skill_scan import (
|
|
12
|
+
TranscriptToolCall,
|
|
12
13
|
invokes_skill_with_argument,
|
|
13
14
|
is_skill_loaded_after_last_compaction,
|
|
14
15
|
is_skill_loaded_since_last_prompt,
|
|
15
16
|
skill_invocation_status,
|
|
17
|
+
tool_calls_since_last_prompt,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
|
|
@@ -158,3 +160,31 @@ def test_slash_command_prompt_loads_the_skill_for_its_own_turn() -> None:
|
|
|
158
160
|
assert is_skill_loaded_since_last_prompt([_prompt_entry("First."), _prompt_entry(marker)], ("verify",), (marker,))
|
|
159
161
|
compaction = json.dumps({"subtype": "compact_boundary"})
|
|
160
162
|
assert not is_skill_loaded_since_last_prompt([_prompt_entry(marker), compaction], ("verify",), (marker,))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def should_list_each_tool_call_since_the_last_prompt_with_its_outcome() -> None:
|
|
166
|
+
def _call(tool_use_id: str, name: str) -> str:
|
|
167
|
+
return json.dumps(
|
|
168
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": tool_use_id, "name": name, "input": {}}]}}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _result(tool_use_id: str, is_error: bool) -> str:
|
|
172
|
+
tool_result = {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error, "content": "x"}
|
|
173
|
+
return json.dumps({"type": "user", "message": {"content": [tool_result]}})
|
|
174
|
+
|
|
175
|
+
lines = [
|
|
176
|
+
json.dumps({"type": "user", "message": {"content": "First."}}),
|
|
177
|
+
_call("old", "Read"),
|
|
178
|
+
_result("old", False),
|
|
179
|
+
json.dumps({"type": "user", "message": {"content": "Second."}}),
|
|
180
|
+
_call("ok", "Bash"),
|
|
181
|
+
_result("ok", False),
|
|
182
|
+
_call("denied", "Edit"),
|
|
183
|
+
_result("denied", True),
|
|
184
|
+
_call("pending", "Write"),
|
|
185
|
+
]
|
|
186
|
+
assert tool_calls_since_last_prompt(lines) == [
|
|
187
|
+
TranscriptToolCall("Bash", {}, True),
|
|
188
|
+
TranscriptToolCall("Edit", {}, False),
|
|
189
|
+
TranscriptToolCall("Write", {}, False),
|
|
190
|
+
]
|
|
@@ -1,23 +1,37 @@
|
|
|
1
|
-
"""Find a skill invocation after the last transcript compaction, or one with a given first argument anywhere.
|
|
1
|
+
"""Find a skill invocation after the last transcript compaction, or one with a given first argument anywhere.
|
|
2
|
+
|
|
3
|
+
It also lists the tool calls made since the last user prompt, each with whether its result came back without an error.
|
|
4
|
+
"""
|
|
2
5
|
|
|
3
6
|
from __future__ import annotations
|
|
4
7
|
|
|
5
8
|
import json
|
|
6
9
|
import re
|
|
7
10
|
from collections.abc import Collection, Iterable, Iterator
|
|
11
|
+
from typing import NamedTuple
|
|
8
12
|
|
|
9
13
|
from hooks_constants.transcript_skill_scan_constants import (
|
|
10
14
|
ASSISTANT_ENTRY_TYPE,
|
|
11
15
|
COMMAND_ARGUMENTS_PATTERN,
|
|
12
16
|
COMPACT_BOUNDARY_SUBTYPE,
|
|
17
|
+
IS_ERROR_FIELD,
|
|
13
18
|
META_ENTRY_FIELD,
|
|
14
19
|
SKILL_TOOL_NAME,
|
|
15
20
|
TOOL_RESULT_BLOCK_TYPE,
|
|
16
21
|
TOOL_USE_BLOCK_TYPE,
|
|
22
|
+
TOOL_USE_ID_FIELD,
|
|
17
23
|
USER_ENTRY_TYPE,
|
|
18
24
|
)
|
|
19
25
|
|
|
20
26
|
|
|
27
|
+
class TranscriptToolCall(NamedTuple):
|
|
28
|
+
"""One tool call in a transcript: its name, its input, and whether its result came back without an error."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
tool_input: dict[str, object]
|
|
32
|
+
succeeded: bool
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
def _user_command_text(all_entry_fields: dict[str, object]) -> str | None:
|
|
22
36
|
message = all_entry_fields.get("message")
|
|
23
37
|
if all_entry_fields.get("type") != USER_ENTRY_TYPE or not isinstance(message, dict):
|
|
@@ -225,3 +239,60 @@ def invokes_skill_with_argument(
|
|
|
225
239
|
if all_entry_fields and _invokes_skill_with_argument(all_entry_fields, skill_names, argument_word, command_marker):
|
|
226
240
|
return True
|
|
227
241
|
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _content_blocks(all_entry_fields: dict[str, object], entry_type: str) -> list[dict[str, object]]:
|
|
245
|
+
message = all_entry_fields.get("message")
|
|
246
|
+
if all_entry_fields.get("type") != entry_type or not isinstance(message, dict):
|
|
247
|
+
return []
|
|
248
|
+
content = message.get("content")
|
|
249
|
+
return [each_block for each_block in (content if isinstance(content, list) else []) if isinstance(each_block, dict)]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _tool_use_calls(all_entry_fields: dict[str, object]) -> list[tuple[object, str, dict[str, object]]]:
|
|
253
|
+
all_tool_use_calls: list[tuple[object, str, dict[str, object]]] = []
|
|
254
|
+
for each_block in _content_blocks(all_entry_fields, ASSISTANT_ENTRY_TYPE):
|
|
255
|
+
tool_name = each_block.get("name")
|
|
256
|
+
tool_input = each_block.get("input")
|
|
257
|
+
if each_block.get("type") == TOOL_USE_BLOCK_TYPE and isinstance(tool_name, str):
|
|
258
|
+
all_tool_use_calls.append((each_block.get("id"), tool_name, tool_input if isinstance(tool_input, dict) else {}))
|
|
259
|
+
return all_tool_use_calls
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _succeeded_result_ids(all_entry_fields: dict[str, object]) -> set[object]:
|
|
263
|
+
return {
|
|
264
|
+
each_block.get(TOOL_USE_ID_FIELD)
|
|
265
|
+
for each_block in _content_blocks(all_entry_fields, USER_ENTRY_TYPE)
|
|
266
|
+
if each_block.get("type") == TOOL_RESULT_BLOCK_TYPE and each_block.get(IS_ERROR_FIELD) is not True
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def tool_calls_since_last_prompt(all_transcript_lines: Iterable[str]) -> list[TranscriptToolCall]:
|
|
271
|
+
"""Return the tool calls after the last user prompt, in order, each marked by whether it succeeded.
|
|
272
|
+
|
|
273
|
+
::
|
|
274
|
+
|
|
275
|
+
prompt, reply call, its result without an error -> [reply succeeded]
|
|
276
|
+
prompt, reply call, its result with is_error -> [reply failed]
|
|
277
|
+
prompt, resolve call with no result yet -> [resolve failed]
|
|
278
|
+
reply call and result, then a new prompt -> []
|
|
279
|
+
|
|
280
|
+
A hook that denies a call leaves an errored result, so a denied call does
|
|
281
|
+
not count as a success. A tool result leaves the turn open.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
285
|
+
"""
|
|
286
|
+
all_calls: list[tuple[object, str, dict[str, object]]] = []
|
|
287
|
+
all_succeeded_ids: set[object] = set()
|
|
288
|
+
all_entries = (each_entry for each_entry in map(_parsed_entry, all_transcript_lines) if each_entry is not None)
|
|
289
|
+
for each_entry_fields in all_entries:
|
|
290
|
+
if _is_user_prompt(each_entry_fields):
|
|
291
|
+
all_calls, all_succeeded_ids = [], set()
|
|
292
|
+
continue
|
|
293
|
+
all_calls.extend(_tool_use_calls(each_entry_fields))
|
|
294
|
+
all_succeeded_ids.update(_succeeded_result_ids(each_entry_fields))
|
|
295
|
+
return [
|
|
296
|
+
TranscriptToolCall(each_name, each_input, each_id is not None and each_id in all_succeeded_ids)
|
|
297
|
+
for each_id, each_name, each_input in all_calls
|
|
298
|
+
]
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ from dev_env_scripts_constants.private_term_constants import (
|
|
|
18
18
|
PRIVATE_TERM_MESSAGE_TEMPLATE,
|
|
19
19
|
)
|
|
20
20
|
from durable_post_lint_config.config.constants import (
|
|
21
|
+
ACTION_GITHUB_MCP_COMMENT,
|
|
21
22
|
ACTION_PR_CREATE,
|
|
22
23
|
ACTION_PR_EDIT,
|
|
23
24
|
ALL_BARE_VOLATILE_PATH_MARKERS,
|
|
@@ -33,6 +34,7 @@ from durable_post_lint_config.config.constants import (
|
|
|
33
34
|
BODY_MUST_NOT_BE_EMPTY_MESSAGE,
|
|
34
35
|
BODY_REQUIRED_MESSAGE,
|
|
35
36
|
CLEAN_EXIT_CODE,
|
|
37
|
+
COMMENT_LENGTH_FINDING_CODE,
|
|
36
38
|
CONTENT_FINDING_EXIT_CODE,
|
|
37
39
|
CONVENTIONAL_TITLE_PATTERN,
|
|
38
40
|
EDIT_INPUT_REQUIRED_MESSAGE,
|
|
@@ -45,6 +47,8 @@ from durable_post_lint_config.config.constants import (
|
|
|
45
47
|
MISSING_HEADING_MESSAGE_TEMPLATE,
|
|
46
48
|
PATH_ANCHOR_CHARACTER,
|
|
47
49
|
PATH_SEGMENT_START_CHARACTERS,
|
|
50
|
+
PROOF_GATE_CREATE_TOOL_NAME,
|
|
51
|
+
PULL_REQUEST_PROOF_FINDING_CODE,
|
|
48
52
|
RELEASE_BRANCH_PREFIX,
|
|
49
53
|
REPOSITORY_OWNER_SEPARATOR,
|
|
50
54
|
REWRITTEN_RELEASE_BODY_CODE,
|
|
@@ -56,6 +60,17 @@ from durable_post_lint_config.config.constants import (
|
|
|
56
60
|
)
|
|
57
61
|
from private_terms import private_term_line_numbers
|
|
58
62
|
|
|
63
|
+
_hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
|
|
64
|
+
if _hooks_directory not in sys.path:
|
|
65
|
+
sys.path.insert(0, _hooks_directory)
|
|
66
|
+
|
|
67
|
+
from blocking.pull_request_proof import (
|
|
68
|
+
missing_eval_section_reason,
|
|
69
|
+
missing_existing_work_reason,
|
|
70
|
+
missing_proof_reason,
|
|
71
|
+
)
|
|
72
|
+
from blocking.reply_length_gate import comment_length_violation
|
|
73
|
+
|
|
59
74
|
|
|
60
75
|
class DurablePostUsageError(ValueError):
|
|
61
76
|
"""Report an invalid GitHub post request shape."""
|
|
@@ -145,6 +160,32 @@ def _description_heading_findings(body_text: str) -> list[DurablePostFinding]:
|
|
|
145
160
|
]
|
|
146
161
|
|
|
147
162
|
|
|
163
|
+
def _pull_request_proof_findings(
|
|
164
|
+
title: str | None, body_text: str
|
|
165
|
+
) -> list[DurablePostFinding]:
|
|
166
|
+
create_payload = {
|
|
167
|
+
"tool_name": PROOF_GATE_CREATE_TOOL_NAME,
|
|
168
|
+
"tool_input": {"title": title, "body": body_text},
|
|
169
|
+
}
|
|
170
|
+
all_reasons = (
|
|
171
|
+
missing_proof_reason(create_payload),
|
|
172
|
+
missing_existing_work_reason(create_payload),
|
|
173
|
+
missing_eval_section_reason(create_payload),
|
|
174
|
+
)
|
|
175
|
+
return [
|
|
176
|
+
DurablePostFinding(code=PULL_REQUEST_PROOF_FINDING_CODE, message=each_reason)
|
|
177
|
+
for each_reason in all_reasons
|
|
178
|
+
if each_reason is not None
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _comment_length_findings(body_text: str) -> list[DurablePostFinding]:
|
|
183
|
+
violation = comment_length_violation(body_text)
|
|
184
|
+
if violation is None:
|
|
185
|
+
return []
|
|
186
|
+
return [DurablePostFinding(code=COMMENT_LENGTH_FINDING_CODE, message=violation)]
|
|
187
|
+
|
|
188
|
+
|
|
148
189
|
def _contrast_framing_findings(text: str) -> list[DurablePostFinding]:
|
|
149
190
|
all_lines = text.splitlines()
|
|
150
191
|
return [
|
|
@@ -210,6 +251,7 @@ def _body_findings(
|
|
|
210
251
|
action: str,
|
|
211
252
|
body_text: str | None,
|
|
212
253
|
head_branch: str | None = None,
|
|
254
|
+
title: str | None = None,
|
|
213
255
|
) -> list[DurablePostFinding]:
|
|
214
256
|
if body_text is None:
|
|
215
257
|
return []
|
|
@@ -226,6 +268,10 @@ def _body_findings(
|
|
|
226
268
|
else:
|
|
227
269
|
if action in ALL_PR_DESCRIPTION_ACTIONS:
|
|
228
270
|
all_findings.extend(_description_heading_findings(body_text))
|
|
271
|
+
if action == ACTION_PR_CREATE:
|
|
272
|
+
all_findings.extend(_pull_request_proof_findings(title, body_text))
|
|
273
|
+
if action == ACTION_GITHUB_MCP_COMMENT:
|
|
274
|
+
all_findings.extend(_comment_length_findings(body_text))
|
|
229
275
|
all_findings.extend(_contrast_framing_findings(body_text))
|
|
230
276
|
if find_volatile_path_marker(body_text) is not None:
|
|
231
277
|
all_findings.append(
|
|
@@ -263,7 +309,7 @@ def lint_durable_post(
|
|
|
263
309
|
return (
|
|
264
310
|
*_title_findings(title),
|
|
265
311
|
*_private_term_findings(title, repository),
|
|
266
|
-
*_body_findings(action, body_text, head_branch),
|
|
312
|
+
*_body_findings(action, body_text, head_branch, title),
|
|
267
313
|
*_private_term_findings(body_text, repository),
|
|
268
314
|
)
|
|
269
315
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
|
+
ACTION_GITHUB_MCP_COMMENT = "github-mcp-comment"
|
|
5
6
|
ACTION_GITHUB_MCP_POST = "github-mcp-post"
|
|
6
7
|
ACTION_ISSUE_COMMENT = "issue-comment"
|
|
7
8
|
ACTION_ISSUE_CREATE = "issue-create"
|
|
@@ -13,6 +14,7 @@ ACTION_PR_REVIEW = "pr-review"
|
|
|
13
14
|
|
|
14
15
|
ALL_POST_ACTIONS: frozenset[str] = frozenset(
|
|
15
16
|
{
|
|
17
|
+
ACTION_GITHUB_MCP_COMMENT,
|
|
16
18
|
ACTION_GITHUB_MCP_POST,
|
|
17
19
|
ACTION_ISSUE_COMMENT,
|
|
18
20
|
ACTION_ISSUE_CREATE,
|
|
@@ -25,6 +27,7 @@ ALL_POST_ACTIONS: frozenset[str] = frozenset(
|
|
|
25
27
|
)
|
|
26
28
|
ALL_BODY_REQUIRED_ACTIONS: frozenset[str] = frozenset(
|
|
27
29
|
{
|
|
30
|
+
ACTION_GITHUB_MCP_COMMENT,
|
|
28
31
|
ACTION_GITHUB_MCP_POST,
|
|
29
32
|
ACTION_ISSUE_COMMENT,
|
|
30
33
|
ACTION_ISSUE_CREATE,
|
|
@@ -102,3 +105,6 @@ REWRITTEN_RELEASE_BODY_MESSAGE = (
|
|
|
102
105
|
"keep the generated text whole"
|
|
103
106
|
)
|
|
104
107
|
REPOSITORY_OWNER_SEPARATOR = "/"
|
|
108
|
+
PROOF_GATE_CREATE_TOOL_NAME = "mcp__github__create_pull_request"
|
|
109
|
+
PULL_REQUEST_PROOF_FINDING_CODE = "pull-request-proof-section"
|
|
110
|
+
COMMENT_LENGTH_FINDING_CODE = "github-comment-length"
|
|
@@ -144,6 +144,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
144
144
|
hooks/blocking/open_tasks_stop_gate.py -> exempt
|
|
145
145
|
hooks/blocking/owned_pr_merge_stop_gate.py -> exempt
|
|
146
146
|
hooks/blocking/session_title_turn_gate.py -> exempt
|
|
147
|
+
hooks/blocking/resolve_thread_reply_gate.py -> exempt
|
|
147
148
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
148
149
|
|
|
149
150
|
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
@@ -202,6 +203,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
202
203
|
closing reply names no ``Blocked:`` line for it. It decides when a turn may
|
|
203
204
|
end and no code or safety policy.
|
|
204
205
|
|
|
206
|
+
The resolve reply gate denies a review thread resolve until a reply on the
|
|
207
|
+
thread has posted. It decides the order of two review calls and no code or
|
|
208
|
+
safety policy.
|
|
209
|
+
|
|
205
210
|
Args:
|
|
206
211
|
registered_string: One command, path, script, or entrypoint string.
|
|
207
212
|
"""
|
|
@@ -83,6 +83,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
83
83
|
"blocking/open_tasks_stop_gate.py",
|
|
84
84
|
"blocking/owned_pr_merge_stop_gate.py",
|
|
85
85
|
"blocking/session_title_turn_gate.py",
|
|
86
|
+
"blocking/resolve_thread_reply_gate.py",
|
|
86
87
|
)
|
|
87
88
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
88
89
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
@@ -11,6 +11,11 @@ import pytest
|
|
|
11
11
|
|
|
12
12
|
import durable_post_lint
|
|
13
13
|
from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
|
|
14
|
+
from hooks_constants.pull_request_proof_constants import (
|
|
15
|
+
MISSING_EVAL_SECTION_REASON,
|
|
16
|
+
MISSING_EXISTING_WORK_REASON,
|
|
17
|
+
TEST_ONLY_PROOF_REASON,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
SCRIPT_PATH = Path(__file__).resolve().parent / "durable_post_lint.py"
|
|
16
21
|
VALID_PR_BODY = """## Why
|
|
@@ -32,7 +37,25 @@ Blast radius text.
|
|
|
32
37
|
## Verification
|
|
33
38
|
|
|
34
39
|
Verification text.
|
|
40
|
+
|
|
41
|
+
## Existing work
|
|
42
|
+
|
|
43
|
+
`scripts/durable_post_lint.py:1` holds the post checks.
|
|
44
|
+
|
|
45
|
+
## Proof in practice
|
|
46
|
+
|
|
47
|
+
Ran `python scripts/durable_post_lint.py --action pr-create --body-file body.md` on the new body.
|
|
48
|
+
|
|
49
|
+
## Eval
|
|
50
|
+
|
|
51
|
+
`python scripts/durable_post_lint.py --action pr-create --body-file eval_body.md`
|
|
35
52
|
"""
|
|
53
|
+
OVER_LONG_REPLY_BODY = (
|
|
54
|
+
"Fixed in `abc1234`.\n\n"
|
|
55
|
+
"The parser now rejects an empty header.\n\n"
|
|
56
|
+
"The test covers both cases.\n\n"
|
|
57
|
+
"---\nPosted by the review agent."
|
|
58
|
+
)
|
|
36
59
|
MINIMAL_PR_BODY = """## Why
|
|
37
60
|
|
|
38
61
|
Why text.
|
|
@@ -54,6 +77,7 @@ Verification text.
|
|
|
54
77
|
("issue-edit", None, "Updated issue body."),
|
|
55
78
|
("issue-comment", None, "Issue comment."),
|
|
56
79
|
("github-mcp-post", None, "MCP body."),
|
|
80
|
+
("github-mcp-comment", None, "Reply body."),
|
|
57
81
|
],
|
|
58
82
|
)
|
|
59
83
|
def test_all_actions_accept_valid_inputs(
|
|
@@ -112,6 +136,7 @@ def test_pr_edit_requires_one_edit_field() -> None:
|
|
|
112
136
|
"issue-edit",
|
|
113
137
|
"issue-comment",
|
|
114
138
|
"github-mcp-post",
|
|
139
|
+
"github-mcp-comment",
|
|
115
140
|
],
|
|
116
141
|
)
|
|
117
142
|
def test_body_actions_require_body(action: str) -> None:
|
|
@@ -176,10 +201,9 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
|
|
|
176
201
|
]
|
|
177
202
|
|
|
178
203
|
|
|
179
|
-
|
|
180
|
-
def should_accept_a_body_carrying_only_the_two_required_headings(action: str) -> None:
|
|
204
|
+
def should_accept_an_edit_body_carrying_only_the_two_required_headings() -> None:
|
|
181
205
|
all_findings = durable_post_lint.lint_durable_post(
|
|
182
|
-
action=
|
|
206
|
+
action="pr-edit",
|
|
183
207
|
title="fix(cli): tighten input handling",
|
|
184
208
|
body_text=MINIMAL_PR_BODY,
|
|
185
209
|
)
|
|
@@ -201,6 +225,155 @@ def should_still_accept_a_body_published_under_the_superseded_heading_set() -> N
|
|
|
201
225
|
assert all_findings == ()
|
|
202
226
|
|
|
203
227
|
|
|
228
|
+
def should_report_a_create_body_with_no_existing_work_section() -> None:
|
|
229
|
+
body_text = VALID_PR_BODY.replace(
|
|
230
|
+
"## Existing work\n\n`scripts/durable_post_lint.py:1` holds the post checks.\n\n", ""
|
|
231
|
+
)
|
|
232
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
233
|
+
action="pr-create",
|
|
234
|
+
title="feat: add durable post linting",
|
|
235
|
+
body_text=body_text,
|
|
236
|
+
)
|
|
237
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
238
|
+
MISSING_EXISTING_WORK_REASON
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def should_report_a_create_body_whose_proof_runs_only_tests() -> None:
|
|
243
|
+
body_text = VALID_PR_BODY.replace(
|
|
244
|
+
"Ran `python scripts/durable_post_lint.py --action pr-create --body-file body.md` on the new body.",
|
|
245
|
+
"Ran `python -m pytest scripts`.",
|
|
246
|
+
)
|
|
247
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
248
|
+
action="pr-create",
|
|
249
|
+
title="fix: tighten durable post linting",
|
|
250
|
+
body_text=body_text,
|
|
251
|
+
)
|
|
252
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
253
|
+
TEST_ONLY_PROOF_REASON
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def should_report_a_feature_create_body_with_no_eval_section() -> None:
|
|
258
|
+
body_text = VALID_PR_BODY.split("## Eval")[0]
|
|
259
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
260
|
+
action="pr-create",
|
|
261
|
+
title="feat: add durable post linting",
|
|
262
|
+
body_text=body_text,
|
|
263
|
+
)
|
|
264
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
265
|
+
MISSING_EVAL_SECTION_REASON
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def should_leave_an_edit_body_with_no_existing_work_section_clean() -> None:
|
|
270
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
271
|
+
action="pr-edit",
|
|
272
|
+
title=None,
|
|
273
|
+
body_text=MINIMAL_PR_BODY,
|
|
274
|
+
)
|
|
275
|
+
assert all_findings == ()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def should_exit_with_findings_for_a_create_body_with_no_existing_work_section(
|
|
279
|
+
tmp_path: Path,
|
|
280
|
+
) -> None:
|
|
281
|
+
body_file = tmp_path / "body.md"
|
|
282
|
+
body_file.write_text(
|
|
283
|
+
VALID_PR_BODY.replace("## Existing work", "## Prior art"), encoding="utf-8"
|
|
284
|
+
)
|
|
285
|
+
completed_process = subprocess.run(
|
|
286
|
+
[
|
|
287
|
+
sys.executable,
|
|
288
|
+
str(SCRIPT_PATH),
|
|
289
|
+
"--action",
|
|
290
|
+
"pr-create",
|
|
291
|
+
"--title",
|
|
292
|
+
"feat: add durable post linting",
|
|
293
|
+
"--body-file",
|
|
294
|
+
str(body_file),
|
|
295
|
+
],
|
|
296
|
+
check=False,
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
)
|
|
300
|
+
assert completed_process.returncode == 1
|
|
301
|
+
assert completed_process.stderr == f"{MISSING_EXISTING_WORK_REASON}\n"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def should_report_a_review_reply_over_three_sentences() -> None:
|
|
305
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
306
|
+
action="github-mcp-comment",
|
|
307
|
+
title=None,
|
|
308
|
+
body_text=OVER_LONG_REPLY_BODY,
|
|
309
|
+
)
|
|
310
|
+
assert [each_finding.code for each_finding in all_findings] == [
|
|
311
|
+
"github-comment-length"
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def should_report_a_review_reply_sentence_over_fifteen_words() -> None:
|
|
316
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
317
|
+
action="github-mcp-comment",
|
|
318
|
+
title=None,
|
|
319
|
+
body_text=(
|
|
320
|
+
"This change moves the parser into its own module and keeps every"
|
|
321
|
+
" caller on the same import path."
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
assert [each_finding.code for each_finding in all_findings] == [
|
|
325
|
+
"github-comment-length"
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def should_accept_a_short_review_reply() -> None:
|
|
330
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
331
|
+
action="github-mcp-comment",
|
|
332
|
+
title=None,
|
|
333
|
+
body_text="Fixed in `abc1234`.\n\nThe parser now rejects an empty header.",
|
|
334
|
+
)
|
|
335
|
+
assert all_findings == ()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def should_exit_with_findings_for_an_over_long_review_reply(tmp_path: Path) -> None:
|
|
339
|
+
body_file = tmp_path / "reply.md"
|
|
340
|
+
body_file.write_text(OVER_LONG_REPLY_BODY, encoding="utf-8")
|
|
341
|
+
completed_process = subprocess.run(
|
|
342
|
+
[
|
|
343
|
+
sys.executable,
|
|
344
|
+
str(SCRIPT_PATH),
|
|
345
|
+
"--action",
|
|
346
|
+
"github-mcp-comment",
|
|
347
|
+
"--body-file",
|
|
348
|
+
str(body_file),
|
|
349
|
+
],
|
|
350
|
+
check=False,
|
|
351
|
+
capture_output=True,
|
|
352
|
+
text=True,
|
|
353
|
+
)
|
|
354
|
+
assert completed_process.returncode == 1
|
|
355
|
+
assert "at most three short sentences" in completed_process.stderr
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def should_exit_clean_for_a_short_review_reply(tmp_path: Path) -> None:
|
|
359
|
+
body_file = tmp_path / "reply.md"
|
|
360
|
+
body_file.write_text("Fixed in `abc1234`.\n", encoding="utf-8")
|
|
361
|
+
completed_process = subprocess.run(
|
|
362
|
+
[
|
|
363
|
+
sys.executable,
|
|
364
|
+
str(SCRIPT_PATH),
|
|
365
|
+
"--action",
|
|
366
|
+
"github-mcp-comment",
|
|
367
|
+
"--body-file",
|
|
368
|
+
str(body_file),
|
|
369
|
+
],
|
|
370
|
+
check=False,
|
|
371
|
+
capture_output=True,
|
|
372
|
+
text=True,
|
|
373
|
+
)
|
|
374
|
+
assert completed_process.returncode == 0
|
|
375
|
+
|
|
376
|
+
|
|
204
377
|
def test_non_pr_body_needs_no_pr_headings() -> None:
|
|
205
378
|
all_findings = durable_post_lint.lint_durable_post(
|
|
206
379
|
action="issue-comment",
|
|
@@ -58,6 +58,14 @@ def test_pull_request_lifecycle_gate_registration_is_clean(tmp_path: Path) -> No
|
|
|
58
58
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def test_new_resolve_thread_reply_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
62
|
+
current_document = _hook_document(
|
|
63
|
+
["hooks/blocking/resolve_thread_reply_gate.py"],
|
|
64
|
+
'{"hooks": {}}',
|
|
65
|
+
)
|
|
66
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
67
|
+
|
|
68
|
+
|
|
61
69
|
def test_new_merge_offer_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
62
70
|
current_document = _hook_document(
|
|
63
71
|
["hooks/blocking/merge_over_review_offer_gate.py"],
|