145 lines
6.7 KiB
Python
145 lines
6.7 KiB
Python
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()
|