from __future__ import annotations import json import hashlib import sys from pathlib import Path import tempfile import unittest RUNTIME = Path(__file__).resolve().parents[1] / "runtime" sys.path.insert(0, str(RUNTIME)) from layout_check import LayoutContractError, check_layout, enforce_write_paths, resolve_authority # noqa: E402 class LayoutCheckTests(unittest.TestCase): def _fixture(self, root: Path) -> None: for path in ( "raw/branch-notes", "wiki/concepts", "harness/source", "harness/adapters", "harness/runtime", "harness/tests", "vault/10-projects", "vault/30-knowledge", ): (root / path).mkdir(parents=True, exist_ok=True) (root / "harness/source/vault-layout.json").write_text( json.dumps( { "schema_version": "vault-layout/v1", "mode": "compatibility", "vault_root": "vault", "write_roots": { "compatibility": ["raw", "wiki"], "shadow": ["raw", "wiki"], "canonical": ["vault"], }, "migration_manifest": {"schema_version": "vault-migration/v1", "entries": []}, "rollback_mapping": {"schema_version": "vault-rollback/v1", "entries": []}, "areas": { "10-projects": ["raw/branch-notes"], "30-knowledge": ["wiki/concepts"], }, } ), encoding="utf-8", ) def test_complete_unique_mapping_passes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) self._fixture(root) self.assertEqual(check_layout(root)["status"], "PASS") def test_unassigned_root_fails(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) self._fixture(root) (root / "raw/orphan").mkdir() result = check_layout(root) self.assertEqual(result["status"], "FAIL") self.assertIn("UNASSIGNED_CONTENT_ROOT", {item["code"] for item in result["findings"]}) def _configure_cutover(self, root: Path, mode: str, *, canonical_bytes: bytes, legacy_bytes: bytes) -> None: legacy = root / "raw/branch-notes/feature-example.md" canonical = root / "vault/10-projects/feature-example.md" legacy.write_bytes(legacy_bytes) canonical.write_bytes(canonical_bytes) manifest_path = root / "harness/source/vault-layout.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["mode"] = mode manifest["migration_manifest"]["entries"] = [{ "legacy_path": "raw/branch-notes/feature-example.md", "canonical_path": "vault/10-projects/feature-example.md", "sha256": hashlib.sha256(canonical_bytes).hexdigest(), }] manifest["rollback_mapping"]["entries"] = [{ "canonical_path": "vault/10-projects/feature-example.md", "legacy_path": "raw/branch-notes/feature-example.md", }] manifest_path.write_text(json.dumps(manifest), encoding="utf-8") def test_shadow_requires_byte_identical_mirror(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) self._fixture(root) content = b"---\ntitle: example\n---\n# Example\n" self._configure_cutover(root, "shadow", canonical_bytes=content, legacy_bytes=content) self.assertEqual(check_layout(root)["status"], "PASS") (root / "vault/10-projects/feature-example.md").write_text("drift\n", encoding="utf-8") result = check_layout(root) codes = {item["code"] for item in result["findings"]} self.assertIn("SHADOW_MIRROR_DRIFT", codes) self.assertIn("MIGRATION_HASH_MISMATCH", codes) def test_shadow_mirror_symlink_is_rejected(self) -> None: """shadow 미러가 심링크면 read_bytes 가 정본을 따라가 drift 를 못 잡는다(symlink-blind). 예전엔 legacy 가 정본을 가리키는 심링크여도 바이트가 같아 PASS 했다 — shadow 불변식 (독립 실파일 미러)을 위반하는데도 조용했다. 이제 심링크 자체를 loud 하게 잡는다. """ with tempfile.TemporaryDirectory() as directory: root = Path(directory) self._fixture(root) content = b"---\ntitle: example\n---\n# Example\n" self._configure_cutover(root, "shadow", canonical_bytes=content, legacy_bytes=content) self.assertEqual(check_layout(root)["status"], "PASS") # legacy 실파일을 정본을 가리키는 심링크로 바꾼다 → 바이트는 여전히 동일. legacy = root / "raw/branch-notes/feature-example.md" legacy.unlink() legacy.symlink_to(Path("../../vault/10-projects/feature-example.md")) result = check_layout(root) codes = {item["code"] for item in result["findings"]} self.assertEqual(result["status"], "FAIL") self.assertIn("SHADOW_MIRROR_SYMLINK", codes) self.assertNotIn("SHADOW_MIRROR_DRIFT", codes) # 심링크가 바이트 비교를 가리기 전에 잡힘 def test_canonical_requires_stub_owner_and_rollback_mapping(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) self._fixture(root) canonical = b"---\ntitle: example\n---\n# Example\n" stub = b"---\ntitle: moved\ncanonical_path: vault/10-projects/feature-example.md\n---\n# Moved\n" self._configure_cutover(root, "canonical", canonical_bytes=canonical, legacy_bytes=stub) result = check_layout(root) self.assertEqual(result["status"], "PASS", result["findings"]) self.assertEqual(result["authority"], "vault") self.assertEqual(result["write_roots"], ["vault"]) authority = resolve_authority(root) enforce_write_paths(root, [root / "vault/10-projects/new.md"], authority) with self.assertRaises(LayoutContractError) as raised: enforce_write_paths(root, [root / "raw/branch-notes/new.md"], authority) self.assertEqual(raised.exception.code, "WRITE_ROOT_VIOLATION") (root / "raw/branch-notes/feature-example.md").write_bytes(canonical) result = check_layout(root) self.assertIn("OLD_NEW_FULL_CONTENT_DUPLICATE", {item["code"] for item in result["findings"]}) if __name__ == "__main__": unittest.main()