Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions confluence-mdx/bin/reverse_sync/preserving_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 57 additions & 8 deletions confluence-mdx/bin/reverse_sync/xhtml_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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']
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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':
Expand All @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_equivalence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<p>Before</p>"
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 = " \n<p>First</p>\n<!-- gap -->\n<p>Second</p>\n "
original_mdx = "# Title\n\nFirst\n\nSecond\n"
Expand Down
39 changes: 38 additions & 1 deletion confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -57,6 +57,43 @@ def test_no_change_when_text_not_found():
assert result == xhtml # 변경 없음


def test_strict_mode_rejects_source_text_mismatch():
xhtml = '<p>Original text.</p>'
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 = '<p>Original text.</p>'
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(
'<p>Original text.</p>',
[{'action': 'merge', 'xhtml_xpath': 'p[1]'}],
strict=True,
)


def test_compound_xpath_patches_callout_child():
"""복합 xpath로 callout 매크로 내부 자식 요소를 패치한다."""
xhtml = (
Expand Down
7 changes: 7 additions & 0 deletions openspec/changes/complete-reverse-sync/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 모두 통과해야 합니다.
Expand Down
16 changes: 14 additions & 2 deletions openspec/changes/complete-reverse-sync/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
Loading