diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index edc9664ca..d66c81261 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -17,6 +17,7 @@ find_sidecar_block_by_identity, sha256_text, SidecarEntry, + build_mdx_line_range_index, ) 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 @@ -39,6 +40,34 @@ _CLEAN_BLOCK_TYPES = frozenset(("heading", "code_block", "hr")) +def _build_mdx_to_sidecar_from_v3( + roundtrip_sidecar: RoundtripSidecar, + original_blocks: List[MdxBlock], +) -> Dict[int, SidecarEntry]: + """roundtrip sidecar v3와 original_blocks에서 mdx_to_sidecar 인덱스를 생성한다. + + mapping.yaml 없이 v3 sidecar의 mdx_line_range를 기준으로 + original_blocks의 절대 인덱스 → SidecarEntry를 구축한다. + find_mapping_by_sidecar()가 entry.xhtml_xpath만 사용하므로 + xhtml_xpath 필드만 채운다. + """ + from reverse_sync.block_diff import NON_CONTENT_TYPES as _NON_CONTENT + line_range_idx = build_mdx_line_range_index(roundtrip_sidecar) + result: Dict[int, SidecarEntry] = {} + for idx, block in enumerate(original_blocks): + if block.type in _NON_CONTENT: + continue + sc_block = line_range_idx.get((block.line_start, block.line_end)) + if sc_block is None: + continue + result[idx] = SidecarEntry( + xhtml_xpath=sc_block.xhtml_xpath, + xhtml_type="", + mdx_blocks=[idx], + ) + return result + + def _contains_preserved_anchor_markup(xhtml_text: str) -> bool: """preservation unit이 있으면 clean whole-fragment replacement 대상이 아니다.""" return " List[Dict[str, str]]: """diff 변경과 매핑을 결합하여 XHTML 패치 목록을 구성한다. - sidecar 인덱스를 사용하여 O(1) 직접 조회를 수행한다. + mdx_to_sidecar=None (기본값)이면 roundtrip_sidecar v3에서 자동으로 구축한다. """ + # v3 sidecar 기반 경로: mdx_to_sidecar가 없으면 roundtrip_sidecar에서 구축 + if mdx_to_sidecar is None: + if roundtrip_sidecar is not None: + mdx_to_sidecar = _build_mdx_to_sidecar_from_v3( + roundtrip_sidecar, original_blocks) + else: + mdx_to_sidecar = {} + xpath_to_mapping = xpath_to_mapping or {} patches = [] xpath_to_sidecar_block: Dict[str, SidecarBlock] = {} if roundtrip_sidecar is not None: diff --git a/confluence-mdx/bin/reverse_sync/sidecar.py b/confluence-mdx/bin/reverse_sync/sidecar.py index f865b9431..a0abb13bb 100644 --- a/confluence-mdx/bin/reverse_sync/sidecar.py +++ b/confluence-mdx/bin/reverse_sync/sidecar.py @@ -154,6 +154,21 @@ def build_sidecar_identity_index( return dict(grouped) +def build_mdx_line_range_index( + sidecar: "RoundtripSidecar", +) -> Dict[tuple, "SidecarBlock"]: + """(line_start, line_end) → SidecarBlock 인덱스를 구축한다. + + roundtrip sidecar v3 기반 mapping lookup에 사용된다. + mdx_line_range가 (0, 0)인 블록(MDX 대응 없음)은 제외된다. + """ + return { + tuple(b.mdx_line_range): b + for b in sidecar.blocks + if b.mdx_line_range != (0, 0) + } + + def find_sidecar_block_by_identity( blocks: List[SidecarBlock], mdx_content_hash: str, @@ -206,11 +221,13 @@ def build_sidecar( ) -> RoundtripSidecar: """Block-level sidecar를 생성한다. + generate_sidecar_mapping()과 동일한 type-compatible two-pointer 매칭으로 + XHTML top-level block과 MDX content block을 정렬한다. Fragment 추출 → MDX alignment → 무결성 검증 → RoundtripSidecar 반환. """ from reverse_sync.fragment_extractor import extract_block_fragments from reverse_sync.mapping_recorder import record_mapping - from reverse_sync.mdx_block_parser import parse_mdx_blocks + from mdx_to_storage.parser import parse_mdx_blocks # 1. XHTML mapping + fragment 추출 xhtml_mappings = record_mapping(page_xhtml_text) @@ -224,19 +241,47 @@ def build_sidecar( child_ids.update(m.children) top_mappings = [m for m in xhtml_mappings if m.block_id not in child_ids] - # 3. MDX content 블록 (frontmatter, empty, import 제외) - mdx_content_blocks = [b for b in mdx_blocks if b.type not in NON_CONTENT_TYPES] + # 3. MDX content 블록 — NON_CONTENT_TYPES 제외, 원본 인덱스 보존 + mdx_content_indexed = [ + (i, b) for i, b in enumerate(mdx_blocks) if b.type not in NON_CONTENT_TYPES + ] + + # 4. MDX H1 헤딩(페이지 제목) 건너뜀 + # forward converter가 MDX 첫 줄에 `# <페이지 제목>`을 자동 생성하며, + # 이 블록은 Confluence XHTML의 페이지 제목(본문 외부)에 해당한다. + mdx_ptr = 0 + while (mdx_ptr < len(mdx_content_indexed) + and mdx_content_indexed[mdx_ptr][1].type == 'heading' + and mdx_content_indexed[mdx_ptr][1].content.startswith('# ')): + mdx_ptr += 1 - # 4. Block 생성 — fragment와 top-level mapping을 정렬 + # 5. type-compatible two-pointer 매칭으로 fragment-MDX block 쌍 생성 sidecar_blocks: List[SidecarBlock] = [] for i, fragment in enumerate(frag_result.fragments): xpath = top_mappings[i].xhtml_xpath if i < len(top_mappings) else f"unknown[{i}]" + mapping = top_mappings[i] if i < len(top_mappings) else None + + mdx_block = None + if mapping is not None and not _should_skip_xhtml(mapping): + # 빈 paragraph: MDX 대응 없음 + if mapping.xhtml_plain_text.strip() or mapping.type != 'paragraph': + if mdx_ptr < len(mdx_content_indexed): + _, candidate = mdx_content_indexed[mdx_ptr] + if _type_compatible(mapping.type, candidate.type): + mdx_block = candidate + mdx_ptr += 1 + elif mapping.type == 'heading': + la_ptr = _heading_lookahead( + mapping.xhtml_plain_text, mdx_content_indexed, mdx_ptr) + if la_ptr is not None: + mdx_ptr = la_ptr + _, candidate = mdx_content_indexed[mdx_ptr] + mdx_block = candidate + mdx_ptr += 1 + # else: 타입 불일치 → XHTML 블록이 MDX 출력을 생성하지 않음 - # 순차 1:1 대응 (향후 block alignment로 개선) - mdx_block = mdx_content_blocks[i] if i < len(mdx_content_blocks) else None mdx_hash = sha256_text(mdx_block.content) if mdx_block else "" mdx_range = (mdx_block.line_start, mdx_block.line_end) if mdx_block else (0, 0) - mapping = top_mappings[i] if i < len(top_mappings) else None sidecar_blocks.append( SidecarBlock( @@ -437,7 +482,7 @@ def load_sidecar(path: Path) -> RoundtripSidecar: # XHTML record_mapping type → 호환 MDX parse_mdx type _TYPE_COMPAT: Dict[str, frozenset] = { 'heading': frozenset({'heading'}), - 'paragraph': frozenset({'paragraph'}), + 'paragraph': frozenset({'paragraph', 'list'}), 'list': frozenset({'list'}), 'code': frozenset({'code_block'}), 'table': frozenset({'table', 'html_block'}), @@ -458,12 +503,53 @@ def _should_skip_xhtml(xm: Any) -> bool: return False +def _normalize_heading_text(text: str) -> str: + """heading 텍스트에서 `#` prefix와 앞뒤 공백을 제거한다.""" + return text.lstrip('#').strip() + + +def _heading_lookahead( + xhtml_plain: str, + mdx_content_indexed: List, + mdx_ptr: int, + lookahead_limit: int = 20, +) -> Optional[int]: + """XHTML heading의 plain text와 content-matching MDX heading을 lookahead로 탐색한다. + + XHTML heading이 타입 불일치(MISS)일 때 전방 탐색으로 구조적 정렬을 복원한다. + 두 텍스트 중 하나가 다른 텍스트에 포함(substring)되면 일치로 판단한다. + + ⚠️ TECH DEBT — 반드시 제거해야 할 중대한 부채. + `parse_mdx_blocks`가 list item 뒤 빈 줄 없이 이어지는 연속행을 별도 paragraph + 블록으로 잘못 파싱하여 sidecar two-pointer alignment가 어긋난다. + Markdown 규칙상 paragraph 분리는 빈 줄이 있어야 하므로, 빈 줄 없이 이어지는 + 줄은 동일 list 블록의 연속행이다. 올바른 수정은 `parse_mdx_blocks`에서 list item + 연속행을 같은 블록으로 합치는 것이며, 그러면 이 함수는 불필요해진다. + 추적 케이스: page 544112828, XHTML p[6] → MDX list(L48) + paragraph(L49) 오분리. + + Returns: + 일치하는 MDX block의 포인터 인덱스, 없으면 None + """ + xhtml_norm = _normalize_heading_text(xhtml_plain) + if len(xhtml_norm) < 8: + return None + end = min(mdx_ptr + lookahead_limit, len(mdx_content_indexed)) + for ptr in range(mdx_ptr, end): + _, candidate = mdx_content_indexed[ptr] + if candidate.type == 'heading': + mdx_norm = _normalize_heading_text(candidate.content) + if xhtml_norm in mdx_norm or mdx_norm in xhtml_norm: + return ptr + return None + + def _type_compatible(xhtml_type: str, mdx_type: str) -> bool: """XHTML 타입과 MDX 블록 타입이 호환되는지 확인한다.""" return mdx_type in _TYPE_COMPAT.get(xhtml_type, frozenset()) + def _align_children( xm: Any, mdx_block: Any, @@ -667,6 +753,12 @@ def generate_sidecar_mapping( mdx_idx, mdx_block = mdx_content_indexed[mdx_ptr] + if not _type_compatible(xm.type, mdx_block.type) and xm.type == 'heading': + la_ptr = _heading_lookahead(xm.xhtml_plain_text, mdx_content_indexed, mdx_ptr) + if la_ptr is not None: + mdx_ptr = la_ptr + mdx_idx, mdx_block = mdx_content_indexed[mdx_ptr] + if _type_compatible(xm.type, mdx_block.type): entry: Dict[str, Any] = { 'xhtml_xpath': xm.xhtml_xpath, diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py index 20d32a68c..d6234990e 100755 --- a/confluence-mdx/bin/reverse_sync_cli.py +++ b/confluence-mdx/bin/reverse_sync_cli.py @@ -350,49 +350,20 @@ def run_verify( (var_dir / 'reverse-sync.mapping.original.yaml').write_text( yaml.dump(original_mapping_data, allow_unicode=True, default_flow_style=False)) - # Step 3.5: Sidecar mapping 생성 + 인덱스 구축 + # Step 3.5: Roundtrip sidecar v3 구축 — mapping.yaml 재생성 없이 v3 경로로 동작 from reverse_sync.sidecar import ( - SidecarEntry, SidecarChildEntry, generate_sidecar_mapping, - build_mdx_to_sidecar_index, build_xpath_to_mapping, + build_xpath_to_mapping, build_sidecar, + load_page_lost_info, ) - # forward converter가 생성한 mapping.yaml에서 lost_info를 보존 - existing_mapping = var_dir / 'mapping.yaml' - existing_lost_info = None - if existing_mapping.exists(): - existing_data = yaml.safe_load(existing_mapping.read_text()) or {} - existing_lost_info = existing_data.get('lost_info') or None - sidecar_yaml = generate_sidecar_mapping( - xhtml, original_mdx, page_id, lost_infos=existing_lost_info) - (var_dir / 'mapping.yaml').write_text(sidecar_yaml) - sidecar_data = yaml.safe_load(sidecar_yaml) or {} - page_lost_info = sidecar_data.get('lost_info', {}) - sidecar_entries = [] - for item in sidecar_data.get('mappings', []): - children = [ - SidecarChildEntry( - xhtml_xpath=ch.get('xhtml_xpath', ''), - xhtml_block_id=ch.get('xhtml_block_id', ''), - mdx_line_start=ch.get('mdx_line_start', 0), - mdx_line_end=ch.get('mdx_line_end', 0), - ) - for ch in item.get('children', []) - ] - sidecar_entries.append(SidecarEntry( - xhtml_xpath=item['xhtml_xpath'], - xhtml_type=item.get('xhtml_type', ''), - mdx_blocks=item.get('mdx_blocks', []), - mdx_line_start=item.get('mdx_line_start', 0), - mdx_line_end=item.get('mdx_line_end', 0), - children=children, - )) - mdx_to_sidecar = build_mdx_to_sidecar_index(sidecar_entries) + # forward converter가 생성한 mapping.yaml에서 lost_info만 로드 + page_lost_info = load_page_lost_info(str(var_dir / 'mapping.yaml')) roundtrip_sidecar = build_sidecar(xhtml, original_mdx, page_id=page_id) xpath_to_mapping = build_xpath_to_mapping(original_mappings) - # Step 4: XHTML 패치 → patched.xhtml 저장 + # Step 4: XHTML 패치 → patched.xhtml 저장 (mdx_to_sidecar=None → v3 자동 구축) patches = build_patches(changes, original_blocks, improved_blocks, - original_mappings, mdx_to_sidecar, xpath_to_mapping, + original_mappings, None, xpath_to_mapping, alignment, page_lost_info=page_lost_info, roundtrip_sidecar=roundtrip_sidecar) patched_xhtml = patch_xhtml(xhtml, patches) diff --git a/confluence-mdx/docs/architecture.md b/confluence-mdx/docs/architecture.md index 97fe3d0eb..6c580a83d 100644 --- a/confluence-mdx/docs/architecture.md +++ b/confluence-mdx/docs/architecture.md @@ -315,6 +315,8 @@ MDX 파일의 교정 내용을 Confluence XHTML에 반영한다. 블록 단위 d MDX 텍스트를 줄 단위 상태머신으로 파싱하여 블록 시퀀스를 생성한다. (`mdx_block_parser.py`는 backward-compat re-export 래퍼) +**⚠️ 알려진 파싱 버그:** list item 뒤 빈 줄 없이 이어지는 연속행(문장 단위 줄바꿈)을 별도 `paragraph` 블록으로 잘못 분리한다. Markdown 규칙상 paragraph 분리는 빈 줄이 필요하며, 빈 줄 없는 연속행은 동일 list item의 일부다. 이 버그가 sidecar alignment 오류의 근본 원인이다. + ```python Block(type, content, level, language, children, attrs, line_start, line_end) # type: "frontmatter" | "import_statement" | "heading" | "paragraph" | @@ -641,6 +643,48 @@ tests/testcases/ --- +## Reverse Sync 설계 불변조건 + +Reverse Sync 파이프라인의 정확성은 다음 불변조건에 의존한다. **이 조건이 위반된 상태에서 heuristic을 추가하는 것은 근본 원인을 숨기는 것이다.** + +### 핵심 흐름 + +``` +old_xhtml ──(forward converter)──▶ old_mdx + sidecar(mapping.yaml) + │ +new_mdx ────────────────────────────── diff(old_mdx, new_mdx) + │ + ▼ + edit sequence + │ + sidecar로 MDX 위치 → XHTML 위치 변환 + │ + ▼ + old_xhtml 패치 → new_xhtml +``` + +### 불변조건: old_xhtml ↔ old_mdx는 항상 동기화되어 있다 + +`old_mdx`는 `old_xhtml`에서 forward converter가 생성한 결과다. 따라서: + +- XHTML 블록과 MDX 블록의 대응 관계는 **변환 시점에 완전히 결정**된다. +- 정보 유실(emoticon, link 등)이 있더라도 **구조적 대응 관계는 확정적**이다. +- sidecar 생성 시 two-pointer alignment이 실패할 수 없다. + +### 위반 징후 + +sidecar 생성 함수(`build_sidecar`, `generate_sidecar_mapping`)에서 alignment 오류가 발생하거나 이를 보완하는 heuristic이 필요하다면, 다음 중 하나를 의미한다: + +| 징후 | 실제 원인 | 올바른 수정 | +|------|----------|------------| +| two-pointer alignment MISS | forward converter가 XHTML 구조를 MDX에 올바르게 반영하지 못함 | forward converter 버그 수정 → old_mdx 재생성 | +| sidecar 없이 현재 MDX로 alignment 시도 | new_mdx(편집본)를 old_mdx로 잘못 사용 | old_mdx 복원 경로 확보 | +| alignment heuristic 추가 (예: heading lookahead) | 위 두 경우 중 하나 | heuristic 제거, 근본 원인 수정 | + +> **교훈:** `_heading_lookahead()`처럼 "XHTML-MDX 구조 불일치를 heading으로 재동기화"하는 코드가 필요하다고 느껴진다면, sidecar.py를 수정할 것이 아니라 forward converter의 변환 결과가 왜 XHTML 구조를 정확히 반영하지 못하는지를 먼저 조사해야 한다. + +--- + ## 알려진 제약과 구조적 이슈 ### 정보 손실 카테고리 @@ -660,6 +704,18 @@ Forward Conversion(XHTML → MDX)은 구조적으로 다음 정보를 손실한 | Self-closing 표기 | `
` vs `
` | | 블록 간 공백 | 정규화 | +### ⚠️ TECH DEBT: `_heading_lookahead` — 제거해야 할 중대한 부채 + +`sidecar.py`의 `_heading_lookahead()` 함수는 반드시 제거해야 할 설계 부채다. + +**문제:** `parse_mdx_blocks`가 list item 뒤 빈 줄 없이 이어지는 연속행을 별도 `paragraph` 블록으로 잘못 파싱하여 sidecar two-pointer alignment가 어긋난다. `_heading_lookahead`는 heading을 anchor 삼아 이 어긋남을 임시 보상하는 heuristic이다. + +**Markdown 규칙:** paragraph 분리는 반드시 빈 줄이 있어야 한다. forward converter는 한 문장을 한 줄에 표현하는 스펙에 따라 list item 내 문장을 빈 줄 없이 줄바꿈한다. 이 연속행은 동일 list item의 일부이며 별도 블록이 아니다. + +**제거 조건:** `parse_mdx_blocks`에서 list item 연속행(빈 줄 없이 이어지는 non-list-marker 줄)을 같은 블록으로 합치면 alignment 오류가 발생하지 않으며 이 함수를 제거할 수 있다. + +**추적 케이스:** page 544112828 — XHTML `p[6]`이 MDX에서 `list`(L48) + `paragraph`(L49)로 오분리됨 + ### Converter 모듈 구조적 이슈 - **전역 가변 상태**: `context.py`의 모듈 수준 전역 변수로 인해 in-process 병렬화 불가. 현재는 subprocess 격리로 우회. diff --git a/confluence-mdx/tests/test_reverse_sync_phase3.py b/confluence-mdx/tests/test_reverse_sync_phase3.py new file mode 100644 index 000000000..4f740e45a --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_phase3.py @@ -0,0 +1,79 @@ +"""Phase 3 inline-anchor/list reconstruction tests (v3 sidecar 경로).""" + +from pathlib import Path + +import pytest + +from reverse_sync.block_diff import diff_blocks +from reverse_sync.mapping_recorder import record_mapping +from mdx_to_storage.parser import parse_mdx_blocks +from reverse_sync.patch_builder import build_patches +from reverse_sync.sidecar import ( + build_sidecar, + build_xpath_to_mapping, +) +from reverse_sync.xhtml_patcher import patch_xhtml + + +def _build_patched_xhtml(xhtml: str, original_mdx: str, improved_mdx: str): + """v3 sidecar 경로로 XHTML 패치를 생성한다 (mdx_to_sidecar 없음).""" + original_blocks = parse_mdx_blocks(original_mdx) + improved_blocks = parse_mdx_blocks(improved_mdx) + changes, alignment = diff_blocks(original_blocks, improved_blocks) + + mappings = record_mapping(xhtml) + roundtrip_sidecar = build_sidecar(xhtml, original_mdx) + xpath_to_mapping = build_xpath_to_mapping(mappings) + + patches = build_patches( + changes, + original_blocks, + improved_blocks, + mappings, + xpath_to_mapping=xpath_to_mapping, + alignment=alignment, + roundtrip_sidecar=roundtrip_sidecar, + ) + return patches, patch_xhtml(xhtml, patches) + + +def test_list_with_inline_image_uses_replace_fragment_reconstruction(): + xhtml = ( + '' + ) + original_mdx = '* **Dry Run :** sample.png버튼을 클릭합니다.\n' + improved_mdx = '* **Dry Run :** sample.png버튼을 다시 클릭합니다.\n' + + patches, patched = _build_patched_xhtml(xhtml, original_mdx, improved_mdx) + + assert len(patches) == 1 + assert patches[0]["action"] == "replace_fragment" + assert "" in patched + assert "버튼을 다시 클릭합니다." in patched + + +class Test544376004: + @pytest.fixture(autouse=True) + def require_fixture(self): + case_dir = Path(__file__).parent / "reverse-sync" / "544376004" + if not case_dir.is_dir(): + pytest.skip("reverse-sync/544376004 fixture not found") + + def test_preserves_double_space_and_inline_image(self): + case_dir = Path(__file__).parent / "reverse-sync" / "544376004" + xhtml = (case_dir / "page.xhtml").read_text(encoding="utf-8") + original_mdx = (case_dir / "original.mdx").read_text(encoding="utf-8") + improved_mdx = (case_dir / "improved.mdx").read_text(encoding="utf-8") + + patches, patched = _build_patched_xhtml(xhtml, original_mdx, improved_mdx) + + replace_patches = [patch for patch in patches if patch.get("action") == "replace_fragment"] + assert replace_patches, "Phase 3 list reconstruction should emit replace_fragment" + assert any(patch["xhtml_xpath"] == "ul[3]" for patch in replace_patches) + assert "Enable Attribute Synchronization : LDAP" in patched + assert '