Pulse/scripts/release_control/mobile_compatibility.py
rcourtman 9e39ffc920 Enforce Pulse Mobile compatibility from the core contract
Contract-Neutral: mobile compatibility generation does not change agent lifecycle or storage recovery contracts
2026-07-22 13:41:09 +01:00

355 lines
15 KiB
Python

#!/usr/bin/env python3
"""Verify Pulse core against the checked-in Pulse Mobile consumer contract."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import subprocess
import sys
from pathlib import Path
from typing import Any, Iterable
from generate_mobile_compatibility import (
MANIFEST_REL,
ManifestError,
check_outputs,
generated_outputs,
load_manifest,
manifest_sha256,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
CONSUMER_REL = Path("config/mobile-api-surface.json")
VALID_FIELD_TYPES = {"array", "boolean", "integer", "number", "object", "string"}
class CompatibilityError(ValueError):
"""Raised when the mobile consumer contract is invalid or incompatible."""
def load_consumer(mobile_repo: Path) -> dict[str, Any]:
path = mobile_repo / CONSUMER_REL
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise CompatibilityError(f"cannot read mobile consumer contract {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise CompatibilityError(f"invalid JSON in mobile consumer contract {path}: {exc}") from exc
if not isinstance(payload, dict):
raise CompatibilityError("mobile consumer contract root must be an object")
validate_consumer(payload)
return payload
def _string_list(value: Any, label: str, *, allow_empty: bool = True) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise CompatibilityError(f"{label} must be a list of non-empty strings")
if not allow_empty and not value:
raise CompatibilityError(f"{label} must not be empty")
if len(value) != len(set(value)):
raise CompatibilityError(f"{label} must not contain duplicates")
return value
def _validate_consumer_fields(value: Any, label: str) -> None:
if not isinstance(value, list):
raise CompatibilityError(f"{label} must be a list")
seen: set[str] = set()
for index, entry in enumerate(value):
if not isinstance(entry, dict):
raise CompatibilityError(f"{label}[{index}] must be an object")
path = entry.get("path")
field_type = entry.get("type")
if not isinstance(path, str) or not path:
raise CompatibilityError(f"{label}[{index}].path must be non-empty")
if field_type not in VALID_FIELD_TYPES:
raise CompatibilityError(f"{label}[{index}].type is invalid")
if "required" in entry and not isinstance(entry["required"], bool):
raise CompatibilityError(f"{label}[{index}].required must be boolean")
if path in seen:
raise CompatibilityError(f"duplicate {label} path {path!r}")
seen.add(path)
def validate_consumer(payload: dict[str, Any]) -> None:
if payload.get("schemaVersion") != 1:
raise CompatibilityError("consumer schemaVersion must be 1")
contract = payload.get("coreContract")
if not isinstance(contract, dict):
raise CompatibilityError("coreContract must be an object")
versions = contract.get("supportedVersions")
if not isinstance(versions, dict):
raise CompatibilityError("coreContract.supportedVersions must be an object")
minimum = versions.get("minimum")
maximum = versions.get("maximum")
if not isinstance(minimum, int) or not isinstance(maximum, int) or minimum < 1:
raise CompatibilityError("core contract versions must be positive integers")
if minimum > maximum:
raise CompatibilityError("minimum core contract version cannot exceed maximum")
pairing = contract.get("pairing")
if not isinstance(pairing, dict):
raise CompatibilityError("pairing must be an object")
for key in ("schema", "scheme", "host"):
if not isinstance(pairing.get(key), str) or not pairing[key]:
raise CompatibilityError(f"pairing.{key} must be a non-empty string")
_string_list(pairing.get("requiredFields"), "pairing.requiredFields", allow_empty=False)
push = contract.get("push")
if not isinstance(push, dict):
raise CompatibilityError("push must be an object")
_string_list(push.get("requiredFields"), "push.requiredFields", allow_empty=False)
actions = push.get("actions")
if not isinstance(actions, list) or not actions:
raise CompatibilityError("push.actions must be a non-empty list")
action_values: set[str] = set()
for index, action in enumerate(actions):
if not isinstance(action, dict):
raise CompatibilityError(f"push.actions[{index}] must be an object")
value = action.get("value")
destination = action.get("destination")
if not isinstance(value, str) or not value:
raise CompatibilityError(f"push.actions[{index}].value must be non-empty")
if destination not in {"approval-detail", "finding-detail"}:
raise CompatibilityError(f"push.actions[{index}].destination is invalid")
if value in action_values:
raise CompatibilityError(f"duplicate push action {value!r}")
action_values.add(value)
routes = payload.get("endpoints")
if not isinstance(routes, list) or not routes:
raise CompatibilityError("endpoints must be a non-empty list")
route_fields = contract.get("routeFields")
if not isinstance(route_fields, dict):
raise CompatibilityError("coreContract.routeFields must be an object")
route_ids: set[str] = set()
for index, route in enumerate(routes):
if not isinstance(route, dict):
raise CompatibilityError(f"endpoints[{index}] must be an object")
route_id = route.get("id")
if not isinstance(route_id, str) or not route_id:
raise CompatibilityError(f"endpoints[{index}].id must be non-empty")
core_route_id = route.get("coreRouteId", route_id)
if not isinstance(core_route_id, str) or not core_route_id:
raise CompatibilityError(f"endpoint {route_id!r} coreRouteId must be non-empty")
if route.get("method") not in {"DELETE", "GET", "PATCH", "POST", "PUT"}:
raise CompatibilityError(f"endpoint {route_id!r} has invalid method")
fields = route_fields.get(route_id)
if not isinstance(fields, dict):
raise CompatibilityError(
f"coreContract.routeFields is missing endpoint {route_id!r}"
)
_validate_consumer_fields(fields.get("request"), f"endpoint {route_id} request fields")
_validate_consumer_fields(fields.get("response"), f"endpoint {route_id} response fields")
if route_id in route_ids:
raise CompatibilityError(f"duplicate endpoint {route_id!r}")
route_ids.add(route_id)
stale_fields = set(route_fields) - route_ids
if stale_fields:
raise CompatibilityError(
f"coreContract.routeFields has stale endpoints: {sorted(stale_fields)}"
)
def _field_map(fields: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {str(field["path"]): field for field in fields}
def compare_contracts(
provider: dict[str, Any], consumer: dict[str, Any]
) -> list[str]:
errors: list[str] = []
version = provider["contract_version"]
consumer_contract = consumer["coreContract"]
versions = consumer_contract["supportedVersions"]
if not versions["minimum"] <= version <= versions["maximum"]:
errors.append(
f"core contract version {version} is outside mobile's supported range "
f"{versions['minimum']}..{versions['maximum']}"
)
provider_pairing = provider["pairing"]
consumer_pairing = consumer_contract["pairing"]
for key in ("schema", "scheme", "host"):
if provider_pairing[key] != consumer_pairing[key]:
errors.append(
f"pairing {key} = {provider_pairing[key]!r}, mobile requires {consumer_pairing[key]!r}"
)
missing_pairing = set(consumer_pairing["requiredFields"]) - (
set(provider_pairing["required_fields"]) | set(provider_pairing["optional_fields"])
)
if missing_pairing:
errors.append(f"core pairing contract is missing fields: {sorted(missing_pairing)}")
provider_push = provider["push"]
consumer_push = consumer_contract["push"]
missing_push_fields = set(consumer_push["requiredFields"]) - set(
provider_push["required_fields"]
)
if missing_push_fields:
errors.append(f"core push contract is missing fields: {sorted(missing_push_fields)}")
provider_actions = {entry["value"]: entry for entry in provider_push["actions"]}
for required_action in consumer_push["actions"]:
actual = provider_actions.get(required_action["value"])
if actual is None:
errors.append(f"core push contract removed action {required_action['value']!r}")
continue
if actual["destination"] != required_action["destination"]:
errors.append(
f"push action {required_action['value']!r} destination changed from "
f"{required_action['destination']!r} to {actual['destination']!r}"
)
provider_routes = {route["id"]: route for route in provider["routes"]}
route_fields = consumer_contract["routeFields"]
for required_route in consumer["endpoints"]:
endpoint_id = required_route["id"]
route_id = required_route.get("coreRouteId", endpoint_id)
actual = provider_routes.get(route_id)
if actual is None:
errors.append(
f"core contract removed mobile route {route_id!r} used by endpoint {endpoint_id!r}"
)
continue
if actual["method"] != required_route["method"]:
errors.append(
f"route {route_id!r} method changed from {required_route['method']} "
f"to {actual['method']}"
)
if actual["mobile_impact"] != "mobile-required":
errors.append(
f"route {route_id!r} is consumed by mobile but classified "
f"{actual['mobile_impact']!r}"
)
compatible_scopes = {
"relay:mobile:access",
actual["required_scope"],
*([actual["legacy_scope"]] if actual.get("legacy_scope") else []),
}
if "relay:mobile:access" not in compatible_scopes:
errors.append(f"route {route_id!r} no longer accepts relay:mobile:access")
for consumer_direction, provider_direction in (
("request", "request_fields"),
("response", "response_fields"),
):
provider_fields = _field_map(actual[provider_direction])
for field in route_fields[endpoint_id][consumer_direction]:
provider_field = provider_fields.get(field["path"])
if provider_field is None:
errors.append(
f"route {route_id!r} removed mobile {consumer_direction} field {field['path']!r}"
)
continue
if provider_field["type"] != field["type"]:
errors.append(
f"route {route_id!r} field {field['path']!r} changed type from "
f"{field['type']} to {provider_field['type']}"
)
consumer_requires = field.get("required", True)
provider_guarantees = provider_field.get("required", True)
if consumer_requires and not provider_guarantees:
errors.append(
f"route {route_id!r} field {field['path']!r} is no longer guaranteed"
)
return errors
def git_revision(repo: Path) -> tuple[str, bool]:
def run(*args: str) -> str:
completed = subprocess.run(
["git", "-C", str(repo), *args],
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip()
try:
revision = run("rev-parse", "HEAD")
dirty = bool(run("status", "--porcelain"))
return revision, dirty
except (OSError, subprocess.CalledProcessError):
return "unavailable", True
def read_candidate(mobile_repo: Path) -> dict[str, Any]:
path = mobile_repo / "store/release-readiness.json"
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
candidate = payload.get("currentCandidate")
return candidate if isinstance(candidate, dict) else {}
def build_evidence(
pulse_root: Path,
mobile_repo: Path,
provider: dict[str, Any],
manifest_raw: bytes,
) -> dict[str, Any]:
pulse_revision, pulse_dirty = git_revision(pulse_root)
mobile_revision, mobile_dirty = git_revision(mobile_repo)
return {
"schemaVersion": 1,
"status": "passed",
"generatedAt": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
"contractVersion": provider["contract_version"],
"contractSha256": manifest_sha256(manifest_raw),
"pulseRevision": pulse_revision,
"pulseDirty": pulse_dirty,
"mobileRevision": mobile_revision,
"mobileDirty": mobile_dirty,
"mobileCandidate": read_candidate(mobile_repo),
"checks": [
"canonical-manifest-valid",
"generated-projections-current",
"route-method-scope-compatible",
"request-response-fields-compatible",
"pairing-contract-compatible",
"push-routing-compatible",
],
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pulse-root", default=str(REPO_ROOT))
parser.add_argument("--mobile-repo", required=True)
parser.add_argument("--evidence-out", default="")
parser.add_argument("--pretty", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
pulse_root = Path(args.pulse_root).resolve()
mobile_repo = Path(args.mobile_repo).resolve()
try:
provider, raw = load_manifest(pulse_root)
consumer = load_consumer(mobile_repo)
errors = compare_contracts(provider, consumer)
errors.extend(check_outputs(generated_outputs(pulse_root, mobile_repo)))
except (CompatibilityError, ManifestError, OSError, subprocess.CalledProcessError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
evidence = build_evidence(pulse_root, mobile_repo, provider, raw)
rendered = json.dumps(evidence, indent=2 if args.pretty else None, sort_keys=True) + "\n"
if args.evidence_out:
output = Path(args.evidence_out)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())