from __future__ import annotations import sys from pathlib import Path import tempfile import unittest from unittest import mock RUNTIME = Path(__file__).resolve().parents[1] / "runtime" sys.path.insert(0, str(RUNTIME)) import fs_transaction # noqa: E402 from migrate_graph_contracts import build_updates, prepare_updates # noqa: E402 class GraphContractMigrationTests(unittest.TestCase): @staticmethod def _write_candidate_scope(root: Path) -> tuple[Path, Path]: projects = root / "raw/project-notes" branches = root / "raw/branch-notes" projects.mkdir(parents=True) branches.mkdir(parents=True) project = projects / "demo.md" project.write_text( """--- project_revision: 3 --- ## 결정 | Decision ID | Revision | Decision Summary | Owner | |---|---|---|---| | `DEC-DEMO-001` | 1 | 기준 결정을 사용한다 | demo | ## 작업 | Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status | |---|---|---|---|---|---| | `WI-DEMO-001` | `feature-demo` | 검사가 통과한다 | `DEC-DEMO-001@1` | - | `planned` | ## Cluster / 묶음 manual """, encoding="utf-8", ) branch = branches / "feature-demo.md" branch.write_text( """--- title: demo source_type: branch-note status: raw tags: [branch] created: 2026-07-20 parent_branch: legacy-parent --- # demo ## 부모 (필수) - [[raw/project-notes/demo]] ## 목표 테스트한다. """, encoding="utf-8", ) return project, branch def test_direct_work_item_is_migrated_from_registry(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) projects = root / "raw/project-notes" branches = root / "raw/branch-notes" projects.mkdir(parents=True) branches.mkdir(parents=True) (projects / "demo.md").write_text( """--- project_revision: 3 --- ## 6.1 Project Decision Registry / 안정 결정 레지스트리 | Decision ID | Revision | Decision Summary | Owner | |---|---|---|---| | `DEC-DEMO-001` | 1 | 기준 결정을 사용한다 | demo | ## 8.0 Work Item Registry / 실행계획 | Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status | |---|---|---|---|---|---| | `WI-DEMO-001` | `feature-demo` | 검사가 통과한다 | `DEC-DEMO-001@1` | - | `planned` | """, encoding="utf-8", ) branch = branches / "feature-demo.md" branch.write_text( """--- title: demo parent_branch: legacy-parent --- # demo ## Parent / 부모 (필수) - [[raw/project-notes/demo]] ## 목표 / WHY 테스트한다. """, encoding="utf-8", ) updates, stats = build_updates(root) self.assertEqual(stats["eligible"], ["feature-demo"]) migrated = updates[branch] self.assertIn("id: BR-DEMO-001", migrated) self.assertIn("kind: project-work-item", migrated) self.assertIn("work_item: WI-DEMO-001", migrated) self.assertIn("inherits: [DEC-DEMO-001@1]", migrated) self.assertIn("parent_branch:\n", migrated) self.assertIn("branch: feature-demo", migrated) self.assertIn("contract_packet_sha256:", migrated) self.assertIn("", migrated) self.assertIn("## 브랜치 계약 패킷", migrated) self.assertIn("**생성 시 프로젝트 개정**: `3`", migrated) self.assertIn("| `DEC-DEMO-001@1` | 기준 결정을 사용한다 |", migrated) def test_unmapped_and_v2_notes_are_not_guessed(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "raw/project-notes").mkdir(parents=True) branches = root / "raw/branch-notes" branches.mkdir(parents=True) (branches / "feature-child.md").write_text("---\ntitle: child\n---\n", encoding="utf-8") (branches / "feature-v2.md").write_text( "---\ncontract_packet: 1\n---\n", encoding="utf-8" ) updates, stats = build_updates(root) self.assertEqual(updates, {}) self.assertEqual(stats["unmapped"], ["feature-child"]) self.assertEqual(stats["already_v2"], ["feature-v2"]) self.assertEqual(stats["blocked"][0]["code"], "UNMAPPED_V2_BRANCH") def test_blocked_registry_prevents_all_scope_writes(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) projects = root / "raw/project-notes" branches = root / "raw/branch-notes" projects.mkdir(parents=True) branches.mkdir(parents=True) (projects / "demo.md").write_text( """--- project_revision: 2 --- ## 결정 | Decision ID | Revision | Decision Summary | |---|---|---| | `DEC-DEMO-001` | 2 | 기준 | ## 작업 | Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status | |---|---|---|---|---|---| | `WI-DEMO-001` | `feature-demo-one` | 완료 | `DEC-DEMO-001@1` | `WI-DEMO-001` | `planned` | | `WI-DEMO-001` | `feature-demo-two` | 완료 | - | `WI-DEMO-404` | `planned` | """, encoding="utf-8", ) (branches / "feature-demo-one.md").write_text( "---\ntitle: demo\n---\n# demo\n\n## 목표\n", encoding="utf-8" ) updates, stats = build_updates(root) self.assertEqual(updates, {}) codes = {item["code"] for item in stats["blocked"]} self.assertTrue( {"DUPLICATE_WORK_ITEM", "STALE_REVISION", "SELF_DEPENDENCY", "MISSING_APPLIED_DECISION", "MISSING_DEPENDENCY"} <= codes, stats, ) def test_staged_quality_failure_leaves_entire_scope_unchanged(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) project, branch = self._write_candidate_scope(root) before = {project: project.read_bytes(), branch: branch.read_bytes()} observed: dict[str, object] = {} def fail_gate(_root, touched_paths, **kwargs): observed["touched"] = list(touched_paths) observed["include_graph"] = kwargs["include_graph"] return { "schema_version": "quality-gate-result/v1", "status": "FAIL", "findings": [{"code": "INJECTED_GRAPH_FAILURE"}], "checked_paths": list(touched_paths), } updates, stats = prepare_updates(root, quality_runner=fail_gate) self.assertEqual(updates, {}) self.assertEqual(stats["blocked"][0]["code"], "MIGRATION_QUALITY_GATE_FAILED") self.assertTrue(observed["include_graph"]) self.assertIn("raw/branch-notes/feature-demo.md", observed["touched"]) self.assertEqual({path: path.read_bytes() for path in before}, before) def test_single_transaction_rolls_back_and_success_is_idempotent(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) project, branch = self._write_candidate_scope(root) before = {project: project.read_bytes(), branch: branch.read_bytes()} def pass_gate(_root, touched_paths, **_kwargs): return { "schema_version": "quality-gate-result/v1", "status": "PASS", "findings": [], "checked_paths": list(touched_paths), "touched_paths": list(touched_paths), "checks": [], } updates, stats = prepare_updates(root, quality_runner=pass_gate) self.assertGreaterEqual(len(updates), 2, stats) 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({path: text.encode("utf-8") for path, text in updates.items()}) self.assertEqual({path: path.read_bytes() for path in before}, before) fs_transaction.replace_many({path: text.encode("utf-8") for path, text in updates.items()}) second, second_stats = prepare_updates(root, quality_runner=pass_gate) self.assertEqual(second, {}, second_stats) self.assertEqual(second_stats["blocked"], []) if __name__ == "__main__": unittest.main()