남은 셋은 묶음 경고가 0건이라 게이트를 아무리 조여도 지나간다. 뿌리가 하나다 — 지금 검사기는 사라진 것과 새로 생긴 보호 구간을 보고, 범위가 넓어진 것과 정본을 안 거친 것을 보는 자리가 없었다. 그림이 정본을 거쳤는지 본다. SVG 는 바뀌었는데 spec.json 은 그대로면 그 그림은 정본에서 나온 것이 아니고, 화살표 뒤집기가 그 모양이다. spec 의 간선과 SVG 의 경로를 직접 견주려면 렌더러가 id 를 어떻게 붙이는지 알아야 하는데, 정본을 거쳤는지만 보면 몰라도 된다. 작업 트리와 이력 두 자리를 본다. 정본이 없는 그림은 볼 것이 아니라 세기만 한다. 적용 범위를 넓히는 말이 새로 들어왔는지 본다. 로컬에서 확인했다에 운영 환경에서도를 더하기만 하면 유보도 보호 구간도 안 바뀐다 — 지운 것이 없기 때문이다. 유보 감소의 반대편이고, 판정하지 않고 경고로 올린다. 경고 생산자를 더하는 것이 과잉 차단이 생기는 자리라 후보마다 채택 편집 100쌍을 돌렸다. error 도 경고도 안 늘었다. 정상 편집에 경고가 붙으면 그것도 사실상 차단이다. 목록에 운영·항상 같은 흔한 말이 들어가서, 원래 있던 말을 두고 주변만 고쳐도 걸리는지 보는 대조군을 회귀에 넣었다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
250 lines
13 KiB
Python
250 lines
13 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"] == "적용 범위"])
|