Files
document-haness/scripts/check-core-support.py
T
DongHyeonkaandClaude Opus 5 ad055fb3b9 feat(scripts): 핵심이 측정을 주장하는데 근거 목록이 비었는지 본다
후보를 둘 버렸다. 처음에 「핵심의 수치가 근거에 없으면」으로 만들었는데 사례에 아라비아
숫자가 하나도 없다 — 「응답시간의 p99 가 절반이 됐다」. 275건에 돌려 오탐 0 · 검출 0 이었다.

두 번째는 비교 낱말만 봤다. 275건에서 둘이 걸렸고 둘 다 오탐이었다 — 「계약의 절반은
라우트 레지스트리에서 유도한다」·「가드는 절반만 존재합니다」. 한국어에서 「절반」은
측정이 아닌 쓰임이 흔하다.

그래서 성능을 재는 명사와 비교하는 말이 함께 나올 때만 측정 주장으로 본다. 그 주장을
하면서 evidence 가 비어 있으면 핵심에 근거가 없는 모양이다. 근거를 대면 통과한다 —
막는 것은 주장이 아니라 근거 없는 주장이다.

경고를 만드는 것으로 끝내지 않고 review-package 의 관문에 넣었다. 관문이 exit≠0 이면
studio-save 의 approved() 가 그 묶음을 거절한다. 만드는 것과 막는 것은 둘 다 있어야
한 쌍이다.

저장소 실제 기록 275건에 하나도 안 걸린다. 문제·결론에 수치를 적는 건 정상 기록이 늘
하는 일이라 여기가 과잉 차단이 가장 나기 쉬운 자리다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 17:20:47 +09:00

152 lines
6.1 KiB
Python

#!/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 = "<!-- 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("--samples", type=int, default=3)
args = ap.parse_args()
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())