Files
reducers/backend/knowledge/tools/validate_knowledge.py
T

290 lines
12 KiB
Python

#!/usr/bin/env python3
"""Read-only consistency validator for the detached knowledge base."""
from __future__ import annotations
import ast
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
KNOWLEDGE_ROOT = Path(__file__).resolve().parents[1]
BACKEND_ROOT = KNOWLEDGE_ROOT.parent
ENTRY_DIRS = {
"component": KNOWLEDGE_ROOT / "components",
"relation": KNOWLEDGE_ROOT / "relations",
"mechanism": KNOWLEDGE_ROOT / "mechanisms",
"composition": KNOWLEDGE_ROOT / "compositions",
"evidence": KNOWLEDGE_ROOT / "evidence",
}
REQUIRED_FIELDS = {
"component": {"schema_version", "id", "version", "kind", "parameters", "ports", "capability_refs", "maturity"},
"relation": {"schema_version", "id", "version", "relation_type", "endpoint_rules", "constraint_rules", "capability_refs", "maturity"},
"mechanism": {"schema_version", "id", "version", "legacy_family", "members", "relations", "external_ports", "boundary_profiles", "constraint_rules", "capability_refs", "maturity"},
"composition": {"schema_version", "id", "version", "composition_type", "source_port_types", "target_port_types", "connection_relation", "constraint_rules", "capability_refs", "execution_support", "maturity"},
"capability": {"schema_version", "id", "version", "kind", "implementation", "supports", "maturity"},
"evidence": {"schema_version", "id", "version", "evidence_type", "subject_refs", "artifacts", "maturity"},
}
def load_json(path: Path, errors: list[str]) -> Any | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
errors.append(f"invalid_json:{path.relative_to(BACKEND_ROOT)}:{exc}")
return None
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def collect_entries(errors: list[str]) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
entries: dict[str, dict[str, Any]] = {}
kinds: dict[str, str] = {}
for kind, root in ENTRY_DIRS.items():
for path in sorted(root.rglob("*.json")):
data = load_json(path, errors)
if not isinstance(data, dict):
continue
entry_id = data.get("id")
if not isinstance(entry_id, str):
errors.append(f"missing_id:{path.relative_to(BACKEND_ROOT)}")
continue
if entry_id in entries:
errors.append(f"duplicate_id:{entry_id}")
continue
entries[entry_id] = data
kinds[entry_id] = kind
capability_registry_path = KNOWLEDGE_ROOT / "capabilities" / "registry.json"
registry = load_json(capability_registry_path, errors)
if isinstance(registry, dict):
for data in registry.get("entries", []):
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
errors.append("invalid_capability_registry_entry")
continue
entry_id = data["id"]
if entry_id in entries:
errors.append(f"duplicate_id:{entry_id}")
continue
entries[entry_id] = data
kinds[entry_id] = "capability"
return entries, kinds
def validate_required_fields(
entries: dict[str, dict[str, Any]],
kinds: dict[str, str],
errors: list[str],
) -> None:
for entry_id, entry in entries.items():
kind = kinds[entry_id]
missing = sorted(REQUIRED_FIELDS[kind] - set(entry))
if missing:
errors.append(f"missing_fields:{entry_id}:{','.join(missing)}")
if entry.get("schema_version") != "1.0":
errors.append(f"unsupported_schema_version:{entry_id}:{entry.get('schema_version')}")
expected_prefix = f"{kind}."
if not entry_id.startswith(expected_prefix):
errors.append(f"bad_id_prefix:{entry_id}:expected={expected_prefix}")
def referenced_ids(entry: dict[str, Any], kind: str) -> list[str]:
refs: list[str] = []
refs.extend(entry.get("capability_refs", []))
if kind == "mechanism":
for member in entry.get("members", []):
refs.extend(
value
for key in ("component_ref", "mechanism_ref")
if isinstance((value := member.get(key)), str)
)
for relation in entry.get("relations", []):
refs.extend(
value
for key in ("relation_ref", "composition_ref")
if isinstance((value := relation.get(key)), str)
)
elif kind == "composition":
relation_ref = entry.get("connection_relation")
if isinstance(relation_ref, str):
refs.append(relation_ref)
elif kind == "evidence":
refs.extend(entry.get("subject_refs", []))
return refs
def validate_references(
entries: dict[str, dict[str, Any]],
kinds: dict[str, str],
port_ids: set[str],
errors: list[str],
) -> None:
for entry_id, entry in entries.items():
for reference in referenced_ids(entry, kinds[entry_id]):
if reference not in entries:
errors.append(f"unknown_reference:{entry_id}:{reference}")
for port in entry.get("ports", []):
port_type = port.get("type")
if port_type not in port_ids:
errors.append(f"unknown_port_type:{entry_id}:{port_type}")
for port in entry.get("external_ports", []):
port_type = port.get("type")
if port_type not in port_ids:
errors.append(f"unknown_port_type:{entry_id}:{port_type}")
for key in ("source_port_types", "target_port_types"):
for port_type in entry.get(key, []):
if port_type not in port_ids:
errors.append(f"unknown_port_type:{entry_id}:{port_type}")
def validate_mechanism_graphs(entries: dict[str, dict[str, Any]], errors: list[str]) -> None:
for entry_id, mechanism in entries.items():
if not entry_id.startswith("mechanism."):
continue
member_ids = {member.get("id") for member in mechanism.get("members", [])}
if None in member_ids or len(member_ids) != len(mechanism.get("members", [])):
errors.append(f"invalid_or_duplicate_member:{entry_id}")
relation_ids: set[str] = set()
for relation in mechanism.get("relations", []):
relation_id = relation.get("id")
if relation_id in relation_ids or not relation_id:
errors.append(f"invalid_or_duplicate_relation:{entry_id}:{relation_id}")
relation_ids.add(relation_id)
for endpoint in ("source", "target"):
if relation.get(endpoint) not in member_ids:
errors.append(
f"unknown_relation_endpoint:{entry_id}:{relation_id}:{endpoint}={relation.get(endpoint)}"
)
carrier = relation.get("carrier_ref")
if carrier is not None and carrier not in member_ids:
errors.append(f"unknown_carrier_ref:{entry_id}:{relation_id}:{carrier}")
for port in mechanism.get("external_ports", []):
if port.get("member") not in member_ids:
errors.append(f"unknown_external_port_member:{entry_id}:{port.get('id')}")
for profile in mechanism.get("boundary_profiles", []):
boundary_members = [profile.get("input"), profile.get("output"), *profile.get("fixed", [])]
for member in boundary_members:
root_member = member.split(".", 1)[0] if isinstance(member, str) else member
if root_member not in member_ids:
errors.append(f"unknown_boundary_member:{entry_id}:{profile.get('id')}:{member}")
def validate_capability_sources(entries: dict[str, dict[str, Any]], errors: list[str]) -> None:
for entry_id, entry in entries.items():
if not entry_id.startswith("capability."):
continue
implementation = entry.get("implementation", {})
source_path = implementation.get("source_path")
if not isinstance(source_path, str):
errors.append(f"missing_capability_source:{entry_id}")
continue
source = BACKEND_ROOT / source_path
if not source.is_file():
errors.append(f"missing_capability_source:{entry_id}:{source_path}")
continue
symbol = implementation.get("symbol")
if symbol:
try:
tree = ast.parse(source.read_text(encoding="utf-8"))
except SyntaxError as exc:
errors.append(f"invalid_capability_source_python:{entry_id}:{exc}")
continue
top_level_symbols = {
node.name
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
}
if symbol not in top_level_symbols:
errors.append(f"missing_capability_symbol:{entry_id}:{source_path}:{symbol}")
def validate_topology_hashes(entries: dict[str, dict[str, Any]], errors: list[str]) -> None:
for entry_id, entry in entries.items():
source_topology = entry.get("source_topology")
if not source_topology:
continue
source = BACKEND_ROOT / source_topology["path"]
if not source.is_file():
errors.append(f"missing_source_topology:{entry_id}:{source_topology['path']}")
continue
actual = sha256_file(source)
if actual != source_topology.get("sha256"):
errors.append(
f"source_topology_hash_mismatch:{entry_id}:expected={source_topology.get('sha256')}:actual={actual}"
)
def validate_detached_boundary(errors: list[str]) -> None:
for path in sorted((BACKEND_ROOT / "src").rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError:
continue
for node in ast.walk(tree):
module: str | None = None
if isinstance(node, ast.ImportFrom):
module = node.module
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "knowledge" or alias.name.startswith("knowledge."):
errors.append(f"stable_kernel_imports_knowledge:{path.relative_to(BACKEND_ROOT)}:{alias.name}")
if module == "knowledge" or (module and module.startswith("knowledge.")):
errors.append(f"stable_kernel_imports_knowledge:{path.relative_to(BACKEND_ROOT)}:{module}")
def main() -> int:
errors: list[str] = []
for path in sorted(KNOWLEDGE_ROOT.rglob("*.json")):
load_json(path, errors)
catalog = load_json(KNOWLEDGE_ROOT / "catalog.json", errors)
if isinstance(catalog, dict):
if catalog.get("default_enabled") is not False:
errors.append("catalog_default_enabled_must_be_false")
if catalog.get("integration_state") != "not_connected":
errors.append("catalog_integration_state_must_be_not_connected")
entries, kinds = collect_entries(errors)
validate_required_fields(entries, kinds, errors)
ports = load_json(KNOWLEDGE_ROOT / "ontology" / "port_types.json", errors)
port_ids = {
entry["id"]
for entry in (ports or {}).get("entries", [])
if isinstance(entry, dict) and isinstance(entry.get("id"), str)
}
validate_references(entries, kinds, port_ids, errors)
validate_mechanism_graphs(entries, errors)
validate_capability_sources(entries, errors)
validate_topology_hashes(entries, errors)
validate_detached_boundary(errors)
if errors:
print(f"knowledge validation failed: {len(errors)} error(s)", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
counts: dict[str, int] = {}
for kind in kinds.values():
counts[kind] = counts.get(kind, 0) + 1
count_text = ", ".join(f"{kind}={counts[kind]}" for kind in sorted(counts))
print(f"knowledge validation passed: {len(entries)} entries ({count_text})")
print("integration_state=not_connected default_enabled=false stable_kernel_imports=0")
return 0
if __name__ == "__main__":
raise SystemExit(main())