#!/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"「([^」]+)」"), # 수치 — 소수·천단위 구분·단위·백분율까지 한 덩어리로 잡는다. # # 경계는 **아스키 낱말 문자와 `.` `-` 만** 막는다. `\w` 로 막으면 한글도 낱말 문자라 # `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서 # 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다. # 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다 "수치": 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 _sentence_of(text: str, needle: str) -> str: """그 값이 들어 있던 문장. 검토자가 다시 찾지 않게 한다.""" i = text.find(needle) if i < 0: return "" start = max(text.rfind("\n\n", 0, i) + 2, 0) 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": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 " "근거를 읽어야 안다"}) 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())