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

390 lines
18 KiB
Python

#!/usr/bin/env python3
"""Materialize a project Work Item as a validated branch contract packet."""
from __future__ import annotations
import argparse
from datetime import date
import hashlib
import json
from pathlib import Path
import re
import shutil
import sys
import tempfile
from typing import Any
from contract_markdown import cell, clean, parse_frontmatter, parse_tables, replace_table_cell, table_for
from fs_transaction import ReplacementValue, SymlinkValue, replace_many
import layout_check
import moc_indexer
import quality_gate
import semantic_certificate
import semantic_surface_extractor
import template_renderer
import typed_contract_check
import vault_migrate
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*)$")
DEC_ID_RE = re.compile(r"^DEC-[A-Z0-9][A-Z0-9-]*-\d{3}$")
WI_RE = re.compile(r"^WI-[A-Z0-9][A-Z0-9-]*-\d{3}$")
SLUG_RE = re.compile(r"^(feature|fix|chore|experiment)-[a-z0-9]+(?:-[a-z0-9]+)*$")
class BranchError(ValueError):
def __init__(self, code: str, message: str, location: str = "") -> None:
self.code = code
self.location = location
super().__init__(message)
def _refs(value: str) -> dict[str, int]:
items = [clean(item) for item in value.split(",") if clean(item) and clean(item) != "-"]
if not items:
raise BranchError("MISSING_APPLIED_DECISION", "Applies Decisions must contain at least one pinned reference")
parsed: dict[str, int] = {}
for item in items:
match = DEC_REF_RE.fullmatch(item)
if not match:
raise BranchError("INVALID_DECISION_REF", f"invalid pinned decision reference: {item}")
if match.group(1) in parsed:
raise BranchError("DUPLICATE_DECISION_REF", f"duplicate pinned decision reference: {item}")
parsed[match.group(1)] = int(match.group(2))
return parsed
def _dependencies(value: str) -> list[str]:
items = [clean(item) for item in value.split(",") if clean(item) and clean(item) != "-"]
for item in items:
if not WI_RE.fullmatch(item):
raise BranchError("INVALID_WORK_ITEM_DEPENDENCY", f"invalid Work Item dependency: {item}")
return sorted(set(items))
def _inline(values: list[str]) -> str:
return "[" + ", ".join(values) + "]"
def _table_cell(value: str) -> str:
return value.replace("|", "\\|")
def _render_branch(
root: Path,
project: str,
wi_id: str,
slug: str,
revision: int,
completion: str,
decisions: list[tuple[str, int, str]],
dependencies: list[str],
) -> str:
branch_id = wi_id.replace("WI-", "BR-", 1)
inherited = [f"{decision_id}@{decision_revision}" for decision_id, decision_revision, _ in decisions]
rows = "\n".join(
f"| `{decision_id}@{decision_revision}` | {_table_cell(summary)} | `{wi_id}` 완료 조건에 적용 | "
f"`[[raw/project-notes/{project}]]` |"
for decision_id, decision_revision, summary in decisions
)
template_path = root / "templates/branch-note-template.md"
if not template_path.is_file():
template_path = DEFAULT_ROOT / "templates/branch-note-template.md"
values = {
"branch_slug": slug,
"branch_id": branch_id,
"project": project,
"project_parent_link": f"- [[raw/project-notes/{project}]]",
"work_item": wi_id,
"inherits_yaml": _inline(inherited),
"depends_on_yaml": _inline(dependencies),
"created": date.today().isoformat(),
"contract_packet_sha256": "0" * 64,
"project_revision": str(revision),
"completion": completion,
"inherited_rows": rows,
"dependency_display": ", ".join(f"`{item}`" for item in dependencies) if dependencies else "해당 없음",
}
try:
provisional = template_renderer.render_branch_note(template_path, values)
values["contract_packet_sha256"] = template_renderer.generated_sha256(provisional)
return template_renderer.render_branch_note(template_path, values)
except template_renderer.TemplateRenderError as exc:
raise BranchError(exc.code, str(exc), str(template_path)) from exc
def _moc_staging_roots() -> set[Path]:
"""staging 에 담을 문서 root — moc_indexer 가 스캔하는 모든 relation root.
staging 이 이보다 좁으면 stage 에 없는 child_root(raw/official-docs 등)의
문서로 파생되던 generated region 이 *빈 목록* 으로 재생성되고, 그 파괴적
결과가 실 저장소에 그대로 반영된다(2026-07-23 실측 — 무관 문서 100+개의
sources region 소실). relations config 를 읽지 못하면 기존 최소 2개 root 로
되돌아간다 — 그 경우 build_updates 가 같은 파일을 읽다 스스로 실패한다.
"""
roots = {Path("raw/project-notes"), Path("raw/branch-notes")}
try:
relations = json.loads(moc_indexer.DEFAULT_RELATIONS.read_text(encoding="utf-8"))
except (OSError, ValueError):
return roots
for relation in relations.get("relations", []):
for key in ("child_roots", "parent_roots"):
for value in relation.get(key) or []:
roots.add(Path(str(value)))
return roots
def _plan_sha256(root: Path, changes: dict[Path, ReplacementValue]) -> str:
digest = hashlib.sha256()
for path in sorted(changes, key=lambda item: item.relative_to(root).as_posix()):
relative = path.relative_to(root).as_posix().encode("utf-8")
payload = changes[path]
if isinstance(payload, SymlinkValue):
# canonical 모드 신규 문서의 호환 심링크. vault_migrate 의
# _replacement_fingerprint 와 같은 "symlink\0<target>" 인코딩으로
# bytes 와 구분해 결정론 해시에 넣는다.
payload = ("symlink\0" + payload.target).encode("utf-8")
digest.update(len(relative).to_bytes(8, "big"))
digest.update(relative)
digest.update(len(payload).to_bytes(8, "big"))
digest.update(payload)
return digest.hexdigest()
def prepare(
root: Path,
project: str,
wi_id: str,
*,
layout_path: Path = layout_check.DEFAULT_MANIFEST,
) -> tuple[dict[Path, bytes], dict[str, Any]]:
root = root.resolve()
# Isolated runtime fixtures may omit copied harness sources. In that case
# validate their documents with the packaged schema. For the real repo
# this resolves to the same local path, so a missing schema still fails
# closed instead of silently disabling the gate.
typed_schema = (
typed_contract_check.DEFAULT_SCHEMA
if (root / typed_contract_check.DEFAULT_SCHEMA).is_file()
else DEFAULT_ROOT / typed_contract_check.DEFAULT_SCHEMA
)
try:
typed_result = typed_contract_check.check(root, typed_schema)
except (typed_contract_check.TypedContractError, OSError, UnicodeError) as exc:
raise BranchError("TYPED_CONTRACT_ERROR", str(exc)) from exc
if typed_result["status"] != "PASS":
codes = ",".join(
sorted({str(item.get("code", "UNKNOWN")) for item in typed_result.get("findings", [])})
)
raise BranchError("TYPED_CONTRACT_FAILED", codes or "typed contract gate failed")
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", project):
raise BranchError("INVALID_PROJECT_SLUG", f"invalid project slug: {project}")
expected_wi = re.compile(rf"^WI-{re.escape(project.upper())}-\d{{3}}$")
if not expected_wi.fullmatch(wi_id):
raise BranchError("INVALID_WORK_ITEM_ID", f"Work Item must match WI-{project.upper()}-NNN")
project_path = root / "raw/project-notes" / f"{project}.md"
# cutover 이후 legacy 경로는 정본을 가리키는 심링크다. lexical 경로 그대로
# write-root 집행에 넘기면 canonical 모드에서 "raw/… 는 write root 밖" 으로
# 거부돼 **모든 입력에서 실패**한다 — 실제로 그 상태였다. 위치 판정은 정본으로 한다.
project_authority_path = project_path.resolve() if project_path.is_symlink() else project_path
# 신규 branch 노트의 목적지는 이제 vault_migrate.plan_new_documents 가 계산한다
# (정본 + 호환 심링크 + manifest 2행). 존재하지 않는 합성 경로로 write root 를
# 미리 찔러보던 authority-check probe 는 그 계산을 우회하므로 제거한다.
try:
authority = layout_check.resolve_authority(root, layout_path)
layout_check.enforce_write_paths(root, [project_authority_path], authority)
except layout_check.LayoutContractError as exc:
raise BranchError(exc.code, str(exc), exc.location) from exc
if not project_path.is_file():
raise BranchError("PROJECT_NOT_FOUND", f"project note does not exist: {project_path}")
if (root / semantic_surface_extractor.DEFAULT_POLICY).is_file():
try:
semantic_parent = semantic_certificate.check(root, mode="hub", paths=[project_path])
except (
semantic_certificate.SemanticCertificateError,
semantic_surface_extractor.SemanticSurfaceError,
typed_contract_check.TypedContractError,
OSError,
UnicodeError,
) as exc:
raise BranchError("PARENT_SEMANTIC_CERTIFICATE_ERROR", str(exc), project_path.relative_to(root).as_posix()) from exc
if semantic_parent["status"] != "PASS":
codes = ",".join(sorted({str(item.get("code", "UNKNOWN")) for item in semantic_parent["findings"]}))
raise BranchError(
"PARENT_SEMANTIC_CERTIFICATE_INVALID",
codes or "parent project hub certificate is missing, stale, or blocking",
project_path.relative_to(root).as_posix(),
)
project_text = project_path.read_text(encoding="utf-8")
frontmatter = parse_frontmatter(project_text)
revision_raw = str(frontmatter.get("project_revision", ""))
if not revision_raw.isdigit() or int(revision_raw) < 1:
raise BranchError("LEGACY_PROJECT_CONTRACT", "project_revision must be a positive integer")
revision = int(revision_raw)
tables = parse_tables(project_text)
decision_table = table_for(tables, "section-id:project-decisions", "안정 결정 레지스트리", "Project Decision Registry")
work_table = table_for(tables, "section-id:project-work-items", "실행계획", "Work Item Registry")
if decision_table is None or work_table is None:
raise BranchError("LEGACY_PROJECT_CONTRACT", "project decision/work-item registry is required")
registry: dict[str, tuple[int, str]] = {}
for line, row in decision_table.rows:
decision_id = clean(cell(row, "Decision ID"))
decision_revision = clean(cell(row, "Revision"))
if not DEC_ID_RE.fullmatch(decision_id) or not decision_revision.isdigit() or int(decision_revision) < 1:
raise BranchError("INVALID_PROJECT_DECISION", "invalid decision registry row", f"{project_path}:{line}")
if decision_id in registry:
raise BranchError("DUPLICATE_DECISION_OWNER", f"duplicate decision: {decision_id}", f"{project_path}:{line}")
registry[decision_id] = (int(decision_revision), clean(cell(row, "Decision Summary")))
work_rows = [(line, row) for line, row in work_table.rows if clean(cell(row, "Work Item ID")) == wi_id]
if len(work_rows) != 1:
code = "WORK_ITEM_NOT_FOUND" if not work_rows else "DUPLICATE_WORK_ITEM"
raise BranchError(code, f"expected exactly one {wi_id} row, observed {len(work_rows)}")
work_line, work_row = work_rows[0]
all_work_ids = {clean(cell(row, "Work Item ID")) for _line, row in work_table.rows}
slug = clean(cell(work_row, "branch slug"))
if not SLUG_RE.fullmatch(slug) or not 4 <= len(slug.split("-")[1:]) <= 8:
raise BranchError("INVALID_BRANCH_SLUG", f"branch slug violates naming-conventions: {slug}")
target = root / "raw/branch-notes" / f"{slug}.md"
if target.exists():
raise BranchError("TARGET_EXISTS", f"branch target already exists: {target}")
completion = clean(cell(work_row, "완료 조건 (측정가능)"))
if not completion:
raise BranchError("MISSING_COMPLETION_CRITERION", f"{wi_id} has no completion criterion")
applied = _refs(cell(work_row, "Applies Decisions"))
inherited: list[tuple[str, int, str]] = []
for decision_id, pinned_revision in applied.items():
current = registry.get(decision_id)
if current is None:
raise BranchError("MISSING_PROJECT_DECISION", f"{decision_id} is not in the project registry")
if pinned_revision != current[0]:
raise BranchError(
"STALE_INHERITANCE_REVISION",
f"{decision_id}@{pinned_revision} does not match current @{current[0]}",
)
inherited.append((decision_id, pinned_revision, current[1]))
dependencies = _dependencies(cell(work_row, "Dependencies"))
missing_dependencies = sorted(set(dependencies) - all_work_ids)
if missing_dependencies:
raise BranchError("MISSING_WORK_ITEM_DEPENDENCY", f"unknown dependencies: {missing_dependencies}")
updated_project = project_text
if clean(cell(work_row, "Status")) == "planned":
updated_project = replace_table_cell(updated_project, work_table, work_line, "Status", "`in-progress`")
branch_text = _render_branch(root, project, wi_id, slug, revision, completion, sorted(inherited), dependencies)
with tempfile.TemporaryDirectory(prefix=".branch-from-project-stage-", dir=root) as directory:
stage = Path(directory)
for rel in sorted(_moc_staging_roots()):
source = root / rel
if source.exists():
shutil.copytree(source, stage / rel)
staged_project = stage / project_path.relative_to(root)
staged_target = stage / target.relative_to(root)
staged_project.parent.mkdir(parents=True, exist_ok=True)
staged_target.parent.mkdir(parents=True, exist_ok=True)
staged_project.write_text(updated_project, encoding="utf-8")
staged_target.write_text(branch_text, encoding="utf-8")
moc_updates, _moc_stats = moc_indexer.build_updates(stage)
for path, text in moc_updates.items():
path.write_text(text, encoding="utf-8")
remaining, _ = moc_indexer.build_updates(stage)
if remaining:
raise BranchError("MOC_VALIDATION_FAILED", "MOC indexer did not converge")
try:
gate = quality_gate.run(
stage,
[staged_project, staged_target],
structure_paths=[staged_target],
template_root=DEFAULT_ROOT,
)
except quality_gate.QualityGateError as exc:
raise BranchError("QUALITY_GATE_ERROR", str(exc)) from exc
if gate["status"] != "PASS":
summary = "; ".join(
f"{item['code']} {item['path']}: {item['message']}" for item in gate["findings"][:10]
)
raise BranchError("QUALITY_GATE_FAILED", summary)
changes = {project_path: staged_project.read_bytes(), target: staged_target.read_bytes()}
for staged_path in moc_updates:
actual_path = root / staged_path.relative_to(stage)
changes[actual_path] = staged_path.read_bytes()
try:
changes, authority = vault_migrate.expand_authoritative_changes(
root,
changes,
layout_path=layout_path,
)
except vault_migrate.MigrationError as exc:
raise BranchError(exc.code, str(exc), exc.location) from exc
plan_sha256 = _plan_sha256(root, changes)
return changes, {
"project": project,
"work_item": wi_id,
"project_revision": revision,
"target": target.relative_to(root).as_posix(),
"inherits_count": len(inherited),
"depends_on_count": len(dependencies),
"changed_paths": [path.relative_to(root).as_posix() for path in sorted(changes)],
"must_not_exist_paths": [
path.relative_to(root).as_posix() for path in sorted(changes) if not path.exists()
],
"active_layout": {
"mode": authority["mode"],
"authority": authority["authority"],
"write_roots": authority["write_roots"],
"manifest_sha256": authority["manifest_sha256"],
},
"plan_sha256": plan_sha256,
}
def _emit(document: dict[str, Any]) -> None:
json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
sys.stdout.write("\n")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("project")
parser.add_argument("work_item")
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
parser.add_argument("--layout", type=Path, default=layout_check.DEFAULT_MANIFEST)
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)
changes, result = prepare(root, args.project, args.work_item, layout_path=args.layout)
if args.apply:
expected = args.expected_plan_sha256
if expected is not None:
if not re.fullmatch(r"[0-9a-f]{64}", expected):
raise BranchError("INVALID_PLAN_SHA256", "expected plan hash must be 64 lowercase hex characters")
if expected != result["plan_sha256"]:
raise BranchError(
"PLAN_HASH_MISMATCH",
f"expected {expected}, current plan is {result['plan_sha256']}",
)
forbidden = {root / path for path in result["must_not_exist_paths"]}
replace_many(changes, must_not_exist=forbidden)
_emit({"schema_version": "branch-from-project-result/v1", "status": "APPLIED" if args.apply else "DRY_RUN", "findings": [], **result})
return 0
except BranchError as exc:
error = {"code": exc.code, "location": exc.location, "message": str(exc)}
_emit({"schema_version": "branch-from-project-result/v1", "status": "FAIL", "errors": [error]})
return 1
except (OSError, UnicodeError) as exc:
_emit({"schema_version": "branch-from-project-result/v1", "status": "ERROR", "errors": [{"code": "IO_ERROR", "location": "", "message": str(exc)}]})
return 2
if __name__ == "__main__":
raise SystemExit(main())