From 9ea1109ef959a6742178c836a0913b75f8b52a86 Mon Sep 17 00:00:00 2001 From: JK Date: Fri, 24 Jul 2026 19:34:48 +0900 Subject: [PATCH] =?UTF-8?q?confluence-mdx:=20reverse-sync=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A0=95=ED=95=A9=EC=84=B1=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary MDX 변경이 올바른 Confluence page와 dependency를 대상으로 하는지 검증하고, 검증된 link/attachment만 Storage XHTML과 publisher에 전달합니다. - page ID, confluenceUrl, repository path를 하나의 source identity로 결합합니다. - stale original과 forward converter drift를 provenance evidence로 구분합니다. - attachment catalog와 internal link resolver를 local proof에 결합합니다. - internal page link와 attachment reference를 Confluence macro로 안전하게 렌더링합니다. - publisher가 attachment와 linked page identity를 PUT 직전에 다시 확인합니다. - OpenSpec의 P0 base parity/dependency contract와 검증 결과를 갱신합니다. ## Test plan - [x] 전체 Python test 1085 passed, 2 skipped - [x] reverse-sync Python test 762 passed - [x] make test-convert 21 passed - [x] make test-reverse-sync golden 16 passed, regression fixture 통과 - [x] make test-byte-verify fast/splice 각각 21/21 passed - [x] openspec validate complete-reverse-sync --strict - [x] git diff --check 🤖 Generated with Codex Co-Authored-By: Atlas --- confluence-mdx/bin/mdx_to_storage/inline.py | 21 +- .../bin/mdx_to_storage/link_resolver.py | 129 +++- .../bin/reverse_sync/base_parity.py | 206 ++++-- .../bin/reverse_sync/confluence_client.py | 177 ++++- .../bin/reverse_sync/dependencies.py | 384 +++++++++++ confluence-mdx/bin/reverse_sync/manifest.py | 2 +- .../bin/reverse_sync/mdx_to_xhtml_inline.py | 125 ++-- confluence-mdx/bin/reverse_sync/models.py | 62 ++ .../bin/reverse_sync/patch_builder.py | 138 +++- confluence-mdx/bin/reverse_sync/proof.py | 15 +- confluence-mdx/bin/reverse_sync/publisher.py | 202 +++++- confluence-mdx/bin/reverse_sync_cli.py | 75 ++- .../tests/test_mdx_to_storage/test_inline.py | 25 + .../test_mdx_to_storage/test_link_resolver.py | 31 + confluence-mdx/tests/test_reverse_sync_cli.py | 8 +- .../tests/test_reverse_sync_equivalence.py | 7 +- .../tests/test_reverse_sync_input_gates.py | 402 +++++++++++ .../test_reverse_sync_online_proof_fixture.py | 18 +- .../test_reverse_sync_push_transaction.py | 633 +++++++++++++++++- .../changes/complete-reverse-sync/design.md | 64 +- .../specs/contract-reverse-sync/spec.md | 43 +- .../changes/complete-reverse-sync/tasks.md | 15 +- 22 files changed, 2556 insertions(+), 226 deletions(-) create mode 100644 confluence-mdx/bin/reverse_sync/dependencies.py create mode 100644 confluence-mdx/tests/test_reverse_sync_input_gates.py diff --git a/confluence-mdx/bin/mdx_to_storage/inline.py b/confluence-mdx/bin/mdx_to_storage/inline.py index 399f762eb..e25fb06cc 100644 --- a/confluence-mdx/bin/mdx_to_storage/inline.py +++ b/confluence-mdx/bin/mdx_to_storage/inline.py @@ -16,6 +16,9 @@ _BADGE_INLINE_RE = re.compile( r'(.*?)', flags=re.DOTALL ) +_BARE_XML_AMPERSAND_RE = re.compile( + r"&(?!(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);)" +) _BADGE_COLOR_MAP = { "green": "Green", "blue": "Blue", @@ -27,6 +30,11 @@ } +def escape_bare_xml_ampersands(value: str) -> str: + """inline markup은 유지하면서 XML entity가 아닌 ampersand만 escape합니다.""" + return _BARE_XML_AMPERSAND_RE.sub("&", value) + + def _replace_badge(match: re.Match[str]) -> str: color = match.group(1).strip().lower() text = match.group(2).strip() @@ -110,11 +118,18 @@ def _replace_link(match: re.Match[str]) -> str: if not content_title: return f'{link_text}' - anchor_attr = f' ac:anchor="{anchor}"' if anchor else "" + anchor_attr = ( + f' ac:anchor="{html.escape(anchor, quote=True)}"' + if anchor + else "" + ) return ( f"" - f'' - f"{link_text}" + f'' + f"" + f"{escape_bare_xml_ampersands(link_text)}" + f"" f"" ) diff --git a/confluence-mdx/bin/mdx_to_storage/link_resolver.py b/confluence-mdx/bin/mdx_to_storage/link_resolver.py index 24b2cf893..7993dad8f 100644 --- a/confluence-mdx/bin/mdx_to_storage/link_resolver.py +++ b/confluence-mdx/bin/mdx_to_storage/link_resolver.py @@ -30,6 +30,17 @@ class PageEntry: path: list[str] = field(default_factory=list) +@dataclass(frozen=True) +class LinkResolution: + """internal link resolution 결과와 fail-closed 상태.""" + + status: str + href: str + content_title: Optional[str] = None + anchor: Optional[str] = None + candidate_page_ids: tuple[str, ...] = () + + def load_pages_yaml(yaml_path: Path) -> list[PageEntry]: if not yaml_path.exists(): return [] @@ -73,56 +84,81 @@ def __init__(self, pages: Optional[list[PageEntry] | Path] = None) -> None: self._by_id: dict[str, PageEntry] = {} self._current_page: PageEntry | None = None - self._path_to_title: dict[str, str] = {} - self._titles: set[str] = set() + self._path_to_entries: dict[str, list[PageEntry]] = {} + self._title_to_entries: dict[str, list[PageEntry]] = {} self._load_pages(pages) def has_pages(self) -> bool: - return bool(self._path_to_title) + return bool(self._path_to_entries) def set_current_page(self, page_id: str) -> None: self._current_page = self._by_id.get(str(page_id)) def resolve(self, href: str, link_text: str = "") -> tuple[Optional[str], Optional[str]]: """Resolve href to (content_title, anchor) or (None, None).""" + resolution = self.resolve_with_evidence(href, link_text=link_text) + if resolution.status != "resolved": + return None, None + return resolution.content_title, resolution.anchor + + def resolve_with_evidence( + self, + href: str, + link_text: str = "", + ) -> LinkResolution: + """href를 resolve하고 unresolved/ambiguous를 구분합니다.""" raw_href = href.strip() if not raw_href: - return None, None - if _EXTERNAL_SCHEME_RE.match(raw_href): - return None, None + return LinkResolution("unresolved", raw_href) + if _EXTERNAL_SCHEME_RE.match(raw_href) or raw_href.startswith("//"): + return LinkResolution("external", raw_href) if raw_href.startswith("#"): - return None, None + return LinkResolution("local_anchor", raw_href, anchor=raw_href[1:] or None) path_part, anchor = self._split_anchor(raw_href) + if path_part in {".", "./"} and anchor: + return LinkResolution( + "local_anchor", + raw_href, + anchor=anchor, + ) normalized_path = self._normalize_path(path_part) current_page_path = self._resolve_from_current_page(path_part) if current_page_path: - title = self._path_to_title.get(current_page_path) - if title: - return title, anchor + resolution = self._resolution_for_entries( + raw_href, + self._path_to_entries.get(current_page_path, []), + anchor, + ) + if resolution.status != "unresolved": + return resolution if not normalized_path and link_text: - title = self._resolve_by_title(link_text) - if title: - return title, anchor + resolution = self._resolve_by_title( + raw_href, + link_text, + anchor, + ) + if resolution.status != "unresolved": + return resolution - title = self._path_to_title.get(normalized_path) - if title: - return title, anchor + resolution = self._resolution_for_entries( + raw_href, + self._path_to_entries.get(normalized_path, []), + anchor, + ) + if resolution.status != "unresolved": + return resolution - if link_text: - title = self._resolve_by_title(link_text) - if title: - return title, anchor - return None, None + return LinkResolution("unresolved", raw_href, anchor=anchor) def _load_pages(self, pages: list[PageEntry]) -> None: for page in pages: normalized_path = self._normalize_path("/".join(page.path)) if normalized_path: - self._path_to_title[normalized_path] = page.title_orig - self._titles.add(page.title_orig) + self._path_to_entries.setdefault(normalized_path, []).append(page) + self._title_to_entries.setdefault(page.title_orig, []).append(page) if page.page_id: self._by_id[page.page_id] = page @@ -149,19 +185,56 @@ def _normalize_path(path: str) -> str: parts.append(segment) return "/".join(parts) - def _resolve_by_title(self, link_text: str) -> Optional[str]: + @staticmethod + def _resolution_for_entries( + href: str, + entries: list[PageEntry], + anchor: Optional[str], + ) -> LinkResolution: + if not entries: + return LinkResolution("unresolved", href, anchor=anchor) + page_ids = tuple(sorted({entry.page_id for entry in entries})) + if ( + len(entries) != 1 + or len(page_ids) != 1 + or not page_ids[0] + ): + return LinkResolution( + "ambiguous" if len(entries) > 1 else "unresolved", + href, + anchor=anchor, + candidate_page_ids=page_ids, + ) + return LinkResolution( + "resolved", + href, + content_title=entries[0].title_orig, + anchor=anchor, + candidate_page_ids=page_ids, + ) + + def _resolve_by_title( + self, + href: str, + link_text: str, + anchor: Optional[str], + ) -> LinkResolution: title_candidate = link_text.strip() if not title_candidate: - return None - return title_candidate if title_candidate in self._titles else None + return LinkResolution("unresolved", href, anchor=anchor) + return self._resolution_for_entries( + href, + self._title_to_entries.get(title_candidate, []), + anchor, + ) def _resolve_from_current_page(self, path_part: str) -> Optional[str]: if self._current_page is None: return None if not path_part or path_part.startswith("/"): return None - if not (path_part.startswith(".") or path_part.startswith("..")): - return None + if path_part in {".", "./"}: + return self._normalize_path("/".join(self._current_page.path)) current_dir = "/" + "/".join(self._current_page.path[:-1]) joined = posixpath.normpath(posixpath.join(current_dir, path_part)) diff --git a/confluence-mdx/bin/reverse_sync/base_parity.py b/confluence-mdx/bin/reverse_sync/base_parity.py index 15228d7d0..bb8922f07 100644 --- a/confluence-mdx/bin/reverse_sync/base_parity.py +++ b/confluence-mdx/bin/reverse_sync/base_parity.py @@ -4,9 +4,8 @@ from dataclasses import dataclass import difflib +from pathlib import Path import re -from pathlib import PurePosixPath -from urllib.parse import unquote, urlparse import yaml @@ -51,61 +50,16 @@ def _content_without_frontmatter(content: str) -> str: def _page_id_from_url(value: object) -> str: if not isinstance(value, str): return "" - match = re.search(r"/pages/(\d+)(?:/|$)", value) + match = re.search(r"/pages/(\d+)(?:[/?#]|$)", value) return match.group(1) if match else "" -def _attachment_filenames_from_mdx(content: str) -> set[str]: - references = re.findall(r"!\[[^\]]*\]\(([^)\s]+)", content) - references += re.findall( - r"]*\bsrc=[\"']([^\"']+)[\"']", - content, - flags=re.IGNORECASE, - ) - filenames: set[str] = set() - for reference in references: - parsed = urlparse(reference) - if parsed.scheme in ("http", "https", "data"): - continue - filename = PurePosixPath(unquote(parsed.path)).name - if filename: - filenames.add(filename) - return filenames - - -def verify_attachment_dependencies( - snapshot: PageSnapshot, - original_mdx: str, - improved_mdx: str, -) -> BaseParityResult: - """improved MDX가 base에 없는 새 attachment를 요구하면 차단한다.""" - original_references = _attachment_filenames_from_mdx(original_mdx) - improved_references = _attachment_filenames_from_mdx(improved_mdx) - added_references = improved_references - original_references - if not added_references: - return BaseParityResult(True) - - available = set( - re.findall( - r"\bri:filename=[\"']([^\"']+)[\"']", - snapshot.storage_xhtml, - flags=re.IGNORECASE, - ) - ) - missing = sorted(added_references - available) - if missing: - return BaseParityResult( - False, - "missing_attachment", - "base page에 없는 attachment: " + ", ".join(missing), - ) - return BaseParityResult(True) - - def verify_source_identity( snapshot: PageSnapshot, original_mdx: str, improved_mdx: str, + *, + require_confluence_url: bool = False, ) -> BaseParityResult: """title/H1/page URL이 같은 page mutation boundary인지 확인한다.""" original_meta = _frontmatter(original_mdx) @@ -117,6 +71,15 @@ def verify_source_identity( if original_title != improved_title or original_h1 != improved_h1: return BaseParityResult(False, "title_change_unsupported") + if ( + (original_title and original_h1 and original_title != original_h1) + or (improved_title and improved_h1 and improved_title != improved_h1) + ): + return BaseParityResult( + False, + "title_change_unsupported", + "frontmatter title과 첫 H1이 일치하지 않습니다", + ) declared_title = original_title or original_h1 if declared_title and declared_title != snapshot.title: @@ -124,19 +87,151 @@ def verify_source_identity( for metadata in (original_meta, improved_meta): declared_page_id = _page_id_from_url(metadata.get("confluenceUrl")) + if require_confluence_url and not declared_page_id: + return BaseParityResult(False, "page_identity_mismatch") if declared_page_id and declared_page_id != snapshot.page_id: return BaseParityResult(False, "page_identity_mismatch") return BaseParityResult(True) +def _repository_path(descriptor: str) -> str: + value = descriptor.split(":", 1)[-1] if ":" in descriptor else descriptor + marker = "src/content/ko/" + if marker not in value or not value.endswith(".mdx"): + return "" + return value[value.index(marker) :] + + +def verify_repository_source_identity( + snapshot: PageSnapshot, + original_mdx: str, + improved_mdx: str, + *, + original_descriptor: str, + improved_descriptor: str, + pages_path: Path, +) -> BaseParityResult: + """page ID, confluenceUrl, repository path를 하나의 identity로 검증합니다.""" + content_identity = verify_source_identity( + snapshot, + original_mdx, + improved_mdx, + ) + if not content_identity.passed: + return content_identity + + original_path = _repository_path(original_descriptor) + improved_path = _repository_path(improved_descriptor) + if not original_path or original_path != improved_path: + return BaseParityResult( + False, + "page_identity_mismatch", + "original/improved repository MDX path가 없거나 서로 다릅니다", + ) + + try: + loaded = yaml.safe_load(Path(pages_path).read_text()) + except (OSError, yaml.YAMLError) as exc: + return BaseParityResult( + False, + "page_identity_mismatch", + f"page catalog를 읽을 수 없습니다: {exc}", + ) + relative = original_path.removeprefix("src/content/ko/").removesuffix(".mdx") + expected_parts = relative.split("/") + rows = loaded if isinstance(loaded, list) else [] + matches = [ + row + for row in rows + if isinstance(row, dict) and row.get("path") == expected_parts + ] + page_id_matches = [ + row + for row in rows + if isinstance(row, dict) + and str(row.get("page_id", "")) == snapshot.page_id + ] + if len(matches) != 1 or str(matches[0].get("page_id", "")) != snapshot.page_id: + return BaseParityResult( + False, + "page_identity_mismatch", + ( + f"repository path {original_path}가 page {snapshot.page_id}와 " + "유일하게 대응하지 않습니다" + ), + ) + if ( + len(page_id_matches) != 1 + or page_id_matches[0].get("path") != expected_parts + ): + return BaseParityResult( + False, + "page_identity_mismatch", + ( + f"page {snapshot.page_id}가 repository catalog에서 " + "유일한 path identity를 갖지 않습니다" + ), + ) + + for label, content in ( + ("original", original_mdx), + ("improved", improved_mdx), + ): + declared_page_id = _page_id_from_url( + _frontmatter(content).get("confluenceUrl") + ) + if not declared_page_id or declared_page_id != snapshot.page_id: + return BaseParityResult( + False, + "page_identity_mismatch", + ( + f"{label} MDX confluenceUrl이 page " + f"{snapshot.page_id}를 가리키지 않습니다" + ), + ) + return BaseParityResult(True) + + +def load_provenance_storage_xhtml( + page_v1_path: Path, + *, + expected_page_id: str | None = None, +) -> str | None: + """과거 forward conversion source였던 page.v1 storage body를 읽습니다.""" + try: + value = yaml.safe_load(Path(page_v1_path).read_text()) + except (OSError, yaml.YAMLError): + return None + if not isinstance(value, dict) or str(value.get("id", "")) == "": + return None + if ( + expected_page_id is not None + and str(value.get("id")) != str(expected_page_id) + ): + return None + storage = value.get("body", {}).get("storage", {}) + if storage.get("representation") != "storage": + return None + xhtml = storage.get("value") + return xhtml if isinstance(xhtml, str) else None + + def verify_base_parity( snapshot: PageSnapshot, original_mdx: str, converted_base_mdx: str, + *, + provenance_storage_xhtml: str | None = None, + require_confluence_url: bool = False, ) -> BaseParityResult: """forward(base XHTML)과 original MDX content가 동등한지 확인한다.""" - identity = verify_source_identity(snapshot, original_mdx, converted_base_mdx) + identity = verify_source_identity( + snapshot, + original_mdx, + converted_base_mdx, + require_confluence_url=require_confluence_url, + ) if not identity.passed: return identity @@ -155,4 +250,11 @@ def verify_base_parity( lineterm="", ) ) - return BaseParityResult(False, "base_parity_mismatch", diff) + reason_code = "base_parity_mismatch" + if provenance_storage_xhtml is not None: + reason_code = ( + "forward_converter_drift" + if provenance_storage_xhtml == snapshot.storage_xhtml + else "stale_original_mdx" + ) + return BaseParityResult(False, reason_code, diff) diff --git a/confluence-mdx/bin/reverse_sync/confluence_client.py b/confluence-mdx/bin/reverse_sync/confluence_client.py index 22135c163..0c1fb2da0 100644 --- a/confluence-mdx/bin/reverse_sync/confluence_client.py +++ b/confluence-mdx/bin/reverse_sync/confluence_client.py @@ -1,12 +1,18 @@ """Confluence API 클라이언트 — 일관된 snapshot과 version-bound update.""" -from pathlib import Path - -import requests from dataclasses import dataclass from datetime import datetime, timezone +from pathlib import Path from typing import Dict, Any, Tuple +from urllib.parse import urljoin, urlparse + +import requests -from reverse_sync.models import PageSnapshot, ReasonCode +from reverse_sync.models import ( + AttachmentCatalog, + AttachmentRecord, + PageSnapshot, + ReasonCode, +) CONFIG_FILE = Path.home() / '.config' / 'atlassian' / 'confluence.conf' @@ -43,6 +49,10 @@ class InvalidPageSnapshotError(ConfluenceClientError): reason_code = ReasonCode.INVALID_PAGE_SNAPSHOT.value +class InvalidDependencySnapshotError(ConfluenceClientError): + reason_code = ReasonCode.DEPENDENCY_FAILURE.value + + class VersionConflictError(ConfluenceClientError): reason_code = ReasonCode.VERSION_CONFLICT.value @@ -55,12 +65,21 @@ class NetworkError(ConfluenceClientError): reason_code = ReasonCode.NETWORK_ERROR.value -def _raise_mapped_http_error(exc: requests.HTTPError, *, update: bool = False) -> None: +def _raise_mapped_http_error( + exc: requests.HTTPError, + *, + update: bool = False, + dependency: bool = False, +) -> None: status = exc.response.status_code if exc.response is not None else None if status == 409 or (update and status == 400): raise VersionConflictError("Confluence page version conflict") from exc if status in (401, 403): raise PermissionDeniedError("Confluence page 접근 권한이 없습니다") from exc + if dependency and status == 404: + raise InvalidDependencySnapshotError( + "Confluence dependency가 존재하지 않습니다" + ) from exc raise NetworkError(f"Confluence API 요청이 실패했습니다 (HTTP {status})") from exc @@ -111,7 +130,7 @@ def _parse_page_snapshot( or representation != "storage" or not isinstance(title, str) or not title - or not isinstance(version, int) + or type(version) is not int or version < 1 or not isinstance(storage_xhtml, str) ) @@ -136,6 +155,7 @@ def get_page_snapshot( page_id: str, *, fetched_at: datetime | None = None, + dependency: bool = False, ) -> PageSnapshot: """version/title/Storage body를 하나의 v2 response에서 획득한다.""" url = f"{config.base_url}/api/v2/pages/{page_id}" @@ -155,15 +175,22 @@ def get_page_snapshot( response.raise_for_status() data = response.json() except requests.HTTPError as exc: - _raise_mapped_http_error(exc) + _raise_mapped_http_error(exc, dependency=dependency) except (requests.RequestException, ValueError) as exc: raise NetworkError("Confluence page snapshot을 읽지 못했습니다") from exc - return _parse_page_snapshot( - data, - page_id, - expected_status="current", - fetched_at=captured_at, - ) + try: + return _parse_page_snapshot( + data, + page_id, + expected_status="current", + fetched_at=captured_at, + ) + except InvalidPageSnapshotError as exc: + if dependency: + raise InvalidDependencySnapshotError( + f"linked page {page_id} identity가 올바르지 않습니다" + ) from exc + raise def get_active_draft( @@ -207,6 +234,120 @@ def get_active_draft( ) +def _validated_next_url(base_url: str, value: str) -> str: + if not isinstance(value, str) or not value: + raise InvalidDependencySnapshotError( + "attachment pagination URL 형식이 올바르지 않습니다" + ) + resolved = urljoin(base_url.rstrip("/") + "/", value) + expected = urlparse(base_url) + actual = urlparse(resolved) + if ( + actual.scheme != expected.scheme + or actual.netloc != expected.netloc + or not actual.path.startswith(expected.path.rstrip("/") + "/api/v2/") + ): + raise InvalidDependencySnapshotError( + "attachment pagination URL이 Confluence v2 API 범위를 벗어납니다" + ) + return resolved + + +def get_attachment_catalog( + config: ConfluenceConfig, + page_id: str, + *, + fetched_at: datetime | None = None, +) -> AttachmentCatalog: + """page의 current attachment를 pagination 끝까지 조회합니다.""" + url = f"{config.base_url}/api/v2/pages/{page_id}/attachments" + params: dict[str, Any] | None = { + "status": ["current"], + "limit": 250, + } + records: list[AttachmentRecord] = [] + seen_ids: set[str] = set() + seen_urls: set[str] = set() + + while url: + if url in seen_urls: + raise InvalidDependencySnapshotError( + "attachment pagination URL이 순환합니다" + ) + seen_urls.add(url) + try: + response = requests.get( + url, + params=params, + auth=(config.email, config.api_token), + headers={"Accept": "application/json"}, + timeout=config.timeout_seconds, + ) + response.raise_for_status() + data = response.json() + except requests.HTTPError as exc: + _raise_mapped_http_error(exc, dependency=True) + except (requests.RequestException, ValueError) as exc: + raise NetworkError( + "Confluence attachment catalog를 읽지 못했습니다" + ) from exc + + results = data.get("results") if isinstance(data, dict) else None + if not isinstance(results, list): + raise InvalidDependencySnapshotError( + "attachment catalog response에 results가 없습니다" + ) + for item in results: + try: + attachment_id = str(item["id"]) + status = item["status"] + filename = item["title"] + response_page_id = str(item["pageId"]) + version = item["version"]["number"] + except (KeyError, TypeError) as exc: + raise InvalidDependencySnapshotError( + "attachment catalog item의 필수 field가 없습니다" + ) from exc + if ( + not attachment_id + or attachment_id in seen_ids + or status != "current" + or not isinstance(filename, str) + or not filename + or response_page_id != str(page_id) + or type(version) is not int + or version < 1 + ): + raise InvalidDependencySnapshotError( + "attachment catalog item identity가 올바르지 않습니다" + ) + seen_ids.add(attachment_id) + records.append( + AttachmentRecord( + attachment_id=attachment_id, + page_id=response_page_id, + filename=filename, + version=version, + ) + ) + + next_value = ( + response.links.get("next", {}).get("url") + if isinstance(response.links, dict) + else None + ) + url = _validated_next_url(config.base_url, next_value) if next_value else "" + params = None + + captured_at = fetched_at or datetime.now(timezone.utc) + return AttachmentCatalog( + page_id=str(page_id), + attachments=tuple(records), + fetched_at=captured_at.astimezone(timezone.utc).isoformat(), + api="confluence-v2", + ) + + def update_page( config: ConfluenceConfig, page_id: str, @@ -248,6 +389,16 @@ def get_current_page(self, page_id: str) -> PageSnapshot: def get_active_draft(self, page_id: str) -> PageSnapshot | None: return get_active_draft(self.config, page_id) + def get_page_identity(self, page_id: str) -> PageSnapshot: + return get_page_snapshot( + self.config, + page_id, + dependency=True, + ) + + def get_attachment_catalog(self, page_id: str) -> AttachmentCatalog: + return get_attachment_catalog(self.config, page_id) + def update_page( self, page_id: str, diff --git a/confluence-mdx/bin/reverse_sync/dependencies.py b/confluence-mdx/bin/reverse_sync/dependencies.py new file mode 100644 index 000000000..ca0b1606e --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/dependencies.py @@ -0,0 +1,384 @@ +"""reverse-sync가 새로 도입하는 attachment/internal link dependency 검증.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +import re +from typing import Any, Iterable +from urllib.parse import unquote, urlparse + +from bs4 import BeautifulSoup +from mdx_to_storage.link_resolver import LinkResolver, load_pages_yaml +from reverse_sync.equivalence import ( + CanonicalDocument, + InlineToken, + canonicalize_mdx, +) +from reverse_sync.models import AttachmentCatalog + + +@dataclass(frozen=True) +class ResolvedLink: + href: str + content_title: str + page_id: str + anchor: str = "" + + def to_dict(self) -> dict[str, str]: + value = { + "content_title": self.content_title, + "href": self.href, + "page_id": self.page_id, + } + if self.anchor: + value["anchor"] = self.anchor + return value + + +@dataclass(frozen=True) +class AttachmentRequirement: + filename: str + attachment_id: str + version: int + + def to_dict(self) -> dict[str, Any]: + return { + "attachment_id": self.attachment_id, + "filename": self.filename, + "version": self.version, + } + + +@dataclass(frozen=True) +class DependencyEvidence: + attachments: tuple[AttachmentRequirement, ...] = () + internal_links: tuple[ResolvedLink, ...] = () + attachment_catalog_sha256: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "attachment_catalog_sha256": self.attachment_catalog_sha256, + "attachments": [ + requirement.to_dict() + for requirement in sorted( + self.attachments, + key=lambda item: (item.filename, item.attachment_id), + ) + ], + "internal_links": [ + link.to_dict() + for link in sorted( + self.internal_links, + key=lambda item: (item.href, item.page_id, item.anchor), + ) + ], + } + + +@dataclass(frozen=True) +class DependencyResult: + passed: bool + evidence: DependencyEvidence = DependencyEvidence() + reason_code: str = "" + detail: str = "" + + +def _walk_tokens(value: Any) -> Iterable[InlineToken]: + if isinstance(value, InlineToken): + yield value + for child in value.children: + yield from _walk_tokens(child) + return + if isinstance(value, (tuple, list)): + for item in value: + yield from _walk_tokens(item) + + +def _document_tokens(document: CanonicalDocument) -> Iterable[InlineToken]: + for block in document.blocks: + yield from _walk_tokens(block.tokens) + yield from _walk_tokens(block.structure) + + +def _token_text(token: InlineToken) -> str: + if token.kind in {"text", "code"}: + return token.value + if token.kind == "title": + return "" + return "".join(_token_text(child) for child in token.children) + + +def _markdown_references(mdx: str) -> tuple[set[tuple[str, str]], set[str]]: + links: set[tuple[str, str]] = set() + images: set[str] = set() + for token in _document_tokens(canonicalize_mdx(mdx)): + if token.kind == "link": + links.add((token.target, _token_text(token))) + elif token.kind == "image": + images.add(token.target) + + images.update( + re.findall( + r"]*\bsrc=[\"']([^\"']+)[\"']", + mdx, + flags=re.IGNORECASE, + ) + ) + for match in re.finditer( + r"]*\bhref=[\"']([^\"']+)[\"'][^>]*>(.*?)", + mdx, + flags=re.IGNORECASE | re.DOTALL, + ): + links.add( + ( + match.group(1), + BeautifulSoup(match.group(2), "html.parser").get_text(), + ) + ) + return links, images + + +def _raw_anchor_attributes( + mdx: str, +) -> set[tuple[str, str, tuple[str, ...]]]: + anchors: set[tuple[str, str, tuple[str, ...]]] = set() + for match in re.finditer( + r"]*>.*?", + mdx, + flags=re.IGNORECASE | re.DOTALL, + ): + tag = BeautifulSoup(match.group(0), "html.parser").find("a") + if tag is None: + continue + href = tag.get("href") + if not isinstance(href, str): + continue + extras = tuple(sorted(set(tag.attrs) - {"href"})) + anchors.add((href, tag.get_text(), extras)) + return anchors + + +def _is_remote_reference(target: str) -> bool: + parsed = urlparse(target) + return bool(parsed.scheme) or target.startswith("//") + + +def _is_external(target: str) -> bool: + return _is_remote_reference(target) or target.startswith("#") + + +def _attachment_filename(target: str) -> str: + if _is_external(target): + return "" + parsed = urlparse(target) + return PurePosixPath(unquote(parsed.path)).name + + +def added_attachment_filenames(original_mdx: str, improved_mdx: str) -> tuple[str, ...]: + """original 대비 새로 참조하는 local attachment filename을 반환합니다.""" + original_links, original_images = _markdown_references(original_mdx) + improved_links, improved_images = _markdown_references(improved_mdx) + original_names = { + filename + for target in original_images + if (filename := _attachment_filename(target)) + } + improved_names = { + filename + for target in improved_images + if (filename := _attachment_filename(target)) + } + for target, _link_text in original_links: + if _path_looks_like_attachment(target): + if filename := _attachment_filename(target): + original_names.add(filename) + for target, _link_text in improved_links: + if _path_looks_like_attachment(target): + if filename := _attachment_filename(target): + improved_names.add(filename) + return tuple(sorted(improved_names - original_names)) + + +def _attachment_requirements( + filenames: Iterable[str], + catalog: AttachmentCatalog | None, +) -> tuple[tuple[AttachmentRequirement, ...], DependencyResult | None]: + required = tuple(sorted(set(filenames))) + if not required: + return (), None + if catalog is None: + return (), DependencyResult( + False, + reason_code="dependency_failure", + detail=( + "attachment catalog가 없어 새 attachment reference를 " + "검증할 수 없습니다" + ), + ) + + by_filename: dict[str, list[Any]] = {} + for attachment in catalog.attachments: + by_filename.setdefault(attachment.filename, []).append(attachment) + + requirements: list[AttachmentRequirement] = [] + for filename in required: + matches = by_filename.get(filename, []) + if not matches: + return (), DependencyResult( + False, + reason_code="missing_attachment", + detail=f"attachment catalog에 없는 filename입니다: {filename}", + ) + attachment_ids = {match.attachment_id for match in matches} + if len(matches) != 1 or len(attachment_ids) != 1: + return (), DependencyResult( + False, + reason_code="ambiguous_target", + detail=f"attachment filename이 여러 identity와 일치합니다: {filename}", + ) + match = matches[0] + requirements.append( + AttachmentRequirement( + filename=filename, + attachment_id=match.attachment_id, + version=match.version, + ) + ) + return tuple(requirements), None + + +def _path_looks_like_attachment(target: str) -> bool: + suffix = PurePosixPath(unquote(urlparse(target).path)).suffix.lower() + return bool(suffix and suffix != ".mdx") + + +def verify_dependencies( + *, + page_id: str, + original_mdx: str, + improved_mdx: str, + pages_path: Path, + attachment_catalog: AttachmentCatalog | None, +) -> tuple[DependencyResult, LinkResolver]: + """새 attachment/link dependency를 catalog로 resolve합니다.""" + pages = load_pages_yaml(pages_path) + resolver = LinkResolver(pages) + resolver.set_current_page(str(page_id)) + page_ids = [page.page_id for page in pages] + if ( + not pages + or any(not candidate for candidate in page_ids) + or len(page_ids) != len(set(page_ids)) + ): + return ( + DependencyResult( + False, + reason_code="dependency_failure", + detail="page catalog의 page identity가 없거나 중복되었습니다", + ), + resolver, + ) + if attachment_catalog is not None and attachment_catalog.page_id != str(page_id): + return ( + DependencyResult( + False, + reason_code="dependency_failure", + detail="attachment catalog page ID가 target page와 다릅니다", + ), + resolver, + ) + + original_links, original_images = _markdown_references(original_mdx) + improved_links, improved_images = _markdown_references(improved_mdx) + added_raw_anchors = ( + _raw_anchor_attributes(improved_mdx) + - _raw_anchor_attributes(original_mdx) + ) + for href, _link_text, extra_attrs in sorted(added_raw_anchors): + if not _is_remote_reference(href) and extra_attrs: + return ( + DependencyResult( + False, + reason_code="dependency_failure", + detail=( + "새 internal HTML link의 추가 attribute는 " + "지원하지 않습니다: " + f"{href} ({', '.join(extra_attrs)})" + ), + ), + resolver, + ) + for target in sorted(improved_images - original_images): + if _is_external(target): + return ( + DependencyResult( + False, + reason_code="dependency_failure", + detail=( + "새 external image reference는 지원하지 않습니다: " + f"{target}" + ), + ), + resolver, + ) + added_links = sorted(improved_links - original_links) + attachment_names = set( + added_attachment_filenames(original_mdx, improved_mdx) + ) + resolved_links: list[ResolvedLink] = [] + + for href, link_text in added_links: + resolution = resolver.resolve_with_evidence(href, link_text=link_text) + if resolution.status in {"external", "local_anchor"}: + continue + if resolution.status == "resolved": + resolved_links.append( + ResolvedLink( + href=href, + content_title=resolution.content_title or "", + page_id=resolution.candidate_page_ids[0], + anchor=resolution.anchor or "", + ) + ) + continue + filename = _attachment_filename(href) + if filename and _path_looks_like_attachment(href): + attachment_names.add(filename) + continue + if resolution.status == "ambiguous": + return ( + DependencyResult( + False, + reason_code="ambiguous_target", + detail=( + f"internal link가 여러 page와 일치합니다: {href} " + f"({', '.join(resolution.candidate_page_ids)})" + ), + ), + resolver, + ) + return ( + DependencyResult( + False, + reason_code="internal_link_unresolved", + detail=f"internal link target을 resolve할 수 없습니다: {href}", + ), + resolver, + ) + + requirements, attachment_error = _attachment_requirements( + attachment_names, + attachment_catalog, + ) + if attachment_error is not None: + return attachment_error, resolver + + evidence = DependencyEvidence( + attachments=requirements, + internal_links=tuple(resolved_links), + attachment_catalog_sha256=( + attachment_catalog.sha256 if requirements and attachment_catalog else "" + ), + ) + return DependencyResult(True, evidence=evidence), resolver diff --git a/confluence-mdx/bin/reverse_sync/manifest.py b/confluence-mdx/bin/reverse_sync/manifest.py index 1722b73d1..95cafe5d1 100644 --- a/confluence-mdx/bin/reverse_sync/manifest.py +++ b/confluence-mdx/bin/reverse_sync/manifest.py @@ -19,7 +19,7 @@ MANIFEST_SCHEMA_VERSION = 2 SUPPORTED_VERIFIER_POLICIES = frozenset({"reverse-sync-equivalence-v1"}) -CURRENT_TOOL_VERSION = "reverse-sync-cli-v2" +CURRENT_TOOL_VERSION = "reverse-sync-cli-v3" REQUIRED_PUSH_GATES = frozenset( { "source_identity", diff --git a/confluence-mdx/bin/reverse_sync/mdx_to_xhtml_inline.py b/confluence-mdx/bin/reverse_sync/mdx_to_xhtml_inline.py index b5f168848..64427fa39 100644 --- a/confluence-mdx/bin/reverse_sync/mdx_to_xhtml_inline.py +++ b/confluence-mdx/bin/reverse_sync/mdx_to_xhtml_inline.py @@ -3,15 +3,22 @@ MDX 블록의 content를 파싱하여 대상 XHTML 요소의 innerHTML로 직접 교체할 수 있는 HTML 문자열을 생성한다. """ -import html import re -from typing import List +from typing import List, Optional from bs4 import BeautifulSoup, Tag -from mdx_to_storage.inline import convert_inline, _BADGE_INLINE_RE, _replace_badge - - -def mdx_block_to_inner_xhtml(content: str, block_type: str) -> str: +from mdx_to_storage.inline import ( + convert_heading_inline, + convert_inline, +) +from mdx_to_storage.link_resolver import LinkResolver + + +def mdx_block_to_inner_xhtml( + content: str, + block_type: str, + link_resolver: Optional[LinkResolver] = None, +) -> str: """MDX 블록 content → XHTML inner HTML. heading: "## Title\\n" → "Title" @@ -22,37 +29,36 @@ def mdx_block_to_inner_xhtml(content: str, block_type: str) -> str: text = content.strip() if block_type == 'heading': - return _convert_heading(text) + return _convert_heading(text, link_resolver=link_resolver) elif block_type == 'paragraph': - return _convert_paragraph(text) + return _convert_paragraph(text, link_resolver=link_resolver) elif block_type == 'callout': - return _convert_callout_inner(text) + return _convert_callout_inner(text, link_resolver=link_resolver) elif block_type == 'blockquote': - return _convert_blockquote_inner(text) + return _convert_blockquote_inner(text, link_resolver=link_resolver) elif block_type == 'list': - return _convert_list_content(text) + return _convert_list_content(text, link_resolver=link_resolver) elif block_type == 'code_block': return _convert_code_block(text) elif block_type == 'html_block': - return _convert_html_block_inner(text) + return _convert_html_block_inner(text, link_resolver=link_resolver) else: - return convert_inline(text) + return convert_inline(text, link_resolver=link_resolver) -def _convert_heading(text: str) -> str: +def _convert_heading( + text: str, + link_resolver: Optional[LinkResolver] = None, +) -> str: """heading: # 마커 제거 후 인라인 변환 (bold는 마커만 제거).""" stripped = re.sub(r'^#+\s+', '', text) - # heading 내부의 **bold**는 변환 없이 마커만 제거 - # (forward converter가 heading 내부 strong을 strip하므로) - stripped = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) - # code span, link, Badge는 변환 - stripped = _convert_code_spans(stripped) - stripped = _convert_links(stripped) - stripped = _BADGE_INLINE_RE.sub(_replace_badge, stripped) - return stripped + return convert_heading_inline(stripped, link_resolver=link_resolver) -def _convert_paragraph(text: str) -> str: +def _convert_paragraph( + text: str, + link_resolver: Optional[LinkResolver] = None, +) -> str: """paragraph: 인라인 변환 적용. 여러 줄이면 join.""" lines = text.split('\n') converted = [] @@ -60,11 +66,14 @@ def _convert_paragraph(text: str) -> str: line = line.strip() if not line: continue - converted.append(convert_inline(line)) + converted.append(convert_inline(line, link_resolver=link_resolver)) return ' '.join(converted) -def _convert_callout_inner(text: str) -> str: +def _convert_callout_inner( + text: str, + link_resolver: Optional[LinkResolver] = None, +) -> str: """callout: 래퍼 태그를 제거하고 내부 텍스트를 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는 ``...**bold**...
`` 처럼 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 기반으로
  • 안에