feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
+186
View File
@@ -102,6 +102,85 @@ def _values(node: dict, key: str) -> list[str]:
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)
@@ -186,6 +265,7 @@ def verify(project: str) -> Report:
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():
@@ -236,8 +316,11 @@ def verify(project: str) -> Report:
# ── 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}")
@@ -252,6 +335,30 @@ def verify(project: str) -> Report:
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]}")
@@ -289,6 +396,85 @@ def verify(project: str) -> Report:
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: