Files
document-haness/scripts/check-preservation.py
T
DongHyeonkaandClaude Opus 5 16cbe141a0 fix: 고친 자리에 같은 버그를 다시 넣었고, 게이트가 관측 창을 덮었다
한글 수사의 뒤 경계가 세는 말 다음의 조사를 낱말의 일부로 봤다. 「다섯 개다」·「다섯 건을」·
「여섯 장이」가 전부 안 걸린다. 아라비아 숫자 쪽에서 같은 이유로 「500행」·「5개다」를
놓쳤던 것을 고쳤는데, 그 고침을 한글로 옮기면서 다시 넣었다. 회귀가 초록이었던 건 시험
문구가 전부 조사 없이 끝나서다.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 16:15:04 +09:00

239 lines
13 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`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다
# 한글 수사. 아라비아 숫자만 보면 「다섯 개 → 여섯 개」가 안 보인다.
#
# **뒤를 막지 않는다.** 세는 말 뒤에는 거의 항상 조사가 붙는다 — `다섯 개다` ·
# `다섯 건을` · `여섯 장이`. `(?![가-힣])` 로 막으면 그 자리가 전부 안 걸린다.
# 아라비아 숫자 쪽에서 `(?![\w.-])` 때문에 `500행`·`5개다` 를 놓쳤던 것과 같은 모양이고,
# 그 고침을 한글로 옮기면서 다시 넣었다. 회귀 문구가 조사 없이 끝나서 초록이었다.
#
# **잡는 것은 수사뿐이고 세는 말은 문맥으로만 본다.** 「수사+세는 말」을 한 덩어리로
# 잡으면 `여덟 자리 → 여덟 곳` 처럼 **세는 말만 바뀐 편집**이 수치 변경으로 읽힌다.
# 그것은 spatial-metaphor 를 고치는 정상 편집이고 실제로 채택된 편집 둘이 그 모양이다.
#
# 대안은 **긴 것부터** 적는다. 짧은 것부터 두면 `열다섯` 이 `열` 로 먼저 걸려 실패한다.
# 순서에 기대는 코드는 다음 사람이 낱말 하나를 더하면 깨진다.
#
# 「하나」·「둘」은 목록에 없다 — `하나뿐`·`둘 다` 처럼 수가 아닌 쓰임이 많다.
"한글수사": 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())