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
\nBody
"
+ 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]',
+ '',
+ 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 = ''
+ 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]',
+ '',
+ 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]",
+ "",
+ 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(
+ "",
+ 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
\nBody
"
+ 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]',
+ '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]',
+ '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]')