init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user