fix: 고친 자리에 같은 버그를 다시 넣었고, 게이트가 관측 창을 덮었다

한글 수사의 뒤 경계가 세는 말 다음의 조사를 낱말의 일부로 봤다. 「다섯 개다」·「다섯 건을」·
「여섯 장이」가 전부 안 걸린다. 아라비아 숫자 쪽에서 같은 이유로 「500행」·「5개다」를
놓쳤던 것을 고쳤는데, 그 고침을 한글로 옮기면서 다시 넣었다. 회귀가 초록이었던 건 시험
문구가 전부 조사 없이 끝나서다.

뒤 경계를 풀었더니 채택된 편집 둘이 새로 막혔다. 「여덟 자리 → 여덟 곳」이다. 숫자가 안
바뀌었고 세는 말이 바뀌었다 — spatial-metaphor 를 고치는 정상 편집이다. 그래서 잡는 것을
수사로 좁히고 세는 말은 문맥으로만 본다. 대안도 긴 것부터로 정렬했다.

대조군을 주변이 바뀌는 쌍으로 다시 짰다. 같은 문자열은 어떤 검사기든 조용해서 대조가 되지
않는다. 그 대조군이 없었으면 위 오탐을 못 봤다.

그리고 저장 게이트가 그림 붙은 기록을 전부 막고 있었다. 저장소의 그림에 종류 표시가 하나도
없어서 그림이 붙으면 무조건 경고가 하나 붙는다. 게이트가 틀린 게 아니라 그 경고가 어느
기록에 대한 정보도 아니다 — 모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지
않는다. 저장소 전체의 미비는 세고 보고하되 저장을 막지 않는다. 조용히 빼지도 않는다.

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 16:15:04 +09:00
co-authored by Claude Opus 5
parent 20c8535169
commit 16cbe141a0
4 changed files with 146 additions and 13 deletions
+18 -6
View File
@@ -41,13 +41,25 @@ EXTRACTORS: dict[str, re.Pattern[str]] = {
# `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서 # `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서
# 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다. # 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다.
# 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다 # 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다
# 한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다. 실제로 이 배치에서 # 한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다.
# 한 번 놓쳤고 사람이 잡았다. 세는 말(개·건·장·줄…)이 뒤따르는 자리만 본다 — #
# 「하나」·「둘」은 「하나뿐」·「둘 다」처럼 수가 아닌 쓰임이 많다 # **뒤를 막지 않는다.** 세는 말 뒤에는 거의 항상 조사가 붙는다 — `다섯 개다` ·
# `다섯 건을` · `여섯 장이`. `(?![가-힣])` 로 막으면 그 자리가 전부 안 걸린다.
# 아라비아 숫자 쪽에서 `(?![\w.-])` 때문에 `500행`·`5개다` 를 놓쳤던 것과 같은 모양이고,
# 그 고침을 한글로 옮기면서 다시 넣었다. 회귀 문구가 조사 없이 끝나서 초록이었다.
#
# **잡는 것은 수사뿐이고 세는 말은 문맥으로만 본다.** 「수사+세는 말」을 한 덩어리로
# 잡으면 `여덟 자리 → 여덟 곳` 처럼 **세는 말만 바뀐 편집**이 수치 변경으로 읽힌다.
# 그것은 spatial-metaphor 를 고치는 정상 편집이고 실제로 채택된 편집 둘이 그 모양이다.
#
# 대안은 **긴 것부터** 적는다. 짧은 것부터 두면 `열다섯` 이 `열` 로 먼저 걸려 실패한다.
# 순서에 기대는 코드는 다음 사람이 낱말 하나를 더하면 깨진다.
#
# 「하나」·「둘」은 목록에 없다 — `하나뿐`·`둘 다` 처럼 수가 아닌 쓰임이 많다.
"한글수사": re.compile( "한글수사": re.compile(
r"(?<![가-힣])((?:한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열|열세|열|열다섯|" r"(?<![가-힣])((?:열여덟|열일곱|열여섯|열다섯|열아홉|열|열세|열|열|"
r"열여섯|열일곱|열여덟|열아홉|스무|스물한|스물두|서른|마흔|쉰)" r"스물두|스물한|스무|아홉|여덟|일곱|여섯|다섯|서른|마흔|쉰|열|네|세|두|한))"
r"\s*(?:개|건·|건|장|줄|번|가지|편|곳|명|쪽|회|배|단계|자리))(?![가-힣])"), r"(?=\s*(?:개|건|장|줄|번|가지|편|곳|명|쪽|회|배|단계|자리))"),
"수치": re.compile( "수치": re.compile(
r"(?<![0-9A-Za-z_.\-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)" r"(?<![0-9A-Za-z_.\-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)"
r"(?![0-9A-Za-z_.\-])"), r"(?![0-9A-Za-z_.\-])"),
+40 -1
View File
@@ -31,6 +31,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import glob
import hashlib import hashlib
import json import json
import os import os
@@ -133,9 +134,11 @@ def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]:
if kind is None: if kind is None:
warnings.append({ warnings.append({
"id": "그림 종류가 표시돼 있지 않다", "asset": rel, "id": "그림 종류가 표시돼 있지 않다", "asset": rel,
"scope": "repository",
"detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다', "detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다',
"note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. " "note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. "
"설명용을 증거로 읽는 것을 막을 수 없다", "이 기록의 결함이 아니라 저장소 전체의 미비라 저장을 막지 않는다 — "
"모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지 않는다",
}) })
elif kind == "illustrative": elif kind == "illustrative":
warnings.append({ warnings.append({
@@ -248,6 +251,24 @@ def build_input(record_path: str) -> dict:
VERDICTS = ("PASS", "FAIL", "UNKNOWN") VERDICTS = ("PASS", "FAIL", "UNKNOWN")
def _figure_kind_coverage() -> tuple[int, int]:
"""(저장소의 그림 수, 종류 표시가 없는 그림 수).
세는 쪽 경고를 **조용히 빼지 않기 위해** 낸다. 저장을 막지 않는다는 것과 안 보인다는 것은
다르다 — 안 보이면 그것이 또 「본 것만 같다」다.
"""
total = unmarked = 0
for path in glob.glob(os.path.join(ROOT, "docs/*/final/assets/**/*.svg"),
recursive=True):
total += 1
try:
if not FIGURE_KIND.search(open(path, encoding="utf-8", errors="replace").read()):
unmarked += 1
except OSError:
unmarked += 1
return total, unmarked
def _review_gate(warnings: list[dict], verdict_path: str | None, def _review_gate(warnings: list[dict], verdict_path: str | None,
package_path: str) -> list[dict]: package_path: str) -> list[dict]:
"""경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다. """경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다.
@@ -259,7 +280,25 @@ def _review_gate(warnings: list[dict], verdict_path: str | None,
없었다 — **싣는 것과 막는 것은 다른 일이다.** 없었다 — **싣는 것과 막는 것은 다른 일이다.**
경고가 없으면 판정 파일이 없어도 그대로 나간다. 정상은 이 게이트에 안 걸린다. 경고가 없으면 판정 파일이 없어도 그대로 나간다. 정상은 이 게이트에 안 걸린다.
**막는 것과 세는 것을 가른다.** `scope: "repository"` 인 경고는 이 기록에 대한 발견이
아니라 저장소 전체의 미비다 — 그림 종류 표시가 그렇다. 저장소의 그림 어느 것에도 그
표시가 없어서 그림 붙은 기록이면 무조건 걸린다. **모든 기록에 걸리는 경고는 어느 기록에
대해서도 아무 말을 하지 않는다.** 그것으로 막으면 게이트의 첫 실사용에서 그림 붙은
기록이 전부 막히고, 그러면 사람이 게이트를 우회하기 시작한다.
세는 쪽도 **조용히 빼지 않는다.** 몇 건인지 함께 낸다.
""" """
blocking = [w for w in warnings if w.get("scope") != "repository"]
counted = [w for w in warnings if w.get("scope") == "repository"]
if counted:
print(f"세는 경고 {len(counted)}건 — 저장소 전체의 미비라 저장을 막지 않는다: "
+ " · ".join(sorted({w["id"] for w in counted})), file=sys.stderr)
if any(w["id"] == "그림 종류가 표시돼 있지 않다" for w in counted):
total, unmarked = _figure_kind_coverage()
print(f" 이 저장소에 아직 표시 안 된 그림 {unmarked}장이 있다 (그림 {total}장 중). "
f"조용히 빼지 않고 센다", file=sys.stderr)
warnings = blocking
if not warnings: if not warnings:
return [] return []
if not verdict_path: if not verdict_path:
+51 -6
View File
@@ -122,11 +122,54 @@ class KoreanNumeralTest(unittest.TestCase):
with self.subTest(before=before): with self.subTest(before=before):
self.assertTrue(cp.compare(before, after)["errors"]) self.assertTrue(cp.compare(before, after)["errors"])
def test_counting_words_that_are_not_numbers_are_left_alone(self): def test_a_particle_after_the_counter_does_not_hide_the_change(self):
"""대조군. 「하나뿐」·「둘 다」는 수가 아니다. 세면 정상을 막는다.""" """세는 말 뒤에는 거의 항상 조사가 붙는다.
for text in ("하나뿐이다", "둘 다 맞다", "한편으로는 그렇다"):
with self.subTest(text=text): 뒤를 `(?![가-힣])` 로 막으면 그 자리가 전부 안 걸린다. 아라비아 숫자 쪽에서
self.assertEqual([], cp.compare(text, text)["errors"]) `(?![\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): class UnsourcedVoiceTest(unittest.TestCase):
@@ -135,7 +178,9 @@ class UnsourcedVoiceTest(unittest.TestCase):
BEFORE = "피드 아이템을 엔티티로 조회한 뒤 메모리에서 DTO로 옮기는 코드다." BEFORE = "피드 아이템을 엔티티로 조회한 뒤 메모리에서 DTO로 옮기는 코드다."
def test_added_first_person_experience_is_surfaced_as_a_warning(self): def test_added_first_person_experience_is_surfaced_as_a_warning(self):
after = ("처음에는 조인 한 번으로 가져올 거라고 믿었다. " # 시험 문구에 수사를 넣지 않는다. `한 번` 을 넣었더니 한글수사 규칙이 옳게 걸렸고,
# 이 시험이 보려는 것(1인칭 검출)과 섞였다
after = ("처음에는 조인으로 가져올 거라고 믿었다. "
"팀에서는 다른 의견이 많았지만 나는 조회 방식을 먼저 보자고 했다.\n\n" "팀에서는 다른 의견이 많았지만 나는 조회 방식을 먼저 보자고 했다.\n\n"
+ self.BEFORE) + self.BEFORE)
res = cp.compare(self.BEFORE, after) res = cp.compare(self.BEFORE, after)
+37
View File
@@ -381,6 +381,43 @@ class ReviewGateTest(unittest.TestCase):
with self.assertRaises(ss.Refused): with self.assertRaises(ss.Refused):
ss._review_gate(self.WARN, self.book, self.pkg) ss._review_gate(self.WARN, self.book, self.pkg)
def test_a_repository_wide_gap_does_not_block(self):
"""모든 기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지 않는다.
저장소의 그림 어느 것에도 종류 표시가 없어서 그림 붙은 기록이면 무조건 걸린다.
그것으로 막으면 게이트의 첫 실사용에서 그림 붙은 기록이 전부 막히고, 그러면 사람이
게이트를 우회하기 시작한다. **막을 수 없는 게이트보다 나쁜 건 우회당하는 게이트다.**
"""
only_repo = [{"id": "그림 종류가 표시돼 있지 않다", "scope": "repository",
"detail": "", "key": "cccc3333"}]
self.assertEqual([], ss._review_gate(only_repo, None, self.pkg))
def test_a_finding_about_this_record_still_blocks(self):
"""저장소 미비가 섞여 있어도 이 기록에 대한 발견 하나면 막힌다."""
mixed = [{"id": "그림 종류가 표시돼 있지 않다", "scope": "repository",
"detail": "", "key": "cccc3333"},
{"id": "그림이 검토 뒤에 바뀌었다", "detail": "fd5f → 3ba4", "key": "dddd4444"}]
with self.assertRaises(ss.Refused) as e:
ss._review_gate(mixed, None, self.pkg)
self.assertIn("dddd4444", str(e.exception))
self.assertNotIn("cccc3333", str(e.exception),
"세는 경고를 막는 목록에 넣지 않는다")
def test_a_verdict_is_not_needed_for_a_counted_warning(self):
"""세는 경고에는 판정을 요구하지 않는다 — 요구하면 그게 막는 것이다."""
mixed = [{"id": "그림 종류가 표시돼 있지 않다", "scope": "repository",
"detail": "", "key": "cccc3333"},
{"id": "유보 감소", "detail": "가능성 2회 → 1회", "key": "aaaa1111"}]
self._write(["PASS"], keys=["aaaa1111"])
cleared = ss._review_gate(mixed, self.book, self.pkg)
self.assertEqual(["aaaa1111"], [c["key"] for c in cleared])
def test_the_repository_gap_is_counted_not_hidden(self):
"""조용히 빼면 그게 또 「본 것만 같다」다."""
total, unmarked = ss._figure_kind_coverage()
self.assertGreater(total, 100, "그림을 못 찾았다 — 이 셈이 무의미해진다")
self.assertGreaterEqual(total, unmarked)
def test_the_key_changes_when_the_warning_changes(self): def test_the_key_changes_when_the_warning_changes(self):
"""판정이 다른 경고에 옛 판정으로 붙지 않아야 한다.""" """판정이 다른 경고에 옛 판정으로 붙지 않아야 한다."""
import hashlib import hashlib