init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user