init: document-haness 설계
This commit is contained in:
@@ -0,0 +1,951 @@
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user