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:
co-authored by
Claude Opus 5
parent
20c8535169
commit
16cbe141a0
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user