Files
document-haness/scripts/check-preservation.py
T
DongHyeonkaandClaude Opus 5 845ee89054 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
2026-09-10 15:47:54 +09:00

227 lines
12 KiB
Python

#!/usr/bin/env python3
"""문장을 고치기 전과 후에 보호 구간이 그대로인지 본다.
윤문(S5·S6)은 뜻을 바꾸지 않고 문장만 고치는 단계다. 그런데 지금 관문 가운데 **편집 전후를
견주는 것이 하나도 없다.** `check_prose` 는 고친 뒤 파일만 보고, `check_evidence` 는 인용이
SSOT 에 있는지만 본다. 그래서 수치를 바꾸거나 유보를 지운 편집이 그대로 통과한다.
보는 것은 둘이다.
**1. 보호 구간** — 수치·날짜·버전·단위·코드·명령어·URL·직접 인용은 한 글자도 달라지면 안 된다
(CLAUDE.md 「작업 규칙」). 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸
값일 수 있어서 사라진 것과 같은 무게로 본다.
**2. 유보 표현의 수** — 「추정」·「보인다」·「확인하지 못했다」 같은 말이 편집으로 줄면
확신이 올라간 것이다. **이 검사기는 그것이 옳은지 모른다.** 줄었다는 사실만 내고 판단은
근거를 받은 검토가 한다. 늘어난 것은 세지 않는다 — 유보를 더하는 것은 이 규범에서 안전한 쪽이다.
python3 scripts/check-preservation.py <편집 전.md> <편집 후.md>
python3 scripts/check-preservation.py --json <before> <after>
"""
from __future__ import annotations
import argparse
import collections
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 보호 구간. CLAUDE.md 「수치, 날짜, 버전, 단위, 코드, 명령어, URL, 직접 인용, 공식 명칭」
EXTRACTORS: dict[str, re.Pattern[str]] = {
"코드블록": re.compile(r"```[^\n]*\n(.*?)```", re.S),
"인라인코드": re.compile(r"`([^`\n]+)`"),
"URL": re.compile(r"(https?://[^\s`)\"'\]]+)"),
"직접인용": re.compile(r"「([^」]+)」"),
# 수치 — 소수·천단위 구분·단위·백분율까지 한 덩어리로 잡는다.
#
# 경계는 **아스키 낱말 문자와 `.` `-` 만** 막는다. `\w` 로 막으면 한글도 낱말 문자라
# `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서
# 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다.
# 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다
# 한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다. 실제로 이 배치에서
# 한 번 놓쳤고 사람이 잡았다. 세는 말(개·건·장·줄…)이 뒤따르는 자리만 본다 —
# 「하나」·「둘」은 「하나뿐」·「둘 다」처럼 수가 아닌 쓰임이 많다
"한글수사": re.compile(
r"(?<![가-힣])((?:한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두|열세|열네|열다섯|"
r"열여섯|열일곱|열여덟|열아홉|스무|스물한|스물두|서른|마흔|쉰)"
r"\s*(?:개|건·|건|장|줄|번|가지|편|곳|명|쪽|회|배|단계|자리))(?![가-힣])"),
"수치": re.compile(
r"(?<![0-9A-Za-z_.\-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)"
r"(?![0-9A-Za-z_.\-])"),
}
# 유보 표현. 늘어난 것은 세지 않고 줄어든 것만 낸다.
#
# 두 갈래다. 앞쪽은 **확신을 낮추는 말**이고, 뒤쪽은 **「안 했다」로 한계를 밝히는 말**이다.
# 처음에는 앞쪽에만 몰려 있었는데, 그러면 「확인하지 못한 것」 절을 통째로 지워도 계수가
# 안 움직인다 — 그 절의 문장이 대개 뒤쪽 모양이기 때문이다.
HEDGES = (
# 확신을 낮춘다
"추정", "가능성", "보인다", "보였다", "아마", "듯", "일 수 있다", "일지도",
"모른다", "로컬", "이 환경에서", "이번에는", "한정", "범위 안",
# 「안 했다」로 한계를 밝힌다
"확인하지 못했다", "확인하지 않았다", "확인 안 했다", "미확인",
"세지 않았다", "세 보지 않았다", "재지 않았다", "재 보지 않았다",
"안 쟀다", "못 쟀다", "돌리지 않았다", "돌려 보지 않았다", "안 돌렸다", "못 돌렸다",
"열지 않았다", "열어 보지 않았다", "안 봤다", "못 봤다",
"대조하지 않았다", "시험하지 않았다", "정하지 않았다", "만들지 않았다",
"하지 않았다", "않은 것", "못 한 것",
)
# 자료에 없는 1인칭 경험·선택 이유. **늘어난 것만** 본다.
#
# 이것은 판정이 아니라 **라우팅**이다. 「처음에는 …라고 믿었다」가 자료에 있으면 정상이고
# 없으면 지어낸 것인데, 있는지 없는지는 상류 자료를 읽어야 안다. 검사기가 가릴 수 없다.
# 그래서 error 가 아니라 warning 이고, 무엇을 보라고 문장까지 짚어 준다.
#
# 보호 구간 비교로는 원리적으로 안 보이던 자리다 — 수치도 인용도 없이 산문만 더하면
# 어느 계수도 안 움직인다. 이 배치에서 그 사례가 관문 여덟을 전부 통과했다.
VOICE_MARKS = (
"처음에는", "처음엔", "믿었다", "믿었는데", "고민", "고민 끝에", "놀랍", "당황",
"돌이켜", "그제서야", "그제야", "보고서야", "뒤늦게", "뼈아", "아찔",
"팀에서는", "팀은", "동료", "우리는", "나는", "내가", "직접 겪", "경험상",
"시행착오", "삽질", "헤맸", "덕분에", "다행히", "결국 내",
)
# `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면
# 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다
SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M)
def _counts(text: str) -> dict[str, collections.Counter]:
out = {}
for name, pat in EXTRACTORS.items():
out[name] = collections.Counter(m.strip() for m in pat.findall(text))
return out
def _hedges(text: str) -> collections.Counter:
return collections.Counter({h: text.count(h) for h in HEDGES if text.count(h)})
def _sentence_of(text: str, needle: str) -> str:
"""그 값이 들어 있던 문장. 검토자가 다시 찾지 않게 한다."""
i = text.find(needle)
if i < 0:
return ""
# 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)
chunk = text[start:stop if stop > 0 else len(text)]
return re.sub(r"\s+", " ", chunk).strip()[:200]
def compare(before: str, after: str) -> dict:
"""보호 구간의 변화를 낸다. **사라진 것과 새로 생긴 것을 다르게 판정한다.**
- **새로 생겼다 → error.** 없던 수치·인용·코드가 붙은 것은 날조다. 코드가 판정할 수 있다.
- **사라졌다 → warning.** 부수 문장을 덜어 낸 것과 조건을 지운 것은 **같은 연산**이다.
지운 문장에 숫자가 있었는지로는 안 갈린다. 갈릴 수 있는 척하면 사람이 채택한 편집을
막는다 — 실제로 100쌍 중 9건을 막았고 그 전부가 「자료가 뒷받침하지 않는 덧붙인 이득」과
「되풀이」를 지운 편집이었다. 근거를 읽어야 갈리는 자리는 검토로 넘긴다.
"""
b, a = _counts(before), _counts(after)
errors, warnings = [], []
for name in EXTRACTORS:
for value, n in sorted((b[name] - a[name]).items()):
warnings.append({"kind": name, "change": "사라짐", "count": n, "value": value,
"sentence": _sentence_of(before, value),
"note": "덜어 낸 것인지 조건을 지운 것인지는 근거를 읽어야 안다"})
for value, n in sorted((a[name] - b[name]).items()):
errors.append({"kind": name, "change": "새로생김", "count": n, "value": value,
"sentence": _sentence_of(after, value),
"note": "편집 전에 없던 값이다"})
# 절이 통째로 사라졌나. 제목만으로 무엇이 사라졌는지 충분하다
gone = [t for t in SECTION.findall(before) if t not in set(SECTION.findall(after))]
for title in gone:
warnings.append({"kind": "절", "change": "사라짐", "count": 1, "value": f"## {title}",
"sentence": _sentence_of(before, f"## {title}"),
"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)
hedge = []
for w in sorted(hb - ha):
hedge.append({"word": w, "before": hb[w], "after": ha[w],
"sentence": _sentence_of(before, w)})
return {"findings": errors + warnings, # 옛 이름을 남긴다 — 전부 보고 싶은 쪽이 있다
"errors": errors, "warnings": warnings,
"hedgesDropped": hedge,
"hedgeTotalBefore": sum(hb.values()), "hedgeTotalAfter": sum(ha.values())}
def main() -> int:
ap = argparse.ArgumentParser(description="편집 전후 보호 구간이 그대로인지 본다.")
ap.add_argument("before")
ap.add_argument("after")
ap.add_argument("--json", action="store_true")
ap.add_argument("--samples", type=int, default=5)
args = ap.parse_args()
for p in (args.before, args.after):
if not os.path.isfile(p):
print(f"그런 파일이 없다: {p}", file=sys.stderr)
return 2
before = open(args.before, encoding="utf-8").read()
after = open(args.after, encoding="utf-8").read()
res = compare(before, after)
if args.json:
print(json.dumps(res, ensure_ascii=False, indent=2))
return 1 if res["errors"] else 0
print(f"\n편집 전 {os.path.relpath(args.before, ROOT)}"
f"\n편집 후 {os.path.relpath(args.after, ROOT)}")
for label, bucket, mark in (("error", res["errors"], "✗"),
("warn", res["warnings"], "!")):
grouped = collections.defaultdict(list)
for f in bucket:
grouped[(f["kind"], f["change"])].append(f)
for (kind, change), items in sorted(grouped.items()):
print(f" {mark} {label} {kind} {change} {len(items):>3}건")
for f in items[:args.samples]:
v = f["value"].replace("\n", "⏎")
print(f" · {v[:90]}")
if f.get("sentence"):
print(f" 그 자리: {f['sentence'][:88]}")
if len(items) > args.samples:
print(f" … 외 {len(items) - args.samples}건")
if res["hedgesDropped"]:
print(f" ! warn 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}"
f" → 편집 후 {res['hedgeTotalAfter']}")
for h in res["hedgesDropped"][:args.samples]:
print(f" · {h['word']} {h['before']}회 → {h['after']}회")
if h.get("sentence"):
print(f" 그 자리: {h['sentence'][:88]}")
print(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다")
e, w = len(res["errors"]), len(res["warnings"]) + len(res["hedgesDropped"])
print(f"\nPRESERVATION: {'FAIL' if e else 'PASS'} — 새로 생긴 보호 구간 {e}건"
f" · 읽어야 할 것 {w}건")
if w and not e:
print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr)
return 1 if e else 0
if __name__ == "__main__":
raise SystemExit(main())