init: document-haness 설계

This commit is contained in:
DongHyeonka
2026-07-23 17:52:22 +09:00
parent 993788c14e
commit d6f78f92a0
127 changed files with 20099 additions and 1 deletions
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Reassemble split-document chunks after verifying the immutable inputs."""
from __future__ import annotations
import argparse
import os
import sys
import tempfile
from pathlib import Path
from typing import Any
from harness_common import (
InputError,
atomic_write_text,
load_json,
sha256_bytes,
sha256_text,
validate_with_schema,
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="검증된 Markdown 청크를 순서대로 재조립합니다.")
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument(
"--source",
choices=("input", "rewritten"),
default="input",
help="input은 무손실 round-trip, rewritten은 에이전트가 고친 청크를 조립합니다.",
)
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def require_relative_file(base: Path, value: Any, label: str) -> Path:
if not isinstance(value, str) or not value or Path(value).is_absolute():
raise InputError(f"{label}은 manifest 기준 상대 파일 경로여야 합니다.")
candidate = (base / value).resolve()
try:
candidate.relative_to(base.resolve())
except ValueError as exc:
raise InputError(f"{label}이 manifest 디렉터리를 벗어납니다: {value}") from exc
if not candidate.is_file():
raise InputError(f"{label} 파일이 없습니다: {candidate}")
return candidate
def read_exact_utf8(path: Path) -> tuple[bytes, str]:
try:
data = path.read_bytes()
return data, data.decode("utf-8")
except (OSError, UnicodeError) as exc:
raise InputError(f"UTF-8 청크를 읽을 수 없습니다: {path}: {exc}") from exc
def validate_manifest_shape(manifest: dict[str, Any]) -> list[dict[str, Any]]:
validate_with_schema(manifest, "chunk-manifest.schema.json", "chunk manifest")
if manifest.get("schema_version") != "1.0" or manifest.get("tool") != "split_document":
raise InputError("지원하지 않는 chunk manifest입니다.")
if manifest.get("offset_unit") != "unicode_codepoint" or manifest.get("self_check") is not True:
raise InputError("chunk manifest self-check 계약이 유효하지 않습니다.")
chunks = manifest.get("chunks")
if not isinstance(chunks, list) or not chunks:
raise InputError("chunk manifest의 chunks가 비어 있습니다.")
if not isinstance(manifest.get("source"), dict):
raise InputError("chunk manifest에 source 객체가 필요합니다.")
return chunks
def validate_input_chunks(base: Path, manifest: dict[str, Any]) -> list[str]:
chunks = validate_manifest_shape(manifest)
contents: list[str] = []
expected_start = 0
for expected_index, chunk in enumerate(chunks, start=1):
if not isinstance(chunk, dict) or chunk.get("index") != expected_index:
raise InputError(f"청크 index가 연속적이지 않습니다: expected={expected_index}")
start = chunk.get("start_offset")
end = chunk.get("end_offset")
count = chunk.get("char_count")
if not all(isinstance(value, int) and not isinstance(value, bool) for value in (start, end, count)):
raise InputError(f"청크 {expected_index}의 offset/count 형식이 잘못되었습니다.")
if start != expected_start or end < start or count != end - start:
raise InputError(f"청크 {expected_index}의 offset이 연속적이지 않습니다.")
path = require_relative_file(base, chunk.get("input_file"), f"chunk {expected_index} input_file")
_, text = read_exact_utf8(path)
if len(text) != count or sha256_text(text) != chunk.get("sha256"):
raise InputError(f"청크 {expected_index} 입력 hash 또는 길이가 달라졌습니다.")
contents.append(text)
expected_start = end
joined = "".join(contents)
source = manifest["source"]
if expected_start != source.get("char_count"):
raise InputError("마지막 청크 offset이 source char_count와 다릅니다.")
expected_hash = source.get("sha256")
if sha256_text(joined) != expected_hash or manifest.get("round_trip_sha256") != expected_hash:
raise InputError("입력 청크의 round-trip hash가 원문과 다릅니다.")
if len(joined.encode("utf-8")) != source.get("size_bytes"):
raise InputError("입력 청크의 byte 길이가 원문과 다릅니다.")
return contents
def rewritten_chunks(base: Path, chunks: list[dict[str, Any]]) -> list[str]:
result: list[str] = []
for index, chunk in enumerate(chunks, start=1):
path = require_relative_file(
base, chunk.get("rewritten_file"), f"chunk {index} rewritten_file"
)
_, text = read_exact_utf8(path)
result.append(text)
return result
def write_text_no_clobber(path: Path, value: str) -> None:
"""Publish a regular file atomically only when the target name is absent."""
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream:
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, path)
except FileExistsError as exc:
raise InputError(f"출력 파일이 이미 존재합니다(--force로 교체): {path}") from exc
finally:
temporary.unlink(missing_ok=True)
def manifest_member_path(base: Path, value: Any, label: str) -> Path:
if not isinstance(value, str) or not value or Path(value).is_absolute():
raise InputError(f"{label}은 manifest 기준 상대 파일 경로여야 합니다.")
candidate = (base / value).resolve(strict=False)
try:
candidate.relative_to(base.resolve())
except ValueError as exc:
raise InputError(f"{label}이 manifest 디렉터리를 벗어납니다: {value}") from exc
return candidate
def reject_output_alias(
output: Path,
manifest_path: Path,
base: Path,
manifest: dict[str, Any],
) -> None:
protected = [manifest_path.resolve()]
source_path = manifest.get("source", {}).get("resolved_path")
if isinstance(source_path, str) and source_path:
protected.append(Path(source_path).expanduser().resolve(strict=False))
for index, chunk in enumerate(manifest["chunks"], start=1):
for field in ("input_file", "rewritten_file"):
protected.append(
manifest_member_path(base, chunk.get(field), f"chunk {index} {field}")
)
resolved_output = output.resolve(strict=False)
for path in protected:
same_name = resolved_output == path.resolve(strict=False)
same_inode = False
if output.exists() and path.exists():
try:
same_inode = os.path.samefile(output, path)
except OSError:
same_inode = False
if same_name or same_inode:
raise InputError(f"출력은 manifest/source/chunk 파일을 덮을 수 없습니다: {path}")
def main() -> int:
args = parse_args()
try:
manifest_path = args.manifest.expanduser().resolve(strict=True)
manifest = load_json(manifest_path)
base = manifest_path.parent
original = validate_input_chunks(base, manifest)
selected = original if args.source == "input" else rewritten_chunks(base, manifest["chunks"])
raw_output = args.output.expanduser()
output = Path(os.path.abspath(raw_output))
reject_output_alias(output, manifest_path, base, manifest)
if args.force:
atomic_write_text(output, "".join(selected))
else:
write_text_no_clobber(output, "".join(selected))
if args.source == "input":
written = output.read_bytes()
if sha256_bytes(written) != manifest["source"]["sha256"]:
raise InputError("재조립 파일의 최종 hash self-check가 실패했습니다.")
print(str(output))
return 0
except (InputError, KeyError, TypeError, OSError) as exc:
print(f"input error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())