Files
document-haness/scripts/audit-records.py
T
DongHyeonkaandClaude Opus 5 e00c1a2b76 검사: 볼 것이 없어서 통과한 것을 「문제 없음」이라고 쓰지 않는다
R4 · R13 · R6 · R3. 네 가지가 같은 자리를 본다 — 검사기가 대상을 못 찾았을 때 무엇을
내는가.

세 상태를 가른다 (CLAUDE.md 「검사」 절의 표).

  봤고 괜찮다              exit 0   문제 없음 · error 0
  대상이 성립하지 않는다   exit 2   대상이 성립하지 않는다 — <이유>
  볼 것이 아직 없다        exit 0   기록 0건 — 아직 쓴 기록이 없다

R4 — 없는 프로젝트를 주면 여섯 중 넷이 초록을 냈다. `verify-tech-log-tree.py` 는 그것을
「프로젝트 1 · error 0 · PASS」로 셌다 — 오타 한 번이면 검사를 다 돈 것처럼 보인다.
판정은 `techlog.check_targets()` 하나로 모은다. 여섯 곳에 같은 규칙을 따로 쓰면 다음에
하나만 어긋난다. `check_evidence.mjs` 는 이미 exit 2 라 문구만 맞춘다.

R13 — 프로젝트 이름 쪽만 고치면 `--file` 로 오타를 내는 순간 다시 조용히 0건이 된다.
`techlog.check_files()` 로 같은 자리에 둔다. `preview-figure.py` 는 인자가 아예 없을 때
exit 1 을 냈는데 그것도 「대상이 성립하지 않는다」다.

R6 — 두 자리를 함께 고쳐야 했다.
  (a) `verify_projects()` 가 `docs/*/tech-log-studio` 만 훑어 계약 없는 프로젝트가
      목록에서 사라졌다. 기준을 `final/document.md` 로 바꾼다 — SSOT 가 있으면 대상이다.
  (b) `verify-tech-log-tree.py:194` 가 「분해 계약 없음」을 warn 으로 냈다. CLAUDE.md 는
      「계약 미채택도 error 다 — 경고로 두면 옛 스키마로 남아 있는 한 검사를 피한다」고
      적어 두었는데, 경고로 두었더니 실제로 그렇게 됐다.

R3 — `verify-pipeline.py` 가 `check-figure-text.py` 와 `check_evidence.mjs --repo` 를
프로젝트마다 돌린다(`OUTPUT CHECKS`). 게시 전에 돌리라고 적어 둔 검사인데 전체 훑기가
부르지 않아 결함이 있는 채로 PASS 로 보고됐다. `check-required-content.py` 자리는 주석으로
남겨 둔다 — 그 파일이 들어온 뒤에 더한다.

회귀 `scripts/tests/test_no_target.py` 7건 (92 → 99). 「실재하는 경로는 통과한다」 대조를
함께 넣는다 — 무조건 거절로 성공률을 올리는 것이 R-003 이 든 실패다. 판정을 일부러
되돌려 FAILED 가 나는 것을 확인한 뒤 복구했다.

알려진 부채는 그대로 둔다. `verify-pipeline.py` 는 exit 1 이다 — 미준수 런 1건과
`OUTPUT CHECKS` 5건, 그리고 `ca-tmpl` 의 계약 없음이 이제 `TECH LOG TREES` 에서도 보인다.
같은 사실이고 두 번 세지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp9jNbePAmWc5jQwCYhK9v
2026-09-10 11:07:24 +09:00

163 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""기록과 증거가 이 저장소 규칙을 지키는지 전수로 본다.
읽어서 확인할 수 없는 분량이라 기계로 센다. 파서 검사는 check_body.mjs 가 따로 한다.
python3 scripts/audit-records.py [프로젝트 ...]
"""
from __future__ import annotations
import os, re, sys, glob, json, collections
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
import techlog # noqa: E402
KINDS = {"case": "CASE", "concept": "CONCEPT", "reference": "REFERENCE",
"question": "QUESTION", "decision": "PROJECT_DECISION"}
BODY_KINDS = {"case", "concept"}
PLAIN_FIELDS = {"case": ("문제", "결론", "검증 환경", "재현 조건"),
"reference": ("목적", "규칙", "적용 조건", "예외", "예시"),
"question": ("사실", "가정", "미지수", "제약", "선택지", "다음 검증"),
"decision": ("결정문", "판단 이유", "영향")}
REQUIRED = ("kind", "slug", "title", "topic", "project", "status")
def front_matter(text: str) -> dict:
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
def audit_project(project: str) -> dict:
base = os.path.join(ROOT, "docs", project)
studio = os.path.join(base, "tech-log-studio")
ev = os.path.join(base, "final", "evidence")
issues = collections.Counter()
samples = collections.defaultdict(list)
def flag(key: str, detail: str) -> None:
issues[key] += 1
if len(samples[key]) < 3:
samples[key].append(detail)
shown: set[str] = set()
cited: list[str] = []
records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md"))
if not f.split(os.sep)[-3].startswith("_")]
for f in records:
rel = os.path.relpath(f, ROOT)
parts = f.split(os.sep)
topic_dir, kind_dir = parts[-3], parts[-2]
text = open(f, encoding="utf-8").read()
fm = front_matter(text)
for key in REQUIRED:
if not fm.get(key):
flag(f"칸 없음: {key}", rel)
if kind_dir in KINDS and fm.get("kind") != KINDS[kind_dir]:
flag("kind 와 폴더 불일치", f"{rel} — kind={fm.get('kind')}")
if fm.get("topic") and fm["topic"] != topic_dir:
flag("topic 과 폴더 불일치", f"{rel} — topic={fm.get('topic')}")
has_body = "<!-- body:start -->" in text and "<!-- body:end -->" in text
if kind_dir in BODY_KINDS and not has_body:
flag("본문 마커 없음", rel)
if kind_dir not in BODY_KINDS and has_body:
flag("본문이 없어야 하는 종류에 본문", rel)
# frontmatter 가 가리키는 파일이 실제로 있나
d = os.path.dirname(f)
for m in re.finditer(r"^ file: (\S+)$", text, re.M):
if not os.path.exists(os.path.normpath(os.path.join(d, m.group(1)))):
flag("assets 링크 깨짐", f"{rel}{m.group(1)}")
for m in re.finditer(r"^ - (\.\./\S+)$", text, re.M):
if not os.path.exists(os.path.normpath(os.path.join(d, m.group(1)))):
flag("evidence 링크 깨짐", f"{rel}{m.group(1)}")
# 평문 칸은 백틱·코드펜스가 글자 그대로 보인다
head = text if "<!-- body:start -->" not in text \
else text[:text.index("<!-- body:start -->")]
for field in PLAIN_FIELDS.get(kind_dir, ()):
fm2 = re.search(rf"^## {re.escape(field)}\n(.*?)(?=\n## |\Z)", head, re.M | re.S)
if fm2 and ("`" in fm2.group(1) or "```" in fm2.group(1)):
flag("평문 칸에 마크업", f"{rel}{field}")
# 본문이 부르는 자산이 frontmatter 에 선언돼 있나
declared = set(re.findall(r"^ - key: (\S+)$", text, re.M))
body_keys = set(re.findall(r':::evidence key="([^"]+)"', text))
shown |= {k[:-8] if k.endswith("-diagram") else k for k in body_keys}
# 본문에 싣지 않고 evidence 로만 잇거나 산문에서 이름을 대도 쓰인 것이다
shown |= {os.path.basename(m)[:-4]
for m in re.findall(r"^ - \S+/raw/(\S+\.txt)$", text, re.M)}
cited.append(text)
for key in body_keys:
if key not in declared:
flag("본문 자산이 frontmatter 에 없음", f"{rel}{key}")
# 증거 삼종 — 기록이 화면에 쓰는 증거만 원문·메타·렌더가 다 있어야 한다.
# raw 에만 있는 캡처는 분석 단계 자료다. 실행 메타를 요구하지 않는다.
def stems(sub: str, ext: str) -> set:
return {os.path.splitext(os.path.basename(p))[0]
for p in glob.glob(f"{ev}/{sub}/*{ext}")}
raw, meta, rendered = stems("raw", ".txt"), stems("meta", ".json"), stems("rendered", ".svg")
for s in sorted(rendered - raw):
flag("렌더에 원문 없음", s)
for s in sorted(rendered - meta):
flag("렌더에 실행 메타 없음", s)
blob = "\n".join(cited)
for s in sorted(rendered - shown):
if s not in blob:
flag("아무 기록도 쓰지 않는 렌더", s)
# meta 가 가리키는 파일이 실제로 있나 (final/ 기준 상대경로)
for p in sorted(glob.glob(f"{ev}/meta/*.json")):
try:
m = json.load(open(p, encoding="utf-8"))
except Exception as exc:
flag("meta 파싱 실패", f"{os.path.basename(p)}{exc}")
continue
for field in ("raw", "svg", "rawPath", "presentationPath"):
v = m.get(field)
if v and not os.path.exists(os.path.join(base, "final", v)):
flag("meta 경로 깨짐", f"{os.path.basename(p)}{field}: {v}")
return {"project": project, "records": len(records),
"evidence": {"raw": len(raw), "meta": len(meta), "rendered": len(rendered)},
"issues": issues, "samples": samples}
def main(argv: list[str]) -> int:
if argv[1:]:
bad = techlog.check_targets(argv[1:], ROOT, "tech-log-studio")
if bad is not None:
return bad
projects = argv[1:] or sorted(
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio")))
total = 0
for project in projects:
r = audit_project(project)
n = sum(r["issues"].values())
total += n
ev = r["evidence"]
print(f"\n{r['project']} — 기록 {r['records']}건 · 원문 {ev['raw']} · 메타 {ev['meta']} · 렌더 {ev['rendered']}")
if not n:
# 볼 것이 아직 없는 것과 봤더니 괜찮은 것을 가른다
print(" 기록 0건 — 아직 쓴 기록이 없다" if not r["records"] else " 문제 없음")
continue
for key, count in r["issues"].most_common():
print(f" {count:>5} {key}")
for s in r["samples"][key]:
print(f" {s}")
print(f"\n합계 {total}건")
return 0 if total == 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv))