feat: add semantic contract diff
This commit is contained in:
@@ -73,7 +73,7 @@ api-import: ## Import an API snapshot
|
|||||||
$(BIN)/proxmox-api-contract import $(ARGS)
|
$(BIN)/proxmox-api-contract import $(ARGS)
|
||||||
|
|
||||||
api-diff: ## Compare API snapshots
|
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
|
seed: ## Seed simulation data
|
||||||
@echo "Database seed is scheduled for milestone D2" >&2; exit 2
|
@echo "Database seed is scheduled for milestone D2" >&2; exit 2
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ reject private address resolution, unsafe redirects, oversized responses, and
|
|||||||
unbounded retries. Imported revisions are addressed by their normalized
|
unbounded retries. Imported revisions are addressed by their normalized
|
||||||
snapshot checksum and are never overwritten.
|
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
|
See [the architecture](docs/architecture.md) for component boundaries and
|
||||||
durability decisions. Commands for not-yet-implemented milestones intentionally
|
durability decisions. Commands for not-yet-implemented milestones intentionally
|
||||||
return a non-zero status instead of pretending to succeed.
|
return a non-zero status instead of pretending to succeed.
|
||||||
|
|||||||
@@ -8,7 +8,16 @@ import json
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
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.importer import RemoteSourceImporter
|
||||||
|
from app.contracts.model import Snapshot
|
||||||
from app.contracts.normalize import normalize_snapshot
|
from app.contracts.normalize import normalize_snapshot
|
||||||
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter
|
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter
|
||||||
from app.contracts.store import RevisionStore
|
from app.contracts.store import RevisionStore
|
||||||
@@ -28,6 +37,10 @@ def parser() -> argparse.ArgumentParser:
|
|||||||
commands.add_parser("list")
|
commands.add_parser("list")
|
||||||
show = commands.add_parser("show")
|
show = commands.add_parser("show")
|
||||||
show.add_argument("revision")
|
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
|
return root
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +53,18 @@ async def run(arguments: argparse.Namespace) -> int:
|
|||||||
if arguments.command == "show":
|
if arguments.command == "show":
|
||||||
print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2))
|
print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2))
|
||||||
return 0
|
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":
|
if arguments.command == "validate":
|
||||||
parsed = ApiViewerParser().parse(arguments.file.read_bytes())
|
parsed = ApiViewerParser().parse(arguments.file.read_bytes())
|
||||||
print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)}))
|
print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)}))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"<tr>"
|
||||||
|
+ "".join(
|
||||||
|
f"<td>{html.escape(str(value))}</td>"
|
||||||
|
for value in (
|
||||||
|
change.severity,
|
||||||
|
change.method,
|
||||||
|
change.path,
|
||||||
|
change.category,
|
||||||
|
change.detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "</tr>"
|
||||||
|
for change in changes
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"<!doctype html><meta charset=utf-8><title>API contract diff</title><table>{rows}</table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_breaking_changes(changes: tuple[Change, ...]) -> bool:
|
||||||
|
return any(change.severity is Severity.BREAKING for change in changes)
|
||||||
@@ -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="/<old>", 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(())
|
||||||
Reference in New Issue
Block a user