4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다. 이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는** 결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나, 값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다. CRITICAL 4건: R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now 기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는 항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서 초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는 회귀 테스트를 넣었다. R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50] 이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은 2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달 불가였다. R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어 400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이 하지 않는 일을 약속하고 있었다. R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에 트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시 prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다. ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다. HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두 희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한 사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던 문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개 누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프. BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번 실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션 목록도 같은 이유로 테스트를 붙였다. MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된 그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다. 데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인 넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져 눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다. 문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을 소개서 감지 표에 넣었다. 검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건. dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
123 lines
6 KiB
Python
123 lines
6 KiB
Python
"""납품 산출물(dist/)이 소스와 어긋났는지 검사.
|
|
|
|
dist/ 는 gitignore 대상 빌드 출력이라 git 이 드리프트를 알려주지 않는다. 실제로 기능 커밋 4개가
|
|
들어간 뒤 번들이 재생성되지 않아, Report/Export/Actions 컨트롤러와 마이그레이션 1건이 빠진
|
|
패키지가 납품 직전까지 남아 있었다. 스테일 번들은 '깨진' 상태가 아니라 '내부적으로 정합적인
|
|
구버전'이라 SHA256SUMS 도 통과한다 — 그래서 소스와 대조하는 검사가 따로 필요하다.
|
|
|
|
dist/ 가 없으면(클린 체크아웃) 스킵한다. 있으면 소스와 같아야 한다.
|
|
"""
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DIST = ROOT / "dist"
|
|
CAFE24_PRIV = DIST / "1_cafe24_upload" / "move_detect_app"
|
|
PI_SRC = DIST / "2_pi_install" / "src" / "edge"
|
|
|
|
needs_dist = pytest.mark.skipif(not DIST.is_dir(), reason="dist/ not built in this checkout")
|
|
|
|
|
|
def _digest(p: Path) -> str:
|
|
return hashlib.sha256(p.read_bytes()).hexdigest()
|
|
|
|
|
|
def _compare_tree(src: Path, packaged: Path, suffix: str) -> tuple[list[str], list[str]]:
|
|
"""(빠진 파일, 내용이 다른 파일) — 소스 기준."""
|
|
missing, differing = [], []
|
|
for f in sorted(src.rglob(f"*{suffix}")):
|
|
if "__pycache__" in f.parts:
|
|
continue
|
|
rel = f.relative_to(src)
|
|
target = packaged / rel
|
|
if not target.is_file():
|
|
missing.append(str(rel))
|
|
elif _digest(f) != _digest(target):
|
|
differing.append(str(rel))
|
|
return missing, differing
|
|
|
|
|
|
@needs_dist
|
|
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
|
|
def test_cafe24_package_matches_server_source():
|
|
missing, differing = _compare_tree(ROOT / "server" / "src", CAFE24_PRIV / "src", ".php")
|
|
assert not missing, f"번들에 없는 서버 소스(생성기 재실행 필요): {missing}"
|
|
assert not differing, f"번들과 소스 내용 불일치: {differing}"
|
|
|
|
|
|
@needs_dist
|
|
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
|
|
def test_cafe24_package_ships_every_migration_and_current_schema():
|
|
# 마이그레이션이 하나라도 빠지면 기존 DB 업그레이드가 전 요청 500 이 된다.
|
|
src = sorted(p.name for p in (ROOT / "server" / "migrations").glob("*.sql"))
|
|
packaged = sorted(p.name for p in (CAFE24_PRIV / "migrations").glob("*.sql"))
|
|
assert src == packaged, "동봉 마이그레이션이 소스와 다름 — 생성기 재실행 필요"
|
|
assert _digest(ROOT / "server" / "schema.sql") == _digest(CAFE24_PRIV / "schema.sql")
|
|
|
|
|
|
@needs_dist
|
|
@pytest.mark.skipif(not PI_SRC.is_dir(), reason="pi bundle not built")
|
|
def test_pi_bundle_matches_edge_source():
|
|
missing, differing = _compare_tree(ROOT / "edge", PI_SRC, ".py")
|
|
assert not missing, f"번들에 없는 엣지 소스(생성기 재실행 필요): {missing}"
|
|
assert not differing, f"번들과 소스 내용 불일치: {differing}"
|
|
|
|
|
|
@needs_dist
|
|
@pytest.mark.skipif(not (DIST / "2_pi_install" / "MANIFEST.txt").is_file(),
|
|
reason="pi bundle not built")
|
|
def test_pi_bundle_records_its_provenance():
|
|
# 버전·커밋이 없으면 현장에 올라간 빌드를 나중에 식별할 수 없다 — 스테일 번들이 조용히
|
|
# 통과한 구조적 원인.
|
|
text = (DIST / "2_pi_install" / "MANIFEST.txt").read_text(encoding="utf-8")
|
|
for field in ("version:", "git_commit:", "built_at:"):
|
|
assert field in text, f"MANIFEST.txt에 {field} 없음"
|
|
assert "version: unknown" not in text
|
|
|
|
|
|
@needs_dist
|
|
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
|
|
def test_build_info_stamps_the_commit_it_actually_ships():
|
|
"""BUILD-INFO 의 git_commit 이 현재 HEAD 와 같은가.
|
|
|
|
1.1.0 에서 이 결함(R3-6)을 고쳤는데 1.2.0 에서 그대로 재발했다 — 릴리스 커밋 **전에**
|
|
패키지를 만들면 스탬프가 코드를 담지 않은 커밋을 가리킨다. 기존 테스트는 필드의 존재만
|
|
확인했으므로 두 번 다 통과했다.
|
|
|
|
스탬프가 틀리면 나중에 "고객이 받은 것이 어느 코드인지"를 되짚을 방법이 사라진다.
|
|
절차로 막으려다 두 번 실패했으므로 테스트로 막는다.
|
|
"""
|
|
import subprocess
|
|
|
|
info = (CAFE24_PRIV / "BUILD-INFO.txt").read_text(encoding="utf-8")
|
|
stamped = next((l.split(":", 1)[1].strip() for l in info.splitlines()
|
|
if l.startswith("git_commit:")), None)
|
|
assert stamped, f"BUILD-INFO 에 git_commit 이 없습니다:\n{info}"
|
|
if stamped in ("unknown", ""):
|
|
pytest.skip("git 정보 없이 빌드된 패키지")
|
|
|
|
head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT,
|
|
capture_output=True, text=True).stdout.strip()
|
|
assert stamped == head, (
|
|
f"패키지가 담은 코드와 스탬프가 다릅니다: BUILD-INFO={stamped}, HEAD={head}. "
|
|
"릴리스 커밋 이후에 생성기를 다시 돌리십시오.")
|
|
|
|
|
|
@needs_dist
|
|
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
|
|
def test_upload_readme_lists_every_migration():
|
|
"""업그레이드 안내의 마이그레이션 표가 실제 파일을 전부 담는가.
|
|
|
|
빠지면 업그레이드한 고객이 그 마이그레이션을 영영 실행하지 않는다. 1.2.0 직전 상태에서
|
|
두 개가 빠져 있었고, 그중 하나가 없으면 신규 이벤트 유형의 INSERT 가 DB CHECK 에 걸려
|
|
전부 거부된다 — 감지기는 도는데 대시보드에 아무것도 안 뜨는, 이 프로젝트가 반복해서
|
|
잡아온 조용한 실패다.
|
|
"""
|
|
readme = (ROOT / "server" / "deploy" / "README-UPLOAD.md").read_text(encoding="utf-8")
|
|
for sql in sorted((ROOT / "server" / "migrations").glob("*.sql")):
|
|
assert sql.name in readme, (
|
|
f"업그레이드 안내에 {sql.name} 이 없습니다 — 업그레이드한 현장은 이 마이그레이션을 "
|
|
"영영 실행하지 않습니다")
|