#!/usr/bin/env python3 """Developer-only validation for local F01/F07 contracts, examples, and hashes.""" import hashlib import json import re from pathlib import Path try: from jsonschema import Draft202012Validator, FormatChecker, ValidationError from referencing import Registry, Resource except ImportError as exc: raise SystemExit("requires the developer tool jsonschema 4.x; not a Go runtime dependency") from exc ROOT = Path(__file__).resolve().parents[1] DOC = ROOT / "docs/thirds/第三方对接事件与请求消费顺序_v0.1.md" SCHEMA_PATHS = { "config-read": ROOT / "docs/contracts/config-read-v0.1.schema.json", "task-discovery": ROOT / "docs/contracts/task-discovery-v0.1-proposal.schema.json", "command-next": ROOT / "docs/contracts/command-next-v0.1-proposal.schema.json", "call-result": ROOT / "docs/contracts/call-result-v0.1-proposal.schema.json", } EXAMPLES = ROOT / "docs/contracts/examples" MANIFEST_PATH = ROOT / "docs/contracts/local-contract-manifest-v0.1.json" STATUS_FIXTURE = EXAMPLES / "config-read-http-statuses-v0.1.json" TASK_STATUS_FIXTURE = EXAMPLES / "task-discovery-http-statuses-v0.1.json" def load_json(path): return json.loads(path.read_text(encoding="utf-8")) registry = Registry() for path in (ROOT / "contracts/upstream/v1").glob("*.schema.json"): schema = load_json(path) if "$id" in schema: registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) def schema_versions(node): found = set() if isinstance(node, dict): version = node.get("properties", {}).get("schema_version", {}).get("const") if isinstance(version, str): found.add(version) for value in node.values(): found.update(schema_versions(value)) elif isinstance(node, list): for value in node: found.update(schema_versions(value)) return found validators = {} versions = {} for name, path in SCHEMA_PATHS.items(): schema = load_json(path) Draft202012Validator.check_schema(schema) schema_version_values = schema_versions(schema) if not schema_version_values: raise SystemExit(f"no schema_version const found: {path.relative_to(ROOT)}") for version in schema_version_values: if version in versions and versions[version] != name: raise SystemExit(f"duplicate schema_version {version}") versions[version] = name validators[name] = Draft202012Validator( schema, registry=registry, format_checker=FormatChecker(), ) positive_counts = {name: 0 for name in SCHEMA_PATHS} doc = DOC.read_text(encoding="utf-8") for index, match in enumerate(re.finditer(r"```json\s*(.*?)\s*```", doc, re.DOTALL), 1): try: sample = json.loads(match.group(1)) except json.JSONDecodeError as exc: if any(version in match.group(1) for version in versions): raise SystemExit(f"invalid JSON in local-schema Markdown example {index}: {exc}") from exc continue if not isinstance(sample, dict) or sample.get("schema_version") not in versions: continue name = versions[sample["schema_version"]] validators[name].validate(sample) positive_counts[name] += 1 for path in sorted(EXAMPLES.glob("config-read-*.json")): if "invalid" in path.name or path == STATUS_FIXTURE: continue validators["config-read"].validate(load_json(path)) positive_counts["config-read"] += 1 for path in sorted(EXAMPLES.glob("task-discovery-*-v0.1.json")): if "invalid" in path.name or path == TASK_STATUS_FIXTURE: continue validators["task-discovery"].validate(load_json(path)) positive_counts["task-discovery"] += 1 for path in sorted(EXAMPLES.glob("call-result-*.json")): if "invalid" in path.name: continue validators["call-result"].validate(load_json(path)) positive_counts["call-result"] += 1 CONFIG_READ_HTTP_ERROR_CODES = { 400: {"invalid_request"}, 401: {"unauthorized"}, 403: {"dispatcher_not_authorized"}, 404: {"resource_not_found"}, 503: {"tenant_quota_unavailable", "service_unavailable"}, } TASK_DISCOVERY_HTTP_ERROR_CODES = { 400: {"invalid_cursor", "invalid_page_token"}, 401: {"unauthorized"}, 403: {"dispatcher_not_authorized"}, 410: {"cursor_expired", "snapshot_expired"}, 503: {"service_unavailable"}, } def validate_status_fixture(path, schema_name, fixture_version, status_codes): fixture = load_json(path) if not isinstance(fixture, dict) or set(fixture) != {"fixture_version", "responses"}: raise SystemExit(f"invalid HTTP status fixture structure: {path.relative_to(ROOT)}") if fixture["fixture_version"] != fixture_version or not isinstance(fixture["responses"], list): raise SystemExit(f"unexpected HTTP status fixture version or responses: {path.relative_to(ROOT)}") expected = {(status, code) for status, codes in status_codes.items() for code in codes} actual = set() for response in fixture["responses"]: if not isinstance(response, dict) or set(response) != {"status", "body"}: raise SystemExit(f"invalid HTTP error fixture entry: {response}") status = response["status"] body = response["body"] if type(status) is not int or not isinstance(body, dict): raise SystemExit(f"invalid HTTP error fixture status/body: {response}") validators[schema_name].validate(body) code = body["error"]["code"] if code not in status_codes.get(status, set()): raise SystemExit(f"HTTP {status} does not match local {schema_name} error code {code}") pair = (status, code) if pair in actual: raise SystemExit(f"duplicate HTTP error case: {pair}") actual.add(pair) if actual != expected: raise SystemExit(f"HTTP error cases differ from local {schema_name} mapping: missing={expected - actual}, extra={actual - expected}") return len(actual) positive_counts["config-read"] += validate_status_fixture( STATUS_FIXTURE, "config-read", "config-read-http-statuses.v0.1", CONFIG_READ_HTTP_ERROR_CODES ) positive_counts["task-discovery"] += validate_status_fixture( TASK_STATUS_FIXTURE, "task-discovery", "task-discovery-http-statuses.v0.1", TASK_DISCOVERY_HTTP_ERROR_CODES ) if any(count == 0 for count in positive_counts.values()): raise SystemExit(f"missing positive contract example: {positive_counts}") invalid_prefixes = { "config-read-invalid-": "config-read", "task-discovery-invalid-": "task-discovery", "command-next-invalid-": "command-next", "call-result-invalid-": "call-result", } negative_counts = {name: 0 for name in SCHEMA_PATHS} for path in sorted(EXAMPLES.glob("*-invalid-*.json")): name = next((schema for prefix, schema in invalid_prefixes.items() if path.name.startswith(prefix)), None) if name is None or (name == "task-discovery" and not path.name.endswith("-v0.1.json")): continue sample = load_json(path) try: validators[name].validate(sample) except ValidationError: negative_counts[name] += 1 else: raise SystemExit(f"negative fixture unexpectedly valid: {path.relative_to(ROOT)}") if any(count == 0 for count in negative_counts.values()): raise SystemExit(f"missing negative fixture per local schema: {negative_counts}") def validate_manifest(): if not MANIFEST_PATH.is_file(): raise SystemExit(f"required local contract manifest is missing: {MANIFEST_PATH.relative_to(ROOT)}") manifest = load_json(MANIFEST_PATH) expected_source = DOC.relative_to(ROOT).as_posix() if manifest.get("manifest_version") != "local-contract-manifest.v0.1": raise SystemExit("unexpected local contract manifest version") if manifest.get("hash_algorithm") != "SHA-256": raise SystemExit("local contract manifest must use SHA-256") source = manifest.get("source") if not isinstance(source, dict) or source.get("path") != expected_source: raise SystemExit(f"local contract manifest source must be {expected_source}") artifacts = manifest.get("artifacts") if not isinstance(artifacts, list): raise SystemExit("local contract manifest artifacts must be a list") required_artifacts = {path.relative_to(ROOT).as_posix() for path in SCHEMA_PATHS.values()} required_artifacts.update({ "docs/contracts/config-read-fields-v0.1-proposal.md", "docs/contracts/mq-topology-v0.1-proposal.json", }) required_artifacts.update( path.relative_to(ROOT).as_posix() for path in EXAMPLES.glob("*.json") if path.name.startswith(("config-read-", "command-next-", "call-result-")) or (path.name.startswith("task-discovery-") and path.name.endswith("-v0.1.json")) ) artifact_paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)] if len(artifact_paths) != len(artifacts) or len(set(artifact_paths)) != len(artifact_paths): raise SystemExit("local contract manifest has malformed or duplicate artifacts") if set(artifact_paths) != required_artifacts: raise SystemExit(f"local contract manifest artifact set mismatch: missing={required_artifacts - set(artifact_paths)}, extra={set(artifact_paths) - required_artifacts}") entries = [source, *artifacts] for entry in entries: relative = Path(entry.get("path", "")) expected_hash = entry.get("sha256") if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(expected_hash)): raise SystemExit(f"invalid path or SHA-256 in local contract manifest: {entry}") path = ROOT / relative if not path.is_file(): raise SystemExit(f"manifest file is missing: {relative}") actual_hash = hashlib.sha256(path.read_bytes()).hexdigest() if actual_hash != expected_hash: raise SystemExit(f"SHA-256 mismatch for {relative}: expected {expected_hash}, got {actual_hash}") return len(entries) manifest_file_count = validate_manifest() print(f"Local schemas: {len(validators)} valid; positive examples: {positive_counts}; negative fixtures rejected: {negative_counts}; SHA-256 manifest files verified: {manifest_file_count}") # Validate the separate v0.2 task-discovery proposal without changing the # v0.1 runtime baseline or the historical v0.1 manifest. proposal_path = ROOT / "docs/contracts/task-discovery-v0.2-proposal.schema.json" proposal_schema = load_json(proposal_path) Draft202012Validator.check_schema(proposal_schema) if schema_versions(proposal_schema) != {"task-discovery.v0.2-proposal"}: raise SystemExit("unexpected task-discovery v0.2 schema version") validators["task-discovery-v0.2"] = Draft202012Validator( proposal_schema, registry=registry, format_checker=FormatChecker() ) proposal = validators["task-discovery-v0.2"] proposal_examples = sorted(EXAMPLES.glob("task-discovery-*-v0.2.json")) proposal_status = EXAMPLES / "task-discovery-http-statuses-v0.2.json" proposal_positive = 0 proposal_negative = 0 for path in proposal_examples: if path == proposal_status: continue sample = load_json(path) if "invalid" in path.name: try: proposal.validate(sample) except ValidationError: proposal_negative += 1 else: raise SystemExit(f"negative v0.2 fixture unexpectedly valid: {path.relative_to(ROOT)}") else: proposal.validate(sample) proposal_positive += 1 proposal_positive += validate_status_fixture( proposal_status, "task-discovery-v0.2", "task-discovery-http-statuses.v0.2", {400: {"invalid_cursor"}, 401: {"unauthorized"}, 403: {"dispatcher_not_authorized"}, 410: {"cursor_expired"}, 503: {"service_unavailable"}}, ) if proposal_positive < 8 or proposal_negative < 2: raise SystemExit("missing v0.2 positive/negative contract cases") for example in ("snapshot", "changes"): payload = load_json(EXAMPLES / f"task-discovery-{example}-v0.2.json") field = "tasks" if example == "snapshot" else "changes" payload[field] = [payload[field][0]] * 256 proposal.validate(payload) payload[field].append(payload[field][0]) try: proposal.validate(payload) except ValidationError: proposal_negative += 1 else: raise SystemExit(f"v0.2 {field} accepted more than 256 items") proposal_doc = ROOT / "docs/thirds/v0.2.md" proposal_versions = {**versions, "task-discovery.v0.2-proposal": "task-discovery-v0.2"} for match in re.finditer(r"```json\s*(.*?)\s*```", proposal_doc.read_text(encoding="utf-8"), re.DOTALL): sample = json.loads(match.group(1)) if isinstance(sample, dict) and sample.get("schema_version") in proposal_versions: validators[proposal_versions[sample["schema_version"]]].validate(sample) proposal_positive += 1 proposal_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.2.json") if proposal_manifest.get("manifest_version") != "local-contract-manifest.v0.2" or proposal_manifest.get("hash_algorithm") != "SHA-256": raise SystemExit("invalid v0.2 manifest version or hash algorithm") proposal_artifacts = { proposal_path.relative_to(ROOT).as_posix(), *(path.relative_to(ROOT).as_posix() for path in proposal_examples), *(SCHEMA_PATHS[name].relative_to(ROOT).as_posix() for name in ("config-read", "command-next", "call-result")), "docs/contracts/mq-topology-v0.1-proposal.json", } source = proposal_manifest.get("source", {}) artifacts = proposal_manifest.get("artifacts", []) if source.get("path") != proposal_doc.relative_to(ROOT).as_posix() or not isinstance(artifacts, list): raise SystemExit("v0.2 manifest source/artifacts mismatch") paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)] if len(paths) != len(artifacts) or len(paths) != len(set(paths)) or set(paths) != proposal_artifacts: raise SystemExit("v0.2 manifest artifact set mismatch") for entry in [source, *artifacts]: relative = Path(entry.get("path", "")) digest = entry.get("sha256", "") if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)): raise SystemExit(f"invalid v0.2 manifest entry: {entry}") if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: raise SystemExit(f"v0.2 manifest SHA-256 mismatch: {relative}") print(f"Task discovery v0.2 proposal: positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}") # The approved Agent→Dispatcher failure fact is Mock-only. Its independent # manifest must not alter the historical SaaS v0.1 or discovery v0.2 baselines. mock_doc = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.md" mock_schema_path = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.schema.json" mock_schema = load_json(mock_schema_path) embedded_mock_schema = ROOT / "contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json" if embedded_mock_schema.read_bytes() != mock_schema_path.read_bytes(): raise SystemExit("embedded Mock failure schema differs from its hashed project source") Draft202012Validator.check_schema(mock_schema) if schema_versions(mock_schema) != {"local-mock-recording-failure.v0.1"}: raise SystemExit("unexpected local Mock recording-failure schema version") mock_validator = Draft202012Validator(mock_schema, registry=registry, format_checker=FormatChecker()) mock_examples = sorted(EXAMPLES.glob("local-mock-recording-failure-*-v0.1.json")) mock_positive = mock_negative = 0 for path in mock_examples: sample = load_json(path) if "invalid" in path.name: try: mock_validator.validate(sample) except ValidationError: mock_negative += 1 else: raise SystemExit(f"invalid Mock failure fixture passed: {path.relative_to(ROOT)}") else: mock_validator.validate(sample) mock_positive += 1 if mock_positive < 2 or mock_negative < 2: raise SystemExit("missing positive/negative Mock recording-failure examples") mock_manifest = load_json(ROOT / "docs/contracts/local-mock-recording-failure-manifest-v0.1.json") if mock_manifest.get("manifest_version") != "local-mock-recording-failure-manifest.v0.1" or mock_manifest.get("hash_algorithm") != "SHA-256": raise SystemExit("invalid Mock failure manifest version or hash algorithm") mock_source = mock_manifest.get("source", {}) mock_artifacts = mock_manifest.get("artifacts", []) mock_paths = [entry.get("path") for entry in mock_artifacts if isinstance(entry, dict)] required_mock_paths = {mock_schema_path.relative_to(ROOT).as_posix(), *(path.relative_to(ROOT).as_posix() for path in mock_examples)} if mock_source.get("path") != mock_doc.relative_to(ROOT).as_posix() or len(mock_paths) != len(mock_artifacts) or len(mock_paths) != len(set(mock_paths)) or set(mock_paths) != required_mock_paths: raise SystemExit("Mock failure manifest source/artifact set mismatch") for entry in [mock_source, *mock_artifacts]: relative = Path(entry.get("path", "")) digest = entry.get("sha256", "") if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)): raise SystemExit(f"invalid Mock failure manifest entry: {entry}") if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: raise SystemExit(f"Mock failure manifest SHA-256 mismatch: {relative}") print(f"Mock recording failure v0.1: positive={mock_positive}, negative={mock_negative}, manifest files={1 + len(mock_artifacts)}")