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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Behavior tests for the test_failure_recorder PostToolUse hook.
|
|
2
|
+
|
|
3
|
+
Drives the hook's main through real stdin payloads shaped like a genuine
|
|
4
|
+
Bash PostToolUse call, then reads the shared content-hash store back to
|
|
5
|
+
confirm what -- if anything -- it recorded. The store's real state file
|
|
6
|
+
under the OS temp directory is read directly, the same way
|
|
7
|
+
test_content_hash_store.py and test_tdd_enforcer.py already do, rather than
|
|
8
|
+
mocking tempfile.gettempdir. ``hooks/conftest.py`` already puts the hooks
|
|
9
|
+
and blocking directories on sys.path for test collection, so this file needs
|
|
10
|
+
no bootstrap of its own before importing content_hash_store.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
from tdd_enforcer_parts import content_hash_store
|
|
21
|
+
|
|
22
|
+
_HOOK_DIRECTORY = Path(__file__).parent
|
|
23
|
+
_hook_spec = importlib.util.spec_from_file_location(
|
|
24
|
+
"test_failure_recorder_under_test", _HOOK_DIRECTORY / "test_failure_recorder.py"
|
|
25
|
+
)
|
|
26
|
+
assert _hook_spec is not None
|
|
27
|
+
assert _hook_spec.loader is not None
|
|
28
|
+
_hook_module = importlib.util.module_from_spec(_hook_spec)
|
|
29
|
+
_hook_spec.loader.exec_module(_hook_module)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_hook_with_payload(payload: dict) -> None:
|
|
33
|
+
with (
|
|
34
|
+
mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
|
|
35
|
+
contextlib.suppress(SystemExit),
|
|
36
|
+
):
|
|
37
|
+
_hook_module.main()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _recorded_failure_entry(test_file: Path, session_id: str, repository_root: str) -> dict | None:
|
|
41
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
42
|
+
if not state_file.exists():
|
|
43
|
+
return None
|
|
44
|
+
state = json.loads(state_file.read_text())
|
|
45
|
+
return state.get(content_hash_store._state_key_for(test_file))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bash_payload(command: str, tool_response: object, cwd: Path, session_id: str) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"cwd": str(cwd),
|
|
52
|
+
"tool_name": "Bash",
|
|
53
|
+
"tool_input": {"command": command},
|
|
54
|
+
"tool_response": tool_response,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_FAILING_OUTPUT = (
|
|
59
|
+
"Error: Exit code 1\n"
|
|
60
|
+
"F [100%]\n"
|
|
61
|
+
"=================================== FAILURES ===================================\n"
|
|
62
|
+
"FAILED test_orders.py::test_fulfill - AssertionError\n"
|
|
63
|
+
"1 failed in 0.02s"
|
|
64
|
+
)
|
|
65
|
+
_PASSING_OUTPUT = {"stdout": "1 passed in 0.02s", "stderr": "", "interrupted": False}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_records_a_failure_for_a_bare_pytest_command_naming_a_real_file(
|
|
69
|
+
tmp_path: Path,
|
|
70
|
+
) -> None:
|
|
71
|
+
test_file = tmp_path / "test_orders.py"
|
|
72
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
73
|
+
session_id = "recorder-session-1"
|
|
74
|
+
|
|
75
|
+
_run_hook_with_payload(
|
|
76
|
+
_bash_payload("pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
entry = _recorded_failure_entry(test_file, session_id, str(tmp_path))
|
|
80
|
+
assert entry is not None
|
|
81
|
+
assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
|
|
82
|
+
assert entry[content_hash_store.STORED_FAILURE_COMMAND_KEY] == "pytest test_orders.py"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_does_not_record_for_a_passing_command(tmp_path: Path) -> None:
|
|
86
|
+
test_file = tmp_path / "test_orders.py"
|
|
87
|
+
test_file.write_text("def test_fulfill(): pass\n")
|
|
88
|
+
session_id = "recorder-session-2"
|
|
89
|
+
|
|
90
|
+
_run_hook_with_payload(
|
|
91
|
+
_bash_payload("pytest test_orders.py", _PASSING_OUTPUT, tmp_path, session_id)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_does_not_record_for_a_non_pytest_command(tmp_path: Path) -> None:
|
|
98
|
+
test_file = tmp_path / "test_orders.py"
|
|
99
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
100
|
+
session_id = "recorder-session-3"
|
|
101
|
+
|
|
102
|
+
_run_hook_with_payload(
|
|
103
|
+
_bash_payload("npm test", "Error: Exit code 1\n1 failing", tmp_path, session_id)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_does_not_record_for_a_chained_command(tmp_path: Path) -> None:
|
|
110
|
+
test_file = tmp_path / "test_orders.py"
|
|
111
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
112
|
+
session_id = "recorder-session-4"
|
|
113
|
+
|
|
114
|
+
_run_hook_with_payload(
|
|
115
|
+
_bash_payload("cd /repo && pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_does_not_record_for_a_bare_pytest_run_with_no_path_argument(
|
|
122
|
+
tmp_path: Path,
|
|
123
|
+
) -> None:
|
|
124
|
+
test_file = tmp_path / "test_orders.py"
|
|
125
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
126
|
+
session_id = "recorder-session-5"
|
|
127
|
+
|
|
128
|
+
_run_hook_with_payload(_bash_payload("pytest -q", _FAILING_OUTPUT, tmp_path, session_id))
|
|
129
|
+
|
|
130
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_records_only_the_named_file_not_an_option_or_expression_token(
|
|
134
|
+
tmp_path: Path,
|
|
135
|
+
) -> None:
|
|
136
|
+
test_file = tmp_path / "test_orders.py"
|
|
137
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
138
|
+
session_id = "recorder-session-6"
|
|
139
|
+
|
|
140
|
+
_run_hook_with_payload(
|
|
141
|
+
_bash_payload("pytest test_orders.py -k fulfill", _FAILING_OUTPUT, tmp_path, session_id)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
entry = _recorded_failure_entry(test_file, session_id, str(tmp_path))
|
|
145
|
+
assert entry is not None
|
|
146
|
+
state_file = content_hash_store._state_file_path(session_id, str(tmp_path))
|
|
147
|
+
state = json.loads(state_file.read_text())
|
|
148
|
+
assert len(state) == 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_ignores_a_tool_other_than_bash(tmp_path: Path) -> None:
|
|
152
|
+
test_file = tmp_path / "test_orders.py"
|
|
153
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
154
|
+
session_id = "recorder-session-7"
|
|
155
|
+
payload = _bash_payload("pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
156
|
+
payload["tool_name"] = "Write"
|
|
157
|
+
|
|
158
|
+
_run_hook_with_payload(payload)
|
|
159
|
+
|
|
160
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_does_not_record_when_the_named_file_does_not_exist_on_disk(
|
|
164
|
+
tmp_path: Path,
|
|
165
|
+
) -> None:
|
|
166
|
+
session_id = "recorder-session-8"
|
|
167
|
+
missing_file = tmp_path / "test_missing.py"
|
|
168
|
+
|
|
169
|
+
_run_hook_with_payload(
|
|
170
|
+
_bash_payload("pytest test_missing.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
assert _recorded_failure_entry(missing_file, session_id, str(tmp_path)) is None
|
|
@@ -38,15 +38,6 @@ ALL_PYTEST_SANITIZED_TEST_NAME_CHARACTERS: frozenset[str] = frozenset(
|
|
|
38
38
|
LOWERCASE_ASCII_LETTER_CHARACTERS + DECIMAL_DIGIT_CHARACTERS + UNDERSCORE_CHARACTER
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
-
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
42
|
-
{
|
|
43
|
-
"TEMP",
|
|
44
|
-
"TMP",
|
|
45
|
-
"TMPDIR",
|
|
46
|
-
"RUNNER_TEMP",
|
|
47
|
-
}
|
|
48
|
-
)
|
|
49
|
-
|
|
50
41
|
ALL_AUTHORITATIVE_DIRECTORY_PATH_PATTERN_SETS: tuple[Iterable[str], ...] = (
|
|
51
42
|
TEST_PATH_PATTERNS,
|
|
52
43
|
WORKFLOW_REGISTRY_PATTERNS,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Constants naming where the system keeps temporary directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# A run reaches its temp root through any of these, so a walk that stops at
|
|
6
|
+
# that boundary checks all four rather than gettempdir() alone. RUNNER_TEMP
|
|
7
|
+
# is the hosted-CI case: pytest writes there while gettempdir() stays /tmp.
|
|
8
|
+
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"TEMP",
|
|
11
|
+
"TMP",
|
|
12
|
+
"TMPDIR",
|
|
13
|
+
"RUNNER_TEMP",
|
|
14
|
+
}
|
|
15
|
+
)
|
|
@@ -13,28 +13,26 @@ _validators_directory = str(Path(__file__).resolve().parent)
|
|
|
13
13
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
14
14
|
|
|
15
15
|
try:
|
|
16
|
+
from project_roots import enclosing_project_root
|
|
16
17
|
from pyproject_config_discovery import (
|
|
17
|
-
ancestor_directories,
|
|
18
|
+
ancestor_directories, # noqa: F401 re-exported for validation/mypy_validator
|
|
18
19
|
find_pyproject_configuring_tool,
|
|
19
20
|
)
|
|
20
|
-
from system_temporary_roots import enclosing_system_temporary_root
|
|
21
21
|
except ModuleNotFoundError:
|
|
22
22
|
if _validators_directory not in sys.path:
|
|
23
23
|
sys.path.insert(0, _validators_directory)
|
|
24
|
+
from project_roots import enclosing_project_root
|
|
24
25
|
from pyproject_config_discovery import (
|
|
25
|
-
ancestor_directories,
|
|
26
|
+
ancestor_directories, # noqa: F401 re-exported for validation/mypy_validator
|
|
26
27
|
find_pyproject_configuring_tool,
|
|
27
28
|
)
|
|
28
|
-
from system_temporary_roots import enclosing_system_temporary_root
|
|
29
29
|
|
|
30
30
|
try:
|
|
31
31
|
from hooks_constants.mypy_integration_constants import (
|
|
32
32
|
FOLLOW_IMPORTS_FLAG,
|
|
33
33
|
FOLLOW_IMPORTS_SKIP_VALUE,
|
|
34
|
-
GIT_DIRECTORY_NAME,
|
|
35
34
|
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS,
|
|
36
35
|
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE,
|
|
37
|
-
PYPROJECT_FILENAME,
|
|
38
36
|
PYTHON_SOURCE_SUFFIX,
|
|
39
37
|
)
|
|
40
38
|
from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
|
|
@@ -44,10 +42,8 @@ except ModuleNotFoundError:
|
|
|
44
42
|
from hooks_constants.mypy_integration_constants import (
|
|
45
43
|
FOLLOW_IMPORTS_FLAG,
|
|
46
44
|
FOLLOW_IMPORTS_SKIP_VALUE,
|
|
47
|
-
GIT_DIRECTORY_NAME,
|
|
48
45
|
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS,
|
|
49
46
|
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE,
|
|
50
|
-
PYPROJECT_FILENAME,
|
|
51
47
|
PYTHON_SOURCE_SUFFIX,
|
|
52
48
|
)
|
|
53
49
|
from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
|
|
@@ -97,41 +93,19 @@ def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
|
|
|
97
93
|
|
|
98
94
|
|
|
99
95
|
def find_module_resolution_root(starting_file: Path) -> Path | None:
|
|
100
|
-
"""Return the
|
|
96
|
+
"""Return the project root mypy resolves this file's first-party imports against.
|
|
101
97
|
|
|
102
|
-
|
|
103
|
-
``
|
|
104
|
-
|
|
105
|
-
project and keeps a foreign ``config`` in the caller's directory out of scope.
|
|
106
|
-
|
|
107
|
-
The walk does not climb out of the system temp directory. A PreToolUse
|
|
108
|
-
staging copy lives under ``%TEMP%``, and a ``.git`` in the user home above
|
|
109
|
-
that temp root is not this file's project.
|
|
110
|
-
|
|
111
|
-
::
|
|
112
|
-
|
|
113
|
-
target_repo/.git + target_repo/tools/x.py -> target_repo
|
|
114
|
-
/tmp/detached/x.py (home .git above temp) -> None
|
|
115
|
-
flag: walk past %TEMP% into ~/.git -> mypy cwd=home, hook timeout
|
|
98
|
+
Mypy resolves a first-party import against its working directory, so
|
|
99
|
+
anchoring there binds ``config.*`` to the target file's own project and
|
|
100
|
+
keeps a foreign ``config`` in the caller's directory out of scope.
|
|
116
101
|
|
|
117
102
|
Args:
|
|
118
103
|
starting_file: The file (or directory) the walk begins from.
|
|
119
104
|
|
|
120
105
|
Returns:
|
|
121
|
-
The
|
|
122
|
-
or ``None`` when no such ancestor exists inside the walk limit.
|
|
106
|
+
The project root, or None when the file sits inside none.
|
|
123
107
|
"""
|
|
124
|
-
|
|
125
|
-
pyproject_filename = PYPROJECT_FILENAME
|
|
126
|
-
enclosing_temporary_root = enclosing_system_temporary_root(starting_file)
|
|
127
|
-
for each_candidate_directory in ancestor_directories(starting_file):
|
|
128
|
-
has_git_entry = (each_candidate_directory / git_entry_name).exists()
|
|
129
|
-
has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
|
|
130
|
-
if has_git_entry or has_pyproject:
|
|
131
|
-
return each_candidate_directory
|
|
132
|
-
if each_candidate_directory == enclosing_temporary_root:
|
|
133
|
-
return None
|
|
134
|
-
return None
|
|
108
|
+
return enclosing_project_root(starting_file)
|
|
135
109
|
|
|
136
110
|
|
|
137
111
|
def _first_module_resolution_root(all_py_files: list[str]) -> Path | None:
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Resolve the project root that contains a path, if any.
|
|
2
|
+
|
|
3
|
+
A project root is the nearest ancestor holding a ``.git`` entry or a
|
|
4
|
+
``pyproject.toml``. Callers need that anchor for unrelated reasons: binding a
|
|
5
|
+
first-party import, deciding which directory segments a project owns. One
|
|
6
|
+
module answers it rather than each re-coding the walk.
|
|
7
|
+
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
target_repo/.git + target_repo/tools/x.py -> target_repo
|
|
11
|
+
/tmp/detached/x.py (home .git above temp) -> None
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_validators_directory = str(Path(__file__).resolve().parent)
|
|
20
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
21
|
+
for each_directory in (_validators_directory, _hooks_directory):
|
|
22
|
+
if each_directory not in sys.path:
|
|
23
|
+
sys.path.insert(0, each_directory)
|
|
24
|
+
|
|
25
|
+
from pyproject_config_discovery import ancestor_directories # noqa: E402
|
|
26
|
+
from system_temporary_roots import ( # noqa: E402
|
|
27
|
+
enclosing_system_temporary_root,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from hooks_constants.mypy_integration_constants import ( # noqa: E402
|
|
31
|
+
GIT_DIRECTORY_NAME,
|
|
32
|
+
PYPROJECT_FILENAME,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def enclosing_project_root(starting_file: Path) -> Path | None:
|
|
37
|
+
"""Return the nearest ancestor directory that roots a project, else None.
|
|
38
|
+
|
|
39
|
+
The walk stops at the system temp root. A staging copy lives under a temp
|
|
40
|
+
directory, and a ``.git`` in the user home above that root roots no
|
|
41
|
+
project of the staged file's.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
starting_file: The file (or directory) the walk begins from.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The nearest ancestor holding ``.git`` or ``pyproject.toml``, or None
|
|
48
|
+
when the walk reaches the temp root or runs out of ancestors.
|
|
49
|
+
"""
|
|
50
|
+
enclosing_temporary_root = enclosing_system_temporary_root(starting_file)
|
|
51
|
+
for each_candidate_directory in ancestor_directories(starting_file):
|
|
52
|
+
has_git_entry = (each_candidate_directory / GIT_DIRECTORY_NAME).exists()
|
|
53
|
+
has_pyproject = (each_candidate_directory / PYPROJECT_FILENAME).is_file()
|
|
54
|
+
if has_git_entry or has_pyproject:
|
|
55
|
+
return each_candidate_directory
|
|
56
|
+
if each_candidate_directory == enclosing_temporary_root:
|
|
57
|
+
return None
|
|
58
|
+
return None
|
|
@@ -16,6 +16,7 @@ import tempfile
|
|
|
16
16
|
import time
|
|
17
17
|
from collections import Counter
|
|
18
18
|
from dataclasses import dataclass
|
|
19
|
+
from functools import lru_cache
|
|
19
20
|
from datetime import datetime
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Callable, Dict, List, Optional, Tuple
|
|
@@ -35,16 +36,24 @@ from .config.directory_exemption_constants import (
|
|
|
35
36
|
)
|
|
36
37
|
from .fast_save_validators import run_fast_save_validators
|
|
37
38
|
from .health_check import get_system_health, get_validator_version, print_health_report
|
|
38
|
-
from .mypy_integration import
|
|
39
|
-
check_mypy_available,
|
|
40
|
-
find_module_resolution_root,
|
|
41
|
-
run_mypy_check,
|
|
42
|
-
)
|
|
39
|
+
from .mypy_integration import check_mypy_available, run_mypy_check
|
|
43
40
|
from .output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
|
|
41
|
+
from .project_roots import enclosing_project_root
|
|
44
42
|
from .python_style_checks import fix_file
|
|
45
43
|
from .ruff_integration import run_ruff_check
|
|
44
|
+
from .config.validator_base_constants import SOURCE_CACHE_ENTRIES
|
|
46
45
|
from .validator_base import ValidatorResult
|
|
47
46
|
from blocking.code_rules_shared import is_ephemeral_path
|
|
47
|
+
from blocking.codex_apply_patch import (
|
|
48
|
+
CODEX_DELETE_OPERATION,
|
|
49
|
+
CodexPatchError,
|
|
50
|
+
CodexPatchFile,
|
|
51
|
+
parse_codex_apply_patch,
|
|
52
|
+
payload_patch_command,
|
|
53
|
+
)
|
|
54
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
55
|
+
APPLY_PATCH_TOOL_NAME,
|
|
56
|
+
)
|
|
48
57
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
49
58
|
from hooks_constants.multi_edit_reconstruction import (
|
|
50
59
|
apply_edits,
|
|
@@ -793,8 +802,8 @@ def _path_parts_the_project_owns(destination_path: Path) -> tuple[str, ...]:
|
|
|
793
802
|
|
|
794
803
|
::
|
|
795
804
|
|
|
796
|
-
ok: "/
|
|
797
|
-
ok: "/
|
|
805
|
+
ok: "/srv/tests/proj/src/worker.py" (proj is the root) -> ("src", "worker.py")
|
|
806
|
+
ok: "/srv/anna/.claude/hooks/gate.py" (no root) -> (".claude", "hooks", "gate.py")
|
|
798
807
|
ok: "config/x.py" (relative) -> ("config", "x.py")
|
|
799
808
|
|
|
800
809
|
Directory names above the project root were chosen by whoever laid out the
|
|
@@ -819,13 +828,13 @@ def _path_parts_the_project_owns(destination_path: Path) -> tuple[str, ...]:
|
|
|
819
828
|
if not destination_path.anchor:
|
|
820
829
|
return all_path_parts
|
|
821
830
|
all_path_parts = all_path_parts[1:]
|
|
822
|
-
project_root =
|
|
831
|
+
project_root = enclosing_project_root(destination_path)
|
|
823
832
|
if project_root is None:
|
|
824
833
|
return all_path_parts
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
except ValueError:
|
|
834
|
+
resolved_destination = destination_path.resolve()
|
|
835
|
+
if not resolved_destination.is_relative_to(project_root):
|
|
828
836
|
return all_path_parts
|
|
837
|
+
return resolved_destination.relative_to(project_root).parts
|
|
829
838
|
|
|
830
839
|
|
|
831
840
|
def _temporary_path_preserving_directory_signal(
|
|
@@ -1158,6 +1167,7 @@ def _line_identity(
|
|
|
1158
1167
|
)
|
|
1159
1168
|
|
|
1160
1169
|
|
|
1170
|
+
@lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
|
|
1161
1171
|
def _scope_agnostic_message(identity_scope: str, violation_message: str) -> str:
|
|
1162
1172
|
"""Return the message with its own function's name and shape metrics masked.
|
|
1163
1173
|
|
|
@@ -1199,22 +1209,6 @@ def _scope_agnostic_message(identity_scope: str, violation_message: str) -> str:
|
|
|
1199
1209
|
)
|
|
1200
1210
|
|
|
1201
1211
|
|
|
1202
|
-
def _shape_key(violation_identity: ViolationIdentity) -> tuple[str, str]:
|
|
1203
|
-
"""Return an identity's ``(validator, scope-agnostic message)`` shape key.
|
|
1204
|
-
|
|
1205
|
-
Args:
|
|
1206
|
-
violation_identity: One violation's full identity key.
|
|
1207
|
-
|
|
1208
|
-
Returns:
|
|
1209
|
-
The key a violation keeps when only its enclosing function's name
|
|
1210
|
-
changes, so a rename, a split, or a move still finds its baseline.
|
|
1211
|
-
"""
|
|
1212
|
-
return (
|
|
1213
|
-
violation_identity[0],
|
|
1214
|
-
_scope_agnostic_message(violation_identity[1], violation_identity[2]),
|
|
1215
|
-
)
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
1212
|
def _violation_identities(
|
|
1219
1213
|
failed_results: List[ValidatorResult], content: str
|
|
1220
1214
|
) -> Counter[ViolationIdentity]:
|
|
@@ -1283,9 +1277,12 @@ def _compatible_baseline_identity(
|
|
|
1283
1277
|
for each_identity in all_available_identities:
|
|
1284
1278
|
if each_identity[1] == line_identity[1]:
|
|
1285
1279
|
return each_identity
|
|
1286
|
-
|
|
1280
|
+
line_shape_message = _scope_agnostic_message(line_identity[1], line_identity[2])
|
|
1287
1281
|
for each_identity in all_available_identities:
|
|
1288
|
-
if
|
|
1282
|
+
if (
|
|
1283
|
+
_scope_agnostic_message(each_identity[1], each_identity[2])
|
|
1284
|
+
== line_shape_message
|
|
1285
|
+
):
|
|
1289
1286
|
return each_identity
|
|
1290
1287
|
return None
|
|
1291
1288
|
|
|
@@ -1448,26 +1445,80 @@ def _emit_pre_existing_warning(all_preexisting_results: List[ValidatorResult]) -
|
|
|
1448
1445
|
sys.stderr.flush()
|
|
1449
1446
|
|
|
1450
1447
|
|
|
1451
|
-
def _decide_pre_tool_use(file_path: str, proposed_content: str) ->
|
|
1448
|
+
def _decide_pre_tool_use(file_path: str, proposed_content: str) -> bool:
|
|
1452
1449
|
"""Deny only violations absent from the baseline; warn on the ones that persist.
|
|
1453
1450
|
|
|
1454
1451
|
Args:
|
|
1455
1452
|
file_path: The write's target path.
|
|
1456
1453
|
proposed_content: The reconstructed post-edit content of that file.
|
|
1454
|
+
|
|
1455
|
+
Returns:
|
|
1456
|
+
True when this target denies, so a caller holding several targets stops
|
|
1457
|
+
at the first one and emits a single decision.
|
|
1457
1458
|
"""
|
|
1458
1459
|
all_proposed_failed = _failed_results(
|
|
1459
1460
|
validate_proposed_file(file_path, proposed_content)
|
|
1460
1461
|
)
|
|
1461
1462
|
if not all_proposed_failed:
|
|
1462
|
-
return
|
|
1463
|
+
return False
|
|
1463
1464
|
baseline_identities = _baseline_violation_identities(file_path)
|
|
1464
1465
|
all_new_results, all_preexisting_results = _scope_new_and_preexisting(
|
|
1465
1466
|
all_proposed_failed, proposed_content, baseline_identities
|
|
1466
1467
|
)
|
|
1467
1468
|
if all_preexisting_results:
|
|
1468
1469
|
_emit_pre_existing_warning(all_preexisting_results)
|
|
1469
|
-
if all_new_results:
|
|
1470
|
-
|
|
1470
|
+
if not all_new_results:
|
|
1471
|
+
return False
|
|
1472
|
+
_emit_pre_tool_use_deny(_proposed_content_deny_reason(all_new_results))
|
|
1473
|
+
return True
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _decide_codex_patch_file(
|
|
1477
|
+
each_patch_file: CodexPatchFile, pre_tool_use_payload: dict
|
|
1478
|
+
) -> bool:
|
|
1479
|
+
"""Grade one path a Codex patch writes, skipping a deletion and an exemption.
|
|
1480
|
+
|
|
1481
|
+
Args:
|
|
1482
|
+
each_patch_file: One parsed path with its projected post-edit content.
|
|
1483
|
+
pre_tool_use_payload: The whole payload, read for its working directory.
|
|
1484
|
+
|
|
1485
|
+
Returns:
|
|
1486
|
+
True when this path denies the patch.
|
|
1487
|
+
"""
|
|
1488
|
+
if each_patch_file.operation == CODEX_DELETE_OPERATION:
|
|
1489
|
+
return False
|
|
1490
|
+
if is_ephemeral_path(each_patch_file.file_path, pre_tool_use_payload):
|
|
1491
|
+
return False
|
|
1492
|
+
return _decide_pre_tool_use(each_patch_file.file_path, each_patch_file.post_content)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _evaluate_codex_apply_patch(pre_tool_use_payload: dict, tool_input: dict) -> None:
|
|
1496
|
+
"""Grade every path a Codex apply_patch writes, stopping at the first denial.
|
|
1497
|
+
|
|
1498
|
+
::
|
|
1499
|
+
|
|
1500
|
+
one patch adding two files, the second violating -> deny, names file two
|
|
1501
|
+
one patch deleting a file -> allow, nothing to grade
|
|
1502
|
+
|
|
1503
|
+
A patch carries several paths in one call, so each is graded on its own
|
|
1504
|
+
projected post-edit content and the first denial is the decision.
|
|
1505
|
+
|
|
1506
|
+
Args:
|
|
1507
|
+
pre_tool_use_payload: The whole PreToolUse payload.
|
|
1508
|
+
tool_input: The payload's tool input mapping.
|
|
1509
|
+
"""
|
|
1510
|
+
patch_command, working_directory = payload_patch_command(
|
|
1511
|
+
pre_tool_use_payload, tool_input
|
|
1512
|
+
)
|
|
1513
|
+
if not patch_command:
|
|
1514
|
+
return
|
|
1515
|
+
try:
|
|
1516
|
+
all_patch_files = parse_codex_apply_patch(patch_command, working_directory)
|
|
1517
|
+
except CodexPatchError:
|
|
1518
|
+
return
|
|
1519
|
+
for each_patch_file in all_patch_files:
|
|
1520
|
+
if _decide_codex_patch_file(each_patch_file, pre_tool_use_payload):
|
|
1521
|
+
return
|
|
1471
1522
|
|
|
1472
1523
|
|
|
1473
1524
|
def _evaluate_pre_tool_use_payload() -> None:
|
|
@@ -1491,6 +1542,9 @@ def _evaluate_pre_tool_use_payload() -> None:
|
|
|
1491
1542
|
tool_input = pre_tool_use_payload.get("tool_input", {})
|
|
1492
1543
|
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
1493
1544
|
return
|
|
1545
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
1546
|
+
_evaluate_codex_apply_patch(pre_tool_use_payload, tool_input)
|
|
1547
|
+
return
|
|
1494
1548
|
file_path = tool_input.get("file_path", "")
|
|
1495
1549
|
if not isinstance(file_path, str) or not file_path:
|
|
1496
1550
|
return
|
|
@@ -22,7 +22,7 @@ _hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
|
22
22
|
if _hooks_directory not in sys.path:
|
|
23
23
|
sys.path.insert(0, _hooks_directory)
|
|
24
24
|
|
|
25
|
-
from validators.config.
|
|
25
|
+
from validators.config.system_temporary_roots_constants import ( # noqa: E402
|
|
26
26
|
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES,
|
|
27
27
|
)
|
|
28
28
|
|
|
@@ -398,3 +398,25 @@ class TestHooksSubprocessWorkingDirectory:
|
|
|
398
398
|
)
|
|
399
399
|
assert working_directory_string == tempfile.gettempdir()
|
|
400
400
|
assert not working_directory_string.startswith("\\\\")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class TestDocstringExamplePaths:
|
|
404
|
+
def test_module_source_carries_no_home_directory_example_paths(self) -> None:
|
|
405
|
+
"""Example paths avoid /home so the privacy gate reads no personal data.
|
|
406
|
+
|
|
407
|
+
The gate allowlists a fixed set of placeholder home usernames, so an
|
|
408
|
+
invented name outside that set reads as somebody's real home directory
|
|
409
|
+
and denies the commit. Rooting an example somewhere other than /home
|
|
410
|
+
keeps the illustration and leaves nothing for the gate to flag.
|
|
411
|
+
"""
|
|
412
|
+
module_source = (Path(__file__).parent / "run_all_validators.py").read_text(
|
|
413
|
+
encoding="utf-8"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
all_home_example_lines = [
|
|
417
|
+
each_line
|
|
418
|
+
for each_line in module_source.splitlines()
|
|
419
|
+
if '"/home/' in each_line
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
assert all_home_example_lines == []
|