generated from coulomb/repo-seed
feat: validate financial fabric graph exports
This commit is contained in:
@@ -91,24 +91,34 @@ NODE_KIND_CANON_MAP: dict[str, CanonNodeMapping] = {
|
||||
"DomainName": CanonNodeMapping("endpoint", "model/network", "partial"),
|
||||
"ExternalLibrary": CanonNodeMapping("software-system", "model/landscape", "partial"),
|
||||
"FabricRegistryEntry": CanonNodeMapping("source-repository", "model/devsecops", "partial"),
|
||||
"Fabric": CanonNodeMapping("control", "model/governance", "gap"),
|
||||
"FabricActor": CanonNodeMapping("control", "model/governance", "gap"),
|
||||
"InterfaceDeclaration": CanonNodeMapping("endpoint", "model/network", "partial"),
|
||||
"Library": CanonNodeMapping("software-system", "model/landscape", "partial"),
|
||||
"Lockfile": CanonNodeMapping("evidence", "model/observability", "partial"),
|
||||
"Netkingdom": CanonNodeMapping("software-system", "model/landscape", "gap"),
|
||||
"NetworkPort": CanonNodeMapping("endpoint", "model/network", "direct"),
|
||||
"ProfitCenter": CanonNodeMapping("control", "model/governance", "gap"),
|
||||
"Repository": CanonNodeMapping("source-repository", "model/devsecops", "direct"),
|
||||
"RuntimeService": CanonNodeMapping("runtime-resource", "model/landscape", "direct"),
|
||||
"ScoreWorkload": CanonNodeMapping("deployment", "model/devsecops", "direct"),
|
||||
"Server": CanonNodeMapping("runtime-resource", "model/landscape", "partial"),
|
||||
"ServiceConfig": CanonNodeMapping("evidence", "model/observability", "partial"),
|
||||
"ServiceDeclaration": CanonNodeMapping("service", "model/landscape", "direct"),
|
||||
"Subfabric": CanonNodeMapping("control", "model/governance", "gap"),
|
||||
"UtilityInterface": CanonNodeMapping("endpoint", "model/network", "partial"),
|
||||
"CostCenter": CanonNodeMapping("control", "model/governance", "gap"),
|
||||
}
|
||||
|
||||
EDGE_TYPE_CANON_MAP: dict[str, CanonEdgeMapping] = {
|
||||
"available_via": CanonEdgeMapping("exposes", "model/network", "partial"),
|
||||
"attributed_to_cost_center": CanonEdgeMapping("governed_by", "model/governance", "gap"),
|
||||
"attributed_to_profit_center": CanonEdgeMapping("governed_by", "model/governance", "gap"),
|
||||
"binds": CanonEdgeMapping("depends_on", "model/landscape", "partial"),
|
||||
"builds_container": CanonEdgeMapping("built_from", "model/devsecops", "partial"),
|
||||
"cataloged_as": CanonEdgeMapping("evidenced_by", "model/observability", "partial"),
|
||||
"consumes": CanonEdgeMapping("depends_on", "model/landscape", "partial"),
|
||||
"contains": CanonEdgeMapping("part_of", "model/landscape", "partial"),
|
||||
"declares": CanonEdgeMapping("part_of", "model/devsecops", "partial", display_only=True),
|
||||
"declares_package": CanonEdgeMapping("built_from", "model/devsecops", "partial"),
|
||||
"defines_deployment": CanonEdgeMapping("built_from", "model/devsecops", "partial"),
|
||||
@@ -122,8 +132,11 @@ EDGE_TYPE_CANON_MAP: dict[str, CanonEdgeMapping] = {
|
||||
"listens_on": CanonEdgeMapping("exposes", "model/network", "direct"),
|
||||
"names_endpoint": CanonEdgeMapping("exposes", "model/network", "partial"),
|
||||
"opens_port": CanonEdgeMapping("exposes", "model/network", "partial"),
|
||||
"operated_by": CanonEdgeMapping("governed_by", "model/governance", "partial"),
|
||||
"owned_by": CanonEdgeMapping("governed_by", "model/governance", "partial"),
|
||||
"owns_deployment": CanonEdgeMapping("part_of", "model/devsecops", "partial", display_only=True),
|
||||
"provides": CanonEdgeMapping("implements", "model/landscape", "partial"),
|
||||
"provides_utility_to": CanonEdgeMapping("depends_on", "model/landscape", "partial"),
|
||||
"resolves_to": CanonEdgeMapping("flows_to", "model/network", "partial"),
|
||||
"routes_to_port": CanonEdgeMapping("flows_to", "model/network", "partial"),
|
||||
"routes_to_service": CanonEdgeMapping("flows_to", "model/network", "partial"),
|
||||
|
||||
400
railiance_fabric/financial.py
Normal file
400
railiance_fabric/financial.py
Normal file
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
FINANCIAL_API_VERSION = "railiance.fabric/v1alpha2"
|
||||
FINANCIAL_SCHEMA_VERSION = "financial-fabric-v1"
|
||||
|
||||
ACTOR_ROLES = {"king", "lord", "tenant", "operator", "steward"}
|
||||
OWNERSHIP_RESOLUTIONS = {"explicit", "inherited", "unresolved", "ambiguous"}
|
||||
EVIDENCE_STATES = {"observed", "declared", "inferred", "proposed", "gap"}
|
||||
REVIEW_STATES = {"accepted", "candidate", "needs_review", "rejected"}
|
||||
RELATIONSHIP_CATEGORIES = {
|
||||
"containment",
|
||||
"ownership",
|
||||
"technical",
|
||||
"utility",
|
||||
"accounting",
|
||||
"evidence",
|
||||
}
|
||||
FABRIC_KINDS = {"Fabric", "Subfabric"}
|
||||
ENVIRONMENT_LABELS = {"local", "dev", "staging", "prod", "lab", "all"}
|
||||
|
||||
|
||||
def is_financial_graph_export(graph: dict[str, Any]) -> bool:
|
||||
return (
|
||||
graph.get("apiVersion") == FINANCIAL_API_VERSION
|
||||
or graph.get("schema_version") == FINANCIAL_SCHEMA_VERSION
|
||||
)
|
||||
|
||||
|
||||
def materialize_financial_graph_export(graph: dict[str, Any]) -> dict[str, Any]:
|
||||
result = json.loads(json.dumps(graph))
|
||||
if not is_financial_graph_export(result):
|
||||
return result
|
||||
|
||||
result["apiVersion"] = FINANCIAL_API_VERSION
|
||||
result.setdefault("kind", "FabricGraphExport")
|
||||
result.setdefault("schema_version", FINANCIAL_SCHEMA_VERSION)
|
||||
result.setdefault("compatibility", {})
|
||||
result.setdefault("actors", [])
|
||||
result.setdefault("fabrics", [])
|
||||
result.setdefault("nodes", [])
|
||||
result.setdefault("edges", [])
|
||||
result.setdefault("unresolved", [])
|
||||
|
||||
for node in _items(result, "nodes"):
|
||||
_materialize_node(node)
|
||||
for edge in _items(result, "edges"):
|
||||
_materialize_edge(edge)
|
||||
return result
|
||||
|
||||
|
||||
def financial_graph_errors(graph: dict[str, Any]) -> list[str]:
|
||||
if not is_financial_graph_export(graph):
|
||||
return []
|
||||
|
||||
errors: list[str] = []
|
||||
if graph.get("apiVersion") != FINANCIAL_API_VERSION:
|
||||
errors.append(f"apiVersion must be {FINANCIAL_API_VERSION!r}")
|
||||
if graph.get("kind") != "FabricGraphExport":
|
||||
errors.append("kind must be 'FabricGraphExport'")
|
||||
if graph.get("schema_version") != FINANCIAL_SCHEMA_VERSION:
|
||||
errors.append(f"schema_version must be {FINANCIAL_SCHEMA_VERSION!r}")
|
||||
|
||||
netkingdom = graph.get("netkingdom")
|
||||
if not isinstance(netkingdom, dict):
|
||||
errors.append("netkingdom must be an object")
|
||||
netkingdom = {}
|
||||
netkingdom_id = _required_text(errors, "netkingdom.id", netkingdom, "id")
|
||||
king_actor_id = _required_text(errors, "netkingdom.king_actor_id", netkingdom, "king_actor_id")
|
||||
|
||||
actors = _indexed_items(errors, graph, "actors")
|
||||
actor_roles: dict[str, str] = {}
|
||||
for index, actor in actors:
|
||||
actor_id = _required_text(errors, f"actors[{index}].id", actor, "id")
|
||||
role = _required_text(errors, f"actors[{index}].role", actor, "role")
|
||||
if role and role not in ACTOR_ROLES:
|
||||
errors.append(f"actors[{index}].role {role!r} is not valid")
|
||||
if actor_id:
|
||||
if actor_id in actor_roles:
|
||||
errors.append(f"actors[{index}].id {actor_id!r} is duplicated")
|
||||
actor_roles[actor_id] = role
|
||||
if king_actor_id and actor_roles.get(king_actor_id) != "king":
|
||||
errors.append("netkingdom.king_actor_id must reference an actor with role 'king'")
|
||||
|
||||
fabrics = _indexed_items(errors, graph, "fabrics")
|
||||
fabric_kinds: dict[str, str] = {}
|
||||
for index, fabric in fabrics:
|
||||
fabric_id = _required_text(errors, f"fabrics[{index}].id", fabric, "id")
|
||||
kind = _required_text(errors, f"fabrics[{index}].kind", fabric, "kind")
|
||||
if kind and kind not in FABRIC_KINDS:
|
||||
errors.append(f"fabrics[{index}].kind {kind!r} is not valid")
|
||||
if fabric_id:
|
||||
if fabric_id in ENVIRONMENT_LABELS:
|
||||
errors.append(f"fabrics[{index}].id must not be an environment label")
|
||||
if fabric_id in fabric_kinds:
|
||||
errors.append(f"fabrics[{index}].id {fabric_id!r} is duplicated")
|
||||
fabric_kinds[fabric_id] = kind
|
||||
if _text(fabric.get("netkingdom_id")) != netkingdom_id:
|
||||
errors.append(f"fabrics[{index}].netkingdom_id must match netkingdom.id")
|
||||
if kind == "Fabric":
|
||||
lord_actor_id = _required_text(errors, f"fabrics[{index}].lord_actor_id", fabric, "lord_actor_id")
|
||||
if lord_actor_id and actor_roles.get(lord_actor_id) not in {"lord", "king"}:
|
||||
errors.append(f"fabrics[{index}].lord_actor_id must reference a lord or king actor")
|
||||
if kind == "Subfabric":
|
||||
parent_id = _required_text(errors, f"fabrics[{index}].parent_fabric_id", fabric, "parent_fabric_id")
|
||||
tenant_actor_id = _required_text(errors, f"fabrics[{index}].tenant_actor_id", fabric, "tenant_actor_id")
|
||||
if parent_id and parent_id not in fabric_kinds:
|
||||
errors.append(f"fabrics[{index}].parent_fabric_id references unknown fabric {parent_id!r}")
|
||||
if tenant_actor_id and actor_roles.get(tenant_actor_id) != "tenant":
|
||||
errors.append(f"fabrics[{index}].tenant_actor_id must reference a tenant actor")
|
||||
|
||||
node_ids = set()
|
||||
nodes = _indexed_items(errors, graph, "nodes")
|
||||
for index, node in nodes:
|
||||
node_id = _required_text(errors, f"nodes[{index}].id", node, "id")
|
||||
_required_text(errors, f"nodes[{index}].kind", node, "kind")
|
||||
_required_text(errors, f"nodes[{index}].name", node, "name")
|
||||
if node_id:
|
||||
if node_id in node_ids:
|
||||
errors.append(f"nodes[{index}].id {node_id!r} is duplicated")
|
||||
node_ids.add(node_id)
|
||||
review_state = _validate_evidence(errors, f"nodes[{index}]", node)
|
||||
_validate_containment(errors, f"nodes[{index}]", node, netkingdom_id, fabric_kinds, accepted=review_state == "accepted")
|
||||
_validate_ownership(errors, f"nodes[{index}]", node, actor_roles, accepted=review_state == "accepted")
|
||||
_validate_optional_object(errors, f"nodes[{index}].accounting", node, "accounting")
|
||||
|
||||
edges = _indexed_items(errors, graph, "edges")
|
||||
for index, edge in edges:
|
||||
path = f"edges[{index}]"
|
||||
source = _required_text(errors, f"{path}.from", edge, "from")
|
||||
target = _required_text(errors, f"{path}.to", edge, "to")
|
||||
edge_type = _required_text(errors, f"{path}.type", edge, "type")
|
||||
if source and source not in node_ids:
|
||||
errors.append(f"{path}.from references unknown node {source!r}")
|
||||
if target and target not in node_ids:
|
||||
errors.append(f"{path}.to references unknown node {target!r}")
|
||||
category = _required_text(errors, f"{path}.relationship_category", edge, "relationship_category")
|
||||
if category and category not in RELATIONSHIP_CATEGORIES:
|
||||
errors.append(f"{path}.relationship_category {category!r} is not valid")
|
||||
_validate_evidence(errors, path, edge)
|
||||
_validate_optional_object(errors, f"{path}.accounting", edge, "accounting")
|
||||
if edge_type == "provides_utility_to" and category != "utility":
|
||||
errors.append(f"{path}.relationship_category must be 'utility' for provides_utility_to edges")
|
||||
if category == "utility":
|
||||
_validate_utility_edge(errors, path, edge, actor_roles, fabric_kinds)
|
||||
|
||||
for index, item in _indexed_items(errors, graph, "unresolved"):
|
||||
_required_text(errors, f"unresolved[{index}].target_id", item, "target_id")
|
||||
_required_text(errors, f"unresolved[{index}].kind", item, "kind")
|
||||
_required_text(errors, f"unresolved[{index}].message", item, "message")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def merge_financial_graph_exports(graphs: list[dict[str, Any]], *, generated_at: str) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {
|
||||
"apiVersion": FINANCIAL_API_VERSION,
|
||||
"kind": "FabricGraphExport",
|
||||
"schema_version": FINANCIAL_SCHEMA_VERSION,
|
||||
"generated_at": generated_at,
|
||||
"source": {"producer": "railiance-fabric", "registry": "registry", "commit": ""},
|
||||
"compatibility": {"legacy_v1alpha1_supported": True, "breaking_reset": False},
|
||||
"actors": [],
|
||||
"fabrics": [],
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"unresolved": [],
|
||||
}
|
||||
actors: dict[str, dict[str, Any]] = {}
|
||||
fabrics: dict[str, dict[str, Any]] = {}
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
edges: dict[str, dict[str, Any]] = {}
|
||||
unresolved: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for graph in graphs:
|
||||
graph = materialize_financial_graph_export(graph)
|
||||
if "netkingdom" in graph and "netkingdom" not in merged:
|
||||
merged["netkingdom"] = graph["netkingdom"]
|
||||
for actor in _items(graph, "actors"):
|
||||
actor_id = str(actor.get("id") or "")
|
||||
if actor_id:
|
||||
actors[actor_id] = actor
|
||||
for fabric in _items(graph, "fabrics"):
|
||||
fabric_id = str(fabric.get("id") or "")
|
||||
if fabric_id:
|
||||
fabrics[fabric_id] = fabric
|
||||
for node in _items(graph, "nodes"):
|
||||
node_id = str(node.get("id") or "")
|
||||
if node_id:
|
||||
nodes[node_id] = node
|
||||
for edge in _items(graph, "edges"):
|
||||
edges[_edge_identity(edge)] = edge
|
||||
for item in _items(graph, "unresolved"):
|
||||
target = str(item.get("target_id") or "")
|
||||
kind = str(item.get("kind") or "")
|
||||
if target or kind:
|
||||
unresolved[f"{target}:{kind}"] = item
|
||||
|
||||
merged["actors"] = [actors[key] for key in sorted(actors)]
|
||||
merged["fabrics"] = [fabrics[key] for key in sorted(fabrics)]
|
||||
merged["nodes"] = [nodes[key] for key in sorted(nodes)]
|
||||
merged["edges"] = [edges[key] for key in sorted(edges)]
|
||||
merged["unresolved"] = [unresolved[key] for key in sorted(unresolved)]
|
||||
return materialize_financial_graph_export(merged)
|
||||
|
||||
|
||||
def _materialize_node(node: dict[str, Any]) -> None:
|
||||
attrs = node.get("attributes") if isinstance(node.get("attributes"), dict) else {}
|
||||
for key in ("containment", "ownership", "accounting"):
|
||||
if key not in node and isinstance(attrs.get(key), dict):
|
||||
node[key] = attrs[key]
|
||||
node.setdefault("evidence", _legacy_evidence(node, attrs))
|
||||
|
||||
|
||||
def _materialize_edge(edge: dict[str, Any]) -> None:
|
||||
attrs = edge.get("attributes") if isinstance(edge.get("attributes"), dict) else {}
|
||||
if "accounting" not in edge and isinstance(attrs.get("accounting"), dict):
|
||||
edge["accounting"] = attrs["accounting"]
|
||||
edge.setdefault("relationship_category", _relationship_category(edge))
|
||||
edge.setdefault("evidence", _legacy_evidence(edge, attrs))
|
||||
if edge.get("relationship_category") == "utility":
|
||||
provider = edge.get("provider") if isinstance(edge.get("provider"), dict) else {}
|
||||
consumer = edge.get("consumer") if isinstance(edge.get("consumer"), dict) else {}
|
||||
boundary = edge.setdefault("boundary", {})
|
||||
if isinstance(boundary, dict) and provider and consumer:
|
||||
boundary.setdefault("crosses_fabric_boundary", provider.get("fabric_id") != consumer.get("fabric_id"))
|
||||
boundary.setdefault("crosses_subfabric_boundary", provider.get("subfabric_id") != consumer.get("subfabric_id"))
|
||||
|
||||
|
||||
def _legacy_evidence(item: dict[str, Any], attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
confidence = attrs.get("discovery_confidence")
|
||||
evidence: dict[str, Any] = {
|
||||
"state": item.get("evidence_state") or "declared",
|
||||
"review_state": attrs.get("discovery_review_state") or "accepted",
|
||||
"refs": attrs.get("discovery_source_anchors") if isinstance(attrs.get("discovery_source_anchors"), list) else [],
|
||||
}
|
||||
if isinstance(confidence, (int, float)):
|
||||
evidence["confidence"] = float(confidence)
|
||||
return evidence
|
||||
|
||||
|
||||
def _relationship_category(edge: dict[str, Any]) -> str:
|
||||
edge_type = str(edge.get("type") or "")
|
||||
if edge_type in {"contains", "part_of"}:
|
||||
return "containment"
|
||||
if edge_type in {"owned_by", "operated_by"}:
|
||||
return "ownership"
|
||||
if edge_type == "provides_utility_to":
|
||||
return "utility"
|
||||
if edge_type in {"attributed_to_cost_center", "attributed_to_profit_center"}:
|
||||
return "accounting"
|
||||
if edge_type == "evidenced_by":
|
||||
return "evidence"
|
||||
return "technical"
|
||||
|
||||
|
||||
def _validate_evidence(errors: list[str], path: str, item: dict[str, Any]) -> str:
|
||||
evidence = item.get("evidence")
|
||||
if not isinstance(evidence, dict):
|
||||
errors.append(f"{path}.evidence must be an object")
|
||||
return ""
|
||||
state = _required_text(errors, f"{path}.evidence.state", evidence, "state")
|
||||
review_state = _required_text(errors, f"{path}.evidence.review_state", evidence, "review_state")
|
||||
if state and state not in EVIDENCE_STATES:
|
||||
errors.append(f"{path}.evidence.state {state!r} is not valid")
|
||||
if review_state and review_state not in REVIEW_STATES:
|
||||
errors.append(f"{path}.evidence.review_state {review_state!r} is not valid")
|
||||
return review_state
|
||||
|
||||
|
||||
def _validate_containment(
|
||||
errors: list[str],
|
||||
path: str,
|
||||
node: dict[str, Any],
|
||||
netkingdom_id: str,
|
||||
fabric_kinds: dict[str, str],
|
||||
*,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
containment = node.get("containment")
|
||||
if not isinstance(containment, dict):
|
||||
if accepted:
|
||||
errors.append(f"{path}.containment must be an object for accepted nodes")
|
||||
return
|
||||
if accepted:
|
||||
if _text(containment.get("netkingdom_id")) != netkingdom_id:
|
||||
errors.append(f"{path}.containment.netkingdom_id must match netkingdom.id")
|
||||
fabric_id = _required_text(errors, f"{path}.containment.fabric_id", containment, "fabric_id")
|
||||
if fabric_id:
|
||||
if fabric_id in ENVIRONMENT_LABELS:
|
||||
errors.append(f"{path}.containment.fabric_id must not be an environment label")
|
||||
if fabric_id not in fabric_kinds:
|
||||
errors.append(f"{path}.containment.fabric_id references unknown fabric {fabric_id!r}")
|
||||
subfabric_id = _text(containment.get("subfabric_id"))
|
||||
if subfabric_id:
|
||||
if fabric_kinds.get(subfabric_id) != "Subfabric":
|
||||
errors.append(f"{path}.containment.subfabric_id references unknown subfabric {subfabric_id!r}")
|
||||
environment = _text(containment.get("environment"))
|
||||
if environment and environment in fabric_kinds:
|
||||
errors.append(f"{path}.containment.environment must not reference a fabric id")
|
||||
|
||||
|
||||
def _validate_ownership(
|
||||
errors: list[str],
|
||||
path: str,
|
||||
node: dict[str, Any],
|
||||
actor_roles: dict[str, str],
|
||||
*,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
ownership = node.get("ownership")
|
||||
if not isinstance(ownership, dict):
|
||||
if accepted:
|
||||
errors.append(f"{path}.ownership must be an object for accepted nodes")
|
||||
return
|
||||
owner_actor_id = _required_text(errors, f"{path}.ownership.owner_actor_id", ownership, "owner_actor_id")
|
||||
owner_role = _required_text(errors, f"{path}.ownership.owner_role", ownership, "owner_role")
|
||||
resolution = _required_text(errors, f"{path}.ownership.resolution", ownership, "resolution")
|
||||
if owner_role and owner_role not in ACTOR_ROLES:
|
||||
errors.append(f"{path}.ownership.owner_role {owner_role!r} is not valid")
|
||||
if resolution and resolution not in OWNERSHIP_RESOLUTIONS:
|
||||
errors.append(f"{path}.ownership.resolution {resolution!r} is not valid")
|
||||
if owner_actor_id and owner_actor_id not in actor_roles:
|
||||
errors.append(f"{path}.ownership.owner_actor_id references unknown actor {owner_actor_id!r}")
|
||||
if owner_actor_id and owner_role and actor_roles.get(owner_actor_id) not in {owner_role, ""}:
|
||||
errors.append(f"{path}.ownership.owner_role does not match referenced actor role")
|
||||
if accepted and resolution in {"unresolved", "ambiguous"}:
|
||||
errors.append(f"{path}.ownership.resolution must be explicit or inherited for accepted nodes")
|
||||
|
||||
|
||||
def _validate_utility_edge(
|
||||
errors: list[str],
|
||||
path: str,
|
||||
edge: dict[str, Any],
|
||||
actor_roles: dict[str, str],
|
||||
fabric_kinds: dict[str, str],
|
||||
) -> None:
|
||||
provider = _required_object(errors, f"{path}.provider", edge, "provider")
|
||||
consumer = _required_object(errors, f"{path}.consumer", edge, "consumer")
|
||||
boundary = _required_object(errors, f"{path}.boundary", edge, "boundary")
|
||||
utility = _required_object(errors, f"{path}.utility", edge, "utility")
|
||||
for side_name, side in (("provider", provider), ("consumer", consumer)):
|
||||
actor_id = _required_text(errors, f"{path}.{side_name}.owner_actor_id", side, "owner_actor_id")
|
||||
fabric_id = _required_text(errors, f"{path}.{side_name}.fabric_id", side, "fabric_id")
|
||||
if actor_id and actor_id not in actor_roles:
|
||||
errors.append(f"{path}.{side_name}.owner_actor_id references unknown actor {actor_id!r}")
|
||||
if fabric_id and fabric_id not in fabric_kinds:
|
||||
errors.append(f"{path}.{side_name}.fabric_id references unknown fabric {fabric_id!r}")
|
||||
subfabric_id = _text(side.get("subfabric_id"))
|
||||
if subfabric_id and fabric_kinds.get(subfabric_id) != "Subfabric":
|
||||
errors.append(f"{path}.{side_name}.subfabric_id references unknown subfabric {subfabric_id!r}")
|
||||
_required_text(errors, f"{path}.utility.utility_type", utility, "utility_type")
|
||||
for key in ("crosses_fabric_boundary", "crosses_subfabric_boundary"):
|
||||
if not isinstance(boundary.get(key), bool):
|
||||
errors.append(f"{path}.boundary.{key} must be a boolean")
|
||||
|
||||
|
||||
def _validate_optional_object(errors: list[str], path: str, item: dict[str, Any], key: str) -> None:
|
||||
if key in item and not isinstance(item[key], dict):
|
||||
errors.append(f"{path} must be an object")
|
||||
|
||||
|
||||
def _required_object(errors: list[str], path: str, item: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
value = item.get(key)
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{path} must be an object")
|
||||
return {}
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(errors: list[str], path: str, item: dict[str, Any], key: str) -> str:
|
||||
value = _text(item.get(key))
|
||||
if not value:
|
||||
errors.append(f"{path} is required")
|
||||
return value
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _indexed_items(errors: list[str], graph: dict[str, Any], key: str) -> list[tuple[int, dict[str, Any]]]:
|
||||
value = graph.get(key, [])
|
||||
if not isinstance(value, list):
|
||||
errors.append(f"{key} must be an array")
|
||||
return []
|
||||
return [(index, item) for index, item in enumerate(value) if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _items(graph: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
||||
value = graph.get(key, [])
|
||||
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _edge_identity(edge: dict[str, Any]) -> str:
|
||||
if edge.get("id"):
|
||||
return str(edge["id"])
|
||||
return f"{edge.get('from', '')}:{edge.get('type', '')}:{edge.get('to', '')}"
|
||||
@@ -10,6 +10,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .canon import DISPLAY_ONLY_EDGE_TYPES, edge_canon_mapping, node_canon_mapping
|
||||
from .financial import (
|
||||
financial_graph_errors,
|
||||
is_financial_graph_export,
|
||||
materialize_financial_graph_export,
|
||||
merge_financial_graph_exports,
|
||||
)
|
||||
from .loader import repo_root
|
||||
from .schema_validation import draft202012_validator
|
||||
|
||||
@@ -183,6 +189,7 @@ class RegistryStore:
|
||||
if not isinstance(graph, dict):
|
||||
raise RegistryError("snapshot payload requires object field 'graph'")
|
||||
graph = _with_source(graph, repo_slug, commit, generated_at)
|
||||
graph = materialize_financial_graph_export(graph)
|
||||
validate_graph_export(graph)
|
||||
|
||||
now = _utc_now()
|
||||
@@ -419,9 +426,15 @@ class RegistryStore:
|
||||
}
|
||||
|
||||
def combined_graph(self) -> dict[str, Any]:
|
||||
snapshots = self.latest_snapshots()
|
||||
if snapshots and all(is_financial_graph_export(snapshot["graph"]) for snapshot in snapshots):
|
||||
return merge_financial_graph_exports(
|
||||
[snapshot["graph"] for snapshot in snapshots],
|
||||
generated_at=_utc_now(),
|
||||
)
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
edges: list[dict[str, str]] = []
|
||||
for snapshot in self.latest_snapshots():
|
||||
for snapshot in snapshots:
|
||||
graph = snapshot["graph"]
|
||||
for node in graph.get("nodes", []):
|
||||
if isinstance(node, dict):
|
||||
@@ -913,6 +926,16 @@ class RegistryStore:
|
||||
|
||||
|
||||
def validate_graph_export(graph: dict[str, Any]) -> None:
|
||||
graph = materialize_financial_graph_export(graph)
|
||||
if is_financial_graph_export(graph):
|
||||
errors = financial_graph_errors(graph)
|
||||
if errors:
|
||||
raise RegistryError(f"invalid FinancialFabricGraphExport: {errors[0]}")
|
||||
canon_errors = _graph_canon_metadata_errors(graph)
|
||||
if canon_errors:
|
||||
raise RegistryError(f"invalid FinancialFabricGraphExport canon metadata: {canon_errors[0]}")
|
||||
return
|
||||
|
||||
schema_path = repo_root() / "schemas" / "state-hub-export.schema.yaml"
|
||||
validator = draft202012_validator(schema_path)
|
||||
errors = sorted(validator.iter_errors(graph), key=lambda error: list(error.path))
|
||||
|
||||
Reference in New Issue
Block a user