claude-dev-env 2.30.1 → 2.31.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/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +57 -31
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Unit tests for the pr-description-writer spawn tracker PostToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_OBSERVABILITY_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
_HOOKS_DIRECTORY = _OBSERVABILITY_DIRECTORY.parent
|
|
16
|
+
for each_directory in (_OBSERVABILITY_DIRECTORY, _HOOKS_DIRECTORY):
|
|
17
|
+
if str(each_directory) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(each_directory))
|
|
19
|
+
|
|
20
|
+
_TRACKER_SPEC = importlib.util.spec_from_file_location(
|
|
21
|
+
"pr_description_writer_spawn_tracker",
|
|
22
|
+
_OBSERVABILITY_DIRECTORY / "pr_description_writer_spawn_tracker.py",
|
|
23
|
+
)
|
|
24
|
+
assert _TRACKER_SPEC is not None
|
|
25
|
+
assert _TRACKER_SPEC.loader is not None
|
|
26
|
+
tracker_module = importlib.util.module_from_spec(_TRACKER_SPEC)
|
|
27
|
+
_TRACKER_SPEC.loader.exec_module(tracker_module)
|
|
28
|
+
|
|
29
|
+
_SESSION_ID = "session-abc123"
|
|
30
|
+
_OTHER_SUBAGENT_TYPE = "general-purpose"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _spawn_payload(
|
|
34
|
+
subagent_type: str,
|
|
35
|
+
tool_name: str = "Agent",
|
|
36
|
+
session_id: str = _SESSION_ID,
|
|
37
|
+
) -> dict[str, object]:
|
|
38
|
+
"""Build a PostToolUse payload for one agent spawn.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
subagent_type: The agent type the spawn names.
|
|
42
|
+
tool_name: The spawning tool name.
|
|
43
|
+
session_id: The session the spawn belongs to.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The payload dictionary the tracker reads from stdin.
|
|
47
|
+
"""
|
|
48
|
+
return {
|
|
49
|
+
"tool_name": tool_name,
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"tool_input": {
|
|
52
|
+
"subagent_type": subagent_type,
|
|
53
|
+
"description": "Write the PR body",
|
|
54
|
+
"prompt": "Write a PR description from the diff.",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_tracker(payload: dict[str, object], temp_directory: Path) -> None:
|
|
60
|
+
"""Run the tracker's main against a payload with temp redirected.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
payload: The PostToolUse payload to feed on stdin.
|
|
64
|
+
temp_directory: The directory standing in for the system temp root.
|
|
65
|
+
"""
|
|
66
|
+
with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
|
|
67
|
+
with mock.patch.object(
|
|
68
|
+
tracker_module.tempfile, "gettempdir", return_value=str(temp_directory)
|
|
69
|
+
):
|
|
70
|
+
tracker_module.main()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def should_record_marker_when_pr_description_writer_spawns(tmp_path: Path) -> None:
|
|
74
|
+
"""A pr-description-writer spawn leaves this session's marker on disk."""
|
|
75
|
+
_run_tracker(_spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE), tmp_path)
|
|
76
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def should_leave_no_marker_when_another_agent_spawns(tmp_path: Path) -> None:
|
|
80
|
+
"""A spawn of a different agent type records nothing."""
|
|
81
|
+
_run_tracker(_spawn_payload(_OTHER_SUBAGENT_TYPE), tmp_path)
|
|
82
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("spawn_tool_name", ["Agent", "Task"])
|
|
86
|
+
def should_record_marker_for_each_spawn_tool_name(spawn_tool_name: str, tmp_path: Path) -> None:
|
|
87
|
+
"""Both spawning tool names record the marker."""
|
|
88
|
+
payload = _spawn_payload(
|
|
89
|
+
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name=spawn_tool_name
|
|
90
|
+
)
|
|
91
|
+
_run_tracker(payload, tmp_path)
|
|
92
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def should_leave_no_marker_for_an_unrelated_tool(tmp_path: Path) -> None:
|
|
96
|
+
"""A tool that is not a spawn records nothing, even naming the agent."""
|
|
97
|
+
payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, tool_name="Bash")
|
|
98
|
+
_run_tracker(payload, tmp_path)
|
|
99
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def should_keep_each_session_marker_separate(tmp_path: Path) -> None:
|
|
103
|
+
"""A spawn in one session leaves another session's marker absent."""
|
|
104
|
+
payload = _spawn_payload(
|
|
105
|
+
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE, session_id="session-one"
|
|
106
|
+
)
|
|
107
|
+
_run_tracker(payload, tmp_path)
|
|
108
|
+
assert tracker_module.spawn_marker_path("session-one", tmp_path).exists() is True
|
|
109
|
+
assert tracker_module.spawn_marker_path("session-two", tmp_path).exists() is False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def should_record_twice_without_error(tmp_path: Path) -> None:
|
|
113
|
+
"""A second spawn in one session leaves the marker in place."""
|
|
114
|
+
payload = _spawn_payload(tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE)
|
|
115
|
+
_run_tracker(payload, tmp_path)
|
|
116
|
+
_run_tracker(payload, tmp_path)
|
|
117
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def should_ignore_a_malformed_payload(tmp_path: Path) -> None:
|
|
121
|
+
"""Unparseable stdin records nothing and raises nothing."""
|
|
122
|
+
with mock.patch("sys.stdin", io.StringIO("this is not json")):
|
|
123
|
+
with mock.patch.object(tracker_module.tempfile, "gettempdir", return_value=str(tmp_path)):
|
|
124
|
+
tracker_module.main()
|
|
125
|
+
assert list(tmp_path.iterdir()) == []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def should_ignore_a_payload_with_no_tool_input(tmp_path: Path) -> None:
|
|
129
|
+
"""A spawn payload missing tool_input records nothing."""
|
|
130
|
+
payload: dict[str, object] = {"tool_name": "Agent", "session_id": _SESSION_ID}
|
|
131
|
+
_run_tracker(payload, tmp_path)
|
|
132
|
+
assert tracker_module.spawn_marker_path(_SESSION_ID, tmp_path).exists() is False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("unusable_session_id", ["", "///"])
|
|
136
|
+
def should_record_nothing_without_a_usable_session_id(
|
|
137
|
+
unusable_session_id: str, tmp_path: Path
|
|
138
|
+
) -> None:
|
|
139
|
+
"""A payload naming no usable session records no marker at all."""
|
|
140
|
+
payload = _spawn_payload(
|
|
141
|
+
tracker_module.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
|
|
142
|
+
session_id=unusable_session_id,
|
|
143
|
+
)
|
|
144
|
+
_run_tracker(payload, tmp_path)
|
|
145
|
+
assert tracker_module.spawn_marker_path(unusable_session_id, tmp_path) is None
|
|
146
|
+
assert list(tmp_path.iterdir()) == []
|
|
@@ -172,3 +172,35 @@ def test_should_exit_zero_on_malformed_stdin(
|
|
|
172
172
|
) -> None:
|
|
173
173
|
_run_main_with_stdin("not valid json {{{")
|
|
174
174
|
assert not any(redirected_temp_directory.iterdir())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_should_record_every_apply_patch_target_file(
|
|
178
|
+
redirected_temp_directory: pathlib.Path,
|
|
179
|
+
) -> None:
|
|
180
|
+
working_directory = redirected_temp_directory / "repo"
|
|
181
|
+
working_directory.mkdir()
|
|
182
|
+
(working_directory / "updated.py").write_text("before\n", encoding="utf-8")
|
|
183
|
+
patch_command = (
|
|
184
|
+
"*** Begin Patch\n"
|
|
185
|
+
"*** Update File: updated.py\n"
|
|
186
|
+
"@@\n"
|
|
187
|
+
"-before\n"
|
|
188
|
+
"+after\n"
|
|
189
|
+
"*** Add File: added.py\n"
|
|
190
|
+
"+new\n"
|
|
191
|
+
"*** End Patch"
|
|
192
|
+
)
|
|
193
|
+
payload = json.dumps(
|
|
194
|
+
{
|
|
195
|
+
"session_id": _SESSION_ID,
|
|
196
|
+
"tool_name": "apply_patch",
|
|
197
|
+
"cwd": str(working_directory),
|
|
198
|
+
"tool_input": {"command": patch_command},
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
_run_main_with_stdin(payload)
|
|
203
|
+
|
|
204
|
+
recorded = _read_recorded_paths(redirected_temp_directory, _SESSION_ID)
|
|
205
|
+
assert str((working_directory / "updated.py").resolve()) in recorded
|
|
206
|
+
assert str((working_directory / "added.py").resolve()) in recorded
|
|
@@ -9,17 +9,34 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
sys.path
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
try:
|
|
13
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _hooks_dir not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_dir)
|
|
16
|
+
|
|
17
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
18
|
+
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
19
|
+
except ImportError as import_error:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"hook_format_validator: cannot import its sibling modules; "
|
|
22
|
+
"ensure the hooks directory is importable."
|
|
23
|
+
) from import_error
|
|
17
24
|
|
|
18
25
|
SIMPLE_PATTERN = re.compile(
|
|
19
26
|
r'python3?\s+~/\.claude/hooks/'
|
|
20
27
|
)
|
|
21
28
|
|
|
22
29
|
|
|
30
|
+
def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
|
|
31
|
+
"""Return the text a Write, Edit, or MultiEdit payload introduces."""
|
|
32
|
+
if tool_name == "MultiEdit":
|
|
33
|
+
return joined_new_strings(all_tool_input)
|
|
34
|
+
content = all_tool_input.get("content", "")
|
|
35
|
+
if not content:
|
|
36
|
+
content = all_tool_input.get("new_string", "")
|
|
37
|
+
return content
|
|
38
|
+
|
|
39
|
+
|
|
23
40
|
def main() -> None:
|
|
24
41
|
try:
|
|
25
42
|
hook_input = json.load(sys.stdin)
|
|
@@ -36,10 +53,7 @@ def main() -> None:
|
|
|
36
53
|
sys.exit(0)
|
|
37
54
|
|
|
38
55
|
tool_name = hook_input.get("tool_name", "")
|
|
39
|
-
content =
|
|
40
|
-
if not content:
|
|
41
|
-
new_string = tool_input.get("new_string", "")
|
|
42
|
-
content = new_string
|
|
56
|
+
content = _resolve_content(tool_name, tool_input)
|
|
43
57
|
|
|
44
58
|
if tool_name == "Write" and content:
|
|
45
59
|
try:
|
|
@@ -41,6 +41,64 @@ def test_simple_pattern_blocks_with_deny_payload(tmp_path: Path) -> None:
|
|
|
41
41
|
assert hook_specific_output["permissionDecision"] == "deny"
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def test_write_payload_reads_the_content_field(tmp_path: Path) -> None:
|
|
45
|
+
"""_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
|
|
46
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
47
|
+
payload = {
|
|
48
|
+
"tool_name": "Write",
|
|
49
|
+
"tool_input": {
|
|
50
|
+
"file_path": str(settings_path),
|
|
51
|
+
"content": "python3 ~/.claude/hooks/blocking/my-hook.py",
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
hook_run = _run_hook(payload)
|
|
56
|
+
|
|
57
|
+
assert hook_run.returncode == 0
|
|
58
|
+
deny_payload = json.loads(hook_run.stdout)
|
|
59
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_second_multi_edit_new_string_is_caught(tmp_path: Path) -> None:
|
|
63
|
+
"""A simple-pattern hook command in the second edit of a MultiEdit is denied."""
|
|
64
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
65
|
+
payload = {
|
|
66
|
+
"tool_name": "MultiEdit",
|
|
67
|
+
"tool_input": {
|
|
68
|
+
"file_path": str(settings_path),
|
|
69
|
+
"edits": [
|
|
70
|
+
{"old_string": "a", "new_string": "b"},
|
|
71
|
+
{"old_string": "c", "new_string": "python3 ~/.claude/hooks/blocking/my-hook.py"},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hook_run = _run_hook(payload)
|
|
77
|
+
|
|
78
|
+
assert hook_run.returncode == 0
|
|
79
|
+
deny_payload = json.loads(hook_run.stdout)
|
|
80
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_clean_multi_edit_passes(tmp_path: Path) -> None:
|
|
84
|
+
settings_path = tmp_path / ".claude" / "settings.json"
|
|
85
|
+
payload = {
|
|
86
|
+
"tool_name": "MultiEdit",
|
|
87
|
+
"tool_input": {
|
|
88
|
+
"file_path": str(settings_path),
|
|
89
|
+
"edits": [
|
|
90
|
+
{"old_string": "a", "new_string": "b"},
|
|
91
|
+
{"old_string": "c", "new_string": "node -e \"require('run-hook-wrapper')\""},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
hook_run = _run_hook(payload)
|
|
97
|
+
|
|
98
|
+
assert hook_run.returncode == 0
|
|
99
|
+
assert hook_run.stdout.strip() == ""
|
|
100
|
+
|
|
101
|
+
|
|
44
102
|
def test_block_logs_pre_tool_use_event(tmp_path: Path) -> None:
|
|
45
103
|
fake_home = tmp_path / "home"
|
|
46
104
|
fake_home.mkdir()
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
AST-based Python style checks for code quality enforcement.
|
|
4
4
|
|
|
5
|
-
The checks live in `python_style_checks.py`; the shared source-line splitting
|
|
5
|
+
The checks live in `python_style_checks.py`; the shared source-line splitting, function-discovery, and statement-classification helpers they build on live in `python_style_helpers.py`. `python_style_import_bootstrap.py` recognizes the repo's sys.path bootstrap-guard idiom so the imports-at-top check does not penalize a file for following it. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count. `fast_save_validators.py` runs the twelve non-Mypy, non-Ruff checks in-process for the Write/Edit save-path gate, with no per-check subprocess.
|
|
6
6
|
|
|
7
7
|
## Checks Implemented
|
|
8
8
|
|
|
@@ -11,20 +11,18 @@ Detects:
|
|
|
11
11
|
import ast
|
|
12
12
|
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import List
|
|
14
|
+
from typing import List
|
|
15
15
|
|
|
16
|
+
from .config.abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
|
|
16
17
|
from .validator_base import Violation
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
ALLOWED_SINGLE_LETTERS: Set[str] = frozenset({"i", "j", "k", "_"})
|
|
20
|
-
|
|
21
|
-
|
|
22
20
|
def check_single_letter_variables(tree: ast.AST, filename: str) -> List[Violation]:
|
|
23
21
|
violations: List[Violation] = []
|
|
24
22
|
|
|
25
23
|
for node in ast.walk(tree):
|
|
26
24
|
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
27
|
-
if len(node.id) == 1 and node.id not in
|
|
25
|
+
if len(node.id) == 1 and node.id not in ALL_ALLOWED_SINGLE_LETTERS:
|
|
28
26
|
violations.append(
|
|
29
27
|
Violation(
|
|
30
28
|
filename,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""File suffixes and report text the save-path validator roster reuses.
|
|
2
|
+
|
|
3
|
+
Config files are exempt from the string-literal magic-value checks, so the
|
|
4
|
+
per-language file suffixes and shared report strings live here rather than
|
|
5
|
+
inline in ``fast_save_validators.py``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
|
|
9
|
+
|
|
10
|
+
TYPESCRIPT_SOURCE_SUFFIX = ".ts"
|
|
11
|
+
REACT_TYPESCRIPT_SOURCE_SUFFIX = ".tsx"
|
|
12
|
+
JAVASCRIPT_SOURCE_SUFFIX = ".js"
|
|
13
|
+
REACT_JAVASCRIPT_SOURCE_SUFFIX = ".jsx"
|
|
14
|
+
|
|
15
|
+
ALL_REACT_COMPONENT_FILE_SUFFIXES: tuple[str, ...] = (
|
|
16
|
+
REACT_TYPESCRIPT_SOURCE_SUFFIX,
|
|
17
|
+
REACT_JAVASCRIPT_SOURCE_SUFFIX,
|
|
18
|
+
)
|
|
19
|
+
ALL_CODE_REFERENCE_FILE_SUFFIXES: tuple[str, ...] = (
|
|
20
|
+
PYTHON_SOURCE_SUFFIX,
|
|
21
|
+
TYPESCRIPT_SOURCE_SUFFIX,
|
|
22
|
+
REACT_TYPESCRIPT_SOURCE_SUFFIX,
|
|
23
|
+
JAVASCRIPT_SOURCE_SUFFIX,
|
|
24
|
+
REACT_JAVASCRIPT_SOURCE_SUFFIX,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
NO_PYTHON_FILES_MESSAGE = "No Python files to check"
|
|
28
|
+
NO_TEST_FILES_MESSAGE = "No test files to check"
|
|
29
|
+
NO_REACT_FILES_MESSAGE = "No React files to check"
|
|
30
|
+
NO_CODE_REFERENCE_FILES_MESSAGE = "No code files to check"
|
|
31
|
+
NO_VIOLATIONS_FOUND_MESSAGE = "All checks passed"
|
|
32
|
+
VIOLATION_REPORT_LINE_SEPARATOR = "\n"
|
|
@@ -9,7 +9,9 @@ P50_FRACTION = 0.50
|
|
|
9
9
|
P95_FRACTION = 0.95
|
|
10
10
|
DEFAULT_TARGET_RELATIVE_PATH = "hooks/blocking/code_rules_shared.py"
|
|
11
11
|
SUBPROCESS_TIMEOUT_SECONDS = 60.0
|
|
12
|
-
ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset(
|
|
12
|
+
ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset(
|
|
13
|
+
{"Write|Edit", "Write|Edit|MultiEdit", "Write|Edit|MultiEdit|apply_patch"}
|
|
14
|
+
)
|
|
13
15
|
ALL_TIMED_EVENT_NAMES = ("PreToolUse", "PostToolUse")
|
|
14
16
|
RUN_ALL_VALIDATORS_LABEL = "run_all_validators"
|
|
15
17
|
CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Tests for the shared abbreviation_checks allow-list constant."""
|
|
2
|
+
|
|
3
|
+
from .abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_allowed_single_letters_covers_loop_counters_and_underscore() -> None:
|
|
7
|
+
assert ALL_ALLOWED_SINGLE_LETTERS == frozenset({"i", "j", "k", "_"})
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_allowed_single_letters_is_immutable() -> None:
|
|
11
|
+
assert isinstance(ALL_ALLOWED_SINGLE_LETTERS, frozenset)
|