Files
computer-playbook/cli/meta/roles/applications/complexity/graph.py
kevinveenbirkenbachandClaude Opus 4.8 90b3845998 feat(readme): role README generator with Cosmos diagrams, Quick Setup, and lint
Add cli/build/readme: derives each role README's managed sections from
templates/roles/README.md.j2.tmpl (the Cosmos mermaid diagram from
meta/services.yml, an invokable-gated Quick Setup, and Credits).

The Cosmos diagram shows dependencies, every declared service, and
dependents (roles that consume this one, matched by role name and by
provides provision-name in both directions). Role nodes carry deploy-mode
emoji markers (compose/swarm/host); disabled and shared services get a
stop mark; dependents are capped at 12.

test_readme enforces the Quick Setup body is byte-exact with the template
render. `make readme-generate` and `make cosmos` regenerate; `make cosmos`
also runs inside `make quality`.

Refactor: utils/symbol_glossary.py is the single source of truth for the
word/emoji mapping (unique project-wide), consumed by the complexity
matrix and the Cosmos builder. utils/roles/deploy.py holds role_has_stack
and role_deploy_modes, shared by both so the logic never drifts.

Add `make lint-mermaid` and its CI workflow, rendering every diagram via
mermaid-cli; the render step provisions chrome-headless-shell and unzip
on demand so it works in the minimal container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:44:33 +02:00

151 lines
4.8 KiB
Python

"""Role discovery and the shared-service dependency graph it induces."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from utils.cache.yaml import load_yaml_any
from utils.roles.applications.services.registry import (
build_service_registry_from_roles_dir,
is_explicit_truth,
)
from utils.roles.deploy import role_has_stack # noqa: F401 - re-exported for callers
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_VARS_MAIN
if TYPE_CHECKING:
from pathlib import Path
TruthFn = Callable[[Any], bool]
def _strict_truth(value: Any) -> bool:
"""Like ``is_explicit_truth`` but rejects the ``'<X>' in group_names``
Jinja form, so only literal ``True`` flags count as deps."""
return value is True
def truth_predicate(*, include_group_names: bool) -> TruthFn:
return is_explicit_truth if include_group_names else _strict_truth
def is_application_role(role_dir: Path) -> bool:
vars_file = role_dir / ROLE_FILE_VARS_MAIN
if not vars_file.is_file():
return False
data = load_yaml_any(str(vars_file), default_if_missing={}) or {}
if not isinstance(data, dict):
return False
application_id = data.get("application_id")
return isinstance(application_id, str) and bool(application_id.strip())
def _load_role_services(role_dir: Path) -> dict[str, Any]:
services_path = role_dir / ROLE_FILE_META_SERVICES
if not services_path.is_file():
return {}
data = load_yaml_any(str(services_path), default_if_missing={}) or {}
return data if isinstance(data, dict) else {}
def _direct_service_dep_roles(
services_map: dict[str, Any],
registry: dict[str, dict[str, Any]],
*,
truth: TruthFn,
) -> list[str]:
raw: list[str] = []
for service_key, entry in services_map.items():
if not isinstance(entry, dict):
continue
if not (truth(entry.get("enabled")) and truth(entry.get("shared"))):
continue
registry_entry = registry.get(service_key)
if not isinstance(registry_entry, dict):
continue
provider = registry_entry.get("role")
if isinstance(provider, str) and provider.strip():
raw.append(provider.strip())
seen: set[str] = set()
deduped: list[str] = []
for role_name in raw:
if role_name not in seen:
seen.add(role_name)
deduped.append(role_name)
return deduped
def build_service_registry(roles_dir: Path) -> dict[str, dict[str, Any]]:
return build_service_registry_from_roles_dir(roles_dir)
def direct_dep_roles(
services_map: dict[str, Any],
registry: dict[str, dict[str, Any]],
*,
truth: TruthFn,
) -> list[str]:
"""Provider roles a services map directly embeds (enabled AND shared
truthy under ``truth``), deduped in first-seen order."""
return _direct_service_dep_roles(services_map, registry, truth=truth)
def build_graphs(
roles_dir: Path,
*,
truth: TruthFn,
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Return ``(forward, reverse)`` adjacency maps over all role dirs.
``forward[A]`` is the list of provider roles that ``A`` directly
depends on (services it embeds). ``reverse[B]`` is the list of
roles that directly depend on ``B`` (consumers that embed ``B``).
"""
registry = build_service_registry_from_roles_dir(roles_dir)
forward: dict[str, list[str]] = {}
reverse: dict[str, list[str]] = {}
for role_dir in sorted(p for p in roles_dir.iterdir() if p.is_dir()):
consumer = role_dir.name
providers = _direct_service_dep_roles(
_load_role_services(role_dir), registry, truth=truth
)
forward[consumer] = providers
for provider in providers:
reverse.setdefault(provider, []).append(consumer)
return forward, reverse
def resolve_transitively(
start_role: str,
forward_graph: dict[str, list[str]],
*,
max_level: int | None = None,
) -> list[str]:
"""BFS over an adjacency map. ``max_level`` caps recursion depth:
``1`` returns direct neighbours only, ``2`` adds their direct
neighbours, etc. ``None`` walks the full closure. The start role
itself is never included in the result.
"""
seen: set[str] = {start_role}
order: list[str] = []
queue: list[tuple[str, int]] = [
(role_name, 1) for role_name in forward_graph.get(start_role, [])
]
while queue:
role_name, depth = queue.pop(0)
if role_name in seen:
continue
seen.add(role_name)
order.append(role_name)
if max_level is not None and depth >= max_level:
continue
next_depth = depth + 1
queue.extend(
(next_role, next_depth)
for next_role in forward_graph.get(role_name, [])
if next_role not in seen
)
return order