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.
- package/agents/clean-coder.md +109 -1
- package/agents/docs-agent.md +1 -1
- package/agents/project-docs-analyzer.md +0 -1
- package/agents/skill-to-agent-converter.md +0 -1
- package/bin/install.mjs +28 -8
- package/bin/install.test.mjs +9 -1
- package/commands/initialize.md +0 -1
- package/commands/readability-review.md +4 -4
- package/commands/review-plan.md +2 -4
- package/commands/stubcheck.md +1 -2
- package/docs/CODE_RULES.md +3 -0
- package/docs/agents-md-alignment-plan.md +123 -0
- package/hooks/blocking/code_rules_enforcer.py +686 -60
- package/hooks/blocking/es_exe_path_rewriter.py +10 -4
- package/hooks/blocking/test_code_rules_enforcer.py +273 -39
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +97 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +173 -0
- package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +328 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +0 -20
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +33 -11
- package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +0 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +291 -0
- package/hooks/blocking/test_code_rules_enforcer_inline_literal_collections.py +155 -0
- package/hooks/blocking/test_code_rules_enforcer_loop_variable_naming.py +194 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +49 -13
- package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +0 -26
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +234 -0
- package/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +244 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +81 -3
- package/hooks/blocking/test_windows_rmtree_blocker.py +120 -8
- package/hooks/blocking/windows_rmtree_blocker.py +23 -6
- package/hooks/config/banned_identifiers_constants.py +24 -0
- package/hooks/config/hardcoded_user_path_constants.py +12 -0
- package/hooks/config/hook_log_extractor_constants.py +1 -1
- package/hooks/config/pre_tool_use_stdin.py +48 -0
- package/hooks/config/setup_project_paths_constants.py +4 -0
- package/hooks/config/stuttering_check_config.py +14 -0
- package/hooks/config/stuttering_import_binding_constants.py +11 -0
- package/hooks/config/sys_path_insert_constants.py +4 -0
- package/hooks/config/test_banned_identifiers_constants.py +48 -0
- package/hooks/config/test_hardcoded_user_path_constants.py +78 -0
- package/hooks/config/test_hook_log_extractor_constants.py +3 -3
- package/hooks/config/test_pre_tool_use_stdin.py +80 -0
- package/hooks/config/unused_module_import_constants.py +7 -0
- package/hooks/config/windows_rmtree_blocker_constants.py +3 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +7 -4
- package/hooks/git-hooks/config.py +3 -3
- package/hooks/git-hooks/test_gate_utils.py +10 -10
- package/hooks/mypy.ini +2 -0
- package/package.json +1 -1
- package/rules/gh-paginate.md +125 -0
- package/skills/bugteam/CONSTRAINTS.md +12 -6
- package/skills/bugteam/PROMPTS.md +0 -39
- package/skills/bugteam/SKILL.md +93 -125
- package/skills/bugteam/SKILL_EVALS.md +25 -23
- package/skills/bugteam/reference/README.md +2 -0
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +12 -0
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +113 -0
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +48 -0
- package/skills/bugteam/test_skill_additions.py +13 -4
- package/skills/bugteam/test_team_lifecycle.py +94 -0
- package/skills/findbugs/SKILL.md +3 -3
- package/skills/fixbugs/SKILL.md +4 -4
- package/skills/monitor-open-prs/SKILL.md +32 -2
- package/skills/monitor-open-prs/test_team_lifecycle.py +46 -0
- package/skills/pr-converge/SKILL.md +576 -95
- package/skills/pr-converge/scripts/README.md +145 -0
- package/skills/pr-converge/scripts/caller-window-pid.ps1 +86 -0
- package/skills/pr-converge/scripts/check_pr_mergeability.py +79 -0
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +65 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +176 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-caller.cmd +9 -0
- package/skills/pr-converge/scripts/cursor-agents-continue-stop-others.ps1 +16 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +172 -0
- package/skills/pr-converge/scripts/cursor-agents-continue.cmd +2 -0
- package/skills/pr-converge/scripts/evict_cached_config_modules.py +20 -0
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +110 -0
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +103 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +112 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +121 -0
- package/skills/pr-converge/scripts/mark_pr_ready.py +54 -0
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +136 -0
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +49 -0
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +33 -0
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +84 -0
- package/skills/pr-converge/scripts/request_copilot_review.py +71 -0
- package/skills/pr-converge/scripts/resolve_pr_head.py +58 -0
- package/skills/pr-converge/scripts/review_field_helpers.py +43 -0
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +126 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +22 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +342 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +220 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +372 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +280 -0
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +69 -0
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +236 -0
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +195 -0
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +159 -0
- package/skills/pr-converge/scripts/test_request_copilot_review.py +101 -0
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +79 -0
- package/skills/pr-converge/scripts/test_review_field_helpers.py +80 -0
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +139 -0
- package/skills/pr-converge/scripts/test_view_pr_context.py +111 -0
- package/skills/pr-converge/scripts/trigger_bugbot.py +77 -0
- package/skills/pr-converge/scripts/view_pr_context.py +47 -0
- package/skills/pr-converge/test_team_lifecycle.py +47 -0
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +108 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +37 -0
- package/skills/qbug/SKILL.md +4 -4
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +2 -2
- package/skills/resume-review/SKILL.md +261 -0
- package/agents/agent-writer.md +0 -157
- package/agents/config-centralizer.md +0 -686
- package/agents/config-extraction-agent.md +0 -225
- package/agents/doc-orchestrator.md +0 -47
- package/agents/docx-agent.md +0 -211
- package/agents/magic-value-eliminator-agent.md +0 -72
- package/agents/mandatory-agent-workflow-agent.md +0 -88
- package/agents/parallel-workflow-coordinator.md +0 -779
- package/agents/pdf-agent.md +0 -302
- package/agents/project-context-loader.md +0 -238
- package/agents/readability-review-agent.md +0 -76
- package/agents/refactoring-specialist.md +0 -69
- package/agents/right-sized-engineer.md +0 -129
- package/agents/session-continuity-manager.md +0 -53
- package/agents/stub-detector-agent.md +0 -140
- package/agents/tdd-test-writer.md +0 -62
- package/agents/test-data-builder.md +0 -68
- package/agents/tooling-builder.md +0 -78
- package/agents/validation-expert.md +0 -71
- package/agents/xlsx-agent.md +0 -169
- package/skills/bugteam/scripts/README.md +0 -58
- package/skills/bugteam/scripts/_claude_permissions_common.py +0 -219
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +0 -633
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +0 -260
- package/skills/bugteam/scripts/bugteam_preflight.py +0 -201
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +0 -17
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -109
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -135
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +0 -271
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +0 -267
- package/skills/bugteam/scripts/test_bugteam_preflight.py +0 -189
- package/skills/bugteam/scripts/test_claude_permissions_common.py +0 -44
- /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
|
|
43
|
-
"""Return (stdout, stderr, exit_code) from running main() with the given
|
|
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(
|
|
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(
|
|
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
|
|
106
|
-
|
|
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(
|
|
172
|
+
sys.stdin = io.StringIO(stdin_text)
|
|
109
173
|
try:
|
|
110
|
-
with redirect_stdout(
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|