diff --git a/confluence-mdx/bin/reverse_sync/manifest.py b/confluence-mdx/bin/reverse_sync/manifest.py index bce6749b4..f9230fba8 100644 --- a/confluence-mdx/bin/reverse_sync/manifest.py +++ b/confluence-mdx/bin/reverse_sync/manifest.py @@ -4,6 +4,7 @@ import hashlib import json +from collections import Counter from pathlib import Path from typing import Iterable @@ -212,6 +213,158 @@ def load_sync_manifest(path: Path) -> SyncManifest: return manifest +def _verify_patch_plan_contract(path: Path, manifest: SyncManifest) -> None: + plan_ref = manifest.artifact("patch_plan") + try: + plan = json.loads((path.parent / plan_ref.path).read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise ArtifactTamperedError("patch plan JSON을 읽을 수 없습니다") from exc + if not isinstance(plan, dict): + raise ArtifactTamperedError("patch plan은 JSON object여야 합니다") + if plan.get("schema_version") != 2: + raise StaleVerificationError( + "push에는 현재 publisher가 지원하는 PatchPlan schema v2가 필요합니다" + ) + if plan.get("intent_complete") is not True: + raise ArtifactTamperedError( + "intent_complete가 아닌 PatchPlan은 발행할 수 없습니다" + ) + + intents = plan.get("intents") + operations = plan.get("operations") + issues = plan.get("issues") + if ( + not isinstance(intents, list) + or not isinstance(operations, list) + or not isinstance(issues, list) + ): + raise ArtifactTamperedError( + "PatchPlan intents/operations/issues 형식이 올바르지 않습니다" + ) + if not intents or not operations or issues: + raise ArtifactTamperedError( + "complete PatchPlan은 intent와 operation이 있고 issue가 없어야 합니다" + ) + + intent_ordinals: list[int] = [] + for intent in intents: + ordinal = intent.get("ordinal") if isinstance(intent, dict) else None + if ( + not isinstance(ordinal, int) + or isinstance(ordinal, bool) + or ordinal < 0 + ): + raise ArtifactTamperedError( + "PatchPlan intent ordinal이 올바르지 않습니다" + ) + intent_ordinals.append(ordinal) + if len(intent_ordinals) != len(set(intent_ordinals)): + raise ArtifactTamperedError("PatchPlan intent ordinal이 중복되었습니다") + + operation_ids: list[str] = [] + coverage: Counter[int] = Counter() + for operation in operations: + if not isinstance(operation, dict): + raise ArtifactTamperedError( + "PatchPlan operation 형식이 올바르지 않습니다" + ) + operation_id = operation.get("operation_id") + covered = operation.get("intent_ordinals") + if not isinstance(operation_id, str) or not operation_id: + raise ArtifactTamperedError("PatchPlan operation ID가 올바르지 않습니다") + if operation.get("executable") is not True: + raise ArtifactTamperedError( + "complete PatchPlan에 실행 불가능한 operation이 있습니다" + ) + if not isinstance(covered, list) or not covered: + raise ArtifactTamperedError( + "PatchPlan operation의 intent coverage가 비어 있습니다" + ) + if any( + not isinstance(ordinal, int) + or isinstance(ordinal, bool) + or ordinal < 0 + for ordinal in covered + ): + raise ArtifactTamperedError( + "PatchPlan operation intent ordinal이 올바르지 않습니다" + ) + operation_ids.append(operation_id) + coverage.update(covered) + if len(operation_ids) != len(set(operation_ids)): + raise ArtifactTamperedError("PatchPlan operation ID가 중복되었습니다") + if set(coverage) != set(intent_ordinals) or any( + count != 1 for count in coverage.values() + ): + raise ArtifactTamperedError( + "PatchPlan은 각 intent를 정확히 한 번 실행해야 합니다" + ) + + +def _verification_gate_map(gates: Iterable[dict]) -> dict[str, dict]: + result: dict[str, dict] = {} + for gate in gates: + if not isinstance(gate, dict): + raise ArtifactTamperedError("local proof gate 형식이 올바르지 않습니다") + name = gate.get("name") + passed = gate.get("passed") + reason_code = gate.get("reason_code", "") + if ( + not isinstance(name, str) + or not name + or type(passed) is not bool + or not isinstance(reason_code, str) + or name in result + ): + raise ArtifactTamperedError("local proof gate 형식이 올바르지 않습니다") + normalized = {"name": name, "passed": passed} + if reason_code: + normalized["reason_code"] = reason_code + result[name] = normalized + return result + + +def _verify_local_proof_binding(path: Path, manifest: SyncManifest) -> None: + proof_ref = manifest.artifact("local_proof") + try: + proof = json.loads((path.parent / proof_ref.path).read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise ArtifactTamperedError("local proof JSON을 읽을 수 없습니다") from exc + if not isinstance(proof, dict): + raise ArtifactTamperedError("local proof는 JSON object여야 합니다") + if ( + proof.get("status") != "verified_local" + or proof.get("push_eligible") is not True + or proof.get("blocked_reasons") != [] + ): + raise ArtifactTamperedError( + "local proof가 verified_local 발행 계약과 일치하지 않습니다" + ) + + expected_artifacts = { + "base_sha256": manifest.artifact("base_xhtml").sha256, + "candidate_sha256": manifest.artifact("candidate_xhtml").sha256, + "plan_sha256": manifest.artifact("patch_plan").sha256, + } + if proof.get("artifacts") != expected_artifacts: + raise ArtifactTamperedError( + "local proof artifact hash가 manifest artifact와 일치하지 않습니다" + ) + + proof_gates = proof.get("gates") + if not isinstance(proof_gates, list): + raise ArtifactTamperedError("local proof gate 목록이 없습니다") + actual_gate_map = _verification_gate_map(proof_gates) + expected_gate_map = { + gate.name: gate.to_dict() + for gate in manifest.gates + } + if actual_gate_map != expected_gate_map: + raise ArtifactTamperedError( + "local proof gate가 manifest gate와 일치하지 않습니다" + ) + + def verify_manifest_integrity(path: Path, manifest: SyncManifest) -> None: """manifest sidecar와 모든 referenced artifact hash를 재검산한다.""" path = Path(path) @@ -270,3 +423,6 @@ def verify_manifest_integrity(path: Path, manifest: SyncManifest) -> None: base_ref = manifest.artifact("base_xhtml") if base_ref.sha256 != manifest.base_storage_sha256: raise ArtifactTamperedError("base snapshot hash와 base.xhtml hash가 다릅니다") + if manifest.push_eligible: + _verify_patch_plan_contract(path, manifest) + _verify_local_proof_binding(path, manifest) diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py index 2d45475b6..e73cf6712 100755 --- a/confluence-mdx/bin/reverse_sync_cli.py +++ b/confluence-mdx/bin/reverse_sync_cli.py @@ -47,6 +47,20 @@ class MdxSource: descriptor: str # 출처 표시 (예: "main:src/content/ko/...", 파일 경로 등) +@dataclass(frozen=True) +class ManifestPushSummary: + """explicit manifest push의 확인 화면에 필요한 immutable identity.""" + + manifest_path: Path + run_id: str + page_id: str + title: str + base_version: int + candidate_sha256: str + change_count: int + operation_count: int + + def _is_valid_git_ref(ref: str) -> bool: """ref가 유효한 git ref인지 확인한다.""" result = subprocess.run( @@ -1009,11 +1023,13 @@ def c(code: str, text: str) -> str: reverse-sync debug --branch [--lenient] [--no-normalize] reverse-sync push [--original-mdx ] [--dry-run] [--yes] [--lenient] [--no-normalize] reverse-sync push --branch [--dry-run] [--yes] [--lenient] [--no-normalize] + reverse-sync push --manifest [--yes] reverse-sync -h | --help Commands: push 원격 current snapshot을 기준으로 검증 후 Confluence에 반영 (--dry-run은 manifest까지만 만들고 PUT을 생략) + (--manifest는 이미 검증한 explicit run을 online verify 없이 발행) verify 로컬 page.xhtml 기반 진단 (push_eligible은 항상 false) debug 로컬 verify + MDX diff, XHTML diff, Verify diff 상세 출력 @@ -1059,6 +1075,10 @@ def c(code: str, text: str) -> str: # 원격 snapshot을 사용하되 PUT은 생략 reverse-sync push --dry-run "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" + # 이미 검증한 immutable manifest를 명시적으로 발행 + reverse-sync push --manifest \ + var//reverse-sync//manifest.json + Run 'reverse-sync -h' for command-specific help and more examples. """ @@ -1101,6 +1121,10 @@ def c(code: str, text: str) -> str: # 브랜치 전체 배치 push reverse-sync push --branch proofread/fix-typo + # 이전 online verify에서 생성한 explicit run을 발행 + reverse-sync push --manifest \\ + var//reverse-sync//manifest.json + # original을 명시적으로 지정 reverse-sync push "proofread/fix-typo:src/content/ko/user-manual/user-agent.mdx" \\ --original-mdx "main:src/content/ko/user-manual/user-agent.mdx" @@ -1322,31 +1346,78 @@ def _ensure_confluence_config(): return config -def _do_push(page_id: str, config=None, manifest_path: str = None): +def _load_manifest_push_summary(manifest_path: str) -> ManifestPushSummary: + """explicit manifest의 integrity와 typed plan schema를 PUT 전에 검증합니다.""" + from reverse_sync.manifest import ( + ArtifactTamperedError, + load_sync_manifest, + verify_manifest_integrity, + ) + + resolved_path = Path(manifest_path).expanduser().resolve() + manifest = load_sync_manifest(resolved_path) + verify_manifest_integrity(resolved_path, manifest) + if not manifest.push_eligible: + raise ValueError("push eligible이 아닌 manifest는 발행할 수 없습니다") + + plan_ref = manifest.artifact("patch_plan") + try: + plan = json.loads((resolved_path.parent / plan_ref.path).read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise ArtifactTamperedError("patch plan JSON을 읽을 수 없습니다") from exc + if not isinstance(plan, dict) or plan.get("schema_version") != 2: + raise ArtifactTamperedError("explicit push는 PatchPlan schema v2가 필요합니다") + if plan.get("intent_complete") is not True: + raise ArtifactTamperedError( + "intent_complete가 아닌 PatchPlan은 발행할 수 없습니다" + ) + intents = plan.get("intents") + operations = plan.get("operations") + if not isinstance(intents, list) or not isinstance(operations, list): + raise ArtifactTamperedError( + "PatchPlan intents/operations 형식이 올바르지 않습니다" + ) + operation_count = sum( + 1 + for operation in operations + if isinstance(operation, dict) and operation.get("executable") is True + ) + if operation_count < 1: + raise ArtifactTamperedError("PatchPlan에 executable operation이 없습니다") + + candidate_ref = manifest.artifact("candidate_xhtml") + return ManifestPushSummary( + manifest_path=resolved_path, + run_id=manifest.run_id, + page_id=manifest.page_id, + title=manifest.base_title, + base_version=manifest.base_version, + candidate_sha256=candidate_ref.sha256, + change_count=len(intents), + operation_count=operation_count, + ) + + +def _do_push(page_id: str, config=None, *, manifest_path: str): """verified manifest에 결합된 candidate만 안전하게 push한다.""" from reverse_sync.confluence_client import ConfluenceGateway, VersionConflictError - from reverse_sync.manifest import load_sync_manifest, verify_manifest_integrity + from reverse_sync.manifest import load_sync_manifest from reverse_sync.publisher import publish_verified_manifest if config is None: config = _ensure_confluence_config() + if not manifest_path: + raise ValueError("push에는 explicit manifest_path가 필요합니다") + var_dir = _PROJECT_DIR / 'var' / page_id - if manifest_path is None: - pointer_path = var_dir / "reverse-sync.manifest.path" - if not pointer_path.is_file(): - raise ValueError( - f"페이지 {page_id}의 verified manifest가 없습니다. " - "online verify를 다시 실행하세요." - ) - manifest_path = pointer_path.read_text().strip() - resolved_manifest_path = Path(manifest_path) + summary = _load_manifest_push_summary(manifest_path) + resolved_manifest_path = summary.manifest_path manifest = load_sync_manifest(resolved_manifest_path) if manifest.page_id != str(page_id): raise ValueError( f"manifest page ID({manifest.page_id})와 요청 page ID({page_id})가 다릅니다." ) - verify_manifest_integrity(resolved_manifest_path, manifest) def semantic_verifier(snapshot, verified_manifest_path: Path) -> bool: improved_ref = manifest.artifact("improved_mdx") @@ -1392,6 +1463,7 @@ def semantic_verifier(snapshot, verified_manifest_path: Path) -> bool: ) from exc backup_path = var_dir / 'reverse-sync.backup.xhtml' + var_dir.mkdir(parents=True, exist_ok=True) base_ref = manifest.artifact("base_xhtml") shutil.copy2(resolved_manifest_path.parent / base_ref.path, backup_path) @@ -1425,6 +1497,10 @@ def main(): _add_common_args(push_parser) push_parser.add_argument('--dry-run', action='store_true', help='검증만 수행, Confluence 반영 안 함 (= verify)') + push_parser.add_argument( + '--manifest', + help='online verify에서 생성한 explicit manifest.json을 발행', + ) push_parser.add_argument('--yes', '-y', action='store_true', help='확인 프롬프트 없이 바로 push (CI/자동화용)') push_parser.add_argument('--json', action='store_true', @@ -1457,6 +1533,57 @@ def main(): show_all_diffs = args.command == 'debug' try: + explicit_manifest = getattr(args, "manifest", None) + if explicit_manifest: + conflicting = ( + args.improved_mdx + or getattr(args, "branch", None) + or args.original_mdx + or getattr(args, "page_dir", None) + or getattr(args, "page_id", None) + or getattr(args, "limit", 0) + or getattr(args, "failures_only", False) + or getattr(args, "lenient", False) + or getattr(args, "no_normalize", False) + or getattr(args, "dry_run", False) + ) + if conflicting: + print( + "Error: --manifest는 MDX/branch/diagnostic 옵션과 " + "동시에 사용할 수 없습니다.", + file=sys.stderr, + ) + sys.exit(1) + auto_yes = getattr(args, "yes", False) + if not auto_yes and not sys.stdin.isatty(): + print( + "Error: 비대화형 환경에서는 --yes 옵션이 필요합니다.", + file=sys.stderr, + ) + sys.exit(1) + summary = _load_manifest_push_summary(explicit_manifest) + if not auto_yes: + prompt = ( + f"Push verified run {summary.run_id} " + f"{summary.title} ({summary.page_id}) " + f"v{summary.base_version}→v{summary.base_version + 1}, " + f"{summary.change_count} change(s), " + f"{summary.operation_count} operation(s), " + f"candidate {summary.candidate_sha256[:12]} " + "to Confluence? [y/N] " + ) + if not _confirm(prompt): + print("Push 취소", file=sys.stderr) + sys.exit(0) + config = _ensure_confluence_config() + push_result = _do_push( + summary.page_id, + config=config, + manifest_path=str(summary.manifest_path), + ) + print(json.dumps(push_result, ensure_ascii=False, indent=2)) + return + # 인자 검증 if not args.improved_mdx and not getattr(args, 'branch', None): print('Error: 또는 --branch 중 하나를 지정하세요.', file=sys.stderr) @@ -1581,6 +1708,9 @@ def main(): print("Error: online verify 결과가 push eligible 상태가 아닙니다.", file=sys.stderr) sys.exit(1) + except PushConflictError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) except ValueError as e: print(f'Error: {e}', file=sys.stderr) sys.exit(1) diff --git a/confluence-mdx/tests/test_reverse_sync_cli.py b/confluence-mdx/tests/test_reverse_sync_cli.py index 196e433f0..3f6edf105 100644 --- a/confluence-mdx/tests/test_reverse_sync_cli.py +++ b/confluence-mdx/tests/test_reverse_sync_cli.py @@ -12,13 +12,18 @@ _USAGE_SUMMARY, _detect_language, _validate_improved_mdx, _find_blockquotes_missing_blank_line, - PushConflictError, _confirm, + ManifestPushSummary, PushConflictError, _confirm, + _load_manifest_push_summary, ) from text_utils import normalize_mdx_to_plain from reverse_sync.patch_builder import build_patches from reverse_sync.confluence_client import NetworkError, VersionConflictError -from reverse_sync.manifest import create_sync_manifest -from reverse_sync.models import PageSnapshot, VerificationGate +from reverse_sync.manifest import ( + ArtifactTamperedError, + StaleVerificationError, + create_sync_manifest, +) +from reverse_sync.models import PageSnapshot, VerificationGate, sha256_text from reverse_sync.publisher import PostconditionError from reverse_sync.proof import REQUIRED_LOCAL_GATES @@ -30,6 +35,7 @@ def _create_push_manifest( base_body: str = "

Old

", candidate_body: str = "

New

", confluence_url: str = "", + patch_plan: str | None = None, ) -> tuple[Path, PageSnapshot, PageSnapshot]: var_dir = tmp_path / "var" / page_id var_dir.mkdir(parents=True, exist_ok=True) @@ -59,6 +65,35 @@ def _create_push_manifest( f"confluenceUrl: {confluence_url}\n" "---\n\n" ) + plan_json = patch_plan or ( + '{"intent_complete":true,"intents":[{"ordinal":0}],"issues":[],' + '"operations":[{"executable":true,"intent_ordinals":[0],' + '"operation_id":"op-0001"}],"schema_version":2}\n' + ) + gates = tuple( + VerificationGate(name, True) + for name in REQUIRED_LOCAL_GATES + ) + local_proof = json.dumps( + { + "artifacts": { + "base_sha256": sha256_text(base_body), + "candidate_sha256": sha256_text(candidate_body), + "plan_sha256": sha256_text(plan_json), + }, + "blocked_reasons": [], + "dependencies": { + "attachments": [], + "internal_links": [], + "attachment_catalog_sha256": "", + }, + "gates": [gate.to_dict() for gate in gates], + "push_eligible": True, + "status": "verified_local", + }, + separators=(",", ":"), + sort_keys=True, + ) + "\n" manifest_path = create_sync_manifest( runs_dir=var_dir / "reverse-sync", base=base, @@ -66,20 +101,13 @@ def _create_push_manifest( original_descriptor="main:src/content/ko/test.mdx", improved_mdx=f"{frontmatter}# Test\n\nNew\n", improved_descriptor="src/content/ko/test.mdx", - patch_plan='{"schema_version":1}\n', + patch_plan=plan_json, candidate_xhtml=candidate_body, - local_proof=( - '{"dependencies":{"attachments":[],"internal_links":[],' - '"attachment_catalog_sha256":""},"push_eligible":true,' - '"status":"verified_local"}\n' - ), + local_proof=local_proof, verifier_policy="reverse-sync-equivalence-v1", tool_version="reverse-sync-cli-v5", push_eligible=True, - gates=tuple( - VerificationGate(name, True) - for name in REQUIRED_LOCAL_GATES - ), + gates=gates, ) return manifest_path, base, persisted @@ -313,6 +341,167 @@ def test_push_no_changes_is_successful_noop(monkeypatch): push.assert_not_called() +def test_push_explicit_manifest_skips_online_reverification(tmp_path, monkeypatch): + manifest_path, _, _ = _create_push_manifest(tmp_path, "explicit-page") + summary = ManifestPushSummary( + manifest_path=manifest_path.resolve(), + run_id=manifest_path.parent.name, + page_id="explicit-page", + title="Test", + base_version=5, + candidate_sha256="a" * 64, + change_count=1, + operation_count=1, + ) + monkeypatch.setattr( + "sys.argv", + [ + "reverse_sync_cli.py", + "push", + "--manifest", + str(manifest_path), + "--yes", + "--json", + ], + ) + push_result = { + "page_id": "explicit-page", + "status": "remote_verified", + "title": "Test", + "version": 6, + } + + with patch( + "reverse_sync_cli._load_manifest_push_summary", + return_value=summary, + ) as load_summary, patch( + "reverse_sync_cli._ensure_confluence_config", + return_value=MagicMock(), + ), patch( + "reverse_sync_cli._do_verify", + ) as verify, patch( + "reverse_sync_cli._do_push", + return_value=push_result, + ) as push, patch("builtins.print") as output: + main() + + load_summary.assert_called_once_with(str(manifest_path)) + verify.assert_not_called() + push.assert_called_once() + assert push.call_args.args[0] == "explicit-page" + assert push.call_args.kwargs["manifest_path"] == str(manifest_path.resolve()) + assert json.loads(output.call_args.args[0])["status"] == "remote_verified" + + +def test_push_explicit_manifest_confirmation_shows_run_identity( + tmp_path, + monkeypatch, +): + manifest_path, _, _ = _create_push_manifest(tmp_path, "explicit-page") + summary = _load_manifest_push_summary(str(manifest_path)) + monkeypatch.setattr( + "sys.argv", + ["reverse_sync_cli.py", "push", "--manifest", str(manifest_path)], + ) + + with patch( + "reverse_sync_cli.sys.stdin", + ) as stdin, patch( + "reverse_sync_cli._confirm", + return_value=False, + ) as confirm, patch( + "reverse_sync_cli._ensure_confluence_config", + ) as config, patch( + "reverse_sync_cli._do_verify", + ) as verify, patch( + "reverse_sync_cli._do_push", + ) as push, patch("builtins.print"): + stdin.isatty.return_value = True + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + prompt = confirm.call_args.args[0] + assert summary.run_id in prompt + assert "v5→v6" in prompt + assert "1 change(s)" in prompt + assert "1 operation(s)" in prompt + assert f"candidate {summary.candidate_sha256[:12]}" in prompt + config.assert_not_called() + verify.assert_not_called() + push.assert_not_called() + + +def test_push_explicit_manifest_rejects_legacy_plan_schema(tmp_path): + manifest_path, _, _ = _create_push_manifest( + tmp_path, + "explicit-page", + patch_plan='{"schema_version":1}\n', + ) + + with pytest.raises(StaleVerificationError, match="schema v2"): + _load_manifest_push_summary(str(manifest_path)) + + +def test_push_explicit_manifest_recomputes_intent_coverage(tmp_path): + manifest_path, _, _ = _create_push_manifest( + tmp_path, + "explicit-page", + patch_plan=( + '{"intent_complete":true,"intents":[{"ordinal":0},{"ordinal":1}],' + '"issues":[],"operations":[{"executable":true,' + '"intent_ordinals":[0],"operation_id":"op-0001"}],' + '"schema_version":2}\n' + ), + ) + + with pytest.raises(ArtifactTamperedError, match="정확히 한 번"): + _load_manifest_push_summary(str(manifest_path)) + + +@pytest.mark.parametrize( + "extra_args", + [ + ["src/content/ko/test/page.mdx"], + ["--branch", "proofread/fix-typo"], + ["--dry-run"], + ["--original-mdx", "main:src/content/ko/test/page.mdx"], + ], +) +def test_push_explicit_manifest_rejects_conflicting_inputs( + tmp_path, + monkeypatch, + extra_args, +): + manifest_path, _, _ = _create_push_manifest(tmp_path, "explicit-page") + monkeypatch.setattr( + "sys.argv", + [ + "reverse_sync_cli.py", + "push", + "--manifest", + str(manifest_path), + "--yes", + *extra_args, + ], + ) + + with patch("reverse_sync_cli._do_verify") as verify, patch( + "reverse_sync_cli._do_push" + ) as push, patch("builtins.print"): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + verify.assert_not_called() + push.assert_not_called() + + +def test_do_push_requires_explicit_manifest_path(): + with pytest.raises(TypeError): + _do_push("page-with-pointer", config=MagicMock()) + + def test_verify_is_dry_run_alias(monkeypatch): """verify 커맨드는 push --dry-run과 동일하게 동작한다.""" mdx_arg = 'src/content/ko/test/page.mdx' @@ -676,6 +865,7 @@ def test_usage_summary_includes_push_no_normalize(): """push usage도 --no-normalize 지원과 일치해야 한다.""" assert 'reverse-sync push [--original-mdx ] [--dry-run] [--yes] [--lenient] [--no-normalize]' in _USAGE_SUMMARY assert 'reverse-sync push --branch [--dry-run] [--yes] [--lenient] [--no-normalize]' in _USAGE_SUMMARY + assert 'reverse-sync push --manifest [--yes]' in _USAGE_SUMMARY # --- normalize_mdx_to_plain tests --- diff --git a/confluence-mdx/tests/test_reverse_sync_push_transaction.py b/confluence-mdx/tests/test_reverse_sync_push_transaction.py index 9cd57293e..da638ead5 100644 --- a/confluence-mdx/tests/test_reverse_sync_push_transaction.py +++ b/confluence-mdx/tests/test_reverse_sync_push_transaction.py @@ -108,7 +108,19 @@ def _manifest( required_attachment: str = "", required_link: tuple[str, str, str] | None = None, malformed_attachment_evidence: bool = False, + proof_artifact_overrides: dict[str, str] | None = None, ) -> Path: + base_snapshot = base or _snapshot() + candidate_xhtml = "

After

" + patch_plan = ( + '{"intent_complete":true,"intents":[{"ordinal":0}],"issues":[],' + '"operations":[{"executable":true,"intent_ordinals":[0],' + '"operation_id":"op-0001"}],"schema_version":2}\n' + ) + gates = tuple( + VerificationGate(name, True) + for name in REQUIRED_LOCAL_GATES + ) attachments = [] if required_attachment: attachments.append( @@ -130,13 +142,22 @@ def _manifest( "page_id": page_id, } ) + proof_artifacts = { + "base_sha256": base_snapshot.storage_sha256, + "candidate_sha256": sha256_text(candidate_xhtml), + "plan_sha256": sha256_text(patch_plan), + } + proof_artifacts.update(proof_artifact_overrides or {}) local_proof = json.dumps( { + "artifacts": proof_artifacts, + "blocked_reasons": [], "dependencies": { "attachment_catalog_sha256": "0" * 64 if attachments else "", "attachments": attachments, "internal_links": internal_links, }, + "gates": [gate.to_dict() for gate in gates], "push_eligible": True, "status": "verified_local", }, @@ -145,21 +166,18 @@ def _manifest( ) + "\n" return create_sync_manifest( runs_dir=tmp_path / "reverse-sync", - base=base or _snapshot(), + base=base_snapshot, original_mdx="# Test page\n\nBefore\n", 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

", + patch_plan=patch_plan, + candidate_xhtml=candidate_xhtml, local_proof=local_proof, verifier_policy="reverse-sync-equivalence-v1", tool_version="reverse-sync-cli-v5", push_eligible=True, - gates=tuple( - VerificationGate(name, True) - for name in REQUIRED_LOCAL_GATES - ), + gates=gates, ) @@ -265,6 +283,20 @@ def test_proof_artifact_tampering_blocks_before_remote_read( assert gateway.update_calls == [] +def test_local_proof_hash_binding_blocks_before_remote_read(tmp_path): + manifest_path = _manifest( + tmp_path, + proof_artifact_overrides={"candidate_sha256": "f" * 64}, + ) + gateway = FakeGateway([_snapshot()]) + + with pytest.raises(ArtifactTamperedError, match="artifact hash"): + publish_verified_manifest(manifest_path, gateway) + + assert gateway.current_calls == 0 + assert gateway.update_calls == [] + + def test_malformed_dependency_evidence_blocks_before_remote_read(tmp_path): manifest_path = _manifest( tmp_path, diff --git a/openspec/changes/complete-reverse-sync/design.md b/openspec/changes/complete-reverse-sync/design.md index b8c58dc31..0c542439d 100644 --- a/openspec/changes/complete-reverse-sync/design.md +++ b/openspec/changes/complete-reverse-sync/design.md @@ -410,6 +410,18 @@ push는 file name이 아니라 manifest가 가리키는 candidate hash를 읽습 artifact는 `var//reverse-sync//`에 실행별로 저장합니다. “최근 파일”을 덮어쓰는 방식은 호환 bridge로만 유지합니다. publisher는 local proof manifest를 수정하지 않고, manifest hash를 참조하는 별도 `PushReceipt`와 post-snapshot을 기록합니다. +CLI에서 verify와 publish를 분리할 때는 `push --manifest +/manifest.json`으로 run을 명시합니다. `_do_push()`는 latest pointer나 +flat compatibility artifact를 암묵적으로 선택하지 않으며 explicit +`manifest_path`만 받습니다. CLI는 confirmation 전에 manifest와 referenced artifact +hash, tool/policy version, required gate, PatchPlan schema v2와 +`intent_complete`를 다시 확인합니다. 이때 직렬화된 `intent_complete` boolean만 +신뢰하지 않고 intent ordinal과 executable operation coverage를 다시 계산하여 각 +intent가 정확히 한 번 실행되는지 검증합니다. `local-proof.json`의 +base/candidate/plan hash와 gate 결과도 manifest의 artifact/gate와 교차 검증합니다. +`--manifest`는 MDX/branch/diagnostic 옵션과 상호 배타적이고 `--yes`는 +confirmation만 생략합니다. + ### Decision: push는 preflight compare-and-set과 postcondition으로 구성합니다 publisher의 순서는 다음과 같습니다. diff --git a/openspec/changes/complete-reverse-sync/tasks.md b/openspec/changes/complete-reverse-sync/tasks.md index 23816357b..3bbdc6a35 100644 --- a/openspec/changes/complete-reverse-sync/tasks.md +++ b/openspec/changes/complete-reverse-sync/tasks.md @@ -123,7 +123,14 @@ - [ ] `reverse_sync_cli.py`를 prepare/verify/push lifecycle orchestration으로 축소합니다. - [x] online verify(`push --dry-run`) 출력에 run ID, base version/hash, local gates, push eligibility, reason code를 표시합니다. -- [ ] `push`가 explicit run/manifest를 받도록 합니다. +- [x] `push`가 explicit run/manifest를 받도록 합니다. + - `push --manifest `은 online verify를 다시 실행하지 않고 해당 + immutable run만 publisher에 전달합니다. + - `_do_push()`의 `reverse-sync.manifest.path` pointer fallback을 제거합니다. + - MDX/branch/diagnostic 옵션과의 상호 배제 및 confirmation identity를 + contract test로 고정합니다. + - publisher 진입 전에 PatchPlan intent coverage를 재계산하고 local proof의 + base/candidate/plan hash와 gate를 manifest에 교차 검증합니다. - [x] interactive confirmation에 page ID, base version, target version, change count, candidate hash를 표시합니다. - [x] batch는 모든 local proof 후 page별 transaction을 실행합니다. - [ ] batch partial success, conflict, postcondition failure exit code와 JSON schema를 정의합니다. @@ -200,7 +207,7 @@ cd confluence-mdx/tests 기준선: 2026-07-24 `origin/main`에서 676 passed입니다. -typed plan 변경 결과: 773 passed입니다. +explicit manifest 변경 결과: 783 passed입니다. ### 3.3 Page fixture regression @@ -257,10 +264,17 @@ typed plan branch 검증 결과: - `make test-byte-verify`: fast/splice 각각 21/21 passed - 전체 Python test: 1096 passed, 2 skipped +explicit manifest 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: 1106 passed, 2 skipped + - [x] 영향도에 따라 전체 Python test와 render test를 실행합니다. 이번 변경은 Python CLI/planner 범위이므로 전체 Python test -(`1096 passed, 2 skipped`)를 실행했고 frontend render test는 영향 범위에서 +(`1106 passed, 2 skipped`)를 실행했고 frontend render test는 영향 범위에서 제외했습니다. ```bash