952 lines
33 KiB
Python
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",
|
|
]
|