"""신규 문서 생성이 3요소를 모두 계획하는지 지킨다. canonical 모드에서 문서 하나를 새로 만들려면 정본 파일 · 호환 심링크 · manifest 2행이 *동시에* 필요하다(하나라도 빠지면 layout_check 가 CANONICAL_OWNER_MISSING / MIGRATION_ENTRY_MISSING 으로 FAIL). 기존 문서가 그냥 되는 건 권한이 있어서가 아니라 심링크가 이미 있어 ``resolve()`` 가 번역기 노릇을 하기 때문이고, 신규 문서에는 그 번역기가 없어 **문서 생성 자체가 막혀 있었다**. 이 테스트는 저장소를 쓰지 않는다 — 계획만 계산해 검사한다. """ from __future__ import annotations import json from pathlib import Path import sys import unittest REPO_ROOT = Path(__file__).resolve().parents[2] RUNTIME = REPO_ROOT / "harness/runtime" sys.path.insert(0, str(RUNTIME)) import vault_migrate # noqa: E402 DOC = b"""--- title: branch / feature-plan-probe source_type: branch-note status: raw branch: feature-plan-probe project: ca-skeleton-frontend-operational-contract tags: [branch] created: 2026-07-22 status_label: in-progress --- # branch: feature-plan-probe """ def _canonical_mode() -> bool: layout = REPO_ROOT / "harness/source/vault-layout.json" if not layout.is_file(): return False try: return json.loads(layout.read_text(encoding="utf-8")).get("mode") == "canonical" except (OSError, ValueError): return False class PlanNewDocumentTest(unittest.TestCase): def setUp(self) -> None: if not _canonical_mode(): self.skipTest("canonical 모드가 아님 — cutover 이전") self.legacy = REPO_ROOT / "raw/branch-notes/feature-plan-probe.md" if self.legacy.exists() or self.legacy.is_symlink(): self.skipTest("probe 슬러그가 이미 존재") def test_plan_covers_canonical_symlink_and_manifest(self) -> None: changes = vault_migrate.plan_new_document(REPO_ROOT, self.legacy, DOC) rels = {p.relative_to(REPO_ROOT).as_posix(): v for p, v in changes.items()} canonical = [r for r in rels if r.startswith("vault/")] self.assertEqual(len(canonical), 1, f"정본이 정확히 1개여야 한다: {sorted(rels)}") self.assertIn("ca-skeleton-frontend-operational-contract", canonical[0], "frontmatter 의 project 로 라우팅돼야 한다") self.assertTrue(canonical[0].endswith("/branch-notes/feature-plan-probe.md")) self.assertEqual(rels[canonical[0]], DOC) legacy_rel = "raw/branch-notes/feature-plan-probe.md" self.assertIsInstance(rels[legacy_rel], vault_migrate.SymlinkValue, "legacy 경로에는 실파일이 아니라 심링크가 놓여야 한다") self.assertTrue((self.legacy.parent / rels[legacy_rel].target).resolve() == (REPO_ROOT / canonical[0])) self.assertIn("harness/source/vault-layout.json", rels, "manifest 갱신이 같은 트랜잭션에 없으면 layout_check 가 FAIL 한다") manifest = json.loads(rels["harness/source/vault-layout.json"].decode("utf-8")) for key in ("migration_manifest", "rollback_mapping"): paths = {row["legacy_path"] for row in manifest[key]["entries"]} self.assertIn(legacy_rel, paths, f"{key} 에 신규 문서 행이 없다") def test_existing_document_is_rejected(self) -> None: existing = REPO_ROOT / "raw/branch-notes" candidates = [p for p in sorted(existing.glob("*.md")) if p.is_symlink()] if not candidates: self.skipTest("심링크 레이아웃이 아님") with self.assertRaises(vault_migrate.MigrationError) as ctx: vault_migrate.plan_new_document(REPO_ROOT, candidates[0], DOC) self.assertEqual(ctx.exception.code, "LEGACY_ALREADY_EXISTS") EXPAND_DOC = b"""--- title: branch / feature-expand-probe source_type: branch-note status: raw branch: feature-expand-probe project: ca-skeleton-frontend-operational-contract tags: [branch] created: 2026-07-23 status_label: in-progress --- # branch: feature-expand-probe """ class ExpandNewDocumentTest(unittest.TestCase): """계획 3요소가 write-root 집행에 살해당하지 않는지 지킨다. ``plan_new_documents`` 가 3요소를 올바르게 계산해도, ``expand_authoritative_changes`` 가 그 결과 *전부* 를 ``enforce_write_paths`` 에 넣으면 ②호환 심링크(raw/…)와 ③manifest(harness/source/…) 가 canonical write root('vault') 밖이라 트랜잭션 전체가 WRITE_ROOT_VIOLATION 으로 죽는다 — 2026-07-23 branch_from_project WI-019 실측. 집행 대상은 caller 가 고른 목적지이지, planner 가 layout 계약(①~③ 동시 성립)을 위해 스스로 도출한 산출물이 아니다. 이 테스트도 저장소를 쓰지 않는다 — expand 는 계산만 한다. """ def setUp(self) -> None: if not _canonical_mode(): self.skipTest("canonical 모드가 아님 — cutover 이전") self.legacy = REPO_ROOT / "raw/branch-notes/feature-expand-probe.md" if self.legacy.exists() or self.legacy.is_symlink(): self.skipTest("probe 슬러그가 이미 존재") def test_expand_admits_planned_three_elements(self) -> None: expanded, authority = vault_migrate.expand_authoritative_changes( REPO_ROOT, {self.legacy: EXPAND_DOC} ) self.assertEqual(authority["mode"], "canonical") rels = {p.relative_to(REPO_ROOT).as_posix(): v for p, v in expanded.items()} canonical = [r for r in rels if r.startswith("vault/")] self.assertEqual(len(canonical), 1, f"정본이 정확히 1개여야 한다: {sorted(rels)}") self.assertEqual(rels[canonical[0]], EXPAND_DOC) legacy_rel = "raw/branch-notes/feature-expand-probe.md" self.assertIsInstance(rels.get(legacy_rel), vault_migrate.SymlinkValue, "호환 심링크가 트랜잭션에서 빠지면 CANONICAL_OWNER_MISSING") self.assertIn("harness/source/vault-layout.json", rels, "manifest 갱신이 트랜잭션에서 빠지면 MIGRATION_ENTRY_MISSING") def test_non_legacy_write_is_still_rejected(self) -> None: stray = REPO_ROOT / "docs/feature-expand-probe-stray.md" with self.assertRaises(vault_migrate.MigrationError) as ctx: vault_migrate.expand_authoritative_changes(REPO_ROOT, {stray: EXPAND_DOC}) self.assertEqual(ctx.exception.code, "WRITE_ROOT_VIOLATION", "planner 면제가 legacy-content 밖 경로까지 새면 안 된다") if __name__ == "__main__": unittest.main()