claude-dev-env 2.20.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/hooks/blocking/code_rules_enforcer.py +83 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +148 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +395 -17
- package/scripts/tests/test_codex_compat_materializer.py +17 -3
|
@@ -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,
|
|
@@ -203,6 +206,11 @@ from code_rules_typeddict_stub import ( # noqa: E402
|
|
|
203
206
|
from code_rules_unused_imports import ( # noqa: E402
|
|
204
207
|
check_unused_module_level_imports,
|
|
205
208
|
)
|
|
209
|
+
from codex_apply_patch import ( # noqa: E402
|
|
210
|
+
CodexPatchError,
|
|
211
|
+
CodexPatchFile,
|
|
212
|
+
parse_codex_apply_patch,
|
|
213
|
+
)
|
|
206
214
|
|
|
207
215
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
208
216
|
ALL_CODE_EXTENSIONS,
|
|
@@ -211,6 +219,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
211
219
|
DENY_REASON_ISSUE_PREVIEW_COUNT,
|
|
212
220
|
PRECHECK_USAGE_EXIT_CODE,
|
|
213
221
|
PRECHECK_USAGE_MESSAGE,
|
|
222
|
+
VIOLATION_SEPARATOR,
|
|
214
223
|
)
|
|
215
224
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
216
225
|
from hooks_constants.setup_project_paths_constants import ( # noqa: E402
|
|
@@ -218,6 +227,76 @@ from hooks_constants.setup_project_paths_constants import ( # noqa: E402
|
|
|
218
227
|
)
|
|
219
228
|
|
|
220
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
|
+
|
|
221
300
|
def validate_content(
|
|
222
301
|
content: str,
|
|
223
302
|
file_path: str,
|
|
@@ -1259,6 +1338,10 @@ def main(all_arguments: list[str]) -> None:
|
|
|
1259
1338
|
sys.exit(0)
|
|
1260
1339
|
|
|
1261
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
|
+
|
|
1262
1345
|
tool_input = pretooluse_payload.get("tool_input", {})
|
|
1263
1346
|
file_path = tool_input.get("file_path", "")
|
|
1264
1347
|
|
|
@@ -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,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"]
|
|
@@ -36,6 +36,7 @@ ADVISORY_LINE_THRESHOLD_SOFT = 400
|
|
|
36
36
|
ADVISORY_LINE_THRESHOLD_HARD = 1000
|
|
37
37
|
|
|
38
38
|
DENY_REASON_ISSUE_PREVIEW_COUNT = 10
|
|
39
|
+
VIOLATION_SEPARATOR = "; "
|
|
39
40
|
|
|
40
41
|
ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
|
|
41
42
|
UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
package/package.json
CHANGED
|
@@ -9,14 +9,14 @@ import os
|
|
|
9
9
|
import re
|
|
10
10
|
import stat
|
|
11
11
|
import tempfile
|
|
12
|
-
import
|
|
12
|
+
from collections.abc import Callable, Iterable
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import
|
|
15
|
+
from typing import NoReturn
|
|
16
16
|
|
|
17
|
+
import tomllib
|
|
17
18
|
import yaml
|
|
18
19
|
|
|
19
|
-
|
|
20
20
|
ManagedContent = str | bytes
|
|
21
21
|
|
|
22
22
|
path_separator = "/"
|
|
@@ -27,6 +27,87 @@ publish_plan_max_positional_arguments = 3
|
|
|
27
27
|
publish_plan_failure_injector_position = 2
|
|
28
28
|
frontmatter_unsupported_fields = ("tools", "model", "color")
|
|
29
29
|
instruction_alias_filenames = frozenset({"AGENTS.md", "CLAUDE.md"})
|
|
30
|
+
failure_blast_radius_rule_relative_path = "rules/failure-blast-radius.md"
|
|
31
|
+
codex_instruction_target_path = "AGENTS.md"
|
|
32
|
+
codex_instruction_section_heading = "## Excerpt for repository-instruction sessions"
|
|
33
|
+
codex_hook_manifest_source_path = "hooks/hooks.json"
|
|
34
|
+
codex_hook_manifest_target_path = "hooks.json"
|
|
35
|
+
codex_hook_event_name = "PreToolUse"
|
|
36
|
+
codex_hook_matcher = "apply_patch"
|
|
37
|
+
codex_enforcer_script_relative_path = "hooks/blocking/code_rules_enforcer.py"
|
|
38
|
+
codex_enforcer_script_name = "code_rules_enforcer.py"
|
|
39
|
+
codex_enforcer_path_suffix = path_separator + codex_enforcer_script_relative_path
|
|
40
|
+
codex_hook_command_token_pattern = r'''(?:"[^"]*"|'[^']*'|\S+)'''
|
|
41
|
+
codex_hook_merge_action = "merge"
|
|
42
|
+
codex_hook_timeout_seconds = 60
|
|
43
|
+
codex_hook_dependency_manifest = (
|
|
44
|
+
"hooks/blocking/__init__.py",
|
|
45
|
+
"hooks/blocking/code_rules_annotations_length.py",
|
|
46
|
+
"hooks/blocking/code_rules_banned_identifiers.py",
|
|
47
|
+
"hooks/blocking/code_rules_blast_radius.py",
|
|
48
|
+
"hooks/blocking/code_rules_boolean_mustcheck.py",
|
|
49
|
+
"hooks/blocking/code_rules_command_dispatch.py",
|
|
50
|
+
"hooks/blocking/code_rules_comments.py",
|
|
51
|
+
"hooks/blocking/code_rules_constants_config.py",
|
|
52
|
+
"hooks/blocking/codex_apply_patch.py",
|
|
53
|
+
"hooks/blocking/code_rules_dead_argparse_argument.py",
|
|
54
|
+
"hooks/blocking/code_rules_dead_config_field.py",
|
|
55
|
+
"hooks/blocking/code_rules_dead_dataclass_field.py",
|
|
56
|
+
"hooks/blocking/code_rules_dead_module_constant.py",
|
|
57
|
+
"hooks/blocking/code_rules_dead_split_branch.py",
|
|
58
|
+
"hooks/blocking/code_rules_docstrings.py",
|
|
59
|
+
"hooks/blocking/code_rules_duplicate_body.py",
|
|
60
|
+
"hooks/blocking/code_rules_enforcer.py",
|
|
61
|
+
"hooks/blocking/code_rules_imports_logging.py",
|
|
62
|
+
"hooks/blocking/code_rules_js_conventions.py",
|
|
63
|
+
"hooks/blocking/code_rules_magic_values.py",
|
|
64
|
+
"hooks/blocking/code_rules_mock_completeness.py",
|
|
65
|
+
"hooks/blocking/code_rules_naming_collection.py",
|
|
66
|
+
"hooks/blocking/code_rules_optional_params.py",
|
|
67
|
+
"hooks/blocking/code_rules_orphan_css_class.py",
|
|
68
|
+
"hooks/blocking/code_rules_paired_test.py",
|
|
69
|
+
"hooks/blocking/code_rules_path_utils.py",
|
|
70
|
+
"hooks/blocking/code_rules_paths_syspath.py",
|
|
71
|
+
"hooks/blocking/code_rules_probe_chains.py",
|
|
72
|
+
"hooks/blocking/code_rules_probe_detection.py",
|
|
73
|
+
"hooks/blocking/code_rules_probe_recording.py",
|
|
74
|
+
"hooks/blocking/code_rules_scope_binding.py",
|
|
75
|
+
"hooks/blocking/code_rules_shared.py",
|
|
76
|
+
"hooks/blocking/code_rules_string_magic.py",
|
|
77
|
+
"hooks/blocking/code_rules_test_assertions.py",
|
|
78
|
+
"hooks/blocking/code_rules_test_branching_except.py",
|
|
79
|
+
"hooks/blocking/code_rules_test_isolation.py",
|
|
80
|
+
"hooks/blocking/code_rules_test_layout.py",
|
|
81
|
+
"hooks/blocking/code_rules_type_escape.py",
|
|
82
|
+
"hooks/blocking/code_rules_typeddict_stub.py",
|
|
83
|
+
"hooks/blocking/code_rules_unused_imports.py",
|
|
84
|
+
"hooks/hooks_constants/__init__.py",
|
|
85
|
+
"hooks/hooks_constants/any_type_config.py",
|
|
86
|
+
"hooks/hooks_constants/banned_identifiers_constants.py",
|
|
87
|
+
"hooks/hooks_constants/blast_radius_constants.py",
|
|
88
|
+
"hooks/hooks_constants/blocking_check_limits.py",
|
|
89
|
+
"hooks/hooks_constants/code_rules_enforcer_constants.py",
|
|
90
|
+
"hooks/hooks_constants/code_rules_path_utils_constants.py",
|
|
91
|
+
"hooks/hooks_constants/command_dispatch_constants.py",
|
|
92
|
+
"hooks/hooks_constants/dead_argparse_argument_constants.py",
|
|
93
|
+
"hooks/hooks_constants/dead_config_field_constants.py",
|
|
94
|
+
"hooks/hooks_constants/dead_dataclass_field_constants.py",
|
|
95
|
+
"hooks/hooks_constants/dead_module_constant_constants.py",
|
|
96
|
+
"hooks/hooks_constants/duplicate_function_body_constants.py",
|
|
97
|
+
"hooks/hooks_constants/hardcoded_user_path_constants.py",
|
|
98
|
+
"hooks/hooks_constants/harness_scratchpad_constants.py",
|
|
99
|
+
"hooks/hooks_constants/hook_block_logger.py",
|
|
100
|
+
"hooks/hooks_constants/inline_tuple_string_magic_constants.py",
|
|
101
|
+
"hooks/hooks_constants/js_conventions_constants.py",
|
|
102
|
+
"hooks/hooks_constants/orphan_css_class_constants.py",
|
|
103
|
+
"hooks/hooks_constants/paired_test_coverage_constants.py",
|
|
104
|
+
"hooks/hooks_constants/setup_project_paths_constants.py",
|
|
105
|
+
"hooks/hooks_constants/stuttering_check_config.py",
|
|
106
|
+
"hooks/hooks_constants/stuttering_import_binding_constants.py",
|
|
107
|
+
"hooks/hooks_constants/sys_path_insert_constants.py",
|
|
108
|
+
"hooks/hooks_constants/test_layout_constants.py",
|
|
109
|
+
"hooks/hooks_constants/unused_module_import_constants.py",
|
|
110
|
+
)
|
|
30
111
|
full_prune_opt_in_flag = "--allow-prune-all"
|
|
31
112
|
unreadable_source_root_message = (
|
|
32
113
|
"source root is missing or is not a directory, so nothing was planned or changed; "
|
|
@@ -50,6 +131,10 @@ class MaterializerError(ValueError):
|
|
|
50
131
|
"""Raised when a materialization request cannot be safely planned."""
|
|
51
132
|
|
|
52
133
|
|
|
134
|
+
class MaterializerRunFatal(MaterializerError):
|
|
135
|
+
"""Raised when invalid materializer state stops the whole run."""
|
|
136
|
+
|
|
137
|
+
|
|
53
138
|
class ArgumentParserError(ValueError):
|
|
54
139
|
"""Raised when command-line arguments cannot be parsed."""
|
|
55
140
|
|
|
@@ -57,7 +142,7 @@ class ArgumentParserError(ValueError):
|
|
|
57
142
|
class MaterializerArgumentParser(argparse.ArgumentParser):
|
|
58
143
|
"""Parse materializer arguments while keeping errors in the JSON contract."""
|
|
59
144
|
|
|
60
|
-
def error(self, message: str) ->
|
|
145
|
+
def error(self, message: str) -> NoReturn:
|
|
61
146
|
"""Raise a reportable parser error instead of writing process output."""
|
|
62
147
|
raise ArgumentParserError(message)
|
|
63
148
|
|
|
@@ -407,6 +492,246 @@ def convert_agent(agent: ClaudeAgent) -> str:
|
|
|
407
492
|
return content
|
|
408
493
|
|
|
409
494
|
|
|
495
|
+
def render_codex_failure_blast_radius(rule_content: str) -> str:
|
|
496
|
+
"""Extract the repository-instruction contract from the canonical rule.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
rule_content: Canonical failure blast-radius rule text.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
The fenced repository-instruction excerpt with a trailing newline.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
MaterializerError: If the canonical rule lacks the required excerpt.
|
|
506
|
+
"""
|
|
507
|
+
heading_start = rule_content.find(codex_instruction_section_heading)
|
|
508
|
+
if heading_start < 0:
|
|
509
|
+
raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
|
|
510
|
+
fence_start = rule_content.find("```", heading_start)
|
|
511
|
+
if fence_start < 0:
|
|
512
|
+
raise MaterializerError("failure blast-radius rule requires a Codex excerpt")
|
|
513
|
+
content_start = rule_content.find(line_separator, fence_start)
|
|
514
|
+
fence_end = rule_content.find(line_separator + "```", content_start + 1)
|
|
515
|
+
if content_start < 0 or fence_end < 0:
|
|
516
|
+
raise MaterializerError("failure blast-radius rule requires a complete Codex excerpt")
|
|
517
|
+
return rule_content[content_start + len(line_separator) : fence_end].rstrip() + line_separator
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _build_codex_instruction_projection(config: MaterializerConfig) -> PlannedFile | None:
|
|
521
|
+
"""Build the managed AGENTS.md projection when the canonical rule is present."""
|
|
522
|
+
source_path = config.source_root / failure_blast_radius_rule_relative_path
|
|
523
|
+
if not source_path.is_file():
|
|
524
|
+
return None
|
|
525
|
+
rule_content = source_path.read_text(encoding="utf-8")
|
|
526
|
+
projected_content = render_codex_failure_blast_radius(rule_content)
|
|
527
|
+
return PlannedFile(
|
|
528
|
+
failure_blast_radius_rule_relative_path,
|
|
529
|
+
codex_instruction_target_path,
|
|
530
|
+
projected_content,
|
|
531
|
+
hash_content(projected_content),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _read_json_object(file_path: Path, description: str) -> dict[str, object]:
|
|
536
|
+
"""Read one JSON object and report the required content shape."""
|
|
537
|
+
try:
|
|
538
|
+
parsed_json = json.loads(file_path.read_text(encoding="utf-8"))
|
|
539
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
540
|
+
raise MaterializerError(f"{description} requires readable UTF-8 content: {file_path}") from error
|
|
541
|
+
if not isinstance(parsed_json, dict):
|
|
542
|
+
raise MaterializerError(f"{description} must be a JSON object: {file_path}")
|
|
543
|
+
if not all(isinstance(each_key, str) for each_key in parsed_json):
|
|
544
|
+
raise MaterializerError(f"{description} requires string keys: {file_path}")
|
|
545
|
+
return {each_key: each_record for each_key, each_record in parsed_json.items()}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _validated_agent_iterable(candidate: object) -> tuple[ClaudeAgent, ...]:
|
|
549
|
+
"""Validate the optional agent iterable used by the legacy call form."""
|
|
550
|
+
if not isinstance(candidate, Iterable):
|
|
551
|
+
raise TypeError("agent collection requires an iterable")
|
|
552
|
+
all_candidate_agents = tuple(candidate)
|
|
553
|
+
if not all(isinstance(each_agent, ClaudeAgent) for each_agent in all_candidate_agents):
|
|
554
|
+
raise TypeError("agent collection requires ClaudeAgent entries")
|
|
555
|
+
return all_candidate_agents
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _validated_planned_file_iterable(candidate: object) -> tuple[PlannedFile, ...]:
|
|
559
|
+
"""Validate planned files passed through the legacy publication call form."""
|
|
560
|
+
if not isinstance(candidate, Iterable):
|
|
561
|
+
raise TypeError("planned files require an iterable")
|
|
562
|
+
all_candidate_files = tuple(candidate)
|
|
563
|
+
if not all(isinstance(each_file, PlannedFile) for each_file in all_candidate_files):
|
|
564
|
+
raise TypeError("planned files require PlannedFile entries")
|
|
565
|
+
return all_candidate_files
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _find_codex_hook_source(config: MaterializerConfig) -> Path | None:
|
|
569
|
+
"""Find the source hook manifest alongside the compatibility sources."""
|
|
570
|
+
all_candidates = (
|
|
571
|
+
config.source_root / codex_hook_manifest_source_path,
|
|
572
|
+
config.source_root / Path(codex_hook_manifest_source_path).name,
|
|
573
|
+
)
|
|
574
|
+
return next((each_path for each_path in all_candidates if each_path.is_file()), None)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _resolved_codex_enforcer_command(config: MaterializerConfig) -> str:
|
|
578
|
+
"""Build the target-root command for the focused Codex enforcer."""
|
|
579
|
+
script_path = (config.target_root / codex_enforcer_script_relative_path).resolve()
|
|
580
|
+
return f'python3 "{script_path}"'
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _source_codex_enforcer_hook(
|
|
584
|
+
all_source_manifest: dict[str, object], command: str
|
|
585
|
+
) -> dict[str, object]:
|
|
586
|
+
"""Read the source enforcer hook shape and resolve its target command."""
|
|
587
|
+
all_events = all_source_manifest.get("hooks")
|
|
588
|
+
if not isinstance(all_events, dict):
|
|
589
|
+
raise MaterializerError("source Codex hook manifest requires a hooks object")
|
|
590
|
+
all_pre_tool_use = all_events.get(codex_hook_event_name)
|
|
591
|
+
if not isinstance(all_pre_tool_use, list):
|
|
592
|
+
raise MaterializerError("source Codex hook manifest requires a PreToolUse list")
|
|
593
|
+
for each_entry in all_pre_tool_use:
|
|
594
|
+
if not isinstance(each_entry, dict) or each_entry.get("matcher") != codex_hook_matcher:
|
|
595
|
+
continue
|
|
596
|
+
all_hook_records = each_entry.get("hooks")
|
|
597
|
+
if not isinstance(all_hook_records, list):
|
|
598
|
+
continue
|
|
599
|
+
for each_hook in all_hook_records:
|
|
600
|
+
if not isinstance(each_hook, dict):
|
|
601
|
+
continue
|
|
602
|
+
if not _is_code_rules_enforcer_hook(each_hook):
|
|
603
|
+
continue
|
|
604
|
+
resolved_hook = dict(each_hook)
|
|
605
|
+
resolved_hook["command"] = command
|
|
606
|
+
return resolved_hook
|
|
607
|
+
return {
|
|
608
|
+
"type": "command",
|
|
609
|
+
"command": command,
|
|
610
|
+
"timeout": codex_hook_timeout_seconds,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _hook_records(raw_hooks: object) -> list[dict[str, object]]:
|
|
615
|
+
"""Validate and copy a Codex hook-record list."""
|
|
616
|
+
if not isinstance(raw_hooks, list):
|
|
617
|
+
raise MaterializerError("Codex hook manifest entry requires a hooks list")
|
|
618
|
+
if not all(isinstance(each_hook, dict) for each_hook in raw_hooks):
|
|
619
|
+
raise MaterializerError("Codex hook manifest requires valid hook entries")
|
|
620
|
+
return [dict(each_hook) for each_hook in raw_hooks]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
|
|
624
|
+
"""Report whether a hook record names the focused code-rules enforcer."""
|
|
625
|
+
command = str(all_hook_record.get("command", ""))
|
|
626
|
+
normalized_command = command.replace("\\", path_separator)
|
|
627
|
+
all_command_tokens = (
|
|
628
|
+
each_token.strip("\"'")
|
|
629
|
+
for each_token in re.findall(codex_hook_command_token_pattern, normalized_command)
|
|
630
|
+
)
|
|
631
|
+
return any(
|
|
632
|
+
each_token in (codex_enforcer_script_name, codex_enforcer_script_relative_path)
|
|
633
|
+
or each_token.endswith(codex_enforcer_path_suffix)
|
|
634
|
+
for each_token in all_command_tokens
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _merge_codex_hook_manifest(
|
|
639
|
+
all_target_manifest: dict[str, object], all_focused_hook: dict[str, object]
|
|
640
|
+
) -> dict[str, object]:
|
|
641
|
+
"""Preserve target hook order while merging one deterministic enforcer entry."""
|
|
642
|
+
all_events = all_target_manifest.get("hooks", {})
|
|
643
|
+
if not isinstance(all_events, dict):
|
|
644
|
+
raise MaterializerError("Codex hook manifest requires a hooks object")
|
|
645
|
+
all_pre_tool_use = all_events.get(codex_hook_event_name, [])
|
|
646
|
+
if not isinstance(all_pre_tool_use, list):
|
|
647
|
+
raise MaterializerError("Codex hook manifest requires a PreToolUse list")
|
|
648
|
+
merged_pre_tool_use: list[dict[str, object]] = []
|
|
649
|
+
merged_apply_patch_entry: dict[str, object] | None = None
|
|
650
|
+
merged_hooks: list[dict[str, object]] = []
|
|
651
|
+
for each_entry in all_pre_tool_use:
|
|
652
|
+
if not isinstance(each_entry, dict):
|
|
653
|
+
raise MaterializerRunFatal("Codex hook manifest requires valid matcher entries")
|
|
654
|
+
copied_entry = dict(each_entry)
|
|
655
|
+
if copied_entry.get("matcher") != codex_hook_matcher:
|
|
656
|
+
merged_pre_tool_use.append(copied_entry)
|
|
657
|
+
continue
|
|
658
|
+
all_hook_records = _hook_records(copied_entry.get("hooks", []))
|
|
659
|
+
if merged_apply_patch_entry is None:
|
|
660
|
+
merged_apply_patch_entry = copied_entry
|
|
661
|
+
merged_hooks = [
|
|
662
|
+
each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
|
|
663
|
+
]
|
|
664
|
+
merged_apply_patch_entry["hooks"] = merged_hooks
|
|
665
|
+
merged_pre_tool_use.append(merged_apply_patch_entry)
|
|
666
|
+
else:
|
|
667
|
+
merged_hooks.extend(
|
|
668
|
+
each_hook for each_hook in all_hook_records if not _is_code_rules_enforcer_hook(each_hook)
|
|
669
|
+
)
|
|
670
|
+
if merged_apply_patch_entry is None:
|
|
671
|
+
merged_apply_patch_entry = {"matcher": codex_hook_matcher, "hooks": merged_hooks}
|
|
672
|
+
merged_pre_tool_use.append(merged_apply_patch_entry)
|
|
673
|
+
merged_hooks.append(all_focused_hook)
|
|
674
|
+
merged_events = dict(all_events)
|
|
675
|
+
merged_events[codex_hook_event_name] = merged_pre_tool_use
|
|
676
|
+
merged_manifest = dict(all_target_manifest)
|
|
677
|
+
merged_manifest["hooks"] = merged_events
|
|
678
|
+
return merged_manifest
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _build_codex_hook_projection(config: MaterializerConfig) -> PlannedFile | None:
|
|
682
|
+
"""Build an additive managed hooks.json projection when its source exists."""
|
|
683
|
+
source_path = _find_codex_hook_source(config)
|
|
684
|
+
if source_path is None:
|
|
685
|
+
return None
|
|
686
|
+
source_manifest = _read_json_object(source_path, "source Codex hook manifest")
|
|
687
|
+
target_path = config.target_root / codex_hook_manifest_target_path
|
|
688
|
+
target_manifest = (
|
|
689
|
+
_read_json_object(target_path, "target Codex hook manifest")
|
|
690
|
+
if target_path.is_file()
|
|
691
|
+
else {"hooks": {}}
|
|
692
|
+
)
|
|
693
|
+
focused_hook = _source_codex_enforcer_hook(
|
|
694
|
+
source_manifest, _resolved_codex_enforcer_command(config)
|
|
695
|
+
)
|
|
696
|
+
projected_manifest = _merge_codex_hook_manifest(target_manifest, focused_hook)
|
|
697
|
+
projected_content = json.dumps(projected_manifest, ensure_ascii=False, indent=manifest_indentation_width) + line_separator
|
|
698
|
+
return PlannedFile(
|
|
699
|
+
codex_hook_manifest_source_path,
|
|
700
|
+
codex_hook_manifest_target_path,
|
|
701
|
+
projected_content,
|
|
702
|
+
hash_content(projected_content),
|
|
703
|
+
action=codex_hook_merge_action,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _build_codex_hook_dependency_projection(
|
|
708
|
+
config: MaterializerConfig,
|
|
709
|
+
) -> list[PlannedFile]:
|
|
710
|
+
"""Build the reviewed source files required by the target enforcer."""
|
|
711
|
+
all_dependencies: list[PlannedFile] = []
|
|
712
|
+
for each_relative_path in codex_hook_dependency_manifest:
|
|
713
|
+
source_path = config.source_root / each_relative_path
|
|
714
|
+
if not source_path.is_file():
|
|
715
|
+
raise MaterializerError(
|
|
716
|
+
f"source file is missing from the reviewed hook source list: {each_relative_path}"
|
|
717
|
+
)
|
|
718
|
+
try:
|
|
719
|
+
dependency_content = source_path.read_bytes()
|
|
720
|
+
except OSError as error:
|
|
721
|
+
raise MaterializerError(
|
|
722
|
+
f"reviewed Codex hook dependency has unreadable bytes: {each_relative_path}"
|
|
723
|
+
) from error
|
|
724
|
+
all_dependencies.append(
|
|
725
|
+
PlannedFile(
|
|
726
|
+
each_relative_path,
|
|
727
|
+
each_relative_path,
|
|
728
|
+
dependency_content,
|
|
729
|
+
hash_content(dependency_content),
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
return all_dependencies
|
|
733
|
+
|
|
734
|
+
|
|
410
735
|
def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
|
|
411
736
|
"""Discover and parse Markdown agents below the source root.
|
|
412
737
|
|
|
@@ -437,14 +762,17 @@ def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
|
|
|
437
762
|
if _is_reparse_point(each_path):
|
|
438
763
|
raise MaterializerError(f"source reparse point is not allowed: {each_path}")
|
|
439
764
|
relative_source = each_path.relative_to(config.source_root).as_posix()
|
|
765
|
+
if relative_source == failure_blast_radius_rule_relative_path:
|
|
766
|
+
_validate_containment(config.source_root, each_path)
|
|
767
|
+
continue
|
|
440
768
|
_validate_containment(config.source_root, each_path)
|
|
441
769
|
all_agents.append(parse_frontmatter(each_path, each_path.read_text(encoding="utf-8"), relative_source))
|
|
442
770
|
return all_agents
|
|
443
771
|
|
|
444
772
|
|
|
445
|
-
def _case_fold_collision_error(target_relative_path: str) ->
|
|
773
|
+
def _case_fold_collision_error(target_relative_path: str) -> MaterializerRunFatal:
|
|
446
774
|
"""Build the error for two target names that differ only by letter case."""
|
|
447
|
-
return
|
|
775
|
+
return MaterializerRunFatal(f"case-fold collision: {target_relative_path}")
|
|
448
776
|
|
|
449
777
|
|
|
450
778
|
def _validate_orphan_target_is_adoptable(
|
|
@@ -479,6 +807,13 @@ def _validate_orphan_target_is_adoptable(
|
|
|
479
807
|
raise MaterializerError(unmanaged_target_message.format(path=target_relative_path))
|
|
480
808
|
|
|
481
809
|
|
|
810
|
+
def _configured_manifest_path(config: MaterializerConfig) -> Path:
|
|
811
|
+
"""Return the manifest path established during configuration validation."""
|
|
812
|
+
if config.manifest_path is None:
|
|
813
|
+
raise MaterializerError("compatibility manifest path requires configuration")
|
|
814
|
+
return config.manifest_path
|
|
815
|
+
|
|
816
|
+
|
|
482
817
|
def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -> tuple[list[PlannedFile], MaterializationReport]:
|
|
483
818
|
"""Build planned agent publications and their report.
|
|
484
819
|
|
|
@@ -495,7 +830,8 @@ def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -
|
|
|
495
830
|
report = MaterializationReport()
|
|
496
831
|
planned: list[PlannedFile] = []
|
|
497
832
|
target_by_name: dict[str, str] = {}
|
|
498
|
-
|
|
833
|
+
manifest_path = _configured_manifest_path(config)
|
|
834
|
+
previous_records = _manifest_record_by_path(load_manifest(manifest_path))
|
|
499
835
|
existing_by_name = {
|
|
500
836
|
each_path.relative_to(config.target_root).as_posix().casefold(): each_path
|
|
501
837
|
for each_path in config.target_root.rglob("*")
|
|
@@ -506,16 +842,45 @@ def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -
|
|
|
506
842
|
target_relative_path = _normalize_relative_path(each_agent.name + toml_suffix)
|
|
507
843
|
folded_path = target_relative_path.casefold()
|
|
508
844
|
if folded_path in target_by_name:
|
|
509
|
-
raise
|
|
845
|
+
raise MaterializerRunFatal(f"case-fold collision: {target_relative_path}")
|
|
510
846
|
content = convert_agent(each_agent)
|
|
511
847
|
_validate_orphan_target_is_adoptable(config, existing_by_name.get(folded_path), target_relative_path, content)
|
|
512
848
|
target_by_name[folded_path] = target_relative_path
|
|
513
849
|
target_path = validate_target_path(config.target_root, target_relative_path)
|
|
514
|
-
if _casefold_normalized_path(target_path) == _casefold_normalized_path(
|
|
515
|
-
raise
|
|
850
|
+
if _casefold_normalized_path(target_path) == _casefold_normalized_path(manifest_path):
|
|
851
|
+
raise MaterializerRunFatal("planned target collides with compatibility manifest")
|
|
516
852
|
planned.append(PlannedFile(source_identity, target_relative_path, content, hash_content(content)))
|
|
517
853
|
report.unsupported += len(each_agent.unsupported)
|
|
518
854
|
report.details["unsupported"].extend(f"{source_identity}:{each_key}" for each_key in each_agent.unsupported)
|
|
855
|
+
codex_instruction = _build_codex_instruction_projection(config)
|
|
856
|
+
if codex_instruction is not None:
|
|
857
|
+
folded_path = codex_instruction.target_relative_path.casefold()
|
|
858
|
+
if folded_path in target_by_name:
|
|
859
|
+
raise _case_fold_collision_error(codex_instruction.target_relative_path)
|
|
860
|
+
_validate_orphan_target_is_adoptable(
|
|
861
|
+
config,
|
|
862
|
+
existing_by_name.get(folded_path),
|
|
863
|
+
codex_instruction.target_relative_path,
|
|
864
|
+
codex_instruction.content,
|
|
865
|
+
)
|
|
866
|
+
validate_target_path(config.target_root, codex_instruction.target_relative_path)
|
|
867
|
+
planned.append(codex_instruction)
|
|
868
|
+
target_by_name[folded_path] = codex_instruction.target_relative_path
|
|
869
|
+
codex_hooks = _build_codex_hook_projection(config)
|
|
870
|
+
if codex_hooks is not None:
|
|
871
|
+
all_hook_dependencies = _build_codex_hook_dependency_projection(config)
|
|
872
|
+
for each_dependency in all_hook_dependencies:
|
|
873
|
+
folded_dependency_path = each_dependency.target_relative_path.casefold()
|
|
874
|
+
if folded_dependency_path in target_by_name:
|
|
875
|
+
raise _case_fold_collision_error(each_dependency.target_relative_path)
|
|
876
|
+
validate_target_path(config.target_root, each_dependency.target_relative_path)
|
|
877
|
+
target_by_name[folded_dependency_path] = each_dependency.target_relative_path
|
|
878
|
+
planned.append(each_dependency)
|
|
879
|
+
folded_path = codex_hooks.target_relative_path.casefold()
|
|
880
|
+
if folded_path in target_by_name:
|
|
881
|
+
raise _case_fold_collision_error(codex_hooks.target_relative_path)
|
|
882
|
+
validate_target_path(config.target_root, codex_hooks.target_relative_path)
|
|
883
|
+
planned.append(codex_hooks)
|
|
519
884
|
report.planned_files = planned
|
|
520
885
|
return planned, report
|
|
521
886
|
|
|
@@ -542,7 +907,11 @@ def build_plan(config: MaterializerConfig, *all_arguments: object, **all_keyword
|
|
|
542
907
|
if supplied_agents is not None:
|
|
543
908
|
raise TypeError("build_plan received duplicate all_agents")
|
|
544
909
|
supplied_agents = all_arguments[0]
|
|
545
|
-
discovered_agents =
|
|
910
|
+
discovered_agents = (
|
|
911
|
+
discover_agents(config)
|
|
912
|
+
if supplied_agents is None
|
|
913
|
+
else _validated_agent_iterable(supplied_agents)
|
|
914
|
+
)
|
|
546
915
|
return _build_plan(config, discovered_agents)
|
|
547
916
|
|
|
548
917
|
|
|
@@ -770,6 +1139,8 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
|
|
|
770
1139
|
if current_bytes == content_to_bytes(planned_file.content):
|
|
771
1140
|
_record_matching_target(report, planned_file.target_relative_path, previous_record)
|
|
772
1141
|
return target_path, current_bytes, False
|
|
1142
|
+
if planned_file.action == codex_hook_merge_action:
|
|
1143
|
+
return target_path, current_bytes, True
|
|
773
1144
|
if _is_pristine_managed(previous_record, current_bytes):
|
|
774
1145
|
return target_path, current_bytes, True
|
|
775
1146
|
_record_target_conflict(report, planned_file.target_relative_path, previous_record)
|
|
@@ -845,9 +1216,10 @@ def _publish_planned_targets(
|
|
|
845
1216
|
all_backups: dict[Path, bytes | None],
|
|
846
1217
|
failure_injector: Callable[[str], None] | None,
|
|
847
1218
|
) -> None:
|
|
1219
|
+
manifest_path = _configured_manifest_path(config)
|
|
848
1220
|
for each_planned_file in all_planned_files:
|
|
849
1221
|
target_path, current_bytes, is_publishable = _record_target_state(config, each_planned_file, all_previous_records, report)
|
|
850
|
-
if _casefold_normalized_path(target_path) == _casefold_normalized_path(
|
|
1222
|
+
if _casefold_normalized_path(target_path) == _casefold_normalized_path(manifest_path):
|
|
851
1223
|
raise MaterializerError("planned target collides with compatibility manifest")
|
|
852
1224
|
if not is_publishable:
|
|
853
1225
|
continue
|
|
@@ -950,7 +1322,8 @@ def _publish_plan(
|
|
|
950
1322
|
publication.planned_files = all_planned_files
|
|
951
1323
|
if not config.should_apply:
|
|
952
1324
|
return publication
|
|
953
|
-
|
|
1325
|
+
manifest_path = _configured_manifest_path(config)
|
|
1326
|
+
previous_manifest = load_manifest(manifest_path)
|
|
954
1327
|
previous_records = _manifest_record_by_path(previous_manifest)
|
|
955
1328
|
_validate_full_prune_consent(config, all_planned_files, previous_records)
|
|
956
1329
|
backups: dict[Path, bytes | None] = {}
|
|
@@ -962,10 +1335,10 @@ def _publish_plan(
|
|
|
962
1335
|
config, all_planned_files, previous_records, publication, backups, failure_injector
|
|
963
1336
|
)
|
|
964
1337
|
_remove_stale_files(config, previous_records, all_planned_files, publication, backups)
|
|
965
|
-
save_manifest(
|
|
966
|
-
except (OSError, RuntimeError, ValueError)
|
|
1338
|
+
save_manifest(manifest_path, _build_manifest(all_planned_files), failure_injector)
|
|
1339
|
+
except (OSError, RuntimeError, ValueError):
|
|
967
1340
|
_rollback_publication(backups, publication, initial_written, initial_deleted)
|
|
968
|
-
raise
|
|
1341
|
+
raise
|
|
969
1342
|
_sort_report_details(publication)
|
|
970
1343
|
return publication
|
|
971
1344
|
|
|
@@ -1003,7 +1376,10 @@ def publish_plan(config: MaterializerConfig, *all_arguments: object, **all_keywo
|
|
|
1003
1376
|
raise TypeError("publish_plan received duplicate failure injector")
|
|
1004
1377
|
failure_injector = all_arguments[2]
|
|
1005
1378
|
publication = report if isinstance(report, MaterializationReport) else MaterializationReport()
|
|
1006
|
-
|
|
1379
|
+
all_planned_files = _validated_planned_file_iterable(planned_files)
|
|
1380
|
+
if failure_injector is not None and not callable(failure_injector):
|
|
1381
|
+
raise TypeError("failure injector requires a callable")
|
|
1382
|
+
return _publish_plan(config, all_planned_files, publication, failure_injector)
|
|
1007
1383
|
|
|
1008
1384
|
|
|
1009
1385
|
def _redact_private_paths(
|
|
@@ -1086,6 +1462,8 @@ def main(*all_arguments: object) -> int:
|
|
|
1086
1462
|
should_apply = options.should_apply
|
|
1087
1463
|
source_root = options.source_root
|
|
1088
1464
|
target_root = options.target_root
|
|
1465
|
+
if source_root is None or target_root is None:
|
|
1466
|
+
raise TypeError("materializer roots are required")
|
|
1089
1467
|
config = MaterializerConfig(
|
|
1090
1468
|
source_root,
|
|
1091
1469
|
target_root,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
|
-
from pathlib import Path
|
|
3
2
|
import sys
|
|
4
|
-
import
|
|
3
|
+
from pathlib import Path
|
|
5
4
|
|
|
6
5
|
import pytest
|
|
6
|
+
import tomllib
|
|
7
7
|
|
|
8
8
|
module_directory = str(Path(__file__).parents[1])
|
|
9
9
|
if module_directory not in sys.path:
|
|
@@ -17,8 +17,8 @@ from codex_compat_materializer import (
|
|
|
17
17
|
PlannedFile,
|
|
18
18
|
atomic_write,
|
|
19
19
|
build_plan,
|
|
20
|
-
convert_agent,
|
|
21
20
|
content_to_bytes,
|
|
21
|
+
convert_agent,
|
|
22
22
|
hash_content,
|
|
23
23
|
load_manifest,
|
|
24
24
|
parse_frontmatter,
|
|
@@ -144,6 +144,20 @@ def test_validation_rejects_overlap_and_unsafe_paths(tmp_path: Path) -> None:
|
|
|
144
144
|
validate_target_path(config.target_root, name)
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
def test_public_legacy_call_forms_validate_collection_entries(tmp_path: Path) -> None:
|
|
148
|
+
config = MaterializerConfig(tmp_path / "source", tmp_path / "target")
|
|
149
|
+
|
|
150
|
+
with pytest.raises(TypeError, match="ClaudeAgent entries"):
|
|
151
|
+
build_plan(config, all_agents=[object()])
|
|
152
|
+
with pytest.raises(TypeError, match="PlannedFile entries"):
|
|
153
|
+
publish_plan(config, all_planned_files=[object()])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_render_codex_failure_blast_radius_requires_the_excerpt_heading() -> None:
|
|
157
|
+
with pytest.raises(MaterializerError, match="requires a Codex excerpt"):
|
|
158
|
+
materializer.render_codex_failure_blast_radius("# No excerpt\n")
|
|
159
|
+
|
|
160
|
+
|
|
147
161
|
def test_validation_rejects_reparse_point_from_portable_attribute_seam(
|
|
148
162
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
149
163
|
) -> None:
|