From d186ca48c0c4c6dea3e02a0590581b70e39310b4 Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 15 Mar 2026 15:58:35 +0900 Subject: [PATCH 1/8] confluence-mdx: add splice page-title regression test --- confluence-mdx/tests/test_reverse_sync_rehydrator.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/confluence-mdx/tests/test_reverse_sync_rehydrator.py b/confluence-mdx/tests/test_reverse_sync_rehydrator.py index dcda309a9..152c339d4 100644 --- a/confluence-mdx/tests/test_reverse_sync_rehydrator.py +++ b/confluence-mdx/tests/test_reverse_sync_rehydrator.py @@ -114,6 +114,17 @@ def test_preserves_envelope(self): result = splice_rehydrate_xhtml(mdx, sidecar) assert result.xhtml == xhtml + def test_skips_page_title_heading_that_matches_frontmatter(self): + xhtml = "

Overview

\n

Body

" + mdx = "---\ntitle: T\n---\n\n# T\n\n### Overview\n\nBody\n" + sidecar = build_sidecar(xhtml, mdx) + + result = splice_rehydrate_xhtml(mdx, sidecar) + + assert result.xhtml == xhtml + assert result.matched_count == 2 + assert result.emitted_count == 0 + class TestSpliceRealTestcases: """실제 testcase에 대한 forced-splice byte-equal 검증.""" From bb01b307cf0059e27a70c4c582835fc50c022253 Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 15 Mar 2026 16:01:13 +0900 Subject: [PATCH 2/8] confluence-mdx: fix splice rehydrator identity path --- confluence-mdx/bin/reverse_sync/rehydrator.py | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/rehydrator.py b/confluence-mdx/bin/reverse_sync/rehydrator.py index d9e2dc8af..4a5cc77a8 100644 --- a/confluence-mdx/bin/reverse_sync/rehydrator.py +++ b/confluence-mdx/bin/reverse_sync/rehydrator.py @@ -59,6 +59,45 @@ def _mdx_block_to_parser_block(mdx_block: MdxBlock) -> Block: return Block(type=block_type, content=mdx_block.content) +def _extract_frontmatter_title(mdx_blocks: List[MdxBlock]) -> str: + for block in mdx_blocks: + if block.type != "frontmatter": + continue + for raw_line in block.content.splitlines(): + line = raw_line.strip() + if not line.startswith("title:"): + continue + value = line.split(":", 1)[1].strip() + if ( + len(value) >= 2 + and value[0] == value[-1] + and value[0] in {"'", '"'} + ): + return value[1:-1] + return value + return "" + + +def _content_blocks_for_splice(mdx_text: str) -> List[MdxBlock]: + mdx_blocks = parse_mdx_blocks(mdx_text) + content_blocks = [b for b in mdx_blocks if b.type not in _NON_CONTENT] + + if not content_blocks: + return content_blocks + + frontmatter_title = _extract_frontmatter_title(mdx_blocks).strip() + first_block = content_blocks[0] + if ( + first_block.type == "heading" + and frontmatter_title + and first_block.content.startswith("# ") + and first_block.content[2:].strip() == frontmatter_title + ): + return content_blocks[1:] + + return content_blocks + + def splice_rehydrate_xhtml( mdx_text: str, sidecar: RoundtripSidecar, @@ -71,8 +110,27 @@ def splice_rehydrate_xhtml( - 해시가 일치하는 블록: 원본 xhtml_fragment 사용 - 해시가 불일치하는 블록: emitter로 재생성 """ - mdx_blocks = parse_mdx_blocks(mdx_text) - content_blocks = [b for b in mdx_blocks if b.type not in _NON_CONTENT] + if sidecar_matches_mdx(mdx_text, sidecar): + details: List[dict] = [] + matched_count = 0 + for i, sb in enumerate(sidecar.blocks): + method = "sidecar" if sb.mdx_content_hash else "preserved" + if method == "sidecar": + matched_count += 1 + details.append({ + "index": i, + "method": method, + "xpath": sb.xhtml_xpath, + }) + return SpliceResult( + xhtml=sidecar.reassemble_xhtml(), + matched_count=matched_count, + emitted_count=0, + total_blocks=len(sidecar.blocks), + block_details=details, + ) + + content_blocks = _content_blocks_for_splice(mdx_text) matched_count = 0 emitted_count = 0 From 73e1f81e025f801b01a10cf60b002d4c21041e9e Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 15 Mar 2026 23:24:47 +0900 Subject: [PATCH 3/8] confluence-mdx: add reverse-sync fallback regression tests --- .../tests/test_reverse_sync_patch_builder.py | 50 ++++++++ .../tests/test_reverse_sync_reconstructors.py | 121 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 confluence-mdx/tests/test_reverse_sync_reconstructors.py diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index c79c026a9..f7f661967 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -12,9 +12,11 @@ RoundtripSidecar, SidecarBlock, SidecarEntry, + sha256_text, ) from text_utils import normalize_mdx_to_plain from reverse_sync.patch_builder import ( + _find_roundtrip_sidecar_block, _flush_containing_changes, _resolve_mapping_for_change, build_patches, @@ -421,6 +423,54 @@ def test_roundtrip_sidecar_non_paragraph_reconstruction_stays_modify(self): assert patches[0].get('action', 'modify') == 'modify' assert 'new_element_xhtml' not in patches[0] + def test_roundtrip_identity_fallback_rejects_cross_type_sidecar_block(self): + mapping = _make_mapping('m1', 'same text', xpath='p[6]') + change = _make_change(0, 'same text', 'updated text') + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock( + 0, + 'table[2]', + '
same text
', + sha256_text(change.old_block.content), + (change.old_block.line_start, change.old_block.line_end), + ) + ]) + + sidecar_block = _find_roundtrip_sidecar_block( + change, + mapping, + roundtrip_sidecar, + {block.xhtml_xpath: block for block in roundtrip_sidecar.blocks}, + ) + + assert sidecar_block is None + + def test_list_roundtrip_identity_fallback_rejects_cross_type_mapping(self): + m1 = _make_mapping('m1', 'same text', xpath='ul[1]', type_='list') + m1.xhtml_text = '
  • same text

' + mappings = [m1] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + mdx_to_sidecar = self._setup_sidecar('ul[1]', 0) + change = _make_change(0, '- same text', '- updated text', type_='list') + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock( + 0, + 'table[2]', + '
same text
', + sha256_text(change.old_block.content), + (change.old_block.line_start, change.old_block.line_end), + ) + ]) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping, + roundtrip_sidecar=roundtrip_sidecar) + + assert len(patches) == 1 + assert patches[0]['action'] == 'replace_fragment' + assert patches[0]['xhtml_xpath'] == 'ul[1]' + # NON_CONTENT_TYPES 스킵 def test_skips_non_content_types(self): m1 = _make_mapping('m1', 'text', xpath='p[1]') diff --git a/confluence-mdx/tests/test_reverse_sync_reconstructors.py b/confluence-mdx/tests/test_reverse_sync_reconstructors.py new file mode 100644 index 000000000..0fbf1bc49 --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_reconstructors.py @@ -0,0 +1,121 @@ +"""reverse_sync/reconstructors.py unit tests.""" + +from reverse_sync.reconstructors import ( + reconstruct_fragment_with_sidecar, + sidecar_block_requires_reconstruction, +) +from reverse_sync.sidecar import SidecarBlock + + +def _make_image_anchor(offset: int, affinity: str = "before") -> dict: + return { + "anchor_kind": "ac:image", + "old_plain_offset": offset, + "affinity": affinity, + "raw_xhtml": ( + '' + '' + '' + ), + } + + +def test_sidecar_block_requires_reconstruction_for_paragraph_anchors(): + sidecar_block = SidecarBlock( + 0, + "p[1]", + "

Hello world

", + reconstruction={ + "kind": "paragraph", + "old_plain_text": "Hello world", + "anchors": [_make_image_anchor(6)], + }, + ) + + assert sidecar_block_requires_reconstruction(sidecar_block) is True + + +def test_sidecar_block_requires_reconstruction_for_list_item_anchors(): + sidecar_block = SidecarBlock( + 0, + "ul[1]", + "
  • button

", + reconstruction={ + "kind": "list", + "old_plain_text": "button", + "items": [ + { + "path": [0], + "old_plain_text": "button", + "anchors": [_make_image_anchor(0)], + } + ], + }, + ) + + assert sidecar_block_requires_reconstruction(sidecar_block) is True + + +def test_reconstruct_fragment_with_sidecar_rehydrates_paragraph_anchor(): + sidecar_block = SidecarBlock( + 0, + "p[1]", + "

Hello world

", + reconstruction={ + "kind": "paragraph", + "old_plain_text": "Hello world", + "anchors": [_make_image_anchor(6)], + }, + ) + + result = reconstruct_fragment_with_sidecar( + "

Hello brave world

", + sidecar_block, + ) + + assert "
  • button

  • ", + reconstruction={ + "kind": "list", + "old_plain_text": "button", + "items": [ + { + "path": [0], + "old_plain_text": "button", + "anchors": [_make_image_anchor(0)], + } + ], + }, + ) + + result = reconstruct_fragment_with_sidecar( + "
    • button again

    ", + sidecar_block, + ) + + assert "Hello world

    ", + reconstruction={ + "kind": "paragraph", + "old_plain_text": "Hello world", + "anchors": [], + }, + ) + + assert sidecar_block_requires_reconstruction(sidecar_block) is False From b0e86160bcfc5a4890fb22e5ebb0a048ed162850 Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 15 Mar 2026 23:24:57 +0900 Subject: [PATCH 4/8] confluence-mdx: harden reverse-sync sidecar identity fallback --- .../bin/reverse_sync/patch_builder.py | 31 ++++++++++++++++--- .../tests/test_reverse_sync_patch_builder.py | 3 +- .../tests/test_reverse_sync_reconstructors.py | 28 +++++++++++------ 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 37ffceccf..00c1f3175 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -147,7 +147,7 @@ def _find_roundtrip_sidecar_block( return xpath_match # identity fallback: mapping.yaml이 어긋난 경우 hash 기반으로 재탐색 - # xpath 태그 타입(p, ul, ol, table 등)이 일치하는 경우에만 반환하여 cross-type 오매칭 방지 + # block family(paragraph/list/table 등)가 일치하는 경우에만 반환하여 cross-type 오매칭 방지 if identity_block is not None and identity_block.content: identity_match = find_sidecar_block_by_identity( roundtrip_sidecar.blocks, @@ -155,15 +155,38 @@ def _find_roundtrip_sidecar_block( (identity_block.line_start, identity_block.line_end), ) if identity_match is not None: - mapping_tag = mapping.xhtml_xpath.split('[')[0] if mapping else '' - identity_tag = identity_match.xhtml_xpath.split('[')[0] if identity_match.xhtml_xpath else '' - if mapping_tag == identity_tag: + if mapping is None or _mapping_block_family(mapping) == _xpath_block_family(identity_match.xhtml_xpath): return identity_match # xpath 결과를 마지막 fallback으로 반환 (hash 불일치라도 없는 것보다 나음) return xpath_match +def _xpath_root_tag(xpath: str) -> str: + """Extract the top-level tag portion from an xpath-like storage path.""" + head = xpath.split("/", 1)[0] + return head.split("[", 1)[0] + + +def _xpath_block_family(xpath: str) -> str: + root_tag = _xpath_root_tag(xpath) + if root_tag == "p": + return "paragraph" + if root_tag in {"ul", "ol"}: + return "list" + if root_tag == "table": + return "table" + if root_tag.startswith("h") and root_tag[1:].isdigit(): + return "heading" + return root_tag + + +def _mapping_block_family(mapping: BlockMapping) -> str: + if mapping.type in {"paragraph", "list", "heading", "table"}: + return mapping.type + return _xpath_block_family(mapping.xhtml_xpath) + + def _flush_containing_changes( containing_changes: dict, used_ids: 'set | None' = None, diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index f7f661967..08ba9ffd2 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -468,8 +468,9 @@ def test_list_roundtrip_identity_fallback_rejects_cross_type_mapping(self): roundtrip_sidecar=roundtrip_sidecar) assert len(patches) == 1 - assert patches[0]['action'] == 'replace_fragment' assert patches[0]['xhtml_xpath'] == 'ul[1]' + # cross-type sidecar(table[2])가 거부되어 list 재생성 경로로 처리됨 + assert 'new_inner_xhtml' in patches[0] # NON_CONTENT_TYPES 스킵 def test_skips_non_content_types(self): diff --git a/confluence-mdx/tests/test_reverse_sync_reconstructors.py b/confluence-mdx/tests/test_reverse_sync_reconstructors.py index 0fbf1bc49..da2f40a7f 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstructors.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstructors.py @@ -7,11 +7,10 @@ from reverse_sync.sidecar import SidecarBlock -def _make_image_anchor(offset: int, affinity: str = "before") -> dict: +def _make_image_anchor(offset: int) -> dict: return { - "anchor_kind": "ac:image", - "old_plain_offset": offset, - "affinity": affinity, + "kind": "image", + "offset": offset, "raw_xhtml": ( '' '' @@ -45,9 +44,14 @@ def test_sidecar_block_requires_reconstruction_for_list_item_anchors(): "old_plain_text": "button", "items": [ { + "kind": "image", "path": [0], - "old_plain_text": "button", - "anchors": [_make_image_anchor(0)], + "offset": 0, + "raw_xhtml": ( + '' + '' + '' + ), } ], }, @@ -74,7 +78,8 @@ def test_reconstruct_fragment_with_sidecar_rehydrates_paragraph_anchor(): ) assert "' + '' + '' + ), } ], }, From d4b2166ad0b4412c7fe318164cda22b4313ef0b6 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 16 Mar 2026 00:14:18 +0900 Subject: [PATCH 5/8] confluence-mdx: harden clean-list sidecar fallback --- confluence-mdx/bin/reverse_sync/patch_builder.py | 11 ++++++++++- .../tests/test_reverse_sync_patch_builder.py | 3 +-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 00c1f3175..7759cdfa1 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -393,9 +393,18 @@ def _mark_used(block_id: str, m: BlockMapping): list_sidecar = _find_roundtrip_sidecar_block( change, mapping, roundtrip_sidecar, xpath_to_sidecar_block, ) + should_replace_clean_list = ( + mapping is not None + and not _contains_preserved_anchor_markup(mapping.xhtml_text) + and roundtrip_sidecar is not None + and list_sidecar is None + ) if (mapping is not None and not _contains_preserved_anchor_markup(mapping.xhtml_text) - and sidecar_block_requires_reconstruction(list_sidecar)): + and ( + sidecar_block_requires_reconstruction(list_sidecar) + or should_replace_clean_list + )): _mark_used(mapping.block_id, mapping) patches.append( _build_replace_fragment_patch( diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index 08ba9ffd2..f7f661967 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -468,9 +468,8 @@ def test_list_roundtrip_identity_fallback_rejects_cross_type_mapping(self): roundtrip_sidecar=roundtrip_sidecar) assert len(patches) == 1 + assert patches[0]['action'] == 'replace_fragment' assert patches[0]['xhtml_xpath'] == 'ul[1]' - # cross-type sidecar(table[2])가 거부되어 list 재생성 경로로 처리됨 - assert 'new_inner_xhtml' in patches[0] # NON_CONTENT_TYPES 스킵 def test_skips_non_content_types(self): From 7eb58a832e5d1d0657e797d30aa6e45757facf44 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 16 Mar 2026 00:26:19 +0900 Subject: [PATCH 6/8] confluence-mdx: add reverse-sync regression coverage --- .../tests/test_reverse_sync_byte_verify.py | 17 +++++++ .../tests/test_reverse_sync_patch_builder.py | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/confluence-mdx/tests/test_reverse_sync_byte_verify.py b/confluence-mdx/tests/test_reverse_sync_byte_verify.py index bb3093ef2..00b078d7c 100644 --- a/confluence-mdx/tests/test_reverse_sync_byte_verify.py +++ b/confluence-mdx/tests/test_reverse_sync_byte_verify.py @@ -87,6 +87,23 @@ def test_verify_case_dir_splice_fails_when_sidecar_missing(tmp_path): assert result.reason.startswith("sidecar_missing") +def test_verify_case_dir_splice_skips_page_title_heading(tmp_path): + case = tmp_path / "100" + case.mkdir() + xhtml = "

    Overview

    \n

    Body

    " + mdx = "---\ntitle: T\n---\n\n# T\n\n### Overview\n\nBody\n" + (case / "expected.mdx").write_text(mdx, encoding="utf-8") + (case / "page.xhtml").write_text(xhtml, encoding="utf-8") + write_sidecar(build_sidecar(xhtml, mdx, page_id="100"), case / "expected.roundtrip.json") + + result = verify_case_dir_splice(case) + + assert result.passed is True + assert result.reason == "byte_equal_splice" + assert result.matched_count == 2 + assert result.emitted_count == 0 + + class TestSpliceRealTestcases: """실제 testcase에 대한 forced-splice byte-equal 검증.""" diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index f7f661967..9325158f5 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -471,6 +471,52 @@ def test_list_roundtrip_identity_fallback_rejects_cross_type_mapping(self): assert patches[0]['action'] == 'replace_fragment' assert patches[0]['xhtml_xpath'] == 'ul[1]' + def test_roundtrip_identity_fallback_accepts_ul_ol_same_list_family(self): + mapping = _make_mapping('m1', 'same text', xpath='ul[1]', type_='list') + change = _make_change(0, '- same text', '- updated text', type_='list') + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock( + 0, + 'ol[2]', + '
    1. same text

    ', + sha256_text(change.old_block.content), + (change.old_block.line_start, change.old_block.line_end), + ) + ]) + + sidecar_block = _find_roundtrip_sidecar_block( + change, + mapping, + roundtrip_sidecar, + {block.xhtml_xpath: block for block in roundtrip_sidecar.blocks}, + ) + + assert sidecar_block is not None + assert sidecar_block.xhtml_xpath == 'ol[2]' + + def test_roundtrip_identity_fallback_accepts_heading_family(self): + mapping = _make_mapping('m1', 'same heading', xpath='h2[1]', type_='heading') + change = _make_change(0, '## same heading', '## updated heading', type_='heading') + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock( + 0, + 'h3[4]', + '

    same heading

    ', + sha256_text(change.old_block.content), + (change.old_block.line_start, change.old_block.line_end), + ) + ]) + + sidecar_block = _find_roundtrip_sidecar_block( + change, + mapping, + roundtrip_sidecar, + {block.xhtml_xpath: block for block in roundtrip_sidecar.blocks}, + ) + + assert sidecar_block is not None + assert sidecar_block.xhtml_xpath == 'h3[4]' + # NON_CONTENT_TYPES 스킵 def test_skips_non_content_types(self): m1 = _make_mapping('m1', 'text', xpath='p[1]') From b9d39f76ecce51cd990f4e85653ac6f729e8dfe6 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 16 Mar 2026 00:46:11 +0900 Subject: [PATCH 7/8] =?UTF-8?q?confluence-mdx:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=9E=90=EB=AA=85=ED=95=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rehydrator._content_blocks_for_splice: 불필요한 .strip() 제거 - patch_builder._xpath_block_family: 미지원 태그 처리 의도 docstring 추가 - patch_builder.should_replace_clean_list: cross-type 거부/mapping drift 처리 의도 주석 추가 - test_reverse_sync_reconstructors: sidecar_block=None passthrough 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 --- confluence-mdx/bin/reverse_sync/patch_builder.py | 7 +++++++ confluence-mdx/bin/reverse_sync/rehydrator.py | 2 +- confluence-mdx/tests/test_reverse_sync_reconstructors.py | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 7759cdfa1..60cf664d7 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -169,6 +169,11 @@ def _xpath_root_tag(xpath: str) -> str: def _xpath_block_family(xpath: str) -> str: + """xpath의 최상위 태그를 block family 문자열로 변환한다. + + 알 수 없는 태그(pre, blockquote, ac:* 등)는 raw tag를 반환하여 + cross-type 보호 목적상 보수적으로 동작한다. + """ root_tag = _xpath_root_tag(xpath) if root_tag == "p": return "paragraph" @@ -393,6 +398,8 @@ def _mark_used(block_id: str, m: BlockMapping): list_sidecar = _find_roundtrip_sidecar_block( change, mapping, roundtrip_sidecar, xpath_to_sidecar_block, ) + # roundtrip sidecar가 있지만 이 list에 매칭되는 block이 없을 때 + # (cross-type 거부 또는 mapping drift) clean list는 whole-fragment 재생성으로 처리 should_replace_clean_list = ( mapping is not None and not _contains_preserved_anchor_markup(mapping.xhtml_text) diff --git a/confluence-mdx/bin/reverse_sync/rehydrator.py b/confluence-mdx/bin/reverse_sync/rehydrator.py index 4a5cc77a8..8409f061a 100644 --- a/confluence-mdx/bin/reverse_sync/rehydrator.py +++ b/confluence-mdx/bin/reverse_sync/rehydrator.py @@ -85,7 +85,7 @@ def _content_blocks_for_splice(mdx_text: str) -> List[MdxBlock]: if not content_blocks: return content_blocks - frontmatter_title = _extract_frontmatter_title(mdx_blocks).strip() + frontmatter_title = _extract_frontmatter_title(mdx_blocks) first_block = content_blocks[0] if ( first_block.type == "heading" diff --git a/confluence-mdx/tests/test_reverse_sync_reconstructors.py b/confluence-mdx/tests/test_reverse_sync_reconstructors.py index da2f40a7f..eb186ac67 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstructors.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstructors.py @@ -116,6 +116,11 @@ def test_reconstruct_fragment_with_sidecar_rehydrates_list_item_anchor(): assert "text

    ", None) + assert result == "

    text

    " + + def test_sidecar_block_requires_reconstruction_false_without_anchors(): sidecar_block = SidecarBlock( 0, From d4c42e4d79f9fab934d4ad05bf75bbba7ade4db6 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 16 Mar 2026 00:51:17 +0900 Subject: [PATCH 8/8] confluence-mdx: narrow sidecar identity fallback --- .../bin/reverse_sync/patch_builder.py | 2 +- .../tests/test_reverse_sync_patch_builder.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 60cf664d7..0e951d9a1 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -155,7 +155,7 @@ def _find_roundtrip_sidecar_block( (identity_block.line_start, identity_block.line_end), ) if identity_match is not None: - if mapping is None or _mapping_block_family(mapping) == _xpath_block_family(identity_match.xhtml_xpath): + if mapping is not None and _mapping_block_family(mapping) == _xpath_block_family(identity_match.xhtml_xpath): return identity_match # xpath 결과를 마지막 fallback으로 반환 (hash 불일치라도 없는 것보다 나음) diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index 9325158f5..65ea23c8d 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -517,6 +517,27 @@ def test_roundtrip_identity_fallback_accepts_heading_family(self): assert sidecar_block is not None assert sidecar_block.xhtml_xpath == 'h3[4]' + def test_roundtrip_identity_fallback_does_not_guess_without_mapping(self): + change = _make_change(0, '- same text', '- updated text', type_='list') + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock( + 0, + 'ol[2]', + '
    1. same text

    ', + sha256_text(change.old_block.content), + (change.old_block.line_start, change.old_block.line_end), + ) + ]) + + sidecar_block = _find_roundtrip_sidecar_block( + change, + None, + roundtrip_sidecar, + {block.xhtml_xpath: block for block in roundtrip_sidecar.blocks}, + ) + + assert sidecar_block is None + # NON_CONTENT_TYPES 스킵 def test_skips_non_content_types(self): m1 = _make_mapping('m1', 'text', xpath='p[1]')