claude-dev-env 2.31.1 → 2.32.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/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +1 -1
- 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_duplicate_body.py +13 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -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_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/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/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/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/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +87 -33
- package/hooks/validators/system_temporary_roots.py +1 -1
- 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/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -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/bin/install.test.mjs
CHANGED
|
@@ -925,9 +925,17 @@ function isPreToolUseDispatcherCommand(command) {
|
|
|
925
925
|
return /(?:^|[/\\])pre_tool_use_dispatcher\.py(?![A-Za-z0-9_])/.test(command);
|
|
926
926
|
}
|
|
927
927
|
|
|
928
|
+
function matcherCoversWriteAndEdit(matcher) {
|
|
929
|
+
const allToolNames = String(matcher || '').split('|');
|
|
930
|
+
return allToolNames.includes('Write') && allToolNames.includes('Edit');
|
|
931
|
+
}
|
|
932
|
+
|
|
928
933
|
function countManagedRunAllValidatorsHooks(settings) {
|
|
929
|
-
|
|
930
|
-
|
|
934
|
+
// The runner is registered inline on the write path. Which mutation tools
|
|
935
|
+
// that path lists is the gate's business, so this counts any matcher
|
|
936
|
+
// carrying Write and Edit rather than one exact tool list.
|
|
937
|
+
const writeEditGroups = (settings.hooks.PreToolUse || []).filter(group =>
|
|
938
|
+
matcherCoversWriteAndEdit(group.matcher)
|
|
931
939
|
);
|
|
932
940
|
let runAllValidatorsCount = 0;
|
|
933
941
|
for (const group of writeEditGroups) {
|
|
@@ -1382,7 +1390,7 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
|
|
|
1382
1390
|
assert.equal(
|
|
1383
1391
|
countManagedRunAllValidatorsHooks(shippedHooksConfig),
|
|
1384
1392
|
1,
|
|
1385
|
-
'shipped hooks.json must retain the inline run_all_validators runner
|
|
1393
|
+
'shipped hooks.json must retain the inline run_all_validators runner on the write path',
|
|
1386
1394
|
);
|
|
1387
1395
|
|
|
1388
1396
|
const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -130,4 +130,4 @@ Three surfaces report on the roster, and each reports a specific thing:
|
|
|
130
130
|
|
|
131
131
|
- `hooks/validators/hook_timing_harness.py` builds a `Write` payload against a target that already holds content. `_contents_for_validation` returns `None` for that payload, so the harness times interpreter start and hook dispatch. Time an `Edit` payload against a real file to measure the checks.
|
|
132
132
|
- `~/.claude/logs/hook-blocks.log` records the denials raised by fixtures in `test_code_rules_enforcer_*.py` and by the timing harness's default target.
|
|
133
|
-
- `hooks/validators/run_all_validators.py`
|
|
133
|
+
- `hooks/validators/run_all_validators.py` stages the target under a temporary root and rebuilds the shortest path tail that carries every exemption signal. The walk starts at the target's own project root and skips a directory pytest generated for its own scratch tree, so the staged path reads the same wherever `--basetemp` places that tree.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse dispatcher that hosts the Bash hook chain running after a call.
|
|
3
|
+
|
|
4
|
+
Named ``bash_post_call_dispatcher`` rather than the more obvious
|
|
5
|
+
``bash_post_tool_use_dispatcher``: ``bin/install.test.mjs`` counts every
|
|
6
|
+
PostToolUse command whose text contains the literal substring
|
|
7
|
+
``post_tool_use_dispatcher.py`` and asserts exactly one match, against the
|
|
8
|
+
unrelated Write/Edit dispatcher in ``validation/post_tool_use_dispatcher.py``.
|
|
9
|
+
That check carries no basename anchor (its PreToolUse sibling does), so a
|
|
10
|
+
correctly-named Bash-side sibling here would read as a second match of an
|
|
11
|
+
unrelated dispatcher. This name carries the same meaning without the
|
|
12
|
+
collision, and needs no change to that shared, out-of-scope test file.
|
|
13
|
+
|
|
14
|
+
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
15
|
+
the payload's tool name, and runs each one in-process via the shared
|
|
16
|
+
hosted-hook runner. A hosted PostToolUse hook here carries no permission
|
|
17
|
+
decision -- it is a side-effecting observer, not a gate -- so this dispatcher
|
|
18
|
+
runs the whole roster unconditionally and emits nothing itself, matching every
|
|
19
|
+
hosted hook's own empty-stdout contract.
|
|
20
|
+
|
|
21
|
+
A single hosted hook crash fails open: ``run_hook_capturing_output`` isolates
|
|
22
|
+
it, so it contributes nothing and does not stop the remaining hosted hooks.
|
|
23
|
+
|
|
24
|
+
One interpreter start here serves the whole roster: every hosted hook's
|
|
25
|
+
imports load once into this process rather than once per hook.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
import _path_setup # noqa: F401
|
|
32
|
+
|
|
33
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
34
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
35
|
+
)
|
|
36
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BashHostedHookEntry
|
|
37
|
+
from hooks_constants.hosted_hook_runner import (
|
|
38
|
+
run_hook_capturing_output,
|
|
39
|
+
resolved_hook_script_path,
|
|
40
|
+
run_dispatcher_main,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
|
|
45
|
+
"""Return the ordered hosted-hook entries that apply to tool_name."""
|
|
46
|
+
return [
|
|
47
|
+
each_entry
|
|
48
|
+
for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
49
|
+
if tool_name in each_entry.applicable_tool_names
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dispatch(payload_text: str, tool_name: str) -> None:
|
|
54
|
+
"""Run every hosted hook applicable to tool_name, in registration order.
|
|
55
|
+
|
|
56
|
+
Each hosted hook's crash is isolated by ``run_hook_capturing_output``, so
|
|
57
|
+
one hook failing never stops a later one in the same roster.
|
|
58
|
+
"""
|
|
59
|
+
for each_entry in select_applicable_entries(tool_name):
|
|
60
|
+
script_path = resolved_hook_script_path(each_entry.script_relative_path)
|
|
61
|
+
run_hook_capturing_output(script_path, payload_text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
"""Read stdin once and dispatch the Bash PostToolUse hosted-hook roster."""
|
|
66
|
+
run_dispatcher_main(dispatch)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
@@ -22,7 +22,6 @@ from __future__ import annotations
|
|
|
22
22
|
import json
|
|
23
23
|
import sys
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
|
-
from pathlib import Path
|
|
26
25
|
|
|
27
26
|
import _path_setup # noqa: F401
|
|
28
27
|
|
|
@@ -36,8 +35,12 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
36
35
|
REASON_JOIN_SEPARATOR,
|
|
37
36
|
BashHostedHookEntry,
|
|
38
37
|
)
|
|
39
|
-
from hooks_constants.hosted_hook_runner import
|
|
40
|
-
|
|
38
|
+
from hooks_constants.hosted_hook_runner import (
|
|
39
|
+
run_dispatcher_main,
|
|
40
|
+
run_hook_capturing_output,
|
|
41
|
+
HostedHookRun,
|
|
42
|
+
resolved_hook_script_path,
|
|
43
|
+
)
|
|
41
44
|
|
|
42
45
|
_PERMISSION_DECISION_KEY = "permissionDecision"
|
|
43
46
|
_PERMISSION_REASON_KEY = "permissionDecisionReason"
|
|
@@ -203,12 +206,6 @@ def aggregate_bash_hook_results(
|
|
|
203
206
|
)
|
|
204
207
|
|
|
205
208
|
|
|
206
|
-
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
207
|
-
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
208
|
-
hooks_root = Path(__file__).resolve().parent.parent
|
|
209
|
-
return str(hooks_root / relative_path)
|
|
210
|
-
|
|
211
|
-
|
|
212
209
|
def _emit_decision(decision: BashDispatcherDecision) -> None:
|
|
213
210
|
"""Write one PreToolUse permission payload to stdout when an outcome exists."""
|
|
214
211
|
if not decision.decision:
|
|
@@ -251,7 +248,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
251
248
|
applicable_entries = select_applicable_entries(tool_name)
|
|
252
249
|
all_runs: list[HostedHookRun] = []
|
|
253
250
|
for each_entry in applicable_entries:
|
|
254
|
-
script_path =
|
|
251
|
+
script_path = resolved_hook_script_path(each_entry.script_relative_path)
|
|
255
252
|
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
256
253
|
all_runs.append(hook_run)
|
|
257
254
|
if _run_is_deny(hook_run):
|
|
@@ -264,17 +261,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
264
261
|
|
|
265
262
|
def main() -> None:
|
|
266
263
|
"""Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
|
|
267
|
-
|
|
268
|
-
if payload_dictionary is None:
|
|
269
|
-
sys.exit(0)
|
|
270
|
-
|
|
271
|
-
payload_text = json.dumps(payload_dictionary)
|
|
272
|
-
tool_name = payload_dictionary.get("tool_name", "")
|
|
273
|
-
if not isinstance(tool_name, str) or not tool_name:
|
|
274
|
-
sys.exit(0)
|
|
275
|
-
|
|
276
|
-
dispatch(payload_text, tool_name)
|
|
277
|
-
sys.exit(0)
|
|
264
|
+
run_dispatcher_main(dispatch)
|
|
278
265
|
|
|
279
266
|
|
|
280
267
|
if __name__ == "__main__":
|
|
@@ -492,6 +492,7 @@ def _total_reachable_statement_count(
|
|
|
492
492
|
|
|
493
493
|
def _helper_match_window_dumps(
|
|
494
494
|
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
495
|
+
dump_by_statement: dict[ast.stmt, str],
|
|
495
496
|
) -> list[str] | None:
|
|
496
497
|
"""Return the helper's substantive block as per-statement dumps, or None.
|
|
497
498
|
|
|
@@ -541,7 +542,10 @@ def _helper_match_window_dumps(
|
|
|
541
542
|
)
|
|
542
543
|
if not has_substantial_compound:
|
|
543
544
|
return None
|
|
544
|
-
return [
|
|
545
|
+
return [
|
|
546
|
+
_memoized_statement_dump(each_statement, dump_by_statement)
|
|
547
|
+
for each_statement in match_window
|
|
548
|
+
]
|
|
545
549
|
|
|
546
550
|
|
|
547
551
|
def check_same_file_inline_duplicate_body(
|
|
@@ -763,14 +767,14 @@ def _inline_duplicate_violation(
|
|
|
763
767
|
|
|
764
768
|
|
|
765
769
|
def _violations_for_qualifying_helpers(
|
|
766
|
-
window_by_function: dict[_ModuleScopeFunction, list[str]
|
|
770
|
+
window_by_function: dict[_ModuleScopeFunction, list[str]],
|
|
767
771
|
profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
|
|
768
772
|
maximum_issue_count: int,
|
|
769
773
|
) -> list[tuple[frozenset[int], str]]:
|
|
770
774
|
"""Walk the helpers in source order and report the first inliner of each.
|
|
771
775
|
|
|
772
776
|
Args:
|
|
773
|
-
window_by_function: Each function's helper match window
|
|
777
|
+
window_by_function: Each qualifying function's helper match window.
|
|
774
778
|
profile_by_function: Each function's readings, keyed in source order.
|
|
775
779
|
maximum_issue_count: The report ceiling the walk stops at.
|
|
776
780
|
|
|
@@ -779,8 +783,6 @@ def _violations_for_qualifying_helpers(
|
|
|
779
783
|
"""
|
|
780
784
|
all_violations_in_walk_order: list[tuple[frozenset[int], str]] = []
|
|
781
785
|
for each_helper, each_window_dumps in window_by_function.items():
|
|
782
|
-
if each_window_dumps is None:
|
|
783
|
-
continue
|
|
784
786
|
each_enclosing = _first_enclosing_inliner(
|
|
785
787
|
each_helper, each_window_dumps, profile_by_function
|
|
786
788
|
)
|
|
@@ -811,10 +813,14 @@ def _inline_duplicate_violations(
|
|
|
811
813
|
"""
|
|
812
814
|
dump_by_statement: dict[ast.stmt, str] = {}
|
|
813
815
|
window_by_function = {
|
|
814
|
-
each_function:
|
|
816
|
+
each_function: each_window
|
|
815
817
|
for each_function in all_top_level_functions
|
|
818
|
+
if (
|
|
819
|
+
each_window := _helper_match_window_dumps(each_function, dump_by_statement)
|
|
820
|
+
)
|
|
821
|
+
is not None
|
|
816
822
|
}
|
|
817
|
-
if
|
|
823
|
+
if not window_by_function:
|
|
818
824
|
return []
|
|
819
825
|
profile_by_function = {
|
|
820
826
|
each_function: _function_scan_profile(each_function, dump_by_statement)
|
|
@@ -14,6 +14,45 @@ from pathlib import Path
|
|
|
14
14
|
_ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def repository_root() -> Path:
|
|
18
|
+
"""Return the root of the checkout these tests run inside.
|
|
19
|
+
|
|
20
|
+
::
|
|
21
|
+
|
|
22
|
+
<checkout>/packages/claude-dev-env/hooks/blocking/test_x.py -> <checkout>
|
|
23
|
+
|
|
24
|
+
Found by walking up for a ``.git`` entry rather than by counting
|
|
25
|
+
directories, so moving a test file does not silently anchor it somewhere
|
|
26
|
+
else. Falls back to the four-levels-up ancestor when no marker is found,
|
|
27
|
+
which is where the checkout root sits for this directory.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The checkout root directory.
|
|
31
|
+
"""
|
|
32
|
+
all_ancestors = Path(__file__).resolve().parents
|
|
33
|
+
for each_ancestor in all_ancestors:
|
|
34
|
+
if (each_ancestor / ".git").exists():
|
|
35
|
+
return each_ancestor
|
|
36
|
+
return all_ancestors[3]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def repository_file_path(relative_path: str) -> str:
|
|
40
|
+
"""Return the absolute path of a repository-relative file in this checkout.
|
|
41
|
+
|
|
42
|
+
A hook resolves a bare relative path against the process working
|
|
43
|
+
directory, so a payload carrying one answers to wherever pytest was
|
|
44
|
+
invoked from. An absolute path under the checkout root keeps the
|
|
45
|
+
decision stable however the suite is launched.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
relative_path: The target file's path relative to the checkout root.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
That file's absolute path inside this checkout.
|
|
52
|
+
"""
|
|
53
|
+
return str(repository_root() / relative_path)
|
|
54
|
+
|
|
55
|
+
|
|
17
56
|
def build_write_payload(file_path: str, content: str) -> str:
|
|
18
57
|
"""Build the JSON payload sent by a Write tool call."""
|
|
19
58
|
return json.dumps(
|
|
@@ -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"
|