from __future__ import annotations import hashlib import io import json from pathlib import Path import sys import tempfile import unittest from unittest import mock from contextlib import redirect_stdout RUNTIME = Path(__file__).resolve().parents[1] / "runtime" sys.path.insert(0, str(RUNTIME)) import document_commit # noqa: E402 import fs_transaction # noqa: E402 import proof_manifest # noqa: E402 import semantic_audit # noqa: E402 import semantic_candidate_builder # noqa: E402 import semantic_certificate # noqa: E402 import semantic_surface_extractor # noqa: E402 class DocumentCommitTest(unittest.TestCase): def setUp(self) -> None: self.tempdir = tempfile.TemporaryDirectory() self.addCleanup(self.tempdir.cleanup) self.root = Path(self.tempdir.name) (self.root / "raw/branch-notes").mkdir(parents=True) (self.root / "raw/errors").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/branch-notes", "raw/errors"]}, }), encoding="utf-8", ) (self.root / "harness/source/typed-contracts.json").write_bytes( (RUNTIME.parents[1] / "harness/source/typed-contracts.json").read_bytes() ) self.parent = self.root / "raw/branch-notes/feature-sample-parent-contract.md" self.parent.write_text("---\ntitle: parent\n---\n# Parent\n## Cluster / 묶음\nmanual bytes\n", encoding="utf-8") self.candidate = self.root / "candidate.md" self.candidate.write_text( "---\ntitle: child\nsource_type: error-note\nstatus: raw\nrelated_branches: [feature-sample-parent-contract]\ntags: [error]\n---\n# Child\n", encoding="utf-8", ) self.source = self.root / "source.md" self.source.write_text("evidence\n", encoding="utf-8") profiles = self.root / "profiles.json" profiles.write_text(json.dumps({"schema_version": "execution-profiles/v1", "profiles": {"capture": {}}}), encoding="utf-8") self.profiles = profiles proof = { "schema_version": proof_manifest.SCHEMA_VERSION, "run": {"id": "run-1", "profile": "capture"}, "proofs": [{ "finding": {"id": "F1", "role": "quote"}, "source": { "path": "source.md", "sha256": hashlib.sha256(self.source.read_bytes()).hexdigest(), "line_start": 1, "line_end": 1, "quote_utf8": "evidence", }, "execution": { "argv": ["proof-runner/exact-utf8-v1", "source.md", "1:1"], "exit_code": 0, "stdout_utf8": "evidence", "stdout_sha256": hashlib.sha256(b"evidence").hexdigest(), "exact_match": True, }, }], } verified = proof_manifest.verify_manifest(proof, self.root, {"capture"}) self.proof = self.root / "proof-manifest.json" self.proof.write_bytes(proof_manifest.manifest_bytes(verified)) relations = { "schema_version": "document-relations/v1", "relations": [{ "id": "error-to-branch", "child_roots": ["raw/errors"], "parent_field": "related_branches", "parent_roots": ["raw/branch-notes"], "marker": "errors", }], } self.relations = self.root / "relations.json" self.relations.write_text(json.dumps(relations), encoding="utf-8") self.request = { "schema_version": "document-commit/v1", "candidate": {"path": "candidate.md", "sha256": hashlib.sha256(self.candidate.read_bytes()).hexdigest()}, "target": {"path": "raw/errors/child.md", "must_not_exist": True}, "proof_manifest": {"path": "proof-manifest.json", "sha256": hashlib.sha256(self.proof.read_bytes()).hexdigest()}, } @staticmethod def _pass_gate(_root, touched_paths, **_kwargs): return {"schema_version": "quality-gate-result/v1", "status": "PASS", "findings": [], "checked_paths": list(touched_paths)} def _prepare(self): return document_commit.prepare( self.root, self.request, profiles_path=self.profiles, relations_path=self.relations, quality_runner=self._pass_gate, ) def test_prepare_is_read_only_and_commit_updates_child_and_parent_together(self) -> None: original_parent = self.parent.read_bytes() changes, result, forbidden = self._prepare() target = self.root / "raw/errors/child.md" self.assertFalse(target.exists()) self.assertEqual(self.parent.read_bytes(), original_parent) self.assertEqual(result["proof_count"], 1) document_commit.commit(changes, forbidden) self.assertEqual(target.read_bytes(), self.candidate.read_bytes()) parent_text = self.parent.read_text(encoding="utf-8") self.assertIn("", parent_text) self.assertIn("[[raw/errors/child]]", parent_text) self.assertIn("manual bytes", parent_text) def test_quality_gate_receives_typed_and_projection_extensions(self) -> None: observed: list[str] = [] def gate(_root, touched_paths, **kwargs): observed.extend(item.name for item in kwargs["extensions"]) return { "schema_version": "quality-gate-result/v1", "status": "PASS", "findings": [], "checked_paths": list(touched_paths), } document_commit.prepare( self.root, self.request, profiles_path=self.profiles, relations_path=self.relations, quality_runner=gate, ) self.assertEqual(observed, ["typed-contract", "contract-projection"]) def test_hash_or_quality_failure_writes_nothing(self) -> None: original_parent = self.parent.read_bytes() bad = json.loads(json.dumps(self.request)) bad["candidate"]["sha256"] = "0" * 64 with self.assertRaises(document_commit.DocumentCommitError) as raised: document_commit.prepare(self.root, bad, profiles_path=self.profiles, relations_path=self.relations, quality_runner=self._pass_gate) self.assertEqual(raised.exception.code, "CANDIDATE_HASH_MISMATCH") def failing_gate(_root, touched_paths, **_kwargs): return {"schema_version": "quality-gate-result/v1", "status": "FAIL", "findings": [{"code": "INJECTED"}], "checked_paths": list(touched_paths)} with self.assertRaises(document_commit.DocumentCommitError) as raised: document_commit.prepare(self.root, self.request, profiles_path=self.profiles, relations_path=self.relations, quality_runner=failing_gate) self.assertEqual(raised.exception.code, "QUALITY_GATE_FAILED") self.assertFalse((self.root / "raw/errors/child.md").exists()) self.assertEqual(self.parent.read_bytes(), original_parent) def test_failed_proof_manifest_blocks_completion(self) -> None: manifest = json.loads(self.proof.read_text(encoding="utf-8")) manifest["verification"]["status"] = "FAIL" manifest["verification"]["fail_count"] = 1 self.proof.write_bytes(proof_manifest.manifest_bytes(manifest)) self.request["proof_manifest"]["sha256"] = hashlib.sha256(self.proof.read_bytes()).hexdigest() with self.assertRaises(document_commit.DocumentCommitError) as raised: self._prepare() self.assertEqual(raised.exception.code, "PROOF_NOT_PASS") self.assertFalse((self.root / "raw/errors/child.md").exists()) def test_replace_failure_rolls_back_child_and_parent(self) -> None: changes, _result, forbidden = self._prepare() original_parent = self.parent.read_bytes() real_replace = fs_transaction.os.replace calls = 0 def fail_second(source, target): nonlocal calls calls += 1 if calls == 2: raise OSError("injected replacement failure") return real_replace(source, target) with mock.patch("fs_transaction.os.replace", side_effect=fail_second): with self.assertRaises(fs_transaction.TransactionError): document_commit.commit(changes, forbidden) self.assertFalse((self.root / "raw/errors/child.md").exists()) self.assertEqual(self.parent.read_bytes(), original_parent) def test_concurrent_parent_edit_blocks_commit_without_overwrite(self) -> None: changes, _result, forbidden = self._prepare() self.parent.write_text(self.parent.read_text(encoding="utf-8") + "concurrent\n", encoding="utf-8") concurrent = self.parent.read_bytes() with self.assertRaises(document_commit.DocumentCommitError) as raised: document_commit.commit(changes, forbidden) self.assertEqual(raised.exception.code, "CONCURRENT_MODIFICATION") self.assertEqual(self.parent.read_bytes(), concurrent) self.assertFalse((self.root / "raw/errors/child.md").exists()) def test_plan_hash_binds_apply_and_includes_active_layout(self) -> None: prepared = self._prepare() _changes, result, _forbidden = prepared self.assertRegex(result["plan_sha256"], r"^[0-9a-f]{64}$") self.assertEqual(result["active_layout"]["mode"], "compatibility") request_path = self.root / "request.json" request_path.write_text(json.dumps(self.request), encoding="utf-8") command = [ str(request_path), "--root", str(self.root), "--profiles", str(self.profiles), "--relations", str(self.relations), ] output = io.StringIO() with mock.patch("document_commit.prepare", return_value=prepared), redirect_stdout(output): exit_code = document_commit.main([*command, "--apply", "--expected-plan-sha256", "0" * 64]) self.assertEqual(exit_code, 1, output.getvalue()) self.assertEqual(json.loads(output.getvalue())["error"]["code"], "PLAN_HASH_MISMATCH") self.assertFalse((self.root / "raw/errors/child.md").exists()) def test_canonical_authority_refuses_legacy_target(self) -> None: """canonical 모드의 신규 legacy 문서는 pre-check 가 아니라 planner 로 위임된다. 2026-07-23 cutover 수정: 신규 legacy 목적지는 expand 의 planner 가 정본·심링크·manifest 를 계산하므로 pre-check 는 통과시킨다. 이 fixture 는 planner 가 요구하는 project relation 설정이 없어 여전히 fail-closed 로 거부된다(쓰기 0건) — 조용한 통과가 아님을 계속 보장한다. """ authority = { "mode": "canonical", "authority": "vault", "write_roots": ["vault"], "manifest_sha256": "0" * 64, } with mock.patch("document_commit.layout_check.resolve_authority", return_value=authority): with self.assertRaises(document_commit.DocumentCommitError) as raised: self._prepare() self.assertEqual(raised.exception.code, "PROJECT_RELATION_MISSING") self.assertFalse((self.root / "raw/errors/child.md").exists()) def test_semantic_commit_accepts_compatibility_symlink_target(self) -> None: """canonical cutover 의 호환 심링크 target 에서 semantic commit 이 성립해야 한다. target 을 resolve() 한 vault 철자와 인증서의 logical(raw/…) subject 를 비교하면 영구 mismatch — branch_contract_check 가 이미 고친 pre-resolve 판정 규율의 쌍둥이(2026-07-23 WI-019 hub 재인증 실측, SEMANTIC_AUDIT_SUBJECT_MISMATCH). """ repo_root = RUNTIME.parents[1] for relative in ( semantic_surface_extractor.DEFAULT_POLICY, semantic_candidate_builder.DEFAULT_ONTOLOGY, semantic_certificate.DEFAULT_AGENT_METADATA, semantic_certificate.DEFAULT_AGENT_BODY, ): target = self.root / relative target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes((repo_root / relative).read_bytes()) real = self.root / "raw/branch-notes/feature-symlink-child-real.md" real.parent.mkdir(parents=True, exist_ok=True) link = self.root / "raw/branch-notes/feature-symlink-child.md" self.candidate.write_text( "---\n" "title: symlink child\n" "source_type: branch-note\n" "status: verified\n" "---\n\n" "\n## 계약\ncontract assertion\n" "\n## 범위\nscope assertion\n" "\n## 근거\nevidence assertion\n" "\n## 구현\nimplementation assertion\n" "\n## 실패\nfailure assertion\n" "\n## 주장\nclaims assertion\n", encoding="utf-8", ) real.write_bytes(self.candidate.read_bytes()) link.symlink_to(Path("feature-symlink-child-real.md")) policy = semantic_surface_extractor.load_policy(self.root) extraction = semantic_surface_extractor.extract_document(self.root, link, policy) self.assertEqual(extraction["path"], "raw/branch-notes/feature-symlink-child.md") lines = link.read_text(encoding="utf-8").splitlines() assertions = [] for index, surface in enumerate(extraction["surfaces"]): line = surface["line_start"] + 1 assertions.append({ "assertion_id": f"A{index + 1}", "source_surface": surface["surface_id"], "subject": f"subject-{index}", "predicate": "other", "object": f"object-{index}", "condition": f"condition-{index}", "modality": "observed", "scope": "branch", "quote": lines[line - 1], "line_start": line, "line_end": line, }) assertion_result = { "schema_version": semantic_candidate_builder.ASSERTION_SCHEMA, "subject": extraction["path"], "mode": extraction["mode"], "surface_manifest_sha256": hashlib.sha256(semantic_surface_extractor.canonical_json_bytes(extraction)).hexdigest(), "assertions": assertions, } candidates = semantic_candidate_builder.build(self.root, extraction, assertion_result) audit_request = semantic_audit.build_verdict_request(candidates) audit_result = { "schema_version": semantic_audit.AUDIT_RESULT_SCHEMA, "request_sha256": hashlib.sha256(semantic_audit.canonical_json_bytes(audit_request)).hexdigest(), "subject": extraction["path"], "mode": extraction["mode"], "auditor": {"contract_version": "semantic-coherence/v1", "model_id": "fixture", "run_id": "run-symlink"}, "verdicts": [], } audit_request_path = self.root / "semantic-request.json" audit_result_path = self.root / "semantic-result.json" audit_request_path.write_bytes(semantic_audit.canonical_json_bytes(audit_request)) audit_result_path.write_bytes(semantic_audit.canonical_json_bytes(audit_result)) self.request["candidate"]["sha256"] = hashlib.sha256(self.candidate.read_bytes()).hexdigest() self.request["target"] = {"path": "raw/branch-notes/feature-symlink-child.md", "must_not_exist": False} self.request["semantic_audit"] = { "request": {"path": "semantic-request.json", "sha256": hashlib.sha256(audit_request_path.read_bytes()).hexdigest()}, "result": {"path": "semantic-result.json", "sha256": hashlib.sha256(audit_result_path.read_bytes()).hexdigest()}, } changes, result, forbidden = self._prepare() self.assertEqual(result["target"], "raw/branch-notes/feature-symlink-child.md") certificate_path = self.root / result["semantic_certificate"] document_commit.commit(changes, forbidden) certificate = json.loads(certificate_path.read_text(encoding="utf-8")) self.assertEqual(certificate["subject"], "raw/branch-notes/feature-symlink-child.md") self.assertTrue(link.is_symlink(), "호환 심링크가 실파일로 대체되면 안 된다") self.assertEqual(real.read_bytes(), self.candidate.read_bytes()) def test_semantic_certificate_and_document_share_one_rollback_boundary(self) -> None: repo_root = RUNTIME.parents[1] for relative in ( semantic_surface_extractor.DEFAULT_POLICY, semantic_candidate_builder.DEFAULT_ONTOLOGY, semantic_certificate.DEFAULT_AGENT_METADATA, semantic_certificate.DEFAULT_AGENT_BODY, ): target = self.root / relative target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes((repo_root / relative).read_bytes()) target = self.root / "raw/branch-notes/feature-semantic-child.md" self.candidate.write_text( "---\n" "title: semantic child\n" "source_type: branch-note\n" "status: verified\n" "---\n\n" "\n## 계약\ncontract assertion\n" "\n## 범위\nscope assertion\n" "\n## 근거\nevidence assertion\n" "\n## 구현\nimplementation assertion\n" "\n## 실패\nfailure assertion\n" "\n## 주장\nclaims assertion\n", encoding="utf-8", ) target.write_bytes(self.candidate.read_bytes()) policy = semantic_surface_extractor.load_policy(self.root) extraction = semantic_surface_extractor.extract_document(self.root, target, policy) lines = target.read_text(encoding="utf-8").splitlines() assertions = [] for index, surface in enumerate(extraction["surfaces"]): line = surface["line_start"] + 1 assertions.append({ "assertion_id": f"A{index + 1}", "source_surface": surface["surface_id"], "subject": f"subject-{index}", "predicate": "other", "object": f"object-{index}", "condition": f"condition-{index}", "modality": "observed", "scope": "branch", "quote": lines[line - 1], "line_start": line, "line_end": line, }) assertion_result = { "schema_version": semantic_candidate_builder.ASSERTION_SCHEMA, "subject": extraction["path"], "mode": extraction["mode"], "surface_manifest_sha256": hashlib.sha256(semantic_surface_extractor.canonical_json_bytes(extraction)).hexdigest(), "assertions": assertions, } candidates = semantic_candidate_builder.build(self.root, extraction, assertion_result) audit_request = semantic_audit.build_verdict_request(candidates) audit_result = { "schema_version": semantic_audit.AUDIT_RESULT_SCHEMA, "request_sha256": hashlib.sha256(semantic_audit.canonical_json_bytes(audit_request)).hexdigest(), "subject": extraction["path"], "mode": extraction["mode"], "auditor": {"contract_version": "semantic-coherence/v1", "model_id": "fixture", "run_id": "run-1"}, "verdicts": [], } audit_request_path = self.root / "semantic-request.json" audit_result_path = self.root / "semantic-result.json" audit_request_path.write_bytes(semantic_audit.canonical_json_bytes(audit_request)) audit_result_path.write_bytes(semantic_audit.canonical_json_bytes(audit_result)) target.unlink() self.request["candidate"]["sha256"] = hashlib.sha256(self.candidate.read_bytes()).hexdigest() self.request["target"] = {"path": "raw/branch-notes/feature-semantic-child.md", "must_not_exist": True} self.request["semantic_audit"] = { "request": {"path": "semantic-request.json", "sha256": hashlib.sha256(audit_request_path.read_bytes()).hexdigest()}, "result": {"path": "semantic-result.json", "sha256": hashlib.sha256(audit_result_path.read_bytes()).hexdigest()}, } observed_extensions: list[str] = [] def gate(stage, touched_paths, **kwargs): findings = [] for extension in kwargs["extensions"]: observed_extensions.append(extension.name) extension_result = extension.runner(stage) if extension_result["status"] != "PASS": findings.extend(extension_result.get("findings", [])) return { "schema_version": "quality-gate-result/v1", "status": "FAIL" if findings else "PASS", "findings": findings, "checked_paths": list(touched_paths), } changes, result, forbidden = document_commit.prepare( self.root, self.request, profiles_path=self.profiles, relations_path=self.relations, quality_runner=gate, ) certificate_path = self.root / result["semantic_certificate"] self.assertIn("semantic-certificate", observed_extensions) self.assertFalse(target.exists()) self.assertFalse(certificate_path.exists()) real_replace = fs_transaction.os.replace calls = 0 def fail_second(source, destination): nonlocal calls calls += 1 if calls == 2: raise OSError("injected semantic transaction failure") return real_replace(source, destination) with mock.patch("fs_transaction.os.replace", side_effect=fail_second): with self.assertRaises(fs_transaction.TransactionError): document_commit.commit(changes, forbidden) self.assertFalse(target.exists()) self.assertFalse(certificate_path.exists()) document_commit.commit(changes, forbidden) self.assertTrue(target.is_file()) self.assertEqual(semantic_certificate.validate_certificate(self.root, certificate_path)["verdict"], "PASS") if __name__ == "__main__": unittest.main()