From f682aa176b743704d7195819f095095428da612a Mon Sep 17 00:00:00 2001
From: JK
Date: Wed, 25 Feb 2026 22:15:02 +0900
Subject: [PATCH] =?UTF-8?q?confluence-mdx:=20reverse=5Fsync=20=EB=B8=94?=
=?UTF-8?q?=EB=A1=9D=20=EA=B2=BD=EA=B3=84=EC=97=90=EC=84=9C=20insert?=
=?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=9D=8C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EB=84=98=EC=B9=98?=
=?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
- `_apply_text_changes`에서 `` 내 서로 다른 `- ` 항목의 텍스트 노드 경계에서 insert opcode가
잘못된 항목에 할당되는 문제를 수정합니다.
- `_map_text_range`에 `include_insert_at_end`, `exclude_insert_at_start` 파라미터를 추가하여
블록 경계에서의 insert 할당을 제어합니다.
- `_find_block_ancestor` 헬퍼를 추가하여 텍스트 노드의 블록 레벨 부모를 감지합니다.
- right 노드 내부에 자체 변경이 있으면 boundary insert를 right에 유지하고,
없으면 left 노드에 할당하는 휴리스틱을 적용합니다.
### Background
flat list의 containing block 전략에서 `
` xpath로 전체 리스트의 텍스트를 패치할 때,
항목 끝에 추가된 텍스트(예: "이슈 해결")가 `SequenceMatcher`의 insert opcode로 생성되는데,
half-open range `[start, end)` 조건 때문에 경계 위치의 insert가 다음 `- `의 텍스트 노드에
할당되어 "해결[Privilege Type]..."과 같이 항목이 뒤섞이는 문제가 있었습니다.
## Added/updated tests?
- [x] Yes
- `test_flat_list_append_text_stays_in_same_item` — 항목 끝 텍스트 추가 시 같은 항목에 유지
- `test_flat_list_append_text_multiple_items` — 다수 항목 동시 추가 검증
- `test_flat_list_prepend_bracket_stays_in_correct_item` — bracket 삽입이 right에 유지 (회귀 방지)
- `test_flat_list_append_text_at_end_of_item` — patch_builder 레벨 검증
Co-Authored-By: Claude Opus 4.6
---
.../bin/reverse_sync/xhtml_patcher.py | 73 +++++++++++-
.../tests/test_reverse_sync_patch_builder.py | 54 +++++++++
.../tests/test_reverse_sync_xhtml_patcher.py | 109 ++++++++++++++++++
3 files changed, 233 insertions(+), 3 deletions(-)
diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py
index 58d3f3587..3a77fc201 100644
--- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py
+++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py
@@ -299,6 +299,40 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
last_nonempty_idx = j
break
+ # 블록 경계 감지: 서로 다른 블록 부모(p, li 등)에 속하는 인접 텍스트 노드 사이에서
+ # insert가 잘못된 노드에 할당되는 것을 방지한다.
+ # block_boundary_pairs: (left_idx, right_idx) 쌍 - 블록 경계를 이루는 노드 인덱스
+ block_boundary_pairs = []
+ prev_nonempty_idx = -1
+ for j, (ns, ne, nd) in enumerate(node_ranges):
+ if ns == ne:
+ continue
+ if prev_nonempty_idx >= 0:
+ prev_node = node_ranges[prev_nonempty_idx][2]
+ if _find_block_ancestor(prev_node) is not _find_block_ancestor(nd):
+ block_boundary_pairs.append((prev_nonempty_idx, j))
+ prev_nonempty_idx = j
+
+ # 블록 경계에서 insert가 right 노드에 기본 할당되는 것을 수정한다.
+ # right 노드 내부에 자체 변경(replace/delete/strictly-inside-insert)이 있으면
+ # boundary insert는 right의 동반 변경이므로 기본 동작을 유지한다.
+ # right 노드에 변경이 없으면 insert는 left 노드에 귀속시킨다.
+ # 예: "이모지 깨지는 이슈" + insert(" 해결") + unchanged → left에 할당
+ # 예: unchanged + insert("[") + "Authentication]..." → right에 할당
+ claim_end_set = set()
+ exclude_start_set = set()
+ for left_idx, right_idx in block_boundary_pairs:
+ right_start, right_end, _ = node_ranges[right_idx]
+ has_changes_in_right = any(
+ (tag in ('replace', 'delete')
+ and max(i1, right_start) < min(i2, right_end))
+ or (tag == 'insert' and right_start < i1 < right_end)
+ for tag, i1, i2, j1, j2 in opcodes
+ )
+ if not has_changes_in_right:
+ claim_end_set.add(left_idx)
+ exclude_start_set.add(right_idx)
+
for i, (node_start, node_end, node) in enumerate(node_ranges):
if node_start == node_end:
continue
@@ -306,8 +340,14 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
# _map_text_range는 half-open [start, end)를 사용하므로,
# 마지막 non-empty 노드에서는 end를 확장하여 trailing insert를 포함한다.
effective_end = node_end + 1 if i == last_nonempty_idx else node_end
+ # 블록 경계에서는 include_insert_at_end/exclude_insert_at_start로
+ # insert를 올바른 노드에 할당한다.
+ include_at_end = i in claim_end_set and i != last_nonempty_idx
+ exclude_at_start = i in exclude_start_set
new_node_text = _map_text_range(
- old_stripped, new_stripped, opcodes, node_start, effective_end
+ old_stripped, new_stripped, opcodes, node_start, effective_end,
+ include_insert_at_end=include_at_end,
+ exclude_insert_at_start=exclude_at_start,
)
node_str = str(node)
@@ -330,8 +370,31 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
node.replace_with(NavigableString(leading + new_node_text + trailing))
-def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int) -> str:
- """old_text[start:end] 범위에 대응하는 new_text 부분을 추출한다."""
+def _find_block_ancestor(node):
+ """텍스트 노드의 가장 가까운 블록 레벨 부모 요소를 찾는다."""
+ _BLOCK_TAGS = {
+ 'p', 'li', 'td', 'th', 'div', 'blockquote',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ }
+ parent = node.parent
+ while parent:
+ if isinstance(parent, Tag) and parent.name in _BLOCK_TAGS:
+ return parent
+ parent = parent.parent
+ return None
+
+
+def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int,
+ include_insert_at_end: bool = False,
+ exclude_insert_at_start: bool = False) -> str:
+ """old_text[start:end] 범위에 대응하는 new_text 부분을 추출한다.
+
+ Args:
+ include_insert_at_end: True이면 i1 == end 위치의 insert도 포함한다.
+ 블록 경계에서 trailing insert를 현재 노드에 할당할 때 사용.
+ exclude_insert_at_start: True이면 i1 == start 위치의 insert를 제외한다.
+ 이전 노드가 해당 insert를 이미 claim한 경우 중복 방지용.
+ """
result_parts = []
for tag, i1, i2, j1, j2 in opcodes:
# 이 opcode가 [start, end) 범위와 겹치는지 확인
@@ -360,8 +423,12 @@ def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int)
# insert는 old 텍스트에서 위치 i1 == i2
# 이 insert가 현재 노드 범위 [start, end) 안에 위치하면 포함
# half-open range를 사용하여 인접 노드 경계에서 중복 삽입 방지
+ if exclude_insert_at_start and i1 == start:
+ continue
if start <= i1 < end:
result_parts.append(new_text[j1:j2])
+ elif include_insert_at_end and i1 == end:
+ result_parts.append(new_text[j1:j2])
elif tag == 'delete':
# 삭제된 부분은 new에 아무것도 추가하지 않음
pass
diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py
index b5cd9850b..a93ad99c3 100644
--- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py
+++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py
@@ -804,6 +804,60 @@ def test_child_miss_falls_back_to_containing(self):
assert len(patches) == 1
assert patches[0]['xhtml_xpath'] == 'ul[1]'
+ def test_flat_list_append_text_at_end_of_item(self):
+ """flat list에서 항목 끝에 텍스트를 추가할 때 다음 항목으로 넘치지 않아야 한다.
+
+ 실제 사례: 9.10.0-9.10.4.mdx
+ Original: "* [MongoDB] 데이터 조회 시 이모지 깨지는 이슈"
+ Improved: "* [MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결"
+ 현상: "해결"이 다음 항목 앞에 붙어 "해결[Privilege Type]..."이 됨
+
+ 실제 사례: 11.1.0-11.1.2.mdx
+ Original: "* [General] 워크플로우 승인완료되었거나 ... External API"
+ Improved: "* [General] 워크플로우 승인 완료 상태이거나 ... External API 추가"
+ 현상: "추가"가 다음 항목 앞에 붙어 "추가[DAC]..."이 됨
+ """
+ parent = _make_mapping(
+ 'list-55',
+ '[MongoDB] Aggregate 커멘드 정책 적용 지원 (Web Editor) '
+ '[MongoDB] 데이터 조회 시 이모지 깨지는 이슈 '
+ '[Privilege Type] Default Privilege Type 이름 변경 (-role 제거)',
+ xpath='ul[5]',
+ type_='list',
+ children=[],
+ )
+ mappings = [parent]
+ xpath_to_mapping = {m.xhtml_xpath: m for m in mappings}
+ id_to_mapping = {m.block_id: m for m in mappings}
+
+ change = _make_change(
+ 0,
+ '* [MongoDB] Aggregate 커멘드 정책 적용 지원 (Web Editor)\n'
+ '* [MongoDB] 데이터 조회 시 이모지 깨지는 이슈\n'
+ '* [Privilege Type] Default Privilege Type 이름 변경 (-role 제거)\n',
+ '* [MongoDB] Aggregate 커맨드 정책 적용 지원(Web Editor)\n'
+ '* [MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결\n'
+ '* [Privilege Type] Default Privilege Type 이름 변경 (-role 제거)\n',
+ type_='list',
+ )
+ mdx_to_sidecar = {0: _make_sidecar('ul[5]', [0])}
+
+ patches = build_list_item_patches(
+ change, mappings, set(),
+ mdx_to_sidecar, xpath_to_mapping, id_to_mapping)
+
+ assert len(patches) == 1
+ p = patches[0]
+ assert 'new_plain_text' in p
+ # "해결"이 올바른 위치(이슈 뒤)에 있어야 함
+ assert '이슈 해결' in p['new_plain_text'], (
+ f"'이슈 해결'이 연속되어야 함: {p['new_plain_text']!r}"
+ )
+ # "해결"이 다음 항목 앞에 붙으면 안 됨
+ assert '해결[Privilege' not in p['new_plain_text'], (
+ f"'해결'이 다음 항목 앞에 잘못 삽입됨: {p['new_plain_text']!r}"
+ )
+
def test_flat_list_bracket_insert_at_item_start(self):
"""flat list에서 리스트 아이템 맨 앞에 '[' 삽입 시
XHTML의 해당 아이템 위치에 정확히 삽입되어야 한다.
diff --git a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py
index 47e3eb8fa..e825524b0 100644
--- a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py
+++ b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py
@@ -301,3 +301,112 @@ def test_insert_space_at_inline_element_boundary_not_duplicated():
assert ' 문서를 참조해' in result, (
f' 뒤에 공백이 없거나 다른 위치에 삽입됨: {result}'
)
+
+
+def test_flat_list_append_text_stays_in_same_item():
+ """flat list에서 항목 끝에 텍스트를 추가할 때 다음 항목으로 넘치지 않아야 한다.
+
+ 재현 시나리오:
+ XHTML:
+ 교정: '이모지 깨지는 이슈' → '이모지가 깨지는 이슈 해결'
+
+ 기대: '해결'이 첫 번째
- 안에 남음.
+ 버그: _apply_text_changes가 insert를 노드 경계에서 다음
- 의 text node에 할당하여
+ '해결이름 변경'이 됨.
+ """
+ xhtml = (
+ ''
+ )
+ patches = [
+ {
+ 'xhtml_xpath': 'ul[1]',
+ 'old_plain_text': (
+ '[MongoDB] 데이터 조회 시 이모지 깨지는 이슈'
+ '[Privilege Type] Default Privilege Type 이름 변경'
+ ),
+ 'new_plain_text': (
+ '[MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결'
+ '[Privilege Type] Default Privilege Type 이름 변경'
+ ),
+ }
+ ]
+ result = patch_xhtml(xhtml, patches)
+
+ # '해결'이 첫 번째 항목에 남아야 함
+ assert '이슈 해결
' in result, (
+ f"'해결'이 첫 번째 안에 없음: {result}"
+ )
+ # '해결'이 두 번째 항목 앞에 붙으면 안 됨
+ assert '해결[Privilege' not in result, (
+ f"'해결'이 다음 항목으로 넘침: {result}"
+ )
+
+
+def test_flat_list_append_text_multiple_items():
+ """여러 항목에서 동시에 텍스트를 추가해도 올바른 항목에 남아야 한다."""
+ xhtml = (
+ ''
+ 'item A text
'
+ 'item B text
'
+ 'item C text
'
+ '
'
+ )
+ patches = [
+ {
+ 'xhtml_xpath': 'ul[1]',
+ 'old_plain_text': 'item A textitem B textitem C text',
+ 'new_plain_text': 'item A text appendeditem B text modifieditem C text',
+ }
+ ]
+ result = patch_xhtml(xhtml, patches)
+
+ assert 'item A text appended
' in result, (
+ f"첫 번째 항목에 ' appended' 누락: {result}"
+ )
+ assert 'item B text modified
' in result, (
+ f"두 번째 항목에 ' modified' 누락: {result}"
+ )
+
+
+def test_flat_list_prepend_bracket_stays_in_correct_item():
+ """리스트 항목 앞에 '[' 삽입 시 해당 항목에 남아야 한다 (이전 항목으로 이동하면 안 됨).
+
+ 재현 시나리오:
+ old: "DynamoDB 데이터 조회 관련 이슈 개선Authentication Type 변경 시"
+ new: "DynamoDB 데이터 조회 관련 이슈 개선[Authentication] Type 변경 시"
+
+ 기대: '[' 가 두 번째 에 삽입됨.
+ 회귀 버그: block boundary fix가 '[' 를 이전 에 잘못 할당.
+ """
+ xhtml = (
+ ''
+ )
+ patches = [
+ {
+ 'xhtml_xpath': 'ul[1]',
+ 'old_plain_text': (
+ 'DynamoDB 데이터 조회 관련 이슈 개선'
+ 'Authentication Type 변경 시 오류 메시지 개선'
+ ),
+ 'new_plain_text': (
+ 'DynamoDB 데이터 조회 관련 이슈 개선'
+ '[Authentication] Type 변경 시 오류 메시지 개선'
+ ),
+ }
+ ]
+ result = patch_xhtml(xhtml, patches)
+
+ # DynamoDB 항목은 변경 없이 그대로
+ assert 'DynamoDB 데이터 조회 관련 이슈 개선
' in result, (
+ f"DynamoDB 항목이 변경됨: {result}"
+ )
+ # '[' 가 두 번째 항목에 삽입됨
+ assert '[Authentication] Type 변경 시 오류 메시지 개선
' in result, (
+ f"Authentication 항목에 bracket이 없음: {result}"
+ )