#!/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 (historical): positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}") # v0.3 replaces the v0.2 runtime path; v0.2 files above remain historical evidence. event_doc = ROOT / "docs/thirds/v0.3.md" event_schema_path = ROOT / "docs/contracts/task-discovery-v0.3-proposal.schema.json" event_schema = load_json(event_schema_path) event_embedded = ROOT / "contracts/local/v0.3/task-discovery-v0.3-proposal.schema.json" if event_embedded.read_bytes() != event_schema_path.read_bytes(): raise SystemExit("embedded v0.3 discovery schema differs from its project source") Draft202012Validator.check_schema(event_schema) if schema_versions(event_schema) != {"task-discovery.v0.3-proposal"}: raise SystemExit("unexpected v0.3 discovery schema version") event_validator = Draft202012Validator(event_schema, registry=registry, format_checker=FormatChecker()) event_examples = sorted(EXAMPLES.glob("task-discovery-*-v0.3.json")) event_status_path = EXAMPLES / "task-discovery-http-statuses-v0.3.json" event_positive = event_negative = 0 for path in event_examples: if path == event_status_path: continue sample = load_json(path) if "invalid" in path.name: try: event_validator.validate(sample) except ValidationError: event_negative += 1 else: raise SystemExit(f"invalid v0.3 discovery fixture passed: {path.relative_to(ROOT)}") else: event_validator.validate(sample) if int(sample["next_cursor"]) > 18446744073709551615: raise SystemExit(f"v0.3 event cursor exceeds uint64: {path.relative_to(ROOT)}") event_positive += 1 if sample["tasks"]: too_many = {**sample, "tasks": [sample["tasks"][0]] * 257} try: event_validator.validate(too_many) except ValidationError: pass else: raise SystemExit("v0.3 discovery page accepted over 256 rows") event_statuses = load_json(event_status_path) if event_statuses.get("fixture_version") != "task-discovery-http-statuses.v0.3": raise SystemExit("invalid v0.3 HTTP status fixture version") expected_event_errors = {400: "invalid_cursor", 401: "unauthorized", 403: "dispatcher_not_authorized", 503: "service_unavailable"} seen_event_errors = set() for entry in event_statuses.get("responses", []): status, body = entry.get("status"), entry.get("body") if status not in expected_event_errors or status in seen_event_errors: raise SystemExit(f"unexpected or duplicate v0.3 HTTP status: {status}") event_validator.validate(body) if body.get("error", {}).get("code") != expected_event_errors[status]: raise SystemExit(f"incorrect v0.3 error for HTTP {status}") seen_event_errors.add(status) if seen_event_errors != set(expected_event_errors) or event_positive < 4 or event_negative < 2: raise SystemExit("missing v0.3 discovery positives, negatives or HTTP statuses") event_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.3.json") if event_manifest.get("manifest_version") != "local-contract-manifest.v0.3" or event_manifest.get("hash_algorithm") != "SHA-256": raise SystemExit("invalid v0.3 manifest version or hash algorithm") event_source = event_manifest.get("source", {}) event_artifacts = event_manifest.get("artifacts", []) required_event_paths = { event_schema_path.relative_to(ROOT).as_posix(), *(path.relative_to(ROOT).as_posix() for path in event_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", } event_paths = [entry.get("path") for entry in event_artifacts if isinstance(entry, dict)] if (event_source.get("path") != event_doc.relative_to(ROOT).as_posix() or not isinstance(event_artifacts, list) or len(event_paths) != len(event_artifacts) or len(event_paths) != len(set(event_paths)) or set(event_paths) != required_event_paths): raise SystemExit("v0.3 manifest source/artifacts mismatch") for entry in [event_source, *event_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.3 manifest entry: {entry}") if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: raise SystemExit(f"v0.3 manifest SHA-256 mismatch: {relative}") print(f"Task discovery v0.3 proposal: positive={event_positive + len(seen_event_errors)}, negative={event_negative}, manifest files={1 + len(event_artifacts)}") # v0.4 is a new project-local contract, not a mutation of v0.3 or upstream v1. v4_doc = ROOT / "docs/thirds/v0.4.md" v4_schema_paths = { name: ROOT / f"docs/contracts/{name}-v0.4-proposal.schema.json" for name in ("task-discovery", "task-control", "call-execute") } v4_registry = registry v4_examples = {name: sorted(EXAMPLES.glob(f"{name}-*-v0.4.json")) for name in v4_schema_paths} v4_positive = v4_negative = 0 for name, schema_path in v4_schema_paths.items(): schema = load_json(schema_path) Draft202012Validator.check_schema(schema) if schema_versions(schema) != {f"{name}.v0.4-proposal"}: raise SystemExit(f"unexpected v0.4 schema version: {schema_path}") embedded = ROOT / "contracts/local/v0.4" / schema_path.name if embedded.read_bytes() != schema_path.read_bytes(): raise SystemExit(f"embedded v0.4 schema differs from project source: {schema_path.name}") validator = Draft202012Validator(schema, registry=v4_registry, format_checker=FormatChecker()) positive = negative = 0 for path in v4_examples[name]: sample = load_json(path) if "invalid" in path.name: try: validator.validate(sample) except ValidationError: negative += 1 else: raise SystemExit(f"invalid v0.4 fixture passed: {path.relative_to(ROOT)}") else: validator.validate(sample) positive += 1 if positive < 3 or negative < 1: raise SystemExit(f"missing v0.4 positive/negative fixtures for {schema_path.name}") v4_positive += positive v4_negative += negative v4_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.4.json") if v4_manifest.get("manifest_version") != "local-contract-manifest.v0.4" or v4_manifest.get("hash_algorithm") != "SHA-256": raise SystemExit("invalid v0.4 manifest version or hash algorithm") v4_source = v4_manifest.get("source", {}) v4_artifacts = v4_manifest.get("artifacts", []) required_v4 = { *(p.relative_to(ROOT).as_posix() for p in v4_schema_paths.values()), *(p.relative_to(ROOT).as_posix() for examples in v4_examples.values() for p in examples), "contracts/upstream/v1/mq.schema.json", } v4_paths = [entry.get("path") for entry in v4_artifacts if isinstance(entry, dict)] if v4_source.get("path") != v4_doc.relative_to(ROOT).as_posix() or len(v4_paths) != len(v4_artifacts) or len(v4_paths) != len(set(v4_paths)) or set(v4_paths) != required_v4: raise SystemExit("v0.4 manifest source/artifacts mismatch") for entry in [v4_source, *v4_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.4 manifest entry: {entry}") if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: raise SystemExit(f"v0.4 manifest SHA-256 mismatch: {relative}") print(f"Task discovery/control/execute v0.4 proposal: positive={v4_positive}, negative={v4_negative}, manifest files={1 + len(v4_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)}")