3ba4e7e777
contract/contract-check.sh + contract/shape_diff.py — 라이브 엔드포인트 재호출 → 동결 fixture 와 키/타입 *모양* diff(LLM 스칼라 값 무시). 드리프트 = 비0 exit + 재캡처 안내. PR 게이트 아님(수동/Tailscale-CI 트리거). 가시적 스킵(silent green 금지). - llm-router /v1/chat/completions ↔ llm-router-chat.response.json (라이브 실행 PASS) - DS /search/ask ↔ ask.json (best-effort, 인증 필요시 가시 SKIP) - exit 0=드리프트없음 · 1=breaking 드리프트 · 2=전부 도달불가(green 아님) - 음성 테스트 검증: 타입변경/키삭제 드리프트 감지 + exit 1 확인(no-op 아님) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
2.6 KiB
Python
Executable File
84 lines
2.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""S2-Ff shape diff — frozen fixture ↔ live response 의 *모양*(키/타입)만 비교.
|
|
|
|
스칼라 *값*은 무시한다(LLM 응답 본문·id·토큰수는 호출마다 다름). 비교 대상 = 키 존재 + 타입.
|
|
argv[1] = frozen fixture 경로, argv[2] = live 응답 JSON 경로.
|
|
exit 1 = breaking 드리프트(frozen 키 삭제 / 타입 변경), exit 0 = clean(동일 or live 신규키만).
|
|
양쪽 top-level `_meta`(우리 주석)는 비교에서 제외.
|
|
"""
|
|
import json
|
|
import sys
|
|
|
|
|
|
def strip_meta(o):
|
|
return {k: v for k, v in o.items() if k != "_meta"} if isinstance(o, dict) else o
|
|
|
|
|
|
def tag(v):
|
|
if v is None:
|
|
return "null"
|
|
if isinstance(v, bool):
|
|
return "bool"
|
|
if isinstance(v, (int, float)):
|
|
return "number"
|
|
if isinstance(v, str):
|
|
return "string"
|
|
if isinstance(v, list):
|
|
return "array"
|
|
if isinstance(v, dict):
|
|
return "object"
|
|
return "unknown"
|
|
|
|
|
|
breaking = []
|
|
info = []
|
|
|
|
|
|
def diff(live, frozen, path):
|
|
lt, ft = tag(live), tag(frozen)
|
|
# nullability 는 드리프트로 보지 않음(fixture 의 null = nullable 표식).
|
|
if lt == "null" or ft == "null":
|
|
if lt != ft:
|
|
info.append(f"{path or '<root>'}: null↔{ft if lt == 'null' else lt} (nullable, 무시 가능)")
|
|
return
|
|
if lt != ft:
|
|
breaking.append(f"{path or '<root>'}: frozen={ft} live={lt} (타입 변경)")
|
|
return
|
|
if ft == "object":
|
|
for k, fv in frozen.items():
|
|
p = f"{path}.{k}" if path else k
|
|
if k in live:
|
|
diff(live[k], fv, p)
|
|
else:
|
|
breaking.append(f"{p}: frozen 에 있으나 live 에 없음 (키 삭제)")
|
|
for k in live:
|
|
if k not in frozen:
|
|
p = f"{path}.{k}" if path else k
|
|
info.append(f"{p}: live 신규 키 (비파괴)")
|
|
elif ft == "array":
|
|
if frozen:
|
|
if live:
|
|
diff(live[0], frozen[0], f"{path}[0]")
|
|
else:
|
|
info.append(f"{path}[]: live 빈 배열 — element shape 검증 불가")
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 3:
|
|
print("usage: shape_diff.py <frozen_fixture> <live_json>", file=sys.stderr)
|
|
sys.exit(2)
|
|
frozen = strip_meta(json.load(open(sys.argv[1], encoding="utf-8")))
|
|
live = strip_meta(json.load(open(sys.argv[2], encoding="utf-8")))
|
|
diff(live, frozen, "")
|
|
for m in breaking:
|
|
print(f" DRIFT {m}")
|
|
for m in info:
|
|
print(f" info {m}")
|
|
if not breaking and not info:
|
|
print(" (shape identical)")
|
|
sys.exit(1 if breaking else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|