Files
document-haness/scripts/check-preservation.py
T
DongHyeonkaandClaude Opus 5 87d70e7c80 feat(check-preservation): 편집이 핵심 칸에 측정 주장을 새로 더했는지 본다
check-core-support 는 근거 목록이 비었는데 측정을 주장하는 것을 본다. 근거가 차 있는
기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다 — 출처가 있다는 것과
그 출처가 그 주장을 지지한다는 것은 다르다.

「측정 주장이 있으면 경고」로 가지 않았다. 그건 저장소의 정상 기록 다수에 걸리고, 모든
기록에 걸리는 경고는 어느 기록에 대해서도 아무 말을 하지 않는다. 편집이 핵심 칸에 측정
주장을 새로 더했는지만 본다 — 적용 범위 검출과 같은 모양이고 더해진 것을 본다.

판정은 check-core-support 의 목록을 그대로 불러 쓴다. 두 곳에 두면 갈린다.

판정하지 않는다. 근거가 이 주장을 지지하는지는 근거를 읽어야 알고, 그것은 검토의 몫이다.

채택 편집 100쌍에 한 번도 안 걸린다. 그 쌍들은 문제·결론 칸이 없는 조각이라 대상이
아니고, 회귀에 그것도 넣었다.

곁들여 check-core-support 에 --file 을 더했다. 다른 검사기들이 이미 받은 것이다.

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

306 lines
17 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 = (
"운영 환경", "운영에서", "운영에도", "프로덕션",
"항상", "언제나", "언제든", "어디서나", "어느 경우에도",
"모든 경우", "전부 그렇다", "예외 없이", "무조건", "반드시", "절대",
)
def _core_measurement(text: str):
"""`문제`·`결론` 칸이 측정을 주장하나. (성능 명사, 비교어) 또는 None.
`check-core-support.py` 의 판정을 그대로 쓴다 — 목록을 두 곳에 두면 갈린다.
"""
import importlib.util
path = os.path.join(ROOT, "scripts", "check-core-support.py")
spec = importlib.util.spec_from_file_location("check_core_support", path)
if spec is None or spec.loader is None:
return None
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
secs = SECTION_BODY.findall(text)
core = " ".join(body for name, body in secs if name.strip() in ("문제", "결론"))
if not core.strip():
return None
perf, cmpw = m.PERF.search(core), m.COMPARE.search(core)
return (perf.group(0), cmpw.group(0)) if (perf and cmpw) else None
# `## 이름` 의 이름과 그 아래 내용. 편집 전후의 핵심 칸을 견주는 데 쓴다
SECTION_BODY = re.compile(r"^##[ \t]+(.+?)[ \t]*$\n(.*?)(?=^##[ \t]|\Z)", re.M | re.S)
# `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면
# 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다
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": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 "
"근거를 읽어야 안다"})
# 편집이 **측정 주장을 새로 더했나.** 있던 것은 안 본다 — 있던 것을 보면 저장소의
# 정상 기록 다수에 걸리고, 그것은 「모든 기록에 걸리는 경고는 어느 기록에 대해서도
# 아무 말을 하지 않는다」로 뺀 것과 같은 모양이 된다.
#
# `check-core-support.py` 는 「근거 목록이 **비었는데** 측정을 주장한다」를 본다.
# 근거가 **차 있는** 기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다
# — **출처가 있다 ≠ 그 출처가 그 주장을 지지한다.** 그 자리를 여기서 검토로 보낸다.
before_claim, after_claim = _core_measurement(before), _core_measurement(after)
if after_claim and not before_claim:
warnings.append({
"kind": "핵심 주장", "change": "새로생김", "count": 1,
"value": f"{after_claim[0]} · {after_claim[1]}",
"sentence": _sentence_of(after, after_claim[0]),
"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())