#!/usr/bin/env python3 """기록과 증거가 이 저장소 규칙을 지키는지 전수로 본다. 읽어서 확인할 수 없는 분량이라 기계로 센다. 파서 검사는 check_body.mjs 가 따로 한다. python3 scripts/audit-records.py [프로젝트 ...] """ from __future__ import annotations import os, re, sys, glob, json, collections ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) KINDS = {"case": "CASE", "concept": "CONCEPT", "reference": "REFERENCE", "question": "QUESTION", "decision": "PROJECT_DECISION"} BODY_KINDS = {"case", "concept"} PLAIN_FIELDS = {"case": ("문제", "결론", "검증 환경", "재현 조건"), "reference": ("목적", "규칙", "적용 조건", "예외", "예시"), "question": ("사실", "가정", "미지수", "제약", "선택지", "다음 검증"), "decision": ("결정문", "판단 이유", "영향")} REQUIRED = ("kind", "slug", "title", "topic", "project", "status") def front_matter(text: str) -> dict: if not text.startswith("---"): return {} end = text.find("\n---", 3) out = {} for line in text[3:end].splitlines(): m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line) if m: out[m.group(1)] = m.group(2).strip().strip('"') return out def audit_project(project: str) -> dict: base = os.path.join(ROOT, "docs", project) studio = os.path.join(base, "tech-log-studio") ev = os.path.join(base, "final", "evidence") issues = collections.Counter() samples = collections.defaultdict(list) def flag(key: str, detail: str) -> None: issues[key] += 1 if len(samples[key]) < 3: samples[key].append(detail) shown: set[str] = set() cited: list[str] = [] records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md")) if not f.split(os.sep)[-3].startswith("_")] for f in records: rel = os.path.relpath(f, ROOT) parts = f.split(os.sep) topic_dir, kind_dir = parts[-3], parts[-2] text = open(f, encoding="utf-8").read() fm = front_matter(text) for key in REQUIRED: if not fm.get(key): flag(f"칸 없음: {key}", rel) if kind_dir in KINDS and fm.get("kind") != KINDS[kind_dir]: flag("kind 와 폴더 불일치", f"{rel} — kind={fm.get('kind')}") if fm.get("topic") and fm["topic"] != topic_dir: flag("topic 과 폴더 불일치", f"{rel} — topic={fm.get('topic')}") has_body = "" in text and "" in text if kind_dir in BODY_KINDS and not has_body: flag("본문 마커 없음", rel) if kind_dir not in BODY_KINDS and has_body: flag("본문이 없어야 하는 종류에 본문", rel) # frontmatter 가 가리키는 파일이 실제로 있나 d = os.path.dirname(f) for m in re.finditer(r"^ file: (\S+)$", text, re.M): if not os.path.exists(os.path.normpath(os.path.join(d, m.group(1)))): flag("assets 링크 깨짐", f"{rel} — {m.group(1)}") for m in re.finditer(r"^ - (\.\./\S+)$", text, re.M): if not os.path.exists(os.path.normpath(os.path.join(d, m.group(1)))): flag("evidence 링크 깨짐", f"{rel} — {m.group(1)}") # 평문 칸은 백틱·코드펜스가 글자 그대로 보인다 head = text if "" not in text \ else text[:text.index("")] for field in PLAIN_FIELDS.get(kind_dir, ()): fm2 = re.search(rf"^## {re.escape(field)}\n(.*?)(?=\n## |\Z)", head, re.M | re.S) if fm2 and ("`" in fm2.group(1) or "```" in fm2.group(1)): flag("평문 칸에 마크업", f"{rel} — {field}") # 본문이 부르는 자산이 frontmatter 에 선언돼 있나 declared = set(re.findall(r"^ - key: (\S+)$", text, re.M)) body_keys = set(re.findall(r':::evidence key="([^"]+)"', text)) shown |= {k[:-8] if k.endswith("-diagram") else k for k in body_keys} # 본문에 싣지 않고 evidence 로만 잇거나 산문에서 이름을 대도 쓰인 것이다 shown |= {os.path.basename(m)[:-4] for m in re.findall(r"^ - \S+/raw/(\S+\.txt)$", text, re.M)} cited.append(text) for key in body_keys: if key not in declared: flag("본문 자산이 frontmatter 에 없음", f"{rel} — {key}") # 증거 삼종 — 기록이 화면에 쓰는 증거만 원문·메타·렌더가 다 있어야 한다. # raw 에만 있는 캡처는 분석 단계 자료다. 실행 메타를 요구하지 않는다. def stems(sub: str, ext: str) -> set: return {os.path.splitext(os.path.basename(p))[0] for p in glob.glob(f"{ev}/{sub}/*{ext}")} raw, meta, rendered = stems("raw", ".txt"), stems("meta", ".json"), stems("rendered", ".svg") for s in sorted(rendered - raw): flag("렌더에 원문 없음", s) for s in sorted(rendered - meta): flag("렌더에 실행 메타 없음", s) blob = "\n".join(cited) for s in sorted(rendered - shown): if s not in blob: flag("아무 기록도 쓰지 않는 렌더", s) # meta 가 가리키는 파일이 실제로 있나 (final/ 기준 상대경로) for p in sorted(glob.glob(f"{ev}/meta/*.json")): try: m = json.load(open(p, encoding="utf-8")) except Exception as exc: flag("meta 파싱 실패", f"{os.path.basename(p)} — {exc}") continue for field in ("raw", "svg", "rawPath", "presentationPath"): v = m.get(field) if v and not os.path.exists(os.path.join(base, "final", v)): flag("meta 경로 깨짐", f"{os.path.basename(p)} — {field}: {v}") return {"project": project, "records": len(records), "evidence": {"raw": len(raw), "meta": len(meta), "rendered": len(rendered)}, "issues": issues, "samples": samples} def main(argv: list[str]) -> int: projects = argv[1:] or sorted( os.path.basename(os.path.dirname(p)) for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))) total = 0 for project in projects: r = audit_project(project) n = sum(r["issues"].values()) total += n ev = r["evidence"] print(f"\n{r['project']} — 기록 {r['records']}건 · 원문 {ev['raw']} · 메타 {ev['meta']} · 렌더 {ev['rendered']}") if not n: print(" 문제 없음") continue for key, count in r["issues"].most_common(): print(f" {count:>5} {key}") for s in r["samples"][key]: print(f" {s}") print(f"\n합계 {total}건") return 0 if total == 0 else 1 if __name__ == "__main__": raise SystemExit(main(sys.argv))