claude-dev-env 1.72.0 → 1.74.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 +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Native-equivalence tests for the nativized PreToolUse hosted hooks.
|
|
2
|
+
|
|
3
|
+
For each hook the dispatcher runs natively (state_description_blocker and
|
|
4
|
+
plain_language_blocker), this suite asserts the native evaluate() call and the
|
|
5
|
+
hook's standalone __main__ subprocess path decide identically on the same
|
|
6
|
+
payload: same allow-or-deny, same deny-reason text. It also asserts the
|
|
7
|
+
dispatcher reaches the same decision through its native path.
|
|
8
|
+
|
|
9
|
+
The corpus pairs allowing payloads with denying payloads for each hook so the
|
|
10
|
+
equivalence holds across both outcomes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
21
|
+
if _HOOKS_DIR not in sys.path:
|
|
22
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
23
|
+
|
|
24
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
25
|
+
DENY_DECISION,
|
|
26
|
+
EDIT_TOOL_NAME,
|
|
27
|
+
MULTI_EDIT_TOOL_NAME,
|
|
28
|
+
WRITE_TOOL_NAME,
|
|
29
|
+
)
|
|
30
|
+
import plain_language_blocker # noqa: E402, I001
|
|
31
|
+
import state_description_blocker # noqa: E402, I001
|
|
32
|
+
|
|
33
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
34
|
+
_STATE_DESCRIPTION_SCRIPT = str(_BLOCKING_DIR / "state_description_blocker.py")
|
|
35
|
+
_PLAIN_LANGUAGE_SCRIPT = str(_BLOCKING_DIR / "plain_language_blocker.py")
|
|
36
|
+
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
|
|
37
|
+
|
|
38
|
+
_MARKDOWN_PATH = "docs/native_equivalence_probe.md"
|
|
39
|
+
_PYTHON_PATH = "src/native_equivalence_probe.py"
|
|
40
|
+
|
|
41
|
+
_STATE_DESCRIPTION_ALLOW_CONTENT = "# Guide\n\nThe API uses port 8080.\n"
|
|
42
|
+
_STATE_DESCRIPTION_DENY_CONTENT = "# Guide\n\nPreviously the system used port 8080.\n"
|
|
43
|
+
_PLAIN_LANGUAGE_ALLOW_CONTENT = "# Guide\n\nStart the build to make the report.\n"
|
|
44
|
+
_PLAIN_LANGUAGE_DENY_CONTENT = "# Guide\n\nUtilize this to commence the process.\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_payload_dictionary(file_path: str, content: str) -> dict[str, object]:
|
|
48
|
+
"""Build a Write tool payload dict.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
file_path: The target file path.
|
|
52
|
+
content: The file content to write.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The PreToolUse payload dict for a Write tool call.
|
|
56
|
+
"""
|
|
57
|
+
return {
|
|
58
|
+
"tool_name": WRITE_TOOL_NAME,
|
|
59
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, object]:
|
|
64
|
+
"""Build an Edit tool payload dict.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
file_path: The target file path.
|
|
68
|
+
new_string: The replacement text.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The PreToolUse payload dict for an Edit tool call.
|
|
72
|
+
"""
|
|
73
|
+
return {
|
|
74
|
+
"tool_name": EDIT_TOOL_NAME,
|
|
75
|
+
"tool_input": {
|
|
76
|
+
"file_path": file_path,
|
|
77
|
+
"old_string": "old line",
|
|
78
|
+
"new_string": new_string,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _multi_edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, object]:
|
|
84
|
+
"""Build a MultiEdit tool payload dict.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
file_path: The target file path.
|
|
88
|
+
new_string: The replacement text for the single edit.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The PreToolUse payload dict for a MultiEdit tool call.
|
|
92
|
+
"""
|
|
93
|
+
return {
|
|
94
|
+
"tool_name": MULTI_EDIT_TOOL_NAME,
|
|
95
|
+
"tool_input": {
|
|
96
|
+
"file_path": file_path,
|
|
97
|
+
"edits": [{"old_string": "old line", "new_string": new_string}],
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run_script_subprocess(script_path: str, payload_dictionary: dict[str, object]) -> str:
|
|
103
|
+
"""Run a hook script as a subprocess and return its stripped stdout.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
script_path: Absolute path of the hook script to run.
|
|
107
|
+
payload_dictionary: The payload dict to send as JSON on stdin.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The hook's stdout text, stripped of surrounding whitespace.
|
|
111
|
+
"""
|
|
112
|
+
completed_process = subprocess.run(
|
|
113
|
+
[sys.executable, script_path],
|
|
114
|
+
check=False,
|
|
115
|
+
input=json.dumps(payload_dictionary),
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
encoding="utf-8",
|
|
119
|
+
)
|
|
120
|
+
return completed_process.stdout.strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _deny_reason_from_script_stdout(stdout_text: str) -> str | None:
|
|
124
|
+
"""Parse a script's stdout into its deny-reason text, or None for allow.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
stdout_text: The script's stripped stdout.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The permissionDecisionReason text when the script denied, or None when
|
|
131
|
+
the script produced no deny output.
|
|
132
|
+
"""
|
|
133
|
+
if not stdout_text:
|
|
134
|
+
return None
|
|
135
|
+
parsed_output = json.loads(stdout_text)
|
|
136
|
+
hook_specific = parsed_output.get("hookSpecificOutput", {})
|
|
137
|
+
if hook_specific.get("permissionDecision") != DENY_DECISION:
|
|
138
|
+
return None
|
|
139
|
+
reason_text = hook_specific.get("permissionDecisionReason", "")
|
|
140
|
+
return reason_text if isinstance(reason_text, str) else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _deny_reason_from_dispatcher(payload_dictionary: dict[str, object]) -> str | None:
|
|
144
|
+
"""Run the dispatcher as a subprocess and return its deny-reason text.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
payload_dictionary: The payload dict to send as JSON on stdin.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The dispatcher's combined permissionDecisionReason when it denies, or
|
|
151
|
+
None when it allows.
|
|
152
|
+
"""
|
|
153
|
+
completed_process = subprocess.run(
|
|
154
|
+
[sys.executable, _DISPATCHER_SCRIPT],
|
|
155
|
+
check=False,
|
|
156
|
+
input=json.dumps(payload_dictionary),
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
)
|
|
161
|
+
return _deny_reason_from_script_stdout(completed_process.stdout.strip())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _deny_payload_from_dispatcher(payload_dictionary: dict[str, object]) -> dict[str, object]:
|
|
165
|
+
"""Run the dispatcher as a subprocess and return its parsed deny payload.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
payload_dictionary: The payload dict to send as JSON on stdin.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The dispatcher's emitted deny JSON parsed into a dict.
|
|
172
|
+
"""
|
|
173
|
+
completed_process = subprocess.run(
|
|
174
|
+
[sys.executable, _DISPATCHER_SCRIPT],
|
|
175
|
+
check=False,
|
|
176
|
+
input=json.dumps(payload_dictionary),
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
encoding="utf-8",
|
|
180
|
+
)
|
|
181
|
+
parsed_payload = json.loads(completed_process.stdout.strip())
|
|
182
|
+
assert isinstance(parsed_payload, dict)
|
|
183
|
+
return parsed_payload
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_state_description_native_allows_match_script() -> None:
|
|
187
|
+
"""state_description_blocker native allow matches the script's allow."""
|
|
188
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_ALLOW_CONTENT)
|
|
189
|
+
native_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
190
|
+
script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
|
|
191
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
192
|
+
assert native_reason is None
|
|
193
|
+
assert script_reason is None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_state_description_native_deny_matches_script_reason() -> None:
|
|
197
|
+
"""state_description_blocker native deny reason matches the script's reason."""
|
|
198
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
|
|
199
|
+
native_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
200
|
+
script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
|
|
201
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
202
|
+
assert native_reason is not None
|
|
203
|
+
assert native_reason == script_reason
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_state_description_native_edit_deny_matches_script_reason() -> None:
|
|
207
|
+
"""state_description_blocker native and script agree on an Edit denial."""
|
|
208
|
+
payload_dictionary = _edit_payload_dictionary(
|
|
209
|
+
_MARKDOWN_PATH, "Previously this used the old client.\n"
|
|
210
|
+
)
|
|
211
|
+
native_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
212
|
+
script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
|
|
213
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
214
|
+
assert native_reason is not None
|
|
215
|
+
assert native_reason == script_reason
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_state_description_native_non_target_tool_allows_match_script() -> None:
|
|
219
|
+
"""state_description_blocker native allows MultiEdit, matching the script."""
|
|
220
|
+
payload_dictionary = _multi_edit_payload_dictionary(
|
|
221
|
+
_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT
|
|
222
|
+
)
|
|
223
|
+
native_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
224
|
+
script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
|
|
225
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
226
|
+
assert native_reason is None
|
|
227
|
+
assert script_reason is None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_plain_language_native_allows_match_script() -> None:
|
|
231
|
+
"""plain_language_blocker native allow matches the script's allow."""
|
|
232
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_ALLOW_CONTENT)
|
|
233
|
+
native_reason = plain_language_blocker.evaluate(payload_dictionary)
|
|
234
|
+
script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
|
|
235
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
236
|
+
assert native_reason is None
|
|
237
|
+
assert script_reason is None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_plain_language_native_deny_matches_script_reason() -> None:
|
|
241
|
+
"""plain_language_blocker native deny reason matches the script's reason."""
|
|
242
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
|
|
243
|
+
native_reason = plain_language_blocker.evaluate(payload_dictionary)
|
|
244
|
+
script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
|
|
245
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
246
|
+
assert native_reason is not None
|
|
247
|
+
assert native_reason == script_reason
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_plain_language_native_multi_edit_deny_matches_script_reason() -> None:
|
|
251
|
+
"""plain_language_blocker native and script agree on a MultiEdit denial."""
|
|
252
|
+
payload_dictionary = _multi_edit_payload_dictionary(
|
|
253
|
+
_MARKDOWN_PATH, "Utilize this to commence the process.\n"
|
|
254
|
+
)
|
|
255
|
+
native_reason = plain_language_blocker.evaluate(payload_dictionary)
|
|
256
|
+
script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
|
|
257
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
258
|
+
assert native_reason is not None
|
|
259
|
+
assert native_reason == script_reason
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_plain_language_native_non_markdown_allows_match_script() -> None:
|
|
263
|
+
"""plain_language_blocker native allows a non-markdown Write, matching the script."""
|
|
264
|
+
payload_dictionary = _write_payload_dictionary(_PYTHON_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
|
|
265
|
+
native_reason = plain_language_blocker.evaluate(payload_dictionary)
|
|
266
|
+
script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
|
|
267
|
+
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
268
|
+
assert native_reason is None
|
|
269
|
+
assert script_reason is None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_dispatcher_native_path_denies_state_description() -> None:
|
|
273
|
+
"""The dispatcher's native path denies a state_description_blocker violation."""
|
|
274
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
|
|
275
|
+
native_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
276
|
+
dispatcher_reason = _deny_reason_from_dispatcher(payload_dictionary)
|
|
277
|
+
assert native_reason is not None
|
|
278
|
+
assert dispatcher_reason is not None
|
|
279
|
+
assert native_reason in dispatcher_reason
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_dispatcher_native_path_denies_plain_language() -> None:
|
|
283
|
+
"""The dispatcher's native path denies a plain_language_blocker violation."""
|
|
284
|
+
payload_dictionary = _multi_edit_payload_dictionary(
|
|
285
|
+
_MARKDOWN_PATH, "Utilize this to commence the process.\n"
|
|
286
|
+
)
|
|
287
|
+
native_reason = plain_language_blocker.evaluate(payload_dictionary)
|
|
288
|
+
dispatcher_reason = _deny_reason_from_dispatcher(payload_dictionary)
|
|
289
|
+
assert native_reason is not None
|
|
290
|
+
assert dispatcher_reason is not None
|
|
291
|
+
assert native_reason in dispatcher_reason
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_dispatcher_native_plain_language_carries_system_message() -> None:
|
|
295
|
+
"""The dispatcher's plain-language deny carries the standalone systemMessage."""
|
|
296
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
|
|
297
|
+
deny_reason = plain_language_blocker.evaluate(payload_dictionary)
|
|
298
|
+
assert deny_reason is not None
|
|
299
|
+
standalone_payload = plain_language_blocker.build_deny_payload(deny_reason)
|
|
300
|
+
expected_system_message = standalone_payload["systemMessage"]
|
|
301
|
+
assert isinstance(expected_system_message, str)
|
|
302
|
+
dispatcher_payload = _deny_payload_from_dispatcher(payload_dictionary)
|
|
303
|
+
dispatcher_system_message = dispatcher_payload.get("systemMessage")
|
|
304
|
+
assert isinstance(dispatcher_system_message, str)
|
|
305
|
+
assert expected_system_message in dispatcher_system_message
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_dispatcher_native_plain_language_carries_suppress_output() -> None:
|
|
309
|
+
"""The dispatcher's plain-language deny carries the standalone suppressOutput flag."""
|
|
310
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
|
|
311
|
+
dispatcher_payload = _deny_payload_from_dispatcher(payload_dictionary)
|
|
312
|
+
assert dispatcher_payload.get("suppressOutput") is True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_dispatcher_native_state_description_carries_additional_context() -> None:
|
|
316
|
+
"""The dispatcher's state-description deny carries the standalone additionalContext."""
|
|
317
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
|
|
318
|
+
deny_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
319
|
+
assert deny_reason is not None
|
|
320
|
+
standalone_payload = state_description_blocker.build_deny_payload(deny_reason)
|
|
321
|
+
standalone_hook_specific = standalone_payload["hookSpecificOutput"]
|
|
322
|
+
assert isinstance(standalone_hook_specific, dict)
|
|
323
|
+
expected_additional_context = standalone_hook_specific["additionalContext"]
|
|
324
|
+
dispatcher_payload = _deny_payload_from_dispatcher(payload_dictionary)
|
|
325
|
+
dispatcher_hook_specific = dispatcher_payload.get("hookSpecificOutput", {})
|
|
326
|
+
assert isinstance(dispatcher_hook_specific, dict)
|
|
327
|
+
assert dispatcher_hook_specific.get("additionalContext") == expected_additional_context
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_dispatcher_native_state_description_carries_system_message() -> None:
|
|
331
|
+
"""The dispatcher's state-description deny carries the standalone systemMessage."""
|
|
332
|
+
payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
|
|
333
|
+
deny_reason = state_description_blocker.evaluate(payload_dictionary)
|
|
334
|
+
assert deny_reason is not None
|
|
335
|
+
standalone_payload = state_description_blocker.build_deny_payload(deny_reason)
|
|
336
|
+
expected_system_message = standalone_payload["systemMessage"]
|
|
337
|
+
assert isinstance(expected_system_message, str)
|
|
338
|
+
dispatcher_payload = _deny_payload_from_dispatcher(payload_dictionary)
|
|
339
|
+
dispatcher_system_message = dispatcher_payload.get("systemMessage")
|
|
340
|
+
assert isinstance(dispatcher_system_message, str)
|
|
341
|
+
assert expected_system_message in dispatcher_system_message
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Tests for pytest_testpaths_orphan_blocker hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
|
|
11
|
+
from pytest_testpaths_orphan_blocker import (
|
|
12
|
+
_explicit_testpaths,
|
|
13
|
+
_is_collected_by_entry,
|
|
14
|
+
find_unregistered_test_directory,
|
|
15
|
+
is_test_file,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "pytest_testpaths_orphan_blocker.py")
|
|
19
|
+
|
|
20
|
+
PYPROJECT_WITH_EXPLICIT_TESTPATHS = (
|
|
21
|
+
"[tool.pytest.ini_options]\n"
|
|
22
|
+
'testpaths = [\n "tests",\n "samsung_utils/tests",\n]\n'
|
|
23
|
+
'python_files = ["test_*.py"]\n'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
PYPROJECT_WITH_NO_PYTEST_SECTION = '[build-system]\nrequires = ["setuptools"]\n'
|
|
27
|
+
|
|
28
|
+
PYPROJECT_WITH_EMPTY_TESTPATHS = "[tool.pytest.ini_options]\ntestpaths = []\n"
|
|
29
|
+
|
|
30
|
+
PYPROJECT_WITH_DOT_TESTPATHS = '[tool.pytest.ini_options]\ntestpaths = ["."]\n'
|
|
31
|
+
|
|
32
|
+
PYPROJECT_WITH_DOT_PREFIXED_TESTPATHS = '[tool.pytest.ini_options]\ntestpaths = ["./tests"]\n'
|
|
33
|
+
|
|
34
|
+
PYPROJECT_WITH_GLOB_TESTPATHS = '[tool.pytest.ini_options]\ntestpaths = ["tests/*"]\n'
|
|
35
|
+
|
|
36
|
+
PYPROJECT_WITH_SCALAR_TOOL = 'tool = "x"\n'
|
|
37
|
+
|
|
38
|
+
PYPROJECT_WITH_SCALAR_PYTEST = "[tool]\npytest = \"oops\"\n"
|
|
39
|
+
|
|
40
|
+
PYPROJECT_WITH_SCALAR_INI_OPTIONS = "[tool.pytest]\nini_options = \"oops\"\n"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _write_package(package_root: Path, pyproject_text: str) -> None:
|
|
44
|
+
"""Write a pyproject.toml into *package_root*, creating the directory tree.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
package_root: The directory that holds the package's pyproject.toml.
|
|
48
|
+
pyproject_text: The pyproject content to write.
|
|
49
|
+
"""
|
|
50
|
+
package_root.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
(package_root / "pyproject.toml").write_text(pyproject_text, encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _RunHook:
|
|
55
|
+
"""Helper to test the hook via subprocess, mirroring the sibling test style."""
|
|
56
|
+
|
|
57
|
+
def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
|
|
58
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
59
|
+
return subprocess.run(
|
|
60
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
61
|
+
input=payload,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
check=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_run_hook = _RunHook()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_is_test_file_accepts_test_prefixed_python_file() -> None:
|
|
72
|
+
assert is_test_file("/repo/package/theme_assets/tests/test_palette.py") is True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_is_test_file_rejects_production_module() -> None:
|
|
76
|
+
assert is_test_file("/repo/package/theme_assets/palette.py") is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_find_flags_test_directory_absent_from_explicit_testpaths(tmp_path: Path) -> None:
|
|
80
|
+
package_root = tmp_path / "shared_utils"
|
|
81
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
82
|
+
unregistered_test_file = package_root / "theme_assets" / "tests" / "test_palette.py"
|
|
83
|
+
block_details = find_unregistered_test_directory(str(unregistered_test_file))
|
|
84
|
+
assert block_details is not None
|
|
85
|
+
assert block_details["test_directory"] == "theme_assets/tests"
|
|
86
|
+
assert block_details["suggested_entry"] == "theme_assets/tests"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_find_passes_test_directory_listed_in_testpaths(tmp_path: Path) -> None:
|
|
90
|
+
package_root = tmp_path / "shared_utils"
|
|
91
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
92
|
+
registered_test_file = package_root / "samsung_utils" / "tests" / "test_normalizer.py"
|
|
93
|
+
assert find_unregistered_test_directory(str(registered_test_file)) is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_find_passes_test_directory_named_tests_at_package_root(tmp_path: Path) -> None:
|
|
97
|
+
package_root = tmp_path / "shared_utils"
|
|
98
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
99
|
+
registered_test_file = package_root / "tests" / "test_root_behavior.py"
|
|
100
|
+
assert find_unregistered_test_directory(str(registered_test_file)) is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_find_passes_when_pyproject_has_no_pytest_section(tmp_path: Path) -> None:
|
|
104
|
+
package_root = tmp_path / "loose_package"
|
|
105
|
+
_write_package(package_root, PYPROJECT_WITH_NO_PYTEST_SECTION)
|
|
106
|
+
any_test_file = package_root / "deep" / "nested" / "test_anything.py"
|
|
107
|
+
assert find_unregistered_test_directory(str(any_test_file)) is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_find_passes_when_testpaths_list_is_empty(tmp_path: Path) -> None:
|
|
111
|
+
package_root = tmp_path / "loose_package"
|
|
112
|
+
_write_package(package_root, PYPROJECT_WITH_EMPTY_TESTPATHS)
|
|
113
|
+
any_test_file = package_root / "deep" / "test_anything.py"
|
|
114
|
+
assert find_unregistered_test_directory(str(any_test_file)) is None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_find_passes_when_no_governing_pyproject_exists(tmp_path: Path) -> None:
|
|
118
|
+
bare_test_file = tmp_path / "no_package_here" / "test_orphan.py"
|
|
119
|
+
assert find_unregistered_test_directory(str(bare_test_file)) is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_hook_blocks_create_of_unregistered_test_file(tmp_path: Path) -> None:
|
|
123
|
+
package_root = tmp_path / "shared_utils"
|
|
124
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
125
|
+
unregistered_test_file = package_root / "theme_assets" / "tests" / "test_palette.py"
|
|
126
|
+
completed = _run_hook(
|
|
127
|
+
"Write",
|
|
128
|
+
{
|
|
129
|
+
"file_path": str(unregistered_test_file),
|
|
130
|
+
"content": "def test_x() -> None:\n assert True\n",
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
decision = json.loads(completed.stdout)
|
|
134
|
+
hook_output = decision["hookSpecificOutput"]
|
|
135
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
136
|
+
assert "theme_assets/tests" in hook_output["permissionDecisionReason"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_hook_allows_create_of_registered_test_file(tmp_path: Path) -> None:
|
|
140
|
+
package_root = tmp_path / "shared_utils"
|
|
141
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
142
|
+
registered_test_file = package_root / "tests" / "test_root_behavior.py"
|
|
143
|
+
completed = _run_hook(
|
|
144
|
+
"Write",
|
|
145
|
+
{
|
|
146
|
+
"file_path": str(registered_test_file),
|
|
147
|
+
"content": "def test_x() -> None:\n assert True\n",
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
assert completed.stdout.strip() == ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_hook_ignores_edit_of_existing_test_file(tmp_path: Path) -> None:
|
|
154
|
+
package_root = tmp_path / "shared_utils"
|
|
155
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
156
|
+
existing_test_file = package_root / "theme_assets" / "tests" / "test_palette.py"
|
|
157
|
+
existing_test_file.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
existing_test_file.write_text("def test_old() -> None:\n assert True\n", encoding="utf-8")
|
|
159
|
+
completed = _run_hook(
|
|
160
|
+
"Edit",
|
|
161
|
+
{
|
|
162
|
+
"file_path": str(existing_test_file),
|
|
163
|
+
"old_string": "assert True",
|
|
164
|
+
"new_string": "assert 1 == 1",
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
assert completed.stdout.strip() == ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_hook_ignores_non_test_python_file(tmp_path: Path) -> None:
|
|
171
|
+
package_root = tmp_path / "shared_utils"
|
|
172
|
+
_write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
|
|
173
|
+
production_module = package_root / "theme_assets" / "palette.py"
|
|
174
|
+
completed = _run_hook(
|
|
175
|
+
"Write",
|
|
176
|
+
{"file_path": str(production_module), "content": "VALUE = 1\n"},
|
|
177
|
+
)
|
|
178
|
+
assert completed.stdout.strip() == ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_find_passes_when_testpaths_is_dot_for_nested_file(tmp_path: Path) -> None:
|
|
182
|
+
package_root = tmp_path / "shared_utils"
|
|
183
|
+
_write_package(package_root, PYPROJECT_WITH_DOT_TESTPATHS)
|
|
184
|
+
nested_test_file = package_root / "theme_assets" / "tests" / "test_palette.py"
|
|
185
|
+
assert find_unregistered_test_directory(str(nested_test_file)) is None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_find_passes_when_testpaths_is_dot_for_root_file(tmp_path: Path) -> None:
|
|
189
|
+
package_root = tmp_path / "shared_utils"
|
|
190
|
+
_write_package(package_root, PYPROJECT_WITH_DOT_TESTPATHS)
|
|
191
|
+
root_test_file = package_root / "test_root_behavior.py"
|
|
192
|
+
assert find_unregistered_test_directory(str(root_test_file)) is None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_find_passes_when_testpaths_entry_has_dot_slash_prefix(tmp_path: Path) -> None:
|
|
196
|
+
package_root = tmp_path / "shared_utils"
|
|
197
|
+
_write_package(package_root, PYPROJECT_WITH_DOT_PREFIXED_TESTPATHS)
|
|
198
|
+
registered_test_file = package_root / "tests" / "test_root_behavior.py"
|
|
199
|
+
assert find_unregistered_test_directory(str(registered_test_file)) is None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_find_passes_when_testpaths_entry_is_glob(tmp_path: Path) -> None:
|
|
203
|
+
package_root = tmp_path / "shared_utils"
|
|
204
|
+
_write_package(package_root, PYPROJECT_WITH_GLOB_TESTPATHS)
|
|
205
|
+
registered_test_file = package_root / "tests" / "test_x.py"
|
|
206
|
+
assert find_unregistered_test_directory(str(registered_test_file)) is None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_is_collected_by_entry_treats_dot_as_package_root() -> None:
|
|
210
|
+
assert _is_collected_by_entry(Path("theme_assets/tests/test_palette.py"), ".") is True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_is_collected_by_entry_matches_glob_segment() -> None:
|
|
214
|
+
assert _is_collected_by_entry(Path("tests/test_x.py"), "tests/*") is True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_explicit_testpaths_returns_none_for_scalar_tool(tmp_path: Path) -> None:
|
|
218
|
+
pyproject_path = tmp_path / "pyproject.toml"
|
|
219
|
+
pyproject_path.write_text(PYPROJECT_WITH_SCALAR_TOOL, encoding="utf-8")
|
|
220
|
+
assert _explicit_testpaths(pyproject_path) is None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_explicit_testpaths_returns_none_for_scalar_pytest(tmp_path: Path) -> None:
|
|
224
|
+
pyproject_path = tmp_path / "pyproject.toml"
|
|
225
|
+
pyproject_path.write_text(PYPROJECT_WITH_SCALAR_PYTEST, encoding="utf-8")
|
|
226
|
+
assert _explicit_testpaths(pyproject_path) is None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_explicit_testpaths_returns_none_for_scalar_ini_options(tmp_path: Path) -> None:
|
|
230
|
+
pyproject_path = tmp_path / "pyproject.toml"
|
|
231
|
+
pyproject_path.write_text(PYPROJECT_WITH_SCALAR_INI_OPTIONS, encoding="utf-8")
|
|
232
|
+
assert _explicit_testpaths(pyproject_path) is None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_hook_does_not_crash_on_scalar_tool_ancestor(tmp_path: Path) -> None:
|
|
236
|
+
package_root = tmp_path / "scalar_package"
|
|
237
|
+
_write_package(package_root, PYPROJECT_WITH_SCALAR_TOOL)
|
|
238
|
+
any_test_file = package_root / "deep" / "test_anything.py"
|
|
239
|
+
completed = _run_hook(
|
|
240
|
+
"Write",
|
|
241
|
+
{
|
|
242
|
+
"file_path": str(any_test_file),
|
|
243
|
+
"content": "def test_x() -> None:\n assert True\n",
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
assert completed.returncode == 0
|
|
247
|
+
assert completed.stdout.strip() == ""
|