New
') - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, 'manifest_path': '/tmp/manifest.json', } - with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result), \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli._do_push', return_value={ 'page_id': page_id, 'title': 'Test', 'version': 6, @@ -1524,7 +1580,7 @@ def test_batch_confirm_no_aborts(self, monkeypatch): """배치 push 시 확인 거부 → push 안 함.""" files = ['src/content/ko/a.mdx'] verify_result = { - 'status': 'pass', + 'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push_eligible': True, @@ -1552,7 +1608,7 @@ def test_batch_yes_skips_confirm(self, tmp_path, monkeypatch): files = ['src/content/ko/a.mdx'] verify_result = { - 'status': 'pass', + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, @@ -1577,14 +1633,14 @@ def test_batch_stops_after_postcondition_failure(self, monkeypatch): files = ['src/content/ko/a.mdx', 'src/content/ko/b.mdx'] verify_results = [ { - 'status': 'pass', + 'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push_eligible': True, 'manifest_path': '/tmp/p1/manifest.json', }, { - 'status': 'pass', + 'status': 'verified_local', 'page_id': 'p2', 'changes_count': 1, 'push_eligible': True, @@ -1636,15 +1692,15 @@ def test_non_tty_with_yes_proceeds(self, tmp_path, monkeypatch): var_dir.mkdir(parents=True) (var_dir / 'reverse-sync.patched.xhtml').write_text('New
') - pass_result = { - 'status': 'pass', + verified_result = { + 'status': 'verified_local', 'page_id': page_id, 'changes_count': 1, 'push_eligible': True, 'manifest_path': '/tmp/manifest.json', } - with patch('reverse_sync_cli._do_verify', return_value=pass_result), \ + with patch('reverse_sync_cli._do_verify', return_value=verified_result), \ patch('reverse_sync_cli._ensure_confluence_config', return_value=MagicMock()), \ patch('reverse_sync_cli._do_push', return_value={ 'page_id': page_id, 'title': 'Test', 'version': 6, @@ -1660,7 +1716,7 @@ def test_batch_push_conflict_exits_nonzero(self, monkeypatch): """배치에서 push conflict 발생 시 exit 1.""" monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--branch', 'b', '--yes']) batch_results = [ - {'status': 'pass', 'page_id': 'p1', 'changes_count': 1, + {'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push': {'status': 'conflict', 'error': 'conflict'}}, ] @@ -1675,7 +1731,7 @@ def test_batch_push_all_success_exits_zero(self, monkeypatch): """배치에서 모든 push 성공 시 exit 0.""" monkeypatch.setattr('sys.argv', ['reverse_sync_cli.py', 'push', '--branch', 'b', '--yes']) batch_results = [ - {'status': 'pass', 'page_id': 'p1', 'changes_count': 1, + {'status': 'verified_local', 'page_id': 'p1', 'changes_count': 1, 'push': {'page_id': 'p1', 'title': 'T', 'version': 2, 'url': '/t'}}, ] @@ -1687,6 +1743,45 @@ def test_batch_push_all_success_exits_zero(self, monkeypatch): class TestPrintResultsPushStatus: """텍스트 출력이 push 실패 상태를 반영하는지 확인.""" + def test_verified_local_is_distinct_from_diagnostic_pass( + self, monkeypatch, capsys + ): + monkeypatch.setattr('reverse_sync_cli._supports_color', lambda: False) + + _print_results([ + { + 'file': 'src/content/ko/verified.mdx', + 'status': 'verified_local', + 'changes_count': 1, + }, + { + 'file': 'src/content/ko/diagnostic.mdx', + 'status': 'pass', + 'changes_count': 1, + }, + ]) + + out = capsys.readouterr().out + assert 'VERIFIED LOCAL' in out + assert '1 verified local' in out + assert '1 passed' in out + + def test_blocked_result_prints_reason_code(self, monkeypatch, capsys): + monkeypatch.setattr('reverse_sync_cli._supports_color', lambda: False) + + _print_results([ + { + 'file': 'src/content/ko/blocked.mdx', + 'status': 'blocked', + 'reason_code': 'semantic_roundtrip_mismatch', + 'changes_count': 1, + } + ]) + + out = capsys.readouterr().out + assert 'BLOCKED' in out + assert 'reason: semantic_roundtrip_mismatch' in out + def test_failures_only_shows_push_conflict(self, monkeypatch, capsys): monkeypatch.setattr('reverse_sync_cli._supports_color', lambda: False) diff --git a/confluence-mdx/tests/test_reverse_sync_equivalence.py b/confluence-mdx/tests/test_reverse_sync_equivalence.py new file mode 100644 index 000000000..32450a768 --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_equivalence.py @@ -0,0 +1,290 @@ +"""typed push equivalence와 strict local proof 계약 테스트.""" + +from dataclasses import dataclass + +from reverse_sync.equivalence import ( + PUSH_EQUIVALENCE_POLICY, + canonicalize_mdx, + verify_push_equivalence, +) +from reverse_sync.preserving_patcher import ( + PatchApplicationError, + patch_xhtml_preserving, +) +from reverse_sync.proof import ( + REQUIRED_LOCAL_GATES, + build_local_proof, + canonical_plan_json, + verify_storage_well_formed, +) +from reverse_sync.sidecar import build_sidecar + + +def test_typed_equivalence_allows_only_markdown_table_source_padding(): + expected = ( + "# Title\n\n" + "| Name | Value |\n" + "| :--------- | ----: |\n" + "| QueryPie | 42 |\n" + ) + actual = ( + "# Title\n\n" + "| Name | Value |\n" + "| :--- | ---: |\n" + "| QueryPie | 42 |\n" + ) + + result = verify_push_equivalence(expected, actual) + + assert result.passed is True + assert result.policy == PUSH_EQUIVALENCE_POLICY + assert result.expected_sha256 == result.actual_sha256 + + +def test_typed_equivalence_preserves_table_alignment_and_cell_content(): + expected = "| Name | Value |\n| :--- | ---: |\n| QueryPie | 42 |\n" + changed_alignment = "| Name | Value |\n| --- | ---: |\n| QueryPie | 42 |\n" + changed_content = "| Name | Value |\n| :--- | ---: |\n| QueryPie | 43 |\n" + + assert verify_push_equivalence(expected, changed_alignment).passed is False + assert verify_push_equivalence(expected, changed_content).passed is False + + +def test_typed_equivalence_does_not_hide_visible_whitespace_or_title(): + assert verify_push_equivalence("Text here\n", "Text here\n").passed is False + assert verify_push_equivalence("# A\n\nText\n", "# B\n\nText\n").passed is False + assert verify_push_equivalence("- item\n", "- item\n").passed is False + + +def test_typed_equivalence_preserves_link_target_and_attachment_filename(): + expected = "[Guide](/guide) \n" + changed_link = "[Guide](/other) \n" + changed_attachment = "[Guide](/guide) \n" + + assert verify_push_equivalence(expected, changed_link).passed is False + assert verify_push_equivalence(expected, changed_attachment).passed is False + + model = canonicalize_mdx(expected).to_dict() + tokens = model["blocks"][0]["tokens"] + assert tokens[0]["kind"] == "link" + assert tokens[0]["target"] == "/guide" + assert tokens[2]["attachment_filename"] == "image.png" + + +def test_preserving_patcher_keeps_untouched_entity_bytes(): + base = 'Before
“untouched”
' + original_mdx = "# Title\n\nBefore\n\n“untouched”\n" + sidecar = build_sidecar(base, original_mdx, page_id="123") + patches = [ + { + "xhtml_xpath": "p[1]", + "old_plain_text": "Before", + "new_plain_text": "After", + } + ] + + candidate = patch_xhtml_preserving(base, patches, sidecar) + + assert candidate == 'After
“untouched”
' + + +def test_preserving_patcher_fails_when_exact_target_is_missing(): + base = "Before
" + sidecar = build_sidecar(base, "# Title\n\nBefore\n", page_id="123") + + try: + patch_xhtml_preserving( + base, + [{"xhtml_xpath": "p[2]", "old_plain_text": "Before", "new_plain_text": "After"}], + sidecar, + ) + except PatchApplicationError as exc: + assert exc.reason_code == "missing_identity" + else: + raise AssertionError("missing target이 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" + sidecar = build_sidecar(base, original_mdx, page_id="123") + patches = [ + {"action": "delete", "xhtml_xpath": "p[1]"}, + { + "action": "insert", + "after_xpath": "p[2]", + "new_element_xhtml": "Third
", + }, + ] + + candidate = patch_xhtml_preserving(base, patches, sidecar) + + assert candidate == " \n\n\nSecond
Third
\n " + + +def test_preserving_patcher_rebases_nested_macro_xpath(): + base = ( + 'Before
“untouched”
" + ) + original_mdx = ( + "# Title\n\n" + 'After
" in candidate + assert candidate.endswith("“untouched”
") + + +def test_storage_well_formed_supports_confluence_namespaces_and_html_entities(): + valid = ( + '“text”
" + ) + invalid = "unclosed" + + assert verify_storage_well_formed(valid) == (True, "") + passed, detail = verify_storage_well_formed(invalid) + assert passed is False + assert detail + + +@dataclass +class _Block: + content: str + + +@dataclass +class _Change: + index: int + change_type: str + old_block: _Block | None + new_block: _Block | None + + +def _proof_inputs(): + base = '
Before
“untouched”
' + improved = "# Title\n\nAfter\n\n“untouched”\n" + roundtrip = improved + original = "# Title\n\nBefore\n\n“untouched”\n" + sidecar = build_sidecar(base, original, page_id="123") + patches = [ + { + "xhtml_xpath": "p[1]", + "old_plain_text": "Before", + "new_plain_text": "After", + } + ] + candidate = patch_xhtml_preserving(base, patches, sidecar) + changes = [_Change(2, "modified", _Block("Before\n"), _Block("After\n"))] + plan = canonical_plan_json(changes=changes, patches=patches, skipped_changes=[]) + return base, improved, roundtrip, sidecar, patches, candidate, changes, plan + + +def test_local_proof_requires_every_gate_and_returns_verified_local(): + base, improved, roundtrip, sidecar, patches, candidate, changes, plan = _proof_inputs() + + proof = build_local_proof( + base_xhtml=base, + improved_mdx=improved, + roundtrip_mdx=roundtrip, + candidate_xhtml=candidate, + sidecar=sidecar, + changes=changes, + patches=patches, + skipped_changes=[], + plan_json=plan, + deterministic_plan_json=plan, + deterministic_candidate_xhtml=candidate, + idempotent_candidate_xhtml=candidate, + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + + assert proof.status == "verified_local" + assert proof.push_eligible is True + assert tuple(gate.name for gate in proof.gates) == REQUIRED_LOCAL_GATES + assert all(gate.passed for gate in proof.gates) + + +def test_local_proof_blocks_diagnostic_match_skips_and_non_idempotency(): + base, improved, _, sidecar, patches, candidate, changes, plan = _proof_inputs() + diagnostic_only = improved.replace("After", "After ") + + proof = build_local_proof( + base_xhtml=base, + improved_mdx=improved, + roundtrip_mdx=diagnostic_only, + candidate_xhtml=candidate, + sidecar=sidecar, + changes=changes, + patches=patches, + skipped_changes=[{"reason": "unsupported"}], + plan_json=plan, + deterministic_plan_json=plan, + deterministic_candidate_xhtml=candidate, + idempotent_candidate_xhtml=candidate + "duplicate
", + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + + assert proof.status == "blocked" + assert proof.push_eligible is False + assert "incomplete_patch_plan" in proof.blocked_reasons + assert "semantic_roundtrip_mismatch" in proof.blocked_reasons + assert "non_idempotent_output" in proof.blocked_reasons + + +def test_insert_operation_is_not_claimed_idempotent_when_it_duplicates(): + base = "Before
" + original = "# Title\n\nBefore\n" + improved = "# Title\n\nBefore\n\nAdded\n" + sidecar = build_sidecar(base, original, page_id="123") + patches = [ + { + "action": "insert", + "after_xpath": "p[1]", + "new_element_xhtml": "Added
", + } + ] + candidate = patch_xhtml_preserving(base, patches, sidecar) + candidate_sidecar = build_sidecar(candidate, improved, page_id="123") + applied_twice = patch_xhtml_preserving(candidate, patches, candidate_sidecar) + changes = [_Change(4, "added", None, _Block("Added\n"))] + plan = canonical_plan_json(changes=changes, patches=patches, skipped_changes=[]) + + proof = build_local_proof( + base_xhtml=base, + improved_mdx=improved, + roundtrip_mdx=improved, + candidate_xhtml=candidate, + sidecar=sidecar, + changes=changes, + patches=patches, + skipped_changes=[], + plan_json=plan, + deterministic_plan_json=plan, + deterministic_candidate_xhtml=candidate, + idempotent_candidate_xhtml=applied_twice, + source_identity_passed=True, + base_parity_passed=True, + dependency_passed=True, + ) + + assert proof.push_eligible is False + assert "non_idempotent_output" in proof.blocked_reasons diff --git a/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py b/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py new file mode 100644 index 000000000..97c113e93 --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_online_proof_fixture.py @@ -0,0 +1,50 @@ +"""실제 converter/golden fixture를 사용하는 online local-proof shadow test.""" + +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from reverse_sync.models import PageSnapshot +from reverse_sync.proof import REQUIRED_LOCAL_GATES +from reverse_sync_cli import MdxSource, run_verify + + +PROJECT_DIR = Path(__file__).resolve().parent.parent + + +def test_golden_page_builds_verified_manifest_without_remote_put(): + page_id = "1911652402" + page_dir = PROJECT_DIR / "tests" / "testcases" / page_id + original = (page_dir / "original.mdx").read_text() + improved = (page_dir / "improved.mdx").read_text() + frontmatter = yaml.safe_load(original.split("---", 2)[1]) + snapshot = PageSnapshot( + page_id=page_id, + status="current", + title=frontmatter["title"], + version=42, + storage_xhtml=(page_dir / "page.xhtml").read_text(), + fetched_at=datetime(2026, 7, 24, tzinfo=timezone.utc).isoformat(), + api="fixture-shadow", + ) + + result = run_verify( + page_id=page_id, + original_src=MdxSource(original, str(page_dir / "original.mdx")), + improved_src=MdxSource(improved, str(page_dir / "improved.mdx")), + page_dir=str(page_dir), + base_snapshot=snapshot, + for_push=True, + ) + + assert result["status"] == "verified_local" + assert result["push_eligible"] is True + assert tuple(gate["name"] for gate in result["local_gates"]) == ( + REQUIRED_LOCAL_GATES + ) + assert all(gate["passed"] for gate in result["local_gates"]) + manifest_path = Path(result["manifest_path"]) + assert manifest_path.is_file() + assert (manifest_path.parent / "patch-plan.json").is_file() + assert (manifest_path.parent / "local-proof.json").is_file() diff --git a/confluence-mdx/tests/test_reverse_sync_push_transaction.py b/confluence-mdx/tests/test_reverse_sync_push_transaction.py index 09a1e831f..33ff07e92 100644 --- a/confluence-mdx/tests/test_reverse_sync_push_transaction.py +++ b/confluence-mdx/tests/test_reverse_sync_push_transaction.py @@ -33,6 +33,7 @@ RemoteDriftError, publish_verified_manifest, ) +from reverse_sync.proof import REQUIRED_LOCAL_GATES from reverse_sync_cli import MdxSource, _do_verify, run_verify @@ -66,19 +67,15 @@ def _manifest(tmp_path: Path, base: PageSnapshot | None = None) -> Path: original_descriptor="main:src/content/ko/test.mdx", improved_mdx="# Test page\n\nAfter\n", improved_descriptor="src/content/ko/test.mdx", + patch_plan='{"schema_version":1}\n', candidate_xhtml="After
", - verifier_policy="reverse-sync-push-v1", - tool_version="reverse-sync-cli-v1", + local_proof='{"status":"verified_local"}\n', + verifier_policy="reverse-sync-equivalence-v1", + tool_version="reverse-sync-cli-v2", push_eligible=True, gates=tuple( VerificationGate(name, True) - for name in ( - "source_identity", - "base_parity", - "intent_complete", - "semantic_roundtrip", - "artifact_integrity", - ) + for name in REQUIRED_LOCAL_GATES ), ) @@ -154,6 +151,21 @@ def test_candidate_tampering_blocks_before_remote_read(tmp_path): assert gateway.update_calls == [] +@pytest.mark.parametrize("artifact_name", ["patch-plan.json", "local-proof.json"]) +def test_proof_artifact_tampering_blocks_before_remote_read( + tmp_path, artifact_name +): + manifest_path = _manifest(tmp_path) + (manifest_path.parent / artifact_name).write_text('{"tampered":true}\n') + gateway = FakeGateway([_snapshot()]) + + with pytest.raises(ArtifactTamperedError, match=artifact_name): + publish_verified_manifest(manifest_path, gateway) + + assert gateway.current_calls == 0 + assert gateway.update_calls == [] + + def test_remote_drift_blocks_without_adopting_latest_version(tmp_path): manifest_path = _manifest(tmp_path) remote_edit = _snapshot(version=6, body="Remote edit
") @@ -339,9 +351,11 @@ def test_push_manifest_requires_all_local_proof_gates(tmp_path): original_descriptor="main:src/content/ko/test.mdx", improved_mdx="# Test page\n\nAfter\n", improved_descriptor="src/content/ko/test.mdx", + patch_plan='{"schema_version":1}\n', candidate_xhtml="After
", - verifier_policy="reverse-sync-push-v1", - tool_version="reverse-sync-cli-v1", + local_proof='{"status":"verified_local"}\n', + verifier_policy="reverse-sync-equivalence-v1", + tool_version="reverse-sync-cli-v2", push_eligible=True, gates=(VerificationGate("semantic_roundtrip", True),), ) @@ -577,7 +591,11 @@ def test_prepare_push_fetches_one_snapshot_and_passes_it_to_verify(): base = _snapshot() improved = MdxSource("# Test page\n\nAfter\n", "src/content/ko/test.mdx") original = MdxSource("# Test page\n\nBefore\n", "main:src/content/ko/test.mdx") - expected = {"status": "pass", "page_id": "123", "push_eligible": True} + expected = { + "status": "verified_local", + "page_id": "123", + "push_eligible": True, + } with patch( "reverse_sync_cli._resolve_mdx_source", @@ -625,22 +643,64 @@ def forward_convert(input_path, output_path, _page_id, **_kwargs): for_push=True, ) - assert result["status"] == "pass" + assert result["status"] == "verified_local" assert result["push_eligible"] is True assert result["base_version"] == 5 assert result["base_storage_sha256"] == base.storage_sha256 assert len(result["candidate_sha256"]) == 64 - assert "semantic_roundtrip" in result["local_gates"] + assert any( + gate["name"] == "semantic_roundtrip" and gate["passed"] + for gate in result["local_gates"] + ) manifest_path = Path(result["manifest_path"]) assert manifest_path.is_file() manifest = load_sync_manifest(manifest_path) assert manifest.base_version == 5 assert manifest.base_storage_sha256 == base.storage_sha256 + assert manifest.verifier_policy == "reverse-sync-equivalence-v1" + assert manifest.tool_version == "reverse-sync-cli-v2" + assert (manifest_path.parent / "patch-plan.json").is_file() + assert (manifest_path.parent / "local-proof.json").is_file() assert (manifest_path.parent / "candidate.xhtml").read_text() == ( tmp_path / "var" / page_id / "reverse-sync.patched.xhtml" ).read_text() +def test_online_verify_proves_insert_idempotent_by_replanning( + tmp_path, monkeypatch +): + monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path) + page_id = "123" + (tmp_path / "var" / page_id).mkdir(parents=True) + base = _snapshot( + title="Test page", + body="Before
", + ) + original = "# Test page\n\n## Section\n\nBefore\n" + improved = "# Test page\n\n## Section\n\nBefore\n\nAdded\n" + + def forward_convert(input_path, output_path, _page_id, **_kwargs): + content = original if Path(output_path).name == "reverse-sync.base.mdx" else improved + Path(output_path).write_text(content) + return content + + with patch("reverse_sync_cli._forward_convert", side_effect=forward_convert): + result = run_verify( + page_id=page_id, + original_src=MdxSource(original, "main:src/content/ko/test.mdx"), + improved_src=MdxSource(improved, "src/content/ko/test.mdx"), + base_snapshot=base, + for_push=True, + ) + + assert result["status"] == "verified_local" + assert result["push_eligible"] is True + idempotency = next( + gate for gate in result["local_gates"] if gate["name"] == "idempotency" + ) + assert idempotency["passed"] is True + + def test_online_verify_blocks_stale_original_before_patch(tmp_path, monkeypatch): monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path) page_id = "123" @@ -720,23 +780,44 @@ def test_online_verify_blocks_title_change(tmp_path, monkeypatch): assert result["push_eligible"] is False -def test_lenient_verify_is_never_push_eligible(tmp_path, monkeypatch): +def test_lenient_match_is_diagnostic_and_never_grants_push_eligibility( + tmp_path, monkeypatch +): monkeypatch.setattr("reverse_sync_cli._PROJECT_DIR", tmp_path) page_id = "123" (tmp_path / "var" / page_id).mkdir(parents=True) - - result = run_verify( - page_id=page_id, - original_src=MdxSource("# Test page\n\nBefore\n", "original.mdx"), - improved_src=MdxSource("# Test page\n\nAfter\n", "improved.mdx"), - base_snapshot=_snapshot(title="Test page"), - for_push=True, - lenient=True, + base = _snapshot( + title="Test page", + body="Before
", ) + original = "# Test page\n\n## Section\n\nBefore\n" + improved = "# Test page\n\n## Section\n\n2024년 01월 15일\n" + diagnostic_roundtrip = "# Test page\n\n## Section\n\nJan 15, 2024\n" + + def forward_convert(_input_path, output_path, _page_id, **_kwargs): + content = ( + original + if Path(output_path).name == "reverse-sync.base.mdx" + else diagnostic_roundtrip + ) + Path(output_path).write_text(content) + return content + + with patch("reverse_sync_cli._forward_convert", side_effect=forward_convert): + result = run_verify( + page_id=page_id, + original_src=MdxSource(original, "original.mdx"), + improved_src=MdxSource(improved, "improved.mdx"), + base_snapshot=base, + for_push=True, + lenient=True, + ) assert result["status"] == "blocked" - assert result["reason_code"] == "lenient_verification_not_pushable" + assert result["reason_code"] == "semantic_roundtrip_mismatch" assert result["push_eligible"] is False + assert result["diagnostics"]["lenient"]["passed"] is True + assert result["diagnostics"]["lenient"]["push_eligible"] is False def test_online_verify_blocks_missing_attachment(tmp_path, monkeypatch): diff --git a/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py b/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py index 05d20498d..3c5faa189 100644 --- a/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py +++ b/confluence-mdx/tests/test_reverse_sync_roundtrip_verifier.py @@ -1,5 +1,7 @@ import pytest from reverse_sync.roundtrip_verifier import ( + NORMALIZATION_RULES, + NormalizationClass, verify_roundtrip, VerifyResult, _normalize_consecutive_spaces_in_text, @@ -19,6 +21,32 @@ def test_identical_mdx_passes(): assert result.diff_report == "" +def test_normalization_rules_are_classified_for_diagnostics(): + classifications = {rule.name: rule.classification for rule in NORMALIZATION_RULES} + + assert set(classifications) == { + "br_space", + "consecutive_spaces", + "date_reformat", + "empty_bold", + "empty_list_items", + "html_entities_in_code", + "inline_code_boundary", + "leading_blank_lines", + "link_text_spacing", + "sentence_break_merge", + "smart_quotes", + "table_cell_line_merge", + "table_cell_padding", + "title_removal", + "trailing_blank_lines", + "trailing_whitespace", + } + assert classifications["table_cell_padding"] is NormalizationClass.SOURCE_FORMATTING + assert classifications["consecutive_spaces"] is NormalizationClass.RENDERED_VISIBLE + assert classifications["title_removal"] is NormalizationClass.UNSUPPORTED_LOSSY + + def test_different_mdx_fails(): result = verify_roundtrip( expected_mdx="# Title\n\nParagraph.\n", diff --git a/openspec/changes/complete-reverse-sync/design.md b/openspec/changes/complete-reverse-sync/design.md index 31eaa3a48..ab3a5bac5 100644 --- a/openspec/changes/complete-reverse-sync/design.md +++ b/openspec/changes/complete-reverse-sync/design.md @@ -299,12 +299,19 @@ canonical model은 block type, nesting, inline token, visible whitespace policy, 기존 `--no-normalize`는 raw diagnosis, `--lenient`는 triage 용도로 유지할 수 있지만 둘 다 push eligibility와 분리합니다. +`reverse-sync-equivalence-v1`의 초기 허용 범위는 Markdown table의 바깥 cell +padding, separator dash 길이, body block 사이의 빈 source line으로 제한합니다. +table alignment와 cell content, 연속 공백, list marker 뒤 공백, inline boundary, +link target, attachment filename, H1은 canonical model에 남겨 비교합니다. +해석하지 못하는 MDX JSX와 raw HTML은 marker와 source를 exact token으로 보존하여 +equivalence를 임의로 확대하지 않습니다. + ### Decision: `SyncManifest`를 verify와 push 사이의 계약으로 둡니다 manifest는 최소한 다음을 기록합니다. ```yaml -schema_version: 1 +schema_version: 2 run_id: "..." tool: git_sha: "..." @@ -327,7 +334,7 @@ plan: candidate: storage_sha256: "..." verification: - status: verified + status: verified_local gates: base_parity: pass intent_complete: pass diff --git a/openspec/changes/complete-reverse-sync/tasks.md b/openspec/changes/complete-reverse-sync/tasks.md index 3393a08e2..212c9d96b 100644 --- a/openspec/changes/complete-reverse-sync/tasks.md +++ b/openspec/changes/complete-reverse-sync/tasks.md @@ -83,14 +83,14 @@ ### 2.5 P0 — strict proof와 push eligibility -- [ ] `confluence-mdx/bin/reverse_sync/proof.py`를 추가하여 필수 gate를 orchestration합니다. -- [ ] `roundtrip_verifier.py` normalization을 source formatting, rendered-visible, unsupported/lossy로 분류합니다. -- [ ] push equivalence v1 typed canonical model을 구현합니다. +- [x] `confluence-mdx/bin/reverse_sync/proof.py`를 추가하여 필수 gate를 orchestration합니다. +- [x] `roundtrip_verifier.py` normalization을 source formatting, rendered-visible, unsupported/lossy로 분류합니다. +- [x] push equivalence v1 typed canonical model을 구현합니다. - [x] `skipped_changes > 0`이면 `intent_complete`를 실패시킵니다. -- [ ] `--lenient`와 `--no-normalize` 결과를 diagnostic field로 이동합니다. -- [ ] unchanged fragment, separator, document envelope byte-equal을 proof에 포함합니다. -- [ ] well-formed Storage XHTML, determinism, idempotency 검사를 추가합니다. -- [ ] 기존 `pass`를 `verified_local`과 분리합니다. +- [x] `--lenient`와 `--no-normalize` 결과를 diagnostic field로 이동합니다. +- [x] unchanged fragment, separator, document envelope byte-equal을 proof에 포함합니다. +- [x] well-formed Storage XHTML, determinism, idempotency 검사를 추가합니다. +- [x] 기존 `pass`를 `verified_local`과 분리합니다. 완료 gate: @@ -171,7 +171,15 @@ cd confluence-mdx/tests ../venv/bin/python3 -m pytest -q test_reverse_sync_push_transaction.py ``` -`proof.py`와 typed equivalence contract test는 2.5 구현 시 별도 test module로 분리합니다. +strict proof와 typed equivalence는 다음 test module과 golden shadow fixture로 검증합니다. + +```bash +cd confluence-mdx/tests +../venv/bin/python3 -m pytest -q \ + test_reverse_sync_equivalence.py \ + test_reverse_sync_online_proof_fixture.py \ + test_reverse_sync_push_transaction.py +``` ### 3.2 Existing reverse-sync unit regression @@ -216,7 +224,7 @@ make test-byte-verify ### 3.5 Broader converter regression -- [ ] verifier/equivalence 또는 emitter를 변경한 PR은 다음을 추가로 실행합니다. +- [x] verifier/equivalence 또는 emitter를 변경한 PR은 다음을 추가로 실행합니다. ```bash cd confluence-mdx/tests @@ -225,9 +233,18 @@ make test-reverse-sync make test-byte-verify ``` +strict proof 구현 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 +- 전체 Python test: 1054 passed, 2 skipped +- 16개 golden page shadow online verify: 4개 `verified_local`, 나머지는 + visible whitespace, unresolved link, raw HTML table mutation 등에서 fail-closed + - [x] 영향도에 따라 전체 Python test와 render test를 실행합니다. -이번 변경은 Python CLI/API adapter 범위이므로 전체 Python test(`1033 passed, 2 skipped`)를 +이번 변경은 Python CLI/API adapter 범위이므로 전체 Python test(`1054 passed, 2 skipped`)를 실행했고 frontend render test는 영향 범위에서 제외했습니다. ```bash