claude-dev-env 1.74.0 → 1.76.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 (78) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/docs/CODE_RULES.md +1 -1
  6. package/hooks/blocking/CLAUDE.md +3 -2
  7. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  8. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  9. package/hooks/blocking/code_rules_enforcer.py +36 -5
  10. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  11. package/hooks/blocking/code_rules_shared.py +8 -5
  12. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
  14. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  15. package/hooks/blocking/hedging_language_blocker.py +1 -13
  16. package/hooks/blocking/intent_only_ending_blocker.py +1 -15
  17. package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
  18. package/hooks/blocking/question_to_user_enforcer.py +1 -11
  19. package/hooks/blocking/session_handoff_blocker.py +1 -15
  20. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  22. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  23. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  24. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  25. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  26. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  27. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
  28. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  29. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  30. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  31. package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
  32. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  33. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  34. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  35. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  36. package/hooks/blocking/verification_verdict_store.py +19 -5
  37. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  38. package/hooks/hooks_constants/CLAUDE.md +4 -1
  39. package/hooks/hooks_constants/blocking_check_limits.py +30 -1
  40. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -1
  42. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  43. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  44. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  45. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
  46. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
  47. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  48. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  49. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  50. package/hooks/hooks_constants/text_stripping.py +36 -0
  51. package/hooks/validation/CLAUDE.md +1 -0
  52. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  53. package/hooks/validation/test_mypy_validator.py +1 -1
  54. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  55. package/hooks/workflow/auto_formatter.py +8 -5
  56. package/hooks/workflow/test_auto_formatter.py +33 -0
  57. package/package.json +1 -1
  58. package/rules/claude-md-orphan-file.md +7 -8
  59. package/rules/docstring-prose-matches-implementation.md +1 -0
  60. package/rules/package-inventory-stale-entry.md +8 -0
  61. package/rules/windows-filesystem-safe.md +2 -0
  62. package/skills/anthropic-plan/CLAUDE.md +1 -1
  63. package/skills/anthropic-plan/SKILL.md +15 -2
  64. package/skills/autoconverge/SKILL.md +6 -3
  65. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  66. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  67. package/skills/autoconverge/workflow/converge.contract.test.mjs +306 -137
  68. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  69. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -44
  70. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  71. package/skills/autoconverge/workflow/converge.mjs +581 -613
  72. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  73. package/skills/autoconverge/workflow/render_report.py +2 -6
  74. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  75. package/skills/autoconverge/workflow/test_render_report.py +1 -0
  76. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  77. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  78. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -0,0 +1,134 @@
1
+ """Tests for the printf-token check on str.format-logger (automation_logging) calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ ENFORCER_FILENAME = "code_rules_enforcer.py"
10
+ ENFORCER_MODULE_NAME = "code_rules_enforcer_printf_under_test"
11
+ PRODUCTION_FILE_PATH = "shared_utils/web_automation/sample.py"
12
+ TEST_FILE_PATH = "shared_utils/web_automation/tests/test_sample.py"
13
+ FORMAT_LOGGER_IMPORT = (
14
+ "from shared_utils.automation_logging import log_error, log_info, log_debug\n"
15
+ )
16
+
17
+
18
+ def load_enforcer_module() -> ModuleType:
19
+ enforcer_path = Path(__file__).parent / ENFORCER_FILENAME
20
+ module_spec = importlib.util.spec_from_file_location(ENFORCER_MODULE_NAME, enforcer_path)
21
+ assert module_spec is not None
22
+ assert module_spec.loader is not None
23
+ enforcer_module = importlib.util.module_from_spec(module_spec)
24
+ module_spec.loader.exec_module(enforcer_module)
25
+ return enforcer_module
26
+
27
+
28
+ enforcer = load_enforcer_module()
29
+
30
+
31
+ def test_should_flag_percent_s_in_format_logger_call() -> None:
32
+ source = FORMAT_LOGGER_IMPORT + 'log_error("Skipping %s after error: %s", name, err)\n'
33
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
34
+ assert len(issues) == 1
35
+ assert "printf token" in issues[0]
36
+
37
+
38
+ def test_should_flag_percent_d_in_format_logger_call() -> None:
39
+ source = FORMAT_LOGGER_IMPORT + 'log_debug("attempt %d of %d", index, total)\n'
40
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
41
+ assert len(issues) == 1
42
+
43
+
44
+ def test_should_allow_brace_placeholders_in_format_logger_call() -> None:
45
+ source = FORMAT_LOGGER_IMPORT + 'log_info("Processing {} of {}", index, total)\n'
46
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
47
+ assert issues == []
48
+
49
+
50
+ def test_should_ignore_percent_token_without_format_logger_import() -> None:
51
+ source = 'log_error("Skipping %s", name)\n'
52
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
53
+ assert issues == []
54
+
55
+
56
+ def test_should_ignore_attribute_style_logger_call() -> None:
57
+ source = FORMAT_LOGGER_IMPORT + 'logger.info("delivered %s", message_id)\n'
58
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
59
+ assert issues == []
60
+
61
+
62
+ def test_should_inspect_message_literal_not_argument_value() -> None:
63
+ source = FORMAT_LOGGER_IMPORT + 'log_info("status: {}", "100%s done")\n'
64
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
65
+ assert issues == []
66
+
67
+
68
+ def test_should_exempt_test_files() -> None:
69
+ source = FORMAT_LOGGER_IMPORT + 'log_error("Skipping %s", name)\n'
70
+ issues = enforcer.check_logging_printf_tokens(source, TEST_FILE_PATH)
71
+ assert issues == []
72
+
73
+
74
+ def test_should_resolve_aliased_format_logger_import() -> None:
75
+ source = (
76
+ "from shared_utils.automation_logging import log_error as report_error\n"
77
+ + 'report_error("failed %s", name)\n'
78
+ )
79
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
80
+ assert len(issues) == 1
81
+
82
+
83
+ def test_should_allow_token_bearing_message_with_no_format_args() -> None:
84
+ source = FORMAT_LOGGER_IMPORT + 'log_info("Use %s for string substitution")\n'
85
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
86
+ assert issues == []
87
+
88
+
89
+ def test_should_allow_documentation_style_token_message_with_no_args() -> None:
90
+ source = FORMAT_LOGGER_IMPORT + 'log_warning("avoid %s-style tokens here")\n'
91
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
92
+ assert issues == []
93
+
94
+
95
+ def test_should_allow_percent_adjacent_to_word_in_format_message() -> None:
96
+ source = FORMAT_LOGGER_IMPORT + 'log_info("memory 80%free now", host)\n'
97
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
98
+ assert issues == []
99
+
100
+
101
+ def test_should_allow_doubled_percent_in_format_message() -> None:
102
+ source = FORMAT_LOGGER_IMPORT + 'log_info("100%% done", host)\n'
103
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
104
+ assert issues == []
105
+
106
+
107
+ def test_should_allow_trailing_percent_in_format_message() -> None:
108
+ source = FORMAT_LOGGER_IMPORT + 'log_info("100% done", host)\n'
109
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
110
+ assert issues == []
111
+
112
+
113
+ def test_should_flag_width_token_in_format_logger_call() -> None:
114
+ source = FORMAT_LOGGER_IMPORT + 'log_error("item %5d", index)\n'
115
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
116
+ assert len(issues) == 1
117
+
118
+
119
+ def test_should_flag_precision_token_in_format_logger_call() -> None:
120
+ source = FORMAT_LOGGER_IMPORT + 'log_error("took %0.2f sec", elapsed)\n'
121
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
122
+ assert len(issues) == 1
123
+
124
+
125
+ def test_should_flag_float_general_token_in_format_logger_call() -> None:
126
+ source = FORMAT_LOGGER_IMPORT + 'log_error("ratio %g", ratio)\n'
127
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
128
+ assert len(issues) == 1
129
+
130
+
131
+ def test_should_flag_scientific_token_in_format_logger_call() -> None:
132
+ source = FORMAT_LOGGER_IMPORT + 'log_error("value %e", measurement)\n'
133
+ issues = enforcer.check_logging_printf_tokens(source, PRODUCTION_FILE_PATH)
134
+ assert len(issues) == 1
@@ -342,6 +342,22 @@ def test_real_code_rules_violation_on_added_line_denies(tmp_path: Path) -> None:
342
342
  assert "Line " in reason
343
343
 
344
344
 
345
+ def test_committed_on_branch_violation_with_clean_working_tree_denies(tmp_path: Path) -> None:
346
+ repository_root = tmp_path / "repo"
347
+ repository_root.mkdir()
348
+ initialize_repository(repository_root)
349
+ run_git(repository_root, "checkout", "-b", "feature")
350
+ commit_file(repository_root, "committed.py", VIOLATING_MODULE_SOURCE, "commit violation")
351
+ status_output = run_git(repository_root, "status", "--porcelain")
352
+ assert status_output == "", "working tree must be clean so the deny comes from the committed line"
353
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
354
+ result = run_hook(payload, repository_root)
355
+ assert not is_allow(result)
356
+ reason = deny_reason(result)
357
+ assert "committed.py" in reason
358
+ assert "Line " in reason
359
+
360
+
345
361
  def test_preexisting_violation_on_untouched_line_allows(tmp_path: Path) -> None:
346
362
  repository_root = tmp_path / "repo"
347
363
  repository_root.mkdir()
@@ -0,0 +1,328 @@
1
+ """Unit tests for duplicate_rmtree_helper_blocker PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import pathlib
7
+ import sys
8
+ from contextlib import redirect_stderr, redirect_stdout
9
+
10
+ _HOOK_DIR = pathlib.Path(__file__).parent
11
+ if str(_HOOK_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_HOOK_DIR))
13
+ _HOOKS_ROOT = _HOOK_DIR.parent
14
+ if str(_HOOKS_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(_HOOKS_ROOT))
16
+
17
+ hook_spec = importlib.util.spec_from_file_location(
18
+ "duplicate_rmtree_helper_blocker",
19
+ _HOOK_DIR / "duplicate_rmtree_helper_blocker.py",
20
+ )
21
+ assert hook_spec is not None
22
+ assert hook_spec.loader is not None
23
+ hook_module = importlib.util.module_from_spec(hook_spec)
24
+ hook_spec.loader.exec_module(hook_module)
25
+
26
+ payload_defines_sanctioned_helper = hook_module.payload_defines_sanctioned_helper
27
+ path_is_exempt = hook_module.path_is_exempt
28
+ extract_payload_text = hook_module.extract_payload_text
29
+
30
+ COPIED_TRIO = (
31
+ "def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
32
+ " try:\n"
33
+ " os.chmod(target_path, stat.S_IWRITE)\n"
34
+ " removal_function(target_path)\n"
35
+ " except OSError:\n"
36
+ " pass\n\n\n"
37
+ "_rmtree_supports_onexc = 'onexc' in inspect.signature(shutil.rmtree).parameters\n\n\n"
38
+ "def _force_remove_tree(target_path):\n"
39
+ " if _rmtree_supports_onexc:\n"
40
+ " shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)\n"
41
+ )
42
+
43
+
44
+ def test_detects_strip_read_only_definition() -> None:
45
+ assert payload_defines_sanctioned_helper(
46
+ "def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n pass"
47
+ )
48
+
49
+
50
+ def test_detects_force_remove_tree_definition() -> None:
51
+ assert payload_defines_sanctioned_helper(
52
+ "def _force_remove_tree(target_path: Path) -> None:\n pass"
53
+ )
54
+
55
+
56
+ def test_detects_force_rmtree_definition() -> None:
57
+ assert payload_defines_sanctioned_helper(
58
+ "def force_rmtree(target_path: str) -> None:\n pass"
59
+ )
60
+
61
+
62
+ def test_detects_indented_method_definition() -> None:
63
+ assert payload_defines_sanctioned_helper(
64
+ "class FileTools:\n def _strip_read_only_and_retry(self, fn, path):\n pass"
65
+ )
66
+
67
+
68
+ def test_detects_copied_trio_block() -> None:
69
+ assert payload_defines_sanctioned_helper(COPIED_TRIO)
70
+
71
+
72
+ def test_allows_import_of_shared_helper() -> None:
73
+ assert not payload_defines_sanctioned_helper(
74
+ "from shared_utils.web_automation.utils.windows_filesystem import force_rmtree"
75
+ )
76
+
77
+
78
+ def test_allows_call_site_without_definition() -> None:
79
+ assert not payload_defines_sanctioned_helper("force_rmtree(staging_directory)")
80
+
81
+
82
+ def test_allows_helper_name_inside_string_literal() -> None:
83
+ corrective_message = (
84
+ ' " def _strip_read_only_and_retry(removal_function, target_path):\\n"'
85
+ )
86
+ assert not payload_defines_sanctioned_helper(corrective_message)
87
+
88
+
89
+ def test_allows_helper_definition_inside_triple_quoted_string() -> None:
90
+ documentation_snippet = (
91
+ 'EXAMPLE = """\\\n'
92
+ 'def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n'
93
+ ' pass\n'
94
+ '"""\n'
95
+ )
96
+ assert not payload_defines_sanctioned_helper(documentation_snippet)
97
+
98
+
99
+ def test_allows_force_rmtree_definition_inside_triple_quoted_string() -> None:
100
+ documentation_snippet = (
101
+ "snippet = '''\n"
102
+ "def force_rmtree(target_path: str) -> None:\n"
103
+ " pass\n"
104
+ "'''\n"
105
+ )
106
+ assert not payload_defines_sanctioned_helper(documentation_snippet)
107
+
108
+
109
+ def test_detects_real_definition_following_triple_quoted_docstring() -> None:
110
+ module_text = (
111
+ '"""Module docstring."""\n'
112
+ 'def force_rmtree(target_path: str) -> None:\n'
113
+ ' pass\n'
114
+ )
115
+ assert payload_defines_sanctioned_helper(module_text)
116
+
117
+
118
+ def test_detects_real_definition_between_two_triple_quoted_strings() -> None:
119
+ module_text = (
120
+ '"""Leading docstring."""\n'
121
+ 'def _force_remove_tree(target_path: str) -> None:\n'
122
+ ' pass\n'
123
+ '"""Trailing docstring."""\n'
124
+ )
125
+ assert payload_defines_sanctioned_helper(module_text)
126
+
127
+
128
+ def test_allows_unrelated_definition() -> None:
129
+ assert not payload_defines_sanctioned_helper("def categorize_and_move(theme_folder):\n pass")
130
+
131
+
132
+ def test_path_exempts_blocker_source() -> None:
133
+ assert path_is_exempt("packages/x/hooks/blocking/windows_rmtree_blocker.py")
134
+ assert path_is_exempt("packages/x/hooks/blocking/duplicate_rmtree_helper_blocker.py")
135
+
136
+
137
+ def test_path_exempts_shared_helper_module() -> None:
138
+ assert path_is_exempt("shared_utils/web_automation/utils/windows_filesystem.py")
139
+
140
+
141
+ def test_path_exempts_existing_session_env_cleanup_definition_site() -> None:
142
+ assert path_is_exempt("packages/claude-dev-env/hooks/session/session_env_cleanup.py")
143
+
144
+
145
+ def test_path_exempts_existing_md_to_html_test_support_definition_site() -> None:
146
+ assert path_is_exempt(
147
+ "packages/claude-dev-env/hooks/blocking/_md_to_html_blocker_test_support.py"
148
+ )
149
+
150
+
151
+ def test_path_exempts_existing_teardown_worktrees_definition_site() -> None:
152
+ assert path_is_exempt(
153
+ "packages/claude-dev-env/skills/_shared/pr-loop/scripts/teardown_worktrees.py"
154
+ )
155
+
156
+
157
+ def test_main_allows_full_file_write_of_existing_definition_site() -> None:
158
+ stdout_text, exit_code = _run_hook(
159
+ {
160
+ "tool_name": "Write",
161
+ "tool_input": {
162
+ "file_path": "packages/claude-dev-env/hooks/session/session_env_cleanup.py",
163
+ "content": COPIED_TRIO,
164
+ },
165
+ }
166
+ )
167
+ assert exit_code == 0
168
+ assert stdout_text == ""
169
+
170
+
171
+ def test_path_exempts_test_file_prefix() -> None:
172
+ assert path_is_exempt("hooks/blocking/test_duplicate_rmtree_helper_blocker.py")
173
+
174
+
175
+ def test_path_exempts_test_file_suffix() -> None:
176
+ assert path_is_exempt("shared_utils/something_test.py")
177
+
178
+
179
+ def test_path_does_not_exempt_production_module() -> None:
180
+ assert not path_is_exempt(
181
+ "shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
182
+ )
183
+
184
+
185
+ def test_path_does_not_exempt_filename_containing_exempt_fragment() -> None:
186
+ assert not path_is_exempt("packages/x/not_windows_filesystem.py")
187
+ assert not path_is_exempt("packages/x/my_windows_filesystem.py")
188
+
189
+
190
+ def test_path_does_not_exempt_backslash_path_with_containing_fragment() -> None:
191
+ assert not path_is_exempt("packages\\x\\not_windows_filesystem.py")
192
+
193
+
194
+ def test_extract_payload_text_reads_write_content() -> None:
195
+ extracted = extract_payload_text("Write", {"file_path": "foo.py", "content": "abc"})
196
+ assert extracted == ("foo.py", "abc")
197
+
198
+
199
+ def test_extract_payload_text_reads_edit_new_string() -> None:
200
+ extracted = extract_payload_text("Edit", {"file_path": "foo.py", "new_string": "abc"})
201
+ assert extracted == ("foo.py", "abc")
202
+
203
+
204
+ def test_extract_payload_text_returns_empty_for_non_python_file() -> None:
205
+ extracted = extract_payload_text("Write", {"file_path": "notes.md", "content": COPIED_TRIO})
206
+ assert extracted == ("notes.md", "")
207
+
208
+
209
+ def test_extract_payload_text_returns_empty_for_unknown_tool() -> None:
210
+ extracted = extract_payload_text("Read", {"file_path": "foo.py"})
211
+ assert extracted == ("", "")
212
+
213
+
214
+ def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
215
+ captured_stdout = io.StringIO()
216
+ captured_stderr = io.StringIO()
217
+ exit_code = 0
218
+ sys.stdin = io.StringIO(stdin_text)
219
+ try:
220
+ with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
221
+ try:
222
+ hook_module.main()
223
+ except SystemExit as exit_signal:
224
+ raw_exit_code = exit_signal.code
225
+ exit_code = raw_exit_code if isinstance(raw_exit_code, int) else 0
226
+ finally:
227
+ sys.stdin = sys.__stdin__
228
+ return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
229
+
230
+
231
+ def _run_hook(hook_input: dict) -> tuple[str, int]:
232
+ stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(json.dumps(hook_input))
233
+ return stdout_text, exit_code
234
+
235
+
236
+ def test_main_blocks_local_trio_copy_in_production_module() -> None:
237
+ stdout_text, exit_code = _run_hook(
238
+ {
239
+ "tool_name": "Write",
240
+ "tool_input": {
241
+ "file_path": (
242
+ "shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
243
+ ),
244
+ "content": COPIED_TRIO,
245
+ },
246
+ }
247
+ )
248
+ assert exit_code == 0
249
+ response_payload = json.loads(stdout_text)
250
+ decision_block = response_payload["hookSpecificOutput"]
251
+ assert decision_block["permissionDecision"] == "deny"
252
+ assert "duplicate-rmtree-helper" in decision_block["permissionDecisionReason"]
253
+
254
+
255
+ def test_main_allows_import_of_shared_helper() -> None:
256
+ stdout_text, exit_code = _run_hook(
257
+ {
258
+ "tool_name": "Write",
259
+ "tool_input": {
260
+ "file_path": "shared_utils/samsung_utils/cleanup.py",
261
+ "content": (
262
+ "from shared_utils.web_automation.utils.windows_filesystem import "
263
+ "force_rmtree\n\nforce_rmtree(path)\n"
264
+ ),
265
+ },
266
+ }
267
+ )
268
+ assert exit_code == 0
269
+ assert stdout_text == ""
270
+
271
+
272
+ def test_main_allows_definition_in_shared_helper_module() -> None:
273
+ stdout_text, exit_code = _run_hook(
274
+ {
275
+ "tool_name": "Write",
276
+ "tool_input": {
277
+ "file_path": "shared_utils/web_automation/utils/windows_filesystem.py",
278
+ "content": COPIED_TRIO,
279
+ },
280
+ }
281
+ )
282
+ assert exit_code == 0
283
+ assert stdout_text == ""
284
+
285
+
286
+ def test_main_allows_definition_in_test_file() -> None:
287
+ stdout_text, exit_code = _run_hook(
288
+ {
289
+ "tool_name": "Write",
290
+ "tool_input": {
291
+ "file_path": "shared_utils/test_windows_filesystem.py",
292
+ "content": COPIED_TRIO,
293
+ },
294
+ }
295
+ )
296
+ assert exit_code == 0
297
+ assert stdout_text == ""
298
+
299
+
300
+ def test_main_passes_through_non_python_file() -> None:
301
+ stdout_text, exit_code = _run_hook(
302
+ {
303
+ "tool_name": "Write",
304
+ "tool_input": {"file_path": "docs/cleanup.md", "content": COPIED_TRIO},
305
+ }
306
+ )
307
+ assert exit_code == 0
308
+ assert stdout_text == ""
309
+
310
+
311
+ def test_main_passes_through_unrelated_tool() -> None:
312
+ stdout_text, exit_code = _run_hook({"tool_name": "Read", "tool_input": {"file_path": "foo.py"}})
313
+ assert exit_code == 0
314
+ assert stdout_text == ""
315
+
316
+
317
+ def test_main_with_empty_stdin_exits_silently() -> None:
318
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
319
+ assert exit_code == 0
320
+ assert stdout_text == ""
321
+ assert stderr_text == ""
322
+
323
+
324
+ def test_main_with_invalid_json_stdin_exits_silently() -> None:
325
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
326
+ assert exit_code == 0
327
+ assert stdout_text == ""
328
+ assert stderr_text == ""
@@ -17,6 +17,12 @@ if _HOOKS_ROOT not in sys.path:
17
17
  sys.path.insert(0, _HOOKS_ROOT)
18
18
  import hedging_language_blocker
19
19
  from hooks_constants.messages import USER_FACING_NOTICE
20
+ from hooks_constants.text_stripping import strip_code_and_quotes
21
+
22
+
23
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
24
+ assert hedging_language_blocker.strip_code_and_quotes is strip_code_and_quotes
25
+
20
26
 
21
27
  RESEARCH_MODE_SKILL_BODY_MARKER = "Three anti-hallucination constraints are ALWAYS active."
22
28
  HEDGING_MESSAGE = "This is likely correct."
@@ -16,6 +16,11 @@ if _HOOKS_ROOT not in sys.path:
16
16
  sys.path.insert(0, _HOOKS_ROOT)
17
17
  import intent_only_ending_blocker
18
18
  from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE
19
+ from hooks_constants.text_stripping import strip_code_and_quotes
20
+
21
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
22
+ assert intent_only_ending_blocker.strip_code_and_quotes is strip_code_and_quotes
23
+
19
24
 
20
25
  INTENT_ENDING_MESSAGE = "I'll now run the test suite and fix any failures that come up."
21
26
  NEXT_STEPS_MESSAGE = "Next steps:"
@@ -26,6 +26,7 @@ if _HOOKS_DIR not in sys.path:
26
26
 
27
27
  from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
28
28
  ALL_HOSTED_HOOK_ENTRIES,
29
+ BLOCKING_CRASH_DENY_REASON,
29
30
  BLOCKING_CRASH_EXIT_CODE,
30
31
  DENY_DECISION,
31
32
  EDIT_TOOL_NAME,
@@ -485,6 +486,52 @@ def test_aggregate_exit_code_two_signals_deny() -> None:
485
486
  )
486
487
 
487
488
 
489
+ def test_aggregate_blocking_hook_crash_surfaces_a_deny() -> None:
490
+ """A crash in a blocking hook surfaces a deny with the crash reason.
491
+
492
+ When a blocking hook raises a non-SystemExit exception before emitting any
493
+ output, the aggregator must still deny so a bad write does not silently
494
+ pass. The deny reason must be the BLOCKING_CRASH_DENY_REASON constant.
495
+ """
496
+ all_results = [
497
+ HostedHookResult(
498
+ exit_code=0,
499
+ captured_stdout="",
500
+ did_crash=True,
501
+ is_blocking=True,
502
+ )
503
+ ]
504
+ decision = aggregate_hosted_hook_results(all_results)
505
+ assert decision.should_deny, "a blocking hook crash must surface a deny"
506
+ assert decision.all_deny_reasons, (
507
+ "the deny reasons list must be non-empty after a blocking hook crash"
508
+ )
509
+ assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons, (
510
+ "the deny reason from a blocking hook crash must be BLOCKING_CRASH_DENY_REASON.\n"
511
+ f"Got: {decision.all_deny_reasons!r}"
512
+ )
513
+
514
+
515
+ def test_aggregate_non_blocking_hook_crash_does_not_deny() -> None:
516
+ """A crash in a non-blocking hook does not change an allow to a deny.
517
+
518
+ A hosted hook carrying is_blocking=False must not surface a deny when it
519
+ crashes — the aggregated decision stays allow.
520
+ """
521
+ all_results = [
522
+ HostedHookResult(
523
+ exit_code=0,
524
+ captured_stdout="",
525
+ did_crash=True,
526
+ is_blocking=False,
527
+ )
528
+ ]
529
+ decision = aggregate_hosted_hook_results(all_results)
530
+ assert not decision.should_deny, (
531
+ "a non-blocking hook crash must not change an allow to a deny"
532
+ )
533
+
534
+
488
535
  def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
489
536
  """A HostedHookResult with exit_code 0 and empty stdout signals allow.
490
537
 
@@ -585,16 +632,16 @@ def test_dispatcher_write_applies_both_groups() -> None:
585
632
  assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
586
633
  "plain_language_blocker (Group B) must be in Write applicable set"
587
634
  )
588
- assert len(all_write_entries) == 17, (
589
- f"Write tool must apply to all 17 hosted hooks, got {len(all_write_entries)}"
635
+ assert len(all_write_entries) == 18, (
636
+ f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
590
637
  )
591
638
 
592
639
 
593
640
  def test_dispatcher_edit_applies_both_groups() -> None:
594
641
  """Edit tool triggers both Group A and Group B hooks through the dispatcher."""
595
642
  all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
596
- assert len(all_edit_entries) == 17, (
597
- f"Edit tool must apply to all 17 hosted hooks, got {len(all_edit_entries)}"
643
+ assert len(all_edit_entries) == 18, (
644
+ f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
598
645
  )
599
646
 
600
647
 
@@ -613,7 +660,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
613
660
  it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
614
661
  A Python Write payload that run_all_validators would have flagged (mypy errors, for
615
662
  instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
616
- dispatcher covers only its 17 hosted blocking hooks — none of which includes the
663
+ dispatcher covers only its 18 hosted blocking hooks — none of which includes the
617
664
  validators runner.
618
665
  """
619
666
  python_content_with_type_error = (
@@ -14,7 +14,9 @@ if _HOOKS_DIR not in sys.path:
14
14
  sys.path.insert(0, _HOOKS_DIR)
15
15
  if _HOOKS_ROOT not in sys.path:
16
16
  sys.path.insert(0, _HOOKS_ROOT)
17
+ import question_to_user_enforcer
17
18
  from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE
19
+ from hooks_constants.text_stripping import strip_code_and_quotes
18
20
 
19
21
  CLEAN_DECLARATIVE_MESSAGE = "I applied the rename across both files. The tests pass."
20
22
  TRAILING_QUESTION_MESSAGE = (
@@ -68,6 +70,10 @@ def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess
68
70
  return run_hook_with_payload({"last_assistant_message": assistant_message})
69
71
 
70
72
 
73
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
74
+ assert question_to_user_enforcer.strip_code_and_quotes is strip_code_and_quotes
75
+
76
+
71
77
  def test_clean_declarative_message_passes_through():
72
78
  completed_process = run_hook_with_message(CLEAN_DECLARATIVE_MESSAGE)
73
79
  assert completed_process.returncode == 0
@@ -16,6 +16,12 @@ if _HOOKS_ROOT not in sys.path:
16
16
  sys.path.insert(0, _HOOKS_ROOT)
17
17
  import session_handoff_blocker
18
18
  from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE
19
+ from hooks_constants.text_stripping import strip_code_and_quotes
20
+
21
+
22
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
23
+ assert session_handoff_blocker.strip_code_and_quotes is strip_code_and_quotes
24
+
19
25
 
20
26
  NEW_SESSION_PROPOSAL_MESSAGE = (
21
27
  "I recommend we continue this in a fresh session to keep things manageable."