336 lines
15 KiB
Python
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())
|