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.
Files changed (50) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  2. package/bin/install.mjs +73 -5
  3. package/bin/install.test.mjs +360 -4
  4. package/hooks/blocking/CLAUDE.md +3 -1
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  6. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  7. package/hooks/blocking/code_rules_docstrings.py +616 -0
  8. package/hooks/blocking/code_rules_enforcer.py +22 -0
  9. package/hooks/blocking/code_rules_shared.py +19 -0
  10. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  11. package/hooks/blocking/md_to_html_blocker.py +7 -8
  12. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  13. package/hooks/blocking/plain_language_blocker.py +51 -16
  14. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  15. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  16. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  17. package/hooks/blocking/state_description_blocker.py +75 -36
  18. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  22. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  25. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  26. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  27. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  28. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  29. package/hooks/hooks.json +9 -79
  30. package/hooks/hooks_constants/CLAUDE.md +3 -1
  31. package/hooks/hooks_constants/blocking_check_limits.py +61 -0
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  33. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  34. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  35. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  37. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  38. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  39. package/hooks/validation/mypy_validator.py +215 -17
  40. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  41. package/hooks/validation/test_mypy_validator.py +184 -1
  42. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  43. package/hooks/workflow/test_auto_formatter.py +10 -9
  44. package/package.json +1 -1
  45. package/rules/docstring-prose-matches-implementation.md +2 -1
  46. package/skills/autoconverge/SKILL.md +93 -0
  47. package/skills/autoconverge/workflow/converge.mjs +27 -2
  48. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  49. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  50. 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 main() -> None:
164
- try:
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
- if not isinstance(input_data, dict):
170
- sys.exit(0)
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
- tool_name = input_data.get("tool_name", "")
173
- if not isinstance(tool_name, str):
174
- sys.exit(0)
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
- if tool_name not in ("Write", "Edit"):
181
- sys.exit(0)
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
- file_path = tool_input.get("file_path", "")
184
- if not file_path or not (
185
- is_markdown_file(file_path) or is_comment_bearing_file(file_path)
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
- content_to_check = ""
190
- if tool_name == "Write":
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
- sys.exit(0)
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
- sys.exit(0)
219
+ return None
220
+
221
+ return _build_deny_reason(file_path, all_detected_patterns)
222
+
201
223
 
202
- formatted = ", ".join(f'"{p}"' for p in all_detected_patterns)
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
- block_payload = {
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
- _emit_hook_result(block_payload, sys.stdout)
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