감사에서 나온 결론은 "그림은 지금 정확하지만 정확함을 지킬 장치가 없다" 였다. 코드 유래 리터럴 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
130 lines
4.9 KiB
Python
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)}) 이 다르다"
|
|
)
|