claude-dev-env 1.73.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 +1 -1
- package/hooks/blocking/CLAUDE.md +3 -0
- 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 +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
- 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 +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -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 +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -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 +8 -8
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -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 +10 -1
- 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 +10 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- 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/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
- 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 +30 -1
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +22 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +18 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +2 -1
|
@@ -18,6 +18,7 @@ _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,
|
|
@@ -169,19 +170,24 @@ def main() -> None:
|
|
|
169
170
|
if not find_session_handoff_proposal(assistant_message):
|
|
170
171
|
sys.exit(0)
|
|
171
172
|
|
|
173
|
+
block_reason = (
|
|
174
|
+
"LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
|
|
175
|
+
"stop, summarize, or suggest a new session on account of context limits. "
|
|
176
|
+
"Continue the work.\n\n"
|
|
177
|
+
"Re-output your response continuing the task without the handoff suggestion, "
|
|
178
|
+
"per the long-horizon-autonomy rule."
|
|
179
|
+
)
|
|
172
180
|
block_response = {
|
|
173
181
|
"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
|
-
),
|
|
182
|
+
"reason": block_reason,
|
|
181
183
|
"systemMessage": USER_FACING_CONTEXT_REASSURANCE_NOTICE,
|
|
182
184
|
"suppressOutput": True,
|
|
183
185
|
}
|
|
184
|
-
|
|
186
|
+
log_hook_block(
|
|
187
|
+
calling_hook_name="session_handoff_blocker.py",
|
|
188
|
+
hook_event="Stop",
|
|
189
|
+
block_reason=block_reason,
|
|
190
|
+
)
|
|
185
191
|
print(json.dumps(block_response))
|
|
186
192
|
sys.exit(0)
|
|
187
193
|
|
|
@@ -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":
|
|
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
|
+
)
|
|
@@ -8,6 +8,7 @@ test_precommit_code_rules_gate.py lines 1-70.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import shutil
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
@@ -454,3 +455,47 @@ def test_conflict_and_violation_single_deny_names_both(tmp_path: Path) -> None:
|
|
|
454
455
|
assert "shared.py" in reason
|
|
455
456
|
assert "CODE_RULES violations on changed lines:" in reason
|
|
456
457
|
assert "violator.py" in reason
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_hook_imports_real_config_when_parent_holds_shadowing_config(
|
|
461
|
+
tmp_path: Path,
|
|
462
|
+
) -> None:
|
|
463
|
+
real_hooks_directory = HOOK_PATH.parent.parent
|
|
464
|
+
real_package_directory = real_hooks_directory.parent
|
|
465
|
+
|
|
466
|
+
staged_package_directory = tmp_path / "claude-dev-env"
|
|
467
|
+
shutil.copytree(
|
|
468
|
+
real_hooks_directory,
|
|
469
|
+
staged_package_directory / "hooks",
|
|
470
|
+
)
|
|
471
|
+
shutil.copytree(
|
|
472
|
+
real_package_directory / "_shared",
|
|
473
|
+
staged_package_directory / "_shared",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
shadowing_config_directory = staged_package_directory / "hooks" / "config"
|
|
477
|
+
shadowing_config_directory.mkdir(parents=True, exist_ok=True)
|
|
478
|
+
(shadowing_config_directory / "__init__.py").write_text("", encoding="utf-8")
|
|
479
|
+
(shadowing_config_directory / "unrelated_constants.py").write_text(
|
|
480
|
+
"UNRELATED_VALUE = 1\n", encoding="utf-8"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
staged_hook = (
|
|
484
|
+
staged_package_directory
|
|
485
|
+
/ "hooks"
|
|
486
|
+
/ "blocking"
|
|
487
|
+
/ "code_verifier_spawn_preflight_gate.py"
|
|
488
|
+
)
|
|
489
|
+
payload = write_agent_payload("general-purpose", "unrelated work", tmp_path)
|
|
490
|
+
completed = subprocess.run(
|
|
491
|
+
[sys.executable, str(staged_hook)],
|
|
492
|
+
check=False,
|
|
493
|
+
input=payload,
|
|
494
|
+
capture_output=True,
|
|
495
|
+
text=True,
|
|
496
|
+
cwd=str(tmp_path),
|
|
497
|
+
timeout=120,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
assert "ModuleNotFoundError" not in completed.stderr
|
|
501
|
+
assert completed.returncode == 0
|