249 lines
9.1 KiB
Python
249 lines
9.1 KiB
Python
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
|
|
---
|
|
<!-- section-id: project-decisions -->
|
|
## 결정
|
|
| Decision ID | Revision | Decision Summary | Owner |
|
|
|---|---|---|---|
|
|
| `DEC-DEMO-001` | 1 | 기준 결정을 사용한다 | demo |
|
|
<!-- section-id: project-work-items -->
|
|
## 작업
|
|
| 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("<!-- GENERATED: branch-contract:start -->", 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
|
|
---
|
|
<!-- section-id: project-decisions -->
|
|
## 결정
|
|
| Decision ID | Revision | Decision Summary |
|
|
|---|---|---|
|
|
| `DEC-DEMO-001` | 2 | 기준 |
|
|
<!-- section-id: project-work-items -->
|
|
## 작업
|
|
| 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()
|