783 lines
31 KiB
Python
783 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared deterministic helpers for the technical-doc-flow runtime."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import stat
|
|
import tempfile
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, timezone
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import fcntl
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
SKILL_DIR = SCRIPT_DIR.parent
|
|
DEFAULT_RULES_PATH = SKILL_DIR / "config" / "quality-rules.json"
|
|
DEFAULT_CONTRACT_PATH = SKILL_DIR / "config" / "runtime-contract.json"
|
|
DEFAULT_SCHEMA_DIR = SKILL_DIR / "schemas"
|
|
|
|
|
|
class InputError(ValueError):
|
|
"""Raised when a CLI input or artifact contract is invalid."""
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise InputError(f"UTF-8 파일을 읽을 수 없습니다: {path}: {exc}") from exc
|
|
|
|
|
|
def load_json_text(text: str, label: str) -> dict[str, Any]:
|
|
def reject_nonfinite(value: str) -> None:
|
|
raise ValueError(f"JSON 표준에 없는 숫자입니다: {value}")
|
|
|
|
try:
|
|
value = json.loads(text, parse_constant=reject_nonfinite)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
if isinstance(exc, json.JSONDecodeError):
|
|
detail = f"{exc.lineno}:{exc.colno}: {exc.msg}"
|
|
else:
|
|
detail = str(exc)
|
|
raise InputError(
|
|
f"JSON 형식이 올바르지 않습니다: {label}:{detail}"
|
|
) from exc
|
|
if not isinstance(value, dict):
|
|
raise InputError(f"JSON 최상위 값은 객체여야 합니다: {label}")
|
|
return value
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
return load_json_text(read_text(path), str(path))
|
|
|
|
|
|
class _SchemaViolation(ValueError):
|
|
"""Internal signal used while evaluating JSON Schema branches."""
|
|
|
|
|
|
def _json_equal(left: Any, right: Any) -> bool:
|
|
if isinstance(left, bool) != isinstance(right, bool):
|
|
return False
|
|
return left == right
|
|
|
|
|
|
def _type_matches(value: Any, expected: str) -> bool:
|
|
if expected == "null":
|
|
return value is None
|
|
if expected == "boolean":
|
|
return isinstance(value, bool)
|
|
if expected == "object":
|
|
return isinstance(value, dict)
|
|
if expected == "array":
|
|
return isinstance(value, list)
|
|
if expected == "string":
|
|
return isinstance(value, str)
|
|
if expected == "integer":
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
if expected == "number":
|
|
return (
|
|
isinstance(value, (int, float))
|
|
and not isinstance(value, bool)
|
|
and math.isfinite(value)
|
|
)
|
|
return False
|
|
|
|
|
|
def _resolve_local_ref(root: dict[str, Any], reference: str) -> Any:
|
|
if reference == "#":
|
|
return root
|
|
if not reference.startswith("#/"):
|
|
raise _SchemaViolation(f"지원하지 않는 외부 $ref입니다: {reference}")
|
|
current: Any = root
|
|
for raw_part in reference[2:].split("/"):
|
|
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
if not isinstance(current, dict) or part not in current:
|
|
raise _SchemaViolation(f"$ref 대상을 찾을 수 없습니다: {reference}")
|
|
current = current[part]
|
|
return current
|
|
|
|
|
|
def _schema_path(parent: str, part: str | int) -> str:
|
|
if isinstance(part, int):
|
|
return f"{parent}[{part}]"
|
|
return f"{parent}.{part}" if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_\-]*", part) else f"{parent}[{part!r}]"
|
|
|
|
|
|
def _validate_schema(value: Any, schema: Any, root: dict[str, Any], path: str) -> None:
|
|
if schema is True:
|
|
return
|
|
if schema is False:
|
|
raise _SchemaViolation(f"{path}: 허용되지 않는 값입니다.")
|
|
if not isinstance(schema, dict):
|
|
raise _SchemaViolation(f"{path}: schema가 객체 또는 boolean이 아닙니다.")
|
|
|
|
reference = schema.get("$ref")
|
|
if reference is not None:
|
|
if not isinstance(reference, str):
|
|
raise _SchemaViolation(f"{path}: $ref는 문자열이어야 합니다.")
|
|
_validate_schema(value, _resolve_local_ref(root, reference), root, path)
|
|
|
|
expected_type = schema.get("type")
|
|
if expected_type is not None:
|
|
expected_types = [expected_type] if isinstance(expected_type, str) else expected_type
|
|
if (
|
|
not isinstance(expected_types, list)
|
|
or not expected_types
|
|
or any(not isinstance(item, str) for item in expected_types)
|
|
):
|
|
raise _SchemaViolation(f"{path}: schema type 선언이 잘못되었습니다.")
|
|
if not any(_type_matches(value, item) for item in expected_types):
|
|
raise _SchemaViolation(
|
|
f"{path}: 값 형식이 {expected_types!r} 중 하나여야 합니다."
|
|
)
|
|
|
|
if "const" in schema and not _json_equal(value, schema["const"]):
|
|
raise _SchemaViolation(f"{path}: 값은 {schema['const']!r}이어야 합니다.")
|
|
if "enum" in schema:
|
|
choices = schema["enum"]
|
|
if not isinstance(choices, list) or not any(_json_equal(value, item) for item in choices):
|
|
raise _SchemaViolation(f"{path}: 허용된 enum 값이 아닙니다.")
|
|
|
|
for keyword in ("allOf", "anyOf", "oneOf"):
|
|
branches = schema.get(keyword)
|
|
if branches is None:
|
|
continue
|
|
if not isinstance(branches, list) or not branches:
|
|
raise _SchemaViolation(f"{path}: {keyword}는 비어 있지 않은 배열이어야 합니다.")
|
|
matches = 0
|
|
first_error: str | None = None
|
|
for branch in branches:
|
|
try:
|
|
_validate_schema(value, branch, root, path)
|
|
matches += 1
|
|
except _SchemaViolation as exc:
|
|
if first_error is None:
|
|
first_error = str(exc)
|
|
if keyword == "allOf" and matches != len(branches):
|
|
raise _SchemaViolation(first_error or f"{path}: allOf 조건을 만족하지 않습니다.")
|
|
if keyword == "anyOf" and matches == 0:
|
|
raise _SchemaViolation(first_error or f"{path}: anyOf 조건을 만족하지 않습니다.")
|
|
if keyword == "oneOf" and matches != 1:
|
|
raise _SchemaViolation(f"{path}: oneOf 중 정확히 하나를 만족해야 합니다(matches={matches}).")
|
|
|
|
condition = schema.get("if")
|
|
if condition is not None:
|
|
try:
|
|
_validate_schema(value, condition, root, path)
|
|
condition_matches = True
|
|
except _SchemaViolation:
|
|
condition_matches = False
|
|
selected = schema.get("then") if condition_matches else schema.get("else")
|
|
if selected is not None:
|
|
_validate_schema(value, selected, root, path)
|
|
|
|
if "not" in schema:
|
|
try:
|
|
_validate_schema(value, schema["not"], root, path)
|
|
except _SchemaViolation:
|
|
pass
|
|
else:
|
|
raise _SchemaViolation(f"{path}: not 조건에 해당하는 값입니다.")
|
|
|
|
if isinstance(value, dict):
|
|
required = schema.get("required", [])
|
|
if not isinstance(required, list) or any(not isinstance(item, str) for item in required):
|
|
raise _SchemaViolation(f"{path}: required 선언이 잘못되었습니다.")
|
|
missing = [item for item in required if item not in value]
|
|
if missing:
|
|
raise _SchemaViolation(f"{path}: 필수 필드가 없습니다: {', '.join(missing)}")
|
|
properties = schema.get("properties", {})
|
|
if not isinstance(properties, dict):
|
|
raise _SchemaViolation(f"{path}: properties 선언이 객체가 아닙니다.")
|
|
for key, child_schema in properties.items():
|
|
if key in value:
|
|
_validate_schema(value[key], child_schema, root, _schema_path(path, key))
|
|
additional = schema.get("additionalProperties", True)
|
|
for key in value.keys() - properties.keys():
|
|
if additional is False:
|
|
raise _SchemaViolation(f"{_schema_path(path, key)}: 선언되지 않은 필드입니다.")
|
|
if isinstance(additional, dict) or isinstance(additional, bool):
|
|
_validate_schema(value[key], additional, root, _schema_path(path, key))
|
|
else:
|
|
raise _SchemaViolation(f"{path}: additionalProperties 선언이 잘못되었습니다.")
|
|
|
|
if isinstance(value, list):
|
|
if "minItems" in schema and len(value) < schema["minItems"]:
|
|
raise _SchemaViolation(f"{path}: 항목 수가 {schema['minItems']}보다 작습니다.")
|
|
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
raise _SchemaViolation(f"{path}: 항목 수가 {schema['maxItems']}보다 큽니다.")
|
|
if schema.get("uniqueItems"):
|
|
for index, item in enumerate(value):
|
|
if any(_json_equal(item, previous) for previous in value[:index]):
|
|
raise _SchemaViolation(f"{_schema_path(path, index)}: 중복 항목입니다.")
|
|
item_schema = schema.get("items")
|
|
if item_schema is not None:
|
|
for index, item in enumerate(value):
|
|
_validate_schema(item, item_schema, root, _schema_path(path, index))
|
|
|
|
if isinstance(value, str):
|
|
if "minLength" in schema and len(value) < schema["minLength"]:
|
|
raise _SchemaViolation(f"{path}: 문자열 길이가 {schema['minLength']}보다 작습니다.")
|
|
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
|
raise _SchemaViolation(f"{path}: 문자열 길이가 {schema['maxLength']}보다 큽니다.")
|
|
if "pattern" in schema:
|
|
try:
|
|
matched = re.search(schema["pattern"], value)
|
|
except (re.error, TypeError) as exc:
|
|
raise _SchemaViolation(f"{path}: schema pattern이 잘못되었습니다: {exc}") from exc
|
|
if matched is None:
|
|
raise _SchemaViolation(f"{path}: pattern {schema['pattern']!r}과 맞지 않습니다.")
|
|
value_format = schema.get("format")
|
|
if value_format == "date-time":
|
|
if re.fullmatch(
|
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
|
|
value,
|
|
) is None:
|
|
raise _SchemaViolation(f"{path}: 유효한 RFC 3339 date-time이 아닙니다.")
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise _SchemaViolation(f"{path}: 유효한 RFC 3339 date-time이 아닙니다.") from exc
|
|
if parsed.tzinfo is None:
|
|
raise _SchemaViolation(f"{path}: date-time에는 timezone이 필요합니다.")
|
|
elif value_format == "date":
|
|
try:
|
|
date.fromisoformat(value)
|
|
except ValueError as exc:
|
|
raise _SchemaViolation(f"{path}: 유효한 calendar date가 아닙니다.") from exc
|
|
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
if "minimum" in schema and value < schema["minimum"]:
|
|
raise _SchemaViolation(f"{path}: 값이 minimum {schema['minimum']}보다 작습니다.")
|
|
if "maximum" in schema and value > schema["maximum"]:
|
|
raise _SchemaViolation(f"{path}: 값이 maximum {schema['maximum']}보다 큽니다.")
|
|
if "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"]:
|
|
raise _SchemaViolation(f"{path}: 값이 {schema['exclusiveMinimum']}보다 커야 합니다.")
|
|
if "exclusiveMaximum" in schema and value >= schema["exclusiveMaximum"]:
|
|
raise _SchemaViolation(f"{path}: 값이 {schema['exclusiveMaximum']}보다 작아야 합니다.")
|
|
|
|
|
|
def validate_json_schema(value: Any, schema: dict[str, Any], label: str = "JSON") -> None:
|
|
"""Validate the bundled Draft 2020-12 subset without third-party packages."""
|
|
|
|
if not isinstance(schema, dict):
|
|
raise InputError(f"{label} schema 최상위 값은 객체여야 합니다.")
|
|
try:
|
|
_validate_schema(value, schema, schema, "$")
|
|
except _SchemaViolation as exc:
|
|
raise InputError(f"{label} schema 위반: {exc}") from exc
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def load_schema(name: str, schema_dir: str | None = None) -> dict[str, Any]:
|
|
directory = Path(schema_dir) if schema_dir else DEFAULT_SCHEMA_DIR
|
|
path = require_file(directory / name, f"schema {name}")
|
|
return load_json(path)
|
|
|
|
|
|
def validate_with_schema(
|
|
value: Any,
|
|
schema_name: str,
|
|
label: str,
|
|
schema_dir: Path | None = None,
|
|
) -> None:
|
|
schema = load_schema(schema_name, str(schema_dir) if schema_dir else None)
|
|
validate_json_schema(value, schema, label)
|
|
|
|
|
|
def atomic_write_text(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
temporary_path = Path(temporary)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
|
|
stream.write(text)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary_path, path)
|
|
except BaseException:
|
|
temporary_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
temporary_path = Path(temporary)
|
|
try:
|
|
with os.fdopen(fd, "wb") as stream:
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary_path, path)
|
|
except BaseException:
|
|
temporary_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def atomic_write_json(path: Path, value: Any) -> None:
|
|
atomic_write_text(path, json_text(value))
|
|
|
|
|
|
def paths_alias(left: Path, right: Path) -> bool:
|
|
"""Return whether two paths name the same target, including hard links."""
|
|
|
|
try:
|
|
return os.path.samefile(left, right)
|
|
except (FileNotFoundError, OSError):
|
|
return left.expanduser().resolve(strict=False) == right.expanduser().resolve(
|
|
strict=False
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReportOutputSnapshot:
|
|
exists: bool
|
|
stat_signature: tuple[int, int, int, int, int, int, int] | None
|
|
sha256: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PreparedReportOutput:
|
|
path: Path
|
|
protected_paths: tuple[Path, ...]
|
|
snapshot: ReportOutputSnapshot
|
|
|
|
|
|
def _report_stat_signature(metadata: os.stat_result) -> tuple[int, int, int, int, int, int, int]:
|
|
return (
|
|
metadata.st_dev,
|
|
metadata.st_ino,
|
|
metadata.st_mode,
|
|
metadata.st_nlink,
|
|
metadata.st_size,
|
|
metadata.st_mtime_ns,
|
|
metadata.st_ctime_ns,
|
|
)
|
|
|
|
|
|
def _snapshot_report_output(path: Path) -> tuple[ReportOutputSnapshot, bytes | None]:
|
|
if not os.path.lexists(path):
|
|
return ReportOutputSnapshot(False, None, None), None
|
|
|
|
flags = os.O_RDONLY
|
|
if hasattr(os, "O_CLOEXEC"):
|
|
flags |= os.O_CLOEXEC
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
try:
|
|
descriptor = os.open(path, flags)
|
|
except OSError as exc:
|
|
raise InputError(f"기존 output을 안전하게 열 수 없습니다: {path}: {exc}") from exc
|
|
try:
|
|
opened_before = os.fstat(descriptor)
|
|
lexical_before = path.lstat()
|
|
if stat.S_ISLNK(lexical_before.st_mode):
|
|
raise InputError(f"report output은 symbolic link일 수 없습니다: {path}")
|
|
if not stat.S_ISREG(opened_before.st_mode) or not stat.S_ISREG(
|
|
lexical_before.st_mode
|
|
):
|
|
raise InputError(f"report output은 일반 파일이어야 합니다: {path}")
|
|
if (
|
|
opened_before.st_dev != lexical_before.st_dev
|
|
or opened_before.st_ino != lexical_before.st_ino
|
|
):
|
|
raise InputError(f"report output 경로가 검사 중 변경되었습니다: {path}")
|
|
|
|
chunks: list[bytes] = []
|
|
while True:
|
|
chunk = os.read(descriptor, 1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
data = b"".join(chunks)
|
|
|
|
opened_after = os.fstat(descriptor)
|
|
lexical_after = path.lstat()
|
|
before_signature = _report_stat_signature(opened_before)
|
|
after_signature = _report_stat_signature(opened_after)
|
|
lexical_signature = _report_stat_signature(lexical_after)
|
|
if (
|
|
before_signature != after_signature
|
|
or after_signature != lexical_signature
|
|
or len(data) != opened_after.st_size
|
|
):
|
|
raise InputError(f"report output이 snapshot 중 변경되었습니다: {path}")
|
|
return (
|
|
ReportOutputSnapshot(
|
|
True,
|
|
after_signature,
|
|
sha256_bytes(data),
|
|
),
|
|
data,
|
|
)
|
|
except OSError as exc:
|
|
raise InputError(f"기존 output을 검사할 수 없습니다: {path}: {exc}") from exc
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def prepare_report_output(
|
|
path: Path,
|
|
*,
|
|
protected_paths: list[Path],
|
|
expected_tool: str,
|
|
schema_name: str,
|
|
) -> PreparedReportOutput:
|
|
"""Validate a report destination before any state-changing work begins."""
|
|
|
|
expanded = path.expanduser()
|
|
try:
|
|
resolved = expanded.resolve(strict=False)
|
|
except (OSError, RuntimeError) as exc:
|
|
raise InputError(f"output 경로를 해석할 수 없습니다: {path}: {exc}") from exc
|
|
|
|
protected_snapshot = tuple(item.expanduser() for item in protected_paths)
|
|
for protected in protected_snapshot:
|
|
if paths_alias(resolved, protected):
|
|
raise InputError(f"report output이 입력 파일을 가리킵니다: {path}")
|
|
|
|
snapshot, existing_bytes = _snapshot_report_output(expanded)
|
|
if existing_bytes is not None:
|
|
existing = load_json_text(
|
|
decode_utf8(existing_bytes, f"기존 report output {expanded}"),
|
|
str(expanded),
|
|
)
|
|
if existing.get("tool") != expected_tool:
|
|
raise InputError(
|
|
f"다른 파일을 덮어쓸 수 없습니다: output tool={existing.get('tool')!r}, "
|
|
f"required={expected_tool!r}"
|
|
)
|
|
validate_with_schema(existing, schema_name, str(expanded))
|
|
return PreparedReportOutput(resolved, protected_snapshot, snapshot)
|
|
|
|
|
|
def _assert_report_output_unchanged(prepared: PreparedReportOutput) -> None:
|
|
for protected in prepared.protected_paths:
|
|
if paths_alias(prepared.path, protected):
|
|
raise InputError(
|
|
f"report output이 preflight 이후 입력 파일을 가리킵니다: {prepared.path}"
|
|
)
|
|
current, _ = _snapshot_report_output(prepared.path)
|
|
if current != prepared.snapshot:
|
|
raise InputError(
|
|
f"report output이 preflight 이후 변경되었습니다: {prepared.path}"
|
|
)
|
|
|
|
|
|
def publish_report_json(prepared: PreparedReportOutput, value: Any) -> Path:
|
|
"""Publish only while the report destination still matches its preflight token."""
|
|
|
|
path = prepared.path
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
temporary_path = Path(temporary)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
|
|
stream.write(json_text(value))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
|
|
_assert_report_output_unchanged(prepared)
|
|
if prepared.snapshot.exists:
|
|
os.replace(temporary_path, path)
|
|
else:
|
|
try:
|
|
os.link(temporary_path, path, follow_symlinks=False)
|
|
except FileExistsError as exc:
|
|
raise InputError(
|
|
f"report output이 publish 직전에 생성되었습니다: {path}"
|
|
) from exc
|
|
temporary_path.unlink()
|
|
return path
|
|
except BaseException:
|
|
temporary_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
@contextmanager
|
|
def run_lock(run_dir: Path):
|
|
"""Hold a crash-safe run-wide advisory lock.
|
|
|
|
The kernel releases ``flock`` on every process exit, including SIGKILL. The
|
|
small lock file intentionally remains and is reused by later processes.
|
|
"""
|
|
|
|
lock_path = run_dir / ".00_run.lock"
|
|
flags = os.O_CREAT | os.O_RDWR
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
try:
|
|
descriptor = os.open(lock_path, flags, 0o600)
|
|
except OSError as exc:
|
|
raise InputError(f"run lock을 열 수 없습니다: {lock_path}: {exc}") from exc
|
|
try:
|
|
opened = os.fstat(descriptor)
|
|
if not stat.S_ISREG(opened.st_mode):
|
|
raise InputError(f"run lock은 일반 파일이어야 합니다: {lock_path}")
|
|
if opened.st_nlink != 1:
|
|
raise InputError(f"run lock은 hard link일 수 없습니다: {lock_path}")
|
|
path_metadata = lock_path.lstat()
|
|
if (
|
|
path_metadata.st_dev != opened.st_dev
|
|
or path_metadata.st_ino != opened.st_ino
|
|
or path_metadata.st_nlink != 1
|
|
):
|
|
raise InputError(f"run lock 경로가 안전하지 않습니다: {lock_path}")
|
|
try:
|
|
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as exc:
|
|
raise InputError(f"다른 run 작업이 진행 중입니다: {lock_path}") from exc
|
|
# The lock needs no payload. Avoiding truncate/write means even a link
|
|
# introduced after validation cannot make the lock mutate another name.
|
|
locked = os.fstat(descriptor)
|
|
current_path = lock_path.lstat()
|
|
if (
|
|
locked.st_nlink != 1
|
|
or current_path.st_dev != locked.st_dev
|
|
or current_path.st_ino != locked.st_ino
|
|
or current_path.st_nlink != 1
|
|
):
|
|
raise InputError(f"run lock 경로가 잠금 중 변경되었습니다: {lock_path}")
|
|
yield
|
|
finally:
|
|
try:
|
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def json_text(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, indent=2) + "\n"
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def sha256_text(text: str) -> str:
|
|
return sha256_bytes(text.encode("utf-8"))
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
except OSError as exc:
|
|
raise InputError(f"파일 hash를 계산할 수 없습니다: {path}: {exc}") from exc
|
|
return digest.hexdigest()
|
|
|
|
|
|
def require_file(path: Path, label: str, *, nonempty: bool = True) -> Path:
|
|
try:
|
|
resolved = path.expanduser().resolve(strict=True)
|
|
except OSError as exc:
|
|
raise InputError(f"{label} 파일이 없습니다: {path}") from exc
|
|
if not resolved.is_file():
|
|
raise InputError(f"{label}는 일반 파일이어야 합니다: {path}")
|
|
if nonempty and resolved.stat().st_size == 0:
|
|
raise InputError(f"{label} 파일이 비어 있습니다: {path}")
|
|
return resolved
|
|
|
|
|
|
def require_regular_nonsymlink(
|
|
path: Path, label: str, *, nonempty: bool = True
|
|
) -> Path:
|
|
"""Require a lexical path to be a regular file rather than a symlink."""
|
|
|
|
expanded = path.expanduser().absolute()
|
|
try:
|
|
metadata = expanded.lstat()
|
|
except OSError as exc:
|
|
raise InputError(f"{label} 파일이 없습니다: {path}") from exc
|
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
|
raise InputError(f"{label}는 symbolic link가 아닌 일반 파일이어야 합니다: {path}")
|
|
if nonempty and metadata.st_size == 0:
|
|
raise InputError(f"{label} 파일이 비어 있습니다: {path}")
|
|
return expanded
|
|
|
|
|
|
def snapshot_file(
|
|
path: Path,
|
|
role: str,
|
|
display_path: str | None = None,
|
|
*,
|
|
item_id: str | None = None,
|
|
) -> tuple[dict[str, Any], bytes]:
|
|
resolved = require_file(path, role)
|
|
try:
|
|
data = resolved.read_bytes()
|
|
except OSError as exc:
|
|
raise InputError(f"{role} 파일 snapshot을 읽을 수 없습니다: {path}: {exc}") from exc
|
|
return (
|
|
{
|
|
"id": item_id or role,
|
|
"role": role,
|
|
"path": display_path if display_path is not None else str(path),
|
|
"resolved_path": str(resolved),
|
|
"size_bytes": len(data),
|
|
"sha256": sha256_bytes(data),
|
|
},
|
|
data,
|
|
)
|
|
|
|
|
|
def inventory_file(
|
|
path: Path,
|
|
role: str,
|
|
display_path: str | None = None,
|
|
*,
|
|
item_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
inventory, _ = snapshot_file(
|
|
path, role, display_path, item_id=item_id
|
|
)
|
|
return inventory
|
|
|
|
|
|
def decode_utf8(data: bytes, label: str) -> str:
|
|
try:
|
|
return data.decode("utf-8")
|
|
except UnicodeError as exc:
|
|
raise InputError(f"{label} 파일은 UTF-8이어야 합니다: {exc}") from exc
|
|
|
|
|
|
def load_rules(path: Path | None = None) -> dict[str, Any]:
|
|
rules_path = path or DEFAULT_RULES_PATH
|
|
rules = load_json(rules_path)
|
|
validate_with_schema(rules, "quality-rules.schema.json", str(rules_path))
|
|
if rules.get("schema_version") != "1.0":
|
|
raise InputError(f"지원하지 않는 quality rules schema_version: {rules.get('schema_version')!r}")
|
|
if not isinstance(rules.get("rules_version"), str):
|
|
raise InputError("quality rules에 rules_version 문자열이 필요합니다.")
|
|
thresholds = rules.get("thresholds")
|
|
if not isinstance(thresholds, dict):
|
|
raise InputError("quality rules thresholds 객체가 필요합니다.")
|
|
required_thresholds: dict[str, tuple[str, ...]] = {
|
|
"heading": ("required_h1_count", "max_level_jump"),
|
|
"term": (
|
|
"max_new_terms_per_sentence",
|
|
"max_new_terms_per_paragraph",
|
|
"max_new_terms_per_section",
|
|
"definition_window_chars",
|
|
"max_assumed_known",
|
|
"max_assumed_per_prerequisite",
|
|
),
|
|
"paragraph": ("max_chars", "max_sentences"),
|
|
"route": ("light", "standard", "deep"),
|
|
"split": ("default_max_chars", "minimum_h2_fill_ratio"),
|
|
"finalization": ("max_change_rate",),
|
|
}
|
|
for group, names in required_thresholds.items():
|
|
group_value = thresholds.get(group)
|
|
if not isinstance(group_value, dict):
|
|
raise InputError(f"quality rules thresholds.{group} 객체가 필요합니다.")
|
|
missing = [name for name in names if name not in group_value]
|
|
if missing:
|
|
raise InputError(
|
|
f"quality rules thresholds.{group} 필드가 없습니다: {', '.join(missing)}"
|
|
)
|
|
route = thresholds["route"]
|
|
for name, keys in {
|
|
"light": ("requires_existing_draft", "max_input_chars", "max_sources", "max_headings"),
|
|
"standard": ("max_input_chars", "max_sources", "max_headings"),
|
|
"deep": ("min_input_chars", "min_sources", "min_headings"),
|
|
}.items():
|
|
value = route.get(name)
|
|
if not isinstance(value, dict) or any(key not in value for key in keys):
|
|
raise InputError(f"quality rules route.{name} 임계값이 불완전합니다.")
|
|
patterns = rules.get("patterns")
|
|
if not isinstance(patterns, dict):
|
|
raise InputError("quality rules patterns 객체가 필요합니다.")
|
|
for key in ("placeholders", "evidence_markers", "technical_candidate_allowlist"):
|
|
values = patterns.get(key)
|
|
if not isinstance(values, list) or any(not isinstance(value, str) for value in values):
|
|
raise InputError(f"quality rules patterns.{key}는 문자열 배열이어야 합니다.")
|
|
for pattern in patterns["placeholders"]:
|
|
try:
|
|
re.compile(pattern)
|
|
except re.error as exc:
|
|
raise InputError(f"placeholder 정규식이 잘못되었습니다: {pattern}: {exc}") from exc
|
|
for pattern in patterns["evidence_markers"]:
|
|
if pattern.count("{claim_id}") != 1:
|
|
raise InputError(
|
|
"evidence marker에는 {claim_id} placeholder가 정확히 하나 필요합니다."
|
|
)
|
|
try:
|
|
re.compile(
|
|
pattern.replace("{claim_id}", r"(?P<id>[^\s<>\[\]{}()]+)")
|
|
)
|
|
except re.error as exc:
|
|
raise InputError(
|
|
f"evidence marker 정규식이 잘못되었습니다: {pattern}: {exc}"
|
|
) from exc
|
|
entries = rules.get("rules")
|
|
if not isinstance(entries, list) or not entries:
|
|
raise InputError("quality rules의 rules 배열이 비어 있습니다.")
|
|
seen: set[str] = set()
|
|
for entry in entries:
|
|
if not isinstance(entry, dict):
|
|
raise InputError("quality rules의 각 rule은 객체여야 합니다.")
|
|
rule_id = entry.get("id")
|
|
if not isinstance(rule_id, str) or not rule_id:
|
|
raise InputError("quality rule id가 비어 있습니다.")
|
|
if rule_id in seen:
|
|
raise InputError(f"quality rule id가 중복됩니다: {rule_id}")
|
|
seen.add(rule_id)
|
|
if entry.get("severity") not in {"error", "warning", "info"}:
|
|
raise InputError(f"quality rule severity가 잘못되었습니다: {rule_id}")
|
|
if not isinstance(entry.get("description"), str) or not entry["description"].strip():
|
|
raise InputError(f"quality rule description이 비어 있습니다: {rule_id}")
|
|
return rules
|
|
|
|
|
|
def rule_index(rules: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
return {entry["id"]: entry for entry in rules["rules"]}
|
|
|
|
|
|
def find_repository_root(start: Path | None = None) -> Path:
|
|
candidates = [start or Path.cwd(), SCRIPT_DIR, SKILL_DIR]
|
|
visited: set[Path] = set()
|
|
for candidate in candidates:
|
|
current = candidate.resolve()
|
|
if current.is_file():
|
|
current = current.parent
|
|
for directory in (current, *current.parents):
|
|
if directory in visited:
|
|
continue
|
|
visited.add(directory)
|
|
if (directory / "harness.json").is_file() and (directory / "VERSION").is_file():
|
|
return directory
|
|
raise InputError("harness.json과 VERSION이 있는 저장소 루트를 찾지 못했습니다.")
|
|
|
|
|
|
def schema_version(value: dict[str, Any], path: Path) -> None:
|
|
if value.get("schema_version") != "1.0":
|
|
raise InputError(f"{path.name} schema_version은 '1.0'이어야 합니다.")
|
|
|
|
|
|
def json_type(value: Any, expected: type | tuple[type, ...], field: str) -> None:
|
|
if not isinstance(value, expected):
|
|
names = (
|
|
", ".join(item.__name__ for item in expected)
|
|
if isinstance(expected, tuple)
|
|
else expected.__name__
|
|
)
|
|
raise InputError(f"{field} 값은 {names} 형식이어야 합니다.")
|