claude-dev-env 2.21.0 → 2.21.1
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/docs/codex-compatibility.md +4 -2
- package/hooks/blocking/plain_language_blocker.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +25 -0
- package/hooks/blocking/test_plain_language_blocker.py +119 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +26 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +67 -7
- package/scripts/tests/test_codex_compat_materializer.py +311 -0
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
Run `codex-compat materialize --source-root <claude-root> --target-root <codex-root>`. The command defaults to a dry run; add `--apply` to publish files. Use `--python <command>` or `CODEX_COMPAT_PYTHON` to select Python. If no usable interpreter is found, the command reports that condition. The launcher passes an argv array, never a shell command.
|
|
8
8
|
|
|
9
|
-
The Python materializer maps Claude `_shared/`, `agents/`, `hooks/`, `rules/`, and `scripts/` into the target according to the package's compatibility materialization rules. Claude agent frontmatter is converted to Codex TOML metadata.
|
|
9
|
+
The Python materializer maps Claude `_shared/`, `agents/`, `hooks/`, `rules/`, and `scripts/` into the target according to the package's compatibility materialization rules. Claude agent frontmatter is converted to Codex TOML metadata. The canonical failure blast-radius rule projects its repository-instruction excerpt into a managed `AGENTS.md` file. Claude metadata reports its supported-field shape.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The Codex hook projection merges a managed `apply_patch` entry for `code_rules_enforcer.py` into the target `hooks.json`. Existing Codex hook entries keep their order, repeated enforcer entries collapse to one deterministic record, and the command resolves under the target root. The enforcer reads the patch command, reconstructs every file's pre-edit and projected post-edit content, and returns a blocking diagnostic for patch shapes requiring correction or code-rule violations. The existing Claude `Write`, `Edit`, and `MultiEdit` dispatcher keeps its current order and behavior.
|
|
12
|
+
|
|
13
|
+
The capability bridge emits declarative records and leaves translated surfaces for their owning runtime.
|
|
12
14
|
|
|
13
15
|
Materialization uses a compatibility manifest to identify generated files. Dry runs report the plan without writing. Apply mode uses safe link/copy fallback where linking is unavailable, writes atomically, removes only stale managed files, and rolls back managed changes on failure. A failed rollback reports that reconciliation is required.
|
|
14
16
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that checks AskUserQuestion prose when enabled."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _hooks_dir not in sys.path:
|
|
13
|
+
sys.path.insert(0, _hooks_dir)
|
|
14
|
+
|
|
15
|
+
from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
|
|
16
|
+
prose_style_enforcement_enabled_in_environment,
|
|
17
|
+
)
|
|
18
|
+
from hooks_constants.ask_user_question_shape_constants import ( # noqa: E402
|
|
19
|
+
ASK_USER_QUESTION_TOOL_NAME,
|
|
20
|
+
)
|
|
21
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
|
+
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
23
|
+
ALL_PLAIN_LANGUAGE_TERM_PATTERNS,
|
|
24
|
+
FENCED_CODE_PATTERN,
|
|
25
|
+
FILE_PATH_PATTERN,
|
|
26
|
+
INLINE_CODE_PATTERN,
|
|
27
|
+
PLAIN_LANGUAGE_BLOCK_PREFIX,
|
|
28
|
+
PLAIN_LANGUAGE_NOTICE,
|
|
29
|
+
PLAIN_LANGUAGE_TERM_SEPARATOR,
|
|
30
|
+
URL_PATTERN,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
33
|
+
read_hook_input_dictionary_from_stdin,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_non_prose_regions(text: str) -> str:
|
|
38
|
+
"""Remove exact code, URL, and path regions before scanning prose."""
|
|
39
|
+
without_fenced_code = FENCED_CODE_PATTERN.sub(" ", text)
|
|
40
|
+
without_inline_code = INLINE_CODE_PATTERN.sub(" ", without_fenced_code)
|
|
41
|
+
without_urls = URL_PATTERN.sub(" ", without_inline_code)
|
|
42
|
+
return FILE_PATH_PATTERN.sub(" ", without_urls)
|
|
43
|
+
|
|
44
|
+
def find_banned_terms(text: str) -> list[tuple[str, str]]:
|
|
45
|
+
"""Return each detected formal term and its familiar replacement."""
|
|
46
|
+
prose_text = strip_non_prose_regions(text)
|
|
47
|
+
all_matches: list[tuple[str, str]] = []
|
|
48
|
+
seen_terms: set[str] = set()
|
|
49
|
+
for each_pattern, each_replacement in ALL_PLAIN_LANGUAGE_TERM_PATTERNS:
|
|
50
|
+
match = each_pattern.search(prose_text)
|
|
51
|
+
if match is None:
|
|
52
|
+
continue
|
|
53
|
+
matched_term = match.group(0).lower()
|
|
54
|
+
if matched_term in seen_terms:
|
|
55
|
+
continue
|
|
56
|
+
seen_terms.add(matched_term)
|
|
57
|
+
all_matches.append((matched_term, each_replacement))
|
|
58
|
+
return all_matches
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _question_prose(payload_by_key: Mapping[str, object]) -> list[str]:
|
|
62
|
+
"""Return question and option-description prose from a tool payload."""
|
|
63
|
+
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
64
|
+
if not isinstance(raw_tool_input, Mapping):
|
|
65
|
+
return []
|
|
66
|
+
raw_questions = raw_tool_input.get("questions", [])
|
|
67
|
+
if not isinstance(raw_questions, Sequence) or isinstance(
|
|
68
|
+
raw_questions, (str, bytes)
|
|
69
|
+
):
|
|
70
|
+
return []
|
|
71
|
+
all_prose: list[str] = []
|
|
72
|
+
for each_raw_question in raw_questions:
|
|
73
|
+
if not isinstance(each_raw_question, Mapping):
|
|
74
|
+
continue
|
|
75
|
+
question = each_raw_question.get("question")
|
|
76
|
+
if isinstance(question, str):
|
|
77
|
+
all_prose.append(question)
|
|
78
|
+
raw_options = each_raw_question.get("options", [])
|
|
79
|
+
if not isinstance(raw_options, Sequence) or isinstance(
|
|
80
|
+
raw_options, (str, bytes)
|
|
81
|
+
):
|
|
82
|
+
continue
|
|
83
|
+
for each_raw_option in raw_options:
|
|
84
|
+
if not isinstance(each_raw_option, Mapping):
|
|
85
|
+
continue
|
|
86
|
+
description = each_raw_option.get("description")
|
|
87
|
+
if isinstance(description, str):
|
|
88
|
+
all_prose.append(description)
|
|
89
|
+
return all_prose
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def evaluate(payload_by_key: Mapping[str, object]) -> str | None:
|
|
93
|
+
"""Return a deny reason for formal AskUserQuestion prose."""
|
|
94
|
+
if not prose_style_enforcement_enabled_in_environment():
|
|
95
|
+
return None
|
|
96
|
+
if payload_by_key.get("tool_name") != ASK_USER_QUESTION_TOOL_NAME:
|
|
97
|
+
return None
|
|
98
|
+
all_matches: list[tuple[str, str]] = []
|
|
99
|
+
for each_prose in _question_prose(payload_by_key):
|
|
100
|
+
for each_match in find_banned_terms(each_prose):
|
|
101
|
+
if each_match not in all_matches:
|
|
102
|
+
all_matches.append(each_match)
|
|
103
|
+
if not all_matches:
|
|
104
|
+
return None
|
|
105
|
+
return build_block_reason(all_matches)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_block_reason(all_matches: Sequence[tuple[str, str]]) -> str:
|
|
109
|
+
"""Build a concise denial reason with one replacement per detected term."""
|
|
110
|
+
swaps = PLAIN_LANGUAGE_TERM_SEPARATOR.join(
|
|
111
|
+
f"{term} -> {replacement}" for term, replacement in all_matches
|
|
112
|
+
)
|
|
113
|
+
return f"{PLAIN_LANGUAGE_BLOCK_PREFIX}{swaps}."
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
117
|
+
"""Build the standard PreToolUse deny response."""
|
|
118
|
+
log_hook_block(
|
|
119
|
+
calling_hook_name="plain_language_blocker.py",
|
|
120
|
+
hook_event="PreToolUse",
|
|
121
|
+
block_reason=deny_reason,
|
|
122
|
+
tool_name=ASK_USER_QUESTION_TOOL_NAME,
|
|
123
|
+
)
|
|
124
|
+
return {
|
|
125
|
+
"hookSpecificOutput": {
|
|
126
|
+
"hookEventName": "PreToolUse",
|
|
127
|
+
"permissionDecision": "deny",
|
|
128
|
+
"permissionDecisionReason": deny_reason,
|
|
129
|
+
},
|
|
130
|
+
"systemMessage": PLAIN_LANGUAGE_NOTICE,
|
|
131
|
+
"suppressOutput": True,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main() -> None:
|
|
136
|
+
"""Read one hook payload and emit a deny response when needed."""
|
|
137
|
+
payload_by_key = read_hook_input_dictionary_from_stdin()
|
|
138
|
+
if payload_by_key is None:
|
|
139
|
+
return
|
|
140
|
+
deny_reason = evaluate(payload_by_key)
|
|
141
|
+
if deny_reason is None:
|
|
142
|
+
return
|
|
143
|
+
sys.stdout.write(json.dumps(build_deny_payload(deny_reason)) + "\n")
|
|
144
|
+
sys.stdout.flush()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -143,6 +143,31 @@ def test_codex_payload_blocks_malformed_patch(
|
|
|
143
143
|
|
|
144
144
|
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
145
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"]
|
|
149
|
+
def test_codex_payload_blocks_a_nul_containing_patch_path(
|
|
150
|
+
tmp_path: Path,
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
152
|
+
capsys: pytest.CaptureFixture[str],
|
|
153
|
+
) -> None:
|
|
154
|
+
"""A path containing NUL is converted into the standard JSON deny response."""
|
|
155
|
+
production_directory = _production_directory(tmp_path)
|
|
156
|
+
payload = {
|
|
157
|
+
"tool_name": "apply_patch",
|
|
158
|
+
"cwd": str(production_directory),
|
|
159
|
+
"tool_input": {
|
|
160
|
+
"command": (
|
|
161
|
+
"*** Begin Patch\n"
|
|
162
|
+
"*** Add File: unsafe\x00.py\n"
|
|
163
|
+
"+raise RuntimeError()\n"
|
|
164
|
+
"*** End Patch"
|
|
165
|
+
)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
170
|
+
|
|
146
171
|
deny_payload = json.loads(stdout)
|
|
147
172
|
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
148
173
|
assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Production-path tests for the AskUserQuestion plain-language blocker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
HOOK_PATH = Path(__file__).with_name("plain_language_blocker.py")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_hook(
|
|
17
|
+
payload: dict[str, object], *, enabled: bool
|
|
18
|
+
) -> subprocess.CompletedProcess[str]:
|
|
19
|
+
environment = os.environ.copy()
|
|
20
|
+
if enabled:
|
|
21
|
+
environment["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
|
|
22
|
+
else:
|
|
23
|
+
environment.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
|
|
24
|
+
return subprocess.run(
|
|
25
|
+
[sys.executable, str(HOOK_PATH)],
|
|
26
|
+
input=json.dumps(payload),
|
|
27
|
+
capture_output=True,
|
|
28
|
+
check=False,
|
|
29
|
+
env=environment,
|
|
30
|
+
text=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _decision(result: subprocess.CompletedProcess[str]) -> str | None:
|
|
35
|
+
if not result.stdout:
|
|
36
|
+
return None
|
|
37
|
+
parsed_payload = json.loads(result.stdout)
|
|
38
|
+
return parsed_payload.get("hookSpecificOutput", {}).get("permissionDecision")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_default_off_allows_formal_question_word() -> None:
|
|
42
|
+
result = _run_hook(
|
|
43
|
+
{
|
|
44
|
+
"tool_name": "AskUserQuestion",
|
|
45
|
+
"tool_input": {
|
|
46
|
+
"questions": [{"question": "Should we utilize this path?"}]
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
enabled=False,
|
|
50
|
+
)
|
|
51
|
+
assert result.returncode == 0
|
|
52
|
+
assert result.stdout == ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_enabled_blocks_formal_question_word() -> None:
|
|
56
|
+
result = _run_hook(
|
|
57
|
+
{
|
|
58
|
+
"tool_name": "AskUserQuestion",
|
|
59
|
+
"tool_input": {
|
|
60
|
+
"questions": [
|
|
61
|
+
{
|
|
62
|
+
"question": "Should we utilize this path?",
|
|
63
|
+
"options": [{"description": "Initiate the migration."}],
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
enabled=True,
|
|
69
|
+
)
|
|
70
|
+
assert result.returncode == 0
|
|
71
|
+
assert _decision(result) == "deny"
|
|
72
|
+
assert "utilize -> use" in result.stdout
|
|
73
|
+
assert "initiate -> start" in result.stdout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_exact_code_url_and_path_text_is_exempt() -> None:
|
|
77
|
+
result = _run_hook(
|
|
78
|
+
{
|
|
79
|
+
"tool_name": "AskUserQuestion",
|
|
80
|
+
"tool_input": {
|
|
81
|
+
"questions": [
|
|
82
|
+
{
|
|
83
|
+
"question": (
|
|
84
|
+
"Use `utilize` in src/initiate.py or "
|
|
85
|
+
"https://example.test/utilize."
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
enabled=True,
|
|
92
|
+
)
|
|
93
|
+
assert result.returncode == 0
|
|
94
|
+
assert result.stdout == ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_other_tools_are_ignored() -> None:
|
|
98
|
+
result = _run_hook(
|
|
99
|
+
{
|
|
100
|
+
"tool_name": "Write",
|
|
101
|
+
"tool_input": {"content": "Utilize the existing helper."},
|
|
102
|
+
},
|
|
103
|
+
enabled=True,
|
|
104
|
+
)
|
|
105
|
+
assert result.returncode == 0
|
|
106
|
+
assert result.stdout == ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize("raw_input", ["", "[]", "not json"])
|
|
110
|
+
def test_invalid_input_is_fail_open(raw_input: str) -> None:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
[sys.executable, str(HOOK_PATH)],
|
|
113
|
+
input=raw_input,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
check=False,
|
|
116
|
+
text=True,
|
|
117
|
+
)
|
|
118
|
+
assert result.returncode == 0
|
|
119
|
+
assert result.stdout == ""
|
package/hooks/hooks.json
CHANGED
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
|
+
{
|
|
26
|
+
"matcher": "apply_patch",
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
|
|
31
|
+
"timeout": 60
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
25
35
|
{
|
|
26
36
|
"matcher": "Bash|PowerShell",
|
|
27
37
|
"hooks": [
|
|
@@ -80,6 +90,11 @@
|
|
|
80
90
|
{
|
|
81
91
|
"matcher": "AskUserQuestion",
|
|
82
92
|
"hooks": [
|
|
93
|
+
{
|
|
94
|
+
"type": "command",
|
|
95
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
|
|
96
|
+
"timeout": 10
|
|
97
|
+
},
|
|
83
98
|
{
|
|
84
99
|
"type": "command",
|
|
85
100
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/ask_user_question_shape_blocker.py",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Configuration for the AskUserQuestion plain-language blocker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
ALL_PLAIN_LANGUAGE_TERM_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
8
|
+
(re.compile(r"\butilize\b", re.IGNORECASE), "use"),
|
|
9
|
+
(re.compile(r"\binitiate\b", re.IGNORECASE), "start"),
|
|
10
|
+
(re.compile(r"\bsufficient\b", re.IGNORECASE), "enough"),
|
|
11
|
+
(re.compile(r"\bprior to\b", re.IGNORECASE), "before"),
|
|
12
|
+
(re.compile(r"\bin order to\b", re.IGNORECASE), "to"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
FENCED_CODE_PATTERN = re.compile(r"```[\s\S]*?```")
|
|
16
|
+
INLINE_CODE_PATTERN = re.compile(r"`[^`\n]+`")
|
|
17
|
+
URL_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE)
|
|
18
|
+
FILE_PATH_PATTERN = re.compile(
|
|
19
|
+
r"(?<!\w)(?:[A-Za-z]:[\\/]|\.\.?[\\/])?[\w.-]+(?:[\\/][\w.-]+)+"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
PLAIN_LANGUAGE_BLOCK_PREFIX = "BLOCKED: [PLAIN_LANGUAGE] Use familiar words: "
|
|
23
|
+
PLAIN_LANGUAGE_TERM_SEPARATOR = "; "
|
|
24
|
+
PLAIN_LANGUAGE_NOTICE = (
|
|
25
|
+
"Plain-language check: use familiar words in the question and its options."
|
|
26
|
+
)
|
package/package.json
CHANGED
|
@@ -520,8 +520,9 @@ def render_codex_failure_blast_radius(rule_content: str) -> str:
|
|
|
520
520
|
def _build_codex_instruction_projection(config: MaterializerConfig) -> PlannedFile | None:
|
|
521
521
|
"""Build the managed AGENTS.md projection when the canonical rule is present."""
|
|
522
522
|
source_path = config.source_root / failure_blast_radius_rule_relative_path
|
|
523
|
-
if not source_path.
|
|
523
|
+
if not source_path.exists() and not _is_reparse_point(source_path):
|
|
524
524
|
return None
|
|
525
|
+
source_path = _validated_source_file(config, failure_blast_radius_rule_relative_path, "failure blast-radius rule")
|
|
525
526
|
rule_content = source_path.read_text(encoding="utf-8")
|
|
526
527
|
projected_content = render_codex_failure_blast_radius(rule_content)
|
|
527
528
|
return PlannedFile(
|
|
@@ -545,6 +546,24 @@ def _read_json_object(file_path: Path, description: str) -> dict[str, object]:
|
|
|
545
546
|
return {each_key: each_record for each_key, each_record in parsed_json.items()}
|
|
546
547
|
|
|
547
548
|
|
|
549
|
+
def _validated_source_file(
|
|
550
|
+
config: MaterializerConfig, relative_path: str, description: str
|
|
551
|
+
) -> Path:
|
|
552
|
+
"""Resolve one source file while rejecting reparse points and escapes."""
|
|
553
|
+
source_path = config.source_root.joinpath(*relative_path.split(path_separator))
|
|
554
|
+
current_path = config.source_root
|
|
555
|
+
for each_part in relative_path.split(path_separator):
|
|
556
|
+
current_path /= each_part
|
|
557
|
+
if current_path.exists() and _is_reparse_point(current_path):
|
|
558
|
+
raise MaterializerRunFatal(
|
|
559
|
+
f"{description} source reparse point is not allowed: {relative_path}"
|
|
560
|
+
)
|
|
561
|
+
resolved_path = _validate_containment(config.source_root, source_path)
|
|
562
|
+
if not resolved_path.is_file():
|
|
563
|
+
raise MaterializerError(f"{description} source file is missing: {relative_path}")
|
|
564
|
+
return resolved_path
|
|
565
|
+
|
|
566
|
+
|
|
548
567
|
def _validated_agent_iterable(candidate: object) -> tuple[ClaudeAgent, ...]:
|
|
549
568
|
"""Validate the optional agent iterable used by the legacy call form."""
|
|
550
569
|
if not isinstance(candidate, Iterable):
|
|
@@ -571,7 +590,11 @@ def _find_codex_hook_source(config: MaterializerConfig) -> Path | None:
|
|
|
571
590
|
config.source_root / codex_hook_manifest_source_path,
|
|
572
591
|
config.source_root / Path(codex_hook_manifest_source_path).name,
|
|
573
592
|
)
|
|
574
|
-
|
|
593
|
+
for each_path in all_candidates:
|
|
594
|
+
if each_path.exists() or _is_reparse_point(each_path):
|
|
595
|
+
relative_path = each_path.relative_to(config.source_root).as_posix()
|
|
596
|
+
return _validated_source_file(config, relative_path, "Codex hook manifest")
|
|
597
|
+
return None
|
|
575
598
|
|
|
576
599
|
|
|
577
600
|
def _resolved_codex_enforcer_command(config: MaterializerConfig) -> str:
|
|
@@ -635,6 +658,41 @@ def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
|
|
|
635
658
|
)
|
|
636
659
|
|
|
637
660
|
|
|
661
|
+
def _codex_enforcer_hooks(all_manifest: object) -> tuple[dict[str, object], ...]:
|
|
662
|
+
"""Return enforcer records from the target apply-patch matcher."""
|
|
663
|
+
if not isinstance(all_manifest, dict):
|
|
664
|
+
return ()
|
|
665
|
+
all_events = all_manifest.get("hooks")
|
|
666
|
+
if not isinstance(all_events, dict):
|
|
667
|
+
return ()
|
|
668
|
+
all_pre_tool_use = all_events.get(codex_hook_event_name)
|
|
669
|
+
if not isinstance(all_pre_tool_use, list):
|
|
670
|
+
return ()
|
|
671
|
+
all_enforcer_hooks: list[dict[str, object]] = []
|
|
672
|
+
for each_entry in all_pre_tool_use:
|
|
673
|
+
if not isinstance(each_entry, dict) or each_entry.get("matcher") != codex_hook_matcher:
|
|
674
|
+
continue
|
|
675
|
+
for each_hook in _hook_records(each_entry.get("hooks", [])):
|
|
676
|
+
if _is_code_rules_enforcer_hook(each_hook):
|
|
677
|
+
all_enforcer_hooks.append(each_hook)
|
|
678
|
+
return tuple(all_enforcer_hooks)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _has_modified_codex_enforcer_hook(
|
|
682
|
+
current_bytes: bytes, planned_content: ManagedContent
|
|
683
|
+
) -> bool:
|
|
684
|
+
"""Report whether an existing enforcer record differs from the plan."""
|
|
685
|
+
try:
|
|
686
|
+
current_manifest = json.loads(current_bytes.decode("utf-8"))
|
|
687
|
+
planned_manifest = json.loads(content_to_bytes(planned_content).decode("utf-8"))
|
|
688
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
689
|
+
return True
|
|
690
|
+
current_hooks = _codex_enforcer_hooks(current_manifest)
|
|
691
|
+
if not current_hooks:
|
|
692
|
+
return False
|
|
693
|
+
return current_hooks != _codex_enforcer_hooks(planned_manifest)
|
|
694
|
+
|
|
695
|
+
|
|
638
696
|
def _merge_codex_hook_manifest(
|
|
639
697
|
all_target_manifest: dict[str, object], all_focused_hook: dict[str, object]
|
|
640
698
|
) -> dict[str, object]:
|
|
@@ -710,11 +768,9 @@ def _build_codex_hook_dependency_projection(
|
|
|
710
768
|
"""Build the reviewed source files required by the target enforcer."""
|
|
711
769
|
all_dependencies: list[PlannedFile] = []
|
|
712
770
|
for each_relative_path in codex_hook_dependency_manifest:
|
|
713
|
-
source_path =
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
f"source file is missing from the reviewed hook source list: {each_relative_path}"
|
|
717
|
-
)
|
|
771
|
+
source_path = _validated_source_file(
|
|
772
|
+
config, each_relative_path, "reviewed Codex hook dependency"
|
|
773
|
+
)
|
|
718
774
|
try:
|
|
719
775
|
dependency_content = source_path.read_bytes()
|
|
720
776
|
except OSError as error:
|
|
@@ -1119,6 +1175,7 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
|
|
|
1119
1175
|
|
|
1120
1176
|
target absent -> ok: publish
|
|
1121
1177
|
on-disk bytes == planned bytes -> ok: unchanged, no write
|
|
1178
|
+
_has_modified_codex_enforcer_hook -> flag: conflicted, preserved untouched
|
|
1122
1179
|
on-disk hash == manifest hash -> ok: publish, the tool owns these bytes
|
|
1123
1180
|
on-disk hash != manifest hash -> flag: conflicted, preserved untouched
|
|
1124
1181
|
|
|
@@ -1140,6 +1197,9 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
|
|
|
1140
1197
|
_record_matching_target(report, planned_file.target_relative_path, previous_record)
|
|
1141
1198
|
return target_path, current_bytes, False
|
|
1142
1199
|
if planned_file.action == codex_hook_merge_action:
|
|
1200
|
+
if _has_modified_codex_enforcer_hook(current_bytes, planned_file.content):
|
|
1201
|
+
_record_target_conflict(report, planned_file.target_relative_path, previous_record)
|
|
1202
|
+
return target_path, current_bytes, False
|
|
1143
1203
|
return target_path, current_bytes, True
|
|
1144
1204
|
if _is_pristine_managed(previous_record, current_bytes):
|
|
1145
1205
|
return target_path, current_bytes, True
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import subprocess
|
|
2
3
|
import sys
|
|
3
4
|
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
import tomllib
|
|
@@ -442,6 +444,69 @@ def _apply_source_agent(source_root: Path, target_root: Path, description: str)
|
|
|
442
444
|
return target_root / "Luna.toml"
|
|
443
445
|
|
|
444
446
|
|
|
447
|
+
def _write_codex_hook_source(source_root: Path) -> None:
|
|
448
|
+
package_root = Path(__file__).parents[2]
|
|
449
|
+
source_hooks = source_root / "hooks"
|
|
450
|
+
source_hooks.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
(source_hooks / "hooks.json").write_bytes(
|
|
452
|
+
(package_root / "hooks" / "hooks.json").read_bytes()
|
|
453
|
+
)
|
|
454
|
+
for each_relative_path in materializer.codex_hook_dependency_manifest:
|
|
455
|
+
source_path = source_root / each_relative_path
|
|
456
|
+
source_path.parent.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
source_path.write_bytes((package_root / each_relative_path).read_bytes())
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_build_plan_rejects_reparse_hook_dependencies(
|
|
461
|
+
tmp_path: Path,
|
|
462
|
+
) -> None:
|
|
463
|
+
source = tmp_path / "source"
|
|
464
|
+
_write_codex_hook_source(source)
|
|
465
|
+
dependency_path = source / materializer.codex_enforcer_script_relative_path
|
|
466
|
+
outside_path = tmp_path / "outside.py"
|
|
467
|
+
outside_path.write_text("secret", encoding="utf-8")
|
|
468
|
+
try:
|
|
469
|
+
dependency_path.unlink()
|
|
470
|
+
dependency_path.symlink_to(outside_path)
|
|
471
|
+
except OSError as error:
|
|
472
|
+
pytest.skip(f"symlinks unavailable: {error}")
|
|
473
|
+
|
|
474
|
+
with pytest.raises(MaterializerError, match="reparse point"):
|
|
475
|
+
build_plan(MaterializerConfig(source, tmp_path / "target"))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_publish_plan_preserves_modified_enforcer_hook_as_conflict(
|
|
479
|
+
tmp_path: Path,
|
|
480
|
+
) -> None:
|
|
481
|
+
source = tmp_path / "source"
|
|
482
|
+
target = tmp_path / "target"
|
|
483
|
+
_write_codex_hook_source(source)
|
|
484
|
+
target.mkdir()
|
|
485
|
+
modified_command = f'python3 "{target / materializer.codex_enforcer_script_relative_path}" --custom'
|
|
486
|
+
existing_manifest = {
|
|
487
|
+
"hooks": {
|
|
488
|
+
"PreToolUse": [
|
|
489
|
+
{
|
|
490
|
+
"matcher": materializer.codex_hook_matcher,
|
|
491
|
+
"hooks": [
|
|
492
|
+
{"type": "command", "command": modified_command, "timeout": 5}
|
|
493
|
+
],
|
|
494
|
+
}
|
|
495
|
+
]
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
hooks_path = target / materializer.codex_hook_manifest_target_path
|
|
499
|
+
hooks_path.write_text(json.dumps(existing_manifest), encoding="utf-8")
|
|
500
|
+
config = MaterializerConfig(source, target, should_apply=True)
|
|
501
|
+
|
|
502
|
+
planned, report = build_plan(config)
|
|
503
|
+
publication = publish_plan(config, planned, report)
|
|
504
|
+
|
|
505
|
+
assert publication.conflicted == 1
|
|
506
|
+
assert publication.details["conflicted"] == ["hooks.json"]
|
|
507
|
+
assert json.loads(hooks_path.read_text(encoding="utf-8")) == existing_manifest
|
|
508
|
+
|
|
509
|
+
|
|
445
510
|
def test_apply_with_a_missing_source_root_keeps_managed_files_and_fails(
|
|
446
511
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
447
512
|
) -> None:
|
|
@@ -712,3 +777,249 @@ def test_frontmatter_rejects_non_string_name() -> None:
|
|
|
712
777
|
"---\nname: 1\ndescription: y\n---\n",
|
|
713
778
|
"bad.md",
|
|
714
779
|
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_failure_blast_radius_projection_uses_the_canonical_excerpt() -> None:
|
|
783
|
+
canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
|
|
784
|
+
canonical_rule = canonical_rule_path.read_text(encoding="utf-8")
|
|
785
|
+
|
|
786
|
+
projected_instruction = materializer.render_codex_failure_blast_radius(canonical_rule)
|
|
787
|
+
|
|
788
|
+
assert projected_instruction.startswith("Failure handling for this run")
|
|
789
|
+
assert "Three real attempts, then park." in projected_instruction
|
|
790
|
+
assert "Report as: N of M complete" in projected_instruction
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def test_build_plan_publishes_owned_agents_projection_and_tracks_drift(
|
|
794
|
+
tmp_path: Path,
|
|
795
|
+
) -> None:
|
|
796
|
+
canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
|
|
797
|
+
source = tmp_path / "source"
|
|
798
|
+
target = tmp_path / "target"
|
|
799
|
+
rule_path = source / "rules" / "failure-blast-radius.md"
|
|
800
|
+
rule_path.parent.mkdir(parents=True)
|
|
801
|
+
rule_path.write_text(canonical_rule_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
802
|
+
config = MaterializerConfig(source, target, should_apply=True)
|
|
803
|
+
|
|
804
|
+
planned, report = build_plan(config, all_agents=[])
|
|
805
|
+
publish_plan(config, planned, report)
|
|
806
|
+
|
|
807
|
+
projected_path = target / "AGENTS.md"
|
|
808
|
+
first_projection = projected_path.read_text(encoding="utf-8")
|
|
809
|
+
manifest_record = load_manifest(config.manifest_path)["files"]["AGENTS.md"]
|
|
810
|
+
assert manifest_record["ownership"] == "codex-compat"
|
|
811
|
+
assert manifest_record["source"] == "rules/failure-blast-radius.md"
|
|
812
|
+
|
|
813
|
+
rule_path.write_text(
|
|
814
|
+
rule_path.read_text(encoding="utf-8").replace("Three real attempts, then park.", "Three tested attempts, then park."),
|
|
815
|
+
encoding="utf-8",
|
|
816
|
+
)
|
|
817
|
+
drifted_plan, _ = build_plan(config, all_agents=[])
|
|
818
|
+
|
|
819
|
+
drifted_projection = next(
|
|
820
|
+
each_file.content
|
|
821
|
+
for each_file in drifted_plan
|
|
822
|
+
if each_file.target_relative_path == "AGENTS.md"
|
|
823
|
+
)
|
|
824
|
+
assert drifted_projection != first_projection
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def test_codex_hook_manifest_keeps_dispatcher_order_and_adds_enforcer() -> None:
|
|
828
|
+
hooks_path = Path(__file__).parents[2] / "hooks" / "hooks.json"
|
|
829
|
+
hooks_configuration = json.loads(hooks_path.read_text(encoding="utf-8"))
|
|
830
|
+
pre_tool_use_entries = hooks_configuration["hooks"]["PreToolUse"]
|
|
831
|
+
matcher_names = [each_entry.get("matcher") for each_entry in pre_tool_use_entries]
|
|
832
|
+
|
|
833
|
+
dispatcher_index = matcher_names.index("Write|Edit|MultiEdit")
|
|
834
|
+
enforcer_entries = [
|
|
835
|
+
each_entry
|
|
836
|
+
for each_entry in pre_tool_use_entries
|
|
837
|
+
if each_entry.get("matcher") == "apply_patch"
|
|
838
|
+
]
|
|
839
|
+
assert len(enforcer_entries) == 1
|
|
840
|
+
assert matcher_names.index("apply_patch") > dispatcher_index
|
|
841
|
+
assert enforcer_entries[0]["hooks"][0]["command"].endswith(
|
|
842
|
+
"/hooks/blocking/code_rules_enforcer.py"
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _prepare_projection_fixture(
|
|
847
|
+
tmp_path: Path,
|
|
848
|
+
) -> tuple[Path, Path, dict[str, Any], Path]:
|
|
849
|
+
source = tmp_path / "source"
|
|
850
|
+
target = tmp_path.parent / "codex-prod-target"
|
|
851
|
+
source_rule = source / "rules" / "failure-blast-radius.md"
|
|
852
|
+
source_hooks = source / "hooks" / "hooks.json"
|
|
853
|
+
source_rule.parent.mkdir(parents=True)
|
|
854
|
+
source_hooks.parent.mkdir(parents=True)
|
|
855
|
+
source_rule.write_text(
|
|
856
|
+
(Path(__file__).parents[2] / "rules" / "failure-blast-radius.md").read_text(
|
|
857
|
+
encoding="utf-8"
|
|
858
|
+
),
|
|
859
|
+
encoding="utf-8",
|
|
860
|
+
)
|
|
861
|
+
source_hooks.write_text(
|
|
862
|
+
(Path(__file__).parents[2] / "hooks" / "hooks.json").read_text(encoding="utf-8"),
|
|
863
|
+
encoding="utf-8",
|
|
864
|
+
)
|
|
865
|
+
package_root = Path(__file__).parents[2]
|
|
866
|
+
for each_relative_path in materializer.codex_hook_dependency_manifest:
|
|
867
|
+
target_dependency = source / each_relative_path
|
|
868
|
+
target_dependency.parent.mkdir(parents=True, exist_ok=True)
|
|
869
|
+
target_dependency.write_bytes((package_root / each_relative_path).read_bytes())
|
|
870
|
+
target.mkdir()
|
|
871
|
+
existing_hooks = {
|
|
872
|
+
"hooks": {
|
|
873
|
+
"PreToolUse": [
|
|
874
|
+
{
|
|
875
|
+
"matcher": "Write|Edit",
|
|
876
|
+
"hooks": [{"type": "command", "command": "python existing.py"}],
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
"matcher": "apply_patch",
|
|
880
|
+
"hooks": [
|
|
881
|
+
{
|
|
882
|
+
"type": "command",
|
|
883
|
+
"command": (
|
|
884
|
+
f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
|
|
885
|
+
),
|
|
886
|
+
"timeout": 60,
|
|
887
|
+
}
|
|
888
|
+
],
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
"matcher": "apply_patch",
|
|
892
|
+
"hooks": [
|
|
893
|
+
{"type": "command", "command": "python not_code_rules_enforcer.py"}
|
|
894
|
+
],
|
|
895
|
+
},
|
|
896
|
+
],
|
|
897
|
+
"Bash": [{"matcher": "", "hooks": []}],
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
(target / "hooks.json").write_text(json.dumps(existing_hooks), encoding="utf-8")
|
|
901
|
+
retired_owned_path = target / "hooks" / "obsolete.py"
|
|
902
|
+
retired_owned_path.parent.mkdir(parents=True)
|
|
903
|
+
retired_owned_path.write_text("retired owned hook\n", encoding="utf-8")
|
|
904
|
+
retired_manifest = {
|
|
905
|
+
"version": 1,
|
|
906
|
+
"files": {
|
|
907
|
+
"hooks/obsolete.py": {
|
|
908
|
+
"source": "hooks/obsolete.py",
|
|
909
|
+
"hash": materializer.hash_content(retired_owned_path.read_bytes()),
|
|
910
|
+
"ownership": "codex-compat",
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
}
|
|
914
|
+
(target / ".codex-compat-manifest.json").write_text(
|
|
915
|
+
json.dumps(retired_manifest), encoding="utf-8"
|
|
916
|
+
)
|
|
917
|
+
return source, target, existing_hooks, retired_owned_path
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _run_materializer_and_read_report(
|
|
921
|
+
source: Path,
|
|
922
|
+
target: Path,
|
|
923
|
+
capsys: pytest.CaptureFixture[str],
|
|
924
|
+
) -> dict[str, Any]:
|
|
925
|
+
exit_code = materializer.main([str(source), str(target), "--apply"])
|
|
926
|
+
report = json.loads(capsys.readouterr().out)
|
|
927
|
+
assert exit_code == 0, report
|
|
928
|
+
assert report["errors"] == 0, report
|
|
929
|
+
return report
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _assert_projection_and_manifest(
|
|
933
|
+
target: Path,
|
|
934
|
+
existing_hooks: dict[str, Any],
|
|
935
|
+
retired_owned_path: Path,
|
|
936
|
+
) -> None:
|
|
937
|
+
assert not retired_owned_path.exists()
|
|
938
|
+
projected_instruction = (target / "AGENTS.md").read_text(encoding="utf-8")
|
|
939
|
+
assert "Three real attempts, then park." in projected_instruction
|
|
940
|
+
manifest = json.loads((target / "hooks.json").read_text(encoding="utf-8"))
|
|
941
|
+
assert manifest["hooks"]["PreToolUse"][0] == existing_hooks["hooks"]["PreToolUse"][0]
|
|
942
|
+
apply_patch_entries = [
|
|
943
|
+
each_entry
|
|
944
|
+
for each_entry in manifest["hooks"]["PreToolUse"]
|
|
945
|
+
if each_entry["matcher"] == "apply_patch"
|
|
946
|
+
]
|
|
947
|
+
assert len(apply_patch_entries) == 1
|
|
948
|
+
apply_patch_commands = [
|
|
949
|
+
each_hook["command"] for each_hook in apply_patch_entries[0]["hooks"]
|
|
950
|
+
]
|
|
951
|
+
assert apply_patch_commands[0] == "python not_code_rules_enforcer.py"
|
|
952
|
+
managed_command = (
|
|
953
|
+
f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
|
|
954
|
+
)
|
|
955
|
+
assert managed_command in apply_patch_commands
|
|
956
|
+
manifest_record = load_manifest(target / ".codex-compat-manifest.json")["files"]
|
|
957
|
+
assert manifest_record["AGENTS.md"]["ownership"] == "codex-compat"
|
|
958
|
+
assert manifest_record["hooks.json"]["source"] == "hooks/hooks.json"
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _assert_detached_enforcer_behavior(target: Path) -> None:
|
|
962
|
+
target_entrypoint = target / materializer.codex_enforcer_script_relative_path
|
|
963
|
+
allow_payload = {
|
|
964
|
+
"tool_name": "apply_patch",
|
|
965
|
+
"cwd": str(target),
|
|
966
|
+
"tool_input": {
|
|
967
|
+
"command": (
|
|
968
|
+
"*** Begin Patch\n"
|
|
969
|
+
"*** Add File: module.py\n"
|
|
970
|
+
"+for each_member in all_members:\n"
|
|
971
|
+
"+ raise AssetItemBlocked()\n"
|
|
972
|
+
"*** End Patch"
|
|
973
|
+
)
|
|
974
|
+
},
|
|
975
|
+
}
|
|
976
|
+
allow_run = subprocess.run(
|
|
977
|
+
[sys.executable, str(target_entrypoint)],
|
|
978
|
+
cwd=target,
|
|
979
|
+
input=json.dumps(allow_payload),
|
|
980
|
+
capture_output=True,
|
|
981
|
+
text=True,
|
|
982
|
+
check=False,
|
|
983
|
+
)
|
|
984
|
+
assert allow_run.returncode == 0
|
|
985
|
+
assert allow_run.stdout == ""
|
|
986
|
+
|
|
987
|
+
deny_payload = {
|
|
988
|
+
**allow_payload,
|
|
989
|
+
"tool_input": {
|
|
990
|
+
"command": allow_payload["tool_input"]["command"].replace(
|
|
991
|
+
"AssetItemBlocked", "RuntimeError"
|
|
992
|
+
)
|
|
993
|
+
},
|
|
994
|
+
}
|
|
995
|
+
deny_run = subprocess.run(
|
|
996
|
+
[sys.executable, str(target_entrypoint)],
|
|
997
|
+
cwd=target,
|
|
998
|
+
input=json.dumps(deny_payload),
|
|
999
|
+
capture_output=True,
|
|
1000
|
+
text=True,
|
|
1001
|
+
check=False,
|
|
1002
|
+
)
|
|
1003
|
+
assert deny_run.returncode == 0
|
|
1004
|
+
assert json.loads(deny_run.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
1005
|
+
assert target_entrypoint.is_file()
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def test_exact_cli_publishes_instruction_and_additive_hook_projection(
|
|
1009
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
1010
|
+
) -> None:
|
|
1011
|
+
source, target, existing_hooks, retired_owned_path = _prepare_projection_fixture(
|
|
1012
|
+
tmp_path
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
first_report = _run_materializer_and_read_report(source, target, capsys)
|
|
1016
|
+
assert first_report["written"] > 0
|
|
1017
|
+
_assert_projection_and_manifest(target, existing_hooks, retired_owned_path)
|
|
1018
|
+
|
|
1019
|
+
hooks_before_second_run = (target / "hooks.json").read_bytes()
|
|
1020
|
+
second_report = _run_materializer_and_read_report(source, target, capsys)
|
|
1021
|
+
assert second_report["written"] == 0
|
|
1022
|
+
assert (target / "hooks.json").read_bytes() == hooks_before_second_run
|
|
1023
|
+
|
|
1024
|
+
source.rename(tmp_path / "source-detached")
|
|
1025
|
+
_assert_detached_enforcer_behavior(target)
|