290 lines
12 KiB
Python
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())
|