#!/usr/bin/env python3 """Dependency-free JSON contract shape diff for local use and CI. Exit codes: 0=no breaking changes, 2=breaking changes, 1=input/runtime error. Representative array elements are compared; semantic behavior still needs review. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any def value_type(value: Any) -> str: if value is None: return "null" if isinstance(value, bool): return "boolean" if isinstance(value, (int, float)) and not isinstance(value, bool): return "number" if isinstance(value, str): return "string" if isinstance(value, list): return "array" if isinstance(value, dict): return "object" return type(value).__name__ def flatten(value: Any, path: str = "$", output: dict[str, dict[str, Any]] | None = None) -> dict[str, dict[str, Any]]: output = {} if output is None else output output[path] = {"type": value_type(value), "value": value} if isinstance(value, dict): for key in sorted(value): flatten(value[key], f"{path}.{key}", output) elif isinstance(value, list) and value: flatten(value[0], f"{path}[]", output) return output def compare(old: Any, new: Any) -> list[dict[str, Any]]: before, after = flatten(old), flatten(new) changes: list[dict[str, Any]] = [] for path, left in before.items(): if path == "$": continue if path not in after: changes.append({"impact": "breaking", "path": path, "change": "removed", "old_type": left["type"], "new_type": None}) elif left["type"] != after[path]["type"]: changes.append({"impact": "breaking", "path": path, "change": "type_changed", "old_type": left["type"], "new_type": after[path]["type"]}) for path, right in after.items(): if path != "$" and path not in before: changes.append({"impact": "additive", "path": path, "change": "added", "old_type": None, "new_type": right["type"]}) return sorted(changes, key=lambda item: (item["impact"] != "breaking", item["path"])) def markdown(changes: list[dict[str, Any]], old_name: str, new_name: str) -> str: breaking = sum(row["impact"] == "breaking" for row in changes) additive = sum(row["impact"] == "additive" for row in changes) lines = [ "# JSON contract diff", "", f"Compared `{old_name}` → `{new_name}`.", "", f"- Breaking changes: **{breaking}**", f"- Additive changes: **{additive}**", "", "| Impact | Path | Change | Old type | New type |", "|---|---|---|---|---|", ] if changes: for row in changes: lines.append(f"| {row['impact']} | `{row['path']}` | {row['change']} | {row['old_type'] or '—'} | {row['new_type'] or '—'} |") else: lines.append("| none | — | No structural changes detected | — | — |") lines += ["", "_Heuristic structural comparison: review array samples, nullable fields, constraints, and runtime semantics manually._"] return "\n".join(lines) + "\n" def load(path: str) -> Any: if path == "-": return json.load(sys.stdin) with Path(path).open(encoding="utf-8") as handle: return json.load(handle) def main() -> int: parser = argparse.ArgumentParser(description="Compare representative JSON payload shapes.") parser.add_argument("old", help="Old/expected JSON file, or - for stdin") parser.add_argument("new", help="New/actual JSON file") parser.add_argument("--format", choices=("markdown", "json"), default="markdown") parser.add_argument("--output", help="Write report to this file instead of stdout") parser.add_argument("--allow-breaking", action="store_true", help="Return exit code 0 even when breaking changes exist") args = parser.parse_args() try: changes = compare(load(args.old), load(args.new)) except (OSError, json.JSONDecodeError) as exc: print(f"json-contract-diff: {exc}", file=sys.stderr) return 1 breaking = sum(row["impact"] == "breaking" for row in changes) if args.format == "json": rendered = json.dumps({"breaking_count": breaking, "additive_count": len(changes)-breaking, "changes": changes}, indent=2) + "\n" else: rendered = markdown(changes, args.old, args.new) if args.output: Path(args.output).write_text(rendered, encoding="utf-8") else: sys.stdout.write(rendered) return 0 if args.allow_breaking or not breaking else 2 if __name__ == "__main__": raise SystemExit(main())