diff --git a/confluence-mdx/bin/reverse_sync/preserving_patcher.py b/confluence-mdx/bin/reverse_sync/preserving_patcher.py index 803829a66..0f2d4eeb1 100644 --- a/confluence-mdx/bin/reverse_sync/preserving_patcher.py +++ b/confluence-mdx/bin/reverse_sync/preserving_patcher.py @@ -8,7 +8,7 @@ from reverse_sync.models import sha256_text from reverse_sync.sidecar import RoundtripSidecar -from reverse_sync.xhtml_patcher import patch_xhtml +from reverse_sync.xhtml_patcher import XhtmlPatchError, patch_xhtml if TYPE_CHECKING: from reverse_sync.operations import PatchPlan @@ -111,7 +111,14 @@ def patch_xhtml_preserving( for position, block in enumerate(sidecar.blocks): local_patches = fragment_patches.get(position) if local_patches: - rendered = patch_xhtml(block.xhtml_fragment, local_patches) + try: + rendered = patch_xhtml( + block.xhtml_fragment, + local_patches, + strict=True, + ) + except XhtmlPatchError as exc: + raise PatchApplicationError(str(exc)) from exc parts.append(rendered) else: parts.append(block.xhtml_fragment) diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 802f845e3..49672fe5e 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -6,7 +6,16 @@ from reverse_sync.mapping_recorder import get_text_with_emoticons, iter_block_children -def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: +class XhtmlPatchError(ValueError): + """strict XHTML renderer가 operation을 정확히 적용하지 못했습니다.""" + + +def patch_xhtml( + xhtml: str, + patches: List[Dict[str, str]], + *, + strict: bool = False, +) -> str: """XHTML에 패치를 적용한다. Args: @@ -22,6 +31,17 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: 패치된 XHTML 문자열 """ soup = BeautifulSoup(xhtml, 'html.parser') + supported_actions = {'modify', 'delete', 'insert', 'replace_fragment'} + if strict: + unsupported = [ + str(patch.get('action', 'modify')) + for patch in patches + if patch.get('action', 'modify') not in supported_actions + ] + if unsupported: + raise XhtmlPatchError( + f"지원하지 않는 XHTML patch action입니다: {unsupported[0]}" + ) # 패치를 action별로 분류 delete_patches = [p for p in patches if p.get('action') == 'delete'] @@ -34,8 +54,13 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: resolved_deletes = [] for p in delete_patches: el = _find_element_by_xpath(soup, p['xhtml_xpath']) - if el is not None: - resolved_deletes.append(el) + if el is None: + if strict: + raise XhtmlPatchError( + f"delete target을 찾을 수 없습니다: {p['xhtml_xpath']}" + ) + continue + resolved_deletes.append(el) resolved_inserts = [] for p in insert_patches: @@ -44,20 +69,34 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: if after_xpath is not None: anchor = _find_element_by_xpath(soup, after_xpath) if anchor is None: - continue # anchor를 못 찾으면 skip + if strict: + raise XhtmlPatchError( + f"insert anchor를 찾을 수 없습니다: {after_xpath}" + ) + continue resolved_inserts.append((anchor, p)) resolved_modifies = [] for p in modify_patches: el = _find_element_by_xpath(soup, p['xhtml_xpath']) - if el is not None: - resolved_modifies.append((el, p)) + if el is None: + if strict: + raise XhtmlPatchError( + f"modify target을 찾을 수 없습니다: {p['xhtml_xpath']}" + ) + continue + resolved_modifies.append((el, p)) resolved_replacements = [] for p in replace_patches: el = _find_element_by_xpath(soup, p['xhtml_xpath']) - if el is not None: - resolved_replacements.append((el, p)) + if el is None: + if strict: + raise XhtmlPatchError( + f"replace target을 찾을 수 없습니다: {p['xhtml_xpath']}" + ) + continue + resolved_replacements.append((el, p)) # 1단계: delete for element in resolved_deletes: @@ -81,6 +120,11 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: if old_text and current_plain.strip() != old_text.strip(): current_plain_with_emoticons = get_text_with_emoticons(element) if current_plain_with_emoticons.strip() != old_text.strip(): + if strict: + raise XhtmlPatchError( + "modify source text가 target fragment와 다릅니다: " + f"{patch['xhtml_xpath']}" + ) continue _replace_inner_html(element, patch['new_inner_xhtml']) if 'ol_start' in patch and isinstance(element, Tag) and element.name == 'ol': @@ -98,6 +142,11 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: if current_plain.strip() != old_text.strip(): current_plain_with_emoticons = get_text_with_emoticons(element) if current_plain_with_emoticons.strip() != old_text.strip(): + if strict: + raise XhtmlPatchError( + "modify source text가 target fragment와 다릅니다: " + f"{patch['xhtml_xpath']}" + ) continue _materialize_replaced_emoticons(element, old_text, new_text) _apply_text_changes(element, old_text, new_text) diff --git a/confluence-mdx/tests/test_reverse_sync_equivalence.py b/confluence-mdx/tests/test_reverse_sync_equivalence.py index 614e65998..72ce55edf 100644 --- a/confluence-mdx/tests/test_reverse_sync_equivalence.py +++ b/confluence-mdx/tests/test_reverse_sync_equivalence.py @@ -105,6 +105,28 @@ def test_preserving_patcher_fails_when_exact_target_is_missing(): raise AssertionError("missing target이 block되지 않았습니다") +def test_preserving_patcher_fails_when_fragment_source_text_mismatches(): + base = "
Before
" + sidecar = build_sidecar(base, "# Title\n\nBefore\n", page_id="123") + + try: + patch_xhtml_preserving( + base, + [ + { + "xhtml_xpath": "p[1]", + "old_plain_text": "Different source", + "new_plain_text": "After", + } + ], + sidecar, + ) + except PatchApplicationError as exc: + assert "source text" in str(exc) + else: + raise AssertionError("source text mismatch가 block되지 않았습니다") + + def test_preserving_patcher_keeps_envelope_and_separators_across_insert_delete(): base = " \nFirst
\n\nSecond
\n " original_mdx = "# Title\n\nFirst\n\nSecond\n" diff --git a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py index e453d4886..a4f842c99 100644 --- a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py +++ b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py @@ -1,6 +1,6 @@ import pytest from bs4 import BeautifulSoup -from reverse_sync.xhtml_patcher import patch_xhtml +from reverse_sync.xhtml_patcher import XhtmlPatchError, patch_xhtml def test_simple_text_replacement(): @@ -57,6 +57,43 @@ def test_no_change_when_text_not_found(): assert result == xhtml # 변경 없음 +def test_strict_mode_rejects_source_text_mismatch(): + xhtml = 'Original text.
' + patches = [ + { + 'xhtml_xpath': 'p[1]', + 'old_plain_text': 'Different source.', + 'new_plain_text': 'Replaced.', + } + ] + + with pytest.raises(XhtmlPatchError, match='source text'): + patch_xhtml(xhtml, patches, strict=True) + + +def test_strict_mode_rejects_unresolved_nested_target(): + xhtml = 'Original text.
' + patches = [ + { + 'xhtml_xpath': 'macro-info[1]/p[1]', + 'old_plain_text': 'Original text.', + 'new_plain_text': 'Replaced.', + } + ] + + with pytest.raises(XhtmlPatchError, match='modify target'): + patch_xhtml(xhtml, patches, strict=True) + + +def test_strict_mode_rejects_unknown_action(): + with pytest.raises(XhtmlPatchError, match='action'): + patch_xhtml( + 'Original text.
', + [{'action': 'merge', 'xhtml_xpath': 'p[1]'}], + strict=True, + ) + + def test_compound_xpath_patches_callout_child(): """복합 xpath로 callout 매크로 내부 자식 요소를 패치한다.""" xhtml = ( diff --git a/openspec/changes/complete-reverse-sync/design.md b/openspec/changes/complete-reverse-sync/design.md index a6556a854..d9764c8c7 100644 --- a/openspec/changes/complete-reverse-sync/design.md +++ b/openspec/changes/complete-reverse-sync/design.md @@ -324,6 +324,13 @@ sidecar와 다시 비교합니다. CLI와 proof는 raw patch dict를 직접 소 typed target identity가 바뀌었거나 다른 base에 plan을 적용하려 하면 `PatchApplicationError`로 중단합니다. +target identity를 통과한 operation은 `xhtml_patcher.patch_xhtml(strict=true)`로 +적용합니다. strict renderer는 rebased target/anchor가 없거나 source visible +text가 base fragment와 다르거나 action이 미등록이면 조용히 skip하지 않고 +`PatchApplicationError`로 중단합니다. offline diagnostic은 migration fixture +호환성을 위해 기본 lenient renderer를 계속 사용할 수 있지만 push candidate를 +생성할 수 없습니다. + ### Decision: local proof를 여러 독립 gate로 분해합니다 `verified` 상태는 다음 gate를 모두 통과해야 합니다. diff --git a/openspec/changes/complete-reverse-sync/tasks.md b/openspec/changes/complete-reverse-sync/tasks.md index 815057259..fb648cb20 100644 --- a/openspec/changes/complete-reverse-sync/tasks.md +++ b/openspec/changes/complete-reverse-sync/tasks.md @@ -181,6 +181,10 @@ - mapping/sidecar 부재는 `missing_identity`로 정규화하고 같은 intent의 중복 issue를 제거합니다. - [ ] 기존 `xhtml_patcher.py`는 validated operation 적용에 집중하도록 축소합니다. + - typed preserving renderer는 `strict=true`로 호출하여 unresolved target, + source text mismatch, unknown action을 `PatchApplicationError`로 전환합니다. + - offline diagnostic raw patch caller와 legacy helper를 분리하는 작업은 계속 + 남아 있습니다. 완료 gate: @@ -330,10 +334,18 @@ visible segment branch 검증 결과: - reverse-sync Python test: 801 passed - 전체 Python test: 1124 passed, 2 skipped +strict renderer branch 검증 결과: + +- `make test-convert`: 21 passed +- `make test-reverse-sync`: golden 16 passed, regression 43 passed +- `make test-byte-verify`: fast/splice 각각 21/21 passed +- reverse-sync Python test: 805 passed +- 전체 Python test: 1128 passed, 2 skipped + - [x] 영향도에 따라 전체 Python test와 render test를 실행합니다. -이번 변경은 Python planner/renderer adapter 범위이므로 전체 Python test -(`1124 passed, 2 skipped`)를 실행했고 frontend render test는 영향 범위에서 +이번 변경은 Python renderer 범위이므로 전체 Python test +(`1128 passed, 2 skipped`)를 실행했고 frontend render test는 영향 범위에서 제외했습니다. ```bash