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,238 @@
|
|
|
1
|
+
"""Parse Codex apply_patch commands into safe pre-edit and post-edit views."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_codex_patch_begin_marker = "*** Begin Patch"
|
|
9
|
+
_codex_patch_end_marker = "*** End Patch"
|
|
10
|
+
_codex_update_marker = "*** Update File:"
|
|
11
|
+
_codex_add_marker = "*** Add File:"
|
|
12
|
+
_codex_delete_marker = "*** Delete File:"
|
|
13
|
+
_codex_hunk_marker = "@@"
|
|
14
|
+
_codex_end_of_file_marker = "*** End of File"
|
|
15
|
+
_codex_no_newline_marker = "\"
|
|
16
|
+
_codex_minimum_patch_line_count = 2
|
|
17
|
+
_codex_update_operation = "update"
|
|
18
|
+
_codex_add_operation = "add"
|
|
19
|
+
_codex_delete_operation = "delete"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodexPatchError(ValueError):
|
|
23
|
+
"""Describe the accepted file views required by a Codex patch."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CodexPatchFile:
|
|
28
|
+
"""Represent one Codex path with pre-edit and projected post-edit content."""
|
|
29
|
+
|
|
30
|
+
file_path: str
|
|
31
|
+
prior_content: str
|
|
32
|
+
post_content: str
|
|
33
|
+
operation: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _codex_marker_text(patch_line: str) -> str:
|
|
37
|
+
"""Return one patch control line minus its line ending."""
|
|
38
|
+
return patch_line.rstrip("\r\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _codex_resolve_patch_path(relative_path: str, working_directory: Path) -> str:
|
|
42
|
+
"""Resolve one relative patch path under the Codex working directory."""
|
|
43
|
+
normalized_path = relative_path.replace("\\", "/")
|
|
44
|
+
all_path_parts = tuple(
|
|
45
|
+
each_part
|
|
46
|
+
for each_part in normalized_path.split("/")
|
|
47
|
+
if each_part not in ("", ".")
|
|
48
|
+
)
|
|
49
|
+
if (
|
|
50
|
+
not all_path_parts
|
|
51
|
+
or normalized_path.startswith("/")
|
|
52
|
+
or re.match(r"^[A-Za-z]:", normalized_path)
|
|
53
|
+
):
|
|
54
|
+
raise CodexPatchError("patch path requires a relative location")
|
|
55
|
+
if any(each_part == ".." for each_part in all_path_parts):
|
|
56
|
+
raise CodexPatchError("patch path requires a traversal-free location")
|
|
57
|
+
target_path = (working_directory / Path(*all_path_parts)).resolve()
|
|
58
|
+
resolved_working_directory = working_directory.resolve()
|
|
59
|
+
if target_path != resolved_working_directory and resolved_working_directory not in target_path.parents:
|
|
60
|
+
raise CodexPatchError("patch path requires a location under the working directory")
|
|
61
|
+
return str(target_path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
|
|
65
|
+
"""Parse operation sections from a Codex apply_patch command."""
|
|
66
|
+
all_lines = command.splitlines(keepends=True)
|
|
67
|
+
if len(all_lines) < _codex_minimum_patch_line_count:
|
|
68
|
+
raise CodexPatchError("patch requires begin and end markers")
|
|
69
|
+
if _codex_marker_text(all_lines[0]) != _codex_patch_begin_marker:
|
|
70
|
+
raise CodexPatchError("patch requires a begin marker")
|
|
71
|
+
if _codex_marker_text(all_lines[-1]) != _codex_patch_end_marker:
|
|
72
|
+
raise CodexPatchError("patch requires an end marker")
|
|
73
|
+
all_sections: list[tuple[str, str, list[str]]] = []
|
|
74
|
+
current_section: tuple[str, str, list[str]] | None = None
|
|
75
|
+
for each_line in all_lines[1:-1]:
|
|
76
|
+
marker_text = _codex_marker_text(each_line)
|
|
77
|
+
operation = next(
|
|
78
|
+
(
|
|
79
|
+
each_operation
|
|
80
|
+
for each_operation, each_marker in (
|
|
81
|
+
(_codex_update_operation, _codex_update_marker),
|
|
82
|
+
(_codex_add_operation, _codex_add_marker),
|
|
83
|
+
(_codex_delete_operation, _codex_delete_marker),
|
|
84
|
+
)
|
|
85
|
+
if marker_text.startswith(each_marker)
|
|
86
|
+
),
|
|
87
|
+
None,
|
|
88
|
+
)
|
|
89
|
+
if operation is not None:
|
|
90
|
+
if current_section is not None:
|
|
91
|
+
all_sections.append(current_section)
|
|
92
|
+
marker_by_operation = {
|
|
93
|
+
_codex_update_operation: _codex_update_marker,
|
|
94
|
+
_codex_add_operation: _codex_add_marker,
|
|
95
|
+
_codex_delete_operation: _codex_delete_marker,
|
|
96
|
+
}
|
|
97
|
+
path_text = marker_text[len(marker_by_operation[operation]) :].strip()
|
|
98
|
+
if not path_text:
|
|
99
|
+
raise CodexPatchError("patch operation requires a path")
|
|
100
|
+
current_section = (operation, path_text, [])
|
|
101
|
+
continue
|
|
102
|
+
if current_section is None:
|
|
103
|
+
raise CodexPatchError("patch content requires a file operation")
|
|
104
|
+
current_section[2].append(each_line)
|
|
105
|
+
if current_section is not None:
|
|
106
|
+
all_sections.append(current_section)
|
|
107
|
+
if not all_sections:
|
|
108
|
+
raise CodexPatchError("patch requires a file operation")
|
|
109
|
+
return all_sections
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _codex_find_patch_block(
|
|
113
|
+
all_current_lines: list[str], all_old_lines: list[str], search_start: int
|
|
114
|
+
) -> int:
|
|
115
|
+
"""Find one hunk's old lines at or after the prior hunk position."""
|
|
116
|
+
if not all_old_lines:
|
|
117
|
+
return search_start
|
|
118
|
+
last_start = len(all_current_lines) - len(all_old_lines)
|
|
119
|
+
for each_start in range(search_start, last_start + 1):
|
|
120
|
+
if all_current_lines[each_start : each_start + len(all_old_lines)] == all_old_lines:
|
|
121
|
+
return each_start
|
|
122
|
+
return -1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _codex_apply_hunk(
|
|
126
|
+
all_current_lines: list[str], all_hunk_lines: list[str], search_start: int
|
|
127
|
+
) -> tuple[list[str], int]:
|
|
128
|
+
"""Apply one context, deletion, and addition hunk to file lines."""
|
|
129
|
+
all_old_lines: list[str] = []
|
|
130
|
+
all_new_lines: list[str] = []
|
|
131
|
+
for each_line in all_hunk_lines:
|
|
132
|
+
marker_text = _codex_marker_text(each_line)
|
|
133
|
+
if marker_text in (_codex_end_of_file_marker, _codex_no_newline_marker):
|
|
134
|
+
continue
|
|
135
|
+
if not each_line or each_line[0] not in " +-":
|
|
136
|
+
raise CodexPatchError("patch hunk requires context, deletion, or addition lines")
|
|
137
|
+
line_content = each_line[1:]
|
|
138
|
+
if each_line[0] in " -":
|
|
139
|
+
all_old_lines.append(line_content)
|
|
140
|
+
if each_line[0] in " +":
|
|
141
|
+
all_new_lines.append(line_content)
|
|
142
|
+
block_start = _codex_find_patch_block(all_current_lines, all_old_lines, search_start)
|
|
143
|
+
if block_start < 0:
|
|
144
|
+
raise CodexPatchError("patch hunk requires matching file content")
|
|
145
|
+
all_current_lines[block_start : block_start + len(all_old_lines)] = all_new_lines
|
|
146
|
+
return all_current_lines, block_start + len(all_new_lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _codex_apply_update(prior_content: str, all_section_lines: list[str]) -> str:
|
|
150
|
+
"""Apply every hunk in one Codex update section."""
|
|
151
|
+
all_current_lines = prior_content.splitlines(keepends=True)
|
|
152
|
+
all_hunk_lines: list[str] = []
|
|
153
|
+
search_start = 0
|
|
154
|
+
has_hunk = False
|
|
155
|
+
for each_line in all_section_lines:
|
|
156
|
+
marker_text = _codex_marker_text(each_line)
|
|
157
|
+
if marker_text.startswith(_codex_hunk_marker):
|
|
158
|
+
if all_hunk_lines:
|
|
159
|
+
all_current_lines, search_start = _codex_apply_hunk(
|
|
160
|
+
all_current_lines, all_hunk_lines, search_start
|
|
161
|
+
)
|
|
162
|
+
all_hunk_lines = []
|
|
163
|
+
has_hunk = True
|
|
164
|
+
continue
|
|
165
|
+
all_hunk_lines.append(each_line)
|
|
166
|
+
if all_hunk_lines:
|
|
167
|
+
all_current_lines, _ = _codex_apply_hunk(all_current_lines, all_hunk_lines, search_start)
|
|
168
|
+
if not has_hunk:
|
|
169
|
+
raise CodexPatchError("update section requires a hunk marker")
|
|
170
|
+
return "".join(all_current_lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _codex_add_content(all_section_lines: list[str]) -> str:
|
|
174
|
+
"""Build new content from an Add File section."""
|
|
175
|
+
all_content_lines: list[str] = []
|
|
176
|
+
for each_line in all_section_lines:
|
|
177
|
+
marker_text = _codex_marker_text(each_line)
|
|
178
|
+
if marker_text == _codex_end_of_file_marker:
|
|
179
|
+
continue
|
|
180
|
+
if not each_line or each_line[0] != "+":
|
|
181
|
+
raise CodexPatchError("add section requires added lines")
|
|
182
|
+
all_content_lines.append(each_line[1:])
|
|
183
|
+
return "".join(all_content_lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _codex_read_patch_file(
|
|
187
|
+
operation: str, target_path: Path, all_section_lines: list[str]
|
|
188
|
+
) -> CodexPatchFile:
|
|
189
|
+
"""Read one pre-edit file and project its post-edit content."""
|
|
190
|
+
try:
|
|
191
|
+
prior_content = target_path.read_text(encoding="utf-8")
|
|
192
|
+
except (FileNotFoundError, IsADirectoryError, OSError, UnicodeDecodeError, ValueError) as error:
|
|
193
|
+
if operation == _codex_add_operation and isinstance(error, FileNotFoundError):
|
|
194
|
+
prior_content = ""
|
|
195
|
+
else:
|
|
196
|
+
raise CodexPatchError("patch target requires readable UTF-8 content") from error
|
|
197
|
+
if operation == _codex_add_operation:
|
|
198
|
+
if target_path.exists():
|
|
199
|
+
raise CodexPatchError("add target requires a new path")
|
|
200
|
+
post_content = _codex_add_content(all_section_lines)
|
|
201
|
+
elif operation == _codex_update_operation:
|
|
202
|
+
post_content = _codex_apply_update(prior_content, all_section_lines)
|
|
203
|
+
else:
|
|
204
|
+
if any(
|
|
205
|
+
_codex_marker_text(each_line) != _codex_end_of_file_marker
|
|
206
|
+
for each_line in all_section_lines
|
|
207
|
+
):
|
|
208
|
+
raise CodexPatchError("delete section requires end-of-file content")
|
|
209
|
+
post_content = ""
|
|
210
|
+
return CodexPatchFile(str(target_path), prior_content, post_content, operation)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_codex_apply_patch(
|
|
214
|
+
command: str, working_directory: str | None = None
|
|
215
|
+
) -> tuple[CodexPatchFile, ...]:
|
|
216
|
+
"""Return pre-edit and post-edit views for every Codex patch path."""
|
|
217
|
+
if not isinstance(command, str) or not command.strip():
|
|
218
|
+
raise CodexPatchError("patch command requires text")
|
|
219
|
+
resolved_working_directory = Path(working_directory or os.getcwd()).expanduser().resolve()
|
|
220
|
+
if not resolved_working_directory.is_dir():
|
|
221
|
+
raise CodexPatchError("patch working directory requires an existing directory")
|
|
222
|
+
all_patch_files: list[CodexPatchFile] = []
|
|
223
|
+
seen_paths: set[str] = set()
|
|
224
|
+
for each_operation, each_relative_path, each_section_lines in _codex_patch_sections(command):
|
|
225
|
+
try:
|
|
226
|
+
resolved_path = _codex_resolve_patch_path(
|
|
227
|
+
each_relative_path, resolved_working_directory
|
|
228
|
+
)
|
|
229
|
+
except (OSError, ValueError) as error:
|
|
230
|
+
raise CodexPatchError("patch path requires a resolvable location") from error
|
|
231
|
+
path_key = resolved_path.casefold()
|
|
232
|
+
if path_key in seen_paths:
|
|
233
|
+
raise CodexPatchError("patch paths require unique entries")
|
|
234
|
+
seen_paths.add(path_key)
|
|
235
|
+
all_patch_files.append(
|
|
236
|
+
_codex_read_patch_file(each_operation, Path(resolved_path), each_section_lines)
|
|
237
|
+
)
|
|
238
|
+
return tuple(all_patch_files)
|
|
@@ -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 == ""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for the blast-radius declaration check."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
|
|
12
|
+
from code_rules_blast_radius import check_blast_radius_declared # noqa: E402
|
|
13
|
+
|
|
14
|
+
PRODUCTION_PATH = "pipeline/asset_run.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_should_report_a_loop_raise_with_pending_blast_radius_declaration() -> None:
|
|
18
|
+
"""A loop-body raise gets an advisory requesting its blast-radius declaration."""
|
|
19
|
+
content = (
|
|
20
|
+
"def run(all_members):\n"
|
|
21
|
+
" for each_member in all_members:\n"
|
|
22
|
+
" if each_member.requires_preparation:\n"
|
|
23
|
+
" raise AssetError('member requires preparation')\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
all_issues = check_blast_radius_declared(content, PRODUCTION_PATH)
|
|
27
|
+
|
|
28
|
+
assert len(all_issues) == 1
|
|
29
|
+
assert "Line 4" in all_issues[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_should_accept_a_run_fatal_raise_inside_a_loop() -> None:
|
|
33
|
+
"""A RunFatal type declares that the whole run ends."""
|
|
34
|
+
content = (
|
|
35
|
+
"def run(all_members):\n"
|
|
36
|
+
" for each_member in all_members:\n"
|
|
37
|
+
" if each_member.digest_differs:\n"
|
|
38
|
+
" raise AssetRunFatal('source bytes changed')\n"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_should_accept_an_item_blocked_raise_inside_a_loop() -> None:
|
|
45
|
+
"""An ItemBlocked type declares a member-scoped stop."""
|
|
46
|
+
content = (
|
|
47
|
+
"def run(all_members):\n"
|
|
48
|
+
" for each_member in all_members:\n"
|
|
49
|
+
" if each_member.requires_resize:\n"
|
|
50
|
+
" raise AssetItemBlocked('member requires resizing')\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_should_accept_a_raise_wrapped_by_a_blast_radius_boundary() -> None:
|
|
57
|
+
"""A per-member boundary catches a declared type and parks the failure."""
|
|
58
|
+
content = (
|
|
59
|
+
"def run(all_members):\n"
|
|
60
|
+
" for each_member in all_members:\n"
|
|
61
|
+
" try:\n"
|
|
62
|
+
" if each_member.requires_resize:\n"
|
|
63
|
+
" raise AssetItemBlocked('member requires resizing')\n"
|
|
64
|
+
" except AssetItemBlocked as failure:\n"
|
|
65
|
+
" park(each_member, failure)\n"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_report_runtime_error_with_a_different_declared_handler() -> None:
|
|
72
|
+
"""A different declared handler leaves a runtime crash requiring a name."""
|
|
73
|
+
content = (
|
|
74
|
+
"def run(all_members):\n"
|
|
75
|
+
" for each_member in all_members:\n"
|
|
76
|
+
" try:\n"
|
|
77
|
+
" raise RuntimeError('code defect')\n"
|
|
78
|
+
" except AssetItemBlocked:\n"
|
|
79
|
+
" park(each_member)\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("raised_type", ["RuntimeError", "TypeError", "AttributeError", "ValueError"])
|
|
86
|
+
def test_should_require_corresponding_handler_for_each_explicit_raise(
|
|
87
|
+
raised_type: str,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Each explicit raise needs a handler naming that same type."""
|
|
90
|
+
content = (
|
|
91
|
+
"def run(all_members):\n"
|
|
92
|
+
" for each_member in all_members:\n"
|
|
93
|
+
" try:\n"
|
|
94
|
+
f" raise {raised_type}('code defect')\n"
|
|
95
|
+
" except AssetItemBlocked:\n"
|
|
96
|
+
" park(each_member)\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_accept_a_run_level_raise_during_manifest_preparation() -> None:
|
|
103
|
+
"""A run-level raise belongs to manifest preparation."""
|
|
104
|
+
content = (
|
|
105
|
+
"def prepare(manifest):\n"
|
|
106
|
+
" if manifest.requires_preparation:\n"
|
|
107
|
+
" raise AssetError('manifest requires preparation')\n"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_accept_a_bare_reraise_inside_a_loop() -> None:
|
|
114
|
+
"""A bare re-raise propagates an error carrying a declared radius."""
|
|
115
|
+
content = (
|
|
116
|
+
"def run(all_members):\n"
|
|
117
|
+
" for each_member in all_members:\n"
|
|
118
|
+
" try:\n"
|
|
119
|
+
" process(each_member)\n"
|
|
120
|
+
" except AssetItemBlocked:\n"
|
|
121
|
+
" raise\n"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_accept_test_files() -> None:
|
|
128
|
+
"""Test modules raise freely during scenario coverage."""
|
|
129
|
+
content = (
|
|
130
|
+
"def test_records_member_failure(all_members):\n"
|
|
131
|
+
" for each_member in all_members:\n"
|
|
132
|
+
" raise AssetError('member failure')\n"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert check_blast_radius_declared(content, "pipeline/test_asset_run.py") == []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_should_report_each_loop_raise_with_pending_blast_radius_declaration() -> None:
|
|
139
|
+
"""Each loop-body raise gets an advisory requesting its blast-radius declaration."""
|
|
140
|
+
content = (
|
|
141
|
+
"def run(all_members):\n"
|
|
142
|
+
" for each_member in all_members:\n"
|
|
143
|
+
" if each_member.requires_source:\n"
|
|
144
|
+
" raise AssetError('member requires a source')\n"
|
|
145
|
+
" if each_member.requires_resize:\n"
|
|
146
|
+
" raise AssetError('member requires resizing')\n"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert len(check_blast_radius_declared(content, PRODUCTION_PATH)) == 2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_ignore_raise_in_nested_helper_body() -> None:
|
|
153
|
+
"""A nested helper body is checked at its own call boundary."""
|
|
154
|
+
content = (
|
|
155
|
+
"def run(all_members):\n"
|
|
156
|
+
" for each_member in all_members:\n"
|
|
157
|
+
" def validate_member():\n"
|
|
158
|
+
" raise AssetError('member validation failed')\n"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert check_blast_radius_declared(content, PRODUCTION_PATH) == []
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tests for the Codex apply_patch adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_HOOK_DIRECTORY = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_PARENT = _HOOK_DIRECTORY.parent
|
|
15
|
+
if str(_HOOK_DIRECTORY) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(_HOOK_DIRECTORY))
|
|
17
|
+
if str(_HOOKS_PARENT) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_HOOKS_PARENT))
|
|
19
|
+
|
|
20
|
+
import code_rules_enforcer
|
|
21
|
+
import codex_apply_patch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_codex_payload(
|
|
25
|
+
payload: dict[str, object],
|
|
26
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
27
|
+
capsys: pytest.CaptureFixture[str],
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Run the real enforcer entry point and return its stdout."""
|
|
30
|
+
monkeypatch.setattr(code_rules_enforcer.sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
31
|
+
with contextlib.suppress(SystemExit):
|
|
32
|
+
code_rules_enforcer.main([])
|
|
33
|
+
return capsys.readouterr().out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _production_directory(tmp_path: Path) -> Path:
|
|
37
|
+
"""Return a temporary directory whose path carries production semantics."""
|
|
38
|
+
production_directory = tmp_path.parent / "codex-prod"
|
|
39
|
+
production_directory.mkdir(exist_ok=True)
|
|
40
|
+
return production_directory
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_parse_codex_apply_patch_projects_every_multi_file_operation(
|
|
44
|
+
tmp_path: Path,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""The parser returns pre-edit and post-edit content for update, add, and delete."""
|
|
47
|
+
updated_path = tmp_path / "updated.py"
|
|
48
|
+
deleted_path = tmp_path / "deleted.py"
|
|
49
|
+
updated_path.write_text("before\nkeep\n", encoding="utf-8")
|
|
50
|
+
deleted_path.write_text("remove\n", encoding="utf-8")
|
|
51
|
+
patch = (
|
|
52
|
+
"*** Begin Patch\n"
|
|
53
|
+
"*** Update File: updated.py\n"
|
|
54
|
+
"@@\n"
|
|
55
|
+
"-before\n"
|
|
56
|
+
"+after\n"
|
|
57
|
+
" keep\n"
|
|
58
|
+
"*** Add File: added.py\n"
|
|
59
|
+
"+new\n"
|
|
60
|
+
"*** Delete File: deleted.py\n"
|
|
61
|
+
"*** End Patch"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
all_patch_files = codex_apply_patch.parse_codex_apply_patch(patch, str(tmp_path))
|
|
65
|
+
|
|
66
|
+
views_by_name = {
|
|
67
|
+
Path(each_patch.file_path).name: each_patch for each_patch in all_patch_files
|
|
68
|
+
}
|
|
69
|
+
assert views_by_name["updated.py"].prior_content == "before\nkeep\n"
|
|
70
|
+
assert views_by_name["updated.py"].post_content == "after\nkeep\n"
|
|
71
|
+
assert views_by_name["added.py"].prior_content == ""
|
|
72
|
+
assert views_by_name["added.py"].post_content == "new\n"
|
|
73
|
+
assert views_by_name["deleted.py"].prior_content == "remove\n"
|
|
74
|
+
assert views_by_name["deleted.py"].post_content == ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_codex_payload_allows_declared_blast_radius(
|
|
78
|
+
tmp_path: Path,
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
capsys: pytest.CaptureFixture[str],
|
|
81
|
+
) -> None:
|
|
82
|
+
"""A loop raise with a declared stopping scope passes the Codex hook."""
|
|
83
|
+
production_directory = _production_directory(tmp_path)
|
|
84
|
+
payload = {
|
|
85
|
+
"tool_name": "apply_patch",
|
|
86
|
+
"cwd": str(production_directory),
|
|
87
|
+
"tool_input": {
|
|
88
|
+
"command": (
|
|
89
|
+
"*** Begin Patch\n"
|
|
90
|
+
"*** Add File: module.py\n"
|
|
91
|
+
"+for each_member in all_members:\n"
|
|
92
|
+
"+ raise AssetItemBlocked()\n"
|
|
93
|
+
"*** End Patch"
|
|
94
|
+
)
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
99
|
+
|
|
100
|
+
assert stdout == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_codex_payload_blocks_undeclared_blast_radius(
|
|
104
|
+
tmp_path: Path,
|
|
105
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
106
|
+
capsys: pytest.CaptureFixture[str],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""A loop raise requires a stopping-scope declaration for acceptance."""
|
|
109
|
+
production_directory = _production_directory(tmp_path)
|
|
110
|
+
payload = {
|
|
111
|
+
"tool_name": "apply_patch",
|
|
112
|
+
"cwd": str(production_directory),
|
|
113
|
+
"tool_input": {
|
|
114
|
+
"command": (
|
|
115
|
+
"*** Begin Patch\n"
|
|
116
|
+
"*** Add File: module.py\n"
|
|
117
|
+
"+for each_member in all_members:\n"
|
|
118
|
+
"+ raise RuntimeError()\n"
|
|
119
|
+
"*** End Patch"
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
125
|
+
|
|
126
|
+
deny_payload = json.loads(stdout)
|
|
127
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
128
|
+
assert "blast radius" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_codex_payload_blocks_malformed_patch(
|
|
132
|
+
tmp_path: Path,
|
|
133
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
134
|
+
capsys: pytest.CaptureFixture[str],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""A malformed Codex patch returns a blocking diagnostic."""
|
|
137
|
+
production_directory = _production_directory(tmp_path)
|
|
138
|
+
payload = {
|
|
139
|
+
"tool_name": "apply_patch",
|
|
140
|
+
"cwd": str(production_directory),
|
|
141
|
+
"tool_input": {"command": "*** Begin Patch\n*** End Patch"},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
145
|
+
|
|
146
|
+
deny_payload = json.loads(stdout)
|
|
147
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
148
|
+
assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|