Files
document-haness/scripts/check-code-anchors.py
T
DongHyeonkaandClaude Opus 5 845ee89054 feat(scripts): 놓친 8건을 코드로 막거나 검토로 보낸다
먼저 쟀다. 경고를 일괄 차단으로 올리면 채택된 편집 9건이 막힌다 — V-004 에서 고친 바로
그 9건이다. 그래서 경고는 검토로 보내고 차단은 검토가 판정할 때 일어나게 뒀다.

코드로 막은 것 셋. code[] 리비전 — check_evidence 는 리비전이 있는지만 보고 인용한 코드가
그 리비전에서 왔는지는 안 본다. 이 배치에서 실제로 났고 사람이 손으로 잡았다. 한글 수사와
그 경계 — 「다섯 개 → 여섯 개」는 결정적이다.

앵커 검사기의 오탐을 0 으로 만드는 데 시간의 절반이 갔다. 처음 판이 저장소 전체에서
401건을 냈고 전부 오탐이었다. code[] 는 한 모양이 아니다 — 심볼, 축약 경로, 줄 범위,
호스트 절대 경로, 설정 키가 섞여 있다. 축약 경로를 「없다」로 세면 있는 코드를 없다고 하는
것이고 그게 채택된 편집 아홉 건을 막았던 실패와 같은 모양이다. 판정할 수 있는 것만
판정하고 못 보는 것은 세어서 낸다.

검토로 보낸 것 다섯. D2 는 아무 계수도 안 움직이던 자리였다 — 수치도 인용도 없이 산문만
더하면 보호 구간 비교에 잡힐 것이 없다. 1인칭 표지가 늘어난 것만 보고 그 문장을 짚어 준다.
판정이 아니라 라우팅이다.

만들다 버그를 찾았다. 경고가 인용하는 문장이 파일 첫 문단에서 한 글자씩 깎이고 있었다.
rfind 가 -1 을 낼 때 +2 를 해서 1 이 됐다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 15:47:54 +09:00

173 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""기록이 가리키는 코드가 **그 리비전에** 실재하는지 본다.
`check_evidence.mjs --repo` 는 `sourceRepository.revision` 이 그 저장소에 **있는지**만 본다
(`:126`, `git cat-file -e <rev>^{commit}`). **인용한 코드가 그 리비전에서 왔는지는 안 본다.**
그래서 「작업 트리에는 있고 지정 커밋에는 없는 파일」을 인용해도 통과한다 — 체크아웃을 그냥
읽는 분석이 자동으로 만드는 결함이다.
이 배치에서 실제로 났다. 기록이 `scripts/capture-evidence.py:72` 를 가리켰는데 그 파일은
그 배치가 **만든** 것이라 고정 리비전에 없었고, 관문은 전부 통과했다. 사람이 `git cat-file`
을 손으로 쳐서 잡았다. **사람이 한 번 잡은 것과 다음에도 잡히는 것은 다르다.**
**결정적으로 판정 가능하다** — 그 커밋에 그 경로가 있는지, 줄 번호가 파일 길이 안인지.
그래서 코드로 막는다. 내용이 맞는 인용인지는 보지 않는다. 그것은 사람이 읽을 일이다.
python3 scripts/check-code-anchors.py <프로젝트>
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import techlog # noqa: E402
ANCHOR = re.compile(r"^(?P<path>[^:]+?)(?::(?P<line>\d+))?$")
def _undecidable(rel: str) -> str | None:
"""이 앵커를 리비전과 대조할 수 있나. 못 하면 그 사유.
저장소의 `code[]` 는 한 모양이 아니다 — 심볼(`FeedPersistenceIT.l2Eager…`),
축약 경로(`` `.../CaSkeletonApplication.java` ``), 줄 범위(`:141,159-184`),
호스트 절대 경로(`/etc/letsencrypt/…`), 설정 키(`refresh:disabled`) 가 섞여 있다.
**판정할 수 있는 것만 판정한다.** 축약 경로를 「그 리비전에 없다」로 세면 있는 코드를
없다고 하는 것이고, 그것이 이 배치에서 채택된 편집 아홉 건을 막았던 실패와 같은 모양이다.
못 보는 것은 세어서 낸다 — 조용히 건너뛰면 「전부 맞다」가 「본 것만 맞다」를 가린다.
"""
if not LOOKS_LIKE_PATH.search(rel):
return "심볼이거나 경로가 아니다"
if "/" not in rel:
# 이름만 있는 것은 저장소 어디에 있는지 말하지 않는다. 뿌리에 있다고 가정하면
# 있는 파일을 없다고 한다 — TechLog 의 앵커 여덟이 그 모양이었다
return "폴더 없이 파일 이름만 있다"
if "..." in rel or "…" in rel:
return "축약된 경로다"
if rel.startswith("/") or rel.startswith("~"):
return "저장소 밖의 절대 경로다"
if any(ch in rel for ch in " `\"'"):
return "따옴표·백틱·공백이 섞여 있다"
if rel.endswith("/"):
return "폴더를 가리킨다"
return None
# 계약의 `code[]` 는 `<path>:<line-or-symbol>` 이다. **심볼 앵커는 파일 경로가 아니다** —
# `FeedPersistenceIT.l2EagerToOneFires…` 같은 것을 경로로 읽으면 있는 코드를 없다고 한다.
# 경로로 보이는 것만 대조하고 나머지는 「못 대조한 앵커」로 센다. 세지 않고 넘기면
# 「전부 맞다」가 「본 것만 맞다」를 가린다
LOOKS_LIKE_PATH = re.compile(r"[/\\]|\.(?:java|kt|py|ts|tsx|js|mjs|go|rs|sql|ya?ml|json|xml|"
r"gradle|properties|md|sh|toml|cfg|conf)$")
def _git(repo: str, *args: str) -> tuple[int, str]:
try:
p = subprocess.run(["git", "-C", repo, *args],
capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError) as e:
return 127, str(e)
return p.returncode, p.stdout
def verify(project: str) -> tuple[techlog.Report, str | None]:
rep = techlog.Report(project)
base = os.path.join(ROOT, "docs", project)
index_path = os.path.join(base, "tech-log-studio", "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
return rep, "tech-log-tree.json 이 없다"
repos = index.get("sourceRepository") or {}
repos = repos if isinstance(repos, list) else [repos]
checked = skipped = 0
for _topic, _kind, node in techlog.nodes(index):
anchors = node.get("code") or []
if not anchors:
continue
for r in repos:
path, rev = r.get("path"), r.get("revision")
if not path or not os.path.isdir(path):
rep.warn("저장소가 이 기계에 없다", f"{r.get('name') or project}: {path}")
skipped += len(anchors)
continue
if not rev:
rep.warn("리비전이 없어 대조하지 못한다",
f"{r.get('name') or project} — sourceRepository.revision 이 비었다")
skipped += len(anchors)
continue
for anchor in anchors:
m = ANCHOR.match(anchor.strip())
if not m:
skipped += 1
rep.warn("대조하지 못한 앵커 — 형식을 못 읽겠다", anchor[:70])
continue
rel, line = m.group("path"), m.group("line")
why = _undecidable(rel)
if why:
skipped += 1
rep.warn(f"대조하지 못한 앵커 — {why}", f"{node.get('slug')}{anchor[:70]}")
continue
checked += 1
code, blob = _git(path, "cat-file", "-e", f"{rev}:{rel}")
if code != 0:
rep.error("인용한 코드가 그 리비전에 없다",
f"{node.get('slug')}{rel} @ {rev[:8]}")
continue
if line:
code, text = _git(path, "show", f"{rev}:{rel}")
if code == 0 and int(line) > len(text.splitlines()):
rep.error("인용한 줄이 그 리비전의 파일 길이를 넘는다",
f"{node.get('slug')}{rel}:{line} @ {rev[:8]} "
f"(그 커밋에서 {len(text.splitlines())}줄)")
rep.facts["대조한 앵커"] = checked
if skipped:
rep.facts["못 대조한 앵커"] = skipped
return rep, None
def main() -> int:
ap = argparse.ArgumentParser(description="인용한 코드가 그 리비전에 실재하는지 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--samples", type=int, default=3)
args = ap.parse_args()
projects = args.projects or sorted(
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
if not os.path.basename(os.path.dirname(p)).startswith("_"))
missing = [p for p in projects if not os.path.isdir(os.path.join(ROOT, "docs", p))]
if missing:
print(f"대상이 성립하지 않는다 — 그런 프로젝트가 없다: {', '.join(missing)}",
file=sys.stderr)
return 2
reports = []
for p in projects:
rep, why = verify(p)
if why:
print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr)
return 2
reports.append(rep)
for rep in reports:
facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items()) or "앵커 없음"
print(f"\n[{rep.project}] {facts}")
for label, bucket, mark in (("error", rep.errors, "✗"), ("warn", rep.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for d in details[:args.samples]:
print(f" · {d}")
e = sum(r.error_count for r in reports)
print(f"\nCODE ANCHORS: {'FAIL' if e else 'PASS'} — 프로젝트 {len(reports)} · error {e}")
return 1 if e else 0
if __name__ == "__main__":
raise SystemExit(main())