#!/usr/bin/env python3 """Deterministic preflight/postflight validation for project Work Item branches.""" from __future__ import annotations import argparse import hashlib import importlib.util import json from pathlib import Path import re import sys import tempfile from typing import Any from contract_markdown import as_list, cell, clean, parse_frontmatter, parse_tables, table_for import fs_transaction import migrate_graph_contracts import quality_gate import template_renderer DEFAULT_ROOT = Path(__file__).resolve().parents[2] DEC_REF_RE = re.compile(r"DEC-[A-Z0-9][A-Z0-9-]*-\d{3}@[1-9]\d*") WI_RE = re.compile(r"WI-[A-Z0-9][A-Z0-9-]*-\d{3}") EDITABLE_SECTION_IDS = ("branch-parent", "branch-goal", "branch-scope") class ContractCheckError(RuntimeError): pass def _module(name: str, path: Path) -> Any: spec = importlib.util.spec_from_file_location(name, path) if spec is None or spec.loader is None: raise ContractCheckError(f"checker cannot be loaded: {path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _refs(value: object) -> set[str]: text = " ".join(as_list(value)) return set(DEC_REF_RE.findall(text)) def _wis(value: object) -> set[str]: return set(WI_RE.findall(" ".join(as_list(value)))) def _finding(code: str, path: str, message: str, line: int = 0) -> dict[str, Any]: return {"code": code, "path": path, "line": line, "message": message} def validate(root: Path, branch_path: str | Path, *, postflight: bool = False) -> dict[str, Any]: try: root = root.resolve(strict=True) path = Path(branch_path) path = path if path.is_absolute() else root / path # vault cutover 이후 raw/branch-notes/*.md 는 vault 정본을 가리키는 심링크다. # resolve() 한 경로로 부모를 검사하면 모든 branch 가 "raw/branch-notes 직계가 # 아니다"로 거부돼 postflight 자체가 돌지 않는다 — 그래서 packet digest drift 가 # 조용히 쌓였다. 위치 판정은 심링크를 따라가기 *전* 경로로, 읽기는 정본으로 한다. legacy = path if path.is_absolute() else root / path if legacy.parent.resolve() != (root / "raw/branch-notes").resolve(): raise ContractCheckError( f"branch must be a direct raw/branch-notes child: {legacy.relative_to(root).as_posix()}" ) path = path.resolve(strict=True) rel = legacy.relative_to(root).as_posix() text = path.read_text(encoding="utf-8") fm = parse_frontmatter(text) findings: list[dict[str, Any]] = [] project = str(fm.get("project", "")).strip() work_item = str(fm.get("work_item", "")).strip() project_path = root / "raw/project-notes" / f"{project}.md" if not project or not project_path.is_file(): findings.append(_finding("PROJECT_NOT_FOUND", rel, f"project does not exist: {project}")) if not WI_RE.fullmatch(work_item): findings.append(_finding("WORK_ITEM_NOT_FOUND", rel, f"invalid Work Item: {work_item}")) registry, _summaries, registry_blocked = migrate_graph_contracts._registries(root) findings.extend( _finding(item["code"], item["path"], item["message"]) for item in registry_blocked ) kind = str(fm.get("kind", "")).strip() item = registry.get(path.stem) if item is None and kind == "branch-child": candidates = [candidate for candidate in registry.values() if candidate["work_item"] == work_item] item = candidates[0] if len(candidates) == 1 else None if item is None: findings.append(_finding("BRANCH_SLUG_MISMATCH", rel, "branch cannot be resolved to one Work Item row")) elif kind != "branch-child" and registry.get(path.stem) is None: findings.append(_finding("BRANCH_SLUG_MISMATCH", rel, "project Work Item branch slug differs from filename")) elif item["project"] != project or item["work_item"] != work_item: findings.append( _finding( "WORK_ITEM_BINDING_MISMATCH", rel, f"expected {item['project']}/{item['work_item']}, observed {project}/{work_item}", ) ) project_prefix = project.upper() for reference in sorted(_refs(fm.get("inherits")) | _refs(fm.get("refines")) | _refs(fm.get("overrides"))): if not reference.startswith(f"DEC-{project_prefix}-"): findings.append(_finding("FOREIGN_PROJECT_PREFIX", rel, reference)) for dependency in sorted(_wis(fm.get("depends_on")) | ({work_item} if work_item else set())): if not dependency.startswith(f"WI-{project_prefix}-"): findings.append(_finding("FOREIGN_PROJECT_PREFIX", rel, dependency)) tables = parse_tables(text) inherited_table = table_for(tables, "section-id:inherited-project-decisions") local_table = table_for(tables, "section-id:branch-local-decisions") overrides_table = table_for(tables, "section-id:declared-overrides") packet_refs = { reference for _line, row in (inherited_table.rows if inherited_table else []) for reference in _refs(cell(row, "Decision Ref")) } fm_inherits = _refs(fm.get("inherits")) if packet_refs != fm_inherits: findings.append( _finding("INHERITED_DECISION_MISMATCH", rel, f"frontmatter={sorted(fm_inherits)}, packet={sorted(packet_refs)}") ) if item is not None: expected_refs = set(item["decisions"]) if fm_inherits != expected_refs: findings.append( _finding("INHERITED_DECISION_MISMATCH", rel, f"Work Item={sorted(expected_refs)}, branch={sorted(fm_inherits)}") ) dependencies = _wis(fm.get("depends_on")) if dependencies != set(item["dependencies"]): findings.append( _finding("DEPENDENCY_MISMATCH", rel, f"Work Item={sorted(item['dependencies'])}, branch={sorted(dependencies)}") ) revision_match = re.search(r"^-\s*\*\*생성 시 프로젝트 개정\*\*:\s*`?([1-9]\d*)`?\s*$", text, re.MULTILINE) observed_revision = int(revision_match.group(1)) if revision_match else None if observed_revision != item["project_revision"]: findings.append( _finding("STALE_PROJECT_REVISION", rel, f"expected {item['project_revision']}, observed {observed_revision}") ) completion_match = re.search(r"^-\s*\*\*완료 조건\*\*:\s*(.*?)\s*$", text, re.MULTILINE) observed_completion = clean(completion_match.group(1)) if completion_match else "" if observed_completion != clean(item["completion"]): findings.append( _finding("COMPLETION_CRITERION_MISMATCH", rel, f"expected {item['completion']!r}, observed {observed_completion!r}") ) fm_refines = _refs(fm.get("refines")) relation_refines: set[str] = set() for _line, row in (local_table.rows if local_table else []): relation = cell(row, "Relation") if re.search(r"\brefines\b", relation, re.IGNORECASE): relation_refines.update(_refs(relation)) if fm_refines != relation_refines: findings.append( _finding("REFINES_RELATION_MISMATCH", rel, f"frontmatter={sorted(fm_refines)}, rows={sorted(relation_refines)}") ) fm_overrides = _refs(fm.get("overrides")) table_overrides: set[str] = set() unapproved: set[str] = set() invalid_approvals = {"", "pending", "tbd", "none", "needs-confirmation", "unapproved", "needs-approval"} for _line, row in (overrides_table.rows if overrides_table else []): refs = _refs(cell(row, "Overrides")) table_overrides.update(refs) if clean(cell(row, "Approval")).lower() in invalid_approvals: unapproved.update(refs) if fm_overrides != table_overrides or unapproved: findings.append( _finding( "OVERRIDE_APPROVAL_MISMATCH", rel, f"frontmatter={sorted(fm_overrides)}, rows={sorted(table_overrides)}, unapproved={sorted(unapproved)}", ) ) expected_hash = str(fm.get("contract_packet_sha256", "")).strip() actual_hash = "" try: actual_hash = template_renderer.generated_sha256(text) if not re.fullmatch(r"[0-9a-f]{64}", expected_hash) or expected_hash != actual_hash: findings.append( _finding("GENERATED_REGION_DRIFT", rel, f"expected hash {expected_hash or '(missing)'}, actual {actual_hash}") ) except template_renderer.TemplateRenderError as exc: findings.append(_finding(exc.code, rel, str(exc))) graph = _module("wiki_graph_contract_check_branch_contract", DEFAULT_ROOT / ".claude/hooks/wiki_graph_contract_check.py") graph_findings, _warnings, _stats = graph.scan(root, include_expected_edges=True) findings.extend(_finding(code, finding_path, message, line) for code, finding_path, line, message in graph_findings) if postflight: # R1 branch workflows validate their reverse view through the graph # checker above. The generalized all-document MOC is an R2 gate and # may contain unrelated legacy edges that must not disable R1 edits. gate = quality_gate.run( root, [path], structure_paths=[path], template_root=DEFAULT_ROOT, require_moc_convergence=False, ) findings.extend(gate["findings"]) unique = { (item["code"], item["path"], item.get("line", 0), item["message"]): item for item in findings } findings = [unique[key] for key in sorted(unique)] if any(item["code"] == "OVERRIDE_APPROVAL_MISMATCH" for item in findings): findings.append( { **next(item for item in findings if item["code"] == "OVERRIDE_APPROVAL_MISMATCH"), "code": "UNDECLARED_OVERRIDE", "alias_of": "OVERRIDE_APPROVAL_MISMATCH", } ) findings.sort(key=lambda item: (item["path"], item.get("line", 0), item["code"], item["message"])) return { "schema_version": "branch-contract-check-result/v1", "status": "PASS" if not findings else "FAIL", "phase": "postflight" if postflight else "preflight", "branch": rel, "project": project, "work_item": work_item, "project_revision": item["project_revision"] if item is not None else None, "inherits": sorted(fm_inherits), "editable_sections": list(EDITABLE_SECTION_IDS), "generated_hashes": { "declared_sha256": expected_hash, "observed_sha256": actual_hash, }, "failure_code_aliases": {"OVERRIDE_APPROVAL_MISMATCH": ["UNDECLARED_OVERRIDE"]}, "findings": findings, } except ContractCheckError: raise except quality_gate.QualityGateError as exc: raise ContractCheckError(str(exc)) from exc except (OSError, UnicodeError, ValueError, ImportError) as exc: raise ContractCheckError(str(exc)) from exc def _stage_repository(root: Path, destination: Path) -> None: fs_transaction.stage_repository(root, destination) def validate_candidate( root: Path, branch_path: str | Path, candidate_path: str | Path, *, postflight: bool = True, ) -> dict[str, Any]: """Validate candidate bytes in an isolated repository without touching target.""" root = root.resolve(strict=True) target = Path(branch_path) target = target if target.is_absolute() else root / target # validate() 와 같은 규율 — 위치 판정은 심링크를 따라가기 *전* 경로로 한다. # 예전에는 resolve() 후의 경로로 relative 를 뽑아 validate() 에 되먹였고, validate() # 의 수정(pre-resolve 판정)이 그대로 상쇄돼 candidate 경로의 postflight 는 여전히 # "raw/branch-notes 직계가 아니다"로 거부됐다 — 고친 함수의 쌍둥이가 안 고쳐진 사례다. legacy = target try: relative = legacy.relative_to(root) except ValueError as exc: raise ContractCheckError(f"branch escapes repository: {target}") from exc target = target.resolve(strict=True) candidate = Path(candidate_path) candidate = candidate if candidate.is_absolute() else root / candidate candidate = candidate.resolve(strict=True) if not candidate.is_file(): raise ContractCheckError(f"candidate is not a file: {candidate}") original = target.read_bytes() original_sha256 = hashlib.sha256(original).hexdigest() with tempfile.TemporaryDirectory(prefix="branch-contract-stage-") as directory: stage = Path(directory) / "repo" stage.mkdir() _stage_repository(root, stage) staged_target = stage / relative staged_target.write_bytes(candidate.read_bytes()) result = validate(stage, relative, postflight=postflight) if target.read_bytes() != original: raise ContractCheckError("branch target changed during candidate validation") result["candidate"] = candidate.relative_to(root).as_posix() if candidate.is_relative_to(root) else candidate.as_posix() result["target_sha256"] = original_sha256 result["candidate_sha256"] = hashlib.sha256(candidate.read_bytes()).hexdigest() result["staged"] = True return result def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("branch") parser.add_argument("--candidate", type=Path, help="candidate bytes to validate in an isolated staged repository") parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--preflight", action="store_true") mode.add_argument("--postflight", action="store_true") args = parser.parse_args(argv) try: if args.candidate is not None and not args.postflight: raise ContractCheckError("--candidate requires --postflight") result = ( validate_candidate(args.root, args.branch, args.candidate, postflight=True) if args.candidate is not None else validate(args.root, args.branch, postflight=args.postflight) ) json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 0 if result["status"] == "PASS" else 1 except ContractCheckError as exc: json.dump( { "schema_version": "branch-contract-check-result/v1", "status": "ERROR", "errors": [{"code": "CONTRACT_CHECK_ERROR", "message": str(exc)}], }, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True, ) sys.stdout.write("\n") return 2 if __name__ == "__main__": raise SystemExit(main())