Files
document-haness/scripts/tests/test_preservation.py
T
DongHyeonkaandClaude Opus 5 87d70e7c80 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
2026-09-10 17:40:02 +09:00

323 lines
16 KiB
Python

"""윤문 전후에 보호 구간이 그대로인지 보는 검사기.
정상 윤문은 통과하고, 수치·코드·인용·URL 을 건드린 편집은 걸린다.
유보 표현이 줄어든 것은 걸러 내되 판정하지 않는다 — 판단은 근거를 읽는 검토가 한다.
"""
import importlib.util
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
F = os.path.join(ROOT, "scripts", "tests", "fixtures", "preservation")
_spec = importlib.util.spec_from_file_location(
"check_preservation", os.path.join(ROOT, "scripts", "check-preservation.py"))
cp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cp)
def _read(name):
return open(os.path.join(F, name), encoding="utf-8").read()
class PreservationTest(unittest.TestCase):
def setUp(self):
self.before = _read("before.md")
def test_an_ordinary_rewrite_passes(self):
res = cp.compare(self.before, _read("after-ok.md"))
self.assertEqual([], res["findings"])
self.assertEqual([], res["hedgesDropped"])
def test_a_number_changed_in_prose_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
values = {f["value"] for f in res["findings"] if f["kind"] == "수치"}
self.assertIn("14ms", values)
self.assertIn("4ms", values)
def test_a_number_changed_inside_a_code_block_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
blocks = {f["value"] for f in res["findings"] if f["kind"] == "코드블록"}
self.assertTrue(any("exit=1" in b for b in blocks))
self.assertTrue(any("exit=0" in b for b in blocks))
def test_a_changed_direct_quotation_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("직접인용", kinds)
def test_a_changed_url_is_caught(self):
res = cp.compare(self.before, _read("after-tampered.md"))
kinds = {f["kind"] for f in res["findings"]}
self.assertIn("URL", kinds)
def test_dropped_hedges_are_surfaced_without_a_verdict(self):
"""유보가 줄면 낸다. 옳은지 그른지는 말하지 않는다."""
res = cp.compare(self.before, _read("after-tampered.md"))
dropped = {h["word"] for h in res["hedgesDropped"]}
self.assertIn("확인하지 못했다", dropped)
self.assertIn("로컬", dropped)
self.assertEqual(0, res["hedgeTotalAfter"])
def test_adding_a_hedge_is_not_reported(self):
"""유보를 더하는 것은 이 규범에서 안전한 쪽이다."""
after = self.before.replace("14ms 였다", "14ms 였다. 다만 한 번만 쟀다")
res = cp.compare(self.before, after)
self.assertEqual([], res["hedgesDropped"])
def test_a_whole_section_removed_is_surfaced(self):
"""절을 통째로 지우면 안에 보호 구간이 없어도 보여야 한다.
「확인하지 못한 것」 절을 지우는 편집이 그 모양이다. 낱말 계수만 보면
아무것도 안 움직여서 경고가 비고, 「경고가 비면 자동 통과」로 그대로 지나간다.
"""
before = ("# 제목\n\n요약이다.\n\n## 결론\n\n값은 14ms 였다.\n\n"
"## 확인하지 못한 것\n\n다른 환경에서는 세 보지 않았다.\n")
after = "# 제목\n\n요약이다.\n\n## 결론\n\n값은 14ms 였다.\n"
res = cp.compare(before, after)
self.assertEqual([], res["errors"], "절 삭제는 error 가 아니다")
gone = [w["value"] for w in res["warnings"] if w["kind"] == "절"]
self.assertIn("## 확인하지 못한 것", gone)
def test_a_section_that_stays_is_not_reported(self):
"""대조군. 절이 그대로면 아무것도 안 나온다."""
text = "# 제목\n\n요약이다.\n\n## 결론\n\n값은 14ms 였다.\n"
self.assertEqual([], cp.compare(text, text)["warnings"])
def test_limitation_hedges_are_counted(self):
"""「안 했다」로 한계를 밝히는 말도 유보다.
목록이 추정·가능성 쪽에만 몰려 있으면 한계를 적은 문장을 지워도 계수가 안 움직인다.
"""
for word in ("세 보지 않았다", "돌려 보지 않았다", "재 보지 않았다", "확인 안 했다"):
with self.subTest(word=word):
before = f"값은 14ms 였다. 다른 환경에서는 {word}."
after = "값은 14ms 였다."
dropped = {h["word"] for h in cp.compare(before, after)["hedgesDropped"]}
self.assertIn(word, dropped)
def test_a_missing_file_is_not_reported_as_clean(self):
import subprocess
p = subprocess.run(
["python3", os.path.join(ROOT, "scripts", "check-preservation.py"),
os.path.join(F, "before.md"), os.path.join(F, "nope.md")],
cwd=ROOT, capture_output=True, text=True)
self.assertEqual(2, p.returncode)
if __name__ == "__main__":
unittest.main()
class KoreanNumeralTest(unittest.TestCase):
"""한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다.
이 배치에서 실제로 한 번 놓쳤고 사람이 잡았다.
"""
def test_a_changed_korean_numeral_is_an_error(self):
for before, after in (("증거 다섯 개", "증거 여섯 개"),
("문장 세 줄", "문장 네 줄"),
("열두 건", "열세 건")):
with self.subTest(before=before):
self.assertTrue(cp.compare(before, after)["errors"])
def test_a_particle_after_the_counter_does_not_hide_the_change(self):
"""세는 말 뒤에는 거의 항상 조사가 붙는다.
뒤를 `(?![가-힣])` 로 막으면 그 자리가 전부 안 걸린다. 아라비아 숫자 쪽에서
`(?![\w.-])` 때문에 `500행`·`5개다` 를 놓쳤던 것과 같은 모양이고, 그 고침을
한글로 옮기면서 다시 넣었다. **회귀 문구가 전부 조사 없이 끝나서 초록이었다.**
"""
for a, b in (("증거는 다섯 개다.", "증거는 여섯 개다."),
("실험 다섯 건을 돌렸다", "실험 여섯 건을 돌렸다"),
("그림 여섯 장이 있다", "그림 일곱 장이 있다"),
("스무 곳을 봤다", "서른 곳을 봤다"),
("두 번째다", "세 번째다")):
with self.subTest(a=a):
self.assertTrue(cp.compare(a, b)["errors"], f"{a}{b} 를 놓쳤다")
def test_a_long_numeral_is_matched_by_rule_not_by_luck(self):
"""대안을 짧은 것부터 두면 `열다섯` 이 `열` 로 먼저 걸려 실패한다."""
self.assertTrue(cp.compare("열다섯 개다", "열여섯 개다")["errors"])
def test_changing_only_the_counter_is_not_a_number_change(self):
"""`여덟 자리 → 여덟 곳`. 숫자가 안 바뀌었다.
채택된 편집이고 `check_prose` 의 spatial-metaphor 를 고치는 정상 편집이다.
「수사+세는 말」을 한 덩어리로 잡으면 이것이 수치 변경으로 읽힌다 — 되돌리면
이 회귀가 잡는다.
"""
res = cp.compare("여덟 자리를 목록으로 확정하고", "여덟 곳을 목록으로 확정하고")
self.assertEqual([], [x for x in res["errors"] if x["kind"] == "한글수사"])
def test_controls_where_the_surrounding_text_changes(self):
"""대조군은 **주변이 바뀌는 쌍**이어야 한다.
같은 문자열은 어떤 검사기든 조용하다 — 「입력이 없으면 출력도 없다」를 보일 뿐이고
검사기가 무엇을 보는지는 말해 주지 않는다. 이 대조군이 없었으면 「수사+세는 말」을
한 덩어리로 잡던 첫 후보의 오탐을 못 봤다.
"""
for a, b in (("한편으로는 그렇다", "한편으로는 아니다"),
("하나뿐이다", "하나뿐이라고 적었다"),
("세종대왕이 만들었다", "세종대왕이 반포했다"),
("한계가 있다", "한계가 뚜렷하다"),
("두 번째 줄을 본다", "두 번째 칸을 본다"),
("네트워크 경계를 본다", "네트워크 계층을 본다"),
("개발자 한편의 글", "개발자 한편의 기록"),
("세 곳을 봤다", "세 곳을 다시 봤다")):
with self.subTest(a=a):
res = cp.compare(a, b)
self.assertEqual([], [x for x in res["errors"] + res["warnings"]
if x["kind"] == "한글수사"], f"{a}{b} 오탐")
class UnsourcedVoiceTest(unittest.TestCase):
"""자료에 없는 1인칭. 보호 구간 비교로는 원리적으로 안 보이던 자리다."""
BEFORE = "피드 아이템을 엔티티로 조회한 뒤 메모리에서 DTO로 옮기는 코드다."
def test_added_first_person_experience_is_surfaced_as_a_warning(self):
# 시험 문구에 수사를 넣지 않는다. `한 번` 을 넣었더니 한글수사 규칙이 옳게 걸렸고,
# 이 시험이 보려는 것(1인칭 검출)과 섞였다
after = ("처음에는 조인으로 가져올 거라고 믿었다. "
"팀에서는 다른 의견이 많았지만 나는 조회 방식을 먼저 보자고 했다.\n\n"
+ self.BEFORE)
res = cp.compare(self.BEFORE, after)
self.assertEqual([], res["errors"], "판정이 아니라 라우팅이다")
marks = {w["value"] for w in res["warnings"] if w["kind"] == "1인칭"}
self.assertTrue({"처음에는", "믿었다", "팀에서는"} <= marks, marks)
def test_the_warning_points_at_the_sentence(self):
after = "처음에는 그렇게 믿었다.\n\n" + self.BEFORE
w = next(x for x in cp.compare(self.BEFORE, after)["warnings"]
if x["kind"] == "1인칭")
self.assertIn("처음에는", w["sentence"])
def test_removing_first_person_is_not_reported(self):
"""지우는 것은 이 규범에서 안전한 쪽이다."""
after = "처음에는 그렇게 믿었다. " + self.BEFORE
self.assertEqual([], [w for w in cp.compare(after, self.BEFORE)["warnings"]
if w["kind"] == "1인칭"])
def test_an_ordinary_rewrite_raises_no_voice_warning(self):
"""대조군."""
self.assertEqual([], [w for w in cp.compare(self.BEFORE, self.BEFORE)["warnings"]
if w["kind"] == "1인칭"])
class WidenedScopeTest(unittest.TestCase):
"""범위를 넓히는 말이 새로 들어왔나.
지금까지 검사기는 「사라진 것」과 「새로 생긴 보호 구간」을 봤다. **「범위가 넓어진 것」을
보는 자리가 없었다** — 더하기만 하면 유보도 보호 구간도 안 바뀐다.
"""
BEFORE = "PK 조회라 두 쿼리 모두 Index Scan으로 1건을 약 0.02 ms에 가져온다."
def test_adding_a_scope_word_is_surfaced_as_a_warning(self):
after = self.BEFORE.replace("조회라 ", "조회라 운영 환경에서도 ")
res = cp.compare(self.BEFORE, after)
self.assertEqual([], res["errors"], "판정이 아니라 라우팅이다")
self.assertIn("적용 범위", {w["kind"] for w in res["warnings"]})
def test_the_warning_points_at_the_sentence(self):
after = "항상 그렇다.\n\n" + self.BEFORE
w = next(x for x in cp.compare(self.BEFORE, after)["warnings"]
if x["kind"] == "적용 범위")
self.assertIn("항상", w["sentence"])
def test_narrowing_the_scope_is_not_reported(self):
"""좁히는 것은 이 규범에서 안전한 쪽이다."""
wide = "항상 그렇다. " + self.BEFORE
self.assertEqual([], [w for w in cp.compare(wide, self.BEFORE)["warnings"]
if w["kind"] == "적용 범위"])
def test_a_scope_word_that_was_already_there_is_not_reported(self):
"""대조군. 원래 있던 말을 그대로 두고 주변만 고치면 조용해야 한다.
이게 없으면 「운영」이 든 문장을 다듬기만 해도 걸리는지 알 수 없다.
"""
before = "운영 환경에서 잰 값이다. " + self.BEFORE
after = "운영 환경에서 측정한 값이다. " + self.BEFORE
self.assertEqual([], [w for w in cp.compare(before, after)["warnings"]
if w["kind"] == "적용 범위"])
def test_an_ordinary_rewrite_raises_no_scope_warning(self):
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 가 절반이 됐다."))