diff --git a/confluence-mdx/bin/reverse_sync/base_parity.py b/confluence-mdx/bin/reverse_sync/base_parity.py index e00f9c5de..4c3c46d88 100644 --- a/confluence-mdx/bin/reverse_sync/base_parity.py +++ b/confluence-mdx/bin/reverse_sync/base_parity.py @@ -11,7 +11,7 @@ import yaml from reverse_sync.models import PageSnapshot -from reverse_sync.roundtrip_verifier import verify_roundtrip +from reverse_sync.equivalence import verify_push_equivalence @dataclass(frozen=True) @@ -159,15 +159,11 @@ def verify_base_parity( expected = _content_without_frontmatter(original_mdx) actual = _content_without_frontmatter(converted_base_mdx) - result = verify_roundtrip( - expected_mdx=expected, - actual_mdx=actual, - no_normalize=True, - ) + result = verify_push_equivalence(expected, actual) if result.passed: return BaseParityResult(True) - diff = "".join( + diff = result.diff_report or "".join( difflib.unified_diff( expected.splitlines(keepends=True), actual.splitlines(keepends=True), diff --git a/confluence-mdx/bin/reverse_sync/equivalence.py b/confluence-mdx/bin/reverse_sync/equivalence.py new file mode 100644 index 000000000..e34308528 --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/equivalence.py @@ -0,0 +1,513 @@ +"""push eligibility를 위한 versioned typed MDX equivalence. + +진단용 regex normalization과 달리 이 모듈은 MDX를 block/token model로 +변환한 뒤 구조와 visible content를 비교한다. v1에서 허용하는 source +formatting 차이는 Markdown table의 cell padding과 separator dash 길이뿐이다. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import difflib +import hashlib +import json +from pathlib import PurePosixPath +import re +from typing import Any +from urllib.parse import unquote, urlparse + +from mdx_to_storage.parser import Block, parse_mdx_blocks + + +PUSH_EQUIVALENCE_POLICY = "reverse-sync-equivalence-v1" +_NON_BODY_BLOCKS = frozenset({"empty", "frontmatter", "import_statement"}) +_TABLE_SEPARATOR_CELL = re.compile(r"^:?-+:?$") +_LIST_ITEM = re.compile(r"^([ \t]*)([-+*]|\d+\.)([ \t]+)(.*)$") + + +@dataclass(frozen=True) +class InlineToken: + """MDX inline source에서 의미 보존이 필요한 token.""" + + kind: str + value: str = "" + target: str = "" + attachment_filename: str = "" + children: tuple["InlineToken", ...] = () + + def to_dict(self) -> dict[str, Any]: + value: dict[str, Any] = {"kind": self.kind} + if self.value: + value["value"] = self.value + if self.target: + value["target"] = self.target + if self.attachment_filename: + value["attachment_filename"] = self.attachment_filename + if self.children: + value["children"] = [child.to_dict() for child in self.children] + return value + + +@dataclass(frozen=True) +class CanonicalBlock: + """push equivalence에서 비교하는 typed block.""" + + kind: str + level: int = 0 + language: str = "" + tokens: tuple[InlineToken, ...] = () + structure: tuple[Any, ...] = () + marker: str = "" + + def to_dict(self) -> dict[str, Any]: + value: dict[str, Any] = {"kind": self.kind} + if self.level: + value["level"] = self.level + if self.language: + value["language"] = self.language + if self.tokens: + value["tokens"] = [token.to_dict() for token in self.tokens] + if self.structure: + value["structure"] = _jsonable(self.structure) + if self.marker: + value["marker"] = self.marker + return value + + +@dataclass(frozen=True) +class CanonicalDocument: + """policy version과 block sequence로 구성된 canonical MDX.""" + + policy: str + blocks: tuple[CanonicalBlock, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "blocks": [block.to_dict() for block in self.blocks], + "policy": self.policy, + } + + def to_canonical_json(self) -> str: + return json.dumps( + self.to_dict(), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + "\n" + + @property + def sha256(self) -> str: + return hashlib.sha256(self.to_canonical_json().encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class EquivalenceResult: + """두 typed document의 비교 결과와 재현 가능한 evidence.""" + + passed: bool + policy: str + expected_sha256: str + actual_sha256: str + diff_report: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "actual_sha256": self.actual_sha256, + "diff_report": self.diff_report, + "expected_sha256": self.expected_sha256, + "passed": self.passed, + "policy": self.policy, + } + + +def _jsonable(value: Any) -> Any: + if isinstance(value, tuple): + return [_jsonable(item) for item in value] + if isinstance(value, InlineToken): + return value.to_dict() + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in sorted(value.items())} + return value + + +def _strip_single_terminal_newline(value: str) -> str: + return value[:-1] if value.endswith("\n") else value + + +def _find_closing_bracket(value: str, start: int) -> int: + escaped = False + for index in range(start, len(value)): + char = value[index] + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == "]": + return index + return -1 + + +def _find_closing_paren(value: str, start: int) -> int: + depth = 1 + escaped = False + quote = "" + for index in range(start, len(value)): + char = value[index] + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if quote: + if char == quote: + quote = "" + continue + if char in {"'", '"'}: + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return index + return -1 + + +def _find_tag_end(value: str, start: int) -> int: + quote = "" + for index in range(start, len(value)): + char = value[index] + if quote: + if char == quote: + quote = "" + continue + if char in {"'", '"'}: + quote = char + elif char == ">": + return index + return -1 + + +def _split_link_destination(value: str) -> tuple[str, str]: + """link destination과 optional title을 보수적으로 분리한다.""" + value = value.strip() + match = re.match(r"^(\S+?)(?:\s+([\"'])(.*)\2)?$", value, flags=re.DOTALL) + if not match: + return value, "" + return match.group(1), match.group(3) or "" + + +def _attachment_filename(target: str) -> str: + parsed = urlparse(target) + if parsed.scheme in {"http", "https", "data"}: + return "" + return PurePosixPath(unquote(parsed.path)).name + + +def _append_text(tokens: list[InlineToken], value: str) -> None: + if not value: + return + if tokens and tokens[-1].kind == "text": + previous = tokens.pop() + tokens.append(InlineToken(kind="text", value=previous.value + value)) + else: + tokens.append(InlineToken(kind="text", value=value)) + + +def tokenize_inline(value: str) -> tuple[InlineToken, ...]: + """inline source를 link/code/HTML marker/text token으로 분해한다. + + 해석할 수 없는 syntax는 text로 남긴다. 따라서 parser가 오인한 입력이 + 동등하다고 완화되는 대신 exact source 차이로 fail-closed된다. + """ + tokens: list[InlineToken] = [] + index = 0 + text_start = 0 + while index < len(value): + image = value.startswith("![", index) + link = value.startswith("[", index) + if image or link: + label_start = index + (2 if image else 1) + label_end = _find_closing_bracket(value, label_start) + if label_end >= 0 and label_end + 1 < len(value) and value[label_end + 1] == "(": + destination_end = _find_closing_paren(value, label_end + 2) + if destination_end >= 0: + _append_text(tokens, value[text_start:index]) + destination, title = _split_link_destination( + value[label_end + 2 : destination_end] + ) + label = value[label_start:label_end] + if image: + tokens.append( + InlineToken( + kind="image", + value=label, + target=destination, + attachment_filename=_attachment_filename(destination), + children=( + InlineToken(kind="title", value=title), + ) + if title + else (), + ) + ) + else: + children = list(tokenize_inline(label)) + if title: + children.append(InlineToken(kind="title", value=title)) + tokens.append( + InlineToken( + kind="link", + target=destination, + children=tuple(children), + ) + ) + index = destination_end + 1 + text_start = index + continue + + if value[index] == "`": + delimiter_length = 1 + while ( + index + delimiter_length < len(value) + and value[index + delimiter_length] == "`" + ): + delimiter_length += 1 + delimiter = "`" * delimiter_length + closing = value.find(delimiter, index + delimiter_length) + if closing >= 0: + _append_text(tokens, value[text_start:index]) + tokens.append( + InlineToken( + kind="code", + value=value[index + delimiter_length : closing], + ) + ) + index = closing + delimiter_length + text_start = index + continue + + if value[index] == "<": + tag_end = _find_tag_end(value, index + 1) + if tag_end >= 0: + candidate = value[index : tag_end + 1] + if re.match(r"^]*>$", candidate): + _append_text(tokens, value[text_start:index]) + marker_match = re.match(r"^ list[str]: + stripped = line.strip() + if not (stripped.startswith("|") and stripped.endswith("|")): + raise ValueError(f"Markdown table row가 아닙니다: {line!r}") + body = stripped[1:-1] + cells: list[str] = [] + current: list[str] = [] + escaped = False + code_delimiter = 0 + index = 0 + while index < len(body): + char = body[index] + if escaped: + current.append(char) + escaped = False + index += 1 + continue + if char == "\\": + current.append(char) + escaped = True + index += 1 + continue + if char == "`": + run = 1 + while index + run < len(body) and body[index + run] == "`": + run += 1 + current.extend("`" * run) + if code_delimiter == 0: + code_delimiter = run + elif code_delimiter == run: + code_delimiter = 0 + index += run + continue + if char == "|" and code_delimiter == 0: + cells.append("".join(current).strip(" \t")) + current = [] + else: + current.append(char) + index += 1 + cells.append("".join(current).strip(" \t")) + return cells + + +def _table_structure(content: str) -> tuple[Any, ...]: + rows = [_split_table_row(line) for line in content.rstrip("\n").splitlines()] + structure: list[Any] = [] + for row_index, cells in enumerate(rows): + if row_index == 1 and all(_TABLE_SEPARATOR_CELL.match(cell) for cell in cells): + alignments = [] + for cell in cells: + if cell.startswith(":") and cell.endswith(":"): + alignments.append("center") + elif cell.endswith(":"): + alignments.append("right") + elif cell.startswith(":"): + alignments.append("left") + else: + alignments.append("default") + structure.append(("separator", tuple(alignments))) + else: + structure.append( + ("row", tuple(tuple(tokenize_inline(cell)) for cell in cells)) + ) + return tuple(structure) + + +def _list_structure(content: str) -> tuple[Any, ...]: + entries: list[Any] = [] + for line in content.rstrip("\n").splitlines(): + match = _LIST_ITEM.match(line) + if match: + indent, marker, separator, body = match.groups() + list_kind = "ordered" if marker.endswith(".") and marker[:-1].isdigit() else "unordered" + ordinal = int(marker[:-1]) if list_kind == "ordered" else 0 + entries.append( + ( + "item", + indent, + list_kind, + ordinal, + separator, + tokenize_inline(body), + ) + ) + elif line == "": + entries.append(("blank",)) + else: + entries.append(("continuation", line)) + return tuple(entries) + + +def _blockquote_structure(content: str) -> tuple[Any, ...]: + lines: list[Any] = [] + for line in content.rstrip("\n").splitlines(): + match = re.match(r"^(>+)([ \t]?)(.*)$", line) + if not match: + lines.append(("raw", line)) + continue + markers, separator, body = match.groups() + lines.append(("quote", len(markers), separator, tokenize_inline(body))) + return tuple(lines) + + +def _opaque_marker(block: Block) -> str: + content = block.content.lstrip() + match = re.match(r"<([A-Za-z][\w:.-]*)", content) + if match: + return match.group(1) + return block.type + + +def canonicalize_block(block: Block) -> CanonicalBlock: + content = _strip_single_terminal_newline(block.content) + if block.type == "heading": + match = re.match(r"^#{1,6}[ \t]+(.*)$", content, flags=re.DOTALL) + body = match.group(1) if match else content + return CanonicalBlock( + kind="heading", + level=block.level, + tokens=tokenize_inline(body), + ) + if block.type == "paragraph": + return CanonicalBlock(kind="paragraph", tokens=tokenize_inline(content)) + if block.type == "table": + return CanonicalBlock(kind="table", structure=_table_structure(content)) + if block.type == "list": + return CanonicalBlock(kind="list", structure=_list_structure(content)) + if block.type == "blockquote": + return CanonicalBlock( + kind="blockquote", + structure=_blockquote_structure(content), + ) + if block.type == "code_block": + lines = content.splitlines() + body_lines = lines[1:-1] if len(lines) >= 2 else [] + return CanonicalBlock( + kind="code_block", + language=block.language, + tokens=(InlineToken(kind="code_block_text", value="\n".join(body_lines)),), + ) + if block.type == "hr": + return CanonicalBlock(kind="hr", marker=content) + + # Macro/JSX/HTML 구조는 v1에서 임의로 의미 해석하지 않는다. type과 + # marker를 기록하고 source를 exact token으로 보존해 unsafe equivalence를 막는다. + return CanonicalBlock( + kind=block.type, + marker=_opaque_marker(block), + tokens=(InlineToken(kind="opaque_source", value=content),), + ) + + +def canonicalize_mdx(mdx: str) -> CanonicalDocument: + blocks = tuple( + canonicalize_block(block) + for block in parse_mdx_blocks(mdx) + if block.type not in _NON_BODY_BLOCKS + ) + return CanonicalDocument(policy=PUSH_EQUIVALENCE_POLICY, blocks=blocks) + + +def _unified_model_diff( + expected: CanonicalDocument, + actual: CanonicalDocument, +) -> str: + expected_json = json.dumps( + expected.to_dict(), + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + "\n" + actual_json = json.dumps( + actual.to_dict(), + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + "\n" + return "".join( + difflib.unified_diff( + expected_json.splitlines(keepends=True), + actual_json.splitlines(keepends=True), + fromfile="expected.typed.json", + tofile="actual.typed.json", + lineterm="", + ) + ) + + +def verify_push_equivalence(expected_mdx: str, actual_mdx: str) -> EquivalenceResult: + """v1 typed canonical model로 두 MDX body를 비교한다.""" + expected = canonicalize_mdx(expected_mdx) + actual = canonicalize_mdx(actual_mdx) + passed = expected == actual + return EquivalenceResult( + passed=passed, + policy=PUSH_EQUIVALENCE_POLICY, + expected_sha256=expected.sha256, + actual_sha256=actual.sha256, + diff_report="" if passed else _unified_model_diff(expected, actual), + ) diff --git a/confluence-mdx/bin/reverse_sync/fragment_extractor.py b/confluence-mdx/bin/reverse_sync/fragment_extractor.py index b249dad40..964acaea0 100644 --- a/confluence-mdx/bin/reverse_sync/fragment_extractor.py +++ b/confluence-mdx/bin/reverse_sync/fragment_extractor.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field from typing import List, Tuple -from bs4 import BeautifulSoup, NavigableString, Tag +from bs4 import BeautifulSoup, Comment, NavigableString, Tag from reverse_sync.mapping_recorder import iter_block_children @@ -46,7 +46,7 @@ def extract_block_fragments(xhtml_text: str) -> FragmentExtractionResult: for child in iter_block_children(soup): if isinstance(child, Tag): top_elements.append(("tag", child.name)) - elif isinstance(child, NavigableString): + elif isinstance(child, NavigableString) and not isinstance(child, Comment): text = str(child).strip() if text: top_elements.append(("text", text)) diff --git a/confluence-mdx/bin/reverse_sync/manifest.py b/confluence-mdx/bin/reverse_sync/manifest.py index 39a34597e..1722b73d1 100644 --- a/confluence-mdx/bin/reverse_sync/manifest.py +++ b/confluence-mdx/bin/reverse_sync/manifest.py @@ -17,20 +17,32 @@ ) -MANIFEST_SCHEMA_VERSION = 1 -SUPPORTED_VERIFIER_POLICIES = frozenset({"reverse-sync-push-v1"}) -CURRENT_TOOL_VERSION = "reverse-sync-cli-v1" +MANIFEST_SCHEMA_VERSION = 2 +SUPPORTED_VERIFIER_POLICIES = frozenset({"reverse-sync-equivalence-v1"}) +CURRENT_TOOL_VERSION = "reverse-sync-cli-v2" REQUIRED_PUSH_GATES = frozenset( { "source_identity", "base_parity", "intent_complete", - "semantic_roundtrip", "artifact_integrity", + "storage_well_formed", + "preservation", + "semantic_roundtrip", + "determinism", + "idempotency", + "dependency", } ) REQUIRED_ARTIFACTS = frozenset( - {"base_xhtml", "original_mdx", "improved_mdx", "candidate_xhtml"} + { + "base_xhtml", + "original_mdx", + "improved_mdx", + "patch_plan", + "candidate_xhtml", + "local_proof", + } ) @@ -71,7 +83,9 @@ def _derive_run_id( base: PageSnapshot, original_mdx: str, improved_mdx: str, + patch_plan: str, candidate_xhtml: str, + local_proof: str, verifier_policy: str, tool_version: str, ) -> str: @@ -82,8 +96,10 @@ def _derive_run_id( "base_fetched_at": base.fetched_at, "candidate_sha256": sha256_text(candidate_xhtml), "improved_sha256": sha256_text(improved_mdx), + "local_proof_sha256": sha256_text(local_proof), "original_sha256": sha256_text(original_mdx), "page_id": base.page_id, + "patch_plan_sha256": sha256_text(patch_plan), "tool_version": tool_version, "verifier_policy": verifier_policy, } @@ -99,7 +115,9 @@ def create_sync_manifest( original_descriptor: str, improved_mdx: str, improved_descriptor: str, + patch_plan: str, candidate_xhtml: str, + local_proof: str, verifier_policy: str, tool_version: str, push_eligible: bool, @@ -119,7 +137,9 @@ def create_sync_manifest( base=base, original_mdx=original_mdx, improved_mdx=improved_mdx, + patch_plan=patch_plan, candidate_xhtml=candidate_xhtml, + local_proof=local_proof, verifier_policy=verifier_policy, tool_version=tool_version, ) @@ -140,7 +160,9 @@ def create_sync_manifest( _artifact_ref(run_dir, "base_xhtml", "base.xhtml", base.storage_xhtml), _artifact_ref(run_dir, "original_mdx", "original.mdx", original_mdx), _artifact_ref(run_dir, "improved_mdx", "improved.mdx", improved_mdx), + _artifact_ref(run_dir, "patch_plan", "patch-plan.json", patch_plan), _artifact_ref(run_dir, "candidate_xhtml", "candidate.xhtml", candidate_xhtml), + _artifact_ref(run_dir, "local_proof", "local-proof.json", local_proof), ) manifest = SyncManifest( schema_version=MANIFEST_SCHEMA_VERSION, diff --git a/confluence-mdx/bin/reverse_sync/mapping_recorder.py b/confluence-mdx/bin/reverse_sync/mapping_recorder.py index f77ec1f77..1817c0334 100644 --- a/confluence-mdx/bin/reverse_sync/mapping_recorder.py +++ b/confluence-mdx/bin/reverse_sync/mapping_recorder.py @@ -1,7 +1,7 @@ """Mapping Recorder — XHTML 블록 요소를 추출하여 매핑 레코드를 생성한다.""" from dataclasses import dataclass, field from typing import List, Optional -from bs4 import BeautifulSoup, NavigableString, Tag +from bs4 import BeautifulSoup, Comment, NavigableString, Tag @dataclass @@ -69,6 +69,8 @@ def record_mapping(xhtml: str) -> List[BlockMapping]: counters: dict = {} for child in iter_block_children(soup): + if isinstance(child, Comment): + continue if isinstance(child, NavigableString): if child.strip(): _add_mapping(mappings, counters, 'p', child.strip(), child.strip()) diff --git a/confluence-mdx/bin/reverse_sync/models.py b/confluence-mdx/bin/reverse_sync/models.py index 7e2d46477..233af37b7 100644 --- a/confluence-mdx/bin/reverse_sync/models.py +++ b/confluence-mdx/bin/reverse_sync/models.py @@ -35,6 +35,15 @@ class ReasonCode(str, Enum): PERMISSION_DENIED = "permission_denied" NETWORK_ERROR = "network_error" POSTCONDITION_FAILED = "postcondition_failed" + PAGE_IDENTITY_MISMATCH = "page_identity_mismatch" + BASE_PARITY_MISMATCH = "base_parity_mismatch" + INCOMPLETE_PATCH_PLAN = "incomplete_patch_plan" + INVALID_STORAGE_XHTML = "invalid_storage_xhtml" + PRESERVATION_MISMATCH = "preservation_mismatch" + SEMANTIC_ROUNDTRIP_MISMATCH = "semantic_roundtrip_mismatch" + NON_DETERMINISTIC_OUTPUT = "non_deterministic_output" + NON_IDEMPOTENT_OUTPUT = "non_idempotent_output" + DEPENDENCY_FAILURE = "dependency_failure" @dataclass(frozen=True) diff --git a/confluence-mdx/bin/reverse_sync/preserving_patcher.py b/confluence-mdx/bin/reverse_sync/preserving_patcher.py new file mode 100644 index 000000000..77e791d03 --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/preserving_patcher.py @@ -0,0 +1,128 @@ +"""top-level fragment와 separator를 byte-preserving하는 XHTML patch renderer.""" + +from __future__ import annotations + +from copy import deepcopy +import re +from typing import Any, Iterable + +from reverse_sync.sidecar import RoundtripSidecar +from reverse_sync.xhtml_patcher import patch_xhtml + + +class PatchApplicationError(ValueError): + """patch target을 exact base fragment로 해결하지 못했습니다.""" + + reason_code = "missing_identity" + + +_XPATH_PART = re.compile(r"^([a-z0-9:-]+)\[(\d+)\]$", flags=re.IGNORECASE) + + +def _root_xpath(xpath: str) -> str: + return xpath.split("/", 1)[0] + + +def _rebase_xpath(xpath: str) -> str: + parts = xpath.split("/") + match = _XPATH_PART.match(parts[0]) + if not match: + raise PatchApplicationError(f"지원하지 않는 patch xpath입니다: {xpath}") + parts[0] = f"{match.group(1)}[1]" + return "/".join(parts) + + +def _rebase_patch(patch: dict[str, Any]) -> dict[str, Any]: + rebased = deepcopy(patch) + if "xhtml_xpath" in rebased: + rebased["xhtml_xpath"] = _rebase_xpath(str(rebased["xhtml_xpath"])) + if "after_xpath" in rebased and rebased["after_xpath"] is not None: + rebased["after_xpath"] = _rebase_xpath(str(rebased["after_xpath"])) + return rebased + + +def _index_by_xpath(sidecar: RoundtripSidecar) -> dict[str, int]: + index: dict[str, int] = {} + for position, block in enumerate(sidecar.blocks): + if block.xhtml_xpath in index: + raise PatchApplicationError( + f"sidecar xpath가 중복되었습니다: {block.xhtml_xpath}" + ) + index[block.xhtml_xpath] = position + return index + + +def _validate_patch_targets( + patches: Iterable[dict[str, Any]], + xpath_index: dict[str, int], +) -> None: + for patch in patches: + action = patch.get("action", "modify") + if action == "insert": + anchor = patch.get("after_xpath") + if anchor is not None and _root_xpath(str(anchor)) not in xpath_index: + raise PatchApplicationError(f"insert anchor를 찾을 수 없습니다: {anchor}") + if "new_element_xhtml" not in patch: + raise PatchApplicationError("insert patch에 new_element_xhtml이 없습니다") + continue + target = patch.get("xhtml_xpath") + if not target or _root_xpath(str(target)) not in xpath_index: + raise PatchApplicationError(f"patch target을 찾을 수 없습니다: {target}") + + +def patch_xhtml_preserving( + base_xhtml: str, + patches: list[dict[str, Any]], + sidecar: RoundtripSidecar, +) -> str: + """변경된 top-level fragment만 DOM patch하고 나머지 bytes를 유지한다.""" + if sidecar.reassemble_xhtml() != base_xhtml: + raise PatchApplicationError("sidecar가 base XHTML과 byte-equal하지 않습니다") + + xpath_index = _index_by_xpath(sidecar) + _validate_patch_targets(patches, xpath_index) + + fragment_patches: dict[int, list[dict[str, Any]]] = {} + insert_before: list[str] = [] + insert_after: dict[int, list[str]] = {} + + for patch in patches: + action = patch.get("action", "modify") + if action == "insert": + new_fragment = str(patch["new_element_xhtml"]) + anchor = patch.get("after_xpath") + if anchor is None: + insert_before.append(new_fragment) + else: + position = xpath_index[_root_xpath(str(anchor))] + insert_after.setdefault(position, []).append(new_fragment) + continue + + target = str(patch["xhtml_xpath"]) + position = xpath_index[_root_xpath(target)] + fragment_patches.setdefault(position, []).append(_rebase_patch(patch)) + + parts = [sidecar.document_envelope.prefix] + parts.extend(insert_before) + for position, block in enumerate(sidecar.blocks): + local_patches = fragment_patches.get(position) + if local_patches: + rendered = patch_xhtml(block.xhtml_fragment, local_patches) + parts.append(rendered) + else: + parts.append(block.xhtml_fragment) + parts.extend(insert_after.get(position, ())) + if position < len(sidecar.separators): + parts.append(sidecar.separators[position]) + parts.append(sidecar.document_envelope.suffix) + return "".join(parts) + + +def changed_root_xpaths(patches: Iterable[dict[str, Any]]) -> frozenset[str]: + """기존 base fragment 중 mutation 대상인 top-level xpath를 반환한다.""" + roots = { + _root_xpath(str(patch["xhtml_xpath"])) + for patch in patches + if patch.get("action", "modify") != "insert" and patch.get("xhtml_xpath") + } + return frozenset(roots) diff --git a/confluence-mdx/bin/reverse_sync/proof.py b/confluence-mdx/bin/reverse_sync/proof.py new file mode 100644 index 000000000..ac9fc3edf --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/proof.py @@ -0,0 +1,265 @@ +"""reverse-sync candidate의 strict local proof gate orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import html.entities +import json +import re +from typing import Any, Iterable +from xml.etree import ElementTree + +from reverse_sync.equivalence import EquivalenceResult, verify_push_equivalence +from reverse_sync.models import SyncStatus, VerificationGate, sha256_text +from reverse_sync.preserving_patcher import changed_root_xpaths +from reverse_sync.sidecar import RoundtripSidecar + + +REQUIRED_LOCAL_GATES = ( + "source_identity", + "base_parity", + "intent_complete", + "artifact_integrity", + "storage_well_formed", + "preservation", + "semantic_roundtrip", + "determinism", + "idempotency", + "dependency", +) + +_XML_ENTITIES = frozenset({"amp", "lt", "gt", "apos", "quot"}) +_NAMED_ENTITY = re.compile(r"&([A-Za-z][A-Za-z0-9]+);") +_XML_NAMESPACES = ( + 'xmlns:ac="urn:atlassian-confluence:ac" ' + 'xmlns:ri="urn:atlassian-confluence:ri"' +) + + +@dataclass(frozen=True) +class LocalProof: + """manifest에 기록할 local proof 결과.""" + + status: str + push_eligible: bool + gates: tuple[VerificationGate, ...] + equivalence: EquivalenceResult + base_sha256: str + candidate_sha256: str + plan_sha256: str + blocked_reasons: tuple[str, ...] = () + + def to_dict(self) -> dict[str, Any]: + return { + "artifacts": { + "base_sha256": self.base_sha256, + "candidate_sha256": self.candidate_sha256, + "plan_sha256": self.plan_sha256, + }, + "blocked_reasons": list(self.blocked_reasons), + "equivalence": self.equivalence.to_dict(), + "gates": [gate.to_dict() for gate in self.gates], + "push_eligible": self.push_eligible, + "status": self.status, + } + + def to_canonical_json(self) -> str: + return json.dumps( + self.to_dict(), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + "\n" + + +def _replace_named_entity(match: re.Match[str]) -> str: + name = match.group(1) + if name in _XML_ENTITIES: + return match.group(0) + value = html.entities.html5.get(name + ";") + if value is None: + return match.group(0) + return "".join(f"&#{ord(character)};" for character in value) + + +def verify_storage_well_formed(xhtml: str) -> tuple[bool, str]: + """Confluence prefix를 namespace로 선언한 XML fragment parse를 수행한다.""" + xml_compatible = _NAMED_ENTITY.sub(_replace_named_entity, xhtml) + wrapped = f"{xml_compatible}" + try: + ElementTree.fromstring(wrapped) + except ElementTree.ParseError as exc: + return False, str(exc) + return True, "" + + +def _root_index(sidecar: RoundtripSidecar) -> dict[str, int]: + return {block.xhtml_xpath: index for index, block in enumerate(sidecar.blocks)} + + +def verify_preservation( + *, + base_xhtml: str, + candidate_xhtml: str, + sidecar: RoundtripSidecar, + patches: Iterable[dict[str, Any]], +) -> tuple[bool, str]: + """unchanged fragment, separator, document envelope의 byte 보존을 검증한다.""" + if sidecar.reassemble_xhtml() != base_xhtml: + return False, "base sidecar integrity가 일치하지 않습니다" + + index = _root_index(sidecar) + changed = changed_root_xpaths(patches) + unknown = sorted(changed - set(index)) + if unknown: + return False, "unknown changed fragment: " + ", ".join(unknown) + + insert_before = False + insert_after: set[int] = set() + for patch in patches: + if patch.get("action", "modify") != "insert": + continue + anchor = patch.get("after_xpath") + if anchor is None: + insert_before = True + continue + root = str(anchor).split("/", 1)[0] + if root not in index: + return False, f"unknown insert anchor: {root}" + insert_after.add(index[root]) + + # renderer가 변경할 수 있는 영역만 wildcard로 두고, 나머지 source bytes를 + # 모두 exact match하는 anchored pattern을 만든다. + pattern: list[str] = [re.escape(sidecar.document_envelope.prefix)] + if insert_before: + pattern.append(".*?") + for position, block in enumerate(sidecar.blocks): + if block.xhtml_xpath in changed: + pattern.append(".*?") + else: + pattern.append(re.escape(block.xhtml_fragment)) + if position in insert_after: + pattern.append(".*?") + if position < len(sidecar.separators): + pattern.append(re.escape(sidecar.separators[position])) + pattern.append(re.escape(sidecar.document_envelope.suffix)) + + if re.fullmatch("".join(pattern), candidate_xhtml, flags=re.DOTALL) is None: + return False, "unchanged fragment, separator 또는 document envelope bytes가 바뀌었습니다" + return True, "" + + +def canonical_plan_json( + *, + changes: Iterable[Any], + patches: Iterable[dict[str, Any]], + skipped_changes: Iterable[dict[str, Any]], +) -> str: + """legacy patch builder output을 immutable plan boundary로 직렬화한다.""" + change_items = [] + for ordinal, change in enumerate(changes): + old_block = getattr(change, "old_block", None) + new_block = getattr(change, "new_block", None) + change_items.append( + { + "change_type": str(getattr(change, "change_type", "")), + "index": int(getattr(change, "index", ordinal)), + "new_sha256": sha256_text(new_block.content) if new_block else "", + "old_sha256": sha256_text(old_block.content) if old_block else "", + } + ) + value = { + "adapter": "legacy-patch-builder-v1", + "changes": change_items, + "operations": list(patches), + "schema_version": 1, + "skipped_changes": list(skipped_changes), + } + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + "\n" + + +def build_local_proof( + *, + base_xhtml: str, + improved_mdx: str, + roundtrip_mdx: str, + candidate_xhtml: str, + sidecar: RoundtripSidecar, + changes: Iterable[Any], + patches: list[dict[str, Any]], + skipped_changes: list[dict[str, Any]], + plan_json: str, + deterministic_plan_json: str, + deterministic_candidate_xhtml: str, + idempotent_candidate_xhtml: str, + source_identity_passed: bool, + base_parity_passed: bool, + dependency_passed: bool, +) -> LocalProof: + """모든 required local gate를 독립적으로 계산한다.""" + equivalence = verify_push_equivalence(improved_mdx, roundtrip_mdx) + well_formed, well_formed_detail = verify_storage_well_formed(candidate_xhtml) + preservation, preservation_detail = verify_preservation( + base_xhtml=base_xhtml, + candidate_xhtml=candidate_xhtml, + sidecar=sidecar, + patches=patches, + ) + changes_tuple = tuple(changes) + intent_complete = ( + bool(changes_tuple) + and not skipped_changes + and bool(patches) + ) + determinism = ( + plan_json == deterministic_plan_json + and candidate_xhtml == deterministic_candidate_xhtml + ) + idempotency = candidate_xhtml == idempotent_candidate_xhtml + + gate_values = ( + ("source_identity", source_identity_passed, "page_identity_mismatch"), + ("base_parity", base_parity_passed, "base_parity_mismatch"), + ("intent_complete", intent_complete, "incomplete_patch_plan"), + ("artifact_integrity", True, ""), + ("storage_well_formed", well_formed, "invalid_storage_xhtml"), + ("preservation", preservation, "preservation_mismatch"), + ( + "semantic_roundtrip", + equivalence.passed, + "semantic_roundtrip_mismatch", + ), + ("determinism", determinism, "non_deterministic_output"), + ("idempotency", idempotency, "non_idempotent_output"), + ("dependency", dependency_passed, "dependency_failure"), + ) + gates = tuple( + VerificationGate(name=name, passed=passed, reason_code="" if passed else reason) + for name, passed, reason in gate_values + ) + blocked_reasons = tuple( + gate.reason_code for gate in gates if not gate.passed and gate.reason_code + ) + # Parse/preservation detail은 canonical evidence에서 유실되지 않도록 + # equivalence diff 뒤에 diagnostic block으로 붙이지 않고 reason별로 유지한다. + if not well_formed and well_formed_detail: + blocked_reasons += (f"invalid_storage_xhtml:{well_formed_detail}",) + if not preservation and preservation_detail: + blocked_reasons += (f"preservation_mismatch:{preservation_detail}",) + + push_eligible = not blocked_reasons and all(gate.passed for gate in gates) + return LocalProof( + status=SyncStatus.VERIFIED_LOCAL.value if push_eligible else "blocked", + push_eligible=push_eligible, + gates=gates, + equivalence=equivalence, + base_sha256=sha256_text(base_xhtml), + candidate_sha256=sha256_text(candidate_xhtml), + plan_sha256=sha256_text(plan_json), + blocked_reasons=blocked_reasons, + ) diff --git a/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py b/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py index 53ca17c3f..ab054d046 100644 --- a/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py +++ b/confluence-mdx/bin/reverse_sync/roundtrip_verifier.py @@ -1,10 +1,45 @@ """Roundtrip Verifier — 패치된 XHTML의 forward 변환 결과와 개선 MDX의 완전 일치를 검증한다.""" from dataclasses import dataclass import difflib +from enum import Enum import html as html_module import re +class NormalizationClass(str, Enum): + """diagnostic normalization이 숨길 수 있는 차이의 성격.""" + + SOURCE_FORMATTING = "source_formatting" + RENDERED_VISIBLE = "rendered_visible" + UNSUPPORTED_LOSSY = "unsupported_lossy" + + +@dataclass(frozen=True) +class NormalizationRule: + name: str + classification: NormalizationClass + + +NORMALIZATION_RULES = ( + NormalizationRule("table_cell_padding", NormalizationClass.SOURCE_FORMATTING), + NormalizationRule("leading_blank_lines", NormalizationClass.SOURCE_FORMATTING), + NormalizationRule("trailing_blank_lines", NormalizationClass.SOURCE_FORMATTING), + NormalizationRule("consecutive_spaces", NormalizationClass.RENDERED_VISIBLE), + NormalizationRule("trailing_whitespace", NormalizationClass.RENDERED_VISIBLE), + NormalizationRule("br_space", NormalizationClass.RENDERED_VISIBLE), + NormalizationRule("link_text_spacing", NormalizationClass.RENDERED_VISIBLE), + NormalizationRule("empty_bold", NormalizationClass.RENDERED_VISIBLE), + NormalizationRule("empty_list_items", NormalizationClass.RENDERED_VISIBLE), + NormalizationRule("title_removal", NormalizationClass.UNSUPPORTED_LOSSY), + NormalizationRule("sentence_break_merge", NormalizationClass.UNSUPPORTED_LOSSY), + NormalizationRule("date_reformat", NormalizationClass.UNSUPPORTED_LOSSY), + NormalizationRule("table_cell_line_merge", NormalizationClass.UNSUPPORTED_LOSSY), + NormalizationRule("inline_code_boundary", NormalizationClass.UNSUPPORTED_LOSSY), + NormalizationRule("html_entities_in_code", NormalizationClass.UNSUPPORTED_LOSSY), + NormalizationRule("smart_quotes", NormalizationClass.UNSUPPORTED_LOSSY), +) + + @dataclass class VerifyResult: passed: bool diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py index 7dd2f78a2..472110978 100755 --- a/confluence-mdx/bin/reverse_sync_cli.py +++ b/confluence-mdx/bin/reverse_sync_cli.py @@ -30,10 +30,14 @@ from reverse_sync.xhtml_patcher import patch_xhtml from reverse_sync.roundtrip_verifier import verify_roundtrip from reverse_sync.patch_builder import build_patches +from reverse_sync.equivalence import ( + PUSH_EQUIVALENCE_POLICY, + verify_push_equivalence, +) from xhtml_beautify_diff import xhtml_diff -_PUSH_VERIFIER_POLICY = "reverse-sync-push-v1" -_TOOL_VERSION = "reverse-sync-cli-v1" +_PUSH_VERIFIER_POLICY = PUSH_EQUIVALENCE_POLICY +_TOOL_VERSION = "reverse-sync-cli-v2" @dataclass @@ -171,6 +175,7 @@ def _forward_convert(patched_xhtml_path: str, output_mdx_path: str, page_id: str def _clean_reverse_sync_artifacts(page_id: str) -> Path: """var// 내의 이전 reverse-sync 산출물을 정리하고 var_dir을 반환한다.""" var_dir = _PROJECT_DIR / 'var' / page_id + var_dir.mkdir(parents=True, exist_ok=True) for f in var_dir.glob('reverse-sync.*'): if f.name == 'reverse-sync.backup.xhtml': continue @@ -254,6 +259,12 @@ def _compile_result( 'diff_report': roundtrip_diff_report, }, } + if isinstance(getattr(verify_result, "policy", None), str): + result["verification"].update( + policy=verify_result.policy, + expected_sha256=verify_result.expected_sha256, + actual_sha256=verify_result.actual_sha256, + ) if title: result['title'] = title if skipped_changes: @@ -385,15 +396,6 @@ def run_verify( "invalid_page_snapshot", detail="online push에는 remote PageSnapshot이 필요합니다.", ) - if for_push and lenient: - return _blocked_result( - var_dir, - page_id, - now, - "lenient_verification_not_pushable", - detail="--lenient 결과는 진단용이며 push proof로 사용할 수 없습니다.", - ) - if base_snapshot is not None: from reverse_sync.base_parity import ( verify_attachment_dependencies, @@ -512,7 +514,16 @@ def run_verify( } (var_dir / 'reverse-sync.mapping.original.yaml').write_text( yaml.dump(original_mapping_data, allow_unicode=True, default_flow_style=False)) - patched_xhtml = patch_xhtml(xhtml, patches) + if for_push: + from reverse_sync.preserving_patcher import patch_xhtml_preserving + + patched_xhtml = patch_xhtml_preserving( + xhtml, + patches, + roundtrip_sidecar, + ) + else: + patched_xhtml = patch_xhtml(xhtml, patches) (var_dir / 'reverse-sync.patched.xhtml').write_text(patched_xhtml) # XHTML beautify-diff (page.xhtml → patched.xhtml) @@ -577,12 +588,15 @@ def run_verify( # Step 7: 완전 일치 검증 → result.yaml 저장 verify_stripped = _strip_frontmatter(verify_mdx) - verify_result = verify_roundtrip( - expected_mdx=impr_stripped, - actual_mdx=verify_stripped, - lenient=lenient, - no_normalize=True if for_push else no_normalize, - ) + if for_push: + verify_result = verify_push_equivalence(impr_stripped, verify_stripped) + else: + verify_result = verify_roundtrip( + expected_mdx=impr_stripped, + actual_mdx=verify_stripped, + lenient=lenient, + no_normalize=no_normalize, + ) # Roundtrip diff (improved → verify): PASS/FAIL 무관하게 항상 생성 roundtrip_diff_lines = difflib.unified_diff( impr_stripped.splitlines(keepends=True), @@ -599,15 +613,139 @@ def run_verify( verify_result, roundtrip_diff_report, title=title, skipped_changes=skipped_changes) - if for_push and result["status"] == "pass" and skipped_changes: + if for_push: + from reverse_sync.manifest import create_sync_manifest + from reverse_sync.models import sha256_text + from reverse_sync.preserving_patcher import patch_xhtml_preserving + from reverse_sync.proof import build_local_proof, canonical_plan_json + + plan_json = canonical_plan_json( + changes=changes, + patches=patches, + skipped_changes=skipped_changes, + ) + (var_dir / "reverse-sync.plan.json").write_text(plan_json) + + deterministic_patches, _, deterministic_skips = build_patches( + changes, + original_blocks, + improved_blocks, + page_xhtml=xhtml, + alignment=alignment, + page_lost_info=page_lost_info, + roundtrip_sidecar=roundtrip_sidecar, + ) + deterministic_plan_json = canonical_plan_json( + changes=changes, + patches=deterministic_patches, + skipped_changes=deterministic_skips, + ) + deterministic_candidate = patch_xhtml_preserving( + xhtml, + deterministic_patches, + roundtrip_sidecar, + ) + + try: + candidate_sidecar = build_sidecar( + patched_xhtml, + verify_mdx, + page_id=page_id, + ) + ( + idempotency_changes, + idempotency_alignment, + idempotency_original_blocks, + idempotency_improved_blocks, + ) = _parse_and_diff(verify_mdx, improved_mdx) + if not idempotency_changes: + idempotent_candidate = patched_xhtml + else: + ( + idempotency_patches, + _, + idempotency_skips, + ) = build_patches( + idempotency_changes, + idempotency_original_blocks, + idempotency_improved_blocks, + page_xhtml=patched_xhtml, + alignment=idempotency_alignment, + page_lost_info=page_lost_info, + roundtrip_sidecar=candidate_sidecar, + ) + idempotent_candidate = ( + "" + if idempotency_skips + else patch_xhtml_preserving( + patched_xhtml, + idempotency_patches, + candidate_sidecar, + ) + ) + except (ValueError, KeyError): + idempotent_candidate = "" + + proof = build_local_proof( + base_xhtml=xhtml, + improved_mdx=improved_mdx, + roundtrip_mdx=verify_mdx, + candidate_xhtml=patched_xhtml, + sidecar=roundtrip_sidecar, + changes=changes, + patches=patches, + skipped_changes=skipped_changes, + plan_json=plan_json, + deterministic_plan_json=deterministic_plan_json, + deterministic_candidate_xhtml=deterministic_candidate, + idempotent_candidate_xhtml=idempotent_candidate, + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + proof_json = proof.to_canonical_json() + (var_dir / "reverse-sync.proof.json").write_text(proof_json) + + diagnostics = {} + if lenient: + diagnostic = verify_roundtrip( + expected_mdx=impr_stripped, + actual_mdx=verify_stripped, + lenient=True, + ) + diagnostics["lenient"] = { + "passed": diagnostic.passed, + "diff_report": diagnostic.diff_report, + "push_eligible": False, + } + if no_normalize: + diagnostic = verify_roundtrip( + expected_mdx=impr_stripped, + actual_mdx=verify_stripped, + no_normalize=True, + ) + diagnostics["raw"] = { + "passed": diagnostic.passed, + "diff_report": diagnostic.diff_report, + "push_eligible": False, + } + result.update( - status="blocked", - push_eligible=False, - reason_code="incomplete_patch_plan", + status=proof.status, + push_eligible=proof.push_eligible, + reason_code=proof.blocked_reasons[0] if proof.blocked_reasons else "", + blocked_reasons=list(proof.blocked_reasons), + local_gates=[gate.to_dict() for gate in proof.gates], + verification=proof.equivalence.to_dict(), ) - elif for_push and result["status"] == "pass": - from reverse_sync.manifest import create_sync_manifest - from reverse_sync.models import VerificationGate, sha256_text + if diagnostics: + result["diagnostics"] = diagnostics + + if not proof.push_eligible: + (var_dir / "reverse-sync.result.yaml").write_text( + yaml.dump(result, allow_unicode=True, default_flow_style=False) + ) + return result manifest_path = create_sync_manifest( runs_dir=var_dir / "reverse-sync", @@ -616,17 +754,13 @@ def run_verify( original_descriptor=original_src.descriptor, improved_mdx=improved_mdx, improved_descriptor=improved_src.descriptor, + patch_plan=plan_json, candidate_xhtml=patched_xhtml, + local_proof=proof_json, verifier_policy=_PUSH_VERIFIER_POLICY, tool_version=_TOOL_VERSION, push_eligible=True, - gates=( - VerificationGate("source_identity", True), - VerificationGate("base_parity", True), - VerificationGate("intent_complete", True), - VerificationGate("semantic_roundtrip", True), - VerificationGate("artifact_integrity", True), - ), + gates=proof.gates, ) result.update( push_eligible=True, @@ -635,13 +769,6 @@ def run_verify( base_version=base_snapshot.version, base_storage_sha256=base_snapshot.storage_sha256, candidate_sha256=sha256_text(patched_xhtml), - local_gates=[ - "source_identity", - "base_parity", - "intent_complete", - "semantic_roundtrip", - "artifact_integrity", - ], ) (var_dir / "reverse-sync.manifest.path").write_text(str(manifest_path) + "\n") @@ -694,6 +821,11 @@ def _display_status(result: Dict[str, Any]) -> str: return result.get('status', 'unknown') +def _is_success_status(status: str) -> bool: + """offline diagnostic pass와 online verified_local을 성공으로 분류한다.""" + return status in ("pass", "verified_local", "no_changes") + + def _display_error(result: Dict[str, Any], status: str) -> str: """출력용 에러 메시지를 반환한다.""" if status in ('push_conflict', 'push_error', 'push_postcondition_failed'): @@ -718,13 +850,15 @@ def c(code: str, text: str) -> str: for r in results: status = _display_status(r) - if failures_only and status in ('pass', 'no_changes'): + if failures_only and _is_success_status(status): continue file_path = r.get('file', r.get('page_id', '?')) changes = r.get('changes_count', 0) # 상태별 컬러 배지 - if status == 'pass': + if status == 'verified_local': + badge = c(GREEN, 'VERIFIED LOCAL') + elif status == 'pass': badge = c(GREEN, 'PASS') elif status == 'no_changes': badge = c(DIM, 'NO CHANGES') @@ -736,6 +870,8 @@ def c(code: str, text: str) -> str: badge = c(RED, 'POSTCONDITION FAILED') elif status == 'error': badge = c(YELLOW, 'ERROR') + elif status == 'blocked': + badge = c(RED, 'BLOCKED') else: badge = c(RED, 'FAIL') @@ -750,6 +886,9 @@ def c(code: str, text: str) -> str: ): print(f' {c(RED, _display_error(r, status))}') continue + if status == "blocked": + reason_code = r.get("reason_code", "unknown") + print(f' {c(RED, f"reason: {reason_code}")}') if show_all_diffs: # MDX diff (original → improved) @@ -792,6 +931,9 @@ def c(code: str, text: str) -> str: total = len(results) display_statuses = [_display_status(r) for r in results] passed = sum(1 for status in display_statuses if status == 'pass') + verified_local = sum( + 1 for status in display_statuses if status == 'verified_local' + ) failed = sum(1 for status in display_statuses if status == 'fail') errors = sum(1 for status in display_statuses if status == 'error') conflicts = sum(1 for status in display_statuses if status == 'push_conflict') @@ -804,6 +946,8 @@ def c(code: str, text: str) -> str: parts = [] if passed: parts.append(c(GREEN, f'{passed} passed')) + if verified_local: + parts.append(c(GREEN, f'{verified_local} verified local')) if failed: parts.append(c(RED, f'{failed} failed')) if errors: @@ -862,7 +1006,7 @@ def c(code: str, text: str) -> str: --lenient 관대 모드: trailing whitespace, 날짜 형식 등 XHTML↔MDX 변환기 한계에 의한 차이를 기본 비교보다 더 넓게 정규화한다. - 진단 전용이며 push에는 사용할 수 없다. + 진단 결과만 추가하며 online push eligibility에는 영향을 주지 않는다. Examples: # 단일 파일 검증 @@ -1014,7 +1158,7 @@ def _do_verify_batch(branch: str, limit: int = 0, failures_only: bool = False, prepare_push: bool = False) -> List[dict]: """브랜치의 변경 ko MDX 파일을 배치 처리한다. - push=True이면 verify 전체 완료 후 pass 건만 일괄 push한다. + push=True이면 online verify 전체 완료 후 verified_local 건만 일괄 push한다. yes=True이면 확인 프롬프트를 스킵한다. lenient=True이면 변경된 행만 검사하는 관대 모드로 검증한다. """ @@ -1046,12 +1190,11 @@ def _do_verify_batch(branch: str, limit: int = 0, failures_only: bool = False, ) else: result = _do_verify(args) - if online and result.get("status") == "pass" and not result.get( - "push_eligible" - ): + if online and result.get("status") == "pass": result.update( status="blocked", - reason_code="not_push_eligible", + push_eligible=False, + reason_code="diagnostic_result_not_pushable", ) result['file'] = ko_path status = result.get('status', 'unknown') @@ -1060,7 +1203,7 @@ def _do_verify_batch(branch: str, limit: int = 0, failures_only: bool = False, except Exception as e: print("error", file=sys.stderr) results.append({'file': ko_path, 'status': 'error', 'error': str(e)}) - if results[-1].get('status') not in ('pass', 'no_changes'): + if not _is_success_status(results[-1].get('status', 'unknown')): failure_count += 1 if not push and failures_only and limit > 0 and failure_count >= limit: break @@ -1071,15 +1214,20 @@ def _do_verify_batch(branch: str, limit: int = 0, failures_only: bool = False, # push 대상 집계 pushable = [ r for r in results - if r.get('status') == 'pass' and r.get('push_eligible') is True + if r.get('status') == 'verified_local' + and r.get('push_eligible') is True ] if not pushable: - print("\nPush 대상 없음 (pass 0건)", file=sys.stderr) + print("\nPush 대상 없음 (verified_local 0건)", file=sys.stderr) return results # 확인 프롬프트 if not yes: - print(f"\n검증 완료: pass {len(pushable)}건 / 전체 {len(results)}건", file=sys.stderr) + print( + f"\n검증 완료: verified_local {len(pushable)}건 / " + f"전체 {len(results)}건", + file=sys.stderr, + ) if not _confirm(f"{len(pushable)}건을 Confluence에 push 할까요? [y/N] "): print("Push 취소", file=sys.stderr) return results @@ -1175,10 +1323,9 @@ def semantic_verifier(snapshot, verified_manifest_path: Path) -> bool: identity = verify_source_identity(snapshot, expected_mdx, actual_mdx) if not identity.passed: return False - return verify_roundtrip( - expected_mdx=_strip_frontmatter(expected_mdx), - actual_mdx=_strip_frontmatter(actual_mdx), - no_normalize=True, + return verify_push_equivalence( + _strip_frontmatter(expected_mdx), + _strip_frontmatter(actual_mdx), ).passed try: @@ -1296,14 +1443,19 @@ def main(): output = results if failures_only: output = [r for r in results - if r.get('status') not in ('pass', 'no_changes') + if not _is_success_status( + r.get('status', 'unknown') + ) or r.get('push', {}).get('status') in ('conflict', 'error', 'postcondition_failed')] print(json.dumps(output, ensure_ascii=False, indent=2)) else: _print_results(results, show_all_diffs=show_all_diffs, failures_only=failures_only) - has_failure = any(r.get('status') not in ('pass', 'no_changes') for r in results) + has_failure = any( + not _is_success_status(r.get('status', 'unknown')) + for r in results + ) has_push_failure = any( r.get('push', {}).get('status') in ('conflict', 'error', 'postcondition_failed') @@ -1325,7 +1477,7 @@ def main(): _print_results([result], show_all_diffs=show_all_diffs) if (not dry_run - and result.get('status') == 'pass' + and result.get('status') == 'verified_local' and result.get('push_eligible') is True): page_id = result['page_id'] title = result.get('title', page_id) @@ -1359,17 +1511,21 @@ def main(): reason_code = getattr(e, "reason_code", "push_error") print(f"Error [{reason_code}]: {e}", file=sys.stderr) sys.exit(1) - elif not dry_run and result.get('status') == 'no_changes': - print("변경 사항이 없어 Confluence update를 생략합니다.", file=sys.stderr) - elif not dry_run and result.get('status') != 'pass': + elif result.get('status') == 'no_changes': + if not dry_run: + print( + "변경 사항이 없어 Confluence update를 생략합니다.", + file=sys.stderr, + ) + elif ( + args.command == "push" + and result.get('status') != 'verified_local' + ): print(f"Error: 검증 상태가 '{result.get('status')}'입니다. push하지 않습니다.", file=sys.stderr) sys.exit(1) - elif not dry_run: - print("Error: 검증 결과가 push eligible 상태가 아닙니다.", file=sys.stderr) - sys.exit(1) elif (args.command == "push" - and result.get("status") == "pass" + and result.get("status") == "verified_local" and result.get("push_eligible") is not True): print("Error: online verify 결과가 push eligible 상태가 아닙니다.", file=sys.stderr) diff --git a/confluence-mdx/tests/test_reverse_sync_cli.py b/confluence-mdx/tests/test_reverse_sync_cli.py index 4d6b99b87..fc0e49341 100644 --- a/confluence-mdx/tests/test_reverse_sync_cli.py +++ b/confluence-mdx/tests/test_reverse_sync_cli.py @@ -20,6 +20,7 @@ from reverse_sync.manifest import create_sync_manifest from reverse_sync.models import PageSnapshot, VerificationGate from reverse_sync.publisher import PostconditionError +from reverse_sync.proof import REQUIRED_LOCAL_GATES def _create_push_manifest( @@ -56,19 +57,15 @@ def _create_push_manifest( original_descriptor="main:src/content/ko/test.mdx", improved_mdx="# Test\n\nNew\n", improved_descriptor="src/content/ko/test.mdx", + patch_plan='{"schema_version":1}\n', candidate_xhtml=candidate_body, - verifier_policy="reverse-sync-push-v1", - tool_version="reverse-sync-cli-v1", + local_proof='{"status":"verified_local"}\n', + verifier_policy="reverse-sync-equivalence-v1", + tool_version="reverse-sync-cli-v2", push_eligible=True, gates=tuple( VerificationGate(name, True) - for name in ( - "source_identity", - "base_parity", - "intent_complete", - "semantic_roundtrip", - "artifact_integrity", - ) + for name in REQUIRED_LOCAL_GATES ), ) return manifest_path, base, persisted @@ -162,16 +159,16 @@ def test_push_verify_fail_exits(monkeypatch): assert exc_info.value.code == 1 -def test_push_verify_pass_then_pushes(tmp_path, monkeypatch): - """push --yes 시 verify pass → _do_push 호출.""" +def test_push_verified_local_then_pushes(tmp_path, monkeypatch): + """push --yes 시 verified_local → _do_push 호출.""" page_id = 'test-page-001' mdx_arg = 'src/content/ko/test/page.mdx' monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--yes', '--json', mdx_arg]) monkeypatch.setattr('reverse_sync_cli._PROJECT_DIR', tmp_path) manifest_path = tmp_path / "manifest.json" - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, @@ -184,7 +181,7 @@ def test_push_verify_pass_then_pushes(tmp_path, monkeypatch): "version": 6, } - with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result), \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli._do_push', return_value=push_result) as mock_push, \ patch('builtins.print') as mock_print: @@ -205,14 +202,14 @@ def test_push_dry_run_skips_push(monkeypatch): """push --dry-run은 verify만 수행하고 push하지 않는다.""" mdx_arg = 'src/content/ko/test/page.mdx' monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--dry-run', mdx_arg]) - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': 'test-page-001', 'changes_count': 1, 'push_eligible': True, } - with patch('reverse_sync_cli._do_verify', return_value=pass_result) as mock_verify, \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result) as mock_verify, \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli._do_push') as mock_push, \ patch('builtins.print'): @@ -250,6 +247,35 @@ def test_push_yes_does_not_bypass_push_eligibility(monkeypatch): push.assert_not_called() +def test_push_rejects_diagnostic_pass_even_if_marked_eligible(monkeypatch): + """online 발행은 기존 diagnostic pass 상태를 신뢰하지 않습니다.""" + mdx_arg = 'src/content/ko/test/page.mdx' + monkeypatch.setattr( + 'sys.argv', + ['reverse_sync_cli.py', 'push', '--yes', mdx_arg], + ) + diagnostic_result = { + 'status': 'pass', + 'page_id': 'test-page-001', + 'changes_count': 1, + 'push_eligible': True, + 'manifest_path': '/tmp/legacy-manifest.json', + } + + with patch( + 'reverse_sync_cli._do_verify', + return_value=diagnostic_result, + ), patch( + 'reverse_sync_cli._ensure_confluence_config', + return_value=MagicMock(), + ), patch('reverse_sync_cli._do_push') as push, patch('builtins.print'): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + push.assert_not_called() + + def test_push_no_changes_is_successful_noop(monkeypatch): mdx_arg = 'src/content/ko/test/page.mdx' monkeypatch.setattr( @@ -434,6 +460,36 @@ def test_do_verify_batch_all_pass(): assert all(r['status'] == 'pass' for r in results) +def test_do_verify_batch_online_rejects_diagnostic_pass(): + """online batch는 legacy diagnostic pass를 발행 후보로 승격하지 않습니다.""" + files = ['src/content/ko/a.mdx'] + diagnostic_result = { + 'status': 'pass', + 'page_id': 'p1', + 'changes_count': 1, + 'push_eligible': True, + } + + with patch( + 'reverse_sync_cli._get_changed_ko_mdx_files', + return_value=files, + ), patch( + 'reverse_sync_cli._do_verify', + return_value=diagnostic_result, + ), patch( + 'reverse_sync_cli._ensure_confluence_config', + return_value=MagicMock(), + ), patch('builtins.print'): + results = _do_verify_batch( + 'proofread/fix-typo', + prepare_push=True, + ) + + assert results[0]['status'] == 'blocked' + assert results[0]['push_eligible'] is False + assert results[0]['reason_code'] == 'diagnostic_result_not_pushable' + + def test_do_verify_batch_with_error(): """1파일 에러, 나머지 계속 처리.""" files = [ @@ -499,9 +555,9 @@ def test_main_push_branch(tmp_path, monkeypatch): monkeypatch.setattr('reverse_sync_cli._PROJECT_DIR', tmp_path) batch_results = [ - {'status': 'pass', 'page_id': 'p1', 'changes_count': 1, + {'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push': {'page_id': 'p1', 'title': 'T1', 'version': 2, 'url': '/t1'}}, - {'status': 'pass', 'page_id': 'p2', 'changes_count': 2, + {'status': 'verified_local', 'page_id': 'p2', 'changes_count': 2, 'push': {'page_id': 'p2', 'title': 'T2', 'version': 3, 'url': '/t2'}}, ] @@ -516,7 +572,7 @@ def test_main_push_branch_with_failure(monkeypatch): """배치 push 시 일부 fail → exit 1 (pass한 문서는 이미 push됨).""" monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--yes', '--branch', 'proofread/fix-typo']) batch_results = [ - {'status': 'pass', 'page_id': 'p1', 'changes_count': 1, + {'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push': {'page_id': 'p1', 'title': 'T', 'version': 2, 'url': '/t'}}, {'status': 'fail', 'page_id': 'p2', 'changes_count': 1}, ] @@ -1462,8 +1518,8 @@ def test_confirm_no_aborts_single(self, monkeypatch): """단일 push 시 확인 거부 → push 안 함.""" mdx_arg = 'src/content/ko/test/page.mdx' monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', mdx_arg]) - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': 'p1', 'title': 'Test', 'changes_count': 1, @@ -1473,7 +1529,7 @@ def test_confirm_no_aborts_single(self, monkeypatch): 'candidate_sha256': 'a' * 64, } - with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result), \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli.sys.stdin') as mock_stdin, \ patch('reverse_sync_cli._confirm', return_value=False) as confirm, \ @@ -1501,15 +1557,15 @@ def test_yes_flag_skips_confirm_single(self, tmp_path, monkeypatch): var_dir.mkdir(parents=True) (var_dir / 'reverse-sync.patched.xhtml').write_text('

New

') - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, 'manifest_path': '/tmp/manifest.json', } - with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result), \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli._do_push', return_value={ 'page_id': page_id, 'title': 'Test', 'version': 6, @@ -1524,7 +1580,7 @@ def test_batch_confirm_no_aborts(self, monkeypatch): """배치 push 시 확인 거부 → push 안 함.""" files = ['src/content/ko/a.mdx'] verify_result = { - 'status': 'pass', + 'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push_eligible': True, @@ -1552,7 +1608,7 @@ def test_batch_yes_skips_confirm(self, tmp_path, monkeypatch): files = ['src/content/ko/a.mdx'] verify_result = { - 'status': 'pass', + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, @@ -1577,14 +1633,14 @@ def test_batch_stops_after_postcondition_failure(self, monkeypatch): files = ['src/content/ko/a.mdx', 'src/content/ko/b.mdx'] verify_results = [ { - 'status': 'pass', + 'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push_eligible': True, 'manifest_path': '/tmp/p1/manifest.json', }, { - 'status': 'pass', + 'status': 'verified_local', 'page_id': 'p2', 'changes_count': 1, 'push_eligible': True, @@ -1636,15 +1692,15 @@ def test_non_tty_with_yes_proceeds(self, tmp_path, monkeypatch): var_dir.mkdir(parents=True) (var_dir / 'reverse-sync.patched.xhtml').write_text('

New

') - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, 'manifest_path': '/tmp/manifest.json', } - with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result), \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli._do_push', return_value={ 'page_id': page_id, 'title': 'Test', 'version': 6, @@ -1660,7 +1716,7 @@ def test_batch_push_conflict_exits_nonzero(self, monkeypatch): """배치에서 push conflict 발생 시 exit 1.""" monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--branch', 'b', '--yes']) batch_results = [ - {'status': 'pass', 'page_id': 'p1', 'changes_count': 1, + {'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push': {'status': 'conflict', 'error': 'conflict'}}, ] @@ -1675,7 +1731,7 @@ def test_batch_push_all_success_exits_zero(self, monkeypatch): """배치에서 모든 push 성공 시 exit 0.""" monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--branch', 'b', '--yes']) batch_results = [ - {'status': 'pass', 'page_id': 'p1', 'changes_count': 1, + {'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push': {'page_id': 'p1', 'title': 'T', 'version': 2, 'url': '/t'}}, ] @@ -1687,6 +1743,45 @@ def test_batch_push_all_success_exits_zero(self, monkeypatch): class TestPrintResultsPushStatus: """텍스트 출력이 push 실패 상태를 반영하는지 확인.""" + def test_verified_local_is_distinct_from_diagnostic_pass( + self, monkeypatch, capsys + ): + monkeypatch.setattr('reverse_sync_cli._supports_color', lambda: False) + + _print_results([ + { + 'file': 'src/content/ko/verified.mdx', + 'status': 'verified_local', + 'changes_count': 1, + }, + { + 'file': 'src/content/ko/diagnostic.mdx', + 'status': 'pass', + 'changes_count': 1, + }, + ]) + + out = capsys.readouterr().out + assert 'VERIFIED LOCAL' in out + assert '1 verified local' in out + assert '1 passed' in out + + def test_blocked_result_prints_reason_code(self, monkeypatch, capsys): + monkeypatch.setattr('reverse_sync_cli._supports_color', lambda: False) + + _print_results([ + { + 'file': 'src/content/ko/blocked.mdx', + 'status': 'blocked', + 'reason_code': 'semantic_roundtrip_mismatch', + 'changes_count': 1, + } + ]) + + out = capsys.readouterr().out + assert 'BLOCKED' in out + assert 'reason: semantic_roundtrip_mismatch' in out + def test_failures_only_shows_push_conflict(self, monkeypatch, capsys): monkeypatch.setattr('reverse_sync_cli._supports_color', lambda: False) diff --git a/confluence-mdx/tests/test_reverse_sync_equivalence.py b/confluence-mdx/tests/test_reverse_sync_equivalence.py new file mode 100644 index 000000000..32450a768 --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_equivalence.py @@ -0,0 +1,290 @@ +"""typed push equivalence와 strict local proof 계약 테스트.""" + +from dataclasses import dataclass + +from reverse_sync.equivalence import ( + PUSH_EQUIVALENCE_POLICY, + canonicalize_mdx, + verify_push_equivalence, +) +from reverse_sync.preserving_patcher import ( + PatchApplicationError, + patch_xhtml_preserving, +) +from reverse_sync.proof import ( + REQUIRED_LOCAL_GATES, + build_local_proof, + canonical_plan_json, + verify_storage_well_formed, +) +from reverse_sync.sidecar import build_sidecar + + +def test_typed_equivalence_allows_only_markdown_table_source_padding(): + expected = ( + "# Title\n\n" + "| Name | Value |\n" + "| :--------- | ----: |\n" + "| QueryPie | 42 |\n" + ) + actual = ( + "# Title\n\n" + "| Name | Value |\n" + "| :--- | ---: |\n" + "| QueryPie | 42 |\n" + ) + + result = verify_push_equivalence(expected, actual) + + assert result.passed is True + assert result.policy == PUSH_EQUIVALENCE_POLICY + assert result.expected_sha256 == result.actual_sha256 + + +def test_typed_equivalence_preserves_table_alignment_and_cell_content(): + expected = "| Name | Value |\n| :--- | ---: |\n| QueryPie | 42 |\n" + changed_alignment = "| Name | Value |\n| --- | ---: |\n| QueryPie | 42 |\n" + changed_content = "| Name | Value |\n| :--- | ---: |\n| QueryPie | 43 |\n" + + assert verify_push_equivalence(expected, changed_alignment).passed is False + assert verify_push_equivalence(expected, changed_content).passed is False + + +def test_typed_equivalence_does_not_hide_visible_whitespace_or_title(): + assert verify_push_equivalence("Text here\n", "Text here\n").passed is False + assert verify_push_equivalence("# A\n\nText\n", "# B\n\nText\n").passed is False + assert verify_push_equivalence("- item\n", "- item\n").passed is False + + +def test_typed_equivalence_preserves_link_target_and_attachment_filename(): + expected = "[Guide](/guide) ![screen](./image.png)\n" + changed_link = "[Guide](/other) ![screen](./image.png)\n" + changed_attachment = "[Guide](/guide) ![screen](./other.png)\n" + + assert verify_push_equivalence(expected, changed_link).passed is False + assert verify_push_equivalence(expected, changed_attachment).passed is False + + model = canonicalize_mdx(expected).to_dict() + tokens = model["blocks"][0]["tokens"] + assert tokens[0]["kind"] == "link" + assert tokens[0]["target"] == "/guide" + assert tokens[2]["attachment_filename"] == "image.png" + + +def test_preserving_patcher_keeps_untouched_entity_bytes(): + base = '

Before

“untouched”

' + original_mdx = "# Title\n\nBefore\n\n“untouched”\n" + sidecar = build_sidecar(base, original_mdx, page_id="123") + patches = [ + { + "xhtml_xpath": "p[1]", + "old_plain_text": "Before", + "new_plain_text": "After", + } + ] + + candidate = patch_xhtml_preserving(base, patches, sidecar) + + assert candidate == '

After

“untouched”

' + + +def test_preserving_patcher_fails_when_exact_target_is_missing(): + base = "

Before

" + sidecar = build_sidecar(base, "# Title\n\nBefore\n", page_id="123") + + try: + patch_xhtml_preserving( + base, + [{"xhtml_xpath": "p[2]", "old_plain_text": "Before", "new_plain_text": "After"}], + sidecar, + ) + except PatchApplicationError as exc: + assert exc.reason_code == "missing_identity" + else: + raise AssertionError("missing target이 block되지 않았습니다") + + +def test_preserving_patcher_keeps_envelope_and_separators_across_insert_delete(): + base = " \n

First

\n\n

Second

\n " + original_mdx = "# Title\n\nFirst\n\nSecond\n" + sidecar = build_sidecar(base, original_mdx, page_id="123") + patches = [ + {"action": "delete", "xhtml_xpath": "p[1]"}, + { + "action": "insert", + "after_xpath": "p[2]", + "new_element_xhtml": "

Third

", + }, + ] + + candidate = patch_xhtml_preserving(base, patches, sidecar) + + assert candidate == " \n\n\n

Second

Third

\n " + + +def test_preserving_patcher_rebases_nested_macro_xpath(): + base = ( + '' + "

Before

" + "
" + "

“untouched”

" + ) + original_mdx = ( + "# Title\n\n" + '\nBefore\n\n\n' + "“untouched”\n" + ) + sidecar = build_sidecar(base, original_mdx, page_id="123") + patches = [ + { + "xhtml_xpath": "macro-info[1]/p[1]", + "old_plain_text": "Before", + "new_plain_text": "After", + } + ] + + candidate = patch_xhtml_preserving(base, patches, sidecar) + + assert "

After

" in candidate + assert candidate.endswith("

“untouched”

") + + +def test_storage_well_formed_supports_confluence_namespaces_and_html_entities(): + valid = ( + '' + "

“text”

" + ) + invalid = "

unclosed" + + assert verify_storage_well_formed(valid) == (True, "") + passed, detail = verify_storage_well_formed(invalid) + assert passed is False + assert detail + + +@dataclass +class _Block: + content: str + + +@dataclass +class _Change: + index: int + change_type: str + old_block: _Block | None + new_block: _Block | None + + +def _proof_inputs(): + base = '

Before

“untouched”

' + improved = "# Title\n\nAfter\n\n“untouched”\n" + roundtrip = improved + original = "# Title\n\nBefore\n\n“untouched”\n" + sidecar = build_sidecar(base, original, page_id="123") + patches = [ + { + "xhtml_xpath": "p[1]", + "old_plain_text": "Before", + "new_plain_text": "After", + } + ] + candidate = patch_xhtml_preserving(base, patches, sidecar) + changes = [_Change(2, "modified", _Block("Before\n"), _Block("After\n"))] + plan = canonical_plan_json(changes=changes, patches=patches, skipped_changes=[]) + return base, improved, roundtrip, sidecar, patches, candidate, changes, plan + + +def test_local_proof_requires_every_gate_and_returns_verified_local(): + base, improved, roundtrip, sidecar, patches, candidate, changes, plan = _proof_inputs() + + proof = build_local_proof( + base_xhtml=base, + improved_mdx=improved, + roundtrip_mdx=roundtrip, + candidate_xhtml=candidate, + sidecar=sidecar, + changes=changes, + patches=patches, + skipped_changes=[], + plan_json=plan, + deterministic_plan_json=plan, + deterministic_candidate_xhtml=candidate, + idempotent_candidate_xhtml=candidate, + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + + assert proof.status == "verified_local" + assert proof.push_eligible is True + assert tuple(gate.name for gate in proof.gates) == REQUIRED_LOCAL_GATES + assert all(gate.passed for gate in proof.gates) + + +def test_local_proof_blocks_diagnostic_match_skips_and_non_idempotency(): + base, improved, _, sidecar, patches, candidate, changes, plan = _proof_inputs() + diagnostic_only = improved.replace("After", "After ") + + proof = build_local_proof( + base_xhtml=base, + improved_mdx=improved, + roundtrip_mdx=diagnostic_only, + candidate_xhtml=candidate, + sidecar=sidecar, + changes=changes, + patches=patches, + skipped_changes=[{"reason": "unsupported"}], + plan_json=plan, + deterministic_plan_json=plan, + deterministic_candidate_xhtml=candidate, + idempotent_candidate_xhtml=candidate + "

duplicate

", + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + + assert proof.status == "blocked" + assert proof.push_eligible is False + assert "incomplete_patch_plan" in proof.blocked_reasons + assert "semantic_roundtrip_mismatch" in proof.blocked_reasons + assert "non_idempotent_output" in proof.blocked_reasons + + +def test_insert_operation_is_not_claimed_idempotent_when_it_duplicates(): + base = "

Before

" + original = "# Title\n\nBefore\n" + improved = "# Title\n\nBefore\n\nAdded\n" + sidecar = build_sidecar(base, original, page_id="123") + patches = [ + { + "action": "insert", + "after_xpath": "p[1]", + "new_element_xhtml": "

Added

", + } + ] + candidate = patch_xhtml_preserving(base, patches, sidecar) + candidate_sidecar = build_sidecar(candidate, improved, page_id="123") + applied_twice = patch_xhtml_preserving(candidate, patches, candidate_sidecar) + changes = [_Change(4, "added", None, _Block("Added\n"))] + plan = canonical_plan_json(changes=changes, patches=patches, skipped_changes=[]) + + proof = build_local_proof( + base_xhtml=base, + improved_mdx=improved, + roundtrip_mdx=improved, + candidate_xhtml=candidate, + sidecar=sidecar, + changes=changes, + patches=patches, + skipped_changes=[], + plan_json=plan, + deterministic_plan_json=plan, + deterministic_candidate_xhtml=candidate, + idempotent_candidate_xhtml=applied_twice, + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + + assert proof.push_eligible is False + assert "non_idempotent_output" in proof.blocked_reasons diff --git a/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py b/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py new file mode 100644 index 000000000..97c113e93 --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py @@ -0,0 +1,50 @@ +"""실제 converter/golden fixture를 사용하는 online local-proof shadow test.""" + +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from reverse_sync.models import PageSnapshot +from reverse_sync.proof import REQUIRED_LOCAL_GATES +from reverse_sync_cli import MdxSource, run_verify + + +PROJECT_DIR = Path(__file__).resolve().parent.parent + + +def test_golden_page_builds_verified_manifest_without_remote_put(): + page_id = "1911652402" + page_dir = PROJECT_DIR / "tests" / "testcases" / page_id + original = (page_dir / "original.mdx").read_text() + improved = (page_dir / "improved.mdx").read_text() + frontmatter = yaml.safe_load(original.split("---", 2)[1]) + snapshot = PageSnapshot( + page_id=page_id, + status="current", + title=frontmatter["title"], + version=42, + storage_xhtml=(page_dir / "page.xhtml").read_text(), + fetched_at=datetime(2026, 7, 24, tzinfo=timezone.utc).isoformat(), + api="fixture-shadow", + ) + + result = run_verify( + page_id=page_id, + original_src=MdxSource(original, str(page_dir / "original.mdx")), + improved_src=MdxSource(improved, str(page_dir / "improved.mdx")), + page_dir=str(page_dir), + base_snapshot=snapshot, + for_push=True, + ) + + assert result["status"] == "verified_local" + assert result["push_eligible"] is True + assert tuple(gate["name"] for gate in result["local_gates"]) == ( + REQUIRED_LOCAL_GATES + ) + assert all(gate["passed"] for gate in result["local_gates"]) + manifest_path = Path(result["manifest_path"]) + assert manifest_path.is_file() + assert (manifest_path.parent / "patch-plan.json").is_file() + assert (manifest_path.parent / "local-proof.json").is_file() diff --git a/confluence-mdx/tests/test_reverse_sync_push_transaction.py b/confluence-mdx/tests/test_reverse_sync_push_transaction.py index 09a1e831f..33ff07e92 100644 --- a/confluence-mdx/tests/test_reverse_sync_push_transaction.py +++ b/confluence-mdx/tests/test_reverse_sync_push_transaction.py @@ -33,6 +33,7 @@ RemoteDriftError, publish_verified_manifest, ) +from reverse_sync.proof import REQUIRED_LOCAL_GATES from reverse_sync_cli import MdxSource, _do_verify, run_verify @@ -66,19 +67,15 @@ def _manifest(tmp_path: Path, base: PageSnapshot | None = None) -> Path: original_descriptor="main:src/content/ko/test.mdx", improved_mdx="# Test page\n\nAfter\n", improved_descriptor="src/content/ko/test.mdx", + patch_plan='{"schema_version":1}\n', candidate_xhtml="

After

", - verifier_policy="reverse-sync-push-v1", - tool_version="reverse-sync-cli-v1", + local_proof='{"status":"verified_local"}\n', + verifier_policy="reverse-sync-equivalence-v1", + tool_version="reverse-sync-cli-v2", push_eligible=True, gates=tuple( VerificationGate(name, True) - for name in ( - "source_identity", - "base_parity", - "intent_complete", - "semantic_roundtrip", - "artifact_integrity", - ) + for name in REQUIRED_LOCAL_GATES ), ) @@ -154,6 +151,21 @@ def test_candidate_tampering_blocks_before_remote_read(tmp_path): assert gateway.update_calls == [] +@pytest.mark.parametrize("artifact_name", ["patch-plan.json", "local-proof.json"]) +def test_proof_artifact_tampering_blocks_before_remote_read( + tmp_path, artifact_name +): + manifest_path = _manifest(tmp_path) + (manifest_path.parent / artifact_name).write_text('{"tampered":true}\n') + gateway = FakeGateway([_snapshot()]) + + with pytest.raises(ArtifactTamperedError, match=artifact_name): + publish_verified_manifest(manifest_path, gateway) + + assert gateway.current_calls == 0 + assert gateway.update_calls == [] + + def test_remote_drift_blocks_without_adopting_latest_version(tmp_path): manifest_path = _manifest(tmp_path) remote_edit = _snapshot(version=6, body="

Remote edit

") @@ -339,9 +351,11 @@ def test_push_manifest_requires_all_local_proof_gates(tmp_path): original_descriptor="main:src/content/ko/test.mdx", improved_mdx="# Test page\n\nAfter\n", improved_descriptor="src/content/ko/test.mdx", + patch_plan='{"schema_version":1}\n', candidate_xhtml="

After

", - verifier_policy="reverse-sync-push-v1", - tool_version="reverse-sync-cli-v1", + local_proof='{"status":"verified_local"}\n', + verifier_policy="reverse-sync-equivalence-v1", + tool_version="reverse-sync-cli-v2", push_eligible=True, gates=(VerificationGate("semantic_roundtrip", True),), ) @@ -577,7 +591,11 @@ def test_prepare_push_fetches_one_snapshot_and_passes_it_to_verify(): base = _snapshot() improved = MdxSource("# Test page\n\nAfter\n", "src/content/ko/test.mdx") original = MdxSource("# Test page\n\nBefore\n", "main:src/content/ko/test.mdx") - expected = {"status": "pass", "page_id": "123", "push_eligible": True} + expected = { + "status": "verified_local", + "page_id": "123", + "push_eligible": True, + } with patch( "reverse_sync_cli._resolve_mdx_source", @@ -625,22 +643,64 @@ def forward_convert(input_path, output_path, _page_id, **_kwargs): for_push=True, ) - assert result["status"] == "pass" + assert result["status"] == "verified_local" assert result["push_eligible"] is True assert result["base_version"] == 5 assert result["base_storage_sha256"] == base.storage_sha256 assert len(result["candidate_sha256"]) == 64 - assert "semantic_roundtrip" in result["local_gates"] + assert any( + gate["name"] == "semantic_roundtrip" and gate["passed"] + for gate in result["local_gates"] + ) manifest_path = Path(result["manifest_path"]) assert manifest_path.is_file() manifest = load_sync_manifest(manifest_path) assert manifest.base_version == 5 assert manifest.base_storage_sha256 == base.storage_sha256 + assert manifest.verifier_policy == "reverse-sync-equivalence-v1" + assert manifest.tool_version == "reverse-sync-cli-v2" + assert (manifest_path.parent / "patch-plan.json").is_file() + assert (manifest_path.parent / "local-proof.json").is_file() assert (manifest_path.parent / "candidate.xhtml").read_text() == ( tmp_path / "var" / page_id / "reverse-sync.patched.xhtml" ).read_text() +def test_online_verify_proves_insert_idempotent_by_replanning( + tmp_path, monkeypatch +): + monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path) + page_id = "123" + (tmp_path / "var" / page_id).mkdir(parents=True) + base = _snapshot( + title="Test page", + body="

Section

Before

", + ) + original = "# Test page\n\n## Section\n\nBefore\n" + improved = "# Test page\n\n## Section\n\nBefore\n\nAdded\n" + + def forward_convert(input_path, output_path, _page_id, **_kwargs): + content = original if Path(output_path).name == "reverse-sync.base.mdx" else improved + Path(output_path).write_text(content) + return content + + with patch("reverse_sync_cli._forward_convert", side_effect=forward_convert): + result = run_verify( + page_id=page_id, + original_src=MdxSource(original, "main:src/content/ko/test.mdx"), + improved_src=MdxSource(improved, "src/content/ko/test.mdx"), + base_snapshot=base, + for_push=True, + ) + + assert result["status"] == "verified_local" + assert result["push_eligible"] is True + idempotency = next( + gate for gate in result["local_gates"] if gate["name"] == "idempotency" + ) + assert idempotency["passed"] is True + + def test_online_verify_blocks_stale_original_before_patch(tmp_path, monkeypatch): monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path) page_id = "123" @@ -720,23 +780,44 @@ def test_online_verify_blocks_title_change(tmp_path, monkeypatch): assert result["push_eligible"] is False -def test_lenient_verify_is_never_push_eligible(tmp_path, monkeypatch): +def test_lenient_match_is_diagnostic_and_never_grants_push_eligibility( + tmp_path, monkeypatch +): monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path) page_id = "123" (tmp_path / "var" / page_id).mkdir(parents=True) - - result = run_verify( - page_id=page_id, - original_src=MdxSource("# Test page\n\nBefore\n", "original.mdx"), - improved_src=MdxSource("# Test page\n\nAfter\n", "improved.mdx"), - base_snapshot=_snapshot(title="Test page"), - for_push=True, - lenient=True, + base = _snapshot( + title="Test page", + body="

Section

Before

", ) + original = "# Test page\n\n## Section\n\nBefore\n" + improved = "# Test page\n\n## Section\n\n2024년 01월 15일\n" + diagnostic_roundtrip = "# Test page\n\n## Section\n\nJan 15, 2024\n" + + def forward_convert(_input_path, output_path, _page_id, **_kwargs): + content = ( + original + if Path(output_path).name == "reverse-sync.base.mdx" + else diagnostic_roundtrip + ) + Path(output_path).write_text(content) + return content + + with patch("reverse_sync_cli._forward_convert", side_effect=forward_convert): + result = run_verify( + page_id=page_id, + original_src=MdxSource(original, "original.mdx"), + improved_src=MdxSource(improved, "improved.mdx"), + base_snapshot=base, + for_push=True, + lenient=True, + ) assert result["status"] == "blocked" - assert result["reason_code"] == "lenient_verification_not_pushable" + assert result["reason_code"] == "semantic_roundtrip_mismatch" assert result["push_eligible"] is False + assert result["diagnostics"]["lenient"]["passed"] is True + assert result["diagnostics"]["lenient"]["push_eligible"] is False def test_online_verify_blocks_missing_attachment(tmp_path, monkeypatch): diff --git a/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py b/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py index 05d20498d..3c5faa189 100644 --- a/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py +++ b/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py @@ -1,5 +1,7 @@ import pytest from reverse_sync.roundtrip_verifier import ( + NORMALIZATION_RULES, + NormalizationClass, verify_roundtrip, VerifyResult, _normalize_consecutive_spaces_in_text, @@ -19,6 +21,32 @@ def test_identical_mdx_passes(): assert result.diff_report == "" +def test_normalization_rules_are_classified_for_diagnostics(): + classifications = {rule.name: rule.classification for rule in NORMALIZATION_RULES} + + assert set(classifications) == { + "br_space", + "consecutive_spaces", + "date_reformat", + "empty_bold", + "empty_list_items", + "html_entities_in_code", + "inline_code_boundary", + "leading_blank_lines", + "link_text_spacing", + "sentence_break_merge", + "smart_quotes", + "table_cell_line_merge", + "table_cell_padding", + "title_removal", + "trailing_blank_lines", + "trailing_whitespace", + } + assert classifications["table_cell_padding"] is NormalizationClass.SOURCE_FORMATTING + assert classifications["consecutive_spaces"] is NormalizationClass.RENDERED_VISIBLE + assert classifications["title_removal"] is NormalizationClass.UNSUPPORTED_LOSSY + + def test_different_mdx_fails(): result = verify_roundtrip( expected_mdx="# Title\n\nParagraph.\n", diff --git a/openspec/changes/complete-reverse-sync/design.md b/openspec/changes/complete-reverse-sync/design.md index 31eaa3a48..ab3a5bac5 100644 --- a/openspec/changes/complete-reverse-sync/design.md +++ b/openspec/changes/complete-reverse-sync/design.md @@ -299,12 +299,19 @@ canonical model은 block type, nesting, inline token, visible whitespace policy, 기존 `--no-normalize`는 raw diagnosis, `--lenient`는 triage 용도로 유지할 수 있지만 둘 다 push eligibility와 분리합니다. +`reverse-sync-equivalence-v1`의 초기 허용 범위는 Markdown table의 바깥 cell +padding, separator dash 길이, body block 사이의 빈 source line으로 제한합니다. +table alignment와 cell content, 연속 공백, list marker 뒤 공백, inline boundary, +link target, attachment filename, H1은 canonical model에 남겨 비교합니다. +해석하지 못하는 MDX JSX와 raw HTML은 marker와 source를 exact token으로 보존하여 +equivalence를 임의로 확대하지 않습니다. + ### Decision: `SyncManifest`를 verify와 push 사이의 계약으로 둡니다 manifest는 최소한 다음을 기록합니다. ```yaml -schema_version: 1 +schema_version: 2 run_id: "..." tool: git_sha: "..." @@ -327,7 +334,7 @@ plan: candidate: storage_sha256: "..." verification: - status: verified + status: verified_local gates: base_parity: pass intent_complete: pass diff --git a/openspec/changes/complete-reverse-sync/tasks.md b/openspec/changes/complete-reverse-sync/tasks.md index 3393a08e2..212c9d96b 100644 --- a/openspec/changes/complete-reverse-sync/tasks.md +++ b/openspec/changes/complete-reverse-sync/tasks.md @@ -83,14 +83,14 @@ ### 2.5 P0 — strict proof와 push eligibility -- [ ] `confluence-mdx/bin/reverse_sync/proof.py`를 추가하여 필수 gate를 orchestration합니다. -- [ ] `roundtrip_verifier.py` normalization을 source formatting, rendered-visible, unsupported/lossy로 분류합니다. -- [ ] push equivalence v1 typed canonical model을 구현합니다. +- [x] `confluence-mdx/bin/reverse_sync/proof.py`를 추가하여 필수 gate를 orchestration합니다. +- [x] `roundtrip_verifier.py` normalization을 source formatting, rendered-visible, unsupported/lossy로 분류합니다. +- [x] push equivalence v1 typed canonical model을 구현합니다. - [x] `skipped_changes > 0`이면 `intent_complete`를 실패시킵니다. -- [ ] `--lenient`와 `--no-normalize` 결과를 diagnostic field로 이동합니다. -- [ ] unchanged fragment, separator, document envelope byte-equal을 proof에 포함합니다. -- [ ] well-formed Storage XHTML, determinism, idempotency 검사를 추가합니다. -- [ ] 기존 `pass`를 `verified_local`과 분리합니다. +- [x] `--lenient`와 `--no-normalize` 결과를 diagnostic field로 이동합니다. +- [x] unchanged fragment, separator, document envelope byte-equal을 proof에 포함합니다. +- [x] well-formed Storage XHTML, determinism, idempotency 검사를 추가합니다. +- [x] 기존 `pass`를 `verified_local`과 분리합니다. 완료 gate: @@ -171,7 +171,15 @@ cd confluence-mdx/tests ../venv/bin/python3 -m pytest -q test_reverse_sync_push_transaction.py ``` -`proof.py`와 typed equivalence contract test는 2.5 구현 시 별도 test module로 분리합니다. +strict proof와 typed equivalence는 다음 test module과 golden shadow fixture로 검증합니다. + +```bash +cd confluence-mdx/tests +../venv/bin/python3 -m pytest -q \ + test_reverse_sync_equivalence.py \ + test_reverse_sync_online_proof_fixture.py \ + test_reverse_sync_push_transaction.py +``` ### 3.2 Existing reverse-sync unit regression @@ -216,7 +224,7 @@ make test-byte-verify ### 3.5 Broader converter regression -- [ ] verifier/equivalence 또는 emitter를 변경한 PR은 다음을 추가로 실행합니다. +- [x] verifier/equivalence 또는 emitter를 변경한 PR은 다음을 추가로 실행합니다. ```bash cd confluence-mdx/tests @@ -225,9 +233,18 @@ make test-reverse-sync make test-byte-verify ``` +strict proof 구현 branch 검증 결과: + +- `make test-convert`: 21 passed +- `make test-reverse-sync`: golden 16 passed, regression 43 passed +- `make test-byte-verify`: fast/splice 각각 21/21 passed +- 전체 Python test: 1054 passed, 2 skipped +- 16개 golden page shadow online verify: 4개 `verified_local`, 나머지는 + visible whitespace, unresolved link, raw HTML table mutation 등에서 fail-closed + - [x] 영향도에 따라 전체 Python test와 render test를 실행합니다. -이번 변경은 Python CLI/API adapter 범위이므로 전체 Python test(`1033 passed, 2 skipped`)를 +이번 변경은 Python CLI/API adapter 범위이므로 전체 Python test(`1054 passed, 2 skipped`)를 실행했고 frontend render test는 영향 범위에서 제외했습니다. ```bash