Files
document-haness/scripts/check-figure-overlap.py
T
DongHyeonkaandClaude Opus 5 e00c1a2b76 검사: 볼 것이 없어서 통과한 것을 「문제 없음」이라고 쓰지 않는다
R4 · R13 · R6 · R3. 네 가지가 같은 자리를 본다 — 검사기가 대상을 못 찾았을 때 무엇을
내는가.

세 상태를 가른다 (CLAUDE.md 「검사」 절의 표).

  봤고 괜찮다              exit 0   문제 없음 · error 0
  대상이 성립하지 않는다   exit 2   대상이 성립하지 않는다 — <이유>
  볼 것이 아직 없다        exit 0   기록 0건 — 아직 쓴 기록이 없다

R4 — 없는 프로젝트를 주면 여섯 중 넷이 초록을 냈다. `verify-tech-log-tree.py` 는 그것을
「프로젝트 1 · error 0 · PASS」로 셌다 — 오타 한 번이면 검사를 다 돈 것처럼 보인다.
판정은 `techlog.check_targets()` 하나로 모은다. 여섯 곳에 같은 규칙을 따로 쓰면 다음에
하나만 어긋난다. `check_evidence.mjs` 는 이미 exit 2 라 문구만 맞춘다.

R13 — 프로젝트 이름 쪽만 고치면 `--file` 로 오타를 내는 순간 다시 조용히 0건이 된다.
`techlog.check_files()` 로 같은 자리에 둔다. `preview-figure.py` 는 인자가 아예 없을 때
exit 1 을 냈는데 그것도 「대상이 성립하지 않는다」다.

R6 — 두 자리를 함께 고쳐야 했다.
  (a) `verify_projects()` 가 `docs/*/tech-log-studio` 만 훑어 계약 없는 프로젝트가
      목록에서 사라졌다. 기준을 `final/document.md` 로 바꾼다 — SSOT 가 있으면 대상이다.
  (b) `verify-tech-log-tree.py:194` 가 「분해 계약 없음」을 warn 으로 냈다. CLAUDE.md 는
      「계약 미채택도 error 다 — 경고로 두면 옛 스키마로 남아 있는 한 검사를 피한다」고
      적어 두었는데, 경고로 두었더니 실제로 그렇게 됐다.

R3 — `verify-pipeline.py` 가 `check-figure-text.py` 와 `check_evidence.mjs --repo` 를
프로젝트마다 돌린다(`OUTPUT CHECKS`). 게시 전에 돌리라고 적어 둔 검사인데 전체 훑기가
부르지 않아 결함이 있는 채로 PASS 로 보고됐다. `check-required-content.py` 자리는 주석으로
남겨 둔다 — 그 파일이 들어온 뒤에 더한다.

회귀 `scripts/tests/test_no_target.py` 7건 (92 → 99). 「실재하는 경로는 통과한다」 대조를
함께 넣는다 — 무조건 거절로 성공률을 올리는 것이 R-003 이 든 실패다. 판정을 일부러
되돌려 FAILED 가 나는 것을 확인한 뒤 복구했다.

알려진 부채는 그대로 둔다. `verify-pipeline.py` 는 exit 1 이다 — 미준수 런 1건과
`OUTPUT CHECKS` 5건, 그리고 `ca-tmpl` 의 계약 없음이 이제 `TECH LOG TREES` 에서도 보인다.
같은 사실이고 두 번 세지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp9jNbePAmWc5jQwCYhK9v
2026-09-10 11:07:24 +09:00

189 lines
6.9 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` 가 그 라벨이 실제로 차지하는 자리다.
"""
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 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
for path in files:
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}건")
print(f"FIGURE OVERLAP: {'FAIL' if bad else 'PASS'} — 그림 {len(files)} · 겹친 그림 {bad}")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())