#!/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 _headings(path: str) -> list[tuple[int, str, str]]: """(단계, 제목, 슬러그). 앵커가 실재하는 절을 가리키는지 대조하는 데 쓴다.""" try: text = open(path, encoding="utf-8").read() except OSError: return [] out = [] for m in re.finditer(r"^(#{2,4})\s+(.+)$", text, re.M): title = m.group(2).strip() slug = re.sub(r"\s+", "-", re.sub(r"[`*(),:·—?./]", " ", title).strip()).lower() out.append((len(m.group(1)), title, slug)) return out def _anchor_base(anchor: str, slugs: list[str]) -> str | None: """앵커가 어느 절 슬러그로 시작하는가. 가장 긴 것을 고른다. 이 저장소의 앵커는 「h2 슬러그 + 구분자」다 — `검토한-선택지와-막힌-지점-ap1` 처럼. 구분자는 패턴 번호이거나 그 아래 h3 의 슬러그 앞부분이다. """ best = None for slug in slugs: if anchor == slug or anchor.startswith(slug + "-"): if best is None or len(slug) > len(best): best = slug return best def _ledger_names(entries) -> set[str]: """assetLedger 의 한 칸을 이름 집합으로 편다. 사람이 쓰는 칸이라 모양이 둘이다 — 이름만 적기도 하고, 왜 그렇게 두었는지를 `{"asset": [...], "reason": "..."}` 로 적기도 한다. """ out: set[str] = set() for entry in entries or []: if isinstance(entry, str): out.add(entry) elif isinstance(entry, dict): names = entry.get("asset") or entry.get("assets") or [] out.update(names if isinstance(names, list) else [names]) return {str(n) for n in out} def _bare_anchor(ref: str) -> str: """앵커만 남긴다. 계약은 `` `경로#앵커` §14.1 `` 처럼 꾸며 적기도 한다.""" return ref.strip().strip("`").split()[0].strip("`") if ref.strip() else "" def _record_sources(path: str) -> list[str]: """기록 frontmatter 의 `source` 목록. 계약과 같은 것을 말하는지 대조하는 데 쓴다.""" try: text = open(path, encoding="utf-8").read() except OSError: return [] m = re.search(r"^source:\s*\n((?:\s+-\s+\S+\n)+)", text, re.M) if not m: return [] return [line.strip()[2:].strip() for line in m.group(1).splitlines() if line.strip()] def _all_anchors(index: dict): """(어디, 앵커 목록). 후보의 sourceRefs 와 글감의 source 를 함께 낸다.""" for c in index.get("candidates") or []: refs = c.get("sourceRefs") or [] if refs: yield f"후보 {c.get('id')}", refs for slug, kind, node in techlog.nodes(index): refs = _values(node, "source") if refs: yield f"{slug}/{kind}/{node.get('slug') or node.get('title')}", refs 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}") # ── 주제 ─────────────────────────────────────────────────────── assigned_to_nodes: set[str] = set() 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 (node.get("assets") or []) + (node.get("assetFiles") or []): assigned_to_nodes.add(os.path.basename(str(name)).replace(".svg", "")) for name in _values(node, "ssot-assets"): stem = os.path.basename(name)[:-4] if name.endswith(".svg") else os.path.basename(name) assigned_to_nodes.add(stem) 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 가 가리키지 않는다") # 계약의 source 와 기록의 source 가 갈리면 어느 쪽이 근거인지 알 수 없다. # build 는 이 칸을 다시 채우지 않으므로 갈린 채로 남는다 if node.get("file"): on_disk = _record_sources(os.path.join(studio, node["file"])) if on_disk: # SSOT 를 가리키는 것끼리만 견준다. 기록의 `source` 에는 코드 파일 경로가 # 함께 적히기도 하는데(clean-architecture 가 그렇다) 그것은 계약이 적는 # 자리가 아니라 이 검사의 대상이 아니다 def _ssot_only(refs): return {a for a in (_bare_anchor(r) for r in refs) if a and ssot_rel in a} have = _ssot_only(on_disk) want = _ssot_only(_values(node, "source")) if have != want: only_record = sorted(have - want) only_tree = sorted(want - have) detail = [] if only_record: detail.append(f"기록에만 {only_record[:2]}") if only_tree: detail.append(f"계약에만 {only_tree[:2]}") rep.error("계약과 기록의 source 가 다르다", f"{where} — {' · '.join(detail)}") 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 # ── 앵커가 실재하는 절을 가리키나 ────────────────────────────── # 검사기가 지금까지 본 것은 「SSOT 경로를 포함하는가」뿐이었다. 그래서 어느 절도 # 가리키지 않는 앵커가 그대로 통과했다 heads = _headings(ssot_path) if os.path.exists(ssot_path) else [] head_slugs = [h[2] for h in heads] if heads and has_contract: # 앵커 형식은 프로젝트마다 다르다 — 절 제목 슬러그를 쓰는 곳도 있고 # `§1.1`·`10-2`·`a18` 처럼 번호나 마커를 쓰는 곳도 있다. 형식을 강요하지 않고, # 슬러그를 쓰는 프로젝트에서만 실재를 대조한다 seen_anchors: list[tuple[str, str]] = [] for where, refs in _all_anchors(index): for ref in refs: if "#" in ref: seen_anchors.append((where, ref.split("#", 1)[1])) resolved = [(w, a, _anchor_base(a, head_slugs)) for w, a in seen_anchors] hit = sum(1 for _, _, b in resolved if b) slug_style = seen_anchors and hit * 2 >= len(seen_anchors) pointed: list[tuple[str, str]] = [] if slug_style: for where, anchor_slug, base_slug in resolved: if base_slug is None: rep.error("SSOT 에 없는 절을 가리키는 앵커", f"{where} — #{anchor_slug}") else: pointed.append((base_slug, anchor_slug[len(base_slug):].lstrip("-"))) elif seen_anchors: rep.warn("앵커가 절 제목이 아니라 번호·마커다", f"{len(seen_anchors)}건 — 검사기가 그 절이 실재하는지 대조하지 못한다") # 범위 안의 절을 후보 대장이 하나도 안 짚었나. # 검사기는 「후보 ↔ 글감」만 봐서 SSOT 재료를 통째로 지나쳐도 error 가 0 이었다. # h2 만 보면 성기다 — 놓치는 것은 그 아래 h3 이다 included = {re.sub(r"\s+", "-", re.sub(r"[`*(),:·—?./]", " ", t).strip()).lower() for t in (scope.get("sections") or [])} if included and slug_style: groups: dict[str, list[tuple[str, str]]] = {} current = None for level, title, slug in heads: if level == 2: current = slug groups.setdefault(current, []) elif level == 3 and current is not None: groups[current].append((title, slug)) for h2_slug, children in groups.items(): if h2_slug not in included: continue rems = [rem for base, rem in pointed if base == h2_slug] if not rems: rep.warn("범위 안인데 아무 후보도 가리키지 않는 절", f"{h2_slug} — SSOT 재료를 후보 대장이 지나쳤다") continue if "" in rems: # 절 전체를 가리키는 앵커가 있다 continue for title, slug in children: if any(r and (slug.startswith(r) or r.startswith(slug)) for r in rems): continue rep.warn("범위 안인데 아무 후보도 가리키지 않는 절", f"{title} — 처분도 적히지 않았다") # ── SSOT 가 만들어 둔 그림의 대장 ────────────────────────────── ledger = index.get("assetLedger") or {} if ledger and has_contract: # 그림 폴더는 `assets/<이름>/` 이기도 하고 `assets/diagrams/<이름>/` 이기도 하다. # 이름은 SVG 파일 이름이 정한다 on_disk = {os.path.basename(p)[:-4] for p in glob.glob( os.path.join(base, "final", "assets", "**", "*.svg"), recursive=True)} assigned = _ledger_names(ledger.get("assigned")) unassigned = _ledger_names(ledger.get("unassigned")) for name in sorted(assigned - on_disk): rep.error("assetLedger 가 없는 그림을 배정했다고 적었다", name) missing = on_disk - assigned - unassigned for name in sorted(missing): rep.error("그림이 assetLedger 에 없다", f"{name} — 배정했는지 안 했는지 적히지 않았다") for name in sorted(assigned - assigned_to_nodes): rep.error("assetLedger 는 배정했다는데 글감이 안 쓴다", name) # ── 후보와 처분 ──────────────────────────────────────────────── 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())