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
This commit is contained in:
changukyu 2026-09-21 10:51:55 +09:00
parent d45018c4d0
commit 93a4a82bff
8 changed files with 402 additions and 4 deletions

7
.gitattributes vendored
View file

@ -19,3 +19,10 @@
*.webp binary *.webp binary
*.ico binary *.ico binary
*.pdf binary *.pdf binary
# 생성 산출물 — 소스는 같은 폴더의 spec JSON 이다.
#
# 15,000줄짜리 생성 HTML 이라 텍스트 diff 는 리뷰에 쓸모가 없고(최장 라인 41,844자),
# 두 브랜치가 각각 재생성하면 손으로 해소할 수 없는 충돌이 난다. 정답은 항상
# "재생성"이므로 머지를 텍스트로 시도하지 않게 해 도구가 그 사실을 강제하게 한다.
docs/diagrams/*.html linguist-generated=true -diff merge=binary

7
.gitignore vendored
View file

@ -45,3 +45,10 @@ Thumbs.db
# PowerShell module-analysis cache written into cwd by some automation # PowerShell module-analysis cache written into cwd by some automation
Microsoft/ Microsoft/
# 다이어그램 검증 사이드카 (scripts/rebuild-diagrams.ps1 이 재생성한다)
#
# visual-check 산출물은 브라우저 증거이지 납품물이 아니다. 게다가 *.visual-check.json
# 은 개발 머신의 절대 경로와 크롬 설치 경로를 평문으로 담는다 - 폐쇄망 납품 저장소가
# removable media 로 건너가는 구조라 이력에 한 번 들어가면 제거가 어렵다.
docs/diagrams/*.visual-check.*

View file

@ -1,6 +1,8 @@
# Project INDEX # Project INDEX
- 2026-09-19 · 다이어그램 4종으로 확장 — `system-architecture`(폐쇄망 경계·SSRF 가드 앞 호출 게이트), `operator-review-workflow`(4레인, 비상 차단 시 심사 계속 경로), `submission-lifecycle`(분석 실패·보류가 분석으로 복귀하는 두 고리)를 신설하고 기존 `evidence-dataflow` 포함 4종의 범례를 한글화했다. 렌더러 기본 범례가 "User UI / Agent logic / policy·PII" 같은 타 도메인 문구였다. 작성 중 내린 고도 판단: 아키텍처에서는 analyzer/scorer 를 한 컴포넌트로 합쳤고(세부는 dataflow 담당), 라이프사이클에서 "재분석 요청"은 decisionStatus 가 아닌 운영자 액션이라 상태에서 제외해 복귀 엣지를 하나로 줄였다. 4종 모두 archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0) + visual-check 통과(1440×900 · 2048×1320 각 Light/Dark, 오버플로 0). 수신증 — dataflow html a62f0f5c / architecture f5a4b6e1 / workflow 402914a3 / lifecycle 62e934a3 - 2026-09-21 · 다이어그램 감사 후 **드리프트 게이트 신설**. 에이전트 6축 감사가 주간 한도로 절반(2축)만 완주했고 검증 단계도 죽어, 완주분 15건을 저널에서 복구한 뒤 결정적 주장은 직접 재검증했다. 확정된 구멍 — 그림 4종의 재생성 절차가 **문서대로는 실행 불가**(`archify` 실행파일이 PATH 에 없음), 소스↔산출물 동기화를 보는 장치 0개, 사이드카 24개가 개발 머신 절대경로를 담은 채 미추적·비ignore, 임베드 폰트에 **한글 구간 0건**(`U+AC00` 매치 0 · 폴백은 중국어 폰트뿐). 실수정: `scripts/rebuild-diagrams.ps1`(validate→deliver→visual-check→manifest, 실행 확인)과 `scripts/update_diagram_manifest.py`, 수신증 `docs/diagrams/manifest.json`, 게이트 `tests/test_diagram_sync.py` 14건(HTML 직접 수정·spec 만 수정 두 시나리오 모두 실제로 차단됨을 실증), `.gitignore` 에 사이드카 제외(미추적 24→0), `.gitattributes` 에 `linguist-generated -diff merge=binary`. 재생성해도 4종 sha256 불변 — 바이트 결정성 재확인. 한글 폰트·영어 뷰어 UI·architecture 좌표 하드코딩·lifecycle 손튜닝 8개소는 PROJECT_MAP 부채 표에 등재. **ruff clean · pytest 600 passed**(게이트 +14)
- 2026-09-19 · 다이어그램 4종으로 확장 — `system-architecture`(폐쇄망 경계·SSRF 가드 앞 호출 게이트), `operator-review-workflow`(4레인, 비상 차단 시 심사 계속 경로), `submission-lifecycle`(분석 실패·보류가 분석으로 복귀하는 두 고리)를 신설하고 기존 `evidence-dataflow` 포함 4종의 **범례 항목**을 한글화했다(렌더러 기본값이 "User UI / Agent logic / policy·PII" 같은 타 도메인 문구였다). **뷰어 크롬은 영어로 남는다** — `locale` enum 이 en/zh-CN 뿐이고 템플릿이 `<html lang="en">` 을 하드코딩하므로 Light/Dark·Present·Export·범례 제목("Legend")은 저자가 바꿀 수 없다. 9/21 감사에서 확인. 작성 중 내린 고도 판단: 아키텍처에서는 analyzer/scorer 를 한 컴포넌트로 합쳤고(세부는 dataflow 담당), 라이프사이클에서 "재분석 요청"은 decisionStatus 가 아닌 운영자 액션이라 상태에서 제외해 복귀 엣지를 하나로 줄였다. 4종 모두 archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0) + visual-check 통과(1440×900 · 2048×1320 각 Light/Dark, 오버플로 0). 수신증 — dataflow html a62f0f5c / architecture f5a4b6e1 / workflow 402914a3 / lifecycle 62e934a3
- 2026-09-19 · `docs/diagrams/evidence-dataflow.html` 신설 — 증거 데이터 흐름(수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비) 자체 완결 다이어그램. 9/18 리뷰 수정의 불변식을 그림으로 고정한다: **제공자별 풀·버킷**(RiskScorer v3), **라운드트립이 원 점수를 재현**(`_evidence_payload` ↔ `_domain_evidence_from_ui`), **host 는 저장 시 해석·복원 시 우선 반환**, 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로. 프로덕션 LLM 경로가 영속화 payload 를 복원해 쓴다는 사실(`store_operations.py:487`, `EvidenceEnricher` 는 테스트 전용)도 반영. archify showcase 검증 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0), 브라우저 증거 통과(1440×900 · 2048×1320 각 Light/Dark 4컷, 오버플로 0, 최소 투영 텍스트 6.71px ≥ 6px). spec sha256 42c334cd · html sha256 268f3352 (822,655 B). CDN·원격 fetch 없음 — 폐쇄망 규칙 준수 - 2026-09-19 · `docs/diagrams/evidence-dataflow.html` 신설 — 증거 데이터 흐름(수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비) 자체 완결 다이어그램. 9/18 리뷰 수정의 불변식을 그림으로 고정한다: **제공자별 풀·버킷**(RiskScorer v3), **라운드트립이 원 점수를 재현**(`_evidence_payload` ↔ `_domain_evidence_from_ui`), **host 는 저장 시 해석·복원 시 우선 반환**, 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로. 프로덕션 LLM 경로가 영속화 payload 를 복원해 쓴다는 사실(`store_operations.py:487`, `EvidenceEnricher` 는 테스트 전용)도 반영. archify showcase 검증 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0), 브라우저 증거 통과(1440×900 · 2048×1320 각 Light/Dark 4컷, 오버플로 0, 최소 투영 텍스트 6.71px ≥ 6px). spec sha256 42c334cd · html sha256 268f3352 (822,655 B). CDN·원격 fetch 없음 — 폐쇄망 규칙 준수

View file

@ -169,9 +169,28 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
## 다이어그램 ## 다이어그램
소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **그림을 고치려면 JSON 을 고쳐 소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **HTML 직접 수정 금지** —
`archify validate` → `deliver` → `visual-check` 로 재생성한다** (HTML 직접 수정 금지). 고칠 것은 언제나 spec JSON 이고, 재생성은 스크립트 하나다:
모두 자체 완결 HTML 이라 외부 요청이 없다.
```powershell
.\scripts ebuild-diagrams.ps1 # validate -> deliver -> visual-check -> manifest
.\scripts ebuild-diagrams.ps1 -SkipVisualCheck # 브라우저 없는 호스트
```
`tests/test_diagram_sync.py` 가 `manifest.json` 의 sha256 으로 이 계약을 강제한다
(브라우저·Node·archify 없이 도는 순수 파일 비교). HTML 을 직접 고치거나 spec 만
고치고 재생성을 잊으면 `verify.ps1` 에서 막힌다. 두 시나리오 모두 실증 확인됨.
**문서 재생성 전용 요구사항** (런타임 아님, 폐쇄망 타깃에는 불필요):
Node 18+ 와 archify 스킬(`$env:ARCHIFY_HOME`, 기본값 `~/.claude/skills/archify`),
visual-check 단계에만 Chrome/Chromium. 산출 당시 generator 는 `archify 2.17.0-dev.1`
이고 `manifest.json` 에 기록된다 — 스킬 버전이 바뀌면 같은 spec 에서도 바이트가
달라지므로, 해시가 깨졌을 때 코드 변경과 툴 변경을 구분하는 근거가 된다.
모두 자체 완결 HTML 이라 외부 요청이 없다(9/21 감사에서 실측: src/href/@import/fetch
모두 0건). 단 **file:// 로 여는 산출물**이다 — 운영자 콘솔의 static_root 로 서빙하면
app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트 4개를 차단해
상호작용이 죽는다. 서빙이 필요하면 CSP 완화가 아니라 별도 라우트에서 nonce 정책을 쓴다.
| 파일 | 내용 | 타입 | | 파일 | 내용 | 타입 |
|---|---|---| |---|---|---|
@ -189,4 +208,8 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 | | 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 |
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 | | 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
| 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 | | 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 |
| 다이어그램에 한글 폰트 미번들 | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). 고객 인수물로 확정되면 blocker |
| 뷰어 UI 가 영어 고정 | `docs/diagrams/*.html` | `locale` enum 이 `en`/`zh-CN` 뿐이고 템플릿이 `<html lang="en">` 하드코딩. 저자 내용은 한국어인데 조작 UI·aria 라벨·범례 제목은 영어. 스크린리더가 한글 본문을 영어로 읽는다 |
| architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 21자에서 validate 하드 실패(현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 |
| lifecycle 기하 손튜닝 8개소 | `docs/diagrams/submission-lifecycle.lifecycle.json` | `labelDy`×5·`channelX`×2·`yOffset`×1 이 현재 배치 전제의 절대 픽셀이다. 상태를 하나 추가하면 **손대지 않은 기존 전이까지** 검증 실패(실증: 오류 5건 중 4건이 기존 `t-reject`). 상태 추가 시 전량 재검증 필요 |
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 | | 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |

View file

@ -0,0 +1,38 @@
{
"note": "archify deliver 는 바이트 결정적이다. 이 수신증은 tests/test_diagram_sync.py 가 강제한다 - HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 커밋 게이트가 막는다. 갱신은 손으로 하지 말고 scripts/rebuild-diagrams.ps1 로 한다.",
"generator": "archify 2.17.0-dev.1",
"diagrams": [
{
"type": "architecture",
"spec": "system-architecture.architecture.json",
"html": "system-architecture.html",
"specSha256": "8359880ec5a7e64012cf7533169d279fff364b36c84855a1678ea8be2414d699",
"htmlSha256": "f5a4b6e1c6f94ab65bd55d6b348c9d1257db194508285541e3fb94acdfabd44c",
"htmlBytes": 816201
},
{
"type": "dataflow",
"spec": "evidence-dataflow.dataflow.json",
"html": "evidence-dataflow.html",
"specSha256": "7503e9be9f100440d965877f6bb7e9ce8863f85015a5144b7a30404a524ba65a",
"htmlSha256": "a62f0f5cab31f880a98877f0d1b4353d09c86fb1152157fbdebe62bcd491114e",
"htmlBytes": 822657
},
{
"type": "workflow",
"spec": "operator-review-workflow.workflow.json",
"html": "operator-review-workflow.html",
"specSha256": "222d2d7565ee06c783a93868775732d96a9300e3baa4e8ddff1f8a57a421e79c",
"htmlSha256": "402914a3d29ad899d136b7e011bbe2aaf7d36a2a2880ee59fa33929bb614974d",
"htmlBytes": 818137
},
{
"type": "lifecycle",
"spec": "submission-lifecycle.lifecycle.json",
"html": "submission-lifecycle.html",
"specSha256": "b53d44dc70393c7a0a7746d774563c61e412eceafed5d10fd1a5e0e509aa3ac9",
"htmlSha256": "62e934a32e3b3c5b1660794d8919e2fc1910f3937e82e882f464abebbfbb2634",
"htmlBytes": 812325
}
]
}

View file

@ -0,0 +1,98 @@
<#
.SYNOPSIS
docs/diagrams 의 다이어그램 4종을 spec JSON 에서 다시 생성한다.
.DESCRIPTION
PROJECT_MAP 에 적혀 있던 "archify validate -> deliver -> visual-check" 절차는
그대로는 실행되지 않았다 - archify 라는 실행 파일이 PATH 에 없고, 실제로는
저장소 밖 사용자 전역 스킬을 node 로 불러야 한다. 이 스크립트가 그 절차다.
순서:
1. validate (--quality showcase) 합성/기하 검증
2. deliver 산출물 HTML 을 원자적으로 교체
3. visual-check 실제 브라우저 증거 (Chrome 필요)
4. manifest 갱신 tests/test_diagram_sync.py 의 수신증
산출물은 바이트 결정적이다. 그래서 4번의 수신증만으로 드리프트를 잡을 수 있고,
테스트는 브라우저도 Node 도 없이 돈다.
.PARAMETER SkipVisualCheck
3단계를 건너뛴다. Chrome 이 없는 빌드 호스트용. 건너뛰면 그 사실을 출력한다
(조용한 통과 금지).
.PARAMETER ArchifyHome
archify 스킬 경로. 기본값은 $env:ARCHIFY_HOME, 없으면 ~/.claude/skills/archify.
.EXAMPLE
.\scripts
ebuild-diagrams.ps1
.EXAMPLE
.\scripts
ebuild-diagrams.ps1 -SkipVisualCheck
#>
[CmdletBinding()]
param(
[switch]$SkipVisualCheck,
[string]$ArchifyHome
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $repoRoot
if (-not $ArchifyHome) {
if ($env:ARCHIFY_HOME) { $ArchifyHome = $env:ARCHIFY_HOME }
else { $ArchifyHome = Join-Path $HOME '.claude/skills/archify' }
}
$cli = Join-Path $ArchifyHome 'bin/archify.mjs'
if (-not (Test-Path -LiteralPath $cli)) {
Write-Host "archify CLI not found: $cli" -ForegroundColor Red
Write-Host 'Set -ArchifyHome or $env:ARCHIFY_HOME to the archify skill directory.' -ForegroundColor Red
exit 1
}
# (type, basename) - tests/test_diagram_sync.py 및 update_diagram_manifest.py 와 같은 목록
$diagrams = @(
@{ Type = 'architecture'; Base = 'system-architecture' },
@{ Type = 'dataflow'; Base = 'evidence-dataflow' },
@{ Type = 'workflow'; Base = 'operator-review-workflow' },
@{ Type = 'lifecycle'; Base = 'submission-lifecycle' }
)
$failures = @()
$skipped = @()
foreach ($d in $diagrams) {
$spec = Join-Path $repoRoot ("docs/diagrams/{0}.{1}.json" -f $d.Base, $d.Type)
$html = Join-Path $repoRoot ("docs/diagrams/{0}.html" -f $d.Base)
Write-Host "`n=== $($d.Base) ($($d.Type)) ===" -ForegroundColor Cyan
& node $cli validate $d.Type $spec --quality showcase
if ($LASTEXITCODE -ne 0) { $failures += "validate:$($d.Base)"; continue }
& node $cli deliver $d.Type $spec $html --quality showcase
if ($LASTEXITCODE -ne 0) { $failures += "deliver:$($d.Base)"; continue }
if ($SkipVisualCheck) { $skipped += $d.Base; continue }
& node $cli visual-check $html
# exit 2 = skipped (Chrome/Chromium 부재). 실패와 구분해 기록한다.
if ($LASTEXITCODE -eq 2) { $skipped += $d.Base }
elseif ($LASTEXITCODE -ne 0) { $failures += "visual-check:$($d.Base)" }
}
Write-Host "`n=== manifest ===" -ForegroundColor Cyan
& python (Join-Path $PSScriptRoot 'update_diagram_manifest.py')
if ($LASTEXITCODE -ne 0) { $failures += 'manifest' }
Write-Host ''
if ($skipped.Count -gt 0) {
Write-Host "VISUAL-CHECK SKIPPED (no browser evidence): $($skipped -join ', ')" -ForegroundColor Yellow
}
if ($failures.Count -gt 0) {
Write-Host "REBUILD FAILED: $($failures -join ', ')" -ForegroundColor Red
exit 1
}
Write-Host 'REBUILD OK - run verify.ps1 to confirm the diagram gate' -ForegroundColor Green
exit 0

View file

@ -0,0 +1,93 @@
"""docs/diagrams/manifest.json 을 산출물 실제 바이트에서 다시 쓴다.
수신증의 단일 작성자다. PowerShell 과 Python 이 각자 JSON 을 쓰면 들여쓰기·키 순서가
갈려 매니페스트가 왔다 갔다 하므로, rebuild-diagrams.ps1 도 이 스크립트를 부른다.
단독 실행도 된다: python scripts/update_diagram_manifest.py
"""
from __future__ import annotations
import hashlib
import io
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIAGRAM_DIR = ROOT / "docs" / "diagrams"
# (타입, 파일 basename). 다이어그램을 추가하면 여기와 tests/test_diagram_sync.py 의
# NODE_COLLECTION 을 함께 고친다.
DIAGRAMS = [
("architecture", "system-architecture"),
("dataflow", "evidence-dataflow"),
("workflow", "operator-review-workflow"),
("lifecycle", "submission-lifecycle"),
]
NOTE = (
"archify deliver 는 바이트 결정적이다. 이 수신증은 tests/test_diagram_sync.py 가 "
"강제한다 - HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 커밋 게이트가 막는다. "
"갱신은 손으로 하지 말고 scripts/rebuild-diagrams.ps1 로 한다."
)
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _generator_stamp(html: Path) -> str:
match = re.search(
r'<meta name="generator" content="([^"]+)"', html.read_text(encoding="utf-8")
)
if not match:
raise SystemExit(f"{html.name} 에 generator meta 가 없다 - archify 산출물이 맞는가?")
return match.group(1)
def main() -> int:
entries = []
for diagram_type, base in DIAGRAMS:
spec = DIAGRAM_DIR / f"{base}.{diagram_type}.json"
html = DIAGRAM_DIR / f"{base}.html"
for path in (spec, html):
if not path.exists():
raise SystemExit(f"없는 파일: {path}")
entries.append(
{
"type": diagram_type,
"spec": spec.name,
"html": html.name,
"specSha256": _sha256(spec),
"htmlSha256": _sha256(html),
"htmlBytes": html.stat().st_size,
}
)
generators = {_generator_stamp(DIAGRAM_DIR / e["html"]) for e in entries}
if len(generators) != 1:
raise SystemExit(
f"산출물마다 generator 가 다르다: {sorted(generators)} - 전부 재생성해야 한다."
)
manifest = {
"note": NOTE,
"generator": generators.pop(),
"diagrams": entries,
}
target = DIAGRAM_DIR / "manifest.json"
with io.open(target, "w", encoding="utf-8", newline="\n") as handle:
json.dump(manifest, handle, ensure_ascii=False, indent=2)
handle.write("\n")
# 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 UnicodeEncodeError 로 죽은 이력).
print("manifest updated: %d diagrams, generator %s" % (len(entries), manifest["generator"]))
for entry in entries:
print(" %-30s %s.. %d B" % (entry["html"], entry["htmlSha256"][:8], entry["htmlBytes"]))
return 0
if __name__ == "__main__":
sys.exit(main())

130
tests/test_diagram_sync.py Normal file
View file

@ -0,0 +1,130 @@
"""다이어그램 소스(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)}) 이 다르다"
)