#!/usr/bin/env python3 """Bulk-migrate direct project Work Item branches to graph-contract v2. The project decision/work-item registries are the only input authority. The command deliberately skips branch children and already migrated notes; those need an explicit parent/work-item decision instead of a guessed binding. """ from __future__ import annotations import argparse import json from pathlib import Path import re import sys import tempfile from typing import Any, Callable from contract_markdown import cell, clean, parse_frontmatter, parse_tables, table_for from fs_transaction import replace_many, stage_repository import moc_indexer import quality_gate import template_renderer DEFAULT_ROOT = Path(__file__).resolve().parents[2] PROJECT_DIR = Path("raw/project-notes") BRANCH_DIR = Path("raw/branch-notes") DEC_REF_RE = re.compile(r"\b(DEC-[A-Z0-9][A-Z0-9-]*-\d{3})@(\d+)\b") DEC_ID_RE = re.compile(r"\bDEC-[A-Z0-9][A-Z0-9-]*-\d{3}\b") WI_RE = re.compile(r"\bWI-[A-Z0-9][A-Z0-9-]*-\d{3}\b") class MigrationError(ValueError): pass QualityRunner = Callable[..., dict[str, Any]] def _refs(value: str) -> list[str]: return [f"{match.group(1)}@{match.group(2)}" for match in DEC_REF_RE.finditer(value)] def _wis(value: str) -> list[str]: return WI_RE.findall(value) def _plain(value: str) -> str: # contract_markdown.clean() 과 같은 규칙 — 셀 전체가 단일 코드스팬/강조일 때만 벗긴다. # strip("`* ") 로 양끝을 무조건 깎으면 코드스팬으로 시작만 하는 셀이 여는 백틱을 잃는다. return clean(value) def _yaml_list(values: list[str]) -> str: return "[" + ", ".join(values) + "]" def _replace_frontmatter(text: str, values: dict[str, str]) -> str: lines = text.splitlines(keepends=True) if not lines or lines[0].strip() != "---": raise MigrationError("branch note has no YAML frontmatter") try: end = next(index for index, line in enumerate(lines[1:], 1) if line.strip() == "---") except StopIteration as exc: raise MigrationError("branch note has unterminated YAML frontmatter") from exc pending = dict(values) rendered: list[str] = [lines[0]] for line in lines[1:end]: match = re.match(r"^([A-Za-z_][\w-]*):", line) key = match.group(1) if match else "" if key in pending: value = pending.pop(key) rendered.append(f"{key}:{' ' + value if value else ''}\n") else: rendered.append(line) for key, value in pending.items(): rendered.append(f"{key}:{' ' + value if value else ''}\n") rendered.extend(lines[end:]) return "".join(rendered) def _packet(project: str, item: dict[str, Any], summaries: dict[str, str]) -> str: rows = [] for ref in item["decisions"]: decision_id = ref.rsplit("@", 1)[0] summary = summaries.get(decision_id, "") rows.append( f"| `{ref}` | {summary} | Work Item 완료 조건에 적용 | " f"`[[raw/project-notes/{project}]]` |" ) inherited_rows = "\n".join(rows) if not inherited_rows: inherited_rows = "| - | - | - | - |" return f""" ## 브랜치 계약 패킷 - **생성 시 프로젝트 개정**: `{item['project_revision']}` - **패킷 스키마**: `contract_packet: 1` - **완료 조건**: {item['completion']} ### 상속한 프로젝트 결정 | Decision Ref | Project Summary | Branch Application | Source | |---|---|---|---| {inherited_rows} ### 브랜치 지역 결정 > 기존 branch-local 결정은 아래 `## 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다. | Decision ID | Decision | Relation | Supporting Claims | Status | |---|---|---|---|---| ### 선언한 예외 | Override ID | Overrides | Reason | Approval | Status | |---|---|---|---|---| """ def _insert_packet(text: str, packet: str) -> str: if "## 브랜치 계약 패킷" in text or "## Branch Contract Packet" in text: return text goal = re.search(r"^##\s+.*(?:목표|WHY).*$", text, re.MULTILINE | re.IGNORECASE) if not goal: raise MigrationError("branch note has no goal heading for packet insertion") return text[: goal.start()] + packet + text[goal.start() :] def _upgrade_generated_packet(text: str, project_revision: int, completion: str) -> str: """Seal an existing v2 packet without rewriting any of its owned bytes.""" start_token = template_renderer.GENERATED_START end_token = template_renderer.GENERATED_END if start_token in text or end_token in text: # Exact marker validation is delegated to the shared renderer helper. template_renderer.generated_region(text) wrapped = text else: packet = re.search( r"^(?:\s*\n)?##\s+(?:브랜치 계약 패킷|Branch Contract Packet)\s*$", text, re.MULTILINE, ) declared = re.search( r"^\s*$", text, re.MULTILINE, ) if packet is None or declared is None or declared.start() <= packet.start(): raise MigrationError("v2 branch packet has no ordered packet/override sections") next_heading = re.search(r"^##\s+", text[declared.end() :], re.MULTILINE) if next_heading is None: raise MigrationError("v2 branch packet has no editable section after declared overrides") end_at = declared.end() + next_heading.start() prefix = text[: packet.start()] + start_token + "\n" packet_bytes = text[packet.start() : end_at].rstrip("\n") suffix = text[end_at:].lstrip("\n") wrapped = prefix + packet_bytes + "\n" + end_token + "\n\n" + suffix wrapped = re.sub( r"(^-\s*\*\*생성 시 프로젝트 개정\*\*:\s*`?)[1-9]\d*(`?\s*$)", rf"\g<1>{project_revision}\g<2>", wrapped, count=1, flags=re.MULTILINE, ) wrapped, completion_count = re.subn( r"(^-\s*\*\*완료 조건\*\*:\s*).*$", lambda match: match.group(1) + completion, wrapped, count=1, flags=re.MULTILINE, ) if completion_count != 1: raise MigrationError("v2 branch packet has no completion criterion") digest = template_renderer.generated_sha256(wrapped) return _replace_frontmatter(wrapped, {"contract_packet_sha256": digest}) def _blocked(code: str, path: Path | str, message: str) -> dict[str, str]: return {"code": code, "path": path.as_posix() if isinstance(path, Path) else path, "message": message} def _pinned_refs(value: str) -> tuple[list[str], list[str]]: refs: list[str] = [] invalid: list[str] = [] for raw in value.split(","): item = _plain(raw) if not item or item == "-": continue match = re.fullmatch(r"(DEC-[A-Z0-9][A-Z0-9-]*-\d{3})@([1-9]\d*)", item) if match: refs.append(f"{match.group(1)}@{match.group(2)}") else: invalid.append(item) return refs, invalid def _dependency_ids(value: str) -> tuple[list[str], list[str]]: dependencies: list[str] = [] invalid: list[str] = [] for raw in value.split(","): item = _plain(raw) if not item or item == "-": continue if re.fullmatch(r"WI-[A-Z0-9][A-Z0-9-]*-\d{3}", item): dependencies.append(item) else: invalid.append(item) return dependencies, invalid def _registries(root: Path) -> tuple[dict[str, dict[str, Any]], dict[str, str], list[dict[str, str]]]: by_slug: dict[str, dict[str, Any]] = {} summaries: dict[str, str] = {} decision_revisions: dict[str, int] = {} work_by_id: dict[str, dict[str, Any]] = {} work_records: list[dict[str, Any]] = [] blocked: list[dict[str, str]] = [] for project_path in sorted((root / PROJECT_DIR).glob("*.md")): text = project_path.read_text(encoding="utf-8") fm = parse_frontmatter(text) if "project_revision" not in fm: continue revision_raw = str(fm.get("project_revision", "")) if not revision_raw.isdigit() or int(revision_raw) < 1: blocked.append(_blocked("INVALID_PROJECT_REVISION", project_path.relative_to(root), "project_revision must be a positive integer")) continue project_revision = int(revision_raw) project_prefix = project_path.stem.upper() tables = parse_tables(text) decisions = table_for(tables, "section-id:project-decisions", "안정 결정 레지스트리", "Project Decision Registry") work_items = table_for(tables, "section-id:project-work-items", "실행계획", "Work Item Registry") if decisions is None or work_items is None: blocked.append(_blocked("INCOMPLETE_PROJECT_REGISTRY", project_path.relative_to(root), "decision/work-item registry is required")) continue for line, row in decisions.rows: decision_id = next(iter(DEC_ID_RE.findall(cell(row, "Decision ID"))), "") revision = _plain(cell(row, "Revision")) location = f"{project_path.relative_to(root)}:{line}" if not decision_id or not revision.isdigit() or int(revision) < 1: blocked.append(_blocked("INVALID_PROJECT_DECISION", location, "decision id/revision is invalid")) continue if not decision_id.startswith(f"DEC-{project_prefix}-"): blocked.append(_blocked("FOREIGN_PROJECT_PREFIX", location, f"{decision_id} does not belong to {project_path.stem}")) if decision_id in decision_revisions: blocked.append(_blocked("DUPLICATE_PROJECT_DECISION", location, f"duplicate decision id: {decision_id}")) else: decision_revisions[decision_id] = int(revision) summaries[decision_id] = cell(row, "Decision Summary") for line, row in work_items.rows: wi = next(iter(_wis(cell(row, "Work Item ID"))), "") slug = _plain(cell(row, "branch slug")) location = f"{project_path.relative_to(root)}:{line}" if not wi or not slug: blocked.append(_blocked("INVALID_WORK_ITEM", location, "Work Item ID and branch slug are required")) continue if not wi.startswith(f"WI-{project_prefix}-"): blocked.append(_blocked("FOREIGN_PROJECT_PREFIX", location, f"{wi} does not belong to {project_path.stem}")) if wi in work_by_id: blocked.append(_blocked("DUPLICATE_WORK_ITEM", location, f"duplicate Work Item id: {wi}")) if slug in by_slug: blocked.append(_blocked("DUPLICATE_BRANCH_SLUG", location, f"duplicate Work Item branch slug: {slug}")) decisions_refs, invalid_decisions = _pinned_refs(cell(row, "Applies Decisions")) dependencies, invalid_dependencies = _dependency_ids(cell(row, "Dependencies")) if invalid_decisions: blocked.append(_blocked("INVALID_APPLIED_DECISION", location, f"invalid references: {invalid_decisions}")) if not decisions_refs: blocked.append(_blocked("MISSING_APPLIED_DECISION", location, f"{wi} has no applied decision")) if invalid_dependencies: blocked.append(_blocked("INVALID_DEPENDENCY", location, f"invalid dependencies: {invalid_dependencies}")) record = { "project": project_path.stem, "project_revision": project_revision, "work_item": wi, "completion": cell(row, "완료 조건 (측정가능)"), "decisions": decisions_refs, "dependencies": dependencies, "location": location, } by_slug.setdefault(slug, record) work_by_id.setdefault(wi, record) work_records.append(record) for item in work_records: wi = item["work_item"] expected_prefix = f"DEC-{item['project'].upper()}-" for ref in item["decisions"]: decision_id, revision_raw = ref.rsplit("@", 1) if not decision_id.startswith(expected_prefix): blocked.append(_blocked("FOREIGN_PROJECT_PREFIX", item["location"], f"{decision_id} does not belong to {item['project']}")) current = decision_revisions.get(decision_id) if current is None: blocked.append(_blocked("MISSING_APPLIED_DECISION", item["location"], f"{decision_id} is not declared")) elif int(revision_raw) != current: blocked.append(_blocked("STALE_REVISION", item["location"], f"{ref} != current {decision_id}@{current}")) for dependency in item["dependencies"]: if dependency == wi: blocked.append(_blocked("SELF_DEPENDENCY", item["location"], f"{wi} depends on itself")) elif dependency not in work_by_id: blocked.append(_blocked("MISSING_DEPENDENCY", item["location"], f"dependency does not exist: {dependency}")) visiting: set[str] = set() visited: set[str] = set() def visit(wi: str, trail: list[str]) -> None: if wi in visited: return if wi in visiting: cycle = trail[trail.index(wi):] + [wi] blocked.append(_blocked("DEPENDENCY_CYCLE", work_by_id[wi]["location"], " -> ".join(cycle))) return visiting.add(wi) for dependency in work_by_id[wi]["dependencies"]: if dependency in work_by_id: visit(dependency, trail + [dependency]) visiting.remove(wi) visited.add(wi) for wi in sorted(work_by_id): visit(wi, [wi]) unique = {(item["code"], item["path"], item["message"]): item for item in blocked} return by_slug, summaries, [unique[key] for key in sorted(unique)] def build_updates(root: Path) -> tuple[dict[Path, str], dict[str, Any]]: registry, summaries, blocked = _registries(root) updates: dict[Path, str] = {} eligible: list[str] = [] already_v2: list[str] = [] unmapped: list[str] = [] for path in sorted((root / BRANCH_DIR).glob("*.md")): text = path.read_text(encoding="utf-8") fm = parse_frontmatter(text) if "contract_packet" in fm or "## 브랜치 계약 패킷" in text or "## Branch Contract Packet" in text: already_v2.append(path.stem) item = registry.get(path.stem) if item is None: work_item = str(fm.get("work_item", "")).strip() matches = [candidate for candidate in registry.values() if candidate["work_item"] == work_item] item = matches[0] if len(matches) == 1 else None if item is None: blocked.append(_blocked("UNMAPPED_V2_BRANCH", path.relative_to(root), "cannot resolve v2 branch to one Work Item")) continue try: migrated = _upgrade_generated_packet(text, item["project_revision"], item["completion"]) except (MigrationError, template_renderer.TemplateRenderError) as exc: blocked.append(_blocked("INVALID_V2_PACKET", path.relative_to(root), str(exc))) continue if migrated != text: updates[path] = migrated continue item = registry.get(path.stem) if item is None: unmapped.append(path.stem) continue eligible.append(path.stem) values = { "id": item["work_item"].replace("WI-", "BR-", 1), "kind": "project-work-item", "project": item["project"], "work_item": item["work_item"], "inherits": _yaml_list(item["decisions"]), "refines": "[]", "overrides": "[]", "depends_on": _yaml_list(item["dependencies"]), "contract_packet": "1", "parent_branch": "", "branch": path.stem, } migrated = _replace_frontmatter(text, values) migrated = _insert_packet(migrated, _packet(item["project"], item, summaries)) migrated = _upgrade_generated_packet(migrated, item["project_revision"], item["completion"]) if migrated != text: updates[path] = migrated if blocked: updates = {} return updates, { "eligible": eligible, "already_v2": already_v2, "unmapped": unmapped, "blocked": blocked, "changed": [path.relative_to(root).as_posix() for path in sorted(updates)], } def _stage_repository(root: Path, destination: Path) -> None: stage_repository(root, destination) def prepare_updates( root: Path, *, quality_runner: QualityRunner = quality_gate.run, relations_path: Path = moc_indexer.DEFAULT_RELATIONS, ) -> tuple[dict[Path, str], dict[str, Any]]: """Stage the full migration scope and return bytes only after all gates pass.""" root = root.resolve(strict=True) branch_updates, stats = build_updates(root) stats = dict(stats) stats["migration_changed"] = list(stats["changed"]) stats["moc_changed"] = [] stats["quality"] = None if stats["blocked"] or not branch_updates: return {}, stats with tempfile.TemporaryDirectory(prefix="graph-contract-migration-stage-") as directory: stage = Path(directory) / "repo" stage.mkdir() _stage_repository(root, stage) staged_branches: list[Path] = [] for path, text in branch_updates.items(): staged = stage / path.relative_to(root) staged.write_text(text, encoding="utf-8") staged_branches.append(staged) moc_updates, _moc_stats = moc_indexer.build_updates(stage, relations_path) for path, text in moc_updates.items(): path.write_text(text, encoding="utf-8") remaining_moc, _ = moc_indexer.build_updates(stage, relations_path) if remaining_moc: stats["blocked"] = [ _blocked("MIGRATION_MOC_NOT_IDEMPOTENT", path.relative_to(stage), "relation projection did not converge") for path in sorted(remaining_moc) ] return {}, stats touched_rel = sorted( { *(path.relative_to(stage).as_posix() for path in staged_branches), *(path.relative_to(stage).as_posix() for path in moc_updates), } ) try: gate = quality_runner( stage, touched_rel, structure_paths=[path.relative_to(stage).as_posix() for path in staged_branches], template_root=DEFAULT_ROOT, include_graph=True, require_moc_convergence=True, ) except quality_gate.QualityGateError as exc: raise MigrationError(f"migration quality gate error: {exc}") from exc if not isinstance(gate, dict) or gate.get("schema_version") != "quality-gate-result/v1": raise MigrationError("migration quality gate returned an unexpected schema") stats["quality"] = { "status": gate.get("status"), "checks": gate.get("checks", []), "touched_paths": gate.get("touched_paths", gate.get("checked_paths", [])), } if gate.get("status") != "PASS": codes = sorted({str(item.get("code", "UNKNOWN")) for item in gate.get("findings", [])}) stats["blocked"] = [ _blocked("MIGRATION_QUALITY_GATE_FAILED", "", ",".join(codes) or "quality gate failed") ] return {}, stats repeated_branches, repeated_stats = build_updates(stage) if repeated_branches or repeated_stats["blocked"]: stats["blocked"] = [ _blocked("MIGRATION_NOT_IDEMPOTENT", "", "second migration pass was not current") ] return {}, stats all_relative = set(touched_rel) prepared = {root / relative: (stage / relative).read_text(encoding="utf-8") for relative in all_relative} stats["moc_changed"] = sorted(path.relative_to(stage).as_posix() for path in moc_updates) stats["changed"] = sorted(all_relative) return prepared, stats def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--check", action="store_true") mode.add_argument("--write", action="store_true") args = parser.parse_args(argv) try: root = args.root.resolve(strict=True) updates, stats = prepare_updates(root) if args.write and updates and not stats["blocked"]: replace_many({path: text.encode("utf-8") for path, text in updates.items()}) status = ( "BLOCKED" if stats["blocked"] else "DRIFT" if args.check and updates else "UPDATED" if updates else "CURRENT" ) json.dump( {"schema_version": "graph-contract-migration/v1", "status": status, **stats}, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True, ) sys.stdout.write("\n") return 1 if stats["blocked"] or (args.check and updates) else 0 except (MigrationError, OSError, UnicodeError) as exc: json.dump( {"schema_version": "graph-contract-migration/v1", "status": "ERROR", "errors": [{"code": "MIGRATION_ERROR", "message": str(exc)}]}, sys.stdout, ensure_ascii=False, indent=2, ) sys.stdout.write("\n") return 2 if __name__ == "__main__": raise SystemExit(main())