521 lines
22 KiB
Python
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())
|