이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 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>
316 lines
15 KiB
Python
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())
|