feat(check-preservation): 편집이 핵심 칸에 측정 주장을 새로 더했는지 본다

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
This commit is contained in:
DongHyeonka
2026-09-10 17:40:02 +09:00
co-authored by Claude Opus 5
parent ad055fb3b9
commit 87d70e7c80
4 changed files with 159 additions and 0 deletions
+21
View File
@@ -114,9 +114,30 @@ def verify(project: str) -> tuple[techlog.Report, str | 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"))
+41
View File
@@ -112,6 +112,29 @@ SCOPE_MARKS = (
"모든 경우", "전부 그렇다", "예외 없이", "무조건", "반드시", "절대",
)
def _core_measurement(text: str):
"""`문제`·`결론` 칸이 측정을 주장하나. (성능 명사, 비교어) 또는 None.
`check-core-support.py` 의 판정을 그대로 쓴다 — 목록을 두 곳에 두면 갈린다.
"""
import importlib.util
path = os.path.join(ROOT, "scripts", "check-core-support.py")
spec = importlib.util.spec_from_file_location("check_core_support", path)
if spec is None or spec.loader is None:
return None
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
secs = SECTION_BODY.findall(text)
core = " ".join(body for name, body in secs if name.strip() in ("문제", "결론"))
if not core.strip():
return None
perf, cmpw = m.PERF.search(core), m.COMPARE.search(core)
return (perf.group(0), cmpw.group(0)) if (perf and cmpw) else None
# `## 이름` 의 이름과 그 아래 내용. 편집 전후의 핵심 칸을 견주는 데 쓴다
SECTION_BODY = re.compile(r"^##[ \t]+(.+?)[ \t]*$\n(.*?)(?=^##[ \t]|\Z)", re.M | re.S)
# `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면
# 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다
SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M)
@@ -171,6 +194,24 @@ def compare(before: str, after: str) -> dict:
"note": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 "
"근거를 읽어야 안다"})
# 편집이 **측정 주장을 새로 더했나.** 있던 것은 안 본다 — 있던 것을 보면 저장소의
# 정상 기록 다수에 걸리고, 그것은 「모든 기록에 걸리는 경고는 어느 기록에 대해서도
# 아무 말을 하지 않는다」로 뺀 것과 같은 모양이 된다.
#
# `check-core-support.py` 는 「근거 목록이 **비었는데** 측정을 주장한다」를 본다.
# 근거가 **차 있는** 기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다
# — **출처가 있다 ≠ 그 출처가 그 주장을 지지한다.** 그 자리를 여기서 검토로 보낸다.
before_claim, after_claim = _core_measurement(before), _core_measurement(after)
if after_claim and not before_claim:
warnings.append({
"kind": "핵심 주장", "change": "새로생김", "count": 1,
"value": f"{after_claim[0]} · {after_claim[1]}",
"sentence": _sentence_of(after, after_claim[0]),
"note": "편집이 핵심 칸에 측정 주장을 더했다. 그 기록이 대는 근거가 이 주장을 "
"지지하는지는 근거를 읽어야 안다 — 출처가 있다는 것과 그 출처가 이 "
"주장을 지지한다는 것은 다르다",
})
# 적용 범위가 넓어졌나. 늘어난 것만 본다 — 좁히는 것은 이 규범에서 안전한 쪽이다
for mark in SCOPE_MARKS:
gained = after.count(mark) - before.count(mark)
+24
View File
@@ -127,3 +127,27 @@ class CoreSupportTest(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class FileModeTest(unittest.TestCase):
"""`--file` 로 기록을 직접 준다. 다른 검사기들이 R13 에서 받은 것과 맞춘다."""
def _cli(self, *args):
return subprocess.run(["python3", TOOL, *args], cwd=ROOT,
capture_output=True, text=True)
def setUp(self):
self.tmp = tempfile.mkdtemp()
def test_a_record_given_directly_is_checked(self):
bad = _record(self.tmp, "운영 피드 응답시간의 p99 가 절반이 됐다.")
self.assertEqual(1, self._cli("--file", bad).returncode)
def test_a_record_with_evidence_passes_in_file_mode(self):
ok = _record(self.tmp, "운영 피드 응답시간의 p99 가 절반이 됐다.", evidence=True)
self.assertEqual(0, self._cli("--file", ok).returncode)
def test_a_missing_file_is_not_reported_as_clean(self):
p = self._cli("--file", os.path.join(self.tmp, "nope.md"))
self.assertEqual(2, p.returncode)
self.assertIn("대상이 성립하지 않는다", p.stderr)
+73
View File
@@ -247,3 +247,76 @@ class WidenedScopeTest(unittest.TestCase):
after = self.BEFORE.replace("가져온다", "돌려준다")
self.assertEqual([], [w for w in cp.compare(self.BEFORE, after)["warnings"]
if w["kind"] == "적용 범위"])
class AddedCoreClaimTest(unittest.TestCase):
"""편집이 핵심 칸에 측정 주장을 **새로 더했나.**
`check-core-support.py` 는 「근거 목록이 **비었는데** 측정을 주장한다」를 본다.
근거가 **차 있는** 기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다 —
**출처가 있다 ≠ 그 출처가 그 주장을 지지한다.**
있던 주장은 안 본다. 보면 저장소의 정상 기록 다수에 걸리고, 그것은 「모든 기록에 걸리는
경고는 어느 기록에 대해서도 아무 말을 하지 않는다」로 뺀 것과 같은 모양이 된다.
"""
HEAD = """---
kind: CASE
slug: x
title: x
evidence:
- ../../../final/evidence/raw/explain.txt
---
# x
요약이다.
## 문제
피드 조회가 느렸다.
## 결론
{c}
## 검증 환경
python 3.12.3
"""
PLAIN = "실행계획에서 Nested Loop 가 사라진 것을 확인했다."
CLAIM = "운영 피드 응답시간의 p99 가 절반이 됐다."
def _hits(self, before, after):
return [w for w in cp.compare(before, after)["warnings"]
if w["kind"] == "핵심 주장"]
def test_an_edit_that_adds_a_measurement_claim_is_surfaced(self):
before = self.HEAD.format(c=self.PLAIN)
after = self.HEAD.format(c=f"{self.PLAIN} 그래서 {self.CLAIM}")
hits = self._hits(before, after)
self.assertEqual(1, len(hits), hits)
self.assertIn("응답시간", hits[0]["value"])
def test_a_claim_that_was_already_there_is_not_reported(self):
"""대조군. 원래 있던 주장을 다듬기만 하면 조용해야 한다."""
before = self.HEAD.format(c=self.CLAIM)
after = self.HEAD.format(c="운영 피드 응답시간의 p99 가 절반으로 줄었다.")
self.assertEqual([], self._hits(before, after))
def test_editing_outside_the_core_fields_is_not_reported(self):
"""대조군. 핵심 칸을 안 건드리면 조용해야 한다."""
before = self.HEAD.format(c=self.PLAIN)
after = before.replace("python 3.12.3", "python 3.12.4")
self.assertEqual([], self._hits(before, after))
def test_removing_a_claim_is_not_reported(self):
"""지우는 것은 이 규범에서 안전한 쪽이다."""
before = self.HEAD.format(c=f"{self.PLAIN} 그래서 {self.CLAIM}")
after = self.HEAD.format(c=self.PLAIN)
self.assertEqual([], self._hits(before, after))
def test_it_does_not_fire_on_texts_without_core_fields(self):
"""`문제`·`결론` 이 없는 글에는 안 건다 — 채택 편집 100쌍이 그 모양이다."""
self.assertEqual([], self._hits("응답시간이 느리다.",
"운영 피드 응답시간의 p99 가 절반이 됐다."))