354 lines
17 KiB
Python
354 lines
17 KiB
Python
#!/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)}")
|