519 lines
25 KiB
Python
519 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Atomically commit one candidate document and all generated reverse views."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
from typing import Any, Callable, Mapping
|
|
|
|
from fs_transaction import replace_many, stage_repository
|
|
import contract_projection
|
|
import layout_check
|
|
import moc_indexer
|
|
import proof_manifest
|
|
import quality_gate
|
|
import semantic_audit
|
|
import semantic_certificate
|
|
import semantic_surface_extractor
|
|
import typed_contract_check
|
|
import vault_migrate
|
|
|
|
|
|
SCHEMA_VERSION = "document-commit/v1"
|
|
RESULT_SCHEMA = "document-commit-result/v1"
|
|
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
|
HEX_SHA256 = proof_manifest.HEX_SHA256
|
|
QualityRunner = Callable[..., dict[str, Any]]
|
|
|
|
|
|
class DocumentCommitError(ValueError):
|
|
def __init__(self, code: str, message: str, location: str = "") -> None:
|
|
self.code = code
|
|
self.location = location
|
|
super().__init__(message)
|
|
|
|
|
|
class PreparedChanges(dict[Path, bytes]):
|
|
"""Final bytes plus optimistic preconditions for concurrent-edit detection."""
|
|
|
|
def __init__(self, values: Mapping[Path, bytes], expected: Mapping[Path, str | None]) -> None:
|
|
super().__init__(values)
|
|
self.expected = dict(expected)
|
|
|
|
|
|
def _projection_quality_result(staged_root: Path) -> dict[str, Any]:
|
|
updates, result = contract_projection.build_updates(staged_root)
|
|
current = result.get("status") == "CURRENT" and not updates
|
|
return {
|
|
"schema_version": contract_projection.RESULT_SCHEMA,
|
|
"status": "PASS" if current else "FAIL",
|
|
"findings": result.get("findings", []),
|
|
}
|
|
|
|
|
|
def _repo_path(root: Path, value: Any, location: str, *, must_exist: bool) -> Path:
|
|
if not isinstance(value, str) or not value or "\\" in value:
|
|
raise DocumentCommitError("INVALID_PATH", "path must be a non-empty repo-relative POSIX path", location)
|
|
candidate = Path(value)
|
|
if candidate.is_absolute():
|
|
raise DocumentCommitError("PATH_OUTSIDE_REPO", "absolute path is not allowed", location)
|
|
resolved = (root / candidate).resolve()
|
|
try:
|
|
resolved.relative_to(root)
|
|
except ValueError as exc:
|
|
raise DocumentCommitError("PATH_OUTSIDE_REPO", "path escapes repository root", location) from exc
|
|
if must_exist and not resolved.is_file():
|
|
raise DocumentCommitError("FILE_NOT_FOUND", "file does not exist", location)
|
|
return resolved
|
|
|
|
|
|
def _sha256(value: Any, location: str) -> str:
|
|
if not isinstance(value, str) or not HEX_SHA256.fullmatch(value):
|
|
raise DocumentCommitError("INVALID_SHA256", "expected 64 lowercase hexadecimal characters", location)
|
|
return value
|
|
|
|
|
|
def _plan_sha256(
|
|
root: Path,
|
|
changes: "PreparedChanges",
|
|
*,
|
|
candidate_hash: str,
|
|
proof_hash: str,
|
|
target: Path,
|
|
authority: Mapping[str, Any],
|
|
) -> str:
|
|
document = {
|
|
"schema_version": "document-commit-plan/v1",
|
|
"candidate_sha256": candidate_hash,
|
|
"proof_manifest_sha256": proof_hash,
|
|
"target": target.relative_to(root).as_posix(),
|
|
"active_layout": {
|
|
"authority": authority["authority"],
|
|
"manifest_sha256": authority["manifest_sha256"],
|
|
"mode": authority["mode"],
|
|
"write_roots": authority["write_roots"],
|
|
},
|
|
"writes": [
|
|
{
|
|
"path": path.relative_to(root).as_posix(),
|
|
"sha256": hashlib.sha256(changes[path]).hexdigest(),
|
|
"expected_sha256": changes.expected[path],
|
|
}
|
|
for path in sorted(changes)
|
|
],
|
|
}
|
|
payload = (json.dumps(document, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8")
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _request_parts(root: Path, request: Any) -> tuple[Path, str, Path, bool, Path, str]:
|
|
required = {"schema_version", "candidate", "target", "proof_manifest"}
|
|
if not isinstance(request, dict) or not required.issubset(request) or set(request) - required - {"semantic_audit"}:
|
|
raise DocumentCommitError("INVALID_REQUEST", "request has missing or unknown top-level fields")
|
|
if request.get("schema_version") != SCHEMA_VERSION:
|
|
raise DocumentCommitError("REQUEST_SCHEMA_MISMATCH", f"expected {SCHEMA_VERSION}")
|
|
candidate = request.get("candidate")
|
|
target = request.get("target")
|
|
proof = request.get("proof_manifest")
|
|
if not isinstance(candidate, dict) or set(candidate) != {"path", "sha256"}:
|
|
raise DocumentCommitError("INVALID_CANDIDATE", "candidate must contain path and sha256", "candidate")
|
|
if not isinstance(target, dict) or set(target) != {"path", "must_not_exist"}:
|
|
raise DocumentCommitError("INVALID_TARGET", "target must contain path and must_not_exist", "target")
|
|
if not isinstance(proof, dict) or set(proof) != {"path", "sha256"}:
|
|
raise DocumentCommitError("INVALID_PROOF_MANIFEST", "proof_manifest must contain path and sha256", "proof_manifest")
|
|
candidate_path = _repo_path(root, candidate.get("path"), "candidate.path", must_exist=True)
|
|
candidate_hash = _sha256(candidate.get("sha256"), "candidate.sha256")
|
|
target_path = _repo_path(root, target.get("path"), "target.path", must_exist=False)
|
|
if target_path.suffix != ".md" or target_path == candidate_path:
|
|
raise DocumentCommitError("INVALID_TARGET", "target must be a distinct Markdown path", "target.path")
|
|
if not isinstance(target.get("must_not_exist"), bool):
|
|
raise DocumentCommitError("INVALID_TARGET", "must_not_exist must be boolean", "target.must_not_exist")
|
|
proof_path = _repo_path(root, proof.get("path"), "proof_manifest.path", must_exist=True)
|
|
proof_hash = _sha256(proof.get("sha256"), "proof_manifest.sha256")
|
|
return candidate_path, candidate_hash, target_path, target["must_not_exist"], proof_path, proof_hash
|
|
|
|
|
|
def _semantic_parts(root: Path, request: Mapping[str, Any], run_root: Path | None) -> tuple[Path, str, Path, str] | None:
|
|
value = request.get("semantic_audit")
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, dict) or set(value) != {"request", "result"}:
|
|
raise DocumentCommitError("INVALID_SEMANTIC_AUDIT", "semantic_audit must contain request and result", "semantic_audit")
|
|
parsed: list[Path | str] = []
|
|
for key in ("request", "result"):
|
|
reference = value.get(key)
|
|
if not isinstance(reference, dict) or set(reference) not in ({"path", "sha256"}, {"namespace", "path", "sha256"}):
|
|
raise DocumentCommitError("INVALID_SEMANTIC_AUDIT", f"semantic_audit.{key} must contain namespace/path/sha256", f"semantic_audit.{key}")
|
|
namespace = reference.get("namespace", "repo")
|
|
if namespace not in {"repo", "run"}:
|
|
raise DocumentCommitError("INVALID_SEMANTIC_AUDIT", "semantic audit namespace must be repo or run", f"semantic_audit.{key}.namespace")
|
|
source_root = root if namespace == "repo" else run_root
|
|
if source_root is None:
|
|
raise DocumentCommitError("SEMANTIC_RUN_ROOT_REQUIRED", "run namespace requires semantic_run_root", f"semantic_audit.{key}.namespace")
|
|
parsed.extend((
|
|
_repo_path(source_root, reference.get("path"), f"semantic_audit.{key}.path", must_exist=True),
|
|
_sha256(reference.get("sha256"), f"semantic_audit.{key}.sha256"),
|
|
))
|
|
return parsed[0], parsed[1], parsed[2], parsed[3] # type: ignore[return-value]
|
|
|
|
|
|
def _verify_hash(path: Path, expected: str, code: str, location: str) -> bytes:
|
|
content = path.read_bytes()
|
|
observed = hashlib.sha256(content).hexdigest()
|
|
if observed != expected:
|
|
raise DocumentCommitError(code, f"expected {expected}, observed {observed}", location)
|
|
return content
|
|
|
|
|
|
def _verify_proof(path: Path, expected_hash: str, root: Path, profiles_path: Path) -> dict[str, Any]:
|
|
content = _verify_hash(path, expected_hash, "PROOF_MANIFEST_HASH_MISMATCH", "proof_manifest.sha256")
|
|
try:
|
|
manifest = json.loads(content.decode("utf-8"))
|
|
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
raise DocumentCommitError("INVALID_PROOF_MANIFEST", str(exc), "proof_manifest.path") from exc
|
|
verification = manifest.get("verification") if isinstance(manifest, dict) else None
|
|
if not isinstance(verification, dict) or verification.get("status") != "PASS" or verification.get("fail_count") != 0:
|
|
raise DocumentCommitError("PROOF_NOT_PASS", "persisted proof manifest must have PASS and fail_count=0")
|
|
profiles = proof_manifest.load_allowed_profiles(profiles_path.resolve(strict=True))
|
|
try:
|
|
verified = proof_manifest.verify_manifest(manifest, root, profiles)
|
|
except proof_manifest.ManifestValidationError as exc:
|
|
codes = ",".join(sorted({issue["code"] for issue in exc.issues}))
|
|
raise DocumentCommitError("PROOF_REVALIDATION_FAILED", codes, "proof_manifest.path") from exc
|
|
if verified["verification"]["fail_count"] != 0:
|
|
raise DocumentCommitError("PROOF_NOT_PASS", "failed proofs block document completion")
|
|
return verified
|
|
|
|
|
|
def _stage_repository(root: Path, destination: Path) -> None:
|
|
stage_repository(root, destination)
|
|
|
|
|
|
def prepare(
|
|
root: Path,
|
|
request: Any,
|
|
*,
|
|
profiles_path: Path = proof_manifest.DEFAULT_PROFILES,
|
|
relations_path: Path = moc_indexer.DEFAULT_RELATIONS,
|
|
layout_path: Path = layout_check.DEFAULT_MANIFEST,
|
|
quality_runner: QualityRunner = quality_gate.run,
|
|
semantic_run_root: Path | None = None,
|
|
) -> tuple[PreparedChanges, dict[str, Any], set[Path]]:
|
|
"""Prepare verified final bytes without changing the repository."""
|
|
root = root.resolve(strict=True)
|
|
candidate, candidate_hash, target, must_not_exist, proof_path, proof_hash = _request_parts(root, request)
|
|
# 정체성 판정(TARGET_EXISTS 위치 표기·subject 대조·staging·touched/report)은 심링크를
|
|
# 따라가기 *전* 논리 경로로 한다 — canonical cutover 에서 target 을 resolve() 하면
|
|
# vault 철자가 되어 인증서의 logical(raw/…) subject 와 영구 mismatch 였다
|
|
# (branch_contract_check 가 고친 pre-resolve 규율의 쌍둥이, 2026-07-23 실측).
|
|
# resolve() 된 `target` 은 실제 바이트가 쓰일 목적지로만 쓴다.
|
|
logical_target = layout_check._lexical_absolute(root / Path(str(request["target"]["path"])))
|
|
logical_rel = logical_target.relative_to(root).as_posix()
|
|
resolved_run_root = semantic_run_root.resolve(strict=True) if semantic_run_root is not None else None
|
|
semantic_parts = _semantic_parts(root, request, resolved_run_root)
|
|
try:
|
|
authority = layout_check.resolve_authority(root, layout_path)
|
|
enforcement_targets = [target]
|
|
if (
|
|
authority["mode"] == "canonical"
|
|
and not logical_target.exists()
|
|
and not logical_target.is_symlink()
|
|
and vault_migrate._is_legacy_content_path(root, logical_target, layout_path)
|
|
):
|
|
# 신규 legacy 문서 — 목적지·호환 심링크·manifest 는 expand 의 planner 가
|
|
# 계산·검증한다(정본은 그 단계에서 계속 write-root 집행 대상).
|
|
enforcement_targets = []
|
|
layout_check.enforce_write_paths(root, enforcement_targets, authority)
|
|
except layout_check.LayoutContractError as exc:
|
|
raise DocumentCommitError(exc.code, str(exc), exc.location) from exc
|
|
if must_not_exist and target.exists():
|
|
raise DocumentCommitError("TARGET_EXISTS", "target already exists", logical_rel)
|
|
candidate_bytes = _verify_hash(candidate, candidate_hash, "CANDIDATE_HASH_MISMATCH", "candidate.sha256")
|
|
verified_proof = _verify_proof(proof_path, proof_hash, root, profiles_path)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="document-commit-stage-") as directory:
|
|
stage = Path(directory) / "repo"
|
|
stage.mkdir()
|
|
_stage_repository(root, stage)
|
|
staged_target = stage / logical_rel
|
|
staged_target.parent.mkdir(parents=True, exist_ok=True)
|
|
target_original_hash = hashlib.sha256(staged_target.read_bytes()).hexdigest() if staged_target.is_file() else None
|
|
staged_target.write_bytes(candidate_bytes)
|
|
|
|
moc_updates, moc_stats = moc_indexer.build_updates(stage, relations_path)
|
|
moc_original_hashes = {
|
|
path.relative_to(stage).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
for path in moc_updates
|
|
}
|
|
for path, text in moc_updates.items():
|
|
path.write_text(text, encoding="utf-8")
|
|
remaining, _ = moc_indexer.build_updates(stage, relations_path)
|
|
if remaining:
|
|
raise DocumentCommitError("MOC_NOT_CONVERGED", "relation indexer did not converge")
|
|
|
|
projection_updates, projection_result = contract_projection.build_updates(stage)
|
|
if projection_result["status"] == "FAIL":
|
|
codes = ",".join(sorted({str(item.get("code", "UNKNOWN")) for item in projection_result["findings"]}))
|
|
raise DocumentCommitError("TYPED_CONTRACT_FAILED", codes)
|
|
projection_original_hashes = {
|
|
path.relative_to(stage).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
for path in projection_updates
|
|
}
|
|
for path, text in projection_updates.items():
|
|
path.write_text(text, encoding="utf-8")
|
|
projection_remaining, projection_after = contract_projection.build_updates(stage)
|
|
if projection_remaining or projection_after["status"] != "CURRENT":
|
|
raise DocumentCommitError("CONTRACT_PROJECTION_NOT_CONVERGED", "typed projections did not converge")
|
|
|
|
staged_policy = stage / semantic_surface_extractor.DEFAULT_POLICY
|
|
target_frontmatter = semantic_surface_extractor.parse_frontmatter(staged_target.read_text(encoding="utf-8"))
|
|
semantic_required = False
|
|
if str(target_frontmatter.get("source_type", "")) in {"project-note", "branch-note"}:
|
|
if not staged_policy.is_file():
|
|
raise DocumentCommitError("SEMANTIC_POLICY_SOURCE_MISSING", "design-bearing document requires semantic surface policy")
|
|
policy = semantic_surface_extractor.load_policy(stage)
|
|
semantic_required = semantic_surface_extractor.is_required(target_frontmatter, policy)
|
|
|
|
certificate_stage_path: Path | None = None
|
|
certificate_root_path: Path | None = None
|
|
certificate_original_hash: str | None = None
|
|
certificate_document: dict[str, Any] | None = None
|
|
if semantic_parts is not None:
|
|
audit_request_path, audit_request_hash, audit_result_path, audit_result_hash = semantic_parts
|
|
request_bytes = _verify_hash(audit_request_path, audit_request_hash, "SEMANTIC_AUDIT_REQUEST_HASH_MISMATCH", "semantic_audit.request.sha256")
|
|
result_bytes = _verify_hash(audit_result_path, audit_result_hash, "SEMANTIC_AUDIT_RESULT_HASH_MISMATCH", "semantic_audit.result.sha256")
|
|
try:
|
|
audit_request = json.loads(request_bytes.decode("utf-8"))
|
|
audit_result = json.loads(result_bytes.decode("utf-8"))
|
|
validated_audit = semantic_audit.validate_result(
|
|
stage,
|
|
audit_request,
|
|
audit_result,
|
|
profiles_path=profiles_path,
|
|
run_root=resolved_run_root,
|
|
)
|
|
certificate_stage_path, certificate_bytes, certificate_document = semantic_certificate.prepare_certificate(
|
|
stage,
|
|
validated_audit,
|
|
audit_request=audit_request,
|
|
audit_result=audit_result,
|
|
run_root=resolved_run_root,
|
|
)
|
|
except (
|
|
UnicodeError,
|
|
json.JSONDecodeError,
|
|
semantic_audit.SemanticAuditError,
|
|
semantic_certificate.SemanticCertificateError,
|
|
proof_manifest.ManifestValidationError,
|
|
) as exc:
|
|
raise DocumentCommitError("SEMANTIC_AUDIT_FAILED", str(exc), "semantic_audit") from exc
|
|
if certificate_document["subject"] != logical_rel:
|
|
raise DocumentCommitError("SEMANTIC_AUDIT_SUBJECT_MISMATCH", "semantic audit subject must equal target path", "semantic_audit")
|
|
if certificate_document["verdict"] != "PASS":
|
|
raise DocumentCommitError("SEMANTIC_BLOCKING_VERDICT", "semantic audit contains blocking or readiness-blocking findings", "semantic_audit")
|
|
certificate_stage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
certificate_stage_path.write_bytes(certificate_bytes)
|
|
certificate_root_path = root / certificate_stage_path.relative_to(stage)
|
|
certificate_original_hash = hashlib.sha256(certificate_root_path.read_bytes()).hexdigest() if certificate_root_path.is_file() else None
|
|
elif semantic_required:
|
|
raise DocumentCommitError("SEMANTIC_CERTIFICATE_MISSING", "required design-bearing candidate has no semantic audit", logical_rel)
|
|
|
|
touched_rel = {logical_rel}
|
|
touched_rel.update(path.relative_to(stage).as_posix() for path in moc_updates)
|
|
touched_rel.update(path.relative_to(stage).as_posix() for path in projection_updates)
|
|
extensions = [
|
|
quality_gate.QualityExtension(
|
|
"typed-contract",
|
|
lambda staged: typed_contract_check.check(
|
|
staged,
|
|
include_projection=False,
|
|
),
|
|
),
|
|
quality_gate.QualityExtension(
|
|
"contract-projection",
|
|
_projection_quality_result,
|
|
),
|
|
]
|
|
if certificate_stage_path is not None:
|
|
extensions.append(
|
|
quality_gate.QualityExtension(
|
|
"semantic-certificate",
|
|
lambda staged: semantic_certificate.quality_extension(staged, [staged_target]),
|
|
)
|
|
)
|
|
try:
|
|
gate = quality_runner(
|
|
stage,
|
|
sorted(touched_rel),
|
|
structure_paths=[logical_rel],
|
|
template_root=root,
|
|
include_graph=True,
|
|
require_moc_convergence=True,
|
|
extensions=extensions,
|
|
)
|
|
except quality_gate.QualityGateError as exc:
|
|
raise DocumentCommitError("QUALITY_GATE_ERROR", str(exc)) from exc
|
|
if not isinstance(gate, dict) or gate.get("schema_version") != "quality-gate-result/v1":
|
|
raise DocumentCommitError("QUALITY_GATE_ERROR", "unexpected quality gate result schema")
|
|
if gate.get("status") != "PASS":
|
|
codes = ",".join(sorted({str(item.get("code", "UNKNOWN")) for item in gate.get("findings", [])}))
|
|
raise DocumentCommitError("QUALITY_GATE_FAILED", codes)
|
|
|
|
raw_changes = {root / rel: (stage / rel).read_bytes() for rel in sorted(touched_rel)}
|
|
expected_before_expansion = {
|
|
root / rel: (
|
|
target_original_hash
|
|
if rel == logical_rel
|
|
else moc_original_hashes.get(rel)
|
|
if rel in moc_original_hashes
|
|
else projection_original_hashes.get(rel)
|
|
)
|
|
for rel in sorted(touched_rel)
|
|
}
|
|
try:
|
|
expanded, authority = vault_migrate.expand_authoritative_changes(
|
|
root,
|
|
raw_changes,
|
|
layout_path=layout_path,
|
|
relations_path=relations_path,
|
|
)
|
|
except vault_migrate.MigrationError as exc:
|
|
raise DocumentCommitError(exc.code, str(exc), exc.location) from exc
|
|
if certificate_stage_path is not None and certificate_root_path is not None:
|
|
expanded[certificate_root_path] = certificate_stage_path.read_bytes()
|
|
expected_before_expansion[certificate_root_path] = certificate_original_hash
|
|
expected = {
|
|
path: expected_before_expansion.get(
|
|
path,
|
|
hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None,
|
|
)
|
|
for path in expanded
|
|
}
|
|
changes = PreparedChanges(expanded, expected)
|
|
|
|
forbidden = {path for path, digest in changes.expected.items() if digest is None} if must_not_exist else set()
|
|
plan_sha256 = _plan_sha256(
|
|
root,
|
|
changes,
|
|
candidate_hash=candidate_hash,
|
|
proof_hash=proof_hash,
|
|
target=root / logical_rel,
|
|
authority=authority,
|
|
)
|
|
result = {
|
|
"target": logical_rel,
|
|
"candidate_sha256": candidate_hash,
|
|
"proof_manifest": proof_path.relative_to(root).as_posix(),
|
|
"proof_manifest_sha256": proof_hash,
|
|
"proof_count": verified_proof["verification"]["proof_count"],
|
|
"semantic_certificate": (
|
|
certificate_root_path.relative_to(root).as_posix()
|
|
if certificate_root_path is not None
|
|
else None
|
|
),
|
|
"relation_edges": moc_stats["canonical_edges"],
|
|
"changed_paths": [path.relative_to(root).as_posix() for path in sorted(changes)],
|
|
"active_layout": {
|
|
"mode": authority["mode"],
|
|
"authority": authority["authority"],
|
|
"write_roots": authority["write_roots"],
|
|
"manifest_sha256": authority["manifest_sha256"],
|
|
},
|
|
"plan_sha256": plan_sha256,
|
|
}
|
|
return changes, result, forbidden
|
|
|
|
|
|
def commit(changes: PreparedChanges, forbidden: set[Path]) -> None:
|
|
"""Check snapshot preconditions, then perform exactly one multi-file replace."""
|
|
for path, expected_hash in changes.expected.items():
|
|
if expected_hash is None:
|
|
if path.exists():
|
|
raise DocumentCommitError("CONCURRENT_MODIFICATION", "new target appeared after staging", path.as_posix())
|
|
continue
|
|
if not path.is_file():
|
|
raise DocumentCommitError("CONCURRENT_MODIFICATION", "existing target disappeared after staging", path.as_posix())
|
|
observed = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
if observed != expected_hash:
|
|
raise DocumentCommitError(
|
|
"CONCURRENT_MODIFICATION",
|
|
f"expected pre-commit hash {expected_hash}, observed {observed}",
|
|
path.as_posix(),
|
|
)
|
|
replace_many(changes, must_not_exist=forbidden)
|
|
|
|
|
|
def _failure(exc: Exception) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": RESULT_SCHEMA,
|
|
"status": "FAIL",
|
|
"error": {
|
|
"code": getattr(exc, "code", "IO_ERROR"),
|
|
"location": getattr(exc, "location", ""),
|
|
"message": str(exc),
|
|
},
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("request", type=Path)
|
|
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
|
parser.add_argument("--profiles", type=Path, default=proof_manifest.DEFAULT_PROFILES)
|
|
parser.add_argument("--relations", type=Path, default=moc_indexer.DEFAULT_RELATIONS)
|
|
parser.add_argument("--layout", type=Path, default=layout_check.DEFAULT_MANIFEST)
|
|
parser.add_argument("--semantic-run-root", type=Path)
|
|
mode = parser.add_mutually_exclusive_group(required=True)
|
|
mode.add_argument("--dry-run", action="store_true")
|
|
mode.add_argument("--apply", action="store_true")
|
|
parser.add_argument("--expected-plan-sha256")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
root = args.root.resolve(strict=True)
|
|
request = json.loads(args.request.read_text(encoding="utf-8"))
|
|
changes, result, forbidden = prepare(
|
|
root,
|
|
request,
|
|
profiles_path=args.profiles,
|
|
relations_path=args.relations,
|
|
layout_path=args.layout,
|
|
semantic_run_root=args.semantic_run_root,
|
|
)
|
|
if args.apply:
|
|
if args.expected_plan_sha256 is not None:
|
|
expected = args.expected_plan_sha256
|
|
if not HEX_SHA256.fullmatch(expected):
|
|
raise DocumentCommitError("INVALID_PLAN_SHA256", "expected plan hash must be 64 lowercase hex characters")
|
|
if expected != result["plan_sha256"]:
|
|
raise DocumentCommitError(
|
|
"PLAN_HASH_MISMATCH",
|
|
f"expected {expected}, current plan is {result['plan_sha256']}",
|
|
)
|
|
commit(changes, forbidden)
|
|
json.dump(
|
|
{"schema_version": RESULT_SCHEMA, "status": "APPLIED" if args.apply else "DRY_RUN", **result},
|
|
sys.stdout,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
sys.stdout.write("\n")
|
|
return 0
|
|
except (DocumentCommitError, proof_manifest.ManifestValidationError) as exc:
|
|
json.dump(_failure(exc), sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
return 1
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
json.dump(_failure(exc), sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|