claude-dev-env 2.19.0 → 2.21.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 (53) hide show
  1. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
  2. package/.agents/skills/e-code-review/SKILL.md +12 -1
  3. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  4. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  5. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  6. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  7. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  8. package/.agents/skills/pr-cleanup/SKILL.md +109 -11
  9. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  11. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  13. package/docs/CODE_RULES.md +2 -0
  14. package/hooks/advisory/conftest.py +10 -0
  15. package/hooks/advisory/refactor_guard.py +250 -144
  16. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  17. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  18. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  19. package/hooks/blocking/block_main_commit.py +66 -33
  20. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  21. package/hooks/blocking/code_rules_enforcer.py +95 -0
  22. package/hooks/blocking/codex_apply_patch.py +238 -0
  23. package/hooks/blocking/test_block_main_commit.py +145 -0
  24. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  25. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
  26. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  28. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
  29. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  30. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
  31. package/hooks/git-hooks/AGENTS.md +1 -1
  32. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  33. package/hooks/git-hooks/post_commit.py +160 -51
  34. package/hooks/git-hooks/pre_commit.py +3 -3
  35. package/hooks/git-hooks/test_post_commit.py +203 -0
  36. package/hooks/git-hooks/test_pre_commit.py +2 -2
  37. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  38. package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
  39. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  40. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  41. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  42. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  43. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  44. package/hooks/validation/mypy_validator.py +213 -80
  45. package/hooks/validation/test_mypy_validator.py +288 -13
  46. package/hooks/workflow/auto_formatter.py +225 -93
  47. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  48. package/hooks/workflow/test_auto_formatter.py +261 -12
  49. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  50. package/package.json +1 -1
  51. package/rules/failure-blast-radius.md +126 -0
  52. package/scripts/codex_compat_materializer.py +395 -17
  53. package/scripts/tests/test_codex_compat_materializer.py +17 -3
@@ -0,0 +1,166 @@
1
+ """Tests for refactor candidate eligibility against a temporary Git repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ ADVISORY_DIRECTORY = Path(__file__).resolve().parent
11
+ if str(ADVISORY_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(ADVISORY_DIRECTORY))
13
+
14
+ import refactor_guard # noqa: E402
15
+ from refactor_guard_test_support import commit_file, stage_file # noqa: E402
16
+
17
+
18
+ def test_refactor_candidate_is_eligible_when_old_lines_are_outside_changed_surface(
19
+ git_repository: Path,
20
+ ) -> None:
21
+ source_path = git_repository / "module.py"
22
+ commit_file(
23
+ git_repository,
24
+ source_path,
25
+ "def calculate_total(amount: int) -> int:\n return amount\n",
26
+ )
27
+ stage_file(
28
+ git_repository,
29
+ source_path,
30
+ "def calculate_total(amount: int) -> int:\n return amount + 1\n",
31
+ )
32
+
33
+ old_function = "def calculate_total(amount: int) -> int:\n return amount"
34
+ renamed_function = "def compute_total(amount: int) -> int:\n return amount"
35
+
36
+ assert refactor_guard.is_refactor_eligible(str(source_path), old_function, renamed_function)
37
+
38
+
39
+ def test_refactor_candidate_is_ineligible_when_old_lines_are_in_changed_surface(
40
+ git_repository: Path,
41
+ ) -> None:
42
+ source_path = git_repository / "module.py"
43
+ commit_file(git_repository, source_path, "pass\n")
44
+ old_function = "def calculate_total(amount: int) -> int:\n return amount"
45
+ stage_file(git_repository, source_path, f"{old_function}\n")
46
+ renamed_function = "def compute_total(amount: int) -> int:\n return amount"
47
+
48
+ assert not refactor_guard.is_refactor_eligible(str(source_path), old_function, renamed_function)
49
+
50
+
51
+ def test_changed_surface_reads_staged_and_unstaged_lines(
52
+ git_repository: Path, monkeypatch: pytest.MonkeyPatch
53
+ ) -> None:
54
+ source_path = git_repository / "module.py"
55
+ commit_file(git_repository, source_path, "baseline = 1\n")
56
+
57
+ stage_file(git_repository, source_path, "staged_line = 1\n")
58
+ source_path.write_text("unstaged_line = 1\n", encoding="utf-8")
59
+
60
+ monkeypatch.setenv("GIT_DIR", str(git_repository / "missing-git-dir"))
61
+ monkeypatch.setenv("GIT_WORK_TREE", str(git_repository / "missing-work-tree"))
62
+ monkeypatch.setenv("GIT_INDEX_FILE", str(git_repository / "missing-index"))
63
+ monkeypatch.setenv("GIT_COMMON_DIR", str(git_repository / "missing-common-dir"))
64
+ monkeypatch.setenv("GIT_PREFIX", "adversarial-prefix")
65
+ scrubbed_environment = refactor_guard._git_environment()
66
+ assert not any(each_name.startswith("GIT_") for each_name in scrubbed_environment)
67
+ all_added_lines = refactor_guard.get_git_diff_added_lines(str(source_path))
68
+
69
+ assert all_added_lines == {"staged_line = 1", "unstaged_line = 1"}
70
+
71
+
72
+ def test_duplicate_old_lines_require_duplicate_changed_occurrences(
73
+ git_repository: Path, monkeypatch: pytest.MonkeyPatch
74
+ ) -> None:
75
+ source_path = git_repository / "module.py"
76
+ commit_file(git_repository, source_path, "pass\n")
77
+ old_function = "def calculate_total(amount: int) -> int:\n return amount\n return amount"
78
+ stage_file(
79
+ git_repository,
80
+ source_path,
81
+ "def calculate_total(amount: int) -> int:\n return amount\n",
82
+ )
83
+ renamed_function = old_function.replace("calculate_total", "compute_total")
84
+ monkeypatch.setattr(
85
+ refactor_guard,
86
+ "_get_added_line_occurrences",
87
+ lambda _file_path: ["def calculate_total(amount: int) -> int:", " return amount"],
88
+ )
89
+
90
+ assert refactor_guard.is_refactor_edit(old_function, renamed_function)
91
+ assert not refactor_guard.is_edit_within_changed_surface(str(source_path), old_function)
92
+
93
+
94
+ def test_ordinary_edit_is_not_a_refactor_candidate(git_repository: Path) -> None:
95
+ source_path = git_repository / "module.py"
96
+ commit_file(git_repository, source_path, "return_amount = 1\n")
97
+
98
+ assert not refactor_guard.is_refactor_eligible(
99
+ str(source_path), "return_amount = 1", "return_amount = 2"
100
+ )
101
+
102
+
103
+ def test_new_file_is_not_a_refactor_candidate(git_repository: Path) -> None:
104
+ source_path = git_repository / "new_module.py"
105
+ source_path.write_text(
106
+ "def calculate_total(amount: int) -> int:\n return amount\n",
107
+ encoding="utf-8",
108
+ )
109
+
110
+ assert refactor_guard.is_new_file(str(source_path))
111
+ assert not refactor_guard.is_refactor_eligible(
112
+ str(source_path),
113
+ "def calculate_total(amount: int) -> int:\n return amount",
114
+ "def compute_total(amount: int) -> int:\n return amount",
115
+ )
116
+
117
+
118
+ def test_hook_infrastructure_is_not_a_refactor_candidate(
119
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
120
+ ) -> None:
121
+ monkeypatch.setenv("HOME", str(tmp_path))
122
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
123
+ hook_path = str(Path.home() / ".claude" / "settings.json")
124
+
125
+ assert refactor_guard.is_hook_infrastructure(hook_path)
126
+ assert not refactor_guard.is_refactor_eligible(
127
+ hook_path,
128
+ "def calculate_total(amount: int) -> int:\n return amount",
129
+ "def compute_total(amount: int) -> int:\n return amount",
130
+ )
131
+
132
+
133
+ def test_changed_surface_below_half_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
134
+ monkeypatch.setattr(
135
+ refactor_guard,
136
+ "_get_added_line_occurrences",
137
+ lambda _file_path: ["changed line"],
138
+ )
139
+
140
+ assert not refactor_guard.is_edit_within_changed_surface(
141
+ "module.py", "changed line\noriginal line\noriginal line"
142
+ )
143
+
144
+
145
+ def test_changed_surface_at_half_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
146
+ monkeypatch.setattr(
147
+ refactor_guard,
148
+ "_get_added_line_occurrences",
149
+ lambda _file_path: ["changed one", "changed two"],
150
+ )
151
+
152
+ assert refactor_guard.is_edit_within_changed_surface(
153
+ "module.py", "changed one\nchanged two\noriginal one\noriginal two"
154
+ )
155
+
156
+
157
+ def test_changed_surface_above_half_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
158
+ monkeypatch.setattr(
159
+ refactor_guard,
160
+ "_get_added_line_occurrences",
161
+ lambda _file_path: ["changed one", "changed two", "changed three"],
162
+ )
163
+
164
+ assert refactor_guard.is_edit_within_changed_surface(
165
+ "module.py", "changed one\nchanged two\nchanged three\noriginal line"
166
+ )
@@ -28,46 +28,73 @@ PROTECTED_BRANCHES = ("main", "master")
28
28
  PROTECTED_REMOTE_PATTERNS: list[str] = []
29
29
 
30
30
 
31
- def extract_git_working_directory(bash_command: str) -> str | None:
32
- """Extract the directory where git commit will actually execute.
33
-
34
- Parses the bash command for directory-changing patterns that precede
35
- the git commit, and for git's own -C flag.
36
-
37
- Returns None if the commit runs in the hook's CWD.
38
- """
39
- git_c_match = re.search(
40
- r"git\s+-C\s+[\"']?([^\"';&|]+?)[\"']?\s+commit",
31
+ def _match_git_c_commit(bash_command: str) -> re.Match[str] | None:
32
+ """Return the match for a Git commit with an explicit directory."""
33
+ return re.search(
34
+ r"(?:^|(?<=[;&|]))\s*git\s+-C\s+[\"']?([^\"';&|]+?)[\"']?\s+commit(?:\s|$)",
41
35
  bash_command,
36
+ flags=re.IGNORECASE,
42
37
  )
38
+
39
+
40
+ def parse_git_commit_directory(bash_command: str) -> tuple[bool, str | None]:
41
+ """Return the Git commit match state and selected working directory."""
42
+ git_c_match = _match_git_c_commit(bash_command)
43
43
  if git_c_match:
44
- return git_c_match.group(1).strip()
44
+ return True, git_c_match.group(1).strip()
45
45
 
46
- commit_pos = bash_command.lower().find("git commit")
47
- if commit_pos == -1:
48
- return None
46
+ git_commit_match = re.search(
47
+ r"(?:^|(?<=[;&|]))\s*git\s+commit(?:\s|$)",
48
+ bash_command,
49
+ flags=re.IGNORECASE,
50
+ )
51
+ if git_commit_match is None:
52
+ return False, None
49
53
 
50
- prefix = bash_command[:commit_pos]
54
+ prefix = bash_command[:git_commit_match.start()]
51
55
 
52
56
  cd_matches = re.findall(
53
57
  r"(?:cd|pushd)\s+[\"']?([^\"';&|]+?)[\"']?\s*[;&|]",
54
58
  prefix,
59
+ flags=re.IGNORECASE,
55
60
  )
56
61
  if cd_matches:
57
- return cd_matches[-1].strip()
62
+ return True, cd_matches[-1].strip()
63
+
64
+ return True, None
65
+
66
+
67
+ def extract_git_working_directory(bash_command: str) -> str | None:
68
+ """Return the working directory selected by a Git commit command."""
69
+ _, working_directory = parse_git_commit_directory(bash_command)
70
+ return working_directory
58
71
 
59
- return None
72
+
73
+ def is_commit_command(bash_command: str) -> bool:
74
+ """Return the Git commit match state for the shell command."""
75
+ is_commit, _ = parse_git_commit_directory(bash_command)
76
+ return is_commit
77
+
78
+
79
+ def is_git_c_commit_command(bash_command: str) -> bool:
80
+ """Return whether the command names a Git commit target with ``-C``."""
81
+ return _match_git_c_commit(bash_command) is not None
60
82
 
61
83
 
62
- def resolve_directory(directory: str | None) -> str | None:
84
+ def resolve_directory(
85
+ directory: str | None,
86
+ from_directory: str | None = None,
87
+ ) -> str | None:
63
88
  """Resolve a directory path, expanding ~ and validating existence."""
64
- if directory is None:
89
+ selected_directory = directory if directory is not None else from_directory
90
+ if selected_directory is None:
65
91
  return None
66
92
 
67
- expanded = os.path.expanduser(directory)
93
+ expanded = os.path.expanduser(selected_directory)
68
94
 
69
95
  if not os.path.isabs(expanded):
70
- expanded = os.path.abspath(expanded)
96
+ base_directory = from_directory or os.getcwd()
97
+ expanded = os.path.abspath(os.path.join(base_directory, expanded))
71
98
 
72
99
  if os.path.isdir(expanded):
73
100
  return expanded
@@ -112,22 +139,25 @@ def is_protected_repo(working_dir: str | None = None) -> bool:
112
139
  return False
113
140
 
114
141
 
115
- def is_commit_command(bash_command: str) -> bool:
116
- return "git commit" in bash_command.lower().strip()
117
-
118
-
119
142
  def is_main_commit_confirmed(bash_command: str) -> bool:
120
143
  """Return True if the command includes the explicit confirmation sentinel."""
121
144
  return "--allow-main-commit" in bash_command
122
145
 
123
146
 
124
- def parse_bash_command_from_stdin() -> str:
147
+ def parse_hook_context_from_stdin() -> tuple[str, str | None]:
125
148
  try:
126
149
  hook_event = json.load(sys.stdin)
127
150
  except json.JSONDecodeError:
128
- return ""
151
+ return "", None
152
+
153
+ bash_command = hook_event.get("tool_input", {}).get("command", "")
154
+ return bash_command, hook_event.get("cwd")
155
+
129
156
 
130
- return hook_event.get("tool_input", {}).get("command", "")
157
+ def parse_bash_command_from_stdin() -> str:
158
+ """Return the Bash command from the hook payload on standard input."""
159
+ bash_command, _ = parse_hook_context_from_stdin()
160
+ return bash_command
131
161
 
132
162
 
133
163
  DRAFT_PR_INSTRUCTION = (
@@ -156,18 +186,21 @@ def build_denial_response(branch_name: str, repo_dir: str | None) -> dict:
156
186
 
157
187
 
158
188
  def main() -> None:
159
- bash_command = parse_bash_command_from_stdin()
189
+ bash_command, event_cwd = parse_hook_context_from_stdin()
190
+ has_commit_command, target_dir_raw = parse_git_commit_directory(bash_command)
160
191
 
161
- if not is_commit_command(bash_command):
192
+ if not has_commit_command:
162
193
  sys.exit(0)
163
194
 
164
195
  if is_main_commit_confirmed(bash_command):
165
196
  sys.exit(0)
166
197
 
167
- target_dir_raw = extract_git_working_directory(bash_command)
168
- target_dir = resolve_directory(target_dir_raw)
198
+ if event_cwd is None and is_git_c_commit_command(bash_command):
199
+ sys.exit(0)
200
+
201
+ target_dir = resolve_directory(target_dir_raw, from_directory=event_cwd)
169
202
 
170
- if target_dir_raw and not target_dir:
203
+ if (target_dir_raw or event_cwd) and not target_dir:
171
204
  sys.exit(0)
172
205
 
173
206
  current_branch = get_branch_at_directory(working_dir=target_dir)
@@ -0,0 +1,194 @@
1
+ """Blast-radius check: a raise inside per-item work must name what it stops."""
2
+
3
+ import ast
4
+ import sys
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+
8
+ _blocking_directory = str(Path(__file__).resolve().parent)
9
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
10
+ if _blocking_directory not in sys.path:
11
+ sys.path.insert(0, _blocking_directory)
12
+ if _hooks_directory not in sys.path:
13
+ sys.path.insert(0, _hooks_directory)
14
+
15
+ from code_rules_shared import ( # noqa: E402
16
+ is_hook_infrastructure,
17
+ is_test_file,
18
+ )
19
+
20
+ from hooks_constants.blast_radius_constants import ( # noqa: E402
21
+ ALL_BLAST_RADIUS_SUFFIXES,
22
+ BLAST_RADIUS_MESSAGE_SUFFIX,
23
+ MAX_BLAST_RADIUS_ISSUES,
24
+ )
25
+
26
+
27
+ def _raised_type_name(raise_node: ast.Raise) -> str | None:
28
+ """Return the name of the exception type a raise statement constructs.
29
+
30
+ Args:
31
+ raise_node: The raise statement to read.
32
+
33
+ Returns:
34
+ The exception type name. A ``None`` return classifies bare re-raises and
35
+ alternate AST forms.
36
+ """
37
+ raised = raise_node.exc
38
+ if raised is None:
39
+ return None
40
+ if isinstance(raised, ast.Call):
41
+ raised = raised.func
42
+ if isinstance(raised, ast.Name):
43
+ return raised.id
44
+ if isinstance(raised, ast.Attribute):
45
+ return raised.attr
46
+ return None
47
+
48
+
49
+ def _declares_blast_radius(type_name: str) -> bool:
50
+ """Report whether an exception type name states what its failure stops.
51
+
52
+ Args:
53
+ type_name: The exception type name to inspect.
54
+
55
+ Returns:
56
+ ``True`` when the name ends in a recognized blast-radius suffix.
57
+ """
58
+ return any(type_name.endswith(each_suffix) for each_suffix in ALL_BLAST_RADIUS_SUFFIXES)
59
+
60
+
61
+ def _handler_type_names(handler: ast.ExceptHandler) -> list[str]:
62
+ """Return named exception types caught by one except clause."""
63
+ caught = handler.type
64
+ if caught is None:
65
+ return []
66
+ all_caught = caught.elts if isinstance(caught, ast.Tuple) else [caught]
67
+ return [
68
+ each_caught.id
69
+ for each_caught in all_caught
70
+ if isinstance(each_caught, ast.Name)
71
+ ] + [
72
+ each_caught.attr
73
+ for each_caught in all_caught
74
+ if isinstance(each_caught, ast.Attribute)
75
+ ]
76
+
77
+
78
+ def _handler_names_blast_radius_type(handler: ast.ExceptHandler) -> bool:
79
+ """Report whether an except clause catches a blast-radius-declaring type.
80
+
81
+ Args:
82
+ handler: The except clause to inspect.
83
+
84
+ Returns:
85
+ ``True`` when any caught type name carries a blast-radius suffix.
86
+ """
87
+ return any(_declares_blast_radius(each_name) for each_name in _handler_type_names(handler))
88
+
89
+
90
+ def _handler_matches_raise(handler: ast.ExceptHandler, type_name: str | None) -> bool:
91
+ """Report whether a handler corresponds to the raise it encloses."""
92
+ all_handler_names = _handler_type_names(handler)
93
+ if type_name is None:
94
+ return _handler_names_blast_radius_type(handler)
95
+ return type_name in all_handler_names
96
+
97
+
98
+ def _walk_loop_body(node: ast.AST) -> Iterator[ast.AST]:
99
+ """Walk loop statements without entering nested definition scopes."""
100
+ for each_child in ast.iter_child_nodes(node):
101
+ if isinstance(
102
+ each_child,
103
+ (ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef, ast.Lambda),
104
+ ):
105
+ continue
106
+ yield each_child
107
+ yield from _walk_loop_body(each_child)
108
+
109
+
110
+ def _boundary_guarded_raise_lines(tree: ast.Module) -> set[int]:
111
+ """Collect raise lines already sitting inside a blast-radius boundary.
112
+
113
+ Args:
114
+ tree: The parsed module to walk.
115
+
116
+ Returns:
117
+ The line numbers of raise statements enclosed by a try whose handlers
118
+ name a blast-radius-declaring type.
119
+ """
120
+ all_guarded: set[int] = set()
121
+ for each_node in ast.walk(tree):
122
+ if not isinstance(each_node, ast.Try):
123
+ continue
124
+ for each_body_node in each_node.body:
125
+ for each_inner in ast.walk(each_body_node):
126
+ if isinstance(each_inner, ast.Raise) and any(
127
+ _handler_matches_raise(each_handler, _raised_type_name(each_inner))
128
+ for each_handler in each_node.handlers
129
+ ):
130
+ all_guarded.add(each_inner.lineno)
131
+ return all_guarded
132
+
133
+
134
+ def _per_item_raise_nodes(tree: ast.Module) -> list[ast.Raise]:
135
+ """Collect raise statements that sit inside a loop body.
136
+
137
+ A raise reached through per-item iteration ends every remaining item by
138
+ default. A declared radius identifies the intended scope, so each raise
139
+ in this path benefits from an explicit name.
140
+
141
+ Args:
142
+ tree: The parsed module to walk.
143
+
144
+ Returns:
145
+ Every raise statement found under a for or while loop body.
146
+ """
147
+ all_raises: list[ast.Raise] = []
148
+ for each_node in ast.walk(tree):
149
+ if not isinstance(each_node, (ast.For, ast.AsyncFor, ast.While)):
150
+ continue
151
+ for each_inner in _walk_loop_body(each_node):
152
+ if isinstance(each_inner, ast.Raise):
153
+ all_raises.append(each_inner)
154
+ return all_raises
155
+
156
+
157
+ def check_blast_radius_declared(content: str, file_path: str) -> list[str]:
158
+ """Check that raises inside per-item work name their stopping scope.
159
+
160
+ A raise reached through a loop body ends the whole batch by default, so a
161
+ one-item defect discards every item that already succeeded. Naming the type
162
+ ``*RunFatal`` or ``*ItemBlocked`` states the intent, and a boundary catching
163
+ a declared type already handles it.
164
+
165
+ Args:
166
+ content: The file body to inspect.
167
+ file_path: The path the body will be written to.
168
+
169
+ Returns:
170
+ One advisory line per raise that needs a declared blast radius, capped
171
+ at the configured maximum.
172
+ """
173
+ if is_test_file(file_path) or is_hook_infrastructure(file_path):
174
+ return []
175
+
176
+ try:
177
+ parsed_tree = ast.parse(content)
178
+ except SyntaxError:
179
+ return []
180
+
181
+ all_guarded_lines = _boundary_guarded_raise_lines(parsed_tree)
182
+ all_issues: list[str] = []
183
+ all_reported_lines: set[int] = set()
184
+ for each_raise in _per_item_raise_nodes(parsed_tree):
185
+ if each_raise.lineno in all_guarded_lines or each_raise.lineno in all_reported_lines:
186
+ continue
187
+ type_name = _raised_type_name(each_raise)
188
+ if type_name is None or _declares_blast_radius(type_name):
189
+ continue
190
+ all_reported_lines.add(each_raise.lineno)
191
+ all_issues.append(f"Line {each_raise.lineno}: {type_name} {BLAST_RADIUS_MESSAGE_SUFFIX}")
192
+ if len(all_issues) >= MAX_BLAST_RADIUS_ISSUES:
193
+ break
194
+ return all_issues
@@ -16,6 +16,7 @@ concern focused. The separate ``tdd_enforcer.py`` hook accepts any
16
16
  ``code_rules_*`` module family, so the suffix files satisfy its gate.
17
17
  """
18
18
  import json
19
+ import os
19
20
  import sys
20
21
  from collections import Counter
21
22
  from collections.abc import Callable
@@ -29,6 +30,8 @@ if _BLOCKING_DIRECTORY not in sys.path:
29
30
  if _HOOKS_DIRECTORY not in sys.path:
30
31
  sys.path.insert(0, _HOOKS_DIRECTORY)
31
32
 
33
+ _codex_apply_patch_tool_name = "apply_patch"
34
+
32
35
  from code_rules_annotations_length import ( # noqa: E402
33
36
  check_function_length,
34
37
  check_known_pytest_fixture_annotations,
@@ -41,6 +44,9 @@ from code_rules_banned_identifiers import ( # noqa: E402
41
44
  check_banned_noun_word_boundary,
42
45
  check_banned_prefixes,
43
46
  )
47
+ from code_rules_blast_radius import ( # noqa: E402
48
+ check_blast_radius_declared,
49
+ )
44
50
  from code_rules_boolean_mustcheck import ( # noqa: E402
45
51
  check_boolean_naming,
46
52
  check_ignored_must_check_return,
@@ -200,6 +206,11 @@ from code_rules_typeddict_stub import ( # noqa: E402
200
206
  from code_rules_unused_imports import ( # noqa: E402
201
207
  check_unused_module_level_imports,
202
208
  )
209
+ from codex_apply_patch import ( # noqa: E402
210
+ CodexPatchError,
211
+ CodexPatchFile,
212
+ parse_codex_apply_patch,
213
+ )
203
214
 
204
215
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
205
216
  ALL_CODE_EXTENSIONS,
@@ -208,6 +219,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
208
219
  DENY_REASON_ISSUE_PREVIEW_COUNT,
209
220
  PRECHECK_USAGE_EXIT_CODE,
210
221
  PRECHECK_USAGE_MESSAGE,
222
+ VIOLATION_SEPARATOR,
211
223
  )
212
224
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
213
225
  from hooks_constants.setup_project_paths_constants import ( # noqa: E402
@@ -215,6 +227,76 @@ from hooks_constants.setup_project_paths_constants import ( # noqa: E402
215
227
  )
216
228
 
217
229
 
230
+ def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
231
+ """Run the existing code-rules verdict over one Codex patch view."""
232
+ if not each_patch_file.post_content and each_patch_file.operation == "delete":
233
+ return []
234
+ if _is_hook_infrastructure_python_target(each_patch_file.file_path):
235
+ all_issues = _hook_infrastructure_blocking_issues(
236
+ each_patch_file.post_content,
237
+ each_patch_file.file_path,
238
+ each_patch_file.post_content,
239
+ each_patch_file.prior_content,
240
+ )
241
+ elif _is_validated_target(each_patch_file.file_path):
242
+ all_issues = validate_content(
243
+ each_patch_file.post_content,
244
+ each_patch_file.file_path,
245
+ each_patch_file.prior_content,
246
+ each_patch_file.post_content,
247
+ each_patch_file.prior_content,
248
+ )
249
+ else:
250
+ return []
251
+ return [
252
+ f"{each_patch_file.file_path}: {each_issue}"
253
+ for each_issue in all_issues
254
+ ]
255
+
256
+
257
+ def _report_codex_patch_payload(
258
+ all_pretooluse_payload: dict[str, object], deny_stream: TextIO
259
+ ) -> None:
260
+ """Validate every file view carried by a Codex apply_patch payload."""
261
+ tool_input = all_pretooluse_payload.get("tool_input")
262
+ if not isinstance(tool_input, dict):
263
+ _write_deny_payload(
264
+ "BLOCKED: [CODE_RULES] apply_patch payload requires tool input",
265
+ deny_stream,
266
+ )
267
+ return
268
+ patch_command = tool_input.get("command")
269
+ if not isinstance(patch_command, str):
270
+ _write_deny_payload(
271
+ "BLOCKED: [CODE_RULES] apply_patch payload requires a string command",
272
+ deny_stream,
273
+ )
274
+ return
275
+ working_directory = all_pretooluse_payload.get("cwd")
276
+ if not isinstance(working_directory, str):
277
+ working_directory = os.getcwd()
278
+ try:
279
+ all_patch_files = parse_codex_apply_patch(patch_command, working_directory)
280
+ except CodexPatchError as error:
281
+ _write_deny_payload(
282
+ f"BLOCKED: [CODE_RULES] apply_patch payload requires accepted patch markers: {error}",
283
+ deny_stream,
284
+ )
285
+ return
286
+ all_issues = [
287
+ each_issue
288
+ for each_patch_file in all_patch_files
289
+ for each_issue in _codex_patch_issues(each_patch_file)
290
+ ]
291
+ if all_issues:
292
+ _write_deny_payload(
293
+ f"BLOCKED: [CODE_RULES] {len(all_issues)} violation(s): "
294
+ + VIOLATION_SEPARATOR.join(all_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
295
+ + _precheck_hint(),
296
+ deny_stream,
297
+ )
298
+
299
+
218
300
  def validate_content(
219
301
  content: str,
220
302
  file_path: str,
@@ -295,6 +377,15 @@ def validate_content(
295
377
  defer_scope_to_caller,
296
378
  )
297
379
  )
380
+ all_issues.extend(
381
+ _fragment_or_deferred_check(
382
+ check_blast_radius_declared,
383
+ old_content,
384
+ content,
385
+ file_path,
386
+ defer_scope_to_caller,
387
+ )
388
+ )
298
389
  all_issues.extend(check_fstring_structural_literals(content, file_path))
299
390
  all_issues.extend(check_constants_outside_config(content, file_path))
300
391
  all_issues.extend(check_config_duplicate_path_anchor(content, file_path))
@@ -1247,6 +1338,10 @@ def main(all_arguments: list[str]) -> None:
1247
1338
  sys.exit(0)
1248
1339
 
1249
1340
  tool_name = pretooluse_payload.get("tool_name", "")
1341
+ if tool_name == _codex_apply_patch_tool_name:
1342
+ _report_codex_patch_payload(pretooluse_payload, sys.stdout)
1343
+ sys.exit(0)
1344
+
1250
1345
  tool_input = pretooluse_payload.get("tool_input", {})
1251
1346
  file_path = tool_input.get("file_path", "")
1252
1347