#!/usr/bin/env python3 """그림이 정본에서 나온 것인지 본다 — **손으로 고친 SVG 를 잡는다.** `technical-visualizer` 는 `.techviz/<이름>/spec.json` 을 정본으로 삼아 SVG 를 만든다. **SVG 는 바뀌었는데 spec 은 그대로면 그 그림은 정본에서 나온 것이 아니다.** 화살표를 뒤집거나 라벨을 바꾸는 편집이 정확히 그 모양이다. 지금 그림 쪽 검사기가 보는 것은 셋이다 — `` 가 이름인지 · 상자와 라벨이 겹치는지 · 해시가 검토 뒤에 바뀌었는지. **셋 다 그림이 무엇을 말하는지는 안 본다.** 화살표 방향과 주체는 사람이 봐야 하지만, **그림이 정본을 거치지 않았다는 것은 문자열로 판정된다.** `spec.json` 의 간선과 SVG 의 경로를 직접 견주는 길도 있지만, 어느 `` 가 어느 간선인지 대조하려면 렌더러가 id 를 어떻게 붙이는지 알아야 한다. **정본을 거쳤는지만 보면 렌더러를 몰라도 된다.** 두 자리를 본다. - **작업 트리** — SVG 가 고쳐졌는데 spec 은 안 고쳐졌다. 지금 손으로 고치는 중이다 - **이력** — SVG 의 마지막 커밋이 spec 의 마지막 커밋보다 나중이다. 손으로 고쳐 커밋했다 **spec 이 없는 그림은 이 검사기가 볼 것이 아니다.** 저장소의 그림 272장 중 209장이 그렇고, 그것은 `verify-project-layout.py` 의 「techviz 정본이 없는 그림」이 세는 자리다. 여기서는 못 본 것으로 센다 — 조용히 건너뛰면 「전부 맞다」가 「본 것만 맞다」를 가린다. python3 scripts/check-figure-provenance.py <프로젝트> """ from __future__ import annotations import argparse import glob import os import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "scripts")) import techlog # noqa: E402 def _git(*args: str) -> str: try: p = subprocess.run(["git", "-C", ROOT, *args], capture_output=True, text=True, timeout=30) except (OSError, subprocess.SubprocessError): return "" return p.stdout.strip() if p.returncode == 0 else "" def _dirty(rel: str) -> bool: return bool(_git("status", "--porcelain", "--", rel)) def _last_commit_time(rel: str) -> int | None: out = _git("log", "-1", "--format=%ct", "--", rel) return int(out) if out.isdigit() else None def verify(project: str) -> techlog.Report: rep = techlog.Report(project) base = os.path.join(ROOT, "docs", project, "final") paired = unpaired = 0 for svg in sorted(glob.glob(os.path.join(base, "assets", "**", "*.svg"), recursive=True)): name = os.path.basename(svg)[:-4] spec = os.path.join(base, ".techviz", name, "spec.json") svg_rel = os.path.relpath(svg, ROOT) if not os.path.exists(spec): unpaired += 1 continue paired += 1 spec_rel = os.path.relpath(spec, ROOT) if _dirty(svg_rel) and not _dirty(spec_rel): rep.error("정본을 거치지 않고 고친 그림 — 작업 트리", f"{svg_rel} 이 고쳐졌는데 {spec_rel} 은 그대로다") continue st, sp = _last_commit_time(svg_rel), _last_commit_time(spec_rel) if st is not None and sp is not None and st > sp: rep.error("정본을 거치지 않고 고친 그림 — 이력", f"{svg_rel} 의 마지막 커밋이 {spec_rel} 보다 {st - sp}초 나중이다") rep.facts["정본과 짝지은 그림"] = paired if unpaired: rep.facts["정본이 없어 못 본 그림"] = unpaired return rep def main() -> int: ap = argparse.ArgumentParser(description="그림이 정본에서 나온 것인지 본다.") ap.add_argument("projects", nargs="*") ap.add_argument("--samples", type=int, default=3) args = ap.parse_args() projects = args.projects or sorted( os.path.basename(os.path.dirname(os.path.dirname(p))) for p in glob.glob(os.path.join(ROOT, "docs/*/final/assets")) if not os.path.basename(os.path.dirname(os.path.dirname(p))).startswith("_")) missing = [p for p in projects if not os.path.isdir(os.path.join(ROOT, "docs", p))] if missing: print(f"대상이 성립하지 않는다 — 그런 프로젝트가 없다: {', '.join(missing)}", file=sys.stderr) return 2 if not projects: print("대상이 성립하지 않는다 — 그림을 가진 프로젝트가 없다", file=sys.stderr) return 2 reports = [verify(p) for p in projects] for rep in reports: facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items()) or "그림 없음" print(f"\n[{rep.project}] {facts}") for rule, details in sorted(rep.errors.items(), key=lambda kv: -len(kv[1])): print(f" ✗ error {len(details):>4} {rule}") for d in details[:args.samples]: print(f" · {d}") e = sum(r.error_count for r in reports) print(f"\nFIGURE PROVENANCE: {'FAIL' if e else 'PASS'} — " f"프로젝트 {len(reports)} · error {e}") return 1 if e else 0 if __name__ == "__main__": raise SystemExit(main())