claude-dev-env 2.19.0 → 2.20.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 (48) 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 +12 -0
  22. package/hooks/blocking/test_block_main_commit.py +145 -0
  23. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  24. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
  25. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  26. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
  27. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  28. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
  29. package/hooks/git-hooks/AGENTS.md +1 -1
  30. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  31. package/hooks/git-hooks/post_commit.py +160 -51
  32. package/hooks/git-hooks/pre_commit.py +3 -3
  33. package/hooks/git-hooks/test_post_commit.py +203 -0
  34. package/hooks/git-hooks/test_pre_commit.py +2 -2
  35. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  36. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  37. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  38. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  39. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  40. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  41. package/hooks/validation/mypy_validator.py +213 -80
  42. package/hooks/validation/test_mypy_validator.py +288 -13
  43. package/hooks/workflow/auto_formatter.py +225 -93
  44. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  45. package/hooks/workflow/test_auto_formatter.py +261 -12
  46. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  47. package/package.json +1 -1
  48. package/rules/failure-blast-radius.md +126 -0
@@ -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
@@ -41,6 +41,9 @@ from code_rules_banned_identifiers import ( # noqa: E402
41
41
  check_banned_noun_word_boundary,
42
42
  check_banned_prefixes,
43
43
  )
44
+ from code_rules_blast_radius import ( # noqa: E402
45
+ check_blast_radius_declared,
46
+ )
44
47
  from code_rules_boolean_mustcheck import ( # noqa: E402
45
48
  check_boolean_naming,
46
49
  check_ignored_must_check_return,
@@ -295,6 +298,15 @@ def validate_content(
295
298
  defer_scope_to_caller,
296
299
  )
297
300
  )
301
+ all_issues.extend(
302
+ _fragment_or_deferred_check(
303
+ check_blast_radius_declared,
304
+ old_content,
305
+ content,
306
+ file_path,
307
+ defer_scope_to_caller,
308
+ )
309
+ )
298
310
  all_issues.extend(check_fstring_structural_literals(content, file_path))
299
311
  all_issues.extend(check_constants_outside_config(content, file_path))
300
312
  all_issues.extend(check_config_duplicate_path_anchor(content, file_path))
@@ -0,0 +1,145 @@
1
+ """Production-path tests for direct commit branch protection."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def _run_git(from_directory: Path, *arguments: str) -> None:
11
+ subprocess.run(
12
+ ["git", *arguments],
13
+ cwd=from_directory,
14
+ check=True,
15
+ capture_output=True,
16
+ text=True,
17
+ )
18
+
19
+
20
+ def _create_repository(from_directory: Path, branch_name: str) -> Path:
21
+ repository = from_directory / branch_name
22
+ repository.mkdir()
23
+ _run_git(repository, "init", "--initial-branch", "main")
24
+ hook_directory = repository / ".git" / "test-hooks"
25
+ hook_directory.mkdir()
26
+ _run_git(repository, "config", "core.hooksPath", str(hook_directory))
27
+ _run_git(repository, "config", "user.email", "test@example.com")
28
+ _run_git(repository, "config", "user.name", "Test User")
29
+ (repository / "tracked.txt").write_text("tracked\n", encoding="utf-8")
30
+ _run_git(repository, "add", "tracked.txt")
31
+ _run_git(repository, "-c", "commit.gpgsign=false", "commit", "-m", "initial")
32
+ if branch_name != "main":
33
+ _run_git(repository, "switch", "-c", branch_name)
34
+ return repository
35
+
36
+
37
+ def _run_commit_gate(
38
+ from_directory: Path,
39
+ process_directory: Path,
40
+ command: str = "git commit -m test",
41
+ include_event_cwd: bool = True,
42
+ ) -> subprocess.CompletedProcess[str]:
43
+ hook_script = Path(__file__).with_name("block_main_commit.py")
44
+ home_directory = from_directory.parent / "test-home"
45
+ home_directory.mkdir(exist_ok=True)
46
+ environment = os.environ.copy()
47
+ environment.update({"HOME": str(home_directory), "USERPROFILE": str(home_directory)})
48
+ hook_payload: dict[str, object] = {
49
+ "tool_name": "Bash",
50
+ "tool_input": {"command": command},
51
+ }
52
+ if include_event_cwd:
53
+ hook_payload["cwd"] = str(from_directory)
54
+ return subprocess.run(
55
+ [sys.executable, str(hook_script)],
56
+ cwd=process_directory,
57
+ input=json.dumps(hook_payload),
58
+ check=False,
59
+ capture_output=True,
60
+ text=True,
61
+ env=environment,
62
+ )
63
+
64
+
65
+ def test_blocks_commit_on_protected_branch_from_hook_event_cwd(tmp_path: Path) -> None:
66
+ repository = _create_repository(tmp_path, "main")
67
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
68
+
69
+ completed_process = _run_commit_gate(repository, process_repository)
70
+
71
+ assert completed_process.returncode == 0
72
+ hook_response = json.loads(completed_process.stdout)
73
+ assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
74
+ assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
75
+
76
+
77
+ def test_allows_commit_on_owned_branch_from_hook_event_cwd(tmp_path: Path) -> None:
78
+ repository = _create_repository(tmp_path, "agent-owned-change")
79
+ process_repository = _create_repository(tmp_path, "main")
80
+
81
+ completed_process = _run_commit_gate(repository, process_repository)
82
+
83
+ assert completed_process.returncode == 0
84
+ assert completed_process.stdout == ""
85
+
86
+
87
+ def test_resolves_relative_git_c_from_hook_event_cwd(tmp_path: Path) -> None:
88
+ repository = _create_repository(tmp_path, "main")
89
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
90
+
91
+ completed_process = _run_commit_gate(
92
+ tmp_path,
93
+ process_repository,
94
+ command="git -C main commit -m test",
95
+ )
96
+
97
+ assert completed_process.returncode == 0
98
+ hook_response = json.loads(completed_process.stdout)
99
+ assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
100
+ assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
101
+
102
+
103
+ def test_preserves_dispatcher_ownership_without_event_cwd(tmp_path: Path) -> None:
104
+ repository = _create_repository(tmp_path, "main")
105
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
106
+
107
+ completed_process = _run_commit_gate(
108
+ tmp_path,
109
+ process_repository,
110
+ command=f'git -C "{repository}" commit -m test',
111
+ include_event_cwd=False,
112
+ )
113
+
114
+ assert completed_process.returncode == 0
115
+ assert completed_process.stdout == ""
116
+
117
+
118
+ def test_matches_case_insensitive_shell_git_command(tmp_path: Path) -> None:
119
+ repository = _create_repository(tmp_path, "main")
120
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
121
+
122
+ completed_process = _run_commit_gate(
123
+ tmp_path,
124
+ process_repository,
125
+ command="CD main && GIT COMMIT -m test",
126
+ )
127
+
128
+ assert completed_process.returncode == 0
129
+ hook_response = json.loads(completed_process.stdout)
130
+ assert hook_response["hookSpecificOutput"]["permissionDecision"] == "deny"
131
+ assert str(repository) in hook_response["hookSpecificOutput"]["permissionDecisionReason"]
132
+
133
+
134
+ def test_ignores_unrelated_command_containing_git_commit(tmp_path: Path) -> None:
135
+ repository = _create_repository(tmp_path, "main")
136
+ process_repository = _create_repository(tmp_path, "agent-owned-change")
137
+
138
+ completed_process = _run_commit_gate(
139
+ repository,
140
+ process_repository,
141
+ command="echo git commit",
142
+ )
143
+
144
+ assert completed_process.returncode == 0
145
+ assert completed_process.stdout == ""