Establish Railiance Fabric graph model

This commit is contained in:
2026-05-17 19:47:37 +02:00
parent 9c1f4d1381
commit 19f9fddc35
89 changed files with 5007 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
"""Railiance Fabric graph tooling."""
__all__ = ["__version__"]
__version__ = "0.1.0"

183
railiance_fabric/cli.py Normal file
View File

@@ -0,0 +1,183 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from .graph import FabricGraph, build_graph
from .validation import validate_roots
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="railiance-fabric",
description="Load and validate Railiance Fabric declarations.",
)
sub = parser.add_subparsers(dest="command", required=True)
validate = sub.add_parser(
"validate",
help="Validate one or more repo roots or declaration files.",
)
validate.add_argument(
"paths",
nargs="+",
type=Path,
help="Repo root, fabric directory, or declaration YAML file.",
)
validate.add_argument(
"--warnings-as-errors",
action="store_true",
help="Exit non-zero when warnings are present.",
)
providers = sub.add_parser("providers", help="List providers for a capability type or id.")
providers.add_argument("capability", help="Capability type or capability id.")
providers.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
consumers = sub.add_parser("consumers", help="List consumers of a capability or interface.")
consumers.add_argument("target", help="Capability/interface type or declaration id.")
consumers.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
dependency_path = sub.add_parser("dependency-path", help="Show dependency path for a service.")
dependency_path.add_argument("service_id", help="Service declaration id.")
dependency_path.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
unresolved = sub.add_parser("unresolved", help="Show missing or unresolved dependencies.")
unresolved.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
blast = sub.add_parser("blast-radius", help="Show consumers affected by an interface change.")
blast.add_argument("interface", help="Interface type or interface declaration id.")
blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
export = sub.add_parser("export", help="Export graph as JSON or Mermaid.")
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
export.add_argument("--format", choices=["json", "mermaid"], default="json")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "validate":
report = validate_roots(args.paths)
for diagnostic in report.diagnostics:
print(diagnostic.format())
print(report.summary())
if report.errors:
return 1
if args.warnings_as_errors and report.warnings:
return 1
return 0
if args.command == "providers":
graph = _load_graph_or_exit(args.paths)
_print_providers(graph, args.capability)
return 0
if args.command == "consumers":
graph = _load_graph_or_exit(args.paths)
_print_consumers(graph, args.target)
return 0
if args.command == "dependency-path":
graph = _load_graph_or_exit(args.paths)
print("\n".join(graph.dependency_path_lines(args.service_id)))
return 0
if args.command == "unresolved":
graph = _load_graph_or_exit(args.paths)
_print_unresolved(graph)
return 0
if args.command == "blast-radius":
graph = _load_graph_or_exit(args.paths)
_print_consumers(graph, args.interface, matches=graph.blast_radius(args.interface))
return 0
if args.command == "export":
graph = _load_graph_or_exit(args.paths)
print(graph.to_mermaid() if args.format == "mermaid" else graph.to_json())
return 0
parser.error(f"unknown command {args.command!r}")
return 2
def _load_graph_or_exit(paths: list[Path]) -> FabricGraph:
graph = build_graph(paths)
if graph.load_errors:
for path, message in graph.load_errors:
print(f"ERROR {path}: {message}", file=sys.stderr)
raise SystemExit(1)
return graph
def _print_providers(graph: FabricGraph, capability: str) -> None:
providers = graph.providers(capability)
if not providers:
print(f"no providers found for {capability}")
return
print("provider_id\tservice_id\tlifecycle\tenvironments\tinterfaces")
for provider in providers:
spec = provider.spec
print(
"\t".join(
[
provider.id,
str(spec.get("service_id", "")),
str(spec.get("lifecycle", "")),
",".join(spec.get("environments", [])),
",".join(spec.get("interface_ids", [])),
]
)
)
def _print_consumers(
graph: FabricGraph,
target: str,
matches: object | None = None,
) -> None:
consumer_matches = graph.consumers(target) if matches is None else list(matches)
if not consumer_matches:
print(f"no consumers found for {target}")
return
print("consumer_service_id\tdependency_id\trequires\tprovider_capability_id\tprovider_interface_id\tstatus")
for match in consumer_matches:
print(
"\t".join(
[
match.consumer_service_id,
match.dependency_id,
match.required_capability_type,
match.provider_capability_id,
match.provider_interface_id,
match.status,
]
)
)
def _print_unresolved(graph: FabricGraph) -> None:
unresolved = graph.unresolved_dependencies()
if not unresolved:
print("no unresolved dependencies")
return
print("dependency_id\tconsumer_service_id\trequires")
for dependency in unresolved:
spec = dependency.spec
requires = spec.get("requires", {})
print(
"\t".join(
[
dependency.id,
str(spec.get("consumer_service_id", "")),
str(requires.get("capability_id") or requires.get("capability_type", "")),
]
)
)
if __name__ == "__main__":
sys.exit(main())

264
railiance_fabric/graph.py Normal file
View File

@@ -0,0 +1,264 @@
from __future__ import annotations
import json
import re
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .loader import load_declarations
from .model import Declaration
@dataclass(frozen=True)
class ConsumerMatch:
consumer_service_id: str
dependency_id: str
required_capability_type: str
provider_capability_id: str = ""
provider_interface_id: str = ""
status: str = ""
@dataclass
class FabricGraph:
declarations: list[Declaration]
load_errors: list[tuple[Path, str]] = field(default_factory=list)
def __post_init__(self) -> None:
self.by_id: dict[str, Declaration] = {}
self.by_kind: dict[str, list[Declaration]] = defaultdict(list)
for declaration in self.declarations:
if declaration.id and declaration.id not in self.by_id:
self.by_id[declaration.id] = declaration
self.by_kind[declaration.kind].append(declaration)
self.services = {d.id: d for d in self.by_kind["ServiceDeclaration"]}
self.capabilities = {d.id: d for d in self.by_kind["CapabilityDeclaration"]}
self.interfaces = {d.id: d for d in self.by_kind["InterfaceDeclaration"]}
self.dependencies = {d.id: d for d in self.by_kind["DependencyDeclaration"]}
self.bindings = {d.id: d for d in self.by_kind["BindingAssertion"]}
self.bindings_by_dependency: dict[str, list[Declaration]] = defaultdict(list)
for binding in self.bindings.values():
dependency_id = str(binding.spec.get("dependency_id", ""))
if dependency_id:
self.bindings_by_dependency[dependency_id].append(binding)
def providers(self, capability: str) -> list[Declaration]:
if capability in self.capabilities:
return [self.capabilities[capability]]
return [
declaration
for declaration in self.capabilities.values()
if declaration.spec.get("capability_type") == capability
]
def consumers(self, target: str) -> list[ConsumerMatch]:
matches: list[ConsumerMatch] = []
target_interface_type = self.interfaces.get(target, Declaration(Path(), {})).spec.get("interface_type")
for dependency in self.dependencies.values():
spec = dependency.spec
requires = spec.get("requires", {})
dependency_matches = (
target == dependency.id
or target == requires.get("capability_id")
or target == requires.get("capability_type")
or target == spec.get("interface", {}).get("type")
or (target_interface_type and target_interface_type == spec.get("interface", {}).get("type"))
)
dependency_bindings = self.bindings_by_dependency.get(dependency.id, [])
binding_matches = [
binding
for binding in dependency_bindings
if target in {
binding.spec.get("provider_capability_id"),
binding.spec.get("provider_interface_id"),
}
]
if dependency_matches and not dependency_bindings:
matches.append(
ConsumerMatch(
consumer_service_id=str(spec.get("consumer_service_id", "")),
dependency_id=dependency.id,
required_capability_type=str(requires.get("capability_type", "")),
)
)
for binding in dependency_bindings:
if dependency_matches or binding in binding_matches:
matches.append(
ConsumerMatch(
consumer_service_id=str(spec.get("consumer_service_id", "")),
dependency_id=dependency.id,
required_capability_type=str(requires.get("capability_type", "")),
provider_capability_id=str(binding.spec.get("provider_capability_id", "")),
provider_interface_id=str(binding.spec.get("provider_interface_id", "")),
status=str(binding.spec.get("status", "")),
)
)
return sorted(matches, key=lambda item: (item.consumer_service_id, item.dependency_id))
def unresolved_dependencies(self) -> list[Declaration]:
unresolved: list[Declaration] = []
for dependency in self.dependencies.values():
providers = self.matching_providers(dependency)
bindings = self.bindings_by_dependency.get(dependency.id, [])
has_missing_binding = any(binding.spec.get("status") in {"missing", "disputed"} for binding in bindings)
if not providers or has_missing_binding:
unresolved.append(dependency)
return sorted(unresolved, key=lambda item: item.id)
def matching_providers(self, dependency: Declaration) -> list[Declaration]:
requires = dependency.spec.get("requires", {})
capability_id = requires.get("capability_id")
if capability_id:
provider = self.capabilities.get(str(capability_id))
return [provider] if provider is not None else []
capability_type = requires.get("capability_type")
return [
provider
for provider in self.capabilities.values()
if provider.spec.get("capability_type") == capability_type
]
def dependency_path_lines(self, service_id: str) -> list[str]:
if service_id not in self.services:
return [f"unknown service: {service_id}"]
lines: list[str] = []
def walk(current: str, indent: int, stack: list[str]) -> None:
prefix = " " * indent
if current in stack:
lines.append(f"{prefix}{current} (cycle)")
return
lines.append(f"{prefix}{current}")
deps = [
dep
for dep in self.dependencies.values()
if dep.spec.get("consumer_service_id") == current
]
if not deps:
lines.append(f"{prefix} no declared dependencies")
return
for dep in sorted(deps, key=lambda item: item.id):
required = dep.spec.get("requires", {}).get("capability_type", "")
lines.append(f"{prefix} requires {required}: {dep.id}")
bindings = self.bindings_by_dependency.get(dep.id, [])
if not bindings:
providers = self.matching_providers(dep)
if providers:
for provider in providers:
lines.append(f"{prefix} candidate {provider.id}")
else:
lines.append(f"{prefix} unresolved")
continue
for binding in sorted(bindings, key=lambda item: item.id):
provider_id = str(binding.spec.get("provider_capability_id", ""))
provider = self.capabilities.get(provider_id)
provider_service = provider.spec.get("service_id") if provider else ""
status = binding.spec.get("status", "")
lines.append(f"{prefix} {status} -> {provider_id}")
if provider_service and provider_service != current:
walk(str(provider_service), indent + 3, stack + [current])
walk(service_id, 0, [])
return lines
def blast_radius(self, interface: str) -> list[ConsumerMatch]:
if interface in self.interfaces:
return [
match
for match in self.consumers(interface)
if match.provider_interface_id == interface
]
return [
match
for match in self.consumers(interface)
if self.dependencies[match.dependency_id].spec.get("interface", {}).get("type") == interface
]
def to_export(self) -> dict[str, Any]:
nodes: list[dict[str, Any]] = []
edges: list[dict[str, str]] = []
for declaration in sorted(self.declarations, key=lambda item: (item.kind, item.id)):
nodes.append(
{
"id": declaration.id,
"kind": declaration.kind,
"name": declaration.metadata.get("name", declaration.id),
"repo": declaration.metadata.get("repo", ""),
"domain": declaration.metadata.get("domain", ""),
"lifecycle": declaration.spec.get("lifecycle", ""),
}
)
for service in self.services.values():
for capability_id in service.spec.get("provides_capabilities", []):
edges.append({"from": service.id, "to": capability_id, "type": "provides"})
for interface_id in service.spec.get("exposes_interfaces", []):
edges.append({"from": service.id, "to": interface_id, "type": "exposes"})
for capability in self.capabilities.values():
for interface_id in capability.spec.get("interface_ids", []):
edges.append({"from": capability.id, "to": interface_id, "type": "available_via"})
for dependency in self.dependencies.values():
consumer = str(dependency.spec.get("consumer_service_id", ""))
if consumer:
edges.append({"from": consumer, "to": dependency.id, "type": "consumes"})
for binding in self.bindings_by_dependency.get(dependency.id, []):
edges.append(
{
"from": dependency.id,
"to": str(binding.spec.get("provider_capability_id", "")),
"type": f"binds:{binding.spec.get('status', '')}",
}
)
interface_id = str(binding.spec.get("provider_interface_id", ""))
if interface_id:
edges.append({"from": dependency.id, "to": interface_id, "type": "uses_interface"})
return {
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "FabricGraphExport",
"nodes": nodes,
"edges": edges,
}
def to_json(self) -> str:
return json.dumps(self.to_export(), indent=2, sort_keys=True)
def to_mermaid(self) -> str:
export = self.to_export()
lines = ["graph TD"]
for node in export["nodes"]:
node_id = _mermaid_id(node["id"])
label = f"{node['name']}\\n{node['kind']}"
lines.append(f' {node_id}["{_escape_mermaid(label)}"]')
for edge in export["edges"]:
source = _mermaid_id(edge["from"])
target = _mermaid_id(edge["to"])
label = _escape_mermaid(edge["type"])
lines.append(f" {source} -- {label} --> {target}")
return "\n".join(lines)
def build_graph(paths: list[Path]) -> FabricGraph:
declarations, load_errors = load_declarations(paths)
return FabricGraph(declarations=declarations, load_errors=load_errors)
def _mermaid_id(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", value)
if cleaned and cleaned[0].isdigit():
cleaned = "_" + cleaned
return cleaned or "unknown"
def _escape_mermaid(value: str) -> str:
return value.replace('"', '\\"')

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from .model import Declaration
DECLARATION_DIRS = ("services", "capabilities", "interfaces", "dependencies", "bindings")
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def declaration_files(path: Path) -> list[Path]:
path = path.resolve()
if path.is_file():
return [path]
fabric = path if path.name == "fabric" else path / "fabric"
files: list[Path] = []
for directory in DECLARATION_DIRS:
root = fabric / directory
if root.is_dir():
files.extend(sorted(root.glob("*.yaml")))
files.extend(sorted(root.glob("*.yml")))
return files
def load_yaml(path: Path) -> Any:
return yaml.safe_load(path.read_text(encoding="utf-8"))
def load_declarations(paths: list[Path]) -> tuple[list[Declaration], list[tuple[Path, str]]]:
declarations: list[Declaration] = []
errors: list[tuple[Path, str]] = []
seen_files: set[Path] = set()
for raw in paths:
for path in declaration_files(raw):
if path in seen_files:
continue
seen_files.add(path)
try:
data = load_yaml(path)
except Exception as exc:
errors.append((path, str(exc)))
continue
if not isinstance(data, dict):
errors.append((path, "declaration must be a YAML mapping"))
continue
declarations.append(Declaration(path=path, data=data))
return declarations, errors

65
railiance_fabric/model.py Normal file
View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class Declaration:
path: Path
data: dict[str, Any]
@property
def kind(self) -> str:
return str(self.data.get("kind", ""))
@property
def id(self) -> str:
return str(self.data.get("metadata", {}).get("id", ""))
@property
def spec(self) -> dict[str, Any]:
spec = self.data.get("spec", {})
return spec if isinstance(spec, dict) else {}
@property
def metadata(self) -> dict[str, Any]:
meta = self.data.get("metadata", {})
return meta if isinstance(meta, dict) else {}
@dataclass(frozen=True)
class Diagnostic:
severity: str
code: str
message: str
path: Path | None = None
def format(self) -> str:
prefix = f"{self.severity} {self.code}"
if self.path is not None:
return f"{prefix} {self.path}: {self.message}"
return f"{prefix}: {self.message}"
@dataclass
class ValidationReport:
diagnostics: list[Diagnostic] = field(default_factory=list)
@property
def errors(self) -> list[Diagnostic]:
return [d for d in self.diagnostics if d.severity == "ERROR"]
@property
def warnings(self) -> list[Diagnostic]:
return [d for d in self.diagnostics if d.severity == "WARN"]
def add(self, severity: str, code: str, message: str, path: Path | None = None) -> None:
self.diagnostics.append(Diagnostic(severity, code, message, path))
def summary(self) -> str:
return (
f"Validation complete: {len(self.errors)} error(s), "
f"{len(self.warnings)} warning(s)"
)

View File

@@ -0,0 +1,333 @@
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import Any
import jsonschema
from .loader import load_declarations, load_yaml, repo_root
from .model import Declaration, ValidationReport
SCHEMA_BY_KIND = {
"ServiceDeclaration": "service.schema.yaml",
"CapabilityDeclaration": "capability.schema.yaml",
"InterfaceDeclaration": "interface.schema.yaml",
"DependencyDeclaration": "dependency.schema.yaml",
"BindingAssertion": "binding.schema.yaml",
}
def validate_roots(paths: list[Path]) -> ValidationReport:
root = repo_root()
report = ValidationReport()
declarations, load_errors = load_declarations(paths)
for path, message in load_errors:
report.add("ERROR", "load.yaml", message, path)
_validate_schema(root, declarations, report)
_validate_graph(root, declarations, report)
return report
def _validate_schema(root: Path, declarations: list[Declaration], report: ValidationReport) -> None:
schemas_dir = root / "schemas"
store = {
path.resolve().as_uri(): load_yaml(path)
for path in sorted(schemas_dir.glob("*.schema.yaml"))
}
for declaration in declarations:
schema_name = SCHEMA_BY_KIND.get(declaration.kind)
if schema_name is None:
report.add(
"ERROR",
"schema.kind",
f"unknown declaration kind {declaration.kind!r}",
declaration.path,
)
continue
schema_path = schemas_dir / schema_name
schema = load_yaml(schema_path)
resolver = jsonschema.RefResolver(
base_uri=schema_path.resolve().as_uri(),
referrer=schema,
store=store,
)
validator = jsonschema.Draft202012Validator(schema, resolver=resolver)
for error in sorted(validator.iter_errors(declaration.data), key=lambda e: list(e.path)):
location = ".".join(str(part) for part in error.path) or "<root>"
report.add(
"ERROR",
"schema.invalid",
f"{location}: {error.message}",
declaration.path,
)
def _validate_graph(root: Path, declarations: list[Declaration], report: ValidationReport) -> None:
by_id: dict[str, Declaration] = {}
by_kind: dict[str, list[Declaration]] = defaultdict(list)
for declaration in declarations:
if not declaration.id:
continue
if declaration.id in by_id:
report.add(
"ERROR",
"graph.duplicate_id",
f"duplicate declaration id {declaration.id!r}",
declaration.path,
)
else:
by_id[declaration.id] = declaration
by_kind[declaration.kind].append(declaration)
cap_types, iface_types, expected_ifaces = _load_type_catalog(root, report)
services = {d.id: d for d in by_kind["ServiceDeclaration"]}
capabilities = {d.id: d for d in by_kind["CapabilityDeclaration"]}
interfaces = {d.id: d for d in by_kind["InterfaceDeclaration"]}
dependencies = {d.id: d for d in by_kind["DependencyDeclaration"]}
for declaration in by_kind["CapabilityDeclaration"]:
spec = declaration.spec
capability_type = spec.get("capability_type")
if capability_type not in cap_types:
report.add("ERROR", "catalog.unknown_capability_type", f"unknown capability type {capability_type!r}", declaration.path)
_require_ref(report, declaration, "service_id", services, spec.get("service_id"))
for interface_id in spec.get("interface_ids", []):
_require_ref(report, declaration, "interface_ids", interfaces, interface_id)
for declaration in by_kind["InterfaceDeclaration"]:
spec = declaration.spec
interface_type = spec.get("interface_type")
if interface_type not in iface_types:
report.add("ERROR", "catalog.unknown_interface_type", f"unknown interface type {interface_type!r}", declaration.path)
_require_ref(report, declaration, "service_id", services, spec.get("service_id"))
for capability_id in spec.get("capability_ids", []):
_require_ref(report, declaration, "capability_ids", capabilities, capability_id)
for declaration in by_kind["ServiceDeclaration"]:
spec = declaration.spec
for capability_id in spec.get("provides_capabilities", []):
_require_ref(report, declaration, "provides_capabilities", capabilities, capability_id)
for interface_id in spec.get("exposes_interfaces", []):
_require_ref(report, declaration, "exposes_interfaces", interfaces, interface_id)
for declaration in by_kind["DependencyDeclaration"]:
_validate_dependency(
declaration,
report,
services,
capabilities,
interfaces,
cap_types,
iface_types,
expected_ifaces,
)
for declaration in by_kind["BindingAssertion"]:
_validate_binding(declaration, report, dependencies, capabilities, interfaces)
_detect_cycles(by_kind["DependencyDeclaration"], by_kind["BindingAssertion"], services, capabilities, report)
def _load_type_catalog(root: Path, report: ValidationReport) -> tuple[set[str], set[str], dict[str, set[str]]]:
cap_types: set[str] = set()
iface_types: set[str] = set()
expected_ifaces: dict[str, set[str]] = {}
try:
cap_catalog = load_yaml(root / "catalog/capability-types.yaml")
for item in cap_catalog["spec"]["types"]:
cap_types.add(item["id"])
expected_ifaces[item["id"]] = set(item.get("expected_interface_types", []))
except Exception as exc:
report.add("ERROR", "catalog.load", f"cannot load capability catalog: {exc}")
try:
iface_catalog = load_yaml(root / "catalog/interface-types.yaml")
for item in iface_catalog["spec"]["types"]:
iface_types.add(item["id"])
except Exception as exc:
report.add("ERROR", "catalog.load", f"cannot load interface catalog: {exc}")
return cap_types, iface_types, expected_ifaces
def _require_ref(
report: ValidationReport,
declaration: Declaration,
field: str,
collection: dict[str, Declaration],
value: Any,
) -> None:
if isinstance(value, str) and value in collection:
return
report.add("ERROR", "graph.missing_ref", f"{field} references unknown id {value!r}", declaration.path)
def _validate_dependency(
declaration: Declaration,
report: ValidationReport,
services: dict[str, Declaration],
capabilities: dict[str, Declaration],
interfaces: dict[str, Declaration],
cap_types: set[str],
iface_types: set[str],
expected_ifaces: dict[str, set[str]],
) -> None:
spec = declaration.spec
_require_ref(report, declaration, "consumer_service_id", services, spec.get("consumer_service_id"))
requires = spec.get("requires", {})
capability_type = requires.get("capability_type")
capability_id = requires.get("capability_id")
if capability_type not in cap_types:
report.add("ERROR", "catalog.unknown_capability_type", f"unknown required capability type {capability_type!r}", declaration.path)
if capability_id:
_require_ref(report, declaration, "requires.capability_id", capabilities, capability_id)
interface_type = spec.get("interface", {}).get("type")
if interface_type:
if interface_type not in iface_types:
report.add("ERROR", "catalog.unknown_interface_type", f"unknown dependency interface type {interface_type!r}", declaration.path)
expected = expected_ifaces.get(str(capability_type), set())
if expected and interface_type not in expected:
report.add(
"WARN",
"graph.unexpected_interface_type",
f"interface type {interface_type!r} is not expected for capability type {capability_type!r}",
declaration.path,
)
providers = _matching_providers(requires, capabilities)
if not providers:
report.add(
"ERROR",
"graph.missing_provider",
f"no provider capability found for {requires!r}",
declaration.path,
)
if _is_active_production(spec) and not declaration.metadata.get("source_links"):
report.add(
"ERROR",
"graph.missing_source_links",
"active production dependency requires metadata.source_links",
declaration.path,
)
if _is_active_production(spec):
viable = [provider for provider in providers if _provider_covers_dependency(provider.spec, spec)]
if providers and not viable:
report.add(
"ERROR",
"graph.incompatible_provider",
"no matching provider is active in the dependency environment",
declaration.path,
)
def _validate_binding(
declaration: Declaration,
report: ValidationReport,
dependencies: dict[str, Declaration],
capabilities: dict[str, Declaration],
interfaces: dict[str, Declaration],
) -> None:
spec = declaration.spec
dependency = dependencies.get(str(spec.get("dependency_id")))
provider = capabilities.get(str(spec.get("provider_capability_id")))
_require_ref(report, declaration, "dependency_id", dependencies, spec.get("dependency_id"))
_require_ref(report, declaration, "provider_capability_id", capabilities, spec.get("provider_capability_id"))
if spec.get("provider_interface_id"):
_require_ref(report, declaration, "provider_interface_id", interfaces, spec.get("provider_interface_id"))
if dependency and provider:
required_type = dependency.spec.get("requires", {}).get("capability_type")
provider_type = provider.spec.get("capability_type")
if required_type != provider_type:
report.add(
"ERROR",
"graph.binding_type_mismatch",
f"binding provider type {provider_type!r} does not satisfy dependency type {required_type!r}",
declaration.path,
)
def _matching_providers(requires: dict[str, Any], capabilities: dict[str, Declaration]) -> list[Declaration]:
capability_id = requires.get("capability_id")
if capability_id:
provider = capabilities.get(str(capability_id))
return [provider] if provider is not None else []
capability_type = requires.get("capability_type")
return [
declaration
for declaration in capabilities.values()
if declaration.spec.get("capability_type") == capability_type
]
def _is_active_production(spec: dict[str, Any]) -> bool:
environments = set(spec.get("environments", []))
return spec.get("lifecycle") == "active" and bool(environments & {"prod", "all"})
def _provider_covers_dependency(provider_spec: dict[str, Any], dependency_spec: dict[str, Any]) -> bool:
if provider_spec.get("lifecycle") != "active":
return False
provider_envs = set(provider_spec.get("environments", []))
dependency_envs = set(dependency_spec.get("environments", []))
if "all" in provider_envs:
return True
if "all" in dependency_envs:
return {"dev", "staging", "prod"}.issubset(provider_envs)
return bool(provider_envs & dependency_envs)
def _detect_cycles(
dependencies: list[Declaration],
bindings: list[Declaration],
services: dict[str, Declaration],
capabilities: dict[str, Declaration],
report: ValidationReport,
) -> None:
dependency_by_id = {d.id: d for d in dependencies}
provider_by_dependency: dict[str, Declaration] = {}
for binding in bindings:
dep = str(binding.spec.get("dependency_id"))
provider_capability_id = str(binding.spec.get("provider_capability_id"))
provider = capabilities.get(provider_capability_id)
if dep and provider:
provider_by_dependency[dep] = provider
edges: dict[str, set[str]] = defaultdict(set)
for dep_id, provider in provider_by_dependency.items():
dependency = dependency_by_id.get(dep_id)
if dependency is None:
continue
consumer_service = dependency.spec.get("consumer_service_id")
provider_service = provider.spec.get("service_id")
if consumer_service in services and provider_service in services and consumer_service != provider_service:
edges[str(consumer_service)].add(str(provider_service))
visiting: set[str] = set()
visited: set[str] = set()
def visit(node: str, stack: list[str]) -> None:
if node in visiting:
cycle = stack[stack.index(node):] + [node]
report.add("WARN", "graph.cycle", "service dependency cycle: " + " -> ".join(cycle))
return
if node in visited:
return
visiting.add(node)
for target in edges.get(node, set()):
visit(target, stack + [target])
visiting.remove(node)
visited.add(node)
for node in sorted(edges):
visit(node, [node])