Files
document-haness/tests/test_update_run.py
T

952 lines
33 KiB
Python

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",
]