check-core-support 는 근거 목록이 비었는데 측정을 주장하는 것을 본다. 근거가 차 있는 기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다 — 출처가 있다는 것과 그 출처가 그 주장을 지지한다는 것은 다르다. 「측정 주장이 있으면 경고」로 가지 않았다. 그건 저장소의 정상 기록 다수에 걸리고, 모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지 않는다. 편집이 핵심 칸에 측정 주장을 새로 더했는지만 본다 — 적용 범위 검출과 같은 모양이고 더해진 것을 본다. 판정은 check-core-support 의 목록을 그대로 불러 쓴다. 두 곳에 두면 갈린다. 판정하지 않는다. 근거가 이 주장을 지지하는지는 근거를 읽어야 알고, 그것은 검토의 몫이다. 채택 편집 100쌍에 한 번도 안 걸린다. 그 쌍들은 문제·결론 칸이 없는 조각이라 대상이 아니고, 회귀에 그것도 넣었다. 곁들여 check-core-support 에 --file 을 더했다. 다른 검사기들이 이미 받은 것이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
173 lines
7.1 KiB
Python
173 lines
7.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("--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())
|