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.
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +109 -11
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/docs/CODE_RULES.md +2 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +95 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +3 -3
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- package/scripts/codex_compat_materializer.py +395 -17
- 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
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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[:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
93
|
+
expanded = os.path.expanduser(selected_directory)
|
|
68
94
|
|
|
69
95
|
if not os.path.isabs(expanded):
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|