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:
co-authored by
Claude Opus 5
parent
e3230ce5ed
commit
ad055fb3b9
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user