#!/usr/bin/env python3 """글의 핵심이 측정을 주장하는데 근거 목록이 비었는지 본다. `문제`·`결론` 칸은 사람이 그 글의 핵심을 적은 자리다. 거기서 **성능을 재는 명사**와 **비교하는 말**이 함께 나오면 그것은 측정을 주장한 것이다. `quality-policy@1` §3 은 성능 비교에 「실험 코드·데이터·부하·반복 횟수·원시 측정치·집계 방식」을 요구한다. **그 주장을 하면서 `evidence[]` 가 비어 있으면 핵심에 근거가 없는 모양이다.** **비교하는 말만으로는 안 된다.** 「계약의 절반은 라우트 레지스트리에서 유도한다」· 「사람이 기억해서 돌리는 가드는 절반만 존재합니다」 — 한국어에서 「절반」은 측정이 아닌 쓰임이 흔하다. 저장소의 기록 275건에 비교 낱말만으로 걸어 봤더니 걸린 둘이 **둘 다 그 모양**이었다. 그래서 성능을 재는 명사와 **함께** 나올 때만 측정 주장으로 본다. **수치로는 못 잡는다.** 「응답시간의 p99 가 절반이 됐다」에는 아라비아 숫자가 없다. 처음에 「핵심의 수치가 근거에 없으면」으로 만들었다가 검출이 0 이라 버렸다. python3 scripts/check-core-support.py <프로젝트> """ from __future__ import annotations import argparse import glob import os import re 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 BODY_START, BODY_END = "", "" CORE_FIELDS = ("문제", "결론") # 성능을 재는 명사. 이것이 있어야 측정 주장이다 PERF = re.compile( r"(p9\d|p5\d|응답\s*시간|처리량|지연\s*시간|레이턴시|latency|throughput|tps|qps|" r"실행\s*시간|소요\s*시간|쿼리\s*수|메모리\s*사용|CPU\s*사용)", re.I) # 비교하는 말. 이것만으로는 측정이 아니다 COMPARE = re.compile( r"(절반|두 배|세 배|배로|배가|퍼센트|%|빨라|느려|줄었|늘었|개선|향상|단축|" r"감소했|증가했)") def _front_matter(text: str) -> str: if not text.startswith("---"): return "" end = text.find("\n---", 3) return text[3:end] if end > 0 else "" def _sections(text: str) -> dict[str, str]: a, b = text.find(BODY_START), text.find(BODY_END) marks = [] for m in re.finditer(r"^##\s+(.+)$", text, re.M): if a >= 0 <= b and a < m.start() < b: continue marks.append((m.group(1).strip(), m.end())) out = {} for i, (name, start) in enumerate(marks): stop = (marks[i + 1][1] - len(f"## {marks[i + 1][0]}") if i + 1 < len(marks) else len(text)) out[name] = text[start:stop] return out def _listed(fm: str, key: str) -> list[str]: out, grab = [], False for line in fm.splitlines(): if re.match(rf"^{key}:\s*$", line): grab = True continue if grab: if re.match(r"^\S", line): break m = re.match(r"^\s+-\s+(\S.*)$", line) if m: out.append(m.group(1).strip()) return out def check_record(path: str, rep: techlog.Report) -> None: text = open(path, encoding="utf-8").read() fm = _front_matter(text) kind = (re.search(r"^kind:\s*(\S+)", fm, re.M) or [None, ""]) kind = kind.group(1).upper() if hasattr(kind, "group") else "" if kind != "CASE": return # `문제`·`결론` 을 가진 종류는 CASE 뿐이다 secs = _sections(text) core = " ".join(secs.get(k, "") for k in CORE_FIELDS) perf, cmpw = PERF.search(core), COMPARE.search(core) if not (perf and cmpw): return if _listed(fm, "evidence"): return rel = os.path.relpath(path, ROOT) rep.error("핵심이 측정을 주장하는데 근거 목록이 비었다", f"{rel} — 「{perf.group(0)}」·「{cmpw.group(0)}」 이 핵심에 있는데 " f"evidence 가 없다") def verify(project: str) -> tuple[techlog.Report, str | None]: rep = techlog.Report(project) studio = os.path.join(ROOT, "docs", project, "tech-log-studio") if not os.path.isfile(os.path.join(studio, "tech-log-tree.json")): return rep, "tech-log-tree.json 이 없다" records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md")) if not f.split(os.sep)[-3].startswith("_")] rep.facts["기록"] = len(records) for f in records: check_record(f, rep) return rep, None def main() -> int: ap = argparse.ArgumentParser(description="핵심이 측정을 주장하는데 근거가 있는지 본다.") ap.add_argument("projects", nargs="*") ap.add_argument("--file", action="append", default=[], help="프로젝트 대신 기록 .md 를 직접 준다") ap.add_argument("--samples", type=int, default=3) args = ap.parse_args() if args.file: rep = techlog.Report("파일") rep.facts["기록"] = len(args.file) for f in args.file: if not os.path.isfile(f): print(f"대상이 성립하지 않는다 — 그런 파일이 없다: {f}", file=sys.stderr) return 2 check_record(os.path.abspath(f), rep) reports = [rep] for r in reports: print(f"\n[{r.project}] " + " · ".join(f"{k}={v}" for k, v in r.facts.items())) for rule, details in sorted(r.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"\nCORE SUPPORT: {'FAIL' if e else 'PASS'} — 기록 {len(args.file)} · error {e}") return 1 if e else 0 projects = args.projects or sorted( os.path.basename(os.path.dirname(p)) for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio")) if not os.path.basename(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 reports = [] for p in projects: rep, why = verify(p) if why: print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr) return 2 reports.append(rep) for rep in reports: facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items()) 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"\nCORE SUPPORT: {'FAIL' if e else 'PASS'} — 프로젝트 {len(reports)} · error {e}") return 1 if e else 0 if __name__ == "__main__": raise SystemExit(main())