이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다. 사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다. 대부분은 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>
235 lines
9.5 KiB
Python
235 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""그림 안에서 상자와 라벨이 서로를 덮는지 본다.
|
|
|
|
`technical-visualizer` 스킬이 적어 둔 사각지대를 기계로 옮긴 것이다.
|
|
|
|
**lint 도 검사기도 라벨이 상자를 덮는 것은 못 잡는다.**
|
|
|
|
lint 는 의미(관계가 이어져 있는가)를 보고 `check-figure-text.py` 는 글자가 이름인가를 본다.
|
|
둘 다 **좌표**를 안 본다. 그래서 zone 둘이 겹쳐 그려지거나 라벨이 상자에 먹혀도 통과한다.
|
|
실제로 keycloak AP4 그림을 다시 만들었을 때 Compose zone 이 Host zone 을 삼키고
|
|
`Host network` 라벨이 `Compose network` 라벨 뒤에 숨었는데 lint 는 PASS 였다.
|
|
|
|
python3 scripts/check-figure-overlap.py [프로젝트 ...]
|
|
python3 scripts/check-figure-overlap.py --file 그림.svg
|
|
|
|
renderer 가 배경 사각형을 정확한 좌표로 남기므로 글자 폭을 어림하지 않는다 —
|
|
`edge-label-bg` 와 `group-label-bg` 가 그 라벨이 실제로 차지하는 자리다.
|
|
|
|
## 그 방법이 성립하는 범위 — 「안 겹친다」와 「못 봤다」는 다른 출력이다
|
|
|
|
상자를 알아보는 근거는 **techviz 렌더러가 붙인 `class`** 다(`node-shape`·`group-box`·
|
|
`edge-label-bg`·`group-label-bg`). 손으로 그린 SVG 에는 그 class 가 없으므로 `boxes()` 가
|
|
**빈 목록**을 돌려주고, 겹칠 짝이 없으니 겹침도 0 이 된다.
|
|
|
|
그래서 고치기 전의 이 검사기는 `그림 182 · 겹친 그림 0` 을 찍었지만 **실제로 본 것은 18장**
|
|
이었다. 나머지 164장은 안 겹친 것이 아니라 **볼 수 없었던 것**이다. CLAUDE.md 가 그 상태를
|
|
이렇게 적는다 — 「이 검사기는 techviz 가 만든 SVG 에서만 유효하다. 손으로 고친 SVG …는
|
|
겹침이 있어도 없다고 답한다」.
|
|
|
|
**그러면 출력이 그 사실을 말해야 한다.** 못 본 그림을 따로 세어 요약 줄에 싣는다. 결함으로
|
|
세지는 않는다 — 손그림이 있는 것 자체는 잘못이 아니다. 다만 초록으로 보이면 안 된다.
|
|
숫자를 되찾으려면 그 그림을 techviz 로 다시 만들어야 하고, `verify-project-layout.py` 의
|
|
「techviz 정본이 없는 그림」이 같은 모집단을 센다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
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
|
|
|
|
RECT = re.compile(r"<rect\b([^>]*)>")
|
|
ATTR = re.compile(r'(\w[\w-]*)="([^"]*)"')
|
|
TEXT = re.compile(r'<text\b([^>]*)>([^<]*)</text>')
|
|
|
|
# 겹치면 안 되는 짝. (왼쪽 종류, 오른쪽 종류, 무엇이 잘못됐나)
|
|
# group-label-bg 가 group-box 위에 앉는 것과 node-shape 가 group-box 안에 드는 것은 설계다.
|
|
PAIRS = (
|
|
("group-box", "group-box", "구역 둘이 겹쳐 그려졌다"),
|
|
("group-label-bg", "group-label-bg", "구역 이름이 서로를 덮는다"),
|
|
("node-shape", "node-shape", "상자 둘이 겹쳐 그려졌다"),
|
|
("edge-label-bg", "node-shape", "이음 라벨이 상자에 먹혔다"),
|
|
("edge-label-bg", "edge-label-bg", "이음 라벨끼리 겹친다"),
|
|
)
|
|
|
|
# 겹침으로 세려면 가로·세로가 둘 다 이만큼은 넘어야 한다.
|
|
# 넓이만 보면 상자가 클수록 예민해진다 — 0.5px 이 50px 짜리 변을 만나면 25px² 이다
|
|
EPS = 1.0
|
|
|
|
|
|
def _kind(cls: str) -> str:
|
|
if "group-box" in cls:
|
|
return "group-box"
|
|
if "group-label-bg" in cls:
|
|
return "group-label-bg"
|
|
if "edge-label-bg" in cls:
|
|
return "edge-label-bg"
|
|
if "node-shape" in cls:
|
|
return "node-shape"
|
|
return ""
|
|
|
|
|
|
def boxes(svg: str) -> list[tuple[str, float, float, float, float]]:
|
|
"""(종류, x, y, w, h). 좌표가 없는 것은 뺀다 — canvas 가 그렇다."""
|
|
out = []
|
|
for attrs in RECT.findall(svg):
|
|
a = dict(ATTR.findall(attrs))
|
|
kind = _kind(a.get("class", ""))
|
|
if not kind:
|
|
continue
|
|
try:
|
|
x, y = float(a["x"]), float(a["y"])
|
|
w, h = float(a["width"]), float(a["height"])
|
|
except (KeyError, ValueError):
|
|
continue
|
|
out.append((kind, x, y, w, h))
|
|
return out
|
|
|
|
|
|
def labels(svg: str) -> list[tuple[float, float, str]]:
|
|
"""(x, y, 글자). 겹친 자리에 무엇이 있었는지 말해 주려고 쓴다."""
|
|
out = []
|
|
for attrs, body in TEXT.findall(svg):
|
|
a = dict(ATTR.findall(attrs))
|
|
try:
|
|
out.append((float(a["x"]), float(a["y"]), body.strip()))
|
|
except (KeyError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def _overlap(a, b) -> float:
|
|
"""겹친 넓이. 맞닿기만 한 것과 한쪽으로만 얇게 스친 것은 0 이다."""
|
|
_, ax, ay, aw, ah = a
|
|
_, bx, by, bw, bh = b
|
|
dx = min(ax + aw, bx + bw) - max(ax, bx)
|
|
dy = min(ay + ah, by + bh) - max(ay, by)
|
|
if dx <= EPS or dy <= EPS:
|
|
return 0.0
|
|
return dx * dy
|
|
|
|
|
|
def _contains(a, b) -> bool:
|
|
"""a 가 b 를 통째로 품는가. 구역 안의 구역은 설계이지 겹침이 아니다."""
|
|
_, ax, ay, aw, ah = a
|
|
_, bx, by, bw, bh = b
|
|
return ax <= bx and ay <= by and ax + aw >= bx + bw and ay + ah >= by + bh
|
|
|
|
|
|
def _near(lbls, box, limit=2) -> str:
|
|
"""상자 안에 든 글자. 어느 라벨이 문제인지 이름으로 말해 준다."""
|
|
_, x, y, w, h = box
|
|
hit = [t for lx, ly, t in lbls
|
|
if t and x - 2 <= lx <= x + w + 2 and y - 14 <= ly <= y + h + 2]
|
|
return " · ".join(hit[:limit])
|
|
|
|
|
|
def is_measurable(path: str) -> bool:
|
|
"""이 그림의 좌표를 읽을 수 있나. 읽을 수 없으면 판정 자체가 성립하지 않는다.
|
|
|
|
근거는 techviz 렌더러가 붙인 `class` 하나뿐이다(모듈 독스트링). 그것이 없으면
|
|
`boxes()` 가 비고, 겹칠 짝이 없어 **겹침 0** 이 나온다 — 「안 겹친다」가 아니라
|
|
「못 봤다」다. 부르는 쪽이 그 둘을 갈라 세라고 따로 낸다.
|
|
"""
|
|
try:
|
|
return bool(boxes(open(path, encoding="utf-8").read()))
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def check(path: str) -> list[str]:
|
|
try:
|
|
svg = open(path, encoding="utf-8").read()
|
|
except OSError as exc:
|
|
return [f"읽지 못했다 — {exc}"]
|
|
bs = boxes(svg)
|
|
lbls = labels(svg)
|
|
found: list[str] = []
|
|
for i, a in enumerate(bs):
|
|
for b in bs[i + 1:]:
|
|
for left, right, why in PAIRS:
|
|
if {a[0], b[0]} != {left, right} if left != right else not (
|
|
a[0] == left and b[0] == left):
|
|
continue
|
|
if left == right == "group-box" and (_contains(a, b) or _contains(b, a)):
|
|
continue
|
|
area = _overlap(a, b)
|
|
if area <= 0:
|
|
continue
|
|
names = " / ".join(n for n in (_near(lbls, a), _near(lbls, b)) if n)
|
|
found.append(f"{why} — {int(area)}px²{' ' + names if names else ''}")
|
|
break
|
|
return found
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="그림 안에서 상자와 라벨이 겹치는지 본다.")
|
|
ap.add_argument("projects", nargs="*")
|
|
ap.add_argument("--file", action="append", default=[], help="SVG 를 직접 준다")
|
|
ap.add_argument("--samples", type=int, default=4)
|
|
args = ap.parse_args()
|
|
|
|
files = list(args.file)
|
|
names = []
|
|
if files:
|
|
bad = techlog.check_files(files)
|
|
if bad is not None:
|
|
return bad
|
|
if not files:
|
|
if args.projects:
|
|
bad = techlog.check_targets(args.projects, ROOT, "final")
|
|
if bad is not None:
|
|
return bad
|
|
names = args.projects or sorted(
|
|
os.path.basename(os.path.dirname(os.path.dirname(p)))
|
|
for p in glob.glob(os.path.join(ROOT, "docs/*/final/document.md")))
|
|
for name in names:
|
|
files += sorted(glob.glob(
|
|
os.path.join(ROOT, "docs", name, "final/assets/**/*.svg"), recursive=True))
|
|
if not files:
|
|
# 대상은 성립하는데 아직 그림이 없다 — 결함이 아니지만 「문제 없음」도 아니다
|
|
print(f"그림 0장 — {' · '.join(names) or '지정한 파일'} 에 아직 SVG 가 없다")
|
|
return 0
|
|
|
|
bad = 0
|
|
unseen: list[str] = []
|
|
for path in files:
|
|
if not is_measurable(path):
|
|
# 좌표를 읽을 수 없다. 「안 겹친다」로 세면 안 본 것을 봤다고 하는 것이다
|
|
unseen.append(os.path.relpath(path, ROOT))
|
|
continue
|
|
hits = check(path)
|
|
if not hits:
|
|
continue
|
|
bad += 1
|
|
rel = os.path.relpath(path, ROOT)
|
|
print(f"✗ {rel}")
|
|
for line in hits[:args.samples]:
|
|
print(f" · {line}")
|
|
if len(hits) > args.samples:
|
|
print(f" … 외 {len(hits) - args.samples}건")
|
|
|
|
if unseen:
|
|
print(f"! 못 본 그림 {len(unseen)}장 — techviz 가 만든 것이 아니라 배경 사각형이 없다. "
|
|
"겹침이 있어도 이 검사기는 답하지 못한다")
|
|
for rel in unseen[:args.samples]:
|
|
print(f" · {rel}")
|
|
if len(unseen) > args.samples:
|
|
print(f" … 외 {len(unseen) - args.samples}장")
|
|
|
|
seen = len(files) - len(unseen)
|
|
tail = f" · 못 본 그림 {len(unseen)}" if unseen else ""
|
|
print(f"FIGURE OVERLAP: {'FAIL' if bad else 'PASS'} — 그림 {len(files)} · "
|
|
f"본 그림 {seen} · 겹친 그림 {bad}{tail}")
|
|
return 1 if bad else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|