claude-dev-env 1.72.0 → 1.74.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 +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- 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 +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- 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/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- 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 +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- 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_returns_plural_cardinality.py +207 -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_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -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 +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_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -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 +21 -7
- 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 +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -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/docstring_rule_gate_count_blocker_constants.py +90 -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/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- 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/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -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/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- 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
|
@@ -16,6 +16,8 @@ _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
|
|
20
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
19
21
|
from hooks_constants.state_description_blocker_constants import ( # noqa: E402
|
|
20
22
|
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
21
23
|
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
@@ -160,57 +162,90 @@ def find_violations(text: str, file_path: str) -> list[str]:
|
|
|
160
162
|
return all_detected
|
|
161
163
|
|
|
162
164
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
input_data = json.load(sys.stdin)
|
|
166
|
-
except json.JSONDecodeError:
|
|
167
|
-
sys.exit(0)
|
|
165
|
+
def _build_deny_reason(file_path: str, all_detected_patterns: list[str]) -> str:
|
|
166
|
+
"""Build the permissionDecisionReason text for a historical-language denial.
|
|
168
167
|
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
Args:
|
|
169
|
+
file_path: The target file path the violation was found in.
|
|
170
|
+
all_detected_patterns: The matched historical/comparative phrases.
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
Returns:
|
|
173
|
+
The deny-reason text naming the file and the detected phrases.
|
|
174
|
+
"""
|
|
175
|
+
formatted = ", ".join(f'"{each_pattern}"' for each_pattern in all_detected_patterns)
|
|
176
|
+
return (
|
|
177
|
+
f"Historical/comparative language detected in {file_path}: "
|
|
178
|
+
f"{formatted}. Describe current state only — no 'instead of', "
|
|
179
|
+
f"'previously', 'now uses', etc. The git log tracks what changed. "
|
|
180
|
+
f"Comments and docs describe what IS."
|
|
181
|
+
)
|
|
175
182
|
|
|
176
|
-
tool_input = input_data.get("tool_input", {})
|
|
177
|
-
if not isinstance(tool_input, dict):
|
|
178
|
-
sys.exit(0)
|
|
179
183
|
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
185
|
+
"""Decide whether a Write/Edit payload carries historical/comparative language.
|
|
182
186
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
):
|
|
187
|
-
sys.exit(0)
|
|
187
|
+
Applies the same tool-name gate, file-extension gate, content selection, and
|
|
188
|
+
pattern scan the standalone hook applies. Returns the deny-reason text when a
|
|
189
|
+
historical phrase is found, or None to allow.
|
|
188
190
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
content_to_check = tool_input.get("content", "")
|
|
192
|
-
elif tool_name == "Edit":
|
|
193
|
-
content_to_check = tool_input.get("new_string", "")
|
|
191
|
+
Args:
|
|
192
|
+
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
194
193
|
|
|
194
|
+
Returns:
|
|
195
|
+
The permissionDecisionReason text when the write is denied, or None when
|
|
196
|
+
the write is allowed.
|
|
197
|
+
"""
|
|
198
|
+
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
199
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
200
|
+
if tool_name not in ("Write", "Edit"):
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
204
|
+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
205
|
+
|
|
206
|
+
file_path = tool_input.get("file_path", "")
|
|
207
|
+
if not isinstance(file_path, str) or not file_path:
|
|
208
|
+
return None
|
|
209
|
+
if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
content_key = "content" if tool_name == "Write" else "new_string"
|
|
213
|
+
raw_content = tool_input.get(content_key, "")
|
|
214
|
+
content_to_check = raw_content if isinstance(raw_content, str) else ""
|
|
195
215
|
if not content_to_check:
|
|
196
|
-
|
|
216
|
+
return None
|
|
197
217
|
|
|
198
218
|
all_detected_patterns = find_violations(content_to_check, file_path)
|
|
199
219
|
if not all_detected_patterns:
|
|
200
|
-
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
return _build_deny_reason(file_path, all_detected_patterns)
|
|
223
|
+
|
|
201
224
|
|
|
202
|
-
|
|
225
|
+
def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
226
|
+
"""Build the full deny payload the hook writes for a deny-reason string.
|
|
203
227
|
|
|
204
|
-
|
|
228
|
+
The payload carries the core permission decision plus the BAD/GOOD rewrite
|
|
229
|
+
guidance in additionalContext, the user-facing systemMessage, and output
|
|
230
|
+
suppression, so a caller routing this hook through a dispatcher reproduces
|
|
231
|
+
the same deny shape the standalone hook writes.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
deny_reason: The permissionDecisionReason text for the denial.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The deny payload dictionary the hook serializes to stdout.
|
|
238
|
+
"""
|
|
239
|
+
log_hook_block(
|
|
240
|
+
calling_hook_name="state_description_blocker.py",
|
|
241
|
+
hook_event="PreToolUse",
|
|
242
|
+
block_reason=deny_reason,
|
|
243
|
+
)
|
|
244
|
+
return {
|
|
205
245
|
"hookSpecificOutput": {
|
|
206
246
|
"hookEventName": "PreToolUse",
|
|
207
247
|
"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
|
-
),
|
|
248
|
+
"permissionDecisionReason": deny_reason,
|
|
214
249
|
"additionalContext": (
|
|
215
250
|
"Rewrite the affected comments or documentation to describe "
|
|
216
251
|
"only the current state. For example:\n"
|
|
@@ -223,7 +258,17 @@ def main() -> None:
|
|
|
223
258
|
"suppressOutput": True,
|
|
224
259
|
}
|
|
225
260
|
|
|
226
|
-
|
|
261
|
+
|
|
262
|
+
def main() -> None:
|
|
263
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
264
|
+
if payload_dictionary is None:
|
|
265
|
+
sys.exit(0)
|
|
266
|
+
|
|
267
|
+
deny_reason = evaluate(payload_dictionary)
|
|
268
|
+
if deny_reason is None:
|
|
269
|
+
sys.exit(0)
|
|
270
|
+
|
|
271
|
+
_emit_hook_result(build_deny_payload(deny_reason), sys.stdout)
|
|
227
272
|
sys.exit(0)
|
|
228
273
|
|
|
229
274
|
|
|
@@ -44,6 +44,7 @@ if _hooks_dir not in sys.path:
|
|
|
44
44
|
|
|
45
45
|
from code_rules_shared import is_test_file # noqa: E402
|
|
46
46
|
|
|
47
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
47
48
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
48
49
|
read_hook_input_dictionary_from_stdin,
|
|
49
50
|
)
|
|
@@ -360,15 +361,20 @@ def main() -> None:
|
|
|
360
361
|
sys.exit(0)
|
|
361
362
|
|
|
362
363
|
function_name, omitted_values = undercounted_budget
|
|
364
|
+
deny_reason = format_block_message(file_path, function_name, omitted_values)
|
|
365
|
+
log_hook_block(
|
|
366
|
+
calling_hook_name="subprocess_budget_completeness.py",
|
|
367
|
+
hook_event="PreToolUse",
|
|
368
|
+
block_reason=deny_reason,
|
|
369
|
+
offending_input_preview=file_path,
|
|
370
|
+
)
|
|
363
371
|
print(
|
|
364
372
|
json.dumps(
|
|
365
373
|
{
|
|
366
374
|
"hookSpecificOutput": {
|
|
367
375
|
"hookEventName": "PreToolUse",
|
|
368
376
|
"permissionDecision": "deny",
|
|
369
|
-
"permissionDecisionReason":
|
|
370
|
-
file_path, function_name, omitted_values
|
|
371
|
-
),
|
|
377
|
+
"permissionDecisionReason": deny_reason,
|
|
372
378
|
}
|
|
373
379
|
}
|
|
374
380
|
)
|
|
@@ -24,6 +24,7 @@ if _blocking_directory_path_string not in sys.path:
|
|
|
24
24
|
|
|
25
25
|
from code_rules_shared import is_ephemeral_script_path # noqa: E402
|
|
26
26
|
|
|
27
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
27
28
|
from hooks_constants.messages import USER_FACING_TDD_NOTICE # noqa: E402
|
|
28
29
|
|
|
29
30
|
PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
|
|
@@ -538,6 +539,11 @@ def emit_deny(reason: str) -> None:
|
|
|
538
539
|
"suppressOutput": True,
|
|
539
540
|
"systemMessage": USER_FACING_TDD_NOTICE,
|
|
540
541
|
}
|
|
542
|
+
log_hook_block(
|
|
543
|
+
calling_hook_name="tdd_enforcer.py",
|
|
544
|
+
hook_event="PreToolUse",
|
|
545
|
+
block_reason=reason,
|
|
546
|
+
)
|
|
541
547
|
print(json.dumps(deny_payload))
|
|
542
548
|
|
|
543
549
|
|
|
@@ -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
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Tests for check_docstring_returns_plural_cardinality — O6 plural-stop drift.
|
|
2
|
+
|
|
3
|
+
A function returns a dict literal whose keys carry prefix families, and its
|
|
4
|
+
Returns clause names one such family with a plural noun ("the sheen stops")
|
|
5
|
+
while only one key in that family exists ("sheen_mid"). The plural prose claims
|
|
6
|
+
two or more entries the dict no longer holds. This is the deterministic
|
|
7
|
+
single-key slice of Category O6 docstring-prose-vs-implementation drift, the
|
|
8
|
+
shape that appears when a producer removes the second key in a family but leaves
|
|
9
|
+
the plural prose untouched.
|
|
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_returns_plural_cardinality(content: str, file_path: str) -> list[str]:
|
|
33
|
+
return code_rules_enforcer.check_docstring_returns_plural_cardinality(content, file_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
37
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PRODUCTION_FILE_PATH = "/project/src/phone_handset.py"
|
|
41
|
+
TEST_FILE_PATH = "/project/src/test_phone_handset.py"
|
|
42
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _drifted_single_sheen_function() -> str:
|
|
46
|
+
return (
|
|
47
|
+
"def _palette_substitutions(colors: PhoneHandsetColors) -> dict[str, str]:\n"
|
|
48
|
+
' """Derive every SVG color field from the three theme hero colors.\n'
|
|
49
|
+
"\n"
|
|
50
|
+
" Returns:\n"
|
|
51
|
+
" The color substitution fields the SVG template fills: the body form\n"
|
|
52
|
+
" stops (highlight, mid, shadow, inner-shadow), the sheen stops, the rim\n"
|
|
53
|
+
" highlight, the depth, and the specular core that lights the gloss.\n"
|
|
54
|
+
' """\n'
|
|
55
|
+
" return {\n"
|
|
56
|
+
' "body_highlight": derive_highlight(colors.body),\n'
|
|
57
|
+
' "body_mid": colors.body,\n'
|
|
58
|
+
' "body_shadow": derive_shadow(colors.body),\n'
|
|
59
|
+
' "body_inner_shadow": derive_inner(colors.body),\n'
|
|
60
|
+
' "sheen_mid": colors.sheen,\n'
|
|
61
|
+
' "rim_highlight": derive_rim(colors.sheen),\n'
|
|
62
|
+
' "specular_core": derive_specular(colors.sheen),\n'
|
|
63
|
+
' "depth_mid": colors.depth,\n'
|
|
64
|
+
" }\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _singular_sheen_function() -> str:
|
|
69
|
+
return (
|
|
70
|
+
"def _palette_substitutions(colors: PhoneHandsetColors) -> dict[str, str]:\n"
|
|
71
|
+
' """Derive every SVG color field from the three theme hero colors.\n'
|
|
72
|
+
"\n"
|
|
73
|
+
" Returns:\n"
|
|
74
|
+
" The color substitution fields the SVG template fills: the body form\n"
|
|
75
|
+
" stops (highlight, mid, shadow, inner-shadow), the sheen stop, the rim\n"
|
|
76
|
+
" highlight, the depth, and the specular core that lights the gloss.\n"
|
|
77
|
+
' """\n'
|
|
78
|
+
" return {\n"
|
|
79
|
+
' "body_highlight": derive_highlight(colors.body),\n'
|
|
80
|
+
' "body_mid": colors.body,\n'
|
|
81
|
+
' "body_shadow": derive_shadow(colors.body),\n'
|
|
82
|
+
' "body_inner_shadow": derive_inner(colors.body),\n'
|
|
83
|
+
' "sheen_mid": colors.sheen,\n'
|
|
84
|
+
' "rim_highlight": derive_rim(colors.sheen),\n'
|
|
85
|
+
' "specular_core": derive_specular(colors.sheen),\n'
|
|
86
|
+
' "depth_mid": colors.depth,\n'
|
|
87
|
+
" }\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _plural_family_with_two_keys_function() -> str:
|
|
92
|
+
return (
|
|
93
|
+
"def _palette_substitutions(colors: PhoneHandsetColors) -> dict[str, str]:\n"
|
|
94
|
+
' """Derive every SVG color field from the three theme hero colors.\n'
|
|
95
|
+
"\n"
|
|
96
|
+
" Returns:\n"
|
|
97
|
+
" The color substitution fields the SVG template fills: the body form\n"
|
|
98
|
+
" stops (highlight, mid, shadow, inner-shadow), the sheen stops, the rim\n"
|
|
99
|
+
" highlight, the depth, and the specular core that lights the gloss.\n"
|
|
100
|
+
' """\n'
|
|
101
|
+
" return {\n"
|
|
102
|
+
' "body_highlight": derive_highlight(colors.body),\n'
|
|
103
|
+
' "body_mid": colors.body,\n'
|
|
104
|
+
' "sheen_highlight": derive_sheen_highlight(colors.sheen),\n'
|
|
105
|
+
' "sheen_mid": colors.sheen,\n'
|
|
106
|
+
' "rim_highlight": derive_rim(colors.sheen),\n'
|
|
107
|
+
' "specular_core": derive_specular(colors.sheen),\n'
|
|
108
|
+
" }\n"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_should_flag_plural_stops_with_single_family_key() -> None:
|
|
113
|
+
issues = check_docstring_returns_plural_cardinality(
|
|
114
|
+
_drifted_single_sheen_function(), PRODUCTION_FILE_PATH
|
|
115
|
+
)
|
|
116
|
+
assert any("sheen" in each for each in issues), (
|
|
117
|
+
f"The plural 'sheen stops' against a single sheen_mid key must be flagged, got: {issues!r}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_should_report_category_o6_in_the_message() -> None:
|
|
122
|
+
issues = check_docstring_returns_plural_cardinality(
|
|
123
|
+
_drifted_single_sheen_function(), PRODUCTION_FILE_PATH
|
|
124
|
+
)
|
|
125
|
+
assert any("O6" in each for each in issues), (
|
|
126
|
+
f"Expected the Category O6 label in the message, got: {issues!r}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_should_not_flag_singular_noun() -> None:
|
|
131
|
+
issues = check_docstring_returns_plural_cardinality(
|
|
132
|
+
_singular_sheen_function(), PRODUCTION_FILE_PATH
|
|
133
|
+
)
|
|
134
|
+
assert issues == [], (
|
|
135
|
+
f"A singular 'sheen stop' matching one key must not be flagged, got: {issues!r}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_not_flag_plural_family_with_two_keys() -> None:
|
|
140
|
+
issues = check_docstring_returns_plural_cardinality(
|
|
141
|
+
_plural_family_with_two_keys_function(), PRODUCTION_FILE_PATH
|
|
142
|
+
)
|
|
143
|
+
assert issues == [], (
|
|
144
|
+
f"A plural 'sheen stops' matching two sheen_ keys must not be flagged, got: {issues!r}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_should_not_flag_family_absent_from_dict() -> None:
|
|
149
|
+
source = (
|
|
150
|
+
"def build() -> dict[str, str]:\n"
|
|
151
|
+
' """Build the fields.\n'
|
|
152
|
+
"\n"
|
|
153
|
+
" Returns:\n"
|
|
154
|
+
" The body stops and the sheen stops the template fills.\n"
|
|
155
|
+
' """\n'
|
|
156
|
+
" return {\n"
|
|
157
|
+
' "body_mid": "a",\n'
|
|
158
|
+
' "rim_highlight": "b",\n'
|
|
159
|
+
" }\n"
|
|
160
|
+
)
|
|
161
|
+
issues = check_docstring_returns_plural_cardinality(source, PRODUCTION_FILE_PATH)
|
|
162
|
+
assert issues == [], (
|
|
163
|
+
f"A plural family with no matching dict keys must not be flagged, got: {issues!r}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_should_not_flag_when_no_returns_section() -> None:
|
|
168
|
+
source = (
|
|
169
|
+
"def build() -> dict[str, str]:\n"
|
|
170
|
+
' """Build the sheen stops without a Returns section."""\n'
|
|
171
|
+
" return {\n"
|
|
172
|
+
' "sheen_mid": "a",\n'
|
|
173
|
+
" }\n"
|
|
174
|
+
)
|
|
175
|
+
issues = check_docstring_returns_plural_cardinality(source, PRODUCTION_FILE_PATH)
|
|
176
|
+
assert issues == [], (
|
|
177
|
+
f"A plural noun outside a Returns section must not be flagged, got: {issues!r}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_should_skip_test_file() -> None:
|
|
182
|
+
issues = check_docstring_returns_plural_cardinality(
|
|
183
|
+
_drifted_single_sheen_function(), TEST_FILE_PATH
|
|
184
|
+
)
|
|
185
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_should_skip_hook_infrastructure() -> None:
|
|
189
|
+
issues = check_docstring_returns_plural_cardinality(
|
|
190
|
+
_drifted_single_sheen_function(), HOOK_INFRASTRUCTURE_PATH
|
|
191
|
+
)
|
|
192
|
+
assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
196
|
+
issues = check_docstring_returns_plural_cardinality("def fetch(\n", PRODUCTION_FILE_PATH)
|
|
197
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_validate_content_surfaces_plural_cardinality_drift() -> None:
|
|
201
|
+
issues = validate_content(
|
|
202
|
+
_drifted_single_sheen_function(), PRODUCTION_FILE_PATH, old_content=""
|
|
203
|
+
)
|
|
204
|
+
matching_issues = [each for each in issues if "sheen" in each and "O6" in each]
|
|
205
|
+
assert matching_issues, (
|
|
206
|
+
f"Expected validate_content to surface the O6 plural-cardinality drift, got: {issues!r}"
|
|
207
|
+
)
|