claude-dev-env 8.83.0 → 8.83.2
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/docs/account-broker.md +1 -1
- package/docs/rule-guides/agent-board.md +27 -0
- package/docs/rule-guides/features-start-with-an-eval.md +4 -2
- package/docs/rule-guides/proof-before-pull-request.md +1 -1
- package/hooks/blocking/build_eval_before_eval_write_gate.py +102 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +42 -16
- package/hooks/blocking/pull_request_proof.py +111 -2
- package/hooks/blocking/reply_length_gate.py +69 -13
- package/hooks/blocking/test_build_eval_before_eval_write_gate.py +162 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +25 -0
- package/hooks/blocking/test_pull_request_proof.py +115 -0
- package/hooks/blocking/test_reply_length_gate.py +98 -10
- package/hooks/context_budget/tests/test_model.py +65 -0
- package/hooks/features/conduct-gates.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/build_eval_before_eval_write_gate_constants.py +19 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +38 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +14 -0
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +8 -0
- package/hooks/test_transcript_skill_scan.py +6 -2
- package/hooks/transcript_skill_scan.py +18 -7
- package/package.json +1 -1
- package/rules/agent-board.md +9 -0
- package/rules/index.md +1 -1
- package/scripts/Sync-RepoMain.ps1 +30 -1
- package/scripts/account_broker.py +1 -0
- package/scripts/account_broker_support.py +71 -24
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +11 -3
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +15 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +19 -1
- package/scripts/test_account_broker_support.py +161 -3
- package/scripts/tests/Sync-RepoMain.Tests.ps1 +25 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/scripts/tests/test_sync_repo_main_run_lock.py +168 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Behavior tests for the eval write build-eval gate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
from blocking import build_eval_before_eval_write_gate as gate
|
|
13
|
+
from hooks_constants.build_eval_before_eval_write_gate_constants import (
|
|
14
|
+
MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON,
|
|
15
|
+
UNREADABLE_TRANSCRIPT_BEFORE_EVAL_WRITE_REASON,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
GATE_SCRIPT = Path(gate.__file__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _transcript(tmp_path: Path, build_eval: bool) -> Path:
|
|
22
|
+
lines = [json.dumps({"type": "user", "message": {"content": "Add a search flag."}})]
|
|
23
|
+
if build_eval:
|
|
24
|
+
skill_call = {
|
|
25
|
+
"type": "tool_use",
|
|
26
|
+
"name": "Skill",
|
|
27
|
+
"input": {"skill": "claude-api", "args": "build-eval"},
|
|
28
|
+
}
|
|
29
|
+
lines.append(json.dumps({"type": "assistant", "message": {"content": [skill_call]}}))
|
|
30
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
31
|
+
transcript_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
32
|
+
return transcript_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _payload(file_path: str, transcript_path: Path, tool_name: str = "Write") -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"tool_name": tool_name,
|
|
38
|
+
"tool_input": {"file_path": file_path, "content": "[]"},
|
|
39
|
+
"transcript_path": str(transcript_path),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reason(decision: dict[str, object] | None) -> object:
|
|
44
|
+
assert decision is not None
|
|
45
|
+
return decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_eval_case_write_without_build_eval_should_be_denied(tmp_path: Path) -> None:
|
|
49
|
+
decision = gate.decision_for(
|
|
50
|
+
_payload("/repo/evals/cases.json", _transcript(tmp_path, build_eval=False))
|
|
51
|
+
)
|
|
52
|
+
assert _reason(decision) == MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_eval_write_after_build_eval_should_pass(tmp_path: Path) -> None:
|
|
56
|
+
assert (
|
|
57
|
+
gate.decision_for(
|
|
58
|
+
_payload("/repo/evals/cases.json", _transcript(tmp_path, build_eval=True))
|
|
59
|
+
)
|
|
60
|
+
is None
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_hillclimb_edit_without_build_eval_should_be_denied(tmp_path: Path) -> None:
|
|
65
|
+
payload = _payload(
|
|
66
|
+
"/repo/.claude/hillclimb/search-flag/run.py",
|
|
67
|
+
_transcript(tmp_path, build_eval=False),
|
|
68
|
+
"Edit",
|
|
69
|
+
)
|
|
70
|
+
assert _reason(gate.decision_for(payload)) == MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_source_write_without_build_eval_should_pass(tmp_path: Path) -> None:
|
|
74
|
+
assert (
|
|
75
|
+
gate.decision_for(_payload("/repo/src/search.py", _transcript(tmp_path, build_eval=False)))
|
|
76
|
+
is None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_eval_write_with_unreadable_transcript_should_be_denied(tmp_path: Path) -> None:
|
|
81
|
+
decision = gate.decision_for(_payload("/repo/evals/cases.json", tmp_path / "missing.jsonl"))
|
|
82
|
+
assert _reason(decision) == UNREADABLE_TRANSCRIPT_BEFORE_EVAL_WRITE_REASON
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_eval_paths_should_be_recognized() -> None:
|
|
86
|
+
assert gate.is_eval_path("evals/cases.json")
|
|
87
|
+
assert gate.is_eval_path("C:\\repo\\eval\\grader.py")
|
|
88
|
+
assert gate.is_eval_path("tools/eval_runner.py")
|
|
89
|
+
assert gate.is_eval_path("tests/search.eval.json")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_notebook_edit_of_an_eval_notebook_should_be_denied(tmp_path: Path) -> None:
|
|
93
|
+
payload = {
|
|
94
|
+
"tool_name": "NotebookEdit",
|
|
95
|
+
"tool_input": {"notebook_path": "/repo/evals/grading.ipynb", "new_source": "x = 1"},
|
|
96
|
+
"transcript_path": str(_transcript(tmp_path, build_eval=False)),
|
|
97
|
+
}
|
|
98
|
+
assert _reason(gate.decision_for(payload)) == MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_multi_edit_of_an_eval_file_should_be_denied(tmp_path: Path) -> None:
|
|
102
|
+
payload = {
|
|
103
|
+
"tool_name": "MultiEdit",
|
|
104
|
+
"tool_input": {
|
|
105
|
+
"file_path": "/repo/evals/cases.json",
|
|
106
|
+
"edits": [{"old_string": "[]", "new_string": "[1]"}],
|
|
107
|
+
},
|
|
108
|
+
"transcript_path": str(_transcript(tmp_path, build_eval=False)),
|
|
109
|
+
}
|
|
110
|
+
assert _reason(gate.decision_for(payload)) == MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_gate_source_files_should_not_be_recognized() -> None:
|
|
114
|
+
assert not gate.is_eval_path("hooks/blocking/build_eval_before_eval_write_gate.py")
|
|
115
|
+
assert not gate.is_eval_path("hooks/blocking/test_build_eval_before_eval_write_gate.py")
|
|
116
|
+
assert not gate.is_eval_path(
|
|
117
|
+
"hooks/hooks_constants/build_eval_before_eval_write_gate_constants.py"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_non_eval_paths_should_not_be_recognized() -> None:
|
|
122
|
+
assert not gate.is_eval_path("src/evaluate_score.py")
|
|
123
|
+
assert not gate.is_eval_path("evals/../src/app.py")
|
|
124
|
+
assert not gate.is_eval_path("docs/rule-guides/features-start-with-an-eval.md")
|
|
125
|
+
assert not gate.is_eval_path("src/retrieval.py")
|
|
126
|
+
assert not gate.is_eval_path("medieval/notes.md")
|
|
127
|
+
assert not gate.is_eval_path("")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_script_should_print_a_deny_for_an_eval_write(tmp_path: Path) -> None:
|
|
131
|
+
payload = _payload("/repo/evals/cases.json", _transcript(tmp_path, build_eval=False))
|
|
132
|
+
completed = subprocess.run(
|
|
133
|
+
[sys.executable, str(GATE_SCRIPT)],
|
|
134
|
+
input=json.dumps(payload),
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
check=False,
|
|
138
|
+
)
|
|
139
|
+
assert completed.returncode == 0
|
|
140
|
+
assert json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_apply_patch_to_an_eval_file_should_be_denied(tmp_path: Path) -> None:
|
|
144
|
+
patch = "*** Begin Patch\n*** Add File: evals/cases.json\n+[]\n*** End Patch\n"
|
|
145
|
+
payload = {
|
|
146
|
+
"tool_name": "apply_patch",
|
|
147
|
+
"tool_input": {"command": patch},
|
|
148
|
+
"cwd": str(tmp_path),
|
|
149
|
+
"transcript_path": str(_transcript(tmp_path, build_eval=False)),
|
|
150
|
+
}
|
|
151
|
+
assert _reason(gate.decision_for(payload)) == MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_unparseable_apply_patch_naming_an_eval_file_should_be_denied(tmp_path: Path) -> None:
|
|
155
|
+
patch = "*** Begin Patch\n*** Add File: evals/cases.json\n+[]\n"
|
|
156
|
+
payload = {
|
|
157
|
+
"tool_name": "apply_patch",
|
|
158
|
+
"tool_input": {"command": patch},
|
|
159
|
+
"cwd": str(tmp_path),
|
|
160
|
+
"transcript_path": str(_transcript(tmp_path, build_eval=False)),
|
|
161
|
+
}
|
|
162
|
+
assert _reason(gate.decision_for(payload)) == MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON
|
|
@@ -354,6 +354,14 @@ def test_feature_pull_request_with_build_eval_invocation_should_pass(tmp_path: P
|
|
|
354
354
|
assert gate.decision_for(_create_payload(tmp_path, "feat: add a gate", transcript_path)) is None
|
|
355
355
|
|
|
356
356
|
|
|
357
|
+
def test_is_build_eval_missing_should_follow_the_transcript(tmp_path: Path) -> None:
|
|
358
|
+
without_call = _transcript_with_lines(tmp_path, "without.jsonl", [SKILL_LOAD_LINE])
|
|
359
|
+
with_call = _transcript_with_lines(tmp_path, "with.jsonl", [BUILD_EVAL_LINE])
|
|
360
|
+
assert gate.is_build_eval_missing({"transcript_path": str(without_call)})
|
|
361
|
+
assert not gate.is_build_eval_missing({"transcript_path": str(with_call)})
|
|
362
|
+
assert not gate.is_build_eval_missing({"transcript_path": str(tmp_path / "missing.jsonl")})
|
|
363
|
+
|
|
364
|
+
|
|
357
365
|
def test_non_feature_pull_request_without_build_eval_invocation_should_pass(tmp_path: Path) -> None:
|
|
358
366
|
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
|
|
359
367
|
assert gate.decision_for(_create_payload(tmp_path, "fix: repair a gate", transcript_path)) is None
|
|
@@ -416,3 +424,20 @@ def test_pull_request_dispatch_without_the_skill_loaded_should_be_denied(tmp_pat
|
|
|
416
424
|
def test_dispatch_that_opens_no_pull_request_should_pass_without_the_skill(tmp_path: Path) -> None:
|
|
417
425
|
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
|
|
418
426
|
assert gate.decision_for(_dispatch_payload(tmp_path, {"work_order": "wo-1"}, transcript_path)) is None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_eval_skill_call_status_should_separate_called_missing_and_unreadable(tmp_path: Path) -> None:
|
|
430
|
+
called_path = tmp_path / "called.jsonl"
|
|
431
|
+
skill_call = {"type": "tool_use", "name": "Skill", "input": {"skill": "claude-api", "args": "build-eval"}}
|
|
432
|
+
called_path.write_text(
|
|
433
|
+
json.dumps({"type": "assistant", "message": {"content": [skill_call]}}) + "\n", encoding="utf-8"
|
|
434
|
+
)
|
|
435
|
+
assert gate.eval_skill_call_status(_payload("git status", called_path)) is True
|
|
436
|
+
assert gate.eval_skill_call_status(_payload("git status", _transcript(tmp_path))) is False
|
|
437
|
+
assert gate.eval_skill_call_status(_payload("git status", tmp_path / "missing.jsonl")) is None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_deny_decision_should_carry_the_reason_as_a_deny() -> None:
|
|
441
|
+
output = gate.deny_decision("Load the skill first.")["hookSpecificOutput"]
|
|
442
|
+
assert output["permissionDecision"] == "deny"
|
|
443
|
+
assert output["permissionDecisionReason"] == "Load the skill first."
|
|
@@ -14,6 +14,7 @@ from hooks_constants.pull_request_proof_constants import (
|
|
|
14
14
|
MISSING_EVAL_SECTION_REASON,
|
|
15
15
|
MISSING_EXISTING_WORK_REASON,
|
|
16
16
|
MISSING_PROOF_REASON,
|
|
17
|
+
TEST_ONLY_PROOF_REASON,
|
|
17
18
|
UNREADABLE_CHANGES_REASON,
|
|
18
19
|
)
|
|
19
20
|
|
|
@@ -79,6 +80,120 @@ def test_gh_create_body_file_without_proof_should_be_denied(tmp_path: Path) -> N
|
|
|
79
80
|
assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def test_proof_whose_only_command_is_a_test_run_should_be_denied() -> None:
|
|
84
|
+
body = "## Proof in practice\nRan `pytest tests/test_x.py`.\n> 4 passed\n"
|
|
85
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_proof_with_a_test_run_and_a_live_command_should_pass() -> None:
|
|
89
|
+
body = "## Proof in practice\nRan `pytest tests/test_x.py` and `python run.py --input a.txt`.\n> denied\n"
|
|
90
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_proof_with_only_a_live_command_should_pass() -> None:
|
|
94
|
+
body = "## Proof in practice\nRan `python run.py --input a.txt`.\n> denied\n"
|
|
95
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_proof_with_several_test_runners_should_be_denied() -> None:
|
|
99
|
+
body = (
|
|
100
|
+
"## Proof in practice\n`python -m pytest -q`, `python3 -m unittest discover`,"
|
|
101
|
+
" `uv run pytest`, `npm test`, `npx vitest run`, `jest`, `go test ./...`, `cargo test`\n"
|
|
102
|
+
)
|
|
103
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_proof_with_a_test_run_and_file_references_should_be_denied() -> None:
|
|
107
|
+
body = "## Proof in practice\nRan `pytest tests/x.py` against `gate.py:12` and `src/x.py`.\n"
|
|
108
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_proof_with_a_test_run_piped_into_tee_should_be_denied() -> None:
|
|
112
|
+
body = "## Proof in practice\nRan `pytest x | tee log`.\n"
|
|
113
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_proof_with_a_test_run_piped_into_tail_should_be_denied() -> None:
|
|
117
|
+
body = "## Proof in practice\nRan `pytest x 2>&1 | tail -5`.\n"
|
|
118
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_proof_with_a_live_script_piped_into_tail_should_pass() -> None:
|
|
122
|
+
body = "## Proof in practice\nRan `pytest x` and `./run.sh --input a.txt | tail -5`.\n"
|
|
123
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_proof_with_a_fenced_live_command_beside_a_test_run_should_pass() -> None:
|
|
127
|
+
body = "## Proof in practice\nRan `pytest`.\n```\n$ python run.py\ndenied\n```\n"
|
|
128
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_proof_with_only_a_fenced_test_run_should_be_denied() -> None:
|
|
132
|
+
body = "## Proof in practice\n```\n$ npm test\n12 passing\n```\n"
|
|
133
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_gh_create_body_file_with_test_only_proof_should_be_denied(tmp_path: Path) -> None:
|
|
137
|
+
(tmp_path / "body.md").write_text("## Proof in practice\nRan `go test ./...`.\n", encoding="utf-8")
|
|
138
|
+
payload = _shell("gh pr create --draft --body-file body.md", tmp_path)
|
|
139
|
+
assert gate.missing_proof_reason(payload) == TEST_ONLY_PROOF_REASON
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_proof_with_only_py_test_should_be_denied() -> None:
|
|
143
|
+
body = "## Proof in practice\nRan `py.test`.\n"
|
|
144
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_proof_with_only_a_fenced_pytest_exe_should_be_denied() -> None:
|
|
148
|
+
body = "## Proof in practice\n```\n$ pytest.exe\n```\n"
|
|
149
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_proof_with_only_a_source_citation_should_pass() -> None:
|
|
153
|
+
body = "## Proof in practice\nSee `src/x.py`.\n"
|
|
154
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_proof_with_unittest_after_an_interpreter_flag_should_be_denied() -> None:
|
|
158
|
+
body = "## Proof in practice\nRan `python -q -m unittest discover`.\n"
|
|
159
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_proof_with_unittest_after_a_valued_interpreter_flag_should_be_denied() -> None:
|
|
163
|
+
body = "## Proof in practice\nRan `python -W default -m unittest`.\n"
|
|
164
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_proof_with_a_script_that_mentions_unittest_should_pass() -> None:
|
|
168
|
+
body = "## Proof in practice\nRan `python script.py -m unittest`.\n"
|
|
169
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_proof_with_a_module_other_than_unittest_should_pass() -> None:
|
|
173
|
+
body = "## Proof in practice\nRan `python -W default -m my_app`.\n"
|
|
174
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_proof_with_a_quoted_test_runner_word_should_be_denied() -> None:
|
|
178
|
+
body = "## Proof in practice\nRan `npm \"test\"` and `cargo \"test\"`.\n"
|
|
179
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_proof_with_a_quoted_unittest_module_should_be_denied() -> None:
|
|
183
|
+
body = "## Proof in practice\nRan `python -m \"unittest\"`.\n"
|
|
184
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_proof_with_coverage_running_unittest_should_be_denied() -> None:
|
|
188
|
+
body = "## Proof in practice\nRan `coverage run -m unittest discover`.\n"
|
|
189
|
+
assert gate.missing_proof_reason(_mcp_create(body)) == TEST_ONLY_PROOF_REASON
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_proof_with_coverage_running_an_application_should_pass() -> None:
|
|
193
|
+
body = "## Proof in practice\nRan `coverage run -m my_app`.\n"
|
|
194
|
+
assert gate.missing_proof_reason(_mcp_create(body)) is None
|
|
195
|
+
|
|
196
|
+
|
|
82
197
|
def test_gh_create_with_fill_should_ask_for_a_body() -> None:
|
|
83
198
|
assert gate.missing_proof_reason(_shell("gh pr create --fill")) == MISSING_BODY_REASON
|
|
84
199
|
|
|
@@ -354,26 +354,114 @@ def test_should_deny_a_decision_card_that_hedges_in_an_option(
|
|
|
354
354
|
assert '"probably"' in stderr_text
|
|
355
355
|
|
|
356
356
|
|
|
357
|
-
def
|
|
357
|
+
def short_card(**all_overrides: object) -> dict[str, object]:
|
|
358
|
+
card: dict[str, object] = {
|
|
359
|
+
"question": "Exclude the call button?",
|
|
360
|
+
"context": "Crops show the call button flagged in 7 themes.",
|
|
361
|
+
"reason": "Seven themes pass with it excluded.",
|
|
362
|
+
"options": [
|
|
363
|
+
{"label": "Exclude it", "consequence": "Seven themes pass."},
|
|
364
|
+
{"label": "Leave it", "consequence": "Seven themes keep a warning."},
|
|
365
|
+
],
|
|
366
|
+
"recommended": 0,
|
|
367
|
+
}
|
|
368
|
+
card.update(all_overrides)
|
|
369
|
+
return card
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_should_allow_a_card_context_of_two_fifteen_word_sentences(
|
|
358
373
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
359
374
|
) -> None:
|
|
360
375
|
exit_code, stderr_text = run_gate(
|
|
361
376
|
monkeypatch,
|
|
362
377
|
capsys,
|
|
363
378
|
DECISION_TOOL_NAME,
|
|
364
|
-
|
|
365
|
-
"question": "Exclude the call button?",
|
|
366
|
-
"context": " ".join([FIFTEEN_WORD_SENTENCE] * 5),
|
|
367
|
-
"options": [
|
|
368
|
-
{"label": "Exclude it", "consequence": SIXTEEN_WORD_SENTENCE},
|
|
369
|
-
{"label": "Leave it", "consequence": "Three themes keep a warning."},
|
|
370
|
-
],
|
|
371
|
-
"recommended": 0,
|
|
372
|
-
},
|
|
379
|
+
short_card(context=" ".join([FIFTEEN_WORD_SENTENCE] * 2)),
|
|
373
380
|
)
|
|
374
381
|
assert (exit_code, stderr_text) == (0, "")
|
|
375
382
|
|
|
376
383
|
|
|
384
|
+
def test_should_deny_a_card_context_of_four_sentences(
|
|
385
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
386
|
+
) -> None:
|
|
387
|
+
exit_code, stderr_text = run_gate(
|
|
388
|
+
monkeypatch,
|
|
389
|
+
capsys,
|
|
390
|
+
DECISION_TOOL_NAME,
|
|
391
|
+
short_card(context="One. Two. Three. Four."),
|
|
392
|
+
)
|
|
393
|
+
assert exit_code == 2
|
|
394
|
+
assert "Card context too long: 4 sentences, limit 2." in stderr_text
|
|
395
|
+
assert "publish an Artifact page with a tap button per item and a Send button" in stderr_text
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@pytest.mark.parametrize(
|
|
399
|
+
"context_text",
|
|
400
|
+
[
|
|
401
|
+
"Three themes fail.\n- `Cedar`\n- `Birch`",
|
|
402
|
+
"Open one.\n- https://github.com/owner/repo/pull/12\n- https://github.com/owner/repo/pull/13",
|
|
403
|
+
],
|
|
404
|
+
)
|
|
405
|
+
def test_should_deny_a_card_context_list_whose_items_are_code_or_urls(
|
|
406
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], context_text: str
|
|
407
|
+
) -> None:
|
|
408
|
+
exit_code, stderr_text = run_gate(
|
|
409
|
+
monkeypatch,
|
|
410
|
+
capsys,
|
|
411
|
+
DECISION_TOOL_NAME,
|
|
412
|
+
short_card(context=context_text),
|
|
413
|
+
)
|
|
414
|
+
assert exit_code == 2
|
|
415
|
+
assert "Card context holds a list of items" in stderr_text
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_should_deny_a_card_context_that_lists_several_items_and_name_the_page_flow(
|
|
419
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
420
|
+
) -> None:
|
|
421
|
+
exit_code, stderr_text = run_gate(
|
|
422
|
+
monkeypatch,
|
|
423
|
+
capsys,
|
|
424
|
+
DECISION_TOOL_NAME,
|
|
425
|
+
short_card(context="Three themes fail.\n- Cedar\n- Birch"),
|
|
426
|
+
)
|
|
427
|
+
assert exit_code == 2
|
|
428
|
+
assert "Card context holds a list of items" in stderr_text
|
|
429
|
+
assert "publish an Artifact page with a tap button per item and a Send button" in stderr_text
|
|
430
|
+
assert "send a short reply that links the page" in stderr_text
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_should_deny_a_long_card_consequence(
|
|
434
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
435
|
+
) -> None:
|
|
436
|
+
exit_code, stderr_text = run_gate(
|
|
437
|
+
monkeypatch,
|
|
438
|
+
capsys,
|
|
439
|
+
DECISION_TOOL_NAME,
|
|
440
|
+
short_card(
|
|
441
|
+
options=[
|
|
442
|
+
{"label": "Exclude it", "consequence": SIXTEEN_WORD_SENTENCE},
|
|
443
|
+
{"label": "Leave it", "consequence": "Seven themes keep a warning."},
|
|
444
|
+
]
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
assert exit_code == 2
|
|
448
|
+
assert "Sentence too long: 16 words, limit 15" in stderr_text
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@pytest.mark.parametrize("card_field_name", ["question", "reason"])
|
|
452
|
+
def test_should_deny_a_long_sentence_in_the_card_question_and_reason(
|
|
453
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], card_field_name: str
|
|
454
|
+
) -> None:
|
|
455
|
+
exit_code, stderr_text = run_gate(
|
|
456
|
+
monkeypatch,
|
|
457
|
+
capsys,
|
|
458
|
+
DECISION_TOOL_NAME,
|
|
459
|
+
short_card(**{card_field_name: SIXTEEN_WORD_SENTENCE}),
|
|
460
|
+
)
|
|
461
|
+
assert exit_code == 2
|
|
462
|
+
assert "Sentence too long: 16 words" in stderr_text
|
|
463
|
+
|
|
464
|
+
|
|
377
465
|
@pytest.mark.parametrize(
|
|
378
466
|
"reply_text",
|
|
379
467
|
["It maybe passed.", "Perhaps it passed.", "It might be the cache.", "I am not sure it passed."],
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for the context budget data shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from context_budget.model import (
|
|
6
|
+
BaselineEntry,
|
|
7
|
+
BudgetFinding,
|
|
8
|
+
BudgetPolicy,
|
|
9
|
+
ContextKind,
|
|
10
|
+
FileMeasure,
|
|
11
|
+
HookBudget,
|
|
12
|
+
SectionScan,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
SKILL_ENTRY_KIND = ContextKind("skill entry", ("**/SKILL.md",), 200, True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_policy_holds_kinds_hooks_and_baselines() -> None:
|
|
19
|
+
hook = HookBudget("startup print", ("python", "hook.py"), "{}", 1500)
|
|
20
|
+
baseline = BaselineEntry(210, ("Plan",))
|
|
21
|
+
|
|
22
|
+
policy = BudgetPolicy(
|
|
23
|
+
section_detail_line_limit=12,
|
|
24
|
+
all_kinds=(SKILL_ENTRY_KIND,),
|
|
25
|
+
all_hooks=(hook,),
|
|
26
|
+
baseline_entry_by_path={"a/SKILL.md": baseline},
|
|
27
|
+
baseline_characters_by_hook_name={"startup print": 5200},
|
|
28
|
+
is_baseline_the_prior_text=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
assert policy.section_detail_line_limit == 12
|
|
32
|
+
assert policy.all_kinds == (SKILL_ENTRY_KIND,)
|
|
33
|
+
assert policy.all_hooks == (hook,)
|
|
34
|
+
assert policy.baseline_entry_by_path["a/SKILL.md"] == baseline
|
|
35
|
+
assert policy.baseline_characters_by_hook_name["startup print"] == 5200
|
|
36
|
+
assert policy.is_baseline_the_prior_text is True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_baseline_entry_holds_no_line_count_for_a_section_only_file() -> None:
|
|
40
|
+
entry = BaselineEntry(None, ("Steps", "Steps"))
|
|
41
|
+
|
|
42
|
+
assert entry.lines is None
|
|
43
|
+
assert entry.all_section_headings == ("Steps", "Steps")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_policy_defaults_are_empty() -> None:
|
|
47
|
+
policy = BudgetPolicy(section_detail_line_limit=12, all_kinds=())
|
|
48
|
+
|
|
49
|
+
assert policy.all_hooks == ()
|
|
50
|
+
assert policy.baseline_entry_by_path == {}
|
|
51
|
+
assert policy.baseline_characters_by_hook_name == {}
|
|
52
|
+
assert policy.is_baseline_the_prior_text is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_measure_and_finding_hold_their_fields() -> None:
|
|
56
|
+
section = SectionScan("Plan", 15, False)
|
|
57
|
+
measure = FileMeasure(SKILL_ENTRY_KIND, 210, (section,))
|
|
58
|
+
finding = BudgetFinding("a/SKILL.md", "Plan", "section exceeds limit")
|
|
59
|
+
|
|
60
|
+
assert measure.kind == SKILL_ENTRY_KIND
|
|
61
|
+
assert measure.line_count == 210
|
|
62
|
+
assert measure.all_over_limit_sections == (section,)
|
|
63
|
+
assert finding.path == "a/SKILL.md"
|
|
64
|
+
assert finding.scope == "Plan"
|
|
65
|
+
assert finding.message == "section exceeds limit"
|
|
@@ -13,6 +13,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
13
13
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
14
14
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
15
15
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
16
|
+
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
16
17
|
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
17
18
|
- `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
|
|
18
19
|
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
@@ -28,6 +29,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
28
29
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
29
30
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
30
31
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
32
|
+
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
31
33
|
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
32
34
|
- `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
|
|
33
35
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
@@ -47,6 +49,7 @@ Preconditions:
|
|
|
47
49
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
48
50
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
49
51
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
52
|
+
- **Eval write.** Input is a Write to `evals/cases.json` with no build-eval call in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_build_eval_before_eval_write_gate.py -q`. The adjacent test observes a denial, then an allow once the call appears; a write outside an eval path passes.
|
|
50
53
|
- **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
|
|
51
54
|
- **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
|
|
52
55
|
- **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
|
|
@@ -59,6 +62,7 @@ Preconditions:
|
|
|
59
62
|
- The final reply gate blocks the stop and returns its reason to the model, which rewrites the reply. Claude Code ends the turn anyway after eight blocks in a row. The cited number checks in both gates skip when the transcript cannot be read.
|
|
60
63
|
- The edit marker gate scans replacement cards as well as the message text.
|
|
61
64
|
- The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
|
|
65
|
+
- The eval write gate reads Write, Edit, MultiEdit, NotebookEdit and apply_patch calls. A Bash or PowerShell command that writes an eval file reaches it without the gate. An apply_patch that will not parse counts as an eval write when any of its words is an eval path. A missing or unreadable transcript denies the write.
|
|
62
66
|
- The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
|
|
63
67
|
- The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
|
|
64
68
|
- The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings, or with every finding ruled out and a rulings link, passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
|
package/hooks/hooks.json
CHANGED
|
@@ -102,6 +102,16 @@
|
|
|
102
102
|
}
|
|
103
103
|
]
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
"matcher": "Write|Edit|MultiEdit|NotebookEdit|apply_patch",
|
|
107
|
+
"hooks": [
|
|
108
|
+
{
|
|
109
|
+
"type": "command",
|
|
110
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/build_eval_before_eval_write_gate.py",
|
|
111
|
+
"timeout": 10
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
105
115
|
{
|
|
106
116
|
"matcher": "Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)",
|
|
107
117
|
"hooks": [
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Constants for the gate that holds eval file writes until /claude-api build-eval runs."""
|
|
2
|
+
|
|
3
|
+
ALL_FILE_WRITE_TOOL_NAMES = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
|
|
4
|
+
ALL_FILE_PATH_FIELDS = ("file_path", "notebook_path")
|
|
5
|
+
APPLY_PATCH_TOOL_NAME = "apply_patch"
|
|
6
|
+
ALL_EVAL_DIRECTORY_NAMES = frozenset({"eval", "evals", "hillclimb"})
|
|
7
|
+
EVAL_FILE_NAME_WORD_PATTERN = r"^evals?(?:[._-]|$)|\.evals?\."
|
|
8
|
+
MISSING_BUILD_EVAL_BEFORE_EVAL_WRITE_REASON = (
|
|
9
|
+
"This file is part of an eval, and this session has not invoked /claude-api"
|
|
10
|
+
" build-eval. An eval starts with that skill. Invoke the Skill tool with skill"
|
|
11
|
+
" 'claude-api' and args 'build-eval', follow the guide it loads, then write this"
|
|
12
|
+
" file again. Read ~/.claude/docs/rule-guides/features-start-with-an-eval.md for the rule."
|
|
13
|
+
)
|
|
14
|
+
UNREADABLE_TRANSCRIPT_BEFORE_EVAL_WRITE_REASON = (
|
|
15
|
+
"This file is part of an eval, and this hook could not read a session transcript"
|
|
16
|
+
" from the payload's transcript_path or agent_transcript_path field. The write is"
|
|
17
|
+
" held because no /claude-api build-eval call can be confirmed. A retry clears only"
|
|
18
|
+
" once the hook receives a readable transcript."
|
|
19
|
+
)
|
|
@@ -24,6 +24,44 @@ MISSING_PROOF_REASON = (
|
|
|
24
24
|
" prove the change, leave the pull request unopened and tell the user what you could"
|
|
25
25
|
" not run. Read " + PROOF_GUIDE_PATH + " for the full rule."
|
|
26
26
|
)
|
|
27
|
+
INLINE_COMMAND_PATTERN = r"(?<!`)`([^`\n]+)`(?!`)"
|
|
28
|
+
CODE_FENCE_MARKER = "```"
|
|
29
|
+
FENCED_COMMAND_PROMPT = "$ "
|
|
30
|
+
UNITTEST_MODULE_NAME = "unittest"
|
|
31
|
+
PATH_WITH_LINE_PATTERN = r"^\S+:\d+(?:[-:]\d+)?$"
|
|
32
|
+
SINGLE_FILE_NAME_PATTERN = r"^\S*[/\\]\S*$|^[\w.-]+\.[A-Za-z0-9]+$"
|
|
33
|
+
ALL_PASSTHROUGH_PROGRAM_NAMES = frozenset({"tee", "tail", "head", "grep", "cat"})
|
|
34
|
+
ALL_TEST_RUNNER_COMMAND_PREFIXES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
("jest",),
|
|
37
|
+
("vitest",),
|
|
38
|
+
("mocha",),
|
|
39
|
+
("npm", "test"),
|
|
40
|
+
("npm", "t"),
|
|
41
|
+
("npm", "run", "test"),
|
|
42
|
+
("yarn", "test"),
|
|
43
|
+
("yarn", "run", "test"),
|
|
44
|
+
("pnpm", "test"),
|
|
45
|
+
("pnpm", "run", "test"),
|
|
46
|
+
("bun", "test"),
|
|
47
|
+
("npx", "jest"),
|
|
48
|
+
("npx", "vitest"),
|
|
49
|
+
("npx", "mocha"),
|
|
50
|
+
("go", "test"),
|
|
51
|
+
("cargo", "test"),
|
|
52
|
+
("deno", "test"),
|
|
53
|
+
("dotnet", "test"),
|
|
54
|
+
("unittest",),
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
UNIT_TESTS_GUIDE_LINE = "Unit tests are welcome as extra evidence. The live run is the proof."
|
|
58
|
+
TEST_ONLY_PROOF_REASON = (
|
|
59
|
+
"Every command under this pull request's 'Proof in practice' heading runs a test"
|
|
60
|
+
" suite. The proof rule says: \"" + UNIT_TESTS_GUIDE_LINE + "\" Run the changed thing"
|
|
61
|
+
" the way its user runs it, once without the change and once with it. List each"
|
|
62
|
+
" command in backticks and quote the output lines that show the difference. Keep the"
|
|
63
|
+
" test run in the section as extra evidence. Read " + PROOF_GUIDE_PATH + " for the full rule."
|
|
64
|
+
)
|
|
27
65
|
EXISTING_WORK_HEADING_PATTERN = r"^(#{1,6})[ \t]+existing work[ \t]*#*[ \t]*$"
|
|
28
66
|
EXISTING_WORK_GUIDE_PATH = "~/.claude/docs/rule-guides/explore-thoroughly.md"
|
|
29
67
|
MISSING_EXISTING_WORK_REASON = (
|