글감 1,001개 중 제1부(§3~§11) 앵커를 하나라도 가진 것은 112개뿐이었다. 나머지 889개는
제2부 모듈 분석 65편의 절 제목에서 나온 것이고, 그것이 재판정이 필요했던 이유다.
주제 44 → 16 (43개가 독자 질문 없이 있었다. 지금은 전부 있다)
글감 1,001 → 123 (제1부 앵커 112 + 제1부가 채택했는데 비어 있던 자리 11)
후보 965 → 1,088 · PENDING 905 → 0
error 3,042 → 0
내려온 889개는 후보 대장에 KEEP_IN_SSOT 로 남는다 — 버린 것이 아니라 분석에 남기고 독립
기록으로 만들지 않기로 한 것이다. 그 글감을 받치던 기록 파일 828개는 지웠다. 계약이 정본이고,
파일이 남아 있다는 이유로 계약에서 뺀 주제가 되살아나면 안 된다. 이력에는 그대로 있다 —
git checkout a0ca2bb -- <경로>.
제1부가 채택했는데 글감이 없던 자리 열하나를 채웠다: mongo high-water mark 가 재전달 이벤트를
삼킨 P1, admin plane 이 가드만 켜고 서비스는 켜지 않은 것과 그 짝인 결정, 실패 어휘 세 층과
SQLState 매트릭스 병합 규칙, 부하 아래에서만 새는 admission 경계, 발행 증거와 완료 판정의
분리, keyset·JSONB 결정 둘.
Concept 17개에 basis-version 을 채우고, 계약 제목과 기록 제목이 갈라져 있던 23건을 기록 쪽에
맞췄다. candidateScope 에 excludedAnchorPattern 을 적어 제2부 앵커만 가진 글감이 다시 올라올
수 없게 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
369 lines
19 KiB
Python
Executable File
369 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""분해 계약이 스스로 맞는지, 그리고 기록·색인이 계약을 따르는지 본다.
|
|
|
|
`verify-pipeline.py` 는 스킬과 틀이 제자리에 있는지만 본다. 이 검사기는 한 프로젝트의
|
|
실제 트리를 본다 — `tech-log-tree.json` 의 주제·글감·후보와 디스크의 기록이 같은 것을
|
|
말하는지.
|
|
|
|
python3 scripts/verify-tech-log-tree.py [프로젝트 ...] [--strict] [--samples N] [--json]
|
|
|
|
정본 순서는 이렇다.
|
|
|
|
코드·설정·실행 증거 사실의 근거
|
|
final/document.md 글감 범위의 SSOT. candidateScope 가 그 범위를 말한다
|
|
analysis/**/*.md 이미 채택한 주장을 상세 확인하는 보조 근거 (분석 중에만 있다)
|
|
tech-log-tree.json 사람이 고른 글감. 분해 계약이자 색인이고 정본이다
|
|
|
|
error 가 하나라도 있으면 실패다. warn 은 편집 판단이 필요한 자리이고 `--strict` 에서만
|
|
실패가 된다. 선별을 마치지 않은 상태 — `dispositionReview: PENDING`, PROMOTE 후보와
|
|
글감이 1:1 이 아닌 것 — 는 warn 이 아니라 error 다. 경고로 두면 재판정하지 않은 트리로
|
|
글을 쓰기 시작할 수 있다.
|
|
|
|
**계약을 아직 채택하지 않은 프로젝트도 error 다.** 칸마다 error 를 내지는 않는다 —
|
|
「아직 쓰지 않았다」가 「잘못 썼다」로 보이기 때문이다. 대신 계약 미채택 자체를 한 건의
|
|
error 로 센다. warn 으로 두면 옛 스키마로 남아 있는 한 검사를 영원히 피한다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import techlog # noqa: E402
|
|
from techlog import KINDS, DISPOSITIONS, READINESS, Report # noqa: E402
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# 종류마다 글감이 반드시 갖는 칸
|
|
REQUIRED_FIELDS = {
|
|
"case": ("slug", "readiness", "source", "classification", "missing-verification", "relations"),
|
|
"concept": ("slug", "readiness", "source", "basis-version", "classification", "relations"),
|
|
"reference": ("slug", "readiness", "source", "classification", "scope", "exceptions", "relations"),
|
|
"question": ("slug", "readiness", "source", "known", "unknown",
|
|
"next-verification", "decision-criterion", "relations"),
|
|
"decision": ("slug", "readiness", "source", "decision-status", "decision-evidence",
|
|
"grounds", "classification", "relations"),
|
|
}
|
|
# 글을 써도 되는 readiness. 나머지는 글감으로만 남는다
|
|
GENERATABLE = {"case": {"READY"}, "concept": {"READY"}, "reference": {"READY"},
|
|
"question": {"OPEN"}, "decision": {"READY"}}
|
|
DECISION_STATUS = {"PROPOSED", "ADOPTED", "SUPERSEDED", "NOT_DECIDED"}
|
|
|
|
# 분석 문서의 절 제목을 그대로 옮겨 온 자리
|
|
COPIED_HEADING = (
|
|
(re.compile(r"^\(\d+(?:\.\d+)*\)"), "분석 문서의 절 번호가 제목에 남아 있다"),
|
|
(re.compile(r"^(Confirmed|P[1-3])\s*[—–-]"), "분석 문서의 finding 등급이 제목에 남아 있다"),
|
|
)
|
|
# Concept 은 「남의 것이 어떻게 동작하는가」다
|
|
CONCEPT_NOT_A_MECHANISM = (
|
|
(r"없다|없음|부재|미배선|배선되지|호출자|호출되지 않|실행되지 않|도달하지 않", "부재·미배선 사실"),
|
|
(r"refs?\s*=\s*0|카운트|개수|몇 개|전부 읽|샘플링|denominator", "분석 범위·계수"),
|
|
(r"보류|남은 것|다음 사이클|이번 pass|커버리지|coverage|레인과 복원|기록이다", "분석 진행 기록"),
|
|
(r"드리프트|불일치|어긋|틀렸|실패했|누락|검증되지", "Finding 문장"),
|
|
)
|
|
|
|
|
|
def _front_matter(path: str) -> dict:
|
|
text = open(path, encoding="utf-8").read()
|
|
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 _records_on_disk(studio: str) -> dict[tuple[str, str], str]:
|
|
found = {}
|
|
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
|
|
parts = path.split(os.sep)
|
|
topic_dir, kind_dir = parts[-3], parts[-2]
|
|
if topic_dir.startswith("_") or kind_dir not in KINDS:
|
|
continue
|
|
fm = _front_matter(path)
|
|
found[(kind_dir, fm.get("slug") or os.path.basename(path)[:-3])] = path
|
|
return found
|
|
|
|
|
|
def _values(node: dict, key: str) -> list[str]:
|
|
value = node.get(key)
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return [str(v) for v in value if str(v).strip()]
|
|
return [str(value)] if str(value).strip() else []
|
|
|
|
|
|
def verify(project: str) -> Report:
|
|
rep = Report(project)
|
|
base = os.path.join(ROOT, "docs", project)
|
|
studio = os.path.join(base, "tech-log-studio")
|
|
records = _records_on_disk(studio)
|
|
rep.facts["records"] = len(records)
|
|
|
|
index_path = os.path.join(studio, "tech-log-tree.json")
|
|
index = techlog.load_index(index_path)
|
|
if index is None:
|
|
rep.warn("분해 계약 없음",
|
|
f"{project}: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다")
|
|
return rep
|
|
|
|
# ── 원본 무결성 ────────────────────────────────────────────────
|
|
ssot_rel = index.get("ssot") or "final/document.md"
|
|
ssot_path = os.path.join(base, ssot_rel)
|
|
if not os.path.exists(ssot_path):
|
|
rep.error("SSOT 파일 없음", ssot_rel)
|
|
else:
|
|
declared = index.get("ssotSha256") or ""
|
|
actual = techlog.sha256_of(ssot_path)
|
|
if not declared:
|
|
rep.error("ssotSha256 없음", ssot_rel)
|
|
elif declared != actual:
|
|
rep.error("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
|
|
"python3 scripts/build-tech-log-tree.py 를 다시 돌린다")
|
|
if not index.get("sourceRevision"):
|
|
rep.warn("sourceRevision 없음", project)
|
|
|
|
# 옛 색인은 디렉터리를 훑어 만든 것이라 계약 칸이 아예 없다. 칸마다 error 를 내면
|
|
# 「아직 쓰지 않았다」가 「잘못 썼다」로 보인다
|
|
has_contract = index.get("schemaVersion", 1) >= 4 or "contract" in index
|
|
rep.facts["contract"] = "있음" if has_contract else "없음"
|
|
if not has_contract:
|
|
rep.error("글감 계약을 아직 쓰지 않았다",
|
|
f"{project}: 옛 색인이다. 주제·독자 질문·글감의 칸을 사람이 적어야 한다 "
|
|
"— 칸마다 error 를 내지 않는 대신 미채택 자체를 여기서 한 번 센다")
|
|
|
|
# ── 분석한 저장소 ──────────────────────────────────────────────
|
|
# 저장소가 여럿인 프로젝트는 목록으로 적는다. 하나면 객체 하나로 적어도 된다
|
|
repo = index.get("sourceRepository") or {}
|
|
repos = repo if isinstance(repo, list) else [repo]
|
|
if has_contract:
|
|
if not repos:
|
|
rep.error("sourceRepository.path 가 없다",
|
|
f"{project}: 어느 저장소를 읽고 쓴 글인지 적혀 있지 않다")
|
|
for r in repos:
|
|
name = r.get("name") or project
|
|
if not r.get("path"):
|
|
rep.error("sourceRepository.path 가 없다",
|
|
f"{project}: 어느 저장소를 읽고 쓴 글인지 적혀 있지 않다")
|
|
elif not os.path.exists(r["path"]) and "://" not in r["path"]:
|
|
rep.warn("sourceRepository.path 가 이 기계에 없다", f"{name}: {r['path']}")
|
|
# 갈래가 여럿이면 단일 커밋으로 표현되지 않는다. revisions 로 적는다
|
|
if not r.get("revision") and not r.get("revisions"):
|
|
rep.warn("sourceRepository 에 리비전이 없다",
|
|
f"{project}/{name}: 문서가 서술한 상태의 커밋을 고정하지 않았다")
|
|
elif not r.get("verified"):
|
|
rep.warn("sourceRepository.verified 가 없다",
|
|
f"{project}/{name}: 그 리비전이 맞다고 판단한 근거가 없다")
|
|
|
|
# ── 후보를 찾는 범위 ───────────────────────────────────────────
|
|
scope = index.get("candidateScope") or {}
|
|
excluded_anchor = None
|
|
if has_contract:
|
|
if not scope:
|
|
rep.error("candidateScope 가 없다",
|
|
f"{project}: SSOT 의 어느 부분에서 후보를 찾는지 적지 않았다")
|
|
else:
|
|
if scope.get("document") and scope["document"] != ssot_rel:
|
|
rep.error("candidateScope.document 가 ssot 과 다르다",
|
|
f"{scope['document']} ≠ {ssot_rel}")
|
|
if not scope.get("sections"):
|
|
rep.error("candidateScope 에 sections 가 없다", project)
|
|
pattern = scope.get("excludedAnchorPattern")
|
|
if pattern:
|
|
try:
|
|
excluded_anchor = re.compile(pattern)
|
|
except re.error as exc:
|
|
rep.error("candidateScope.excludedAnchorPattern 이 정규식이 아니다",
|
|
f"{pattern} — {exc}")
|
|
|
|
# ── 주제 ───────────────────────────────────────────────────────
|
|
topics = index.get("topics") or {}
|
|
rep.facts["topics"] = len(topics)
|
|
for slug, topic in topics.items():
|
|
if topic.get("topic") and topic["topic"] != slug:
|
|
rep.error("주제 키와 topic 이 다르다", f"{slug} ≠ {topic['topic']}")
|
|
if not (topic.get("readerQuestion") or "").strip():
|
|
if has_contract:
|
|
rep.error("Topic 에 독자 질문이 없다", slug)
|
|
elif not topic["readerQuestion"].rstrip().endswith("?"):
|
|
rep.warn("독자 질문이 물음이 아니다", f"{slug}: {topic['readerQuestion'][:60]}")
|
|
n = sum(len(v) for v in (topic.get("kinds") or {}).values())
|
|
if n == 1:
|
|
rep.warn("Topic 에 노드가 하나뿐이다", slug)
|
|
if n == 0:
|
|
rep.error("Topic 에 글감이 없다", slug)
|
|
|
|
# ── 글감 ───────────────────────────────────────────────────────
|
|
slugs: dict[str, str] = {}
|
|
listed: set[tuple[str, str]] = set()
|
|
total = 0
|
|
for topic_slug, kind, node in techlog.nodes(index):
|
|
total += 1
|
|
where = f"{topic_slug} · {kind.upper()} · {str(node.get('title',''))[:44]}"
|
|
if kind not in REQUIRED_FIELDS:
|
|
rep.error("종류 이름이 계약에 없다", f"{where} — {kind}")
|
|
continue
|
|
if has_contract:
|
|
for key in REQUIRED_FIELDS[kind]:
|
|
if not _values(node, key):
|
|
rep.error(f"{kind.upper()} 노드에 `{key}` 가 없다", where)
|
|
slug = str(node.get("slug") or "")
|
|
if slug:
|
|
if slug in slugs:
|
|
rep.error("slug 가 두 글감에 있다", f"{slug} — {slugs[slug]} / {where}")
|
|
slugs[slug] = where
|
|
listed.add((kind, slug))
|
|
readiness = str(node.get("readiness") or "").upper()
|
|
if readiness and readiness not in READINESS:
|
|
rep.error("readiness 값이 계약에 없다", f"{where} — {readiness}")
|
|
if kind == "question" and readiness and readiness != "OPEN":
|
|
rep.error("OPEN QUESTION 의 readiness 는 OPEN 이다", f"{where} — {readiness}")
|
|
if kind == "decision":
|
|
status = str(node.get("decision-status") or "").strip("`").upper()
|
|
if status and status not in DECISION_STATUS:
|
|
rep.error("decision-status 값이 계약에 없다", f"{where} — {status}")
|
|
if has_contract and not _values(node, "relations"):
|
|
rep.warn("관계가 없는 노드", where)
|
|
|
|
# ── SSOT 가 이미 가진 그림·증거를 이 글감에 배정했는가 ─────────
|
|
# 배정만 해 두고 기록이 쓰지 않으면 글 쓸 때 새로 그리게 된다. 그것을 여기서 센다
|
|
for name in _values(node, "ssot-assets"):
|
|
stem = os.path.basename(name)[:-4] if name.endswith(".svg") else os.path.basename(name)
|
|
if not glob.glob(os.path.join(base, "final", "assets", "**", f"{stem}.svg"),
|
|
recursive=True):
|
|
rep.error("배정한 SSOT 그림이 final/assets 에 없다", f"{where} — {name}")
|
|
elif node.get("file") and stem not in (node.get("assetFiles") or []):
|
|
rep.error("배정한 SSOT 그림을 기록이 쓰지 않는다",
|
|
f"{where} — {stem} — 기록의 assets 가 가리키지 않는다")
|
|
for name in _values(node, "ssot-evidence"):
|
|
rel_ev = name[len("final/evidence/"):] if name.startswith("final/evidence/") else name
|
|
if not os.path.exists(os.path.join(base, "final", "evidence", rel_ev)):
|
|
rep.error("배정한 SSOT 증거가 final/evidence 에 없다", f"{where} — {name}")
|
|
elif node.get("file") and not any(
|
|
f.endswith(rel_ev) for f in (node.get("evidenceFiles") or [])):
|
|
rep.error("배정한 SSOT 증거를 기록이 쓰지 않는다",
|
|
f"{where} — {rel_ev} — 기록의 evidence 가 가리키지 않는다")
|
|
anchors = " ".join(_values(node, "source"))
|
|
if anchors and ssot_rel not in anchors:
|
|
rep.warn("근거가 SSOT 밖에만 있다", f"{where} — {anchors[:60]}")
|
|
if excluded_anchor:
|
|
outside = [a for a in _values(node, "source") if excluded_anchor.search(a)]
|
|
if outside and len(outside) == len(_values(node, "source")):
|
|
rep.error("후보를 찾는 범위 밖에서만 나온 글감",
|
|
f"{where} — {outside[0][:60]}")
|
|
title = str(node.get("title") or "")
|
|
for pattern, why in COPIED_HEADING:
|
|
if pattern.match(title):
|
|
rep.warn(why, where)
|
|
break
|
|
if kind == "concept":
|
|
for pattern, why in CONCEPT_NOT_A_MECHANISM:
|
|
if re.search(pattern, title):
|
|
rep.warn(f"Concept 제목이 메커니즘이 아니다 — {why}", where)
|
|
break
|
|
rep.facts["nodes"] = total
|
|
|
|
# ── readiness ↔ 실제로 쓴 글 ───────────────────────────────────
|
|
written = 0
|
|
for (kind, slug), path in sorted(records.items()):
|
|
rel = os.path.relpath(path, ROOT)
|
|
if (kind, slug) not in listed:
|
|
rep.error("계약에 없는 기록", rel)
|
|
continue
|
|
written += 1
|
|
node = next((n for _, k, n in techlog.nodes(index)
|
|
if k == kind and n.get("slug") == slug), None)
|
|
readiness = str((node or {}).get("readiness") or "").upper()
|
|
if readiness and readiness not in GENERATABLE[kind]:
|
|
rep.error("글을 쓰면 안 되는 readiness 인데 기록이 있다",
|
|
f"{rel} — readiness={readiness}")
|
|
rep.facts["written"] = written
|
|
rep.facts["unwritten"] = total - written
|
|
|
|
# ── 후보와 처분 ────────────────────────────────────────────────
|
|
candidates = index.get("candidates") or []
|
|
if candidates:
|
|
counts = collections.Counter()
|
|
promoted: set[str] = set()
|
|
for c in candidates:
|
|
d = c.get("disposition")
|
|
counts[d] += 1
|
|
if d not in DISPOSITIONS:
|
|
rep.error("disposition 값이 계약에 없다", f"{c.get('id')} — {d}")
|
|
if c.get("dispositionReview") != "CONFIRMED":
|
|
rep.error("disposition 을 다시 판정하지 않은 후보",
|
|
f"{c.get('id')} — 선별이 아니라 recall 로 방출됐다")
|
|
if d == "PROMOTE":
|
|
target = c.get("target") or ""
|
|
slug = target.split(":", 1)[1] if ":" in target else target
|
|
if not slug:
|
|
rep.error("PROMOTE 후보에 target 이 없다", str(c.get("id")))
|
|
continue
|
|
promoted.add(slug)
|
|
if slug not in slugs:
|
|
rep.error("PROMOTE 후보가 글감에 없다", f"{c.get('id')} → {target}")
|
|
rep.facts["candidates"] = dict(counts)
|
|
# 반대 방향 — 후보 대장을 거치지 않고 트리에 올라온 글감
|
|
for slug, where in sorted(slugs.items()):
|
|
if slug not in promoted:
|
|
rep.error("글감을 낳은 PROMOTE 후보가 없다", f"{slug} — {where}")
|
|
return rep
|
|
|
|
|
|
def render(rep: Report, samples: int) -> None:
|
|
facts = " · ".join(
|
|
f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else 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]:
|
|
if d:
|
|
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("--samples", type=int, default=3)
|
|
ap.add_argument("--strict", action="store_true", help="warn 도 실패로 센다")
|
|
ap.add_argument("--json", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
projects = args.projects or sorted(
|
|
name for name in (
|
|
os.path.basename(os.path.dirname(p))
|
|
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
|
|
) if not name.startswith("_")
|
|
)
|
|
reports = [verify(p) for p in projects]
|
|
if args.json:
|
|
print(json.dumps([{"project": r.project, "facts": r.facts,
|
|
"errors": dict(r.errors), "warns": dict(r.warns)}
|
|
for r in reports], ensure_ascii=False, indent=2))
|
|
return 1 if sum(r.error_count for r in reports) else 0
|
|
|
|
for r in reports:
|
|
render(r, args.samples)
|
|
e = sum(r.error_count for r in reports)
|
|
w = sum(r.warn_count for r in reports)
|
|
print(f"\nTECH LOG TREE: {'FAIL' if e or (args.strict and w) else 'PASS'}"
|
|
f" — 프로젝트 {len(reports)} · error {e} · warn {w}")
|
|
return 1 if e or (args.strict and w) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|