Files
document-haness/scripts/check-preservation.py
T
DongHyeonkaandClaude Opus 5 e3230ce5ed feat(scripts): 경고를 읽는 장치가 아니라 만드는 장치를 더한다
남은 셋은 묶음 경고가 0건이라 게이트를 아무리 조여도 지나간다. 뿌리가 하나다 — 지금
검사기는 사라진 것과 새로 생긴 보호 구간을 보고, 범위가 넓어진 것과 정본을 안 거친 것을
보는 자리가 없었다.

그림이 정본을 거쳤는지 본다. SVG 는 바뀌었는데 spec.json 은 그대로면 그 그림은 정본에서
나온 것이 아니고, 화살표 뒤집기가 그 모양이다. spec 의 간선과 SVG 의 경로를 직접 견주려면
렌더러가 id 를 어떻게 붙이는지 알아야 하는데, 정본을 거쳤는지만 보면 몰라도 된다.
작업 트리와 이력 두 자리를 본다. 정본이 없는 그림은 볼 것이 아니라 세기만 한다.

적용 범위를 넓히는 말이 새로 들어왔는지 본다. 로컬에서 확인했다에 운영 환경에서도를
더하기만 하면 유보도 보호 구간도 안 바뀐다 — 지운 것이 없기 때문이다. 유보 감소의
반대편이고, 판정하지 않고 경고로 올린다.

경고 생산자를 더하는 것이 과잉 차단이 생기는 자리라 후보마다 채택 편집 100쌍을 돌렸다.
error 도 경고도 안 늘었다. 정상 편집에 경고가 붙으면 그것도 사실상 차단이다.

목록에 운영·항상 같은 흔한 말이 들어가서, 원래 있던 말을 두고 주변만 고쳐도 걸리는지
보는 대조군을 회귀에 넣었다.

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

265 lines
15 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 = (
"처음에는", "처음엔", "믿었다", "믿었는데", "고민", "고민 끝에", "놀랍", "당황",
"돌이켜", "그제서야", "그제야", "보고서야", "뒤늦게", "뼈아", "아찔",
"팀에서는", "팀은", "동료", "우리는", "나는", "내가", "직접 겪", "경험상",
"시행착오", "삽질", "헤맸", "덕분에", "다행히", "결국 내",
)
# 적용 범위를 넓히는 말. **늘어난 것만** 본다.
#
# 지금 검사기는 「사라진 것」과 「새로 생긴 보호 구간」을 본다. **「범위가 넓어진 것」을 보는
# 자리가 없었다.** 「로컬에서 확인했다」에 「운영 환경에서도」를 **더하기만** 하면 유보도
# 보호 구간도 안 바뀐다 — 지운 것이 없기 때문이다. 그래서 관문 전부를 지나갔다.
#
# 유보 감소의 반대편이다. 유보를 지워도 확신이 오르고, 범위 말을 더해도 오른다.
# **판정하지 않는다** — 범위가 실제로 넓어졌는지는 근거를 읽어야 안다.
SCOPE_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": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 "
"근거를 읽어야 안다"})
# 적용 범위가 넓어졌나. 늘어난 것만 본다 — 좁히는 것은 이 규범에서 안전한 쪽이다
for mark in SCOPE_MARKS:
gained = after.count(mark) - before.count(mark)
if gained > 0:
warnings.append({
"kind": "적용 범위", "change": "새로생김", "count": gained, "value": mark,
"sentence": _sentence_of(after, mark),
"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())