390 lines
18 KiB
Python
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())
|