zone-engine/tools/check_canon_lineage.py
tegwick be29c28100 feat: harden zone reference contracts
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0291a-1e87-7151-9934-fcbfe3f65eb1
2026-08-23 12:27:13 +02:00

125 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Verify a local canon-lineage record against an authoritative checkout."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
from pathlib import Path
from typing import Any, Mapping
import yaml
LINEAGE_STANDARD = "canon-lineage_v0.1"
class LineageError(ValueError):
"""The lineage manifest or canonical artifact is inconsistent."""
def _required(mapping: Mapping[str, Any], key: str) -> Any:
value = mapping.get(key)
if value is None or value == "":
raise LineageError(f"lineage.{key} is required")
return value
def _sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _frontmatter(content: bytes) -> dict[str, Any]:
text = content.decode()
if not text.startswith("---\n") or "\n---\n" not in text[4:]:
raise LineageError("canonical artifact requires YAML frontmatter")
raw = text.split("\n---\n", 1)[0][4:]
value = yaml.safe_load(raw) or {}
if not isinstance(value, dict):
raise LineageError("canonical frontmatter must be a mapping")
return value
def check_lineage(
manifest: Any,
canon_root: Path,
*,
verify_revision: bool = True,
) -> dict[str, Any]:
if not isinstance(manifest, Mapping):
raise LineageError("lineage manifest must be a mapping")
if manifest.get("standard") != LINEAGE_STANDARD:
raise LineageError(f"lineage.standard must be {LINEAGE_STANDARD}")
relative = Path(str(_required(manifest, "canonical_path")))
if relative.is_absolute() or ".." in relative.parts:
raise LineageError("canonical_path must stay below canon_root")
revision = str(_required(manifest, "canonical_revision"))
expected_hash = str(_required(manifest, "canonical_sha256"))
expected_status = str(_required(manifest, "canonical_status"))
canonical_path = canon_root / relative
try:
content = canonical_path.read_bytes()
except OSError as exc:
raise LineageError(f"cannot read canonical artifact: {exc}") from exc
actual_hash = _sha256(content)
frontmatter = _frontmatter(content)
errors: list[str] = []
if actual_hash != expected_hash:
errors.append(
f"canonical content hash changed: expected {expected_hash}, got {actual_hash}"
)
if str(frontmatter.get("status")) != expected_status:
errors.append(
"canonical lifecycle changed: "
f"expected {expected_status}, got {frontmatter.get('status')}"
)
revision_hash = None
if verify_revision:
completed = subprocess.run(
["git", "-C", str(canon_root), "show", f"{revision}:{relative.as_posix()}"],
check=False,
capture_output=True,
)
if completed.returncode != 0:
errors.append(
f"cannot read canonical artifact at revision {revision}: "
+ completed.stderr.decode().strip()
)
else:
revision_hash = _sha256(completed.stdout)
if revision_hash != expected_hash:
errors.append(
f"revision {revision} content does not match canonical_sha256"
)
return {
"ok": not errors,
"artifact": manifest.get("artifact"),
"publication_owner": manifest.get("publication_owner"),
"canonical_path": relative.as_posix(),
"canonical_revision": revision,
"canonical_status": frontmatter.get("status"),
"canonical_sha256": actual_hash,
"revision_sha256": revision_hash,
"errors": errors,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--canon-root", required=True, type=Path)
args = parser.parse_args()
try:
manifest = yaml.safe_load(args.manifest.read_text()) or {}
result = check_lineage(manifest, args.canon_root)
except (OSError, yaml.YAMLError, LineageError) as exc:
result = {"ok": False, "errors": [str(exc)]}
print(json.dumps(result, indent=2, sort_keys=True))
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())