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