#!/usr/bin/env python3 """Generate platform rule slices from repository-neutral root rule SSOT files.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path import sys from typing import Any DEFAULT_ROOT = Path(__file__).resolve().parents[2] CONFIG = Path("harness/source/rule-adapters.json") sys.path.insert(0, str(DEFAULT_ROOT / "harness/runtime")) from fs_transaction import replace_many # noqa: E402 class RuleGenerationError(ValueError): pass def _safe_path(value: Any) -> Path: if not isinstance(value, str) or not value: raise RuleGenerationError("path must be a non-empty string") path = Path(value) if path.is_absolute() or ".." in path.parts: raise RuleGenerationError(f"path escapes repository: {value}") return path def _slice(text: str, start: str, end: str | None, source: Path) -> str: lines = text.splitlines(keepends=True) try: start_index = next(index for index, line in enumerate(lines) if line.rstrip("\n") == start) except StopIteration as exc: raise RuleGenerationError(f"{source}: missing start heading {start!r}") from exc end_index = len(lines) if end is not None: try: end_index = next( index for index, line in enumerate(lines[start_index + 1 :], start_index + 1) if line.rstrip("\n") == end ) except StopIteration as exc: raise RuleGenerationError(f"{source}: missing end heading {end!r}") from exc return "".join(lines[start_index:end_index]).rstrip() + "\n" def render(root: Path) -> dict[Path, str]: config_path = root / CONFIG config = json.loads(config_path.read_text(encoding="utf-8")) if config.get("schema_version") != "rule-adapters/v1": raise RuleGenerationError("expected rule-adapters/v1") groups = config.get("groups") if not isinstance(groups, list) or not groups: raise RuleGenerationError("groups must be a non-empty list") rendered: dict[Path, str] = {} for group in groups: if not isinstance(group, dict): raise RuleGenerationError("group must be an object") source_rel = _safe_path(group.get("source")) target_dir = _safe_path(group.get("target_dir")) source = root / source_rel source_text = source.read_text(encoding="utf-8") digest = hashlib.sha256(source_text.encode("utf-8")).hexdigest() slices = group.get("slices") if not isinstance(slices, list) or not slices: raise RuleGenerationError(f"{source_rel}: slices must be non-empty") links: list[str] = [] for item in slices: target_name = _safe_path(item.get("target")) if len(target_name.parts) != 1: raise RuleGenerationError("rule slice target must be a filename") start = item.get("start") end = item.get("end") if not isinstance(start, str) or (end is not None and not isinstance(end, str)): raise RuleGenerationError("slice start/end must be headings") target = target_dir / target_name if target in rendered: raise RuleGenerationError(f"duplicate rule target: {target}") marker = f"" rendered[target] = ( f"{marker}\n\n" f"Root SSOT: [`{source_rel.as_posix()}`](../../../../../{source_rel.as_posix()})\n\n" f"{_slice(source_text, start, end, source_rel)}" ) links.append(f"- [`{target_name.as_posix()}`]({target_name.as_posix()}): `{start}`") index_name = _safe_path(group.get("index")) index = target_dir / index_name marker = f"" rendered[index] = ( f"{marker}\n\n" f"# 생성된 rule index\n\n" f"Root SSOT: [`{source_rel.as_posix()}`](../../../../../{source_rel.as_posix()})\n\n" "아래 파일은 root SSOT의 heading 구간에서 생성된다. 직접 편집하지 않는다.\n\n" + "\n".join(links) + "\n" ) return rendered def generate(root: Path, check: bool) -> tuple[list[str], list[str]]: expected = render(root) stale = [path.as_posix() for path, text in expected.items() if not (root / path).is_file() or (root / path).read_text(encoding="utf-8") != text] written: list[str] = [] if not check and stale: changes = {root / path: expected[path].encode("utf-8") for path in expected if path.as_posix() in stale} replace_many(changes) written = sorted(stale) return sorted(stale), written def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--check", action="store_true") mode.add_argument("--write", action="store_true") parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) args = parser.parse_args(argv) try: root = args.root.resolve(strict=True) stale, written = generate(root, args.check) result = { "schema_version": "rule-adapter-result/v1", "status": "DRIFT" if args.check and stale else "UPDATED" if written else "CURRENT", "stale": stale, "written": written, "target_count": len(render(root)), } json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 1 if args.check and stale else 0 except (OSError, UnicodeError, json.JSONDecodeError, RuleGenerationError) as exc: json.dump({"schema_version": "rule-adapter-result/v1", "status": "FAIL", "error": str(exc)}, sys.stdout, ensure_ascii=False, indent=2) sys.stdout.write("\n") return 2 if __name__ == "__main__": raise SystemExit(main())