이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 — final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인 final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다. 삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다. 그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개, writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물, scripts/check-ssot-facts.py 와 그 시험이 들어 있다. 이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
9.2 KiB
Python
Executable File
193 lines
9.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
|
|
# 폴더 이름 ↔ 종류. 손으로 다시 적지 않는다 — 여섯 번째 종류(SETUP)가 빠졌던 자리다
|
|
KINDS = techlog.KIND_OF_DIR
|
|
BODY_KINDS = techlog.BODY_KINDS
|
|
# 마크다운 **블록** 파서를 안 거치는 칸. 그렇다고 전부 글자로 나오지는 않는다 —
|
|
# 렌더러가 이 칸들을 `<ProseText>` 로 그리고(`tech-log-frontend` 의
|
|
# `presentation/shared/public-render/prose-text.tsx`), 거기서 백틱 쌍은 인라인 `<code>` 로,
|
|
# 빈 줄은 문단으로, 한 줄 바꿈은 `<br>` 로 산다. **살아나지 않는 것만 잡는다** —
|
|
# 코드펜스·별표·파이프다.
|
|
#
|
|
# 여기서 백틱도 잡던 때가 있었다. 그때는 맞았고(칸이 진짜 평문이었다) 지금은 틀렸다.
|
|
# 지금 이 검사가 0건인 것은 깨끗해서가 아니라 **그 규칙을 따라 이미 다 떼어 놨기**
|
|
# 때문이다. 되돌리는 것은 기록 수백 편을 건드리는 일이라 따로 다룬다.
|
|
#
|
|
# **`concept` 과 `setup` 은 여기 없는 것이 맞다** — 본문 밖에 절이 없다. 그 둘의 본문 밖 칸
|
|
# (`basisVersion` · `pinnedVersions`)은 frontmatter 에 있다
|
|
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)}")
|
|
|
|
# 이 칸에서 살아나지 않는 마크업. 백틱은 `<code>` 로 사니까 잡지 않는다
|
|
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 not fm2:
|
|
continue
|
|
chunk = fm2.group(1)
|
|
dead = []
|
|
if "```" in chunk:
|
|
dead.append("코드펜스")
|
|
# 목록 표지(`- **제목**`)는 관계 절의 표기이고 이 칸들에는 안 온다.
|
|
# 줄 첫머리가 아닌 자리의 `**…**` 만 본다
|
|
if re.search(r"(?<!^)(?<!- )\*\*[^*\n]+\*\*", chunk, re.M):
|
|
dead.append("별표")
|
|
if re.search(r"^\s*\|", chunk, re.M):
|
|
dead.append("표")
|
|
# 인용 표지도 안 산다. `studio-save.py` 의 `_sections()` 가 `>` 를 그대로
|
|
# 실어 보내고 `ProseText` 는 백틱·빈 줄·줄바꿈 셋만 해석한다 — 화면에
|
|
# 홑화살괄호가 글자로 나온다. SSOT 를 그대로 옮긴 인용이라도 마찬가지다
|
|
if re.search(r"^\s*>", chunk, re.M):
|
|
dead.append("인용 표지")
|
|
if dead:
|
|
flag("평문 칸에 살아나지 않는 마크업",
|
|
f"{rel} — {field} · {'·'.join(dead)}. 백틱은 괜찮다")
|
|
|
|
# 본문이 부르는 자산이 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))
|