feat(scripts): 형식으로 판정 가능한 것만 코드로 막고 나머지는 검토로 넘긴다
형식 관문 여덟이 확신 승격·수치 조작·화살표 뒤집기·필수 칸 삭제를 하나도 못 막는 것이 재현됐다. 그 가운데 결정적으로 판정 가능한 것을 코드로 옮긴다. check-required-content.py — 종류가 요구하는 칸이 없거나 비었는지 본다. audit-records.py 는 평문 칸 안에 마크업이 있는지만 보고 칸이 있는지는 안 센다. 고정 목차·자료 개수·답은 강제하지 않는다. 답이 없는 QUESTION 은 정상이고 「다음 검증」이 빈 것만 결함이다. 대상이 성립하지 않으면(프로젝트 없음·계약 없음) exit 2 로 막고, 계약은 있고 기록이 0건이면 통과시키되 초록으로 두지 않는다. 「봤고 괜찮다」와 「볼 것이 없어서 통과」는 다르다. check-preservation.py — 윤문 전후를 견준다. 지금 관문 가운데 편집 전후를 보는 것이 하나도 없어 수치를 바꾸거나 유보를 지운 편집이 그대로 통과했다. 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸 값일 수 있다. 유보 표현이 줄면 내되 옳은지는 판정하지 않는다. 늘어난 것은 세지 않는다. review-package.py — 아무것도 판정하지 않는다. 판정할 사람이 받을 것을 모은다. 해시·검사기 버전·여기서 실제로 돌린 관문·주장 후보·판정 기준. 종료 코드로 안 걸리는 것은 warnings 로 따로 올린다 — 확신 승격이 딱 그 모양이라 안 실으면 아무도 못 본다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
co-authored by
Claude Opus 5
parent
7fc7c69157
commit
cf3996711f
@@ -0,0 +1,129 @@
|
||||
#!/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"「([^」]+)」"),
|
||||
# 수치 — 소수·천단위 구분·단위·백분율·시각까지 한 덩어리로 잡는다.
|
||||
# 앞뒤가 한글이면 낱말의 일부일 수 있어 낱말 경계를 요구한다
|
||||
"수치": re.compile(r"(?<![\w.-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)(?![\w.-])"),
|
||||
}
|
||||
|
||||
# 유보 표현. 늘어난 것은 세지 않고 줄어든 것만 낸다
|
||||
HEDGES = (
|
||||
"추정", "가능성", "보인다", "보였다", "아마", "듯", "일 수 있다", "일지도",
|
||||
"확인하지 못했다", "확인하지 않았다", "미확인", "안 봤다", "못 봤다",
|
||||
"모른다", "정하지 않았다", "재지 않았다", "돌리지 않았다", "열지 않았다",
|
||||
"로컬", "이 환경에서", "이번에는", "한정", "범위 안",
|
||||
)
|
||||
|
||||
|
||||
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 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())
|
||||
Reference in New Issue
Block a user