From 637d6dc3f2d405ef7b44c7a26c9bb4629dac56df Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sun, 12 Jul 2026 23:33:20 +0300 Subject: [PATCH] feat: add semantic contract diff --- Makefile | 2 +- README.md | 9 ++ app/contracts/cli.py | 25 ++++ app/contracts/diff.py | 224 +++++++++++++++++++++++++++++++ tests/unit/test_contract_diff.py | 87 ++++++++++++ 5 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 app/contracts/diff.py create mode 100644 tests/unit/test_contract_diff.py diff --git a/Makefile b/Makefile index df11c1d..0fb4f74 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ api-import: ## Import an API snapshot $(BIN)/proxmox-api-contract import $(ARGS) api-diff: ## Compare API snapshots - @echo "API diff is scheduled for milestone B5" >&2; exit 2 + $(BIN)/proxmox-api-contract diff $(ARGS) seed: ## Seed simulation data @echo "Database seed is scheduled for milestone D2" >&2; exit 2 diff --git a/README.md b/README.md index 2582f9e..f4b71d2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ reject private address resolution, unsafe redirects, oversized responses, and unbounded retries. Imported revisions are addressed by their normalized snapshot checksum and are never overwritten. +Normalized snapshots can be compared in text, JSON, Markdown, or HTML. The diff +command exits with status 1 when it finds a breaking change, making it suitable +for CI policy checks: + +```bash +.venv/bin/proxmox-api-contract diff old-snapshot.json new-snapshot.json \ + --format markdown +``` + See [the architecture](docs/architecture.md) for component boundaries and durability decisions. Commands for not-yet-implemented milestones intentionally return a non-zero status instead of pretending to succeed. diff --git a/app/contracts/cli.py b/app/contracts/cli.py index 375488f..3ad3859 100644 --- a/app/contracts/cli.py +++ b/app/contracts/cli.py @@ -8,7 +8,16 @@ import json from datetime import UTC, datetime from pathlib import Path +from app.contracts.diff import ( + compare_snapshots, + has_breaking_changes, + render_html, + render_json, + render_markdown, + render_text, +) from app.contracts.importer import RemoteSourceImporter +from app.contracts.model import Snapshot from app.contracts.normalize import normalize_snapshot from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter from app.contracts.store import RevisionStore @@ -28,6 +37,10 @@ def parser() -> argparse.ArgumentParser: commands.add_parser("list") show = commands.add_parser("show") show.add_argument("revision") + diff = commands.add_parser("diff") + diff.add_argument("before", type=Path) + diff.add_argument("after", type=Path) + diff.add_argument("--format", choices=("text", "json", "markdown", "html"), default="text") return root @@ -40,6 +53,18 @@ async def run(arguments: argparse.Namespace) -> int: if arguments.command == "show": print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2)) return 0 + if arguments.command == "diff": + before = Snapshot.model_validate_json(arguments.before.read_bytes()) + after = Snapshot.model_validate_json(arguments.after.read_bytes()) + changes = compare_snapshots(before, after) + renderers = { + "text": render_text, + "json": render_json, + "markdown": render_markdown, + "html": render_html, + } + print(renderers[arguments.format](changes)) + return 1 if has_breaking_changes(changes) else 0 if arguments.command == "validate": parsed = ApiViewerParser().parse(arguments.file.read_bytes()) print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)})) diff --git a/app/contracts/diff.py b/app/contracts/diff.py new file mode 100644 index 0000000..3b2e441 --- /dev/null +++ b/app/contracts/diff.py @@ -0,0 +1,224 @@ +"""Deterministic semantic differences between normalized snapshots.""" + +from __future__ import annotations + +import html +import json +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from app.contracts.model import Method, Parameter, PathContract, Snapshot + + +class Severity(StrEnum): + BREAKING = "breaking" + NON_BREAKING = "non-breaking" + DOCUMENTATION = "documentation" + + +@dataclass(frozen=True, slots=True, order=True) +class Change: + path: str + method: str + category: str + severity: Severity + detail: str + before: str | None = None + after: str | None = None + + +def _stable(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _methods(snapshot: Snapshot) -> dict[tuple[str, str], Method]: + return {(path.path, method.verb): method for path in snapshot.paths for method in path.methods} + + +def _paths(snapshot: Snapshot) -> dict[str, PathContract]: + return {path.path: path for path in snapshot.paths} + + +def compare_snapshots(before: Snapshot, after: Snapshot) -> tuple[Change, ...]: + changes: list[Change] = [] + old_paths, new_paths = _paths(before), _paths(after) + for path in sorted(old_paths.keys() - new_paths.keys()): + changes.append(Change(path, "", "path", Severity.BREAKING, "path removed")) + for path in sorted(new_paths.keys() - old_paths.keys()): + changes.append(Change(path, "", "path", Severity.NON_BREAKING, "path added")) + + old_methods, new_methods = _methods(before), _methods(after) + for path, verb in sorted(old_methods.keys() - new_methods.keys()): + changes.append(Change(path, verb, "method", Severity.BREAKING, "method removed")) + for path, verb in sorted(new_methods.keys() - old_methods.keys()): + changes.append(Change(path, verb, "method", Severity.NON_BREAKING, "method added")) + for key in sorted(old_methods.keys() & new_methods.keys()): + _compare_method(key, old_methods[key], new_methods[key], changes) + return tuple(sorted(changes)) + + +def _compare_method( + key: tuple[str, str], before: Method, after: Method, changes: list[Change] +) -> None: + path, verb = key + if before.description != after.description: + changes.append( + Change( + path, + verb, + "documentation", + Severity.DOCUMENTATION, + "description changed", + before.description, + after.description, + ) + ) + if before.permissions != after.permissions: + changes.append( + Change( + path, + verb, + "permissions", + Severity.BREAKING, + "permissions changed", + _stable(before.permissions.model_dump(mode="json") if before.permissions else None), + _stable(after.permissions.model_dump(mode="json") if after.permissions else None), + ) + ) + _compare_parameters(path, verb, before.parameters, after.parameters, changes) + _compare_schema( + path, + verb, + "returns", + before.returns.model_dump(mode="json"), + after.returns.model_dump(mode="json"), + changes, + ) + + +def _compare_parameters( + path: str, + verb: str, + before: tuple[Parameter, ...], + after: tuple[Parameter, ...], + changes: list[Change], +) -> None: + old = {parameter.name: parameter for parameter in before} + new = {parameter.name: parameter for parameter in after} + for name in sorted(old.keys() - new.keys()): + changes.append(Change(path, verb, "parameter", Severity.BREAKING, f"removed: {name}")) + for name in sorted(new.keys() - old.keys()): + severity = Severity.NON_BREAKING if new[name].definition.optional else Severity.BREAKING + changes.append(Change(path, verb, "parameter", severity, f"added: {name}")) + for name in sorted(old.keys() & new.keys()): + _compare_schema( + path, + verb, + f"parameter:{name}", + old[name].definition.model_dump(mode="json"), + new[name].definition.model_dump(mode="json"), + changes, + ) + + +def _compare_schema( + path: str, + verb: str, + label: str, + before: dict[str, Any], + after: dict[str, Any], + changes: list[Change], +) -> None: + groups = { + "schema": {"type", "properties", "items", "enum", "format", "pattern"}, + "default": {"default", "optional"}, + "constraint": {"minimum", "maximum", "min_length", "max_length"}, + "documentation": {"description"}, + } + for category, fields in groups.items(): + old = {field: before.get(field) for field in fields} + new = {field: after.get(field) for field in fields} + if old != new: + severity = Severity.DOCUMENTATION if category == "documentation" else Severity.BREAKING + changes.append( + Change( + path, + verb, + category, + severity, + f"{label} {category} changed", + _stable(old), + _stable(new), + ) + ) + + +def render_json(changes: tuple[Change, ...]) -> str: + return json.dumps( + [ + { + "after": change.after, + "before": change.before, + "category": change.category, + "detail": change.detail, + "method": change.method, + "path": change.path, + "severity": change.severity, + } + for change in changes + ], + ensure_ascii=False, + indent=2, + ) + + +def render_text(changes: tuple[Change, ...]) -> str: + return "\n".join( + ( + f"{change.severity}: {change.method} {change.path} [{change.category}] {change.detail}" + ).strip() + for change in changes + ) + + +def render_markdown(changes: tuple[Change, ...]) -> str: + lines = [ + "# API contract diff", + "", + "| Severity | Method | Path | Category | Detail |", + "|---|---|---|---|---|", + ] + lines.extend( + ( + f"| {change.severity} | {change.method} | `{change.path}` | " + f"{change.category} | {change.detail} |" + ) + for change in changes + ) + return "\n".join(lines) + + +def render_html(changes: tuple[Change, ...]) -> str: + rows = "".join( + "" + + "".join( + f"{html.escape(str(value))}" + for value in ( + change.severity, + change.method, + change.path, + change.category, + change.detail, + ) + ) + + "" + for change in changes + ) + return ( + f"API contract diff{rows}
" + ) + + +def has_breaking_changes(changes: tuple[Change, ...]) -> bool: + return any(change.severity is Severity.BREAKING for change in changes) diff --git a/tests/unit/test_contract_diff.py b/tests/unit/test_contract_diff.py new file mode 100644 index 0000000..e0aed90 --- /dev/null +++ b/tests/unit/test_contract_diff.py @@ -0,0 +1,87 @@ +"""Semantic contract diff classification and rendering tests.""" + +import json +from datetime import UTC, datetime + +from app.contracts.diff import ( + Severity, + compare_snapshots, + has_breaking_changes, + render_html, + render_json, + render_markdown, + render_text, +) +from app.contracts.model import Method, PathContract, Schema, Snapshot + + +def snapshot(paths: tuple[PathContract, ...]) -> Snapshot: + return Snapshot( + source_version="test", + retrieved_at=datetime(2026, 1, 1, tzinfo=UTC), + raw_sha256="0" * 64, + paths=paths, + path_count=len(paths), + method_count=sum(len(path.methods) for path in paths), + ) + + +def method(description: str = "old", returns: Schema | None = None) -> Method: + return Method( + verb="GET", + name="read", + description=description, + returns=returns or Schema(type="string"), + checksum="1" * 64, + ) + + +def test_classifies_added_removed_and_changed_contracts() -> None: + before = snapshot( + ( + PathContract(path="/removed", methods=(method(),)), + PathContract(path="/version", methods=(method(),)), + ) + ) + after = snapshot( + ( + PathContract(path="/added", methods=(method(),)), + PathContract( + path="/version", + methods=(method("new", Schema(type="integer", minimum=1)),), + ), + ) + ) + + changes = compare_snapshots(before, after) + + assert changes == tuple(sorted(changes)) + assert {change.category for change in changes} >= { + "path", + "method", + "documentation", + "schema", + "constraint", + } + assert has_breaking_changes(changes) + assert any(change.severity is Severity.NON_BREAKING for change in changes) + + +def test_renderers_are_stable_and_escape_html() -> None: + before = snapshot((PathContract(path="/", methods=(method(),)),)) + after = snapshot(()) + changes = compare_snapshots(before, after) + + assert render_text(changes).startswith("breaking:") + assert "| breaking |" in render_markdown(changes) + assert "<old>" in render_html(changes) + decoded = json.loads(render_json(changes)) + assert decoded[0]["severity"] == "breaking" + assert render_json(changes) == render_json(changes) + + +def test_no_changes_has_clean_ci_policy() -> None: + value = snapshot((PathContract(path="/version", methods=(method(),)),)) + + assert compare_snapshots(value, value) == () + assert not has_breaking_changes(())