POSA_Copyrighter/tests/test_diagram_sync.py
changukyu 93a4a82bff test: 다이어그램 드리프트 게이트 신설, 재생성 절차를 실행 가능하게
감사에서 나온 결론은 "그림은 지금 정확하지만 정확함을 지킬 장치가 없다" 였다.
코드 유래 리터럴 18개가 전부 코드와 일치하고 deliver 는 바이트 결정적인데,
드리프트를 막는 게이트·테스트·훅이 0개였고 문서에 적힌 재생성 절차는 그대로는
실행되지 않았다 — `archify` 실행 파일은 PATH 에 없고 실제로는 저장소 밖 전역
스킬을 node 로 불러야 한다. 다음 사람은 첫 명령에서 막혔을 것이다.

  scripts/rebuild-diagrams.ps1       validate -> deliver -> visual-check -> manifest
  scripts/update_diagram_manifest.py 수신증의 단일 작성자
  docs/diagrams/manifest.json        spec/html sha256 + generator 버전
  tests/test_diagram_sync.py         게이트 14건

게이트는 브라우저도 Node 도 archify 도 없이 돈다. 산출물이 바이트 결정적이라
수신증 대조만으로 충분하고, 그래서 폐쇄망 CI 에서도 돈다. 두 사고 시나리오를
실제로 만들어 차단을 확인했다 — HTML 의 단어 하나를 고치면 html sha 가,
spec 의 태그 하나를 고치면 spec sha 가 어긋나 verify 가 실패한다. 노드 id 가
HTML 에 data-node-id 로 박히므로 의미적 연결도 함께 본다.

git 위생 두 건도 같이 정리했다. *.visual-check.json 이 개발 머신 절대 경로와
크롬 설치 경로를 평문으로 담은 채 미추적·비ignore 상태였다(24개) — 납품
저장소가 removable media 로 건너가는 구조라 한 번 커밋되면 제거가 어렵다.
그리고 15,000줄 생성 HTML 을 텍스트로 머지하려 드는 것을 막았다.

재생성해도 4종 sha256 이 전부 불변이라 바이트 결정성을 다시 확인했다.

감사에서 나온 나머지 부채(한글 폰트 미번들, 영어 뷰어 UI, architecture 좌표
하드코딩, lifecycle 손튜닝 8개소, 콘솔 CSP 비호환)는 PROJECT_MAP 부채 표와
다이어그램 절에 등재했다. 폰트와 UI 는 archify 쪽 한계라 spec 으로는 못 고친다.

검증: ruff clean, pytest 600 passed (게이트 +14).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 10:51:55 +09:00

130 lines
4.9 KiB
Python

"""다이어그램 소스(JSON)와 산출물(HTML)이 갈라지지 않았는지 검사하는 커밋 게이트.
`docs/diagrams/*.json` 이 소스이고 `*.html` 이 archify deliver 산출물이다. 둘의
동기화를 확인하는 자동 장치가 없어서, spec 만 고치고 재생성을 잊거나 HTML 을
직접 고쳐도 커밋이 통과했다. 폐쇄망 납품물에 동봉되는 문서라 고객이 보는 그림과
실제 코드가 갈라져도 검출 경로가 없었다.
이 테스트는 **브라우저도 Node 도 archify 도 필요 없다** — 순수 파일 비교다.
archify deliver 가 바이트 결정적이라는 사실(같은 spec 에서 항상 같은 HTML)이
그 근거이고, 그래서 수신증 대조만으로 충분하다.
실패했을 때 고치는 법은 manifest 를 손으로 맞추는 게 아니라
`scripts/rebuild-diagrams.ps1` 를 돌리는 것이다.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DIAGRAM_DIR = ROOT / "docs" / "diagrams"
MANIFEST = DIAGRAM_DIR / "manifest.json"
# spec 의 어느 배열이 노드를 담는지는 다이어그램 타입마다 다르다.
NODE_COLLECTION = {
"architecture": "components",
"dataflow": "nodes",
"workflow": "nodes",
"lifecycle": "states",
}
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _manifest() -> dict:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
def _entries() -> list[dict]:
return _manifest()["diagrams"]
def _ids() -> list[str]:
return [entry["html"] for entry in _entries()]
def test_manifest_covers_every_delivered_diagram():
"""새 다이어그램을 추가하면 수신증에도 등재해야 한다."""
# 사이드카(*.visual-check.html)는 검증 증거이지 납품물이 아니다.
delivered = {
path.name
for path in DIAGRAM_DIR.glob("*.html")
if ".visual-check." not in path.name
}
assert delivered == set(_ids()), (
"manifest.json 과 실제 산출물 목록이 다르다. "
"다이어그램을 추가/제거했으면 scripts/rebuild-diagrams.ps1 로 갱신한다."
)
@pytest.mark.parametrize("entry", _entries(), ids=_ids())
def test_html_matches_its_receipt(entry: dict):
"""HTML 을 직접 고치면 여기서 막힌다."""
html = DIAGRAM_DIR / entry["html"]
assert html.exists(), f"{entry['html']} 이 없다"
assert html.stat().st_size == entry["htmlBytes"]
assert _sha256(html) == entry["htmlSha256"], (
f"{entry['html']} 이 수신증과 다르다. HTML 을 직접 고쳤거나 재생성 후 "
"manifest 를 갱신하지 않았다 — scripts/rebuild-diagrams.ps1 로 재생성한다."
)
@pytest.mark.parametrize("entry", _entries(), ids=_ids())
def test_spec_matches_its_receipt(entry: dict):
"""spec 만 고치고 재생성을 잊으면 여기서 막힌다."""
spec = DIAGRAM_DIR / entry["spec"]
assert spec.exists(), f"{entry['spec']} 이 없다"
assert _sha256(spec) == entry["specSha256"], (
f"{entry['spec']} 이 수신증과 다르다. spec 을 고쳤으면 "
"scripts/rebuild-diagrams.ps1 로 HTML 을 재생성해야 한다."
)
@pytest.mark.parametrize("entry", _entries(), ids=_ids())
def test_every_spec_node_is_present_in_the_html(entry: dict):
"""sha 대조가 통과해도 이 검사가 소스↔산출물의 의미적 연결을 잡는다.
해시는 '둘 다 최신인가'만 말해준다. 이 검사는 산출물이 정말 그 spec 에서
나왔는지를 본다 — 노드 id 는 HTML 에 data-node-id 로 그대로 박힌다.
"""
spec = json.loads((DIAGRAM_DIR / entry["spec"]).read_text(encoding="utf-8"))
html = (DIAGRAM_DIR / entry["html"]).read_text(encoding="utf-8")
collection = NODE_COLLECTION[entry["type"]]
node_ids = [node["id"] for node in spec[collection]]
assert node_ids, f"{entry['spec']} 에 노드가 없다"
missing = [
node_id
for node_id in node_ids
if f'data-node-id="{node_id}"' not in html
]
assert not missing, (
f"{entry['html']} 에 spec 노드 {missing} 가 없다 — 재생성되지 않은 산출물이다."
)
def test_generator_version_is_recorded():
"""산출물은 archify 버전에 따라 바이트가 달라진다. 그 버전이 기록돼 있어야
나중에 '코드는 그대로인데 해시가 바뀐' 원인을 구분할 수 있다."""
recorded = _manifest()["generator"]
stamped = re.search(
r'<meta name="generator" content="([^"]+)"',
(DIAGRAM_DIR / _entries()[0]["html"]).read_text(encoding="utf-8"),
)
assert stamped, "산출물에 generator meta 가 없다"
assert stamped.group(1) == recorded, (
f"manifest 의 generator({recorded}) 와 산출물 각인({stamped.group(1)}) 이 다르다"
)