init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,860 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plan and atomically apply project-first vault authority transitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from contract_markdown import parse_frontmatter
|
||||
from fs_transaction import ReplacementValue, SymlinkValue, replace_many
|
||||
import layout_check
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_LAYOUT = Path("harness/source/vault-layout.json")
|
||||
DEFAULT_RELATIONS = Path("harness/source/document-relations.json")
|
||||
SCHEMA_VERSION = "vault-migration-plan/v1"
|
||||
RESULT_SCHEMA = "vault-migration-result/v1"
|
||||
HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
# Obsidian table cells commonly escape the alias separator as ``\|``. Keep
|
||||
# the separator in its own group so the target lookup never receives the
|
||||
# trailing escape character and preserve the author's original spelling when
|
||||
# replacing only the authority path.
|
||||
WIKILINK = re.compile(r"\[\[([^\]|#]+?)(#[^\]|]+)?((?:\\)?\|[^\]]+)?\]\]")
|
||||
|
||||
|
||||
class MigrationError(ValueError):
|
||||
def __init__(self, code: str, message: str, location: str = "") -> None:
|
||||
self.code = code
|
||||
self.location = location
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MigrationPlan(dict[Path, ReplacementValue]):
|
||||
def __init__(
|
||||
self,
|
||||
values: Mapping[Path, ReplacementValue],
|
||||
*,
|
||||
expected: Mapping[Path, str | None],
|
||||
forbidden: Iterable[Path],
|
||||
result: Mapping[str, Any],
|
||||
) -> None:
|
||||
super().__init__(values)
|
||||
self.expected = dict(expected)
|
||||
self.forbidden = set(forbidden)
|
||||
self.result = dict(result)
|
||||
|
||||
|
||||
def _sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _lexical_absolute(path: Path) -> Path:
|
||||
"""Return an absolute path without following a compatibility symlink."""
|
||||
|
||||
return Path(os.path.abspath(path))
|
||||
|
||||
|
||||
def _entry_fingerprint(path: Path) -> str | None:
|
||||
if path.is_symlink():
|
||||
return _sha256(("symlink\0" + os.readlink(path)).encode("utf-8"))
|
||||
if path.is_file():
|
||||
return _sha256(path.read_bytes())
|
||||
return None
|
||||
|
||||
|
||||
def _replacement_fingerprint(value: ReplacementValue) -> str:
|
||||
if isinstance(value, SymlinkValue):
|
||||
return _sha256(("symlink\0" + value.target).encode("utf-8"))
|
||||
return _sha256(value)
|
||||
|
||||
|
||||
def _config_path(root: Path, value: Path) -> Path:
|
||||
return value if value.is_absolute() else root / value
|
||||
|
||||
|
||||
def _load_json(path: Path, schema: str) -> dict[str, Any]:
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise MigrationError("CONFIG_IO_ERROR", str(exc), str(path)) from exc
|
||||
if not isinstance(document, dict) or document.get("schema_version") != schema:
|
||||
raise MigrationError("CONFIG_SCHEMA_MISMATCH", f"expected {schema}", str(path))
|
||||
return document
|
||||
|
||||
|
||||
def _document_bytes(document: Mapping[str, Any]) -> bytes:
|
||||
return (json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _assignments(config: Mapping[str, Any]) -> dict[str, str]:
|
||||
areas = config.get("areas")
|
||||
if not isinstance(areas, dict):
|
||||
raise MigrationError("INVALID_LAYOUT_SCHEMA", "areas must be an object", "areas")
|
||||
result: dict[str, str] = {}
|
||||
for area, roots in areas.items():
|
||||
if not isinstance(area, str) or not isinstance(roots, list):
|
||||
raise MigrationError("INVALID_LAYOUT_SCHEMA", "area roots must be arrays", f"areas.{area}")
|
||||
for raw_root in roots:
|
||||
source = Path(str(raw_root)).as_posix().rstrip("/")
|
||||
if source in result:
|
||||
raise MigrationError("DUPLICATE_LAYOUT_OWNER", source, source)
|
||||
result[source] = area
|
||||
return result
|
||||
|
||||
|
||||
def _content_paths(root: Path, assignments: Mapping[str, str], overrides: Mapping[Path, bytes]) -> set[Path]:
|
||||
paths: set[Path] = set()
|
||||
for source in assignments:
|
||||
base = root / source
|
||||
if base.is_dir():
|
||||
paths.update(path for path in base.rglob("*") if path.is_file() and path.name != ".gitkeep")
|
||||
paths.update(path for path in overrides if path.name != ".gitkeep")
|
||||
return paths
|
||||
|
||||
|
||||
def _source_owner(root: Path, legacy: Path, assignments: Mapping[str, str]) -> tuple[str, Path]:
|
||||
owners: list[tuple[int, str, Path]] = []
|
||||
for source, area in assignments.items():
|
||||
base = (root / source).resolve()
|
||||
try:
|
||||
legacy.resolve().relative_to(base)
|
||||
except ValueError:
|
||||
continue
|
||||
owners.append((len(base.parts), area, base))
|
||||
if len(owners) != 1:
|
||||
relative = legacy.relative_to(root).as_posix()
|
||||
code = "MISSING_LAYOUT_OWNER" if not owners else "AMBIGUOUS_LAYOUT_OWNER"
|
||||
raise MigrationError(code, f"expected one assigned source root, observed {len(owners)}", relative)
|
||||
_length, area, base = owners[0]
|
||||
return area, base
|
||||
|
||||
|
||||
def _mapping_policy(config: Mapping[str, Any]) -> dict[str, str]:
|
||||
policy = config.get("canonical_mapping")
|
||||
expected = {
|
||||
"schema_version": "project-first-paths/v1",
|
||||
"project_relation": "branch-to-project",
|
||||
"project_member_pattern": "{vault_root}/{area}/{project}/{category}/{relative_path}",
|
||||
"default_pattern": "{vault_root}/{area}/{category}/{relative_path}",
|
||||
}
|
||||
if policy != expected:
|
||||
raise MigrationError(
|
||||
"INVALID_CANONICAL_MAPPING_POLICY",
|
||||
"canonical_mapping must declare the project-first-paths/v1 deterministic patterns",
|
||||
"canonical_mapping",
|
||||
)
|
||||
return expected
|
||||
|
||||
|
||||
def _project_contract(relations: Mapping[str, Any], relation_id: str) -> tuple[set[str], dict[str, str]]:
|
||||
project_roots: set[str] = set()
|
||||
members: dict[str, str] = {}
|
||||
rows = relations.get("relations")
|
||||
if not isinstance(rows, list):
|
||||
raise MigrationError("INVALID_RELATION_SCHEMA", "relations must be an array", "relations")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if row.get("id") != relation_id:
|
||||
continue
|
||||
parent_roots = row.get("parent_roots")
|
||||
child_roots = row.get("child_roots")
|
||||
field = row.get("parent_field")
|
||||
if not isinstance(parent_roots, list) or not isinstance(child_roots, list) or not isinstance(field, str):
|
||||
raise MigrationError("INVALID_RELATION_SCHEMA", "branch-to-project relation is incomplete")
|
||||
project_roots.update(Path(str(value)).as_posix().rstrip("/") for value in parent_roots)
|
||||
for child in child_roots:
|
||||
source = Path(str(child)).as_posix().rstrip("/")
|
||||
if source in members and members[source] != field:
|
||||
raise MigrationError("AMBIGUOUS_PROJECT_RELATION", source, source)
|
||||
members[source] = field
|
||||
if not project_roots or not members:
|
||||
raise MigrationError("PROJECT_RELATION_MISSING", f"{relation_id} relation is required")
|
||||
return project_roots, members
|
||||
|
||||
|
||||
def _frontmatter_for(path: Path, overrides: Mapping[Path, bytes]) -> dict[str, Any]:
|
||||
try:
|
||||
data = overrides[path] if path in overrides else path.read_bytes()
|
||||
return parse_frontmatter(data.decode("utf-8"))
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise MigrationError("PROJECT_METADATA_UNREADABLE", str(exc), str(path)) from exc
|
||||
|
||||
|
||||
def _canonical_path(
|
||||
root: Path,
|
||||
legacy: Path,
|
||||
config: Mapping[str, Any],
|
||||
assignments: Mapping[str, str],
|
||||
project_roots: set[str],
|
||||
project_members: Mapping[str, str],
|
||||
overrides: Mapping[Path, bytes],
|
||||
) -> Path:
|
||||
area, source_base = _source_owner(root, legacy, assignments)
|
||||
source = source_base.relative_to(root).as_posix()
|
||||
relative = legacy.relative_to(source_base)
|
||||
vault = root / str(config.get("vault_root", "vault")) / area
|
||||
category_parts = Path(source).parts
|
||||
if category_parts and category_parts[0] in {"raw", "wiki"}:
|
||||
category_parts = category_parts[1:]
|
||||
category = Path(*category_parts)
|
||||
|
||||
project: str | None = None
|
||||
if source in project_roots:
|
||||
if relative.parent != Path(".") or legacy.suffix != ".md":
|
||||
raise MigrationError("INVALID_PROJECT_OWNER_PATH", "project notes must be top-level Markdown files", legacy.relative_to(root).as_posix())
|
||||
project = legacy.stem
|
||||
elif source in project_members:
|
||||
metadata = _frontmatter_for(legacy, overrides)
|
||||
raw_project = metadata.get(project_members[source])
|
||||
if not isinstance(raw_project, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", raw_project):
|
||||
raise MigrationError("PROJECT_OWNER_MISSING", f"{project_members[source]} must name exactly one project", legacy.relative_to(root).as_posix())
|
||||
project = raw_project
|
||||
if not any((root / parent_root / f"{project}.md").is_file() for parent_root in project_roots):
|
||||
raise MigrationError("PROJECT_OWNER_NOT_FOUND", project, legacy.relative_to(root).as_posix())
|
||||
|
||||
if project is not None:
|
||||
return vault / project / category / relative
|
||||
return vault / category / relative
|
||||
|
||||
|
||||
def build_mapping(
|
||||
root: Path,
|
||||
config: Mapping[str, Any],
|
||||
relations: Mapping[str, Any],
|
||||
*,
|
||||
overrides: Mapping[Path, bytes] | None = None,
|
||||
) -> dict[Path, Path]:
|
||||
"""Derive one canonical owner for every configured content file."""
|
||||
root = root.resolve()
|
||||
overrides = {path.resolve(): data for path, data in (overrides or {}).items()}
|
||||
assignments = _assignments(config)
|
||||
policy = _mapping_policy(config)
|
||||
project_roots, project_members = _project_contract(relations, policy["project_relation"])
|
||||
mapping: dict[Path, Path] = {}
|
||||
reverse: dict[Path, Path] = {}
|
||||
for legacy in sorted(_content_paths(root, assignments, overrides)):
|
||||
legacy = legacy.resolve()
|
||||
canonical = _canonical_path(
|
||||
root,
|
||||
legacy,
|
||||
config,
|
||||
assignments,
|
||||
project_roots,
|
||||
project_members,
|
||||
overrides,
|
||||
).resolve()
|
||||
if canonical in reverse:
|
||||
first = reverse[canonical].relative_to(root).as_posix()
|
||||
second = legacy.relative_to(root).as_posix()
|
||||
raise MigrationError("CANONICAL_PATH_COLLISION", f"{first}, {second}", canonical.relative_to(root).as_posix())
|
||||
mapping[legacy] = canonical
|
||||
reverse[canonical] = legacy
|
||||
return mapping
|
||||
|
||||
|
||||
def _embedded_documents(config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
migration = config.get("migration_manifest")
|
||||
rollback = config.get("rollback_mapping")
|
||||
if not isinstance(migration, dict) or not isinstance(rollback, dict):
|
||||
raise MigrationError("EXTERNAL_CUTOVER_MANIFEST_UNSUPPORTED", "writer updates require embedded migration and rollback documents")
|
||||
if migration.get("schema_version") != "vault-migration/v1" or rollback.get("schema_version") != "vault-rollback/v1":
|
||||
raise MigrationError("INVALID_CUTOVER_SCHEMA", "invalid embedded migration or rollback schema")
|
||||
return migration, rollback
|
||||
|
||||
|
||||
def _entries(
|
||||
mapping: Mapping[Path, Path],
|
||||
root: Path,
|
||||
payloads: Mapping[Path, ReplacementValue],
|
||||
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
migration: list[dict[str, str]] = []
|
||||
rollback: list[dict[str, str]] = []
|
||||
for legacy, canonical in sorted(mapping.items(), key=lambda item: item[0].relative_to(root).as_posix()):
|
||||
content = payloads.get(canonical)
|
||||
if isinstance(content, SymlinkValue):
|
||||
raise MigrationError(
|
||||
"CANONICAL_OWNER_SYMLINK",
|
||||
"canonical owner must contain bytes",
|
||||
canonical.relative_to(root).as_posix(),
|
||||
)
|
||||
if content is None:
|
||||
if not canonical.is_file():
|
||||
raise MigrationError("CANONICAL_CONTENT_MISSING", "canonical owner does not exist", canonical.relative_to(root).as_posix())
|
||||
content = canonical.read_bytes()
|
||||
migration.append({
|
||||
"legacy_path": legacy.relative_to(root).as_posix(),
|
||||
"canonical_path": canonical.relative_to(root).as_posix(),
|
||||
"sha256": _sha256(content),
|
||||
})
|
||||
rollback.append({
|
||||
"canonical_path": canonical.relative_to(root).as_posix(),
|
||||
"legacy_path": legacy.relative_to(root).as_posix(),
|
||||
})
|
||||
return migration, rollback
|
||||
|
||||
|
||||
def _configured_mapping(root: Path, config: Mapping[str, Any]) -> dict[Path, Path]:
|
||||
migration = config.get("migration_manifest")
|
||||
if not isinstance(migration, dict) or migration.get("schema_version") != "vault-migration/v1":
|
||||
raise MigrationError("INVALID_CUTOVER_SCHEMA", "embedded migration manifest required")
|
||||
rows = migration.get("entries")
|
||||
if not isinstance(rows, list):
|
||||
raise MigrationError("INVALID_CUTOVER_SCHEMA", "migration entries must be an array")
|
||||
result: dict[Path, Path] = {}
|
||||
for index, row in enumerate(rows):
|
||||
if not isinstance(row, dict) or set(row) != {"legacy_path", "canonical_path", "sha256"}:
|
||||
raise MigrationError("INVALID_MIGRATION_ENTRY", str(index))
|
||||
legacy = _lexical_absolute(root / str(row["legacy_path"]))
|
||||
canonical = (root / str(row["canonical_path"])).resolve()
|
||||
try:
|
||||
legacy.relative_to(root)
|
||||
canonical.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise MigrationError("INVALID_MIGRATION_PATH", str(index)) from exc
|
||||
if legacy in result:
|
||||
raise MigrationError("DUPLICATE_LEGACY_OWNER", row["legacy_path"])
|
||||
result[legacy] = canonical
|
||||
return result
|
||||
|
||||
|
||||
def expand_authoritative_changes(
|
||||
root: Path,
|
||||
changes: Mapping[Path, bytes],
|
||||
*,
|
||||
layout_path: Path = DEFAULT_LAYOUT,
|
||||
relations_path: Path = DEFAULT_RELATIONS,
|
||||
) -> tuple[dict[Path, bytes], dict[str, Any]]:
|
||||
"""Enforce active roots and add shadow mirrors plus manifest hash updates."""
|
||||
root = root.resolve(strict=True)
|
||||
normalized = {path.resolve(): data for path, data in changes.items()}
|
||||
try:
|
||||
authority = layout_check.resolve_authority(root, layout_path)
|
||||
except layout_check.LayoutContractError as exc:
|
||||
raise MigrationError(exc.code, str(exc), exc.location) from exc
|
||||
|
||||
planner_surface: set[Path] = set()
|
||||
if authority["mode"] == "canonical":
|
||||
# 기존 문서는 legacy 경로가 심링크라 위의 resolve() 가 정본으로 번역해 준다.
|
||||
# 신규 문서에는 그 심링크가 아직 없어 raw/… 그대로 남고, write root 집행에
|
||||
# 걸려 **문서 생성 자체가 막혀 있었다**. 여기서 목적지를 계산해 정본·호환
|
||||
# 심링크·manifest 2행을 한 트랜잭션으로 만든다(셋 중 하나만 빠져도 layout FAIL).
|
||||
pending = {
|
||||
path: data
|
||||
for path, data in normalized.items()
|
||||
if isinstance(data, bytes)
|
||||
and not path.exists()
|
||||
and not path.is_symlink()
|
||||
and _is_legacy_content_path(root, path, layout_path)
|
||||
}
|
||||
if pending:
|
||||
planned = plan_new_documents(
|
||||
root, pending, layout_path=layout_path, relations_path=relations_path
|
||||
)
|
||||
normalized = {
|
||||
path: data for path, data in normalized.items() if path not in pending
|
||||
}
|
||||
normalized.update(planned)
|
||||
# write-root 집행 대상은 caller 가 고른 목적지다. planner 가 layout 계약
|
||||
# (①~③ 동시 성립)을 위해 스스로 도출한 ②호환 심링크(raw/…)와 ③manifest
|
||||
# (harness/source/…)는 canonical write root 밖에 있는 것이 정상이므로
|
||||
# 집행에서 면제한다 — 면제 없이는 신규 문서 생성 전체가 여기서
|
||||
# WRITE_ROOT_VIOLATION 으로 죽는다(2026-07-23 branch_from_project 실측).
|
||||
# ①정본(bytes)은 계속 집행 대상이다 — vault 밖이면 여전히 위반.
|
||||
planner_surface = {
|
||||
path for path, value in planned.items() if isinstance(value, SymlinkValue)
|
||||
}
|
||||
planner_surface.add(_config_path(root, layout_path).resolve())
|
||||
|
||||
try:
|
||||
layout_check.enforce_write_paths(
|
||||
root,
|
||||
[path for path in normalized if path not in planner_surface],
|
||||
authority,
|
||||
)
|
||||
except layout_check.LayoutContractError as exc:
|
||||
raise MigrationError(exc.code, str(exc), exc.location) from exc
|
||||
if authority["mode"] != "shadow":
|
||||
return dict(normalized), authority
|
||||
|
||||
config_path = _config_path(root, layout_path).resolve()
|
||||
config = _load_json(config_path, "vault-layout/v1")
|
||||
relations = _load_json(_config_path(root, relations_path), "document-relations/v1")
|
||||
derived = build_mapping(root, config, relations, overrides=normalized)
|
||||
configured = _configured_mapping(root, config)
|
||||
for legacy, canonical in configured.items():
|
||||
if derived.get(legacy) != canonical:
|
||||
raise MigrationError("MAPPING_POLICY_DRIFT", canonical.relative_to(root).as_posix(), legacy.relative_to(root).as_posix())
|
||||
|
||||
expanded = dict(normalized)
|
||||
for legacy, content in normalized.items():
|
||||
canonical = derived.get(legacy)
|
||||
if canonical is None:
|
||||
raise MigrationError("SHADOW_MAPPING_MISSING", "write has no canonical mirror", legacy.relative_to(root).as_posix())
|
||||
if canonical in expanded and expanded[canonical] != content:
|
||||
raise MigrationError("SHADOW_WRITE_CONFLICT", "legacy and mirror bytes differ", canonical.relative_to(root).as_posix())
|
||||
expanded[canonical] = content
|
||||
|
||||
updated = copy.deepcopy(config)
|
||||
migration, rollback = _embedded_documents(updated)
|
||||
migration_entries, rollback_entries = _entries(derived, root, expanded)
|
||||
migration["entries"] = migration_entries
|
||||
rollback["entries"] = rollback_entries
|
||||
expanded[config_path] = _document_bytes(updated)
|
||||
authority = dict(authority)
|
||||
authority["control_plane_updates"] = [config_path.relative_to(root).as_posix()]
|
||||
return expanded, authority
|
||||
|
||||
|
||||
def _rewrite_links(text: str, root: Path, mapping: Mapping[Path, Path]) -> str:
|
||||
by_value: dict[str, str] = {}
|
||||
for legacy, canonical in mapping.items():
|
||||
legacy_value = legacy.relative_to(root).as_posix()
|
||||
canonical_value = canonical.relative_to(root).as_posix()
|
||||
by_value[legacy_value] = canonical_value
|
||||
if legacy.suffix == ".md":
|
||||
by_value[legacy_value[:-3]] = canonical_value[:-3] if canonical.suffix == ".md" else canonical_value
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
target, anchor, alias = match.groups()
|
||||
rewritten = by_value.get(target, target)
|
||||
return f"[[{rewritten}{anchor or ''}{alias or ''}]]"
|
||||
|
||||
return WIKILINK.sub(replace, text)
|
||||
|
||||
|
||||
def _stub(legacy: Path, canonical: Path, root: Path) -> bytes:
|
||||
title = legacy.stem
|
||||
canonical_value = canonical.relative_to(root).as_posix()
|
||||
return (
|
||||
"---\n"
|
||||
f"title: {title} (compatibility stub)\n"
|
||||
"status: stale\n"
|
||||
f"canonical_path: {canonical_value}\n"
|
||||
"---\n\n"
|
||||
f"# {title}\n\n"
|
||||
f"Canonical document: [[{canonical_value[:-3] if canonical_value.endswith('.md') else canonical_value}]]\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _is_legacy_content_path(root: Path, path: Path, layout_path: Path) -> bool:
|
||||
"""Is this a legacy content path (raw/… · wiki/…) that a new document could claim?"""
|
||||
try:
|
||||
config = _load_json(_config_path(root, layout_path).resolve(), "vault-layout/v1")
|
||||
assignments = _assignments(config)
|
||||
except MigrationError:
|
||||
return False
|
||||
if path.suffix != ".md":
|
||||
return False
|
||||
for source in assignments:
|
||||
base = _lexical_absolute(root / source)
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def plan_new_documents(
|
||||
root: Path,
|
||||
documents: Mapping[Path, bytes],
|
||||
*,
|
||||
layout_path: Path = DEFAULT_LAYOUT,
|
||||
relations_path: Path = DEFAULT_RELATIONS,
|
||||
) -> dict[Path, ReplacementValue]:
|
||||
"""Batch form of :func:`plan_new_document`.
|
||||
|
||||
manifest 항목은 누적돼야 한다 — 문서마다 디스크의 config 를 새로 읽으면 두 번째가
|
||||
첫 번째의 항목을 덮어써 한쪽이 조용히 사라진다.
|
||||
"""
|
||||
root = root.resolve(strict=True)
|
||||
config_path = _config_path(root, layout_path).resolve()
|
||||
config = _load_json(config_path, "vault-layout/v1")
|
||||
changes: dict[Path, ReplacementValue] = {}
|
||||
for legacy, content in sorted(documents.items(), key=lambda item: item[0].as_posix()):
|
||||
planned, config = _plan_new_document(
|
||||
root, legacy, content, config=config, relations_path=relations_path
|
||||
)
|
||||
changes.update(planned)
|
||||
changes[config_path] = _document_bytes(config)
|
||||
return changes
|
||||
|
||||
|
||||
def plan_new_document(
|
||||
root: Path,
|
||||
legacy: Path,
|
||||
content: bytes,
|
||||
*,
|
||||
layout_path: Path = DEFAULT_LAYOUT,
|
||||
relations_path: Path = DEFAULT_RELATIONS,
|
||||
) -> dict[Path, ReplacementValue]:
|
||||
"""Return the atomic change set that creates one new document under canonical authority.
|
||||
|
||||
canonical 모드에서 문서 하나를 새로 만들려면 세 가지가 *동시에* 있어야 한다
|
||||
(2026-07-22 실측: 하나라도 빠지면 layout_check 가 FAIL):
|
||||
|
||||
① 정본 파일 vault/<area>/<project>/<category>/<name>.md 없으면 MIGRATION_ENTRY_MISSING
|
||||
② 호환 심링크 raw/<category>/<name>.md → 정본 없으면 CANONICAL_OWNER_MISSING
|
||||
③ manifest 2행 migration_manifest + rollback_mapping
|
||||
|
||||
기존 문서가 그냥 되는 건 권한이 있어서가 아니라 ②가 이미 있어서 ``resolve()`` 가
|
||||
번역기 노릇을 하기 때문이다. 신규 문서는 그 번역기가 없으므로 여기서 목적지를
|
||||
계산해 준다. ``build_mapping`` 을 그대로 쓰지 못하는 이유는 canonical 모드에서
|
||||
legacy 가 심링크라 ``legacy.resolve()`` 가 canonical 로 접혀 키가 무너지기 때문이다 —
|
||||
그래서 신규 경로 하나만 ``_canonical_path`` 로 직접 계산한다.
|
||||
"""
|
||||
root = root.resolve(strict=True)
|
||||
config_path = _config_path(root, layout_path).resolve()
|
||||
config = _load_json(config_path, "vault-layout/v1")
|
||||
changes, updated = _plan_new_document(
|
||||
root, legacy, content, config=config, relations_path=relations_path
|
||||
)
|
||||
changes[config_path] = _document_bytes(updated)
|
||||
return changes
|
||||
|
||||
|
||||
def _plan_new_document(
|
||||
root: Path,
|
||||
legacy: Path,
|
||||
content: bytes,
|
||||
*,
|
||||
config: Mapping[str, Any],
|
||||
relations_path: Path,
|
||||
) -> tuple[dict[Path, ReplacementValue], dict[str, Any]]:
|
||||
"""Compute one document's change set and the manifest it leaves behind."""
|
||||
legacy = _lexical_absolute(legacy if legacy.is_absolute() else root / legacy)
|
||||
try:
|
||||
legacy_rel = legacy.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise MigrationError("PATH_OUTSIDE_REPO", "legacy path escapes repository", str(legacy)) from exc
|
||||
if legacy.exists() or legacy.is_symlink():
|
||||
raise MigrationError("LEGACY_ALREADY_EXISTS", "document already exists", legacy_rel.as_posix())
|
||||
|
||||
relations = _load_json(_config_path(root, relations_path), "document-relations/v1")
|
||||
assignments = _assignments(config)
|
||||
policy = _mapping_policy(config)
|
||||
project_roots, project_members = _project_contract(relations, policy["project_relation"])
|
||||
|
||||
overrides = {legacy: content}
|
||||
canonical = _canonical_path(
|
||||
root, legacy, config, assignments, project_roots, project_members, overrides
|
||||
)
|
||||
canonical = _lexical_absolute(canonical)
|
||||
canonical_rel = canonical.relative_to(root)
|
||||
if canonical.exists():
|
||||
raise MigrationError("CANONICAL_ALREADY_EXISTS", "canonical owner already exists", canonical_rel.as_posix())
|
||||
|
||||
updated = copy.deepcopy(config)
|
||||
migration, rollback = _embedded_documents(updated)
|
||||
for document, extra in ((migration, {"sha256": _sha256(content)}), (rollback, {})):
|
||||
rows = document.get("entries")
|
||||
if not isinstance(rows, list):
|
||||
raise MigrationError("INVALID_CUTOVER_SCHEMA", "entries must be an array")
|
||||
rows.append({
|
||||
"canonical_path": canonical_rel.as_posix(),
|
||||
"legacy_path": legacy_rel.as_posix(),
|
||||
**extra,
|
||||
})
|
||||
document["entries"] = sorted(rows, key=lambda row: row["canonical_path"])
|
||||
|
||||
link_target = os.path.relpath(canonical, start=legacy.parent)
|
||||
return {
|
||||
canonical: content,
|
||||
legacy: SymlinkValue(Path(link_target).as_posix()),
|
||||
}, updated
|
||||
|
||||
|
||||
def _stage_repository(root: Path, destination: Path) -> None:
|
||||
ignored = shutil.ignore_patterns(".git", "__pycache__", "*.pyc", ".DS_Store")
|
||||
for child in root.iterdir():
|
||||
if child.name == ".git":
|
||||
continue
|
||||
target = destination / child.name
|
||||
if child.is_dir():
|
||||
shutil.copytree(child, target, ignore=ignored, symlinks=True)
|
||||
elif child.is_file():
|
||||
shutil.copy2(child, target)
|
||||
|
||||
|
||||
def _plan_hash(
|
||||
root: Path,
|
||||
current_mode: str,
|
||||
target_mode: str,
|
||||
expected: Mapping[Path, str | None],
|
||||
changes: Mapping[Path, ReplacementValue],
|
||||
authority: Mapping[str, Any],
|
||||
) -> str:
|
||||
document = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"from_mode": current_mode,
|
||||
"to_mode": target_mode,
|
||||
"active_layout": {
|
||||
"authority": authority["authority"],
|
||||
"manifest_sha256": authority["manifest_sha256"],
|
||||
"mode": authority["mode"],
|
||||
"write_roots": authority["write_roots"],
|
||||
},
|
||||
"preconditions": [
|
||||
{
|
||||
"path": path.relative_to(root).as_posix(),
|
||||
"expected_sha256": expected[path],
|
||||
}
|
||||
for path in sorted(expected)
|
||||
],
|
||||
"writes": [
|
||||
{
|
||||
"path": path.relative_to(root).as_posix(),
|
||||
"kind": "symlink" if isinstance(changes[path], SymlinkValue) else "bytes",
|
||||
"sha256": _replacement_fingerprint(changes[path]),
|
||||
"expected_sha256": expected[path],
|
||||
}
|
||||
for path in sorted(changes)
|
||||
],
|
||||
}
|
||||
return _sha256(_document_bytes(document))
|
||||
|
||||
|
||||
def _validate_stage(
|
||||
stage: Path,
|
||||
layout_path: Path,
|
||||
target_mode: str,
|
||||
*,
|
||||
run_release_gate: bool = True,
|
||||
) -> None:
|
||||
result = layout_check.check_layout(stage, layout_path)
|
||||
if result.get("status") != "PASS":
|
||||
codes = ",".join(sorted({item.get("code", "UNKNOWN") for item in result.get("findings", [])}))
|
||||
sample = json.dumps(result.get("findings", [])[:20], ensure_ascii=False, sort_keys=True)
|
||||
raise MigrationError("LAYOUT_POSTFLIGHT_FAILED", f"{codes}; sample={sample}")
|
||||
release_gate = stage / "harness/runtime/release_gate.py"
|
||||
if run_release_gate and release_gate.is_file():
|
||||
level = "r3" if target_mode == "canonical" else "r2"
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(release_gate), "--root", str(stage), "--level", level],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
try:
|
||||
release_result = json.loads(completed.stdout)
|
||||
nonpass = [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"status": item.get("status"),
|
||||
"exit_code": item.get("exit_code"),
|
||||
}
|
||||
for item in release_result.get("checks", [])
|
||||
if item.get("status") not in {"PASS", "SKIP"}
|
||||
]
|
||||
detail = json.dumps(
|
||||
{
|
||||
"summary": release_result.get("summary"),
|
||||
"nonpass": nonpass,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
detail = completed.stdout[-4000:]
|
||||
raise MigrationError(f"{level.upper()}_POSTFLIGHT_FAILED", detail)
|
||||
|
||||
|
||||
def prepare(
|
||||
root: Path,
|
||||
target_mode: str,
|
||||
*,
|
||||
layout_path: Path = DEFAULT_LAYOUT,
|
||||
relations_path: Path = DEFAULT_RELATIONS,
|
||||
) -> MigrationPlan:
|
||||
root = root.resolve(strict=True)
|
||||
if target_mode not in {"shadow", "canonical"}:
|
||||
raise MigrationError("INVALID_TARGET_MODE", "target mode must be shadow or canonical")
|
||||
try:
|
||||
# A shadow tree is expected to become temporarily stale when the
|
||||
# authoritative legacy corpus gains a document or changes outside a
|
||||
# harness-aware writer. Permit only that repairable class of drift
|
||||
# for an explicit shadow refresh; every other transition remains
|
||||
# fail-closed on a clean layout.
|
||||
requested_config = _load_json(_config_path(root, layout_path), "vault-layout/v1")
|
||||
requested_mode = requested_config.get("mode")
|
||||
refresh_shadow = requested_mode == "shadow" and target_mode == "shadow"
|
||||
authority = layout_check.resolve_authority(root, layout_path, require_clean=not refresh_shadow)
|
||||
if refresh_shadow:
|
||||
layout_result = layout_check.check_layout(root, layout_path)
|
||||
recoverable = {"MIGRATION_ENTRY_MISSING", "SHADOW_MIRROR_DRIFT", "MIGRATION_HASH_MISMATCH"}
|
||||
observed = {str(item.get("code")) for item in layout_result.get("findings", [])}
|
||||
unsupported = sorted(observed - recoverable)
|
||||
if unsupported:
|
||||
raise MigrationError(
|
||||
"LAYOUT_NOT_REFRESHABLE",
|
||||
",".join(unsupported),
|
||||
str(layout_path),
|
||||
)
|
||||
except layout_check.LayoutContractError as exc:
|
||||
raise MigrationError(exc.code, str(exc), exc.location) from exc
|
||||
current_mode = authority["mode"]
|
||||
if (current_mode, target_mode) not in {
|
||||
("compatibility", "shadow"),
|
||||
("shadow", "shadow"),
|
||||
("shadow", "canonical"),
|
||||
("canonical", "shadow"),
|
||||
}:
|
||||
raise MigrationError("INVALID_MODE_TRANSITION", f"{current_mode} -> {target_mode}")
|
||||
|
||||
config_path = _config_path(root, layout_path).resolve()
|
||||
config = _load_json(config_path, "vault-layout/v1")
|
||||
relations = _load_json(_config_path(root, relations_path), "document-relations/v1")
|
||||
configured = _configured_mapping(root, config)
|
||||
derived: dict[Path, Path] = {}
|
||||
changes: dict[Path, ReplacementValue] = {}
|
||||
|
||||
if current_mode == "compatibility" or (current_mode, target_mode) == ("shadow", "shadow"):
|
||||
derived = build_mapping(root, config, relations)
|
||||
mapping = derived
|
||||
for legacy, canonical in mapping.items():
|
||||
changes[canonical] = legacy.read_bytes()
|
||||
else:
|
||||
if current_mode == "shadow":
|
||||
derived = build_mapping(root, config, relations)
|
||||
if configured != derived:
|
||||
raise MigrationError("MAPPING_POLICY_DRIFT", "configured migration mapping differs from deterministic project-first mapping")
|
||||
mapping = configured
|
||||
if current_mode == "shadow" and target_mode == "canonical":
|
||||
for legacy, canonical in mapping.items():
|
||||
# Preserve document bytes across an authority-only cutover.
|
||||
# A compatibility symlink keeps external wikilinks and
|
||||
# repository-owned readers of rules/templates functional,
|
||||
# while write-root enforcement still rejects old-path writes.
|
||||
# Because bytes do not change, semantic certificates remain
|
||||
# current under their stable logical (legacy) subject IDs.
|
||||
changes[canonical] = legacy.read_bytes()
|
||||
relative_target = os.path.relpath(canonical, start=legacy.parent)
|
||||
changes[legacy] = SymlinkValue(Path(relative_target).as_posix())
|
||||
elif current_mode == "canonical" and target_mode == "shadow":
|
||||
for legacy, canonical in mapping.items():
|
||||
changes[legacy] = canonical.read_bytes()
|
||||
|
||||
updated = copy.deepcopy(config)
|
||||
updated["mode"] = target_mode
|
||||
migration, rollback = _embedded_documents(updated)
|
||||
migration_entries, rollback_entries = _entries(mapping, root, changes)
|
||||
migration["entries"] = migration_entries
|
||||
rollback["entries"] = rollback_entries
|
||||
changes[config_path] = _document_bytes(updated)
|
||||
|
||||
expected = {path: _entry_fingerprint(path) for path in changes}
|
||||
for legacy in mapping:
|
||||
expected.setdefault(legacy, _entry_fingerprint(legacy))
|
||||
relations_file = _config_path(root, relations_path).resolve()
|
||||
expected.setdefault(relations_file, _entry_fingerprint(relations_file))
|
||||
forbidden = {path for path in changes if expected[path] is None}
|
||||
with tempfile.TemporaryDirectory(prefix="vault-migration-stage-") as directory:
|
||||
stage = Path(directory) / "repo"
|
||||
stage.mkdir()
|
||||
_stage_repository(root, stage)
|
||||
for path, content in changes.items():
|
||||
staged = stage / path.relative_to(root)
|
||||
staged.parent.mkdir(parents=True, exist_ok=True)
|
||||
if isinstance(content, SymlinkValue):
|
||||
staged.unlink(missing_ok=True)
|
||||
os.symlink(content.target, staged)
|
||||
else:
|
||||
# 실제 apply 는 replace_many(follow_symlinks=False) 로 *경로 자체* 를 실파일로
|
||||
# 만든다(롤백 시 심링크→실파일). 스테이지는 심링크를 보존(_stage_repository
|
||||
# symlinks=True)하므로, 심링크 위에 write_bytes 하면 정본으로 write-through 돼
|
||||
# 스테이지가 실제 apply 와 어긋난다(롤백 후에도 legacy 가 심링크로 남는 것처럼
|
||||
# 보임). 링크를 먼저 끊어 apply 의미를 그대로 재현한다.
|
||||
if staged.is_symlink():
|
||||
staged.unlink()
|
||||
staged.write_bytes(content)
|
||||
_validate_stage(
|
||||
stage,
|
||||
layout_path,
|
||||
target_mode,
|
||||
# A same-mode refresh only restores the shadow invariant. R2 is
|
||||
# still the mandatory prerequisite for the later canonical
|
||||
# transition, but must not make the repair operation depend on
|
||||
# unrelated semantic certificates or corpus-level gates.
|
||||
run_release_gate=(current_mode, target_mode) != ("shadow", "shadow"),
|
||||
)
|
||||
|
||||
plan_sha256 = _plan_hash(root, current_mode, target_mode, expected, changes, authority)
|
||||
result = {
|
||||
"from_mode": current_mode,
|
||||
"to_mode": target_mode,
|
||||
"plan_sha256": plan_sha256,
|
||||
"migration_entries": len(mapping),
|
||||
"changed_paths": [path.relative_to(root).as_posix() for path in sorted(changes)],
|
||||
"rollback_mapping_complete": True,
|
||||
}
|
||||
return MigrationPlan(changes, expected=expected, forbidden=forbidden, result=result)
|
||||
|
||||
|
||||
def apply(plan: MigrationPlan) -> None:
|
||||
for path, expected in plan.expected.items():
|
||||
observed = _entry_fingerprint(path)
|
||||
if observed != expected:
|
||||
raise MigrationError("CONCURRENT_MODIFICATION", f"expected {expected}, observed {observed}", str(path))
|
||||
# cutover/rollback 은 legacy 경로의 *엔트리 종류 자체* 를 바꾸는 것이 목적이다
|
||||
# (실파일 → 심링크, 롤백 시 심링크 → 실파일). 여기서 심링크를 따라가면 롤백이
|
||||
# 정본만 덮어쓰고 심링크는 남겨 마이그레이션이 성립하지 않는다.
|
||||
replace_many(plan, must_not_exist=plan.forbidden, follow_symlinks=False)
|
||||
|
||||
|
||||
def _emit(document: Mapping[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("--root", type=Path, default=DEFAULT_ROOT)
|
||||
parser.add_argument("--layout", type=Path, default=DEFAULT_LAYOUT)
|
||||
parser.add_argument("--relations", type=Path, default=DEFAULT_RELATIONS)
|
||||
parser.add_argument("--to", choices=("shadow", "canonical"), required=True)
|
||||
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:
|
||||
plan = prepare(args.root, args.to, layout_path=args.layout, relations_path=args.relations)
|
||||
if args.apply:
|
||||
expected = args.expected_plan_sha256
|
||||
if expected is None:
|
||||
raise MigrationError("EXPECTED_PLAN_SHA256_REQUIRED", "apply requires --expected-plan-sha256")
|
||||
if not HEX_SHA256.fullmatch(expected):
|
||||
raise MigrationError("INVALID_PLAN_SHA256", "expected plan hash must be 64 lowercase hex characters")
|
||||
if expected != plan.result["plan_sha256"]:
|
||||
raise MigrationError("PLAN_HASH_MISMATCH", f"expected {expected}, current {plan.result['plan_sha256']}")
|
||||
apply(plan)
|
||||
_emit({"schema_version": RESULT_SCHEMA, "status": "APPLIED" if args.apply else "DRY_RUN", **plan.result})
|
||||
return 0
|
||||
except (MigrationError, layout_check.LayoutContractError) as exc:
|
||||
_emit({
|
||||
"schema_version": RESULT_SCHEMA,
|
||||
"status": "FAIL",
|
||||
"errors": [{"code": getattr(exc, "code", "MIGRATION_FAILED"), "location": getattr(exc, "location", ""), "message": str(exc)}],
|
||||
})
|
||||
return 1
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
_emit({"schema_version": RESULT_SCHEMA, "status": "ERROR", "errors": [{"code": "IO_ERROR", "location": "", "message": str(exc)}]})
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user