claude-dev-env 2.30.1 → 2.31.1
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 +12 -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_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- 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 +256 -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 +63 -16
- 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_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -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_same_file_inline_duplicate.py +244 -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 +24 -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 +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- 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 +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -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 +73 -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 +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -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 +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -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 +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- 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/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- 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 +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- 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/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -54,53 +54,31 @@ import json
|
|
|
54
54
|
import sys
|
|
55
55
|
from pathlib import Path
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
sys.path.insert(0, _hooks_dir)
|
|
83
|
-
from hooks_constants.shell_substitution_blocker_constants import (
|
|
84
|
-
BASH_TOOL_NAME,
|
|
85
|
-
CLOSE_PAREN_CHARACTER,
|
|
86
|
-
COMMAND_KEY,
|
|
87
|
-
CORRECTIVE_MESSAGE,
|
|
88
|
-
DENY_DECISION,
|
|
89
|
-
DOLLAR_PAREN_PATTERN,
|
|
90
|
-
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
91
|
-
HOOK_EVENT_NAME,
|
|
92
|
-
HOOK_EVENT_NAME_KEY,
|
|
93
|
-
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
94
|
-
OPEN_PAREN_CHARACTER,
|
|
95
|
-
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
96
|
-
PERMISSION_DECISION_KEY,
|
|
97
|
-
PERMISSION_DECISION_REASON_KEY,
|
|
98
|
-
PROCESS_SUBSTITUTION_PATTERN,
|
|
99
|
-
SINGLE_QUOTED_RUN_PATTERN,
|
|
100
|
-
STRIPPED_RUN_REPLACEMENT,
|
|
101
|
-
TOOL_INPUT_KEY,
|
|
102
|
-
TOOL_NAME_KEY,
|
|
103
|
-
)
|
|
57
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
58
|
+
if _hooks_dir not in sys.path:
|
|
59
|
+
sys.path.insert(0, _hooks_dir)
|
|
60
|
+
|
|
61
|
+
from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
62
|
+
BASH_TOOL_NAME,
|
|
63
|
+
CLOSE_PAREN_CHARACTER,
|
|
64
|
+
COMMAND_KEY,
|
|
65
|
+
CORRECTIVE_MESSAGE,
|
|
66
|
+
DENY_DECISION,
|
|
67
|
+
DOLLAR_PAREN_PATTERN,
|
|
68
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
69
|
+
HOOK_EVENT_NAME,
|
|
70
|
+
HOOK_EVENT_NAME_KEY,
|
|
71
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
72
|
+
OPEN_PAREN_CHARACTER,
|
|
73
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
74
|
+
PERMISSION_DECISION_KEY,
|
|
75
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
76
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
77
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
78
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
79
|
+
TOOL_INPUT_KEY,
|
|
80
|
+
TOOL_NAME_KEY,
|
|
81
|
+
)
|
|
104
82
|
|
|
105
83
|
|
|
106
84
|
def _strip_single_quoted_runs(command: str) -> str:
|
|
@@ -11,28 +11,95 @@ directly above it, denying the edit when that line is a standalone ``#``
|
|
|
11
11
|
comment naming an identifier ``old_string`` carries and ``new_string`` drops.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
+
import functools
|
|
14
15
|
import json
|
|
15
16
|
import re
|
|
16
17
|
import sys
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import TextIO
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
sys.path
|
|
21
|
+
try:
|
|
22
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_dir not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_dir)
|
|
23
25
|
|
|
24
|
-
from hooks_constants.hook_block_logger import log_hook_block
|
|
25
|
-
from hooks_constants.
|
|
26
|
-
from hooks_constants.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
27
|
+
from hooks_constants.multi_edit_reconstruction import edits_for_tool
|
|
28
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
29
|
+
EDIT_TOOL_NAME,
|
|
30
|
+
MULTI_EDIT_TOOL_NAME,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
33
|
+
from hooks_constants.stale_comment_reference_blocker_constants import (
|
|
34
|
+
ALL_COMMENT_STOPWORDS,
|
|
35
|
+
COMMENT_IDENTIFIER_PATTERN,
|
|
36
|
+
COMMENT_LINE_PREFIX,
|
|
37
|
+
PYTHON_FILE_SUFFIX,
|
|
38
|
+
STALE_COMMENT_ADDITIONAL_CONTEXT,
|
|
39
|
+
STALE_COMMENT_DENY_TEMPLATE,
|
|
40
|
+
STALE_COMMENT_SYSTEM_MESSAGE,
|
|
41
|
+
)
|
|
42
|
+
except ImportError as import_error:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"stale_comment_reference_blocker: cannot import its sibling modules; "
|
|
45
|
+
"ensure the hooks directory is importable."
|
|
46
|
+
) from import_error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _edit_step_fields(each_edit: object) -> tuple[str, str, bool] | None:
|
|
50
|
+
"""Return (old_string, new_string, is_replace_all) for one usable MultiEdit step."""
|
|
51
|
+
if not isinstance(each_edit, dict):
|
|
52
|
+
return None
|
|
53
|
+
old_string = each_edit.get("old_string", "")
|
|
54
|
+
new_string = each_edit.get("new_string", "")
|
|
55
|
+
if not isinstance(old_string, str) or not isinstance(new_string, str) or not old_string:
|
|
56
|
+
return None
|
|
57
|
+
return old_string, new_string, each_edit.get("replace_all") is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _evaluate_multi_edit(file_path: str, all_tool_input: dict[str, object]) -> str | None:
|
|
61
|
+
"""Judge each MultiEdit step against the content the prior steps already left.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
file_path: The destination path the MultiEdit targets.
|
|
65
|
+
all_tool_input: The MultiEdit payload's input mapping.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The deny-reason text for the first step whose kept comment orphans an
|
|
69
|
+
identifier, or None when every step stays consistent.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
current_content = Path(file_path).read_text(encoding="utf-8")
|
|
73
|
+
except (OSError, UnicodeDecodeError):
|
|
74
|
+
return None
|
|
75
|
+
for each_edit in edits_for_tool(MULTI_EDIT_TOOL_NAME, all_tool_input):
|
|
76
|
+
edit_fields = _edit_step_fields(each_edit)
|
|
77
|
+
if edit_fields is None:
|
|
78
|
+
continue
|
|
79
|
+
old_string, new_string, is_replace_all = edit_fields
|
|
80
|
+
if old_string not in current_content:
|
|
81
|
+
continue
|
|
82
|
+
deny_reason = _find_stale_comment_reference(
|
|
83
|
+
current_content, old_string, new_string, is_replace_all, file_path
|
|
84
|
+
)
|
|
85
|
+
if deny_reason is not None:
|
|
86
|
+
return deny_reason
|
|
87
|
+
current_content = current_content.replace(old_string, new_string, -1 if is_replace_all else 1)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@functools.lru_cache
|
|
92
|
+
def _bounded_identifier_pattern(identifier: str) -> re.Pattern[str]:
|
|
93
|
+
"""Return the compiled whole-word pattern for one comment-named identifier.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
identifier: The identifier the comment names.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The compiled pattern matching identifier with no adjoining word
|
|
100
|
+
character on either side.
|
|
101
|
+
"""
|
|
102
|
+
return re.compile("(?<![A-Za-z0-9_])" + re.escape(identifier) + "(?![A-Za-z0-9_])")
|
|
36
103
|
|
|
37
104
|
|
|
38
105
|
def _first_orphaned_identifier(
|
|
@@ -57,9 +124,7 @@ def _first_orphaned_identifier(
|
|
|
57
124
|
for each_identifier in COMMENT_IDENTIFIER_PATTERN.findall(words_in_comment):
|
|
58
125
|
if each_identifier.lower() in ALL_COMMENT_STOPWORDS:
|
|
59
126
|
continue
|
|
60
|
-
bounded_pattern =
|
|
61
|
-
"(?<![A-Za-z0-9_])" + re.escape(each_identifier) + "(?![A-Za-z0-9_])"
|
|
62
|
-
)
|
|
127
|
+
bounded_pattern = _bounded_identifier_pattern(each_identifier)
|
|
63
128
|
if bounded_pattern.search(original_block_text) and not bounded_pattern.search(
|
|
64
129
|
revised_block_text
|
|
65
130
|
):
|
|
@@ -159,13 +224,15 @@ def _find_stale_comment_reference(
|
|
|
159
224
|
|
|
160
225
|
|
|
161
226
|
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
162
|
-
"""Decide whether an Edit payload orphans a comment above a changed line.
|
|
227
|
+
"""Decide whether an Edit or MultiEdit payload orphans a comment above a changed line.
|
|
163
228
|
|
|
164
229
|
Reads the target file from disk and checks the line directly above each
|
|
165
|
-
occurrence
|
|
166
|
-
identifier the
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
occurrence a step rewrites for a kept standalone comment whose named
|
|
231
|
+
identifier the step carries and drops. A MultiEdit's steps are judged in
|
|
232
|
+
order against the content the prior steps already left, so a step that
|
|
233
|
+
only reads as stale once an earlier step in the same payload has run is
|
|
234
|
+
still caught. Non-Edit/MultiEdit tools, non-Python targets, unreadable
|
|
235
|
+
files, and an old_string absent from the file all pass.
|
|
169
236
|
|
|
170
237
|
Args:
|
|
171
238
|
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
@@ -175,7 +242,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
175
242
|
"""
|
|
176
243
|
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
177
244
|
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
178
|
-
if tool_name
|
|
245
|
+
if tool_name not in (EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
|
|
179
246
|
return None
|
|
180
247
|
|
|
181
248
|
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
@@ -185,6 +252,9 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
185
252
|
if not isinstance(file_path, str) or not file_path.endswith(PYTHON_FILE_SUFFIX):
|
|
186
253
|
return None
|
|
187
254
|
|
|
255
|
+
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
256
|
+
return _evaluate_multi_edit(file_path, tool_input)
|
|
257
|
+
|
|
188
258
|
raw_old_string = tool_input.get("old_string", "")
|
|
189
259
|
raw_new_string = tool_input.get("new_string", "")
|
|
190
260
|
old_string = raw_old_string if isinstance(raw_old_string, str) else ""
|
|
@@ -15,28 +15,43 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import TextIO
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
sys.path
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
from
|
|
24
|
-
from hooks_constants.
|
|
25
|
-
from hooks_constants.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
18
|
+
try:
|
|
19
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
20
|
+
if _hooks_dir not in sys.path:
|
|
21
|
+
sys.path.insert(0, _hooks_dir)
|
|
22
|
+
|
|
23
|
+
from blocking.code_rules_shared import is_ephemeral_path
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
25
|
+
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
26
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
27
|
+
from hooks_constants.state_description_blocker_constants import (
|
|
28
|
+
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
29
|
+
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
30
|
+
ALL_COMMENT_BEARING_EXTENSIONS,
|
|
31
|
+
ALL_COMMENT_TRANSITION_PATTERNS,
|
|
32
|
+
ALL_DEFINITION_HEADER_PREFIXES,
|
|
33
|
+
ALL_HASH_AND_SLASH_EXTENSIONS,
|
|
34
|
+
ALL_HASH_ONLY_EXTENSIONS,
|
|
35
|
+
ALL_MARKDOWN_EXTENSIONS,
|
|
36
|
+
CODE_FENCE_PATTERN,
|
|
37
|
+
DOUBLE_QUOTED_SPAN_PATTERN,
|
|
38
|
+
INLINE_CODE_PATTERN,
|
|
39
|
+
PYTHON_EXTENSION,
|
|
40
|
+
TRIPLE_QUOTED_BLOCK_PATTERN,
|
|
41
|
+
)
|
|
42
|
+
except ImportError as import_error:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"state_description_blocker: cannot import its sibling modules"
|
|
45
|
+
) from import_error
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _content_to_check(tool_name: str, all_tool_input: dict) -> str:
|
|
49
|
+
"""Return the text a Write, Edit, or MultiEdit payload introduces."""
|
|
50
|
+
if tool_name == "MultiEdit":
|
|
51
|
+
return joined_new_strings(all_tool_input)
|
|
52
|
+
content_key = "content" if tool_name == "Write" else "new_string"
|
|
53
|
+
raw_content = all_tool_input.get(content_key, "")
|
|
54
|
+
return raw_content if isinstance(raw_content, str) else ""
|
|
40
55
|
|
|
41
56
|
|
|
42
57
|
def _get_file_extension(file_path: str) -> str:
|
|
@@ -289,7 +304,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
289
304
|
"""
|
|
290
305
|
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
291
306
|
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
292
|
-
if tool_name not in ("Write", "Edit"):
|
|
307
|
+
if tool_name not in ("Write", "Edit", "MultiEdit"):
|
|
293
308
|
return None
|
|
294
309
|
|
|
295
310
|
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
@@ -303,9 +318,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
303
318
|
if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
|
|
304
319
|
return None
|
|
305
320
|
|
|
306
|
-
|
|
307
|
-
raw_content = tool_input.get(content_key, "")
|
|
308
|
-
content_to_check = raw_content if isinstance(raw_content, str) else ""
|
|
321
|
+
content_to_check = _content_to_check(tool_name, tool_input)
|
|
309
322
|
if not content_to_check:
|
|
310
323
|
return None
|
|
311
324
|
|
|
@@ -42,55 +42,29 @@ if _blocking_dir not in sys.path:
|
|
|
42
42
|
if _hooks_dir not in sys.path:
|
|
43
43
|
sys.path.insert(0, _hooks_dir)
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
from hooks_constants.
|
|
49
|
-
read_hook_input_dictionary_from_stdin
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
from hooks_constants.windows_rmtree_blocker_constants import
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
try:
|
|
46
|
+
from code_rules_shared import is_test_file
|
|
47
|
+
|
|
48
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
49
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
50
|
+
from hooks_constants.subprocess_budget_completeness_constants import (
|
|
51
|
+
ALL_BUDGET_NAME_MARKERS,
|
|
52
|
+
BUDGET_ENTRY_POINT_FUNCTION_NAME,
|
|
53
|
+
SUBPROCESS_TIMEOUT_KEYWORD,
|
|
54
|
+
)
|
|
55
|
+
from hooks_constants.subprocess_budget_completeness_content import resolved_content
|
|
56
|
+
from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION
|
|
57
|
+
except ImportError as import_error:
|
|
58
|
+
raise ImportError(
|
|
59
|
+
"subprocess_budget_completeness: cannot import its sibling modules; "
|
|
60
|
+
"ensure the hooks directory is importable."
|
|
61
|
+
) from import_error
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def is_python_target(file_path: str) -> bool:
|
|
62
65
|
return file_path.endswith(PYTHON_FILE_EXTENSION)
|
|
63
66
|
|
|
64
67
|
|
|
65
|
-
def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
|
|
66
|
-
written_content = all_tool_input_fields.get("content")
|
|
67
|
-
if isinstance(written_content, str):
|
|
68
|
-
return written_content
|
|
69
|
-
return reconstructed_edit_content(all_tool_input_fields)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
|
|
73
|
-
file_path = all_tool_input_fields.get("file_path")
|
|
74
|
-
old_string = all_tool_input_fields.get("old_string")
|
|
75
|
-
new_string = all_tool_input_fields.get("new_string")
|
|
76
|
-
if not isinstance(file_path, str) or not isinstance(old_string, str):
|
|
77
|
-
return ""
|
|
78
|
-
if not isinstance(new_string, str) or not old_string:
|
|
79
|
-
return ""
|
|
80
|
-
existing_content = existing_file_content(file_path)
|
|
81
|
-
if existing_content is None or old_string not in existing_content:
|
|
82
|
-
return ""
|
|
83
|
-
return existing_content.replace(old_string, new_string, 1)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def existing_file_content(file_path: str) -> str | None:
|
|
87
|
-
try:
|
|
88
|
-
with open(file_path, "r", encoding="utf-8") as existing_file:
|
|
89
|
-
return existing_file.read()
|
|
90
|
-
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
|
|
94
68
|
def integer_literal_value(node: ast.expr) -> int | None:
|
|
95
69
|
if (
|
|
96
70
|
isinstance(node, ast.Constant)
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Blocks a write to a production source file when no matching test was modified
|
|
4
4
|
within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
|
|
5
|
+
The gate records each candidate test's content hash at every sighting and
|
|
6
|
+
compares that hash on the next write. A first sighting requires content that
|
|
7
|
+
differs from HEAD.
|
|
5
8
|
Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
|
|
6
9
|
into one PreToolUse gate and re-exports their surface for the test suite.
|
|
7
10
|
"""
|
|
@@ -21,9 +24,14 @@ try:
|
|
|
21
24
|
is_ephemeral_script_path,
|
|
22
25
|
is_under_session_scratchpad,
|
|
23
26
|
)
|
|
27
|
+
from codex_apply_patch import (
|
|
28
|
+
payload_patch_target_paths,
|
|
29
|
+
)
|
|
30
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
|
|
24
31
|
from tdd_enforcer_parts import (
|
|
25
32
|
candidate_paths,
|
|
26
33
|
content_analysis,
|
|
34
|
+
content_hash_store,
|
|
27
35
|
decisions,
|
|
28
36
|
freshness,
|
|
29
37
|
git_tracking,
|
|
@@ -56,8 +64,8 @@ candidate_test_paths_for = candidate_paths.candidate_test_paths_for
|
|
|
56
64
|
_ancestor_tests_directories = candidate_paths._ancestor_tests_directories
|
|
57
65
|
_parent_walk_limit = candidate_paths._parent_walk_limit
|
|
58
66
|
_freshness_seconds = freshness._freshness_seconds
|
|
59
|
-
has_fresh_test = freshness.has_fresh_test
|
|
60
67
|
_read_candidate_text = freshness._read_candidate_text
|
|
68
|
+
has_recorded_or_fresh_test = content_hash_store.has_recorded_or_fresh_test
|
|
61
69
|
_is_constants_only_python_content = content_analysis._is_constants_only_python_content
|
|
62
70
|
_is_post_edit_import_only = content_analysis._is_post_edit_import_only
|
|
63
71
|
_is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
|
|
@@ -72,8 +80,12 @@ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
|
|
|
72
80
|
return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
|
|
73
81
|
|
|
74
82
|
|
|
75
|
-
def _is_silently_skipped(
|
|
76
|
-
|
|
83
|
+
def _is_silently_skipped(
|
|
84
|
+
file_path: str, extension: str, path: Path, repository_root: str = ""
|
|
85
|
+
) -> bool:
|
|
86
|
+
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(
|
|
87
|
+
file_path, repository_root
|
|
88
|
+
):
|
|
77
89
|
return True
|
|
78
90
|
if _is_agent_home_tooling_write(file_path):
|
|
79
91
|
return True
|
|
@@ -117,29 +129,64 @@ def _write_is_exempt(
|
|
|
117
129
|
return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
|
|
118
130
|
|
|
119
131
|
|
|
132
|
+
def _decide_for_target(
|
|
133
|
+
tool_name: str, tool_input: dict, file_path: str, input_data: dict
|
|
134
|
+
) -> tuple[bool, str] | None:
|
|
135
|
+
"""Return (is_allowed, deny_reason) for one target file, or None to skip silently."""
|
|
136
|
+
if not file_path:
|
|
137
|
+
return None
|
|
138
|
+
if _is_session_scratchpad_write(file_path, input_data):
|
|
139
|
+
return None
|
|
140
|
+
path = Path(file_path)
|
|
141
|
+
extension = path.suffix.lower()
|
|
142
|
+
repository_root = str(input_data.get("cwd") or "")
|
|
143
|
+
if _is_silently_skipped(file_path, extension, path, repository_root):
|
|
144
|
+
return None
|
|
145
|
+
if _write_is_exempt(tool_name, tool_input, path, extension):
|
|
146
|
+
return True, ""
|
|
147
|
+
all_candidates = candidate_test_paths_for(path)
|
|
148
|
+
session_id = str(input_data.get("session_id") or "")
|
|
149
|
+
is_recorded_or_fresh = has_recorded_or_fresh_test(
|
|
150
|
+
all_candidates, session_id, repository_root, _freshness_seconds()
|
|
151
|
+
)
|
|
152
|
+
if is_recorded_or_fresh:
|
|
153
|
+
return True, ""
|
|
154
|
+
return False, build_deny_reason(path, all_candidates)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _decide_apply_patch(input_data: dict, tool_input: dict) -> None:
|
|
158
|
+
"""Run the TDD gate over every file a Codex apply_patch payload touches."""
|
|
159
|
+
for each_file_path in payload_patch_target_paths(input_data, tool_input):
|
|
160
|
+
decision = _decide_for_target(
|
|
161
|
+
APPLY_PATCH_TOOL_NAME, tool_input, each_file_path, input_data
|
|
162
|
+
)
|
|
163
|
+
if decision is None:
|
|
164
|
+
continue
|
|
165
|
+
is_allowed, deny_reason = decision
|
|
166
|
+
if not is_allowed:
|
|
167
|
+
emit_deny(deny_reason)
|
|
168
|
+
return
|
|
169
|
+
emit_allow()
|
|
170
|
+
|
|
171
|
+
|
|
120
172
|
def main() -> None:
|
|
121
|
-
"""Run the TDD gate over one PreToolUse Write, Edit, or
|
|
173
|
+
"""Run the TDD gate over one PreToolUse Write, Edit, MultiEdit, or apply_patch payload."""
|
|
122
174
|
try:
|
|
123
175
|
input_data = json.load(sys.stdin)
|
|
124
176
|
except json.JSONDecodeError:
|
|
125
177
|
sys.exit(0)
|
|
126
178
|
tool_name, tool_input, file_path = _resolve_payload(input_data)
|
|
127
|
-
if
|
|
128
|
-
|
|
129
|
-
if _is_session_scratchpad_write(file_path, input_data):
|
|
130
|
-
sys.exit(0)
|
|
131
|
-
path = Path(file_path)
|
|
132
|
-
extension = path.suffix.lower()
|
|
133
|
-
if _is_silently_skipped(file_path, extension, path):
|
|
179
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
180
|
+
_decide_apply_patch(input_data, tool_input)
|
|
134
181
|
sys.exit(0)
|
|
135
|
-
|
|
136
|
-
|
|
182
|
+
decision = _decide_for_target(tool_name, tool_input, file_path, input_data)
|
|
183
|
+
if decision is None:
|
|
137
184
|
sys.exit(0)
|
|
138
|
-
|
|
139
|
-
if
|
|
185
|
+
is_allowed, deny_reason = decision
|
|
186
|
+
if is_allowed:
|
|
140
187
|
emit_allow()
|
|
141
188
|
sys.exit(0)
|
|
142
|
-
emit_deny(
|
|
189
|
+
emit_deny(deny_reason)
|
|
143
190
|
sys.exit(0)
|
|
144
191
|
|
|
145
192
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Constants for the TDD-enforcer hook and its decomposed parts modules.
|
|
2
2
|
|
|
3
3
|
Centralizes the freshness window, the ancestor-walk limit, the git-tracking
|
|
4
|
-
command tokens, the source-file extension sets,
|
|
4
|
+
and git-diff command tokens, the source-file extension sets, the join
|
|
5
|
+
separator, and the last-observed-hash store's filename shape and JSON keys.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
from __future__ import annotations
|
|
@@ -9,9 +10,18 @@ from __future__ import annotations
|
|
|
9
10
|
FRESHNESS_WINDOW_SECONDS: int = 600
|
|
10
11
|
PARENT_WALK_LIMIT: int = 10
|
|
11
12
|
GIT_LS_FILES_TIMEOUT_SECONDS: int = 10
|
|
13
|
+
GIT_DIFF_TIMEOUT_SECONDS: int = 10
|
|
12
14
|
GIT_EXECUTABLE_NAME: str = "git"
|
|
13
15
|
GIT_LS_FILES_SUBCOMMAND: str = "ls-files"
|
|
16
|
+
GIT_DIFF_SUBCOMMAND: str = "diff"
|
|
17
|
+
GIT_DIFF_QUIET_FLAG: str = "--quiet"
|
|
18
|
+
GIT_HEAD_REVISION: str = "HEAD"
|
|
14
19
|
GIT_PATHSPEC_SEPARATOR: str = "--"
|
|
20
|
+
HASH_STATE_FILE_PREFIX: str = "claude-tdd-test-hashes-"
|
|
21
|
+
HASH_STATE_FILE_SUFFIX: str = ".json"
|
|
22
|
+
REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
|
|
23
|
+
STORED_CONTENT_HASH_KEY: str = "hash"
|
|
24
|
+
STORED_CHANGED_AT_KEY: str = "changed_at"
|
|
15
25
|
PYTHON_SOURCE_EXTENSION: str = ".py"
|
|
16
26
|
ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
|
|
17
27
|
NEWLINE_JOIN_SEPARATOR: str = "\n"
|