래퍼 태그를 제거하고 내부 텍스트를 paragraph로 변환."""
lines = text.splitlines()
if lines and lines[0].strip().startswith(' str:
if lines and lines[-1].strip().startswith(' str:
+def _convert_blockquote_inner(
+ text: str,
+ link_resolver: Optional[LinkResolver] = None,
+) -> str:
"""blockquote: > prefix를 제거하고 내용을 로 감싼다.
XHTML에서 blockquote는
...
구조를 사용한다.
@@ -89,7 +101,7 @@ def _convert_blockquote_inner(text: str) -> str:
stripped = re.sub(r'^>\s?', '', line)
stripped_lines.append(stripped)
inner = '\n'.join(stripped_lines).strip()
- converted = _convert_paragraph(inner)
+ converted = _convert_paragraph(inner, link_resolver=link_resolver)
return f'{converted}
'
@@ -104,14 +116,17 @@ def _convert_code_block(text: str) -> str:
return '\n'.join(lines)
-def _convert_html_block_inner(text: str) -> str:
+def _convert_html_block_inner(
+ text: str,
+ link_resolver: Optional[LinkResolver] = None,
+) -> str:
"""html_block: inline 변환 후 루트 요소의 innerHTML만 반환한다.
html_block content는 ```` 처럼
outer 태그를 포함하므로, inline 변환 후 루트 요소를 벗겨내야
_replace_inner_html()에서 중첩이 발생하지 않는다.
"""
- converted = convert_inline(text)
+ converted = convert_inline(text, link_resolver=link_resolver)
soup = BeautifulSoup(converted, 'html.parser')
root = soup.find(True) # 첫 번째 태그 요소
if isinstance(root, Tag):
@@ -119,20 +134,13 @@ def _convert_html_block_inner(text: str) -> str:
return converted
-def _convert_code_spans(text: str) -> str:
- """code span만 변환 (`text` → text)."""
- return re.sub(r'`([^`]+)`', lambda m: f'{html.escape(m.group(1))}', text)
-
-
-def _convert_links(text: str) -> str:
- """link만 변환 ([text](url) → text)."""
- return re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text)
-
-
-def _convert_list_content(text: str) -> str:
+def _convert_list_content(
+ text: str,
+ link_resolver: Optional[LinkResolver] = None,
+) -> str:
"""리스트 블록 → ...
구조의 inner HTML."""
items = _parse_list_items(text)
- return _render_list_items(items)
+ return _render_list_items(items, link_resolver=link_resolver)
def _parse_list_items(content: str) -> List[dict]:
@@ -180,7 +188,10 @@ def _parse_list_items(content: str) -> List[dict]:
return items
-def _render_list_items(items: List[dict]) -> str:
+def _render_list_items(
+ items: List[dict],
+ link_resolver: Optional[LinkResolver] = None,
+) -> str:
"""파싱된 리스트 아이템을 ...
HTML로 렌더링한다.
중첩 리스트: indent 기반으로 안에 / 중첩.
@@ -193,7 +204,10 @@ def _render_list_items(items: List[dict]) -> str:
i = 0
while i < len(items):
item = items[i]
- inner = convert_inline(item['content'])
+ inner = convert_inline(
+ item['content'],
+ link_resolver=link_resolver,
+ )
# 다음 아이템이 더 깊은 indent인지 확인 → 중첩 리스트
children_start = i + 1
@@ -204,7 +218,10 @@ def _render_list_items(items: List[dict]) -> str:
if children_end > children_start:
# 중첩 리스트가 있는 경우
child_items = items[children_start:children_end]
- child_html = _render_nested_list(child_items)
+ child_html = _render_nested_list(
+ child_items,
+ link_resolver=link_resolver,
+ )
result_parts.append(f'{inner}
{child_html} ')
i = children_end
else:
@@ -214,20 +231,30 @@ def _render_list_items(items: List[dict]) -> str:
return ''.join(result_parts)
-def _render_nested_list(items: List[dict]) -> str:
+def _render_nested_list(
+ items: List[dict],
+ link_resolver: Optional[LinkResolver] = None,
+) -> str:
"""중첩 리스트 아이템을 /로 감싸서 렌더링한다."""
if not items:
return ''
ordered = items[0]['ordered']
- inner = _render_list_items(items)
+ inner = _render_list_items(items, link_resolver=link_resolver)
if ordered:
return f'{inner}
'
return f''
-def mdx_block_to_xhtml_element(block) -> str:
+def mdx_block_to_xhtml_element(
+ block,
+ link_resolver: Optional[LinkResolver] = None,
+) -> str:
"""MDX 블록을 완전한 Confluence XHTML 요소(outer tag 포함)로 변환한다."""
- inner = mdx_block_to_inner_xhtml(block.content, block.type)
+ inner = mdx_block_to_inner_xhtml(
+ block.content,
+ block.type,
+ link_resolver=link_resolver,
+ )
if block.type == 'heading':
level = _detect_heading_level(block.content)
diff --git a/confluence-mdx/bin/reverse_sync/models.py b/confluence-mdx/bin/reverse_sync/models.py
index 233af37b7..04430429b 100644
--- a/confluence-mdx/bin/reverse_sync/models.py
+++ b/confluence-mdx/bin/reverse_sync/models.py
@@ -44,6 +44,12 @@ class ReasonCode(str, Enum):
NON_DETERMINISTIC_OUTPUT = "non_deterministic_output"
NON_IDEMPOTENT_OUTPUT = "non_idempotent_output"
DEPENDENCY_FAILURE = "dependency_failure"
+ MISSING_ATTACHMENT = "missing_attachment"
+ INTERNAL_LINK_UNRESOLVED = "internal_link_unresolved"
+ AMBIGUOUS_TARGET = "ambiguous_target"
+ STALE_ORIGINAL_MDX = "stale_original_mdx"
+ FORWARD_CONVERTER_DRIFT = "forward_converter_drift"
+ TITLE_CHANGE_UNSUPPORTED = "title_change_unsupported"
@dataclass(frozen=True)
@@ -77,6 +83,62 @@ def to_dict(self, *, include_body: bool = True) -> dict[str, Any]:
return result
+@dataclass(frozen=True)
+class AttachmentRecord:
+ """dependency gate가 사용하는 current attachment identity."""
+
+ attachment_id: str
+ page_id: str
+ filename: str
+ version: int
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "attachment_id": self.attachment_id,
+ "filename": self.filename,
+ "page_id": self.page_id,
+ "version": self.version,
+ }
+
+
+@dataclass(frozen=True)
+class AttachmentCatalog:
+ """한 시점에 조회한 page attachment 목록."""
+
+ page_id: str
+ attachments: tuple[AttachmentRecord, ...]
+ fetched_at: str
+ api: str
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "api": self.api,
+ "attachments": [
+ attachment.to_dict()
+ for attachment in sorted(
+ self.attachments,
+ key=lambda item: (
+ item.filename,
+ item.attachment_id,
+ item.version,
+ ),
+ )
+ ],
+ "fetched_at": self.fetched_at,
+ "page_id": self.page_id,
+ }
+
+ @property
+ def sha256(self) -> str:
+ canonical = json.dumps(
+ self.to_dict(),
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ return sha256_text(canonical)
+
+
@dataclass(frozen=True)
class ArtifactRef:
"""manifest가 참조하는 실행별 artifact."""
diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py
index 558eda67d..66eee502f 100644
--- a/confluence-mdx/bin/reverse_sync/patch_builder.py
+++ b/confluence-mdx/bin/reverse_sync/patch_builder.py
@@ -1,9 +1,13 @@
"""패치 빌더 — MDX diff 변경과 XHTML 매핑을 결합하여 XHTML 패치를 생성."""
import difflib
+import html
import re
+from pathlib import PurePosixPath
from typing import Any, Dict, List, Optional, Tuple
+from urllib.parse import unquote, urlparse
from mdx_to_storage.emitter import emit_block
+from mdx_to_storage.link_resolver import LinkResolver
from mdx_to_storage.parser import parse_mdx
from reverse_sync.block_diff import BlockChange, NON_CONTENT_TYPES
from reverse_sync.mapping_recorder import BlockMapping, record_mapping
@@ -22,7 +26,7 @@
)
from reverse_sync.lost_info_patcher import apply_lost_info, distribute_lost_info_to_mappings
from reverse_sync.mdx_to_xhtml_inline import mdx_block_to_xhtml_element, mdx_block_to_inner_xhtml
-from mdx_to_storage.inline import convert_inline
+from mdx_to_storage.inline import convert_inline, escape_bare_xml_ampersands
from reverse_sync.reconstructors import (
sidecar_block_requires_reconstruction,
reconstruct_fragment_with_sidecar,
@@ -44,6 +48,10 @@ def is_markdown_table(content: str) -> bool:
_CLEAN_BLOCK_TYPES = frozenset(("heading", "code_block", "hr"))
+_GENERATED_LINK = re.compile(
+ r"]*)>(.*?)",
+ flags=re.DOTALL | re.IGNORECASE,
+)
def _is_container_sidecar(sidecar_block: Optional[SidecarBlock]) -> bool:
@@ -91,6 +99,94 @@ def _contains_preserved_link_markup(xhtml_text: str) -> bool:
return " str:
+ """emitter가 만든 relative 를 Confluence ac:link로 변환합니다."""
+ from bs4 import BeautifulSoup
+
+ def replace(match: re.Match[str]) -> str:
+ tag = BeautifulSoup(match.group(0), "html.parser").find("a")
+ if tag is None:
+ return match.group(0)
+ href_value = tag.get("href")
+ if not isinstance(href_value, str):
+ return match.group(0)
+ href = html.unescape(href_value)
+ body = escape_bare_xml_ampersands(match.group(2))
+ link_text = BeautifulSoup(body, "html.parser").get_text()
+ resolution = resolver.resolve_with_evidence(href, link_text=link_text)
+ if resolution.status == "external":
+ return match.group(0)
+ extra_attrs = set(tag.attrs) - {"href"}
+ if extra_attrs:
+ raise ValueError(
+ "generated internal link의 추가 attribute를 보존할 수 없습니다: "
+ + ", ".join(sorted(extra_attrs))
+ )
+ if resolution.status == "local_anchor":
+ anchor = html.escape(resolution.anchor or "", quote=True)
+ if not anchor:
+ raise ValueError("generated local anchor가 비어 있습니다")
+ return (
+ f''
+ f"{body}"
+ ""
+ )
+ filename = PurePosixPath(unquote(urlparse(href).path)).name
+ if filename and filename in attachment_filenames:
+ escaped_filename = html.escape(filename, quote=True)
+ return (
+ ""
+ f''
+ ""
+ f"{body}"
+ ""
+ )
+ if resolution.status != "resolved":
+ raise ValueError(
+ f"generated internal link를 resolve할 수 없습니다: "
+ f"{href} ({resolution.status})"
+ )
+ anchor_attr = (
+ f' ac:anchor="{html.escape(resolution.anchor, quote=True)}"'
+ if resolution.anchor
+ else ""
+ )
+ title = html.escape(resolution.content_title or "", quote=True)
+ return (
+ f""
+ f''
+ f"{body}"
+ f""
+ )
+
+ return _GENERATED_LINK.sub(replace, xhtml)
+
+
+def _resolve_patch_links(
+ patches: List[Dict[str, Any]],
+ resolver: LinkResolver | None,
+ attachment_filenames: frozenset[str],
+) -> List[Dict[str, Any]]:
+ if resolver is None:
+ return patches
+ resolved: List[Dict[str, Any]] = []
+ for patch in patches:
+ current = dict(patch)
+ for key in ("new_element_xhtml", "new_inner_xhtml"):
+ if key in current:
+ current[key] = _resolve_generated_links(
+ str(current[key]),
+ resolver,
+ attachment_filenames,
+ )
+ resolved.append(current)
+ return resolved
+
+
def _is_clean_block(
block_type: str,
mapping: Optional[BlockMapping],
@@ -548,12 +644,21 @@ def _build_list_item_merge_patch(
}
-def _emit_replacement_fragment(block: MdxBlock) -> str:
+def _emit_replacement_fragment(
+ block: MdxBlock,
+ link_resolver: LinkResolver | None = None,
+) -> str:
"""Block content를 현재 forward emitter 기준 fragment로 변환한다."""
parsed_blocks = [parsed for parsed in parse_mdx(block.content) if parsed.type != "empty"]
if len(parsed_blocks) == 1:
- return emit_block(parsed_blocks[0])
- return mdx_block_to_xhtml_element(block)
+ return emit_block(
+ parsed_blocks[0],
+ context={"link_resolver": link_resolver},
+ )
+ return mdx_block_to_xhtml_element(
+ block,
+ link_resolver=link_resolver,
+ )
def _build_replace_fragment_patch(
@@ -819,6 +924,8 @@ def build_patches(
page_lost_info: Optional[dict] = None,
roundtrip_sidecar: Optional[RoundtripSidecar] = None,
page_xhtml: Optional[str] = None,
+ link_resolver: Optional[LinkResolver] = None,
+ attachment_filenames: frozenset[str] = frozenset(),
) -> Tuple[List[Dict[str, str]], List[BlockMapping], List[Dict[str, str]]]:
"""diff 변경과 매핑을 결합하여 XHTML 패치 목록을 구성한다.
@@ -993,7 +1100,9 @@ def _mark_used(block_id: str, m: BlockMapping):
patch = _build_insert_patch(
change, improved_blocks, alignment,
mdx_to_sidecar, xpath_to_mapping,
- page_lost_info=page_lost_info)
+ page_lost_info=page_lost_info,
+ link_resolver=link_resolver,
+ )
if patch:
patches.append(patch)
continue
@@ -1370,7 +1479,15 @@ def _mark_used(block_id: str, m: BlockMapping):
'new_inner_xhtml': new_inner,
})
- return patches, mappings, skipped_changes
+ return (
+ _resolve_patch_links(
+ patches,
+ link_resolver,
+ attachment_filenames,
+ ),
+ mappings,
+ skipped_changes,
+ )
def _build_delete_patch(
@@ -1396,6 +1513,7 @@ def _build_insert_patch(
mdx_to_sidecar: Dict[int, SidecarEntry],
xpath_to_mapping: Dict[str, 'BlockMapping'],
page_lost_info: Optional[dict] = None,
+ link_resolver: Optional[LinkResolver] = None,
) -> Optional[Dict[str, str]]:
"""추가된 블록에 대한 insert 패치를 생성한다."""
new_block = change.new_block
@@ -1405,7 +1523,13 @@ def _build_insert_patch(
after_xpath = _find_insert_anchor(
change.index, alignment, mdx_to_sidecar, xpath_to_mapping)
- new_xhtml = mdx_block_to_xhtml_element(new_block)
+ if link_resolver is None:
+ new_xhtml = mdx_block_to_xhtml_element(new_block)
+ else:
+ new_xhtml = _emit_replacement_fragment(
+ new_block,
+ link_resolver=link_resolver,
+ )
# L4: lost_info 적용
if page_lost_info:
new_xhtml = apply_lost_info(new_xhtml, page_lost_info)
diff --git a/confluence-mdx/bin/reverse_sync/proof.py b/confluence-mdx/bin/reverse_sync/proof.py
index ac9fc3edf..083bb9100 100644
--- a/confluence-mdx/bin/reverse_sync/proof.py
+++ b/confluence-mdx/bin/reverse_sync/proof.py
@@ -9,6 +9,7 @@
from typing import Any, Iterable
from xml.etree import ElementTree
+from reverse_sync.dependencies import DependencyEvidence, DependencyResult
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
@@ -47,6 +48,8 @@ class LocalProof:
base_sha256: str
candidate_sha256: str
plan_sha256: str
+ dependencies: DependencyEvidence = DependencyEvidence()
+ dependency_detail: str = ""
blocked_reasons: tuple[str, ...] = ()
def to_dict(self) -> dict[str, Any]:
@@ -57,6 +60,8 @@ def to_dict(self) -> dict[str, Any]:
"plan_sha256": self.plan_sha256,
},
"blocked_reasons": list(self.blocked_reasons),
+ "dependencies": self.dependencies.to_dict(),
+ "dependency_detail": self.dependency_detail,
"equivalence": self.equivalence.to_dict(),
"gates": [gate.to_dict() for gate in self.gates],
"push_eligible": self.push_eligible,
@@ -199,7 +204,7 @@ def build_local_proof(
idempotent_candidate_xhtml: str,
source_identity_passed: bool,
base_parity_passed: bool,
- dependency_passed: bool,
+ dependency_result: DependencyResult,
) -> LocalProof:
"""모든 required local gate를 독립적으로 계산한다."""
equivalence = verify_push_equivalence(improved_mdx, roundtrip_mdx)
@@ -236,7 +241,11 @@ def build_local_proof(
),
("determinism", determinism, "non_deterministic_output"),
("idempotency", idempotency, "non_idempotent_output"),
- ("dependency", dependency_passed, "dependency_failure"),
+ (
+ "dependency",
+ dependency_result.passed,
+ dependency_result.reason_code or "dependency_failure",
+ ),
)
gates = tuple(
VerificationGate(name=name, passed=passed, reason_code="" if passed else reason)
@@ -261,5 +270,7 @@ def build_local_proof(
base_sha256=sha256_text(base_xhtml),
candidate_sha256=sha256_text(candidate_xhtml),
plan_sha256=sha256_text(plan_json),
+ dependencies=dependency_result.evidence,
+ dependency_detail=dependency_result.detail,
blocked_reasons=blocked_reasons,
)
diff --git a/confluence-mdx/bin/reverse_sync/publisher.py b/confluence-mdx/bin/reverse_sync/publisher.py
index 8e09ffa54..dc532e280 100644
--- a/confluence-mdx/bin/reverse_sync/publisher.py
+++ b/confluence-mdx/bin/reverse_sync/publisher.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+import json
+import re
+from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Protocol
@@ -12,6 +15,7 @@
verify_manifest_integrity,
)
from reverse_sync.models import (
+ AttachmentCatalog,
PageSnapshot,
PushReceipt,
ReasonCode,
@@ -25,6 +29,10 @@ def get_current_page(self, page_id: str) -> PageSnapshot: ...
def get_active_draft(self, page_id: str) -> PageSnapshot | None: ...
+ def get_page_identity(self, page_id: str) -> PageSnapshot: ...
+
+ def get_attachment_catalog(self, page_id: str) -> AttachmentCatalog: ...
+
def update_page(
self,
page_id: str,
@@ -51,13 +59,28 @@ class PostconditionError(PublishBlockedError):
reason_code = ReasonCode.POSTCONDITION_FAILED.value
+class DependencyChangedError(PublishBlockedError):
+ reason_code = ReasonCode.DEPENDENCY_FAILURE.value
+
+
+@dataclass(frozen=True)
+class RequiredLink:
+ page_id: str
+ content_title: str
+ href: str
+
+
+@dataclass(frozen=True)
+class RequiredDependencies:
+ attachment_filenames: tuple[str, ...] = ()
+ links: tuple[RequiredLink, ...] = ()
+
+
def _write_snapshot(path: Path, snapshot: PageSnapshot) -> None:
_write_json(path, snapshot.to_dict(include_body=True))
def _write_json(path: Path, value: dict) -> None:
- import json
-
path.write_text(
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
+ "\n"
@@ -77,6 +100,142 @@ def _candidate_body(path: Path, manifest: SyncManifest) -> str:
return (path.parent / candidate.path).read_text()
+def _required_dependencies(
+ path: Path,
+ manifest: SyncManifest,
+) -> RequiredDependencies:
+ proof_ref = manifest.artifact("local_proof")
+ try:
+ proof = json.loads((path.parent / proof_ref.path).read_text())
+ if (
+ proof.get("status") != SyncStatus.VERIFIED_LOCAL.value
+ or proof.get("push_eligible") is not True
+ ):
+ raise TypeError("local proof status")
+ dependencies = proof["dependencies"]
+ catalog_sha256 = dependencies["attachment_catalog_sha256"]
+ attachments = dependencies["attachments"]
+ internal_links = dependencies["internal_links"]
+ if not isinstance(attachments, list) or not isinstance(
+ internal_links,
+ list,
+ ):
+ raise TypeError("dependency list")
+ filenames = []
+ for item in attachments:
+ attachment_id = item["attachment_id"]
+ filename = item["filename"]
+ version = item["version"]
+ if (
+ not isinstance(attachment_id, str)
+ or not attachment_id
+ or not isinstance(filename, str)
+ or not filename
+ or not isinstance(version, int)
+ or isinstance(version, bool)
+ or version < 1
+ ):
+ raise TypeError("filename")
+ filenames.append(filename)
+ links = []
+ for item in internal_links:
+ page_id = item["page_id"]
+ content_title = item["content_title"]
+ href = item["href"]
+ if (
+ not isinstance(page_id, str)
+ or not page_id
+ or not isinstance(content_title, str)
+ or not content_title
+ or not isinstance(href, str)
+ or not href
+ ):
+ raise TypeError("internal link")
+ links.append(
+ RequiredLink(
+ page_id=page_id,
+ content_title=content_title,
+ href=href,
+ )
+ )
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise ArtifactTamperedError(
+ "local proof dependency evidence 형식이 올바르지 않습니다"
+ ) from exc
+ if attachments and (
+ not isinstance(catalog_sha256, str)
+ or re.fullmatch(r"[0-9a-f]{64}", catalog_sha256) is None
+ ):
+ raise ArtifactTamperedError(
+ "local proof attachment catalog hash가 올바르지 않습니다"
+ )
+ if not attachments and catalog_sha256 != "":
+ raise ArtifactTamperedError(
+ "attachment requirement 없이 catalog hash가 기록되었습니다"
+ )
+ if len(filenames) != len(set(filenames)):
+ raise ArtifactTamperedError(
+ "local proof attachment requirement가 중복되었습니다"
+ )
+ unique_links = {
+ (link.page_id, link.content_title, link.href): link
+ for link in links
+ }
+ if len(unique_links) != len(links):
+ raise ArtifactTamperedError(
+ "local proof internal link requirement가 중복되었습니다"
+ )
+ titles_by_page: dict[str, set[str]] = {}
+ for link in links:
+ titles_by_page.setdefault(link.page_id, set()).add(link.content_title)
+ if any(len(titles) != 1 for titles in titles_by_page.values()):
+ raise ArtifactTamperedError(
+ "같은 internal page dependency에 여러 title이 기록되었습니다"
+ )
+ return RequiredDependencies(
+ attachment_filenames=tuple(sorted(filenames)),
+ links=tuple(
+ sorted(
+ links,
+ key=lambda item: (item.page_id, item.href, item.content_title),
+ )
+ ),
+ )
+
+
+def _assert_attachment_dependencies(
+ required: tuple[str, ...],
+ catalog: AttachmentCatalog,
+ page_id: str,
+) -> None:
+ if catalog.page_id != page_id:
+ raise DependencyChangedError(
+ "preflight attachment catalog page ID가 manifest와 다릅니다"
+ )
+ available = {attachment.filename for attachment in catalog.attachments}
+ missing = sorted(set(required) - available)
+ if missing:
+ raise DependencyChangedError(
+ "verify 이후 attachment dependency가 사라졌습니다: "
+ + ", ".join(missing)
+ )
+
+
+def _assert_link_dependency(
+ required: RequiredLink,
+ snapshot: PageSnapshot,
+) -> None:
+ if (
+ snapshot.page_id != required.page_id
+ or snapshot.status != "current"
+ or snapshot.title != required.content_title
+ ):
+ raise DependencyChangedError(
+ "verify 이후 internal link target identity가 바뀌었습니다: "
+ f"{required.href}"
+ )
+
+
def _assert_remote_identity(manifest: SyncManifest, remote: PageSnapshot) -> None:
mismatches: list[str] = []
if remote.page_id != manifest.page_id:
@@ -129,6 +288,10 @@ def publish_verified_manifest(
candidate_body = _candidate_body(manifest_path, manifest)
candidate_hash = manifest.artifact("candidate_xhtml").sha256
manifest_hash = _manifest_hash(manifest_path)
+ required_dependencies = _required_dependencies(
+ manifest_path,
+ manifest,
+ )
preflight = gateway.get_current_page(manifest.page_id)
run_dir = manifest_path.parent
@@ -140,6 +303,41 @@ def publish_verified_manifest(
raise ActiveDraftError(
f"페이지 {manifest.page_id}에 active draft가 있어 push를 중단합니다"
)
+ if required_dependencies.attachment_filenames:
+ attachment_catalog = gateway.get_attachment_catalog(manifest.page_id)
+ _write_json(
+ run_dir / "preflight.attachments.json",
+ attachment_catalog.to_dict(),
+ )
+ _assert_attachment_dependencies(
+ required_dependencies.attachment_filenames,
+ attachment_catalog,
+ manifest.page_id,
+ )
+ if required_dependencies.links:
+ page_snapshots: dict[str, PageSnapshot] = {}
+ for required_link in required_dependencies.links:
+ if required_link.page_id not in page_snapshots:
+ page_snapshots[required_link.page_id] = (
+ gateway.get_page_identity(required_link.page_id)
+ )
+ _write_json(
+ run_dir / "preflight.link-pages.json",
+ {
+ "pages": [
+ snapshot.to_dict(include_body=False)
+ for snapshot in sorted(
+ page_snapshots.values(),
+ key=lambda item: item.page_id,
+ )
+ ]
+ },
+ )
+ for required_link in required_dependencies.links:
+ _assert_link_dependency(
+ required_link,
+ page_snapshots[required_link.page_id],
+ )
if preflight.storage_sha256 == candidate_hash:
receipt = PushReceipt(
diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py
index 2550f8f6b..d8c80c9af 100755
--- a/confluence-mdx/bin/reverse_sync_cli.py
+++ b/confluence-mdx/bin/reverse_sync_cli.py
@@ -37,7 +37,7 @@
from xhtml_beautify_diff import xhtml_diff
_PUSH_VERIFIER_POLICY = PUSH_EQUIVALENCE_POLICY
-_TOOL_VERSION = "reverse-sync-cli-v2"
+_TOOL_VERSION = "reverse-sync-cli-v3"
@dataclass
@@ -371,6 +371,7 @@ def run_verify(
language: str = None,
page_dir: str = None,
base_snapshot=None,
+ attachment_catalog=None,
for_push: bool = False,
) -> Dict[str, Any]:
"""로컬 검증 파이프라인을 실행한다.
@@ -384,6 +385,9 @@ def run_verify(
original_mdx = original_src.content
improved_mdx = improved_src.content
+ dependency_result = None
+ link_resolver = None
+ attachment_filenames: frozenset[str] = frozenset()
_validate_improved_mdx(improved_mdx, improved_src.descriptor)
@@ -397,10 +401,12 @@ def run_verify(
)
if base_snapshot is not None:
from reverse_sync.base_parity import (
- verify_attachment_dependencies,
+ load_provenance_storage_xhtml,
verify_base_parity,
+ verify_repository_source_identity,
verify_source_identity,
)
+ from reverse_sync.dependencies import verify_dependencies
if base_snapshot.page_id != str(page_id) or base_snapshot.status != "current":
return _blocked_result(
@@ -410,10 +416,14 @@ def run_verify(
"invalid_page_snapshot",
detail="snapshot page ID 또는 status가 요청과 다릅니다.",
)
- source_identity = verify_source_identity(
+ pages_path = _PROJECT_DIR / "var" / "pages.qm.yaml"
+ source_identity = verify_repository_source_identity(
base_snapshot,
original_mdx,
improved_mdx,
+ original_descriptor=original_src.descriptor,
+ improved_descriptor=improved_src.descriptor,
+ pages_path=pages_path,
)
if not source_identity.passed:
return _blocked_result(
@@ -423,19 +433,25 @@ def run_verify(
source_identity.reason_code,
detail=source_identity.diff_report,
)
- dependency = verify_attachment_dependencies(
- base_snapshot,
- original_mdx,
- improved_mdx,
+ dependency_result, link_resolver = verify_dependencies(
+ page_id=page_id,
+ original_mdx=original_mdx,
+ improved_mdx=improved_mdx,
+ pages_path=pages_path,
+ attachment_catalog=attachment_catalog,
)
- if not dependency.passed:
+ if not dependency_result.passed:
return _blocked_result(
var_dir,
page_id,
now,
- dependency.reason_code,
- detail=dependency.diff_report,
+ dependency_result.reason_code,
+ detail=dependency_result.detail,
)
+ attachment_filenames = frozenset(
+ requirement.filename
+ for requirement in dependency_result.evidence.attachments
+ )
base_xhtml_path = var_dir / "reverse-sync.base.xhtml"
base_mdx_path = var_dir / "reverse-sync.base.mdx"
@@ -448,10 +464,20 @@ def run_verify(
page_dir=page_dir,
)
converted_base_mdx = base_mdx_path.read_text()
+ provenance_dir = (
+ Path(page_dir)
+ if page_dir
+ else _PROJECT_DIR / "var" / page_id
+ )
base_parity = verify_base_parity(
base_snapshot,
original_mdx,
converted_base_mdx,
+ provenance_storage_xhtml=load_provenance_storage_xhtml(
+ provenance_dir / "page.v1.yaml",
+ expected_page_id=page_id,
+ ),
+ require_confluence_url=True,
)
if not base_parity.passed:
return _blocked_result(
@@ -504,6 +530,8 @@ def run_verify(
alignment=alignment,
page_lost_info=page_lost_info,
roundtrip_sidecar=roundtrip_sidecar,
+ link_resolver=link_resolver,
+ attachment_filenames=attachment_filenames,
)
# mapping.original.yaml artifact 저장
@@ -563,6 +591,7 @@ def run_verify(
base_snapshot,
improved_mdx,
verify_mdx,
+ require_confluence_url=True,
)
if not candidate_identity.passed:
return _blocked_result(
@@ -633,6 +662,8 @@ def run_verify(
alignment=alignment,
page_lost_info=page_lost_info,
roundtrip_sidecar=roundtrip_sidecar,
+ link_resolver=link_resolver,
+ attachment_filenames=attachment_filenames,
)
deterministic_plan_json = canonical_plan_json(
changes=changes,
@@ -672,6 +703,8 @@ def run_verify(
alignment=idempotency_alignment,
page_lost_info=page_lost_info,
roundtrip_sidecar=candidate_sidecar,
+ link_resolver=link_resolver,
+ attachment_filenames=attachment_filenames,
)
idempotent_candidate = (
""
@@ -700,7 +733,7 @@ def run_verify(
idempotent_candidate_xhtml=idempotent_candidate,
source_identity_passed=True,
base_parity_passed=True,
- dependency_passed=True,
+ dependency_result=dependency_result,
)
proof_json = proof.to_canonical_json()
(var_dir / "reverse-sync.proof.json").write_text(proof_json)
@@ -1115,12 +1148,22 @@ def _do_verify(args, *, config=None, prepare_push: bool = False) -> dict:
page_dir = getattr(args, 'page_dir', None)
xhtml_path = str(Path(page_dir) / 'page.xhtml') if page_dir else None
base_snapshot = None
+ attachment_catalog = None
if prepare_push:
- from reverse_sync.confluence_client import get_page_snapshot
+ from reverse_sync.confluence_client import (
+ get_attachment_catalog,
+ get_page_snapshot,
+ )
+ from reverse_sync.dependencies import added_attachment_filenames
if config is None:
config = _ensure_confluence_config()
base_snapshot = get_page_snapshot(config, page_id)
+ if added_attachment_filenames(
+ original_src.content,
+ improved_src.content,
+ ):
+ attachment_catalog = get_attachment_catalog(config, page_id)
return run_verify(
page_id=page_id,
@@ -1131,6 +1174,7 @@ def _do_verify(args, *, config=None, prepare_push: bool = False) -> dict:
no_normalize=getattr(args, 'no_normalize', False),
page_dir=page_dir,
base_snapshot=base_snapshot,
+ attachment_catalog=attachment_catalog,
for_push=prepare_push,
)
@@ -1318,7 +1362,12 @@ def semantic_verifier(snapshot, verified_manifest_path: Path) -> bool:
actual_mdx = persisted_mdx_path.read_text()
from reverse_sync.base_parity import verify_source_identity
- identity = verify_source_identity(snapshot, expected_mdx, actual_mdx)
+ identity = verify_source_identity(
+ snapshot,
+ expected_mdx,
+ actual_mdx,
+ require_confluence_url=True,
+ )
if not identity.passed:
return False
return verify_push_equivalence(
diff --git a/confluence-mdx/tests/test_mdx_to_storage/test_inline.py b/confluence-mdx/tests/test_mdx_to_storage/test_inline.py
index c2af1737f..14526ca5f 100644
--- a/confluence-mdx/tests/test_mdx_to_storage/test_inline.py
+++ b/confluence-mdx/tests/test_mdx_to_storage/test_inline.py
@@ -84,6 +84,31 @@ def test_convert_inline_internal_link_to_ac_link(tmp_path):
)
+def test_convert_inline_escapes_internal_link_title_and_anchor(tmp_path):
+ pages_yaml = tmp_path / "pages.yaml"
+ pages_yaml.write_text(
+ """
+- page_id: "1"
+ title_orig: "Role & Policy > Guide"
+ path: ["role-policy"]
+""".strip(),
+ encoding="utf-8",
+ )
+ resolver = LinkResolver(pages_yaml)
+
+ got = convert_inline(
+ "[Role & Policy](role-policy#role&policy)",
+ link_resolver=resolver,
+ )
+
+ assert (
+ got
+ == ''
+ ''
+ "Role & Policy"
+ )
+
+
def test_convert_inline_unresolved_link_keeps_anchor(tmp_path):
pages_yaml = tmp_path / "pages.yaml"
pages_yaml.write_text("[]", encoding="utf-8")
diff --git a/confluence-mdx/tests/test_mdx_to_storage/test_link_resolver.py b/confluence-mdx/tests/test_mdx_to_storage/test_link_resolver.py
index ee50dd107..0cfe9dbd8 100644
--- a/confluence-mdx/tests/test_mdx_to_storage/test_link_resolver.py
+++ b/confluence-mdx/tests/test_mdx_to_storage/test_link_resolver.py
@@ -102,3 +102,34 @@ def test_resolve_relative_dotdot_with_current_page(tmp_path: Path):
title, anchor = resolver.resolve("../sibling", link_text="Sibling")
assert title == "Sibling"
assert anchor is None
+
+
+def test_resolve_relative_child_from_current_page_directory(tmp_path: Path):
+ pages_yaml = tmp_path / "pages.yaml"
+ pages_yaml.write_text(
+ """
+- page_id: "200"
+ title_orig: "Section"
+ path: ["docs", "section"]
+- page_id: "201"
+ title_orig: "Child"
+ path: ["docs", "section", "child"]
+""".strip(),
+ encoding="utf-8",
+ )
+ resolver = LinkResolver(pages_yaml)
+ resolver.set_current_page("200")
+
+ resolution = resolver.resolve_with_evidence("section/child")
+
+ assert resolution.status == "resolved"
+ assert resolution.candidate_page_ids == ("201",)
+
+
+def test_dot_anchor_is_local_to_current_page(tmp_path: Path):
+ resolver = LinkResolver([])
+
+ resolution = resolver.resolve_with_evidence(".#section")
+
+ assert resolution.status == "local_anchor"
+ assert resolution.anchor == "section"
diff --git a/confluence-mdx/tests/test_reverse_sync_cli.py b/confluence-mdx/tests/test_reverse_sync_cli.py
index 5e9455cdb..e568645bf 100644
--- a/confluence-mdx/tests/test_reverse_sync_cli.py
+++ b/confluence-mdx/tests/test_reverse_sync_cli.py
@@ -59,9 +59,13 @@ def _create_push_manifest(
improved_descriptor="src/content/ko/test.mdx",
patch_plan='{"schema_version":1}\n',
candidate_xhtml=candidate_body,
- local_proof='{"status":"verified_local"}\n',
+ local_proof=(
+ '{"dependencies":{"attachments":[],"internal_links":[],'
+ '"attachment_catalog_sha256":""},"push_eligible":true,'
+ '"status":"verified_local"}\n'
+ ),
verifier_policy="reverse-sync-equivalence-v1",
- tool_version="reverse-sync-cli-v2",
+ tool_version="reverse-sync-cli-v3",
push_eligible=True,
gates=tuple(
VerificationGate(name, True)
diff --git a/confluence-mdx/tests/test_reverse_sync_equivalence.py b/confluence-mdx/tests/test_reverse_sync_equivalence.py
index 32450a768..614e65998 100644
--- a/confluence-mdx/tests/test_reverse_sync_equivalence.py
+++ b/confluence-mdx/tests/test_reverse_sync_equivalence.py
@@ -2,6 +2,7 @@
from dataclasses import dataclass
+from reverse_sync.dependencies import DependencyResult
from reverse_sync.equivalence import (
PUSH_EQUIVALENCE_POLICY,
canonicalize_mdx,
@@ -212,7 +213,7 @@ def test_local_proof_requires_every_gate_and_returns_verified_local():
idempotent_candidate_xhtml=candidate,
source_identity_passed=True,
base_parity_passed=True,
- dependency_passed=True,
+ dependency_result=DependencyResult(True),
)
assert proof.status == "verified_local"
@@ -240,7 +241,7 @@ def test_local_proof_blocks_diagnostic_match_skips_and_non_idempotency():
idempotent_candidate_xhtml=candidate + "duplicate
",
source_identity_passed=True,
base_parity_passed=True,
- dependency_passed=True,
+ dependency_result=DependencyResult(True),
)
assert proof.status == "blocked"
@@ -283,7 +284,7 @@ def test_insert_operation_is_not_claimed_idempotent_when_it_duplicates():
idempotent_candidate_xhtml=applied_twice,
source_identity_passed=True,
base_parity_passed=True,
- dependency_passed=True,
+ dependency_result=DependencyResult(True),
)
assert proof.push_eligible is False
diff --git a/confluence-mdx/tests/test_reverse_sync_input_gates.py b/confluence-mdx/tests/test_reverse_sync_input_gates.py
new file mode 100644
index 000000000..c8a359ad0
--- /dev/null
+++ b/confluence-mdx/tests/test_reverse_sync_input_gates.py
@@ -0,0 +1,402 @@
+"""reverse-sync source identity와 dependency gate 계약 테스트."""
+
+from datetime import datetime, timezone
+from pathlib import Path
+
+import yaml
+
+from mdx_to_storage.link_resolver import LinkResolver, PageEntry
+from reverse_sync.base_parity import (
+ verify_base_parity,
+ verify_repository_source_identity,
+ verify_source_identity,
+)
+from reverse_sync.dependencies import verify_dependencies
+from reverse_sync.models import (
+ AttachmentCatalog,
+ AttachmentRecord,
+ PageSnapshot,
+)
+from reverse_sync.patch_builder import _resolve_generated_links
+
+
+NOW = datetime(2026, 7, 24, tzinfo=timezone.utc).isoformat()
+
+
+def _snapshot(body: str = "Before
") -> PageSnapshot:
+ return PageSnapshot(
+ page_id="123",
+ status="current",
+ title="Test page",
+ version=7,
+ storage_xhtml=body,
+ fetched_at=NOW,
+ api="fixture",
+ )
+
+
+def _mdx(body: str, *, page_id: str = "123") -> str:
+ return (
+ "---\n"
+ "title: 'Test page'\n"
+ f"confluenceUrl: 'https://example.atlassian.net/wiki/pages/{page_id}'\n"
+ "---\n\n"
+ "# Test page\n\n"
+ f"{body}"
+ )
+
+
+def _write_pages(path: Path, rows: list[dict]) -> Path:
+ path.write_text(yaml.safe_dump(rows, allow_unicode=True))
+ return path
+
+
+def test_repository_identity_binds_page_url_and_path(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [{"page_id": "123", "title_orig": "Test page", "path": ["guide", "test"]}],
+ )
+
+ result = verify_repository_source_identity(
+ _snapshot(),
+ _mdx("Before\n"),
+ _mdx("After\n"),
+ original_descriptor="main:src/content/ko/guide/test.mdx",
+ improved_descriptor="src/content/ko/guide/test.mdx",
+ pages_path=pages_path,
+ )
+
+ assert result.passed is True
+
+
+def test_repository_identity_blocks_wrong_url_or_descriptor_path(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [{"page_id": "123", "title_orig": "Test page", "path": ["guide", "test"]}],
+ )
+
+ wrong_url = verify_repository_source_identity(
+ _snapshot(),
+ _mdx("Before\n"),
+ _mdx("After\n", page_id="999"),
+ original_descriptor="main:src/content/ko/guide/test.mdx",
+ improved_descriptor="src/content/ko/guide/test.mdx",
+ pages_path=pages_path,
+ )
+ wrong_path = verify_repository_source_identity(
+ _snapshot(),
+ _mdx("Before\n"),
+ _mdx("After\n"),
+ original_descriptor="main:src/content/ko/guide/test.mdx",
+ improved_descriptor="src/content/ko/other.mdx",
+ pages_path=pages_path,
+ )
+
+ assert wrong_url.reason_code == "page_identity_mismatch"
+ assert wrong_path.reason_code == "page_identity_mismatch"
+
+
+def test_source_identity_blocks_frontmatter_title_and_h1_mismatch(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [{"page_id": "123", "title_orig": "Test page", "path": ["guide", "test"]}],
+ )
+ inconsistent = _mdx("Before\n").replace("# Test page", "# Different H1")
+
+ result = verify_repository_source_identity(
+ _snapshot(),
+ inconsistent,
+ inconsistent.replace("Before", "After"),
+ original_descriptor="main:src/content/ko/guide/test.mdx",
+ improved_descriptor="src/content/ko/guide/test.mdx",
+ pages_path=pages_path,
+ )
+
+ assert result.reason_code == "title_change_unsupported"
+
+
+def test_strict_source_identity_requires_confluence_url():
+ without_url = _mdx("Before\n").replace(
+ "confluenceUrl: 'https://example.atlassian.net/wiki/pages/123'\n",
+ "",
+ )
+
+ result = verify_source_identity(
+ _snapshot(),
+ _mdx("Before\n"),
+ without_url,
+ require_confluence_url=True,
+ )
+
+ assert result.reason_code == "page_identity_mismatch"
+
+
+def test_base_parity_classifies_stale_source_and_converter_drift():
+ snapshot = _snapshot("Remote
")
+ original = _mdx("Before\n")
+ converted = _mdx("Remote\n")
+
+ stale = verify_base_parity(
+ snapshot,
+ original,
+ converted,
+ provenance_storage_xhtml="Before
",
+ )
+ drift = verify_base_parity(
+ snapshot,
+ original,
+ converted,
+ provenance_storage_xhtml="Remote
",
+ )
+ unknown = verify_base_parity(snapshot, original, converted)
+
+ assert stale.reason_code == "stale_original_mdx"
+ assert drift.reason_code == "forward_converter_drift"
+ assert unknown.reason_code == "base_parity_mismatch"
+
+
+def test_dependency_gate_resolves_new_internal_link_and_renders_ac_link(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [
+ {
+ "page_id": "123",
+ "title_orig": "Test page",
+ "path": ["guide", "test"],
+ },
+ {
+ "page_id": "456",
+ "title_orig": "Target page",
+ "path": ["guide", "target"],
+ },
+ ],
+ )
+
+ result, resolver = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx("Before\n\n[Target](./target)\n"),
+ pages_path=pages_path,
+ attachment_catalog=None,
+ )
+ rendered = _resolve_generated_links(
+ 'Target
',
+ resolver,
+ )
+
+ assert result.passed is True
+ assert result.evidence.internal_links[0].page_id == "456"
+ assert (
+ rendered
+ == ''
+ 'Target
'
+ )
+
+
+def test_dependency_gate_blocks_unresolved_and_ambiguous_internal_links(tmp_path):
+ unresolved_pages = _write_pages(
+ tmp_path / "unresolved.yaml",
+ [
+ {
+ "page_id": "123",
+ "title_orig": "Test page",
+ "path": ["guide", "test"],
+ }
+ ],
+ )
+ unresolved, _ = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx("Before\n\n[Missing](./missing)\n"),
+ pages_path=unresolved_pages,
+ attachment_catalog=None,
+ )
+
+ ambiguous_pages = _write_pages(
+ tmp_path / "ambiguous.yaml",
+ [
+ {
+ "page_id": "123",
+ "title_orig": "Test page",
+ "path": ["guide", "test"],
+ },
+ {
+ "page_id": "456",
+ "title_orig": "Target A",
+ "path": ["guide", "target"],
+ },
+ {
+ "page_id": "789",
+ "title_orig": "Target B",
+ "path": ["guide", "target"],
+ },
+ ],
+ )
+ ambiguous, _ = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx("Before\n\n[Target](./target)\n"),
+ pages_path=ambiguous_pages,
+ attachment_catalog=None,
+ )
+
+ assert unresolved.reason_code == "internal_link_unresolved"
+ assert ambiguous.reason_code == "ambiguous_target"
+
+
+def test_dependency_gate_uses_attachment_catalog_for_new_image(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [{"page_id": "123", "title_orig": "Test page", "path": ["test"]}],
+ )
+ catalog = AttachmentCatalog(
+ page_id="123",
+ attachments=(
+ AttachmentRecord(
+ attachment_id="att-1",
+ page_id="123",
+ filename="screen.png",
+ version=3,
+ ),
+ ),
+ fetched_at=NOW,
+ api="fixture",
+ )
+
+ passed, _ = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx("Before\n\n\n"),
+ pages_path=pages_path,
+ attachment_catalog=catalog,
+ )
+ missing, _ = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx("Before\n\n\n"),
+ pages_path=pages_path,
+ attachment_catalog=catalog,
+ )
+
+ assert passed.passed is True
+ assert passed.evidence.attachments[0].attachment_id == "att-1"
+ assert missing.reason_code == "missing_attachment"
+
+
+def test_dependency_gate_blocks_new_external_image(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [{"page_id": "123", "title_orig": "Test page", "path": ["test"]}],
+ )
+
+ result, _ = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx(
+ "Before\n\n\n"
+ ),
+ pages_path=pages_path,
+ attachment_catalog=None,
+ )
+
+ assert result.reason_code == "dependency_failure"
+ assert "external image" in result.detail
+
+
+def test_dependency_gate_blocks_internal_html_link_attributes(tmp_path):
+ pages_path = _write_pages(
+ tmp_path / "pages.qm.yaml",
+ [
+ {"page_id": "123", "title_orig": "Test page", "path": ["test"]},
+ {"page_id": "456", "title_orig": "Target", "path": ["target"]},
+ ],
+ )
+
+ result, _ = verify_dependencies(
+ page_id="123",
+ original_mdx=_mdx("Before\n"),
+ improved_mdx=_mdx(
+ 'Before\n\nTarget\n'
+ ),
+ pages_path=pages_path,
+ attachment_catalog=None,
+ )
+
+ assert result.reason_code == "dependency_failure"
+ assert "class" in result.detail
+
+
+def test_attachment_link_renders_as_confluence_attachment_macro():
+ resolver = LinkResolver(
+ [PageEntry("123", "Test page", ["test"])]
+ )
+
+ rendered = _resolve_generated_links(
+ 'Download
',
+ resolver,
+ frozenset({"guide.pdf"}),
+ )
+
+ assert rendered == (
+ ''
+ 'Download
'
+ )
+
+
+def test_local_anchor_renders_as_confluence_link_macro():
+ resolver = LinkResolver(
+ [PageEntry("123", "Test page", ["test"])]
+ )
+
+ rendered = _resolve_generated_links(
+ 'Details
',
+ resolver,
+ )
+
+ assert rendered == (
+ ''
+ "Details
"
+ )
+
+
+def test_internal_html_link_with_href_after_other_whitespace_is_resolved():
+ resolver = LinkResolver(
+ [
+ PageEntry("123", "Test page", ["test"]),
+ PageEntry("456", "Target", ["target"]),
+ ]
+ )
+
+ rendered = _resolve_generated_links(
+ "Target
",
+ resolver,
+ )
+
+ assert '' in rendered
+
+
+def test_link_resolver_reports_duplicate_target_as_ambiguous():
+ resolver = LinkResolver(
+ [
+ PageEntry("1", "One", ["guide", "same"]),
+ PageEntry("2", "Two", ["guide", "same"]),
+ ]
+ )
+
+ resolution = resolver.resolve_with_evidence("guide/same", link_text="same")
+
+ assert resolution.status == "ambiguous"
+ assert resolution.candidate_page_ids == ("1", "2")
+
+
+def test_link_resolver_does_not_hide_wrong_path_with_matching_label():
+ resolver = LinkResolver(
+ [PageEntry("1", "Target", ["guide", "target"])]
+ )
+
+ resolution = resolver.resolve_with_evidence(
+ "guide/typo",
+ link_text="Target",
+ )
+
+ assert resolution.status == "unresolved"
diff --git a/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py b/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py
index 97c113e93..7f0eff567 100644
--- a/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py
+++ b/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py
@@ -19,6 +19,14 @@ def test_golden_page_builds_verified_manifest_without_remote_put():
original = (page_dir / "original.mdx").read_text()
improved = (page_dir / "improved.mdx").read_text()
frontmatter = yaml.safe_load(original.split("---", 2)[1])
+ improved = improved.replace(
+ "title: 'Reverse Sync Test Page'\n",
+ (
+ "title: 'Reverse Sync Test Page'\n"
+ f"confluenceUrl: '{frontmatter['confluenceUrl']}'\n"
+ ),
+ 1,
+ )
snapshot = PageSnapshot(
page_id=page_id,
status="current",
@@ -31,8 +39,14 @@ def test_golden_page_builds_verified_manifest_without_remote_put():
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")),
+ original_src=MdxSource(
+ original,
+ "main:src/content/ko/unreleased/reverse-sync-test-page.mdx",
+ ),
+ improved_src=MdxSource(
+ improved,
+ "src/content/ko/unreleased/reverse-sync-test-page.mdx",
+ ),
page_dir=str(page_dir),
base_snapshot=snapshot,
for_push=True,
diff --git a/confluence-mdx/tests/test_reverse_sync_push_transaction.py b/confluence-mdx/tests/test_reverse_sync_push_transaction.py
index ecb175c3c..6d8c78532 100644
--- a/confluence-mdx/tests/test_reverse_sync_push_transaction.py
+++ b/confluence-mdx/tests/test_reverse_sync_push_transaction.py
@@ -3,19 +3,24 @@
import argparse
from dataclasses import replace
from datetime import datetime, timezone
+import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
+import yaml
from reverse_sync.confluence_client import (
ConfluenceConfig,
+ ConfluenceGateway,
+ InvalidDependencySnapshotError,
InvalidPageSnapshotError,
NetworkError,
PermissionDeniedError,
VersionConflictError,
get_active_draft,
+ get_attachment_catalog,
get_page_snapshot,
update_page,
)
@@ -26,9 +31,16 @@
create_sync_manifest,
load_sync_manifest,
)
-from reverse_sync.models import PageSnapshot, SyncStatus, VerificationGate
+from reverse_sync.models import (
+ AttachmentCatalog,
+ AttachmentRecord,
+ PageSnapshot,
+ SyncStatus,
+ VerificationGate,
+)
from reverse_sync.publisher import (
ActiveDraftError,
+ DependencyChangedError,
PostconditionError,
RemoteDriftError,
publish_verified_manifest,
@@ -59,7 +71,76 @@ def _snapshot(
)
-def _manifest(tmp_path: Path, base: PageSnapshot | None = None) -> Path:
+def _write_page_catalog(tmp_path: Path, page_id: str = "123") -> None:
+ var_dir = tmp_path / "var"
+ var_dir.mkdir(parents=True, exist_ok=True)
+ (var_dir / "pages.qm.yaml").write_text(
+ yaml.safe_dump(
+ [
+ {
+ "page_id": page_id,
+ "title_orig": "Test page",
+ "path": ["test"],
+ }
+ ],
+ allow_unicode=True,
+ )
+ )
+
+
+def _source_mdx(body: str, page_id: str = "123") -> str:
+ return (
+ "---\n"
+ "title: 'Test page'\n"
+ f"confluenceUrl: 'https://example.atlassian.net/wiki/pages/{page_id}'\n"
+ "---\n\n"
+ "# Test page\n\n"
+ f"{body}"
+ )
+
+
+def _manifest(
+ tmp_path: Path,
+ base: PageSnapshot | None = None,
+ *,
+ required_attachment: str = "",
+ required_link: tuple[str, str, str] | None = None,
+ malformed_attachment_evidence: bool = False,
+) -> Path:
+ attachments = []
+ if required_attachment:
+ attachments.append(
+ {
+ "attachment_id": "att-1",
+ "filename": required_attachment,
+ "version": 1,
+ }
+ )
+ if malformed_attachment_evidence:
+ attachments[0].pop("attachment_id")
+ internal_links = []
+ if required_link is not None:
+ page_id, content_title, href = required_link
+ internal_links.append(
+ {
+ "content_title": content_title,
+ "href": href,
+ "page_id": page_id,
+ }
+ )
+ local_proof = json.dumps(
+ {
+ "dependencies": {
+ "attachment_catalog_sha256": "0" * 64 if attachments else "",
+ "attachments": attachments,
+ "internal_links": internal_links,
+ },
+ "push_eligible": True,
+ "status": "verified_local",
+ },
+ separators=(",", ":"),
+ sort_keys=True,
+ ) + "\n"
return create_sync_manifest(
runs_dir=tmp_path / "reverse-sync",
base=base or _snapshot(),
@@ -69,9 +150,9 @@ def _manifest(tmp_path: Path, base: PageSnapshot | None = None) -> Path:
improved_descriptor="src/content/ko/test.mdx",
patch_plan='{"schema_version":1}\n',
candidate_xhtml="After
",
- local_proof='{"status":"verified_local"}\n',
+ local_proof=local_proof,
verifier_policy="reverse-sync-equivalence-v1",
- tool_version="reverse-sync-cli-v2",
+ tool_version="reverse-sync-cli-v3",
push_eligible=True,
gates=tuple(
VerificationGate(name, True)
@@ -87,12 +168,18 @@ def __init__(
*,
draft: PageSnapshot | None = None,
update_error: Exception | None = None,
+ attachment_catalog: AttachmentCatalog | None = None,
+ linked_pages: dict[str, PageSnapshot] | None = None,
):
self.current_snapshots = list(current_snapshots)
self.draft = draft
self.update_error = update_error
+ self.attachment_catalog = attachment_catalog
+ self.linked_pages = linked_pages or {}
self.current_calls = 0
self.draft_calls = 0
+ self.attachment_calls = 0
+ self.link_calls: list[str] = []
self.update_calls: list[dict] = []
def get_current_page(self, page_id: str) -> PageSnapshot:
@@ -107,6 +194,16 @@ def get_active_draft(self, page_id: str) -> PageSnapshot | None:
assert self.draft.page_id == page_id
return self.draft
+ def get_attachment_catalog(self, page_id: str) -> AttachmentCatalog:
+ self.attachment_calls += 1
+ assert self.attachment_catalog is not None
+ assert self.attachment_catalog.page_id == page_id
+ return self.attachment_catalog
+
+ def get_page_identity(self, page_id: str) -> PageSnapshot:
+ self.link_calls.append(page_id)
+ return self.linked_pages[page_id]
+
def update_page(
self,
page_id: str,
@@ -166,6 +263,21 @@ def test_proof_artifact_tampering_blocks_before_remote_read(
assert gateway.update_calls == []
+def test_malformed_dependency_evidence_blocks_before_remote_read(tmp_path):
+ manifest_path = _manifest(
+ tmp_path,
+ required_attachment="screen.png",
+ malformed_attachment_evidence=True,
+ )
+ gateway = FakeGateway([_snapshot()])
+
+ with pytest.raises(ArtifactTamperedError, match="dependency evidence"):
+ 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
")
@@ -202,6 +314,117 @@ def test_active_draft_blocks_before_put(tmp_path):
assert gateway.update_calls == []
+def test_missing_attachment_at_preflight_blocks_before_put(tmp_path):
+ manifest_path = _manifest(
+ tmp_path,
+ required_attachment="screen.png",
+ )
+ catalog = AttachmentCatalog(
+ page_id="123",
+ attachments=(),
+ fetched_at=NOW.isoformat(),
+ api="fixture",
+ )
+ gateway = FakeGateway(
+ [_snapshot()],
+ attachment_catalog=catalog,
+ )
+
+ with pytest.raises(DependencyChangedError) as exc_info:
+ publish_verified_manifest(manifest_path, gateway)
+
+ assert exc_info.value.reason_code == "dependency_failure"
+ assert gateway.attachment_calls == 1
+ assert gateway.update_calls == []
+ assert (manifest_path.parent / "preflight.attachments.json").is_file()
+
+
+def test_existing_attachment_at_preflight_allows_put(tmp_path):
+ manifest_path = _manifest(
+ tmp_path,
+ required_attachment="screen.png",
+ )
+ catalog = AttachmentCatalog(
+ page_id="123",
+ attachments=(
+ AttachmentRecord(
+ attachment_id="att-1",
+ page_id="123",
+ filename="screen.png",
+ version=2,
+ ),
+ ),
+ fetched_at=NOW.isoformat(),
+ api="fixture",
+ )
+ gateway = FakeGateway(
+ [
+ _snapshot(),
+ _snapshot(version=6, body="After
"),
+ ],
+ attachment_catalog=catalog,
+ )
+
+ receipt = publish_verified_manifest(manifest_path, gateway)
+
+ assert receipt.status is SyncStatus.REMOTE_VERIFIED
+ assert gateway.attachment_calls == 1
+ assert len(gateway.update_calls) == 1
+
+
+def test_internal_link_target_at_preflight_allows_put(tmp_path):
+ manifest_path = _manifest(
+ tmp_path,
+ required_link=("456", "Target page", "./target"),
+ )
+ gateway = FakeGateway(
+ [
+ _snapshot(),
+ _snapshot(version=6, body="After
"),
+ ],
+ linked_pages={
+ "456": _snapshot(
+ page_id="456",
+ version=3,
+ title="Target page",
+ body="Target
",
+ )
+ },
+ )
+
+ receipt = publish_verified_manifest(manifest_path, gateway)
+
+ assert receipt.status is SyncStatus.REMOTE_VERIFIED
+ assert gateway.link_calls == ["456"]
+ assert (manifest_path.parent / "preflight.link-pages.json").is_file()
+ assert len(gateway.update_calls) == 1
+
+
+def test_changed_internal_link_target_blocks_before_put(tmp_path):
+ manifest_path = _manifest(
+ tmp_path,
+ required_link=("456", "Target page", "./target"),
+ )
+ gateway = FakeGateway(
+ [_snapshot()],
+ linked_pages={
+ "456": _snapshot(
+ page_id="456",
+ version=4,
+ title="Renamed target",
+ body="Target
",
+ )
+ },
+ )
+
+ with pytest.raises(DependencyChangedError, match="internal link"):
+ publish_verified_manifest(manifest_path, gateway)
+
+ assert gateway.link_calls == ["456"]
+ assert gateway.update_calls == []
+ assert (manifest_path.parent / "preflight.link-pages.json").is_file()
+
+
def test_preflight_put_race_is_not_retried_with_latest_version(tmp_path):
manifest_path = _manifest(tmp_path)
gateway = FakeGateway(
@@ -353,9 +576,13 @@ def test_push_manifest_requires_all_local_proof_gates(tmp_path):
improved_descriptor="src/content/ko/test.mdx",
patch_plan='{"schema_version":1}\n',
candidate_xhtml="After
",
- local_proof='{"status":"verified_local"}\n',
+ local_proof=(
+ '{"dependencies":{"attachments":[],"internal_links":[],'
+ '"attachment_catalog_sha256":""},"push_eligible":true,'
+ '"status":"verified_local"}\n'
+ ),
verifier_policy="reverse-sync-equivalence-v1",
- tool_version="reverse-sync-cli-v2",
+ tool_version="reverse-sync-cli-v3",
push_eligible=True,
gates=(VerificationGate("semantic_roundtrip", True),),
)
@@ -397,11 +624,163 @@ def test_v2_snapshot_uses_one_response_for_version_title_and_body():
assert len(snapshot.storage_sha256) == 64
+def test_v2_attachment_catalog_reads_every_page():
+ first = MagicMock()
+ first.json.return_value = {
+ "results": [
+ {
+ "id": "a1",
+ "status": "current",
+ "title": "first.png",
+ "pageId": "123",
+ "version": {"number": 2},
+ }
+ ]
+ }
+ first.raise_for_status.return_value = None
+ first.links = {
+ "next": {
+ "url": (
+ "https://example.atlassian.net/wiki/api/v2/pages/"
+ "123/attachments?cursor=next"
+ )
+ }
+ }
+ second = MagicMock()
+ second.json.return_value = {
+ "results": [
+ {
+ "id": "a2",
+ "status": "current",
+ "title": "second.png",
+ "pageId": "123",
+ "version": {"number": 1},
+ }
+ ]
+ }
+ second.raise_for_status.return_value = None
+ second.links = {}
+
+ with patch(
+ "reverse_sync.confluence_client.requests.get",
+ side_effect=[first, second],
+ ) as get:
+ catalog = get_attachment_catalog(
+ ConfluenceConfig(
+ base_url="https://example.atlassian.net/wiki",
+ email="e",
+ api_token="t",
+ ),
+ "123",
+ fetched_at=NOW,
+ )
+
+ assert [item.filename for item in catalog.attachments] == [
+ "first.png",
+ "second.png",
+ ]
+ assert get.call_count == 2
+ assert get.call_args_list[0].kwargs["params"] == {
+ "status": ["current"],
+ "limit": 250,
+ }
+ assert get.call_args_list[1].kwargs["params"] is None
+
+
+def test_v2_attachment_catalog_rejects_cross_page_item():
+ response = MagicMock()
+ response.json.return_value = {
+ "results": [
+ {
+ "id": "a1",
+ "status": "current",
+ "title": "screen.png",
+ "pageId": "999",
+ "version": {"number": 1},
+ }
+ ]
+ }
+ response.raise_for_status.return_value = None
+ response.links = {}
+
+ with patch(
+ "reverse_sync.confluence_client.requests.get",
+ return_value=response,
+ ), pytest.raises(InvalidDependencySnapshotError):
+ get_attachment_catalog(
+ ConfluenceConfig(
+ base_url="https://example.atlassian.net/wiki",
+ email="e",
+ api_token="t",
+ ),
+ "123",
+ fetched_at=NOW,
+ )
+
+
+def test_v2_attachment_catalog_rejects_cross_origin_pagination():
+ response = MagicMock()
+ response.json.return_value = {"results": []}
+ response.raise_for_status.return_value = None
+ response.links = {
+ "next": {
+ "url": "https://attacker.example/api/v2/pages/123/attachments"
+ }
+ }
+
+ with patch(
+ "reverse_sync.confluence_client.requests.get",
+ return_value=response,
+ ), pytest.raises(InvalidDependencySnapshotError, match="범위를 벗어납니다"):
+ get_attachment_catalog(
+ ConfluenceConfig(
+ base_url="https://example.atlassian.net/wiki",
+ email="e",
+ api_token="t",
+ ),
+ "123",
+ fetched_at=NOW,
+ )
+
+
+def test_linked_page_not_found_maps_to_dependency_failure():
+ response = MagicMock()
+ response.status_code = 404
+ response.raise_for_status.side_effect = requests.HTTPError(response=response)
+ gateway = ConfluenceGateway(
+ ConfluenceConfig(
+ base_url="https://example.atlassian.net/wiki",
+ email="e",
+ api_token="t",
+ )
+ )
+
+ with patch(
+ "reverse_sync.confluence_client.requests.get",
+ return_value=response,
+ ), pytest.raises(InvalidDependencySnapshotError) as exc_info:
+ gateway.get_page_identity("456")
+
+ assert exc_info.value.reason_code == "dependency_failure"
+
+
@pytest.mark.parametrize(
"payload",
[
{"id": "different", "status": "current", "title": "T", "version": {"number": 1}},
{"id": "123", "status": "draft", "title": "T", "version": {"number": 1}},
+ {
+ "id": "123",
+ "status": "current",
+ "title": "T",
+ "version": {"number": True},
+ "body": {
+ "storage": {
+ "representation": "storage",
+ "value": "x
",
+ }
+ },
+ },
{
"id": "123",
"status": "current",
@@ -618,16 +997,71 @@ def test_prepare_push_fetches_one_snapshot_and_passes_it_to_verify():
assert verify.call_args.kwargs["for_push"] is True
+def test_prepare_push_fetches_attachment_catalog_for_new_reference():
+ args = argparse.Namespace(
+ improved_mdx="src/content/ko/test.mdx",
+ original_mdx=None,
+ page_id=None,
+ page_dir=None,
+ lenient=False,
+ no_normalize=False,
+ )
+ base = _snapshot()
+ improved = MdxSource(
+ "# Test page\n\nBefore\n\n\n",
+ "src/content/ko/test.mdx",
+ )
+ original = MdxSource(
+ "# Test page\n\nBefore\n",
+ "main:src/content/ko/test.mdx",
+ )
+ catalog = AttachmentCatalog(
+ page_id="123",
+ attachments=(
+ AttachmentRecord(
+ attachment_id="att-1",
+ page_id="123",
+ filename="screen.png",
+ version=1,
+ ),
+ ),
+ fetched_at=NOW.isoformat(),
+ api="fixture",
+ )
+
+ with patch(
+ "reverse_sync_cli._resolve_mdx_source",
+ side_effect=[improved, original],
+ ), patch(
+ "reverse_sync_cli._resolve_page_id",
+ return_value="123",
+ ), patch(
+ "reverse_sync.confluence_client.get_page_snapshot",
+ return_value=base,
+ ), patch(
+ "reverse_sync.confluence_client.get_attachment_catalog",
+ return_value=catalog,
+ ) as get_catalog, patch(
+ "reverse_sync_cli.run_verify",
+ return_value={"status": "verified_local"},
+ ) as verify:
+ _do_verify(args, config=MagicMock(), prepare_push=True)
+
+ get_catalog.assert_called_once()
+ assert verify.call_args.kwargs["attachment_catalog"] is catalog
+
+
def test_online_verify_builds_manifest_from_remote_snapshot(tmp_path, monkeypatch):
monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
page_id = "123"
(tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
base = _snapshot(
title="Test page",
body="Section
Before
",
)
- original = "# Test page\n\n## Section\n\nBefore\n"
- improved = "# Test page\n\n## Section\n\nAfter\n"
+ original = _source_mdx("## Section\n\nBefore\n", page_id)
+ improved = _source_mdx("## Section\n\nAfter\n", page_id)
def forward_convert(input_path, output_path, _page_id, **_kwargs):
content = original if Path(output_path).name == "reverse-sync.base.mdx" else improved
@@ -658,7 +1092,7 @@ def forward_convert(input_path, output_path, _page_id, **_kwargs):
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.tool_version == "reverse-sync-cli-v3"
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() == (
@@ -672,12 +1106,13 @@ def test_online_verify_proves_insert_idempotent_by_replanning(
monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
page_id = "123"
(tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
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"
+ original = _source_mdx("## Section\n\nBefore\n", page_id)
+ improved = _source_mdx("## Section\n\nBefore\n\nAdded\n", page_id)
def forward_convert(input_path, output_path, _page_id, **_kwargs):
content = original if Path(output_path).name == "reverse-sync.base.mdx" else improved
@@ -701,17 +1136,147 @@ def forward_convert(input_path, output_path, _page_id, **_kwargs):
assert idempotency["passed"] is True
+def test_online_verify_renders_new_internal_link_as_confluence_macro(
+ tmp_path, monkeypatch
+):
+ monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
+ page_id = "123"
+ (tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
+ pages_path = tmp_path / "var" / "pages.qm.yaml"
+ pages = yaml.safe_load(pages_path.read_text())
+ pages.append(
+ {
+ "page_id": "456",
+ "title_orig": "Target page",
+ "path": ["target"],
+ }
+ )
+ pages_path.write_text(yaml.safe_dump(pages, allow_unicode=True))
+ base = _snapshot(
+ title="Test page",
+ body="Section
Before
",
+ )
+ original = _source_mdx("## Section\n\nBefore\n", page_id)
+ improved = _source_mdx(
+ "## Section\n\nBefore\n\n[Target](target)\n",
+ page_id,
+ )
+
+ 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"
+ candidate = Path(result["manifest_path"]).parent / "candidate.xhtml"
+ assert (
+ ''
+ in candidate.read_text()
+ )
+
+
+def test_online_verify_renders_existing_attachment_reference(
+ tmp_path, monkeypatch
+):
+ monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
+ page_id = "123"
+ (tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
+ base = _snapshot(
+ title="Test page",
+ body="Section
Before
",
+ )
+ original = _source_mdx("## Section\n\nBefore\n", page_id)
+ improved = _source_mdx(
+ '## Section\n\nBefore\n\n
\n',
+ page_id,
+ )
+ catalog = AttachmentCatalog(
+ page_id=page_id,
+ attachments=(
+ AttachmentRecord(
+ attachment_id="att-1",
+ page_id=page_id,
+ filename="screen.png",
+ version=3,
+ ),
+ ),
+ fetched_at=NOW.isoformat(),
+ api="fixture",
+ )
+
+ 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,
+ attachment_catalog=catalog,
+ for_push=True,
+ )
+
+ assert result["status"] == "verified_local"
+ run_dir = Path(result["manifest_path"]).parent
+ candidate = (run_dir / "candidate.xhtml").read_text()
+ proof = json.loads((run_dir / "local-proof.json").read_text())
+ assert '' in candidate
+ assert proof["dependencies"]["attachments"] == [
+ {
+ "attachment_id": "att-1",
+ "filename": "screen.png",
+ "version": 3,
+ }
+ ]
+
+
def test_online_verify_blocks_stale_original_before_patch(tmp_path, monkeypatch):
monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
page_id = "123"
(tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
base = _snapshot(title="Test page", body="Remote edit
")
- original = "# Test page\n\nBefore\n"
- improved = "# Test page\n\nAfter\n"
+ original = _source_mdx("Before\n", page_id)
+ improved = _source_mdx("After\n", page_id)
def forward_convert(_input_path, output_path, _page_id, **_kwargs):
- Path(output_path).write_text("# Test page\n\nRemote edit\n")
- return "# Test page\n\nRemote edit\n"
+ converted = _source_mdx("Remote edit\n", page_id)
+ Path(output_path).write_text(converted)
+ return converted
with patch("reverse_sync_cli._forward_convert", side_effect=forward_convert):
result = run_verify(
@@ -764,13 +1329,17 @@ def test_lenient_match_is_diagnostic_and_never_grants_push_eligibility(
monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
page_id = "123"
(tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
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"
+ original = _source_mdx("## Section\n\nBefore\n", page_id)
+ improved = _source_mdx("## Section\n\n2024년 01월 15일\n", page_id)
+ diagnostic_roundtrip = _source_mdx(
+ "## Section\n\nJan 15, 2024\n",
+ page_id,
+ )
def forward_convert(_input_path, output_path, _page_id, **_kwargs):
content = (
@@ -784,8 +1353,14 @@ def forward_convert(_input_path, output_path, _page_id, **_kwargs):
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"),
+ 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,
lenient=True,
@@ -802,15 +1377,27 @@ def test_online_verify_blocks_missing_attachment(tmp_path, monkeypatch):
monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path)
page_id = "123"
(tmp_path / "var" / page_id).mkdir(parents=True)
+ _write_page_catalog(tmp_path, page_id)
+ original = _source_mdx("Before\n", page_id)
+ improved = _source_mdx("Before\n\n\n", page_id)
result = run_verify(
page_id=page_id,
- original_src=MdxSource("# Test page\n\nBefore\n", "original.mdx"),
+ original_src=MdxSource(
+ original,
+ "main:src/content/ko/test.mdx",
+ ),
improved_src=MdxSource(
- "# Test page\n\nBefore\n\n\n",
- "improved.mdx",
+ improved,
+ "src/content/ko/test.mdx",
),
base_snapshot=_snapshot(title="Test page"),
+ attachment_catalog=AttachmentCatalog(
+ page_id=page_id,
+ attachments=(),
+ fetched_at=NOW.isoformat(),
+ api="fixture",
+ ),
for_push=True,
)
diff --git a/openspec/changes/complete-reverse-sync/design.md b/openspec/changes/complete-reverse-sync/design.md
index ab3a5bac5..2aa239a70 100644
--- a/openspec/changes/complete-reverse-sync/design.md
+++ b/openspec/changes/complete-reverse-sync/design.md
@@ -44,6 +44,8 @@
[Confluence Cloud REST API v2 Page](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/)는 Storage representation과 version을 포함한 page 조회 및 version을 포함한 page update를 제공합니다. 또한 current version update가 draft에 reconciliation될 수 있고, 두 content가 크게 다르면 제공한 current body가 draft를 덮을 수 있음을 명시합니다.
+[Confluence Cloud REST API v2 Attachment](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/)는 page attachment 목록과 cursor pagination을 제공합니다. reverse-sync는 모든 page를 조회한 current attachment catalog를 새 attachment reference의 dependency snapshot으로 사용합니다.
+
따라서 version number만 확인해서는 충분하지 않습니다.
- 검증에 사용한 body와 원격 current body가 같은지 확인해야 합니다.
@@ -192,6 +194,21 @@ active_draft: false
base parity 실패 상태에서 patch를 계속 만들 수는 있지만 결과는 diagnostic이며 push할 수 없습니다. 기본 CLI는 즉시 block합니다.
+구현은 `page.v1.yaml`의 page ID와 Storage body를 classification-only provenance로
+사용합니다. 이 provenance body가 현재 remote body와 같으면 converter drift,
+다르면 stale original로 분류합니다. provenance는 실패 사유만 세분화하며
+push eligibility를 부여하지 않습니다.
+
+source identity는 다음 세 값을 하나로 결합합니다.
+
+- current `PageSnapshot.page_id`
+- original/improved frontmatter의 `confluenceUrl` page ID
+- original/improved descriptor의 동일한 `src/content/ko/**.mdx` path와
+ `pages.qm.yaml`의 유일한 page ID/path row
+
+frontmatter title과 첫 H1도 각 문서 안에서 같아야 하며 original/improved 사이에서
+변경되지 않아야 합니다.
+
### Decision: block identity는 provenance-first로 해결합니다
우선순위는 다음과 같습니다.
@@ -242,7 +259,8 @@ changed fragment는 capability registry에 따라 다음 전략 중 하나를
| `raw_html_table_edit` | blocked | 명시적으로 승인된 cell text-only 전략 전까지 차단 |
| `unknown_macro_mutation` | blocked | unchanged macro는 byte-preserving |
| `page_title_change` | blocked | 별도 page mutation contract 필요 |
-| `new_attachment_reference` | blocked | attachment upload contract 필요 |
+| `existing_attachment_reference` | conditional | current catalog에 유일한 filename이 있어야 함 |
+| `new_attachment_lifecycle` | blocked | attachment upload/update/delete contract 필요 |
| `active_draft_reconciliation` | blocked | 자동 merge하지 않음 |
registry entry는 다음을 가져야 합니다.
@@ -364,29 +382,37 @@ publisher의 순서는 다음과 같습니다.
1. verified manifest와 candidate hash를 검증합니다.
2. 원격 current `PageSnapshot R`을 단일 조회로 가져옵니다.
3. `R.page_id`, `R.status`, `R.version`, `R.title`, `R.storage_sha256`를 base `B`와 비교합니다.
-4. `R`이 base와 다르지만 이미 improved MDX와 동등하면 PUT을 생략하고 `already_applied`로 기록합니다.
-5. active draft가 확인되면 `active_draft`로 차단합니다.
-6. base와 다른 나머지 경우는 `remote_drift`로 차단하고 PUT을 호출하지 않습니다.
-7. `version = B.version + 1`, `title = B.title`, `body = C`로 update합니다.
-8. API conflict는 HTTP 409만 가정하지 않고 adapter가 version conflict response를 표준 reason으로 변환합니다.
-9. 성공 응답 후 원격 `PageSnapshot P`를 다시 가져옵니다.
-10. `P.version == B.version + 1`이고 `forward(P.body) == I`인지 검증합니다.
-11. 통과하면 `remote_verified`, 실패하면 `postcondition_failed`로 기록합니다.
+4. active draft가 확인되면 `active_draft`로 차단합니다.
+5. local proof가 요구한 attachment filename과 internal page ID/title을 원격에서 다시 확인합니다.
+6. `R`이 base와 다르지만 이미 improved MDX와 동등하면 PUT을 생략하고 `already_applied`로 기록합니다.
+7. base와 다른 나머지 경우는 `remote_drift`로 차단하고 PUT을 호출하지 않습니다.
+8. `version = B.version + 1`, `title = B.title`, `body = C`로 update합니다.
+9. API conflict는 HTTP 409만 가정하지 않고 adapter가 version conflict response를 표준 reason으로 변환합니다.
+10. 성공 응답 후 원격 `PageSnapshot P`를 다시 가져옵니다.
+11. `P.version == B.version + 1`이고 `forward(P.body) == I`인지 검증합니다.
+12. 통과하면 `remote_verified`, 실패하면 `postcondition_failed`로 기록합니다.
preflight와 PUT 사이의 race는 API version compare-and-set이 방어합니다. preflight에서 latest version을 읽어 새 base로 채택하지 않습니다.
active draft 감지 방식은 Confluence v2 adapter contract test와 canary에서 확정합니다. API가 안정적으로 draft 존재 여부를 제공하지 못하면 push를 허용하는 조건과 운영 절차를 별도 승인하기 전까지 자동 push rollout을 중단합니다.
-### Decision: title과 attachment를 조용히 무시하지 않습니다
+### Decision: title과 dependency를 조용히 무시하지 않습니다
첫 구현에서 다음은 block reason입니다.
- original/improved frontmatter title 또는 첫 H1이 변경됨
-- frontmatter title과 첫 H1이 일치하지 않음
-- improved MDX가 base에 존재하지 않는 attachment filename을 참조함
-- link resolver가 `#link-error` 또는 ambiguous page를 생성함
+- 각 문서의 frontmatter title과 첫 H1이 일치하지 않음
+- improved MDX가 current attachment catalog에 없는 filename을 새로 참조함
+- link resolver가 target을 resolve하지 못하거나 여러 page가 일치함
+- verify 이후 push preflight에서 attachment가 사라지거나 linked page ID/title이 바뀜
+
+이미 page에 존재하는 attachment의 새 reference는 catalog identity를 local proof에
+기록하고 push 직전에 filename 존재를 다시 확인한 경우에만 허용합니다. Markdown
+internal link는 catalog path로 유일하게 resolve하여 Confluence `ac:link`/`ri:page`
+macro로 렌더링하고, target page ID/status/title을 push 직전에 재검증합니다.
-향후 title update와 attachment upload는 별도 capability와 별도 API transaction으로 추가합니다. body update에 암묵적으로 섞지 않습니다.
+향후 title update와 attachment upload/update/delete는 별도 capability와 별도 API
+transaction으로 추가합니다. body update에 암묵적으로 섞지 않습니다.
### Decision: batch push는 page별 독립 transaction입니다
@@ -420,7 +446,7 @@ planned
- input: `page_identity_mismatch`, `stale_original_mdx`, `base_parity_mismatch`
- planning: `missing_identity`, `ambiguous_target`, `unsupported_capability`
- proof: `skipped_change`, `preservation_mismatch`, `semantic_mismatch`, `artifact_tampered`
-- dependency: `missing_attachment`, `link_resolution_error`, `title_change_unsupported`
+- dependency: `missing_attachment`, `internal_link_unresolved`, `ambiguous_target`, `title_change_unsupported`
- publish: `active_draft`, `remote_drift`, `version_conflict`, `permission_denied`
- postcondition: `persisted_body_mismatch`, `persisted_version_mismatch`
- system: `network_error`, `parse_error`, `converter_error`
@@ -447,7 +473,8 @@ Confluence update는 외부 side effect이며 postcondition 실패 후 완전한
| --- | --- |
| immutable model/state/reason | `reverse_sync/models.py` |
| Confluence snapshot adapter | `reverse_sync/confluence_client.py`, `reverse_sync/snapshot.py` |
-| base parity와 dependency gate | `reverse_sync/base_parity.py` |
+| base parity | `reverse_sync/base_parity.py` |
+| attachment/link dependency gate | `reverse_sync/dependencies.py` |
| capability/identity/edit planning | `reverse_sync/planner.py`, `reverse_sync/capabilities.py` |
| visible edit/node operation | `reverse_sync/visible_model.py`, `reverse_sync/operations.py` |
| capability별 render | `reverse_sync/strategies/**` |
@@ -469,7 +496,8 @@ Confluence update는 외부 side effect이며 postcondition 실패 후 완전한
| normalized/lenient match | 무관 | 불가 | diagnostic only |
| skipped/unsupported 존재 | 무관 | 불가 | `intent_complete` 실패 |
| title 변경 | 무관 | 불가 | 첫 구현 범위 밖 |
-| 새 attachment 참조 | 무관 | 불가 | attachment transaction 없음 |
+| 기존 attachment 새 참조 | catalog와 preflight에서 filename 존재 | 가능 | upload는 수행하지 않음 |
+| 존재하지 않는 attachment 참조 | 무관 | 불가 | `missing_attachment` |
| active draft | 무관 | 불가 | draft reconciliation 자동화 없음 |
| artifact hash 불일치 | 무관 | 불가 | 검증 payload와 push payload 불일치 |
@@ -536,6 +564,8 @@ full emitter는 `owned_replace` 가능한 clean fragment와 insert에만 사용
### Phase 2: base parity와 strict proof
- page identity, base parity, dependency gate를 추가합니다.
+- existing attachment와 internal page dependency evidence를 manifest에 결합하고
+ publisher preflight에서 다시 확인합니다.
- verifier normalization을 typed equivalence로 분류합니다.
- skipped change와 diagnostic match가 push eligibility를 얻지 못하도록 합니다.
diff --git a/openspec/changes/complete-reverse-sync/specs/contract-reverse-sync/spec.md b/openspec/changes/complete-reverse-sync/specs/contract-reverse-sync/spec.md
index 75dee57c9..fc88f275f 100644
--- a/openspec/changes/complete-reverse-sync/specs/contract-reverse-sync/spec.md
+++ b/openspec/changes/complete-reverse-sync/specs/contract-reverse-sync/spec.md
@@ -12,6 +12,7 @@ MDX 변경을 기존 Confluence Storage XHTML의 보존 정보와 안전하게
- `confluence-mdx/bin/reverse_sync_cli.py`
- `confluence-mdx/bin/reverse_sync/**`
- [Confluence Cloud REST API v2 Page](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/)
+- [Confluence Cloud REST API v2 Attachment](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/)
## ADDED Requirements
@@ -53,6 +54,14 @@ reverse-sync는 patch를 push eligible로 판정하기 전에 base snapshot을 f
- THEN 변환 결과와 `O`의 page ID, `confluenceUrl`, content가 push equivalence policy에서 일치해야 합니다(SHALL).
- AND 사용한 converter/tool version과 입력 hash를 manifest에 기록해야 합니다(SHALL).
+#### Scenario: repository source identity
+
+- GIVEN original/improved MDX와 current `PageSnapshot B`가 있습니다.
+- WHEN source identity를 검증합니다.
+- THEN 두 MDX descriptor는 같은 `src/content/ko/**.mdx` path를 가리켜야 합니다(SHALL).
+- AND 해당 path, `B.page_id`, 두 MDX의 `confluenceUrl` page ID가 page catalog의 유일한 row에서 일치해야 합니다(SHALL).
+- AND 하나라도 없거나 중복되거나 다르면 `page_identity_mismatch`로 block해야 합니다(SHALL).
+
#### Scenario: stale original MDX
- GIVEN 원격 base snapshot에는 original MDX에 없는 content가 있습니다.
@@ -247,7 +256,11 @@ publisher는 update 응답을 성공의 최종 증거로 취급하지 않고 per
### Requirement: Explicit Dependency Boundaries
-첫 reverse-sync completion 범위는 body content update로 제한하며 title 변경, 새 attachment lifecycle, unresolved link를 암묵적으로 처리해서는 안 됩니다(SHALL NOT).
+reverse-sync는 body에 새로 추가되는 attachment와 internal page dependency를 명시적 catalog 및 preflight gate로 검증해야 합니다(SHALL).
+
+첫 reverse-sync completion 범위는 body content update로 제한하며 title 변경,
+attachment upload/update/delete lifecycle, unresolved link를 암묵적으로
+처리해서는 안 됩니다(SHALL NOT).
#### Scenario: title 변경
@@ -255,18 +268,42 @@ publisher는 update 응답을 성공의 최종 증거로 취급하지 않고 per
- WHEN planner가 변경을 분석합니다.
- THEN `title_change_unsupported`로 block해야 합니다(SHALL).
-#### Scenario: 새 attachment
+#### Scenario: 존재하지 않는 attachment 참조
- GIVEN improved MDX가 base page에 존재하지 않는 attachment filename을 참조합니다.
- WHEN dependency gate를 실행합니다.
- THEN `missing_attachment`로 block해야 합니다(SHALL).
- AND broken `ri:attachment` 참조를 포함한 body를 push해서는 안 됩니다(SHALL NOT).
+#### Scenario: 기존 attachment의 새 reference
+
+- GIVEN improved MDX가 current attachment catalog에 유일하게 존재하는 filename을 새로 참조합니다.
+- WHEN dependency gate와 candidate renderer를 실행합니다.
+- THEN attachment ID, filename, version, catalog hash를 local proof에 기록해야 합니다(SHALL).
+- AND candidate는 해당 filename을 가진 `ri:attachment` reference를 생성해야 합니다(SHALL).
+- AND attachment upload 또는 version 변경을 암묵적으로 실행해서는 안 됩니다(SHALL NOT).
+
#### Scenario: unresolved internal link
- GIVEN link resolver가 target page를 하나로 결정하지 못합니다.
- WHEN candidate XHTML을 생성합니다.
-- THEN `link_resolution_error`로 block해야 합니다(SHALL).
+- THEN target이 없으면 `internal_link_unresolved`, 여러 target이면 `ambiguous_target`으로 block해야 합니다(SHALL).
+- AND unresolved relative anchor를 일반 HTML link로 조용히 남겨서는 안 됩니다(SHALL NOT).
+
+#### Scenario: resolved internal link
+
+- GIVEN improved MDX의 새 relative link가 page catalog의 유일한 page ID/path와 일치합니다.
+- WHEN candidate XHTML을 생성합니다.
+- THEN target page ID/title/href를 local proof에 기록해야 합니다(SHALL).
+- AND candidate는 target title을 가진 Confluence `ac:link`/`ri:page` macro를 생성해야 합니다(SHALL).
+
+#### Scenario: dependency preflight drift
+
+- GIVEN local proof가 attachment filename 또는 internal page ID/title을 요구합니다.
+- AND verify 이후 attachment가 사라지거나 linked page가 rename/delete되었습니다.
+- WHEN publisher가 PUT 직전 dependency preflight를 실행합니다.
+- THEN `dependency_failure`로 block해야 합니다(SHALL).
+- AND PUT을 호출해서는 안 됩니다(SHALL NOT).
### Requirement: Page-Scoped Batch Semantics
diff --git a/openspec/changes/complete-reverse-sync/tasks.md b/openspec/changes/complete-reverse-sync/tasks.md
index 212c9d96b..05480621e 100644
--- a/openspec/changes/complete-reverse-sync/tasks.md
+++ b/openspec/changes/complete-reverse-sync/tasks.md
@@ -69,11 +69,11 @@
- [x] `confluence-mdx/bin/reverse_sync/base_parity.py`를 추가합니다.
- [x] remote snapshot의 forward conversion 결과와 original MDX를 비교합니다.
-- [ ] page ID, `confluenceUrl`, repository MDX path를 함께 검증합니다.
-- [ ] `stale_original_mdx`, `forward_converter_drift`, `page_identity_mismatch`를 구분합니다.
+- [x] page ID, `confluenceUrl`, repository MDX path를 함께 검증합니다.
+- [x] `stale_original_mdx`, `forward_converter_drift`, `page_identity_mismatch`를 구분합니다.
- [x] original/improved title과 첫 H1 invariant를 검증하고 title change를 block합니다.
-- [ ] attachment catalog에서 improved MDX의 attachment reference를 검증합니다.
-- [ ] internal link resolver error와 ambiguous target을 dependency failure로 변환합니다.
+- [x] attachment catalog에서 improved MDX의 attachment reference를 검증합니다.
+- [x] internal link resolver error와 ambiguous target을 dependency failure로 변환합니다.
- [x] snapshot metadata가 없는 offline verify는 `push_eligible: false`로 표시합니다.
완료 gate:
@@ -176,6 +176,7 @@ strict proof와 typed equivalence는 다음 test module과 golden shadow fixture
```bash
cd confluence-mdx/tests
../venv/bin/python3 -m pytest -q \
+ test_reverse_sync_input_gates.py \
test_reverse_sync_equivalence.py \
test_reverse_sync_online_proof_fixture.py \
test_reverse_sync_push_transaction.py
@@ -194,6 +195,8 @@ cd confluence-mdx/tests
기준선: 2026-07-24 `origin/main`에서 676 passed입니다.
+현재 변경 결과: 762 passed입니다.
+
### 3.3 Page fixture regression
- [x] 정밀 golden fixture와 실제 회귀 fixture를 실행합니다.
@@ -238,13 +241,13 @@ 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
+- 전체 Python test: 1085 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(`1054 passed, 2 skipped`)를
+이번 변경은 Python CLI/API adapter 범위이므로 전체 Python test(`1085 passed, 2 skipped`)를
실행했고 frontend render test는 영향 범위에서 제외했습니다.
```bash