Files
llm-wiki/harness/runtime/branch_contract_check.py
T

336 lines
15 KiB
Python

#!/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())