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,358 @@
#!/usr/bin/env python3
"""Create a collision-safe technical-doc-flow run directory."""
from __future__ import annotations
import argparse
import ctypes
import errno
import os
import re
import shutil
import sys
import tempfile
from datetime import date
from pathlib import Path
from typing import Any
from harness_common import (
DEFAULT_CONTRACT_PATH,
InputError,
atomic_write_bytes,
atomic_write_json,
atomic_write_text,
decode_utf8,
DEFAULT_RULES_PATH,
json_text,
load_rules,
snapshot_file,
sha256_file,
sha256_text,
utc_now,
)
from lint_document import mask_raw_html_blocks, parse_headings, scan_markdown_visibility
ROUTES = ("auto", "light", "standard", "deep")
KINDS = ("explanation", "decision", "how-to", "reference")
MODES = ("write", "revise", "review")
AT_FDCWD = -100
RENAME_NOREPLACE = 1
class RunDestinationOccupied(FileExistsError):
"""Raised when another actor publishes the selected run id first."""
def publish_directory_noreplace(source: Path, destination: Path) -> None:
"""Atomically publish *source* without replacing any destination entry."""
if not sys.platform.startswith("linux"):
raise InputError(
"atomic no-clobber run publish는 현재 Linux에서만 지원됩니다."
)
try:
renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
except AttributeError as exc:
raise InputError(
"이 시스템에는 atomic no-clobber run publish에 필요한 renameat2가 없습니다."
) from exc
renameat2.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
renameat2.restype = ctypes.c_int
result = renameat2(
AT_FDCWD,
os.fsencode(source),
AT_FDCWD,
os.fsencode(destination),
RENAME_NOREPLACE,
)
if result == 0:
return
error_number = ctypes.get_errno()
if error_number in {errno.EEXIST, errno.ENOTEMPTY}:
raise RunDestinationOccupied(str(destination))
unsupported_errors = {errno.EINVAL, errno.ENOSYS}
if hasattr(errno, "EOPNOTSUPP"):
unsupported_errors.add(errno.EOPNOTSUPP)
if hasattr(errno, "ENOTSUP"):
unsupported_errors.add(errno.ENOTSUP)
if error_number in unsupported_errors:
raise InputError(
"이 파일시스템은 atomic no-clobber run publish를 지원하지 않습니다."
)
raise OSError(error_number, os.strerror(error_number), str(destination))
def count_headings(markdown: str) -> int:
_, reader_visible, _, _ = scan_markdown_visibility(markdown)
return len(parse_headings(mask_raw_html_blocks(reader_visible)))
def measure_route_inputs(texts: list[str], source_count: int) -> dict[str, int]:
"""Measure every UTF-8 input that can increase document complexity."""
return {
"total_chars": sum(len(text) for text in texts),
"source_count": source_count,
"total_headings": sum(count_headings(text) for text in texts),
}
def choose_route(
requested: str,
*,
mode: str,
has_draft: bool,
metrics: dict[str, int],
rules: dict[str, Any],
) -> tuple[str, str]:
chars = metrics["total_chars"]
source_count = metrics["source_count"]
headings = metrics["total_headings"]
metric_text = f"chars={chars}, sources={source_count}, headings={headings}"
if requested != "auto":
return requested, f"사용자가 {requested} 경로를 명시했습니다({metric_text})."
route_rules = rules["thresholds"]["route"]
deep = route_rules["deep"]
if (
chars >= int(deep["min_input_chars"])
or source_count >= int(deep["min_sources"])
or headings >= int(deep["min_headings"])
):
return (
"deep",
f"입력 규모가 deep 임계에 도달했습니다({metric_text}).",
)
light = route_rules["light"]
light_allowed = has_draft if light.get("requires_existing_draft", True) else True
if (
mode != "write"
and light_allowed
and chars <= int(light["max_input_chars"])
and source_count <= int(light["max_sources"])
and headings <= int(light["max_headings"])
):
return (
"light",
f"기존 draft가 있고 light 임계 안입니다({metric_text}).",
)
return (
"standard",
f"새 문서는 최소 standard이며 현재 deep 임계 미만입니다({metric_text}).",
)
def reserve_run(workspace: Path, day: str) -> tuple[str, Path, Path]:
for sequence in range(1, 10000):
run_id = f"{day}-{sequence:03d}"
final_path = workspace / run_id
reservation = workspace / f".{run_id}.reserve"
if os.path.lexists(final_path):
continue
try:
descriptor = os.open(reservation, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
continue
with os.fdopen(descriptor, "w", encoding="ascii") as stream:
stream.write(str(os.getpid()))
if os.path.lexists(final_path):
reservation.unlink(missing_ok=True)
continue
return run_id, final_path, reservation
raise InputError(f"{day} 날짜에 사용 가능한 run sequence가 없습니다.")
def create_run(args: argparse.Namespace) -> tuple[Path, str, str]:
rules_path = (Path(args.rules) if args.rules else DEFAULT_RULES_PATH).expanduser().resolve(
strict=True
)
rules_sha256 = sha256_file(rules_path)
rules = load_rules(rules_path)
if sha256_file(rules_path) != rules_sha256:
raise InputError("quality rules가 읽는 동안 변경되었습니다.")
contract_path = DEFAULT_CONTRACT_PATH.resolve(strict=True)
contract_sha256 = sha256_file(contract_path)
brief_path = Path(args.brief)
brief_inventory, brief_bytes = snapshot_file(
brief_path, "brief", args.brief, item_id="brief"
)
brief_text = decode_utf8(brief_bytes, "brief")
draft_inventory: dict[str, Any] | None = None
draft_bytes: bytes | None = None
draft_text: str | None = None
if args.draft:
draft_inventory, draft_bytes = snapshot_file(
Path(args.draft), "draft", args.draft, item_id="draft"
)
draft_text = decode_utf8(draft_bytes, "draft")
source_inventories: list[dict[str, Any]] = []
source_texts: list[str] = []
for index, value in enumerate(args.source, start=1):
inventory, source_bytes = snapshot_file(
Path(value), "source", value, item_id=f"source-{index:03d}"
)
source_inventories.append(inventory)
source_texts.append(decode_utf8(source_bytes, f"source-{index:03d}"))
mode = args.mode or ("revise" if draft_inventory else "write")
if mode == "revise" and draft_inventory is None:
raise InputError("revise 모드는 --draft 파일이 필요합니다.")
if mode in {"revise", "review"} and draft_inventory is None:
raise InputError(f"{mode} 모드는 --draft 파일이 필요합니다.")
if draft_text is None:
input_text = brief_text
else:
input_text = (
"<!-- technical-doc-flow:brief:start -->\n"
f"{brief_text}"
+ ("" if brief_text.endswith("\n") else "\n")
+ "<!-- technical-doc-flow:brief:end -->\n\n"
+ "<!-- technical-doc-flow:draft:start -->\n"
+ draft_text
+ ("" if draft_text.endswith("\n") else "\n")
+ "<!-- technical-doc-flow:draft:end -->\n"
)
route_texts = [brief_text, *([draft_text] if draft_text is not None else []), *source_texts]
route_metrics = measure_route_inputs(route_texts, len(source_inventories))
route, route_reason = choose_route(
args.route,
mode=mode,
has_draft=draft_inventory is not None,
metrics=route_metrics,
rules=rules,
)
workspace = Path(args.workspace).expanduser().resolve()
if workspace.exists() and not workspace.is_dir():
raise InputError(f"workspace가 디렉터리가 아닙니다: {workspace}")
try:
workspace.mkdir(parents=True, exist_ok=True)
except (OSError, TypeError, ValueError, KeyError) as exc:
raise InputError(f"workspace를 만들 수 없습니다: {workspace}: {exc}") from exc
day = args.date or date.today().isoformat()
try:
parsed_day = date.fromisoformat(day)
except ValueError as exc:
raise InputError("--date 값은 유효한 YYYY-MM-DD 날짜여야 합니다.") from exc
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day) or parsed_day.isoformat() != day:
raise InputError("--date 값은 YYYY-MM-DD 형식이어야 합니다.")
sources = {
"schema_version": "1.0",
"brief": brief_inventory,
"draft": draft_inventory,
"sources": source_inventories,
}
sources_manifest_sha256 = sha256_text(json_text(sources))
while True:
run_id, final_path, reservation = reserve_run(workspace, day)
stage: Path | None = None
try:
stage = Path(tempfile.mkdtemp(prefix=f".{run_id}-", dir=workspace))
now = utc_now()
manifest = {
"schema_version": "1.0",
"run_id": run_id,
"created_at": now,
"updated_at": now,
"mode": mode,
"document_kind": args.kind,
"kind_reason": args.kind_reason
or "오케스트레이터가 사용자 목적을 바탕으로 --kind를 명시했습니다.",
"audience": args.audience,
"route_requested": args.route,
"route_hint": route,
"route_reason": route_reason,
"route_metrics": route_metrics,
"status": "initialized",
"error": None,
"contract_sha256": contract_sha256,
"rules_version": rules["rules_version"],
"rules_sha256": rules_sha256,
"omissions": [],
"inputs": {
"brief": brief_inventory,
"draft": draft_inventory,
"source_count": len(source_inventories),
"brief_sha256": brief_inventory["sha256"],
"draft_sha256": draft_inventory["sha256"] if draft_inventory else None,
"sources_manifest_sha256": sources_manifest_sha256,
"input_sha256": sha256_text(input_text),
},
"history": [
{
"at": now,
"from": None,
"to": "initialized",
"reason": "init_run",
"error": None,
}
],
}
atomic_write_json(stage / "00_run.json", manifest)
atomic_write_text(stage / "01_input.md", input_text)
atomic_write_json(stage / "01_sources.json", sources)
if mode == "review":
if draft_bytes is None:
raise InputError("review 모드의 immutable 07_draft.md snapshot이 없습니다.")
atomic_write_bytes(stage / "07_draft.md", draft_bytes)
publish_directory_noreplace(stage, final_path)
except RunDestinationOccupied:
if stage is not None:
shutil.rmtree(stage, ignore_errors=True)
continue
except BaseException:
if stage is not None:
shutil.rmtree(stage, ignore_errors=True)
raise
finally:
reservation.unlink(missing_ok=True)
return final_path, route, mode
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--brief", required=True, help="요청 brief Markdown/text 파일")
value.add_argument("--draft", help="수정할 기존 Markdown draft")
value.add_argument("--source", nargs="+", action="extend", default=[], help="참고 source 파일")
value.add_argument("--audience", help="주 독자 설명")
value.add_argument("--kind", choices=KINDS, required=True)
value.add_argument("--kind-reason")
value.add_argument("--route", choices=ROUTES, default="auto")
value.add_argument("--mode", choices=MODES)
value.add_argument("--workspace", default="_workspace")
value.add_argument("--rules", help=argparse.SUPPRESS)
value.add_argument("--date", help=argparse.SUPPRESS)
return value
def main(argv: list[str] | None = None) -> int:
try:
args = parser().parse_args(argv)
run_path, route, mode = create_run(args)
except InputError as exc:
print(f"input error: {exc}", file=sys.stderr)
return 2
except OSError as exc:
print(f"input error: 실행 디렉터리를 만들 수 없습니다: {exc}", file=sys.stderr)
return 2
print(f"{run_path}\troute={route}\tmode={mode}")
return 0
if __name__ == "__main__":
raise SystemExit(main())