Files
document-haness/scripts/check-required-content.py
T
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 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>
2026-09-17 11:02:02 +09:00

316 lines
15 KiB
Python

#!/usr/bin/env python3
"""종류가 요구하는 내용이 실제로 채워져 있는지 본다.
`audit-records.py` 는 평문 칸 **안에 마크업이 있는지**만 본다. 칸이 아예 없거나 제목만 있고
비어 있는 것은 세지 않는다. Studio 는 빈 칸도 받아 주므로 그대로 저장되고, 화면에서는
제목만 남은 칸으로 보인다.
**결정적으로 판정 가능한 것만 본다.** 칸이 있는가, 비어 있지 않은가, 계약에 없는 `##` 이
있는가(화면에 자리가 없어 통째로 사라진다), 종류가 요구하는 근거의 자리가 채워졌는가.
내용이 옳은지·인과가 맞는지는 보지 않는다 — 그것은 근거를 받은 검토 컨텍스트의 몫이다.
**강제하지 않는 것 셋.**
- 고정 목차. 본문(`## 본문`) 안의 절 구성은 글마다 다르다
- 자료 개수. 그림 몇 장·증거 몇 건을 요구하지 않는다
- 답. 답이 없는 QUESTION 은 정상이다. 물음과 확인된 사실과 답을 구할 방법만 요구한다
python3 scripts/check-required-content.py <프로젝트>
python3 scripts/check-required-content.py --file <기록.md>
"""
from __future__ import annotations
import argparse
import collections
import glob
import os
import re
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
# 종류마다의 `##` 칸. 정본은
# .agents/skills/publishing-tech-log-to-studio/references/studio-form-map.md 다.
# 계약에 없는 `##` 는 화면에 자리가 없어 통째로 사라진다
SECTIONS: dict[str, dict[str, tuple[str, ...]]] = {
"case": {"required": ("관계", "문제", "결론", "검증 환경", "재현 조건", "본문"),
"optional": ()},
"concept": {"required": ("관계", "본문"), "optional": ()},
"reference": {"required": ("관계", "목적", "규칙", "적용 조건", "예외"),
"optional": ("예시",)},
"question": {"required": ("관계", "사실", "미지수", "다음 검증"),
"optional": ("가정", "제약", "선택지")},
# Decision 의 틀에는 `관계` 가 없다 (templates/decision.md). 있으면 받되 요구하지 않는다
"decision": {"required": ("근거", "결정문", "판단 이유", "영향"),
"optional": ("관계",)},
# 환경 구성의 절 구성은 Concept 과 같다 — 본문 밖의 칸이 없다. 「실행 절차·구성 값·확인
# 방법을 `##` 절로 적는다. **절 이름을 강제하지 않는다** — 프로젝트마다 셋업의 모양이
# 다르다」(`SetupInput.bodyMarkdown`)라 그 셋은 본문 구간 안에 있고 여기서 세지 않는다.
# 본문 밖의 칸 `pinnedVersions` 는 절이 아니라 frontmatter 에 있다 (templates/setup.md)
"setup": {"required": ("관계", "본문"), "optional": ()},
}
# 본문이 있는 종류는 셋이다. 목록은 techlog 가 정한다
BODY_KINDS = techlog.BODY_KINDS
# frontmatter 의 `kind` 는 Studio 가 쓰는 값이다. 폴더 이름과 하나가 다르다 —
# decision/ 폴더의 기록은 `kind: PROJECT_DECISION` 이다 (templates/decision.md:3)
KIND_ALIASES = techlog.DIR_OF_KIND
# 종류가 요구하는 근거의 자리. 값이 옳은지가 아니라 **자리가 채워졌는지**만 본다
FRONTMATTER: dict[str, tuple[str, ...]] = {
"case": ("sourceRevision",),
"concept": ("basisVersion",),
"reference": ("sourceRevision",),
"question": ("questionStatus",),
"decision": ("decisionStatus",),
# 환경 구성에는 검증일 칸이 없다 — `lastVerifiedOn` 도 `verifiedOn` 도 계약에 없다.
# 낡음을 말하는 것은 `pinnedVersions` 뿐이라(「어느 버전 위에서 이 절차가 성립했는지가
# 유효 범위다」 · `SetupDetailResponse`) Concept 의 `basisVersion` 과 같은 자리다
"setup": ("pinnedVersions",),
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
def _front_matter(text: str) -> tuple[dict, int]:
"""frontmatter 와 그것이 끝나는 줄 번호."""
if not text.startswith("---"):
return {}, 0
end = text.find("\n---", 3)
if end < 0:
return {}, 0
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, text[:end].count("\n") + 2
def _sections(text: str) -> dict[str, str]:
"""`## 이름` → 그 아래 내용. 본문 구간 안의 `##` 은 세지 않는다."""
body_a = text.find(BODY_START)
body_b = text.find(BODY_END)
out: dict[str, str] = {}
order: list[tuple[str, int]] = []
for m in re.finditer(r"^##\s+(.+)$", text, re.M):
if body_a >= 0 <= body_b and body_a < m.start() < body_b:
continue # 본문 안의 절은 글마다 다르다. 강제하지 않는다
order.append((m.group(1).strip(), m.end()))
for i, (name, start) in enumerate(order):
stop = order[i + 1][1] - len(f"## {order[i + 1][0]}") if i + 1 < len(order) else len(text)
chunk = text[start:stop]
if name == "본문":
chunk = chunk.replace(BODY_START, "").replace(BODY_END, "")
out[name] = chunk.strip()
return out
def _summary(text: str, fm_end: int) -> str:
"""제목 바로 아래 첫 문단. Studio 의 `요약` 칸이다."""
rest = text.split("\n", fm_end)[-1] if fm_end else text
m = re.search(r"^#\s+.+$", rest, re.M)
if not m:
return ""
after = rest[m.end():]
after = re.split(r"^##\s", after, maxsplit=1, flags=re.M)[0]
for para in (p.strip() for p in after.split("\n\n")):
if para and not para.startswith("<!--"):
return para
return ""
def _lead_paragraphs(text: str, fm_end: int) -> list[str]:
"""제목과 첫 `##` 사이의 문단 **전부**.
`_summary()` 는 그중 첫 문단만 돌려준다. `scripts/studio-save.py` 의 같은 이름 함수도
그렇다 — **둘째 문단부터는 Studio 저장에서 통째로 사라진다.** 저장은 성공하고 화면에도
빈 곳이 없어서, 저장소의 `.md` 와 공개본이 갈린 것을 아무도 모른다.
실제로 두 프로젝트에서 15편이 그 상태였고 그중 13편은 이미 그렇게 게시돼 있었다.
버려지던 글자가 2,305자다. 한 편은 REFERENCE 인데 **지침이 통째로 둘째 문단에 있어**
공개본에 무엇을 하라는 말이 한 줄도 없었다.
"""
rest = text.split("\n", fm_end)[-1] if fm_end else text
m = re.search(r"^#\s+.+$", rest, re.M)
if not m:
return []
after = re.split(r"^##\s", rest[m.end():], maxsplit=1, flags=re.M)[0]
return [p for p in (x.strip() for x in after.split("\n\n"))
if p and not p.startswith("<!--")]
def check_record(path: str, rep: techlog.Report) -> None:
rel = os.path.relpath(path, ROOT)
text = open(path, encoding="utf-8").read()
fm, fm_end = _front_matter(text)
# `pinnedVersions:` 처럼 값이 아래 줄에 있는 칸은 한 줄 정규식이 빈 값으로 읽는다.
# 채워진 목록을 「없다」로 세지 않는다 — 스칼라 칸에서는 블록이 없으므로 그대로다
for _key, _value in list(fm.items()):
if not _value:
fm[_key] = techlog.front_matter_block(text, _key)
kind = KIND_ALIASES.get((fm.get("kind") or "").upper(),
(fm.get("kind") or "").lower())
if kind not in SECTIONS:
rep.error("kind 를 모르겠다", f"{rel} — kind={fm.get('kind')!r}")
return
spec = SECTIONS[kind]
found = _sections(text)
for name in spec["required"]:
if name not in found:
rep.error(f"{kind.upper()} 에 `{name}` 칸이 없다", rel)
elif not found[name]:
rep.error(f"{kind.upper()} 의 `{name}` 칸이 비었다",
f"{rel} — 제목만 있고 내용이 없다")
known = set(spec["required"]) | set(spec["optional"])
for name in found:
if name not in known:
rep.error("계약에 없는 칸 — 화면에 자리가 없어 사라진다",
f"{rel} — ## {name}")
if not _summary(text, fm_end):
rep.error("요약이 없다", f"{rel} — 제목 바로 아래 첫 문단이 `요약` 칸이다")
# 제목 아래 문단이 둘 이상이면 둘째부터는 Studio 저장에서 버려진다. 저장은 성공하고
# 화면에도 빈 곳이 없어 아무도 모른다 — 그래서 검사기가 없으면 같은 일이 되풀이된다
lead = _lead_paragraphs(text, fm_end)
if len(lead) > 1:
dropped = sum(len(p) for p in lead[1:])
rep.error("제목 아래 문단이 둘 이상이다 — 둘째부터 Studio 저장에서 사라진다",
f"{rel} — 문단 {len(lead)}개 · 버려지는 글자 {dropped}자. "
f"요약에 합치거나(200자 아래) 다른 칸으로 옮긴다")
# 여기에 「요약에 백틱이 있으면 error」를 한 번 넣었다가 뺐다. **틀린 조항이었다.**
# 근거로 삼은 것이 `record-kinds.md` 의 「본문을 뺀 모든 칸은 평문이라 백틱이 글자 그대로
# 보인다」였는데, 그 문장이 낡았다. 렌더러
# (`tech-log-frontend` 의 `public-render/prose-text.tsx`)가 요약을 `<ProseText>` 로
# 그리고, 그것이 백틱 쌍을 인라인 `<code>` 로 바꾼다. 백틱이 글자로 나오던 것은
# **고쳐진 옛 버그**이고 그 파일 주석에 그렇게 적혀 있다.
#
# 스킬 문서를 근거로 검사기를 만들면 이렇게 된다. 칸이 어떻게 보이는지는 렌더러가
# 정본이다. 백틱을 빼는 쪽이 오히려 계약과 어긋난다.
for key in FRONTMATTER.get(kind, ()):
if not fm.get(key):
rep.error(f"{kind.upper()} 에 `{key}` 가 없다", rel)
# 본문이 있는 종류만 본문 마커를 갖는다. 없는 종류에 있으면 평문으로 새어 나간다
has_body = BODY_START in text and BODY_END in text
if kind in BODY_KINDS and not has_body:
rep.error(f"{kind.upper()} 에 본문 마커가 없다", rel)
if kind not in BODY_KINDS and has_body:
rep.error(f"{kind.upper()} 은 본문이 없는 종류인데 본문 마커가 있다", rel)
# 근거의 자리 — CASE 는 관찰을 뒷받침할 것이 있어야 한다.
# 무엇을 가리키는지는 check_evidence 가 보고, 여기서는 자리가 비었는지만 본다
if kind == "case" and "evidence:" not in text and "source:" not in text:
rep.error("CASE 에 근거 목록이 없다", f"{rel} — evidence: 도 source: 도 없다")
# 답이 없는 QUESTION 은 정상이다. 답을 구할 방법이 없는 것이 결함이다
if kind == "question" and found.get("다음 검증", "").strip() in ("", "-"):
rep.error("QUESTION 에 답을 구할 방법이 없다",
f"{rel} — 답이 없는 것은 결함이 아니지만 방법이 없는 것은 결함이다")
def verify(project: str) -> tuple[techlog.Report, str | None]:
"""(보고, 대상이 성립하지 않는 사유). 사유가 있으면 검사한 것이 하나도 없다.
「봤고 괜찮다」와 「볼 것이 없어서 통과」를 가른다. 셋으로 나뉜다.
| 상태 | 종료 코드 | 문구 |
|---|---|---|
| 봤고 괜찮다 | 0 | `error 0` |
| 대상이 성립하지 않는다 (프로젝트 없음 · 계약 없음) | 2 | `대상이 성립하지 않는다 — <이유>` |
| 볼 것이 아직 없다 (계약은 있고 기록 0건) | 0 | `기록 0건 — 계약의 글감 N개가 아직 안 쓰였다` |
가운데는 결함이다. 아래는 결함이 아니다 — 아직 안 쓴 것은 잘못이 아니다. 다만 초록으로
보이면 안 된다.
"""
rep = techlog.Report(project)
studio = os.path.join(ROOT, "docs", project, "tech-log-studio")
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
return rep, "tech-log-tree.json 이 없다"
records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md"))
if not f.split(os.sep)[-3].startswith("_")]
rep.facts["records"] = len(records)
planned = len(list(techlog.nodes(index)))
if not records:
rep.facts["미작성"] = f"계약의 글감 {planned}개가 아직 안 쓰였다"
kinds = collections.Counter()
for f in records:
kinds[os.path.basename(os.path.dirname(f))] += 1
check_record(f, rep)
rep.facts["kinds"] = dict(kinds)
return rep, None
def render(rep: techlog.Report, samples: int) -> None:
facts = " · ".join(f"{k}={v}" for k, v in rep.facts.items())
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[:samples]:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}건")
def main() -> int:
ap = argparse.ArgumentParser(description="종류가 요구하는 내용이 채워져 있는지 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--file", action="append", default=[], help="기록 .md 를 직접 준다")
ap.add_argument("--samples", type=int, default=3)
args = ap.parse_args()
if args.file:
rep = techlog.Report("파일")
rep.facts["records"] = len(args.file)
for f in args.file:
check_record(os.path.abspath(f), rep)
reports = [rep]
else:
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("_"))
if not projects:
print("볼 프로젝트가 없다", file=sys.stderr)
return 2
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
pairs = [verify(p) for p in projects]
ungrounded = [(p, why) for (r, why), p in zip(pairs, projects) if why]
if ungrounded:
for p, why in ungrounded:
print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr)
return 2
reports = [r for r, _ in pairs]
for r in reports:
render(r, args.samples)
e = sum(r.error_count for r in reports)
total = sum(r.facts.get("records", 0) for r in reports)
unwritten = [f"{r.project}: {r.facts['미작성']}" for r in reports if "미작성" in r.facts]
for line in unwritten:
print(f" · {line}")
print(f"\nREQUIRED CONTENT: {'FAIL' if e else 'PASS'}"
f" — 기록 {total} · error {e}"
+ (f" · 아직 안 쓴 프로젝트 {len(unwritten)}개" if unwritten else ""))
return 1 if e else 0
if __name__ == "__main__":
raise SystemExit(main())