diff --git a/confluence-mdx/docs/plans/2026-03-13-reverse-sync-reconstruction-design-review.md b/confluence-mdx/docs/plans/2026-03-13-reverse-sync-reconstruction-design-review.md new file mode 100644 index 000000000..a8b02493e --- /dev/null +++ b/confluence-mdx/docs/plans/2026-03-13-reverse-sync-reconstruction-design-review.md @@ -0,0 +1,493 @@ +# Reverse Sync 전면 재구성 설계 — 검토 평가 결과 (v5) + +> 검토 대상: `2026-03-13-reverse-sync-reconstruction-design.md` +> 검토일: 2026-03-13 +> 검토 기준 버전: PR #913 head `33fa095e56cb26766995b3930d3616a58559685e` +> 검토 관점: 설계 타당성, 코드베이스 정합성, TDD 관점의 테스트 확보 가능성 + +--- + +## 결론 + +문서가 제시하는 **큰 방향**은 타당하다. + +- patch heuristic을 계속 누적하는 대신 MDX → XHTML 재구성 경로로 수렴시키려는 방향은 맞다 +- list를 sidecar flat entry + `children` ref 구조로 재설계한 점도 기존 `list_items` 계열 설계보다 낫다 +- callout 내부를 paragraph fallback이 아니라 재귀 파싱으로 처리하겠다는 판단도 적절하다 + +하지만 현재 문서는 **최종 설계 승인 가능 상태는 아니다**. + +핵심 이유는 두 가지다. + +1. paragraph 재구성의 핵심 불변식 하나가 현재 코드베이스 기준으로 성립하지 않는다 +2. 테스트 설계가 일부 레벨에서 실제 fixture/loader/API와 맞지 않아, TDD 게이트로 바로 작동하지 않는다 + +즉, 방향은 맞지만 아직 "구현 전에 바로 들어가도 되는 설계" 수준은 아니다. + +--- + +## 주요 지적사항 + +### C-1. ParagraphEditSequence의 핵심 불변식이 현재 설계대로는 성립하지 않는다 + +**심각도:** Critical + +**문서 위치:** + +- Section 3.1.1 `_process_paragraph()` +- Section 3.1.1 `reconstruct_paragraph()` + +**문서의 주장:** + +- XHTML 조각을 누적한 뒤 `convert_inline()`을 적용하면 `TextSegment.text`가 MDX 텍스트 조각이 된다 +- 따라서 `TextSegments`를 이어 붙인 값이 `old_mdx_text`와 정확히 일치한다 +- 그 결과 `old_text == old_mdx_text`를 강한 불변식으로 둘 수 있다 + +**문제:** + +현재 코드베이스의 `convert_inline()`은 **MDX → XHTML 변환기**다. XHTML 조각을 넣었을 때 XHTML을 MDX로 역변환해주지 않는다. + +예를 들어: + +```python +convert_inline("bold") == "bold" +``` + +즉 `_process_paragraph()`가 아래처럼 동작하면: + +```python +cursor_xhtml += str(child) # "bold" +children.append({ + "kind": "text", + "text": convert_inline(cursor_xhtml), +}) +``` + +`TextSegment.text`는 `"**bold**"`가 아니라 `"bold"`로 남는다. + +그러면 문서가 전제한: + +```python +old_text == old_mdx_text +``` + +는 서식이 포함된 paragraph에서 곧바로 깨진다. + +**왜 중요한가:** + +이 불변식이 깨지면 `map_anchor_positions()` 이전 단계에서 좌표계가 이미 무너진다. 즉 paragraph 설계의 중심축이 성립하지 않는다. + +**필요한 수정 방향:** + +둘 중 하나를 명확히 선택해야 한다. + +1. `TextSegment.text`를 "MDX 텍스트"가 아니라 "XHTML/normalized plain 기준 텍스트"로 바꾸고, `reconstruct_paragraph()` 비교식도 그 좌표계에 맞게 다시 설계한다 +2. XHTML inline fragment를 실제로 MDX inline text로 역변환하는 별도 함수/규칙을 설계하고, 그 함수의 지원 범위를 테스트로 고정한다 + +현재 문서는 2번을 이미 해결된 것처럼 쓰고 있지만, 실제로는 해결되지 않았다. + +--- + +### C-2. Level 1 / Level 2 테스트의 oracle 정의가 현재 fixture와 맞지 않는다 + +**심각도:** High + +**문서 위치:** + +- Section 8.4 Level 1 +- Section 8.5 Level 2 + +**문서의 주장:** + +- `mapping.yaml`에서 각 블록의 `xhtml_text`를 읽어 재구성 결과와 비교할 수 있다 +- `load_mapping()`으로 이를 로드할 수 있다 + +**문제:** + +현재 실제 `mapping.yaml`은 `xhtml_xpath`, `xhtml_type`, `mdx_blocks` 중심이며, 문서가 예제로 사용하는 `entry.xhtml_text`는 존재하지 않는다. 실제 loader인 `load_sidecar_mapping()`도 그 필드를 읽지 않는다. + +즉 문서의 예제: + +```python +mapping = load_mapping(...) +assert normalize_xhtml(reconstructed) == normalize_xhtml(entry.xhtml_text) +``` + +는 현재 리포지토리 기준으로 성립하지 않는다. + +**왜 중요한가:** + +TDD에서 가장 중요한 것은 "무엇을 expected로 비교할 것인가"다. 그런데 Level 1/2의 expected source가 현재 정의되지 않았다. + +이 상태에서는: + +- 테스트 파일 이름은 정할 수 있어도 +- 실제 assertion이 무엇을 비교해야 하는지 +- 어느 loader를 쓸지 +- 원본 fragment를 어디서 읽어올지 + +가 정해지지 않은 셈이다. + +**필요한 수정 방향:** + +둘 중 하나를 선택해야 한다. + +1. `mapping.yaml` 스키마를 확장해 테스트 oracle로 쓸 원본 XHTML fragment를 명시적으로 넣는다 +2. Level 1/2의 oracle을 `mapping.yaml`이 아니라 `page.xhtml` 또는 `expected.roundtrip.json`에서 xpath 기반으로 추출하는 방식으로 다시 설계한다 + +현재 리포지토리 자산을 고려하면 2번이 더 현실적이다. + +--- + +### C-3. `normalize_xhtml()` 설계가 현재 저장소 전제와 맞지 않는다 + +**심각도:** High + +**문서 위치:** + +- Section 8.4 `normalize_xhtml()` + +**문서의 주장:** + +- `lxml.etree.fromstring()`으로 fragment를 파싱해 정규화한다 + +**문제 1: 의존성 누락** + +현재 저장소의 `requirements.txt`에는 `lxml`이 없다. + +**문제 2: Confluence fragment 파싱 전제 미정의** + +문서의 테스트 대상에는 `ac:image`, `ri:attachment` 같은 namespace prefix가 포함된 XHTML fragment가 많다. 이들은 namespace 선언 없이 XML parser에 그대로 넣으면 실패할 가능성이 높다. + +이는 단순 구현 디테일이 아니라, Level 1/2 비교 함수가 실제 테스트 데이터에 적용 가능한지 여부를 가르는 전제다. + +**왜 중요한가:** + +비교 함수가 실제 fragment를 파싱하지 못하면 Level 1/2는 시작 자체가 안 된다. + +**필요한 수정 방향:** + +다음 중 하나를 문서에 명시해야 한다. + +1. `lxml`을 새 테스트 의존성으로 도입하고, Confluence namespace wrapper를 어떻게 붙일지 명확히 규정한다 +2. XML 정규화 대신 BeautifulSoup 기반 canonicalization 혹은 byte/string comparison 규칙으로 축소한다 + +현재 문서는 이 전제를 해결하지 않은 채 테스트가 가능한 것처럼 서술하고 있다. + +--- + +## Warning + +### W-1. Level 3의 `mdx_content_hash` 단독 매칭은 중복 content 페이지에서 오검증 위험이 있다 + +**문서 위치:** + +- Section 8.6 Level 3 + +문서는 아래 방식으로 MDX 블록을 찾는다. + +```python +hash_to_block = {sha256_text(b.content): b for b in mdx_blocks if b.content} +mdx_block = hash_to_block.get(sb.mdx_content_hash) +``` + +하지만 실제 testcase에는 **동일한 non-empty content를 가진 블록이 반복되는 페이지가 이미 존재한다**. + +예: + +- `tests/testcases/1454342158` +- `tests/testcases/544375741` +- `tests/testcases/544145591` + +이 경우 dict comprehension은 마지막 블록으로 덮어쓰므로, 다른 위치의 동일 content 블록과 잘못 매칭될 수 있다. + +**영향:** + +- 테스트가 실패해야 할 케이스를 통과시키거나 +- 반대로 맞는 구현을 오검증으로 실패시킬 수 있다 + +**권장 수정:** + +- key를 `mdx_content_hash` 단독이 아니라 `(mdx_content_hash, mdx_line_range)` 또는 "hash -> list of candidate blocks"로 바꾸고 +- sidecar block의 순서나 line range를 함께 사용해 disambiguation 하도록 문서화해야 한다 + +--- + +### W-2. 테스트 실행 순서가 Level 0를 건너뛰고 있어 TDD 루프가 약하다 + +**문서 위치:** + +- Section 8.3 +- Section 8.8 + +문서는 테스트 수준 구조에서 Level 0를 가장 먼저 둔다. + +```text +Level 0 -> Level 1 -> Level 2 -> Level 3 -> Level 4 +``` + +그런데 실제 실행 순서 섹션은 Step 1을 Level 1부터 시작한다. + +이렇게 되면 helper 단위의 red/green이 빠지고, 실패 원인이: + +- helper 버그인지 +- block renderer 버그인지 +- document assembly 버그인지 + +를 뒤늦게 분리하게 된다. + +**권장 수정:** + +Section 8.8의 Step 1은 Level 0여야 한다. + +```bash +python3 -m pytest tests/test_reconstruction_helpers.py -v --tb=short +``` + +그 다음에 Level 1로 넘어가야 문서가 말하는 TDD 순서와 실제 실행 절차가 일치한다. + +--- + +### W-3. "새로운 테스트 입력 파일이 필요 없다"는 표현은 과도하다 + +**문서 위치:** + +- Section 8.1 + +기존 testcase를 최대한 재사용하겠다는 방향은 좋다. 다만 현재 확인된 공백을 메우려면 최소한 다음 중 일부는 새 fixture 또는 기존 fixture 파생 샘플이 필요할 가능성이 높다. + +- formatted paragraph + inline image 혼합 unit fixture +- namespace-bearing XHTML fragment normalization fixture +- duplicate-content Level 3 disambiguation fixture + +즉 "대부분 기존 fixture 재사용"은 맞지만, "새로운 테스트 입력 파일이 전혀 필요 없다"는 문장은 너무 강하다. + +--- + +## Suggestion + +### S-1. Section 8을 "설계 검증 테스트"와 "회귀 방지 테스트"로 분리하면 더 명확하다 + +현재 Section 8은 unit/integration/e2e를 모두 포함하지만, 성격이 다른 두 가지 테스트가 섞여 있다. + +- 설계 자체의 타당성을 입증하는 테스트 +- 구현 후 회귀를 막는 테스트 + +다음을 분리하면 읽는 사람이 덜 헷갈린다. + +- Part A: 설계 검증 테스트 + - ParagraphEditSequence + - list children ref + - callout recursive parsing +- Part B: 회귀 방지 테스트 + - reconstruction coverage + - lossless fragment compare + - reverse-sync-verify + +--- + +### S-2. 승인 기준을 "Phase 진입 가능"과 "구현 완료 가능"으로 나누는 편이 낫다 + +현재 문서는 구현 계획과 테스트 계획은 자세하지만, "지금 당장 Phase 1에 착수 가능한가"를 가르는 gate가 약하다. + +다음처럼 나누면 더 실용적이다. + +- Phase 1 착수 전 필수 해소 + - C-1 paragraph invariant + - C-2 Level 1/2 oracle + - C-3 normalization strategy +- Phase 3 머지 전 필수 해소 + - W-1 Level 3 duplicate hash + - W-2 실행 순서 정합성 + +--- + +## TDD 관점 평가 + +### 좋은 점 + +- 실제 testcase 기반 설계 원칙을 명시한 점은 좋다 +- helper → block → document → byte-equal → E2E로 내려가는 계층적 테스트 구조는 적절하다 +- E2E `reverse-sync-verify`를 최종 회귀 게이트로 유지한 판단도 맞다 + +### 부족한 점 + +- 가장 위험한 설계 가정(paragraph 좌표계)이 unit red test로 먼저 고정되어 있지 않다 +- Level 1/2 expected source가 불명확해 테스트를 바로 쓸 수 없다 +- Level 3 block identity가 불안정해 "pass = correct"라는 신뢰를 주지 못한다 + +### 현재 판단 + +**"문제 해결을 위해 충분한 테스트케이스를 확보하는 방안이 도출되어 있는가?"**라는 질문에 대한 답은: + +**아직 충분하지 않다.** + +테스트 레벨의 개수는 충분하지만, 최소 3개의 핵심 전제가 미정이다. + +1. paragraph sidecar의 좌표계 +2. Level 1/2의 oracle source +3. Level 3의 block identity + +이 셋이 해결되어야 비로소 TDD 계획이 실제 문제 해결을 보장하는 체계가 된다. + +--- + +## 권장 후속 조치 + +### 1. 설계 문서 우선 수정 + +다음 항목을 먼저 문서에서 확정해야 한다. + +- paragraph `TextSegment.text`의 기준 좌표계 +- `normalize_xhtml()` 구현 전략 또는 대체 비교 전략 +- Level 1/2에서 원본 fragment를 어디서 읽을지 +- Level 3에서 duplicate content를 어떻게 disambiguate할지 + +### 2. TDD 진입용 최소 red test 정의 + +구현 전에 아래 4개를 먼저 failing test로 고정하는 것이 좋다. + +1. formatted paragraph + inline image +2. nested list + `inline_trailing_html` +3. callout + nested list + code block +4. duplicate MDX content page에서 Level 3 fragment identity 유지 + +### 3. 승인 기준 재정의 + +현재 문서는 "방향은 맞음" 수준이다. + +다음 기준을 만족하면 구현 착수 가능으로 볼 수 있다. + +- C-1, C-2, C-3 해소 +- Level 0를 포함한 실행 순서 재정의 +- Level 3 identity 전략 확정 + +--- + +## 평가 요약 + +| 항목 | 평가 | +|------|------| +| 재구성 중심 방향성 | ✅ 적절 | +| list flat mapping + `children` ref | ✅ 적절 | +| callout 재귀 파싱 방향 | ✅ 적절 | +| paragraph 설계 완결성 | ❌ Critical | +| Level 1/2 테스트 oracle 정의 | ❌ High | +| XHTML normalization 전략 | ❌ High | +| Level 3 hash 기반 block 식별 | ⚠️ Warning | +| TDD 실행 순서 일관성 | ⚠️ Warning | +| "기존 fixture만으로 충분" 주장 | ⚠️ Warning | + +최종 판단: + +- **설계 방향:** 승인 가능 +- **설계 문서의 현재 완성도:** 수정 필요 +- **TDD 관점의 테스트 확보 방안:** 보강 필요 + +--- + +## 설계 검증 (Claude Code 검토, 2026-03-13) + +### 검증 요약 + +`2026-03-13-reverse-sync-reconstruction-design.md`는 v5 리뷰에서 지적된 세 가지 Critical/High 이슈(paragraph 좌표계 혼란, oracle 출처 불명, lxml 의존성)를 모두 해소하고 구현 착수 가능한 수준으로 재작성됐다. 현재 코드베이스와 정합성도 높다. 다만 설계 내부에 검증이 필요한 가정 몇 가지가 남아 있으며, Phase 3~4에서 현실적 어려움이 예상된다. + +--- + +### 1. 문제 진단의 정확성 + +코드베이스와 대조한 결과, 설계 문서의 현재 시스템 문제 진단은 실제 코드와 일치한다. + +**정확한 진단:** + +- `patch_builder.py`의 전략 분기 누적 (`direct` / `containing` / `list` / `table` / `skip`) 및 각 전략마다 별도 fallback이 실제로 존재한다 (patch_builder.py:88-156). +- `transfer_text_changes()` 기반 수정이 중심 경로임을 확인했다. `containing` 전략과 delete+add 쌍 처리 모두 이 함수에 의존한다 (patch_builder.py:76, 221). +- Confluence 전용 요소(``, `` 등)를 발견하면 재생성 대신 text transfer로 폴백하는 코드가 명시적으로 존재한다 (patch_builder.py:300-309). +- `sidecar.py`의 `SidecarBlock`에 `reconstruction` 필드가 없음을 확인했다. 설계 문서의 "현재 schema v2에 reconstruction metadata가 없다"는 진단이 정확하다. + +**추가로 확인한 사항:** + +`generate_sidecar_mapping()`(sidecar.py:306)의 4차 prefix 매칭(`[:20]`)은 text similarity가 낮은 경우 false positive를 낼 수 있다. PR 이력(#853)에서 이미 버그가 발생한 패턴이며, 설계 문서가 이 경로를 "더 이상 중심축이 되어서는 안 된다"고 명시한 것은 정확하다. + +--- + +### 2. 목표 달성 가능성 + +**달성 가능한 목표:** + +설계의 세 핵심 전환은 코드베이스 자산을 기반으로 달성 가능하다. + +- `convert_inline()` 역변환 가정 제거 → 설계가 명시적으로 "기준 좌표계는 XHTML DOM에서 추출한 normalized plain text"로 전환했다 (§3.1). 현재 `mapping_recorder.py`가 이미 `get_text()` 기반 plain text를 생성하므로 좌표계 기반은 존재한다. +- `expected.roundtrip.json`을 primary oracle로 승격 → 현재 모든 21개 testcase에 `xhtml_fragment` 필드가 존재함을 직접 확인했다. oracle 전환의 전제가 충족된다. +- BeautifulSoup 기반 normalizer 구축 → `mapping_recorder.py`, `xhtml_patcher.py`, `xhtml_beautify_diff.py`가 이미 BeautifulSoup을 사용한다. 공용 `xhtml_normalizer.py`로 통합하는 것은 현실적이다. + +**달성이 불확실한 목표:** + +- **paragraph + inline anchor 재주입 (§5.4)**: `old_plain_offset` 기반 offset mapping이 핵심이다. anchor가 두 개 이상이거나, 변경으로 인해 앞 anchor의 offset이 뒤 anchor에 영향을 줄 때의 순서 보장 로직이 설계에 명시되지 않았다. 구현 시 edge case가 될 가능성이 높다. +- **list reconstruction의 zip 매칭 (§5.5)**: "sidecar list item sequence와 index 기반 zip"은 MDX와 XHTML의 list item 수가 다를 때(항목 추가/삭제) 어떻게 처리할지 명확하지 않다. 설계가 이 케이스를 암묵적으로 "child slot 수 불일치 → fail"로 처리한다면 실용적 커버리지가 낮아질 수 있다. + +--- + +### 3. 아키텍처 설계의 적절성 + +**적절한 설계 결정:** + +- `replace_fragment` 액션을 `xhtml_patcher.py`에 추가하는 접근은 자연스럽다. 기존 `insert` / `delete` 패턴과 일관되고, DOM 전체 교체라는 의미도 명확하다. +- `reconstruction_planner.py`를 분리하고 `patch_builder.py`를 thin orchestration layer로 만드는 방향은 현재 `patch_builder.py`의 1개 함수가 전략 분기 + fallback + 그룹화를 모두 담당하는 문제를 직접 해결한다. +- block identity에 `hash + line_range + order`를 함께 사용하는 방식은 v5 리뷰의 W-1 지적을 정확히 해소한다. +- `SidecarBlock`에 `reconstruction` 필드를 추가하는 스키마 v3 설계는 테스트 oracle과 runtime metadata를 같은 artifact에 담는 좋은 설계다. 현재 `SidecarBlock.lost_info`가 비슷한 패턴으로 이미 존재하므로 확장이 자연스럽다. + +**검토가 필요한 설계 결정:** + +- **callout outer wrapper 보존 (§5.6)**: "outer wrapper 보존은 `lost_info_patcher`가 아니라 reconstruction metadata가 책임진다"는 원칙이 맞지만, 현재 `lost_info_patcher.py`가 `_STRUCTURED_MACRO_RE`로 callout macro를 처리하는 코드가 이미 있다. 두 책임 분리 경계를 구현 시 명확히 해야 한다. +- **Opaque block의 fail-closed 정책 (§5.3-D)**: `UnsupportedReconstructionError`로 명시적 실패하는 방향은 올바르지만, 현재 `build_patches()`가 mapping을 찾지 못하면 `skip`으로 조용히 통과한다. fail-closed로 전환하면 현재 pass하던 케이스 일부가 fail로 바뀔 수 있어 Phase 5 전환 시 회귀 위험이 있다. + +--- + +### 4. 위험 요소 및 미검토 에지케이스 + +**설계에서 명시적으로 언급되지 않은 에지케이스:** + +1. **paragraph anchor affinity 충돌**: `affinity: "after"` anchor가 연속으로 나올 때 두 번째 anchor의 `old_plain_offset`이 첫 번째 anchor 삽입 후 shift된 DOM 기준인지 원본 기준인지 명확하지 않다. 구현 시 "모든 offset은 original XHTML 기준"으로 고정해야 한다. + +2. **list item 수 불일치 처리**: 설계 §5.5는 "child type과 순서를 기준으로 재귀 reconstruct"한다고 하지만, MDX에서 list item을 추가/삭제한 경우의 처리 방식이 명시되지 않았다. 이것이 실제 reverse-sync에서 가장 흔한 변경 패턴 중 하나임을 감안하면, Level 3 테스트(Phase 3 게이트) 전에 결정이 필요하다. + +3. **sidecar v3 빌더의 old_plain_text 생성 시점**: `reconstruction.old_plain_text`는 sidecar 빌드 시(forward convert 시점) 기록된다. 이후 page.xhtml이 Confluence에서 자체 수정되면 `old_plain_text`가 실제 XHTML과 달라질 수 있다. 현재 `source_xhtml_sha256`로 감지 가능하나, 불일치 시 처리 경로가 설계에 없다. + +4. **`_parse_list_items()` 및 `_build_list_tree()` public 승격 범위**: 설계가 이 private 함수들을 `parse_list_tree()` public API로 승격하겠다고 밝혔다. 현재 `_parse_list_items`가 continuation line(마커 없는 줄)을 이전 항목에 붙이는 로직(emitter.py:182-183)이 있는데, 이 동작이 reconstruction context에서도 의도된 것인지 확인이 필요하다. + +5. **BeautifulSoup `html.parser` 속성 순서**: `xhtml_normalizer.py` 구현 시 BeautifulSoup으로 파싱 후 재직렬화하면 attribute 순서가 바뀔 수 있다. fragment comparison에서 false negative를 방지하려면 attribute 정렬 규칙을 명시해야 한다. 현재 `mdx_to_storage_xhtml_verify.py`에 이 로직이 있을 수 있으나 통합 방법을 확인해야 한다. + +--- + +### 5. 구현 복잡도 vs 기대 효과 + +**비용 측면:** + +- Phase 0-2(normalizer, schema v3, clean block replacement): 비교적 낮은 복잡도. 기존 자산 재사용이 명확하고 test oracle이 이미 준비됐다. +- Phase 3(paragraph/list anchor reconstruction): 중간-높은 복잡도. offset mapping 알고리즘, DOM 삽입 순서 보장, list item 수 불일치 처리 등 새로 작성해야 할 로직이 많다. +- Phase 4(container reconstruction): 높은 복잡도. callout/details/ADF panel이 각각 outer wrapper 구조가 다르고, `lost_info_patcher`와의 책임 분리를 정확히 해야 한다. +- Phase 5(planner 전환): 중간 복잡도. 하지만 fail-closed 전환 시 회귀 위험이 있어 신중한 rollout이 필요하다. + +**기대 효과:** + +- 현재 시스템의 근본적 취약점(text coordinate를 벗어난 Confluence 요소 손실)을 구조적으로 해소한다. +- `transfer_text_changes()` fallback에 의존하는 silent corruption 경로를 제거한다. +- test oracle이 `mapping.yaml`에서 `expected.roundtrip.json`으로 전환되어 "무엇을 기준으로 테스트하는가"가 명확해진다. + +**판단:** + +Phase 0-2의 ROI는 높다. Phase 3-4는 복잡도 대비 효과가 여전히 높지만, 에지케이스 처리 결정을 Phase 착수 전에 명확히 해야 낭비 없이 구현할 수 있다. + +--- + +### 종합 판단 + +v5 리뷰의 3개 Critical/High 이슈가 새 설계 문서에서 모두 명시적으로 해소됐다. 특히 paragraph 좌표계를 "XHTML DOM에서 추출한 normalized plain text"로 확정하고, oracle을 `expected.roundtrip.json.xhtml_fragment`로 명시한 결정이 핵심이다. 현재 코드베이스와의 정합성도 양호하다. + +**권고사항:** + +1. **Phase 3 착수 전 필수 결정**: list item 수 불일치(추가/삭제) 시 처리 방식을 명시하라. "수 불일치는 항상 fail"이라면 테스트 설계에 해당 케이스를 명시적으로 포함해야 한다. +2. **anchor offset 기준 명문화**: `reconstruction.anchors[].old_plain_offset`이 "원본 XHTML 기준 누적 offset"임을 설계 문서에 명시하라. 구현자가 이 가정을 따르지 않으면 멀티 anchor 케이스에서 버그가 발생한다. +3. **`lost_info_patcher` vs reconstruction metadata 경계 결정**: callout outer wrapper를 어느 쪽이 책임지는지 Phase 4 착수 전에 코드 수준 경계를 설계 문서에 추가하라. +4. **현재 설계 문서 완성도**: 구현 착수 가능 수준이다. Phase 0-2는 즉시 착수 가능하고, Phase 3-4는 위 항목을 보완 후 착수를 권장한다. diff --git a/confluence-mdx/docs/plans/2026-03-13-reverse-sync-reconstruction-design.md b/confluence-mdx/docs/plans/2026-03-13-reverse-sync-reconstruction-design.md new file mode 100644 index 000000000..c3010079d --- /dev/null +++ b/confluence-mdx/docs/plans/2026-03-13-reverse-sync-reconstruction-design.md @@ -0,0 +1,948 @@ +# Reverse Sync 전면 재구성 설계 + +> 작성일: 2026-03-13 +> 대상 PR: #913 +> 연관 문서: +> - `docs/plans/2026-03-13-reverse-sync-reconstruction-design-review.md` +> - `docs/analysis-reverse-sync-refactoring.md` + +## 1. 문서 목적 + +이 문서는 PR #913의 reverse-sync 설계를 전면 재작성한 버전이다. + +목표는 세 가지다. + +1. MDX 변경을 XHTML로 재구성하여 Confluence 문서를 안정적으로 업데이트한다. +2. 현재의 heuristic 텍스트 패치 체인을 구조적 재구성 경로로 치환한다. +3. 현재 저장소에 이미 존재하는 `tests/testcases/` 와 `tests/reverse-sync/` 자산을 중심으로, 구현·회귀·유지보수가 가능한 테스트 체계를 만든다. + +이 문서는 "방향 제안"이 아니라, 구현 착수 전에 필요한 설계 전제와 테스트 게이트를 코드베이스 기준으로 확정하는 문서다. + +## 2. 현재 문제와 재설계 목표 + +현재 reverse-sync 파이프라인은 다음 흐름이다. + +`MDX diff -> mapping 추론 -> text/plain 기준 패치 -> patched XHTML -> forward convert -> MDX 재검증` + +핵심 병목은 `patch_builder.py` 와 `text_transfer.py` 에 있다. + +- 변경 블록을 XHTML로 다시 만드는 대신, 기존 XHTML 내부 텍스트만 이식한다. +- list, table, callout, containing block, direct replacement 등 전략 분기가 계속 늘어난다. +- Confluence 전용 요소(``, ``, ``, `ac:adf-extension`)는 텍스트 좌표계 밖에 있기 때문에, 텍스트만 옮기는 방식이 구조적으로 불안정하다. + +이번 재설계의 목표는 분명하다. + +- 변경된 MDX 블록은 가능한 한 "다시 emit한 XHTML fragment"로 교체한다. +- emitter가 재현할 수 없는 Confluence 전용 정보만 sidecar metadata로 보존 후 재주입한다. +- modified block 처리의 기본 전략을 `transfer_text_changes()` 가 아니라 `reconstruct_fragment()` 로 바꾼다. + +즉 새 기본 경로는 다음과 같다. + +`MDX diff -> changed block identify -> emit XHTML fragment -> restore preserved anchors/lost info -> replace top-level fragment -> forward verify` + +## 3. 리뷰에서 확정된 수정 요구 + +리뷰 문서에서 지적한 사항 중 설계 착수 전에 반드시 확정해야 하는 항목은 아래 네 가지다. + +### 3.1 Paragraph 좌표계 + +기존 문서는 `convert_inline()` 를 사실상 "XHTML -> MDX 역변환기"처럼 가정했다. 실제 코드에서는 성립하지 않는다. + +- `convert_inline()` 는 `mdx_to_storage.inline.convert_inline` +- 역할은 MDX inline -> XHTML inline 변환 +- XHTML fragment를 넣어도 MDX로 돌아오지 않는다 + +따라서 새 설계는 다음 원칙을 따른다. + +- paragraph/list-item anchor 매핑의 기준 좌표계는 "MDX literal"이 아니다 +- 기준 좌표계는 "XHTML DOM 에서 추출한 normalized plain text"다 +- old/new 비교는 `old_mdx_text` 가 아니라 `old_plain_text -> new_plain_text` 로 수행한다 + +이 결정으로 XHTML -> MDX inverse 가정을 제거한다. + +### 3.2 테스트 oracle + +`mapping.yaml` 은 runtime lookup 용이지, fragment oracle 용이 아니다. 실제 저장소의 `load_sidecar_mapping()` 도 fragment 본문을 읽지 않는다. + +새 설계의 oracle은 다음 순서로 사용한다. + +1. `expected.roundtrip.json` + - 모든 `tests/testcases/*` 21개에 존재 + - top-level `xhtml_fragment` 를 exact oracle로 제공 +2. `page.xhtml` + - sidecar에 없는 nested fragment나 sub-xpath 비교에 사용 +3. `expected.reverse-sync.patched.xhtml` + - 변경 시나리오 16개에 대한 golden page oracle + +즉 unit/integration 테스트는 `mapping.yaml` 에 의존하지 않는다. + +### 3.3 XHTML normalization + +새 비교 전략은 `lxml` 을 도입하지 않는다. + +이 저장소에는 이미 다음 자산이 있다. + +- `bin/reverse_sync/mdx_to_storage_xhtml_verify.py` +- `xhtml_beautify_diff.py` +- BeautifulSoup 기반 attribute stripping / layout stripping / macro stripping + +새 설계는 이 경로를 공용 normalizer로 승격한다. + +- 새 공용 모듈: `reverse_sync/xhtml_normalizer.py` +- 구현 기반: BeautifulSoup + 기존 ignored-attribute 규칙 재사용 +- 비교 단위: page 전체와 fragment 모두 지원 + +이로써 새 의존성 없이 테스트 가능성을 확보한다. + +### 3.4 block identity + +`mdx_content_hash` 단독 매칭은 충분하지 않다. + +현재 실제 데이터에서도 중복 content가 존재한다. 특히 `reverse_sync.mdx_block_parser` 기준으로는 `` 같은 동일 블록이 여러 번 잡히는 케이스가 이미 보인다. + +새 설계의 block identity는 아래를 함께 사용한다. + +- `block_index` +- `mdx_line_range` +- `mdx_content_hash` +- 필요 시 동일 hash 후보군 내 상대 순서 + +즉 lookup key는 "hash 하나"가 아니라 "hash + line range + order"다. + +## 4. 현재 코드베이스와 자산 분석 + +### 4.1 코드베이스에서 재사용할 축 + +이미 있는 구현 중 이번 설계에서 그대로 활용할 축은 다음과 같다. + +- `reverse_sync_cli.py` + - verify / push orchestration + - forward convert 후 strict roundtrip 검증 +- `reverse_sync.sidecar` + - `RoundtripSidecar`, `SidecarBlock`, `expected.roundtrip.json` +- `reverse_sync.mapping_recorder` + - XHTML top-level / callout child mapping 추출 +- `mdx_to_storage.parser`, `mdx_to_storage.emitter` + - MDX 구조 파싱과 XHTML emission + - callout child 재귀 emission 가능 + - nested list tree 구성 함수 보유 +- `reverse_sync.lost_info_patcher` + - 링크, 이모티콘, filename, image, ADF extension 복원 로직 + +반대로 이번 재설계에서 더 이상 중심축이 되어서는 안 되는 부분은 다음과 같다. + +- `transfer_text_changes()` 기반 modified block 패치 +- `mapping.yaml` 을 fragment oracle처럼 사용하는 방식 +- block 내부 구조를 content text만으로 추론하는 방식 + +### 4.2 테스트 자산 현황 + +현재 확보된 테스트 자산은 설계 검증에 충분히 강하다. + +#### `tests/testcases/` + +- 총 21개 케이스 +- `page.xhtml`: 21개 +- `expected.mdx`: 21개 +- `expected.roundtrip.json`: 21개 +- `original.mdx` + `improved.mdx` + `expected.reverse-sync.*`: 16개 +- `attachments.v1.yaml`: 19개 +- `page.v1.yaml`: 19개 +- `page.v2.yaml`, `children.v2.yaml`: 각 19개 +- `page.adf`: 18개 + +구조적 커버리지: + +- list: 20개 +- table: 9개 +- image: 13개 +- callout macro/ADF panel: 12개 +- `ac:adf-extension`: 3개 +- 링크: 12개 +- code macro: 4개 + +대표 케이스: + +- list item + image: `544113141`, `544145591`, `692355151`, `880181257`, `883654669` +- callout + nested list: `1454342158`, `544145591`, `692355151`, `880181257`, `883654669` +- callout + code macro: `544112828` +- ADF panel: `1454342158`, `544379140`, `panels` + +#### `tests/reverse-sync/` + +- 총 42개 실제 reverse-sync 회귀 케이스 +- `pages.yaml` 기준: `pass` 28개, `fail` 14개, `catalog_only` 24개 + +구조적 커버리지: + +- list: 42개 +- image: 38개 +- callout: 28개 +- table: 10개 +- 링크: 19개 +- code macro: 7개 + +특히 중요한 실사례: + +- paragraph/list item 내부 inline image: `544376004` +- callout + code: `544112828` +- 다수의 이미지/링크/callout 혼합 페이지: `544145591`, `1454342158` + +#### 결론 + +새 설계는 "fixture가 부족해서 추상 설계를 해야 하는 상태"가 아니다. 오히려 반대다. + +- unchanged fragment oracle: 이미 충분함 +- changed-page golden oracle: 16개 존재 +- failure reproduction corpus: 42개 존재 + +부족한 것은 fixture 양이 아니라, 이 자산을 설계 검증 단계별로 재배치하는 일이다. + +## 5. 제안 아키텍처 + +### 5.1 최상위 원칙 + +1. modified block는 whole-fragment replacement가 기본이다 +2. preserved 정보는 "text"가 아니라 "raw XHTML preservation unit" 으로 다룬다 +3. anchor 재주입은 MDX 좌표가 아니라 normalized plain-text 좌표에서 수행한다 +4. list / callout / details / ADF panel 은 child order 기반으로 재구성한다 +5. 지원 범위 밖 구조는 fuzzy patch 하지 않고 명시적으로 fail 한다 + +### 5.2 sidecar 전략 + +기존 `RoundtripSidecar` 를 primary runtime artifact 로 승격한다. + +- `mapping.yaml` + - 역할 축소: top-level routing, 사람이 읽는 디버그 용도 +- `expected.roundtrip.json` + - 역할 확대: exact fragment oracle + reconstruction metadata + +새 스키마는 `RoundtripSidecar schema_version = 3` 으로 정의한다. + +핵심 변화: + +- 각 `SidecarBlock` 에 reconstruction metadata 추가 +- modified block 재구성에 필요한 preserved anchor/unit 을 block 단위로 저장 + +예시: + +```json +{ + "block_index": 12, + "xhtml_xpath": "p[3]", + "xhtml_fragment": "

A B

", + "mdx_content_hash": "...", + "mdx_line_range": [40, 40], + "lost_info": {}, + "reconstruction": { + "kind": "paragraph", + "old_plain_text": "A B", + "anchors": [ + { + "anchor_id": "p[3]/ac:image[1]", + "raw_xhtml": "", + "old_plain_offset": 2, + "affinity": "after" + } + ] + } +} +``` + +리스트 예시: + +```json +{ + "block_index": 8, + "xhtml_xpath": "ul[1]", + "xhtml_fragment": "
    ...
", + "reconstruction": { + "kind": "list", + "ordered": false, + "items": [ + { + "item_xpath": "ul[1]/li[1]", + "old_plain_text": "item 1", + "anchors": [], + "child_blocks": [] + }, + { + "item_xpath": "ul[1]/li[2]", + "old_plain_text": "item 2", + "anchors": [ + { + "anchor_id": "ul[1]/li[2]/ac:image[1]", + "raw_xhtml": "", + "old_plain_offset": 6, + "affinity": "after" + } + ], + "child_blocks": [ + { + "kind": "list", + "xpath": "ul[1]/li[2]/ol[1]" + } + ] + } + ] + } +} +``` + +이 구조의 의도는 단순하다. + +- top-level fragment는 `xhtml_fragment` 가 책임진다 +- list/paragraph/container 내부 보존 정보는 `reconstruction` 이 책임진다 +- 테스트 oracle와 runtime metadata가 같은 artifact 안에 있게 한다 + +### 5.3 block 분류 + +새 재구성기는 top-level block를 네 종류로 나눈다. + +#### A. Clean block + +대상: + +- heading +- code macro +- table +- hr +- paragraph without preserved anchors + +처리: + +- `mdx_to_storage.emit_block()` 또는 `mdx_block_to_xhtml_element()` 로 새 fragment emit +- block-level `lost_info` 적용 +- 기존 fragment 전체 replace + +#### B. Inline-anchor block + +대상: + +- paragraph 안의 `ac:image`, `ac:link` 류 preservation unit +- list item 안의 inline image / trailing preserved node + +처리: + +1. improved MDX block를 먼저 XHTML로 emit +2. emit 결과에서 plain text를 추출 +3. sidecar의 `old_plain_text` 와 anchor offset을 기준으로 old -> new offset 매핑 +4. 매핑된 위치에 raw anchor XHTML 삽입 + +중요한 점: + +- old/new 비교는 plain-text 좌표 +- 삽입 대상은 "생성된 XHTML DOM" +- raw 문자열 위치 삽입이 아니라 DOM walk 기반 삽입 + +#### C. Ordered child block + +대상: + +- nested list +- callout / details / ADF panel body + +처리: + +- original XHTML 의 child order를 sidecar에 저장 +- improved MDX 는 `mdx_to_storage.parser.parse_mdx()` 로 child blocks 파싱 +- child type과 순서를 기준으로 재귀 reconstruct + +여기서는 text matching을 하지 않는다. 위치와 child slot이 기준이다. + +#### D. Opaque block + +대상: + +- emitter가 재구성하지 못하는 custom macro +- 현재 testcase에 없거나 metadata 규칙이 정의되지 않은 구조 + +처리: + +- `UnsupportedReconstructionError` +- verify는 fail +- 해당 페이지를 testcase로 승격 후 설계 범위 확장 + +이 fail-closed 정책이 중요하다. unsupported structure에서 silent corruption이 가장 위험하다. + +### 5.4 paragraph / list item anchor 재주입 + +이 설계의 핵심 차별점은 "anchor를 plain-text offset에 고정"하는 것이다. + +#### 좌표계 + +- `old_plain_text`: original XHTML fragment에서 DOM text를 뽑아 정규화한 값 +- `new_plain_text`: improved MDX 를 emit한 XHTML fragment에서 같은 규칙으로 뽑은 값 +- `old_plain_offset`: original plain text 기준 anchor 위치 +- `new_plain_offset`: old -> new diff로 계산된 삽입 위치 + +#### 알고리즘 + +1. `extract_plain_text(fragment)` 로 old/new plain text 생성 +2. `map_offsets(old_plain, new_plain, offsets)` 로 new offset 계산 +3. `insert_raw_anchor_at_plain_offset(soup, raw_xhtml, offset)` 로 DOM 삽입 + +이 방식은 review에서 지적된 "XHTML inline fragment를 MDX text로 역변환해야 하는가" 문제를 제거한다. + +### 5.5 list 재구성 + +리스트는 text queue가 아니라 tree + order 매칭으로 재구성한다. + +재사용 자산: + +- `mdx_to_storage.emitter._parse_list_items()` +- `mdx_to_storage.emitter._build_list_tree()` + +다만 private 함수 직접 import는 피한다. 이번 작업에서 public helper로 승격한다. + +제안: + +- 새 public API: `mdx_to_storage.emitter.parse_list_tree(content: str) -> list[ListNode]` + +재구성 로직: + +1. improved MDX list block -> list tree 생성 +2. sidecar list item sequence와 index 기반 zip +3. 각 item에 대해 + - item text emit + - item-level anchors 재삽입 + - child list / block child 재귀 재구성 +4. top-level `
    ` / `
      ` wrapper regenerate + +이렇게 하면 다음이 가능하다. + +- 동일 텍스트 item이 여러 번 나와도 안정적 +- nested list의 중복 삽입 방지 +- image가 들어간 list item도 text patch 없이 처리 + +### 5.6 callout / details / ADF panel 재구성 + +callout은 이번 설계에서 "containing block에 text만 이식"하지 않는다. + +이미 있는 자산: + +- `mapping_recorder.record_mapping()` 는 callout의 child xpath를 생성한다 +- `mdx_to_storage.parser.parse_mdx()` 와 `_emit_callout()` 은 child block 재귀 emission 을 지원한다 + +따라서 새 경로는 아래와 같다. + +1. original callout body child order를 sidecar metadata에 저장 +2. improved MDX callout body를 `parse_mdx()` 로 child block sequence로 파싱 +3. child slot 단위로 reconstruct +4. 최종 body를 `` 또는 `ac:adf-content` 아래에 다시 조립 + +주의: + +- `macro-panel` 과 `ac:adf-extension` 은 body 구조는 같지만 outer wrapper가 다르다 +- outer wrapper 보존은 `lost_info_patcher` 가 아니라 reconstruction metadata가 책임진다 +- ADF panel raw outer fragment가 필요한 경우 sidecar에 raw wrapper를 저장한다 + +### 5.7 patch 적용 단위 + +modified block는 `new_inner_xhtml` 보다 `new_element_xhtml` 교체가 기본이다. + +이유: + +- top-level element 전체를 교체해야 wrapper, attribute, child structure를 한 번에 통제할 수 있다 +- innerHTML 교체만으로는 callout outer wrapper, list root tag, table root tag의 일관성을 강제하기 어렵다 + +따라서 `xhtml_patcher.py` 에 새 액션을 추가한다. + +- `replace_fragment` + - 입력: `xhtml_xpath`, `new_element_xhtml` + - 의미: xpath 대상 top-level element 전체를 새 fragment로 치환 + +기존 `insert` / `delete` 는 유지한다. + +### 5.8 block identity와 planner + +기존 `patch_builder.py` 는 전략 분기와 fallback이 많다. 새 설계는 planner를 분리한다. + +제안 모듈: + +- `reverse_sync/reconstruction_planner.py` + - changed block -> reconstruction strategy 결정 +- `reverse_sync/reconstruction_sidecar.py` + - sidecar schema v3 load/build +- `reverse_sync/reconstructors.py` + - paragraph/list/container별 fragment rebuild +- `reverse_sync/xhtml_normalizer.py` + - shared normalization / plain-text extraction + +`patch_builder.py` 는 최종적으로 orchestration thin layer가 된다. + +## 6. 구현 범위와 비범위 + +### 이번 설계 범위 + +- modified top-level block의 whole-fragment reconstruction +- paragraph/list item inline anchor 재주입 +- nested list reconstruction +- callout/details/ADF panel body reconstruction +- block identity 안정화 +- golden/oracle 기반 테스트 체계 구축 + +### 이번 설계 비범위 + +- sidecar/rehydrator 전체를 단일 parser 체계로 통합하는 대형 리팩토링 +- testcase에 없는 custom macro 일반화 +- Confluence storage 전체에 대한 generic DOM diff 엔진 + +parser 통합은 후속 과제로 남긴다. 이번 작업은 "reverse-sync를 구조적 재구성 경로로 전환"하는 데 집중한다. + +## 7. 테스트 설계 + +테스트는 두 묶음으로 나눈다. + +1. 설계 검증 테스트 +2. 회귀 방지 테스트 + +### 7.1 설계 검증 테스트 + +#### Level 0. Helper / invariant + +새 파일 제안: + +- `tests/test_reverse_sync_xhtml_normalizer.py` +- `tests/test_reverse_sync_reconstruction_offsets.py` +- `tests/test_reverse_sync_reconstruction_insert.py` + +검증 항목: + +- plain-text extraction이 original/emitted fragment에서 같은 규칙으로 동작하는지 +- old -> new offset mapping이 삽입/삭제/대체에 대해 안정적인지 +- raw anchor insertion이 DOM 파괴 없이 수행되는지 +- `hash + line_range` disambiguation이 duplicate content에서도 안정적인지 + +여기서 review의 Critical 이슈를 먼저 red test로 고정한다. + +필수 red cases: + +1. paragraph + inline image +2. list item + image +3. duplicate hash candidate +4. namespace-bearing fragment normalization + +#### Level 1. Block reconstruction against exact fragment oracle + +새 파일 제안: + +- `tests/test_reverse_sync_reconstruct_paragraph.py` +- `tests/test_reverse_sync_reconstruct_list.py` +- `tests/test_reverse_sync_reconstruct_container.py` + +oracle: + +- 기본: `expected.roundtrip.json.blocks[].xhtml_fragment` +- nested child: `page.xhtml` 에서 xpath extraction + +검증 방식: + +- unchanged MDX block를 reconstruct 했을 때 oracle fragment와 normalize-equal + +대표 파라미터: + +- list item + image: `544113141`, `544145591`, `692355151`, `880181257`, `883654669` +- callout + list: `1454342158`, `544145591`, `692355151`, `880181257`, `883654669` +- callout + code: `544112828` +- ADF panel: `1454342158`, `544379140`, `panels` +- inline paragraph image: `tests/reverse-sync/544376004` + +`544376004` 는 `tests/testcases` 가 아니므로, unit test에서는 해당 page에서 관련 fragment만 추출한 minimal fixture를 추가해도 된다. 이것은 review의 "새 fixture가 아예 불필요하다고 말하면 안 된다"는 지적에 대한 현실적 대응이다. + +#### Level 2. Changed block golden reconstruction + +새 파일 제안: + +- `tests/test_reverse_sync_reconstruction_goldens.py` + +oracle: + +- `expected.reverse-sync.patched.xhtml` +- 필요한 경우 `expected.reverse-sync.mapping.original.yaml` / `expected.reverse-sync.result.yaml` + +대상: + +- `original.mdx` + `improved.mdx` + `expected.reverse-sync.*` 가 존재하는 16개 `tests/testcases` + +검증 방식: + +- changed block만 reconstruct + page assembly 후 `expected.reverse-sync.patched.xhtml` 와 normalize-equal +- `expected.reverse-sync.result.yaml` 의 `status: pass` 케이스는 forward verify까지 exact pass + +### 7.2 회귀 방지 테스트 + +#### Level 3. Existing sidecar / byte-equal gates + +기존 테스트를 유지하고 schema v3에 맞춰 확장한다. + +- `tests/test_reverse_sync_sidecar_v2.py` +- `tests/test_reverse_sync_rehydrator.py` +- `tests/test_reverse_sync_byte_verify.py` + +변경점: + +- `expected.roundtrip.json` builder/loader가 reconstruction metadata를 읽고 써야 한다 +- unchanged case에서는 여전히 21/21 byte-equal 유지 + +#### Level 4. CLI / E2E + +기존 테스트를 유지하되 reconstruction path를 기본 경로로 바꾼다. + +- `tests/test_reverse_sync_cli.py` +- `tests/test_reverse_sync_e2e.py` +- `tests/test_reverse_sync_structural.py` + +여기에 다음을 추가한다. + +- `tests/reverse-sync/pages.yaml` 의 `expected_status: pass` 케이스는 새 경로에서도 계속 pass +- `expected_status: fail` 케이스는 failure type별로 하나씩 우선 red -> green 전환 + +우선순위는 아래 순으로 둔다. + +1. list/image +2. callout/code +3. callout/list +4. ADF panel + +### 7.3 현재 자산 활용 계획 요약 + +| 자산 | 수량 | 새 설계에서의 역할 | +|------|------|--------------------| +| `tests/testcases/*/page.xhtml` | 21 | exact source page, nested fragment extraction | +| `tests/testcases/*/expected.roundtrip.json` | 21 | unchanged top-level fragment oracle | +| `tests/testcases/*/original.mdx` | 16 | reverse-sync original input | +| `tests/testcases/*/improved.mdx` | 16 | reverse-sync changed input | +| `tests/testcases/*/expected.reverse-sync.patched.xhtml` | 16 | changed-page golden oracle | +| `tests/testcases/*/expected.reverse-sync.result.yaml` | 16 | expected verify outcome | +| `tests/testcases/*/attachments.v1.yaml` | 19 | image filename / asset context | +| `tests/testcases/*/page.v1.yaml`, `page.v2.yaml`, `children.v2.yaml`, `page.adf` | 18~19 | forward converter context, ADF/callout/link validation | +| `tests/reverse-sync/*` | 42 | 실사례 회귀 및 failure reproduction | + +## 8. 단계별 구현 계획 + +### Phase 0. 공용 helper 추출 + +- `xhtml_normalizer.py` 추가 +- `extract_plain_text()`, `normalize_fragment()`, `extract_fragment_by_xpath()` 구현 +- list tree helper public API 승격 + +게이트: + +- Level 0 helper tests green + +### Phase 1. sidecar schema v3 + +- `RoundtripSidecar` 에 reconstruction metadata 추가 +- builder/load/write/update 구현 +- `hash + line_range` 기반 identity helper 도입 + +게이트: + +- existing sidecar tests green +- unchanged 21개 `expected.roundtrip.json` roundtrip 유지 + +### Phase 2. clean block whole-fragment replacement + +- heading/code/table/simple paragraph modified block를 reconstruction path로 전환 +- `replace_fragment` patch 추가 + +게이트: + +- simple modified golden cases green +- `transfer_text_changes()` 경로 없이 clean block 변경 처리 가능 + +### Phase 3. paragraph/list anchor reconstruction + +- inline anchor metadata builder +- offset mapping + DOM insertion helper +- list item + nested list reconstruction + +게이트: + +- `544113141`, `544145591`, `692355151`, `880181257`, `883654669` +- `544376004` helper/unit case + +### Phase 4. container reconstruction + +- callout/details/ADF panel body reconstruction +- child slot order 기반 재귀 rebuild + +게이트: + +- `544112828` +- `1454342158` +- `544379140` +- `panels` + +### Phase 5. planner 전환과 batch 회귀 + +- `patch_builder.py` modified path를 reconstruction planner로 위임 +- legacy text-transfer path는 fallback 또는 제거 + +게이트: + +- `tests/testcases` 16개 reverse-sync golden green +- `tests/reverse-sync/pages.yaml` pass 케이스 유지 + +## 9. 승인 기준 + +이 설계는 아래를 만족해야 구현 완료로 본다. + +1. modified block의 기본 경로가 whole-fragment reconstruction 이다 +2. paragraph/list anchor 처리가 plain-text 좌표계 기준으로 구현된다 +3. test oracle이 `mapping.yaml` 이 아니라 `expected.roundtrip.json` / `page.xhtml` / `expected.reverse-sync.patched.xhtml` 로 확정된다 +4. XHTML normalization은 BeautifulSoup 기반 공용 helper로 통일된다 +5. duplicate content에서도 `hash + line_range` 기반 identity가 동작한다 +6. 기존 `tests/testcases` / `tests/reverse-sync` 자산을 그대로 회귀 게이트로 사용할 수 있다 + +## 10. 최종 판단 + +PR #913의 원래 방향은 맞다. 다만 기존 문서는 "재구성으로 간다"는 선언에 비해, 실제 구현이 의존할 좌표계, oracle, sidecar 책임 분리가 부족했다. + +새 설계의 핵심 차이는 다음 세 가지다. + +- `convert_inline()` 역변환 가정을 버리고 plain-text 좌표계를 채택한다 +- `mapping.yaml` 을 oracle 자리에서 내리고 `expected.roundtrip.json` 을 중심 artifact 로 올린다 +- 기존 testcase 자산을 설계 검증 테스트와 회귀 테스트로 분리해 사용한다 + +이 기준으로 구현하면, 최종 목표인 "MDX 변경을 XHTML로 재구성하여 Confluence 문서를 업데이트"하는 기능을 현재 코드베이스 위에서 더 안정적으로 구현하고 유지보수할 수 있다. + +## 11. 기존 코드 삭제 및 정리 범위 + +새 구현이 기본 경로가 되면, 현재 코드베이스에는 "과거 heuristic text patch 경로"가 상당 부분 중복 상태로 남는다. 이 섹션은 무엇을 삭제할 수 있고, 무엇을 단계적으로 축소해야 하는지를 명시한다. + +삭제 원칙은 단순하다. + +1. `tests/testcases` 16개 reverse-sync golden 과 `tests/reverse-sync/pages.yaml` 의 `expected_status: pass` 게이트가 새 경로에서 안정화되기 전에는 삭제하지 않는다. +2. 새 경로가 green 이 된 뒤에는 동일 책임의 구 구현을 남겨두지 않는다. +3. debug artifact는 기본 동작에서 제거하고, 필요하면 명시적 debug flag 뒤로 보낸다. + +### 11.1 완전 삭제 대상 + +아래 모듈은 새 설계가 완료되면 역할이 완전히 대체된다. + +#### `bin/reverse_sync/text_transfer.py` + +삭제 사유: + +- MDX plain text와 XHTML plain text를 문자 단위로 정렬해 수정분만 이식하는 구현이다. +- 새 설계는 modified block 기본 경로를 whole-fragment reconstruction으로 바꾸므로 더 이상 중심 경로가 아니다. +- paragraph/list anchor 처리도 이 모듈이 아니라 plain-text offset + DOM insertion helper가 담당한다. + +함께 삭제/교체할 테스트: + +- `tests/test_reverse_sync_text_transfer.py` +- `tests/test_reverse_sync_cli.py` 내 `align_chars`, `find_insert_pos`, `transfer_text_changes` 관련 테스트 + +#### `bin/reverse_sync/list_patcher.py` + +삭제 사유: + +- 리스트를 item-level text patch 또는 전체 innerHTML 재생성으로 처리하는 전용 heuristic 모듈이다. +- 새 설계에서는 list tree + sidecar reconstruction metadata 기반 재구성기로 대체된다. +- 특히 `_regenerate_list_from_parent()` 의 `transfer_text_changes()` 폴백은 새 경로와 철학적으로 충돌한다. + +함께 삭제/교체할 테스트: + +- `tests/test_reverse_sync_patch_builder.py` 내 `build_list_item_patches`, `split_list_items` 관련 테스트 +- `tests/test_reverse_sync_cli.py` 내 `build_list_item_patches` 직접 호출 테스트 + +#### `bin/reverse_sync/table_patcher.py` + +삭제 사유: + +- table row별 plain text patch를 containing block에 누적 적용하는 구 경로다. +- 새 설계에서는 table도 clean block로 whole-fragment replacement 대상이다. +- row-level text patch는 더 이상 유지할 가치가 없다. + +함께 삭제/교체할 테스트: + +- `tests/test_reverse_sync_patch_builder.py` 내 `build_table_row_patches`, `split_table_rows`, `normalize_table_row` 관련 테스트 + +#### `bin/reverse_sync/inline_detector.py` + +삭제 사유: + +- inline marker 변화 여부를 감지해 기존 heuristic branch를 선택하기 위한 보조 모듈이다. +- 새 경로는 inline marker 변화 감지로 분기하지 않고, block kind와 reconstruction metadata로 분기한다. +- 따라서 `has_inline_format_change()` / `has_inline_boundary_change()` 는 planner 설계에서 더 이상 필요하지 않다. + +함께 삭제/교체할 테스트: + +- `tests/test_reverse_sync_patch_builder.py` 내 `has_inline_format_change`, `has_inline_boundary_change` 관련 테스트 + +### 11.2 부분 삭제 또는 대폭 축소 대상 + +아래 코드는 즉시 파일 전체를 지우기보다는, 새 경로가 기본이 된 뒤 내부 범위를 줄여야 한다. + +#### `bin/reverse_sync/patch_builder.py` + +삭제할 범위: + +- `_flush_containing_changes()` +- `_resolve_mapping_for_change()` +- `'direct' | 'containing' | 'list' | 'table' | 'skip'` 전략 분기 +- `transfer_text_changes()` 를 호출하는 modified path +- `mdx_block_to_inner_xhtml()` 기반 `new_inner_xhtml` 패치 경로 + +남길 가능성이 있는 범위: + +- insert/delete orchestration +- alignment를 이용한 insert anchor 계산 + +권장 최종 형태: + +- `patch_builder.py` 는 사실상 thin wrapper가 되거나, +- 새 `reconstruction_planner.py` / `reconstructors.py` 로 책임을 이동한 후 삭제한다. + +#### `bin/reverse_sync/xhtml_patcher.py` + +삭제할 범위: + +- `old_plain_text` + `new_plain_text` modify path +- `_apply_text_changes()` 와 그에 딸린 text-only patch 로직 + +남겨야 할 범위: + +- XPath resolve +- `insert` +- `delete` +- 새로 추가할 `replace_fragment` +- CDATA 복원 + +즉 이 모듈은 "텍스트 패처"가 아니라 "fragment-level DOM patcher"로 축소되어야 한다. + +함께 정리할 테스트: + +- `tests/test_reverse_sync_xhtml_patcher.py` 의 `new_plain_text` 중심 테스트는 제거하거나 `replace_fragment` 중심 테스트로 교체한다. + +#### `bin/reverse_sync/mdx_to_xhtml_inline.py` + +삭제 후보 범위: + +- `mdx_block_to_inner_xhtml()` + +삭제 검토 사유: + +- 새 설계의 기본 emitter는 `mdx_to_storage.emit_block()` 이다. +- `mdx_block_to_inner_xhtml()` 는 innerHTML 단위 패치에 최적화된 구 계층이다. + +권장 방향: + +- 단기: `mdx_block_to_xhtml_element()` 만 compatibility wrapper로 유지 가능 +- 최종: planner가 `emit_block()` 을 직접 사용하면 모듈 전체 삭제 가능 + +함께 정리할 테스트: + +- `tests/test_reverse_sync_mdx_to_xhtml_inline.py` 의 innerHTML 중심 테스트는 축소하거나 새 reconstruction helper 테스트로 대체한다. + +#### `bin/reverse_sync/sidecar.py` 의 mapping.yaml 계층 + +축소 또는 삭제 대상: + +- `SidecarEntry` +- `SidecarChildEntry` +- `load_sidecar_mapping()` +- `build_mdx_to_sidecar_index()` +- `build_xpath_to_mapping()` +- `generate_sidecar_mapping()` +- `find_mapping_by_sidecar()` + +삭제 조건: + +- `RoundtripSidecar schema v3` 가 top-level routing + reconstruction metadata 책임까지 흡수한 뒤 +- `reverse_sync_cli.py` 와 `converter/cli.py` 가 더 이상 `mapping.yaml` 을 읽고 쓰지 않을 때 + +즉 이 계층은 "즉시 삭제"가 아니라 "sidecar v3 정착 후 제거" 대상이다. + +#### `bin/reverse_sync_cli.py` 의 debug artifact 경로 + +축소 또는 삭제 대상: + +- `reverse-sync.mapping.original.yaml` +- `reverse-sync.mapping.patched.yaml` +- runtime 중간 산출물로서의 `mapping.yaml` + +권장 방향: + +- 기본 verify/push 경로에서는 생성하지 않는다 +- 필요 시 `--debug-mapping` 같은 명시적 플래그 뒤로 이동한다 + +#### `bin/converter/cli.py` 의 자동 `mapping.yaml` 생성 + +삭제 또는 optional 화 대상: + +- `generate_sidecar_mapping()` 호출 블록 전체 + +사유: + +- forward convert 성공 여부와 mapping.yaml 생성은 본질적으로 분리되어야 한다 +- 새 설계의 중심 artifact는 `mapping.yaml` 이 아니라 `expected.roundtrip.json` / sidecar v3 다 + +### 11.3 유지 대상 + +아래 모듈은 새 설계에서도 유지한다. + +#### 유지: `bin/reverse_sync/mapping_recorder.py` + +이유: + +- callout / ADF panel child xpath 추출 +- debug와 fixture 분석 +- nested fragment extraction helper + +다만 역할은 "runtime truth"가 아니라 "XHTML 분석/보조 도구"로 한정한다. + +#### 유지: `bin/reverse_sync/lost_info_patcher.py` + +이유: + +- 링크, emoticon, filename, image, adf-extension 복원은 여전히 필요하다 +- 다만 적용 위치는 modified fragment emit 후의 post-process 단계로 고정한다 + +#### 유지: `bin/reverse_sync/sidecar.py` 의 roundtrip core + +유지 범위: + +- `RoundtripSidecar` +- `SidecarBlock` +- `build_sidecar()` +- `load_sidecar()` +- `write_sidecar()` +- `verify_sidecar_integrity()` + +즉 sidecar 모듈 전체를 지우는 것이 아니라, 그 안의 `mapping.yaml` 서브계층만 걷어내는 방향이다. + +### 11.4 테스트 코드 삭제 범위 + +새 구현으로 전환되면 다음 테스트 묶음은 제거 또는 대체되어야 한다. + +- `tests/test_reverse_sync_text_transfer.py` +- `tests/test_reverse_sync_patch_builder.py` 의 heuristic branch 테스트 +- `tests/test_reverse_sync_xhtml_patcher.py` 의 `new_plain_text` 기반 modify 테스트 +- `tests/test_reverse_sync_cli.py` 내부의 text-transfer helper 직접 테스트 +- `tests/test_reverse_sync_sidecar.py` 중 `mapping.yaml` 전용 테스트 + +대신 아래 묶음이 새 기본 세트가 된다. + +- `tests/test_reverse_sync_xhtml_normalizer.py` +- `tests/test_reverse_sync_reconstruction_offsets.py` +- `tests/test_reverse_sync_reconstruction_insert.py` +- `tests/test_reverse_sync_reconstruct_paragraph.py` +- `tests/test_reverse_sync_reconstruct_list.py` +- `tests/test_reverse_sync_reconstruct_container.py` +- `tests/test_reverse_sync_reconstruction_goldens.py` + +### 11.5 실제 삭제 순서 + +삭제는 아래 순서로 진행한다. + +1. 새 reconstruction path 구현 +2. 새 helper / block / golden / E2E 테스트 green +3. `patch_builder.py` 에서 구 heuristic path unreachable 상태 확인 +4. `text_transfer.py`, `list_patcher.py`, `table_patcher.py`, `inline_detector.py` 삭제 +5. 관련 테스트 삭제 +6. 마지막으로 `mapping.yaml` 계층과 debug artifact 생성 경로 제거 + +이 순서를 지키면 "새 경로 추가 + 구 경로 잔존" 상태를 최소화할 수 있고, 유지보수 비용도 빠르게 줄일 수 있다.