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.
Files changed (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. 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 main() -> None:
164
- try:
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
- if not isinstance(input_data, dict):
170
- sys.exit(0)
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
- tool_name = input_data.get("tool_name", "")
173
- if not isinstance(tool_name, str):
174
- sys.exit(0)
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
- if tool_name not in ("Write", "Edit"):
181
- sys.exit(0)
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
- 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)
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
- 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", "")
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
- sys.exit(0)
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
- sys.exit(0)
220
+ return None
221
+
222
+ return _build_deny_reason(file_path, all_detected_patterns)
223
+
201
224
 
202
- formatted = ", ".join(f'"{p}"' for p in all_detected_patterns)
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
- block_payload = {
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
- _emit_hook_result(block_payload, sys.stdout)
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": format_block_message(
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
+ )