claude-dev-env 2.5.0 → 2.7.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/CLAUDE.md +20 -57
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/agents/CLAUDE.md +1 -1
- package/agents/code-verifier.md +36 -7
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/codex-capability-map.json +13 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/code-review-enforcement.md +31 -6
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +236 -27
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +0 -12
- package/hooks/hooks_constants/CLAUDE.md +5 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/package.json +4 -2
- package/rules/CLAUDE.md +17 -23
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +10 -2
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_invoke_code_review.py +143 -0
- package/scripts/test_invoke_code_review_chain.py +1 -1
- package/scripts/test_invoke_code_review_contract.py +1 -1
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/condensing-instructions/SKILL.md +42 -51
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/orchestrator/SKILL.md +23 -9
- package/skills/orchestrator-refresh/SKILL.md +20 -1
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/session-log/SKILL.md +1 -1
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/state-what-is.md +0 -25
- package/rules/tdd.md +0 -7
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
"""Deterministic, additive Claude-agent to Codex-agent materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ast
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
import tomllib
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ManagedContent = str | bytes
|
|
20
|
+
|
|
21
|
+
path_separator = "/"
|
|
22
|
+
toml_suffix = ".toml"
|
|
23
|
+
reparse_point_attribute_name = "FILE_ATTRIBUTE_REPARSE_POINT"
|
|
24
|
+
manifest_indentation_width = 2
|
|
25
|
+
publish_plan_max_positional_arguments = 3
|
|
26
|
+
publish_plan_failure_injector_position = 2
|
|
27
|
+
frontmatter_required_fields = ("name", "description")
|
|
28
|
+
frontmatter_unsupported_fields = ("tools", "model", "color")
|
|
29
|
+
full_prune_opt_in_flag = "--allow-prune-all"
|
|
30
|
+
unreadable_source_root_message = (
|
|
31
|
+
"source root is missing or is not a directory, so nothing was planned or changed; "
|
|
32
|
+
"check the source root path and re-run"
|
|
33
|
+
)
|
|
34
|
+
reparse_source_root_message = "source root is a reparse point, so nothing was planned or changed"
|
|
35
|
+
full_prune_refusal_message = (
|
|
36
|
+
"refusing to delete every managed file: the plan is empty while the manifest still records "
|
|
37
|
+
"{count} of them, so the target root was left untouched; re-run with " + full_prune_opt_in_flag
|
|
38
|
+
+ " to remove them"
|
|
39
|
+
)
|
|
40
|
+
unmanaged_target_message = (
|
|
41
|
+
"unmanaged file at planned target {path}: the compatibility manifest does not record it, so it "
|
|
42
|
+
"may be yours and it was not overwritten. Review it, then move or delete {path} inside the "
|
|
43
|
+
"target root and re-run. An interrupted run leaves a file whose bytes already match the plan, "
|
|
44
|
+
"and such a file is adopted automatically"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MaterializerError(ValueError):
|
|
49
|
+
"""Raised when a materialization request cannot be safely planned."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ArgumentParserError(ValueError):
|
|
53
|
+
"""Raised when command-line arguments cannot be parsed."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MaterializerArgumentParser(argparse.ArgumentParser):
|
|
57
|
+
"""Parse materializer arguments while keeping errors in the JSON contract."""
|
|
58
|
+
|
|
59
|
+
def error(self, message: str) -> None:
|
|
60
|
+
"""Raise a reportable parser error instead of writing process output."""
|
|
61
|
+
raise ArgumentParserError(message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
report_categories = (
|
|
65
|
+
"written", "unchanged", "adopted", "unmanaged_collision", "modified_managed",
|
|
66
|
+
"stale_managed", "deleted", "unsupported", "conflicted", "errors",
|
|
67
|
+
)
|
|
68
|
+
report_categories_public_name = "REPORT_CATEGORIES"
|
|
69
|
+
frontmatter_allowed_fields = {"name", "description", "tools", "model", "color"}
|
|
70
|
+
line_separator = "\n"
|
|
71
|
+
comma_separator = ", "
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def __getattr__(name: str) -> tuple[str, ...]:
|
|
75
|
+
if name == report_categories_public_name:
|
|
76
|
+
return report_categories
|
|
77
|
+
raise AttributeError(name)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class MaterializerConfig:
|
|
82
|
+
source_root: Path
|
|
83
|
+
target_root: Path
|
|
84
|
+
manifest_path: Path | None = None
|
|
85
|
+
should_apply: bool = False
|
|
86
|
+
should_allow_full_prune: bool = False
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
source = self.source_root.expanduser().resolve()
|
|
90
|
+
target = self.target_root.expanduser().resolve()
|
|
91
|
+
if source == target or source in target.parents or target in source.parents:
|
|
92
|
+
raise MaterializerError("source and target roots must not overlap")
|
|
93
|
+
object.__setattr__(self, "source_root", source)
|
|
94
|
+
object.__setattr__(self, "target_root", target)
|
|
95
|
+
manifest = (self.manifest_path or target / ".codex-compat-manifest.json").expanduser().resolve()
|
|
96
|
+
if target not in manifest.parents:
|
|
97
|
+
raise MaterializerError("manifest must be inside the target root")
|
|
98
|
+
object.__setattr__(self, "manifest_path", manifest)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ClaudeAgent:
|
|
103
|
+
source_path: Path
|
|
104
|
+
relative_source: str
|
|
105
|
+
name: str
|
|
106
|
+
description: str
|
|
107
|
+
tools: tuple[str, ...] = ()
|
|
108
|
+
model: str | None = None
|
|
109
|
+
color: str | None = None
|
|
110
|
+
unsupported: tuple[str, ...] = ()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class ManifestRecord:
|
|
115
|
+
"""One compatibility-manifest entry, as the publication logic reads it.
|
|
116
|
+
|
|
117
|
+
A field that failed validation is carried as ``None``, so ownership questions
|
|
118
|
+
read as attribute checks rather than repeated isinstance guards::
|
|
119
|
+
|
|
120
|
+
{"hash": "ab12..", "ownership": "codex-compat"} -> ok: owned, refreshable
|
|
121
|
+
{"hash": "ab12.."} -> flag: unowned, preserved
|
|
122
|
+
|
|
123
|
+
The manifest also stores ``source`` and ``marker`` for inspection. No code path
|
|
124
|
+
reads either, so neither appears here.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
content_hash: Hash the tool recorded when it last published the file.
|
|
128
|
+
ownership: Ownership marker the tool recorded alongside that hash.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
content_hash: str | None
|
|
132
|
+
ownership: str | None
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_owned_by_tool(self) -> bool:
|
|
136
|
+
"""Report whether this entry carries both fields that prove tool ownership."""
|
|
137
|
+
return self.content_hash is not None and self.ownership is not None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
ManifestRecordByPath = dict[str, ManifestRecord | None]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class PlannedFile:
|
|
145
|
+
source_identity: str
|
|
146
|
+
target_relative_path: str
|
|
147
|
+
content: ManagedContent
|
|
148
|
+
content_hash: str
|
|
149
|
+
ownership: str = "codex-compat"
|
|
150
|
+
generated_marker: str = "codex-compat-generated-v1"
|
|
151
|
+
action: str = "write"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class MaterializationReport:
|
|
156
|
+
written: int = 0
|
|
157
|
+
unchanged: int = 0
|
|
158
|
+
adopted: int = 0
|
|
159
|
+
unmanaged_collision: int = 0
|
|
160
|
+
modified_managed: int = 0
|
|
161
|
+
stale_managed: int = 0
|
|
162
|
+
deleted: int = 0
|
|
163
|
+
unsupported: int = 0
|
|
164
|
+
conflicted: int = 0
|
|
165
|
+
errors: int = 0
|
|
166
|
+
error_details: list[str] = field(default_factory=list)
|
|
167
|
+
details: dict[str, list[str]] = field(default_factory=lambda: {each_category: [] for each_category in report_categories})
|
|
168
|
+
planned_files: list[PlannedFile] = field(default_factory=list)
|
|
169
|
+
is_generation_incomplete: bool = False
|
|
170
|
+
is_reconciliation_required: bool = False
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def incomplete_generation(self) -> bool:
|
|
174
|
+
return self.is_generation_incomplete
|
|
175
|
+
|
|
176
|
+
@incomplete_generation.setter
|
|
177
|
+
def incomplete_generation(self, is_incomplete: bool) -> None:
|
|
178
|
+
self.is_generation_incomplete = is_incomplete
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def reconcile_required(self) -> bool:
|
|
182
|
+
return self.is_reconciliation_required
|
|
183
|
+
|
|
184
|
+
@reconcile_required.setter
|
|
185
|
+
def reconcile_required(self, is_required: bool) -> None:
|
|
186
|
+
self.is_reconciliation_required = is_required
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def preserved(self) -> int:
|
|
190
|
+
"""Return the legacy name for unchanged planned files."""
|
|
191
|
+
return self.unchanged + self.modified_managed + self.stale_managed
|
|
192
|
+
|
|
193
|
+
def add_detail(self, category: str, relative_path: str) -> None:
|
|
194
|
+
self.details[category].append(relative_path)
|
|
195
|
+
|
|
196
|
+
def add_error(self, message: str) -> None:
|
|
197
|
+
self.errors += 1
|
|
198
|
+
self.error_details.append(message)
|
|
199
|
+
self.add_detail("errors", message)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _normalize_relative_path(path: str) -> str:
|
|
203
|
+
canonical_path = path.replace("\\", path_separator)
|
|
204
|
+
if not canonical_path or canonical_path.startswith(("/", "//")) or re.match(r"^[A-Za-z]:($|/)", canonical_path):
|
|
205
|
+
raise MaterializerError("rooted path is not allowed")
|
|
206
|
+
path_parts = canonical_path.split(path_separator)
|
|
207
|
+
if any(each_part in ("", ".", "..") for each_part in path_parts) or canonical_path != path_separator.join(path_parts):
|
|
208
|
+
raise MaterializerError("path is not normalized or contains traversal")
|
|
209
|
+
return canonical_path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _casefold_normalized_path(path: Path) -> str:
|
|
213
|
+
return path.as_posix().casefold()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_source_identity(relative_source: str) -> str:
|
|
217
|
+
canonical_source = _normalize_relative_path(relative_source)
|
|
218
|
+
if canonical_source.startswith(".") or canonical_source.casefold().startswith(("private/", "private\\")):
|
|
219
|
+
raise MaterializerError("private source identity is not allowed")
|
|
220
|
+
return canonical_source
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _is_reparse_point(path: Path) -> bool:
|
|
224
|
+
if path.is_symlink():
|
|
225
|
+
return True
|
|
226
|
+
try:
|
|
227
|
+
attributes = path.stat(follow_symlinks=False).st_file_attributes
|
|
228
|
+
except (AttributeError, OSError):
|
|
229
|
+
return False
|
|
230
|
+
return bool(attributes & getattr(stat, reparse_point_attribute_name, 0))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _validate_containment(root: Path, candidate: Path) -> Path:
|
|
234
|
+
resolved = candidate.resolve()
|
|
235
|
+
if root not in (resolved, *resolved.parents):
|
|
236
|
+
raise MaterializerError("path resolves outside target root")
|
|
237
|
+
return resolved
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_target_path(target_root: Path, relative_path: str) -> Path:
|
|
241
|
+
"""Resolve a relative target path inside a safe target root.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
target_root: Directory that must contain the resolved path.
|
|
245
|
+
relative_path: Normalized path relative to ``target_root``.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The resolved target path.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
MaterializerError: If the path is rooted, unsafe, or crosses a reparse point.
|
|
252
|
+
"""
|
|
253
|
+
canonical_path = _normalize_relative_path(relative_path)
|
|
254
|
+
root = target_root.resolve()
|
|
255
|
+
candidate = root.joinpath(*canonical_path.split(path_separator))
|
|
256
|
+
for each_parent in (root, *candidate.parents):
|
|
257
|
+
if each_parent.exists() and _is_reparse_point(each_parent):
|
|
258
|
+
raise MaterializerError("target path crosses a reparse point")
|
|
259
|
+
return _validate_containment(root, candidate)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _parse_frontmatter_scalar(serialized_field_text: str) -> str | tuple[str, ...] | None:
|
|
263
|
+
normalized_field_text = serialized_field_text.strip()
|
|
264
|
+
if not normalized_field_text:
|
|
265
|
+
return ""
|
|
266
|
+
if normalized_field_text.startswith("["):
|
|
267
|
+
return _parse_frontmatter_list(normalized_field_text)
|
|
268
|
+
try:
|
|
269
|
+
parsed = ast.literal_eval(normalized_field_text)
|
|
270
|
+
except (SyntaxError, ValueError):
|
|
271
|
+
if normalized_field_text[:1] in {'"', "'"} or normalized_field_text[:1] == "[":
|
|
272
|
+
raise MaterializerError("malformed frontmatter value")
|
|
273
|
+
return normalized_field_text
|
|
274
|
+
if isinstance(parsed, str):
|
|
275
|
+
return parsed
|
|
276
|
+
if isinstance(parsed, list) and all(isinstance(each_entry, str) for each_entry in parsed):
|
|
277
|
+
return tuple(parsed)
|
|
278
|
+
raise MaterializerError("frontmatter value must be a string or string list")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_frontmatter_list(serialized_list: str) -> tuple[str, ...]:
|
|
282
|
+
if not serialized_list.endswith("]"):
|
|
283
|
+
raise MaterializerError("malformed frontmatter value")
|
|
284
|
+
all_entries: list[str] = []
|
|
285
|
+
entry_start = 1
|
|
286
|
+
quote: str | None = None
|
|
287
|
+
has_escape_pending = False
|
|
288
|
+
for each_index, each_character in enumerate(serialized_list[1:-1], 1):
|
|
289
|
+
if has_escape_pending:
|
|
290
|
+
has_escape_pending = False
|
|
291
|
+
continue
|
|
292
|
+
if quote == '"' and each_character == "\\":
|
|
293
|
+
has_escape_pending = True
|
|
294
|
+
continue
|
|
295
|
+
if each_character in {'"', "'"}:
|
|
296
|
+
if quote is None:
|
|
297
|
+
quote = each_character
|
|
298
|
+
elif quote == each_character:
|
|
299
|
+
quote = None
|
|
300
|
+
continue
|
|
301
|
+
if each_character == "," and quote is None:
|
|
302
|
+
all_entries.append(_parse_frontmatter_list_entry(serialized_list[entry_start:each_index]))
|
|
303
|
+
entry_start = each_index + 1
|
|
304
|
+
if quote is not None or has_escape_pending:
|
|
305
|
+
raise MaterializerError("malformed frontmatter value")
|
|
306
|
+
all_entries.append(_parse_frontmatter_list_entry(serialized_list[entry_start:-1]))
|
|
307
|
+
return tuple(all_entries) if all_entries != [""] else ()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_frontmatter_list_entry(raw_entry: str) -> str:
|
|
311
|
+
entry = raw_entry.strip()
|
|
312
|
+
if not entry:
|
|
313
|
+
raise MaterializerError("malformed frontmatter value")
|
|
314
|
+
try:
|
|
315
|
+
parsed = ast.literal_eval(entry)
|
|
316
|
+
except (SyntaxError, ValueError):
|
|
317
|
+
if entry[0] in {'"', "'"}:
|
|
318
|
+
raise MaterializerError("malformed frontmatter value")
|
|
319
|
+
if any(character in entry for character in "[]{}:"):
|
|
320
|
+
raise MaterializerError("malformed frontmatter value")
|
|
321
|
+
return entry
|
|
322
|
+
if not isinstance(parsed, str):
|
|
323
|
+
raise MaterializerError("frontmatter list entries must be strings")
|
|
324
|
+
return parsed
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_frontmatter(source_path: Path, source_text: str, relative_source: str) -> ClaudeAgent:
|
|
328
|
+
"""Parse one Claude agent's frontmatter.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
source_path: Path used in validation errors.
|
|
332
|
+
source_text: Markdown source containing the frontmatter block.
|
|
333
|
+
relative_source: Safe source identity recorded in the manifest.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The parsed Claude agent.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
MaterializerError: If frontmatter syntax or required fields are invalid.
|
|
340
|
+
"""
|
|
341
|
+
source_identity = _validate_source_identity(relative_source)
|
|
342
|
+
lines = source_text.splitlines()
|
|
343
|
+
if len(lines) < 3 or lines[0].strip() != "---":
|
|
344
|
+
raise MaterializerError(f"malformed frontmatter: {source_path}")
|
|
345
|
+
delimiters = [each_index for each_index, line in enumerate(lines[1:], 1) if line.strip() == "---"]
|
|
346
|
+
if len(delimiters) != 1:
|
|
347
|
+
raise MaterializerError(f"malformed frontmatter delimiters: {source_path}")
|
|
348
|
+
all_fields: dict[str, str | tuple[str, ...] | None] = {}
|
|
349
|
+
for each_line in lines[1 : delimiters[0]]:
|
|
350
|
+
if not each_line.strip() or ":" not in each_line:
|
|
351
|
+
raise MaterializerError(f"malformed frontmatter: {source_path}")
|
|
352
|
+
key, serialized_field_text = each_line.split(":", 1)
|
|
353
|
+
key = key.strip()
|
|
354
|
+
if key in all_fields or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", key):
|
|
355
|
+
raise MaterializerError(f"malformed frontmatter key: {source_path}")
|
|
356
|
+
all_fields[key] = _parse_frontmatter_scalar(serialized_field_text)
|
|
357
|
+
unknown = tuple(sorted(each_key for each_key in all_fields if each_key not in frontmatter_allowed_fields))
|
|
358
|
+
if unknown:
|
|
359
|
+
raise MaterializerError(f"unknown frontmatter keys: {source_path}")
|
|
360
|
+
unsupported = tuple(sorted(each_key for each_key in all_fields if each_key in frontmatter_unsupported_fields))
|
|
361
|
+
if any(not isinstance(all_fields.get(each_key), str) or not all_fields[each_key] for each_key in frontmatter_required_fields):
|
|
362
|
+
raise MaterializerError(f"name and description are required: {source_path}")
|
|
363
|
+
tools = all_fields.get("tools", ())
|
|
364
|
+
if isinstance(tools, str):
|
|
365
|
+
tools = (tools,)
|
|
366
|
+
if not isinstance(tools, tuple):
|
|
367
|
+
raise MaterializerError(f"tools must be a list: {source_path}")
|
|
368
|
+
return ClaudeAgent(source_path, source_identity, all_fields["name"], all_fields["description"], tools, all_fields.get("model"), all_fields.get("color"), unsupported)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def convert_agent(agent: ClaudeAgent) -> str:
|
|
372
|
+
"""Convert a Claude agent to validated Codex TOML content.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
agent: Parsed Claude agent to convert.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
UTF-8 text containing the generated TOML document.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
MaterializerError: If the generated TOML cannot be validated.
|
|
382
|
+
"""
|
|
383
|
+
toml_string = lambda text: json.dumps(text, ensure_ascii=False)
|
|
384
|
+
content = line_separator.join((f"name = {toml_string(agent.name)}", f"description = {toml_string(agent.description)}", f"developer_instructions = {toml_string('Claude tools: ' + comma_separator.join(agent.tools))}")) + line_separator
|
|
385
|
+
tomllib.loads(content)
|
|
386
|
+
return content
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def discover_agents(config: MaterializerConfig) -> list[ClaudeAgent]:
|
|
390
|
+
"""Discover and parse Markdown agents below the source root.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
config: Materializer paths and application settings.
|
|
394
|
+
|
|
395
|
+
An unreachable source root is an error rather than an empty discovery, so a
|
|
396
|
+
mistyped path or an offline share never reads as "this tree holds no agents"::
|
|
397
|
+
|
|
398
|
+
source root missing -> flag: MaterializerError, nothing planned
|
|
399
|
+
source root empty -> ok: [] , and publication asks for prune consent
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Agents discovered in deterministic relative-path order.
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
MaterializerError: If the source root is unreachable, or a source path is unsafe or malformed.
|
|
406
|
+
"""
|
|
407
|
+
if not config.source_root.is_dir():
|
|
408
|
+
raise MaterializerError(f"{unreadable_source_root_message}: {config.source_root}")
|
|
409
|
+
if _is_reparse_point(config.source_root):
|
|
410
|
+
raise MaterializerError(f"{reparse_source_root_message}: {config.source_root}")
|
|
411
|
+
all_agents: list[ClaudeAgent] = []
|
|
412
|
+
for each_path in sorted(config.source_root.rglob("*.md"), key=lambda path: path.as_posix().casefold()):
|
|
413
|
+
if _is_reparse_point(each_path):
|
|
414
|
+
raise MaterializerError(f"source reparse point is not allowed: {each_path}")
|
|
415
|
+
relative_source = each_path.relative_to(config.source_root).as_posix()
|
|
416
|
+
_validate_containment(config.source_root, each_path)
|
|
417
|
+
all_agents.append(parse_frontmatter(each_path, each_path.read_text(encoding="utf-8"), relative_source))
|
|
418
|
+
return all_agents
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _case_fold_collision_error(target_relative_path: str) -> MaterializerError:
|
|
422
|
+
"""Build the error for two target names that differ only by letter case."""
|
|
423
|
+
return MaterializerError(f"case-fold collision: {target_relative_path}")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _validate_orphan_target_is_adoptable(
|
|
427
|
+
config: MaterializerConfig,
|
|
428
|
+
existing_path: Path | None,
|
|
429
|
+
target_relative_path: str,
|
|
430
|
+
content: ManagedContent,
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Allow an unrecorded target file only when its bytes already match the plan.
|
|
433
|
+
|
|
434
|
+
A run interrupted between the file replacement and the manifest save leaves a
|
|
435
|
+
file the manifest does not record. Byte-identical content proves the tool wrote
|
|
436
|
+
it, so the next run adopts it instead of stopping forever::
|
|
437
|
+
|
|
438
|
+
Nova.toml bytes == planned bytes -> ok: adopted, publication continues
|
|
439
|
+
Nova.toml bytes != planned bytes -> flag: MaterializerError naming the remedy
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
config: Materializer paths and application settings.
|
|
443
|
+
existing_path: Target-root path already holding the planned name, or ``None``.
|
|
444
|
+
target_relative_path: Normalized relative path the plan publishes.
|
|
445
|
+
content: Content the plan would publish at that path.
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
MaterializerError: If the existing file differs from the plan or differs only in case.
|
|
449
|
+
"""
|
|
450
|
+
if existing_path is None:
|
|
451
|
+
return
|
|
452
|
+
if existing_path.relative_to(config.target_root).as_posix() != target_relative_path:
|
|
453
|
+
raise _case_fold_collision_error(target_relative_path)
|
|
454
|
+
if not existing_path.is_file() or existing_path.read_bytes() != content_to_bytes(content):
|
|
455
|
+
raise MaterializerError(unmanaged_target_message.format(path=target_relative_path))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _build_plan(config: MaterializerConfig, all_agents: Iterable[ClaudeAgent]) -> tuple[list[PlannedFile], MaterializationReport]:
|
|
459
|
+
"""Build planned agent publications and their report.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
config: Materializer paths and application settings.
|
|
463
|
+
all_agents: Optional parsed agents used by callers that inject discovery results.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Planned files and the report describing unsupported agent fields.
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
MaterializerError: If a source or target path collides or is unsafe.
|
|
470
|
+
"""
|
|
471
|
+
report = MaterializationReport()
|
|
472
|
+
planned: list[PlannedFile] = []
|
|
473
|
+
target_by_name: dict[str, str] = {}
|
|
474
|
+
previous_records = _manifest_record_by_path(load_manifest(config.manifest_path))
|
|
475
|
+
existing_by_name = {
|
|
476
|
+
each_path.relative_to(config.target_root).as_posix().casefold(): each_path
|
|
477
|
+
for each_path in config.target_root.rglob("*")
|
|
478
|
+
if not _is_known_managed_path(config.target_root, each_path, previous_records)
|
|
479
|
+
} if config.target_root.exists() else {}
|
|
480
|
+
for each_agent in list(all_agents):
|
|
481
|
+
source_identity = _validate_source_identity(each_agent.relative_source)
|
|
482
|
+
target_relative_path = _normalize_relative_path(each_agent.name + toml_suffix)
|
|
483
|
+
folded_path = target_relative_path.casefold()
|
|
484
|
+
if folded_path in target_by_name:
|
|
485
|
+
raise _case_fold_collision_error(target_relative_path)
|
|
486
|
+
content = convert_agent(each_agent)
|
|
487
|
+
_validate_orphan_target_is_adoptable(config, existing_by_name.get(folded_path), target_relative_path, content)
|
|
488
|
+
target_by_name[folded_path] = target_relative_path
|
|
489
|
+
target_path = validate_target_path(config.target_root, target_relative_path)
|
|
490
|
+
if _casefold_normalized_path(target_path) == _casefold_normalized_path(config.manifest_path):
|
|
491
|
+
raise MaterializerError("planned target collides with compatibility manifest")
|
|
492
|
+
planned.append(PlannedFile(source_identity, target_relative_path, content, hash_content(content)))
|
|
493
|
+
report.unsupported += len(each_agent.unsupported)
|
|
494
|
+
report.details["unsupported"].extend(f"{source_identity}:{each_key}" for each_key in each_agent.unsupported)
|
|
495
|
+
report.planned_files = planned
|
|
496
|
+
return planned, report
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def build_plan(config: MaterializerConfig, *all_arguments: object, **all_keywords: object) -> tuple[list[PlannedFile], MaterializationReport]:
|
|
500
|
+
"""Build a plan while preserving the legacy optional-agent call form.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
config: Materializer paths and application settings.
|
|
504
|
+
all_arguments: Optional positional discovered-agent iterable.
|
|
505
|
+
all_keywords: Optional ``all_agents`` keyword argument.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Planned files and the report describing unsupported agent fields.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
TypeError: If more than one agent iterable or an unknown keyword is supplied.
|
|
512
|
+
MaterializerError: If a source or target path collides or is unsafe.
|
|
513
|
+
"""
|
|
514
|
+
supplied_agents = all_keywords.pop("all_agents", None)
|
|
515
|
+
if all_keywords or len(all_arguments) > 1:
|
|
516
|
+
raise TypeError("build_plan accepts at most one agent iterable")
|
|
517
|
+
if all_arguments:
|
|
518
|
+
if supplied_agents is not None:
|
|
519
|
+
raise TypeError("build_plan received duplicate all_agents")
|
|
520
|
+
supplied_agents = all_arguments[0]
|
|
521
|
+
discovered_agents = discover_agents(config) if supplied_agents is None else supplied_agents
|
|
522
|
+
return _build_plan(config, discovered_agents)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def content_to_bytes(content: ManagedContent) -> bytes:
|
|
526
|
+
"""Encode managed text while preserving already-encoded bytes.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
content: Text or bytes intended for publication.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
UTF-8 bytes for the managed content.
|
|
533
|
+
"""
|
|
534
|
+
if isinstance(content, bytes):
|
|
535
|
+
return content
|
|
536
|
+
return content.encode("utf-8")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def hash_content(content: ManagedContent) -> str:
|
|
540
|
+
return hashlib.sha256(content_to_bytes(content)).hexdigest()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _atomic_write(
|
|
544
|
+
target_path: Path,
|
|
545
|
+
content: ManagedContent,
|
|
546
|
+
failure_injector: Callable[[str], None] | None,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Write managed content through a temporary file and replace atomically.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
target_path: Destination path for the replacement.
|
|
552
|
+
content: Text or bytes to write.
|
|
553
|
+
failure_injector: Test seam invoked before replacement, or ``None``.
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
OSError: If the temporary file or replacement cannot be written.
|
|
557
|
+
RuntimeError: If the failure injector requests a failed publication.
|
|
558
|
+
ValueError: If the content cannot be encoded.
|
|
559
|
+
|
|
560
|
+
The optional injector is a test seam. When supplied, it runs after the
|
|
561
|
+
temporary file is durable and before the destination replacement.
|
|
562
|
+
|
|
563
|
+
Raises:
|
|
564
|
+
OSError: If the temporary file or replacement cannot be written.
|
|
565
|
+
RuntimeError: If the failure injector requests a failed publication.
|
|
566
|
+
ValueError: If the content cannot be encoded.
|
|
567
|
+
"""
|
|
568
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
569
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target_path.name}.", dir=target_path.parent)
|
|
570
|
+
try:
|
|
571
|
+
with os.fdopen(descriptor, "wb") as temporary_file:
|
|
572
|
+
temporary_file.write(content_to_bytes(content))
|
|
573
|
+
temporary_file.flush()
|
|
574
|
+
os.fsync(temporary_file.fileno())
|
|
575
|
+
if failure_injector is not None:
|
|
576
|
+
failure_injector(str(target_path))
|
|
577
|
+
os.replace(temporary_name, target_path)
|
|
578
|
+
except (OSError, RuntimeError, ValueError):
|
|
579
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
580
|
+
raise
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def atomic_write(target_path: Path, content: ManagedContent, *all_arguments: object, **all_keywords: object) -> None:
|
|
584
|
+
"""Preserve the legacy atomic-write call form while using an explicit core.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
target_path: Destination path for the replacement.
|
|
588
|
+
content: Text or bytes to write.
|
|
589
|
+
all_arguments: Legacy positional failure-injector argument.
|
|
590
|
+
all_keywords: Legacy keyword failure-injector argument.
|
|
591
|
+
|
|
592
|
+
Raises:
|
|
593
|
+
TypeError: If more than one failure injector is supplied or it is not callable.
|
|
594
|
+
OSError, RuntimeError, ValueError: If the atomic replacement cannot be completed.
|
|
595
|
+
"""
|
|
596
|
+
failure_injector = all_keywords.pop("failure_injector", None)
|
|
597
|
+
if all_keywords or len(all_arguments) > 1:
|
|
598
|
+
raise TypeError("atomic_write accepts at most one failure injector")
|
|
599
|
+
if all_arguments:
|
|
600
|
+
failure_injector = all_arguments[0]
|
|
601
|
+
if failure_injector is not None and not callable(failure_injector):
|
|
602
|
+
raise TypeError("failure injector must be callable")
|
|
603
|
+
_atomic_write(target_path, content, failure_injector)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def load_manifest(manifest_path: Path) -> dict[str, object]:
|
|
607
|
+
"""Load a compatibility manifest or return its empty schema.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
manifest_path: Manifest file to read.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
A validated manifest mapping.
|
|
614
|
+
|
|
615
|
+
Raises:
|
|
616
|
+
MaterializerError: If the manifest has an unsupported shape.
|
|
617
|
+
"""
|
|
618
|
+
if not manifest_path.exists():
|
|
619
|
+
return {"version": 1, "files": {}}
|
|
620
|
+
parsed = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
621
|
+
if not isinstance(parsed, dict) or parsed.get("version") != 1 or not isinstance(parsed.get("files"), dict):
|
|
622
|
+
raise MaterializerError("invalid compatibility manifest")
|
|
623
|
+
return parsed
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def save_manifest(manifest_path: Path, all_manifest: dict[str, object], failure_injector: Callable[[str], None] | None = None) -> None:
|
|
627
|
+
"""Atomically save the compatibility manifest last.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
manifest_path: Destination manifest path.
|
|
631
|
+
all_manifest: Manifest mapping to serialize.
|
|
632
|
+
failure_injector: Optional test seam invoked before replacement.
|
|
633
|
+
|
|
634
|
+
Raises:
|
|
635
|
+
OSError: If the temporary file or replacement cannot be written.
|
|
636
|
+
RuntimeError: If the failure injector requests a failed publication.
|
|
637
|
+
"""
|
|
638
|
+
content = json.dumps(all_manifest, ensure_ascii=False, sort_keys=True, indent=manifest_indentation_width) + "\n"
|
|
639
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
640
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{manifest_path.name}.", dir=manifest_path.parent)
|
|
641
|
+
try:
|
|
642
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as temporary_file:
|
|
643
|
+
temporary_file.write(content)
|
|
644
|
+
temporary_file.flush()
|
|
645
|
+
os.fsync(temporary_file.fileno())
|
|
646
|
+
if failure_injector is not None:
|
|
647
|
+
failure_injector("manifest_before_replace")
|
|
648
|
+
os.replace(temporary_name, manifest_path)
|
|
649
|
+
except (OSError, RuntimeError, ValueError):
|
|
650
|
+
Path(temporary_name).unlink(missing_ok=True)
|
|
651
|
+
raise
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _parse_manifest_record(raw_record: object) -> ManifestRecord | None:
|
|
655
|
+
"""Read one manifest entry into a record, or ``None`` when it is not an object."""
|
|
656
|
+
if not isinstance(raw_record, dict):
|
|
657
|
+
return None
|
|
658
|
+
expected_hash = raw_record.get("hash")
|
|
659
|
+
ownership = raw_record.get("ownership")
|
|
660
|
+
return ManifestRecord(
|
|
661
|
+
expected_hash if isinstance(expected_hash, str) else None,
|
|
662
|
+
ownership if isinstance(ownership, str) else None,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _manifest_record_by_path(all_previous_manifest: dict[str, object]) -> ManifestRecordByPath:
|
|
667
|
+
"""Parse the manifest's file entries once, so untyped JSON stops here.
|
|
668
|
+
|
|
669
|
+
Every path stays in the mapping even when its entry is unreadable, because the
|
|
670
|
+
case-fold checks count the names the manifest claims::
|
|
671
|
+
|
|
672
|
+
{"Luna.toml": {"hash": "ab12..", "ownership": "codex-compat"}} -> ok: record
|
|
673
|
+
{"Luna.toml": 7} -> flag: None
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
all_previous_manifest: Validated manifest mapping from ``load_manifest``.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Each manifest path mapped to its record, or to ``None`` when unreadable.
|
|
680
|
+
|
|
681
|
+
Raises:
|
|
682
|
+
MaterializerError: If the manifest's file entries are not a mapping.
|
|
683
|
+
"""
|
|
684
|
+
records = all_previous_manifest["files"]
|
|
685
|
+
if not isinstance(records, dict):
|
|
686
|
+
raise MaterializerError("invalid compatibility manifest files")
|
|
687
|
+
return {each_path: _parse_manifest_record(each_record) for each_path, each_record in records.items()}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _find_manifest_record(all_previous_records: ManifestRecordByPath, target_relative_path: str) -> ManifestRecord | None:
|
|
691
|
+
for each_path, each_record in all_previous_records.items():
|
|
692
|
+
if each_path.casefold() == target_relative_path.casefold():
|
|
693
|
+
return each_record
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _is_known_managed_path(target_root: Path, target_path: Path, all_previous_records: ManifestRecordByPath) -> bool:
|
|
698
|
+
relative_path = target_path.relative_to(target_root).as_posix()
|
|
699
|
+
previous_record = _find_manifest_record(all_previous_records, relative_path)
|
|
700
|
+
return previous_record is not None and previous_record.is_owned_by_tool
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _is_pristine_managed(previous_record: ManifestRecord | None, current_bytes: bytes) -> bool:
|
|
704
|
+
"""Report whether on-disk bytes are exactly what the tool last published there."""
|
|
705
|
+
if previous_record is None or not previous_record.is_owned_by_tool:
|
|
706
|
+
return False
|
|
707
|
+
return hash_content(current_bytes) == previous_record.content_hash
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _record_target_conflict(report: MaterializationReport, target_relative_path: str, previous_record: ManifestRecord | None) -> None:
|
|
711
|
+
report.conflicted += 1
|
|
712
|
+
report.add_detail("conflicted", target_relative_path)
|
|
713
|
+
if previous_record is None:
|
|
714
|
+
report.unmanaged_collision += 1
|
|
715
|
+
report.add_detail("unmanaged_collision", target_relative_path)
|
|
716
|
+
return
|
|
717
|
+
report.modified_managed += 1
|
|
718
|
+
report.add_detail("modified_managed", target_relative_path)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile, all_previous_records: ManifestRecordByPath, report: MaterializationReport) -> tuple[Path, bytes | None, bool]:
|
|
722
|
+
"""Classify one planned target and say whether publication may overwrite it.
|
|
723
|
+
|
|
724
|
+
The manifest hash decides ownership, so a file the tool wrote is refreshed and a
|
|
725
|
+
file the user edited is preserved::
|
|
726
|
+
|
|
727
|
+
target absent -> ok: publish
|
|
728
|
+
on-disk bytes == planned bytes -> ok: unchanged, no write
|
|
729
|
+
on-disk hash == manifest hash -> ok: publish, the tool owns these bytes
|
|
730
|
+
on-disk hash != manifest hash -> flag: conflicted, preserved untouched
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
config: Materializer paths and application settings.
|
|
734
|
+
planned_file: Planned publication for this target.
|
|
735
|
+
all_previous_records: Manifest records from the last successful run.
|
|
736
|
+
report: Report object to update in place.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
The resolved target path, its current bytes when it exists, and whether to publish.
|
|
740
|
+
"""
|
|
741
|
+
target_path = validate_target_path(config.target_root, planned_file.target_relative_path)
|
|
742
|
+
if not target_path.exists():
|
|
743
|
+
return target_path, None, True
|
|
744
|
+
current_bytes = target_path.read_bytes()
|
|
745
|
+
previous_record = _find_manifest_record(all_previous_records, planned_file.target_relative_path)
|
|
746
|
+
if current_bytes == content_to_bytes(planned_file.content):
|
|
747
|
+
_record_matching_target(report, planned_file.target_relative_path, previous_record)
|
|
748
|
+
return target_path, current_bytes, False
|
|
749
|
+
if _is_pristine_managed(previous_record, current_bytes):
|
|
750
|
+
return target_path, current_bytes, True
|
|
751
|
+
_record_target_conflict(report, planned_file.target_relative_path, previous_record)
|
|
752
|
+
return target_path, current_bytes, False
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _record_matching_target(report: MaterializationReport, target_relative_path: str, previous_record: ManifestRecord | None) -> None:
|
|
756
|
+
report.unchanged += 1
|
|
757
|
+
report.add_detail("unchanged", target_relative_path)
|
|
758
|
+
if previous_record is not None:
|
|
759
|
+
return
|
|
760
|
+
report.adopted += 1
|
|
761
|
+
report.add_detail("adopted", target_relative_path)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _remove_stale_files(config: MaterializerConfig, all_previous_records: ManifestRecordByPath, all_planned_files: list[PlannedFile], report: MaterializationReport, all_backups: dict[Path, bytes | None]) -> None:
|
|
765
|
+
current_names = {each_planned_file.target_relative_path.casefold() for each_planned_file in all_planned_files}
|
|
766
|
+
for each_relative_path, each_record in sorted(all_previous_records.items(), key=lambda pair: pair[0].casefold()):
|
|
767
|
+
if each_relative_path.casefold() in current_names or each_record is None:
|
|
768
|
+
continue
|
|
769
|
+
target_path = validate_target_path(config.target_root, each_relative_path)
|
|
770
|
+
if not target_path.exists():
|
|
771
|
+
report.add_error(f"missing managed path: {each_relative_path}")
|
|
772
|
+
continue
|
|
773
|
+
current_bytes = target_path.read_bytes()
|
|
774
|
+
expected_hash = each_record.content_hash
|
|
775
|
+
if expected_hash is not None and hash_content(current_bytes) == expected_hash:
|
|
776
|
+
all_backups[target_path] = current_bytes
|
|
777
|
+
target_path.unlink()
|
|
778
|
+
report.deleted += 1
|
|
779
|
+
report.add_detail("deleted", each_relative_path)
|
|
780
|
+
continue
|
|
781
|
+
if expected_hash is not None:
|
|
782
|
+
report.modified_managed += 1
|
|
783
|
+
report.add_detail("modified_managed", each_relative_path)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _sort_report_details(report: MaterializationReport) -> None:
|
|
787
|
+
for each_category in report_categories:
|
|
788
|
+
if each_category == "errors":
|
|
789
|
+
continue
|
|
790
|
+
report.details[each_category].sort(key=str.casefold)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _validate_planned_targets(
|
|
794
|
+
all_planned_files: list[PlannedFile],
|
|
795
|
+
all_previous_records: ManifestRecordByPath,
|
|
796
|
+
) -> None:
|
|
797
|
+
folded_manifest_names = {each_key.casefold() for each_key in all_previous_records}
|
|
798
|
+
if len(folded_manifest_names) != len(all_previous_records):
|
|
799
|
+
raise MaterializerError("case-fold collision in compatibility manifest")
|
|
800
|
+
planned_names: set[str] = set()
|
|
801
|
+
for each_file in all_planned_files:
|
|
802
|
+
folded_target = each_file.target_relative_path.casefold()
|
|
803
|
+
if folded_target in planned_names:
|
|
804
|
+
raise _case_fold_collision_error(each_file.target_relative_path)
|
|
805
|
+
planned_names.add(folded_target)
|
|
806
|
+
has_manifest_owner = any(
|
|
807
|
+
each_path.casefold() == folded_target
|
|
808
|
+
and each_record is not None
|
|
809
|
+
and each_record.ownership is not None
|
|
810
|
+
for each_path, each_record in all_previous_records.items()
|
|
811
|
+
)
|
|
812
|
+
if folded_target in folded_manifest_names and not has_manifest_owner:
|
|
813
|
+
raise MaterializerError("case-fold collision in compatibility manifest")
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _publish_planned_targets(
|
|
817
|
+
config: MaterializerConfig,
|
|
818
|
+
all_planned_files: list[PlannedFile],
|
|
819
|
+
all_previous_records: ManifestRecordByPath,
|
|
820
|
+
report: MaterializationReport,
|
|
821
|
+
all_backups: dict[Path, bytes | None],
|
|
822
|
+
failure_injector: Callable[[str], None] | None,
|
|
823
|
+
) -> None:
|
|
824
|
+
for each_planned_file in all_planned_files:
|
|
825
|
+
target_path, current_bytes, is_publishable = _record_target_state(config, each_planned_file, all_previous_records, report)
|
|
826
|
+
if _casefold_normalized_path(target_path) == _casefold_normalized_path(config.manifest_path):
|
|
827
|
+
raise MaterializerError("planned target collides with compatibility manifest")
|
|
828
|
+
if not is_publishable:
|
|
829
|
+
continue
|
|
830
|
+
all_backups[target_path] = current_bytes
|
|
831
|
+
atomic_write(target_path, each_planned_file.content, failure_injector)
|
|
832
|
+
report.written += 1
|
|
833
|
+
report.add_detail("written", each_planned_file.target_relative_path)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _build_manifest(all_planned_files: list[PlannedFile]) -> dict[str, object]:
|
|
837
|
+
return {
|
|
838
|
+
"version": 1,
|
|
839
|
+
"files": {
|
|
840
|
+
each_file.target_relative_path: {
|
|
841
|
+
"source": each_file.source_identity,
|
|
842
|
+
"hash": each_file.content_hash,
|
|
843
|
+
"ownership": each_file.ownership,
|
|
844
|
+
"marker": each_file.generated_marker,
|
|
845
|
+
}
|
|
846
|
+
for each_file in all_planned_files
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _rollback_publication(
|
|
852
|
+
all_backups: dict[Path, bytes | None],
|
|
853
|
+
report: MaterializationReport,
|
|
854
|
+
initial_written: int,
|
|
855
|
+
initial_deleted: int,
|
|
856
|
+
) -> None:
|
|
857
|
+
for each_target_path, each_previous_content in reversed(tuple(all_backups.items())):
|
|
858
|
+
try:
|
|
859
|
+
if each_previous_content is None:
|
|
860
|
+
each_target_path.unlink(missing_ok=True)
|
|
861
|
+
else:
|
|
862
|
+
atomic_write(each_target_path, each_previous_content)
|
|
863
|
+
except OSError:
|
|
864
|
+
report.reconcile_required = True
|
|
865
|
+
report.add_error(f"rollback failed: {each_target_path}")
|
|
866
|
+
report.incomplete_generation = True
|
|
867
|
+
report.reconcile_required = True
|
|
868
|
+
report.written = initial_written
|
|
869
|
+
report.deleted = initial_deleted
|
|
870
|
+
report.details["written"] = report.details["written"][:initial_written]
|
|
871
|
+
report.details["deleted"] = report.details["deleted"][:initial_deleted]
|
|
872
|
+
report.add_error("incomplete_generation/reconcile_required")
|
|
873
|
+
_sort_report_details(report)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _validate_full_prune_consent(
|
|
877
|
+
config: MaterializerConfig,
|
|
878
|
+
all_planned_files: list[PlannedFile],
|
|
879
|
+
all_previous_records: ManifestRecordByPath,
|
|
880
|
+
) -> None:
|
|
881
|
+
"""Require an explicit opt-in before an empty plan erases every managed file.
|
|
882
|
+
|
|
883
|
+
An empty plan means every managed file is stale, so publication would delete the
|
|
884
|
+
whole set. That is a legitimate request and also what a mistyped source root
|
|
885
|
+
produces, so the caller has to ask for it by name::
|
|
886
|
+
|
|
887
|
+
empty plan, empty manifest -> ok: nothing to delete
|
|
888
|
+
empty plan, managed files, opt-in given -> ok: prune proceeds
|
|
889
|
+
empty plan, managed files, no opt-in -> flag: MaterializerError, nothing deleted
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
config: Materializer paths and application settings.
|
|
893
|
+
all_planned_files: Files the current run would publish.
|
|
894
|
+
all_previous_records: Manifest records from the last successful run.
|
|
895
|
+
|
|
896
|
+
Raises:
|
|
897
|
+
MaterializerError: If the run would delete every managed file without the opt-in.
|
|
898
|
+
"""
|
|
899
|
+
if all_planned_files or not all_previous_records or config.should_allow_full_prune:
|
|
900
|
+
return
|
|
901
|
+
raise MaterializerError(full_prune_refusal_message.format(count=len(all_previous_records)))
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _publish_plan(
|
|
905
|
+
config: MaterializerConfig,
|
|
906
|
+
all_planned_files: Iterable[PlannedFile],
|
|
907
|
+
report: MaterializationReport,
|
|
908
|
+
failure_injector: Callable[[str], None] | None,
|
|
909
|
+
) -> MaterializationReport:
|
|
910
|
+
"""Publish planned files with rollback and manifest-last semantics.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
config: Materializer paths and application settings.
|
|
914
|
+
all_planned_files: Files to publish, including generic non-TOML content.
|
|
915
|
+
report: Report object to update in place.
|
|
916
|
+
failure_injector: Test seam invoked before each replacement, or ``None``.
|
|
917
|
+
|
|
918
|
+
Returns:
|
|
919
|
+
The updated materialization report.
|
|
920
|
+
|
|
921
|
+
Raises:
|
|
922
|
+
OSError, RuntimeError, ValueError: If publication fails after rollback.
|
|
923
|
+
"""
|
|
924
|
+
publication = report
|
|
925
|
+
all_planned_files = list(all_planned_files)
|
|
926
|
+
publication.planned_files = all_planned_files
|
|
927
|
+
if not config.should_apply:
|
|
928
|
+
return publication
|
|
929
|
+
previous_manifest = load_manifest(config.manifest_path)
|
|
930
|
+
previous_records = _manifest_record_by_path(previous_manifest)
|
|
931
|
+
_validate_full_prune_consent(config, all_planned_files, previous_records)
|
|
932
|
+
backups: dict[Path, bytes | None] = {}
|
|
933
|
+
initial_written = publication.written
|
|
934
|
+
initial_deleted = publication.deleted
|
|
935
|
+
try:
|
|
936
|
+
_validate_planned_targets(all_planned_files, previous_records)
|
|
937
|
+
_publish_planned_targets(
|
|
938
|
+
config, all_planned_files, previous_records, publication, backups, failure_injector
|
|
939
|
+
)
|
|
940
|
+
_remove_stale_files(config, previous_records, all_planned_files, publication, backups)
|
|
941
|
+
save_manifest(config.manifest_path, _build_manifest(all_planned_files), failure_injector)
|
|
942
|
+
except (OSError, RuntimeError, ValueError) as error:
|
|
943
|
+
_rollback_publication(backups, publication, initial_written, initial_deleted)
|
|
944
|
+
raise error
|
|
945
|
+
_sort_report_details(publication)
|
|
946
|
+
return publication
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def publish_plan(config: MaterializerConfig, *all_arguments: object, **all_keywords: object) -> MaterializationReport:
|
|
950
|
+
"""Publish a plan while preserving the legacy optional-argument call form.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
config: Materializer paths and application settings.
|
|
954
|
+
all_arguments: Planned files, optional report, and optional injector.
|
|
955
|
+
all_keywords: ``all_planned_files``, ``report``, or ``failure_injector``.
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
The updated materialization report.
|
|
959
|
+
|
|
960
|
+
Raises:
|
|
961
|
+
TypeError: If required data is missing or arguments are duplicated.
|
|
962
|
+
OSError, RuntimeError, ValueError: If publication fails after rollback.
|
|
963
|
+
"""
|
|
964
|
+
planned_files = all_keywords.pop("all_planned_files", None)
|
|
965
|
+
report = all_keywords.pop("report", None)
|
|
966
|
+
failure_injector = all_keywords.pop("failure_injector", None)
|
|
967
|
+
if all_keywords or (planned_files is None and not all_arguments) or len(all_arguments) > publish_plan_max_positional_arguments:
|
|
968
|
+
raise TypeError("publish_plan requires planned files and accepts at most three values")
|
|
969
|
+
if planned_files is not None and all_arguments:
|
|
970
|
+
raise TypeError("publish_plan received duplicate planned files")
|
|
971
|
+
if planned_files is None:
|
|
972
|
+
planned_files = all_arguments[0]
|
|
973
|
+
if len(all_arguments) > 1:
|
|
974
|
+
if report is not None:
|
|
975
|
+
raise TypeError("publish_plan received duplicate report")
|
|
976
|
+
report = all_arguments[1]
|
|
977
|
+
if len(all_arguments) > publish_plan_failure_injector_position:
|
|
978
|
+
if failure_injector is not None:
|
|
979
|
+
raise TypeError("publish_plan received duplicate failure injector")
|
|
980
|
+
failure_injector = all_arguments[2]
|
|
981
|
+
publication = report if isinstance(report, MaterializationReport) else MaterializationReport()
|
|
982
|
+
return _publish_plan(config, planned_files, publication, failure_injector)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _redact_private_paths(
|
|
986
|
+
message: str,
|
|
987
|
+
config: MaterializerConfig | None,
|
|
988
|
+
all_private_paths: Iterable[Path],
|
|
989
|
+
) -> str:
|
|
990
|
+
redacted_message = message
|
|
991
|
+
configured_paths = (
|
|
992
|
+
(config.source_root, config.target_root, config.manifest_path)
|
|
993
|
+
if config is not None
|
|
994
|
+
else ()
|
|
995
|
+
)
|
|
996
|
+
for each_private_path in (*configured_paths, *all_private_paths):
|
|
997
|
+
redacted_message = redacted_message.replace(str(each_private_path), "<private-path>")
|
|
998
|
+
return redacted_message
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _build_report_payload(
|
|
1002
|
+
report: MaterializationReport,
|
|
1003
|
+
config: MaterializerConfig | None,
|
|
1004
|
+
should_apply: bool,
|
|
1005
|
+
all_private_paths: Iterable[Path],
|
|
1006
|
+
) -> dict[str, object]:
|
|
1007
|
+
report_payload = {each_category: getattr(report, each_category) for each_category in report_categories}
|
|
1008
|
+
report_payload["incomplete_generation"] = report.incomplete_generation
|
|
1009
|
+
report_payload["reconcile_required"] = report.reconcile_required
|
|
1010
|
+
report_payload["error_details"] = [
|
|
1011
|
+
_redact_private_paths(each_message, config, all_private_paths)
|
|
1012
|
+
for each_message in sorted(report.error_details, key=str.casefold)
|
|
1013
|
+
]
|
|
1014
|
+
report_payload["details"] = {
|
|
1015
|
+
each_category: [_redact_private_paths(each_message, config, all_private_paths) for each_message in messages]
|
|
1016
|
+
for each_category, messages in report.details.items()
|
|
1017
|
+
}
|
|
1018
|
+
report_payload["dry_run"] = not should_apply
|
|
1019
|
+
return report_payload
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def create_argument_parser() -> argparse.ArgumentParser:
|
|
1023
|
+
"""Create the command-line parser for compatibility materialization.
|
|
1024
|
+
|
|
1025
|
+
Returns:
|
|
1026
|
+
A parser accepting source and target roots, the apply flag, and the prune opt-in.
|
|
1027
|
+
"""
|
|
1028
|
+
parser = MaterializerArgumentParser(description=__doc__)
|
|
1029
|
+
parser.add_argument("source_root", type=Path)
|
|
1030
|
+
parser.add_argument("target_root", type=Path)
|
|
1031
|
+
parser.add_argument("--apply", dest="should_apply", action="store_true")
|
|
1032
|
+
parser.add_argument(full_prune_opt_in_flag, dest="should_allow_full_prune", action="store_true")
|
|
1033
|
+
return parser
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def main(*all_arguments: object) -> int:
|
|
1037
|
+
"""Run materialization from command-line arguments.
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
all_arguments: Optional argument list; no value uses process arguments.
|
|
1041
|
+
|
|
1042
|
+
Returns:
|
|
1043
|
+
Zero after reporting the materialization result.
|
|
1044
|
+
|
|
1045
|
+
Raises:
|
|
1046
|
+
TypeError: If the optional argument list has an invalid shape or type.
|
|
1047
|
+
MaterializerError: If the source and target configuration is unsafe.
|
|
1048
|
+
OSError, RuntimeError, ValueError: If discovery, planning, or publication fails.
|
|
1049
|
+
"""
|
|
1050
|
+
report = MaterializationReport()
|
|
1051
|
+
config: MaterializerConfig | None = None
|
|
1052
|
+
should_apply = False
|
|
1053
|
+
source_root: Path | None = None
|
|
1054
|
+
target_root: Path | None = None
|
|
1055
|
+
try:
|
|
1056
|
+
if len(all_arguments) > 1:
|
|
1057
|
+
raise TypeError("main accepts at most one argument list")
|
|
1058
|
+
cli_arguments = all_arguments[0] if all_arguments else None
|
|
1059
|
+
if cli_arguments is not None and not isinstance(cli_arguments, list):
|
|
1060
|
+
raise TypeError("main argument must be a list of command-line strings")
|
|
1061
|
+
options = create_argument_parser().parse_args(cli_arguments)
|
|
1062
|
+
should_apply = options.should_apply
|
|
1063
|
+
source_root = options.source_root
|
|
1064
|
+
target_root = options.target_root
|
|
1065
|
+
config = MaterializerConfig(
|
|
1066
|
+
source_root,
|
|
1067
|
+
target_root,
|
|
1068
|
+
should_apply=should_apply,
|
|
1069
|
+
should_allow_full_prune=options.should_allow_full_prune,
|
|
1070
|
+
)
|
|
1071
|
+
discovered_agents = discover_agents(config)
|
|
1072
|
+
planned, report = build_plan(config, all_agents=discovered_agents)
|
|
1073
|
+
publish_plan(config, all_planned_files=planned, report=report, failure_injector=None)
|
|
1074
|
+
except (MaterializerError, OSError, RuntimeError, ValueError) as error:
|
|
1075
|
+
report.add_error(str(error))
|
|
1076
|
+
all_private_paths = tuple(
|
|
1077
|
+
each_path
|
|
1078
|
+
for each_path in (source_root, target_root)
|
|
1079
|
+
if each_path is not None
|
|
1080
|
+
)
|
|
1081
|
+
report_payload = _build_report_payload(report, config, should_apply, all_private_paths)
|
|
1082
|
+
print(json.dumps(report_payload, sort_keys=True))
|
|
1083
|
+
return 1 if report.errors or report.conflicted else 0
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
if __name__ == "__main__":
|
|
1087
|
+
raise SystemExit(main())
|