claude-dev-env 1.34.1 → 1.36.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 (148) hide show
  1. package/agents/clean-coder.md +109 -1
  2. package/agents/docs-agent.md +1 -1
  3. package/agents/project-docs-analyzer.md +0 -1
  4. package/agents/skill-to-agent-converter.md +0 -1
  5. package/bin/install.mjs +28 -8
  6. package/bin/install.test.mjs +9 -1
  7. package/commands/initialize.md +0 -1
  8. package/commands/readability-review.md +4 -4
  9. package/commands/review-plan.md +2 -4
  10. package/commands/stubcheck.md +1 -2
  11. package/docs/CODE_RULES.md +3 -0
  12. package/docs/agents-md-alignment-plan.md +123 -0
  13. package/hooks/blocking/code_rules_enforcer.py +686 -60
  14. package/hooks/blocking/es_exe_path_rewriter.py +10 -4
  15. package/hooks/blocking/test_code_rules_enforcer.py +273 -39
  16. package/hooks/blocking/test_code_rules_enforcer_annotations.py +97 -0
  17. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
  18. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
  19. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +328 -0
  20. package/hooks/blocking/test_code_rules_enforcer_config_path.py +0 -20
  21. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +33 -11
  22. package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +0 -18
  23. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
  24. package/hooks/blocking/test_code_rules_enforcer_inline_literal_collections.py +155 -0
  25. package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +194 -0
  26. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -13
  27. package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +0 -26
  28. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +234 -0
  29. package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
  30. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
  31. package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
  32. package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
  33. package/hooks/blocking/windows_rmtree_blocker.py +23 -6
  34. package/hooks/config/banned_identifiers_constants.py +24 -0
  35. package/hooks/config/hardcoded_user_path_constants.py +12 -0
  36. package/hooks/config/hook_log_extractor_constants.py +1 -1
  37. package/hooks/config/pre_tool_use_stdin.py +48 -0
  38. package/hooks/config/setup_project_paths_constants.py +4 -0
  39. package/hooks/config/stuttering_check_config.py +14 -0
  40. package/hooks/config/stuttering_import_binding_constants.py +11 -0
  41. package/hooks/config/sys_path_insert_constants.py +4 -0
  42. package/hooks/config/test_banned_identifiers_constants.py +48 -0
  43. package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
  44. package/hooks/config/test_hook_log_extractor_constants.py +3 -3
  45. package/hooks/config/test_pre_tool_use_stdin.py +80 -0
  46. package/hooks/config/unused_module_import_constants.py +7 -0
  47. package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
  48. package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
  49. package/hooks/git-hooks/config.py +3 -3
  50. package/hooks/git-hooks/test_gate_utils.py +10 -10
  51. package/hooks/mypy.ini +2 -0
  52. package/package.json +1 -1
  53. package/rules/gh-paginate.md +125 -0
  54. package/skills/bugteam/CONSTRAINTS.md +12 -6
  55. package/skills/bugteam/PROMPTS.md +0 -39
  56. package/skills/bugteam/SKILL.md +93 -125
  57. package/skills/bugteam/SKILL_EVALS.md +25 -23
  58. package/skills/bugteam/reference/README.md +2 -0
  59. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  60. package/skills/bugteam/reference/copilot-gap-analysis.md +12 -0
  61. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  62. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
  63. package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
  64. package/skills/bugteam/test_skill_additions.py +13 -4
  65. package/skills/bugteam/test_team_lifecycle.py +94 -0
  66. package/skills/findbugs/SKILL.md +3 -3
  67. package/skills/fixbugs/SKILL.md +4 -4
  68. package/skills/monitor-open-prs/SKILL.md +32 -2
  69. package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
  70. package/skills/pr-converge/SKILL.md +576 -95
  71. package/skills/pr-converge/scripts/README.md +145 -0
  72. package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
  73. package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
  74. package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
  75. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
  76. package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
  77. package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
  78. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
  79. package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
  80. package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
  81. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
  82. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
  83. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
  84. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
  85. package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
  86. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
  87. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
  88. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
  89. package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
  90. package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
  91. package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
  92. package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
  93. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
  94. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
  95. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
  96. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
  97. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
  98. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
  99. package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
  100. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
  101. package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
  102. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
  103. package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
  104. package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
  105. package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
  106. package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
  107. package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
  108. package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
  109. package/skills/pr-converge/scripts/view_pr_context.py +47 -0
  110. package/skills/pr-converge/test_team_lifecycle.py +47 -0
  111. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
  112. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
  113. package/skills/qbug/SKILL.md +4 -4
  114. package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
  115. package/skills/resume-review/SKILL.md +261 -0
  116. package/agents/agent-writer.md +0 -157
  117. package/agents/config-centralizer.md +0 -686
  118. package/agents/config-extraction-agent.md +0 -225
  119. package/agents/doc-orchestrator.md +0 -47
  120. package/agents/docx-agent.md +0 -211
  121. package/agents/magic-value-eliminator-agent.md +0 -72
  122. package/agents/mandatory-agent-workflow-agent.md +0 -88
  123. package/agents/parallel-workflow-coordinator.md +0 -779
  124. package/agents/pdf-agent.md +0 -302
  125. package/agents/project-context-loader.md +0 -238
  126. package/agents/readability-review-agent.md +0 -76
  127. package/agents/refactoring-specialist.md +0 -69
  128. package/agents/right-sized-engineer.md +0 -129
  129. package/agents/session-continuity-manager.md +0 -53
  130. package/agents/stub-detector-agent.md +0 -140
  131. package/agents/tdd-test-writer.md +0 -62
  132. package/agents/test-data-builder.md +0 -68
  133. package/agents/tooling-builder.md +0 -78
  134. package/agents/validation-expert.md +0 -71
  135. package/agents/xlsx-agent.md +0 -169
  136. package/skills/bugteam/scripts/README.md +0 -58
  137. package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
  138. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
  139. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
  140. package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
  141. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
  142. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
  143. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
  144. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
  145. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
  146. package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
  147. package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
  148. /package/skills/{bugteam → pr-converge}/scripts/config/__init__.py +0 -0
@@ -0,0 +1,244 @@
1
+ """Tests for unused module-level import detection.
2
+
3
+ Bot reviewers on PR #257 and PR #289 flagged FLAG_INCREMENTAL,
4
+ ALL_REPOSITORY_ROOT_MARKER_FILENAMES, VENV_DIRECTORY_NAME and other
5
+ imports that survived into a PR without ever being referenced.
6
+
7
+ The detector is intentionally narrow: it only flags `from X import Y`
8
+ or `import X` where Y/X is never referenced in the file body, the file
9
+ does not declare `__all__`, and the file does not use TYPE_CHECKING
10
+ conditional imports. The narrow scope keeps false positives low.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import pathlib
17
+ import sys
18
+
19
+
20
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
21
+ if str(_HOOK_DIRECTORY) not in sys.path:
22
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
23
+
24
+ _hook_spec = importlib.util.spec_from_file_location(
25
+ "code_rules_enforcer",
26
+ _HOOK_DIRECTORY / "code_rules_enforcer.py",
27
+ )
28
+ assert _hook_spec is not None
29
+ assert _hook_spec.loader is not None
30
+ _hook_module = importlib.util.module_from_spec(_hook_spec)
31
+ _hook_spec.loader.exec_module(_hook_module)
32
+ check_unused_module_level_imports = _hook_module.check_unused_module_level_imports
33
+
34
+
35
+ PRODUCTION_FILE_PATH = "packages/app/services/loader.py"
36
+ TEST_FILE_PATH = "packages/app/tests/test_loader.py"
37
+
38
+
39
+ def test_should_flag_unused_from_import() -> None:
40
+ source = (
41
+ "from config.preflight_constants import VENV_DIRECTORY_NAME\n"
42
+ "\n"
43
+ "def run() -> None:\n"
44
+ " return None\n"
45
+ )
46
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
47
+ assert any("VENV_DIRECTORY_NAME" in each_issue for each_issue in issues), (
48
+ f"Expected VENV_DIRECTORY_NAME flagged, got: {issues}"
49
+ )
50
+
51
+
52
+ def test_should_not_flag_used_from_import() -> None:
53
+ source = (
54
+ "from config.preflight_constants import VENV_DIRECTORY_NAME\n"
55
+ "\n"
56
+ "def run() -> str:\n"
57
+ " return VENV_DIRECTORY_NAME\n"
58
+ )
59
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
60
+ assert issues == [], f"Used import must not be flagged, got: {issues}"
61
+
62
+
63
+ def test_should_flag_unused_plain_import() -> None:
64
+ source = "import json\n\ndef run() -> None:\n return None\n"
65
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
66
+ assert any("json" in each_issue for each_issue in issues), (
67
+ f"Expected unused 'import json' flagged, got: {issues}"
68
+ )
69
+
70
+
71
+ def test_should_not_flag_when_alias_is_used() -> None:
72
+ source = "import json as _json\n\ndef run() -> str:\n return _json.dumps({})\n"
73
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
74
+ assert issues == [], (
75
+ f"Aliased import referenced via alias must not flag, got: {issues}"
76
+ )
77
+
78
+
79
+ def test_should_skip_file_with_dunder_all() -> None:
80
+ source = (
81
+ "from config.preflight_constants import VENV_DIRECTORY_NAME\n"
82
+ "\n"
83
+ "__all__ = ['something']\n"
84
+ )
85
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
86
+ assert issues == [], (
87
+ f"Files declaring __all__ may re-export — skip to avoid false positives, got: {issues}"
88
+ )
89
+
90
+
91
+ def test_should_skip_file_with_dunder_all_annotated_assignment() -> None:
92
+ source = (
93
+ "from config.preflight_constants import VENV_DIRECTORY_NAME\n"
94
+ "\n"
95
+ '__all__: list[str] = ["VENV_DIRECTORY_NAME"]\n'
96
+ )
97
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
98
+ assert issues == [], (
99
+ "Annotated __all__ must skip unused-import scan like plain __all__, "
100
+ f"got: {issues}"
101
+ )
102
+
103
+
104
+ def test_should_skip_file_using_type_checking_block() -> None:
105
+ source = (
106
+ "from typing import TYPE_CHECKING\n"
107
+ "from config.constants import UNUSED_NAME\n"
108
+ "\n"
109
+ "if TYPE_CHECKING:\n"
110
+ " from somewhere import OtherName\n"
111
+ "\n"
112
+ "def run() -> None:\n"
113
+ " return None\n"
114
+ )
115
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
116
+ assert issues == [], (
117
+ f"TYPE_CHECKING-using files have annotation-only imports — skip, got: {issues}"
118
+ )
119
+
120
+
121
+ def test_should_skip_test_files() -> None:
122
+ source = (
123
+ "from config.constants import UNUSED_NAME\n"
124
+ "\n"
125
+ "def test_thing() -> None:\n"
126
+ " assert True\n"
127
+ )
128
+ issues = check_unused_module_level_imports(source, TEST_FILE_PATH)
129
+ assert issues == [], f"Test files exempt, got: {issues}"
130
+
131
+
132
+ def test_should_handle_syntax_error_gracefully() -> None:
133
+ source = "from config import (\n not python\n"
134
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
135
+ assert issues == [], f"Parse failure must return empty, got: {issues}"
136
+
137
+
138
+ def test_should_include_line_number_in_issue() -> None:
139
+ source = (
140
+ "\n"
141
+ "from config.preflight_constants import VENV_DIRECTORY_NAME\n"
142
+ "\n"
143
+ "def run() -> None:\n"
144
+ " return None\n"
145
+ )
146
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
147
+ assert any("Line 2" in each_issue for each_issue in issues), (
148
+ f"Expected line 2 reference, got: {issues}"
149
+ )
150
+
151
+
152
+ def test_should_flag_each_unused_in_multi_import() -> None:
153
+ source = (
154
+ "from config.constants import USED_ONE, UNUSED_TWO\n"
155
+ "\n"
156
+ "def run() -> str:\n"
157
+ " return USED_ONE\n"
158
+ )
159
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
160
+ assert any("UNUSED_TWO" in each_issue for each_issue in issues), (
161
+ f"Expected UNUSED_TWO flagged independently, got: {issues}"
162
+ )
163
+ assert not any("USED_ONE" in each_issue for each_issue in issues), (
164
+ f"USED_ONE is referenced — must not flag, got: {issues}"
165
+ )
166
+
167
+
168
+ def test_should_not_flag_when_referenced_in_string_annotation() -> None:
169
+ source = (
170
+ "from typing import List\n\ndef run(xs: List[int]) -> None:\n return None\n"
171
+ )
172
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
173
+ assert issues == [], (
174
+ f"List used in annotation must count as a reference, got: {issues}"
175
+ )
176
+
177
+
178
+ def test_should_skip_noqa_marked_imports() -> None:
179
+ source = (
180
+ "from config.constants import UNUSED_BUT_DELIBERATE # noqa: F401\n"
181
+ "\n"
182
+ "def run() -> None:\n"
183
+ " return None\n"
184
+ )
185
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
186
+ assert issues == [], (
187
+ f"noqa-marked imports are deliberate side-effect imports, skip, got: {issues}"
188
+ )
189
+
190
+
191
+ def test_should_skip_noqa_on_from_keyword_line_for_multiline_import() -> None:
192
+ source = (
193
+ "from config.constants import ( # noqa: F401\n"
194
+ " SOME_CONSTANT,\n"
195
+ " ANOTHER_CONSTANT,\n"
196
+ ")\n"
197
+ "\n"
198
+ "def run() -> None:\n"
199
+ " return None\n"
200
+ )
201
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
202
+ assert issues == [], (
203
+ f"noqa on the from-keyword line must suppress every alias in the block, got: {issues}"
204
+ )
205
+
206
+
207
+ def test_should_skip_future_annotations_import() -> None:
208
+ source = (
209
+ "from __future__ import annotations\n"
210
+ "\n"
211
+ "def run() -> None:\n"
212
+ " return None\n"
213
+ )
214
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
215
+ assert issues == [], (
216
+ f"__future__ imports are behavior-changing side-effect imports whose "
217
+ f"binding name is never referenced — skip, got: {issues}"
218
+ )
219
+
220
+
221
+ def test_should_skip_all_future_imports_regardless_of_name() -> None:
222
+ source = (
223
+ "from __future__ import annotations, division\n"
224
+ "\n"
225
+ "def run() -> None:\n"
226
+ " return None\n"
227
+ )
228
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
229
+ assert issues == [], (
230
+ f"All __future__ imports must be skipped regardless of binding name, got: {issues}"
231
+ )
232
+
233
+
234
+ def test_should_skip_star_import() -> None:
235
+ source = (
236
+ "from os.path import *\n"
237
+ "\n"
238
+ "def run() -> str:\n"
239
+ " return join('a', 'b')\n"
240
+ )
241
+ issues = check_unused_module_level_imports(source, PRODUCTION_FILE_PATH)
242
+ assert issues == [], (
243
+ f"Star imports cannot be meaningfully tracked - skip to avoid false positives, got: {issues}"
244
+ )
@@ -39,9 +39,8 @@ OTHER_REPO_NAME = "other-repo"
39
39
  OTHER_REPO_PATH = "C:\\Dev\\other-repo"
40
40
 
41
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)
42
+ def _run_main_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
43
+ """Return (stdout, stderr, exit_code) from running main() with the given stdin text."""
45
44
  captured_stdout = StringIO()
46
45
  captured_stderr = StringIO()
47
46
  exit_code = 0
@@ -57,6 +56,11 @@ def _run_main_with_input(hook_input: dict) -> tuple[str, str, int]:
57
56
  return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
58
57
 
59
58
 
59
+ def _run_main_with_input(hook_input: dict) -> tuple[str, str, int]:
60
+ """Return (stdout, stderr, exit_code) from running main() with the given hook input."""
61
+ return _run_main_with_stdin_text(json.dumps(hook_input))
62
+
63
+
60
64
  def _make_bash_input(command: str, description: str = "search files") -> dict:
61
65
  return {
62
66
  "tool_name": "Bash",
@@ -231,6 +235,80 @@ class TestEmittedJsonShape:
231
235
  assert decision != "deny", f"deny returned for command: {command!r}"
232
236
 
233
237
 
238
+ class TestStdinParsingRobustness:
239
+ def test_empty_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
240
+ stdout, stderr, exit_code = _run_main_with_stdin_text("")
241
+ assert exit_code == 0
242
+ assert stdout.strip() == ""
243
+ assert stderr.strip() == ""
244
+
245
+ def test_whitespace_only_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
246
+ stdout, stderr, exit_code = _run_main_with_stdin_text(" \n\t ")
247
+ assert exit_code == 0
248
+ assert stdout.strip() == ""
249
+ assert stderr.strip() == ""
250
+
251
+ def test_invalid_json_stdin_exits_zero_without_stdout_or_stderr(self) -> None:
252
+ stdout, stderr, exit_code = _run_main_with_stdin_text("{not valid")
253
+ assert exit_code == 0
254
+ assert stdout.strip() == ""
255
+ assert stderr.strip() == ""
256
+
257
+
258
+ class TestMalformedHookPayloadTypes:
259
+ def test_null_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
260
+ hook_input = {
261
+ "tool_name": "Bash",
262
+ "tool_input": None,
263
+ }
264
+ with patch(
265
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
266
+ ):
267
+ stdout, stderr, exit_code = _run_main_with_input(hook_input)
268
+ assert exit_code == 0
269
+ assert stdout.strip() == ""
270
+ assert stderr.strip() == ""
271
+
272
+ def test_list_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
273
+ hook_input = {
274
+ "tool_name": "Bash",
275
+ "tool_input": ["es.exe", KNOWN_REPO_NAME],
276
+ }
277
+ with patch(
278
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
279
+ ):
280
+ stdout, stderr, exit_code = _run_main_with_input(hook_input)
281
+ assert exit_code == 0
282
+ assert stdout.strip() == ""
283
+ assert stderr.strip() == ""
284
+
285
+ def test_string_tool_input_exits_zero_without_stdout_or_stderr(self) -> None:
286
+ hook_input = {
287
+ "tool_name": "Bash",
288
+ "tool_input": f"es.exe {KNOWN_REPO_NAME}",
289
+ }
290
+ with patch(
291
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
292
+ ):
293
+ stdout, stderr, exit_code = _run_main_with_input(hook_input)
294
+ assert exit_code == 0
295
+ assert stdout.strip() == ""
296
+ assert stderr.strip() == ""
297
+
298
+ def test_non_string_command_exits_zero_without_stdout_or_stderr(self) -> None:
299
+ hook_input = {
300
+ "tool_name": "Bash",
301
+ "tool_input": {"command": 12345, "description": "search"},
302
+ }
303
+ with patch(
304
+ "es_exe_path_rewriter.load_registry", return_value=REGISTRY_WITH_ONE_REPO
305
+ ):
306
+ stdout, stderr, exit_code = _run_main_with_input(hook_input)
307
+ assert exit_code == 0
308
+ assert stdout.strip() == ""
309
+ assert stderr.strip() == ""
310
+
311
+
234
312
  class TestNoOutputCases:
235
313
  def test_non_es_exe_command_produces_no_output(self) -> None:
236
314
  hook_input = _make_bash_input("git status")
@@ -5,7 +5,7 @@ import json
5
5
  import io
6
6
  import pathlib
7
7
  import sys
8
- from contextlib import redirect_stdout
8
+ from contextlib import redirect_stderr, redirect_stdout
9
9
 
10
10
  _HOOK_DIR = pathlib.Path(__file__).parent
11
11
  if str(_HOOK_DIR) not in sys.path:
@@ -82,13 +82,23 @@ def test_blocks_rmtree_with_nested_parens_in_args() -> None:
82
82
  )
83
83
 
84
84
 
85
+ DANGEROUS_RMTREE_SNIPPET = "shutil.rm" + "tree(path, ignore_errors" + "=True)"
86
+ DANGEROUS_RMTREE_SNIPPET_WITH_TARGET = (
87
+ "shutil.rm" + "tree(target_path, ignore_errors" + "=True)"
88
+ )
89
+
90
+
85
91
  def test_extract_payload_handles_write_content() -> None:
86
- extracted = extract_payload_text("Write", {"content": "abc"})
92
+ extracted = extract_payload_text(
93
+ "Write", {"file_path": "foo.py", "content": "abc"}
94
+ )
87
95
  assert extracted == "abc"
88
96
 
89
97
 
90
98
  def test_extract_payload_handles_edit_new_string() -> None:
91
- extracted = extract_payload_text("Edit", {"new_string": "abc"})
99
+ extracted = extract_payload_text(
100
+ "Edit", {"file_path": "foo.py", "new_string": "abc"}
101
+ )
92
102
  assert extracted == "abc"
93
103
 
94
104
 
@@ -102,19 +112,80 @@ def test_extract_payload_returns_empty_for_unknown_tool() -> None:
102
112
  assert extracted == ""
103
113
 
104
114
 
105
- def _run_hook(hook_input: dict) -> tuple[str, int]:
106
- captured = io.StringIO()
115
+ def test_extract_payload_returns_empty_for_write_to_non_python_file() -> None:
116
+ extracted = extract_payload_text(
117
+ "Write",
118
+ {
119
+ "file_path": "agents/clean-coder.md",
120
+ "content": DANGEROUS_RMTREE_SNIPPET,
121
+ },
122
+ )
123
+ assert extracted == ""
124
+
125
+
126
+ def test_extract_payload_returns_empty_for_edit_to_non_python_file() -> None:
127
+ extracted = extract_payload_text(
128
+ "Edit",
129
+ {
130
+ "file_path": "docs/something.md",
131
+ "new_string": DANGEROUS_RMTREE_SNIPPET,
132
+ },
133
+ )
134
+ assert extracted == ""
135
+
136
+
137
+ def test_extract_payload_returns_content_for_write_to_python_file() -> None:
138
+ extracted = extract_payload_text(
139
+ "Write",
140
+ {
141
+ "file_path": "hooks/blocking/my_hook.py",
142
+ "content": DANGEROUS_RMTREE_SNIPPET_WITH_TARGET,
143
+ },
144
+ )
145
+ assert extracted == DANGEROUS_RMTREE_SNIPPET_WITH_TARGET
146
+
147
+
148
+ def test_python_file_extension_constant_drives_python_filter() -> None:
149
+ python_extension = hook_module.PYTHON_FILE_EXTENSION
150
+ extracted_for_python = extract_payload_text(
151
+ "Write",
152
+ {
153
+ "file_path": f"hooks/blocking/sample{python_extension}",
154
+ "content": DANGEROUS_RMTREE_SNIPPET_WITH_TARGET,
155
+ },
156
+ )
157
+ assert extracted_for_python == DANGEROUS_RMTREE_SNIPPET_WITH_TARGET
158
+
159
+
160
+ def test_extract_payload_returns_content_for_write_without_file_path() -> None:
161
+ extracted = extract_payload_text(
162
+ "Write",
163
+ {"content": "some python code"},
164
+ )
165
+ assert extracted == "some python code"
166
+
167
+
168
+ def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
169
+ captured_stdout = io.StringIO()
170
+ captured_stderr = io.StringIO()
107
171
  exit_code = 0
108
- sys.stdin = io.StringIO(json.dumps(hook_input))
172
+ sys.stdin = io.StringIO(stdin_text)
109
173
  try:
110
- with redirect_stdout(captured):
174
+ with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
111
175
  try:
112
176
  hook_module.main()
113
177
  except SystemExit as exit_signal:
114
178
  exit_code = exit_signal.code or 0
115
179
  finally:
116
180
  sys.stdin = sys.__stdin__
117
- return captured.getvalue(), exit_code
181
+ return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
182
+
183
+
184
+ def _run_hook(hook_input: dict) -> tuple[str, int]:
185
+ stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(
186
+ json.dumps(hook_input)
187
+ )
188
+ return stdout_text, exit_code
118
189
 
119
190
 
120
191
  def test_main_blocks_unsafe_bash_command() -> None:
@@ -153,3 +224,44 @@ def test_main_passes_through_unrelated_tool() -> None:
153
224
  )
154
225
  assert exit_code == 0
155
226
  assert stdout_text == ""
227
+
228
+
229
+ def test_main_passes_through_unsafe_write_to_non_python_file() -> None:
230
+ stdout_text, exit_code = _run_hook(
231
+ {
232
+ "tool_name": "Write",
233
+ "tool_input": {
234
+ "file_path": "agents/clean-coder.md",
235
+ "content": DANGEROUS_RMTREE_SNIPPET,
236
+ },
237
+ }
238
+ )
239
+ assert exit_code == 0
240
+ assert stdout_text == ""
241
+
242
+
243
+ def test_main_blocks_write_with_missing_file_path_and_unsafe_content() -> None:
244
+ stdout_text, exit_code = _run_hook(
245
+ {
246
+ "tool_name": "Write",
247
+ "tool_input": {"content": DANGEROUS_RMTREE_SNIPPET_WITH_TARGET},
248
+ }
249
+ )
250
+ assert exit_code == 0
251
+ response_payload = json.loads(stdout_text)
252
+ decision_block = response_payload["hookSpecificOutput"]
253
+ assert decision_block["permissionDecision"] == "deny"
254
+
255
+
256
+ def test_main_with_empty_stdin_exits_silently() -> None:
257
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
258
+ assert exit_code == 0
259
+ assert stdout_text == ""
260
+ assert stderr_text == ""
261
+
262
+
263
+ def test_main_with_invalid_json_stdin_exits_silently() -> None:
264
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
265
+ assert exit_code == 0
266
+ assert stdout_text == ""
267
+ assert stderr_text == ""
@@ -15,6 +15,20 @@ blocks it with a corrective message pointing to the force_rmtree replacement.
15
15
  import json
16
16
  import re
17
17
  import sys
18
+ from pathlib import Path
19
+
20
+
21
+ def _insert_hooks_tree_for_imports() -> None:
22
+ hooks_tree = Path(__file__).resolve().parent.parent
23
+ hooks_tree_string = str(hooks_tree)
24
+ if hooks_tree_string not in sys.path:
25
+ sys.path.insert(0, hooks_tree_string)
26
+
27
+
28
+ _insert_hooks_tree_for_imports()
29
+
30
+ from config.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
31
+ from config.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION
18
32
 
19
33
 
20
34
  def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
@@ -29,6 +43,9 @@ def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
29
43
 
30
44
  def extract_payload_text(tool_name: str, tool_input: dict) -> str:
31
45
  if tool_name in {"Write", "Edit"}:
46
+ file_path = tool_input.get("file_path", "")
47
+ if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
48
+ return ""
32
49
  return tool_input.get("content", "") or tool_input.get("new_string", "") or ""
33
50
  if tool_name == "Bash":
34
51
  return tool_input.get("command", "") or ""
@@ -72,14 +89,14 @@ def main() -> None:
72
89
  "the work that originally failed.\n\n"
73
90
  "See ~/.claude/rules/windows-filesystem-safe.md for full guidance."
74
91
  )
75
- try:
76
- hook_input = json.load(sys.stdin)
77
- except json.JSONDecodeError:
78
- sys.stderr.write("windows_rmtree_blocker: malformed JSON on stdin\n")
92
+ hook_input = read_hook_input_dictionary_from_stdin()
93
+ if hook_input is None:
79
94
  sys.exit(0)
80
95
 
81
- tool_name = hook_input.get("tool_name", "")
82
- tool_input = hook_input.get("tool_input", {})
96
+ raw_tool_name = hook_input.get("tool_name", "")
97
+ raw_tool_input = hook_input.get("tool_input", {})
98
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
99
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
83
100
 
84
101
  payload_text = extract_payload_text(tool_name, tool_input)
85
102
 
@@ -0,0 +1,24 @@
1
+ """Configuration constants for the banned-identifier check in code_rules_enforcer."""
2
+
3
+ ALL_BANNED_IDENTIFIERS: frozenset[str] = frozenset(
4
+ {
5
+ "result",
6
+ "data",
7
+ "output",
8
+ "response",
9
+ "value",
10
+ "item",
11
+ "temp",
12
+ "argv",
13
+ "args",
14
+ "kwargs",
15
+ "argc",
16
+ }
17
+ )
18
+ MAX_BANNED_IDENTIFIER_ISSUES: int = 3
19
+ BANNED_IDENTIFIER_MESSAGE_SUFFIX: str = (
20
+ "use descriptive name (see CODE_RULES Naming section)"
21
+ )
22
+ BANNED_IDENTIFIER_SKIP_ADVISORY: str = (
23
+ "banned-identifier check skipped: file did not parse as Python"
24
+ )
@@ -0,0 +1,12 @@
1
+ """Configuration constants for the hardcoded-user-path check in code_rules_enforcer."""
2
+
3
+ import re
4
+
5
+ HARDCODED_USER_PATH_PATTERN: re.Pattern[str] = re.compile(
6
+ r"(?:"
7
+ r"[A-Za-z]:[\\/](?i:users)[\\/](?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
8
+ r"|(?<![A-Za-z:])/Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
9
+ r"|/home/[^/]+(?=/|$))"
10
+ )
11
+ MAX_HARDCODED_USER_PATH_ISSUES: int = 25
12
+ HARDCODED_USER_PATH_GUIDANCE: str = "use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
@@ -226,7 +226,7 @@ STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE: str = str(
226
226
  )
227
227
 
228
228
  WINDOWS_OS_NAME: str = "nt"
229
- WINDOWS_DETACHED_PROCESS_FLAG: int = 0x00000008
229
+ WINDOWS_CREATE_NO_WINDOW_FLAG: int = 0x08000000
230
230
  WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG: int = 0x00000200
231
231
 
232
232
  LOCK_MAXIMUM_RETRY_COUNT: int = 30
@@ -0,0 +1,48 @@
1
+ """Shared stdin parsing for PreToolUse hooks that expect one JSON object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from config.setup_project_paths_constants import (
9
+ DECODE_ERRORS_POLICY,
10
+ UTF8_BYTE_ORDER_MARK,
11
+ UTF8_ENCODING,
12
+ )
13
+
14
+
15
+ def _read_stdin_text() -> str | None:
16
+ try:
17
+ raw_bytes = sys.stdin.buffer.read()
18
+ except (AttributeError, OSError):
19
+ try:
20
+ decoded_text = sys.stdin.read()
21
+ except (AttributeError, OSError):
22
+ return None
23
+ if decoded_text is None:
24
+ return None
25
+ return decoded_text
26
+ return raw_bytes.decode(UTF8_ENCODING, errors=DECODE_ERRORS_POLICY)
27
+
28
+
29
+ def read_hook_input_dictionary_from_stdin() -> dict[str, object] | None:
30
+ """Return the hook payload dict, or None when stdin is empty or not a JSON object.
31
+
32
+ Reads the full stdin stream, strips a UTF-8 BOM and surrounding whitespace, then
33
+ parses JSON. Malformed JSON, non-object roots, and empty payloads yield None so
34
+ callers can exit zero without treating the hook as a hard failure.
35
+ """
36
+ decoded_text = _read_stdin_text()
37
+ if decoded_text is None:
38
+ return None
39
+ normalized_text = decoded_text.strip().removeprefix(UTF8_BYTE_ORDER_MARK).strip()
40
+ if not normalized_text:
41
+ return None
42
+ try:
43
+ parsed_payload = json.loads(normalized_text)
44
+ except json.JSONDecodeError:
45
+ return None
46
+ if not isinstance(parsed_payload, dict):
47
+ return None
48
+ return parsed_payload
@@ -32,6 +32,10 @@ META_KEY = "_meta"
32
32
 
33
33
  UTF8_ENCODING = "utf-8"
34
34
 
35
+ DECODE_ERRORS_POLICY = "replace"
36
+
37
+ UTF8_BYTE_ORDER_MARK = "\ufeff"
38
+
35
39
  CONFIRMATION_PROMPT_TEXT = "Write this mapping to the config file? (yes/no): "
36
40
 
37
41
  ABORTED_NOTHING_WRITTEN_MESSAGE = "Aborted. Nothing written."
@@ -0,0 +1,14 @@
1
+ """Constants for the stuttering ``all_``/``ALL_`` prefix detector.
2
+
3
+ Lives under the hooks-tree ``config/`` package so module-level
4
+ UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
5
+ requirement and share a home with the other hook-tree configuration
6
+ (``messages``, ``dynamic_stderr_handler``, ``project_paths_reader``).
7
+ """
8
+
9
+ import re
10
+
11
+ STUTTERING_ALL_PREFIX_PATTERN: re.Pattern[str] = re.compile(
12
+ r"^_?(?:all_){2,}|^_?(?:ALL_){2,}"
13
+ )
14
+ MAX_STUTTERING_PREFIX_ISSUES: int = 50