Files
llm-wiki/harness/tests/test_document_commit.py
T

480 lines
24 KiB
Python

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("<!-- GENERATED: errors:start -->", 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"
"<!-- section-id: branch-contract-packet -->\n## 계약\ncontract assertion\n"
"<!-- section-id: scope -->\n## 범위\nscope assertion\n"
"<!-- section-id: decision-evidence -->\n## 근거\nevidence assertion\n"
"<!-- section-id: implementation -->\n## 구현\nimplementation assertion\n"
"<!-- section-id: edge-failure-dependency -->\n## 실패\nfailure assertion\n"
"<!-- section-id: claims-to-verify -->\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"
"<!-- section-id: branch-contract-packet -->\n## 계약\ncontract assertion\n"
"<!-- section-id: scope -->\n## 범위\nscope assertion\n"
"<!-- section-id: decision-evidence -->\n## 근거\nevidence assertion\n"
"<!-- section-id: implementation -->\n## 구현\nimplementation assertion\n"
"<!-- section-id: edge-failure-dependency -->\n## 실패\nfailure assertion\n"
"<!-- section-id: claims-to-verify -->\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()