claude-dev-env 1.73.0 → 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +4 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  14. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  15. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  16. package/hooks/blocking/hedging_language_blocker.py +17 -23
  17. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  18. package/hooks/blocking/intent_only_ending_blocker.py +18 -26
  19. package/hooks/blocking/md_to_html_blocker.py +10 -2
  20. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  21. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  22. package/hooks/blocking/plain_language_blocker.py +6 -0
  23. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  24. package/hooks/blocking/pr_description_enforcer.py +6 -0
  25. package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
  26. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  27. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  28. package/hooks/blocking/question_to_user_enforcer.py +19 -23
  29. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  30. package/hooks/blocking/sensitive_file_protector.py +15 -1
  31. package/hooks/blocking/session_handoff_blocker.py +15 -23
  32. package/hooks/blocking/state_description_blocker.py +6 -0
  33. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  34. package/hooks/blocking/tdd_enforcer.py +6 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  36. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  37. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
  38. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  39. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  40. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  41. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  42. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  43. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  44. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  45. package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
  46. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  47. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  48. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  49. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  50. package/hooks/blocking/test_state_description_blocker.py +41 -0
  51. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  52. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  53. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  54. package/hooks/blocking/verified_commit_gate.py +11 -0
  55. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  56. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  57. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  58. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  59. package/hooks/hooks.json +10 -0
  60. package/hooks/hooks_constants/CLAUDE.md +8 -1
  61. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  62. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  63. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  64. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  65. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  66. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  67. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  68. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  69. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
  70. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
  71. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  72. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  73. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  74. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  75. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  76. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  77. package/hooks/hooks_constants/text_stripping.py +36 -0
  78. package/hooks/lifecycle/config_change_guard.py +12 -0
  79. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  80. package/hooks/validation/CLAUDE.md +1 -0
  81. package/hooks/validation/hook_format_validator.py +13 -0
  82. package/hooks/validation/mypy_validator.py +30 -1
  83. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  84. package/hooks/validation/test_hook_format_validator.py +64 -0
  85. package/hooks/validation/test_mypy_validator.py +23 -1
  86. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  87. package/hooks/workflow/auto_formatter.py +8 -5
  88. package/hooks/workflow/test_auto_formatter.py +33 -0
  89. package/package.json +1 -1
  90. package/rules/CLAUDE.md +1 -0
  91. package/rules/docstring-prose-matches-implementation.md +2 -1
  92. package/rules/package-inventory-stale-entry.md +24 -0
  93. package/rules/windows-filesystem-safe.md +2 -0
  94. package/skills/autoconverge/SKILL.md +21 -1
  95. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  96. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
  98. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  99. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  100. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  101. package/skills/autoconverge/workflow/converge.mjs +599 -606
  102. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  103. package/skills/autoconverge/workflow/render_report.py +2 -6
  104. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  105. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block SendUserFile attaches that should open locally.
3
+
4
+ SendUserFile attaches a file to the session. While the user is at the terminal
5
+ (status "normal" or unset) an attach does not let them see the file — it must
6
+ open on screen in its own viewer via Show-Asset.ps1. The one attach allowed
7
+ through is an away-from-desk phone push (status "proactive").
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
15
+ if _hooks_dir not in sys.path:
16
+ sys.path.insert(0, _hooks_dir)
17
+
18
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
19
+ from hooks_constants.send_user_file_open_locally_blocker_constants import ( # noqa: E402
20
+ CORRECTIVE_MESSAGE,
21
+ PROACTIVE_STATUS,
22
+ TOOL_NAME,
23
+ )
24
+
25
+
26
+ def _should_block(status: str) -> bool:
27
+ """Return whether a SendUserFile call with this status should be denied.
28
+
29
+ Args:
30
+ status: The ``status`` field from the SendUserFile input. A proactive
31
+ phone push is allowed; every other value, including an empty one,
32
+ is a desk-side attach the user cannot see and is denied.
33
+ """
34
+ return status != PROACTIVE_STATUS
35
+
36
+
37
+ def main() -> None:
38
+ try:
39
+ hook_input = json.load(sys.stdin)
40
+ except json.JSONDecodeError:
41
+ sys.exit(0)
42
+
43
+ if hook_input.get("tool_name", "") != TOOL_NAME:
44
+ sys.exit(0)
45
+
46
+ tool_input = hook_input.get("tool_input") or {}
47
+ status = tool_input.get("status", "")
48
+ if not _should_block(status):
49
+ sys.exit(0)
50
+
51
+ deny_payload = {
52
+ "hookSpecificOutput": {
53
+ "hookEventName": "PreToolUse",
54
+ "permissionDecision": "deny",
55
+ "permissionDecisionReason": CORRECTIVE_MESSAGE,
56
+ }
57
+ }
58
+ log_hook_block(
59
+ calling_hook_name="send_user_file_open_locally_blocker.py",
60
+ hook_event="PreToolUse",
61
+ block_reason=CORRECTIVE_MESSAGE,
62
+ tool_name=TOOL_NAME,
63
+ )
64
+ print(json.dumps(deny_payload))
65
+ sys.stdout.flush()
66
+ sys.exit(0)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -3,6 +3,13 @@ import fnmatch
3
3
  import json
4
4
  import os
5
5
  import sys
6
+ from pathlib import Path
7
+
8
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
9
+ if _hooks_dir not in sys.path:
10
+ sys.path.insert(0, _hooks_dir)
11
+
12
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
6
13
 
7
14
  SENSITIVE_PATTERNS = [
8
15
  ".env",
@@ -54,13 +61,20 @@ def main() -> None:
54
61
  matched_pattern = is_sensitive_file(file_path)
55
62
 
56
63
  if matched_pattern is not None:
64
+ deny_reason = f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
57
65
  deny_response = {
58
66
  "hookSpecificOutput": {
59
67
  "hookEventName": "PreToolUse",
60
68
  "permissionDecision": "deny",
61
- "permissionDecisionReason": f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
69
+ "permissionDecisionReason": deny_reason,
62
70
  }
63
71
  }
72
+ log_hook_block(
73
+ calling_hook_name="sensitive_file_protector.py",
74
+ hook_event="PreToolUse",
75
+ block_reason=deny_reason,
76
+ offending_input_preview=file_path,
77
+ )
64
78
  print(json.dumps(deny_response))
65
79
 
66
80
  sys.exit(0)
@@ -18,25 +18,12 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
18
  if _hooks_dir not in sys.path:
19
19
  sys.path.insert(0, _hooks_dir)
20
20
 
21
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
21
22
  from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE # noqa: E402
22
23
  from hooks_constants.session_handoff_blocker_constants import ( # noqa: E402
23
24
  FIRST_PERSON_SUBJECT_PATTERN,
24
25
  )
25
-
26
-
27
- def strip_code_and_quotes(text: str) -> str:
28
- """Remove code blocks, inline code, and blockquotes to avoid false positives.
29
-
30
- Args:
31
- text: The raw assistant message to clean.
32
- """
33
- code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
34
- inline_code_pattern = re.compile(r"`[^`]+`")
35
- quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
36
- text = code_block_pattern.sub("", text)
37
- text = inline_code_pattern.sub("", text)
38
- text = quoted_block_pattern.sub("", text)
39
- return text
26
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
40
27
 
41
28
 
42
29
  def split_into_sentences(text: str) -> list[str]:
@@ -169,19 +156,24 @@ def main() -> None:
169
156
  if not find_session_handoff_proposal(assistant_message):
170
157
  sys.exit(0)
171
158
 
159
+ block_reason = (
160
+ "LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
161
+ "stop, summarize, or suggest a new session on account of context limits. "
162
+ "Continue the work.\n\n"
163
+ "Re-output your response continuing the task without the handoff suggestion, "
164
+ "per the long-horizon-autonomy rule."
165
+ )
172
166
  block_response = {
173
167
  "decision": "block",
174
- "reason": (
175
- "LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
176
- "stop, summarize, or suggest a new session on account of context limits. "
177
- "Continue the work.\n\n"
178
- "Re-output your response continuing the task without the handoff suggestion, "
179
- "per the long-horizon-autonomy rule."
180
- ),
168
+ "reason": block_reason,
181
169
  "systemMessage": USER_FACING_CONTEXT_REASSURANCE_NOTICE,
182
170
  "suppressOutput": True,
183
171
  }
184
-
172
+ log_hook_block(
173
+ calling_hook_name="session_handoff_blocker.py",
174
+ hook_event="Stop",
175
+ block_reason=block_reason,
176
+ )
185
177
  print(json.dumps(block_response))
186
178
  sys.exit(0)
187
179
 
@@ -17,6 +17,7 @@ if _hooks_dir not in sys.path:
17
17
  sys.path.insert(0, _hooks_dir)
18
18
 
19
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
20
21
  from hooks_constants.state_description_blocker_constants import ( # noqa: E402
21
22
  ALL_BLOCK_COMMENT_EXTENSIONS,
22
23
  ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
@@ -235,6 +236,11 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
235
236
  Returns:
236
237
  The deny payload dictionary the hook serializes to stdout.
237
238
  """
239
+ log_hook_block(
240
+ calling_hook_name="state_description_blocker.py",
241
+ hook_event="PreToolUse",
242
+ block_reason=deny_reason,
243
+ )
238
244
  return {
239
245
  "hookSpecificOutput": {
240
246
  "hookEventName": "PreToolUse",
@@ -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
 
@@ -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
+ )
@@ -0,0 +1,188 @@
1
+ """Tests for check_docstring_unguarded_malformed_payload_claim — Category O6 drift.
2
+
3
+ A function docstring that promises "a malformed payload resolves to None" asserts
4
+ the body catches a bad payload and turns it into a None return. The claim drifts
5
+ when the value construction that dereferences payload fields (``payload["key"]``,
6
+ ``float(payload["key"])``) sits OUTSIDE the try/except whose handler returns None:
7
+ a present-but-malformed payload raises KeyError or TypeError from that unguarded
8
+ dereference and propagates rather than resolving to None. This is the deterministic
9
+ slice of Category O6 (docstring prose vs implementation drift) for an
10
+ exception-guard claim.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+
19
+
20
+ def _load_enforcer_module() -> ModuleType:
21
+ module_path = Path(__file__).parent / "code_rules_enforcer.py"
22
+ spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
23
+ assert spec is not None
24
+ assert spec.loader is not None
25
+ module = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(module)
27
+ return module
28
+
29
+
30
+ code_rules_enforcer = _load_enforcer_module()
31
+
32
+
33
+ def check_docstring_unguarded_malformed_payload_claim(content: str, file_path: str) -> list[str]:
34
+ return code_rules_enforcer.check_docstring_unguarded_malformed_payload_claim(content, file_path)
35
+
36
+
37
+ PRODUCTION_FILE_PATH = "/project/shared/human_actions.py"
38
+ TEST_FILE_PATH = "/project/shared/test_human_actions.py"
39
+ HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
40
+
41
+
42
+ _UNGUARDED_DEREFERENCE_BODY = (
43
+ "def read_metrics(self, selector: str) -> object:\n"
44
+ ' """Read the container geometry over CDP, or None on failure.\n'
45
+ "\n"
46
+ " A missing element, a CDP error, or a malformed payload resolves to None\n"
47
+ " so the caller skips the drag.\n"
48
+ ' """\n'
49
+ " try:\n"
50
+ " evaluate_payload = self.cdp.run(selector)\n"
51
+ " parsed_metrics = json.loads(evaluate_payload)\n"
52
+ " except (KeyError, TypeError, ValueError):\n"
53
+ " return None\n"
54
+ ' if not parsed_metrics.get("found"):\n'
55
+ " return None\n"
56
+ " return Metrics(\n"
57
+ ' client_width=float(parsed_metrics["client_width"]),\n'
58
+ ' client_height=float(parsed_metrics["client_height"]),\n'
59
+ " )\n"
60
+ )
61
+
62
+
63
+ def test_flags_dereference_outside_the_guarded_block() -> None:
64
+ issues = check_docstring_unguarded_malformed_payload_claim(
65
+ _UNGUARDED_DEREFERENCE_BODY, PRODUCTION_FILE_PATH
66
+ )
67
+ assert len(issues) == 1
68
+ assert "read_metrics" in issues[0]
69
+
70
+
71
+ def test_passes_when_dereference_sits_inside_the_guarded_block() -> None:
72
+ content = (
73
+ "def read_metrics(self, selector: str) -> object:\n"
74
+ ' """Read the container geometry over CDP, or None on failure.\n'
75
+ "\n"
76
+ " A missing element, a CDP error, or a malformed payload resolves to None\n"
77
+ " so the caller skips the drag.\n"
78
+ ' """\n'
79
+ " try:\n"
80
+ " evaluate_payload = self.cdp.run(selector)\n"
81
+ " parsed_metrics = json.loads(evaluate_payload)\n"
82
+ ' if not parsed_metrics.get("found"):\n'
83
+ " return None\n"
84
+ " return Metrics(\n"
85
+ ' client_width=float(parsed_metrics["client_width"]),\n'
86
+ ' client_height=float(parsed_metrics["client_height"]),\n'
87
+ " )\n"
88
+ " except (KeyError, TypeError, ValueError):\n"
89
+ " return None\n"
90
+ )
91
+ assert check_docstring_unguarded_malformed_payload_claim(content, PRODUCTION_FILE_PATH) == []
92
+
93
+
94
+ def test_passes_when_docstring_makes_no_malformed_payload_claim() -> None:
95
+ content = (
96
+ "def read_metrics(self, selector: str) -> object:\n"
97
+ ' """Read the container geometry over CDP, or None on failure."""\n'
98
+ " try:\n"
99
+ " parsed_metrics = json.loads(self.cdp.run(selector))\n"
100
+ " except (KeyError, TypeError, ValueError):\n"
101
+ " return None\n"
102
+ " return Metrics(\n"
103
+ ' client_width=float(parsed_metrics["client_width"]),\n'
104
+ " )\n"
105
+ )
106
+ assert check_docstring_unguarded_malformed_payload_claim(content, PRODUCTION_FILE_PATH) == []
107
+
108
+
109
+ def test_passes_when_no_subscript_dereference_follows_the_guard() -> None:
110
+ content = (
111
+ "def read_metrics(self, selector: str) -> object:\n"
112
+ ' """Read the geometry over CDP, or None on failure.\n'
113
+ "\n"
114
+ " A malformed payload resolves to None so the caller skips the drag.\n"
115
+ ' """\n'
116
+ " try:\n"
117
+ " parsed_metrics = json.loads(self.cdp.run(selector))\n"
118
+ " except (KeyError, TypeError, ValueError):\n"
119
+ " return None\n"
120
+ ' return parsed_metrics.get("found")\n'
121
+ )
122
+ assert check_docstring_unguarded_malformed_payload_claim(content, PRODUCTION_FILE_PATH) == []
123
+
124
+
125
+ def test_passes_when_post_guard_subscript_dereferences_an_unrelated_name() -> None:
126
+ content = (
127
+ "def read_metrics(self, selector: str) -> object:\n"
128
+ ' """Read the geometry over CDP, or None on failure.\n'
129
+ "\n"
130
+ " A malformed payload resolves to None so the caller skips the drag.\n"
131
+ ' """\n'
132
+ " try:\n"
133
+ " parsed_metrics = json.loads(self.cdp.run(selector))\n"
134
+ ' width = float(parsed_metrics["client_width"])\n'
135
+ " except (KeyError, TypeError, ValueError):\n"
136
+ " return None\n"
137
+ " fallback = cache[selector]\n"
138
+ " return Metrics(client_width=width, fallback=fallback)\n"
139
+ )
140
+ assert check_docstring_unguarded_malformed_payload_claim(content, PRODUCTION_FILE_PATH) == []
141
+
142
+
143
+ def _unguarded_body_with_claim(claim_sentence: str) -> str:
144
+ return (
145
+ "def read_metrics(self, selector: str) -> object:\n"
146
+ ' """Read the container geometry over CDP, or None on failure.\n'
147
+ "\n"
148
+ f" {claim_sentence}\n"
149
+ ' """\n'
150
+ " try:\n"
151
+ " parsed_metrics = json.loads(self.cdp.run(selector))\n"
152
+ " except (KeyError, TypeError, ValueError):\n"
153
+ " return None\n"
154
+ ' return float(parsed_metrics["client_width"])\n'
155
+ )
156
+
157
+
158
+ def test_article_prefixed_claim_phrasings_still_flag_via_articleless_substring() -> None:
159
+ all_article_prefixed_claims = (
160
+ "A malformed payload resolves to None so the caller skips the drag.",
161
+ "A bad payload resolves to None so the caller skips the drag.",
162
+ "An invalid payload resolves to None so the caller skips the drag.",
163
+ "A malformed payload yields None so the caller skips the drag.",
164
+ )
165
+ for each_claim in all_article_prefixed_claims:
166
+ issues = check_docstring_unguarded_malformed_payload_claim(
167
+ _unguarded_body_with_claim(each_claim), PRODUCTION_FILE_PATH
168
+ )
169
+ assert len(issues) == 1
170
+ assert "read_metrics" in issues[0]
171
+
172
+
173
+ def test_test_files_are_exempt() -> None:
174
+ assert (
175
+ check_docstring_unguarded_malformed_payload_claim(
176
+ _UNGUARDED_DEREFERENCE_BODY, TEST_FILE_PATH
177
+ )
178
+ == []
179
+ )
180
+
181
+
182
+ def test_hook_infrastructure_is_exempt() -> None:
183
+ assert (
184
+ check_docstring_unguarded_malformed_payload_claim(
185
+ _UNGUARDED_DEREFERENCE_BODY, HOOK_INFRASTRUCTURE_PATH
186
+ )
187
+ == []
188
+ )