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

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} 형식이어야 합니다.")