형식 관문 여덟이 확신 승격·수치 조작·화살표 뒤집기·필수 칸 삭제를 하나도 못 막는 것이 재현됐다. 그 가운데 결정적으로 판정 가능한 것을 코드로 옮긴다. check-required-content.py — 종류가 요구하는 칸이 없거나 비었는지 본다. audit-records.py 는 평문 칸 안에 마크업이 있는지만 보고 칸이 있는지는 안 센다. 고정 목차·자료 개수·답은 강제하지 않는다. 답이 없는 QUESTION 은 정상이고 「다음 검증」이 빈 것만 결함이다. 대상이 성립하지 않으면(프로젝트 없음·계약 없음) exit 2 로 막고, 계약은 있고 기록이 0건이면 통과시키되 초록으로 두지 않는다. 「봤고 괜찮다」와 「볼 것이 없어서 통과」는 다르다. check-preservation.py — 윤문 전후를 견준다. 지금 관문 가운데 편집 전후를 보는 것이 하나도 없어 수치를 바꾸거나 유보를 지운 편집이 그대로 통과했다. 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸 값일 수 있다. 유보 표현이 줄면 내되 옳은지는 판정하지 않는다. 늘어난 것은 세지 않는다. review-package.py — 아무것도 판정하지 않는다. 판정할 사람이 받을 것을 모은다. 해시·검사기 버전·여기서 실제로 돌린 관문·주장 후보·판정 기준. 종료 코드로 안 걸리는 것은 warnings 로 따로 올린다 — 확신 승격이 딱 그 모양이라 안 실으면 아무도 못 본다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
264 lines
12 KiB
Python
264 lines
12 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": ("관계",)},
|
|
}
|
|
BODY_KINDS = {"case", "concept"}
|
|
|
|
# frontmatter 의 `kind` 는 Studio 가 쓰는 값이다. 폴더 이름과 하나가 다르다 —
|
|
# decision/ 폴더의 기록은 `kind: PROJECT_DECISION` 이다 (templates/decision.md:3)
|
|
KIND_ALIASES = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
|
|
"QUESTION": "question", "PROJECT_DECISION": "decision"}
|
|
|
|
# 종류가 요구하는 근거의 자리. 값이 옳은지가 아니라 **자리가 채워졌는지**만 본다
|
|
FRONTMATTER: dict[str, tuple[str, ...]] = {
|
|
"case": ("sourceRevision",),
|
|
"concept": ("basisVersion",),
|
|
"reference": ("sourceRevision",),
|
|
"question": ("questionStatus",),
|
|
"decision": ("decisionStatus",),
|
|
}
|
|
|
|
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 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)
|
|
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} — 제목 바로 아래 첫 문단이 `요약` 칸이다")
|
|
|
|
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())
|