diff --git a/.gitattributes b/.gitattributes index d6b33b5..eca344a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,3 +19,10 @@ *.webp binary *.ico binary *.pdf binary + +# 생성 산출물 — 소스는 같은 폴더의 spec JSON 이다. +# +# 15,000줄짜리 생성 HTML 이라 텍스트 diff 는 리뷰에 쓸모가 없고(최장 라인 41,844자), +# 두 브랜치가 각각 재생성하면 손으로 해소할 수 없는 충돌이 난다. 정답은 항상 +# "재생성"이므로 머지를 텍스트로 시도하지 않게 해 도구가 그 사실을 강제하게 한다. +docs/diagrams/*.html linguist-generated=true -diff merge=binary diff --git a/.gitignore b/.gitignore index 4b20c85..ccdf5ef 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,10 @@ Thumbs.db # PowerShell module-analysis cache written into cwd by some automation Microsoft/ + +# 다이어그램 검증 사이드카 (scripts/rebuild-diagrams.ps1 이 재생성한다) +# +# visual-check 산출물은 브라우저 증거이지 납품물이 아니다. 게다가 *.visual-check.json +# 은 개발 머신의 절대 경로와 크롬 설치 경로를 평문으로 담는다 - 폐쇄망 납품 저장소가 +# removable media 로 건너가는 구조라 이력에 한 번 들어가면 제거가 어렵다. +docs/diagrams/*.visual-check.* diff --git a/docs/INDEX.md b/docs/INDEX.md index 0193cf2..a61c121 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,6 +1,8 @@ # 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 뿐이고 템플릿이 `` 을 하드코딩하므로 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 없음 — 폐쇄망 규칙 준수 diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 05bbd78..7b13a21 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -169,9 +169,28 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore). ## 다이어그램 -소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **그림을 고치려면 JSON 을 고쳐 -`archify validate` → `deliver` → `visual-check` 로 재생성한다** (HTML 직접 수정 금지). -모두 자체 완결 HTML 이라 외부 요청이 없다. +소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **HTML 직접 수정 금지** — +고칠 것은 언제나 spec JSON 이고, 재생성은 스크립트 하나다: + +```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` 교차표가 첫 근거가 된다 | | 출처 신호 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` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 | +| 다이어그램에 한글 폰트 미번들 | `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` 뿐이고 템플릿이 `` 하드코딩. 저자 내용은 한국어인데 조작 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) | 각 항목에 연기 근거 기재 | diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json new file mode 100644 index 0000000..9cbcce0 --- /dev/null +++ b/docs/diagrams/manifest.json @@ -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 + } + ] +} diff --git a/scripts/rebuild-diagrams.ps1 b/scripts/rebuild-diagrams.ps1 new file mode 100644 index 0000000..a15f8de --- /dev/null +++ b/scripts/rebuild-diagrams.ps1 @@ -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 diff --git a/scripts/update_diagram_manifest.py b/scripts/update_diagram_manifest.py new file mode 100644 index 0000000..ab8882f --- /dev/null +++ b/scripts/update_diagram_manifest.py @@ -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' 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()) diff --git a/tests/test_diagram_sync.py b/tests/test_diagram_sync.py new file mode 100644 index 0000000..03cbe42 --- /dev/null +++ b/tests/test_diagram_sync.py @@ -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'