claude-dev-env 2.31.1 → 2.33.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/clean-coder.md +8 -7
- package/.agents/agents/code-quality-agent.md +4 -2
- package/.agents/agents/pr-description-writer.md +8 -3
- package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
- package/.agents/agents/test_agent_frontmatter.py +0 -9
- package/.agents/agents/test_pr_description_writer_style.py +24 -1
- package/.agents/skills/e-simplify/SKILL.md +1 -15
- package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
- package/.agents/skills/pr-refinement/SKILL.md +2 -2
- package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
- package/.agents/skills/pr-title-description/SKILL.md +6 -2
- package/.agents/skills/review-tier/SKILL.md +1 -1
- package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
- package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
- package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
- package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
- package/AGENTS.md +83 -0
- package/_shared/pr-loop/code-rules-gate.md +5 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
- package/audit-rubrics/audit-categories.json +2 -2
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
- package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
- package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +8 -9
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_comments.py +118 -59
- package/hooks/blocking/code_rules_duplicate_body.py +13 -7
- package/hooks/blocking/code_rules_enforcer.py +19 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_type_escape.py +16 -23
- package/hooks/blocking/javascript_comment_scanner.py +99 -0
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +3 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
- package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +56 -71
- package/hooks/blocking/test_tdd_enforcer.py +67 -1
- package/hooks/git-hooks/pull_request_handoff.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +8 -0
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
- package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/comment_checks.py +2 -2
- package/hooks/validators/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/fast_save_validators.py +0 -9
- package/hooks/validators/git_checks.py +0 -84
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +89 -42
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_fast_save_validators.py +2 -8
- package/hooks/validators/test_git_checks.py +0 -249
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_todo_checks.py +1 -0
- package/hooks/validators/todo_checks.py +1 -1
- package/package.json +1 -1
- package/rules/git-workflow.md +0 -8
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/check.ps1 +35 -1
- package/scripts/codex_compat_materializer.py +1 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/shared_tree_paths.py +59 -25
- package/scripts/test_shared_tree_paths.py +30 -0
- package/scripts/tests/test_rules.py +111 -0
- package/system-prompts/software-engineer.xml +6 -19
- package/.agents/skills-archived/split-pr/SKILL.md +0 -85
- package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
- package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
- package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
- package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
- package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
- package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
- package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
- package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
- package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
- package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
- package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
- package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
- package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
- package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
- package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
- package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
- package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
- package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
- package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
- package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
- package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
- package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
- package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
- package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
- package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Extract JavaScript comments without matching comment-like strings."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
import tokenize
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from hooks_constants.code_rules_enforcer_constants import (
|
|
9
|
+
ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES,
|
|
10
|
+
ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES,
|
|
11
|
+
ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JavaScriptCommentOccurrence(NamedTuple):
|
|
16
|
+
"""One JavaScript comment with its source line and placement."""
|
|
17
|
+
|
|
18
|
+
text: str
|
|
19
|
+
line_number: int
|
|
20
|
+
is_inline: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mask_javascript_strings(content: str) -> str:
|
|
24
|
+
"""Replace quoted JavaScript strings while preserving source positions."""
|
|
25
|
+
string_pattern = r'''("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)'''
|
|
26
|
+
return re.sub(
|
|
27
|
+
string_pattern,
|
|
28
|
+
lambda each_match: "".join(
|
|
29
|
+
"\n" if each_character == "\n" else " "
|
|
30
|
+
for each_character in each_match.group()
|
|
31
|
+
),
|
|
32
|
+
content,
|
|
33
|
+
flags=re.DOTALL,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_allowed_comment(
|
|
38
|
+
comment_text: str, is_inline: bool, include_directive_comments: bool
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Return whether a comment is excluded from the policy comparison."""
|
|
41
|
+
if comment_text.startswith("/**"):
|
|
42
|
+
return True
|
|
43
|
+
if include_directive_comments:
|
|
44
|
+
return False
|
|
45
|
+
prefixes = (
|
|
46
|
+
ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES
|
|
47
|
+
if is_inline
|
|
48
|
+
else ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES
|
|
49
|
+
)
|
|
50
|
+
comment_body = (
|
|
51
|
+
comment_text.removeprefix("//").lstrip()
|
|
52
|
+
if comment_text.startswith("//")
|
|
53
|
+
else comment_text
|
|
54
|
+
)
|
|
55
|
+
return comment_body.startswith(prefixes)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_javascript_comment_occurrences(
|
|
59
|
+
content: str, include_directive_comments: bool
|
|
60
|
+
) -> list[JavaScriptCommentOccurrence]:
|
|
61
|
+
"""Return real JavaScript comment occurrences with exact line numbers."""
|
|
62
|
+
masked_content = _mask_javascript_strings(content)
|
|
63
|
+
all_occurrences: list[JavaScriptCommentOccurrence] = []
|
|
64
|
+
for each_match in re.finditer(r"//[^\n]*|/\*[\s\S]*?\*/", masked_content):
|
|
65
|
+
comment_text = content[each_match.start() : each_match.end()].strip()
|
|
66
|
+
line_start = masked_content.rfind("\n", 0, each_match.start()) + 1
|
|
67
|
+
is_inline = bool(masked_content[line_start : each_match.start()].strip())
|
|
68
|
+
if _is_allowed_comment(comment_text, is_inline, include_directive_comments):
|
|
69
|
+
continue
|
|
70
|
+
line_number = content.count("\n", 0, each_match.start()) + 1
|
|
71
|
+
all_occurrences.append(
|
|
72
|
+
JavaScriptCommentOccurrence(comment_text, line_number, is_inline)
|
|
73
|
+
)
|
|
74
|
+
return all_occurrences
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def extract_python_comment_occurrences(
|
|
78
|
+
content: str,
|
|
79
|
+
) -> tuple[list[JavaScriptCommentOccurrence], bool]:
|
|
80
|
+
"""Return Python comments with exact lines and a tokenize status."""
|
|
81
|
+
source_lines = content.split("\n")
|
|
82
|
+
try:
|
|
83
|
+
all_tokens = list(tokenize.generate_tokens(io.StringIO(content).readline))
|
|
84
|
+
except ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS:
|
|
85
|
+
return [], False
|
|
86
|
+
all_occurrences: list[JavaScriptCommentOccurrence] = []
|
|
87
|
+
for each_token in all_tokens:
|
|
88
|
+
if each_token.type != tokenize.COMMENT:
|
|
89
|
+
continue
|
|
90
|
+
if each_token.string.startswith("#!") and each_token.start == (1, 0):
|
|
91
|
+
continue
|
|
92
|
+
source_line = source_lines[each_token.start[0] - 1]
|
|
93
|
+
is_inline = bool(source_line[: each_token.start[1]].strip())
|
|
94
|
+
all_occurrences.append(
|
|
95
|
+
JavaScriptCommentOccurrence(
|
|
96
|
+
each_token.string.strip(), each_token.start[0], is_inline
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return all_occurrences, True
|
|
@@ -23,6 +23,12 @@ ignored because bash performs no substitution inside them (for example
|
|
|
23
23
|
are treated as literal only when the count of preceding backslashes is odd;
|
|
24
24
|
an even count (including zero) means the backtick is a live substitution.
|
|
25
25
|
|
|
26
|
+
A quoted heredoc body is stripped for the same reason. `<<'EOF'`, `<<"EOF"`
|
|
27
|
+
and `<<\\EOF` each tell bash to expand nothing between the opener and the
|
|
28
|
+
terminator, so a backtick there is text the file receives rather than a
|
|
29
|
+
command the shell runs. A bare `<<EOF` expands its body and keeps its scan,
|
|
30
|
+
and text outside any heredoc is scanned either way.
|
|
31
|
+
|
|
26
32
|
Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
|
|
27
33
|
spawn a subshell and does not defeat the allowlist matcher. Telling it apart
|
|
28
34
|
from a disguised command substitution takes more than counting two open
|
|
@@ -41,11 +47,6 @@ another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
|
|
|
41
47
|
the closer that brings depth to zero is a lone ``)``, bash has fallen back to
|
|
42
48
|
running a parenthesized command inside the substitution, so it is blocked.
|
|
43
49
|
This hook applies that walk, tracking paren depth from the opening ``$((``.
|
|
44
|
-
|
|
45
|
-
False positives are still possible when the user is intentionally writing
|
|
46
|
-
shell-script content (for example, authoring a .sh file via heredoc) and the
|
|
47
|
-
substitution is literal payload rather than something to execute. In that
|
|
48
|
-
case, author the file with the Write tool, not a Bash heredoc.
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
52
|
from __future__ import annotations
|
|
@@ -53,6 +54,7 @@ from __future__ import annotations
|
|
|
53
54
|
import json
|
|
54
55
|
import sys
|
|
55
56
|
from pathlib import Path
|
|
57
|
+
from re import Match
|
|
56
58
|
|
|
57
59
|
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
58
60
|
if _hooks_dir not in sys.path:
|
|
@@ -74,6 +76,11 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
|
74
76
|
PERMISSION_DECISION_KEY,
|
|
75
77
|
PERMISSION_DECISION_REASON_KEY,
|
|
76
78
|
PROCESS_SUBSTITUTION_PATTERN,
|
|
79
|
+
BACKSLASH_CHARACTER,
|
|
80
|
+
COMMAND_LINE_SEPARATOR,
|
|
81
|
+
DOUBLE_QUOTE_CHARACTER,
|
|
82
|
+
NO_TERMINATOR_INDEX,
|
|
83
|
+
SINGLE_QUOTE_CHARACTER,
|
|
77
84
|
SINGLE_QUOTED_RUN_PATTERN,
|
|
78
85
|
STRIPPED_RUN_REPLACEMENT,
|
|
79
86
|
TOOL_INPUT_KEY,
|
|
@@ -81,10 +88,169 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
|
81
88
|
)
|
|
82
89
|
|
|
83
90
|
|
|
91
|
+
from hooks_constants.piped_pytest_blocker_constants import ( # noqa: E402
|
|
92
|
+
HEREDOC_ESCAPE_GROUP,
|
|
93
|
+
HEREDOC_OPENER_PATTERN,
|
|
94
|
+
HEREDOC_QUOTE_GROUP,
|
|
95
|
+
HEREDOC_TAB_STRIP_GROUP,
|
|
96
|
+
HEREDOC_TAB_STRIP_MARKER,
|
|
97
|
+
HEREDOC_TERMINATOR_GROUP,
|
|
98
|
+
HEREDOC_UNQUOTED_MARKER,
|
|
99
|
+
)
|
|
100
|
+
from hooks_constants.shell_command_pipeline import ( # noqa: E402
|
|
101
|
+
PendingHeredoc,
|
|
102
|
+
closes_the_heredoc,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
84
106
|
def _strip_single_quoted_runs(command: str) -> str:
|
|
85
107
|
return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
|
|
86
108
|
|
|
87
109
|
|
|
110
|
+
def _opener_suppresses_expansion(opener_match: Match[str]) -> bool:
|
|
111
|
+
"""Return whether an opener's delimiter is quoted or backslash-escaped.
|
|
112
|
+
|
|
113
|
+
::
|
|
114
|
+
|
|
115
|
+
<<'EOF' -> True quoted, the body is literal
|
|
116
|
+
<<EOF -> False bare, bash expands the body
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
opener_match: One HEREDOC_OPENER_PATTERN match from a command line.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True when bash expands nothing down to this opener's terminator.
|
|
123
|
+
"""
|
|
124
|
+
is_quoted = opener_match.group(HEREDOC_QUOTE_GROUP) != HEREDOC_UNQUOTED_MARKER
|
|
125
|
+
is_escaped = opener_match.group(HEREDOC_ESCAPE_GROUP) != HEREDOC_UNQUOTED_MARKER
|
|
126
|
+
return is_quoted or is_escaped
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sits_outside_quotes(command_line: str, character_index: int) -> bool:
|
|
130
|
+
"""Return whether a position on a line sits outside every quoted run.
|
|
131
|
+
|
|
132
|
+
::
|
|
133
|
+
|
|
134
|
+
cat <<'EOF' index of << -> True bash reads an opener
|
|
135
|
+
echo "<<'EOF'" index of << -> False bash reads a string
|
|
136
|
+
|
|
137
|
+
Counting unescaped quotes before the position tells the two apart: an even
|
|
138
|
+
count of each quote character means every run opened before it also closed.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
command_line: One line of the command.
|
|
142
|
+
character_index: Position of the candidate opener on that line.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True when bash reads this position as shell syntax.
|
|
146
|
+
"""
|
|
147
|
+
single_quote_count = 0
|
|
148
|
+
double_quote_count = 0
|
|
149
|
+
is_escaped = False
|
|
150
|
+
for each_character in command_line[:character_index]:
|
|
151
|
+
if is_escaped:
|
|
152
|
+
is_escaped = False
|
|
153
|
+
continue
|
|
154
|
+
if each_character == BACKSLASH_CHARACTER:
|
|
155
|
+
is_escaped = True
|
|
156
|
+
continue
|
|
157
|
+
if each_character == SINGLE_QUOTE_CHARACTER:
|
|
158
|
+
single_quote_count += 1
|
|
159
|
+
elif each_character == DOUBLE_QUOTE_CHARACTER:
|
|
160
|
+
double_quote_count += 1
|
|
161
|
+
return single_quote_count % 2 == 0 and double_quote_count % 2 == 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _first_literal_opener(command_line: str) -> Match[str] | None:
|
|
165
|
+
"""Return the line's first heredoc opener whose body bash leaves literal.
|
|
166
|
+
|
|
167
|
+
An opener spelled inside a quoted string is text bash passes on, so it
|
|
168
|
+
opens nothing and the lines below it stay in the scan.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
command_line: One line of the command.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The matching opener, or None when the line opens no literal heredoc.
|
|
175
|
+
"""
|
|
176
|
+
for each_opener in HEREDOC_OPENER_PATTERN.finditer(command_line):
|
|
177
|
+
if not _sits_outside_quotes(command_line, each_opener.start()):
|
|
178
|
+
continue
|
|
179
|
+
if _opener_suppresses_expansion(each_opener):
|
|
180
|
+
return each_opener
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _index_past_literal_body(
|
|
185
|
+
all_lines: list[str], body_start_index: int, opener_match: Match[str]
|
|
186
|
+
) -> int:
|
|
187
|
+
"""Return the index of the line that closes a literal heredoc body.
|
|
188
|
+
|
|
189
|
+
The terminator match, including the ``<<-`` tab-strip rule, comes from
|
|
190
|
+
``closes_the_heredoc`` so this hook and the shared pipeline agree on what
|
|
191
|
+
ends a body.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
all_lines: Every line of the command.
|
|
195
|
+
body_start_index: Index of the body's first line.
|
|
196
|
+
opener_match: The opener whose terminator closes this body.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The index of the terminator line, or NO_TERMINATOR_INDEX when the
|
|
200
|
+
command holds none.
|
|
201
|
+
"""
|
|
202
|
+
pending_heredoc = PendingHeredoc(
|
|
203
|
+
terminator=opener_match.group(HEREDOC_TERMINATOR_GROUP),
|
|
204
|
+
allows_leading_tabs=(
|
|
205
|
+
opener_match.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
for each_index in range(body_start_index, len(all_lines)):
|
|
209
|
+
if closes_the_heredoc(all_lines[each_index], pending_heredoc):
|
|
210
|
+
return each_index
|
|
211
|
+
return NO_TERMINATOR_INDEX
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _strip_quoted_heredoc_bodies(command: str) -> str:
|
|
215
|
+
"""Return the command holding its openers, terminators, and shell text.
|
|
216
|
+
|
|
217
|
+
::
|
|
218
|
+
|
|
219
|
+
cat <<'EOF' -> cat <<'EOF'
|
|
220
|
+
inert text EOF
|
|
221
|
+
EOF
|
|
222
|
+
|
|
223
|
+
A quoted or escaped delimiter makes the lines below it text the shell hands
|
|
224
|
+
on, so a scan of those lines reports a substitution bash never runs. A bare
|
|
225
|
+
delimiter keeps its body, and text outside any heredoc keeps its scan.
|
|
226
|
+
|
|
227
|
+
An opener with no terminator returns the command whole. Bash consumes the
|
|
228
|
+
rest of the input in that case, and dropping those lines would hide a live
|
|
229
|
+
substitution among them.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
command: The raw Bash command string from the tool input.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The command with each literal heredoc body dropped.
|
|
236
|
+
"""
|
|
237
|
+
all_lines = command.split(COMMAND_LINE_SEPARATOR)
|
|
238
|
+
all_kept_lines: list[str] = []
|
|
239
|
+
each_index = 0
|
|
240
|
+
while each_index < len(all_lines):
|
|
241
|
+
current_line = all_lines[each_index]
|
|
242
|
+
all_kept_lines.append(current_line)
|
|
243
|
+
each_index += 1
|
|
244
|
+
opener_match = _first_literal_opener(current_line)
|
|
245
|
+
if opener_match is None:
|
|
246
|
+
continue
|
|
247
|
+
terminator_index = _index_past_literal_body(all_lines, each_index, opener_match)
|
|
248
|
+
if terminator_index == NO_TERMINATOR_INDEX:
|
|
249
|
+
return command
|
|
250
|
+
each_index = terminator_index
|
|
251
|
+
return COMMAND_LINE_SEPARATOR.join(all_kept_lines)
|
|
252
|
+
|
|
253
|
+
|
|
88
254
|
def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
|
|
89
255
|
"""Return True when a ``$((`` expansion closes on an ``))`` pair.
|
|
90
256
|
|
|
@@ -132,8 +298,9 @@ def has_shell_substitution(command: str) -> bool:
|
|
|
132
298
|
echo '$(not-executed)' ok: single-quoted, inert
|
|
133
299
|
echo $((2 + 2)) ok: arithmetic expansion
|
|
134
300
|
|
|
135
|
-
|
|
136
|
-
|
|
301
|
+
Literal heredoc bodies are dropped first, then single-quoted runs are
|
|
302
|
+
stripped, because bash substitutes nothing inside either. A backtick
|
|
303
|
+
preceded by an odd backslash count is escaped.
|
|
137
304
|
A `$((` pair is walked to its terminator (module docstring) to tell
|
|
138
305
|
real arithmetic from a disguised subshell.
|
|
139
306
|
|
|
@@ -143,7 +310,7 @@ def has_shell_substitution(command: str) -> bool:
|
|
|
143
310
|
Returns:
|
|
144
311
|
True when a substitution the matcher cannot descend into is present.
|
|
145
312
|
"""
|
|
146
|
-
scannable_command = _strip_single_quoted_runs(command)
|
|
313
|
+
scannable_command = _strip_single_quoted_runs(_strip_quoted_heredoc_bodies(command))
|
|
147
314
|
if _has_unsafe_dollar_paren_expansion(scannable_command):
|
|
148
315
|
return True
|
|
149
316
|
if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
|
|
@@ -12,11 +12,14 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import sys
|
|
15
|
-
from pathlib import Path
|
|
16
15
|
|
|
17
16
|
import _path_setup # noqa: F401
|
|
18
17
|
|
|
19
|
-
from hooks_constants.hosted_hook_runner import
|
|
18
|
+
from hooks_constants.hosted_hook_runner import (
|
|
19
|
+
run_hook_capturing_output,
|
|
20
|
+
HostedHookRun,
|
|
21
|
+
resolved_hook_script_path,
|
|
22
|
+
)
|
|
20
23
|
from hooks_constants.stop_dispatcher_constants import (
|
|
21
24
|
ALL_STOP_HOSTED_HOOK_PATHS,
|
|
22
25
|
BLOCK_DECISION,
|
|
@@ -24,12 +27,6 @@ from hooks_constants.stop_dispatcher_constants import (
|
|
|
24
27
|
)
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
28
|
-
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
29
|
-
hooks_root = Path(__file__).resolve().parent.parent
|
|
30
|
-
return str(hooks_root / relative_path)
|
|
31
|
-
|
|
32
|
-
|
|
33
30
|
def _is_block_decision(stdout_text: str) -> bool:
|
|
34
31
|
"""Return True when stdout carries a Stop decision of block."""
|
|
35
32
|
stripped_text = stdout_text.strip()
|
|
@@ -58,7 +55,7 @@ def dispatch(payload_text: str) -> None:
|
|
|
58
55
|
"""Run every Stop hosted hook and emit the first block decision if any."""
|
|
59
56
|
all_runs: list[HostedHookRun] = []
|
|
60
57
|
for each_relative_path in ALL_STOP_HOSTED_HOOK_PATHS:
|
|
61
|
-
script_path =
|
|
58
|
+
script_path = resolved_hook_script_path(each_relative_path)
|
|
62
59
|
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
63
60
|
all_runs.append(hook_run)
|
|
64
61
|
block_stdout = select_first_block_stdout(all_runs)
|
|
@@ -4,7 +4,9 @@ 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
5
|
The gate records each candidate test's content hash at every sighting and
|
|
6
6
|
compares that hash on the next write. A first sighting requires content that
|
|
7
|
-
differs from HEAD.
|
|
7
|
+
differs from HEAD. A recorded failing pytest run, written by
|
|
8
|
+
``hooks/observability/test_failure_recorder.py``, is read before that fallback
|
|
9
|
+
and applies while the candidate's content still matches what failed.
|
|
8
10
|
Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
|
|
9
11
|
into one PreToolUse gate and re-exports their surface for the test suite.
|
|
10
12
|
"""
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
Centralizes the freshness window, the ancestor-walk limit, the git-tracking
|
|
4
4
|
and git-diff command tokens, the source-file extension sets, the join
|
|
5
5
|
separator, and the last-observed-hash store's filename shape and JSON keys.
|
|
6
|
+
The store's JSON keys also cover a recorded failing-test-run entry: the test
|
|
7
|
+
file's content hash at failure time, the failure timestamp, the failing
|
|
8
|
+
command, and its exit status.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
from __future__ import annotations
|
|
@@ -22,6 +25,10 @@ HASH_STATE_FILE_SUFFIX: str = ".json"
|
|
|
22
25
|
REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
|
|
23
26
|
STORED_CONTENT_HASH_KEY: str = "hash"
|
|
24
27
|
STORED_CHANGED_AT_KEY: str = "changed_at"
|
|
28
|
+
STORED_FAILURE_HASH_KEY: str = "failed_hash"
|
|
29
|
+
STORED_FAILED_AT_KEY: str = "failed_at"
|
|
30
|
+
STORED_FAILURE_COMMAND_KEY: str = "failed_command"
|
|
31
|
+
STORED_FAILURE_EXIT_STATUS_KEY: str = "failed_exit_status"
|
|
25
32
|
PYTHON_SOURCE_EXTENSION: str = ".py"
|
|
26
33
|
ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
|
|
27
34
|
NEWLINE_JOIN_SEPARATOR: str = "\n"
|
|
@@ -30,6 +30,7 @@ import time
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
32
|
from atomic_file_writer import write_text_atomically
|
|
33
|
+
from hooks_constants.state_file_lock import hold_state_file_lock
|
|
33
34
|
from hooks_constants.session_edit_stage_gate_constants import (
|
|
34
35
|
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
35
36
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
@@ -45,6 +46,10 @@ from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
|
45
46
|
REPOSITORY_ROOT_DIGEST_LENGTH,
|
|
46
47
|
STORED_CHANGED_AT_KEY,
|
|
47
48
|
STORED_CONTENT_HASH_KEY,
|
|
49
|
+
STORED_FAILED_AT_KEY,
|
|
50
|
+
STORED_FAILURE_COMMAND_KEY,
|
|
51
|
+
STORED_FAILURE_EXIT_STATUS_KEY,
|
|
52
|
+
STORED_FAILURE_HASH_KEY,
|
|
48
53
|
)
|
|
49
54
|
|
|
50
55
|
|
|
@@ -96,6 +101,26 @@ def _recorded_entry(current_hash: str, current_time: float) -> dict[str, object]
|
|
|
96
101
|
return {STORED_CONTENT_HASH_KEY: current_hash, STORED_CHANGED_AT_KEY: current_time}
|
|
97
102
|
|
|
98
103
|
|
|
104
|
+
def _has_matching_recorded_failure(
|
|
105
|
+
stored_entry: object, current_hash: str, current_time: float, freshness_seconds: int
|
|
106
|
+
) -> bool:
|
|
107
|
+
"""Return whether a recorded test failure still evidences the candidate's content.
|
|
108
|
+
|
|
109
|
+
A failure counts as evidence only while the candidate's content is
|
|
110
|
+
exactly what failed and the record has not aged past the freshness
|
|
111
|
+
window -- editing the test again, or letting the record go stale, drops
|
|
112
|
+
it as evidence and falls back to the ordinary content-hash tracking.
|
|
113
|
+
"""
|
|
114
|
+
if not isinstance(stored_entry, dict):
|
|
115
|
+
return False
|
|
116
|
+
if stored_entry.get(STORED_FAILURE_HASH_KEY) != current_hash:
|
|
117
|
+
return False
|
|
118
|
+
failed_at = stored_entry.get(STORED_FAILED_AT_KEY)
|
|
119
|
+
if not isinstance(failed_at, (int, float)):
|
|
120
|
+
return False
|
|
121
|
+
return current_time - failed_at <= freshness_seconds
|
|
122
|
+
|
|
123
|
+
|
|
99
124
|
def _is_qualifying_first_sight(
|
|
100
125
|
candidate_path: Path, candidate_text: str, current_time: float, freshness_seconds: int
|
|
101
126
|
) -> bool:
|
|
@@ -107,6 +132,34 @@ def _is_qualifying_first_sight(
|
|
|
107
132
|
return git_tracking.has_uncommitted_change_from_head(candidate_path)
|
|
108
133
|
|
|
109
134
|
|
|
135
|
+
def _first_sight_decision(
|
|
136
|
+
candidate_path: Path,
|
|
137
|
+
candidate_text: str,
|
|
138
|
+
current_time: float,
|
|
139
|
+
freshness_seconds: int,
|
|
140
|
+
current_hash: str,
|
|
141
|
+
) -> tuple[bool, dict[str, object] | None]:
|
|
142
|
+
if not _is_qualifying_first_sight(
|
|
143
|
+
candidate_path, candidate_text, current_time, freshness_seconds
|
|
144
|
+
):
|
|
145
|
+
return False, None
|
|
146
|
+
return True, _recorded_entry(current_hash, current_time)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _content_hash_decision(
|
|
150
|
+
all_stored_entry_fields: dict[str, object],
|
|
151
|
+
current_hash: str,
|
|
152
|
+
current_time: float,
|
|
153
|
+
freshness_seconds: int,
|
|
154
|
+
) -> tuple[bool, dict[str, object] | None]:
|
|
155
|
+
if current_hash != all_stored_entry_fields.get(STORED_CONTENT_HASH_KEY):
|
|
156
|
+
return True, _recorded_entry(current_hash, current_time)
|
|
157
|
+
stored_changed_at = all_stored_entry_fields.get(STORED_CHANGED_AT_KEY)
|
|
158
|
+
if not isinstance(stored_changed_at, (int, float)):
|
|
159
|
+
return False, None
|
|
160
|
+
return current_time - stored_changed_at <= freshness_seconds, None
|
|
161
|
+
|
|
162
|
+
|
|
110
163
|
def _evaluate_candidate(
|
|
111
164
|
candidate_path: Path,
|
|
112
165
|
stored_entry: object,
|
|
@@ -117,18 +170,15 @@ def _evaluate_candidate(
|
|
|
117
170
|
if candidate_text is None:
|
|
118
171
|
return False, None
|
|
119
172
|
current_hash = _content_hash(candidate_text)
|
|
120
|
-
if
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if not isinstance(stored_changed_at, (int, float)):
|
|
130
|
-
return False, None
|
|
131
|
-
return current_time - stored_changed_at <= freshness_seconds, None
|
|
173
|
+
if _has_matching_recorded_failure(
|
|
174
|
+
stored_entry, current_hash, current_time, freshness_seconds
|
|
175
|
+
):
|
|
176
|
+
return True, None
|
|
177
|
+
if not isinstance(stored_entry, dict) or STORED_CONTENT_HASH_KEY not in stored_entry:
|
|
178
|
+
return _first_sight_decision(
|
|
179
|
+
candidate_path, candidate_text, current_time, freshness_seconds, current_hash
|
|
180
|
+
)
|
|
181
|
+
return _content_hash_decision(stored_entry, current_hash, current_time, freshness_seconds)
|
|
132
182
|
|
|
133
183
|
|
|
134
184
|
def _apply_candidate_decisions(
|
|
@@ -142,7 +192,10 @@ def _apply_candidate_decisions(
|
|
|
142
192
|
for each_candidate in all_candidates:
|
|
143
193
|
candidate_key = _state_key_for(each_candidate)
|
|
144
194
|
candidate_allowed, updated_entry = _evaluate_candidate(
|
|
145
|
-
each_candidate,
|
|
195
|
+
each_candidate,
|
|
196
|
+
all_stored_hashes.get(candidate_key),
|
|
197
|
+
current_time,
|
|
198
|
+
freshness_seconds,
|
|
146
199
|
)
|
|
147
200
|
if candidate_allowed:
|
|
148
201
|
is_allowed = True
|
|
@@ -162,19 +215,84 @@ def has_recorded_or_fresh_test(
|
|
|
162
215
|
|
|
163
216
|
Args:
|
|
164
217
|
all_candidates: Candidate test paths for the production file.
|
|
165
|
-
session_id: Raw
|
|
166
|
-
repository_root: Raw
|
|
218
|
+
session_id: Raw session_id from the hook payload.
|
|
219
|
+
repository_root: Raw cwd from the hook payload.
|
|
167
220
|
freshness_seconds: Maximum age, in seconds, a stored hash may have.
|
|
168
221
|
|
|
169
222
|
Returns:
|
|
170
|
-
True when at least one candidate
|
|
171
|
-
|
|
223
|
+
True when at least one candidate carries a matching recorded test
|
|
224
|
+
failure, is a fresh dirty first sighting, has a recorded hash that
|
|
225
|
+
now differs, or has a recorded hash still in window.
|
|
172
226
|
"""
|
|
173
227
|
state_file = _state_file_path(session_id, repository_root)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
228
|
+
with hold_state_file_lock(state_file):
|
|
229
|
+
all_stored_hashes = _load_state(state_file)
|
|
230
|
+
is_allowed, state_changed = _apply_candidate_decisions(
|
|
231
|
+
all_candidates, all_stored_hashes, time.time(), freshness_seconds
|
|
232
|
+
)
|
|
233
|
+
if state_changed:
|
|
234
|
+
_atomic_write_state(state_file, all_stored_hashes)
|
|
180
235
|
return is_allowed
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _merged_failure_entry(
|
|
239
|
+
existing_entry: object,
|
|
240
|
+
candidate_text: str,
|
|
241
|
+
command: str,
|
|
242
|
+
exit_status: int,
|
|
243
|
+
current_time: float,
|
|
244
|
+
) -> dict[str, object]:
|
|
245
|
+
merged_entry = dict(existing_entry) if isinstance(existing_entry, dict) else {}
|
|
246
|
+
merged_entry[STORED_FAILURE_HASH_KEY] = _content_hash(candidate_text)
|
|
247
|
+
merged_entry[STORED_FAILED_AT_KEY] = current_time
|
|
248
|
+
merged_entry[STORED_FAILURE_COMMAND_KEY] = command
|
|
249
|
+
merged_entry[STORED_FAILURE_EXIT_STATUS_KEY] = exit_status
|
|
250
|
+
return merged_entry
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _record_one_test_file_failure(
|
|
254
|
+
test_file_path: Path,
|
|
255
|
+
all_stored_hashes: dict[str, object],
|
|
256
|
+
command: str,
|
|
257
|
+
exit_status: int,
|
|
258
|
+
current_time: float,
|
|
259
|
+
) -> bool:
|
|
260
|
+
candidate_text = freshness._read_candidate_text(test_file_path)
|
|
261
|
+
if candidate_text is None:
|
|
262
|
+
return False
|
|
263
|
+
candidate_key = _state_key_for(test_file_path)
|
|
264
|
+
all_stored_hashes[candidate_key] = _merged_failure_entry(
|
|
265
|
+
all_stored_hashes.get(candidate_key),
|
|
266
|
+
candidate_text,
|
|
267
|
+
command,
|
|
268
|
+
exit_status,
|
|
269
|
+
current_time,
|
|
270
|
+
)
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def record_test_command_failure(
|
|
275
|
+
all_test_file_paths: list[Path],
|
|
276
|
+
command: str,
|
|
277
|
+
exit_status: int,
|
|
278
|
+
session_id: str,
|
|
279
|
+
repository_root: str,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Record a failing test command as gate evidence, one entry per test file.
|
|
282
|
+
|
|
283
|
+
Keyed exactly as the gate looks candidates up, so a failure satisfies the
|
|
284
|
+
gate only for the file it names, never a sibling the command also ran.
|
|
285
|
+
Existing content-hash fields on that entry are preserved, not overwritten.
|
|
286
|
+
"""
|
|
287
|
+
state_file = _state_file_path(session_id, repository_root)
|
|
288
|
+
current_time = time.time()
|
|
289
|
+
with hold_state_file_lock(state_file):
|
|
290
|
+
all_stored_hashes = _load_state(state_file)
|
|
291
|
+
all_recorded = [
|
|
292
|
+
_record_one_test_file_failure(
|
|
293
|
+
each_path, all_stored_hashes, command, exit_status, current_time
|
|
294
|
+
)
|
|
295
|
+
for each_path in all_test_file_paths
|
|
296
|
+
]
|
|
297
|
+
if any(all_recorded):
|
|
298
|
+
_atomic_write_state(state_file, all_stored_hashes)
|