#!/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 """ 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"「([^」]+)」"), # 수치 — 소수·천단위 구분·단위·백분율·시각까지 한 덩어리로 잡는다. # 앞뒤가 한글이면 낱말의 일부일 수 있어 낱말 경계를 요구한다 "수치": re.compile(r"(? 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 compare(before: str, after: str) -> dict: b, a = _counts(before), _counts(after) findings = [] for name in EXTRACTORS: lost = b[name] - a[name] gained = a[name] - b[name] for value, n in sorted(lost.items()): findings.append({"kind": name, "change": "사라짐", "count": n, "value": value}) for value, n in sorted(gained.items()): findings.append({"kind": name, "change": "새로생김", "count": n, "value": value}) hb, ha = _hedges(before), _hedges(after) dropped = hb - ha hedge = [{"word": w, "before": hb[w], "after": ha[w]} for w in sorted(dropped)] return {"findings": findings, "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["findings"] else 0 print(f"\n편집 전 {os.path.relpath(args.before, ROOT)}" f"\n편집 후 {os.path.relpath(args.after, ROOT)}") grouped = collections.defaultdict(list) for f in res["findings"]: grouped[(f["kind"], f["change"])].append(f) for (kind, change), items in sorted(grouped.items()): print(f" ✗ {kind} {change} {len(items):>3}건") for f in items[:args.samples]: v = f["value"].replace("\n", "⏎") print(f" · {v[:96]}") if len(items) > args.samples: print(f" … 외 {len(items) - args.samples}건") if res["hedgesDropped"]: print(f" ! 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}" f" → 편집 후 {res['hedgeTotalAfter']}") for h in res["hedgesDropped"][:args.samples]: print(f" · {h['word']} {h['before']}회 → {h['after']}회") print(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다") n = len(res["findings"]) print(f"\nPRESERVATION: {'FAIL' if n else 'PASS'} — 보호 구간 변화 {n}건" f" · 유보 감소 {len(res['hedgesDropped'])}종") return 1 if n else 0 if __name__ == "__main__": raise SystemExit(main())