from __future__ import annotations import importlib.util from pathlib import Path import subprocess import sys import tempfile import unittest from unittest import mock import json REPO_ROOT = Path(__file__).resolve().parents[2] RUNTIME = REPO_ROOT / "harness/runtime" sys.path.insert(0, str(RUNTIME)) import branch_from_project # noqa: E402 import branch_contract_check # noqa: E402 import fs_transaction # noqa: E402 PROJECT = """--- title: sample source_type: project-note id: sample-project project_revision: 3 --- # Sample ## Project Decision Registry | Decision ID | Revision | Domain | Decision Summary | Status | Owner | Evidence | |---|---|---|---|---|---|---| | `DEC-SAMPLE-PROJECT-API-001` | 2 | api | canonical summary | active | project | C1 | ## Work Item Registry | Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status | |---|---|---|---|---|---| | `WI-SAMPLE-PROJECT-001` | `feature-sample-api-contract-runtime` | test command exits zero | `DEC-SAMPLE-PROJECT-API-001@2` | - | `planned` | ## 묶음 ### Legacy manual links - [[raw/errors/keep-me]] """ class BranchFromProjectTest(unittest.TestCase): def setUp(self) -> None: self.tempdir = tempfile.TemporaryDirectory() self.addCleanup(self.tempdir.cleanup) self.root = Path(self.tempdir.name) project = self.root / "raw/project-notes/sample-project.md" project.parent.mkdir(parents=True) (self.root / "raw/branch-notes").mkdir(parents=True) for path in ("harness/source", "harness/adapters", "harness/runtime", "harness/tests", "vault/10-projects"): (self.root / path).mkdir(parents=True, exist_ok=True) (self.root / "harness/source/vault-layout.json").write_text( json.dumps({ "schema_version": "vault-layout/v1", "mode": "compatibility", "vault_root": "vault", "canonical_mapping": { "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}", }, "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/project-notes", "raw/branch-notes"]}, }), encoding="utf-8", ) (self.root / "harness/source/typed-contracts.json").write_bytes( (REPO_ROOT / "harness/source/typed-contracts.json").read_bytes() ) project.write_text(PROJECT, encoding="utf-8") def test_typed_contract_failure_blocks_branch_plan(self) -> None: (self.root / "raw/branch-notes/feature-bad-contract.md").write_text( "---\ntitle: bad\nimports: [MISSING-GATE-999@1]\n---\n# Bad\n", encoding="utf-8", ) with self.assertRaises(branch_from_project.BranchError) as raised: branch_from_project.prepare(self.root, "sample-project", "WI-SAMPLE-PROJECT-001") self.assertEqual(raised.exception.code, "TYPED_CONTRACT_FAILED") def test_parent_hub_certificate_is_mandatory_when_semantic_policy_is_active(self) -> None: policy = self.root / "harness/source/document-semantic-surfaces.json" policy.write_bytes((REPO_ROOT / "harness/source/document-semantic-surfaces.json").read_bytes()) with self.assertRaises(branch_from_project.BranchError) as raised: branch_from_project.prepare(self.root, "sample-project", "WI-SAMPLE-PROJECT-001") self.assertEqual(raised.exception.code, "PARENT_SEMANTIC_CERTIFICATE_INVALID") self.assertIn("SEMANTIC_CERTIFICATE_MISSING", str(raised.exception)) self.assertFalse( (self.root / "raw/branch-notes/feature-sample-api-contract-runtime.md").exists() ) def test_apply_updates_status_moc_and_passes_graph_checker(self) -> None: result = subprocess.run( [sys.executable, str(RUNTIME / "branch_from_project.py"), "sample-project", "WI-SAMPLE-PROJECT-001", "--root", str(self.root), "--apply"], check=False, capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, result.stdout) branch = self.root / "raw/branch-notes/feature-sample-api-contract-runtime.md" self.assertTrue(branch.is_file()) branch_text = branch.read_text(encoding="utf-8") self.assertIn("id: BR-SAMPLE-PROJECT-001", branch_text) self.assertIn("contract_packet_sha256:", branch_text) self.assertIn("", branch_text) self.assertIn("- [[raw/project-notes/sample-project]]", branch_text) project_text = (self.root / "raw/project-notes/sample-project.md").read_text(encoding="utf-8") self.assertIn("| `in-progress` |", project_text) self.assertIn("[[raw/branch-notes/feature-sample-api-contract-runtime]]", project_text) self.assertIn("[[raw/errors/keep-me]]", project_text) check = subprocess.run( [sys.executable, str(REPO_ROOT / ".claude/hooks/wiki_graph_contract_check.py"), "--all", "--root", str(self.root)], check=False, capture_output=True, text=True, ) self.assertEqual(check.returncode, 0, check.stdout) def test_stale_decision_and_existing_target_fail_without_writes(self) -> None: project = self.root / "raw/project-notes/sample-project.md" original = project.read_text(encoding="utf-8") project.write_text(original.replace("@2` | -", "@1` | -"), encoding="utf-8") with self.assertRaises(branch_from_project.BranchError) as raised: branch_from_project.prepare(self.root, "sample-project", "WI-SAMPLE-PROJECT-001") self.assertEqual(raised.exception.code, "STALE_INHERITANCE_REVISION") self.assertEqual(project.read_text(encoding="utf-8"), original.replace("@2` | -", "@1` | -")) def test_dry_run_does_not_write_and_existing_target_fails(self) -> None: target = self.root / "raw/branch-notes/feature-sample-api-contract-runtime.md" result = subprocess.run( [sys.executable, str(RUNTIME / "branch_from_project.py"), "sample-project", "WI-SAMPLE-PROJECT-001", "--root", str(self.root), "--dry-run"], check=False, capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, result.stdout) self.assertFalse(target.exists()) target.write_text("sentinel\n", encoding="utf-8") with self.assertRaises(branch_from_project.BranchError) as raised: branch_from_project.prepare(self.root, "sample-project", "WI-SAMPLE-PROJECT-001") self.assertEqual(raised.exception.code, "TARGET_EXISTS") self.assertEqual(target.read_text(encoding="utf-8"), "sentinel\n") def test_dry_run_plan_hash_binds_apply_and_contract_preflight_passes(self) -> None: command = [ sys.executable, str(RUNTIME / "branch_from_project.py"), "sample-project", "WI-SAMPLE-PROJECT-001", "--root", str(self.root), ] dry_run = subprocess.run([*command, "--dry-run"], check=False, capture_output=True, text=True) self.assertEqual(dry_run.returncode, 0, dry_run.stdout) plan = json.loads(dry_run.stdout)["plan_sha256"] rejected = subprocess.run( [*command, "--apply", "--expected-plan-sha256", "0" * 64], check=False, capture_output=True, text=True, ) self.assertEqual(rejected.returncode, 1, rejected.stdout) self.assertFalse((self.root / "raw/branch-notes/feature-sample-api-contract-runtime.md").exists()) applied = subprocess.run( [*command, "--apply", "--expected-plan-sha256", plan], check=False, capture_output=True, text=True, ) self.assertEqual(applied.returncode, 0, applied.stdout) checked = branch_contract_check.validate( self.root, "raw/branch-notes/feature-sample-api-contract-runtime.md", postflight=True, ) self.assertEqual(checked["status"], "PASS", checked) branch = self.root / "raw/branch-notes/feature-sample-api-contract-runtime.md" branch.write_text( branch.read_text(encoding="utf-8").replace("canonical summary", "tampered summary", 1), encoding="utf-8", ) drifted = branch_contract_check.validate(self.root, branch) self.assertEqual(drifted["status"], "FAIL") self.assertIn("GENERATED_REGION_DRIFT", {item["code"] for item in drifted["findings"]}) def test_repository_local_template_changes_rendered_plan(self) -> None: baseline_changes, baseline = branch_from_project.prepare( self.root, "sample-project", "WI-SAMPLE-PROJECT-001" ) template = self.root / "templates/branch-note-template.md" template.parent.mkdir(parents=True) source = (REPO_ROOT / "templates/branch-note-template.md").read_text(encoding="utf-8") template.write_text(source.replace("아직 없음.\n\n## 결정 사항", "template revision.\n\n## 결정 사항", 1), encoding="utf-8") changed, result = branch_from_project.prepare( self.root, "sample-project", "WI-SAMPLE-PROJECT-001" ) target = self.root / "raw/branch-notes/feature-sample-api-contract-runtime.md" self.assertNotEqual(baseline["plan_sha256"], result["plan_sha256"]) self.assertNotEqual(baseline_changes[target], changed[target]) def test_second_replace_failure_rolls_back_project_and_new_branch(self) -> None: project = self.root / "raw/project-notes/sample-project.md" original = project.read_bytes() changes, result = branch_from_project.prepare(self.root, "sample-project", "WI-SAMPLE-PROJECT-001") real_replace = fs_transaction.os.replace calls = 0 def fail_second(source, target): nonlocal calls calls += 1 if calls == 2: raise OSError("injected failure") return real_replace(source, target) with mock.patch("fs_transaction.os.replace", side_effect=fail_second): with self.assertRaises(fs_transaction.TransactionError): fs_transaction.replace_many(changes, must_not_exist={self.root / result["target"]}) self.assertEqual(project.read_bytes(), original) self.assertFalse((self.root / result["target"]).exists()) def test_canonical_authority_refuses_legacy_project_and_branch_writes(self) -> None: authority = { "mode": "canonical", "authority": "vault", "write_roots": ["vault"], "manifest_sha256": "0" * 64, } with mock.patch("branch_from_project.layout_check.resolve_authority", return_value=authority): with self.assertRaises(branch_from_project.BranchError) as raised: branch_from_project.prepare(self.root, "sample-project", "WI-SAMPLE-PROJECT-001") self.assertEqual(raised.exception.code, "WRITE_ROOT_VIOLATION") class MocStagingCoverageTest(BranchFromProjectTest): """stage 밖 자료로 파생된 generated region 이 재생성에서 비워지지 않는지 지킨다. prepare 는 staging 사본 위에서 moc_indexer 를 돌린다. staging 이 raw/project-notes·raw/branch-notes 만 담으면 다른 child_root(raw/official-docs 등)가 stage 에 없어 sources 류 region 이 *빈 목록* 으로 재생성되고, 그 파괴적 결과가 실 저장소에 반영된다 — 2026-07-23 WI-019 apply 실측(무관 문서 100+개의 GENERATED region 소실). staging 은 relations 의 모든 root 를 담아야 한다. """ def test_moc_rebuild_preserves_regions_sourced_outside_old_staging(self) -> None: manifest_path = self.root / "harness/source/vault-layout.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["areas"]["10-projects"].append("raw/official-docs") manifest_path.write_text(json.dumps(manifest), encoding="utf-8") evidence = self.root / "raw/official-docs/sample-evidence.md" evidence.parent.mkdir(parents=True) evidence.write_text( "---\ntitle: evidence\nrelated_branches: [feature-existing-note]\n---\n# E\n", encoding="utf-8", ) existing = self.root / "raw/branch-notes/feature-existing-note.md" existing.write_text( # v2 binding marker 없는 legacy 노트 — graph checker 는 skip 하고 # moc_indexer 의 sources region 재생성 대상으로만 남는다. "---\ntitle: branch / feature-existing-note\nsource_type: branch-note\n" "status: raw\nbranch: feature-existing-note\nparent_branch:\n---\n" "# branch: feature-existing-note\n\n## 묶음\n\n" "\n" "- [[raw/official-docs/sample-evidence]]\n" "\n", encoding="utf-8", ) changes, _result = branch_from_project.prepare( self.root, "sample-project", "WI-SAMPLE-PROJECT-001" ) planned = changes.get(existing) if planned is not None: self.assertIn( "[[raw/official-docs/sample-evidence]]", planned.decode("utf-8"), "stage 에 없는 raw/official-docs 근거가 region 재생성에서 소실됐다", ) class PlanHashSymlinkValueTest(unittest.TestCase): """계획 해시가 planner 산출 SymlinkValue 를 결정론적으로 소화하는지 지킨다. canonical 모드 신규 branch 생성 시 expand 가 호환 심링크(SymlinkValue)를 changes 에 넣는다. ``_plan_sha256`` 이 bytes 만 가정하면 ``len(SymlinkValue)`` 에서 TypeError — 2026-07-23 WI-019 실측(write-root 면제 이후 두 번째 다리). """ def test_plan_hash_digests_symlink_values(self) -> None: root = Path("/repo") changes = { root / "vault/doc.md": b"content", root / "raw/doc.md": fs_transaction.SymlinkValue("../vault/doc.md"), } first = branch_from_project._plan_sha256(root, changes) self.assertRegex(first, r"^[0-9a-f]{64}$") reordered = dict(reversed(list(changes.items()))) self.assertEqual(first, branch_from_project._plan_sha256(root, reordered), "삽입 순서와 무관해야 한다") retargeted = dict(changes) retargeted[root / "raw/doc.md"] = fs_transaction.SymlinkValue("../vault/other.md") self.assertNotEqual(first, branch_from_project._plan_sha256(root, retargeted), "symlink target 변화가 해시에 반영돼야 한다") if __name__ == "__main__": unittest.main()