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

521 lines
22 KiB
Python

#!/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"""<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `{item['project_revision']}`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: {item['completion']}
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
{inherited_rows}
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| 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*section-id:\s*branch-contract-packet\s*-->\s*\n)?##\s+(?:브랜치 계약 패킷|Branch Contract Packet)\s*$",
text,
re.MULTILINE,
)
declared = re.search(
r"^<!--\s*section-id:\s*declared-overrides\s*-->\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", "<staged-scope>", ",".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", "<staged-scope>", "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())