#!/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 ''}: null↔{ft if lt == 'null' else lt} (nullable, 무시 가능)") return if lt != ft: breaking.append(f"{path or ''}: 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 ", 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()