from __future__ import annotations import fcntl import os import shutil from pathlib import Path from helpers import ( FIXTURES, init_run, install_good_contracts, read_json, run_cli, write_evidence, write_json, write_reviews, ) def transition(run_dir: Path, status: str): return run_cli("update_run.py", "--run-dir", run_dir, "--status", status, "--reason", "test") def install_plan_contracts(run_dir: Path) -> None: for source, target in ( ("reader-contract.json", "02_reader_contract.json"), ("logic-map.json", "04_logic_map.json"), ("term-ledger.json", "05_term_ledger.json"), ): shutil.copyfile(FIXTURES / "good" / source, run_dir / target) def lint_checkpoint(run_dir: Path, *, review: bool = False) -> None: manifest = read_json(run_dir / "00_run.json") document = run_dir / ("07_draft.md" if review else "final.md") arguments: list[object] = [ "lint_document.py", "--document", document, "--logic-map", run_dir / "04_logic_map.json", "--term-ledger", run_dir / "05_term_ledger.json", "--reader-contract", run_dir / "02_reader_contract.json", "--output", run_dir / "08_lint.json", ] if not review: arguments.extend(["--draft-baseline", run_dir / "07_draft.md"]) if manifest["mode"] == "revise": arguments.extend(["--baseline", manifest["inputs"]["draft"]["resolved_path"]]) if manifest["route_hint"] == "deep": arguments.extend(["--fail-on", "warning"]) result = run_cli(*arguments) expected = {0, 1} if review else {0} assert result.returncode in expected, result.stderr def prepare_light_publish(run_dir: Path, *, with_reviews: bool = False) -> None: install_good_contracts(run_dir) if with_reviews: manifest_path = run_dir / "00_run.json" manifest = read_json(manifest_path) manifest["omissions"] = [ item for item in manifest["omissions"] if item["artifact"] not in {"08_logic_review.json", "08_reader_review.json"} ] write_json(manifest_path, manifest) write_reviews(run_dir) shutil.copyfile(FIXTURES / "good" / "document.md", run_dir / "final.md") lint_checkpoint(run_dir) def prepare_review_run(run_dir: Path, *, with_evidence: bool = False) -> None: install_good_contracts(run_dir) if with_evidence: write_evidence(run_dir) write_reviews(run_dir) lint_checkpoint(run_dir, review=True) def test_only_verifier_can_record_verified_status(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") prepare_light_publish(run_dir) for status in ("planned", "drafted", "finalized"): result = transition(run_dir, status) assert result.returncode == 0, result.stderr rejected = transition(run_dir, "verified") assert rejected.returncode == 2 assert "verify_run.py" in rejected.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "finalized" assert [item["to"] for item in manifest["history"]] == [ "initialized", "planned", "drafted", "finalized", ] def test_light_write_can_record_optional_independent_review_stage(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") prepare_light_publish(run_dir, with_reviews=True) for status in ("planned", "drafted", "reviewed", "finalized"): result = transition(run_dir, status) assert result.returncode == 0, result.stderr assert read_json(run_dir / "00_run.json")["status"] == "finalized" def test_standard_requires_evidence_and_review_stages(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="standard") install_good_contracts(run_dir) write_evidence(run_dir) write_reviews(run_dir) shutil.copyfile(FIXTURES / "good" / "document.md", run_dir / "final.md") lint_checkpoint(run_dir) illegal = transition(run_dir, "planned") assert illegal.returncode == 2 assert read_json(run_dir / "00_run.json")["status"] == "initialized" for status in ("evidence_ready", "planned", "drafted", "reviewed", "finalized"): assert transition(run_dir, status).returncode == 0 def test_illegal_backward_transition_is_rejected(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) assert transition(run_dir, "planned").returncode == 0 assert transition(run_dir, "initialized").returncode == 2 assert read_json(run_dir / "00_run.json")["status"] == "planned" def test_any_active_stage_can_hold_for_review(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") result = transition(run_dir, "hold_for_review") assert result.returncode == 0 manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "hold_for_review" assert manifest["error"] == { "stage": "initialized", "code": "TERMINAL_HOLD_FOR_REVIEW", "message": "test", "affected_artifact": None, "retryable": False, "safe_next_action": ( "기록된 오류와 run artifact를 확인하고 blocker를 해결한 뒤 재개 여부를 판단한다." ), } assert manifest["history"][-1]["error"] == manifest["error"] def test_terminal_transition_records_explicit_structured_error(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") result = run_cli( "update_run.py", "--run-dir", run_dir, "--status", "hold_for_review", "--reason", "required source is unavailable", "--error-stage", "evidence", "--error-code", "SOURCE_UNAVAILABLE", "--error-message", "필수 source를 읽을 수 없습니다.", "--error-affected-artifact", "03_evidence_map.json", "--error-retryable", "--error-safe-next-action", "source 접근을 복구한 뒤 evidence 단계부터 재실행한다.", ) assert result.returncode == 0, result.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["error"] == { "stage": "evidence", "code": "SOURCE_UNAVAILABLE", "message": "필수 source를 읽을 수 없습니다.", "affected_artifact": "03_evidence_map.json", "retryable": True, "safe_next_action": "source 접근을 복구한 뒤 evidence 단계부터 재실행한다.", } assert manifest["history"][-1] == { "at": manifest["updated_at"], "from": "initialized", "to": "hold_for_review", "reason": "required source is unavailable", "error": manifest["error"], } def test_run_schema_requires_error_shape_to_match_status(tmp_path: Path) -> None: nonterminal_case = tmp_path / "nonterminal" nonterminal_case.mkdir() nonterminal_dir = init_run(nonterminal_case, route="light") nonterminal_path = nonterminal_dir / "00_run.json" nonterminal = read_json(nonterminal_path) nonterminal["error"] = { "stage": "initialized", "code": "SHOULD_BE_NULL", "message": "non-terminal error", "affected_artifact": None, "retryable": False, "safe_next_action": "오류를 제거한다.", } write_json(nonterminal_path, nonterminal) rejected_nonterminal = transition(nonterminal_dir, "planned") assert rejected_nonterminal.returncode == 2 assert "schema 위반" in rejected_nonterminal.stderr terminal_case = tmp_path / "terminal" terminal_case.mkdir() terminal_dir = init_run(terminal_case, route="light") assert transition(terminal_dir, "failed").returncode == 0 terminal_path = terminal_dir / "00_run.json" terminal = read_json(terminal_path) terminal["error"] = None terminal["history"][-1]["error"] = None write_json(terminal_path, terminal) rejected_terminal = transition(terminal_dir, "failed") assert rejected_terminal.returncode == 2 assert "schema 위반" in rejected_terminal.stderr def test_run_history_error_snapshot_must_match_top_level_error(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") assert transition(run_dir, "incomplete").returncode == 0 manifest_path = run_dir / "00_run.json" manifest = read_json(manifest_path) manifest["error"]["message"] = "history와 다른 message" write_json(manifest_path, manifest) result = transition(run_dir, "incomplete") assert result.returncode == 2 assert "마지막 history error snapshot" in result.stderr def test_review_finishes_at_reviewed_without_finalized(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light", mode="review", with_draft=True) prepare_review_run(run_dir) assert transition(run_dir, "planned").returncode == 0 assert transition(run_dir, "reviewed").returncode == 0 assert transition(run_dir, "finalized").returncode == 2 def test_standard_review_requires_evidence_before_plan(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="standard", mode="review", with_draft=True) prepare_review_run(run_dir, with_evidence=True) assert transition(run_dir, "planned").returncode == 2 for status in ("evidence_ready", "planned", "reviewed"): result = transition(run_dir, status) assert result.returncode == 0, result.stderr assert read_json(run_dir / "00_run.json")["status"] == "reviewed" def test_light_omission_only_records_multiple_reasons(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") result = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "03_evidence_map.json", "light 경로에서 별도 근거 큐레이션을 생략한다.", "--omit", "08_logic_review.json", "light 경로에서 독립 논리 리뷰를 생략한다.", "--omit", "08_reader_review.json", "light 경로에서 독립 독자 리뷰를 생략한다.", ) assert result.returncode == 0, result.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "initialized" assert [item["artifact"] for item in manifest["omissions"]] == [ "03_evidence_map.json", "08_logic_review.json", "08_reader_review.json", ] assert [item["to"] for item in manifest["history"]] == ["initialized"] def test_transition_and_omission_are_one_atomic_update(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) result = run_cli( "update_run.py", "--run-dir", run_dir, "--status", "planned", "--reason", "planning contracts validated", "--omit", "03_evidence_map.json", "light 경로에서 evidence map을 생략한다.", ) assert result.returncode == 0, result.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "planned" assert manifest["omissions"] == [ { "artifact": "03_evidence_map.json", "reason": "light 경로에서 evidence map을 생략한다.", } ] assert manifest["history"][-1]["to"] == "planned" def test_duplicate_or_unknown_omission_is_rejected_without_partial_write( tmp_path: Path, ) -> None: run_dir = init_run(tmp_path, route="light") duplicate = run_cli( "update_run.py", "--run-dir", run_dir, "--status", "planned", "--omit", "03_evidence_map.json", "첫 번째 이유", "--omit", "03_evidence_map.json", "중복 이유", ) assert duplicate.returncode == 2 assert "duplicate=" in duplicate.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "initialized" assert manifest["omissions"] == [] unknown = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "08_combined_review.json", "계약에 없는 이름", ) assert unknown.returncode == 2 assert "unknown=" in unknown.stderr assert read_json(run_dir / "00_run.json")["omissions"] == [] def test_present_optional_artifact_cannot_be_declared_omitted(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") (run_dir / "03_evidence_map.json").write_text("{}\n", encoding="utf-8") result = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "03_evidence_map.json", "이미 있는데 생략했다고 선언", ) assert result.returncode == 2 assert "present=" in result.stderr assert read_json(run_dir / "00_run.json")["omissions"] == [] def test_route_and_mode_required_artifacts_cannot_be_omitted(tmp_path: Path) -> None: cases = ( ("light", "write", "07_draft.md"), ("standard", "write", "03_evidence_map.json"), ("light", "review", "08_logic_review.json"), ) for index, (route, mode, artifact) in enumerate(cases): case_dir = tmp_path / f"case-{index}" case_dir.mkdir() run_dir = init_run(case_dir, route=route, mode=mode, with_draft=mode == "review") result = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", artifact, "필수 산출물을 잘못 생략", ) assert result.returncode == 2 assert "required=" in result.stderr assert read_json(run_dir / "00_run.json")["omissions"] == [] def test_update_rejects_invalid_run_schema_before_writing(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") manifest_path = run_dir / "00_run.json" manifest = read_json(manifest_path) manifest["kind_reason"] = "" write_json(manifest_path, manifest) result = transition(run_dir, "planned") assert result.returncode == 2 assert "schema 위반" in result.stderr assert read_json(manifest_path)["status"] == "initialized" def test_unomit_then_create_optional_artifact(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) omitted = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "08_logic_review.json", "처음에는 별도 논리 리뷰가 불필요했다.", ) assert omitted.returncode == 0, omitted.stderr restored = run_cli( "update_run.py", "--run-dir", run_dir, "--unomit", "08_logic_review.json", ) assert restored.returncode == 0, restored.stderr assert read_json(run_dir / "00_run.json")["omissions"] == [] (run_dir / "08_logic_review.json").write_text("{}\n", encoding="utf-8") advanced = transition(run_dir, "planned") assert advanced.returncode == 0, advanced.stderr def test_unomit_and_new_omission_can_be_one_atomic_transition(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) initial = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "03_evidence_map.json", "처음 생략", ) assert initial.returncode == 0, initial.stderr changed = run_cli( "update_run.py", "--run-dir", run_dir, "--status", "planned", "--unomit", "03_evidence_map.json", "--omit", "08_logic_review.json", "대신 논리 리뷰를 생략", ) assert changed.returncode == 0, changed.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "planned" assert [item["artifact"] for item in manifest["omissions"]] == [ "08_logic_review.json" ] def test_unknown_or_undeclared_unomit_is_rejected(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") undeclared = run_cli( "update_run.py", "--run-dir", run_dir, "--unomit", "08_logic_review.json", ) assert undeclared.returncode == 2 assert "not_declared=" in undeclared.stderr unknown = run_cli( "update_run.py", "--run-dir", run_dir, "--unomit", "08_combined_review.json", ) assert unknown.returncode == 2 assert "unknown_unomit=" in unknown.stderr assert read_json(run_dir / "00_run.json")["omissions"] == [] def test_status_only_terminal_transition_ignores_business_invalid_omission( tmp_path: Path, ) -> None: run_dir = init_run(tmp_path, route="light") omitted = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "03_evidence_map.json", "현재는 생략한다.", ) assert omitted.returncode == 0, omitted.stderr (run_dir / "03_evidence_map.json").write_text("{}\n", encoding="utf-8") held = transition(run_dir, "hold_for_review") assert held.returncode == 0, held.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "hold_for_review" assert manifest["omissions"][0]["artifact"] == "03_evidence_map.json" def test_normal_status_transition_revalidates_existing_omissions(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") omitted = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "03_evidence_map.json", "현재는 생략한다.", ) assert omitted.returncode == 0, omitted.stderr (run_dir / "03_evidence_map.json").write_text("{}\n", encoding="utf-8") normal = transition(run_dir, "planned") assert normal.returncode == 2 assert "present=" in normal.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_whitespace_only_omission_reason_is_rejected(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") result = run_cli( "update_run.py", "--run-dir", run_dir, "--omit", "03_evidence_map.json", " ", ) assert result.returncode == 2 assert "reason" in result.stderr assert read_json(run_dir / "00_run.json")["omissions"] == [] def test_stale_lock_file_does_not_block_recovery(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) (run_dir / ".00_run.lock").write_text("99999999", encoding="ascii") result = transition(run_dir, "planned") assert result.returncode == 0, result.stderr assert read_json(run_dir / "00_run.json")["status"] == "planned" def test_live_run_lock_rejects_concurrent_update_without_mutation(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") descriptor = os.open(run_dir / ".00_run.lock", os.O_CREAT | os.O_RDWR, 0o600) try: fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) result = transition(run_dir, "planned") assert result.returncode == 2 assert "진행 중" in result.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" finally: fcntl.flock(descriptor, fcntl.LOCK_UN) os.close(descriptor) def test_run_lock_rejects_hard_link_without_mutating_the_other_name( tmp_path: Path, ) -> None: run_dir = init_run(tmp_path, route="light") outside = tmp_path / "sensitive.txt" outside.write_text("SENSITIVE-CONTENT", encoding="utf-8") os.link(outside, run_dir / ".00_run.lock") result = transition(run_dir, "planned") assert result.returncode == 2 assert "hard link" in result.stderr assert outside.read_text(encoding="utf-8") == "SENSITIVE-CONTENT" assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_checkpoint_transitions_require_current_stage_artifacts(tmp_path: Path) -> None: light_root = tmp_path / "light" light_root.mkdir() light_dir = init_run(light_root, route="light") missing_plan = transition(light_dir, "planned") assert missing_plan.returncode == 2 assert "02_reader_contract.json" in missing_plan.stderr assert read_json(light_dir / "00_run.json")["status"] == "initialized" standard_root = tmp_path / "standard" standard_root.mkdir() standard_dir = init_run(standard_root, route="standard") missing_evidence = transition(standard_dir, "evidence_ready") assert missing_evidence.returncode == 2 assert "03_evidence_map.json" in missing_evidence.stderr assert read_json(standard_dir / "00_run.json")["status"] == "initialized" install_plan_contracts(light_dir) assert transition(light_dir, "planned").returncode == 0 missing_draft = transition(light_dir, "drafted") assert missing_draft.returncode == 2 assert "07_draft.md" in missing_draft.stderr assert read_json(light_dir / "00_run.json")["status"] == "planned" def test_drafted_checkpoint_rejects_non_utf8_markdown(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) assert transition(run_dir, "planned").returncode == 0 (run_dir / "07_draft.md").write_bytes(b"\xff\xfe\x00") rejected = transition(run_dir, "drafted") assert rejected.returncode == 2 assert "UTF-8" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "planned" def test_drafted_checkpoint_rejects_structurally_invalid_markdown( tmp_path: Path, ) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) assert transition(run_dir, "planned").returncode == 0 (run_dir / "07_draft.md").write_text( "# 캐시 실패를 줄이는 설명\n\n```text\n닫히지 않은 코드\n", encoding="utf-8", ) rejected = transition(run_dir, "drafted") assert rejected.returncode == 2 assert "structural lint" in rejected.stderr assert "DOC-M002" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "planned" def test_evidence_checkpoint_binds_registry_and_external_source_hashes( tmp_path: Path, ) -> None: registry_root = tmp_path / "registry" registry_root.mkdir() registry_dir = init_run(registry_root, route="standard") write_evidence(registry_dir) sources_path = registry_dir / "01_sources.json" sources = read_json(sources_path) sources["brief"]["sha256"] = "0" * 64 write_json(sources_path, sources) tampered_registry = transition(registry_dir, "evidence_ready") assert tampered_registry.returncode == 2 assert "sources_manifest_sha256" in tampered_registry.stderr assert read_json(registry_dir / "00_run.json")["status"] == "initialized" source_root = tmp_path / "source" source_root.mkdir() source_dir = init_run(source_root, route="standard") write_evidence(source_dir) manifest = read_json(source_dir / "00_run.json") brief_path = Path(manifest["inputs"]["brief"]["resolved_path"]) brief_path.write_text("drifted source\n", encoding="utf-8") drifted_source = transition(source_dir, "evidence_ready") assert drifted_source.returncode == 2 assert "run 초기화 snapshot" in drifted_source.stderr assert read_json(source_dir / "00_run.json")["status"] == "initialized" def test_evidence_checkpoint_rejects_duplicate_source_locator(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="standard") write_evidence(run_dir) evidence_path = run_dir / "03_evidence_map.json" evidence = read_json(evidence_path) evidence["claims"][0]["source_locations"].append( dict(evidence["claims"][0]["source_locations"][0]) ) write_json(evidence_path, evidence) rejected = transition(run_dir, "evidence_ready") assert rejected.returncode == 2 assert "source locator가 중복" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_light_checkpoint_validates_present_optional_evidence(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_good_contracts(run_dir) manifest_path = run_dir / "00_run.json" manifest = read_json(manifest_path) manifest["omissions"] = [ item for item in manifest["omissions"] if item["artifact"] != "03_evidence_map.json" ] write_json(manifest_path, manifest) write_evidence(run_dir) evidence_path = run_dir / "03_evidence_map.json" evidence = read_json(evidence_path) evidence["claims"][0]["source_locations"].append( dict(evidence["claims"][0]["source_locations"][0]) ) write_json(evidence_path, evidence) rejected = transition(run_dir, "planned") assert rejected.returncode == 2 assert "source locator가 중복" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_plan_checkpoint_rejects_invalid_schema_and_symlink(tmp_path: Path) -> None: invalid_root = tmp_path / "invalid" invalid_root.mkdir() invalid_dir = init_run(invalid_root, route="light") install_plan_contracts(invalid_dir) write_json(invalid_dir / "02_reader_contract.json", {}) invalid = transition(invalid_dir, "planned") assert invalid.returncode == 2 assert "schema 위반" in invalid.stderr assert read_json(invalid_dir / "00_run.json")["status"] == "initialized" symlink_root = tmp_path / "symlink" symlink_root.mkdir() symlink_dir = init_run(symlink_root, route="light") install_plan_contracts(symlink_dir) reader = symlink_dir / "02_reader_contract.json" external = tmp_path / "reader.json" shutil.copyfile(reader, external) reader.unlink() reader.symlink_to(external) linked = transition(symlink_dir, "planned") assert linked.returncode == 2 assert "symbolic link" in linked.stderr assert read_json(symlink_dir / "00_run.json")["status"] == "initialized" def test_plan_checkpoint_rejects_normalized_term_name_collision( tmp_path: Path, ) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) ledger_path = run_dir / "05_term_ledger.json" ledger = read_json(ledger_path) ledger["terms"][1]["aliases"].append(" 멱등성 ") write_json(ledger_path, ledger) rejected = transition(run_dir, "planned") assert rejected.returncode == 2 assert "둘 이상에 배정" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_plan_checkpoint_binds_term_budgets_to_quality_rules(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) ledger_path = run_dir / "05_term_ledger.json" ledger = read_json(ledger_path) ledger["budgets"]["per_sentence"] = 999 write_json(ledger_path, ledger) rejected = transition(run_dir, "planned") assert rejected.returncode == 2 assert "budgets가 quality rules와 다릅니다" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_plan_checkpoint_rejects_blank_reader_list_item(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") install_plan_contracts(run_dir) reader_path = run_dir / "02_reader_contract.json" reader = read_json(reader_path) reader["non_goals"] = [""] write_json(reader_path, reader) rejected = transition(run_dir, "planned") assert rejected.returncode == 2 assert "schema 위반" in rejected.stderr assert "non_goals" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_review_checkpoint_rejects_stale_draft_and_upstream_hashes( tmp_path: Path, ) -> None: run_dir = init_run(tmp_path, route="light") prepare_light_publish(run_dir, with_reviews=True) assert transition(run_dir, "planned").returncode == 0 assert transition(run_dir, "drafted").returncode == 0 draft_path = run_dir / "07_draft.md" draft_path.write_text( draft_path.read_text(encoding="utf-8") + "\n현재 draft 변경\n", encoding="utf-8", ) rejected = transition(run_dir, "reviewed") assert rejected.returncode == 2 assert "현재 07_draft.md" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "drafted" def test_review_mode_checkpoint_requires_current_lint_report(tmp_path: Path) -> None: run_dir = init_run( tmp_path, route="light", mode="review", with_draft=True, ) prepare_review_run(run_dir) assert transition(run_dir, "planned").returncode == 0 lint_report = read_json(run_dir / "08_lint.json") lint_report["limitations"].append("forged limitation") write_json(run_dir / "08_lint.json", lint_report) rejected = transition(run_dir, "reviewed") assert rejected.returncode == 2 assert "canonical lint" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "planned" def test_review_mode_planned_checkpoint_rejects_mutated_immutable_draft( tmp_path: Path, ) -> None: run_dir = init_run( tmp_path, route="light", mode="review", with_draft=True, ) install_good_contracts(run_dir) draft_path = run_dir / "07_draft.md" draft_path.write_text( draft_path.read_text(encoding="utf-8") + "\n초기화 뒤 변경된 문장입니다.\n", encoding="utf-8", ) rejected = transition(run_dir, "planned") assert rejected.returncode == 2 assert "byte-identical" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "initialized" def test_verifier_independently_rejects_mutated_review_mode_draft( tmp_path: Path, ) -> None: run_dir = init_run( tmp_path, route="light", mode="review", with_draft=True, ) prepare_review_run(run_dir) assert transition(run_dir, "planned").returncode == 0 assert transition(run_dir, "reviewed").returncode == 0 draft_path = run_dir / "07_draft.md" draft_path.write_text( draft_path.read_text(encoding="utf-8") + "\n초기화 뒤 변경된 문장입니다.\n", encoding="utf-8", ) write_reviews(run_dir) lint_checkpoint(run_dir, review=True) result = run_cli("verify_run.py", "--run-dir", run_dir) assert result.returncode == 1 report = read_json(run_dir / "09_final_report.json") immutable_check = next( item for item in report["checks"] if item["id"] == "review-draft-immutable" ) assert immutable_check["status"] == "fail" assert read_json(run_dir / "00_run.json")["status"] == "hold_for_review" def test_final_checkpoint_requires_exact_copy_and_canonical_pass_lint( tmp_path: Path, ) -> None: mismatch_root = tmp_path / "mismatch" mismatch_root.mkdir() mismatch_dir = init_run(mismatch_root, route="light") prepare_light_publish(mismatch_dir) assert transition(mismatch_dir, "planned").returncode == 0 assert transition(mismatch_dir, "drafted").returncode == 0 (mismatch_dir / "final.md").write_text("different\n", encoding="utf-8") mismatch = transition(mismatch_dir, "finalized") assert mismatch.returncode == 2 assert "byte-identical" in mismatch.stderr assert read_json(mismatch_dir / "00_run.json")["status"] == "drafted" forged_root = tmp_path / "forged" forged_root.mkdir() forged_dir = init_run(forged_root, route="light") prepare_light_publish(forged_dir) assert transition(forged_dir, "planned").returncode == 0 assert transition(forged_dir, "drafted").returncode == 0 lint_report = read_json(forged_dir / "08_lint.json") lint_report["limitations"].append("forged limitation") write_json(forged_dir / "08_lint.json", lint_report) forged = transition(forged_dir, "finalized") assert forged.returncode == 2 assert "canonical lint" in forged.stderr assert read_json(forged_dir / "00_run.json")["status"] == "drafted" def test_revise_final_checkpoint_rejects_drifted_original_draft( tmp_path: Path, ) -> None: run_dir = init_run( tmp_path, route="light", mode="revise", with_draft=True, ) prepare_light_publish(run_dir) assert transition(run_dir, "planned").returncode == 0 assert transition(run_dir, "drafted").returncode == 0 manifest = read_json(run_dir / "00_run.json") original_path = Path(manifest["inputs"]["draft"]["resolved_path"]) original_path.write_text( original_path.read_text(encoding="utf-8") + "\n원본이 나중에 바뀜\n", encoding="utf-8", ) rejected = transition(run_dir, "finalized") assert rejected.returncode == 2 assert "run 초기화" in rejected.stderr assert "SHA-256" in rejected.stderr assert read_json(run_dir / "00_run.json")["status"] == "drafted" def test_failure_statuses_are_terminal(tmp_path: Path) -> None: run_dir = init_run(tmp_path, route="light") assert transition(run_dir, "failed").returncode == 0 rejected = transition(run_dir, "hold_for_review") assert rejected.returncode == 2 assert "허용되지 않은 status 전이" in rejected.stderr manifest = read_json(run_dir / "00_run.json") assert manifest["status"] == "failed" assert [entry["to"] for entry in manifest["history"]] == [ "initialized", "failed", ]