남은 셋은 묶음 경고가 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
265 lines
15 KiB
Python
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())
|