diff --git a/scripts/check-core-support.py b/scripts/check-core-support.py new file mode 100644 index 0000000..db81ece --- /dev/null +++ b/scripts/check-core-support.py @@ -0,0 +1,151 @@ +#!/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("--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()) diff --git a/scripts/review-package.py b/scripts/review-package.py index fec82b1..67b15ba 100644 --- a/scripts/review-package.py +++ b/scripts/review-package.py @@ -207,6 +207,10 @@ def build(project: str, record: str, figures_dir: str | None = None, _run(["python3", "scripts/verify-project-layout.py", project]), _run(["python3", "scripts/audit-records.py", project]), _run(["python3", "scripts/check-required-content.py", project]), + # 핵심이 측정을 주장하는데 근거 목록이 비었나. 관문이라 exit≠0 이면 + # `studio-save.py` 의 `approved()` 가 이 묶음을 거절한다 — + # **경고를 만드는 것과 그것으로 막는 것은 둘 다 있어야 한 쌍이다** + _run(["python3", "scripts/check-core-support.py", project]), _run(["node", ".agents/skills/writing-tech-log-records/scripts/check_evidence.mjs", project, "--repo"]), _run(["node", ".agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs", diff --git a/scripts/tests/test_core_support.py b/scripts/tests/test_core_support.py new file mode 100644 index 0000000..c7e21f2 --- /dev/null +++ b/scripts/tests/test_core_support.py @@ -0,0 +1,129 @@ +"""글의 핵심이 측정을 주장하는데 근거 목록이 비었는지 보는 검사기. + +후보를 둘 버리고 셋째가 맞았다. 그 과정을 회귀로 남긴다 — 다음 사람이 되돌리면 잡힌다. +""" +import importlib.util +import os +import subprocess +import tempfile +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TOOL = os.path.join(ROOT, "scripts", "check-core-support.py") +_spec = importlib.util.spec_from_file_location("check_core_support", TOOL) +cs = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cs) + +HEAD = """--- +kind: CASE +slug: x +title: x +sourceRevision: 0000000000000000000000000000000000000000 +{evidence}--- + +# x + +요약 문단이다. + +## 관계 + +- **다른 기록** + 왜 관계인지. + +## 문제 + +{problem} + +## 결론 + +{conclusion} + +## 검증 환경 + +python 3.12.3 + +## 재현 조건 + +1. 돌린다. + +## 본문 + + + +## x + +본문이다. + + +""" + + +def _record(tmp, conclusion, problem="느렸다.", evidence=False): + ev = "evidence:\n - ../../../final/evidence/raw/x.txt\n" if evidence else "" + path = os.path.join(tmp, "r.md") + open(path, "w", encoding="utf-8").write( + HEAD.format(evidence=ev, problem=problem, conclusion=conclusion)) + return path + + +def _errors(path): + rep = cs.techlog.Report("t") + cs.check_record(path, rep) + return rep + + +class CoreSupportTest(unittest.TestCase): + CLAIM = "프로젝션 전환으로 운영 피드 응답시간의 p99 가 절반이 됐다." + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_the_case_is_caught(self): + """`B2-core-unsupported` 의 문장 그대로다. 아라비아 숫자가 하나도 없다.""" + rep = _errors(_record(self.tmp, self.CLAIM)) + self.assertEqual(1, rep.error_count) + self.assertIn("핵심이 측정을 주장하는데 근거 목록이 비었다", " / ".join(rep.errors)) + + def test_the_same_claim_with_evidence_passes(self): + """근거를 대면 통과한다. 막는 것은 주장이 아니라 **근거 없는 주장**이다.""" + self.assertEqual(0, _errors(_record(self.tmp, self.CLAIM, evidence=True)).error_count) + + def test_a_comparison_word_alone_is_not_a_measurement(self): + """「절반」은 한국어에서 측정이 아닌 쓰임이 흔하다. + + 저장소의 기록 275건에 비교 낱말만으로 걸었더니 둘이 걸렸고 **둘 다 이 모양**이었다. + 성능을 재는 명사와 함께 나올 때만 측정 주장으로 본다. + """ + for text in ("서빙 계약의 절반은 라우트 레지스트리에서 유도하고 있었다.", + "사람이 기억해서 돌리는 가드는 절반만 존재합니다.", + "두 배로 늘어난 파일을 나눠 담았다."): + with self.subTest(text=text): + self.assertEqual(0, _errors(_record(self.tmp, text)).error_count) + + def test_a_performance_noun_alone_is_not_a_claim(self): + """재는 이름만 나오고 견주는 말이 없으면 측정 주장이 아니다.""" + self.assertEqual( + 0, _errors(_record(self.tmp, "응답 시간을 함께 적어 둔다.")).error_count) + + def test_kinds_other_than_case_are_not_checked(self): + """`문제`·`결론` 을 가진 종류는 CASE 뿐이다.""" + path = _record(self.tmp, self.CLAIM) + text = open(path, encoding="utf-8").read().replace("kind: CASE", "kind: CONCEPT") + open(path, "w", encoding="utf-8").write(text) + self.assertEqual(0, _errors(path).error_count) + + def test_every_record_in_this_repository_passes(self): + """대조군. `문제`·`결론` 에 수치를 적는 건 정상 기록이 늘 하는 일이라 + 여기가 과잉 차단이 가장 나기 쉬운 자리다.""" + p = subprocess.run(["python3", TOOL, "--samples", "1"], cwd=ROOT, + capture_output=True, text=True) + self.assertEqual(0, p.returncode, p.stdout + p.stderr) + + def test_a_missing_project_is_not_reported_as_clean(self): + p = subprocess.run(["python3", TOOL, "nosuchxyz"], cwd=ROOT, + capture_output=True, text=True) + self.assertEqual(2, p.returncode) + + +if __name__ == "__main__": + unittest.main()