claude-dev-env 1.73.0 → 1.75.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/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +17 -23
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +18 -26
- package/hooks/blocking/md_to_html_blocker.py +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
- package/hooks/blocking/question_to_user_enforcer.py +19 -23
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +15 -23
- package/hooks/blocking/state_description_blocker.py +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/CLAUDE.md +8 -1
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +23 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/autoconverge/SKILL.md +21 -1
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +599 -606
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
|
@@ -31,6 +31,7 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
|
|
|
31
31
|
PACKAGES_TOP_LEVEL_SEGMENT,
|
|
32
32
|
PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
|
|
33
33
|
)
|
|
34
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
34
35
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
36
|
read_hook_input_dictionary_from_stdin,
|
|
36
37
|
)
|
|
@@ -123,17 +124,24 @@ def main() -> None:
|
|
|
123
124
|
if is_exempt_path(file_path):
|
|
124
125
|
sys.exit(0)
|
|
125
126
|
|
|
127
|
+
deny_reason = _block_reason(file_path)
|
|
126
128
|
block_payload = {
|
|
127
129
|
"hookSpecificOutput": {
|
|
128
130
|
"hookEventName": "PreToolUse",
|
|
129
131
|
"permissionDecision": "deny",
|
|
130
|
-
"permissionDecisionReason":
|
|
132
|
+
"permissionDecisionReason": deny_reason,
|
|
131
133
|
"additionalContext": _block_context(),
|
|
132
134
|
},
|
|
133
135
|
"systemMessage": _block_system_message(),
|
|
134
136
|
"suppressOutput": True,
|
|
135
137
|
}
|
|
136
|
-
|
|
138
|
+
log_hook_block(
|
|
139
|
+
calling_hook_name="md_to_html_blocker.py",
|
|
140
|
+
hook_event="PreToolUse",
|
|
141
|
+
block_reason=deny_reason,
|
|
142
|
+
tool_name=tool_name,
|
|
143
|
+
offending_input_preview=file_path,
|
|
144
|
+
)
|
|
137
145
|
_emit_hook_result(block_payload, sys.stdout)
|
|
138
146
|
sys.exit(0)
|
|
139
147
|
|
|
@@ -31,6 +31,7 @@ from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa:
|
|
|
31
31
|
PLANS_PATH_SEGMENT,
|
|
32
32
|
UNREADABLE_FILE_SYNTHETIC_CONTENT,
|
|
33
33
|
)
|
|
34
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
34
35
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
36
|
read_hook_input_dictionary_from_stdin,
|
|
36
37
|
)
|
|
@@ -236,17 +237,24 @@ def main() -> None:
|
|
|
236
237
|
if not _content_has_open_questions(candidate_content):
|
|
237
238
|
sys.exit(0)
|
|
238
239
|
|
|
240
|
+
deny_reason = _block_reason(file_path)
|
|
239
241
|
block_payload = {
|
|
240
242
|
"hookSpecificOutput": {
|
|
241
243
|
"hookEventName": "PreToolUse",
|
|
242
244
|
"permissionDecision": "deny",
|
|
243
|
-
"permissionDecisionReason":
|
|
245
|
+
"permissionDecisionReason": deny_reason,
|
|
244
246
|
"additionalContext": _block_context(),
|
|
245
247
|
},
|
|
246
248
|
"systemMessage": _block_system_message(),
|
|
247
249
|
"suppressOutput": True,
|
|
248
250
|
}
|
|
249
|
-
|
|
251
|
+
log_hook_block(
|
|
252
|
+
calling_hook_name="open_questions_in_plans_blocker.py",
|
|
253
|
+
hook_event="PreToolUse",
|
|
254
|
+
block_reason=deny_reason,
|
|
255
|
+
tool_name=tool_name,
|
|
256
|
+
offending_input_preview=file_path,
|
|
257
|
+
)
|
|
250
258
|
_emit_hook_result(block_payload, sys.stdout)
|
|
251
259
|
sys.exit(0)
|
|
252
260
|
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks a new production file absent from its package inventory.
|
|
3
|
+
|
|
4
|
+
A package directory documents its own files in a sibling inventory document — a
|
|
5
|
+
``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list — whose entries
|
|
6
|
+
name each file in backticks. When a Write creates a new production code file in a
|
|
7
|
+
directory whose inventory already names two or more sibling files but carries no
|
|
8
|
+
entry naming the new file, the inventory and the directory disagree on the
|
|
9
|
+
package's file set: a reader who trusts the inventory to map the directory misses
|
|
10
|
+
the new file. This hook fires on a Write that creates such a file and blocks it,
|
|
11
|
+
directing the author to add the inventory entry in the same change. Edits to an
|
|
12
|
+
existing file, exempt files (``__init__.py``, ``conftest.py``, ``setup.py``,
|
|
13
|
+
``_path_setup.py``), test files, and files inside a directory that carries no
|
|
14
|
+
per-file inventory (such as ``config/`` or ``tests/``) are out of scope.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TextIO
|
|
22
|
+
|
|
23
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
24
|
+
if _hooks_dir not in sys.path:
|
|
25
|
+
sys.path.insert(0, _hooks_dir)
|
|
26
|
+
|
|
27
|
+
from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa: E402
|
|
28
|
+
ALL_EXEMPT_BASENAMES,
|
|
29
|
+
ALL_EXEMPT_DIRECTORY_NAMES,
|
|
30
|
+
ALL_INVENTORY_DOCUMENT_NAMES,
|
|
31
|
+
ALL_PRODUCTION_CODE_EXTENSIONS,
|
|
32
|
+
ALL_TEST_FILE_MARKERS,
|
|
33
|
+
BACKTICK_TOKEN_PATTERN,
|
|
34
|
+
CODE_FENCE_PATTERN,
|
|
35
|
+
GLOB_METACHARACTER_PATTERN,
|
|
36
|
+
MAX_INVENTORY_FILE_BYTES,
|
|
37
|
+
MINIMUM_INVENTORY_ENTRY_COUNT,
|
|
38
|
+
NON_FILENAME_TOKEN_PATTERN,
|
|
39
|
+
PYTHON_FILE_EXTENSION,
|
|
40
|
+
STALE_INVENTORY_ADDITIONAL_CONTEXT,
|
|
41
|
+
STALE_INVENTORY_MESSAGE_TEMPLATE,
|
|
42
|
+
STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
43
|
+
)
|
|
44
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
45
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
46
|
+
read_hook_input_dictionary_from_stdin,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _basename_token(backtick_inner_text: str) -> str | None:
|
|
51
|
+
"""Return the bare filename a backticked token names, when it names one.
|
|
52
|
+
|
|
53
|
+
A token names a bare filename when it is a single filename or path token
|
|
54
|
+
carrying a known file extension. A token that holds a path keeps only its
|
|
55
|
+
final segment, so an inventory cell naming ``pipeline/seam_continuity.py``
|
|
56
|
+
yields ``seam_continuity.py`` — the basename the directory file would match.
|
|
57
|
+
A slash-command token (leading ``/``), a glob/pattern token carrying a
|
|
58
|
+
metacharacter (``*``, ``?``, brace or bracket range, so ``*.py`` and
|
|
59
|
+
``test_*.py`` name no literal file), a multi-word command-example span
|
|
60
|
+
carrying whitespace or shell punctuation (``:``, ``$``, ``<``, ``>``, so
|
|
61
|
+
``parent:node_modules package.json`` and ``python <file>.py`` name no
|
|
62
|
+
literal file), and a token with no file extension yield None.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
backtick_inner_text: The text between a backtick pair, stripped.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The bare basename the token references, or None when it names no file.
|
|
69
|
+
"""
|
|
70
|
+
inner_text = backtick_inner_text.strip()
|
|
71
|
+
if not inner_text or inner_text.startswith("/"):
|
|
72
|
+
return None
|
|
73
|
+
if GLOB_METACHARACTER_PATTERN.search(inner_text) is not None:
|
|
74
|
+
return None
|
|
75
|
+
if NON_FILENAME_TOKEN_PATTERN.search(inner_text) is not None:
|
|
76
|
+
return None
|
|
77
|
+
basename = os.path.basename(inner_text.replace("\\", "/").rstrip("/"))
|
|
78
|
+
if not basename:
|
|
79
|
+
return None
|
|
80
|
+
_, extension = os.path.splitext(basename)
|
|
81
|
+
if not extension:
|
|
82
|
+
return None
|
|
83
|
+
return basename
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _lines_outside_code_fences(inventory_content: str) -> list[str]:
|
|
87
|
+
"""Return the inventory lines that sit outside any fenced code block.
|
|
88
|
+
|
|
89
|
+
A line inside a ``` or ~~~ fence pair is example or sample text, not a live
|
|
90
|
+
listing, so it is dropped — mirroring the fence handling in
|
|
91
|
+
``claude_md_orphan_file_blocker``.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
inventory_content: The text of a README.md or CLAUDE.md inventory.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The lines that lie outside every code fence, in document order.
|
|
98
|
+
"""
|
|
99
|
+
live_lines: list[str] = []
|
|
100
|
+
is_inside_code_fence = False
|
|
101
|
+
for each_line in inventory_content.splitlines():
|
|
102
|
+
if CODE_FENCE_PATTERN.match(each_line) is not None:
|
|
103
|
+
is_inside_code_fence = not is_inside_code_fence
|
|
104
|
+
continue
|
|
105
|
+
if is_inside_code_fence:
|
|
106
|
+
continue
|
|
107
|
+
live_lines.append(each_line)
|
|
108
|
+
return live_lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def inventory_named_basenames(inventory_content: str) -> set[str]:
|
|
112
|
+
"""Return every bare filename a package inventory document names in backticks.
|
|
113
|
+
|
|
114
|
+
Lines inside a fenced code block are skipped as example text. Each backticked
|
|
115
|
+
token on a remaining line is examined; one that names a literal file (a single
|
|
116
|
+
filename or path token that carries an extension, no glob metacharacter, and
|
|
117
|
+
no whitespace or shell punctuation) contributes its basename, and a token
|
|
118
|
+
holding a path contributes its final segment. A multi-word command-example
|
|
119
|
+
span contributes nothing. This covers both a README.md table cell and a
|
|
120
|
+
CLAUDE.md bullet, since both name files in backticks.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
inventory_content: The text of a README.md or CLAUDE.md inventory.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The set of bare basenames the inventory names.
|
|
127
|
+
"""
|
|
128
|
+
named_basenames: set[str] = set()
|
|
129
|
+
for each_line in _lines_outside_code_fences(inventory_content):
|
|
130
|
+
for each_match in BACKTICK_TOKEN_PATTERN.finditer(each_line):
|
|
131
|
+
each_basename = _basename_token(each_match.group(1))
|
|
132
|
+
if each_basename is not None:
|
|
133
|
+
named_basenames.add(each_basename)
|
|
134
|
+
return named_basenames
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read_inventory_content(inventory_path: Path) -> str | None:
|
|
138
|
+
"""Return the text of an inventory document, or None when it is unreadable.
|
|
139
|
+
|
|
140
|
+
A document larger than the byte budget is skipped (None), so an oversized
|
|
141
|
+
file never stalls the hook.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
inventory_path: The path of the README.md or CLAUDE.md to read.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The document text, or None when it is missing, oversized, or undecodable.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
if inventory_path.stat().st_size > MAX_INVENTORY_FILE_BYTES:
|
|
151
|
+
return None
|
|
152
|
+
return inventory_path.read_text(encoding="utf-8")
|
|
153
|
+
except (OSError, UnicodeDecodeError):
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _InventorySurvey:
|
|
158
|
+
"""The inventory documents found beside a file and the files they name.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
present_inventory_names: The inventory document basenames present in the
|
|
162
|
+
directory (``README.md`` and/or ``CLAUDE.md``).
|
|
163
|
+
named_basenames: Every bare filename the present inventories name.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self, all_present_inventory_names: list[str], all_named_basenames: set[str]
|
|
168
|
+
) -> None:
|
|
169
|
+
self.present_inventory_names = all_present_inventory_names
|
|
170
|
+
self.named_basenames = all_named_basenames
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def survey_directory_inventories(package_directory: Path) -> _InventorySurvey:
|
|
174
|
+
"""Return the inventory documents beside a file and the basenames they name.
|
|
175
|
+
|
|
176
|
+
Reads each present ``README.md`` and ``CLAUDE.md`` in *package_directory* and
|
|
177
|
+
unions the basenames they name in backticks.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
package_directory: The directory that holds the file being written.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The survey pairing the present inventory document names with the union of
|
|
184
|
+
the basenames they name.
|
|
185
|
+
"""
|
|
186
|
+
present_inventory_names: list[str] = []
|
|
187
|
+
named_basenames: set[str] = set()
|
|
188
|
+
for each_inventory_name in sorted(ALL_INVENTORY_DOCUMENT_NAMES):
|
|
189
|
+
inventory_path = package_directory / each_inventory_name
|
|
190
|
+
inventory_content = _read_inventory_content(inventory_path)
|
|
191
|
+
if inventory_content is None:
|
|
192
|
+
continue
|
|
193
|
+
present_inventory_names.append(each_inventory_name)
|
|
194
|
+
named_basenames |= inventory_named_basenames(inventory_content)
|
|
195
|
+
return _InventorySurvey(present_inventory_names, named_basenames)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_test_file(basename: str) -> bool:
|
|
199
|
+
"""Return whether *basename* names a test file the inventory need not list.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
basename: The bare filename of the file being written.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True when the name matches a test-file shape (``test_*.py``,
|
|
206
|
+
``*_test.py``, ``*.spec.*``, or ``*.test.*``).
|
|
207
|
+
"""
|
|
208
|
+
if basename.startswith("test_") and basename.endswith(PYTHON_FILE_EXTENSION):
|
|
209
|
+
return True
|
|
210
|
+
if basename.endswith("_test" + PYTHON_FILE_EXTENSION):
|
|
211
|
+
return True
|
|
212
|
+
return any(each_marker in basename for each_marker in ALL_TEST_FILE_MARKERS)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _is_under_exempt_directory(package_directory: Path) -> bool:
|
|
216
|
+
"""Return whether the file's directory is itself an exempt directory.
|
|
217
|
+
|
|
218
|
+
A file directly inside a directory that carries no per-file inventory (such
|
|
219
|
+
as ``config/`` or ``tests/``) has no individual entry, so its directory
|
|
220
|
+
exempts it.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
package_directory: The directory that holds the file being written.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True when the directory's own name is an exempt directory name.
|
|
227
|
+
"""
|
|
228
|
+
return package_directory.name in ALL_EXEMPT_DIRECTORY_NAMES
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_inventoried_production_file(file_path: str) -> bool:
|
|
232
|
+
"""Return whether *file_path* is a production file an inventory should name.
|
|
233
|
+
|
|
234
|
+
A production file is a non-test, non-exempt code file (``.py``, ``.mjs``,
|
|
235
|
+
``.js``, ``.ts``, ``.ps1``, ``.sh``) outside a directory that carries no
|
|
236
|
+
per-file inventory (such as ``config/`` or ``tests/``). Exempt basenames
|
|
237
|
+
(``__init__.py``, ``conftest.py``, ``setup.py``, ``_path_setup.py``) and
|
|
238
|
+
test files are out of scope.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
file_path: The destination path of the write.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
True when the file is one an inventory entry should name.
|
|
245
|
+
"""
|
|
246
|
+
basename = os.path.basename(file_path)
|
|
247
|
+
_, extension = os.path.splitext(basename)
|
|
248
|
+
if extension.lower() not in ALL_PRODUCTION_CODE_EXTENSIONS:
|
|
249
|
+
return False
|
|
250
|
+
if basename in ALL_EXEMPT_BASENAMES:
|
|
251
|
+
return False
|
|
252
|
+
if _is_test_file(basename):
|
|
253
|
+
return False
|
|
254
|
+
return not _is_under_exempt_directory(Path(file_path).resolve().parent)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _sibling_named_basenames(
|
|
258
|
+
package_directory: Path, all_named_basenames: set[str]
|
|
259
|
+
) -> set[str]:
|
|
260
|
+
"""Return the named basenames that exist as files in *package_directory*.
|
|
261
|
+
|
|
262
|
+
A maintained inventory lists the directory's own files, so a named basename
|
|
263
|
+
counts toward the inventory only when a file with that basename sits directly
|
|
264
|
+
in the directory. A name the inventory mentions in passing — a file living in
|
|
265
|
+
another directory (``install.mjs``), a sibling doc — is dropped, so prose that
|
|
266
|
+
references non-sibling files never reads as a maintained inventory.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
package_directory: The directory that holds the file being written.
|
|
270
|
+
all_named_basenames: Every bare basename the inventory documents name.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The subset of *all_named_basenames* present as a file in the directory.
|
|
274
|
+
"""
|
|
275
|
+
sibling_basenames: set[str] = set()
|
|
276
|
+
for each_basename in all_named_basenames:
|
|
277
|
+
if (package_directory / each_basename).is_file():
|
|
278
|
+
sibling_basenames.add(each_basename)
|
|
279
|
+
return sibling_basenames
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
|
|
283
|
+
"""Return the maintained inventory survey a new file is absent from, or None.
|
|
284
|
+
|
|
285
|
+
The file's directory inventories are surveyed, then the named basenames are
|
|
286
|
+
filtered to those that exist as files in the directory — the inventory's own
|
|
287
|
+
sibling files. The survey reports a stale inventory only when every condition
|
|
288
|
+
holds: the directory carries at least one inventory document, those documents
|
|
289
|
+
together name at least the minimum entry count of on-disk sibling files
|
|
290
|
+
(marking them a maintained inventory rather than incidental prose that
|
|
291
|
+
mentions files living elsewhere), and the inventory does not already name this
|
|
292
|
+
file's basename. When any condition fails the file is in step with its
|
|
293
|
+
inventory (or there is no inventory to be out of step with), so None results.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
file_path: The destination path of the write.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The inventory survey when the file is a stale omission, or None.
|
|
300
|
+
"""
|
|
301
|
+
package_directory = Path(file_path).resolve().parent
|
|
302
|
+
if not package_directory.is_dir():
|
|
303
|
+
return None
|
|
304
|
+
survey = survey_directory_inventories(package_directory)
|
|
305
|
+
if not survey.present_inventory_names:
|
|
306
|
+
return None
|
|
307
|
+
sibling_basenames = _sibling_named_basenames(package_directory, survey.named_basenames)
|
|
308
|
+
if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
|
|
309
|
+
return None
|
|
310
|
+
if os.path.basename(file_path) in survey.named_basenames:
|
|
311
|
+
return None
|
|
312
|
+
return _InventorySurvey(survey.present_inventory_names, sibling_basenames)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
|
|
316
|
+
"""Build the PreToolUse deny payload for a stale-inventory omission.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
file_path: The destination path of the write.
|
|
320
|
+
survey: The maintained-inventory survey the file is absent from.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
The hook-result dictionary the harness reads to deny the write.
|
|
324
|
+
"""
|
|
325
|
+
package_directory = str(Path(file_path).resolve().parent)
|
|
326
|
+
formatted_inventories = ", ".join(survey.present_inventory_names)
|
|
327
|
+
reason = STALE_INVENTORY_MESSAGE_TEMPLATE.format(
|
|
328
|
+
filename=os.path.basename(file_path),
|
|
329
|
+
directory=package_directory,
|
|
330
|
+
inventories=formatted_inventories,
|
|
331
|
+
entry_count=len(survey.named_basenames),
|
|
332
|
+
)
|
|
333
|
+
return {
|
|
334
|
+
"hookSpecificOutput": {
|
|
335
|
+
"hookEventName": "PreToolUse",
|
|
336
|
+
"permissionDecision": "deny",
|
|
337
|
+
"permissionDecisionReason": reason,
|
|
338
|
+
"additionalContext": STALE_INVENTORY_ADDITIONAL_CONTEXT,
|
|
339
|
+
},
|
|
340
|
+
"systemMessage": STALE_INVENTORY_SYSTEM_MESSAGE,
|
|
341
|
+
"suppressOutput": True,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
346
|
+
"""Write the hook result JSON to the given output stream.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
all_hook_data: The hook-result dictionary to serialize.
|
|
350
|
+
output_stream: The stream the harness reads the decision from.
|
|
351
|
+
"""
|
|
352
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
353
|
+
output_stream.flush()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main() -> None:
|
|
357
|
+
"""Read the PreToolUse payload from stdin and block a stale-inventory Write."""
|
|
358
|
+
input_data = read_hook_input_dictionary_from_stdin()
|
|
359
|
+
if input_data is None:
|
|
360
|
+
sys.exit(0)
|
|
361
|
+
|
|
362
|
+
raw_tool_name = input_data.get("tool_name", "")
|
|
363
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
364
|
+
if tool_name != "Write":
|
|
365
|
+
sys.exit(0)
|
|
366
|
+
|
|
367
|
+
tool_input = input_data.get("tool_input", {})
|
|
368
|
+
if not isinstance(tool_input, dict):
|
|
369
|
+
sys.exit(0)
|
|
370
|
+
|
|
371
|
+
file_path = tool_input.get("file_path", "")
|
|
372
|
+
if not isinstance(file_path, str) or not file_path:
|
|
373
|
+
sys.exit(0)
|
|
374
|
+
|
|
375
|
+
if os.path.exists(file_path):
|
|
376
|
+
sys.exit(0)
|
|
377
|
+
|
|
378
|
+
if not is_inventoried_production_file(file_path):
|
|
379
|
+
sys.exit(0)
|
|
380
|
+
|
|
381
|
+
survey = find_stale_inventory(file_path)
|
|
382
|
+
if survey is None:
|
|
383
|
+
sys.exit(0)
|
|
384
|
+
|
|
385
|
+
block_payload = _build_block_payload(file_path, survey)
|
|
386
|
+
log_hook_block(
|
|
387
|
+
calling_hook_name="package_inventory_stale_blocker.py",
|
|
388
|
+
hook_event="PreToolUse",
|
|
389
|
+
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
390
|
+
tool_name=tool_name,
|
|
391
|
+
offending_input_preview=file_path,
|
|
392
|
+
)
|
|
393
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
394
|
+
sys.exit(0)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|
|
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
19
19
|
if _hooks_dir not in sys.path:
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
23
|
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
23
24
|
ALL_SOFTWARE_TERMS,
|
|
24
25
|
ALL_TERM_PATTERNS,
|
|
@@ -153,6 +154,11 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
|
153
154
|
Returns:
|
|
154
155
|
The deny payload dictionary the hook serializes to stdout.
|
|
155
156
|
"""
|
|
157
|
+
log_hook_block(
|
|
158
|
+
calling_hook_name="plain_language_blocker.py",
|
|
159
|
+
hook_event="PreToolUse",
|
|
160
|
+
block_reason=deny_reason,
|
|
161
|
+
)
|
|
156
162
|
return {
|
|
157
163
|
"hookSpecificOutput": {
|
|
158
164
|
"hookEventName": "PreToolUse",
|
|
@@ -45,6 +45,7 @@ from hooks_constants.pr_converge_bugteam_enforcer_state import ( # noqa: E402
|
|
|
45
45
|
load_state_dictionary,
|
|
46
46
|
resolve_state_path,
|
|
47
47
|
)
|
|
48
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
48
49
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
49
50
|
read_hook_input_dictionary_from_stdin,
|
|
50
51
|
)
|
|
@@ -146,6 +147,11 @@ def _emit_deny_payload(output_stream: TextIO) -> None:
|
|
|
146
147
|
"permissionDecisionReason": ENFORCER_CORRECTIVE_MESSAGE,
|
|
147
148
|
}
|
|
148
149
|
}
|
|
150
|
+
log_hook_block(
|
|
151
|
+
calling_hook_name="pr_converge_bugteam_enforcer.py",
|
|
152
|
+
hook_event="PreToolUse",
|
|
153
|
+
block_reason=ENFORCER_CORRECTIVE_MESSAGE,
|
|
154
|
+
)
|
|
149
155
|
output_stream.write(json.dumps(deny_payload) + "\n")
|
|
150
156
|
output_stream.flush()
|
|
151
157
|
|
|
@@ -41,6 +41,7 @@ from blocking.pr_description_readability import ( # noqa: E402
|
|
|
41
41
|
_is_readability_enabled,
|
|
42
42
|
_load_readability_thresholds,
|
|
43
43
|
)
|
|
44
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
44
45
|
from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
45
46
|
ALL_HEAVY_OPENING_HEADERS,
|
|
46
47
|
ALL_HEAVY_TESTING_HEADERS,
|
|
@@ -187,6 +188,11 @@ def main() -> None:
|
|
|
187
188
|
"permissionDecisionReason": denial_reason,
|
|
188
189
|
}
|
|
189
190
|
}
|
|
191
|
+
log_hook_block(
|
|
192
|
+
calling_hook_name="pr_description_enforcer.py",
|
|
193
|
+
hook_event="PreToolUse",
|
|
194
|
+
block_reason=denial_reason,
|
|
195
|
+
)
|
|
190
196
|
print(json.dumps(denial_payload))
|
|
191
197
|
sys.stdout.flush()
|
|
192
198
|
|
|
@@ -8,9 +8,9 @@ decision when any hook denied (carrying every denying reason) or exits zero to
|
|
|
8
8
|
allow.
|
|
9
9
|
|
|
10
10
|
The per-hook coverage matrix:
|
|
11
|
-
- Write -> Group A (
|
|
12
|
-
- Edit -> Group A (
|
|
13
|
-
- MultiEdit -> Group B only (
|
|
11
|
+
- Write -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
|
|
12
|
+
- Edit -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
|
|
13
|
+
- MultiEdit -> Group B only (7 hooks)
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -40,6 +40,7 @@ from state_description_blocker import evaluate as evaluate_state_description #
|
|
|
40
40
|
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
41
41
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
42
42
|
ALLOW_DECISION,
|
|
43
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
43
44
|
BLOCKING_CRASH_EXIT_CODE,
|
|
44
45
|
DENY_DECISION,
|
|
45
46
|
EXIT_CODE_TWO_DENY_REASON,
|
|
@@ -360,9 +361,7 @@ def aggregate_hosted_hook_results(
|
|
|
360
361
|
parsed_output.deny_reason if parsed_output.deny_reason else EXIT_CODE_TWO_DENY_REASON
|
|
361
362
|
)
|
|
362
363
|
elif each_result.did_crash and each_result.is_blocking:
|
|
363
|
-
all_deny_reasons.append(
|
|
364
|
-
"[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
365
|
-
)
|
|
364
|
+
all_deny_reasons.append(BLOCKING_CRASH_DENY_REASON)
|
|
366
365
|
elif each_result.exit_code == BLOCKING_CRASH_EXIT_CODE and each_result.is_blocking:
|
|
367
366
|
all_deny_reasons.append(EXIT_CODE_TWO_DENY_REASON)
|
|
368
367
|
if parsed_output.is_allow:
|
|
@@ -29,6 +29,7 @@ from block_main_commit import ( # noqa: E402
|
|
|
29
29
|
parse_bash_command_from_stdin,
|
|
30
30
|
resolve_directory,
|
|
31
31
|
)
|
|
32
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
32
33
|
from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
|
|
33
34
|
ALL_GIT_REPOSITORY_ROOT_COMMAND,
|
|
34
35
|
ALL_STAGED_PYTHON_FILES_COMMAND,
|
|
@@ -189,7 +190,15 @@ def main() -> None:
|
|
|
189
190
|
gate_exit_code, gate_report = run_staged_gate(repository_root)
|
|
190
191
|
if gate_exit_code == 0:
|
|
191
192
|
sys.exit(0)
|
|
192
|
-
|
|
193
|
+
denial = build_denial_response(gate_report)
|
|
194
|
+
log_hook_block(
|
|
195
|
+
calling_hook_name="precommit_code_rules_gate.py",
|
|
196
|
+
hook_event="PreToolUse",
|
|
197
|
+
block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
|
|
198
|
+
tool_name="Bash",
|
|
199
|
+
offending_input_preview=bash_command,
|
|
200
|
+
)
|
|
201
|
+
print(json.dumps(denial))
|
|
193
202
|
sys.exit(0)
|
|
194
203
|
|
|
195
204
|
|
|
@@ -26,6 +26,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
26
26
|
if _hooks_dir not in sys.path:
|
|
27
27
|
sys.path.insert(0, _hooks_dir)
|
|
28
28
|
|
|
29
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
29
30
|
from hooks_constants.pytest_testpaths_orphan_blocker_constants import ( # noqa: E402
|
|
30
31
|
ALL_PRUNED_PARENT_DIRECTORY_NAMES,
|
|
31
32
|
GLOB_METACHARACTERS,
|
|
@@ -350,6 +351,13 @@ def main() -> None:
|
|
|
350
351
|
sys.exit(0)
|
|
351
352
|
|
|
352
353
|
block_payload = _build_block_payload(block_details)
|
|
354
|
+
log_hook_block(
|
|
355
|
+
calling_hook_name="pytest_testpaths_orphan_blocker.py",
|
|
356
|
+
hook_event="PreToolUse",
|
|
357
|
+
block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
358
|
+
tool_name=tool_name,
|
|
359
|
+
offending_input_preview=file_path,
|
|
360
|
+
)
|
|
353
361
|
_emit_hook_result(block_payload, sys.stdout)
|
|
354
362
|
sys.exit(0)
|
|
355
363
|
|
|
@@ -19,18 +19,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
19
19
|
if _hooks_dir not in sys.path:
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
23
|
from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE # noqa: E402
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def strip_code_and_quotes(text: str) -> str:
|
|
26
|
-
"""Remove code blocks, inline code, and blockquotes to avoid false positives."""
|
|
27
|
-
code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
|
|
28
|
-
inline_code_pattern = re.compile(r"`[^`]+`")
|
|
29
|
-
quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
|
|
30
|
-
text = code_block_pattern.sub("", text)
|
|
31
|
-
text = inline_code_pattern.sub("", text)
|
|
32
|
-
text = quoted_block_pattern.sub("", text)
|
|
33
|
-
return text
|
|
24
|
+
from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
|
|
34
25
|
|
|
35
26
|
|
|
36
27
|
def extract_final_paragraph(text: str) -> str:
|
|
@@ -109,23 +100,28 @@ def main() -> None:
|
|
|
109
100
|
f'"{each_indicator}"' for each_indicator in matched_indicators
|
|
110
101
|
)
|
|
111
102
|
|
|
103
|
+
block_reason = (
|
|
104
|
+
f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
|
|
105
|
+
f"(indicators: {formatted_indicator_list}). "
|
|
106
|
+
f"User-directed questions must route through the AskUserQuestion tool so the user "
|
|
107
|
+
f"sees structured options with labels.\n\n"
|
|
108
|
+
f"Re-output your response with the trailing question removed from prose and moved "
|
|
109
|
+
f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
|
|
110
|
+
f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
|
|
111
|
+
f"are ignored.\n\n"
|
|
112
|
+
f"You MUST re-output the complete, revised response with the correction applied."
|
|
113
|
+
)
|
|
112
114
|
block_response = {
|
|
113
115
|
"decision": "block",
|
|
114
|
-
"reason":
|
|
115
|
-
f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
|
|
116
|
-
f"(indicators: {formatted_indicator_list}). "
|
|
117
|
-
f"User-directed questions must route through the AskUserQuestion tool so the user "
|
|
118
|
-
f"sees structured options with labels.\n\n"
|
|
119
|
-
f"Re-output your response with the trailing question removed from prose and moved "
|
|
120
|
-
f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
|
|
121
|
-
f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
|
|
122
|
-
f"are ignored.\n\n"
|
|
123
|
-
f"You MUST re-output the complete, revised response with the correction applied."
|
|
124
|
-
),
|
|
116
|
+
"reason": block_reason,
|
|
125
117
|
"systemMessage": USER_FACING_ASKUSERQUESTION_NOTICE,
|
|
126
118
|
"suppressOutput": True,
|
|
127
119
|
}
|
|
128
|
-
|
|
120
|
+
log_hook_block(
|
|
121
|
+
calling_hook_name="question_to_user_enforcer.py",
|
|
122
|
+
hook_event="Stop",
|
|
123
|
+
block_reason=block_reason,
|
|
124
|
+
)
|
|
129
125
|
print(json.dumps(block_response))
|
|
130
126
|
sys.exit(0)
|
|
131
127
|
|