init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user