claude-dev-env 1.72.0 → 1.73.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/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +616 -0
- package/hooks/blocking/code_rules_enforcer.py +22 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +61 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/skills/autoconverge/SKILL.md +93 -0
- package/skills/autoconverge/workflow/converge.mjs +27 -2
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks a test file written outside a package's explicit pytest testpaths allowlist.
|
|
3
|
+
|
|
4
|
+
A package whose ``pyproject.toml`` declares ``[tool.pytest.ini_options]`` with an
|
|
5
|
+
explicit ``testpaths`` list runs only the directories that list names. A new
|
|
6
|
+
``test_*.py`` written into a directory that no ``testpaths`` entry covers is
|
|
7
|
+
collected by no default ``pytest`` run, so the test silently never executes and a
|
|
8
|
+
regression in the code it guards passes the standard suite undetected. This hook
|
|
9
|
+
fires on Write, Edit, and MultiEdit that create a ``test_*.py`` file, walks up
|
|
10
|
+
from the file to the nearest ``pyproject.toml`` that declares an explicit
|
|
11
|
+
``testpaths`` allowlist, and blocks the write when the file's directory (relative
|
|
12
|
+
to that package root) is covered by no entry. A package whose pyproject declares
|
|
13
|
+
no pytest section, or a pytest section with no explicit ``testpaths`` list, is out
|
|
14
|
+
of scope, since pytest then discovers tests by recursive default and the file is
|
|
15
|
+
collected wherever it lands.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import fnmatch
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import tomllib
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TextIO
|
|
24
|
+
|
|
25
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
26
|
+
if _hooks_dir not in sys.path:
|
|
27
|
+
sys.path.insert(0, _hooks_dir)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.pytest_testpaths_orphan_blocker_constants import ( # noqa: E402
|
|
30
|
+
ALL_PRUNED_PARENT_DIRECTORY_NAMES,
|
|
31
|
+
GLOB_METACHARACTERS,
|
|
32
|
+
MAX_PARENT_DIRECTORIES_SEARCHED,
|
|
33
|
+
PACKAGE_ROOT_ENTRY,
|
|
34
|
+
PACKAGE_ROOT_ENTRY_PREFIX,
|
|
35
|
+
PYPROJECT_FILENAME,
|
|
36
|
+
TEST_FILE_BASENAME_PATTERN,
|
|
37
|
+
TESTPATHS_KEY,
|
|
38
|
+
UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT,
|
|
39
|
+
UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE,
|
|
40
|
+
UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_test_file(file_path: str) -> bool:
|
|
45
|
+
"""Return whether *file_path* names a pytest-collectable ``test_*.py`` file.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
file_path: The destination path of the write or edit.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True when the path's basename matches the ``test_*.py`` pattern.
|
|
52
|
+
"""
|
|
53
|
+
return TEST_FILE_BASENAME_PATTERN.match(Path(file_path).name) is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _PytestPackage:
|
|
57
|
+
"""A pyproject.toml that declares an explicit pytest testpaths allowlist.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
package_root: The directory holding the pyproject.toml, against which
|
|
61
|
+
every testpaths entry and the test file's location are resolved.
|
|
62
|
+
all_testpaths: Each directory the testpaths list names, as written.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, package_root: Path, all_testpaths: list[str]) -> None:
|
|
66
|
+
self.package_root = package_root
|
|
67
|
+
self.all_testpaths = all_testpaths
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _nested_dict_table(parent_table: dict, table_key: str) -> dict | None:
|
|
71
|
+
"""Return the child table at *table_key*, or None when it is absent or a scalar.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
parent_table: The enclosing TOML table to look the key up in.
|
|
75
|
+
table_key: The key whose value is expected to be a nested table.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The nested table, or None when the key is missing or maps to a non-table.
|
|
79
|
+
"""
|
|
80
|
+
child_table = parent_table.get(table_key, {})
|
|
81
|
+
if not isinstance(child_table, dict):
|
|
82
|
+
return None
|
|
83
|
+
return child_table
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _explicit_testpaths(pyproject_path: Path) -> list[str] | None:
|
|
87
|
+
"""Return the explicit testpaths entries a pyproject declares, when it has them.
|
|
88
|
+
|
|
89
|
+
The pyproject declares an explicit allowlist only when its
|
|
90
|
+
``[tool.pytest.ini_options]`` table holds a ``testpaths`` key whose value is a
|
|
91
|
+
non-empty list of strings. A pyproject with no pytest table, no ``testpaths``
|
|
92
|
+
key, or a malformed value yields None, so the caller treats that package as
|
|
93
|
+
out of scope (pytest then discovers tests by recursive default). A scalar
|
|
94
|
+
``tool``, ``pytest``, or ``ini_options`` value also yields None, since the
|
|
95
|
+
descent through those nested tables stops at the first non-table.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
pyproject_path: The path of the pyproject.toml to read.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The list of testpaths entries, or None when the pyproject declares no
|
|
102
|
+
explicit list or cannot be parsed.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
parsed_pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
|
|
107
|
+
return None
|
|
108
|
+
tool_table = _nested_dict_table(parsed_pyproject, "tool")
|
|
109
|
+
pytest_table = _nested_dict_table(tool_table, "pytest") if tool_table is not None else None
|
|
110
|
+
pytest_section = (
|
|
111
|
+
_nested_dict_table(pytest_table, "ini_options") if pytest_table is not None else None
|
|
112
|
+
)
|
|
113
|
+
if pytest_section is None:
|
|
114
|
+
return None
|
|
115
|
+
declared_testpaths = pytest_section.get(TESTPATHS_KEY)
|
|
116
|
+
if not isinstance(declared_testpaths, list):
|
|
117
|
+
return None
|
|
118
|
+
string_entries = [each for each in declared_testpaths if isinstance(each, str) and each]
|
|
119
|
+
if not string_entries:
|
|
120
|
+
return None
|
|
121
|
+
return string_entries
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _find_governing_package(test_file: Path) -> _PytestPackage | None:
|
|
125
|
+
"""Return the nearest ancestor package that governs *test_file* with an allowlist.
|
|
126
|
+
|
|
127
|
+
Walks upward from the test file's directory, pruning noise directories, until
|
|
128
|
+
it reaches a ``pyproject.toml`` that declares an explicit ``testpaths`` list.
|
|
129
|
+
The first such pyproject governs the file. The walk stops at the budget, so a
|
|
130
|
+
deeply nested file never searches indefinitely.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
test_file: The resolved path of the test file being written.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The governing package paired with its testpaths entries, or None when no
|
|
137
|
+
ancestor declares an explicit allowlist within the budget.
|
|
138
|
+
"""
|
|
139
|
+
searched_count = 0
|
|
140
|
+
for each_directory in test_file.parents:
|
|
141
|
+
if each_directory.name in ALL_PRUNED_PARENT_DIRECTORY_NAMES:
|
|
142
|
+
continue
|
|
143
|
+
searched_count += 1
|
|
144
|
+
if searched_count > MAX_PARENT_DIRECTORIES_SEARCHED:
|
|
145
|
+
return None
|
|
146
|
+
candidate_pyproject = each_directory / PYPROJECT_FILENAME
|
|
147
|
+
if not candidate_pyproject.is_file():
|
|
148
|
+
continue
|
|
149
|
+
all_testpaths = _explicit_testpaths(candidate_pyproject)
|
|
150
|
+
if all_testpaths is None:
|
|
151
|
+
continue
|
|
152
|
+
return _PytestPackage(each_directory, all_testpaths)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_collected_by_entry(relative_test_path: Path, testpaths_entry: str) -> bool:
|
|
157
|
+
"""Return whether one testpaths entry collects the test at *relative_test_path*.
|
|
158
|
+
|
|
159
|
+
The entry and the relative path are normalized to forward-slash posix form
|
|
160
|
+
(a leading ``./`` is stripped) so a Windows backslash path matches a
|
|
161
|
+
posix-written testpaths entry. An entry that reduces to ``.`` or empty names
|
|
162
|
+
the package root, which collects every test recursively, so it collects the
|
|
163
|
+
file. An entry holding a glob metacharacter is matched as an fnmatch pattern
|
|
164
|
+
against the file's relative path. Otherwise the entry collects the file when
|
|
165
|
+
it names the file itself or names a directory the file sits inside (the entry
|
|
166
|
+
is a path prefix of the file's relative path).
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
relative_test_path: The test file's path relative to the package root.
|
|
170
|
+
testpaths_entry: One entry from the package's testpaths list.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True when the entry collects the test file.
|
|
174
|
+
"""
|
|
175
|
+
normalized_test_path = relative_test_path.as_posix()
|
|
176
|
+
normalized_entry = testpaths_entry.strip().replace("\\", "/")
|
|
177
|
+
if normalized_entry.startswith(PACKAGE_ROOT_ENTRY_PREFIX):
|
|
178
|
+
normalized_entry = normalized_entry[len(PACKAGE_ROOT_ENTRY_PREFIX) :]
|
|
179
|
+
normalized_entry = normalized_entry.strip("/")
|
|
180
|
+
if normalized_entry in (PACKAGE_ROOT_ENTRY, ""):
|
|
181
|
+
return True
|
|
182
|
+
if any(metacharacter in normalized_entry for metacharacter in GLOB_METACHARACTERS):
|
|
183
|
+
return _matches_glob_entry(normalized_test_path, normalized_entry)
|
|
184
|
+
if normalized_test_path == normalized_entry:
|
|
185
|
+
return True
|
|
186
|
+
return normalized_test_path.startswith(normalized_entry + "/")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _matches_glob_entry(normalized_test_path: str, normalized_entry: str) -> bool:
|
|
190
|
+
"""Return whether a glob testpaths entry collects the file at *normalized_test_path*.
|
|
191
|
+
|
|
192
|
+
A glob entry collects the file when the entry matches the file's relative
|
|
193
|
+
path, or when the entry matches an ancestor directory the file sits inside —
|
|
194
|
+
so ``tests/*`` (which fnmatch-matches the directory ``tests/data``) collects
|
|
195
|
+
``tests/data/test_x.py``.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
normalized_test_path: The test file's posix relative path.
|
|
199
|
+
normalized_entry: The glob entry, normalized to posix form.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True when the entry matches the file or a directory containing it.
|
|
203
|
+
"""
|
|
204
|
+
if fnmatch.fnmatch(normalized_test_path, normalized_entry):
|
|
205
|
+
return True
|
|
206
|
+
ancestor_path = Path(normalized_test_path).parent
|
|
207
|
+
while ancestor_path != ancestor_path.parent:
|
|
208
|
+
if fnmatch.fnmatch(ancestor_path.as_posix(), normalized_entry):
|
|
209
|
+
return True
|
|
210
|
+
ancestor_path = ancestor_path.parent
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _suggested_testpaths_entry(relative_test_path: Path) -> str:
|
|
215
|
+
"""Return the testpaths entry that would collect the test file's directory.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
relative_test_path: The test file's path relative to the package root.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The posix-form parent directory of the test file, the entry a maintainer
|
|
222
|
+
would add to the testpaths list to collect it.
|
|
223
|
+
"""
|
|
224
|
+
return relative_test_path.parent.as_posix()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def find_unregistered_test_directory(file_path: str) -> dict[str, str] | None:
|
|
228
|
+
"""Return the block details when a test file lands outside its package's allowlist.
|
|
229
|
+
|
|
230
|
+
Resolves the test file, finds the nearest ancestor pyproject that declares an
|
|
231
|
+
explicit ``testpaths`` allowlist, and checks whether any entry collects the
|
|
232
|
+
file. When a governing allowlist exists and no entry covers the file's
|
|
233
|
+
directory, the details name the file, the pyproject, the uncollected
|
|
234
|
+
directory, and the entry a maintainer would add. A file under no explicit
|
|
235
|
+
allowlist, or one already covered by an entry, yields None. A filesystem error
|
|
236
|
+
yields None (fail open), so an unreadable tree never blocks a write.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
file_path: The destination path of the test file being written.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A mapping of message fields when the file is unregistered, or None.
|
|
243
|
+
"""
|
|
244
|
+
test_file = Path(file_path).resolve()
|
|
245
|
+
governing_package = _find_governing_package(test_file)
|
|
246
|
+
if governing_package is None:
|
|
247
|
+
return None
|
|
248
|
+
try:
|
|
249
|
+
relative_test_path = test_file.relative_to(governing_package.package_root)
|
|
250
|
+
except ValueError:
|
|
251
|
+
return None
|
|
252
|
+
for each_entry in governing_package.all_testpaths:
|
|
253
|
+
if _is_collected_by_entry(relative_test_path, each_entry):
|
|
254
|
+
return None
|
|
255
|
+
return {
|
|
256
|
+
"test_file": relative_test_path.as_posix(),
|
|
257
|
+
"pyproject": (governing_package.package_root / PYPROJECT_FILENAME).as_posix(),
|
|
258
|
+
"test_directory": relative_test_path.parent.as_posix(),
|
|
259
|
+
"suggested_entry": _suggested_testpaths_entry(relative_test_path),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _creates_file(tool_name: str, tool_input: dict, file_path: str) -> bool:
|
|
264
|
+
"""Return whether the tool call creates the test file rather than editing it.
|
|
265
|
+
|
|
266
|
+
The check targets a newly created test file: a Write whose target does not yet
|
|
267
|
+
exist, or any Edit/MultiEdit whose target file is absent (the harness models a
|
|
268
|
+
create-via-edit as an edit against a missing path). An edit to an existing test
|
|
269
|
+
file is out of scope, since the file's collection status was settled when it
|
|
270
|
+
was first created.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
|
|
274
|
+
tool_input: The tool's input payload.
|
|
275
|
+
file_path: The destination path of the write or edit.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
True when the call creates a test file that does not yet exist on disk.
|
|
279
|
+
"""
|
|
280
|
+
if tool_name not in ("Write", "Edit", "MultiEdit"):
|
|
281
|
+
return False
|
|
282
|
+
if not isinstance(tool_input, dict):
|
|
283
|
+
return False
|
|
284
|
+
return not Path(file_path).exists()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _build_block_payload(block_details: dict[str, str]) -> dict:
|
|
288
|
+
"""Build the PreToolUse deny payload naming the uncollected test directory.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
block_details: The message fields the find step produced.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The hook-result dictionary the harness reads to deny the write.
|
|
295
|
+
"""
|
|
296
|
+
reason = UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE.format(**block_details)
|
|
297
|
+
return {
|
|
298
|
+
"hookSpecificOutput": {
|
|
299
|
+
"hookEventName": "PreToolUse",
|
|
300
|
+
"permissionDecision": "deny",
|
|
301
|
+
"permissionDecisionReason": reason,
|
|
302
|
+
"additionalContext": UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT,
|
|
303
|
+
},
|
|
304
|
+
"systemMessage": UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE,
|
|
305
|
+
"suppressOutput": True,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
|
|
310
|
+
"""Write the hook result JSON to the given output stream.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
all_hook_data: The hook-result dictionary to serialize.
|
|
314
|
+
output_stream: The stream the harness reads the decision from.
|
|
315
|
+
"""
|
|
316
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
317
|
+
output_stream.flush()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def main() -> None:
|
|
321
|
+
"""Read the PreToolUse payload from stdin and block an unregistered test file."""
|
|
322
|
+
try:
|
|
323
|
+
input_data = json.load(sys.stdin)
|
|
324
|
+
except json.JSONDecodeError:
|
|
325
|
+
sys.exit(0)
|
|
326
|
+
|
|
327
|
+
if not isinstance(input_data, dict):
|
|
328
|
+
sys.exit(0)
|
|
329
|
+
|
|
330
|
+
tool_name = input_data.get("tool_name", "")
|
|
331
|
+
if not isinstance(tool_name, str):
|
|
332
|
+
sys.exit(0)
|
|
333
|
+
|
|
334
|
+
tool_input = input_data.get("tool_input", {})
|
|
335
|
+
if not isinstance(tool_input, dict):
|
|
336
|
+
sys.exit(0)
|
|
337
|
+
|
|
338
|
+
file_path = tool_input.get("file_path", "")
|
|
339
|
+
if not isinstance(file_path, str) or not is_test_file(file_path):
|
|
340
|
+
sys.exit(0)
|
|
341
|
+
|
|
342
|
+
if not _creates_file(tool_name, tool_input, file_path):
|
|
343
|
+
sys.exit(0)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
block_details = find_unregistered_test_directory(file_path)
|
|
347
|
+
except OSError:
|
|
348
|
+
sys.exit(0)
|
|
349
|
+
if block_details is None:
|
|
350
|
+
sys.exit(0)
|
|
351
|
+
|
|
352
|
+
block_payload = _build_block_payload(block_details)
|
|
353
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
354
|
+
sys.exit(0)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__":
|
|
358
|
+
main()
|
|
@@ -16,6 +16,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
16
16
|
if _hooks_dir not in sys.path:
|
|
17
17
|
sys.path.insert(0, _hooks_dir)
|
|
18
18
|
|
|
19
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
19
20
|
from hooks_constants.state_description_blocker_constants import ( # noqa: E402
|
|
20
21
|
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
21
22
|
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
@@ -160,57 +161,85 @@ def find_violations(text: str, file_path: str) -> list[str]:
|
|
|
160
161
|
return all_detected
|
|
161
162
|
|
|
162
163
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
input_data = json.load(sys.stdin)
|
|
166
|
-
except json.JSONDecodeError:
|
|
167
|
-
sys.exit(0)
|
|
164
|
+
def _build_deny_reason(file_path: str, all_detected_patterns: list[str]) -> str:
|
|
165
|
+
"""Build the permissionDecisionReason text for a historical-language denial.
|
|
168
166
|
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
Args:
|
|
168
|
+
file_path: The target file path the violation was found in.
|
|
169
|
+
all_detected_patterns: The matched historical/comparative phrases.
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
Returns:
|
|
172
|
+
The deny-reason text naming the file and the detected phrases.
|
|
173
|
+
"""
|
|
174
|
+
formatted = ", ".join(f'"{each_pattern}"' for each_pattern in all_detected_patterns)
|
|
175
|
+
return (
|
|
176
|
+
f"Historical/comparative language detected in {file_path}: "
|
|
177
|
+
f"{formatted}. Describe current state only — no 'instead of', "
|
|
178
|
+
f"'previously', 'now uses', etc. The git log tracks what changed. "
|
|
179
|
+
f"Comments and docs describe what IS."
|
|
180
|
+
)
|
|
175
181
|
|
|
176
|
-
tool_input = input_data.get("tool_input", {})
|
|
177
|
-
if not isinstance(tool_input, dict):
|
|
178
|
-
sys.exit(0)
|
|
179
182
|
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
184
|
+
"""Decide whether a Write/Edit payload carries historical/comparative language.
|
|
182
185
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
):
|
|
187
|
-
sys.exit(0)
|
|
186
|
+
Applies the same tool-name gate, file-extension gate, content selection, and
|
|
187
|
+
pattern scan the standalone hook applies. Returns the deny-reason text when a
|
|
188
|
+
historical phrase is found, or None to allow.
|
|
188
189
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
content_to_check = tool_input.get("content", "")
|
|
192
|
-
elif tool_name == "Edit":
|
|
193
|
-
content_to_check = tool_input.get("new_string", "")
|
|
190
|
+
Args:
|
|
191
|
+
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
194
192
|
|
|
193
|
+
Returns:
|
|
194
|
+
The permissionDecisionReason text when the write is denied, or None when
|
|
195
|
+
the write is allowed.
|
|
196
|
+
"""
|
|
197
|
+
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
198
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
199
|
+
if tool_name not in ("Write", "Edit"):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
203
|
+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
204
|
+
|
|
205
|
+
file_path = tool_input.get("file_path", "")
|
|
206
|
+
if not isinstance(file_path, str) or not file_path:
|
|
207
|
+
return None
|
|
208
|
+
if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
content_key = "content" if tool_name == "Write" else "new_string"
|
|
212
|
+
raw_content = tool_input.get(content_key, "")
|
|
213
|
+
content_to_check = raw_content if isinstance(raw_content, str) else ""
|
|
195
214
|
if not content_to_check:
|
|
196
|
-
|
|
215
|
+
return None
|
|
197
216
|
|
|
198
217
|
all_detected_patterns = find_violations(content_to_check, file_path)
|
|
199
218
|
if not all_detected_patterns:
|
|
200
|
-
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
return _build_deny_reason(file_path, all_detected_patterns)
|
|
222
|
+
|
|
201
223
|
|
|
202
|
-
|
|
224
|
+
def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
225
|
+
"""Build the full deny payload the hook writes for a deny-reason string.
|
|
203
226
|
|
|
204
|
-
|
|
227
|
+
The payload carries the core permission decision plus the BAD/GOOD rewrite
|
|
228
|
+
guidance in additionalContext, the user-facing systemMessage, and output
|
|
229
|
+
suppression, so a caller routing this hook through a dispatcher reproduces
|
|
230
|
+
the same deny shape the standalone hook writes.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
deny_reason: The permissionDecisionReason text for the denial.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The deny payload dictionary the hook serializes to stdout.
|
|
237
|
+
"""
|
|
238
|
+
return {
|
|
205
239
|
"hookSpecificOutput": {
|
|
206
240
|
"hookEventName": "PreToolUse",
|
|
207
241
|
"permissionDecision": "deny",
|
|
208
|
-
"permissionDecisionReason":
|
|
209
|
-
f"Historical/comparative language detected in {file_path}: "
|
|
210
|
-
f"{formatted}. Describe current state only — no 'instead of', "
|
|
211
|
-
f"'previously', 'now uses', etc. The git log tracks what changed. "
|
|
212
|
-
f"Comments and docs describe what IS."
|
|
213
|
-
),
|
|
242
|
+
"permissionDecisionReason": deny_reason,
|
|
214
243
|
"additionalContext": (
|
|
215
244
|
"Rewrite the affected comments or documentation to describe "
|
|
216
245
|
"only the current state. For example:\n"
|
|
@@ -223,7 +252,17 @@ def main() -> None:
|
|
|
223
252
|
"suppressOutput": True,
|
|
224
253
|
}
|
|
225
254
|
|
|
226
|
-
|
|
255
|
+
|
|
256
|
+
def main() -> None:
|
|
257
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
258
|
+
if payload_dictionary is None:
|
|
259
|
+
sys.exit(0)
|
|
260
|
+
|
|
261
|
+
deny_reason = evaluate(payload_dictionary)
|
|
262
|
+
if deny_reason is None:
|
|
263
|
+
sys.exit(0)
|
|
264
|
+
|
|
265
|
+
_emit_hook_result(build_deny_payload(deny_reason), sys.stdout)
|
|
227
266
|
sys.exit(0)
|
|
228
267
|
|
|
229
268
|
|
|
@@ -749,6 +749,87 @@ def test_dead_config_field_module_has_no_collection_parameter_naming_violation()
|
|
|
749
749
|
)
|
|
750
750
|
|
|
751
751
|
|
|
752
|
+
SELECTORS_DATACLASS_BODY = (
|
|
753
|
+
"from dataclasses import dataclass\n"
|
|
754
|
+
"\n"
|
|
755
|
+
"@dataclass(frozen=True)\n"
|
|
756
|
+
"class BinarySelectors:\n"
|
|
757
|
+
" show_more_button_active: str = \"a.show_list_btn.active\"\n"
|
|
758
|
+
" show_more_row_visible: str = \"tr.show_list_tr:not(.ng-hide)\"\n"
|
|
759
|
+
"\n"
|
|
760
|
+
"binary_selectors: BinarySelectors = BinarySelectors()\n"
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def test_flags_selectors_dataclass_field_read_by_no_production_module(
|
|
765
|
+
neutral_root: Path,
|
|
766
|
+
) -> None:
|
|
767
|
+
"""A ``*Selectors`` @dataclass field read by no production module is flagged.
|
|
768
|
+
|
|
769
|
+
A selectors dataclass is a config-like export surface: defined once, bound to
|
|
770
|
+
a module-level singleton (``binary_selectors = BinarySelectors()``), and read
|
|
771
|
+
across files. The cross-module dead-field scan treats it the same as a
|
|
772
|
+
``*Config`` dataclass, so a selector field no production module reads —
|
|
773
|
+
``show_more_row_visible`` here — is flagged, while a selector a consumer reads
|
|
774
|
+
(``show_more_button_active``) is not.
|
|
775
|
+
"""
|
|
776
|
+
consumer_body = (
|
|
777
|
+
"from selectors_module import binary_selectors\n"
|
|
778
|
+
"\n"
|
|
779
|
+
"def expand() -> str:\n"
|
|
780
|
+
" return binary_selectors.show_more_button_active\n"
|
|
781
|
+
)
|
|
782
|
+
workflow_directory = neutral_root / "workflow"
|
|
783
|
+
selectors_package = workflow_directory / "selectors"
|
|
784
|
+
selectors_package.mkdir(parents=True)
|
|
785
|
+
(selectors_package / "__init__.py").write_text("", encoding="utf-8")
|
|
786
|
+
selectors_path = selectors_package / "selectors_module.py"
|
|
787
|
+
selectors_path.write_text(SELECTORS_DATACLASS_BODY, encoding="utf-8")
|
|
788
|
+
(workflow_directory / "processor.py").write_text(consumer_body, encoding="utf-8")
|
|
789
|
+
issues = _check(SELECTORS_DATACLASS_BODY, str(selectors_path))
|
|
790
|
+
assert any("'show_more_row_visible'" in each_issue for each_issue in issues), (
|
|
791
|
+
f"Selector field read by no production module must be flagged, got: {issues}"
|
|
792
|
+
)
|
|
793
|
+
assert not any(
|
|
794
|
+
"'show_more_button_active'" in each_issue for each_issue in issues
|
|
795
|
+
), f"Selector field read in the consumer must not be flagged, got: {issues}"
|
|
796
|
+
selector_issue = next(
|
|
797
|
+
each_issue for each_issue in issues if "'show_more_row_visible'" in each_issue
|
|
798
|
+
)
|
|
799
|
+
assert "config dataclass field" not in selector_issue, (
|
|
800
|
+
f"Flagged selectors field must not be mislabelled a config dataclass field, got: {selector_issue}"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def test_does_not_flag_selectors_field_read_in_sibling_module(
|
|
805
|
+
neutral_root: Path,
|
|
806
|
+
) -> None:
|
|
807
|
+
"""A ``*Selectors`` field read through the singleton in a sibling module is live.
|
|
808
|
+
|
|
809
|
+
When every selector field is read by a production consumer, none is flagged.
|
|
810
|
+
"""
|
|
811
|
+
consumer_body = (
|
|
812
|
+
"from selectors_module import binary_selectors\n"
|
|
813
|
+
"\n"
|
|
814
|
+
"def expand() -> tuple[str, str]:\n"
|
|
815
|
+
" return (\n"
|
|
816
|
+
" binary_selectors.show_more_button_active,\n"
|
|
817
|
+
" binary_selectors.show_more_row_visible,\n"
|
|
818
|
+
" )\n"
|
|
819
|
+
)
|
|
820
|
+
workflow_directory = neutral_root / "workflow"
|
|
821
|
+
selectors_package = workflow_directory / "selectors"
|
|
822
|
+
selectors_package.mkdir(parents=True)
|
|
823
|
+
(selectors_package / "__init__.py").write_text("", encoding="utf-8")
|
|
824
|
+
selectors_path = selectors_package / "selectors_module.py"
|
|
825
|
+
selectors_path.write_text(SELECTORS_DATACLASS_BODY, encoding="utf-8")
|
|
826
|
+
(workflow_directory / "processor.py").write_text(consumer_body, encoding="utf-8")
|
|
827
|
+
issues = _check(SELECTORS_DATACLASS_BODY, str(selectors_path))
|
|
828
|
+
assert issues == [], (
|
|
829
|
+
f"All selector fields are read in the consumer, none must be flagged, got: {issues}"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
752
833
|
def test_validate_content_dispatch_runs_dead_config_field_check(neutral_root: Path) -> None:
|
|
753
834
|
workflow_directory = neutral_root / "workflow"
|
|
754
835
|
config_package = workflow_directory / "os_update_workflow"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tests for check_docstring_no_inline_literal_claim — Category O6 completeness drift.
|
|
2
|
+
|
|
3
|
+
A constants-module docstring asserting "no literals appear inline in the
|
|
4
|
+
dispatcher" makes an unverifiable completeness claim about a companion file. The
|
|
5
|
+
claim drifts the moment a literal lands inline in that companion — a deny or
|
|
6
|
+
block reason left inline contradicts the docstring even though the file under
|
|
7
|
+
edit never changed. This is the deterministic slice of Category O6 (docstring
|
|
8
|
+
prose vs implementation drift) and a no-transitional-language violation in its
|
|
9
|
+
own right.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_enforcer_module() -> ModuleType:
|
|
20
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
21
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
22
|
+
assert spec is not None
|
|
23
|
+
assert spec.loader is not None
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
spec.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_docstring_no_inline_literal_claim(content: str, file_path: str) -> list[str]:
|
|
33
|
+
return code_rules_enforcer.check_docstring_no_inline_literal_claim(content, file_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CONSTANTS_FILE_PATH = "/project/hooks/hooks_constants/example_dispatcher_constants.py"
|
|
37
|
+
TEST_FILE_PATH = "/project/hooks/hooks_constants/test_example_dispatcher_constants.py"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_flags_no_literals_appear_inline_in_the_dispatcher_claim() -> None:
|
|
41
|
+
content = (
|
|
42
|
+
'"""Constants for the dispatcher.\n'
|
|
43
|
+
"\n"
|
|
44
|
+
"The dispatcher imports these; no literals appear inline in the dispatcher\n"
|
|
45
|
+
"script.\n"
|
|
46
|
+
'"""\n'
|
|
47
|
+
"\n"
|
|
48
|
+
'DENY_DECISION = "deny"\n'
|
|
49
|
+
)
|
|
50
|
+
issues = check_docstring_no_inline_literal_claim(content, CONSTANTS_FILE_PATH)
|
|
51
|
+
assert len(issues) == 1
|
|
52
|
+
assert "no literals appear inline" in issues[0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_flags_no_literals_appear_inline_short_form() -> None:
|
|
56
|
+
content = (
|
|
57
|
+
'"""Constants module. No literals appear inline in the script."""\n'
|
|
58
|
+
"\n"
|
|
59
|
+
'BLOCK_DECISION = "block"\n'
|
|
60
|
+
)
|
|
61
|
+
assert len(check_docstring_no_inline_literal_claim(content, CONSTANTS_FILE_PATH)) == 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_passes_when_docstring_states_what_is_centralized() -> None:
|
|
65
|
+
content = (
|
|
66
|
+
'"""Constants for the dispatcher.\n'
|
|
67
|
+
"\n"
|
|
68
|
+
"Holds the deny decision string and the crash deny reason. The dispatcher\n"
|
|
69
|
+
"imports each of these by name.\n"
|
|
70
|
+
'"""\n'
|
|
71
|
+
"\n"
|
|
72
|
+
'DENY_DECISION = "deny"\n'
|
|
73
|
+
)
|
|
74
|
+
assert check_docstring_no_inline_literal_claim(content, CONSTANTS_FILE_PATH) == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_test_files_are_exempt() -> None:
|
|
78
|
+
content = (
|
|
79
|
+
'"""Constants module. No literals appear inline in the dispatcher script."""\n'
|
|
80
|
+
"\n"
|
|
81
|
+
'DENY_DECISION = "deny"\n'
|
|
82
|
+
)
|
|
83
|
+
assert check_docstring_no_inline_literal_claim(content, TEST_FILE_PATH) == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_hook_infrastructure_is_in_scope() -> None:
|
|
87
|
+
hook_constants_path = "/home/user/.claude/hooks/hooks_constants/foo_constants.py"
|
|
88
|
+
content = (
|
|
89
|
+
'"""Constants module. No literals appear inline in the dispatcher script."""\n'
|
|
90
|
+
"\n"
|
|
91
|
+
'DENY_DECISION = "deny"\n'
|
|
92
|
+
)
|
|
93
|
+
assert len(check_docstring_no_inline_literal_claim(content, hook_constants_path)) == 1
|