claude-dev-env 1.28.1 → 1.29.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 (54) hide show
  1. package/agents/caveman.md +74 -0
  2. package/hooks/blocking/code_rules_enforcer.py +82 -7
  3. package/hooks/blocking/code_rules_path_utils.py +31 -0
  4. package/hooks/blocking/es_exe_path_rewriter.py +159 -0
  5. package/hooks/blocking/hedging_language_blocker.py +12 -2
  6. package/hooks/blocking/test_code_rules_enforcer.py +148 -0
  7. package/hooks/blocking/test_code_rules_enforcer_config_path.py +123 -0
  8. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
  9. package/hooks/blocking/test_code_rules_path_utils.py +52 -0
  10. package/hooks/blocking/test_es_exe_path_rewriter.py +369 -0
  11. package/hooks/blocking/test_hedging_language_blocker.py +7 -6
  12. package/hooks/config/dynamic_stderr_handler.py +22 -0
  13. package/hooks/config/path_rewriter_constants.py +13 -0
  14. package/hooks/config/project_paths_reader.py +78 -0
  15. package/hooks/config/setup_project_paths_constants.py +41 -0
  16. package/hooks/config/test_dynamic_stderr_handler.py +48 -0
  17. package/hooks/config/test_messages.py +5 -1
  18. package/hooks/config/test_path_rewriter_constants.py +57 -0
  19. package/hooks/config/test_project_paths_reader.py +149 -0
  20. package/hooks/config/test_setup_project_paths_constants.py +74 -0
  21. package/hooks/git-hooks/test_config.py +1 -0
  22. package/hooks/git-hooks/test_gate_utils.py +1 -0
  23. package/hooks/git-hooks/test_pre_commit.py +1 -0
  24. package/hooks/git-hooks/test_pre_push.py +1 -0
  25. package/hooks/hooks.json +10 -0
  26. package/hooks/session/test_untracked_repo_detector.py +192 -0
  27. package/hooks/session/untracked_repo_detector.py +103 -0
  28. package/hooks/validators/exempt_paths.py +17 -14
  29. package/hooks/validators/test_exempt_paths.py +65 -0
  30. package/hooks/validators/test_git_checks.py +17 -17
  31. package/package.json +1 -1
  32. package/scripts/config/__init__.py +1 -0
  33. package/scripts/config/groq_bugteam_config.py +118 -0
  34. package/scripts/config/test_groq_bugteam_config.py +72 -0
  35. package/scripts/groq_bugteam.README.md +129 -0
  36. package/scripts/groq_bugteam.py +586 -0
  37. package/scripts/setup_project_paths.py +347 -0
  38. package/scripts/test_groq_bugteam.py +391 -0
  39. package/scripts/test_setup_project_paths.py +532 -0
  40. package/scripts/test_setup_project_paths_config.py +6 -0
  41. package/skills/bugteam/CONSTRAINTS.md +1 -1
  42. package/skills/bugteam/PROMPTS.md +1 -1
  43. package/skills/bugteam/SKILL.md +5 -5
  44. package/skills/bugteam/SKILL_EVALS.md +5 -5
  45. package/skills/bugteam/reference/audit-and-teammates.md +3 -3
  46. package/skills/bugteam/reference/audit-contract.md +159 -0
  47. package/skills/bugteam/reference/team-setup.md +2 -2
  48. package/skills/bugteam/scripts/bugteam_preflight.py +66 -0
  49. package/skills/bugteam/scripts/test_bugteam_preflight.py +189 -0
  50. package/skills/copilot-review/SKILL.md +145 -0
  51. package/skills/findbugs/SKILL.md +14 -22
  52. package/skills/qbug/SKILL.md +56 -13
  53. package/skills/qbug/test_qbug_skill_audit_schema.py +156 -0
  54. package/skills/qbug/test_qbug_skill_post_fix_audit.py +103 -0
@@ -0,0 +1,123 @@
1
+ """Tests for directory-anchored config path detection and function-local UPPER_SNAKE scanning.
2
+
3
+ Covers:
4
+ - is_config_file: must use directory-segment matching, not filename-stem matching
5
+ - check_constants_outside_config: advisory (not blocking) for function-body UPPER_SNAKE
6
+ - check_constants_outside_config: stable sort order by line number
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import io
13
+ import sys
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+
17
+
18
+ def _load_enforcer_module() -> ModuleType:
19
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
20
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
21
+ assert spec is not None
22
+ assert spec.loader is not None
23
+ module = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(module)
25
+ return module
26
+
27
+
28
+ code_rules_enforcer = _load_enforcer_module()
29
+
30
+ PRODUCTION_FILE_PATH = "packages/claude-dev-env/src/example.py"
31
+
32
+
33
+ def test_should_return_false_for_filename_named_config_dot_py() -> None:
34
+ assert code_rules_enforcer.is_config_file("scripts/db/config.py") is False
35
+
36
+
37
+ def test_should_return_true_for_file_inside_config_directory_forward_slash() -> None:
38
+ assert code_rules_enforcer.is_config_file("config/timing.py") is True
39
+
40
+
41
+ def test_should_return_true_for_file_inside_nested_config_directory() -> None:
42
+ assert code_rules_enforcer.is_config_file("my_project/config/constants.py") is True
43
+
44
+
45
+ def test_should_return_true_for_settings_dot_py() -> None:
46
+ assert code_rules_enforcer.is_config_file("settings.py") is True
47
+
48
+
49
+ def test_should_return_false_for_subconfig_in_non_config_dir() -> None:
50
+ assert code_rules_enforcer.is_config_file("src/subconfiguration.py") is False
51
+
52
+
53
+ def test_should_return_false_for_config_in_filename_not_directory() -> None:
54
+ assert code_rules_enforcer.is_config_file("src/app_config.py") is False
55
+
56
+
57
+ def test_should_return_true_for_config_dir_backslash() -> None:
58
+ assert code_rules_enforcer.is_config_file("project\\config\\constants.py") is True
59
+
60
+
61
+ def test_should_produce_advisory_not_blocking_for_function_local_upper_snake() -> None:
62
+ source = (
63
+ "def fetch_data():\n"
64
+ " MAX_RETRIES = 3\n"
65
+ " for attempt in range(MAX_RETRIES):\n"
66
+ " pass\n"
67
+ )
68
+ advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
69
+ source, PRODUCTION_FILE_PATH
70
+ )
71
+ blocking_issues = code_rules_enforcer.check_constants_outside_config(
72
+ source, PRODUCTION_FILE_PATH
73
+ )
74
+ assert any("MAX_RETRIES" in issue for issue in advisory_issues)
75
+ assert not any("MAX_RETRIES" in issue for issue in blocking_issues)
76
+
77
+
78
+ def test_should_produce_blocking_for_module_level_upper_snake_outside_config() -> None:
79
+ source = "MAX_RETRIES = 3\n\ndef fetch_data():\n pass\n"
80
+ blocking_issues = code_rules_enforcer.check_constants_outside_config(
81
+ source, PRODUCTION_FILE_PATH
82
+ )
83
+ assert any("MAX_RETRIES" in issue for issue in blocking_issues)
84
+
85
+
86
+ def test_should_include_file_path_in_advisory_stderr_output() -> None:
87
+ source = (
88
+ "def fetch_data():\n"
89
+ " MAX_RETRIES = 3\n"
90
+ " for attempt in range(MAX_RETRIES):\n"
91
+ " pass\n"
92
+ )
93
+ captured_stderr = io.StringIO()
94
+ old_stderr = sys.stderr
95
+ sys.stderr = captured_stderr
96
+ try:
97
+ code_rules_enforcer.validate_content(source, PRODUCTION_FILE_PATH, "")
98
+ finally:
99
+ sys.stderr = old_stderr
100
+ advisory_output = captured_stderr.getvalue()
101
+ assert PRODUCTION_FILE_PATH in advisory_output, (
102
+ f"Advisory stderr must include the file path; got: {advisory_output!r}"
103
+ )
104
+
105
+
106
+ def test_should_produce_stable_ordering_sorted_by_line_number() -> None:
107
+ source = (
108
+ "ALPHA_CONSTANT = 1\n"
109
+ "BETA_CONSTANT = 2\n"
110
+ "GAMMA_CONSTANT = 3\n"
111
+ "\n"
112
+ "def placeholder():\n"
113
+ " pass\n"
114
+ )
115
+ issues = code_rules_enforcer.check_constants_outside_config(
116
+ source, PRODUCTION_FILE_PATH
117
+ )
118
+ line_numbers = []
119
+ for each_issue in issues:
120
+ parts = each_issue.split(":")
121
+ if parts[0].startswith("Line "):
122
+ line_numbers.append(int(parts[0].replace("Line ", "").strip()))
123
+ assert line_numbers == sorted(line_numbers)
@@ -1,6 +1,6 @@
1
1
  """Tests for magic-value allowlist alignment with CODE_RULES §HOOK-ENFORCED.
2
2
 
3
- CODE_RULES.md and .github/copilot-instructions.md both state that only
3
+ CODE_RULES.md and AGENTS.md both state that only
4
4
  0, 1, and -1 (plus their float forms 0.0, 1.0) are exempt from the
5
5
  magic-value check. Prior to this change, the hook silently allowed 2
6
6
  and 100 as well, making the hook more permissive than the written rule.
@@ -0,0 +1,52 @@
1
+ """Tests for the shared path-classification helper extracted to code_rules_path_utils.
2
+
3
+ Finding A: is_config_file was duplicated between code_rules_enforcer.py and
4
+ exempt_paths.py. The canonical implementation now lives in code_rules_path_utils.py;
5
+ both files import from here.
6
+
7
+ These tests verify the directory-segment matching semantics: only files whose
8
+ parent directory is named 'config', or whose filename is settings.py, match.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ _BLOCKING_DIR = Path(__file__).resolve().parent
17
+ if str(_BLOCKING_DIR) not in sys.path:
18
+ sys.path.insert(0, str(_BLOCKING_DIR))
19
+
20
+ from code_rules_path_utils import is_config_file # noqa: E402
21
+
22
+
23
+ def test_should_return_false_for_scripts_db_config_py() -> None:
24
+ assert is_config_file("scripts/db/config.py") is False
25
+
26
+
27
+ def test_should_return_false_for_lib_myconfig_py() -> None:
28
+ assert is_config_file("lib/myconfig.py") is False
29
+
30
+
31
+ def test_should_return_true_for_config_timing_py() -> None:
32
+ assert is_config_file("config/timing.py") is True
33
+
34
+
35
+ def test_should_return_true_for_nested_config_dir() -> None:
36
+ assert is_config_file("packages/myapp/config/constants.py") is True
37
+
38
+
39
+ def test_should_return_true_for_settings_py() -> None:
40
+ assert is_config_file("settings.py") is True
41
+
42
+
43
+ def test_should_return_true_for_settings_py_with_prefix() -> None:
44
+ assert is_config_file("any/path/settings.py") is True
45
+
46
+
47
+ def test_should_return_false_for_mysettings_py() -> None:
48
+ assert is_config_file("mysettings.py") is False
49
+
50
+
51
+ def test_should_return_true_for_backslash_config_path() -> None:
52
+ assert is_config_file("packages\\myapp\\config\\timing.py") is True
@@ -0,0 +1,369 @@
1
+ """Tests for es_exe_path_rewriter — PreToolUse hook that rewrites es.exe paths."""
2
+
3
+ import json
4
+ import sys
5
+ from io import StringIO
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ _BLOCKING_DIR = Path(__file__).resolve().parent
12
+ _HOOKS_ROOT = _BLOCKING_DIR.parent
13
+ for each_sys_path_entry in (str(_BLOCKING_DIR), str(_HOOKS_ROOT)):
14
+ if each_sys_path_entry not in sys.path:
15
+ sys.path.insert(0, each_sys_path_entry)
16
+
17
+ import es_exe_path_rewriter as rewriter
18
+
19
+
20
+ def test_rewriter_does_not_redefine_dynamic_stderr_handler_locally() -> None:
21
+ """Pin PR #230 round 3 DRY fix: handler is imported from the shared module.
22
+
23
+ Both project_paths_reader and es_exe_path_rewriter previously defined
24
+ identical `_DynamicStderrHandler` classes. This test fails if the
25
+ duplicate class reappears in es_exe_path_rewriter.
26
+ """
27
+ assert not hasattr(rewriter, "_DynamicStderrHandler")
28
+
29
+
30
+ REGISTRY_WITH_ONE_REPO = {"my-repo": "Y:\\Projects\\my-repo"}
31
+ REGISTRY_WITH_TWO_REPOS = {
32
+ "my-repo": "Y:\\Projects\\my-repo",
33
+ "other-repo": "C:\\Dev\\other-repo",
34
+ }
35
+ ABSOLUTE_PATH_ARGUMENT = "Y:\\Projects\\already-absolute\\file.py"
36
+ KNOWN_REPO_NAME = "my-repo"
37
+ KNOWN_REPO_PATH = "Y:\\Projects\\my-repo"
38
+ OTHER_REPO_NAME = "other-repo"
39
+ OTHER_REPO_PATH = "C:\\Dev\\other-repo"
40
+
41
+
42
+ def _run_main_with_input(hook_input: dict) -> tuple[str, str, int]:
43
+ """Return (stdout, stderr, exit_code) from running main() with the given hook input."""
44
+ stdin_text = json.dumps(hook_input)
45
+ captured_stdout = StringIO()
46
+ captured_stderr = StringIO()
47
+ exit_code = 0
48
+ try:
49
+ with (
50
+ patch("sys.stdin", StringIO(stdin_text)),
51
+ patch("sys.stdout", captured_stdout),
52
+ patch("sys.stderr", captured_stderr),
53
+ ):
54
+ rewriter.main()
55
+ except SystemExit as e:
56
+ exit_code = e.code or 0
57
+ return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
58
+
59
+
60
+ def _make_bash_input(command: str, description: str = "search files") -> dict:
61
+ return {
62
+ "tool_name": "Bash",
63
+ "tool_input": {"command": command, "description": description},
64
+ }
65
+
66
+
67
+ class TestTriggerRegex:
68
+ def test_matches_bare_es_exe(self) -> None:
69
+ assert rewriter.command_invokes_es_exe("es.exe my-repo")
70
+
71
+ def test_matches_everything_forward_slash_path(self) -> None:
72
+ assert rewriter.command_invokes_es_exe("Everything/es.exe my-repo")
73
+
74
+ def test_matches_everything_backslash_path(self) -> None:
75
+ assert rewriter.command_invokes_es_exe("Everything\\es.exe my-repo")
76
+
77
+ def test_does_not_match_unrelated_command(self) -> None:
78
+ assert not rewriter.command_invokes_es_exe("git status")
79
+
80
+ def test_does_not_match_es_exe_inside_longer_word(self) -> None:
81
+ assert not rewriter.command_invokes_es_exe("not_es.exe_here")
82
+
83
+ def test_matches_case_insensitively(self) -> None:
84
+ assert rewriter.command_invokes_es_exe("ES.EXE my-repo")
85
+
86
+
87
+ class TestRewriteCommand:
88
+ def test_bare_token_rewrite_substitutes_registry_path(self) -> None:
89
+ rewritten = rewriter.rewrite_command(
90
+ "es.exe my-repo config.py", REGISTRY_WITH_ONE_REPO
91
+ )
92
+ assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
93
+
94
+ def test_placeholder_token_rewrite_substitutes_registry_path(self) -> None:
95
+ rewritten = rewriter.rewrite_command(
96
+ 'es.exe "{my-repo}" config.py', REGISTRY_WITH_ONE_REPO
97
+ )
98
+ assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
99
+
100
+ def test_single_quoted_placeholder_rewrite_substitutes_registry_path(self) -> None:
101
+ rewritten = rewriter.rewrite_command(
102
+ "es.exe '{my-repo}' config.py", REGISTRY_WITH_ONE_REPO
103
+ )
104
+ assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
105
+
106
+ def test_placeholder_without_quotes_rewrite_substitutes_registry_path(self) -> None:
107
+ rewritten = rewriter.rewrite_command(
108
+ "es.exe {my-repo} config.py", REGISTRY_WITH_ONE_REPO
109
+ )
110
+ assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" config.py'
111
+
112
+ def test_absolute_path_argument_is_never_modified(self) -> None:
113
+ command = f'es.exe "{ABSOLUTE_PATH_ARGUMENT}" config.py'
114
+ rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
115
+ assert rewritten == command
116
+
117
+ def test_unknown_token_is_never_touched(self) -> None:
118
+ command = "es.exe unknown-name config.py"
119
+ rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
120
+ assert rewritten == command
121
+
122
+ def test_multiple_tokens_all_rewrite(self) -> None:
123
+ command = "es.exe my-repo other-repo config.py"
124
+ rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_TWO_REPOS)
125
+ assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" "{OTHER_REPO_PATH}" config.py'
126
+
127
+ def test_empty_registry_returns_unchanged_command(self) -> None:
128
+ command = "es.exe my-repo config.py"
129
+ rewritten = rewriter.rewrite_command(command, {})
130
+ assert rewritten == command
131
+
132
+ def test_double_spaces_inside_quoted_arg_pass_through_unchanged_on_no_registry_hit(
133
+ self,
134
+ ) -> None:
135
+ command = 'es.exe "foo bar" baz'
136
+ rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_ONE_REPO)
137
+ assert rewritten == command
138
+
139
+ def test_tab_separator_is_preserved_when_bare_token_is_rewritten(self) -> None:
140
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
141
+ command = "es.exe my-repo\tconfig.py"
142
+ rewritten = rewriter.rewrite_command(command, registry)
143
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo"\tconfig.py'
144
+
145
+ def test_multiple_substitutions_preserve_all_inter_token_whitespace(self) -> None:
146
+ command = "es.exe my-repo other-repo config.py"
147
+ rewritten = rewriter.rewrite_command(command, REGISTRY_WITH_TWO_REPOS)
148
+ assert rewritten == f'es.exe "{KNOWN_REPO_PATH}" "{OTHER_REPO_PATH}" config.py'
149
+
150
+
151
+ class TestEmittedJsonShape:
152
+ def test_emitted_json_has_correct_hook_event_name(self) -> None:
153
+ hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
154
+ with patch(
155
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
156
+ ):
157
+ stdout, _, _ = _run_main_with_input(hook_input)
158
+ emitted = json.loads(stdout)
159
+ assert emitted["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
160
+
161
+ def test_emitted_json_has_allow_permission_decision(self) -> None:
162
+ hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
163
+ with patch(
164
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
165
+ ):
166
+ stdout, _, _ = _run_main_with_input(hook_input)
167
+ emitted = json.loads(stdout)
168
+ assert emitted["hookSpecificOutput"]["permissionDecision"] == "allow"
169
+
170
+ def test_emitted_json_has_rewritten_command(self) -> None:
171
+ hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
172
+ with patch(
173
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
174
+ ):
175
+ stdout, _, _ = _run_main_with_input(hook_input)
176
+ emitted = json.loads(stdout)
177
+ updated_command = emitted["hookSpecificOutput"]["updatedInput"]["command"]
178
+ assert updated_command == f'es.exe "{KNOWN_REPO_PATH}" config.py'
179
+
180
+ def test_description_field_round_trips_unchanged(self) -> None:
181
+ original_description = "my special search description"
182
+ hook_input = _make_bash_input(
183
+ f"es.exe {KNOWN_REPO_NAME} config.py", original_description
184
+ )
185
+ with patch(
186
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
187
+ ):
188
+ stdout, _, _ = _run_main_with_input(hook_input)
189
+ emitted = json.loads(stdout)
190
+ assert (
191
+ emitted["hookSpecificOutput"]["updatedInput"]["description"]
192
+ == original_description
193
+ )
194
+
195
+ def test_additional_unknown_fields_pass_through_into_updated_input(self) -> None:
196
+ hook_input = {
197
+ "tool_name": "Bash",
198
+ "tool_input": {
199
+ "command": f"es.exe {KNOWN_REPO_NAME} config.py",
200
+ "description": "search",
201
+ "extra_field": "extra_value",
202
+ },
203
+ }
204
+ with patch(
205
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
206
+ ):
207
+ stdout, _, _ = _run_main_with_input(hook_input)
208
+ emitted = json.loads(stdout)
209
+ assert (
210
+ emitted["hookSpecificOutput"]["updatedInput"]["extra_field"]
211
+ == "extra_value"
212
+ )
213
+
214
+ def test_no_code_path_returns_deny_decision(self) -> None:
215
+ for command in [
216
+ f"es.exe {KNOWN_REPO_NAME} config.py",
217
+ "es.exe unknown-token config.py",
218
+ "git status",
219
+ ]:
220
+ hook_input = _make_bash_input(command)
221
+ with patch(
222
+ "es_exe_path_rewriter.load_registry",
223
+ return_value=REGISTRY_WITH_ONE_REPO,
224
+ ):
225
+ stdout, _, _ = _run_main_with_input(hook_input)
226
+ if stdout.strip():
227
+ emitted = json.loads(stdout)
228
+ decision = emitted.get("hookSpecificOutput", {}).get(
229
+ "permissionDecision", ""
230
+ )
231
+ assert decision != "deny", f"deny returned for command: {command!r}"
232
+
233
+
234
+ class TestNoOutputCases:
235
+ def test_non_es_exe_command_produces_no_output(self) -> None:
236
+ hook_input = _make_bash_input("git status")
237
+ with patch(
238
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
239
+ ):
240
+ stdout, _, _ = _run_main_with_input(hook_input)
241
+ assert stdout.strip() == ""
242
+
243
+ def test_empty_registry_produces_no_output(self) -> None:
244
+ hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
245
+ with patch("es_exe_path_rewriter.load_registry", return_value={}):
246
+ stdout, _, _ = _run_main_with_input(hook_input)
247
+ assert stdout.strip() == ""
248
+
249
+ def test_unchanged_command_produces_no_output(self) -> None:
250
+ hook_input = _make_bash_input("es.exe unknown-token config.py")
251
+ with patch(
252
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
253
+ ):
254
+ stdout, _, _ = _run_main_with_input(hook_input)
255
+ assert stdout.strip() == ""
256
+
257
+ def test_malformed_registry_produces_no_output_and_one_stderr_line(self) -> None:
258
+ hook_input = _make_bash_input(f"es.exe {KNOWN_REPO_NAME} config.py")
259
+ with patch(
260
+ "es_exe_path_rewriter.load_registry",
261
+ side_effect=Exception("simulated read error"),
262
+ ):
263
+ stdout, stderr, _ = _run_main_with_input(hook_input)
264
+ assert stdout.strip() == ""
265
+ assert stderr.strip() != ""
266
+ assert stderr.strip().count("\n") == 0
267
+
268
+ def test_non_bash_tool_produces_no_output(self) -> None:
269
+ hook_input = {
270
+ "tool_name": "Write",
271
+ "tool_input": {"command": f"es.exe {KNOWN_REPO_NAME}"},
272
+ }
273
+ with patch(
274
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
275
+ ):
276
+ stdout, _, _ = _run_main_with_input(hook_input)
277
+ assert stdout.strip() == ""
278
+
279
+
280
+ class TestQuoteAwareTokenizer:
281
+ def test_double_quoted_multiword_arg_with_registry_key_prefix_is_not_substituted(
282
+ self,
283
+ ) -> None:
284
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
285
+ command = 'es.exe "my-repo foo" baz'
286
+ rewritten = rewriter.rewrite_command(command, registry)
287
+ assert rewritten == command
288
+
289
+ def test_single_quoted_multiword_arg_with_registry_key_prefix_is_not_substituted(
290
+ self,
291
+ ) -> None:
292
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
293
+ command = "es.exe 'my-repo baz' config.py"
294
+ rewritten = rewriter.rewrite_command(command, registry)
295
+ assert rewritten == command
296
+
297
+ def test_bare_token_matching_registry_key_is_rewritten(self) -> None:
298
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
299
+ command = "es.exe my-repo config.py"
300
+ rewritten = rewriter.rewrite_command(command, registry)
301
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
302
+
303
+ def test_whitespace_between_bare_tokens_is_preserved_after_rewrite(self) -> None:
304
+ registry = {"my-repo": "Y:\\Projects\\my-repo", "other-repo": "C:\\Dev\\other"}
305
+ command = "es.exe my-repo other-repo config.py"
306
+ rewritten = rewriter.rewrite_command(command, registry)
307
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo" "C:\\Dev\\other" config.py'
308
+
309
+ def test_tab_separator_is_preserved_when_bare_token_is_rewritten(self) -> None:
310
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
311
+ command = "es.exe my-repo\tconfig.py"
312
+ rewritten = rewriter.rewrite_command(command, registry)
313
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo"\tconfig.py'
314
+
315
+
316
+ class TestQuotedSingleWordRewrite:
317
+ def test_double_quoted_single_word_registry_key_is_rewritten(self) -> None:
318
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
319
+ command = 'es.exe "my-repo" config.py'
320
+ rewritten = rewriter.rewrite_command(command, registry)
321
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
322
+
323
+ def test_single_quoted_single_word_registry_key_is_rewritten(self) -> None:
324
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
325
+ command = "es.exe 'my-repo' config.py"
326
+ rewritten = rewriter.rewrite_command(command, registry)
327
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
328
+
329
+
330
+ class TestPlaceholderBoundaryEnforcement:
331
+ def test_placeholder_inside_flag_argument_is_not_rewritten(self) -> None:
332
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
333
+ command = "es.exe --regex=^{my-repo}$"
334
+ rewritten = rewriter.rewrite_command(command, registry)
335
+ assert rewritten == command
336
+
337
+ def test_placeholder_embedded_in_token_is_not_rewritten(self) -> None:
338
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
339
+ command = "es.exe foo{my-repo}bar"
340
+ rewritten = rewriter.rewrite_command(command, registry)
341
+ assert rewritten == command
342
+
343
+ def test_standalone_placeholder_is_still_rewritten(self) -> None:
344
+ registry = {"my-repo": "Y:\\Projects\\my-repo"}
345
+ command = "es.exe {my-repo} config.py"
346
+ rewritten = rewriter.rewrite_command(command, registry)
347
+ assert rewritten == f'es.exe "Y:\\Projects\\my-repo" config.py'
348
+
349
+
350
+ class TestAbsolutePathDetection:
351
+ def test_windows_drive_letter_path_detected_as_absolute(self) -> None:
352
+ assert rewriter._token_is_absolute_path("C:\\Users\\x")
353
+
354
+ def test_windows_drive_letter_path_with_forward_slashes_detected_as_absolute(
355
+ self,
356
+ ) -> None:
357
+ assert rewriter._token_is_absolute_path("Y:/Projects/foo")
358
+
359
+ def test_unc_path_detected_as_absolute(self) -> None:
360
+ assert rewriter._token_is_absolute_path("\\\\server\\share\\path")
361
+
362
+ def test_posix_absolute_path_detected_as_absolute(self) -> None:
363
+ assert rewriter._token_is_absolute_path("/etc/hosts")
364
+
365
+ def test_relative_path_not_detected_as_absolute(self) -> None:
366
+ assert not rewriter._token_is_absolute_path("./foo")
367
+
368
+ def test_bare_registry_token_not_detected_as_absolute(self) -> None:
369
+ assert not rewriter._token_is_absolute_path("my-repo")
@@ -9,13 +9,14 @@ import tempfile
9
9
 
10
10
  HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "hedging_language_blocker.py")
11
11
  _HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
12
- _CONFIG_DIR = os.path.join(_HOOKS_DIR, "..", "config")
12
+ _HOOKS_ROOT = os.path.join(_HOOKS_DIR, "..")
13
+ _HOOK_CONFIG_DIR = os.path.join(_HOOKS_ROOT, "config")
13
14
  if _HOOKS_DIR not in sys.path:
14
15
  sys.path.insert(0, _HOOKS_DIR)
15
- if _CONFIG_DIR not in sys.path:
16
- sys.path.insert(0, _CONFIG_DIR)
16
+ if _HOOKS_ROOT not in sys.path:
17
+ sys.path.insert(0, _HOOKS_ROOT)
17
18
  import hedging_language_blocker
18
- from messages import USER_FACING_NOTICE
19
+ from config.messages import USER_FACING_NOTICE
19
20
 
20
21
  RESEARCH_MODE_SKILL_BODY_MARKER = "Three anti-hallucination constraints are ALWAYS active."
21
22
  HEDGING_MESSAGE = "This is likely correct."
@@ -64,8 +65,8 @@ def run_hook_with_patched_search_paths(
64
65
  return completed_process
65
66
 
66
67
 
67
- def test_user_facing_notice_importable_from_config_messages():
68
- config_messages_path = os.path.join(_CONFIG_DIR, "messages.py")
68
+ def test_user_facing_notice_matches_config_messages_module():
69
+ config_messages_path = os.path.join(_HOOK_CONFIG_DIR, "messages.py")
69
70
  specification = importlib.util.spec_from_file_location("messages", config_messages_path)
70
71
  module = importlib.util.module_from_spec(specification)
71
72
  specification.loader.exec_module(module)
@@ -0,0 +1,22 @@
1
+ """Logging handler that resolves sys.stderr at emit time for testability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+
9
+ class DynamicStderrHandler(logging.Handler):
10
+ """Logging handler that resolves sys.stderr at emit time, not at init time.
11
+
12
+ This allows tests that patch sys.stderr to capture log output emitted
13
+ from this handler without needing to re-import the module.
14
+ """
15
+
16
+ def emit(self, record: logging.LogRecord) -> None:
17
+ try:
18
+ formatted_line = self.format(record)
19
+ sys.stderr.write(formatted_line + "\n")
20
+ sys.stderr.flush()
21
+ except Exception:
22
+ self.handleError(record)
@@ -0,0 +1,13 @@
1
+ """Configuration constants for the es_exe_path_rewriter PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ BASH_TOOL_NAME = "Bash"
6
+
7
+ HOOK_EVENT_NAME = "PreToolUse"
8
+
9
+ PERMISSION_ALLOW = "allow"
10
+
11
+ PLACEHOLDER_TOKEN_PATTERN = re.compile(
12
+ r"""(?:(?<=\s)|^)['"]?(?<!\$)\{([^}]+)\}['"]?(?=\s|$)""",
13
+ )