feat(scripts): 놓친 8건을 코드로 막거나 검토로 보낸다
먼저 쟀다. 경고를 일괄 차단으로 올리면 채택된 편집 9건이 막힌다 — V-004 에서 고친 바로 그 9건이다. 그래서 경고는 검토로 보내고 차단은 검토가 판정할 때 일어나게 뒀다. 코드로 막은 것 셋. code[] 리비전 — check_evidence 는 리비전이 있는지만 보고 인용한 코드가 그 리비전에서 왔는지는 안 본다. 이 배치에서 실제로 났고 사람이 손으로 잡았다. 한글 수사와 그 경계 — 「다섯 개 → 여섯 개」는 결정적이다. 앵커 검사기의 오탐을 0 으로 만드는 데 시간의 절반이 갔다. 처음 판이 저장소 전체에서 401건을 냈고 전부 오탐이었다. code[] 는 한 모양이 아니다 — 심볼, 축약 경로, 줄 범위, 호스트 절대 경로, 설정 키가 섞여 있다. 축약 경로를 「없다」로 세면 있는 코드를 없다고 하는 것이고 그게 채택된 편집 아홉 건을 막았던 실패와 같은 모양이다. 판정할 수 있는 것만 판정하고 못 보는 것은 세어서 낸다. 검토로 보낸 것 다섯. D2 는 아무 계수도 안 움직이던 자리였다 — 수치도 인용도 없이 산문만 더하면 보호 구간 비교에 잡힐 것이 없다. 1인칭 표지가 늘어난 것만 보고 그 문장을 짚어 준다. 판정이 아니라 라우팅이다. 만들다 버그를 찾았다. 경고가 인용하는 문장이 파일 첫 문단에서 한 글자씩 깎이고 있었다. rfind 가 -1 을 낼 때 +2 를 해서 1 이 됐다. 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
230ad874ca
commit
845ee89054
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""기록이 가리키는 코드가 **그 리비전에** 실재하는지 본다.
|
||||||
|
|
||||||
|
`check_evidence.mjs --repo` 는 `sourceRepository.revision` 이 그 저장소에 **있는지**만 본다
|
||||||
|
(`:126`, `git cat-file -e <rev>^{commit}`). **인용한 코드가 그 리비전에서 왔는지는 안 본다.**
|
||||||
|
그래서 「작업 트리에는 있고 지정 커밋에는 없는 파일」을 인용해도 통과한다 — 체크아웃을 그냥
|
||||||
|
읽는 분석이 자동으로 만드는 결함이다.
|
||||||
|
|
||||||
|
이 배치에서 실제로 났다. 기록이 `scripts/capture-evidence.py:72` 를 가리켰는데 그 파일은
|
||||||
|
그 배치가 **만든** 것이라 고정 리비전에 없었고, 관문은 전부 통과했다. 사람이 `git cat-file`
|
||||||
|
을 손으로 쳐서 잡았다. **사람이 한 번 잡은 것과 다음에도 잡히는 것은 다르다.**
|
||||||
|
|
||||||
|
**결정적으로 판정 가능하다** — 그 커밋에 그 경로가 있는지, 줄 번호가 파일 길이 안인지.
|
||||||
|
그래서 코드로 막는다. 내용이 맞는 인용인지는 보지 않는다. 그것은 사람이 읽을 일이다.
|
||||||
|
|
||||||
|
python3 scripts/check-code-anchors.py <프로젝트>
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||||
|
import techlog # noqa: E402
|
||||||
|
|
||||||
|
ANCHOR = re.compile(r"^(?P<path>[^:]+?)(?::(?P<line>\d+))?$")
|
||||||
|
|
||||||
|
|
||||||
|
def _undecidable(rel: str) -> str | None:
|
||||||
|
"""이 앵커를 리비전과 대조할 수 있나. 못 하면 그 사유.
|
||||||
|
|
||||||
|
저장소의 `code[]` 는 한 모양이 아니다 — 심볼(`FeedPersistenceIT.l2Eager…`),
|
||||||
|
축약 경로(`` `.../CaSkeletonApplication.java` ``), 줄 범위(`:141,159-184`),
|
||||||
|
호스트 절대 경로(`/etc/letsencrypt/…`), 설정 키(`refresh:disabled`) 가 섞여 있다.
|
||||||
|
|
||||||
|
**판정할 수 있는 것만 판정한다.** 축약 경로를 「그 리비전에 없다」로 세면 있는 코드를
|
||||||
|
없다고 하는 것이고, 그것이 이 배치에서 채택된 편집 아홉 건을 막았던 실패와 같은 모양이다.
|
||||||
|
못 보는 것은 세어서 낸다 — 조용히 건너뛰면 「전부 맞다」가 「본 것만 맞다」를 가린다.
|
||||||
|
"""
|
||||||
|
if not LOOKS_LIKE_PATH.search(rel):
|
||||||
|
return "심볼이거나 경로가 아니다"
|
||||||
|
if "/" not in rel:
|
||||||
|
# 이름만 있는 것은 저장소 어디에 있는지 말하지 않는다. 뿌리에 있다고 가정하면
|
||||||
|
# 있는 파일을 없다고 한다 — TechLog 의 앵커 여덟이 그 모양이었다
|
||||||
|
return "폴더 없이 파일 이름만 있다"
|
||||||
|
if "..." in rel or "…" in rel:
|
||||||
|
return "축약된 경로다"
|
||||||
|
if rel.startswith("/") or rel.startswith("~"):
|
||||||
|
return "저장소 밖의 절대 경로다"
|
||||||
|
if any(ch in rel for ch in " `\"'"):
|
||||||
|
return "따옴표·백틱·공백이 섞여 있다"
|
||||||
|
if rel.endswith("/"):
|
||||||
|
return "폴더를 가리킨다"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 계약의 `code[]` 는 `<path>:<line-or-symbol>` 이다. **심볼 앵커는 파일 경로가 아니다** —
|
||||||
|
# `FeedPersistenceIT.l2EagerToOneFires…` 같은 것을 경로로 읽으면 있는 코드를 없다고 한다.
|
||||||
|
# 경로로 보이는 것만 대조하고 나머지는 「못 대조한 앵커」로 센다. 세지 않고 넘기면
|
||||||
|
# 「전부 맞다」가 「본 것만 맞다」를 가린다
|
||||||
|
LOOKS_LIKE_PATH = re.compile(r"[/\\]|\.(?:java|kt|py|ts|tsx|js|mjs|go|rs|sql|ya?ml|json|xml|"
|
||||||
|
r"gradle|properties|md|sh|toml|cfg|conf)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: str, *args: str) -> tuple[int, str]:
|
||||||
|
try:
|
||||||
|
p = subprocess.run(["git", "-C", repo, *args],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
return 127, str(e)
|
||||||
|
return p.returncode, p.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def verify(project: str) -> tuple[techlog.Report, str | None]:
|
||||||
|
rep = techlog.Report(project)
|
||||||
|
base = os.path.join(ROOT, "docs", project)
|
||||||
|
index_path = os.path.join(base, "tech-log-studio", "tech-log-tree.json")
|
||||||
|
index = techlog.load_index(index_path)
|
||||||
|
if index is None:
|
||||||
|
return rep, "tech-log-tree.json 이 없다"
|
||||||
|
|
||||||
|
repos = index.get("sourceRepository") or {}
|
||||||
|
repos = repos if isinstance(repos, list) else [repos]
|
||||||
|
checked = skipped = 0
|
||||||
|
for _topic, _kind, node in techlog.nodes(index):
|
||||||
|
anchors = node.get("code") or []
|
||||||
|
if not anchors:
|
||||||
|
continue
|
||||||
|
for r in repos:
|
||||||
|
path, rev = r.get("path"), r.get("revision")
|
||||||
|
if not path or not os.path.isdir(path):
|
||||||
|
rep.warn("저장소가 이 기계에 없다", f"{r.get('name') or project}: {path}")
|
||||||
|
skipped += len(anchors)
|
||||||
|
continue
|
||||||
|
if not rev:
|
||||||
|
rep.warn("리비전이 없어 대조하지 못한다",
|
||||||
|
f"{r.get('name') or project} — sourceRepository.revision 이 비었다")
|
||||||
|
skipped += len(anchors)
|
||||||
|
continue
|
||||||
|
for anchor in anchors:
|
||||||
|
m = ANCHOR.match(anchor.strip())
|
||||||
|
if not m:
|
||||||
|
skipped += 1
|
||||||
|
rep.warn("대조하지 못한 앵커 — 형식을 못 읽겠다", anchor[:70])
|
||||||
|
continue
|
||||||
|
rel, line = m.group("path"), m.group("line")
|
||||||
|
why = _undecidable(rel)
|
||||||
|
if why:
|
||||||
|
skipped += 1
|
||||||
|
rep.warn(f"대조하지 못한 앵커 — {why}", f"{node.get('slug')} — {anchor[:70]}")
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
code, blob = _git(path, "cat-file", "-e", f"{rev}:{rel}")
|
||||||
|
if code != 0:
|
||||||
|
rep.error("인용한 코드가 그 리비전에 없다",
|
||||||
|
f"{node.get('slug')} — {rel} @ {rev[:8]}")
|
||||||
|
continue
|
||||||
|
if line:
|
||||||
|
code, text = _git(path, "show", f"{rev}:{rel}")
|
||||||
|
if code == 0 and int(line) > len(text.splitlines()):
|
||||||
|
rep.error("인용한 줄이 그 리비전의 파일 길이를 넘는다",
|
||||||
|
f"{node.get('slug')} — {rel}:{line} @ {rev[:8]} "
|
||||||
|
f"(그 커밋에서 {len(text.splitlines())}줄)")
|
||||||
|
rep.facts["대조한 앵커"] = checked
|
||||||
|
if skipped:
|
||||||
|
rep.facts["못 대조한 앵커"] = skipped
|
||||||
|
return rep, None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="인용한 코드가 그 리비전에 실재하는지 본다.")
|
||||||
|
ap.add_argument("projects", nargs="*")
|
||||||
|
ap.add_argument("--samples", type=int, default=3)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
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"))
|
||||||
|
if not os.path.basename(os.path.dirname(p)).startswith("_"))
|
||||||
|
missing = [p for p in projects if not os.path.isdir(os.path.join(ROOT, "docs", p))]
|
||||||
|
if missing:
|
||||||
|
print(f"대상이 성립하지 않는다 — 그런 프로젝트가 없다: {', '.join(missing)}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
reports = []
|
||||||
|
for p in projects:
|
||||||
|
rep, why = verify(p)
|
||||||
|
if why:
|
||||||
|
print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
reports.append(rep)
|
||||||
|
|
||||||
|
for rep in reports:
|
||||||
|
facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items()) or "앵커 없음"
|
||||||
|
print(f"\n[{rep.project}] {facts}")
|
||||||
|
for label, bucket, mark in (("error", rep.errors, "✗"), ("warn", rep.warns, "!")):
|
||||||
|
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
|
||||||
|
print(f" {mark} {label} {len(details):>4} {rule}")
|
||||||
|
for d in details[:args.samples]:
|
||||||
|
print(f" · {d}")
|
||||||
|
e = sum(r.error_count for r in reports)
|
||||||
|
print(f"\nCODE ANCHORS: {'FAIL' if e else 'PASS'} — 프로젝트 {len(reports)} · error {e}")
|
||||||
|
return 1 if e else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -41,6 +41,13 @@ EXTRACTORS: dict[str, re.Pattern[str]] = {
|
|||||||
# `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서
|
# `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서
|
||||||
# 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다.
|
# 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다.
|
||||||
# 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다
|
# 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다
|
||||||
|
# 한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다. 실제로 이 배치에서
|
||||||
|
# 한 번 놓쳤고 사람이 잡았다. 세는 말(개·건·장·줄…)이 뒤따르는 자리만 본다 —
|
||||||
|
# 「하나」·「둘」은 「하나뿐」·「둘 다」처럼 수가 아닌 쓰임이 많다
|
||||||
|
"한글수사": re.compile(
|
||||||
|
r"(?<![가-힣])((?:한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두|열세|열네|열다섯|"
|
||||||
|
r"열여섯|열일곱|열여덟|열아홉|스무|스물한|스물두|서른|마흔|쉰)"
|
||||||
|
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_.\-])"),
|
||||||
@@ -64,6 +71,21 @@ HEDGES = (
|
|||||||
"하지 않았다", "않은 것", "못 한 것",
|
"하지 않았다", "않은 것", "못 한 것",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 자료에 없는 1인칭 경험·선택 이유. **늘어난 것만** 본다.
|
||||||
|
#
|
||||||
|
# 이것은 판정이 아니라 **라우팅**이다. 「처음에는 …라고 믿었다」가 자료에 있으면 정상이고
|
||||||
|
# 없으면 지어낸 것인데, 있는지 없는지는 상류 자료를 읽어야 안다. 검사기가 가릴 수 없다.
|
||||||
|
# 그래서 error 가 아니라 warning 이고, 무엇을 보라고 문장까지 짚어 준다.
|
||||||
|
#
|
||||||
|
# 보호 구간 비교로는 원리적으로 안 보이던 자리다 — 수치도 인용도 없이 산문만 더하면
|
||||||
|
# 어느 계수도 안 움직인다. 이 배치에서 그 사례가 관문 여덟을 전부 통과했다.
|
||||||
|
VOICE_MARKS = (
|
||||||
|
"처음에는", "처음엔", "믿었다", "믿었는데", "고민", "고민 끝에", "놀랍", "당황",
|
||||||
|
"돌이켜", "그제서야", "그제야", "보고서야", "뒤늦게", "뼈아", "아찔",
|
||||||
|
"팀에서는", "팀은", "동료", "우리는", "나는", "내가", "직접 겪", "경험상",
|
||||||
|
"시행착오", "삽질", "헤맸", "덕분에", "다행히", "결국 내",
|
||||||
|
)
|
||||||
|
|
||||||
# `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면
|
# `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면
|
||||||
# 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다
|
# 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다
|
||||||
SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M)
|
SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M)
|
||||||
@@ -85,7 +107,10 @@ def _sentence_of(text: str, needle: str) -> str:
|
|||||||
i = text.find(needle)
|
i = text.find(needle)
|
||||||
if i < 0:
|
if i < 0:
|
||||||
return ""
|
return ""
|
||||||
start = max(text.rfind("\n\n", 0, i) + 2, 0)
|
# rfind 가 -1 을 낼 때 +2 를 하면 1 이 되어 첫 글자를 잘라먹는다.
|
||||||
|
# 파일 첫 문단의 경고가 전부 한 글자씩 깎여 나갔다
|
||||||
|
at = text.rfind("\n\n", 0, i)
|
||||||
|
start = 0 if at < 0 else at + 2
|
||||||
stop = text.find("\n\n", i)
|
stop = text.find("\n\n", i)
|
||||||
chunk = text[start:stop if stop > 0 else len(text)]
|
chunk = text[start:stop if stop > 0 else len(text)]
|
||||||
return re.sub(r"\s+", " ", chunk).strip()[:200]
|
return re.sub(r"\s+", " ", chunk).strip()[:200]
|
||||||
@@ -120,6 +145,17 @@ def compare(before: str, after: str) -> dict:
|
|||||||
"note": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 "
|
"note": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 "
|
||||||
"근거를 읽어야 안다"})
|
"근거를 읽어야 안다"})
|
||||||
|
|
||||||
|
# 자료에 없는 1인칭이 들어왔나. 늘어난 것만 본다 — 지우는 것은 이 규범에서 안전한 쪽이다
|
||||||
|
for mark in VOICE_MARKS:
|
||||||
|
gained = after.count(mark) - before.count(mark)
|
||||||
|
if gained > 0:
|
||||||
|
warnings.append({
|
||||||
|
"kind": "1인칭", "change": "새로생김", "count": gained, "value": mark,
|
||||||
|
"sentence": _sentence_of(after, mark),
|
||||||
|
"note": "자료에 이 사람의 행동·판단이 남아 있는지 상류에서 확인한다. "
|
||||||
|
"없으면 지어낸 것이고, 있으면 정상이다 — 검사기가 가릴 수 없다",
|
||||||
|
})
|
||||||
|
|
||||||
hb, ha = _hedges(before), _hedges(after)
|
hb, ha = _hedges(before), _hedges(after)
|
||||||
hedge = []
|
hedge = []
|
||||||
for w in sorted(hb - ha):
|
for w in sorted(hb - ha):
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""인용한 코드가 그 리비전에 실재하는지 보는 검사기.
|
||||||
|
|
||||||
|
`check_evidence.mjs --repo` 는 리비전이 **있는지**만 본다. 인용한 코드가 **그 리비전에서
|
||||||
|
왔는지**는 안 본다. 이 배치에서 실제로 그 결함이 났고 사람이 손으로 잡았다.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
TOOL = os.path.join(ROOT, "scripts", "check-code-anchors.py")
|
||||||
|
_spec = importlib.util.spec_from_file_location("check_code_anchors", TOOL)
|
||||||
|
ca = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(ca)
|
||||||
|
|
||||||
|
|
||||||
|
class UndecidableTest(unittest.TestCase):
|
||||||
|
"""판정할 수 있는 것만 판정한다. 못 보는 것은 세어서 낸다."""
|
||||||
|
|
||||||
|
def test_a_plain_repo_path_is_decidable(self):
|
||||||
|
self.assertIsNone(ca._undecidable("scripts/audit-records.py"))
|
||||||
|
|
||||||
|
def test_shapes_this_checker_cannot_decide(self):
|
||||||
|
cases = {
|
||||||
|
"FeedPersistenceIT.l2EagerToOneFires": "심볼",
|
||||||
|
"`.../CaSkeletonApplication.java`": "축약 또는 백틱",
|
||||||
|
"/etc/letsencrypt/renewal-hooks/": "저장소 밖 절대 경로",
|
||||||
|
"tech-log-serving-contract.json": "폴더 없이 이름만",
|
||||||
|
"renewal-hooks/deploy/": "폴더를 가리킨다",
|
||||||
|
}
|
||||||
|
for anchor, why in cases.items():
|
||||||
|
with self.subTest(why=why):
|
||||||
|
self.assertIsNotNone(ca._undecidable(anchor), f"{anchor} 를 판정하려 든다")
|
||||||
|
|
||||||
|
|
||||||
|
class SweepTest(unittest.TestCase):
|
||||||
|
def _cli(self, *args):
|
||||||
|
return subprocess.run(["python3", TOOL, *args], cwd=ROOT,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
def test_every_project_in_this_repository_passes(self):
|
||||||
|
"""대조군. 지금 저장소의 앵커에 하나라도 걸리면 정책이 과하다.
|
||||||
|
|
||||||
|
축약 경로를 「없다」로 세면 있는 코드를 없다고 하는 것이고, 그것이 이 배치에서
|
||||||
|
채택된 편집 아홉 건을 막았던 실패와 같은 모양이다.
|
||||||
|
"""
|
||||||
|
p = self._cli("--samples", "1")
|
||||||
|
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
|
||||||
|
|
||||||
|
def test_a_missing_project_is_not_reported_as_clean(self):
|
||||||
|
self.assertEqual(2, self._cli("nosuchxyz").returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -107,3 +107,55 @@ class PreservationTest(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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_counting_words_that_are_not_numbers_are_left_alone(self):
|
||||||
|
"""대조군. 「하나뿐」·「둘 다」는 수가 아니다. 세면 정상을 막는다."""
|
||||||
|
for text in ("하나뿐이다", "둘 다 맞다", "한편으로는 그렇다"):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertEqual([], cp.compare(text, text)["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
class UnsourcedVoiceTest(unittest.TestCase):
|
||||||
|
"""자료에 없는 1인칭. 보호 구간 비교로는 원리적으로 안 보이던 자리다."""
|
||||||
|
|
||||||
|
BEFORE = "피드 아이템을 엔티티로 조회한 뒤 메모리에서 DTO로 옮기는 코드다."
|
||||||
|
|
||||||
|
def test_added_first_person_experience_is_surfaced_as_a_warning(self):
|
||||||
|
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인칭"])
|
||||||
|
|||||||
Reference in New Issue
Block a user