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
This commit is contained in:
DongHyeonka
2026-09-10 17:20:47 +09:00
co-authored by Claude Opus 5
parent e3230ce5ed
commit ad055fb3b9
3 changed files with 284 additions and 0 deletions
+151
View File
@@ -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 = "<!-- 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())
+4
View File
@@ -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",
+129
View File
@@ -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. 돌린다.
## 본문
<!-- body:start -->
## x
본문이다.
<!-- body:end -->
"""
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()