"""콘텐츠 검증 CLI — `python -m engine.schemas.validate content/`. 통과하지 않는 YAML은 엔진에 적재하지 않는다 (CLAUDE.md). 스키마 위반은 수정하지 않고 리포트로 출력한다 (대표님 전달용). 검사 항목: 1. 3레이어 스키마 검증 (beatcard / chapter / character) + R카드 2. 링크 무결성: next/next_options/choice.next/preset_branch → 실존 카드 또는 에피소드 참조 3. 도달성: entry_beat에서 모든 카드 도달 가능 여부 + 챕터 밖(E07)으로의 출구 존재 4. R카드 참조: 카드가 부르는 R카드가 챕터 등록·YAML 양쪽에 존재 """ from __future__ import annotations import re import sys from collections import deque from pathlib import Path import yaml from engine.schemas.beatcard import BeatCard, EpisodeFile, RCard from engine.schemas.chapter import ChapterFile from engine.schemas.character import CharactersFile RE_EPISODE_REF = re.compile(r"^E\d{2}$") def load_content(root: Path): """content/ 전체를 스키마 검증하며 로드. (episodes, r_cards, chapter, characters, errors)""" errors: list[str] = [] episodes: list[EpisodeFile] = [] r_cards: dict[str, RCard] = {} chapter = None characters = None for f in sorted((root / "beatcards").glob("E*.yaml")): try: episodes.append(EpisodeFile.model_validate(yaml.safe_load(f.read_text(encoding="utf-8")))) except Exception as e: # noqa: BLE001 — 리포트 목적 errors.append(f"{f.name}: {e}") for f in sorted((root / "beatcards").glob("R-*.yaml")): try: rc = RCard.model_validate(yaml.safe_load(f.read_text(encoding="utf-8"))) r_cards[rc.id] = rc except Exception as e: # noqa: BLE001 errors.append(f"{f.name}: {e}") for f in sorted((root / "chapters").glob("*.yaml")): try: chapter = ChapterFile.model_validate(yaml.safe_load(f.read_text(encoding="utf-8"))).chapter except Exception as e: # noqa: BLE001 errors.append(f"{f.name}: {e}") cf = root / "characters.yaml" if cf.exists(): try: characters = CharactersFile.model_validate(yaml.safe_load(cf.read_text(encoding="utf-8"))) except Exception as e: # noqa: BLE001 errors.append(f"characters.yaml: {e}") else: errors.append("characters.yaml 없음") return episodes, r_cards, chapter, characters, errors def check_links(episodes: list[EpisodeFile], chapter, r_cards) -> tuple[list[str], list[str]]: """링크 무결성 + 도달성. (errors, warnings)""" errors: list[str] = [] warnings: list[str] = [] cards: dict[str, BeatCard] = {c.id: c for ep in episodes for c in ep.cards} episode_ids = {ep.episode.id for ep in episodes} def ok(target: str) -> bool: if target in cards: return True if RE_EPISODE_REF.match(target): return True # 에피소드 참조 — 챕터 범위 밖(E07)이면 출구로 취급 return False edges: dict[str, list[str]] = {cid: [] for cid in cards} for c in cards.values(): targets = ([c.next] if c.next else []) + c.next_options for ch in c.choices: targets += [t for t in (ch.next, ch.preset_branch) if t] for t in targets: if not ok(t): errors.append(f"{c.id} → '{t}' 링크 끊김") elif t in cards: edges[c.id].append(t) elif t in episode_ids: # E02 등 → 해당 에피소드 첫 카드 first = next(ep.cards[0].id for ep in episodes if ep.episode.id == t) edges[c.id].append(first) if chapter: if chapter.entry_beat not in cards: errors.append(f"entry_beat '{chapter.entry_beat}' 카드 없음") else: seen = {chapter.entry_beat} q = deque([chapter.entry_beat]) while q: for t in edges[q.popleft()]: if t not in seen: seen.add(t) q.append(t) unreachable = sorted(set(cards) - seen) if unreachable: warnings.append(f"entry에서 도달 불가 카드 {len(unreachable)}개: {', '.join(unreachable)}") registered = {r.id for r in chapter.r_cards} for c in cards.values(): for ref in c.r_card_refs: if ref not in registered: warnings.append(f"{c.id}: R카드 '{ref}' 챕터 미등록") if ref not in r_cards: errors.append(f"{c.id}: R카드 '{ref}' YAML 없음") # 막다른 카드 (next도 분기도 없는데 챕터 출구도 아님) for c in cards.values(): has_out = c.next or c.next_options or any(ch.next for ch in c.choices) if not has_out: warnings.append(f"{c.id}: 나가는 링크 없음 (챕터 출구가 아니면 결함)") return errors, warnings def main(argv: list[str]) -> int: root = Path(argv[0]) if argv else Path("content") episodes, r_cards, chapter, characters, errors = load_content(root) link_errors, warnings = check_links(episodes, chapter, r_cards) errors += link_errors n_cards = sum(len(ep.cards) for ep in episodes) print(f"에피소드 {len(episodes)}개 · 카드 {n_cards}개 · R카드 {len(r_cards)}개 · " f"챕터 {'OK' if chapter else '없음'} · 인물 {len(characters.characters) if characters else 0}명") for w in warnings: print(f" ⚠ {w}") if errors: print(f"\n검증 실패 {len(errors)}건:") for e in errors: print(f" ✗ {e}") return 1 print("✅ 검증 통과") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))