Files
document-haness/skills/technical-doc-flow/scripts/verify_run.py
T

1818 lines
72 KiB
Python

#!/usr/bin/env python3
"""Verify route artifacts, schemas, hashes, lint verdict, and run status."""
from __future__ import annotations
import argparse
import sys
import unicodedata
from collections import Counter
from pathlib import Path
from typing import Any, Callable
from harness_common import (
DEFAULT_CONTRACT_PATH,
DEFAULT_RULES_PATH,
InputError,
decode_utf8,
json_text,
load_json,
load_rules,
paths_alias,
prepare_report_output,
publish_report_json,
read_text,
require_file,
require_regular_nonsymlink,
rule_index,
run_lock,
schema_version,
sha256_file,
sha256_text,
utc_now,
validate_with_schema,
)
from update_run import transition_manifest, validate_run_history
from init_run import choose_route, measure_route_inputs
from lint_document import lint as evaluate_lint
JSON_ARTIFACTS = {
"00_run.json",
"01_sources.json",
"02_reader_contract.json",
"03_evidence_map.json",
"04_logic_map.json",
"05_term_ledger.json",
"08_logic_review.json",
"08_reader_review.json",
"08_lint.json",
"09_final_report.json",
}
ARTIFACT_SCHEMAS = {
"00_run.json": "run.schema.json",
"01_sources.json": "sources.schema.json",
"02_reader_contract.json": "reader-contract.schema.json",
"03_evidence_map.json": "evidence-map.schema.json",
"04_logic_map.json": "logic-map.schema.json",
"05_term_ledger.json": "term-ledger.schema.json",
"08_logic_review.json": "review.schema.json",
"08_reader_review.json": "review.schema.json",
"08_lint.json": "lint-report.schema.json",
"09_final_report.json": "final-report.schema.json",
}
def check(checks: list[dict[str, Any]], check_id: str, ok: bool, message: str, artifact: str | None = None) -> None:
checks.append(
{
"id": check_id,
"status": "pass" if ok else "fail",
"message": message,
"artifact": artifact,
}
)
def fields(value: dict[str, Any], names: tuple[str, ...], label: str) -> None:
missing = [name for name in names if name not in value]
if missing:
raise InputError(f"{label} 필수 필드가 없습니다: {', '.join(missing)}")
def nonempty_string(value: Any, label: str) -> None:
if not isinstance(value, str) or not value.strip():
raise InputError(f"{label}는 비어 있지 않은 문자열이어야 합니다.")
def string_list(value: Any, label: str) -> None:
if not isinstance(value, list) or any(
not isinstance(item, str) or not item.strip() for item in value
):
raise InputError(f"{label}는 비어 있지 않은 문자열 배열이어야 합니다.")
def normalized_label(value: str) -> str:
return unicodedata.normalize("NFKC", " ".join(value.split())).casefold()
def validate_run(value: dict[str, Any]) -> None:
fields(
value,
(
"schema_version",
"run_id",
"mode",
"document_kind",
"route_requested",
"route_hint",
"route_reason",
"route_metrics",
"status",
"error",
"contract_sha256",
"rules_version",
"rules_sha256",
"omissions",
"inputs",
"history",
),
"00_run.json",
)
if value["mode"] not in {"write", "revise", "review"}:
raise InputError("00_run.json mode가 잘못되었습니다.")
if value["route_hint"] not in {"light", "standard", "deep"}:
raise InputError("00_run.json route_hint가 잘못되었습니다.")
if value["route_requested"] not in {"auto", "light", "standard", "deep"}:
raise InputError("00_run.json route_requested가 잘못되었습니다.")
nonempty_string(value["route_reason"], "00_run.json route_reason")
metrics = value["route_metrics"]
if not isinstance(metrics, dict):
raise InputError("00_run.json route_metrics는 객체여야 합니다.")
fields(
metrics,
("total_chars", "source_count", "total_headings"),
"00_run.json route_metrics",
)
if any(
not isinstance(metrics[name], int) or isinstance(metrics[name], bool) or metrics[name] < 0
for name in ("total_chars", "source_count", "total_headings")
):
raise InputError("00_run.json route_metrics 값은 0 이상의 정수여야 합니다.")
allowed_status = {
"initialized",
"evidence_ready",
"planned",
"drafted",
"reviewed",
"finalized",
"verified",
"hold_for_review",
"failed",
"incomplete",
}
if value["status"] not in allowed_status:
raise InputError("00_run.json status가 잘못되었습니다.")
if value["document_kind"] not in {"explanation", "decision", "how-to", "reference"}:
raise InputError("00_run.json document_kind가 잘못되었습니다.")
if not isinstance(value["omissions"], list):
raise InputError("00_run.json omissions는 배열이어야 합니다.")
if not isinstance(value["history"], list) or not value["history"]:
raise InputError("00_run.json history는 비어 있지 않은 배열이어야 합니다.")
validate_run_history(value)
inputs = value["inputs"]
if not isinstance(inputs, dict):
raise InputError("00_run.json inputs는 객체여야 합니다.")
fields(
inputs,
(
"brief",
"draft",
"source_count",
"brief_sha256",
"draft_sha256",
"sources_manifest_sha256",
"input_sha256",
),
"00_run.json inputs",
)
def validate_sources(value: dict[str, Any]) -> None:
fields(value, ("schema_version", "brief", "draft", "sources"), "01_sources.json")
if not isinstance(value["sources"], list):
raise InputError("01_sources.json sources는 배열이어야 합니다.")
values = [value["brief"], value["draft"], *value["sources"]]
ids: set[str] = set()
for index, item in enumerate(values):
if item is None:
continue
if not isinstance(item, dict):
raise InputError(f"01_sources.json inventory[{index}]는 객체여야 합니다.")
fields(item, ("id", "path", "sha256"), f"01_sources.json inventory[{index}]")
if item["id"] in ids:
raise InputError(f"source id가 중복됩니다: {item['id']}")
ids.add(item["id"])
if not isinstance(item["sha256"], str) or len(item["sha256"]) != 64:
raise InputError(f"source sha256이 잘못되었습니다: {item['id']}")
def validate_reader(value: dict[str, Any]) -> None:
fields(
value,
(
"schema_version",
"document_kind",
"primary_audience",
"purpose",
"reader_question",
"reader_outcome",
"prerequisites",
"assumed_known",
"must_explain",
"non_goals",
),
"02_reader_contract.json",
)
for name in ("primary_audience", "purpose", "reader_question", "reader_outcome"):
nonempty_string(value[name], f"reader contract {name}")
for name in ("prerequisites", "assumed_known", "must_explain", "non_goals"):
string_list(value[name], f"reader contract {name}")
def source_ids(sources: dict[str, Any]) -> set[str]:
values = [sources.get("brief"), sources.get("draft"), *sources.get("sources", [])]
return {item["id"] for item in values if isinstance(item, dict) and isinstance(item.get("id"), str)}
def validate_evidence(value: dict[str, Any], known_sources: set[str]) -> None:
fields(value, ("schema_version", "claims"), "03_evidence_map.json")
if not isinstance(value["claims"], list):
raise InputError("evidence claims는 배열이어야 합니다.")
claims: dict[str, dict[str, Any]] = {}
for index, claim in enumerate(value["claims"]):
if not isinstance(claim, dict):
raise InputError(f"claim[{index}]는 객체여야 합니다.")
fields(
claim,
(
"id",
"statement",
"status",
"source_ids",
"source_locations",
"does_not_support",
"load_bearing",
),
f"claim[{index}]",
)
nonempty_string(claim["id"], f"claim[{index}].id")
nonempty_string(claim["statement"], f"claim[{index}].statement")
if claim["id"] in claims:
raise InputError(f"claim id가 중복됩니다: {claim['id']}")
if claim["status"] not in {
"source_backed",
"observed",
"measured",
"derived",
"recommended",
"assumption",
}:
raise InputError(f"claim status가 잘못되었습니다: {claim['id']}")
string_list(claim["source_ids"], f"claim {claim['id']}.source_ids")
string_list(claim["does_not_support"], f"claim {claim['id']}.does_not_support")
locations = claim["source_locations"]
if not isinstance(locations, list):
raise InputError(f"claim {claim['id']}.source_locations는 배열이어야 합니다.")
location_pairs: set[tuple[str, str]] = set()
location_source_ids: set[str] = set()
for location_index, location in enumerate(locations):
if not isinstance(location, dict):
raise InputError(
f"claim {claim['id']}.source_locations[{location_index}]는 객체여야 합니다."
)
fields(
location,
("source_id", "locator"),
f"claim {claim['id']}.source_locations[{location_index}]",
)
nonempty_string(
location["source_id"],
f"claim {claim['id']}.source_locations[{location_index}].source_id",
)
nonempty_string(
location["locator"],
f"claim {claim['id']}.source_locations[{location_index}].locator",
)
pair = (location["source_id"], location["locator"])
if pair in location_pairs:
raise InputError(f"claim {claim['id']}의 source locator가 중복됩니다: {pair}")
location_pairs.add(pair)
location_source_ids.add(location["source_id"])
if not isinstance(claim["load_bearing"], bool):
raise InputError(f"claim {claim['id']}.load_bearing은 boolean이어야 합니다.")
unknown = set(claim["source_ids"]) - known_sources
unknown_locations = location_source_ids - known_sources
if unknown or unknown_locations:
raise InputError(
f"claim {claim['id']}가 모르는 source id를 참조합니다: "
f"{sorted(unknown | unknown_locations)}"
)
if location_source_ids != set(claim["source_ids"]):
raise InputError(
f"claim {claim['id']}의 source_ids와 source_locations가 일치하지 않습니다."
)
claims[claim["id"]] = claim
for claim in claims.values():
status = claim["status"]
load_bearing = claim["load_bearing"]
if status in {"source_backed", "observed", "measured"} and (
not claim["source_ids"] or not claim["source_locations"]
):
raise InputError(
f"{status} claim {claim['id']}에는 source_ids와 source_locations가 필요합니다."
)
if load_bearing and status in {"source_backed", "observed", "measured", "derived"} and not claim["does_not_support"]:
raise InputError(f"load-bearing fact claim {claim['id']}에는 does_not_support가 필요합니다.")
if status == "measured":
for field_name in ("method", "environment", "result"):
nonempty_string(claim.get(field_name), f"measured claim {claim['id']}.{field_name}")
if not claim["does_not_support"]:
raise InputError(f"measured claim {claim['id']}에는 does_not_support가 필요합니다.")
if status == "derived":
premise_ids = claim.get("premise_ids")
string_list(premise_ids, f"derived claim {claim['id']}.premise_ids")
if not premise_ids:
raise InputError(f"derived claim {claim['id']}에는 premise_ids가 필요합니다.")
unknown = set(premise_ids) - set(claims)
if unknown or claim["id"] in premise_ids:
raise InputError(f"derived claim {claim['id']}의 premise_ids가 잘못되었습니다.")
if status in {"recommended", "assumption"}:
nonempty_string(claim.get("label"), f"claim {claim['id']}.label")
visiting: set[str] = set()
visited: set[str] = set()
def visit(claim_id: str, trail: list[str]) -> None:
if claim_id in visiting:
cycle_start = trail.index(claim_id) if claim_id in trail else 0
cycle = [*trail[cycle_start:], claim_id]
raise InputError(f"derived claim premise cycle이 있습니다: {' -> '.join(cycle)}")
if claim_id in visited:
return
visiting.add(claim_id)
trail.append(claim_id)
claim = claims[claim_id]
if claim["status"] == "derived":
for premise_id in claim.get("premise_ids", []):
visit(premise_id, trail)
trail.pop()
visiting.remove(claim_id)
visited.add(claim_id)
for claim_id in claims:
visit(claim_id, [])
def validate_logic(value: dict[str, Any]) -> None:
fields(
value,
(
"schema_version",
"title",
"document_kind",
"core_claim",
"sections",
"closure",
),
"04_logic_map.json",
)
nonempty_string(value["title"], "logic map title")
nonempty_string(value["core_claim"], "logic map core_claim")
nonempty_string(value["closure"], "logic map closure")
if not isinstance(value["sections"], list) or not value["sections"]:
raise InputError("logic map sections는 비어 있지 않은 배열이어야 합니다.")
ids: set[str] = set()
required = (
"id",
"heading",
"role",
"depends_on",
"reader_state_before",
"question",
"answer_plain",
"claim_ids",
"new_terms",
"transition_to",
"reader_state_after",
)
for index, section in enumerate(value["sections"]):
if not isinstance(section, dict):
raise InputError(f"logic section[{index}]는 객체여야 합니다.")
fields(section, required, f"logic section[{index}]")
if section["id"] in ids:
raise InputError(f"logic section id가 중복됩니다: {section['id']}")
ids.add(section["id"])
for name in (
"id",
"heading",
"role",
"reader_state_before",
"question",
"answer_plain",
"reader_state_after",
):
nonempty_string(section[name], f"logic section[{index}].{name}")
for name in ("depends_on", "claim_ids", "new_terms"):
string_list(section[name], f"logic section {section['id']}.{name}")
if index > 0 and not section["depends_on"]:
raise InputError(
f"logic section {section['id']}에는 앞선 절을 가리키는 depends_on이 필요합니다."
)
transition = section["transition_to"]
if transition is not None:
nonempty_string(transition, f"logic section {section['id']}.transition_to")
def validate_terms(value: dict[str, Any]) -> None:
fields(value, ("schema_version", "assumed_known", "budgets", "terms"), "05_term_ledger.json")
string_list(value["assumed_known"], "term ledger assumed_known")
if not isinstance(value["budgets"], dict):
raise InputError("term ledger budgets는 객체여야 합니다.")
if not isinstance(value["terms"], list):
raise InputError("term ledger terms는 배열이어야 합니다.")
ids: set[str] = set()
name_owners: dict[str, tuple[str, str]] = {}
for index, term in enumerate(value["terms"]):
if not isinstance(term, dict):
raise InputError(f"term[{index}]는 객체여야 합니다.")
fields(term, ("id", "canonical", "plain_definition", "why_needed", "aliases", "first_section", "first_use"), f"term[{index}]")
if term["id"] in ids:
raise InputError(f"term id가 중복됩니다: {term['id']}")
ids.add(term["id"])
for name in (
"id",
"canonical",
"plain_definition",
"why_needed",
"first_section",
"first_use",
):
nonempty_string(term[name], f"term[{index}].{name}")
string_list(term["aliases"], f"term {term['id']}.aliases")
for field_name, name in (
("canonical", term["canonical"]),
*(("alias", alias) for alias in term["aliases"]),
("english", term.get("english")),
("abbreviation", term.get("abbreviation")),
):
if not isinstance(name, str) or not name.strip():
continue
normalized = normalized_label(name)
previous = name_owners.get(normalized)
if previous is not None:
raise InputError(
f"용어 이름 {name!r}이 둘 이상에 배정되었습니다: "
f"{previous[0]}.{previous[1]}, {term['id']}.{field_name}"
)
name_owners[normalized] = (term["id"], field_name)
def validate_review(value: dict[str, Any]) -> None:
fields(value, ("schema_version", "review_type", "document", "inputs", "verdict", "findings"), "review")
if value["review_type"] not in {"logic", "reader"}:
raise InputError("review_type이 잘못되었습니다.")
if value["verdict"] not in {"pass", "revise", "hold_for_review"}:
raise InputError("review verdict가 잘못되었습니다.")
if not isinstance(value["findings"], list):
raise InputError("review findings는 배열이어야 합니다.")
finding_ids: set[str] = set()
blocking = 0
for finding in value["findings"]:
if not isinstance(finding, dict):
raise InputError("review finding은 객체여야 합니다.")
fields(finding, ("id", "severity", "location", "reader_impact", "suggestion"), "review finding")
if finding["id"] in finding_ids:
raise InputError(f"review finding id가 중복됩니다: {finding['id']}")
finding_ids.add(finding["id"])
if finding["severity"] not in {"critical", "high", "medium", "low"}:
raise InputError("review finding severity가 잘못되었습니다.")
if finding["severity"] in {"critical", "high"}:
blocking += 1
verdict = value["verdict"]
if verdict == "pass" and blocking:
raise InputError("review verdict=pass에는 critical/high blocking finding이 있을 수 없습니다.")
if verdict in {"revise", "hold_for_review"} and not blocking:
raise InputError(f"review verdict={verdict}에는 critical/high blocking finding이 필요합니다.")
def validate_lint(value: dict[str, Any]) -> None:
fields(value, ("schema_version", "rules_version", "rules_sha256", "tool", "verdict", "summary", "findings", "fidelity"), "08_lint.json")
if value["tool"] != "lint_document":
raise InputError("08_lint.json tool이 lint_document가 아닙니다.")
if value["verdict"] not in {"pass", "fail", "input_error"}:
raise InputError("lint verdict가 잘못되었습니다.")
def validate_lint_semantics(value: dict[str, Any], rules: dict[str, Any]) -> None:
"""Recompute every lint aggregate and gate from canonical findings."""
configured = rule_index(rules)
counts = Counter()
for index, finding in enumerate(value["findings"]):
rule_id = finding["rule_id"]
rule = configured.get(rule_id)
if rule is None:
raise InputError(f"lint finding[{index}]에 모르는 rule_id가 있습니다: {rule_id}")
expected_severity = rule["severity"]
if finding["severity"] != expected_severity:
raise InputError(
f"lint finding[{index}] severity가 현재 규칙과 다릅니다: "
f"{rule_id}={finding['severity']!r}, expected={expected_severity!r}"
)
if finding["path"] != value["document"]["path"]:
raise InputError(
f"lint finding[{index}] path가 lint 대상 document와 다릅니다."
)
counts[finding["severity"]] += 1
expected_summary = {
"errors": counts["error"],
"warnings": counts["warning"],
"info": counts["info"],
"total": len(value["findings"]),
}
if value["summary"] != expected_summary:
raise InputError(
f"lint summary가 findings 재계산 결과와 다릅니다: "
f"recorded={value['summary']!r}, expected={expected_summary!r}"
)
expected_verdict = (
"fail"
if counts["error"] > 0
or (value["fail_on"] == "warning" and counts["warning"] > 0)
else "pass"
)
if value["verdict"] != expected_verdict:
raise InputError(
f"lint verdict가 fail_on과 findings에 맞지 않습니다: "
f"recorded={value['verdict']!r}, expected={expected_verdict!r}"
)
fidelity = value["fidelity"]
by_type = fidelity["by_type"]
protected_types = {
"fenced_code",
"indented_code",
"inline_code",
"url",
"link_destination",
"number_unit",
"number_range",
"number",
"date",
"version",
"quote",
}
expected_types = protected_types if fidelity["baseline"] is not None else set()
if set(by_type) != expected_types:
raise InputError(
"lint fidelity.by_type 종류가 baseline 계약과 다릅니다: "
f"recorded={sorted(by_type)}, expected={sorted(expected_types)}"
)
for name, item in by_type.items():
if item["total"] != item["preserved"] + item["missing"]:
raise InputError(f"lint fidelity.by_type.{name} 합계가 맞지 않습니다.")
expected_total = sum(item["total"] for item in by_type.values())
expected_preserved = sum(item["preserved"] for item in by_type.values())
expected_missing = sum(item["missing"] for item in by_type.values())
if (
fidelity["protected_total"] != expected_total
or fidelity["preserved"] != expected_preserved
or fidelity["missing"] != expected_missing
or expected_total != expected_preserved + expected_missing
):
raise InputError("lint fidelity 합계가 by_type 재계산 결과와 다릅니다.")
configured_change_rate = float(
rules["thresholds"]["finalization"]["max_change_rate"]
)
if fidelity["max_finalization_change_rate"] != configured_change_rate:
raise InputError(
"lint fidelity max_finalization_change_rate가 현재 quality rules와 다릅니다."
)
def lint_report_summary(value: dict[str, Any] | None) -> dict[str, Any] | None:
if value is None or value.get("verdict") not in {"pass", "fail"}:
return None
finding_rule_ids = list(
dict.fromkeys(finding["rule_id"] for finding in value["findings"])
)
return {
"verdict": value["verdict"],
"fail_on": value["fail_on"],
"rules_version": value["rules_version"],
"rules_sha256": value["rules_sha256"],
"document_sha256": value["document"]["sha256"],
"finding_counts": value["summary"],
"finding_rule_ids": finding_rule_ids,
"fidelity": value["fidelity"],
"limitations": value.get("limitations", []),
}
def review_report_summaries(artifacts: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
summaries: list[dict[str, Any]] = []
for artifact_name in ("08_logic_review.json", "08_reader_review.json"):
review = artifacts.get(artifact_name)
if review is None:
continue
counts = Counter(finding["severity"] for finding in review["findings"])
summaries.append(
{
"artifact": artifact_name,
"review_type": review["review_type"],
"verdict": review["verdict"],
"document_sha256": review["document"]["sha256"],
"input_sha256s": review["inputs"],
"finding_counts": {
"critical": counts["critical"],
"high": counts["high"],
"medium": counts["medium"],
"low": counts["low"],
"total": len(review["findings"]),
},
"finding_ids": [finding["id"] for finding in review["findings"]],
}
)
return summaries
def review_mode_document_verdict(
lint_report: dict[str, Any] | None,
artifacts: dict[str, dict[str, Any]],
) -> str:
reviews = [
artifacts.get("08_logic_review.json"),
artifacts.get("08_reader_review.json"),
]
if (
lint_report is None
or lint_report.get("verdict") not in {"pass", "fail"}
or any(review is None for review in reviews)
):
return "not_evaluated"
review_verdicts = {review["verdict"] for review in reviews if review is not None}
if "hold_for_review" in review_verdicts:
return "not_evaluated"
if lint_report["verdict"] == "fail" or "revise" in review_verdicts:
return "revise"
return "pass"
VALIDATORS: dict[str, Callable[[dict[str, Any]], None]] = {
"00_run.json": validate_run,
"01_sources.json": validate_sources,
"02_reader_contract.json": validate_reader,
"04_logic_map.json": validate_logic,
"05_term_ledger.json": validate_terms,
"08_logic_review.json": validate_review,
"08_reader_review.json": validate_review,
"08_lint.json": validate_lint,
}
def required_artifacts(contract: dict[str, Any], manifest: dict[str, Any]) -> set[str]:
artifacts = contract.get("artifacts")
if not isinstance(artifacts, dict):
raise InputError("runtime contract artifacts가 없습니다.")
base = artifacts.get("always")
if not isinstance(base, list):
raise InputError("runtime contract artifacts.always가 배열이 아닙니다.")
mode = manifest["mode"]
route = manifest["route_hint"]
route_branch = artifacts.get(route)
if not isinstance(route_branch, list):
raise InputError(f"runtime contract artifacts.{route}가 배열이 아닙니다.")
required = set(base) | set(route_branch)
if mode == "review":
review_branch = artifacts.get("review_mode")
if not isinstance(review_branch, list):
raise InputError("runtime contract artifacts.review_mode가 배열이 아닙니다.")
required.update(review_branch)
required.discard("final.md")
return required
def artifact_universe(contract: dict[str, Any]) -> set[str]:
artifacts = contract["artifacts"]
universe: set[str] = set()
for branch_name in ("always", "light", "standard", "deep", "review_mode"):
universe.update(artifacts[branch_name])
return universe
def path_lexists(path: Path) -> bool:
"""Return true for ordinary entries and dangling symbolic links."""
return path.exists() or path.is_symlink()
def capture_paths(paths: dict[str, Path]) -> dict[str, str | None]:
snapshot: dict[str, str | None] = {}
for name, path in paths.items():
try:
snapshot[name] = sha256_file(path) if path.is_file() else None
except InputError:
snapshot[name] = None
return snapshot
def snapshot_drift(
expected: dict[str, str | None], paths: dict[str, Path]
) -> list[str]:
current = capture_paths(paths)
return sorted(name for name, digest in expected.items() if current.get(name) != digest)
def source_paths(sources: dict[str, Any]) -> dict[str, Path]:
paths: dict[str, Path] = {}
values = [sources.get("brief"), sources.get("draft"), *sources.get("sources", [])]
for item in values:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
continue
raw_path = item.get("resolved_path") or item.get("path")
if isinstance(raw_path, str):
paths[f"source:{item['id']}"] = Path(raw_path)
return paths
def verify_route_selection(
manifest: dict[str, Any],
sources: dict[str, Any],
rules: dict[str, Any],
checks: list[dict[str, Any]],
) -> None:
inventories = [sources.get("brief"), sources.get("draft"), *sources.get("sources", [])]
texts: list[str] = []
for item in inventories:
if item is None:
continue
path_value = item.get("resolved_path") or item.get("path")
if not isinstance(path_value, str):
raise InputError("route 계산 source 경로가 없습니다.")
path = require_file(Path(path_value), f"route source {item.get('id')}")
try:
data = path.read_bytes()
except OSError as exc:
raise InputError(f"route source를 읽을 수 없습니다: {path}: {exc}") from exc
texts.append(decode_utf8(data, f"route source {item.get('id')}"))
actual_metrics = measure_route_inputs(texts, len(sources.get("sources", [])))
metrics_ok = manifest.get("route_metrics") == actual_metrics
check(
checks,
"route-metrics",
metrics_ok,
"brief, draft, 모든 source를 포함한 route 규모 지표가 일치합니다."
if metrics_ok
else (
f"route metrics drift: recorded={manifest.get('route_metrics')!r}, "
f"actual={actual_metrics!r}"
),
"00_run.json",
)
expected_route, expected_reason = choose_route(
manifest["route_requested"],
mode=manifest["mode"],
has_draft=sources.get("draft") is not None,
metrics=actual_metrics,
rules=rules,
)
selection_ok = (
manifest.get("route_hint") == expected_route
and manifest.get("route_reason") == expected_reason
)
check(
checks,
"route-selection",
selection_ok,
"route 요청, 전체 입력 지표, 선택 이유가 정본 알고리즘과 일치합니다."
if selection_ok
else (
f"expected route={expected_route!r}, reason={expected_reason!r}; "
f"recorded route={manifest.get('route_hint')!r}, "
f"reason={manifest.get('route_reason')!r}"
),
"00_run.json",
)
def immutable_manifest_fields(manifest: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in manifest.items()
if key not in {"status", "error", "updated_at", "history"}
}
def verify_omissions(
run_dir: Path,
contract: dict[str, Any],
manifest: dict[str, Any],
required: set[str],
checks: list[dict[str, Any]],
) -> None:
omissions = manifest["omissions"]
universe = artifact_universe(contract)
declared = [item["artifact"] for item in omissions]
duplicates = sorted(name for name, count in Counter(declared).items() if count > 1)
unknown = sorted(set(declared) - universe)
required_omitted = sorted(set(declared) & required)
present_omitted = sorted(
name for name in set(declared) if path_lexists(run_dir / name)
)
invalid_parts: list[str] = []
if duplicates:
invalid_parts.append(f"duplicate={duplicates}")
if unknown:
invalid_parts.append(f"unknown={unknown}")
if required_omitted:
invalid_parts.append(f"required={required_omitted}")
if present_omitted:
invalid_parts.append(f"present={present_omitted}")
check(
checks,
"omissions-valid",
not invalid_parts,
"omission 선언이 유효합니다."
if not invalid_parts
else "잘못된 omission 선언: " + "; ".join(invalid_parts),
"00_run.json",
)
present = {
name
for name in universe
if (
(run_dir / name).is_file()
and not (run_dir / name).is_symlink()
and (run_dir / name).stat().st_size > 0
)
}
optional_missing = universe - required - present
undeclared = sorted(optional_missing - set(declared))
check(
checks,
"omissions-complete",
not undeclared,
"생략한 optional artifact와 이유가 모두 기록되었습니다."
if not undeclared
else f"omission 이유가 없는 optional artifact: {undeclared}",
"00_run.json",
)
def verify_source_hashes(
run_dir: Path,
manifest: dict[str, Any],
sources: dict[str, Any],
checks: list[dict[str, Any]],
) -> None:
input_path = run_dir / "01_input.md"
actual_input = sha256_file(input_path)
expected_input = manifest["inputs"]["input_sha256"]
check(checks, "hash-input", actual_input == expected_input, "01_input.md SHA256 검증", "01_input.md")
inventory = [sources.get("brief"), sources.get("draft"), *sources.get("sources", [])]
drift: list[str] = []
for item in inventory:
if item is None:
continue
resolved_value = item.get("resolved_path") or item.get("path")
try:
path = require_file(Path(resolved_value), f"source {item.get('id')}")
actual = sha256_file(path)
except InputError:
drift.append(str(item.get("id")))
continue
if actual != item.get("sha256"):
drift.append(str(item.get("id")))
check(
checks,
"hash-sources",
not drift,
"source SHA256 검증" if not drift else f"source hash drift: {', '.join(drift)}",
"01_sources.json",
)
actual_manifest_hash = sha256_text(json_text(sources))
expected_manifest_hash = manifest["inputs"]["sources_manifest_sha256"]
check(
checks,
"hash-source-set",
actual_manifest_hash == expected_manifest_hash,
"source id/hash 집합 검증",
"01_sources.json",
)
inputs = manifest["inputs"]
brief = sources.get("brief")
draft = sources.get("draft")
brief_sha = brief.get("sha256") if isinstance(brief, dict) else None
draft_sha = draft.get("sha256") if isinstance(draft, dict) else None
manifest_brief = inputs.get("brief")
manifest_draft = inputs.get("draft")
inventory_ok = (
inputs.get("brief_sha256") == brief_sha
and isinstance(manifest_brief, dict)
and manifest_brief.get("sha256") == brief_sha
and inputs.get("draft_sha256") == draft_sha
and (
(draft is None and manifest_draft is None)
or (
isinstance(draft, dict)
and isinstance(manifest_draft, dict)
and manifest_draft.get("sha256") == draft_sha
)
)
and inputs.get("source_count") == len(sources.get("sources", []))
)
check(
checks,
"hash-input-inventory",
inventory_ok,
"run input 요약과 01_sources inventory 정합성",
"00_run.json",
)
def verify(args: argparse.Namespace) -> tuple[dict[str, Any], int, dict[str, Any] | None]:
run_dir = Path(args.run_dir).expanduser().resolve(strict=True)
if not run_dir.is_dir():
raise InputError(f"run-dir은 디렉터리여야 합니다: {run_dir}")
if args.contract and not paths_alias(Path(args.contract), DEFAULT_CONTRACT_PATH):
raise InputError("custom runtime contract override는 허용되지 않습니다.")
contract_path = require_file(DEFAULT_CONTRACT_PATH, "runtime contract")
contract_sha256 = sha256_file(contract_path)
contract = load_json(contract_path)
validate_with_schema(contract, "runtime-contract.schema.json", str(contract_path))
schema_version(contract, contract_path)
if sha256_file(contract_path) != contract_sha256:
raise InputError("runtime contract가 읽는 동안 변경되었습니다.")
rules_path = require_file(
Path(args.rules) if args.rules else DEFAULT_RULES_PATH,
"quality rules",
)
rules_sha256 = sha256_file(rules_path)
rules = load_rules(rules_path)
if sha256_file(rules_path) != rules_sha256:
raise InputError("quality rules가 읽는 동안 변경되었습니다.")
manifest_path = require_regular_nonsymlink(
run_dir / "00_run.json", "00_run.json"
)
manifest = load_json(manifest_path)
validate_with_schema(manifest, "run.schema.json", str(manifest_path))
schema_version(manifest, manifest_path)
validate_run(manifest)
initial_manifest_sha256 = sha256_file(manifest_path)
initial_manifest_immutable = immutable_manifest_fields(manifest)
checks: list[dict[str, Any]] = []
contract_name = contract.get("name")
check(checks, "contract-name", contract_name == "technical-doc-flow", f"runtime contract name={contract_name!r}")
run_contract_hash_ok = manifest.get("contract_sha256") == contract_sha256
check(
checks,
"contract-sha256",
run_contract_hash_ok,
"run이 고정한 canonical runtime contract byte hash가 일치합니다.",
"00_run.json",
)
run_rules_version_ok = manifest.get("rules_version") == rules["rules_version"]
check(
checks,
"rules-version",
run_rules_version_ok,
f"run rules_version={manifest.get('rules_version')!r}, runtime={rules['rules_version']!r}",
"00_run.json",
)
run_rules_hash_ok = manifest.get("rules_sha256") == rules_sha256
check(
checks,
"rules-sha256",
run_rules_hash_ok,
"run이 고정한 quality rules byte hash와 runtime rules가 일치합니다.",
"00_run.json",
)
required = required_artifacts(contract, manifest)
universe = artifact_universe(contract)
canonical_paths = {
name: run_dir / name
for name in universe
if name not in {"00_run.json", "09_final_report.json"}
}
canonical_snapshot = capture_paths(canonical_paths)
runtime_paths = {"runtime:contract": contract_path, "runtime:rules": rules_path}
runtime_snapshot = {
"runtime:contract": contract_sha256,
"runtime:rules": rules_sha256,
}
if manifest["mode"] == "review" and path_lexists(run_dir / "final.md"):
check(
checks,
"review-no-final",
False,
"review mode에서는 final.md를 만들지 않아야 합니다.",
"final.md",
)
verify_omissions(run_dir, contract, manifest, required, checks)
output_path = Path(args.output).expanduser().resolve() if args.output else run_dir / "09_final_report.json"
artifacts: dict[str, dict[str, Any]] = {"00_run.json": manifest}
present_optional = {
name for name in universe - required if path_lexists(run_dir / name)
}
for name in sorted(required | present_optional):
path = run_dir / name
if name == "09_final_report.json" and path.resolve() == output_path.resolve():
check(checks, "artifact-final-report", True, "verifier가 final report를 생성합니다.", name)
continue
if path.is_symlink():
check(
checks,
f"artifact-{name}",
False,
"canonical artifact는 symbolic link일 수 없습니다.",
name,
)
continue
if not path.is_file() or path.stat().st_size == 0:
check(checks, f"artifact-{name}", False, "필수 산출물이 없거나 비어 있습니다.", name)
continue
presence_label = "필수" if name in required else "선택"
check(checks, f"artifact-{name}", True, f"{presence_label} 산출물이 존재합니다.", name)
if name in JSON_ARTIFACTS:
try:
value = load_json(path)
schema_version(value, path)
schema_name = ARTIFACT_SCHEMAS.get(name)
if schema_name:
validate_with_schema(value, schema_name, str(path))
validator = VALIDATORS.get(name)
if validator:
validator(value)
artifacts[name] = value
check(checks, f"schema-{name}", True, "schema_version과 필수 필드가 유효합니다.", name)
except InputError as exc:
check(checks, f"schema-{name}", False, str(exc), name)
if manifest["mode"] == "review":
review_draft_path = run_dir / "07_draft.md"
initial_draft = manifest.get("inputs", {}).get("draft")
expected_draft_hash = (
initial_draft.get("sha256") if isinstance(initial_draft, dict) else None
)
review_draft_immutable = (
isinstance(expected_draft_hash, str)
and review_draft_path.is_file()
and not review_draft_path.is_symlink()
and sha256_file(review_draft_path) == expected_draft_hash
)
check(
checks,
"review-draft-immutable",
review_draft_immutable,
"review mode의 07_draft.md가 run 초기화 때 고정한 원본과 byte-identical합니다."
if review_draft_immutable
else (
"review mode의 07_draft.md SHA-256이 초기 원본과 다릅니다: "
f"expected={expected_draft_hash!r}"
),
"07_draft.md",
)
sources = artifacts.get("01_sources.json")
source_snapshot: dict[str, str | None] = {}
current_source_paths: dict[str, Path] = {}
if sources:
current_source_paths = source_paths(sources)
inventory_values = [
sources.get("brief"),
sources.get("draft"),
*sources.get("sources", []),
]
source_snapshot = {
f"source:{item['id']}": item["sha256"]
for item in inventory_values
if isinstance(item, dict)
and isinstance(item.get("id"), str)
and isinstance(item.get("sha256"), str)
}
try:
verify_source_hashes(run_dir, manifest, sources, checks)
verify_route_selection(manifest, sources, rules, checks)
except InputError as exc:
check(checks, "hash-inputs", False, str(exc), "01_sources.json")
reader = artifacts.get("02_reader_contract.json")
logic = artifacts.get("04_logic_map.json")
terms = artifacts.get("05_term_ledger.json")
evidence = artifacts.get("03_evidence_map.json")
if reader and logic:
kind_ok = reader.get("document_kind") == manifest["document_kind"] == logic.get("document_kind")
check(checks, "document-kind", kind_ok, "run/reader/logic document_kind 정합성")
requested_audience = manifest.get("audience")
audience_ok = requested_audience is None or normalized_label(
requested_audience
) == normalized_label(reader.get("primary_audience", ""))
check(
checks,
"requested-audience",
audience_ok,
"명시적으로 요청한 audience와 reader contract primary_audience가 일치합니다."
if audience_ok
else (
f"requested audience={requested_audience!r}, "
f"reader primary_audience={reader.get('primary_audience')!r}"
),
"02_reader_contract.json",
)
if reader and terms:
assumed_ok = set(reader.get("assumed_known", [])) == set(terms.get("assumed_known", []))
check(checks, "assumed-known", assumed_ok, "reader/term assumed_known 정합성")
assumed = {
normalized_label(item)
for item in reader.get("assumed_known", [])
if isinstance(item, str)
}
must_explain = {
normalized_label(item)
for item in reader.get("must_explain", [])
if isinstance(item, str)
}
overlap = sorted(assumed & must_explain)
check(
checks,
"must-explain-disjoint",
not overlap,
"assumed_known와 must_explain의 역할이 분리되어 있습니다."
if not overlap
else f"동시에 assumed_known와 must_explain인 항목: {overlap}",
"02_reader_contract.json",
)
explainable: set[str] = set()
for term in terms.get("terms", []):
if not isinstance(term, dict):
continue
for value in (
term.get("canonical"),
*term.get("aliases", []),
term.get("english"),
term.get("abbreviation"),
):
if isinstance(value, str) and value.strip():
explainable.add(normalized_label(value))
missing_explanations = sorted(
item
for item in reader.get("must_explain", [])
if isinstance(item, str)
and normalized_label(item) not in explainable
)
check(
checks,
"must-explain-terms",
not missing_explanations,
"must_explain 항목이 assumed_known 또는 term ledger에 연결됩니다."
if not missing_explanations
else f"term ledger에 없는 must_explain 항목: {missing_explanations}",
"02_reader_contract.json",
)
if evidence and sources:
try:
validate_evidence(evidence, source_ids(sources))
check(checks, "evidence-contract", True, "claim 근거 경계가 유효합니다.", "03_evidence_map.json")
except InputError as exc:
check(checks, "evidence-contract", False, str(exc), "03_evidence_map.json")
if evidence and logic:
claim_ids = {claim["id"] for claim in evidence.get("claims", []) if isinstance(claim, dict) and "id" in claim}
referenced = {
claim_id
for section in logic.get("sections", [])
if isinstance(section, dict)
for claim_id in section.get("claim_ids", [])
}
unknown = referenced - claim_ids
check(
checks,
"logic-claims",
not unknown,
"logic claim 연결 검증" if not unknown else f"모르는 claim id: {sorted(unknown)}",
"04_logic_map.json",
)
if logic and terms:
term_values = [
term
for term in terms.get("terms", [])
if isinstance(term, dict) and isinstance(term.get("id"), str)
]
term_ids = {term["id"] for term in term_values}
term_sections: dict[str, list[str]] = {}
for section in logic.get("sections", []):
if not isinstance(section, dict) or not isinstance(section.get("id"), str):
continue
for term_id in section.get("new_terms", []):
if isinstance(term_id, str):
term_sections.setdefault(term_id, []).append(section["id"])
referenced_terms = set(term_sections)
unknown_terms = referenced_terms - term_ids
misplaced_terms = {
term["id"]: {
"first_section": term.get("first_section"),
"new_terms_sections": term_sections.get(term["id"], []),
}
for term in term_values
if term_sections.get(term["id"], []) != [term.get("first_section")]
}
terminology_ok = not unknown_terms and not misplaced_terms
check(
checks,
"logic-terms",
terminology_ok,
"각 term이 first_section의 new_terms에 정확히 한 번 연결됩니다."
if terminology_ok
else (
f"모르는 term id={sorted(unknown_terms)}; "
f"first_section 불일치={misplaced_terms}"
),
"05_term_ledger.json",
)
lint_report = artifacts.get("08_lint.json")
trusted_lint: dict[str, Any] | None = None
if lint_report:
lint_trusted = (
run_contract_hash_ok and run_rules_version_ok and run_rules_hash_ok
)
lint_verdict = lint_report.get("verdict")
if manifest["mode"] == "review":
lint_ok = lint_verdict in {"pass", "fail"}
lint_message = (
f"review 진단 lint verdict={lint_verdict!r}; pass/fail은 유효한 문서 판정입니다."
)
else:
lint_ok = lint_verdict == "pass"
lint_message = f"publish lint verdict={lint_verdict!r}; pass가 필요합니다."
check(checks, "lint-verdict", lint_ok, lint_message, "08_lint.json")
version_ok = lint_report.get("rules_version") == rules["rules_version"]
check(
checks,
"lint-rules-version",
version_ok,
"lint rules_version 정합성",
"08_lint.json",
)
lint_trusted = lint_trusted and version_ok
rules_hash_ok = lint_report.get("rules_sha256") == rules_sha256
check(
checks,
"lint-rules-sha256",
rules_hash_ok,
"lint가 사용한 quality rules byte hash가 현재 run과 일치합니다.",
"08_lint.json",
)
lint_trusted = lint_trusted and rules_hash_ok
try:
validate_lint_semantics(lint_report, rules)
check(
checks,
"lint-semantics",
True,
"lint findings, severity, summary, fidelity, verdict 재계산이 일치합니다.",
"08_lint.json",
)
except InputError as exc:
lint_trusted = False
check(checks, "lint-semantics", False, str(exc), "08_lint.json")
document_name = "07_draft.md" if manifest["mode"] == "review" else "final.md"
document_path = run_dir / document_name
if document_path.is_file() and not document_path.is_symlink():
document_record = lint_report.get("document", {})
expected_hash = document_record.get("sha256")
recorded_path = document_record.get("path")
if isinstance(recorded_path, str):
candidate = Path(recorded_path).expanduser()
if not candidate.is_absolute():
candidate = run_dir / candidate
document_path_ok = paths_alias(candidate, document_path)
else:
document_path_ok = False
document_hash_ok = expected_hash == sha256_file(document_path)
check(
checks,
"lint-document-hash",
document_path_ok and document_hash_ok,
f"lint 대상 path/hash와 {document_name} 정합성",
"08_lint.json",
)
lint_trusted = lint_trusted and document_path_ok and document_hash_ok
else:
lint_trusted = False
for artifact_name, hash_field in (
("04_logic_map.json", "logic_map_sha256"),
("05_term_ledger.json", "term_ledger_sha256"),
("02_reader_contract.json", "reader_contract_sha256"),
):
artifact_path = run_dir / artifact_name
if artifact_path.is_file() and not artifact_path.is_symlink():
input_hash_ok = lint_report.get(hash_field) == sha256_file(artifact_path)
check(
checks,
f"lint-input-hash-{artifact_name}",
input_hash_ok,
f"lint 입력 hash와 현재 {artifact_name} 정합성",
"08_lint.json",
)
lint_trusted = lint_trusted and input_hash_ok
else:
lint_trusted = False
for fidelity_name in ("baseline", "draft_baseline"):
fidelity_record = lint_report.get("fidelity", {}).get(fidelity_name)
if fidelity_record is None:
continue
recorded_path = Path(fidelity_record["path"]).expanduser()
if not recorded_path.is_absolute():
recorded_path = run_dir / recorded_path
try:
current_path = require_file(recorded_path, f"lint {fidelity_name}")
fidelity_ok = fidelity_record["sha256"] == sha256_file(current_path)
except InputError:
fidelity_ok = False
check(
checks,
f"lint-fidelity-hash-{fidelity_name}",
fidelity_ok,
f"lint fidelity {fidelity_name} path/hash 정합성",
"08_lint.json",
)
lint_trusted = lint_trusted and fidelity_ok
fidelity = lint_report.get("fidelity", {})
def fidelity_record_matches(
record: Any, expected_path: Path, expected_sha256: str
) -> bool:
if not isinstance(record, dict):
return False
recorded_path = record.get("path")
return (
isinstance(recorded_path, str)
and paths_alias(Path(recorded_path), expected_path)
and record.get("sha256") == expected_sha256
)
mode = manifest["mode"]
invocation_errors: list[str] = []
if mode in {"write", "revise"}:
draft_path = run_dir / "07_draft.md"
draft_record = fidelity.get("draft_baseline")
if not draft_path.is_file() or not fidelity_record_matches(
draft_record, draft_path, sha256_file(draft_path)
):
invocation_errors.append("draft_baseline은 현재 07_draft.md여야 합니다")
elif lint_report.get("document", {}).get("sha256") != draft_record.get(
"sha256"
):
invocation_errors.append(
"final.md는 review 대상 07_draft.md와 byte 단위로 같아야 합니다"
)
if fidelity.get("finalization_change_rate") is None:
invocation_errors.append("finalization_change_rate가 필요합니다")
elif fidelity.get("draft_baseline") is not None:
invocation_errors.append("review mode에는 draft_baseline을 사용하지 않습니다")
original_draft = manifest.get("inputs", {}).get("draft")
baseline_record = fidelity.get("baseline")
if mode == "revise":
if not isinstance(original_draft, dict) or not fidelity_record_matches(
baseline_record,
Path(original_draft.get("resolved_path", "")),
original_draft.get("sha256", ""),
):
invocation_errors.append("revise baseline은 원본 draft여야 합니다")
elif mode == "write" and baseline_record is not None:
invocation_errors.append("write mode에는 원본 baseline을 사용하지 않습니다")
elif mode == "review" and baseline_record is not None:
if not isinstance(original_draft, dict) or not fidelity_record_matches(
baseline_record,
Path(original_draft.get("resolved_path", "")),
original_draft.get("sha256", ""),
):
invocation_errors.append("review baseline은 원본 draft여야 합니다")
if manifest["route_hint"] == "deep" and lint_report.get("fail_on") != "warning":
invocation_errors.append("deep route는 fail_on=warning이어야 합니다")
invocation_ok = not invocation_errors
check(
checks,
"lint-invocation-contract",
invocation_ok,
"mode/route별 lint baseline과 severity gate가 유효합니다."
if invocation_ok
else "; ".join(invocation_errors),
"08_lint.json",
)
lint_trusted = lint_trusted and invocation_ok
if invocation_ok:
baseline_path: Path | None = None
if mode == "revise" or (
mode == "review" and baseline_record is not None
):
baseline_path = Path(original_draft["resolved_path"])
replay_args = argparse.Namespace(
document=str(document_path),
logic_map=str(run_dir / "04_logic_map.json"),
term_ledger=str(run_dir / "05_term_ledger.json"),
reader_contract=str(run_dir / "02_reader_contract.json"),
baseline=str(baseline_path) if baseline_path is not None else None,
draft_baseline=(
str(run_dir / "07_draft.md")
if mode in {"write", "revise"}
else None
),
fail_on=lint_report["fail_on"],
rules=str(rules_path),
)
try:
replay_report, _ = evaluate_lint(replay_args)
recorded_comparable = {
key: value
for key, value in lint_report.items()
if key != "generated_at"
}
replay_comparable = {
key: value
for key, value in replay_report.items()
if key != "generated_at"
}
replay_ok = recorded_comparable == replay_comparable
replay_message = (
"현재 document와 계약으로 canonical lint를 재실행한 결과가 일치합니다."
if replay_ok
else (
"저장된 lint 결과가 canonical 재실행과 다릅니다: "
f"recorded verdict={lint_report.get('verdict')!r}, "
f"rules={[item.get('rule_id') for item in lint_report.get('findings', [])]}; "
f"replayed verdict={replay_report.get('verdict')!r}, "
f"rules={[item.get('rule_id') for item in replay_report.get('findings', [])]}"
)
)
except (InputError, OSError, TypeError, ValueError, KeyError) as exc:
replay_ok = False
replay_message = f"canonical lint 재실행 실패: {exc}"
check(
checks,
"lint-canonical-replay",
replay_ok,
replay_message,
"08_lint.json",
)
lint_trusted = lint_trusted and replay_ok
if lint_trusted:
trusted_lint = lint_report
trusted_reviews: dict[str, dict[str, Any]] = {}
def current_artifact_hash(name: str) -> str | None:
path = run_dir / name
return sha256_file(path) if path.is_file() and not path.is_symlink() else None
expected_review_inputs = {
"input_sha256": current_artifact_hash("01_input.md"),
"sources_sha256": current_artifact_hash("01_sources.json"),
"reader_contract_sha256": current_artifact_hash("02_reader_contract.json"),
"evidence_map_sha256": current_artifact_hash("03_evidence_map.json"),
"logic_map_sha256": current_artifact_hash("04_logic_map.json"),
"term_ledger_sha256": current_artifact_hash("05_term_ledger.json"),
}
for review_name in ("08_logic_review.json", "08_reader_review.json"):
review = artifacts.get(review_name)
if review is not None:
expected_type = "logic" if review_name == "08_logic_review.json" else "reader"
type_ok = review.get("review_type") == expected_type
check(
checks,
f"review-type-{review_name}",
type_ok,
f"filename에 필요한 review_type={expected_type!r}",
review_name,
)
draft_path = run_dir / "07_draft.md"
document = review.get("document")
recorded_path = document.get("path") if isinstance(document, dict) else None
if isinstance(recorded_path, str):
candidate = Path(recorded_path).expanduser()
if not candidate.is_absolute():
candidate = run_dir / candidate
path_ok = candidate.resolve() == draft_path.resolve()
else:
path_ok = False
hash_ok = (
draft_path.is_file()
and not draft_path.is_symlink()
and isinstance(document, dict)
and document.get("sha256") == sha256_file(draft_path)
)
check(
checks,
f"review-document-{review_name}",
path_ok and hash_ok,
"review 대상 path/hash와 현재 07_draft.md 정합성",
review_name,
)
inputs_ok = review.get("inputs") == expected_review_inputs
check(
checks,
f"review-inputs-{review_name}",
inputs_ok,
"review 입력 hash 묶음과 현재 upstream artifact가 일치합니다.",
review_name,
)
if type_ok and path_ok and hash_ok and inputs_ok:
trusted_reviews[review_name] = review
review_verdict = review.get("verdict")
if manifest["mode"] == "review":
review_ok = review_verdict in {"pass", "revise"}
review_message = (
f"review 진단 verdict={review_verdict!r}; pass/revise는 완료된 문서 판정이며 "
"hold_for_review만 실행을 차단합니다."
)
else:
review_ok = review_verdict == "pass"
if review_verdict == "revise":
review_message = (
"review verdict='revise'; finalizer 전에 Phase 3에서 draft를 수정하고 "
"두 독립 review를 모두 다시 실행해야 합니다."
)
elif review_verdict == "hold_for_review":
review_message = (
"review verdict='hold_for_review'; 외부 입력 또는 구조적 blocker를 "
"해결하기 전에는 finalizer를 실행할 수 없습니다."
)
else:
review_message = "review verdict='pass'; blocking finding이 없습니다."
check(
checks,
f"review-verdict-{review_name}",
review_ok,
review_message,
review_name,
)
history_reviewed = any(
isinstance(entry, dict) and entry.get("to") == "reviewed"
for entry in manifest.get("history", [])
)
current_review_names = {
name
for name in ("08_logic_review.json", "08_reader_review.json")
if name in artifacts
}
if manifest["mode"] in {"write", "revise"}:
trusted_pass_reviews = {
name
for name, review in trusted_reviews.items()
if review.get("verdict") == "pass"
}
review_stage_ok = (
history_reviewed
and trusted_pass_reviews
== {"08_logic_review.json", "08_reader_review.json"}
) or (not history_reviewed and not current_review_names)
check(
checks,
"review-stage-consistency",
review_stage_ok,
"reviewed 이력과 현재 두 pass review가 서로 일치합니다."
if review_stage_ok
else (
f"history_reviewed={history_reviewed}, "
f"current_reviews={sorted(current_review_names)}, "
f"trusted_pass_reviews={sorted(trusted_pass_reviews)}"
),
"00_run.json",
)
pre_transition_drift = [
*snapshot_drift(canonical_snapshot, canonical_paths),
*snapshot_drift(runtime_snapshot, runtime_paths),
*snapshot_drift(source_snapshot, current_source_paths),
]
manifest_changed = sha256_file(manifest_path) != initial_manifest_sha256
if manifest_changed:
pre_transition_drift.append("00_run.json")
check(
checks,
"verification-snapshot-pre-transition",
not pre_transition_drift,
"검증에 사용한 파일 snapshot이 상태 전이 직전까지 유지되었습니다."
if not pre_transition_drift
else f"검증 도중 변경된 파일: {sorted(set(pre_transition_drift))}",
)
status = manifest["status"]
expected_status = "reviewed" if manifest["mode"] == "review" else {"finalized", "verified"}
status_ok = status == expected_status if isinstance(expected_status, str) else status in expected_status
check(checks, "run-status", status_ok, f"검증 전 status={status!r}", "00_run.json")
failures = [item for item in checks if item["status"] != "pass"]
verdict = "fail" if failures else "pass"
if verdict == "pass" and manifest["mode"] != "review" and status == "finalized":
try:
manifest = transition_manifest(
manifest_path,
"verified",
reason="verify_run pass",
)
check(checks, "status-transition", True, "status를 verified로 갱신했습니다.", "00_run.json")
except InputError as exc:
check(checks, "status-transition", False, str(exc), "00_run.json")
verdict = "fail"
elif verdict == "fail" and status not in {"hold_for_review", "failed", "incomplete"}:
try:
manifest = transition_manifest(
manifest_path,
"hold_for_review",
reason="verify_run failed",
)
check(checks, "status-transition", True, "status를 hold_for_review로 갱신했습니다.", "00_run.json")
except InputError as exc:
check(checks, "status-transition", False, str(exc), "00_run.json")
post_transition_drift = [
*snapshot_drift(canonical_snapshot, canonical_paths),
*snapshot_drift(runtime_snapshot, runtime_paths),
*snapshot_drift(source_snapshot, current_source_paths),
]
immutable_ok = immutable_manifest_fields(manifest) == initial_manifest_immutable
if not immutable_ok:
post_transition_drift.append("00_run.json:immutable-fields")
check(
checks,
"verification-snapshot-post-transition",
not post_transition_drift,
"검증 snapshot이 최종 상태 기록 뒤에도 유지되었습니다."
if not post_transition_drift
else f"최종 상태 기록 중 변경된 파일: {sorted(set(post_transition_drift))}",
)
if post_transition_drift and manifest.get("status") not in {
"hold_for_review",
"failed",
"incomplete",
}:
try:
manifest = transition_manifest(
manifest_path,
"hold_for_review",
reason="verification snapshot changed during final transition",
)
check(
checks,
"status-transition-after-drift",
True,
"동시 변경을 감지해 상태를 hold_for_review로 낮췄습니다.",
"00_run.json",
)
except InputError as exc:
check(
checks,
"status-transition-after-drift",
False,
str(exc),
"00_run.json",
)
if pre_transition_drift or post_transition_drift:
trusted_lint = None
trusted_reviews = {}
failures = [item for item in checks if item["status"] != "pass"]
verdict = "fail" if failures else "pass"
if manifest.get("mode") == "review" and verdict == "pass":
document_verdict = review_mode_document_verdict(trusted_lint, trusted_reviews)
elif manifest.get("mode") == "review":
document_verdict = "not_evaluated"
elif verdict == "pass":
document_verdict = "pass"
elif (
trusted_lint is not None
and trusted_lint.get("verdict") == "fail"
or any(
trusted_reviews.get(name, {}).get("verdict") == "revise"
for name in ("08_logic_review.json", "08_reader_review.json")
)
):
document_verdict = "revise"
else:
document_verdict = "not_evaluated"
report = {
"schema_version": "1.0",
"tool": "verify_run",
"generated_at": utc_now(),
"run_id": manifest.get("run_id", run_dir.name),
"route": manifest.get("route_hint", "standard"),
"mode": manifest.get("mode", "write"),
"verdict": verdict,
"document_verdict": document_verdict,
"summary": {
"passed": sum(item["status"] == "pass" for item in checks),
"failed": len(failures),
"required_artifacts": sorted(required),
"omissions": manifest.get("omissions", []),
"lint": lint_report_summary(trusted_lint),
"reviews": review_report_summaries(trusted_reviews),
"status": manifest.get("status"),
},
"checks": checks,
}
validate_with_schema(report, "final-report.schema.json", "generated 09_final_report.json")
return report, 0 if verdict == "pass" else 1, manifest
def input_error_report(args: argparse.Namespace, message: str) -> dict[str, Any]:
return {
"schema_version": "1.0",
"tool": "verify_run",
"generated_at": utc_now(),
"run_id": Path(args.run_dir).name,
"route": "standard",
"mode": "write",
"verdict": "input_error",
"document_verdict": "not_evaluated",
"summary": {
"passed": 0,
"failed": 1,
"required_artifacts": [],
"omissions": [],
"lint": None,
"reviews": [],
"status": None,
},
"checks": [{"id": "input", "status": "error", "message": message, "artifact": None}],
}
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--run-dir", required=True)
value.add_argument(
"--output",
help="canonical {run-dir}/09_final_report.json 경로만 허용",
)
value.add_argument("--contract", help="runtime-contract.json override")
value.add_argument("--rules", help=argparse.SUPPRESS)
return value
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
try:
run_dir = Path(args.run_dir).expanduser().resolve(strict=True)
if not run_dir.is_dir():
raise InputError(f"run-dir은 디렉터리여야 합니다: {run_dir}")
canonical_output = run_dir / "09_final_report.json"
requested_output = Path(args.output) if args.output else canonical_output
if not paths_alias(requested_output, canonical_output):
raise InputError(
"verify output은 canonical {run-dir}/09_final_report.json이어야 합니다."
)
protected = [
run_dir / name
for name in (
"00_run.json",
"01_input.md",
"01_sources.json",
"02_reader_contract.json",
"03_evidence_map.json",
"04_logic_map.json",
"05_term_ledger.json",
"07_draft.md",
"08_logic_review.json",
"08_reader_review.json",
"08_lint.json",
"final.md",
)
]
protected.extend(
[
Path(args.contract) if args.contract else DEFAULT_CONTRACT_PATH,
Path(args.rules) if args.rules else DEFAULT_RULES_PATH,
]
)
output = prepare_report_output(
requested_output,
protected_paths=protected,
expected_tool="verify_run",
schema_name="final-report.schema.json",
)
except (InputError, OSError, RuntimeError) as exc:
print(f"input error: {exc}", file=sys.stderr)
return 2
try:
with run_lock(run_dir):
try:
report, exit_code, _ = verify(args)
except (InputError, OSError, TypeError, ValueError, KeyError) as exc:
report = input_error_report(args, str(exc))
exit_code = 2
output_path = publish_report_json(output, report)
except (InputError, OSError, TypeError, ValueError, KeyError) as exc:
print(
f"input error: final report를 쓸 수 없습니다: {output.path}: {exc}",
file=sys.stderr,
)
return 2
print(f"{report['verdict']}: checks={len(report['checks'])} output={output_path}")
return exit_code
if __name__ == "__main__":
raise SystemExit(main())