init
This commit is contained in:
Executable
+276
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate repository contracts and isolated domain deliverables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
from jsonschema import Draft202012Validator, FormatChecker, RefResolver
|
||||
except ImportError as exc: # pragma: no cover - dependency bootstrap path
|
||||
raise SystemExit(
|
||||
f"Missing validation dependency: {exc.name}. Install with `pip install -e '.[test]'`."
|
||||
) from exc
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTRACT_SCHEMA_DIR = ROOT / "contracts" / "schemas"
|
||||
|
||||
KIND_TO_SCHEMA = {
|
||||
"IntentIR": "intent-ir.schema.json",
|
||||
"FunctionConfig": "function-config.schema.json",
|
||||
"FunctionPreset": "function-preset.schema.json",
|
||||
"SceneConfig": "scene-config.schema.json",
|
||||
"CommandPlan": "command-plan.schema.json",
|
||||
"TraceEvent": "trace-event.schema.json",
|
||||
"ExecutionReport": "execution-report.schema.json",
|
||||
"DomainManifest": "domain-manifest.schema.json",
|
||||
"CapabilityCatalog": "capability-catalog.schema.json",
|
||||
"SignalMapping": "signal-mapping.schema.json",
|
||||
"PlatformManifest": "platform-manifest.schema.json",
|
||||
}
|
||||
|
||||
|
||||
def relative(path: Path) -> str:
|
||||
return str(path.relative_to(ROOT))
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("root must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
value = yaml.safe_load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("root must be a mapping")
|
||||
return value
|
||||
|
||||
|
||||
def collect_schemas(
|
||||
errors: list[str],
|
||||
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
schemas: dict[str, dict[str, Any]] = {}
|
||||
schema_store: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for path in sorted(CONTRACT_SCHEMA_DIR.glob("*.json")):
|
||||
try:
|
||||
schema = load_json(path)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
schema_id = schema.get("$id")
|
||||
if not isinstance(schema_id, str):
|
||||
errors.append(f"{relative(path)}: missing string $id")
|
||||
continue
|
||||
schemas[path.name] = schema
|
||||
schema_store[schema_id] = schema
|
||||
except Exception as exc: # noqa: BLE001 - report every repository error together
|
||||
errors.append(f"{relative(path)}: invalid JSON Schema: {exc}")
|
||||
|
||||
return schemas, schema_store
|
||||
|
||||
|
||||
def public_documents() -> list[Path]:
|
||||
paths = list((ROOT / "contracts" / "examples").glob("*.yaml"))
|
||||
paths.extend((ROOT / "platforms").glob("*/manifest.yaml"))
|
||||
for domain_dir in sorted((ROOT / "domains").iterdir()):
|
||||
if not domain_dir.is_dir():
|
||||
continue
|
||||
paths.extend(
|
||||
path
|
||||
for path in [
|
||||
domain_dir / "manifest.yaml",
|
||||
domain_dir / "config" / "capabilities.yaml",
|
||||
domain_dir / "can" / "signal-mapping.yaml",
|
||||
]
|
||||
if path.exists()
|
||||
)
|
||||
paths.extend((domain_dir / "config" / "presets").glob("*.yaml"))
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def validate_documents(
|
||||
schemas: dict[str, dict[str, Any]],
|
||||
schema_store: dict[str, dict[str, Any]],
|
||||
errors: list[str],
|
||||
) -> int:
|
||||
count = 0
|
||||
checker = FormatChecker()
|
||||
|
||||
for path in public_documents():
|
||||
count += 1
|
||||
try:
|
||||
document = load_yaml(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"{relative(path)}: invalid YAML: {exc}")
|
||||
continue
|
||||
|
||||
kind = document.get("kind")
|
||||
schema_name = KIND_TO_SCHEMA.get(kind)
|
||||
if schema_name is None:
|
||||
errors.append(f"{relative(path)}: unsupported or missing kind {kind!r}")
|
||||
continue
|
||||
schema = schemas.get(schema_name)
|
||||
if schema is None:
|
||||
errors.append(f"{relative(path)}: required schema {schema_name} was not loaded")
|
||||
continue
|
||||
|
||||
resolver = RefResolver.from_schema(schema, store=schema_store)
|
||||
validator = Draft202012Validator(
|
||||
schema, resolver=resolver, format_checker=checker
|
||||
)
|
||||
for issue in sorted(validator.iter_errors(document), key=lambda item: list(item.path)):
|
||||
location = ".".join(str(part) for part in issue.absolute_path) or "<root>"
|
||||
errors.append(f"{relative(path)}:{location}: {issue.message}")
|
||||
|
||||
if kind in {"FunctionConfig", "FunctionPreset"}:
|
||||
domain = document.get("spec", {}).get("domain")
|
||||
domain_schema_path = ROOT / "domains" / str(domain) / "schemas" / "function-parameters.schema.json"
|
||||
if domain_schema_path.exists():
|
||||
try:
|
||||
domain_schema = load_json(domain_schema_path)
|
||||
Draft202012Validator.check_schema(domain_schema)
|
||||
domain_validator = Draft202012Validator(domain_schema, format_checker=checker)
|
||||
for issue in sorted(
|
||||
domain_validator.iter_errors(document), key=lambda item: list(item.path)
|
||||
):
|
||||
location = ".".join(str(part) for part in issue.absolute_path) or "<root>"
|
||||
errors.append(
|
||||
f"{relative(path)}:{location}: domain validation: {issue.message}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"{relative(domain_schema_path)}: invalid domain schema: {exc}")
|
||||
return count
|
||||
|
||||
|
||||
def validate_domains(errors: list[str]) -> int:
|
||||
count = 0
|
||||
for domain_dir in sorted((ROOT / "domains").iterdir()):
|
||||
if not domain_dir.is_dir() or domain_dir.name.startswith("_"):
|
||||
continue
|
||||
count += 1
|
||||
domain = domain_dir.name
|
||||
required_paths = [
|
||||
domain_dir / "README.md",
|
||||
domain_dir / "OWNERS.yaml",
|
||||
domain_dir / "manifest.yaml",
|
||||
domain_dir / "config" / "capabilities.yaml",
|
||||
domain_dir / "schemas" / "function-parameters.schema.json",
|
||||
domain_dir / "can" / "signal-mapping.yaml",
|
||||
domain_dir / "tests" / "unit",
|
||||
domain_dir / "tests" / "contract",
|
||||
domain_dir / "tests" / "integration",
|
||||
]
|
||||
for path in required_paths:
|
||||
if not path.exists():
|
||||
errors.append(f"domains/{domain}: missing {relative(path)}")
|
||||
|
||||
try:
|
||||
manifest = load_yaml(domain_dir / "manifest.yaml")
|
||||
catalog = load_yaml(domain_dir / "config" / "capabilities.yaml")
|
||||
mapping = load_yaml(domain_dir / "can" / "signal-mapping.yaml")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"domains/{domain}: cannot perform semantic checks: {exc}")
|
||||
continue
|
||||
|
||||
for label, document in [("manifest", manifest), ("catalog", catalog), ("mapping", mapping)]:
|
||||
actual_domain = document.get("spec", {}).get("domain")
|
||||
if actual_domain != domain:
|
||||
errors.append(
|
||||
f"domains/{domain}: {label} spec.domain is {actual_domain!r}, expected {domain!r}"
|
||||
)
|
||||
|
||||
manifest_spec = manifest.get("spec", {})
|
||||
for field in ["function_schema", "capabilities", "signal_mapping"]:
|
||||
value = manifest_spec.get(field)
|
||||
if isinstance(value, str) and not (domain_dir / value).resolve().is_file():
|
||||
errors.append(f"domains/{domain}: manifest {field} does not exist: {value}")
|
||||
|
||||
capabilities = catalog.get("spec", {}).get("capabilities", [])
|
||||
capability_names = [item.get("name") for item in capabilities if isinstance(item, dict)]
|
||||
if len(capability_names) != len(set(capability_names)):
|
||||
errors.append(f"domains/{domain}: duplicate capability names")
|
||||
for name in capability_names:
|
||||
if not isinstance(name, str) or not name.startswith(f"{domain}."):
|
||||
errors.append(f"domains/{domain}: capability must start with '{domain}.': {name!r}")
|
||||
|
||||
for preset_path in sorted((domain_dir / "config" / "presets").glob("*.yaml")):
|
||||
preset = load_yaml(preset_path)
|
||||
if preset.get("spec", {}).get("domain") != domain:
|
||||
errors.append(f"{relative(preset_path)}: preset domain must be {domain}")
|
||||
for operation in preset.get("spec", {}).get("operations", []):
|
||||
capability = operation.get("capability")
|
||||
if capability not in capability_names:
|
||||
errors.append(
|
||||
f"{relative(preset_path)}: unknown capability {capability!r}"
|
||||
)
|
||||
|
||||
mapping_spec = mapping.get("spec", {})
|
||||
for item in mapping_spec.get("mappings", []):
|
||||
capability = item.get("capability")
|
||||
if capability not in capability_names:
|
||||
errors.append(
|
||||
f"domains/{domain}/can/signal-mapping.yaml: unknown capability {capability!r}"
|
||||
)
|
||||
|
||||
for platform in manifest_spec.get("supported_platforms", []):
|
||||
if not (ROOT / "platforms" / platform / "manifest.yaml").is_file():
|
||||
errors.append(f"domains/{domain}: unsupported platform reference {platform!r}")
|
||||
return count
|
||||
|
||||
|
||||
def validate_platform_resources(errors: list[str], warnings: list[str]) -> int:
|
||||
count = 0
|
||||
for manifest_path in sorted((ROOT / "platforms").glob("*/manifest.yaml")):
|
||||
count += 1
|
||||
manifest = load_yaml(manifest_path)
|
||||
spec = manifest.get("spec", {})
|
||||
platform_dir = manifest_path.parent
|
||||
execution_enabled = spec.get("execution_enabled") is True
|
||||
|
||||
refs = list(spec.get("dbc_files", [])) + list(spec.get("calibrations", {}).values())
|
||||
for ref in refs:
|
||||
target = platform_dir / str(ref)
|
||||
if target.is_file():
|
||||
continue
|
||||
message = f"{relative(manifest_path)}: referenced platform resource is missing: {ref}"
|
||||
if execution_enabled:
|
||||
errors.append(message)
|
||||
else:
|
||||
warnings.append(message + " (allowed because execution_enabled is false)")
|
||||
return count
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
schemas, schema_store = collect_schemas(errors)
|
||||
document_count = validate_documents(schemas, schema_store, errors)
|
||||
domain_count = validate_domains(errors)
|
||||
platform_count = validate_platform_resources(errors, warnings)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"WARNING: {warning}")
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
print(f"Validation failed with {len(errors)} error(s).", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"Repository validation passed: "
|
||||
f"{len(schemas)} schemas, {document_count} documents, "
|
||||
f"{domain_count} domains, {platform_count} platform(s)."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user