claude-dev-env 2.30.0 → 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/skills/e-simplify/SKILL.md +20 -8
- 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 +59 -36
- 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 +62 -8
- 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 +15 -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_code_rules_validation_phase.py +51 -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_shell_substitution_blocker.py +69 -0
- 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 +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- 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/shell_substitution_blocker_constants.py +4 -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/task_tool_prompt_constants.py +13 -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 -9
- 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/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -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
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -21,8 +21,11 @@ __all__ = [
|
|
|
21
21
|
"WRITE_TOOL_NAME",
|
|
22
22
|
"EDIT_TOOL_NAME",
|
|
23
23
|
"MULTI_EDIT_TOOL_NAME",
|
|
24
|
+
"APPLY_PATCH_TOOL_NAME",
|
|
24
25
|
"ALL_WRITE_AND_EDIT_TOOL_NAMES",
|
|
25
26
|
"ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
|
|
27
|
+
"ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES",
|
|
28
|
+
"ALL_IMMEDIATE_HARM_SCRIPT_PATHS",
|
|
26
29
|
"STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
|
|
27
30
|
"SYSTEM_MESSAGE_JOIN_SEPARATOR",
|
|
28
31
|
"HostedHookEntry",
|
|
@@ -42,11 +45,25 @@ CONTEXT_JOIN_SEPARATOR = "\n"
|
|
|
42
45
|
WRITE_TOOL_NAME = "Write"
|
|
43
46
|
EDIT_TOOL_NAME = "Edit"
|
|
44
47
|
MULTI_EDIT_TOOL_NAME = "MultiEdit"
|
|
48
|
+
APPLY_PATCH_TOOL_NAME = "apply_patch"
|
|
45
49
|
|
|
46
50
|
ALL_WRITE_AND_EDIT_TOOL_NAMES: frozenset[str] = frozenset({WRITE_TOOL_NAME, EDIT_TOOL_NAME})
|
|
47
51
|
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
|
|
48
52
|
{WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
|
|
49
53
|
)
|
|
54
|
+
ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES: frozenset[str] = frozenset(
|
|
55
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {APPLY_PATCH_TOOL_NAME}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# apply_patch reaches this narrower roster: the gates whose miss causes immediate
|
|
59
|
+
# harm, plus the TDD gate and the edit tracker. It stays off the full lint surface.
|
|
60
|
+
ALL_IMMEDIATE_HARM_SCRIPT_PATHS: tuple[str, ...] = (
|
|
61
|
+
"blocking/pii_prevention_blocker.py",
|
|
62
|
+
"blocking/sensitive_file_protector.py",
|
|
63
|
+
"blocking/write_existing_file_blocker.py",
|
|
64
|
+
"blocking/code_rules_enforcer.py",
|
|
65
|
+
"blocking/tdd_enforcer.py",
|
|
66
|
+
)
|
|
50
67
|
|
|
51
68
|
|
|
52
69
|
STATE_DESCRIPTION_BLOCKER_MODULE_NAME = "state_description_blocker"
|
|
@@ -78,52 +95,52 @@ class HostedHookEntry:
|
|
|
78
95
|
ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
79
96
|
HostedHookEntry(
|
|
80
97
|
script_relative_path="blocking/write_existing_file_blocker.py",
|
|
81
|
-
applicable_tool_names=
|
|
98
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
82
99
|
),
|
|
83
100
|
HostedHookEntry(
|
|
84
101
|
script_relative_path="blocking/sensitive_file_protector.py",
|
|
85
|
-
applicable_tool_names=
|
|
102
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
86
103
|
),
|
|
87
104
|
HostedHookEntry(
|
|
88
105
|
script_relative_path="blocking/pii_prevention_blocker.py",
|
|
89
|
-
applicable_tool_names=
|
|
106
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
90
107
|
),
|
|
91
108
|
HostedHookEntry(
|
|
92
109
|
script_relative_path="validation/hook_format_validator.py",
|
|
93
|
-
applicable_tool_names=
|
|
110
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
94
111
|
),
|
|
95
112
|
HostedHookEntry(
|
|
96
113
|
script_relative_path="blocking/code_rules_enforcer.py",
|
|
97
|
-
applicable_tool_names=
|
|
114
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
98
115
|
),
|
|
99
116
|
HostedHookEntry(
|
|
100
117
|
script_relative_path="blocking/tdd_enforcer.py",
|
|
101
|
-
applicable_tool_names=
|
|
118
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
102
119
|
),
|
|
103
120
|
HostedHookEntry(
|
|
104
121
|
script_relative_path="blocking/windows_rmtree_blocker.py",
|
|
105
|
-
applicable_tool_names=
|
|
122
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
106
123
|
),
|
|
107
124
|
HostedHookEntry(
|
|
108
125
|
script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
|
|
109
|
-
applicable_tool_names=
|
|
126
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
110
127
|
),
|
|
111
128
|
HostedHookEntry(
|
|
112
129
|
script_relative_path="blocking/state_description_blocker.py",
|
|
113
|
-
applicable_tool_names=
|
|
130
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
114
131
|
native_module_name=STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
115
132
|
),
|
|
116
133
|
HostedHookEntry(
|
|
117
134
|
script_relative_path="blocking/stale_comment_reference_blocker.py",
|
|
118
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
|
|
135
|
+
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
119
136
|
),
|
|
120
137
|
HostedHookEntry(
|
|
121
138
|
script_relative_path="blocking/subprocess_budget_completeness.py",
|
|
122
|
-
applicable_tool_names=
|
|
139
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
123
140
|
),
|
|
124
141
|
HostedHookEntry(
|
|
125
142
|
script_relative_path="blocking/hook_prose_detector_consistency.py",
|
|
126
|
-
applicable_tool_names=
|
|
143
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
127
144
|
),
|
|
128
145
|
HostedHookEntry(
|
|
129
146
|
script_relative_path="blocking/workflow_substitution_slot_blocker.py",
|
|
@@ -155,12 +172,12 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
155
172
|
),
|
|
156
173
|
HostedHookEntry(
|
|
157
174
|
script_relative_path="advisory/refactor_guard.py",
|
|
158
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
|
|
175
|
+
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
159
176
|
is_blocking=False,
|
|
160
177
|
),
|
|
161
178
|
HostedHookEntry(
|
|
162
179
|
script_relative_path="advisory/migration_safety_advisor.py",
|
|
163
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
|
|
180
|
+
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
164
181
|
is_blocking=False,
|
|
165
182
|
),
|
|
166
183
|
)
|
|
@@ -18,6 +18,15 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
20
|
|
|
21
|
+
# The dispatcher constants module is the single source for tool names.
|
|
22
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
23
|
+
APPLY_PATCH_TOOL_NAME,
|
|
24
|
+
EDIT_TOOL_NAME,
|
|
25
|
+
MULTI_EDIT_TOOL_NAME,
|
|
26
|
+
WRITE_TOOL_NAME,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
21
30
|
SESSION_EDIT_FILE_PREFIX: str = "claude-session-edits-"
|
|
22
31
|
SESSION_EDIT_FILE_SUFFIX: str = ".json"
|
|
23
32
|
ALL_EDITED_FILE_PATHS_KEY: str = "all_edited_file_paths"
|
|
@@ -27,7 +36,12 @@ SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9
|
|
|
27
36
|
SESSION_START_SOURCE_PAYLOAD_KEY: str = "source"
|
|
28
37
|
SESSION_START_SOURCE_FRESH_STARTUP: str = "startup"
|
|
29
38
|
|
|
30
|
-
ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = (
|
|
39
|
+
ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = (
|
|
40
|
+
WRITE_TOOL_NAME,
|
|
41
|
+
EDIT_TOOL_NAME,
|
|
42
|
+
MULTI_EDIT_TOOL_NAME,
|
|
43
|
+
APPLY_PATCH_TOOL_NAME,
|
|
44
|
+
)
|
|
31
45
|
|
|
32
46
|
STATE_FILE_ATOMIC_WRITE_SUFFIX: str = ".tmp"
|
|
33
47
|
STATE_FILE_JSON_INDENT_SPACES: int = 2
|
|
@@ -41,7 +41,10 @@ PERMISSION_DECISION_KEY = "permissionDecision"
|
|
|
41
41
|
DENY_DECISION = "deny"
|
|
42
42
|
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
43
43
|
|
|
44
|
-
DOLLAR_PAREN_PATTERN = re.compile(r"\$\(
|
|
44
|
+
DOLLAR_PAREN_PATTERN = re.compile(r"\$\(")
|
|
45
|
+
OPEN_PAREN_CHARACTER = "("
|
|
46
|
+
CLOSE_PAREN_CHARACTER = ")"
|
|
47
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN = 2
|
|
45
48
|
EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
|
|
46
49
|
PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
|
|
47
50
|
SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Resolve the post-edit content a Write, Edit, or MultiEdit payload leaves on disk.
|
|
2
|
+
|
|
3
|
+
Shared by subprocess_budget_completeness.py, which parses this content's AST to
|
|
4
|
+
find a named budget helper that omits a reachable subprocess timeout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from hooks_constants.multi_edit_reconstruction import apply_edits
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"existing_file_content",
|
|
13
|
+
"reconstructed_edit_content",
|
|
14
|
+
"reconstructed_multi_edit_content",
|
|
15
|
+
"resolved_content",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def existing_file_content(file_path: str) -> str | None:
|
|
20
|
+
try:
|
|
21
|
+
with open(file_path, "r", encoding="utf-8") as existing_file:
|
|
22
|
+
return existing_file.read()
|
|
23
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
|
|
28
|
+
file_path = all_tool_input_fields.get("file_path")
|
|
29
|
+
old_string = all_tool_input_fields.get("old_string")
|
|
30
|
+
new_string = all_tool_input_fields.get("new_string")
|
|
31
|
+
if not isinstance(file_path, str) or not isinstance(old_string, str):
|
|
32
|
+
return ""
|
|
33
|
+
if not isinstance(new_string, str) or not old_string:
|
|
34
|
+
return ""
|
|
35
|
+
existing_content = existing_file_content(file_path)
|
|
36
|
+
if existing_content is None or old_string not in existing_content:
|
|
37
|
+
return ""
|
|
38
|
+
return existing_content.replace(old_string, new_string, 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reconstructed_multi_edit_content(
|
|
42
|
+
all_tool_input_fields: dict[str, object], all_edits: list[object]
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Return the whole file MultiEdit's edit list would leave on disk.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
all_tool_input_fields: The MultiEdit payload's tool_input mapping.
|
|
48
|
+
all_edits: The payload's ``edits`` list.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The reconstructed content, or an empty string when the target file
|
|
52
|
+
cannot be read.
|
|
53
|
+
"""
|
|
54
|
+
file_path = all_tool_input_fields.get("file_path")
|
|
55
|
+
if not isinstance(file_path, str):
|
|
56
|
+
return ""
|
|
57
|
+
existing_content = existing_file_content(file_path)
|
|
58
|
+
if existing_content is None:
|
|
59
|
+
return ""
|
|
60
|
+
all_edit_dicts = [each_edit for each_edit in all_edits if isinstance(each_edit, dict)]
|
|
61
|
+
return apply_edits(existing_content, all_edit_dicts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
|
|
65
|
+
written_content = all_tool_input_fields.get("content")
|
|
66
|
+
if isinstance(written_content, str):
|
|
67
|
+
return written_content
|
|
68
|
+
all_edits = all_tool_input_fields.get("edits")
|
|
69
|
+
if isinstance(all_edits, list):
|
|
70
|
+
return reconstructed_multi_edit_content(all_tool_input_fields, all_edits)
|
|
71
|
+
return reconstructed_edit_content(all_tool_input_fields)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Directive text for the task_tool_prompt SessionStart hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"TASK_TOOL_DIRECTIVE",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
TASK_TOOL_DIRECTIVE = (
|
|
10
|
+
"TASK TRACKING: Track this session's work with the session's task or todo "
|
|
11
|
+
"tracking tool, whichever this host provides. Create tasks at the start "
|
|
12
|
+
"of the session, then update them as work proceeds."
|
|
13
|
+
)
|
|
@@ -38,12 +38,14 @@ _EXPECTED_BASH_ORDER = (
|
|
|
38
38
|
"blocking/test_preflight_check.py",
|
|
39
39
|
"blocking/windows_rmtree_blocker.py",
|
|
40
40
|
"blocking/gh_pr_author_enforcer.py",
|
|
41
|
+
"blocking/pr_description_writer_gate.py",
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
_POWERSHELL_APPLICABLE = (
|
|
44
45
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
45
46
|
"blocking/unscoped_search_blocker.py",
|
|
46
47
|
"blocking/pii_prevention_blocker.py",
|
|
48
|
+
"blocking/pr_description_writer_gate.py",
|
|
47
49
|
)
|
|
48
50
|
|
|
49
51
|
|
|
@@ -17,7 +17,6 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
17
17
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
18
|
|
|
19
19
|
from hooks_constants.code_rules_enforcer_constants import (
|
|
20
|
-
ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
|
|
21
20
|
ALL_VALIDATION_PHASES,
|
|
22
21
|
CONFIG_DIRECTORY_SEGMENT,
|
|
23
22
|
CONSTANTS_MODULE_SUFFIX,
|
|
@@ -28,6 +27,8 @@ from hooks_constants.code_rules_enforcer_constants import (
|
|
|
28
27
|
RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
|
|
29
28
|
SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
|
|
30
29
|
UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
|
|
30
|
+
apply_edits,
|
|
31
|
+
edits_for_tool,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
@@ -111,11 +112,3 @@ def test_validation_phase_constants_are_reexported_and_render() -> None:
|
|
|
111
112
|
phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
|
|
112
113
|
)
|
|
113
114
|
assert "bogus_phase" in rendered_message
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def test_hook_infrastructure_edit_lane_check_names_are_reexported() -> None:
|
|
117
|
-
assert ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES == {
|
|
118
|
-
"check_same_file_inline_duplicate_body",
|
|
119
|
-
"check_zero_payload_function_alias",
|
|
120
|
-
"check_unanchored_command_dispatch",
|
|
121
|
-
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for multi_edit_reconstruction.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
10
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
12
|
+
|
|
13
|
+
from hooks_constants.multi_edit_reconstruction import (
|
|
14
|
+
apply_edits,
|
|
15
|
+
edits_for_tool,
|
|
16
|
+
joined_new_strings,
|
|
17
|
+
)
|
|
18
|
+
except ImportError as import_error:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"test_multi_edit_reconstruction: cannot import its sibling modules; "
|
|
21
|
+
"ensure the hooks directory is importable."
|
|
22
|
+
) from import_error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_apply_edits_replaces_each_old_string_in_list_order() -> None:
|
|
26
|
+
"""Two edits land in order, each replacing its own first occurrence."""
|
|
27
|
+
all_edits = [
|
|
28
|
+
{"old_string": "alpha", "new_string": "beta"},
|
|
29
|
+
{"old_string": "gamma", "new_string": "delta"},
|
|
30
|
+
]
|
|
31
|
+
assert apply_edits("alpha gamma", all_edits) == "beta delta"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_edits_for_tool_wraps_an_edit_payload_in_a_single_element_list() -> None:
|
|
35
|
+
"""An Edit payload is its own only edit."""
|
|
36
|
+
edit_payload = {"old_string": "a", "new_string": "b"}
|
|
37
|
+
assert edits_for_tool("Edit", edit_payload) == [edit_payload]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_apply_edits_replaces_every_occurrence_when_replace_all_is_set() -> None:
|
|
41
|
+
"""A replace_all edit rewrites every occurrence, the way MultiEdit does.
|
|
42
|
+
|
|
43
|
+
The gates that read this reconstruction judge the file the write would
|
|
44
|
+
leave on disk. When an edit carries replace_all, MultiEdit rewrites all
|
|
45
|
+
occurrences, so a reconstruction that rewrites only the first one hands
|
|
46
|
+
every gate a file that never existed.
|
|
47
|
+
"""
|
|
48
|
+
all_edits = [{"old_string": "old", "new_string": "new", "replace_all": True}]
|
|
49
|
+
assert apply_edits("old old old", all_edits) == "new new new"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_apply_edits_replaces_only_the_first_occurrence_without_replace_all() -> None:
|
|
53
|
+
"""An edit with no replace_all flag rewrites one occurrence."""
|
|
54
|
+
all_edits = [{"old_string": "old", "new_string": "new"}]
|
|
55
|
+
assert apply_edits("old old old", all_edits) == "new old old"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_apply_edits_treats_a_false_replace_all_as_a_single_replacement() -> None:
|
|
59
|
+
"""An explicit replace_all of False rewrites one occurrence."""
|
|
60
|
+
all_edits = [{"old_string": "old", "new_string": "new", "replace_all": False}]
|
|
61
|
+
assert apply_edits("old old old", all_edits) == "new old old"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_apply_edits_mixes_replace_all_and_single_edits_in_one_list() -> None:
|
|
65
|
+
"""Each edit in a list honors its own flag, not the list's first one."""
|
|
66
|
+
all_edits = [
|
|
67
|
+
{"old_string": "a", "new_string": "A", "replace_all": True},
|
|
68
|
+
{"old_string": "b", "new_string": "B"},
|
|
69
|
+
]
|
|
70
|
+
assert apply_edits("a b a b", all_edits) == "A B A b"
|
|
71
|
+
|
|
72
|
+
def test_joined_new_strings_joins_every_new_string_on_a_newline() -> None:
|
|
73
|
+
"""Each edit's introduced text reaches the scanner as its own line."""
|
|
74
|
+
tool_input = {
|
|
75
|
+
"edits": [
|
|
76
|
+
{"old_string": "x", "new_string": "import os"},
|
|
77
|
+
{"old_string": "y", "new_string": "shutil.rmtree(p)"},
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
assert joined_new_strings(tool_input) == "import os\nshutil.rmtree(p)"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_joined_new_strings_separator_keeps_adjacent_edits_apart() -> None:
|
|
84
|
+
"""A phrase spanning the seam belongs to neither edit, so the join breaks it."""
|
|
85
|
+
tool_input = {
|
|
86
|
+
"edits": [
|
|
87
|
+
{"old_string": "x", "new_string": "currently"},
|
|
88
|
+
{"old_string": "y", "new_string": "holds"},
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
assert "currentlyholds" not in joined_new_strings(tool_input)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_joined_new_strings_skips_edits_whose_new_string_is_not_a_string() -> None:
|
|
95
|
+
"""A malformed edit contributes nothing rather than raising."""
|
|
96
|
+
tool_input = {"edits": [{"new_string": None}, {"new_string": "kept"}]}
|
|
97
|
+
assert joined_new_strings(tool_input) == "kept"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_joined_new_strings_returns_empty_text_for_a_payload_with_no_edits() -> None:
|
|
101
|
+
"""A payload carrying no edit list scans as empty text."""
|
|
102
|
+
assert joined_new_strings({"file_path": "a.py"}) == ""
|
|
@@ -4,25 +4,36 @@ import importlib
|
|
|
4
4
|
import pathlib
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
7
|
+
try:
|
|
8
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
11
|
+
|
|
12
|
+
_BLOCKING_DIR = _HOOKS_ROOT / "blocking"
|
|
13
|
+
if str(_BLOCKING_DIR) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(_BLOCKING_DIR))
|
|
15
|
+
|
|
16
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
17
|
+
ALL_HOSTED_HOOK_ENTRIES,
|
|
18
|
+
ALL_IMMEDIATE_HARM_SCRIPT_PATHS,
|
|
19
|
+
ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
20
|
+
ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
21
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
22
|
+
APPLY_PATCH_TOOL_NAME,
|
|
23
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
24
|
+
EDIT_TOOL_NAME,
|
|
25
|
+
MULTI_EDIT_TOOL_NAME,
|
|
26
|
+
WRITE_TOOL_NAME,
|
|
27
|
+
)
|
|
28
|
+
from pre_tool_use_dispatcher import (
|
|
29
|
+
HostedHookResult,
|
|
30
|
+
aggregate_hosted_hook_results,
|
|
31
|
+
)
|
|
32
|
+
except ImportError as import_error:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"test_pre_tool_use_dispatcher_constants: cannot import its sibling modules; "
|
|
35
|
+
"ensure the hooks and blocking directories are importable."
|
|
36
|
+
) from import_error
|
|
26
37
|
|
|
27
38
|
|
|
28
39
|
def _entry_for(script_relative_path: str):
|
|
@@ -63,11 +74,10 @@ def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
|
|
|
63
74
|
assert entry.native_module_name is None
|
|
64
75
|
|
|
65
76
|
|
|
66
|
-
|
|
67
77
|
def test_windows_rmtree_blocker_still_registered() -> None:
|
|
68
78
|
entry = _entry_for("blocking/windows_rmtree_blocker.py")
|
|
69
79
|
assert entry is not None
|
|
70
|
-
assert entry.applicable_tool_names ==
|
|
80
|
+
assert entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES
|
|
71
81
|
|
|
72
82
|
|
|
73
83
|
def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
|
|
@@ -82,22 +92,22 @@ def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
|
|
|
82
92
|
assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
|
|
83
93
|
|
|
84
94
|
|
|
85
|
-
def
|
|
95
|
+
def test_refactor_guard_is_hosted_as_an_edit_and_multi_edit_advisory_hook() -> None:
|
|
86
96
|
entry = _entry_for("advisory/refactor_guard.py")
|
|
87
97
|
assert entry is not None, (
|
|
88
98
|
"refactor_guard must be hosted by the dispatcher rather than spawning its own process"
|
|
89
99
|
)
|
|
90
|
-
assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME})
|
|
100
|
+
assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
|
|
91
101
|
assert entry.is_blocking is False
|
|
92
102
|
|
|
93
103
|
|
|
94
|
-
def
|
|
104
|
+
def test_migration_safety_advisor_is_hosted_as_an_edit_and_multi_edit_advisory_hook() -> None:
|
|
95
105
|
entry = _entry_for("advisory/migration_safety_advisor.py")
|
|
96
106
|
assert entry is not None, (
|
|
97
107
|
"migration_safety_advisor must be hosted by the dispatcher rather than "
|
|
98
108
|
"spawning its own process"
|
|
99
109
|
)
|
|
100
|
-
assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME})
|
|
110
|
+
assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
|
|
101
111
|
assert entry.is_blocking is False
|
|
102
112
|
|
|
103
113
|
|
|
@@ -125,3 +135,107 @@ def test_every_native_module_exposes_a_callable_evaluate() -> None:
|
|
|
125
135
|
f"{each_entry.native_module_name} must expose a callable named evaluate, "
|
|
126
136
|
"matching the native_module_name docstring contract"
|
|
127
137
|
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_four_way_set_covers_every_mutation_tool_name() -> None:
|
|
141
|
+
assert ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES == (
|
|
142
|
+
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {APPLY_PATCH_TOOL_NAME}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_immediate_harm_hooks_reach_apply_patch() -> None:
|
|
147
|
+
"""PII, sensitive-path, existing-file, CODE_RULES, and TDD gates all see apply_patch."""
|
|
148
|
+
for each_script_path in ALL_IMMEDIATE_HARM_SCRIPT_PATHS:
|
|
149
|
+
entry = _entry_for(each_script_path)
|
|
150
|
+
assert entry is not None, f"{each_script_path} must stay on the hosted roster"
|
|
151
|
+
assert APPLY_PATCH_TOOL_NAME in entry.applicable_tool_names, (
|
|
152
|
+
f"{each_script_path} must reach apply_patch for mutation-tool parity"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_ALL_DEFERRED_LINT_SCRIPT_PATHS = (
|
|
157
|
+
"blocking/state_description_blocker.py",
|
|
158
|
+
"blocking/workflow_substitution_slot_blocker.py",
|
|
159
|
+
"blocking/claude_md_orphan_file_blocker.py",
|
|
160
|
+
"blocking/package_inventory_stale_blocker.py",
|
|
161
|
+
"blocking/env_var_table_code_drift_blocker.py",
|
|
162
|
+
"blocking/pytest_testpaths_orphan_blocker.py",
|
|
163
|
+
"blocking/open_questions_in_plans_blocker.py",
|
|
164
|
+
"blocking/docstring_rule_gate_count_blocker.py",
|
|
165
|
+
"blocking/duplicate_rmtree_helper_blocker.py",
|
|
166
|
+
"validation/hook_format_validator.py",
|
|
167
|
+
"blocking/hook_prose_detector_consistency.py",
|
|
168
|
+
"blocking/stale_comment_reference_blocker.py",
|
|
169
|
+
"blocking/subprocess_budget_completeness.py",
|
|
170
|
+
"blocking/windows_rmtree_blocker.py",
|
|
171
|
+
"advisory/refactor_guard.py",
|
|
172
|
+
"advisory/migration_safety_advisor.py",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_lint_and_advisory_hooks_stay_off_the_apply_patch_roster() -> None:
|
|
177
|
+
"""Every hook reaching MultiEdit but not apply_patch fails the same one rule.
|
|
178
|
+
|
|
179
|
+
apply_patch reaches only a gate whose violation is already real and
|
|
180
|
+
unrecoverable the moment the call returns: a leaked secret, an exposed
|
|
181
|
+
sensitive path, a blind overwrite, or an untested change landing. Every
|
|
182
|
+
hook named here instead judges the quality of the authored content for a
|
|
183
|
+
consequence the write itself does not cause right now: doc-inventory
|
|
184
|
+
drift, prose or naming style, a future-runtime correctness pattern (an
|
|
185
|
+
unsafe rmtree that only misbehaves on Windows, a subprocess-timeout
|
|
186
|
+
budget the harness reads separately), or an advisory suggestion that
|
|
187
|
+
never denies at all. Each is fixable in a later Edit-tool pass, so none
|
|
188
|
+
needs apply_patch's narrower, deny-only roster.
|
|
189
|
+
"""
|
|
190
|
+
for each_script_path in _ALL_DEFERRED_LINT_SCRIPT_PATHS:
|
|
191
|
+
entry = _entry_for(each_script_path)
|
|
192
|
+
assert entry is not None, f"{each_script_path} must stay on the hosted roster"
|
|
193
|
+
assert MULTI_EDIT_TOOL_NAME in entry.applicable_tool_names, (
|
|
194
|
+
f"{each_script_path} must still reach MultiEdit"
|
|
195
|
+
)
|
|
196
|
+
assert APPLY_PATCH_TOOL_NAME not in entry.applicable_tool_names, (
|
|
197
|
+
f"{each_script_path} judges deferred-fixable quality, not immediate harm, "
|
|
198
|
+
"so it must stay off the apply_patch roster"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_edit_and_multi_edit_applicable_sets_are_equal() -> None:
|
|
203
|
+
"""The Edit roster and the MultiEdit roster name the same hooks.
|
|
204
|
+
|
|
205
|
+
Every hook applicable to Edit judges Write's content or an Edit's
|
|
206
|
+
old/new string pair with no dependency on which tool delivered it, so
|
|
207
|
+
the two sets must match exactly. A hook that legitimately stays
|
|
208
|
+
Edit-only is an exception written down here with its reason, not a
|
|
209
|
+
silent gap this test lets back in.
|
|
210
|
+
"""
|
|
211
|
+
all_edit_script_paths = {
|
|
212
|
+
each_entry.script_relative_path
|
|
213
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
214
|
+
if EDIT_TOOL_NAME in each_entry.applicable_tool_names
|
|
215
|
+
}
|
|
216
|
+
all_multi_edit_script_paths = {
|
|
217
|
+
each_entry.script_relative_path
|
|
218
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
219
|
+
if MULTI_EDIT_TOOL_NAME in each_entry.applicable_tool_names
|
|
220
|
+
}
|
|
221
|
+
assert all_edit_script_paths == all_multi_edit_script_paths, (
|
|
222
|
+
"Edit-only (not MultiEdit): "
|
|
223
|
+
f"{sorted(all_edit_script_paths - all_multi_edit_script_paths)}; "
|
|
224
|
+
"MultiEdit-only (not Edit): "
|
|
225
|
+
f"{sorted(all_multi_edit_script_paths - all_edit_script_paths)}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_multi_edit_widened_hooks_reach_multi_edit() -> None:
|
|
230
|
+
all_multi_edit_widened_script_paths = (
|
|
231
|
+
"blocking/write_existing_file_blocker.py",
|
|
232
|
+
"blocking/code_rules_enforcer.py",
|
|
233
|
+
"blocking/tdd_enforcer.py",
|
|
234
|
+
"blocking/state_description_blocker.py",
|
|
235
|
+
)
|
|
236
|
+
for each_script_path in all_multi_edit_widened_script_paths:
|
|
237
|
+
entry = _entry_for(each_script_path)
|
|
238
|
+
assert entry is not None, f"{each_script_path} must stay on the hosted roster"
|
|
239
|
+
assert MULTI_EDIT_TOOL_NAME in entry.applicable_tool_names, (
|
|
240
|
+
f"{each_script_path} must reach MultiEdit for mutation-tool parity"
|
|
241
|
+
)
|