quad/.claude/tools/doc-check.py
qwreey aaefa08c1c
docs(audit): 10차 세션 — 병렬 에이전트 코퍼스 감사, 부정확성 7건 수정
9차 세션의 구조 변경(luau-test 재편/bind-system-plan 분할/question.md
트림)이 남긴 반영 누락을 6개 병렬 에이전트로 찾아 즉시 수정:

- luau-test 재편 후 깨진 flat 경로 참조 9곳을 파일명+실제 폴더로 정정
- bind-system-plan.md 분할 후 자기참조/외부참조 깨짐 8곳 정정
- ref-plan.md에 0-Z 배너가 안 옮겨와 옛 재디스패치 모델을 무배너로
  서술 중이던 것 발견 — 배너 추가, 반영 대상 6개→7개로 갱신
- "8차 세션"으로 잘못 표기된 9차 세션 작업 17곳(git 커밋 타임스탬프로
  교차검증) 정정
- question.md 트림 중 빠진 열린 질문(State<State<T>> 평탄화) 복원,
  트림 후 깨진 참조 2곳 정정
- ROADMAP.md M0 섹션에 0-Y/0-Z 게이트 표시 누락 보강

doc-check.py ERROR 0 유지. 새로 연 설계 질문 없음 — 전부 기존 서술
정합성 문제.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6hzeUi5QdLPEk69B6cXFa
2026-08-13 18:32:53 +09:00

239 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""
.claude/ 코퍼스 기계 점검 — 깊은 수동 감사가 반복되는 걸 막기 위한 도구.
배경: 2026-08-13 일곱/여덟 번째 세션에 6+6라운드 수동 감사를 돌려 총 39+16건을
찾았는데, **그중 대부분이 grep으로 잡히는 종류**였음(옮겨간 절을 가리키는 참조,
색인 누락, 날짜 없는 "아직 안 함" 주장). 사람/에이전트의 성실성에 기대는 대신
기계가 매번 같은 걸 확인하게 함.
실행: python3 .claude/tools/doc-check.py
python3 .claude/tools/doc-check.py --quiet # 실패한 검사만
종료코드: 오류(ERROR)가 하나라도 있으면 1, 경고만 있으면 0.
검사 항목
1. [ERROR] 깨진 파일 참조 — 라이브 문서가 가리키는 .md/.luau가 실제로 없음
2. [ERROR] 깨진 절 참조 — `foo.md` "절 제목" 이 그 파일에 없음
(문서를 쪼개거나 헤딩을 고칠 때 가장 잘 깨지는 것 — 아홉 번째 세션에
bind-system-plan.md를 분할하며 20곳이 여기 걸렸음)
3. [ERROR] 색인 누락 — base/research/archive/reference 파일이 README에 없음
4. [WARN] 날짜 없는 시한부 주장 — "아직 안 돌려봄", "열린 질문 없음"
시간이 지나면 거짓이 되는데 언제 기준인지 안 적힌 문장
5. [WARN] 미반영 배너를 단 파일 vs 반영 목록 일치 여부
`session/`(원문 보존), `initreq/`(읽기 전용 클론)는 검사 대상에서 제외.
"""
import os, re, sys, collections
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CLAUDE = os.path.join(ROOT, '.claude')
SKIP_DIRS = ('session', 'initreq', 'worktrees', 'tools')
errors, warns = [], []
def live_docs():
out = []
for base in (CLAUDE, ROOT):
for dp, dn, fn in os.walk(base):
rel = os.path.relpath(dp, ROOT)
if any(p in rel.split(os.sep) for p in SKIP_DIRS):
dn[:] = []
continue
if base is ROOT and rel != '.' and not rel.startswith('.claude'):
continue
for f in fn:
if f.endswith('.md'):
out.append(os.path.join(dp, f))
if base is ROOT:
break
# 루트 md 직접 추가
for f in ('CLAUDE.md', 'ROADMAP.md', 'HUMAN_TODO.md', 'SAFETY.md'):
p = os.path.join(ROOT, f)
if os.path.exists(p):
out.append(p)
return sorted(set(out))
def rel(p):
return os.path.relpath(p, ROOT)
# ---------- 1 & 2: 참조 무결성 ----------
# `(경로/)파일.md` 또는 `파일.luau` (+ 바로 뒤에 "절 제목"이 오면 절 검사도)
REF = re.compile(r'`\.?/?((?:[\w.-]+/)*[\w.@-]+\.(?:md|luau))`(\s*(?:의\s*)?"([^"\n]{2,60})")?')
def resolve(target, src):
"""참조 문자열을 실제 경로로 해석 — 상대/부분 경로를 관대하게 매칭."""
cands = [
os.path.join(ROOT, target),
os.path.join(CLAUDE, target),
os.path.join(os.path.dirname(src), target),
]
for c in cands:
if os.path.exists(c):
return c
# 파일명만으로 찾히면 인정(initreq 포함 — 읽기 전용 클론이지만 실재함)
name = os.path.basename(target)
for dp, dn, fn in os.walk(CLAUDE):
if 'worktrees' in os.path.relpath(dp, ROOT).split(os.sep):
dn[:] = []
continue
if name in fn:
return os.path.join(dp, name)
return None
# 이 레포의 문서 명명 관례 — 여기 걸리면 "우리 문서"이므로 못 찾으면 ERROR.
# 안 걸리면 외부 문서명(Compose 가이드라인 등)일 수 있어 WARN으로 낮춤.
OURS = re.compile(r'(-plan|-reversed|-rejected|-findings|-map|-audit|-verification'
r'|^README|^STATUS|^CLAUDE|^ROADMAP|^HUMAN_TODO|^SAFETY'
r'|^question|^architecture|^agent-mistake)\.md$')
def headings(path):
if not path.endswith('.md') or not os.path.exists(path):
return None
hs = []
for line in open(path, encoding='utf-8'):
if line.startswith('#'):
# 백틱/강조 표기는 인용할 때 자주 빠지므로 정규화해서 비교
hs.append(re.sub(r'[`*]', '', line.lstrip('#')).strip())
return hs
def interesting(target):
"""검사 가치가 있는 참조인가.
제외: (a) `initreq/`(읽기 전용 클론, gitignore됨), (b) 아직 존재하지 않는
**미래 소스 트리**의 `.luau`(구현 시작 전이라 당연히 없음 — `architecture.md`가
설계로서 적어둔 것), (c) `YYYY-MM-DD-NN-slug.md` 같은 템플릿 자리표시자.
실제로 지금 존재해야 하는 건 `.md` 문서와 `luau-test/`의 스파이크뿐.
"""
if 'YYYY' in target:
return False
if target.endswith('.luau'):
return 'luau-test' in target or re.match(r'^\d\d-', os.path.basename(target))
return True
def check_refs(docs):
hcache = {}
for d in docs:
# archive는 히스토리라 절 참조까지 강제하지 않음(파일 존재만)
is_archive = '/archive/' in rel(d)
for ln, line in enumerate(open(d, encoding='utf-8'), 1):
for m in REF.finditer(line):
target, _, section = m.group(1), m.group(2), m.group(3)
if not interesting(target):
continue
p = resolve(target, d)
if p is None:
name = os.path.basename(target)
msg = f"{rel(d)}:{ln} 깨진 파일 참조 → `{target}`"
(errors if OURS.search(name) else warns).append(
msg if OURS.search(name) else msg + " (외부 문서명일 수 있음)")
continue
if section and not is_archive and p.endswith('.md'):
if p not in hcache:
hcache[p] = headings(p) or []
core = re.sub(r'[`*]', '', section).strip()
if not any(core in h for h in hcache[p]):
# 절 제목은 의역해서 인용하는 관례가 있어 WARN — 다만
# 문서를 쪼개거나 헤딩을 고칠 때 여기가 제일 먼저 걸린다.
warns.append(
f"{rel(d)}:{ln} 절 참조 불일치 → {os.path.basename(p)}"
f'"{section}" 절 없음')
# ---------- 3: 색인 누락 ----------
def check_index():
readme = os.path.join(CLAUDE, 'README.md')
if not os.path.exists(readme):
errors.append("`.claude/README.md`가 없음")
return
txt = open(readme, encoding='utf-8').read()
for sub in ('base', 'research', 'archive', 'reference'):
d = os.path.join(CLAUDE, sub)
if not os.path.isdir(d):
continue
for f in sorted(os.listdir(d)):
if f.endswith('.md') and f'`{f}`' not in txt:
errors.append(f"README 색인 누락 → {sub}/{f}")
# ---------- 4: 날짜 없는 시한부 주장 ----------
TEMPORAL = [
(r'아직 (?:사용자가 )?안 돌려\S*', '실행 여부 주장'),
(r'결과 미확인', '실행 여부 주장'),
(r'열린 질문(?:은)? 없(?:음|다)', '완결 주장'),
(r'더 이상 (?:막힌|열려있는|열려 있는)\S* 없\S*', '완결 주장'),
(r'남은 (?:유일한|건)[^\n]{0,20}뿐', '완결 주장'),
(r'전부 확정됨', '완결 주장'),
]
DATED = re.compile(r'20\d\d-\d\d-\d\d|\[\s*(?:해소|정정|갱신|확정|역전)')
def check_temporal(docs):
for d in docs:
if '/archive/' in rel(d):
continue
lines = open(d, encoding='utf-8').read().split('\n')
for i, line in enumerate(lines, 1):
for pat, kind in TEMPORAL:
if re.search(pat, line):
ctx = '\n'.join(lines[max(0, i - 3):i + 2])
if not DATED.search(ctx):
warns.append(
f"{rel(d)}:{i} 날짜 없는 {kind}"
f"\"{re.search(pat, line).group(0)}\" "
f"(언제 기준인지 적을 것)")
# ---------- 5: 미반영 배너 ----------
def check_banner(docs):
banner = [d for d in docs if '아직 반영 안 됨' in open(d, encoding='utf-8').read()]
if not banner:
return
names = sorted(os.path.basename(b) for b in banner)
plan = os.path.join(CLAUDE, 'research', 'dispatch-redispatch-diff-plan.md')
if os.path.exists(plan):
txt = open(plan, encoding='utf-8').read()
missing = [n for n in names if n not in txt]
if missing:
warns.append(
"미반영 ⚠️ 배너를 달고 있는데 반영 목록"
"(dispatch-redispatch-diff-plan.md 6절)에 없는 파일: "
+ ', '.join(missing))
warns.append(f"미반영 배너를 단 파일 {len(names)}개 — 해소 시 배너도 같이 걷을 것: "
+ ', '.join(names))
def main():
quiet = '--quiet' in sys.argv
docs = live_docs()
check_refs(docs)
check_index()
check_temporal(docs)
check_banner(docs)
print(f"검사 대상 라이브 문서 {len(docs)}"
f"(session/·initreq/ 제외)\n")
if errors:
print(f"■ ERROR {len(errors)}건 — 고쳐야 함")
for e in errors:
print(" " + e)
print()
elif not quiet:
print("■ ERROR 0건 ✓\n")
if warns:
print(f"■ WARN {len(warns)}건 — 확인 권장(자동 판정 불가)")
for w in warns:
print(" " + w)
print()
elif not quiet:
print("■ WARN 0건 ✓\n")
return 1 if errors else 0
if __name__ == '__main__':
sys.exit(main())