306 lines
15 KiB
Python
306 lines
15 KiB
Python
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("<!-- GENERATED: branch-contract:start -->", 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"
|
|
"<!-- GENERATED: sources:start -->\n"
|
|
"- [[raw/official-docs/sample-evidence]]\n"
|
|
"<!-- GENERATED: sources:end -->\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()
|