fix(check-preservation): 삭제를 코드가 판정하지 않는다. 날조는 판정한다
사람이 채택한 편집 100쌍 중 9건을 막고 있었다. 막은 것이 전부 자료가 뒷받침하지 않는 덧붙인 이득과 되풀이를 지운 편집이고, 지운 문장 안에 숫자나 인용부호가 있었다는 이유로 막혔다. 반대로 「확인하지 못한 것」 절을 통째로 지운 편집은 통과했다. 부수 문장 삭제와 조건 삭제는 같은 연산이다. 지운 문장에 숫자가 있었는지로는 안 갈린다. 그래서 사라진 것은 warning 으로 내리고 새로 생긴 것만 error 로 둔다. 없던 수치·인용·코드를 더한 것은 날조이고 그것은 코드가 판정할 수 있다. 경계도 고쳤다. `(?![\w.-])` 의 `\w` 가 한글도 낱말 문자로 세어 `500행`·`5개다` 처럼 조사나 명사가 붙으면 보호가 통째로 풀렸다. 한국어에서 숫자는 거의 항상 뭔가가 바로 붙으므로 보호가 가장 필요한 자리에서 가장 안 걸렸다. 경고마다 그 값이 있던 문장을 함께 낸다. 기제와 수치까지만 있으면 검토자가 다시 찾아야 한다. review-package 의 reviewerNotes 맨 위에 읽는 계약을 넣었다 — gates 가 전부 0 이어도 그것만 으로 통과가 아니고, warnings 가 비어 있어야 자동 통과다. 막은 쌍 9 → 1. 남은 하나는 편집이 인라인 코드를 새로 넣은 쌍이라 규칙이 제대로 도는 것이다. 0 으로 만들려면 그 규칙을 풀어야 하고 그러면 지어낸 식별자가 함께 통과한다. 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
32a369f335
commit
4fae5b5398
@@ -35,9 +35,15 @@ EXTRACTORS: dict[str, re.Pattern[str]] = {
|
||||
"인라인코드": 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.-])"),
|
||||
# 수치 — 소수·천단위 구분·단위·백분율까지 한 덩어리로 잡는다.
|
||||
#
|
||||
# 경계는 **아스키 낱말 문자와 `.` `-` 만** 막는다. `\w` 로 막으면 한글도 낱말 문자라
|
||||
# `500행`·`5개다` 처럼 조사나 명사가 붙은 자리에서 보호가 통째로 풀린다. 한국어에서
|
||||
# 숫자는 거의 항상 뭔가가 바로 붙으므로, 보호가 가장 필요한 자리에서 가장 안 걸렸다.
|
||||
# 버전 문자열(`1.1.0`)은 `.` 이 막아 여전히 토큰이 안 나온다 — 그것은 인라인 코드로 견준다
|
||||
"수치": re.compile(
|
||||
r"(?<![0-9A-Za-z_.\-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)"
|
||||
r"(?![0-9A-Za-z_.\-])"),
|
||||
}
|
||||
|
||||
# 유보 표현. 늘어난 것은 세지 않고 줄어든 것만 낸다
|
||||
@@ -60,21 +66,46 @@ 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)
|
||||
findings = []
|
||||
errors, warnings = [], []
|
||||
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})
|
||||
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": "편집 전에 없던 값이다"})
|
||||
|
||||
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,
|
||||
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())}
|
||||
|
||||
|
||||
@@ -97,32 +128,40 @@ def main() -> int:
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||
return 1 if res["findings"] else 0
|
||||
return 1 if res["errors"] 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}건")
|
||||
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" ! 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}"
|
||||
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(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다")
|
||||
|
||||
n = len(res["findings"])
|
||||
print(f"\nPRESERVATION: {'FAIL' if n else 'PASS'} — 보호 구간 변화 {n}건"
|
||||
f" · 유보 감소 {len(res['hedgesDropped'])}종")
|
||||
return 1 if n else 0
|
||||
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__":
|
||||
|
||||
@@ -225,9 +225,17 @@ def build(project: str, record: str, figures_dir: str | None = None,
|
||||
warnings.append({
|
||||
"id": "유보 감소",
|
||||
"detail": f"{h['word']} {h['before']}회 → {h['after']}회",
|
||||
"sentence": h.get("sentence", ""),
|
||||
"note": "종료 코드로는 안 걸린다. 확신이 올라간 것인지 그 자리에서 "
|
||||
"덜어 낼 만했던 것인지는 근거를 읽어야 안다",
|
||||
})
|
||||
for f in preservation.get("warnings", []):
|
||||
warnings.append({
|
||||
"id": f"보호 구간 {f['change']}",
|
||||
"detail": f"{f['kind']} — {f['value'][:80]}",
|
||||
"sentence": f.get("sentence", ""),
|
||||
"note": f.get("note", ""),
|
||||
})
|
||||
|
||||
return {
|
||||
"schemaVersion": 2,
|
||||
@@ -252,6 +260,9 @@ def build(project: str, record: str, figures_dir: str | None = None,
|
||||
"claimCandidates": _claim_candidates(text),
|
||||
"judgmentCriteria": CLAIM_KINDS,
|
||||
"reviewerNotes": [
|
||||
"**gates 가 전부 exit 0 이어도 그것만으로 통과가 아니다.** warnings 가 비어 있어야 "
|
||||
"자동 통과다. warnings 가 있으면 근거를 읽는 검토가 항목마다 PASS·FAIL·UNKNOWN 을 "
|
||||
"낸다. UNKNOWN 은 통과가 아니다.",
|
||||
"이 파일의 어느 값도 판정이 아니다. 관문의 exit 는 형식 검사의 결과일 뿐이다.",
|
||||
"claimCandidates 는 표면 표지로 뽑은 것이라 주장이 아닌 문장이 섞인다. "
|
||||
"반대로 표지가 없는 주장은 빠진다 — 본문을 읽고 빠진 것을 찾는 것이 검토의 일이다.",
|
||||
|
||||
Reference in New Issue
Block a user