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
|
@@ -91,7 +91,9 @@ bare, quoted, or backslash-escaped (``<<\\EOF`` quotes the delimiter the way
|
|
|
91
91
|
``<<'EOF'`` does). ``<<<`` is a here-string rather than a heredoc, so the
|
|
92
92
|
pattern refuses to read one as an opener and leave the lines below it unread.
|
|
93
93
|
The opener also reports its own ``-``, because ``<<`` closes on a line spelling
|
|
94
|
-
the delimiter exactly while ``<<-`` closes on one carrying leading tabs.
|
|
94
|
+
the delimiter exactly while ``<<-`` closes on one carrying leading tabs. It
|
|
95
|
+
reports the quote and the backslash separately from the delimiter word, because
|
|
96
|
+
either one tells a caller that bash expands nothing inside the body below.
|
|
95
97
|
|
|
96
98
|
The string-executing shell basenames and command flags start from the shared
|
|
97
99
|
``unscoped_search_blocker_constants.py`` sets and add the Windows command shell
|
|
@@ -167,8 +169,10 @@ __all__ = [
|
|
|
167
169
|
"HEREDOC_OPENER_PATTERN",
|
|
168
170
|
"HEREDOC_TAB_STRIP_GROUP",
|
|
169
171
|
"HEREDOC_QUOTE_GROUP",
|
|
172
|
+
"HEREDOC_ESCAPE_GROUP",
|
|
170
173
|
"HEREDOC_TERMINATOR_GROUP",
|
|
171
174
|
"HEREDOC_TAB_STRIP_MARKER",
|
|
175
|
+
"HEREDOC_UNQUOTED_MARKER",
|
|
172
176
|
"HEREDOC_STRIPPED_INDENT_CHARACTERS",
|
|
173
177
|
"NO_FOLLOWING_OPERATOR",
|
|
174
178
|
"ALL_QUOTE_CHARACTERS",
|
|
@@ -340,13 +344,16 @@ CLOSED_GROUP_DEPTH = 0
|
|
|
340
344
|
_HEREDOC_DELIMITER_CHARACTER_CLASS = r"[^\s'\"<>|&;()`$\\]"
|
|
341
345
|
HEREDOC_TAB_STRIP_GROUP = "heredoc_tab_strip"
|
|
342
346
|
HEREDOC_QUOTE_GROUP = "heredoc_quote"
|
|
347
|
+
HEREDOC_ESCAPE_GROUP = "heredoc_escape"
|
|
343
348
|
HEREDOC_TERMINATOR_GROUP = "heredoc_terminator"
|
|
344
349
|
HEREDOC_OPENER_PATTERN = re.compile(
|
|
345
|
-
rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)
|
|
350
|
+
rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)"
|
|
351
|
+
rf"(?P<{HEREDOC_ESCAPE_GROUP}>\\?)"
|
|
346
352
|
rf"(?P<{HEREDOC_TERMINATOR_GROUP}>{_HEREDOC_DELIMITER_CHARACTER_CLASS}+)"
|
|
347
353
|
rf"(?P={HEREDOC_QUOTE_GROUP})"
|
|
348
354
|
)
|
|
349
355
|
HEREDOC_TAB_STRIP_MARKER = "-"
|
|
356
|
+
HEREDOC_UNQUOTED_MARKER = ""
|
|
350
357
|
HEREDOC_STRIPPED_INDENT_CHARACTERS = "\t"
|
|
351
358
|
NO_FOLLOWING_OPERATOR = ""
|
|
352
359
|
ALL_QUOTE_CHARACTERS = "\"'"
|
|
@@ -25,6 +25,11 @@ __all__ = [
|
|
|
25
25
|
"PROCESS_SUBSTITUTION_PATTERN",
|
|
26
26
|
"SINGLE_QUOTED_RUN_PATTERN",
|
|
27
27
|
"STRIPPED_RUN_REPLACEMENT",
|
|
28
|
+
"COMMAND_LINE_SEPARATOR",
|
|
29
|
+
"BACKSLASH_CHARACTER",
|
|
30
|
+
"SINGLE_QUOTE_CHARACTER",
|
|
31
|
+
"DOUBLE_QUOTE_CHARACTER",
|
|
32
|
+
"NO_TERMINATOR_INDEX",
|
|
28
33
|
"CORRECTIVE_MESSAGE",
|
|
29
34
|
]
|
|
30
35
|
|
|
@@ -49,6 +54,11 @@ EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
|
|
|
49
54
|
PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
|
|
50
55
|
SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
|
|
51
56
|
STRIPPED_RUN_REPLACEMENT = ""
|
|
57
|
+
COMMAND_LINE_SEPARATOR = "\n"
|
|
58
|
+
BACKSLASH_CHARACTER = "\\"
|
|
59
|
+
SINGLE_QUOTE_CHARACTER = "'"
|
|
60
|
+
DOUBLE_QUOTE_CHARACTER = '"'
|
|
61
|
+
NO_TERMINATOR_INDEX = -1
|
|
52
62
|
|
|
53
63
|
CORRECTIVE_MESSAGE = (
|
|
54
64
|
"BLOCKED [shell-substitution]: command contains $(...), backtick, or "
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Hold a best-effort exclusive lock across one read-modify-write of a state file.
|
|
2
|
+
|
|
3
|
+
Two hooks can reach the same state file in one turn. The file is written
|
|
4
|
+
through a temporary-plus-replace, so it is never torn; what this closes is
|
|
5
|
+
the separate lost-update race, where one writer reads before the other
|
|
6
|
+
writes and then overwrites it.
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
with hold_state_file_lock(state_file):
|
|
11
|
+
all_stored = read(state_file)
|
|
12
|
+
write(state_file, updated(all_stored))
|
|
13
|
+
|
|
14
|
+
The lock is best effort. A caller that cannot take it inside the timeout
|
|
15
|
+
proceeds without it rather than stalling the tool call that triggered it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
28
|
+
if _hooks_directory not in sys.path:
|
|
29
|
+
sys.path.insert(0, _hooks_directory)
|
|
30
|
+
|
|
31
|
+
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
32
|
+
LOCK_ACQUIRE_RETRY_SECONDS,
|
|
33
|
+
LOCK_ACQUIRE_TIMEOUT_SECONDS,
|
|
34
|
+
SESSION_EDIT_LOCK_FILE_SUFFIX,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _acquire_lock_descriptor(lock_file: Path) -> int | None:
|
|
39
|
+
"""Grab the exclusive lock, spinning until it frees or the timeout passes.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
lock_file: Path to the lock file beside the state file.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
An open descriptor for the held lock, or None when it stayed held
|
|
46
|
+
past the acquire timeout.
|
|
47
|
+
"""
|
|
48
|
+
lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
|
|
49
|
+
while True:
|
|
50
|
+
try:
|
|
51
|
+
return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
52
|
+
except FileExistsError:
|
|
53
|
+
if time.monotonic() >= lock_acquire_deadline:
|
|
54
|
+
return None
|
|
55
|
+
time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextlib.contextmanager
|
|
59
|
+
def hold_state_file_lock(state_file: Path) -> Iterator[None]:
|
|
60
|
+
"""Hold the lock beside one state file for the body of the with-block.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
state_file: The state file whose read-modify-write is being guarded.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
Control to the caller while the lock is held.
|
|
67
|
+
"""
|
|
68
|
+
lock_file = state_file.with_name(state_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
|
|
69
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
lock_descriptor = _acquire_lock_descriptor(lock_file)
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
if lock_descriptor is not None:
|
|
75
|
+
os.close(lock_descriptor)
|
|
76
|
+
lock_file.unlink(missing_ok=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Behavioral tests for the Bash PostToolUse dispatcher's hosted-hook roster."""
|
|
2
|
+
|
|
3
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
4
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
5
|
+
)
|
|
6
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_roster_names_the_gh_pr_author_restore_hook() -> None:
|
|
10
|
+
all_script_paths = [
|
|
11
|
+
each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
12
|
+
]
|
|
13
|
+
assert "blocking/gh_pr_author_restore.py" in all_script_paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_roster_names_the_test_failure_recorder_hook() -> None:
|
|
17
|
+
all_script_paths = [
|
|
18
|
+
each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
19
|
+
]
|
|
20
|
+
assert "observability/test_failure_recorder.py" in all_script_paths
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_every_roster_entry_applies_to_bash() -> None:
|
|
24
|
+
assert all(
|
|
25
|
+
BASH_TOOL_NAME in each_entry.applicable_tool_names
|
|
26
|
+
for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
27
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Constants for the failing-test-run PostToolUse recorder hook.
|
|
2
|
+
|
|
3
|
+
Holds the cheap substring check the hook runs before any shell parsing, the
|
|
4
|
+
harness's own exit-code error prefix, the chaining and redirection operator
|
|
5
|
+
tokens that keep the recognizer to one unchained command, the pytest program
|
|
6
|
+
and Python interpreter basenames, and the flag-token marker the path
|
|
7
|
+
extractor uses to skip an option rather than an existing test file argument.
|
|
8
|
+
|
|
9
|
+
``BASH_TOOL_NAME`` lives in ``gh_pr_author_swap_constants`` already; this
|
|
10
|
+
module reuses that name rather than a second literal. The pytest-program and
|
|
11
|
+
interpreter recognition here is deliberately its own small, stdlib-only
|
|
12
|
+
check rather than a reuse of ``piped_pytest_blocker_constants`` /
|
|
13
|
+
``pytest_invocation``: those solve the harder pipe-and-wrapper problem and
|
|
14
|
+
pull in a much larger transitive import for every Bash call regardless of
|
|
15
|
+
content, working against the point of the recognizer's own cheap prefilter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
ALL_PYTEST_MENTION_SUBSTRINGS: tuple[str, ...] = ("pytest", "py.test")
|
|
23
|
+
EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
|
|
24
|
+
OPTION_TOKEN_PREFIX: str = "-"
|
|
25
|
+
NODE_ID_FILE_PATH_SEPARATOR: str = "::"
|
|
26
|
+
ALL_CHAINING_OPERATOR_SUBSTRINGS: tuple[str, ...] = ("&&", "||", ";", "|", "\n")
|
|
27
|
+
ALL_REDIRECTION_TOKENS: frozenset[str] = frozenset(
|
|
28
|
+
{">", ">>", "<", "<<", "2>&1", "1>&2", "2>", "1>"}
|
|
29
|
+
)
|
|
30
|
+
ALL_PYTEST_PROGRAM_BASENAMES: frozenset[str] = frozenset({"pytest", "py.test"})
|
|
31
|
+
MODULE_RUN_FLAG: str = "-m"
|
|
32
|
+
PYTEST_MODULE_NAME: str = "pytest"
|
|
33
|
+
PYTHON_INTERPRETER_BASENAME_PATTERN: re.Pattern[str] = re.compile(
|
|
34
|
+
r"^(?:python|py|pypy)[0-9.]*(?:\.exe)?$"
|
|
35
|
+
)
|
|
@@ -7,15 +7,22 @@ and the restoration of the interpreter's stdin, stdout, and argv.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import io
|
|
10
11
|
import json
|
|
11
12
|
import sys
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
14
17
|
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
|
|
15
18
|
if _HOOKS_DIR not in sys.path:
|
|
16
19
|
sys.path.insert(0, _HOOKS_DIR)
|
|
17
20
|
|
|
18
|
-
from hooks_constants.hosted_hook_runner import
|
|
21
|
+
from hooks_constants.hosted_hook_runner import ( # noqa: E402
|
|
22
|
+
HostedHookRun,
|
|
23
|
+
run_dispatcher_main,
|
|
24
|
+
run_hook_capturing_output,
|
|
25
|
+
)
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def _write_probe(tmp_path: Path, body: str) -> str:
|
|
@@ -99,3 +106,50 @@ def test_returns_a_hosted_hook_run_instance(tmp_path: Path) -> None:
|
|
|
99
106
|
probe_path = _write_probe(tmp_path, "print('x')\n")
|
|
100
107
|
hook_run = run_hook_capturing_output(probe_path, "{}")
|
|
101
108
|
assert isinstance(hook_run, HostedHookRun)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_run_dispatcher_main_calls_dispatch_with_payload_text_and_tool_name(
|
|
112
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""A well-formed payload reaches dispatch with its JSON text and tool_name."""
|
|
115
|
+
all_calls: list[tuple[str, str]] = []
|
|
116
|
+
monkeypatch.setattr(
|
|
117
|
+
sys, "stdin", io.StringIO(json.dumps({"tool_name": "Bash", "tool_input": {}}))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
121
|
+
run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
|
|
122
|
+
|
|
123
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
124
|
+
assert len(all_calls) == 1
|
|
125
|
+
recorded_payload_text, recorded_tool_name = all_calls[0]
|
|
126
|
+
assert recorded_tool_name == "Bash"
|
|
127
|
+
assert json.loads(recorded_payload_text)["tool_name"] == "Bash"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_run_dispatcher_main_skips_dispatch_for_malformed_stdin(
|
|
131
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Unparseable stdin exits zero and never calls dispatch."""
|
|
134
|
+
all_calls: list[tuple[str, str]] = []
|
|
135
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
136
|
+
|
|
137
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
138
|
+
run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
|
|
139
|
+
|
|
140
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
141
|
+
assert all_calls == []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_run_dispatcher_main_skips_dispatch_when_tool_name_is_missing(
|
|
145
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""A payload with no tool_name exits zero and never calls dispatch."""
|
|
148
|
+
all_calls: list[tuple[str, str]] = []
|
|
149
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"tool_input": {}})))
|
|
150
|
+
|
|
151
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
152
|
+
run_dispatcher_main(lambda payload_text, tool_name: all_calls.append((payload_text, tool_name)))
|
|
153
|
+
|
|
154
|
+
assert int(raised_exit.value.code or 0) == 0
|
|
155
|
+
assert all_calls == []
|
|
@@ -18,13 +18,10 @@ never interrupts the edit that triggered it.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import contextlib
|
|
22
21
|
import json
|
|
23
22
|
import os
|
|
24
23
|
import sys
|
|
25
24
|
import tempfile
|
|
26
|
-
import time
|
|
27
|
-
from collections.abc import Iterator
|
|
28
25
|
from pathlib import Path
|
|
29
26
|
|
|
30
27
|
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
@@ -35,15 +32,13 @@ from blocking.codex_apply_patch import payload_patch_target_paths # noqa: E402
|
|
|
35
32
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
36
33
|
read_hook_input_dictionary_from_stdin,
|
|
37
34
|
)
|
|
35
|
+
from hooks_constants.state_file_lock import hold_state_file_lock # noqa: E402
|
|
38
36
|
from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
|
|
39
37
|
ALL_EDITED_FILE_PATHS_KEY,
|
|
40
38
|
ALL_TRACKED_EDIT_TOOL_NAMES,
|
|
41
39
|
APPLY_PATCH_TOOL_NAME,
|
|
42
|
-
LOCK_ACQUIRE_RETRY_SECONDS,
|
|
43
|
-
LOCK_ACQUIRE_TIMEOUT_SECONDS,
|
|
44
40
|
SESSION_EDIT_FILE_PREFIX,
|
|
45
41
|
SESSION_EDIT_FILE_SUFFIX,
|
|
46
|
-
SESSION_EDIT_LOCK_FILE_SUFFIX,
|
|
47
42
|
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
48
43
|
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
49
44
|
STATE_FILE_DEFAULT_SESSION_ID,
|
|
@@ -140,54 +135,6 @@ def _atomic_write_edit_file(edit_file: Path, all_edited_file_paths: list[str]) -
|
|
|
140
135
|
raise
|
|
141
136
|
|
|
142
137
|
|
|
143
|
-
def _acquire_edit_file_lock(lock_file: Path) -> int | None:
|
|
144
|
-
"""Grab an exclusive per-session lock, spinning until it frees or times out.
|
|
145
|
-
|
|
146
|
-
Args:
|
|
147
|
-
lock_file: Path to this session's lock file.
|
|
148
|
-
|
|
149
|
-
Returns:
|
|
150
|
-
An open file descriptor for the held lock, or None when the lock stayed
|
|
151
|
-
held past the acquire timeout so the caller proceeds without it rather
|
|
152
|
-
than stalling the edit that triggered the hook.
|
|
153
|
-
"""
|
|
154
|
-
lock_acquire_deadline = time.monotonic() + LOCK_ACQUIRE_TIMEOUT_SECONDS
|
|
155
|
-
while True:
|
|
156
|
-
try:
|
|
157
|
-
return os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
158
|
-
except FileExistsError:
|
|
159
|
-
if time.monotonic() >= lock_acquire_deadline:
|
|
160
|
-
return None
|
|
161
|
-
time.sleep(LOCK_ACQUIRE_RETRY_SECONDS)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@contextlib.contextmanager
|
|
165
|
-
def _hold_edit_file_lock(edit_file: Path) -> Iterator[None]:
|
|
166
|
-
"""Hold the per-session lock across one read-modify-write of the tracker.
|
|
167
|
-
|
|
168
|
-
Two PostToolUse invocations run concurrently when Claude issues parallel
|
|
169
|
-
Write or Edit calls in one turn. Serializing the read-then-write on a shared
|
|
170
|
-
lock file keeps each invocation's appended path from overwriting another's.
|
|
171
|
-
The lock is best-effort: when it cannot be acquired within the timeout, the
|
|
172
|
-
write proceeds without it rather than stalling the edit.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
edit_file: This session's tracker file path.
|
|
176
|
-
|
|
177
|
-
Yields:
|
|
178
|
-
Control to the caller while the lock is held.
|
|
179
|
-
"""
|
|
180
|
-
lock_file = edit_file.with_name(edit_file.name + SESSION_EDIT_LOCK_FILE_SUFFIX)
|
|
181
|
-
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
-
lock_descriptor = _acquire_edit_file_lock(lock_file)
|
|
183
|
-
try:
|
|
184
|
-
yield
|
|
185
|
-
finally:
|
|
186
|
-
if lock_descriptor is not None:
|
|
187
|
-
os.close(lock_descriptor)
|
|
188
|
-
lock_file.unlink(missing_ok=True)
|
|
189
|
-
|
|
190
|
-
|
|
191
138
|
def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
|
|
192
139
|
"""Append one edited path to this session's tracker file when it is new.
|
|
193
140
|
|
|
@@ -200,7 +147,7 @@ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
|
|
|
200
147
|
resolved_file_path: The resolved absolute path of the edited file.
|
|
201
148
|
"""
|
|
202
149
|
edit_file = _session_edit_file_path(session_id)
|
|
203
|
-
with
|
|
150
|
+
with hold_state_file_lock(edit_file):
|
|
204
151
|
recorded_paths = _read_recorded_paths(edit_file)
|
|
205
152
|
if resolved_file_path in recorded_paths:
|
|
206
153
|
return
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook: record a failing single pytest run as TDD-gate evidence.
|
|
3
|
+
|
|
4
|
+
A file-content edit is not the same claim as a test that ran and failed. The
|
|
5
|
+
content-hash store in ``tdd_enforcer_parts/content_hash_store.py`` already
|
|
6
|
+
tracks whether a candidate test file's content changed; this hook adds a
|
|
7
|
+
stronger form of evidence -- a real RED -- by watching every Bash call finish
|
|
8
|
+
and, for a genuine failing pytest run, recording the command, its real exit
|
|
9
|
+
status, and the test file paths it named into that same store.
|
|
10
|
+
|
|
11
|
+
::
|
|
12
|
+
|
|
13
|
+
pytest test_orders.py (exit 1) -> record test_orders.py
|
|
14
|
+
pytest test_orders.py -k fulfill (exit 1) -> record test_orders.py
|
|
15
|
+
pytest test_orders.py::test_fulfill (exit 1) -> record test_orders.py
|
|
16
|
+
pytest -q (exit 1) -> no path named, no record
|
|
17
|
+
cd repo && pytest test_orders.py (exit 1) -> chained, no record
|
|
18
|
+
pytest test_orders.py | tee run.log (blocked by piped_pytest_blocker
|
|
19
|
+
first -- this hook never sees it)
|
|
20
|
+
npm test (exit 1) -> not pytest, no record
|
|
21
|
+
pytest test_orders.py (exit 0) -> passed, no record
|
|
22
|
+
|
|
23
|
+
What this hook matches, and why
|
|
24
|
+
--------------------------------
|
|
25
|
+
|
|
26
|
+
Only a single, unchained Bash command whose own program is ``pytest`` /
|
|
27
|
+
``py.test``, or a Python interpreter running ``-m pytest``. A chained command
|
|
28
|
+
(``&&``, ``;``, ``|``, ``||``, a newline) is rejected outright: this repo's own
|
|
29
|
+
``piped_pytest_blocker.py`` already tells the caller to "run pytest alone" for
|
|
30
|
+
exactly this reason, and a chain's reported exit status may belong to a
|
|
31
|
+
command other than pytest, or to none of them if an earlier link short-
|
|
32
|
+
circuited before pytest even ran. ``npm test``, ``node --test``, and
|
|
33
|
+
``vitest`` are deliberately not matched: ``npm test`` is a script indirection
|
|
34
|
+
whose real test runner lives in ``package.json``, not on the command line,
|
|
35
|
+
and the other two have no existing, tested classification helper in this
|
|
36
|
+
repository the way pytest does. Extending to them would mean inventing new
|
|
37
|
+
parsing rather than reusing anything proven, which is the over-reach this
|
|
38
|
+
mechanism is built to avoid.
|
|
39
|
+
|
|
40
|
+
The recognizer here is deliberately its own small, stdlib-only ``shlex``
|
|
41
|
+
check rather than a reuse of ``hooks_constants.pytest_invocation`` /
|
|
42
|
+
``shell_command_pipeline``. Those solve the harder pipe-and-wrapper problem a
|
|
43
|
+
PreToolUse blocker needs, and this repository's own style rule forbids an
|
|
44
|
+
import inside a function body, so importing that larger machinery here would
|
|
45
|
+
mean paying its full transitive import cost on every single Bash call this
|
|
46
|
+
hook sees, pytest or not -- working directly against the point of watching
|
|
47
|
+
every Bash call. The narrower, single-unchained-command scope this hook
|
|
48
|
+
accepts does not need that machinery.
|
|
49
|
+
|
|
50
|
+
Where the exit status comes from
|
|
51
|
+
---------------------------------
|
|
52
|
+
|
|
53
|
+
Not from scraping the command's output text for words like "failed" or
|
|
54
|
+
"error". The Bash tool's own PostToolUse ``tool_response`` takes two shapes
|
|
55
|
+
observed directly in this session's own transcript: a dict with ``stdout``/
|
|
56
|
+
``stderr`` keys when the command's reported exit status is 0, and the literal
|
|
57
|
+
string ``"Error: Exit code <N>\\n<output>"`` when it is not. This hook reads
|
|
58
|
+
that harness-authored ``<N>`` directly; it is a real Bash mechanism doing the
|
|
59
|
+
work correctly, not a lookalike that only mimics one.
|
|
60
|
+
|
|
61
|
+
Where the test paths come from
|
|
62
|
+
--------------------------------
|
|
63
|
+
|
|
64
|
+
The command's own arguments, not its output. A bare ``pytest`` or ``pytest
|
|
65
|
+
-q`` names no path and is left unrecorded on purpose: recording it against
|
|
66
|
+
every test file pytest might have discovered would satisfy the gate for a
|
|
67
|
+
file this run never demonstrably covered, which is the exact bypass the
|
|
68
|
+
audit calls out. Only a token that is not an option flag and resolves to a
|
|
69
|
+
real file on disk, relative to the command's own ``cwd``, counts.
|
|
70
|
+
|
|
71
|
+
What stops a bypass
|
|
72
|
+
---------------------
|
|
73
|
+
|
|
74
|
+
Three things, together:
|
|
75
|
+
|
|
76
|
+
1. ``content_hash_store.record_test_command_failure`` keys each entry
|
|
77
|
+
exactly as the gate looks candidates up, so a failure never satisfies a
|
|
78
|
+
sibling file the same command also happened to run (see
|
|
79
|
+
``test_content_hash_store.py::test_recorded_failure_does_not_satisfy_a_different_candidate``).
|
|
80
|
+
2. The gate consults a recorded failure only while the file's *current*
|
|
81
|
+
content hash still matches what actually failed (see
|
|
82
|
+
``test_recorded_failure_does_not_apply_once_content_no_longer_matches``).
|
|
83
|
+
Editing the test again clears it as evidence.
|
|
84
|
+
3. A record ages out after the same freshness window every other form of
|
|
85
|
+
evidence uses (see ``test_recorded_failure_expires_after_the_freshness_window``).
|
|
86
|
+
|
|
87
|
+
The hook never blocks a tool call. A non-Bash tool, a malformed payload, a
|
|
88
|
+
command that does not mention "pytest", a chained command, a command with no
|
|
89
|
+
real path argument, or a failed record each returns quietly.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
from __future__ import annotations
|
|
93
|
+
|
|
94
|
+
import shlex
|
|
95
|
+
import sys
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
100
|
+
_blocking_directory = str(Path(_hooks_root_directory) / "blocking")
|
|
101
|
+
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
102
|
+
if each_bootstrap_directory not in sys.path:
|
|
103
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
104
|
+
from hooks_constants.gh_pr_author_swap_constants import BASH_TOOL_NAME
|
|
105
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
106
|
+
from hooks_constants.test_failure_recorder_constants import (
|
|
107
|
+
ALL_CHAINING_OPERATOR_SUBSTRINGS,
|
|
108
|
+
ALL_PYTEST_MENTION_SUBSTRINGS,
|
|
109
|
+
ALL_PYTEST_PROGRAM_BASENAMES,
|
|
110
|
+
ALL_REDIRECTION_TOKENS,
|
|
111
|
+
EXIT_CODE_ERROR_PREFIX,
|
|
112
|
+
MODULE_RUN_FLAG,
|
|
113
|
+
NODE_ID_FILE_PATH_SEPARATOR,
|
|
114
|
+
OPTION_TOKEN_PREFIX,
|
|
115
|
+
PYTEST_MODULE_NAME,
|
|
116
|
+
PYTHON_INTERPRETER_BASENAME_PATTERN,
|
|
117
|
+
)
|
|
118
|
+
from tdd_enforcer_parts import content_hash_store
|
|
119
|
+
except ImportError as import_error:
|
|
120
|
+
raise ImportError(
|
|
121
|
+
"test_failure_recorder: cannot import its dependencies; "
|
|
122
|
+
"ensure the hooks directory is importable."
|
|
123
|
+
) from import_error
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mentions_pytest(command: str) -> bool:
|
|
127
|
+
lowered_command = command.lower()
|
|
128
|
+
return any(
|
|
129
|
+
each_substring in lowered_command for each_substring in ALL_PYTEST_MENTION_SUBSTRINGS
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _harness_reported_exit_status(tool_response: object) -> int | None:
|
|
134
|
+
if not isinstance(tool_response, str) or not tool_response.startswith(EXIT_CODE_ERROR_PREFIX):
|
|
135
|
+
return None
|
|
136
|
+
remainder = tool_response[len(EXIT_CODE_ERROR_PREFIX) :]
|
|
137
|
+
digit_count = 0
|
|
138
|
+
while digit_count < len(remainder) and remainder[digit_count].isdigit():
|
|
139
|
+
digit_count += 1
|
|
140
|
+
return int(remainder[:digit_count]) if digit_count else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_single_unchained_command(command: str) -> bool:
|
|
144
|
+
return not any(each_operator in command for each_operator in ALL_CHAINING_OPERATOR_SUBSTRINGS)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _runs_pytest_via_module_flag(all_tokens: list[str]) -> bool:
|
|
148
|
+
if MODULE_RUN_FLAG not in all_tokens:
|
|
149
|
+
return False
|
|
150
|
+
flag_index = all_tokens.index(MODULE_RUN_FLAG)
|
|
151
|
+
return flag_index + 1 < len(all_tokens) and all_tokens[flag_index + 1] == PYTEST_MODULE_NAME
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _command_tokens_run_pytest(all_tokens: list[str]) -> bool:
|
|
155
|
+
if not all_tokens:
|
|
156
|
+
return False
|
|
157
|
+
first_basename = Path(all_tokens[0]).name
|
|
158
|
+
if first_basename in ALL_PYTEST_PROGRAM_BASENAMES:
|
|
159
|
+
return True
|
|
160
|
+
if not PYTHON_INTERPRETER_BASENAME_PATTERN.match(first_basename):
|
|
161
|
+
return False
|
|
162
|
+
return _runs_pytest_via_module_flag(all_tokens)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _existing_test_file_paths(all_tokens: list[str], cwd: str) -> list[Path]:
|
|
166
|
+
all_paths: list[Path] = []
|
|
167
|
+
for each_token in all_tokens:
|
|
168
|
+
if each_token.startswith(OPTION_TOKEN_PREFIX) or each_token in ALL_REDIRECTION_TOKENS:
|
|
169
|
+
continue
|
|
170
|
+
file_path_text = each_token.split(NODE_ID_FILE_PATH_SEPARATOR, 1)[0]
|
|
171
|
+
candidate_path = Path(cwd, file_path_text) if cwd else Path(file_path_text)
|
|
172
|
+
if candidate_path.is_file():
|
|
173
|
+
all_paths.append(candidate_path)
|
|
174
|
+
return all_paths
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _command_tokens(command: str) -> list[str]:
|
|
178
|
+
try:
|
|
179
|
+
return shlex.split(command)
|
|
180
|
+
except ValueError:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _record_pytest_failure(hook_payload: dict, command: str) -> None:
|
|
185
|
+
exit_status = _harness_reported_exit_status(hook_payload.get("tool_response"))
|
|
186
|
+
if exit_status is None or not _is_single_unchained_command(command):
|
|
187
|
+
return
|
|
188
|
+
all_tokens = _command_tokens(command)
|
|
189
|
+
if not _command_tokens_run_pytest(all_tokens):
|
|
190
|
+
return
|
|
191
|
+
cwd = str(hook_payload.get("cwd") or "")
|
|
192
|
+
all_test_file_paths = _existing_test_file_paths(all_tokens, cwd)
|
|
193
|
+
if not all_test_file_paths:
|
|
194
|
+
return
|
|
195
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
196
|
+
content_hash_store.record_test_command_failure(
|
|
197
|
+
all_test_file_paths, command, exit_status, session_id, cwd
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main() -> None:
|
|
202
|
+
"""Record one failing pytest run's evidence from a PostToolUse Bash payload.
|
|
203
|
+
|
|
204
|
+
Reads the payload from stdin. Returns quietly on every branch that is not
|
|
205
|
+
a genuine, single, unchained, path-naming pytest failure, so a logging
|
|
206
|
+
problem never surfaces as a blocked or altered tool call.
|
|
207
|
+
"""
|
|
208
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
209
|
+
if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
|
|
210
|
+
return
|
|
211
|
+
tool_input = hook_payload.get("tool_input")
|
|
212
|
+
command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
|
|
213
|
+
if not isinstance(command, str) or not _mentions_pytest(command):
|
|
214
|
+
return
|
|
215
|
+
_record_pytest_failure(hook_payload, command)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|