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.
Files changed (46) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +1 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +13 -7
  6. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  7. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  8. package/hooks/blocking/stop_dispatcher.py +6 -9
  9. package/hooks/blocking/tdd_enforcer.py +3 -1
  10. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  11. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  12. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  13. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  14. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
  15. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
  16. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  17. package/hooks/blocking/test_state_description_blocker.py +56 -71
  18. package/hooks/blocking/test_tdd_enforcer.py +67 -1
  19. package/hooks/hooks.json +2 -2
  20. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  21. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  22. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  23. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  24. package/hooks/hooks_constants/state_file_lock.py +76 -0
  25. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  26. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  27. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  28. package/hooks/observability/session_file_edit_tracker.py +2 -55
  29. package/hooks/observability/test_failure_recorder.py +219 -0
  30. package/hooks/observability/test_test_failure_recorder.py +173 -0
  31. package/hooks/validators/config/directory_exemption_constants.py +0 -9
  32. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  33. package/hooks/validators/mypy_integration.py +10 -36
  34. package/hooks/validators/project_roots.py +58 -0
  35. package/hooks/validators/run_all_validators.py +87 -33
  36. package/hooks/validators/system_temporary_roots.py +1 -1
  37. package/hooks/validators/test_run_all_validators.py +22 -0
  38. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  39. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  40. package/package.json +1 -1
  41. package/rules/shell-invocation.md +1 -1
  42. package/rules/testing.md +4 -0
  43. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
  44. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  45. package/scripts/shared_tree_paths.py +59 -25
  46. 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 nearest ancestor directory that roots a project, else None.
96
+ """Return the project root mypy resolves this file's first-party imports against.
101
97
 
102
- A project root is the first ancestor holding a ``.git`` entry or a
103
- ``pyproject.toml``. Mypy resolves a first-party import against its working
104
- directory, so anchoring there binds ``config.*`` to the target file's own
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 nearest ancestor Path that holds ``.git`` or ``pyproject.toml``,
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
- git_entry_name = GIT_DIRECTORY_NAME
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: "/home/tests/proj/src/worker.py" (proj is the root) -> ("src", "worker.py")
797
- ok: "/home/dana/.claude/hooks/gate.py" (no root) -> (".claude", "hooks", "gate.py")
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 = find_module_resolution_root(destination_path)
831
+ project_root = enclosing_project_root(destination_path)
823
832
  if project_root is None:
824
833
  return all_path_parts
825
- try:
826
- return destination_path.resolve().relative_to(project_root).parts
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
- line_shape_key = _shape_key(line_identity)
1280
+ line_shape_message = _scope_agnostic_message(line_identity[1], line_identity[2])
1287
1281
  for each_identity in all_available_identities:
1288
- if _shape_key(each_identity) == line_shape_key:
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) -> None:
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
- _emit_pre_tool_use_deny(_proposed_content_deny_reason(all_new_results))
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.directory_exemption_constants import ( # noqa: E402
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 == []