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
|
@@ -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
|
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tests for subprocess_budget_completeness_content.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.subprocess_budget_completeness_content import (
|
|
14
|
+
reconstructed_edit_content,
|
|
15
|
+
reconstructed_multi_edit_content,
|
|
16
|
+
resolved_content,
|
|
17
|
+
)
|
|
18
|
+
except ImportError as import_error:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"test_subprocess_budget_completeness_content: cannot import its sibling modules; "
|
|
21
|
+
"ensure the hooks directory is importable."
|
|
22
|
+
) from import_error
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_resolved_content_returns_write_content_directly() -> None:
|
|
26
|
+
assert resolved_content({"content": "abc"}) == "abc"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_reconstructed_edit_content_replaces_the_old_string(tmp_path: pathlib.Path) -> None:
|
|
30
|
+
target_file = tmp_path / "module.py"
|
|
31
|
+
target_file.write_text("value = 1\n", encoding="utf-8")
|
|
32
|
+
reconstructed = reconstructed_edit_content(
|
|
33
|
+
{"file_path": str(target_file), "old_string": "value = 1", "new_string": "value = 2"}
|
|
34
|
+
)
|
|
35
|
+
assert reconstructed == "value = 2\n"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_reconstructed_multi_edit_content_applies_every_edit_in_order(
|
|
39
|
+
tmp_path: pathlib.Path,
|
|
40
|
+
) -> None:
|
|
41
|
+
target_file = tmp_path / "module.py"
|
|
42
|
+
target_file.write_text("a = 1\nb = 2\n", encoding="utf-8")
|
|
43
|
+
all_edits = [
|
|
44
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
45
|
+
{"old_string": "b = 2", "new_string": "b = 22"},
|
|
46
|
+
]
|
|
47
|
+
reconstructed = reconstructed_multi_edit_content({"file_path": str(target_file)}, all_edits)
|
|
48
|
+
assert reconstructed == "a = 11\nb = 22\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_resolved_content_dispatches_a_multi_edit_payload(tmp_path: pathlib.Path) -> None:
|
|
52
|
+
target_file = tmp_path / "module.py"
|
|
53
|
+
target_file.write_text("a = 1\n", encoding="utf-8")
|
|
54
|
+
reconstructed = resolved_content(
|
|
55
|
+
{
|
|
56
|
+
"file_path": str(target_file),
|
|
57
|
+
"edits": [{"old_string": "a = 1", "new_string": "a = 11"}],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
assert reconstructed == "a = 11\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_resolved_content_returns_empty_for_an_unreadable_edit_target(
|
|
64
|
+
tmp_path: pathlib.Path,
|
|
65
|
+
) -> None:
|
|
66
|
+
missing_target = tmp_path / "missing.py"
|
|
67
|
+
assert (
|
|
68
|
+
resolved_content({"file_path": str(missing_target), "old_string": "a", "new_string": "b"})
|
|
69
|
+
== ""
|
|
70
|
+
)
|
|
@@ -4,6 +4,8 @@ The edit lane and the full gate share every check except six that read
|
|
|
4
4
|
sibling modules from disk. These names identify each phase, name the checks
|
|
5
5
|
the full gate alone runs, name the hook-infrastructure checks the edit lane
|
|
6
6
|
still runs, and template the error for an unrecognized phase.
|
|
7
|
+
|
|
8
|
+
Scope assigns the lane. See ``docs/CODE_RULES.md`` section 11.6.
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
EDIT_LANE_PHASE: str = "edit_lane"
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Configuration constants for the workflow_substitution_slot_blocker PreToolUse hook."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
# The dispatcher constants module is the single source for tool names.
|
|
4
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
|
|
5
|
+
EDIT_TOOL_NAME,
|
|
6
|
+
MULTI_EDIT_TOOL_NAME,
|
|
7
|
+
WRITE_TOOL_NAME,
|
|
8
|
+
)
|
|
9
|
+
|
|
6
10
|
|
|
7
11
|
WORKFLOW_FILE_SUFFIX: str = ".workflow.js"
|
|
8
12
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook: record that this session spawned the pr-description-writer agent.
|
|
3
|
+
|
|
4
|
+
Picture opening a pull request whose body nobody wrote from the diff — it reads
|
|
5
|
+
like a commit message, and the reviewer learns nothing before opening the files.
|
|
6
|
+
The ``pr-description-writer`` agent exists to write that body, and it hands the
|
|
7
|
+
markdown and a body-file path back to the caller to publish.
|
|
8
|
+
|
|
9
|
+
This hook remembers that the agent ran. After each Agent or Task spawn naming
|
|
10
|
+
``pr-description-writer``, it touches an empty marker file for this session in
|
|
11
|
+
the system temp directory. The ``gh pr create`` gate reads that marker.
|
|
12
|
+
|
|
13
|
+
The marker carries one fact — the agent ran this session — so a touch records
|
|
14
|
+
everything the gate needs, and two parallel spawns racing on the same touch
|
|
15
|
+
leave the same result. The file name carries the session id, so a marker a
|
|
16
|
+
crashed session leaves behind is inert: no later session reads it.
|
|
17
|
+
|
|
18
|
+
The hook never blocks a tool call. A tool that is not a spawn, a spawn of
|
|
19
|
+
another agent, a malformed payload, a payload naming no session, or a failed
|
|
20
|
+
touch each returns quietly.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
30
|
+
if _hooks_dir not in sys.path:
|
|
31
|
+
sys.path.insert(0, _hooks_dir)
|
|
32
|
+
|
|
33
|
+
from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
|
|
34
|
+
ALL_AGENT_SPAWN_TOOL_NAMES,
|
|
35
|
+
PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
|
|
36
|
+
SPAWN_MARKER_FILE_PREFIX,
|
|
37
|
+
SPAWN_MARKER_FILE_SUFFIX,
|
|
38
|
+
SUBAGENT_TYPE_FIELD_NAME,
|
|
39
|
+
TOOL_INPUT_FIELD_NAME,
|
|
40
|
+
TOOL_NAME_FIELD_NAME,
|
|
41
|
+
)
|
|
42
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
43
|
+
read_hook_input_dictionary_from_stdin,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
46
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
|
|
51
|
+
"""Return the marker path for one session, or None without a usable id.
|
|
52
|
+
|
|
53
|
+
::
|
|
54
|
+
|
|
55
|
+
session_id = "abc-123" -> <temp>/claude-session-pr-description-writer-abc-123.marker
|
|
56
|
+
session_id = "" -> None
|
|
57
|
+
session_id = "///" -> None
|
|
58
|
+
|
|
59
|
+
Unsafe characters are stripped so the name stays anchored inside the temp
|
|
60
|
+
directory. A session id that sanitizes to nothing yields no path, so every
|
|
61
|
+
marker on disk belongs to one real session and a marker a crashed session
|
|
62
|
+
leaves behind is inert — no later session reads it.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
66
|
+
temp_directory: The directory holding the marker. Defaults to the
|
|
67
|
+
system temp directory.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Absolute path to this session's marker file, or None when the payload
|
|
71
|
+
names no session this hook can key on.
|
|
72
|
+
"""
|
|
73
|
+
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
|
|
74
|
+
if not sanitized_session_id:
|
|
75
|
+
return None
|
|
76
|
+
file_name = f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
|
|
77
|
+
resolved_temp_directory = temp_directory or Path(tempfile.gettempdir())
|
|
78
|
+
return resolved_temp_directory / file_name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _record_spawn(session_id: str) -> None:
|
|
82
|
+
"""Touch this session's marker file, ignoring a write failure.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
86
|
+
"""
|
|
87
|
+
marker_file = spawn_marker_path(session_id)
|
|
88
|
+
if marker_file is None:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
marker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
marker_file.touch(exist_ok=True)
|
|
93
|
+
except OSError:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
"""Record the marker when this payload is a pr-description-writer spawn.
|
|
99
|
+
|
|
100
|
+
Reads the PostToolUse payload from stdin and touches this session's marker
|
|
101
|
+
file when an Agent or Task spawn names the ``pr-description-writer`` agent.
|
|
102
|
+
Returns on every other branch — another tool, another agent, a malformed
|
|
103
|
+
payload, or a missing ``tool_input`` — so the spawn is never blocked.
|
|
104
|
+
"""
|
|
105
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
106
|
+
if hook_payload is None:
|
|
107
|
+
return
|
|
108
|
+
tool_name = hook_payload.get(TOOL_NAME_FIELD_NAME, "")
|
|
109
|
+
if tool_name not in ALL_AGENT_SPAWN_TOOL_NAMES:
|
|
110
|
+
return
|
|
111
|
+
tool_input = hook_payload.get(TOOL_INPUT_FIELD_NAME, {})
|
|
112
|
+
if not isinstance(tool_input, dict):
|
|
113
|
+
return
|
|
114
|
+
if tool_input.get(SUBAGENT_TYPE_FIELD_NAME) != PR_DESCRIPTION_WRITER_SUBAGENT_TYPE:
|
|
115
|
+
return
|
|
116
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
117
|
+
_record_spawn(session_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|
|
@@ -31,12 +31,14 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
31
31
|
if _hooks_dir not in sys.path:
|
|
32
32
|
sys.path.insert(0, _hooks_dir)
|
|
33
33
|
|
|
34
|
+
from blocking.codex_apply_patch import payload_patch_target_paths # noqa: E402
|
|
34
35
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
36
|
read_hook_input_dictionary_from_stdin,
|
|
36
37
|
)
|
|
37
38
|
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
38
39
|
ALL_EDITED_FILE_PATHS_KEY,
|
|
39
40
|
ALL_TRACKED_EDIT_TOOL_NAMES,
|
|
41
|
+
APPLY_PATCH_TOOL_NAME,
|
|
40
42
|
LOCK_ACQUIRE_RETRY_SECONDS,
|
|
41
43
|
LOCK_ACQUIRE_TIMEOUT_SECONDS,
|
|
42
44
|
SESSION_EDIT_FILE_PREFIX,
|
|
@@ -49,6 +51,23 @@ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
|
49
51
|
)
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _resolved_path_or_none(file_path: str) -> str | None:
|
|
55
|
+
"""Return the resolved absolute path, or None when resolution fails."""
|
|
56
|
+
try:
|
|
57
|
+
return str(Path(file_path).resolve())
|
|
58
|
+
except OSError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _record_apply_patch_edits(hook_payload: dict, tool_input: dict) -> None:
|
|
63
|
+
"""Record the resolved path of every file a Codex apply_patch payload names."""
|
|
64
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
65
|
+
for each_file_path in payload_patch_target_paths(hook_payload, tool_input):
|
|
66
|
+
resolved_file_path = _resolved_path_or_none(each_file_path)
|
|
67
|
+
if resolved_file_path is not None:
|
|
68
|
+
_record_edited_path(session_id, resolved_file_path)
|
|
69
|
+
|
|
70
|
+
|
|
52
71
|
def _session_edit_file_path(session_id: str) -> Path:
|
|
53
72
|
"""Return the per-session tracker file path in the system temp directory.
|
|
54
73
|
|
|
@@ -193,7 +212,7 @@ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
|
|
|
193
212
|
|
|
194
213
|
|
|
195
214
|
def main() -> None:
|
|
196
|
-
"""Record the edited file path for a Write, Edit, or
|
|
215
|
+
"""Record the edited file path for a Write, Edit, MultiEdit, or apply_patch.
|
|
197
216
|
|
|
198
217
|
Reads the PostToolUse payload from stdin, and for a tracked edit tool
|
|
199
218
|
appends the resolved absolute file path to this session's tracker file.
|
|
@@ -209,12 +228,14 @@ def main() -> None:
|
|
|
209
228
|
tool_input = hook_payload.get("tool_input", {})
|
|
210
229
|
if not isinstance(tool_input, dict):
|
|
211
230
|
return
|
|
231
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
232
|
+
_record_apply_patch_edits(hook_payload, tool_input)
|
|
233
|
+
return
|
|
212
234
|
file_path = tool_input.get("file_path", "")
|
|
213
235
|
if not isinstance(file_path, str) or not file_path:
|
|
214
236
|
return
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
except OSError:
|
|
237
|
+
resolved_file_path = _resolved_path_or_none(file_path)
|
|
238
|
+
if resolved_file_path is None:
|
|
218
239
|
return
|
|
219
240
|
session_id = str(hook_payload.get("session_id") or "")
|
|
220
241
|
_record_edited_path(session_id, resolved_file_path)
|