claude-dev-env 1.71.0 → 1.73.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.
Files changed (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -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() == ""