From 936b674fda88de505e95ddfb24c065e1e270f052 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 09:34:02 -0700 Subject: [PATCH 01/33] refactor: remove diag library, add den-gram input, expose capture --- nix/lib/default.nix | 2 +- nix/lib/diag/c4.nix | 346 ------------ nix/lib/diag/colors.nix | 80 --- nix/lib/diag/context.nix | 138 ----- nix/lib/diag/default.nix | 383 ------------- nix/lib/diag/dot.nix | 135 ----- nix/lib/diag/export.nix | 346 ------------ nix/lib/diag/filters/closure.nix | 55 -- nix/lib/diag/filters/default.nix | 87 --- nix/lib/diag/filters/diff.nix | 87 --- nix/lib/diag/filters/fold.nix | 190 ------- nix/lib/diag/filters/predicate.nix | 63 --- nix/lib/diag/filters/presence.nix | 53 -- nix/lib/diag/filters/reshape.nix | 227 -------- nix/lib/diag/fleet-ir.nix | 447 --------------- nix/lib/diag/fleet-views.nix | 878 ----------------------------- nix/lib/diag/fleet.nix | 93 --- nix/lib/diag/graph.nix | 602 -------------------- nix/lib/diag/json.nix | 53 -- nix/lib/diag/mermaid.nix | 352 ------------ nix/lib/diag/mindmap.nix | 93 --- nix/lib/diag/namespace.nix | 127 ----- nix/lib/diag/plantuml.nix | 141 ----- nix/lib/diag/render-context.nix | 52 -- nix/lib/diag/render-infra.nix | 124 ---- nix/lib/diag/render-util.nix | 161 ------ nix/lib/diag/sankey.nix | 210 ------- nix/lib/diag/sequence.nix | 532 ----------------- nix/lib/diag/state.nix | 98 ---- nix/lib/diag/text.nix | 447 --------------- nix/lib/diag/themes.nix | 258 --------- nix/lib/diag/treemap.nix | 232 -------- nix/lib/diag/util.nix | 390 ------------- nix/lib/diag/views.nix | 276 --------- 34 files changed, 1 insertion(+), 7757 deletions(-) delete mode 100644 nix/lib/diag/c4.nix delete mode 100644 nix/lib/diag/colors.nix delete mode 100644 nix/lib/diag/context.nix delete mode 100644 nix/lib/diag/default.nix delete mode 100644 nix/lib/diag/dot.nix delete mode 100644 nix/lib/diag/export.nix delete mode 100644 nix/lib/diag/filters/closure.nix delete mode 100644 nix/lib/diag/filters/default.nix delete mode 100644 nix/lib/diag/filters/diff.nix delete mode 100644 nix/lib/diag/filters/fold.nix delete mode 100644 nix/lib/diag/filters/predicate.nix delete mode 100644 nix/lib/diag/filters/presence.nix delete mode 100644 nix/lib/diag/filters/reshape.nix delete mode 100644 nix/lib/diag/fleet-ir.nix delete mode 100644 nix/lib/diag/fleet-views.nix delete mode 100644 nix/lib/diag/fleet.nix delete mode 100644 nix/lib/diag/graph.nix delete mode 100644 nix/lib/diag/json.nix delete mode 100644 nix/lib/diag/mermaid.nix delete mode 100644 nix/lib/diag/mindmap.nix delete mode 100644 nix/lib/diag/namespace.nix delete mode 100644 nix/lib/diag/plantuml.nix delete mode 100644 nix/lib/diag/render-context.nix delete mode 100644 nix/lib/diag/render-infra.nix delete mode 100644 nix/lib/diag/render-util.nix delete mode 100644 nix/lib/diag/sankey.nix delete mode 100644 nix/lib/diag/sequence.nix delete mode 100644 nix/lib/diag/state.nix delete mode 100644 nix/lib/diag/text.nix delete mode 100644 nix/lib/diag/themes.nix delete mode 100644 nix/lib/diag/treemap.nix delete mode 100644 nix/lib/diag/util.nix delete mode 100644 nix/lib/diag/views.nix diff --git a/nix/lib/default.nix b/nix/lib/default.nix index a3c257e62..a1e28e828 100644 --- a/nix/lib/default.nix +++ b/nix/lib/default.nix @@ -31,7 +31,7 @@ let policy = ./policy-effects.nix; resolveEntity = ./resolve-entity.nix; strict = ./strict.nix; - diag = ./diag; + capture = ./diag/capture.nix; policyInspect = ./policy-inspect.nix; schemaUtil = ./schema-util.nix; synthesizePolicies = ./synthesize-policies.nix; diff --git a/nix/lib/diag/c4.nix b/nix/lib/diag/c4.nix deleted file mode 100644 index decdf31aa..000000000 --- a/nix/lib/diag/c4.nix +++ /dev/null @@ -1,346 +0,0 @@ -# C4 model diagram renderers — PlantUML AND Mermaid flavors. -# -# The C4 model gives us three useful framings of the same data: -# -# toC4Component graph — per-host, components = aspects grouped by stage -# toC4Container graph — per-host, containers = classes/ctx stages -# toC4Context fleet — across-host, systems = hosts, people = users -# -# Two render backends: -# - PlantUML via C4 stdlib (`!include `) — `to*With` -# - Mermaid via C4Context/C4Container/C4Component diagrams — `to*MermaidWith` -# -# Both share the same macro-ish body syntax: `Person(...)`, `System(...)`, -# `Rel(...)`, `System_Boundary(...) { ... }`. Only the framing header -# (`@startuml` + include vs mermaid diagramKind + init directive) differs. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (util) meaningful makeIdSanitizer; - inherit (renderUtil) skinparamFor renderMermaid; - - # C4 stdlib respects standard plantuml skinparams, so we share the - # render-util primitive with plantuml.nix. C4 uses Person/System/ - # Container/Component/Boundary/Rectangle element types; only Boundary - # is treated specially (cluster-like palette). - c4Elements = [ - "Person" - "System" - "Container" - "Component" - "Boundary" - "Rectangle" - ]; - c4Skinparam = - theme: - skinparamFor { - inherit theme; - elements = c4Elements; - }; - - # C4 identifiers must be plain (alnum + underscore). - idOf = makeIdSanitizer "c4"; - - # Strings inside C4 macros are double-quoted. Escape embedded quotes. - esc = s: lib.replaceStrings [ "\"" ] [ "\\\"" ] s; - - entityLabel = util.entityLabel { }; - - # --- Shared body-builders --- - # - # These produce the diagram body lines (without any framing wrapper) in - # the C4 macro syntax that both PlantUML's C4 stdlib and Mermaid's native - # C4 diagrams understand. The PlantUML renderers wrap the output in - # @startuml / !include / @enduml; the Mermaid renderers pass it to - # renderMermaid. - - # Component view body: aspects grouped by entity kind inside one host. - # The host becomes a System_Boundary. Each entity kind becomes a - # Container_Boundary holding its aspects as Components. Aspect inclusions - # become Rels. - c4ComponentBody = - theme: graph: - let - inherit (graph) - rootName - rootId - nodes - edges - entityKinds - ; - - aspectNodes = builtins.filter (n: meaningful n.label && n.id != rootId) nodes; - byEntityKind = ek: builtins.filter (n: n.entityKind == ek.name) aspectNodes; - unkindedNodes = builtins.filter (n: n.entityKind == null) aspectNodes; - - componentDecl = node: ''Component(${node.id}, "${esc node.label}", "${esc (node.class or "")}")''; - - containerDecl = - ek: - let - members = byEntityKind ek; - membersStr = lib.concatMapStringsSep "\n " componentDecl members; - in - if members == [ ] then - null - else - '' - Container_Boundary(${idOf ek.name}, "${esc (entityLabel ek)}") { - ${membersStr} - }''; - - containerDecls = builtins.filter (x: x != null) (map containerDecl entityKinds); - - relDecl = - edge: - if (edge.style or "normal") == "excluded" then - ''Rel(${edge.from}, ${edge.to}, "excluded")'' - else if (edge.style or "normal") == "replaced" then - ''Rel(${edge.from}, ${edge.to}, "replaced")'' - else if (edge.style or "normal") == "provide" then - ''Rel(${edge.from}, ${edge.to}, "provides")'' - else - ''Rel(${edge.from}, ${edge.to}, "includes")''; - - keptIds = lib.listToAttrs ( - map (n: { - name = n.id; - value = true; - }) aspectNodes - ); - renderableEdges = builtins.filter (e: keptIds ? ${e.from} && keptIds ? ${e.to}) edges; - in - [ - ''title Component view: ${esc rootName}'' - "" - ''System_Boundary(${rootId}, "${esc rootName}") {'' - ] - ++ map (s: " ${s}") containerDecls - ++ map componentDecl unkindedNodes - ++ [ - "}" - "" - ] - ++ map relDecl renderableEdges; - - # Container view body: one box per entity kind / class, no components inside. - # Each entity kind becomes a Container with a count of aspects it holds. - # Entity kind transitions become Rels. The host is the System_Boundary. - c4ContainerBody = - theme: graph: - let - inherit (graph) - rootName - rootId - nodes - entityKinds - entityEdges - ; - aspectNodes = builtins.filter (n: meaningful n.label && n.id != rootId) nodes; - entityClassHint = - ek: - let - ekNodes = builtins.filter (n: n.entityKind == ek.name) aspectNodes; - classes = lib.unique (builtins.filter (c: c != null && c != "") (map (n: n.class or null) ekNodes)); - in - if classes == [ ] then "mixed" else lib.concatStringsSep "+" classes; - - # Use ek.id for both container declaration and edge endpoints so - # they line up. ek.id is already a valid C4 identifier. - containerDecl = - ek: - let - count = builtins.length (builtins.filter (n: n.entityKind == ek.name) aspectNodes); - desc = "${toString count} aspect${lib.optionalString (count != 1) "s"}"; - in - ''Container(${ek.id}, "${esc (entityLabel ek)}", "${esc (entityClassHint ek)}", "${desc}")''; - - relDecl = - edge: - let - label = if edge.label != null then edge.label else "resolve"; - in - ''Rel(${edge.from}, ${edge.to}, "${esc label}")''; - in - if entityKinds == [ ] then - [ - ''title Container view: ${esc rootName}'' - "" - ''System(${rootId}, "${esc rootName}", "no entity kinds captured")'' - ] - else - [ - ''title Container view: ${esc rootName}'' - "" - ''System_Boundary(${rootId}, "${esc rootName}") {'' - ] - ++ map (s: " ${containerDecl s}") entityKinds - ++ [ - "}" - "" - ] - ++ map relDecl entityEdges; - - # Context view body: fleet-wide overview. - # Expects a fleet record `{ flakeName, hosts, users, relations }` (built by - # fleet.nix). Hosts become Systems, users become Persons, and relations - # (user→host via classes, host→host via cross-provides) become Rels. - c4ContextBody = - theme: fleet: - let - inherit (fleet) - flakeName - hosts - users - relations - ; - personDecl = user: ''Person(${idOf user.name}, "${esc user.name}")''; - systemDecl = - host: - let - desc = host.description or ""; - in - ''System(${idOf host.name}, "${esc host.name}", "${esc desc}")''; - relDecl = rel: ''Rel(${idOf rel.from}, ${idOf rel.to}, "${esc rel.label}")''; - in - [ - ''title ${esc flakeName} — Fleet Context'' - "" - ] - ++ map personDecl users - ++ [ "" ] - ++ map systemDecl hosts - ++ [ "" ] - ++ map relDecl relations; - - # --- PlantUML renderers --- - # - # Each renderer calls the corresponding body-builder and wraps the result - # in PlantUML framing (@startuml, !include , skinparam, @enduml). - - toC4ComponentWith = - { - theme ? themes.defaultTheme, - }: - graph: - lib.concatStringsSep "\n" ( - [ - "@startuml" - "!include " - (c4Skinparam theme) - "" - ] - ++ c4ComponentBody theme graph - ++ [ "@enduml" ] - ); - - toC4ContainerWith = - { - theme ? themes.defaultTheme, - }: - graph: - let - body = c4ContainerBody theme graph; - in - lib.concatStringsSep "\n" ( - [ - "@startuml" - "!include " - (c4Skinparam theme) - "" - ] - ++ body - ++ [ "@enduml" ] - ); - - toC4ContextWith = - { - theme ? themes.defaultTheme, - }: - fleet: - lib.concatStringsSep "\n" ( - [ - "@startuml" - "!include " - (c4Skinparam theme) - "" - ] - ++ c4ContextBody theme fleet - ++ [ - "" - "@enduml" - ] - ); - - # --- Mermaid renderers --- - # - # Mermaid natively supports C4 diagrams (`C4Context`, `C4Container`, - # `C4Component`) with the same macro body syntax as PlantUML's C4 - # stdlib. The body-builder helpers above already produce that syntax; - # we just wrap them in a mermaid init directive + diagram header - # instead of `@startuml` + `!include`. - # - # Mermaid C4 doesn't respect skinparam directives — theme colors come - # from the init directive's `themeVariables`, which our - # `mermaidFrontmatter` already sets per-theme. - - toC4ComponentMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "C4Component"; - } (c4ComponentBody theme graph); - - toC4ContainerMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "C4Container"; - } (c4ContainerBody theme graph); - - toC4ContextMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleet: - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "C4Context"; - } (c4ContextBody theme fleet); - - toC4Component = toC4ComponentWith { }; - toC4Container = toC4ContainerWith { }; - toC4Context = toC4ContextWith { }; - toC4ComponentMermaid = toC4ComponentMermaidWith { }; - toC4ContainerMermaid = toC4ContainerMermaidWith { }; - toC4ContextMermaid = toC4ContextMermaidWith { }; -in -{ - inherit - toC4Component - toC4ComponentWith - toC4Container - toC4ContainerWith - toC4Context - toC4ContextWith - toC4ComponentMermaid - toC4ComponentMermaidWith - toC4ContainerMermaid - toC4ContainerMermaidWith - toC4ContextMermaid - toC4ContextMermaidWith - ; -} diff --git a/nix/lib/diag/colors.nix b/nix/lib/diag/colors.nix deleted file mode 100644 index 5e2d0cb9c..000000000 --- a/nix/lib/diag/colors.nix +++ /dev/null @@ -1,80 +0,0 @@ -# Per-node color selection from a theme's accent pool. -# -# Hashes a node's name and category into one of the theme's accent slots -# (base08-base0F in base16 terms). Nodes with the same category cluster -# around a small range of indices, while each individual name still gets -# a stable-but-distinct selection. The result is scheme-faithful: every -# node color is drawn from the user's chosen base16 palette. -{ lib }: -let - # Hex-digit → integer lookup. - hexDigits = { - "0" = 0; - "1" = 1; - "2" = 2; - "3" = 3; - "4" = 4; - "5" = 5; - "6" = 6; - "7" = 7; - "8" = 8; - "9" = 9; - "a" = 10; - "b" = 11; - "c" = 12; - "d" = 13; - "e" = 14; - "f" = 15; - }; - hexToInt = c: hexDigits.${c} or 0; - - # Parse a 4-character hex substring into an integer. Used to turn the - # first 16 bits of an md5 hash into a number we can modulo against - # the accent pool size. - parseHex4 = - s: - (hexToInt (builtins.substring 0 1 s)) * 4096 - + (hexToInt (builtins.substring 1 1 s)) * 256 - + (hexToInt (builtins.substring 2 1 s)) * 16 - + (hexToInt (builtins.substring 3 1 s)); - - hashNum = s: parseHex4 (builtins.substring 0 4 (builtins.hashString "md5" s)); - - # Given a theme and a (category, name) pair, pick an accent color from - # the theme's palette. The category biases the starting offset so - # related nodes sit near each other in the pool; the name adds a small - # per-item perturbation so they don't all land on the same color. - # - # `* 7` on the category hash is deliberate — 7 is coprime with the - # 8-slot accent pool, so distinct category names land on maximally - # distinct starting offsets instead of bunching on a few buckets. - nodeColorFor = - theme: category: name: - let - pool = theme.accentPool; - poolSize = builtins.length pool; - cat = if category != null then category else "default"; - categoryBase = lib.mod ((hashNum cat) * 7) poolSize; - nameOffset = lib.mod (hashNum name) 3; # 0, 1, or 2 - index = lib.mod (categoryBase + nameOffset) poolSize; - in - builtins.elemAt pool index; - - # Back-compat shim: `nodeColor category name` without a theme argument - # falls back to a built-in github-light palette. Renderers that accept - # a theme should pass it through `nodeColorFor` explicitly. - defaultPool = [ - "#fa4549" # red - "#e16f24" # orange - "#bf8700" # yellow - "#2da44e" # green - "#339D9B" # teal - "#218bff" # blue - "#a475f9" # purple - "#4d2d00" # brown - ]; - nodeColor = category: name: nodeColorFor { accentPool = defaultPool; } category name; -in -{ - inherit nodeColor nodeColorFor; -} diff --git a/nix/lib/diag/context.nix b/nix/lib/diag/context.nix deleted file mode 100644 index b57b73ed5..000000000 --- a/nix/lib/diag/context.nix +++ /dev/null @@ -1,138 +0,0 @@ -# Entity-agnostic context constructors. -# -# Build graph IR from any resolved stage root (host, user, home, -# or custom entity kind). Callers resolve via `den.lib.resolveEntity` -# and pass the result here, or use convenience wrappers below. -{ - den, - lib, - capture, - graphLib, - ... -}: -let - # Entity-agnostic core — build graph IR from a resolved root. - context = - { - root, - name, - classes, - ctx ? { }, - direction ? "LR", - }: - let - captured = capture.captureWithPathsWith { inherit classes root ctx; }; - - graph = graphLib.buildGraph { - entries = captured.entries; - rootName = name; - ctxTrace = captured.ctxTrace; - inherit direction; - }; - pathSets = captured.pathsByClass; - in - graph - // { - rootAspect = root; - inherit pathSets classes; - }; - - # Host convenience wrapper. - hostContext = - { - host, - classes ? null, - direction ? "LR", - }: - let - userClasses = lib.unique (lib.concatMap (u: u.classes or [ ]) (lib.attrValues (host.users or { }))); - actualClasses = - if classes != null then - classes - else - lib.unique ( - [ - "nixos" - "homeManager" - "user" - ] - ++ userClasses - ); - ctx = { inherit host; }; - root = den.lib.resolveEntity "host" ctx; - in - context { - inherit root direction ctx; - name = host.name; - classes = actualClasses; - }; - - # User convenience wrapper. - userContext = - { - host, - user, - classes ? null, - direction ? "LR", - }: - let - actualClasses = - if classes != null then - classes - else - lib.unique ( - [ - "homeManager" - "user" - ] - ++ (user.classes or [ "homeManager" ]) - ); - ctx = { inherit host user; }; - root = den.lib.resolveEntity "user" ctx; - in - context { - inherit root direction ctx; - name = user.name; - classes = actualClasses; - }; - - # Home convenience wrapper. - homeContext = - { - home, - classes ? null, - direction ? "LR", - }: - let - actualClasses = - if classes != null then - classes - else - lib.unique ([ "homeManager" ] ++ (home.classes or [ "homeManager" ])); - ctx = { inherit home; }; - root = den.lib.resolveEntity "home" ctx; - in - context { - inherit root direction ctx; - name = home.name; - classes = actualClasses; - }; - - # Thin wrapper returning a plain graph (no auxiliary fields). - graphOfHost = - args: - removeAttrs (hostContext args) [ - "rootAspect" - "pathSets" - "classes" - ]; -in -{ - inherit - context - hostContext - userContext - homeContext - graphOfHost - ; -} diff --git a/nix/lib/diag/default.nix b/nix/lib/diag/default.nix deleted file mode 100644 index 1551def95..000000000 --- a/nix/lib/diag/default.nix +++ /dev/null @@ -1,383 +0,0 @@ -# Diagram library. -# -# Composable pipeline for rendering aspect-resolution graphs as many -# diagram formats (Mermaid, Graphviz DOT, PlantUML, C4): -# -# 1. trace capture — collect structuredTrace entries from an aspect -# 2. graph construction — build a format-agnostic IR from those entries -# 3. filtering — prune and fold the IR -# 4. rendering — emit Mermaid / DOT / PlantUML strings -# -# Called via `den.lib.diag` from templates and ad-hoc scripts. -# -# ## API layout -# -# diag.graph.* — graph IR construction + filters (data) -# diag.fleet.* — fleet-wide graph construction -# diag.capture* — structured-trace capture -# diag.theme* — theme records from base16 palettes -# diag.toMermaid — renderers (and all `to` variants) -# diag.renderers — pre-configured renderer set constructor -# -# ## One-call convenience for the common host case -# -# g = diag.hostContext { inherit host; }; -# rendered = diag.toMermaid (diag.graph.filterUserAspects g); -# -# ## Generic form (any entity kind) -# -# root = den.lib.resolveEntity "user" { inherit host user; }; -# g = diag.context { inherit root; name = user.name; classes = [ "homeManager" ]; }; -# -# ## Pipeline form for finer control -# -# root = den.lib.resolveEntity "host" { inherit host; }; -# entries = diag.captureAll [ "nixos" "homeManager" ] root; -# g = diag.graph.build { -# inherit entries; -# rootName = host.name; -# }; -# -{ - lib, - den, - inputs, - ... -}: -let - util = import ./util.nix { inherit lib; }; - colors = import ./colors.nix { inherit lib; }; - themes = import ./themes.nix { inherit lib; }; - renderUtil = import ./render-util.nix { inherit lib themes; }; - capture = import ./capture.nix { inherit den lib; }; - graphLib = import ./graph.nix { inherit lib util; }; - filtersLib = import ./filters { inherit lib util graphLib; }; - mermaid = import ./mermaid.nix { - inherit - lib - themes - colors - util - renderUtil - ; - }; - dot = import ./dot.nix { - inherit - lib - themes - colors - util - renderUtil - ; - }; - plantuml = import ./plantuml.nix { - inherit - lib - themes - colors - util - renderUtil - ; - }; - sequence = import ./sequence.nix { - inherit - lib - themes - util - renderUtil - ; - }; - c4 = import ./c4.nix { - inherit - lib - themes - util - renderUtil - ; - }; - sankey = import ./sankey.nix { - inherit - lib - themes - util - renderUtil - ; - }; - treemap = import ./treemap.nix { - inherit - lib - themes - util - renderUtil - ; - }; - mindmap = import ./mindmap.nix { - inherit - lib - themes - util - renderUtil - ; - }; - state = import ./state.nix { - inherit - lib - themes - util - renderUtil - ; - }; - pipeFlow = import ./fleet-views.nix { - inherit - lib - themes - util - renderUtil - ; - }; - textLib = import ./text.nix { inherit lib; }; - fleetIR = import ./fleet-ir.nix { inherit lib; }; - fleetLib = import ./fleet.nix { - inherit - den - lib - capture - ; - }; - exportLib = import ./export.nix { inherit lib; }; - json = import ./json.nix { inherit lib graphLib; }; - - # --- Split modules --- - ctxLib = import ./context.nix { - inherit - den - lib - capture - graphLib - ; - }; - namespaceGraph = import ./namespace.nix { - inherit lib util graphLib; - aspects = den.aspects or { }; - }; - renderInfraFn = import ./render-infra.nix { inherit lib; }; - - # --- Composite bindings --- - inherit (ctxLib) - context - hostContext - userContext - homeContext - graphOfHost - ; - - graph = { - build = graphLib.buildGraph; - ofHost = graphOfHost; - ofNamespace = namespaceGraph; - } - // filtersLib; - - fleet = { - of = fleetLib.fleetGraph; - }; - - pipes = { - buildFlows = pipeFlow.buildPipeFlows; - }; - - fleetGraph = { - build = fleetIR.buildFleetIR; - toJSON = fleetIR.toFleetJSON; - }; - - text = textLib; - - inherit (json) toJSON; - - views = import ./views.nix { inherit graph toJSON; }; - - # Single-source renderer enumeration. Each spec maps a public name to - # its *With function and whether it needs mermaidConfig. - inherit (renderUtil) mkRenderer; - - rendererSpecs = { - toMermaid = { - withFn = mermaid.toMermaidWith; - mc = true; - }; - toDot = { - withFn = dot.toDotWith; - mc = false; - }; - toPlantUML = { - withFn = plantuml.toPlantUMLWith; - mc = false; - }; - toSequenceMermaid = { - withFn = sequence.toSequenceMermaidWith; - mc = true; - }; - toSequenceMermaidExpanded = { - withFn = sequence.toSequenceMermaidExpandedWith; - mc = true; - }; - toPolicySequenceMermaid = { - withFn = sequence.toPolicySequenceMermaidWith; - mc = true; - }; - toScopeEdgesMermaid = { - withFn = sequence.toScopeEdgesMermaidWith; - mc = true; - }; - toSankeyMermaid = { - withFn = sankey.toSankeyMermaidWith; - mc = true; - }; - toFleetSankeyMermaid = { - withFn = sankey.toFleetSankeyMermaidWith; - mc = true; - }; - toFanMetricsSankey = { - withFn = sankey.toFanMetricsSankeyWith; - mc = true; - }; - toTreemapMermaid = { - withFn = treemap.toTreemapMermaidWith; - mc = true; - }; - toFleetTreemapMermaid = { - withFn = treemap.toFleetTreemapMermaidWith; - mc = true; - }; - toFleetProviderMatrix = { - withFn = treemap.toFleetProviderMatrixWith; - mc = true; - }; - toC4Component = { - withFn = c4.toC4ComponentWith; - mc = false; - }; - toC4Container = { - withFn = c4.toC4ContainerWith; - mc = false; - }; - toC4Context = { - withFn = c4.toC4ContextWith; - mc = false; - }; - toC4ComponentMermaid = { - withFn = c4.toC4ComponentMermaidWith; - mc = true; - }; - toC4ContainerMermaid = { - withFn = c4.toC4ContainerMermaidWith; - mc = true; - }; - toC4ContextMermaid = { - withFn = c4.toC4ContextMermaidWith; - mc = true; - }; - toMindmapMermaid = { - withFn = mindmap.toMindmapMermaidWith; - mc = true; - }; - toStateMermaid = { - withFn = state.toStateMermaidWith; - mc = true; - }; - toPipeFlowMermaid = { - withFn = pipeFlow.toPipeFlowMermaidWith; - mc = true; - }; - toScopeTopologyMermaid = { - withFn = pipeFlow.toScopeTopologyMermaidWith; - mc = true; - }; - toAspectMatrixMermaid = { - withFn = pipeFlow.toAspectMatrixMermaidWith; - mc = true; - }; - toPolicyResolutionMapMermaid = { - withFn = pipeFlow.toPolicyResolutionMapMermaidWith; - mc = true; - }; - toPipeSequenceMermaid = { - withFn = pipeFlow.toPipeSequenceMermaidWith; - mc = true; - }; - toFleetDagMermaid = { - withFn = pipeFlow.toFleetDagMermaidWith; - mc = true; - }; - }; - - # Default-args pairs: { toFoo = withFn {}; toFooWith = withFn; } - allRenderers = builtins.foldl' ( - acc: name: acc // mkRenderer name rendererSpecs.${name}.withFn - ) { } (builtins.attrNames rendererSpecs); - - renderers = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - builtins.foldl' ( - acc: name: - let - spec = rendererSpecs.${name}; - args = { - inherit theme; - } - // lib.optionalAttrs spec.mc { inherit mermaidConfig; }; - in - acc // { ${name} = spec.withFn args; } - ) { toJSON = toJSON; } (builtins.attrNames rendererSpecs); - - renderInfra = renderInfraFn; - - renderContext = import ./render-context.nix { - inherit - themes - renderers - renderInfra - views - ; - }; - -in -{ - inherit - context - hostContext - userContext - homeContext - graph - fleet - fleetGraph - pipes - text - views - renderers - renderContext - renderInfra - toJSON - ; - export = exportLib; - - inherit (colors) nodeColor nodeColorFor; - inherit (themes) - paletteFromBase16 - themeFromPalette - themeFromBase16 - defaultTheme - ; - inherit (capture) - capture - captureAll - captureWithPaths - captureWithPathsWith - captureFleet - ; -} -// allRenderers diff --git a/nix/lib/diag/dot.nix b/nix/lib/diag/dot.nix deleted file mode 100644 index 9d4e592a9..000000000 --- a/nix/lib/diag/dot.nix +++ /dev/null @@ -1,135 +0,0 @@ -# Graphviz DOT renderer: graph IR → DOT string. -# -# Emits graph / node / edge defaults from the theme passed in render -# opts so the result matches the shared palette used by mermaid and -# plantuml. Theme is render-time, never on the IR. -{ - lib, - themes, - colors, - util, - renderUtil, -}: -let - inherit (colors) nodeColorFor; - inherit (util) fmtArgs; - inherit (renderUtil) visualFor; - - toDotWith = - { - theme ? themes.defaultTheme, - }: - graph: - let - inherit (graph) - rootName - rootId - nodes - edges - entityKinds - direction - ; - hasEntityKinds = entityKinds != [ ]; - rootColor = theme.rootFill; - vf = visualFor { inherit theme nodeColorFor; }; - - # When the graph is flat, append the node's stage to the label - # (matches mermaid.nix `kindSuffix`). - kindSuffix = - node: if !hasEntityKinds && (node.entityKind or null) != null then " · ${node.entityKind}" else ""; - - dotShape = - node: - if node.shape == "hexagon" then - "hexagon" - else if node.shape == "trapezoid" then - "trapezium" - else - "box"; - - # Excluded/replaced nodes still get their per-node accent fill; - # the dashed stroke style + red/orange border color carries the - # "disabled" semantic. See render-util.nix visualFor. - dotStyle = - node: - let - v = vf node; - styleAttr = if v.isExcluded || v.isReplaced then ''"filled,dashed"'' else "filled"; - in - ''style=${styleAttr},fillcolor="${v.fill}",color="${v.stroke}",fontcolor="${v.text}"''; - - dotLabel = - node: - if node.isParametric then - "${node.label}\\n({ ${fmtArgs node.fnArgNames} })${kindSuffix node}" - else - "${node.label}${kindSuffix node}"; - - nodeDecl = - node: - let - attrs = lib.concatStringsSep "," [ - ''label="${dotLabel node}"'' - "shape=${dotShape node}" - (dotStyle node) - ]; - in - " ${node.id} [${attrs}];"; - - edgeDecl = - edge: - let - attrs = - if edge.style == "excluded" then - " [style=dashed,color=\"${theme.excludedStroke}\"]" - else if edge.style == "replaced" then - " [style=dashed,color=\"${theme.replacedStroke}\",label=\"replaced\"]" - else - ""; - in - " ${edge.from} -> ${edge.to}${attrs};"; - - entitySubgraph = - ek: - let - ekNodes = builtins.filter (n: n.entityKind == ek.name && n.id != rootId) nodes; - ctxLabel = if ek.ctxKeys != [ ] then " { ${lib.concatStringsSep ", " ek.ctxKeys} }" else ""; - in - lib.optional (ekNodes != [ ]) ( - " subgraph cluster_${ek.id} {\n" - + " label=\"${ek.name}${ctxLabel}\";\n" - + " style=dashed;\n" - + " color=\"${theme.clusterBorder}\";\n" - + " fontcolor=\"${theme.foreground}\";\n" - + " bgcolor=\"${theme.clusterBg}\";\n" - + lib.concatMapStringsSep "\n" nodeDecl ekNodes - + "\n }" - ); - - dotDir = if direction == "LR" then "LR" else "TB"; - in - lib.concatStringsSep "\n" ( - [ - "digraph {" - " rankdir=${dotDir};" - " bgcolor=\"${theme.background}\";" - " color=\"${theme.foreground}\";" - " fontcolor=\"${theme.foreground}\";" - " node [style=filled, fillcolor=\"${theme.nodeBg}\", fontcolor=\"${theme.nodeText}\", color=\"${theme.nodeBorder}\"];" - " edge [color=\"${theme.edgeColor}\", fontcolor=\"${theme.edgeText}\"];" - # Stadium-ish rounded rectangle for the host. DOT has no stadium shape. - " ${rootId} [label=\"${rootName}\",shape=box,style=\"rounded,filled\",fillcolor=\"${rootColor}\",color=\"${theme.rootStroke}\",fontcolor=\"${theme.rootText}\"];" - ] - ++ lib.concatMap entitySubgraph entityKinds - ++ map nodeDecl (builtins.filter (n: n.entityKind == null && n.id != rootId) nodes) - ++ [ "" ] - ++ map edgeDecl edges - # Stage transitions are not emitted: they would reference cluster names, - # which DOT cannot use as edge endpoints without lhead/ltail anchor tricks. - ++ [ "}" ] - ); - toDot = toDotWith { }; -in -{ - inherit toDot toDotWith; -} diff --git a/nix/lib/diag/export.nix b/nix/lib/diag/export.nix deleted file mode 100644 index e91cdc719..000000000 --- a/nix/lib/diag/export.nix +++ /dev/null @@ -1,346 +0,0 @@ -# Export helpers: turn view definitions into derivation entries. -# -# Each helper produces a list of `{ name, view, ext, tool, drv }` -# records that templates iterate to build packages and files. -# -# Templates compose these with their own gallery/README builders: -# -# entries = diag.export.ofViews { inherit pkgs rc; } -# allHosts hostViewDefs fleetData fleetViewDefs; -# -{ lib }: -let - # --- Naming conventions --- - - # File name: [.]. (entity name is in the directory) - entryFileName = - e: - let - toolInfix = if e.tool != null then ".${e.tool}" else ""; - in - "${e.view}${toolInfix}.${e.ext}"; - - # Package name: -[-][-] (nix-safe, flat) - entryPackageName = - e: - let - base = "${e.name}-${e.view}"; - toolSuffix = if e.tool != null then "-${e.tool}" else ""; - extSuffix = if e.ext == "svg" then "-svg" else ""; - in - "${base}${toolSuffix}${extSuffix}"; - - entryRelPath = e: "diagrams/${e.dir}/${entryFileName e}"; - - # --- Md wrapper builders --- - - mkViewMd = - pkgs: - { - base, - viewName, - title, - entityName ? null, - altText, - svgInfix, - mdLang, - source, - }: - let - heading = if entityName != null then "# ${title}: ${entityName}" else "# ${title}"; - imageEmbed = if svgInfix == null then "" else "![${altText}](./${viewName}.${svgInfix}.svg)\n\n"; - in - # No indentation in heredoc — ${source} may contain zero-indented - # lines which would prevent Nix from stripping template whitespace. - pkgs.writeText "${base}.md" '' - ${heading} - - ${imageEmbed}```${mdLang} - ${source} - ``` - ''; - - # --- Entry builders --- - - # Single view → [md entry, svg entry?] - mkViewEntries = - pkgs: dir: entityName: graph: view: - let - source = view.compute graph; - base = "${entityName}-${view.view}"; - viewName = view.view; - isRaw = view ? rawExt; - rawExt = view.rawExt or null; - mdDrv = mkViewMd pkgs { - inherit base viewName source; - title = view.title; - inherit entityName; - inherit (view) altText svgInfix mdLang; - }; - rawDrv = - if rawExt == "json" then - pkgs.runCommand "${base}.${rawExt}" { nativeBuildInputs = [ pkgs.jq ]; } '' - echo ${lib.escapeShellArg source} | jq . > $out - '' - else - pkgs.writeText "${base}.${rawExt}" source; - mkEntry = ext: tool: drv: { - name = entityName; - view = viewName; - inherit - dir - ext - tool - drv - ; - }; - in - if isRaw then - [ (mkEntry rawExt null rawDrv) ] - else - [ (mkEntry "md" null mdDrv) ] - ++ lib.optional (view.svgFn != null) (mkEntry "svg" view.svgInfix (view.svgFn base source)); - - # Multi-format DAG view: one md embedding three SVGs. - mkDagEntries = - pkgs: - { - renderDense, - mmdSourceToSvg, - ... - }: - dir: entityName: graph: - let - mmdSrc = renderDense.toMermaid graph; - base = "${entityName}-dag"; - mdDrv = mkViewMd pkgs { - inherit base; - viewName = "dag"; - title = "Full DAG"; - inherit entityName; - altText = "DAG"; - svgInfix = "mmd"; - mdLang = "mermaid"; - source = mmdSrc; - }; - mkEntry = ext: tool: drv: { - name = entityName; - view = "dag"; - inherit - dir - ext - tool - drv - ; - }; - in - [ - (mkEntry "md" null mdDrv) - (mkEntry "svg" "mmd" (mmdSourceToSvg base mmdSrc)) - ]; - - # Fleet view → [md entry, svg entry] - mkFleetViewEntries = - pkgs: fleetData: view: - let - source = view.compute fleetData; - base = "fleet-${view.view}"; - viewName = view.view; - mdDrv = mkViewMd pkgs { - inherit base viewName source; - title = view.title; - inherit (view) altText svgInfix mdLang; - }; - svgDrv = view.svgFn base source; - mkEntry = ext: tool: drv: { - name = "fleet"; - view = viewName; - dir = "fleet"; - inherit ext tool drv; - }; - in - [ (mkEntry "md" null mdDrv) ] - ++ lib.optional (view.svgFn != null) (mkEntry "svg" view.svgInfix svgDrv); - - # --- Batch builders --- - - # All entries for one entity (views + dag + optional gallery entry). - entityEntries = - { - pkgs, - rc, - diag, - }: - { - entity, - name, - dir, - viewDefs, - galleryDrv ? null, - }: - let - g = - if entity ? nodes then - entity - else - throw "entityEntries: entity must be a pre-computed graph (from hostContext, userContext, homeContext, or context)."; - in - lib.concatMap (mkViewEntries pkgs dir name g) viewDefs - ++ mkDagEntries pkgs rc dir name g - ++ lib.optional (galleryDrv != null) { - inherit name dir; - view = "gallery"; - ext = "md"; - tool = null; - drv = galleryDrv; - }; - - # All fleet entries (views + optional gallery entry). - fleetEntries = - { pkgs }: - { - fleetData, - viewDefs, - galleryDrv ? null, - }: - lib.concatMap (mkFleetViewEntries pkgs fleetData) viewDefs - ++ lib.optional (galleryDrv != null) { - name = "fleet"; - dir = "fleet"; - view = "gallery"; - ext = "md"; - tool = null; - drv = galleryDrv; - }; - - # Convert a list of entries to a packages attrset. - entriesToPackages = - entries: - lib.listToAttrs ( - map (e: { - name = entryPackageName e; - value = e.drv; - }) entries - ); - - # Convert entries to files records (for the `files` flake module). - entriesToFiles = - entries: - map (e: { - path_ = entryRelPath e; - inherit (e) drv; - }) entries; - - # Shell script line that copies one entry to the output dir. - entryCopyLine = e: ''cat ${e.drv} > "$dest/${entryRelPath e}"''; - - # Unique directory paths for mkdir -p in write scripts. - entryDirs = entries: lib.unique (map (e: "diagrams/${e.dir}") entries); - - # --- Filter helpers --- - - # Filter entities by a renderList: true = all, list = by name, false = none. - filterByRender = - { - all, - renderList, - getKey ? x: x.name or "", - }: - if renderList == true then - all - else if builtins.isList renderList then - builtins.filter (x: builtins.elem (getKey x) renderList) all - else - [ ]; - - # --- Gallery generation --- - - # Extract unique SVG views for a directory from entry list. - svgViewsForDir = - dir: entries: - lib.unique ( - map (e: { - inherit (e) view; - tool = e.tool; - }) (builtins.filter (e: e.dir == dir && e.ext == "svg") entries) - ); - - # Build a gallery markdown file for a directory. - mkGallery = - pkgs: - { - name, - dir, - title, - entries, - }: - let - svgs = svgViewsForDir dir entries; - subdir = builtins.baseNameOf dir; - embedLine = - sv: - let - toolInfix = if sv.tool != null then ".${sv.tool}" else ""; - file = "${sv.view}${toolInfix}.svg"; - in - "## ${sv.view}\n\n![${sv.view}](./${subdir}/${file})"; - body = lib.concatStringsSep "\n\n" (map embedLine svgs); - in - pkgs.writeText "${name}-gallery.md" '' - # ${title} - - ${body} - ''; - - # --- Write script assembly --- - - # Build a write-diagrams script that copies all entries + galleries to a target directory. - mkWriteScript = - pkgs: - { - entries, - galleries ? [ ], - readmeDrv ? null, - destExpr ? ''"$(${pkgs.git}/bin/git rev-parse --show-toplevel)"'', - scriptName ? "write-diagrams", - }: - let - dirs = entryDirs entries; - mkdirLines = lib.concatMapStringsSep "\n" (d: ''mkdir -p "$dest/${d}"'') dirs; - writeLines = lib.concatMapStringsSep "\n" entryCopyLine entries; - galleryWriteLines = lib.concatMapStringsSep "\n" ( - g: ''cat ${g.drv} > "$dest/${g.path}"'' - ) galleries; - in - pkgs.writeShellScriptBin scriptName '' - set -euo pipefail - dest=${destExpr} - rm -rf "$dest/diagrams" - ${mkdirLines} - ${writeLines} - ${galleryWriteLines} - ${lib.optionalString (readmeDrv != null) ''cat ${readmeDrv} > "$dest/README.md"''} - echo "Wrote $(find "$dest/diagrams" -type f | wc -l) files to $dest/diagrams/" - ''; - -in -{ - inherit - mkViewEntries - mkDagEntries - mkFleetViewEntries - entityEntries - fleetEntries - entriesToPackages - entriesToFiles - entryCopyLine - entryDirs - entryFileName - entryPackageName - entryRelPath - filterByRender - svgViewsForDir - mkGallery - mkWriteScript - ; -} diff --git a/nix/lib/diag/filters/closure.nix b/nix/lib/diag/filters/closure.nix deleted file mode 100644 index bf16bf64a..000000000 --- a/nix/lib/diag/filters/closure.nix +++ /dev/null @@ -1,55 +0,0 @@ -# Closure-based filters — ancestor closure, neighborhood walks. -{ - lib, - util, - graphLib, - filterByNodes, - filterUserAspects, -}: -let - inherit (util) - neighborhoodByNodes - ancestorClosureBy - ; - - # Per-class slice: start from nodes that actively contribute to - # `className` (perClass..hasClass == true), then include - # all ancestors reachable via edges. - classSlice = - className: graph: - ancestorClosureBy (n: n.perClass.${className}.hasClass or false) (filterUserAspects graph); - - # Predicate-based subset view: keep nodes matching `pred` + their - # direct graph neighbors (one hop in/out). Stage subgraphs are dropped - # but each node's own `stage` field is preserved. - neighborhoodOf = - pred: graph: - let - filtered = filterUserAspects graph; - nbhd = neighborhoodByNodes pred filtered; - in - nbhd - // { - entityKinds = [ ]; - entityEdges = [ ]; - entityInstances = [ ]; - }; - - # Handlers view: nodes with resolution handlers plus immediate neighbors. - # Graph nodes carry handler info via `style` field (set from trace entry - # handlers/hasAdapter in graph.nix nodeStyle). This is the correct structural - # check at the graph IR layer. - adaptersOnly = graph: neighborhoodOf (n: util.isAdapter n) graph; - - # Parametric aspects view: only aspects that take function arguments - # (`isParametric = true`). Plus their graph neighbors. - parametricOnly = graph: neighborhoodOf (n: n.isParametric or false) graph; -in -{ - inherit - classSlice - neighborhoodOf - adaptersOnly - parametricOnly - ; -} diff --git a/nix/lib/diag/filters/default.nix b/nix/lib/diag/filters/default.nix deleted file mode 100644 index 710ed03aa..000000000 --- a/nix/lib/diag/filters/default.nix +++ /dev/null @@ -1,87 +0,0 @@ -# Barrel — import all filter sub-modules and merge exports. -{ - lib, - util, - graphLib, -}: -let - inherit (util) filterByNodes meaningful; - - # Core composite used by many sub-modules. - filterMeaningful = filterByNodes (n: meaningful n.label); - - foldMod = import ./fold.nix { - inherit - lib - util - graphLib - filterMeaningful - ; - }; - - filterUserAspects = graph: foldMod.foldWrappers (filterMeaningful graph); - - shared = { - inherit - lib - util - graphLib - filterByNodes - filterUserAspects - ; - }; - - predicate = import ./predicate.nix shared; - closure = import ./closure.nix shared; - reshape = import ./reshape.nix shared; - presence = import ./presence.nix shared; - diffMod = import ./diff.nix { inherit lib util graphLib; }; - - # `simplified` composes across fold + reshape. - simplified = graph: foldMod.foldProviders (foldMod.flattenEntityKinds (reshape.aspectsOnly graph)); - - # Fan-in / fan-out metrics. - fanMetrics = - graph: - let - filtered = filterUserAspects graph; - inCounts = lib.foldl' (acc: e: acc // { ${e.to} = (acc.${e.to} or 0) + 1; }) { } filtered.edges; - outCounts = lib.foldl' ( - acc: e: acc // { ${e.from} = (acc.${e.from} or 0) + 1; } - ) { } filtered.edges; - in - lib.sort (a: b: a.total > b.total) ( - map ( - n: - let - fanIn = inCounts.${n.id} or 0; - fanOut = outCounts.${n.id} or 0; - in - { - inherit (n) - id - label - fullLabel - entityKind - class - ; - inherit fanIn fanOut; - total = fanIn + fanOut; - } - ) filtered.nodes - ); -in -predicate -// foldMod -// closure -// reshape -// presence -// diffMod -// { - inherit - filterMeaningful - filterUserAspects - simplified - fanMetrics - ; -} diff --git a/nix/lib/diag/filters/diff.nix b/nix/lib/diag/filters/diff.nix deleted file mode 100644 index bb7f955ca..000000000 --- a/nix/lib/diag/filters/diff.nix +++ /dev/null @@ -1,87 +0,0 @@ -# Graph diff — merge two graphs with origin tags. -{ - lib, - util, - graphLib, -}: -{ - # Merge two graphs A and B into a single graph where every node and - # edge carries an `origin` tag: "a" (A only), "b" (B only), "both". - # Graph a is treated as the base — rootName, rootId, direction are taken from a. - # Both graphs must share the same node ID namespace for comparison to be meaningful. - diff = - { a, b }: - let - nodesA = lib.listToAttrs ( - map (n: { - name = n.fullLabel; - value = n; - }) a.nodes - ); - nodesB = lib.listToAttrs ( - map (n: { - name = n.fullLabel; - value = n; - }) b.nodes - ); - allKeys = lib.unique (map (n: n.fullLabel) a.nodes ++ map (n: n.fullLabel) b.nodes); - taggedNodes = map ( - k: - let - inA = nodesA ? ${k}; - inB = nodesB ? ${k}; - source = if inA then nodesA.${k} else nodesB.${k}; - in - source - // { - origin = - if inA && inB then - "both" - else if inA then - "a" - else - "b"; - } - ) allKeys; - - edgesA = lib.listToAttrs ( - map (e: { - name = "${e.from}->${e.to}"; - value = e; - }) a.edges - ); - edgesB = lib.listToAttrs ( - map (e: { - name = "${e.from}->${e.to}"; - value = e; - }) b.edges - ); - allEdgeKeys = lib.unique (lib.attrNames edgesA ++ lib.attrNames edgesB); - taggedEdges = map ( - k: - let - inA = edgesA ? ${k}; - inB = edgesB ? ${k}; - source = if inA then edgesA.${k} else edgesB.${k}; - in - source - // { - origin = - if inA && inB then - "both" - else if inA then - "a" - else - "b"; - } - ) allEdgeKeys; - in - a - // { - nodes = taggedNodes; - edges = taggedEdges; - entityKinds = a.entityKinds or [ ]; - entityEdges = a.entityEdges or [ ]; - entityInstances = a.entityInstances or [ ]; - }; -} diff --git a/nix/lib/diag/filters/fold.nix b/nix/lib/diag/filters/fold.nix deleted file mode 100644 index c75db8dbe..000000000 --- a/nix/lib/diag/filters/fold.nix +++ /dev/null @@ -1,190 +0,0 @@ -# Structural fold/reshape — cycle-aware expansion, transitive rewrite. -{ - lib, - util, - graphLib, - filterMeaningful, -}: -let - inherit (util) - dedupBy - meaningful - isWrapper - adjacency - ; -in -{ - # Fold wrapper nodes into their children. Wrapper nodes (stage/kind patterns - # and context nodes) are removed, and their parent edges are rewired to - # point directly at the wrapper's children. - foldWrappers = - graph: - let - isContextNode = - label: - builtins.elem label [ - "host" - "default" - "hm-host" - "hm-user" - "user" - ]; - isFoldable = n: !meaningful n.label || isWrapper n.label || isContextNode n.label; - - foldIds = lib.listToAttrs ( - map (n: { - name = n.id; - value = true; - }) (builtins.filter isFoldable graph.nodes) - ); - - # Adjacency built once; previous implementation linear-scanned - # graph.edges for every expansion step (O(V*E*depth)). - adj = adjacency graph.edges; - childrenOf = id: adj.outOf.${id} or [ ]; - parentsOf = id: adj.inTo.${id} or [ ]; - - # Expand edges: replace foldable endpoints with their non-foldable connections. - # Track visited set to prevent infinite recursion from cycles among foldable nodes. - expandFrom = expandFromWith { }; - expandFromWith = - visited: from: - if !(foldIds ? ${from}) then - [ from ] - else if visited ? ${from} then - [ ] - else - lib.concatMap (expandFromWith (visited // { ${from} = true; })) (parentsOf from); - expandTo = expandToWith { }; - expandToWith = - visited: to: - if !(foldIds ? ${to}) then - [ to ] - else if visited ? ${to} then - [ ] - else - lib.concatMap (expandToWith (visited // { ${to} = true; })) (childrenOf to); - - expandedEdges = lib.concatMap ( - edge: - let - froms = expandFrom edge.from; - tos = expandTo edge.to; - in - lib.concatMap ( - f: - map ( - t: - edge - // { - from = f; - to = t; - } - ) tos - ) froms - ) graph.edges; - - keptNodes = builtins.filter (n: !(foldIds ? ${n.id})) graph.nodes; - keptIds = lib.listToAttrs ( - map (n: { - name = n.id; - value = true; - }) keptNodes - ); - keptEdges = dedupBy (e: "${e.from}->${e.to}") ( - builtins.filter (e: keptIds ? ${e.from} && keptIds ? ${e.to} && e.from != e.to) expandedEdges - ); - in - graph - // { - nodes = keptNodes; - edges = keptEdges; - }; - - # Fold provider sub-aspects into their parent providers. For a node with - # providerPath = [ "p" ... ], if a node whose label matches that path exists, - # the sub-aspect is removed and its edges are rewired to the parent. Chains - # are resolved transitively so nested sub-aspects collapse all the way up. - foldProviders = - graph: - let - nodeByLabel = lib.listToAttrs ( - map (n: { - name = n.fullLabel; - value = n; - }) graph.nodes - ); - parentLabelOf = node: lib.concatStringsSep "/" (node.providerPath or [ ]); - parentIdOf = - node: - let - pl = parentLabelOf node; - in - if pl != "" && nodeByLabel ? ${pl} then nodeByLabel.${pl}.id else null; - - rewritePairs = lib.concatMap ( - n: - if (n.providerPath or [ ]) != [ ] then - let - pid = parentIdOf n; - in - lib.optional (pid != null) { - name = n.id; - value = pid; - } - else - [ ] - ) graph.nodes; - rewriteMap = lib.listToAttrs rewritePairs; - - # Resolve transitively so chains (a -> b -> c) all fold into c. - rewireFinal = - id: - let - go = - cur: visited: - if !(rewriteMap ? ${cur}) then - cur - else if visited ? ${cur} then - cur - else - go rewriteMap.${cur} (visited // { ${cur} = true; }); - in - go id { }; - - keptNodes = builtins.filter (n: !(rewriteMap ? ${n.id})) graph.nodes; - rewiredEdges = map ( - e: - e - // { - from = rewireFinal e.from; - to = rewireFinal e.to; - } - ) graph.edges; - keptEdges = dedupBy (e: "${e.from}->${e.to}") (builtins.filter (e: e.from != e.to) rewiredEdges); - in - graph - // { - nodes = keptNodes; - edges = keptEdges; - }; - - # Drop entity kind subgraph grouping so nodes render as a single flat DAG. - flattenEntityKinds = - graph: - graph - // { - nodes = map ( - n: - n - // { - entityKind = null; - entityInstance = null; - } - ) graph.nodes; - entityKinds = [ ]; - entityEdges = [ ]; - entityInstances = [ ]; - }; - -} diff --git a/nix/lib/diag/filters/predicate.nix b/nix/lib/diag/filters/predicate.nix deleted file mode 100644 index 841e02a95..000000000 --- a/nix/lib/diag/filters/predicate.nix +++ /dev/null @@ -1,63 +0,0 @@ -# Predicate filters — thin wrappers over filterByNodes. -{ - lib, - util, - graphLib, - filterByNodes, - filterUserAspects, -}: -let - inherit (util) - meaningful - isWrapper - adjacency - ; -in -{ - # User-declared view: only nodes that carry `hasClass = true` — i.e. - # aspects a user explicitly wrote, as opposed to plumbing nodes or - # module-merge artifacts. Cuts out a lot of pipeline noise without - # going all the way to `simplified`. - userDeclaredOnly = graph: filterByNodes (n: n.hasClass or false) (filterUserAspects graph); - - # Pipeline meta view: keep ONLY wrapper/plumbing nodes, dropping all - # user-facing aspects. Reveals how a single aspect flows through the - # resolution machinery — `aspect(class) -> self-provide -> cross-provide - # -> resolve` — at the trace level. Useful for debugging adapter - # composition. - pipelineOnly = - graph: filterByNodes (n: isWrapper n.label) (filterByNodes (n: meaningful n.label) graph); - - # Cross-class view: nodes that contribute to 2+ classes via the - # perClass attrset (hasClass = true in more than one class). These - # are the "bridge" aspects spanning nixos + homeManager (or more). - crossClassOnly = - graph: - let - activeClassCount = - n: - builtins.length ( - builtins.filter (c: n.perClass.${c}.hasClass or false) (builtins.attrNames (n.perClass or { })) - ); - in - filterByNodes (n: activeClassCount n >= 2) (filterUserAspects graph); - - # Orphans-and-leaves lint view: nodes with no incoming edges that - # aren't the host itself (orphans) PLUS nodes with no outgoing edges - # (leaves). Useful for spotting dead code and terminal aspects. - orphansAndLeaves = - graph: - let - filtered = filterUserAspects graph; - adj = adjacency filtered.edges; - isOrphan = n: !(adj.inTo ? ${n.id}) && n.id != filtered.rootId; - isLeaf = n: !(adj.outOf ? ${n.id}); - pruned = filterByNodes (n: isOrphan n || isLeaf n) filtered; - in - pruned - // { - entityKinds = [ ]; - entityEdges = [ ]; - entityInstances = [ ]; - }; -} diff --git a/nix/lib/diag/filters/presence.nix b/nix/lib/diag/filters/presence.nix deleted file mode 100644 index e925d9183..000000000 --- a/nix/lib/diag/filters/presence.nix +++ /dev/null @@ -1,53 +0,0 @@ -# hasAspect presence filters. -{ - lib, - util, - graphLib, - filterByNodes, - filterUserAspects, -}: -let - inherit (util) - isTombstone - ancestorClosureBy - ; - - # hasAspect presence slice: nodes that would answer - # `entity.hasAspect ` = true for a given class. - # Ancestor closure keeps the organizer chain visible. - hasAspectPresentWith = - pathSet: graph: - let - filtered = filterUserAspects graph; - isPresent = n: pathSet ? ${n.pathKey} || isTombstone n; - in - ancestorClosureBy isPresent filtered; - - hasAspectPresent = - { class }: - graph: - let - pathSets = - graph.pathSets - or (throw "hasAspectPresent: graph is missing pathSets; build via diag.graph.hostContext, not ofHost."); - pathSet = - pathSets.${class} - or (throw "hasAspectPresent: no pathSet captured for class '${class}'. Known classes: ${lib.concatStringsSep ", " (builtins.attrNames pathSets)}."); - in - hasAspectPresentWith pathSet graph; - - # Union of hasAspectPresent across multiple classes: a node is kept - # if it appears in the presence set of ANY class. - hasAspectForAnyClass = - classes: graph: - let - perClass = builtins.map (c: hasAspectPresent { class = c; } graph) classes; - keepIds = lib.foldl' ( - acc: g: lib.foldl' (acc': n: acc' // { ${n.id} = true; }) acc g.nodes - ) { } perClass; - in - filterByNodes (n: keepIds ? ${n.id}) graph; -in -{ - inherit hasAspectPresentWith hasAspectPresent hasAspectForAnyClass; -} diff --git a/nix/lib/diag/filters/reshape.nix b/nix/lib/diag/filters/reshape.nix deleted file mode 100644 index f1ead26dc..000000000 --- a/nix/lib/diag/filters/reshape.nix +++ /dev/null @@ -1,227 +0,0 @@ -# Reshape — synthesize alternative graph structures from the base graph. -{ - lib, - util, - graphLib, - filterByNodes, - filterUserAspects, -}: -let - inherit (graphLib) emptyNode; -in -{ - # Context hierarchy only: reshape the graph so the entity kinds - # become the nodes. Aspect content is discarded. The host node is retained - # and connected to entry kinds (those with no incoming entity edge) - # so the rendered graph reads "host -> first kind -> ...". - contextOnly = - graph: - let - kindLabel = - ek: ek.name + (if ek.ctxKeys != [ ] then " { ${lib.concatStringsSep ", " ek.ctxKeys} }" else ""); - kindNodes = map ( - ek: - emptyNode - // { - id = ek.id; - label = kindLabel ek; - fullLabel = kindLabel ek; - entityKind = "context"; - hasClass = true; - } - ) graph.entityKinds; - - kindTargets = map (e: e.to) graph.entityEdges; - entryKinds = builtins.filter (s: !(builtins.elem s.id kindTargets)) kindNodes; - hostEdges = map (s: { - from = graph.rootId; - to = s.id; - style = "normal"; - label = null; - }) entryKinds; - in - graph - // { - nodes = kindNodes; - edges = hostEdges ++ graph.entityEdges; - entityKinds = [ ]; - entityEdges = [ ]; - entityInstances = [ ]; - }; - - # Aspect hierarchy only: user aspects with context wrappers folded out - # and provider-provenance edges dropped. Stage subgraphs are retained as - # visual grouping (nixos / homeManager / etc). - aspectsOnly = - graph: - let - filtered = filterUserAspects graph; - in - filtered - // { - edges = builtins.filter (e: (e.style or "normal") != "provide") filtered.edges; - }; - - # Providers-only view: reshape the graph as a true provider hierarchy. - # - # For each node with `providerPath = [a, b, ..., z]` we emit an edge - # from the immediate-parent-provider node (the one whose fullLabel is - # `a/b/.../z`) to this node. The result is a proper multi-level tree - # rooted at top-level provider aspects. - providersOnly = - graph: - let - filtered = filterUserAspects graph; - byFull = lib.listToAttrs ( - map (n: { - name = n.fullLabel; - value = n; - }) filtered.nodes - ); - providerNodes = builtins.filter (n: (n.providerPath or [ ]) != [ ]) filtered.nodes; - edgeFor = - n: - let - parentFull = lib.concatStringsSep "/" n.providerPath; - parent = byFull.${parentFull} or null; - in - lib.optional (parent != null) { - from = parent.id; - to = n.id; - style = "normal"; - label = null; - }; - treeEdges = lib.concatMap edgeFor providerNodes; - keptIds = lib.listToAttrs ( - map - (id: { - name = id; - value = true; - }) - ( - lib.unique ( - lib.concatMap (e: [ - e.from - e.to - ]) treeEdges - ) - ) - ); - keptNodes = builtins.filter (n: keptIds ? ${n.id}) filtered.nodes; - in - filtered - // { - direction = "TD"; - nodes = keptNodes; - edges = treeEdges; - entityKinds = [ ]; - entityEdges = [ ]; - entityInstances = [ ]; - }; - - # Attribution-based structural-decision view. Groups excluded nodes - # by their `perClass..excludedFrom` field. The constraint-owner - # node is shown alongside its direct inclusion-children (survivors and - # tombstones side by side). - decisionsView = - graph: - let - filtered = filterUserAspects graph; - inclusionEdgesOnly = edges: builtins.filter (e: (e.style or "normal") != "provide") edges; - - inherit (util) adjacency isTombstone; - - # Collect all unique adapter-owner names from perClass metadata. - ownerNames = lib.unique ( - lib.concatMap ( - n: - lib.concatMap ( - className: - let - pc = n.perClass.${className} or { }; - from = pc.excludedFrom or null; - in - lib.optional (pc.excluded or false && from != null) from - ) (builtins.attrNames (n.perClass or { })) - ) filtered.nodes - ); - - # Match owner names to graph node IDs. - ownerIds = lib.unique ( - lib.concatMap ( - oname: lib.concatMap (n: lib.optional (n.fullLabel == oname) n.id) filtered.nodes - ) ownerNames - ); - - # Tombstoned nodes attributed to any owner. - tombstoneIds = map (n: n.id) (builtins.filter isTombstone filtered.nodes); - - # Adjacency from inclusion edges only. - adj = adjacency (inclusionEdgesOnly filtered.edges); - childIdsOf = id: adj.outOf.${id} or [ ]; - parentIdsOf = id: adj.inTo.${id} or [ ]; - - # For each tombstone, include its inclusion-parent and the - # parent's other children (surviving siblings) for context. - tombstoneParentIds = lib.unique (lib.concatMap parentIdsOf tombstoneIds); - siblingIds = lib.unique (lib.concatMap childIdsOf tombstoneParentIds); - - keepIds = lib.unique (ownerIds ++ tombstoneIds ++ tombstoneParentIds ++ siblingIds); - keepSet = lib.listToAttrs ( - map (id: { - name = id; - value = true; - }) keepIds - ); - result = filterByNodes (n: keepSet ? ${n.id}) filtered; - in - result - // { - entityKinds = [ ]; - entityEdges = [ ]; - entityInstances = [ ]; - }; - - # Provider-resolved view: shows each provider aspect alongside its - # resolved output nodes. Answers "what did each provider produce for - # this entity?" Provider source nodes link via provide-edges to their - # resolved children, plus the immediate include-children of each - # provider sub-aspect show the concrete output. - providersResolved = - graph: - let - filtered = filterUserAspects graph; - adj = util.adjacency filtered.edges; - - # Provider nodes: any node with a non-empty providerPath. - providerIds = map (n: n.id) (builtins.filter (n: (n.providerPath or [ ]) != [ ]) filtered.nodes); - - # For each provider, include its provide-edge targets and its - # inclusion children (the resolved results). - childIdsOf = id: adj.outOf.${id} or [ ]; - parentIdsOf = id: adj.inTo.${id} or [ ]; - - # Also include the provider source (via provide-edges pointing to it). - provideEdgeTargets = lib.concatMap ( - e: - lib.optionals ((e.style or "normal") == "provide") [ - e.from - e.to - ] - ) filtered.edges; - - keepIds = lib.unique (providerIds ++ lib.concatMap childIdsOf providerIds ++ provideEdgeTargets); - - keepSet = lib.listToAttrs ( - map (id: { - name = id; - value = true; - }) keepIds - ); - result = filterByNodes (n: keepSet ? ${n.id}) filtered; - in - result - // { - direction = "TD"; - }; -} diff --git a/nix/lib/diag/fleet-ir.nix b/nix/lib/diag/fleet-ir.nix deleted file mode 100644 index cdb34cd02..000000000 --- a/nix/lib/diag/fleet-ir.nix +++ /dev/null @@ -1,447 +0,0 @@ -# Fleet-wide graph IR: composable JSON representation of an entire fleet. -# -# Combines per-host graph IRs into a single IR with: -# - Host-namespaced node IDs (no collisions across hosts) -# - Full scope hierarchy (fleet → environment → host → user) -# - Pipe production/consumption annotations on nodes -# - Cross-host pipe flow edges -# - Grouping metadata for interactive expand/collapse -# -# Output shape: -# { -# rootName, direction, -# scopes: [{ id, kind, name, label, parent, children }], -# nodes: [{ id, label, ..., scope, host, pipes }], -# edges: [{ from, to, style, label, scope?, crossHost? }], -# pipes: { : { producers, consumers, flows } }, -# } -{ lib }: -let - sanitize = - s: - lib.replaceStrings - [ - "/" - "-" - " " - "." - "@" - "~" - ":" - "(" - ")" - "{" - "}" - "," - "=" - "'" - "\"" - ] - [ - "__" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - ] - s; - - hostNameFromScope = - scopeId: - let - parts = lib.splitString "," scopeId; - match = lib.findFirst (p: lib.hasPrefix "host=" p) null parts; - in - if match != null then lib.removePrefix "host=" match else null; - - extractScopeName = - kind: scopeId: - let - parts = lib.splitString "," scopeId; - match = lib.findFirst (p: lib.hasPrefix "${kind}=" p) null parts; - in - if match != null then lib.removePrefix "${kind}=" match else scopeId; - - buildFleetIR = - { - fleetCapture, - hostGraphs, # { "lb-prod" = graphIR; ... } - }: - let - inherit (fleetCapture) - scopeParent - scopeEntityKind - scopeContexts - scopedPipeEffects - scopedClassImports - pipeProducers - pipeConsumers - entries - ctxTrace - ; - - # --- Scope hierarchy --- - - allScopeIds = builtins.filter (s: s != "__unscoped" && s != "") (builtins.attrNames scopeParent); - - childrenOf = - parent: - lib.sort (a: b: a < b) (builtins.filter (s: (scopeParent.${s} or null) == parent) allScopeIds); - - mkScope = - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - name = if kind != null then extractScopeName kind scopeId else scopeId; - children = childrenOf scopeId; - parent = scopeParent.${scopeId} or null; - parentNorm = if parent == "__unscoped" || parent == "" then null else parent; - in - { - id = scopeId; - inherit kind name; - label = if kind != null then "${kind}: ${name}" else scopeId; - parent = parentNorm; - children = children; - # Context keys available at this scope. - ctxKeys = builtins.attrNames (scopeContexts.${scopeId} or { }); - }; - - scopes = map mkScope allScopeIds; - - # --- Pipe metadata --- - - classKeys = [ - "nixos" - "homeManager" - "user" - "darwin" - ]; - isPipeKey = k: !builtins.elem k classKeys; - - hostScopes = builtins.filter (s: (scopeEntityKind.${s} or null) == "host") allScopeIds; - - # Pipe producers: from trace data (aspect-level) + class imports. - producersByPipe = lib.foldl' ( - acc: p: acc // { ${p.pipeName} = (acc.${p.pipeName} or [ ]) ++ [ p ]; } - ) { } pipeProducers; - - # Pipe consumers: from trace data. - consumersByPipe = lib.foldl' ( - acc: c: acc // { ${c.pipeName} = (acc.${c.pipeName} or [ ]) ++ [ c ]; } - ) { } pipeConsumers; - - allPipeNames = lib.unique ( - builtins.attrNames producersByPipe ++ builtins.attrNames consumersByPipe - ); - - # Build pipe metadata and flow edges, scoped by parent (siblings only). - # pipe.collect only reaches siblings (same scopeParent). - hostParentScopes = lib.unique (map (hScope: scopeParent.${hScope} or null) hostScopes); - - buildPipeData = - pipeName: - let - producers = producersByPipe.${pipeName} or [ ]; - consumers = consumersByPipe.${pipeName} or [ ]; - - flowsPerParent = lib.concatMap ( - parentScope: - let - siblingHosts = builtins.filter (h: (scopeParent.${h} or null) == parentScope) hostScopes; - siblingNames = builtins.filter (h: h != null) (map hostNameFromScope siblingHosts); - localProducerNames = lib.unique ( - builtins.filter (h: h != null) ( - map (p: hostNameFromScope p.scope) ( - builtins.filter (p: builtins.elem (hostNameFromScope p.scope) siblingNames) producers - ) - ) - ); - localConsumerNames = lib.unique ( - builtins.filter (h: h != null) ( - map (c: hostNameFromScope c.scope) ( - builtins.filter ( - c: (c.hasCollect or false) && builtins.elem (hostNameFromScope c.scope) siblingNames - ) consumers - ) - ) - ); - pureConsumers = builtins.filter (h: !builtins.elem h localProducerNames) localConsumerNames; - effectiveConsumers = if pureConsumers != [ ] then pureConsumers else localConsumerNames; - in - lib.concatMap ( - consumer: - map (producer: { - from = producer; - to = consumer; - inherit pipeName; - }) (builtins.filter (p: p != consumer) localProducerNames) - ) effectiveConsumers - ) hostParentScopes; - in - { - producers = map (p: { - host = hostNameFromScope p.scope; - aspect = p.aspectIdentity; - scope = p.scope; - }) producers; - consumers = map (c: { - host = hostNameFromScope c.scope; - scope = c.scope; - stages = c.stageTypes or [ ]; - hasCollect = c.hasCollect or false; - }) (builtins.filter (c: c.hasCollect or false) consumers); - flows = flowsPerParent; - }; - - pipes = lib.genAttrs allPipeNames buildPipeData; - - # --- Nodes: compose per-host graphs with host-namespaced IDs --- - - prefixId = hostName: id: "${sanitize hostName}__${id}"; - - hostNodes = - hostName: graph: - let - pipeProds = builtins.filter (p: hostNameFromScope p.scope == hostName) pipeProducers; - pipeCons = builtins.filter ( - c: (c.hasCollect or false) && hostNameFromScope c.scope == hostName - ) pipeConsumers; - in - map ( - n: - let - # Pipe annotations for this node. - nodeProduces = lib.unique ( - map (p: p.pipeName) (builtins.filter (p: p.aspectIdentity == (n.fullLabel or n.label)) pipeProds) - ); - nodeConsumes = lib.unique ( - map (c: c.pipeName) ( - builtins.filter ( - c: - # Consumer is at this host scope and the node is in the same instance. - hostNameFromScope c.scope == hostName - ) pipeCons - ) - ); - in - (builtins.removeAttrs n [ - "isExcluded" - "isReplaced" - ]) - // { - id = prefixId hostName n.id; - # Preserve original ID for cross-referencing. - originalId = n.id; - host = hostName; - scope = n.entityInstance or "host:${hostName}"; - pipes = { - produces = nodeProduces; - }; - } - ) graph.nodes; - - allAspectNodes = lib.concatMap ( - hostName: - let - graph = hostGraphs.${hostName} or null; - in - if graph != null then hostNodes hostName graph else [ ] - ) (builtins.attrNames hostGraphs); - - # --- Scope hierarchy nodes --- - # Create nodes for fleet, environment, host, user, flake-system scopes - # so the full resolution tree is visible in the graph. - - scopeNodeId = scopeId: sanitize "scope_${scopeId}"; - - scopeShape = - kind: - if kind == "fleet" then - "rect" - else if kind == "environment" then - "hexagon" - else if kind == "host" then - "rect" - else if kind == "user" then - "rect" - else - "rect"; - - scopeNodes = map ( - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - name = if kind != null then extractScopeName kind scopeId else scopeId; - in - { - id = scopeNodeId scopeId; - label = if kind != null then "${kind}: ${name}" else scopeId; - fullLabel = if kind != null then "${kind}: ${name}" else scopeId; - pathKey = scopeId; - shape = scopeShape kind; - style = "default"; - entityKind = kind; - entityInstance = if kind != null then "${kind}:${name}" else null; - classes = [ ]; - class = ""; - perClass = { }; - fnArgNames = [ ]; - isParametric = false; - isProvider = false; - providerPath = [ ]; - hasClass = false; - isPolicyDispatch = false; - policyName = null; - from = null; - to = null; - host = null; - scope = scopeId; - originalId = scopeId; - isScope = true; - pipes = { - produces = [ ]; - }; - } - ) allScopeIds; - - allNodes = scopeNodes ++ allAspectNodes; - - # --- Edges --- - - # Internal aspect edges (per-host). - hostEdges = - hostName: graph: - map ( - e: - e - // { - from = prefixId hostName e.from; - to = prefixId hostName e.to; - host = hostName; - crossHost = false; - } - ) graph.edges; - - allInternalEdges = lib.concatMap ( - hostName: - let - graph = hostGraphs.${hostName} or null; - in - if graph != null then hostEdges hostName graph else [ ] - ) (builtins.attrNames hostGraphs); - - # Scope hierarchy edges: parent → child for the entire scope tree. - scopeHierarchyEdges = lib.concatMap ( - scopeId: - let - parent = scopeParent.${scopeId} or null; - in - lib.optional (parent != null && parent != "__unscoped" && parent != "") { - from = scopeNodeId parent; - to = scopeNodeId scopeId; - style = "normal"; - label = null; - host = null; - crossHost = false; - } - ) allScopeIds; - - # Host scope → root aspect node edge (connect scope node to the host's root aspect). - hostRootEdges = lib.concatMap ( - hostName: - let - graph = hostGraphs.${hostName} or null; - hostScopeId = lib.findFirst ( - s: (scopeEntityKind.${s} or null) == "host" && hostNameFromScope s == hostName - ) null hostScopes; - rootNodeId = if graph != null then prefixId hostName graph.rootId else null; - in - lib.optional (hostScopeId != null && rootNodeId != null) { - from = scopeNodeId hostScopeId; - to = rootNodeId; - style = "normal"; - label = null; - host = hostName; - crossHost = false; - } - ) (builtins.attrNames hostGraphs); - - # Cross-host pipe flow edges — connect host scope nodes. - pipeFlowEdges = lib.concatMap ( - pipeName: - let - hostScopeOf = - hName: - lib.findFirst ( - s: (scopeEntityKind.${s} or null) == "host" && hostNameFromScope s == hName - ) null hostScopes; - in - map ( - flow: - let - fromScope = hostScopeOf flow.from; - toScope = hostScopeOf flow.to; - in - { - from = if fromScope != null then scopeNodeId fromScope else sanitize "host_${flow.from}"; - to = if toScope != null then scopeNodeId toScope else sanitize "host_${flow.to}"; - style = "pipe"; - label = flow.pipeName; - pipe = flow.pipeName; - crossHost = true; - host = null; - } - ) (pipes.${pipeName}).flows - ) allPipeNames; - - allEdges = scopeHierarchyEdges ++ hostRootEdges ++ allInternalEdges ++ pipeFlowEdges; - - # --- Entity instances with full hierarchy --- - - entityInstances = map ( - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - name = if kind != null then extractScopeName kind scopeId else scopeId; - parent = scopeParent.${scopeId} or null; - parentNorm = if parent == "__unscoped" || parent == "" then null else parent; - in - { - id = sanitize "scope_${scopeId}"; - inherit kind name; - label = if kind != null then "${kind}: ${name}" else scopeId; - parent = if parentNorm != null then sanitize "scope_${parentNorm}" else null; - scopeId = scopeId; - } - ) allScopeIds; - - in - { - rootName = "fleet"; - direction = "LR"; - inherit - scopes - pipes - entityInstances - ; - nodes = allNodes; - edges = allEdges; - }; - - toFleetJSON = args: builtins.toJSON (buildFleetIR args); - -in -{ - inherit buildFleetIR toFleetJSON; -} diff --git a/nix/lib/diag/fleet-views.nix b/nix/lib/diag/fleet-views.nix deleted file mode 100644 index 511878fa3..000000000 --- a/nix/lib/diag/fleet-views.nix +++ /dev/null @@ -1,878 +0,0 @@ -# Fleet-level visualizations from captureFleet data. -# -# Three views: -# - Pipe flow: cross-host quirk data flows with environment subgraphs -# - Scope topology: fleet → environment → host → user resolution tree -# - Aspect matrix: which aspects land on which hosts -# -# All take captureFleet output as input. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (renderUtil) renderMermaid; - inherit (util) makeIdSanitizer; - - sanitize = makeIdSanitizer "h"; - - # Index into theme.accentPool by position. The pool is a list of 8 accent - # colors from the base16 palette; indexing respects the user's chosen scheme. - accent = - theme: i: - let - pool = theme.accentPool; - len = builtins.length pool; - in - assert len > 0; - builtins.elemAt pool (lib.mod i len); - - # Extract host name from a scope ID like "environment=prod,fleet=fleet,host=lb-prod" - hostNameFromScope = - scopeId: - let - parts = lib.splitString "," scopeId; - hostPart = lib.findFirst (p: lib.hasPrefix "host=" p) null parts; - in - if hostPart != null then lib.removePrefix "host=" hostPart else null; - - # Extract environment name from a scope ID - envNameFromScope = - scopeId: - let - parts = lib.splitString "," scopeId; - envPart = lib.findFirst (p: lib.hasPrefix "environment=" p) null parts; - in - if envPart != null then lib.removePrefix "environment=" envPart else null; - - # Find siblings of a scope (same parent, same entity kind). - siblingsOf = - scopeParent: scopeEntityKind: scopeId: - let - parent = scopeParent.${scopeId} or null; - allScopes = builtins.attrNames scopeParent; - siblings = builtins.filter ( - s: - s != scopeId - && (scopeParent.${s} or null) == parent - && (scopeEntityKind.${s} or null) == (scopeEntityKind.${scopeId} or null) - ) allScopes; - in - siblings; - - # Build pipe flow data from fleet capture. - buildPipeFlows = - fleetCapture: - let - inherit (fleetCapture) - scopeParent - scopeContexts - scopeEntityKind - scopedPipeEffects - scopedClassImports - ; - - # Host-level scopes only. - hostScopes = builtins.filter (s: (scopeEntityKind.${s} or null) == "host") ( - builtins.attrNames scopeEntityKind - ); - - # Environment-level scopes. - envScopes = builtins.filter (s: (scopeEntityKind.${s} or null) == "environment") ( - builtins.attrNames scopeEntityKind - ); - - # Hosts grouped by environment. - hostsInEnv = envScope: builtins.filter (h: (scopeParent.${h} or null) == envScope) hostScopes; - - environments = map ( - envScope: - let - eName = envNameFromScope envScope; - in - { - name = if eName != null then eName else envScope; - scope = envScope; - hosts = map ( - hScope: - let - hName = hostNameFromScope hScope; - # Use trace-level pipeProducers when available for accurate - # aspect-level production tracking. - tracedProducers = fleetCapture.pipeProducers or [ ]; - pipeKeys = - if tracedProducers != [ ] then - lib.unique (map (p: p.pipeName) (builtins.filter (p: p.scope == hScope) tracedProducers)) - else - let - classKeys = builtins.attrNames (scopedClassImports.${hScope} or { }); - in - builtins.filter (k: k != "nixos" && k != "homeManager" && k != "user" && k != "darwin") classKeys; - # Pipe effects (pipe.collect) at this scope. - effects = scopedPipeEffects.${hScope} or [ ]; - collectPipes = lib.unique ( - map (e: e.value.pipeName or e.pipeName or null) ( - builtins.filter ( - e: builtins.any (s: (s.__pipeStage or null) == "collect") (e.value.stages or e.stages or [ ]) - ) effects - ) - ); - in - { - name = if hName != null then hName else hScope; - scope = hScope; - produces = pipeKeys; - collects = builtins.filter (p: p != null) collectPipes; - } - ) (hostsInEnv envScope); - } - ) envScopes; - - # Build flow edges: for each host that collects a pipe, find siblings - # that produce it. Only show a host as a meaningful collector if it - # does NOT also produce the same pipe (indicating it has a consumer - # aspect like haproxy). Exception: when ALL collectors also produce - # (bidirectional pattern like host-addrs), show all edges. - flowEdges = lib.concatMap ( - env: - lib.concatMap ( - pipeName: - let - producers = builtins.filter (h: builtins.elem pipeName h.produces) env.hosts; - collectors = builtins.filter (h: builtins.elem pipeName h.collects) env.hosts; - # Pure consumers: collect but don't produce. - pureConsumers = builtins.filter (h: !builtins.elem pipeName h.produces) collectors; - # If no pure consumers exist, it's bidirectional (all produce+collect). - effectiveConsumers = if pureConsumers != [ ] then pureConsumers else collectors; - in - lib.concatMap ( - consumer: - let - # Exclude self-collection. - otherProducers = builtins.filter (h: h.scope != consumer.scope) producers; - in - map (producer: { - from = producer.name; - to = consumer.name; - pipe = pipeName; - environment = env.name; - }) otherProducers - ) effectiveConsumers - ) (lib.unique (lib.concatMap (h: h.collects) env.hosts)) - ) environments; - in - { - inherit environments flowEdges; - # Hosts without an environment (direct children of fleet or flake). - orphanHosts = builtins.filter ( - h: - let - parent = scopeParent.${h} or null; - in - parent != null && !builtins.any (e: e.scope == parent) environments - ) hostScopes; - }; - - # Render pipe flow as mermaid. - toPipeFlowMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleetCapture: - let - flows = buildPipeFlows fleetCapture; - - # Unique pipe names for color assignment. - # Spread pipe colors across the accent pool using coprime stepping - # (step 3 over 8 slots) so adjacent pipes get visually distinct hues - # rather than neighboring palette entries. - pipeNames = lib.unique (map (e: e.pipe) flows.flowEdges); - pipeColorOf = - pipeName: - let - idx = lib.lists.findFirstIndex (p: p == pipeName) 0 pipeNames; - in - accent theme (idx * 3); - - # All hosts flattened with their role (producer, consumer, both). - allHosts = lib.concatMap (env: env.hosts) flows.environments; - allHostNames = lib.unique (map (h: h.name) allHosts); - - # Classify host role for node shape and color. - hostRole = - h: - let - isProducer = h.produces != [ ]; - isCollector = h.collects != [ ]; - in - if isProducer && isCollector then - "both" - else if isCollector then - "consumer" - else - "producer"; - - # Node shapes: producers are boxes, consumers are rounded, both are stadium. - hostShape = - h: - let - role = hostRole h; - in - if role == "consumer" then - "([\"${h.name}\"])" - else if role == "both" then - "([\"${h.name}\"])" - else - "[\"${h.name}\"]"; - - # Environment subgraphs. - envSubgraph = - env: - let - tracedProducers = fleetCapture.pipeProducers or [ ]; - hostDecls = map ( - h: - let - # Show producing aspect:pipe pairs for richer labels. - aspectPipes = - if tracedProducers != [ ] then - let - hostProds = builtins.filter (p: p.scope == h.scope) tracedProducers; - in - map (p: "${p.aspectIdentity}→${p.pipeName}") hostProds - else - h.produces; - annotation = if aspectPipes != [ ] then " (${lib.concatStringsSep ", " aspectPipes})" else ""; - shape = hostShape h; - in - " ${sanitize h.name}${ - if annotation != "" then lib.replaceStrings [ h.name ] [ "${h.name}${annotation}" ] shape else shape - }" - ) env.hosts; - in - " subgraph ${sanitize "env_${env.name}"}[\"${env.name}\"]\n" - + lib.concatStringsSep "\n" hostDecls - + "\n end"; - - # Flow edges grouped by pipe for visual clarity. - edgesForPipe = - pipeName: - let - edges = builtins.filter (e: e.pipe == pipeName) flows.flowEdges; - color = pipeColorOf pipeName; - edgeDecl = e: " ${sanitize e.from} -->|${e.pipe}| ${sanitize e.to}"; - in - map edgeDecl edges; - - # Link styles for coloring edges by pipe. - linkStyles = - let - allEdgeLines = lib.concatMap edgesForPipe pipeNames; - in - lib.imap0 ( - i: _: - let - # Find which pipe this edge belongs to by counting edges per pipe. - edgeCounts = map (p: builtins.length (builtins.filter (e: e.pipe == p) flows.flowEdges)) pipeNames; - pipeIdx = - let - go = - remaining: pIdx: - if pIdx >= builtins.length edgeCounts then - 0 - else if remaining < builtins.elemAt edgeCounts pIdx then - pIdx - else - go (remaining - builtins.elemAt edgeCounts pIdx) (pIdx + 1); - in - go i 0; - color = pipeColorOf (builtins.elemAt pipeNames pipeIdx); - in - " linkStyle ${toString i} stroke:${color},stroke-width:2px" - ) allEdgeLines; - - # Per-host node styles: consistent entity-kind coloring matching - # scope topology and policy resolution views. Pipe colors are on - # edges only — node color shows what the entity IS, edge color - # shows what data FLOWS. - hostColor = accent theme 3; # same index as kindColors.host in other views - hostNodeStyles = lib.concatMap ( - env: - map ( - h: " style ${sanitize h.name} fill:${hostColor},stroke:${hostColor},color:${theme.rootText}" - ) env.hosts - ) flows.environments; - in - if flows.flowEdges == [ ] then - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } [ " note([\"No pipe flows detected\"])" ] - else - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } - ( - map envSubgraph flows.environments - ++ [ "" ] - ++ lib.concatMap edgesForPipe pipeNames - ++ [ "" ] - ++ linkStyles - ++ [ "" ] - ++ hostNodeStyles - ++ map ( - env: - " style ${sanitize "env_${env.name}"} fill:transparent,stroke:${theme.clusterBorder},stroke-width:1px" - ) flows.environments - ); - - toPipeFlowMermaid = toPipeFlowMermaidWith { }; - - # --- View 2: Scope topology --- - # - # Renders the fleet scope tree as a top-down flowchart: - # fleet → environment:prod → host:lb-prod → user:deploy - # → host:web-prod-1 → user:deploy - # environment:staging → host:web-staging → user:deploy - - # Extract a human-readable label from a scope ID. - scopeLabel = - scopeEntityKind: scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - parts = lib.splitString "," scopeId; - # Find the part matching this scope's entity kind. - kindPart = if kind != null then lib.findFirst (p: lib.hasPrefix "${kind}=" p) null parts else null; - name = if kindPart != null then lib.removePrefix "${kind}=" kindPart else scopeId; - in - if kind != null then "${kind}: ${name}" else scopeId; - - toScopeTopologyMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleetCapture: - let - inherit (fleetCapture) scopeParent scopeEntityKind; - - # All scopes except the unscoped root. - allScopes = builtins.filter (s: s != "__unscoped" && s != "") (builtins.attrNames scopeParent); - - # Build nodes and edges from scope tree. - nodeDecl = - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - label = scopeLabel scopeEntityKind scopeId; - shape = - if kind == "fleet" then - "([\"${label}\"])" - else if kind == "environment" then - "[[\"${label}\"]]" - else if kind == "host" then - "[\"${label}\"]" - else if kind == "user" then - "([\"${label}\"])" - else - "[\"${label}\"]"; - in - " ${sanitize scopeId}${shape}"; - - edgeDecl = - scopeId: - let - parent = scopeParent.${scopeId} or null; - in - lib.optional ( - parent != null && parent != "__unscoped" && parent != "" - ) " ${sanitize parent} --> ${sanitize scopeId}"; - - # Color nodes by entity kind. - kindColors = { - fleet = accent theme 5; - environment = accent theme 6; - host = accent theme 3; - user = accent theme 1; - "flake-system" = accent theme 4; - }; - nodeStyle = - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - color = kindColors.${kind} or theme.nodeBg; - text = theme.rootText; - in - " style ${sanitize scopeId} fill:${color},stroke:${color},color:${text}"; - in - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph TD"; - } - ( - map nodeDecl allScopes - ++ [ "" ] - ++ lib.concatMap edgeDecl allScopes - ++ [ "" ] - ++ map nodeStyle allScopes - ); - - toScopeTopologyMermaid = toScopeTopologyMermaidWith { }; - - # --- View 3: Aspect coverage matrix --- - # - # Renders a table showing which meaningful aspects land on which hosts. - # Uses mermaid block-beta for a grid layout. - # - # Falls back to a simple flowchart with host subgraphs containing - # their aspects, since mermaid block-beta has limited support. - - toAspectMatrixMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleetCapture: - let - inherit (fleetCapture) entries scopeEntityKind; - - # Host-level scopes. - hostScopes = builtins.filter (s: (scopeEntityKind.${s} or null) == "host") ( - builtins.attrNames scopeEntityKind - ); - - # For each host scope, collect meaningful aspect names. - hostAspects = map ( - hScope: - let - hName = hostNameFromScope hScope; - # Filter entries belonging to this host's instance. - hostInstance = "host:${hName}"; - hostEntries = builtins.filter ( - e: - (e.entityInstance or null) == hostInstance - && (e.hasClass or false) - && !(e.isPolicyDispatch or false) - && (e.provider or [ ]) == [ ] - && e.name != "host" - && e.name != "user" - && e.name != "default" - && !(lib.hasPrefix "<" (e.name or "")) - ) entries; - aspectNames = lib.unique (lib.sort (a: b: a < b) (map (e: e.name) hostEntries)); - in - { - name = if hName != null then hName else hScope; - aspects = aspectNames; - } - ) hostScopes; - - # All unique aspect names across all hosts. - allAspects = lib.unique (lib.sort (a: b: a < b) (lib.concatMap (h: h.aspects) hostAspects)); - - # Render as a flowchart with one subgraph per host listing its aspects. - hostSubgraph = - h: - let - aspectNodes = map ( - a: - let - present = builtins.elem a h.aspects; - in - if present then " ${sanitize "${h.name}_${a}"}[\"${a}\"]" else null - ) allAspects; - filtered = builtins.filter (x: x != null) aspectNodes; - in - " subgraph ${sanitize "host_${h.name}"}[\"${h.name}\"]\n" - + lib.concatStringsSep "\n" filtered - + "\n end"; - - # Style aspect nodes — same aspect on different hosts gets the same color. - aspectColor = - aspectName: - let - idx = lib.lists.findFirstIndex (a: a == aspectName) 0 allAspects; - in - accent theme idx; - - nodeStyles = lib.concatMap ( - h: - map ( - a: - let - color = aspectColor a; - text = theme.rootText; - in - " style ${sanitize "${h.name}_${a}"} fill:${color},stroke:${color},color:${text}" - ) (builtins.filter (a: builtins.elem a h.aspects) allAspects) - ) hostAspects; - - hostStyles = map ( - h: - " style ${sanitize "host_${h.name}"} fill:${theme.clusterBg},stroke:${theme.clusterBorder},stroke-width:2px" - ) hostAspects; - - # Link same aspects across hosts with dotted edges for visual grouping. - crossHostLinks = lib.concatMap ( - a: - let - hostsWithAspect = builtins.filter (h: builtins.elem a h.aspects) hostAspects; - pairs = - if builtins.length hostsWithAspect < 2 then - [ ] - else - let - first = builtins.head hostsWithAspect; - rest = builtins.tail hostsWithAspect; - in - map (h: { - from = sanitize "${first.name}_${a}"; - to = sanitize "${h.name}_${a}"; - }) rest; - in - map (p: " ${p.from} -..- ${p.to}") pairs - ) allAspects; - in - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } (map hostSubgraph hostAspects ++ [ "" ] ++ crossHostLinks ++ [ "" ] ++ nodeStyles ++ hostStyles); - - toAspectMatrixMermaid = toAspectMatrixMermaidWith { }; - - # --- View 4: Policy entity resolution map --- - # - # Shows the fleet scope tree annotated with which policies drive each - # entity transition: fleet → environment (via fleet-to-envs) → host - # (via env-to-hosts) → user (via host-to-users). - - toPolicyResolutionMapMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleetCapture: - let - inherit (fleetCapture) - entries - scopeParent - scopeEntityKind - ; - - # Policy entries grouped by entity kind they fire at. - policyEntries = builtins.filter (e: e.isPolicyDispatch or false) entries; - - # For each scope transition (parent → child), find the policy that - # fires at the parent scope and creates child scopes of the child's kind. - # The policy's `from` matches the parent's entity kind. - policiesAtKind = - kind: lib.unique (map (e: e.name) (builtins.filter (e: (e.from or null) == kind) policyEntries)); - - allScopes = builtins.filter (s: s != "__unscoped" && s != "") (builtins.attrNames scopeParent); - - # Group scopes by parent for fan-out display. - childrenOf = - parent: - lib.sort (a: b: a < b) (builtins.filter (s: (scopeParent.${s} or null) == parent) allScopes); - - # Build nodes with entity-kind-specific shapes. - nodeDecl = - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - label = scopeLabel scopeEntityKind scopeId; - shape = - if kind == "fleet" then - "([\"${label}\"])" - else if kind == "environment" then - "{{\"${label}\"}}" - else if kind == "host" then - "[\"${label}\"]" - else if kind == "user" then - "([\"${label}\"])" - else - "[\"${label}\"]"; - in - " ${sanitize scopeId}${shape}"; - - # Build edges annotated with the policy that drives the transition. - edgeDecl = - scopeId: - let - parent = scopeParent.${scopeId} or null; - parentKind = if parent != null then scopeEntityKind.${parent} or null else null; - policies = if parentKind != null then policiesAtKind parentKind else [ ]; - policyLabel = if policies != [ ] then lib.concatStringsSep ", " policies else null; - arrow = if policyLabel != null then "-->|${policyLabel}|" else "-->"; - in - lib.optional ( - parent != null && parent != "__unscoped" && parent != "" - ) " ${sanitize parent} ${arrow} ${sanitize scopeId}"; - - # Color by entity kind. - kindColors = { - fleet = accent theme 5; - environment = accent theme 6; - host = accent theme 3; - user = accent theme 1; - "flake-system" = accent theme 4; - }; - nodeStyle = - scopeId: - let - kind = scopeEntityKind.${scopeId} or null; - color = kindColors.${kind} or theme.nodeBg; - text = theme.rootText; - in - " style ${sanitize scopeId} fill:${color},stroke:${color},color:${text}"; - in - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph TD"; - } - ( - map nodeDecl allScopes - ++ [ "" ] - ++ lib.concatMap edgeDecl allScopes - ++ [ "" ] - ++ map nodeStyle allScopes - ); - - toPolicyResolutionMapMermaid = toPolicyResolutionMapMermaidWith { }; - - # --- View 5: Pipe sequence diagram --- - # - # Shows quirk production and collection as a sequence diagram. - # Hosts are participants, grouped by environment via boxes. - # Emissions are notes, collections are arrows. - - toPipeSequenceMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleetCapture: - let - flows = buildPipeFlows fleetCapture; - tracedProducers = fleetCapture.pipeProducers or [ ]; - - # All hosts across all environments, ordered by environment. - allHosts = lib.concatMap (env: env.hosts) flows.environments; - - # Participant declarations grouped by environment. - envBoxes = lib.concatMap ( - env: - let - hostDecls = map (h: " participant ${sanitize h.name} as ${h.name}") env.hosts; - in - [ " box ${env.name}" ] ++ hostDecls ++ [ " end" ] - ) flows.environments; - - # Per-pipe blocks: emission notes then collection arrows. - pipeBlock = - pipeName: - let - # Find producing hosts and their aspects from trace data. - producersByHost = lib.foldl' ( - acc: p: - let - hName = hostNameFromScope p.scope; - in - if hName != null then - acc // { ${hName} = lib.unique ((acc.${hName} or [ ]) ++ [ p.aspectIdentity ]); } - else - acc - ) { } (builtins.filter (p: p.pipeName == pipeName) tracedProducers); - - producerHosts = builtins.attrNames producersByHost; - - # Emission notes. - emissionNotes = map ( - hName: - let - aspects = producersByHost.${hName}; - in - " Note over ${sanitize hName}: ${lib.concatStringsSep ", " aspects} → ${pipeName}" - ) producerHosts; - - # Collection arrows from flow edges. - pipeEdges = builtins.filter (e: e.pipe == pipeName) flows.flowEdges; - collectionArrows = map (e: " ${sanitize e.from} -->> ${sanitize e.to}: ${pipeName}") pipeEdges; - in - lib.optional (emissionNotes != [ ] || collectionArrows != [ ]) "" - ++ emissionNotes - ++ collectionArrows; - - pipeNames = lib.unique (map (p: p.pipeName) tracedProducers ++ map (e: e.pipe) flows.flowEdges); - in - if allHosts == [ ] then - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } [ " participant none as No hosts" ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } (envBoxes ++ lib.concatMap pipeBlock pipeNames); - - toPipeSequenceMermaid = toPipeSequenceMermaidWith { }; - - # --- View 6: Fleet-wide DAG --- - # - # Composes all hosts' aspect trees into a single DAG with: - # - Environment subgraphs containing host subgraphs - # - Per-host aspects inside their host subgraph - # - Cross-host pipe flow edges - # - User scopes nested under their host - # - # Takes fleet capture data + a function to build per-host graphs. - toFleetDagMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - { - fleetCapture, - hostGraphs, # attrset: { "lb-prod" = graphIR; "web-prod-1" = graphIR; ... } - }: - let - flows = buildPipeFlows fleetCapture; - tracedProducers = fleetCapture.pipeProducers or [ ]; - - # Prefix all node/edge IDs with the host name to avoid collisions - # across hosts (e.g., "default" exists on every host). - prefixId = hostName: id: "${sanitize hostName}__${id}"; - - # Build per-host subgraph content. - hostBlock = - hostName: graph: - let - meaningful = builtins.filter ( - n: - (n.hasClass or false) - && !(n.isPolicyDispatch or false) - && !(lib.hasPrefix "<" n.label) - && n.label != "host" - && n.label != "user" - && n.label != "default" - ) graph.nodes; - - nodeDecl = - n: - let - shape = - if n.shape == "hexagon" then - "{{\"${n.label}\"}}" - else if n.shape == "trapezoid" then - "[/\"${n.label}\"\\]" - else - "[\"${n.label}\"]"; - in - " ${prefixId hostName n.id}${shape}"; - - # Internal edges within this host. - internalEdges = builtins.filter ( - e: - let - fromNode = lib.findFirst (n: n.id == e.from) null graph.nodes; - toNode = lib.findFirst (n: n.id == e.to) null graph.nodes; - in - fromNode != null - && toNode != null - && (fromNode.hasClass or false) - && (toNode.hasClass or false) - && !(fromNode.isPolicyDispatch or false) - && !(toNode.isPolicyDispatch or false) - && (e.style or "normal") == "normal" - ) graph.edges; - - edgeDecl = e: " ${prefixId hostName e.from} --> ${prefixId hostName e.to}"; - in - if meaningful == [ ] then - [ ] - else - [ - " subgraph ${sanitize "host_${hostName}"}[\"${hostName}\"]" - ] - ++ map nodeDecl (lib.sort (a: b: a.label < b.label) meaningful) - ++ map edgeDecl internalEdges - ++ [ " end" ]; - - # Environment subgraphs containing host subgraphs. - envBlock = - env: - let - hostBlocks = lib.concatMap ( - h: - let - graph = hostGraphs.${h.name} or null; - in - if graph != null then hostBlock h.name graph else [ ] - ) env.hosts; - in - if hostBlocks == [ ] then - [ ] - else - [ " subgraph ${sanitize "env_${env.name}"}[\"${env.name}\"]" ] ++ hostBlocks ++ [ " end" ]; - - # Pipe flow edges between hosts (cross-host only). - pipeEdges = map ( - e: " ${sanitize "host_${e.from}"} -->|${e.pipe}| ${sanitize "host_${e.to}"}" - ) flows.flowEdges; - - # Host subgraph styles. - hostStyles = lib.concatMap ( - env: - map ( - h: - " style ${sanitize "host_${h.name}"} fill:${theme.nodeBg},stroke:${theme.nodeBorder},stroke-width:1px" - ) env.hosts - ) flows.environments; - - envStyles = map ( - env: - " style ${sanitize "env_${env.name}"} fill:${theme.clusterBg},stroke:${theme.clusterBorder},stroke-width:2px" - ) flows.environments; - in - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } - ( - lib.concatMap envBlock flows.environments - ++ [ "" ] - ++ pipeEdges - ++ [ "" ] - ++ hostStyles - ++ envStyles - ); - - toFleetDagMermaid = toFleetDagMermaidWith { }; - -in -{ - inherit - buildPipeFlows - toPipeFlowMermaid - toPipeFlowMermaidWith - toScopeTopologyMermaid - toScopeTopologyMermaidWith - toAspectMatrixMermaid - toAspectMatrixMermaidWith - toPolicyResolutionMapMermaid - toPolicyResolutionMapMermaidWith - toPipeSequenceMermaid - toPipeSequenceMermaidWith - toFleetDagMermaid - toFleetDagMermaidWith - ; -} diff --git a/nix/lib/diag/fleet.nix b/nix/lib/diag/fleet.nix deleted file mode 100644 index daafa4cc4..000000000 --- a/nix/lib/diag/fleet.nix +++ /dev/null @@ -1,93 +0,0 @@ -# Fleet-level data capture. -# -# Produces a compact record describing all hosts/users in a den flake, -# suitable for rendering as a C4 Context diagram. Unlike per-host tracing -# (capture.nix), this iterates a host registry and does not resolve aspects -# per-host (except lazily for provider sub-aspects). -# -# Output shape: -# -# { flakeName, hosts, users, relations, providerSubAspects } -# where: -# hosts = [ { name, description } ] -# users = [ { name } ] -# relations = [ { from, to, label } ] # user->host (class) edges -{ - den, - lib, - capture, - ... -}: -let - - # Flatten a `den.hosts`-shaped attrset to a list of - # { name, system, host, users : [ { name, classes } ] }. - flattenHosts = - hostsAttr: - lib.concatMap ( - system: - lib.mapAttrsToList (hostName: hostObj: { - name = hostName; - inherit system; - host = hostObj; - users = lib.mapAttrsToList (userName: user: { - name = userName; - classes = user.classes or [ ]; - }) (hostObj.users or { }); - }) (hostsAttr.${system} or { }) - ) (builtins.attrNames hostsAttr); - - # Per-host: capture structured trace and extract provider sub-aspects. - providerSubAspectsOf = - hostInfo: - let - hostAspect = den.lib.resolveEntity "host" { host = hostInfo.host; }; - entries = capture.capture "nixos" hostAspect; - meaningful = - name: name != "" && name != "" && !(lib.hasPrefix "[definition " name); - providerEntries = builtins.filter (e: (e.provider or [ ]) != [ ] && meaningful e.name) entries; - in - map (e: { - provider = builtins.head e.provider; - subAspect = lib.concatStringsSep "/" (e.provider ++ [ e.name ]); - hostName = hostInfo.name; - }) providerEntries; - - fleetGraph = - { - # Host registry. Defaults to den.hosts but callers can override - # with a filtered subset or hosts from a different flake. - hosts ? den.hosts or { }, - flakeName ? "den flake", - }: - let - allHosts = flattenHosts hosts; - - hostRecords = map (h: { - inherit (h) name; - description = h.system; - }) allHosts; - - users = lib.unique (lib.concatMap (h: map (u: { inherit (u) name; }) h.users) allHosts); - - relations = lib.concatMap ( - h: - map (u: { - from = u.name; - to = h.name; - label = if u.classes == [ ] then "uses" else lib.concatStringsSep "+" u.classes; - }) h.users - ) allHosts; - - # Lazy: only forced if a renderer reads this attribute. - providerSubAspects = lib.concatMap providerSubAspectsOf allHosts; - in - { - inherit flakeName relations providerSubAspects; - hosts = hostRecords; - inherit users; - }; -in -{ - inherit fleetGraph; -} diff --git a/nix/lib/diag/graph.nix b/nix/lib/diag/graph.nix deleted file mode 100644 index 625fd0c2e..000000000 --- a/nix/lib/diag/graph.nix +++ /dev/null @@ -1,602 +0,0 @@ -# Graph IR construction. -# -# Transforms structuredTrace entries into a format-agnostic graph IR -# with nodes, edges, entity kinds, and entity kind transitions. The IR -# is consumed by the renderer modules (mermaid, dot, plantuml), which -# are responsible for anything visual — theme, colors, layout, diagram -# config are all render-time concerns and do not appear in the IR. -# -# Filter/reshape operations over the IR live in `filters.nix`. -{ lib, util }: -let - inherit (util) dedupBy makeIdSanitizer sanitizeChars; - - # --- Helpers --- - - sanitize = makeIdSanitizer "n"; - - # Default node record — used by `buildGraph.mkNode` as the starting - # point AND by reshape filters (contextOnly, phantomStubEntries, etc.) - # that need to synthesize nodes outside the normal entry-derived path. - # Keeping defaults in one place means when the node schema grows a - # new field, every synthesized node automatically inherits it. - emptyNode = { - id = ""; - label = ""; - fullLabel = ""; - # Canonical key matching `identity.pathKey (identity.aspectPath aspect)`. - pathKey = ""; - shape = "rect"; - # Rendering style — drives color/border in renderers. Filters - # should NOT use this for structural reasoning; use the structural - # booleans below (`isExcluded`, `isReplaced`) instead. - style = "default"; - entityKind = null; - entityInstance = null; - classes = [ ]; - class = ""; - perClass = { }; - fnArgNames = [ ]; - isParametric = false; - isProvider = false; - providerPath = [ ]; - hasClass = false; - # Structural booleans for filters. Decoupled from `style` so - # structural queries don't depend on the rendering vocabulary. - isExcluded = false; - isReplaced = false; - isPolicyDispatch = false; - policyName = null; - from = null; - to = null; - }; - - # Defensive default for synthetic trace entries (phantom providers, etc.). - # Mirrors emptyNode: any field mkNode reads from entries has a safe fallback. - stubEntry = name: { - inherit name; - class = ""; - parent = null; - provider = [ ]; - excluded = false; - excludedFrom = null; - replacedBy = null; - isProvider = false; - handlers = [ ]; - hasAdapter = false; - hasClass = false; - isParametric = false; - fnArgNames = [ ]; - entityKind = null; - entityInstance = null; - }; - - # Full path: "provider/sub/.../name". Used for stable IDs and edge - # key dedup — never changes meaning, always uniquely identifies an - # entry within a trace. - fullName = - entry: - if entry.provider != [ ] then - lib.concatStringsSep "/" (entry.provider ++ [ entry.name ]) - else - entry.name; - - # Compact display label: `/` for provider - # sub-aspects, bare name for top-level aspects. This keeps visible - # labels short enough that mermaid's HTML foreignObject text doesn't - # wrap mid-path (which made `coolercontrol/class/enable` and - # `coolercontrol/class/setup` look like duplicates in the rendered - # SVG) while still disambiguating same-named leaves across providers - # (`amdcpu/enable` vs `class/enable` vs `bat/enable`). - displayName = - entry: - let - p = entry.provider; - in - if p == [ ] then entry.name else "${lib.last p}/${entry.name}"; - - # An entry's stable identifier is derived from its full path — not - # its bare name — so `monitoring/enable` and `persist/enable` don't collide. - entryId = entry: sanitize (fullName entry); - - # --- Graph IR builder --- - - buildGraph = - { - entries, - rootName, - ctxTrace ? [ ], - direction ? "LR", # LR (left-right) or TD (top-down) - }: - let - # Group raw entries by fullName so we can merge class info when - # the same aspect appears in multiple class traces. - # - # `captureAll` iterates classes and invokes structuredTrace once - # per class. Each invocation walks the entire aspect tree, - # emitting an entry per aspect regardless of whether that aspect - # has content for the current class. The adapter sets - # `hasClass = classModule != []` — true when the aspect actually - # defines attrs for this class, false when it's just traversed. - # - # To answer "which classes does this aspect contribute to?" we - # merge ONLY the entries where `hasClass = true`. Otherwise every - # aspect would look like it belongs to every class just because - # the traversal visited it. - # Pre-tag entries: assign root entity instance to entries with null - # entityInstance so they merge correctly with same-scope entries - # during dedup rather than creating duplicates. - rootInstance = - if ctxTrace != [ ] then - let - rootCtx = builtins.head ctxTrace; - in - "${rootCtx.entityKind}:${rootCtx.selfName}" - else - null; - # Pre-tag: assign root entity instance to null-instance entries so - # they merge with same-scope entries during dedup (no duplicates). - preTagged = map ( - e: - if (e.entityInstance or null) == null && rootInstance != null then - e // { entityInstance = rootInstance; } - else - e - ) entries; - - # Coerce null entityInstance to empty string for safe interpolation. - instOf = e: if e.entityInstance or null == null then "" else e.entityInstance; - - # Scope-qualified key: dedup by (fullName, entityInstance) so the - # same aspect in different entity scopes gets separate nodes. Within - # one scope, class traces are still merged (nixos + homeManager - # entries for the same aspect in the same instance combine). - scopeKey = - e: - let - inst = instOf e; - in - "${fullName e}|${inst}"; - - groupedByName = lib.foldl' ( - acc: e: - let - k = scopeKey e; - in - acc // { ${k} = (acc.${k} or [ ]) ++ [ e ]; } - ) { } preTagged; - - # Detect fullNames appearing in multiple entity instances — these - # need scope-qualified IDs so nodes don't collide. - instancesPerFullName = lib.foldl' ( - acc: e: - let - fn = fullName e; - inst = instOf e; - in - acc // { ${fn} = lib.unique ((acc.${fn} or [ ]) ++ [ inst ]); } - ) { } preTagged; - isMultiInstance = fn: builtins.length (instancesPerFullName.${fn} or [ ]) > 1; - - # Scope-qualified entry ID: append entity instance suffix when the - # same fullName exists in multiple scopes. - seid = - entry: - let - fn = fullName entry; - inst = instOf entry; - in - if isMultiInstance fn && inst != "" then sanitize "${fn}@${inst}" else sanitize fn; - - # The classes this aspect actually contributes to (where - # hasClass = true). Drives `node.class` and `node.classes`. - classesByName = lib.mapAttrs ( - _: es: - lib.unique ( - builtins.filter (c: c != null && c != "") ( - map (e: e.class or null) (builtins.filter (e: e.hasClass or false) es) - ) - ) - ) groupedByName; - - # Per-class metadata for each aspect. Structural fields (parent, - # provider, stage, isParametric) stay on the node top level since - # they're class-independent. The fields here CAN differ per class - # — hasClass is the discriminator, and excluded/replacedBy can - # differ if `meta.handleWith` branches on class. Keyed by class name - # so filters/renderers can ask "is this node active for nixos?". - # - # Merge semantics: when multiple entries exist for the same - # (fullName, class) pair (because the trace visits the aspect - # via multiple include paths), we OR the per-class flags — any - # visit with hasClass=true or excluded=true is enough to mark - # the class's metadata accordingly. - mergePerClassField = a: b: { - hasClass = a.hasClass || b.hasClass; - excluded = a.excluded || b.excluded; - replacedBy = if a.replacedBy != null then a.replacedBy else b.replacedBy; - # Full aspectPath identity of the adapter owner that caused - # the exclusion. Used by `decisionsView` for attribution-based - # grouping. Takes the first non-null value — multiple entries - # for the same (aspect, class) come from different traversal - # paths but the adapter-owner identity is stable. - excludedFrom = if a.excludedFrom != null then a.excludedFrom else b.excludedFrom; - }; - - perClassByName = lib.mapAttrs ( - _: es: - lib.foldl' ( - acc: e: - let - c = e.class or ""; - newEntry = { - hasClass = e.hasClass or false; - excluded = e.excluded or false; - replacedBy = e.replacedBy or null; - excludedFrom = e.excludedFrom or null; - }; - in - if c == "" then - acc - else if acc ? ${c} then - acc // { ${c} = mergePerClassField acc.${c} newEntry; } - else - acc // { ${c} = newEntry; } - ) { } es - ) groupedByName; - - nodes = dedupBy scopeKey preTagged; - # Set of rendered node IDs for parent resolution. - nodeIds = lib.listToAttrs ( - map (e: { - name = seid e; - value = true; - }) nodes - ); - # Set of excluded node IDs — edges FROM these are dropped. - excludedIds = lib.listToAttrs ( - map (e: { - name = seid e; - value = true; - }) (builtins.filter (e: e.excluded or false) preTagged) - ); - - # Resolve a parent reference to the correct scope-qualified ID. - # When the parent appears in multiple scopes, prefer the same scope - # as the child (same entityInstance). - resolveParentId = - parentName: childInst: - let - multi = isMultiInstance parentName; - qualified = sanitize "${parentName}@${childInst}"; - in - if !multi then - sanitize parentName - else if childInst != "" && nodeIds ? ${qualified} then - qualified - else - sanitize parentName; - - edges = dedupBy (e: "${resolveParentId (e.parent or "") (instOf e)}->${seid e}") ( - builtins.filter ( - e: e.parent != null && !(excludedIds ? ${resolveParentId (e.parent or "") (instOf e)}) - ) preTagged - ); - - # Disambiguation: if two distinct entries would render to the same - # short label (e.g. `coolercontrol/class/enable` and - # `lact/class/enable` both shortening to `class/enable`), fall - # back to the full path for the colliding ones. Unique short - # labels stay short. - shortLabelCounts = lib.foldl' ( - acc: e: - let - s = displayName e; - in - acc // { ${s} = (acc.${s} or 0) + 1; } - ) { } nodes; - displayLabel = - entry: - let - s = displayName entry; - in - if (shortLabelCounts.${s} or 0) > 1 then fullName entry else s; - - # Provider-root lookup: map a top-level provider's bare name to - # its entry. Used by `providerEdges` to resolve the source of a - # `provider = [ "foo" ]` chain back to the entry definition of - # `foo`. Only top-level entries (provider == []) are included, - # so a sub-aspect like `bar/foo` can't accidentally shadow a - # top-level `foo` aspect — root-level aspect names are unique - # in the module system. - topLevelEntryByName = lib.listToAttrs ( - map (e: { - name = e.name; - value = e; - }) (builtins.filter (e: (e.provider or [ ]) == [ ]) preTagged) - ); - - # Entity kinds from __ctxTrace. - ctxItems = builtins.filter (i: i.selfName != "") ctxTrace; - entityKindNames = lib.unique ( - builtins.filter (s: s != null) (map (e: e.entityKind or null) preTagged) - ); - - # Node shape classification. - nodeShape = - entry: - if entry.isParametric or false then - "hexagon" - else if entry.isProvider or false then - "trapezoid" - else - "rect"; - - # Node style classification. - nodeStyle = - entry: - if (entry.excluded or false) && (entry.replacedBy or null) != null then - "replaced" - else if entry.excluded or false then - "excluded" - # fx trace: handlers field (list); legacy trace: hasAdapter (bool) - else if (entry.handlers or [ ]) != [ ] || entry.hasAdapter or false then - "adapter" - else if entry.isPolicyDispatch or false then - "policy" - else - "default"; - - # Leaf detection done post-mkNode since we need the final node IDs. - childSet = lib.listToAttrs ( - builtins.concatMap ( - e: - lib.optional (e.parent != null) { - name = resolveParentId e.parent (instOf e); - value = true; - } - ) preTagged - ); - isLeafNode = node: !(childSet ? ${node.id}); - - # Edge style classification. - edgeStyle = - edge: - if (edge.excluded or false) && (edge.replacedBy or null) != null then - "replaced" - else if edge.excluded or false then - "excluded" - else - "normal"; - - inherit (util) nullOr; - - mkNode = - entry: - let - sk = scopeKey entry; - merged = classesByName.${sk} or [ ]; - perClass = perClassByName.${sk} or { }; - in - { - id = seid entry; - # Short form when unique in this graph, full path otherwise. - # See displayLabel / displayName / shortLabelCounts. - label = displayLabel entry; - # Full "provider/sub/.../name" form. Used by structural operations - # like foldProviders that need to resolve a provider chain back - # to its parent node, and by renderers that want to disambiguate - # when the short label would collide. - fullLabel = fullName entry; - # Canonical key matching `identity.pathKey (identity.aspectPath aspect)`. - pathKey = fullName entry; - shape = nodeShape entry; - style = nodeStyle entry; - entityKind = entry.entityKind or null; - entityInstance = entry.entityInstance or null; - # `classes` is the set of classes this aspect contributes to - # (hasClass = true for each). `class` is the legacy joined- - # with-`+` single string for renderers that display it. - # `perClass` is the richer per-class metadata attrset keyed - # by class name — `perClass.nixos.hasClass`, `.excluded`, - # `.replacedBy` — for renderers/filters that need class- - # aware materialization info (e.g. "is this aspect active - # for the homeConfigurations target?"). - classes = merged; - class = if merged == [ ] then "" else lib.concatStringsSep "+" (lib.sort (a: b: a < b) merged); - inherit perClass; - fnArgNames = nullOr [ ] (entry.fnArgNames or [ ]); - isParametric = nullOr false (entry.isParametric or false); - isProvider = nullOr false (entry.isProvider or false); - providerPath = nullOr [ ] (entry.provider or [ ]); - hasClass = nullOr false (entry.hasClass or false); - isExcluded = nullOr false (entry.excluded or false); - isReplaced = (entry.replacedBy or null) != null; - isPolicyDispatch = entry.isPolicyDispatch or false; - policyName = entry.policyName or null; - from = entry.from or null; - to = entry.to or null; - }; - - # Chain identities are ctxId-free (see chainIdentity in aspect.nix), - # so they match entry fullNames directly. Sanitize and use as edge source. - mkEdge = edge: { - from = resolveParentId (edge.parent or "") (instOf edge); - to = seid edge; - style = edgeStyle edge; - label = if (edge.excluded or false) && (edge.replacedBy or null) != null then "replaced" else null; - }; - - # Entity kind ids use `ctx_` prefix unconditionally (never collides with - # mermaid reserved words since the prefix always runs first). - mkEntityKind = kindName: { - id = "ctx_${sanitizeChars kindName}"; - name = kindName; - ctxKeys = - let - item = lib.findFirst (i: i.key == kindName) null ctxItems; - in - if item != null then item.ctxKeys else [ ]; - }; - - # Entity kind transitions: derived from parent→child relationships - # that cross entity kind boundaries. If an entry in kind B has a parent - # in kind A (A ≠ B), there's a transition edge A→B. - entryEntityKindMap = lib.listToAttrs ( - builtins.concatMap ( - e: - let - kind = e.entityKind or null; - in - lib.optional (kind != null) { - name = seid e; - value = kind; - } - ) preTagged - ); - entityEdges = dedupBy (e: "${e.from}->${e.to}") ( - builtins.concatMap ( - e: - let - rawParent = e.parent or null; - parentId = if rawParent == null then "" else resolveParentId rawParent (instOf e); - parentKind = if rawParent == null then null else (entryEntityKindMap.${parentId} or null); - childKind = e.entityKind or null; - in - lib.optional (parentKind != null && childKind != null && parentKind != childKind) { - from = "ctx_${sanitizeChars parentKind}"; - to = "ctx_${sanitizeChars childKind}"; - style = "normal"; - label = null; - } - ) preTagged - ); - - # Provider-provenance edges: dotted "provided-by" links from provider - # sub-aspects back to their provider source (e.g. to-hosts → alice, - # disko/diskoClass → disko). - providerEdges = lib.concatMap ( - entry: - let - prov = entry.provider or [ ]; - providerName = if prov != [ ] then builtins.head prov else null; - providerEntry = if providerName != null then topLevelEntryByName.${providerName} or null else null; - # If the provider has a real top-level entry use its full - # display ID; otherwise fall back to the sanitized bare - # provider name. The phantom target gets a stub node below. - # For multi-instance providers, resolve to the same instance as the child. - providerNodeId = - if providerEntry != null then - let - fn = fullName providerEntry; - inst = instOf entry; - in - if isMultiInstance fn && inst != "" then sanitize "${fn}@${inst}" else sanitize fn - else - sanitize providerName; - in - lib.optional (prov != [ ] && providerName != "den") { - from = providerNodeId; - to = seid entry; - style = "provide"; - label = "provides"; - } - ) nodes; - - # Phantom providers: names referenced by `provider` chains that never - # got their own top-level entry in the trace (e.g. `disko` in a host - # that only includes `disko/diskoClass` and `disko/diskoImport` - # directly). The parent aspect definitionally exists — it's - # defined in the config to host the sub-aspects — so we synthesize - # a stub node for it so the provider-provenance edges have a - # properly-labeled target. - # Policy dispatch edges: connect policy trace entries to the target - # entity kind subgraph. Uses the entity kind ID (ctx_) so the - # edge connects to the kind boundary, not an individual entry. - policyEdges = lib.concatMap ( - entry: - let - isPol = entry.isPolicyDispatch or false; - targetKind = entry.to or null; - targetKindId = if targetKind != null then "ctx_${sanitizeChars targetKind}" else null; - # Only emit if the target kind actually has entries (exists in the graph). - targetExists = targetKind != null && builtins.any (e: (e.entityKind or null) == targetKind) nodes; - in - lib.optional (isPol && targetExists) { - from = seid entry; - to = targetKindId; - style = "policy"; - label = null; - } - ) nodes; - - phantomProviderNames = lib.unique ( - lib.concatMap ( - entry: - let - prov = entry.provider or [ ]; - providerName = if prov != [ ] then builtins.head prov else null; - in - lib.optional ( - providerName != null && providerName != "den" && !(topLevelEntryByName ? ${providerName}) - ) providerName - ) nodes - ); - - phantomStubEntries = map stubEntry phantomProviderNames; - rawNodes = map mkNode (lib.sort (a: b: a.name < b.name) (nodes ++ phantomStubEntries)); - # Tag resolution artifact leaves as terminal — these are parametric - # resolution outputs (e.g., user/resolve(alice,devbox)) that have no - # children. Regular leaf aspects (networking, demo-shell) keep default style. - isResolutionArtifact = n: builtins.match ".*/resolve\\(.*" n.label != null; - finalNodes = map ( - n: - if isLeafNode n && n.style == "default" && isResolutionArtifact n then - n // { style = "terminal"; } - else - n - ) rawNodes; - - # All entries were pre-tagged with rootInstance before dedup, so - # nodes already have correct entityInstance values. No post-hoc - # tagging needed. - taggedNodes = finalNodes; - - entityInstanceNames = lib.unique ( - builtins.filter (s: s != null) (map (n: n.entityInstance) taggedNodes) - ); - entityInstances = map ( - inst: - let - parts = lib.splitString ":" inst; - kind = builtins.head parts; - name = if builtins.length parts > 1 then lib.concatStringsSep ":" (lib.tail parts) else inst; - in - { - id = sanitize "ctx_${inst}"; - inherit kind name; - label = if inst == "flake" then "flake" else "${kind}: ${name}"; - } - ) entityInstanceNames; - in - { - inherit rootName direction; - rootId = sanitize rootName; - nodes = taggedNodes; - edges = - map mkEdge ( - lib.sort ( - a: b: - (a.parent or "") < (b.parent or "") || ((a.parent or "") == (b.parent or "") && a.name < b.name) - ) edges - ) - ++ providerEdges - ++ policyEdges; - entityKinds = map mkEntityKind entityKindNames; - inherit entityEdges entityInstances; - }; - -in -{ - inherit buildGraph emptyNode stubEntry; -} diff --git a/nix/lib/diag/json.nix b/nix/lib/diag/json.nix deleted file mode 100644 index f3a7ff626..000000000 --- a/nix/lib/diag/json.nix +++ /dev/null @@ -1,53 +0,0 @@ -# JSON renderer for the diagram graph IR. -# -# Serialises a graph value to a JSON string suitable for tooling -# consumption (e.g. the fx-diagram integration). -# -# Node fields are derived from `graphLib.emptyNode` so the exported -# schema stays in sync with graph.nix automatically. The structural- -# only booleans `isExcluded` and `isReplaced` are stripped because they -# are filter-internal state, not meaningful to downstream consumers. -{ - lib, - graphLib, -}: -let - # Fields present in emptyNode that are internal to the filter pipeline - # and should not appear in the serialised output. - internalNodeFields = [ - "isExcluded" - "isReplaced" - ]; - - sanitizeNode = - n: - lib.removeAttrs (lib.intersectAttrs graphLib.emptyNode n) internalNodeFields - // lib.optionalAttrs (n ? origin) { inherit (n) origin; }; - - sanitizeEdge = - e: - { - inherit (e) - from - to - style - label - ; - } - // lib.optionalAttrs (e ? origin) { inherit (e) origin; }; - -in -{ - toJSON = - g: - builtins.toJSON { - rootName = g.rootName or ""; - rootId = g.rootId or ""; - direction = g.direction or "LR"; - nodes = map sanitizeNode (g.nodes or [ ]); - edges = map sanitizeEdge (g.edges or [ ]); - entityKinds = g.entityKinds or [ ]; - entityEdges = g.entityEdges or [ ]; - entityInstances = g.entityInstances or [ ]; - }; -} diff --git a/nix/lib/diag/mermaid.nix b/nix/lib/diag/mermaid.nix deleted file mode 100644 index 1d9d4274c..000000000 --- a/nix/lib/diag/mermaid.nix +++ /dev/null @@ -1,352 +0,0 @@ -# Mermaid renderer: graph IR → Mermaid diagram string. -# -# Emits a YAML frontmatter preamble derived from a theme record so that -# mermaid's themeVariables propagate to every downstream diagram type. -# All colors come from the theme; nothing is hardcoded. The graph IR -# carries no theme or color data — both arrive via the render opts. -# -# `toMermaidWith` accepts an opts record: -# -# { theme ? themes.defaultTheme -# # Base16-derived theme record (see diag.themeFromBase16). -# -# , mermaidConfig ? {} -# # Extra config merged over the theme-derived base. Good for -# # layout tweaks, flowchart options, themeVariables overrides. -# } -# -# Example — switch a dense flowchart to ELK layout: -# -# diag.toMermaidWith { -# inherit theme; -# mermaidConfig = { -# layout = "elk"; -# elk = { -# mergeEdges = true; -# nodePlacementStrategy = "LINEAR_SEGMENTS"; -# }; -# }; -# } graph; -# -# Example — force-directed layout via cose-bilkent (availability -# depends on the mermaid layout plugins in use): -# -# diag.toMermaidWith { -# inherit theme; -# mermaidConfig = { -# layout = "cose-bilkent"; -# # cose-bilkent specific tuning goes under its own key if -# # the plugin reads one. Most deployments only need `layout`. -# }; -# } graph; -{ - lib, - themes, - colors, - util, - renderUtil, -}: -let - inherit (colors) nodeColorFor; - inherit (util) fmtArgs; - inherit (renderUtil) renderMermaid visualFor; - - toMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootName - rootId - nodes - edges - entityKinds - entityEdges - direction - ; - hasEntityKinds = entityKinds != [ ]; - hasEntityInstances = (graph.entityInstances or [ ]) != [ ]; - nodeById = builtins.listToAttrs ( - map (n: { - name = n.id; - value = n; - }) nodes - ); - rootColor = theme.rootFill; - vf = visualFor { inherit theme nodeColorFor; }; - - # When the graph has no stage subgraphs (flat views like providers, - # adapters, parametric, simple), append the node's stage to the - # label as a context decoration: `label · stage`. In stage-grouped - # views the stage is already visible via the subgraph cluster, so - # no decoration is added there. - # - # `kindSuffix` lives AFTER parametric fnArgs so hexagon labels - # read `name({ args }) · stage` (not `name · stage({ args })`). - kindSuffix = - node: - if !hasEntityKinds && !hasEntityInstances && (node.entityKind or null) != null then - " · ${node.entityKind}" - else - ""; - - mermaidShape = - node: - if node.shape == "hexagon" then - "{{\"${node.label}${kindSuffix node}\"}}" - else if node.shape == "trapezoid" then - "[/\"${node.label}${kindSuffix node}\"\\]" - else - "[\"${node.label}${kindSuffix node}\"]"; - - # Every node gets its own per-node class. Excluded/replaced nodes - # don't fall through to a flat `excluded` / `replaced` class — - # that would collapse every excluded node onto one color. Instead - # they share the per-node accent fill and signal state via the - # border color + dash pattern (see nodeColorDefs). - mermaidStyle = node: ":::${node.id}_c"; - - mermaidArrow = - edge: - if edge.style == "replaced" then - "-.->|replaced|" - else if edge.style == "excluded" then - "-.-x" - else if edge.style == "provide" then - "-.->|${edge.label}|" - else if edge.style == "policy" then - "-.->|dispatches|" - else - "-->"; - - nodeDecl = node: " ${node.id}${mermaidShape node}${mermaidStyle node}"; - edgeDecl = edge: " ${edge.from} ${mermaidArrow edge} ${edge.to}"; - - entitySubgraph = - ek: - let - ekNodes = builtins.filter (n: n.entityKind == ek.name && n.id != rootId) nodes; - ekEdgesList = builtins.filter ( - e: - let - fromNode = nodeById.${e.from} or null; - toNode = nodeById.${e.to} or null; - fromKind = if fromNode != null then fromNode.entityKind else null; - toKind = if toNode != null then toNode.entityKind else null; - in - fromNode != null - && fromKind == ek.name - && (toKind == null || toKind == ek.name) - && (e.style or "normal") != "policy" - ) edges; - ctxLabel = if ek.ctxKeys != [ ] then " { ${lib.concatStringsSep ", " ek.ctxKeys} }" else ""; - in - lib.optional (ekNodes != [ ]) ( - " subgraph ${ek.id}[\"${ek.name}${ctxLabel}\"]\n" - + lib.concatMapStringsSep "\n" nodeDecl ekNodes - + "\n" - + lib.concatMapStringsSep "\n" edgeDecl ekEdgesList - + "\n end" - ); - - # Instance-based subgraph grouping (used when entityInstances are present). - # Reconstruct the key that nodes carry in their entityInstance field. - instKey = inst: if inst.kind == inst.name then inst.name else "${inst.kind}:${inst.name}"; - - instanceSubgraph = - inst: - let - key = instKey inst; - instNodes = builtins.filter (n: (n.entityInstance or null) == key && n.id != rootId) nodes; - instEdges = builtins.filter ( - e: - let - fromNode = nodeById.${e.from} or null; - toNode = nodeById.${e.to} or null; - fromInst = if fromNode != null then fromNode.entityInstance or null else null; - toInst = if toNode != null then toNode.entityInstance or null else null; - in - fromNode != null - && fromInst == key - && (toInst == null || toInst == key) - && (e.style or "normal") != "policy" - ) edges; - in - lib.optional (instNodes != [ ]) ( - " subgraph ${inst.id}[\"${inst.label}\"]\n" - + lib.concatMapStringsSep "\n" nodeDecl instNodes - + "\n" - + lib.concatMapStringsSep "\n" edgeDecl instEdges - + "\n end" - ); - - # Policy nodes without an entityInstance are rendered outside subgraphs. - # Those with an entityInstance go inside their scope's subgraph. - policyNodes = builtins.filter ( - n: (n.isPolicyDispatch or false) && (n.entityInstance or null) == null - ) nodes; - - # `topLevelNodes` are the nodes declared outside any stage subgraph. - # When the graph is flat (no stages), that's every non-host node. - # When the graph has stage subgraphs, it's only the stage-null - # nodes (the others get declared inside their subgraph block). - topLevelNodes = - if hasEntityInstances then - # All non-policy/non-boundary nodes live in instance subgraphs. - [ ] - else if hasEntityKinds then - builtins.filter (n: n.entityKind == null && n.id != rootId) nodes - else - builtins.filter (n: n.id != rootId) nodes; - # Edges not assigned to any subgraph: either from stage-null nodes, - # or cross-stage edges (from and to in different stages). - unmappedEdges = builtins.filter ( - e: - let - fromNode = nodeById.${e.from} or null; - toNode = nodeById.${e.to} or null; - fromKind = if fromNode != null then fromNode.entityKind else null; - toKind = if toNode != null then toNode.entityKind else null; - isCrossKind = fromKind != null && toKind != null && fromKind != toKind; - in - (fromNode != null && fromKind == null) || (isCrossKind && (e.style or "normal") != "policy") - ) edges; - - # Cross-instance edges + edges FROM bridge nodes. These are rendered - # outside all subgraphs so mermaid doesn't pull bridge nodes into a - # Cross-instance edges: both endpoints have an entityInstance but they differ. - crossInstanceEdges = builtins.filter ( - e: - let - fromNode = nodeById.${e.from} or null; - toNode = nodeById.${e.to} or null; - fromInst = if fromNode != null then fromNode.entityInstance or null else null; - toInst = if toNode != null then toNode.entityInstance or null else null; - in - fromInst != null && toInst != null && fromInst != toInst && (e.style or "normal") != "policy" - ) edges; - - # Stages that would *not* get a subgraph declaration because they - # contain no user-visible nodes, yet are still referenced by entityEdges. - # Emit a stub node declaration so mermaid shows the friendly label - # instead of rendering the raw sanitized ID. - nonEmptyEntityIds = map (s: s.id) ( - builtins.filter (s: builtins.any (n: n.entityKind == s.name) nodes) entityKinds - ); - referencedEntityIds = lib.unique ( - lib.concatMap (e: [ - e.from - e.to - ]) entityEdges - ); - stubEntities = builtins.filter ( - s: builtins.elem s.id referencedEntityIds && !(builtins.elem s.id nonEmptyEntityIds) - ) entityKinds; - stubEntityDecl = - ek: - let - ctxLabel = if ek.ctxKeys != [ ] then " { ${lib.concatStringsSep ", " ek.ctxKeys} }" else ""; - in - " ${ek.id}[\"${ek.name}${ctxLabel}\"]"; - - # Per-node class declarations. Fill/stroke/text come from visualFor - # so changing the theme reshuffles colors without IR rebuilding. - # The borderExtra string is still mermaid-specific CSS (dash patterns - # + stroke widths) and stays local to this renderer. - # - # Excluded / replaced nodes get the per-node accent fill too; the - # 5-5 dash pattern + stroke color (excludedStroke / replacedStroke - # from visualFor) signals state while keeping each node individually - # colored. - # - # Diff views set `node.origin` — a (removed) / b (added) / both. - # In a diff view the origin tag takes precedence over the default - # style, because seeing "this was added by the right-hand graph" - # is the whole point. - nodeColorDefs = map ( - node: - let - v = vf node; - origin = node.origin or null; - # diff-specific stroke overrides accent when origin is set - diffStroke = - if origin == "a" then - theme.excludedStroke - else if origin == "b" then - theme.rootStroke - else - v.stroke; - borderExtra = - if origin == "a" then - ",stroke-dasharray: 5 5,stroke-width:3px" - else if origin == "b" then - ",stroke-width:4px" - else if v.isExcluded || v.isReplaced then - ",stroke-dasharray: 5 5,stroke-width:2px" - else if v.isAdapter then - ",stroke-width:3px" - else if v.isTerminal then - ",stroke-dasharray: 2 2,stroke-width:1px" - else if v.isPolicy then - ",stroke-width:2px,stroke-dasharray: 8 4" - else if !node.hasClass then - ",stroke-dasharray: 3 3,stroke-width:1px" - else - ",stroke-width:2px"; - in - " classDef ${node.id}_c fill:${v.fill},stroke:${diffStroke},color:${v.text}${borderExtra}" - ) nodes; - in - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph ${direction}"; - } - ( - [ " ${rootId}([${rootName}]):::root" ] - ++ map nodeDecl topLevelNodes - ++ [ "" ] - ++ ( - if hasEntityInstances then - lib.concatMap instanceSubgraph (graph.entityInstances or [ ]) - ++ [ "" ] - ++ map nodeDecl policyNodes - ++ map edgeDecl (builtins.filter (e: (e.style or "normal") == "policy") edges) - ++ map edgeDecl crossInstanceEdges - else if hasEntityKinds then - lib.concatMap entitySubgraph entityKinds - ++ map stubEntityDecl stubEntities - ++ [ "" ] - ++ map edgeDecl entityEdges - ++ map edgeDecl (builtins.filter (e: (e.style or "normal") == "policy") edges) - ++ map edgeDecl unmappedEdges - else - map edgeDecl edges - ) - ++ [ - "" - " classDef root fill:${theme.rootFill},stroke:${theme.rootStroke},color:${theme.rootText},font-weight:bold" - ] - ++ nodeColorDefs - ++ lib.optionals hasEntityInstances ( - map ( - inst: "style ${inst.id} fill:${theme.clusterBg},stroke:${theme.clusterBorder},stroke-width:2px" - ) (graph.entityInstances or [ ]) - ) - ++ lib.optionals (hasEntityKinds && !hasEntityInstances) ( - map ( - s: "style ${s.id} fill:${theme.clusterBg},stroke:${theme.clusterBorder},stroke-width:2px" - ) entityKinds - ) - ); - # Back-compat: zero-config form stays the same shape the rest of the - # library uses (`diag.toMermaid graph`), while callers needing to - # tweak frontmatter can use `diag.toMermaidWith { … } graph`. - toMermaid = toMermaidWith { }; -in -{ - inherit toMermaid toMermaidWith; -} diff --git a/nix/lib/diag/mindmap.nix b/nix/lib/diag/mindmap.nix deleted file mode 100644 index 9cebcd615..000000000 --- a/nix/lib/diag/mindmap.nix +++ /dev/null @@ -1,93 +0,0 @@ -# Mindmap renderer (mermaid `mindmap`). -# -# Mermaid mindmap is a pure-tree layout with radial placement around a -# root node. It's a better fit than `graph TD` for hierarchies where -# every child has exactly one parent — no cross-edges, no multiple -# parents. We use it for the provider hierarchy: root = host, branches -# = top-level providers, leaves = provider sub-aspects. -# -# Syntax is indentation-based: -# -# mindmap -# root((Host)) -# Provider1 -# sub1 -# sub2 -# Provider2 -# sub3 -# -# The renderer assumes the graph is already tree-shaped — typically -# the output of `diag.graph.providersOnly`. Non-tree input (multiple -# parents per node) produces confusing output since mindmap can only -# represent trees. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (renderUtil) renderMermaid; - - # Escape quote characters so mermaid doesn't choke on labels. - esc = s: lib.replaceStrings [ "\"" ] [ "'" ] s; - - toMindmapMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootName - rootId - nodes - edges - ; - - # Build adjacency: parent id -> list of child ids. - childrenOf = lib.foldl' ( - acc: e: acc // { ${e.from} = (acc.${e.from} or [ ]) ++ [ e.to ]; } - ) { } edges; - - nodeById = lib.listToAttrs ( - map (n: { - name = n.id; - value = n; - }) nodes - ); - - # Roots: nodes that are not the target of any edge in the tree. - targetIds = lib.listToAttrs ( - map (e: { - name = e.to; - value = true; - }) edges - ); - rootNodes = builtins.filter (n: !(targetIds ? ${n.id}) && n.id != rootId) nodes; - - # Recursively render a subtree at the given indentation depth. - # Indentation step is 2 spaces per level (mermaid mindmap spec). - renderSubtree = - depth: id: - let - indent = lib.concatStrings (lib.replicate depth " "); - node = nodeById.${id} or null; - label = if node != null then node.label else id; - children = childrenOf.${id} or [ ]; - in - [ "${indent}${esc label}" ] ++ lib.concatMap (renderSubtree (depth + 1)) children; - - bodyLines = [ " root((${esc rootName}))" ] ++ lib.concatMap (r: renderSubtree 2 r.id) rootNodes; - in - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "mindmap"; - } bodyLines; - - toMindmapMermaid = toMindmapMermaidWith { }; -in -{ - inherit toMindmapMermaid toMindmapMermaidWith; -} diff --git a/nix/lib/diag/namespace.nix b/nix/lib/diag/namespace.nix deleted file mode 100644 index c854a1447..000000000 --- a/nix/lib/diag/namespace.nix +++ /dev/null @@ -1,127 +0,0 @@ -# Static namespace graph builder. -# -# Walks `den.aspects` declarations (no host resolution) and builds a -# graph IR showing authored building blocks and their static inclusions. -{ - lib, - util, - graphLib, - aspects ? { }, -}: -let - defaultAspects = aspects; - namespaceGraph = - { - name ? "aspects", - aspects ? defaultAspects, - direction ? "TD", - filter ? (_: true), - }: - let - sanitize = util.makeIdSanitizer "ns"; - isAspect = - v: builtins.isAttrs v && v ? includes && v ? name && v ? meta && builtins.isAttrs (v.meta or null); - entries = lib.filterAttrs (_: v: isAspect v && filter v) aspects; - aspectNames = builtins.attrNames entries; - - refToName = - ref: - if !builtins.isAttrs ref then - null - else if ref ? name && entries ? ${ref.name} then - ref.name - else - null; - - staticIncludes = - value: - let - incs = value.includes or [ ]; - in - if builtins.isList incs then incs else [ ]; - - mkNode = - name: value: - let - incs = staticIncludes value; - hasFunctorInclude = lib.any (i: !builtins.isAttrs i) incs; - hasConstraint = (value.meta.handleWith or null) != null; - hasProvides = (value.provides or { }) != { }; - hasIncludes = incs != [ ]; - providerChain = value.meta.provider or [ ]; - in - graphLib.emptyNode - // { - id = sanitize name; - label = name; - fullLabel = name; - shape = - if hasFunctorInclude then - "hexagon" - else if hasProvides then - "trapezoid" - else - "rect"; - style = if hasConstraint then "adapter" else "default"; - providerPath = providerChain; - hasClass = true; - # Structural role for consistent coloring: host aspects that - # include others vs shared leaf aspects that are included. - entityKind = if hasIncludes then "host" else "shared"; - # colorKey makes all nodes of the same role hash to the same - # accent color (no per-name perturbation from nodeColorFor). - colorKey = if hasIncludes then "host" else "shared"; - }; - - mkEdges = - name: value: - let - incs = staticIncludes value; - fromId = sanitize name; - in - lib.concatMap ( - i: - let - target = refToName i; - in - lib.optional (target != null && target != name) { - from = fromId; - to = sanitize target; - style = "normal"; - label = null; - } - ) incs; - - allNodes = lib.mapAttrsToList mkNode entries; - declEdges = lib.concatMap (n: mkEdges n entries.${n}) aspectNames; - - rootId = sanitize name; - includedTargets = lib.listToAttrs ( - map (e: { - name = e.to; - value = true; - }) declEdges - ); - rootEdges = lib.concatMap ( - aname: - let - nid = sanitize aname; - in - lib.optional (!(includedTargets ? ${nid})) { - from = rootId; - to = nid; - style = "normal"; - label = null; - } - ) aspectNames; - in - { - rootName = name; - inherit rootId direction; - nodes = allNodes; - edges = rootEdges ++ declEdges; - entityKinds = [ ]; - entityEdges = [ ]; - }; -in -namespaceGraph diff --git a/nix/lib/diag/plantuml.nix b/nix/lib/diag/plantuml.nix deleted file mode 100644 index d8f4890cc..000000000 --- a/nix/lib/diag/plantuml.nix +++ /dev/null @@ -1,141 +0,0 @@ -# PlantUML renderer: graph IR → PlantUML string. -# -# Emits `skinparam` directives derived from a theme passed via the -# render opts so the rendered SVG matches the shared palette used by -# mermaid and dot. Theme is render-time, never on the IR. -{ - lib, - themes, - colors, - util, - renderUtil, -}: -let - inherit (colors) nodeColorFor; - inherit (util) fmtArgs; - inherit (renderUtil) skinparamFor visualFor; - - # Element types this renderer emits. Rectangle/Hexagon/Card are filled - # per-node with an accent color (see `pumlStyle` below — we override the - # fill at element declaration), so their default font color must be - # dark (rootText) for readability on bright accent fills. Package/Note - # inherit the clusterBg palette. - plantumlElements = [ - "Rectangle" - "Hexagon" - "Card" - "Package" - "Note" - ]; - plantumlAccentElements = [ - "Rectangle" - "Hexagon" - "Card" - ]; - - toPlantUMLWith = - { - theme ? themes.defaultTheme, - }: - graph: - let - inherit (graph) - rootName - rootId - nodes - edges - entityKinds - entityEdges - ; - hasEntityKinds = entityKinds != [ ]; - rootColor = theme.rootFill; - vf = visualFor { inherit theme nodeColorFor; }; - - kindSuffix = - node: if !hasEntityKinds && (node.entityKind or null) != null then " · ${node.entityKind}" else ""; - - pumlShape = - node: - if node.shape == "hexagon" then - "hexagon" - else if node.shape == "trapezoid" then - "card" - else - "rectangle"; - - # Escape angle brackets to prevent PlantUML from interpreting - # as a stereotype/generic. - escapePuml = s: builtins.replaceStrings [ "<" ">" ] [ "<" ">" ] s; - - pumlLabel = - node: - let - label = escapePuml node.label; - in - if node.isParametric then - "${label}\\n({ ${fmtArgs node.fnArgNames} })${kindSuffix node}" - else - "${label}${kindSuffix node}"; - - # PlantUML: `#fill` sets background; `;line.dashed` appends a dashed - # border. Chaining style directives with `;` is the supported form. - pumlStyle = - node: - let - v = vf node; - in - if v.isExcluded || v.isReplaced then " ${v.fill};line.dashed" else " ${v.fill}"; - - nodeDecl = node: "${pumlShape node} \"${pumlLabel node}\" as ${node.id}${pumlStyle node}"; - - edgeDecl = - edge: - let - arrow = - if edge.style == "excluded" then - "..x" - else if edge.style == "replaced" then - "..>" - else - "-->"; - label = if edge.label != null then " : ${edge.label}" else ""; - in - "${edge.from} ${arrow} ${edge.to}${label}"; - - entitySubgraph = - ek: - let - ekNodes = builtins.filter (n: n.entityKind == ek.name && n.id != rootId) nodes; - ctxLabel = if ek.ctxKeys != [ ] then " { ${lib.concatStringsSep ", " ek.ctxKeys} }" else ""; - safeName = builtins.replaceStrings [ "-" " " "/" "." "(" ")" ] [ "_" "_" "__" "_" "_" "_" ] ek.name; - pkgAlias = "ek_${safeName}"; - in - lib.optional (ekNodes != [ ]) ( - "package \"${ek.name}${ctxLabel}\" as ${pkgAlias} {\n" - + lib.concatMapStringsSep "\n" (n: " ${nodeDecl n}") ekNodes - + "\n}" - ); - in - lib.concatStringsSep "\n" ( - [ - "@startuml" - "left to right direction" - (skinparamFor { - inherit theme; - elements = plantumlElements; - onAccentFill = plantumlAccentElements; - }) - "rectangle \"${rootName}\" as ${rootId} ${rootColor}" - ] - ++ lib.concatMap entitySubgraph entityKinds - ++ map nodeDecl (builtins.filter (n: n.entityKind == null && n.id != rootId) nodes) - ++ [ "" ] - ++ map edgeDecl edges - ++ map edgeDecl entityEdges - ++ [ "@enduml" ] - ); - toPlantUML = toPlantUMLWith { }; -in -{ - inherit toPlantUML toPlantUMLWith; -} diff --git a/nix/lib/diag/render-context.nix b/nix/lib/diag/render-context.nix deleted file mode 100644 index 7659f4c09..000000000 --- a/nix/lib/diag/render-context.nix +++ /dev/null @@ -1,52 +0,0 @@ -# Render context factory. -# -# Builds a record carrying everything needed to render views: -# pre-configured renderer sets and SVG builder functions. -{ - themes, - renderers, - renderInfra, - views, -}: -{ - pkgs, - theme ? themes.defaultTheme, - mermaidConfig ? { }, - mermaidCli ? pkgs.mermaid-cli, - renderFonts ? [ - pkgs.jetbrains-mono - pkgs.fira-code - pkgs.dejavu_fonts - pkgs.liberation_ttf - pkgs.noto-fonts - ], - fontFamily ? "JetBrains Mono, Fira Code, DejaVu Sans Mono, monospace", -}: -let - infra = renderInfra { - inherit - pkgs - theme - renderFonts - fontFamily - mermaidCli - ; - }; - render = renderers { inherit theme; }; - renderDense = renderers { inherit theme mermaidConfig; }; - # Self-referential: rc passes itself to view constructors so views - # can access render/renderDense/mmdSourceToSvg from the same record. - # Works because Nix attrsets are lazily evaluated. - rc = infra // { - inherit render renderDense theme; - views = { - core = views.core rc; - host = views.host rc; - user = views.user rc; - home = views.home rc; - fleet = views.fleet rc; - classViews = views.classViews rc; - }; - }; -in -rc diff --git a/nix/lib/diag/render-infra.nix b/nix/lib/diag/render-infra.nix deleted file mode 100644 index c585ac650..000000000 --- a/nix/lib/diag/render-infra.nix +++ /dev/null @@ -1,124 +0,0 @@ -# Nix derivation builders for SVG conversion. -# -# Provides mmdSourceToSvg, pumlSourceToSvg, dotSourceToSvg — pkgs-heavy -# build infrastructure orthogonal to graph IR. -{ lib }: -{ - pkgs, - theme, - renderFonts ? [ - pkgs.jetbrains-mono - pkgs.fira-code - pkgs.dejavu_fonts - pkgs.liberation_ttf - pkgs.noto-fonts - ], - fontFamily ? "JetBrains Mono, Fira Code, DejaVu Sans Mono, monospace", - mermaidCli ? pkgs.mermaid-cli, -}: -let - renderFontsConf = pkgs.makeFontsConf { fontDirectories = renderFonts; }; - renderFontEnv = '' - export HOME=$TMPDIR - export XDG_CACHE_HOME=$TMPDIR/.cache - export XDG_CONFIG_HOME=$TMPDIR/.config - mkdir -p "$XDG_CACHE_HOME/fontconfig" "$XDG_CONFIG_HOME/fontconfig" - ''; - - mmdPuppeteerConfig = pkgs.writeText "puppeteer-config.json" ( - builtins.toJSON { - args = [ - "--no-sandbox" - "--disable-dev-shm-usage" - ]; - } - ); - mmdConfig = pkgs.writeText "mermaid-config.json" ( - builtins.toJSON { - maxTextSize = 10000000; - maxEdges = 100000; - inherit fontFamily; - securityLevel = "loose"; - } - ); - - mmdSourceToSvg = - baseName: source: - let - src = pkgs.writeText "${baseName}.mmd" source; - in - pkgs.runCommand "${baseName}.mmd.svg" - { - buildInputs = renderFonts; - FONTCONFIG_FILE = renderFontsConf; - } - '' - ${renderFontEnv} - if ${mermaidCli}/bin/mmdc \ - -i ${src} \ - -o "$TMPDIR/out.svg" \ - -p ${mmdPuppeteerConfig} \ - -c ${mmdConfig} \ - -b '${theme.background}' \ - -q 2>"$TMPDIR/mmd-err"; then - cp "$TMPDIR/out.svg" "$out" - else - echo "mermaid-cli failed for ${baseName}:" >&2 - cat "$TMPDIR/mmd-err" >&2 || true - cat > $out <<'PLACEHOLDER_EOF' - - - - - Mermaid render unavailable - - - This diagram type may require a newer mermaid than available. - - - See source in the accompanying .md file. - - - PLACEHOLDER_EOF - fi - ''; - - pumlSourceToSvg = - baseName: source: - let - src = pkgs.writeText "${baseName}.puml" source; - in - pkgs.runCommand "${baseName}.puml.svg" - { - buildInputs = renderFonts; - FONTCONFIG_FILE = renderFontsConf; - } - '' - ${renderFontEnv} - ${pkgs.plantuml}/bin/plantuml -tsvg -pipe < ${src} > $out - ''; - - dotSourceToSvg = - base: source: - let - src = pkgs.writeText "${base}.dot" source; - in - pkgs.runCommand "${base}.dot.svg" - { - buildInputs = renderFonts; - FONTCONFIG_FILE = renderFontsConf; - } - '' - ${renderFontEnv} - ${pkgs.graphviz}/bin/dot -Tsvg -o $out ${src} - ''; -in -{ - inherit - renderFonts - renderFontsConf - mmdSourceToSvg - pumlSourceToSvg - dotSourceToSvg - ; -} diff --git a/nix/lib/diag/render-util.nix b/nix/lib/diag/render-util.nix deleted file mode 100644 index 785fefd18..000000000 --- a/nix/lib/diag/render-util.nix +++ /dev/null @@ -1,161 +0,0 @@ -# Renderer-level primitives shared across mermaid / plantuml / c4. -# -# Everything here reads a theme — it's render-time. The graph IR never -# touches anything in this file. -{ lib, themes }: -let - inherit (themes) mermaidFrontmatter; - - # Prepend mermaid init-directive frontmatter + diagram keyword onto a - # list of body lines and join with newlines. Every mermaid renderer - # (flowchart, sequence, sankey, ishikawa, treemap) funnels through here. - # - # diagramKind examples: "graph LR", "sequenceDiagram", "sankey-beta", - # "ishikawa-beta", "treemap-beta". - renderMermaid = - { - theme, - mermaidConfig ? { }, - diagramKind, - }: - bodyLines: - lib.concatStringsSep "\n" ( - [ - (mermaidFrontmatter theme mermaidConfig) - diagramKind - ] - ++ bodyLines - ); - - # Canonical palette-to-skinparam mapping. Takes a theme and a list of - # element types (e.g. [ "Rectangle" "Hexagon" "Card" ]) and emits the - # three `*BackgroundColor` / `*BorderColor` / `*FontColor` directives - # per element plus a shared header (background / fonts / arrows). - # - # Three classes of element: - # - # boundaryLike — Boundary / Package / Note: clusterBg fill, foreground text - # onAccentFill — element types the caller fills per-node with an accent - # color (e.g. plain plantuml's Rectangle/Hexagon/Card). - # skinparam background is irrelevant for these but still - # emitted for parser sanity; the font color must be dark - # (rootText = base16 base00) to be readable on bright - # accent fills. - # default — everything else (C4's Person/System/Container/Component): - # nodeBg fill, nodeText (light on dark theme) — the - # PlantUML macros control these, not per-node styles. - skinparamFor = - { - theme, - elements, - onAccentFill ? [ ], - }: - let - boundaryLike = [ - "Boundary" - "Package" - "Note" - ]; - elementBlock = - el: - let - isBoundary = builtins.elem el boundaryLike; - isAccent = builtins.elem el onAccentFill; - bg = if isBoundary then theme.clusterBg else theme.nodeBg; - border = if isBoundary then theme.clusterBorder else theme.nodeBorder; - fg = - if isAccent then - theme.rootText - else if isBoundary then - theme.foreground - else - theme.nodeText; - in - '' - skinparam ${el}BackgroundColor ${bg} - skinparam ${el}BorderColor ${border} - skinparam ${el}FontColor ${fg} - ''; - header = '' - skinparam backgroundColor ${theme.background} - skinparam defaultFontColor ${theme.foreground} - skinparam defaultFontName "JetBrains Mono,monospace" - skinparam arrowColor ${theme.edgeColor} - skinparam arrowFontColor ${theme.edgeText} - ''; - in - header + lib.concatStrings (map elementBlock elements); - - # Map a node's style ("excluded" / "replaced" / "adapter" / "default") to - # a renderer-neutral visual record. Each renderer formats the record in - # its native syntax but no longer duplicates the style→color decision. - # - # `nodeColorFor` is passed in (not imported) so render-util doesn't pull - # on colors.nix — keeps the dependency shape shallow. - # - # Color policy: EVERY node fills with its per-node accent, including - # excluded/replaced. That way excluded/replaced nodes stay visually - # distinct from each other (instead of all collapsing onto a single - # flat red or orange). The "excluded" / "replaced" semantic is carried - # by the stroke color (excludedStroke = base08 red, replacedStroke = - # base09 orange) + a dashed border. Only the border signals state; the - # fill preserves identity. - visualFor = - { theme, nodeColorFor }: - node: - let - isExcluded = node.style == "excluded"; - isReplaced = node.style == "replaced"; - isAdapter = node.style == "adapter"; - isTerminal = node.style == "terminal"; - isPolicy = node.style == "policy"; - isDefault = !(isExcluded || isReplaced || isAdapter || isTerminal || isPolicy); - # colorKey overrides the name used for per-node hashing. When set, - # all nodes with the same entityKind AND colorKey get the same color - # (no per-name perturbation). Used by namespace graphs where color - # means structural role, not individual identity. - accent = nodeColorFor theme (node.entityKind or null) (node.colorKey or node.label); - in - { - inherit - isExcluded - isReplaced - isAdapter - isTerminal - isPolicy - isDefault - ; - fill = if isTerminal then theme.clusterBg else accent; - stroke = - if isExcluded then - theme.excludedStroke - else if isReplaced then - theme.replacedStroke - else if isTerminal then - theme.clusterBorder - else - accent; - text = if isTerminal then theme.foreground else theme.rootText; - strokeStyle = - if isExcluded || isReplaced then - "dashed" - else if isTerminal then - "dotted" - else - "solid"; - }; - # Creates a { toFoo, toFooWith } pair from a *With function. - # withFn already has defaults for all its args, so `withFn {}` works. - mkRenderer = name: withFn: { - "${name}With" = withFn; - ${name} = withFn { }; - }; -in -{ - inherit - renderMermaid - skinparamFor - visualFor - mkRenderer - ; -} diff --git a/nix/lib/diag/sankey.nix b/nix/lib/diag/sankey.nix deleted file mode 100644 index e7a52e281..000000000 --- a/nix/lib/diag/sankey.nix +++ /dev/null @@ -1,210 +0,0 @@ -# Sankey flow diagrams (mermaid sankey-beta). -# -# The inclusion hierarchy mapped onto a sankey diagram: each edge carries -# weight = the number of leaves reachable from its target node. Edges near -# the root accumulate a lot of flow (many descendants); edges near leaves -# carry weight 1. The resulting diagram narrows naturally with depth and -# reveals where a host's configuration mass lives. -# -# Per-host: flows from host → top-level aspects → descendants → leaves. -# Fleet: flows from users → hosts, weighted by class count, so a -# user that configures many hosts produces a wide ribbon. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (util) dedupBy adjacency; - inherit (renderUtil) renderMermaid; - - # Sankey uses CSV rows per edge. Labels are quoted (RFC 4180 style) so - # commas and whitespace in aspect names don't break parsing. - csvQuote = s: "\"${lib.replaceStrings [ "\"" ] [ "\"\"" ] s}\""; - - # --- Per-host sankey: depth-oriented inclusion flow --- - toSankeyMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootId - rootName - nodes - edges - ; - - # Dedup edges, drop self-loops, and break cycles. - # Sankey diagrams don't support any circular links. - noSelfLoops = builtins.filter (e: e.from != e.to) edges; - dedupedEdges = dedupBy (e: "${e.from}->${e.to}") noSelfLoops; - fwdAdj = (adjacency dedupedEdges).outOf; - isReachable = - start: target: visited: - if start == target then - true - else if visited ? ${start} then - false - else - builtins.any (next: isReachable next target (visited // { ${start} = true; })) ( - fwdAdj.${start} or [ ] - ); - # O(E*V) worst case — DFS per edge. Fine for typical aspect graphs - # (tens to low hundreds of nodes). Would need optimization for fleet-scale. - uniqueEdges = builtins.filter (e: !(isReachable e.to e.from { })) dedupedEdges; - - childMap = (adjacency uniqueEdges).outOf; - - # Precompute leaf counts bottom-up. Nodes processed in reverse so - # children are counted before parents. A leaf = 1; interior = sum of children. - allNodeIds = map (n: n.id) nodes; - leafCounts = - let - go = - memo: ids: - if ids == [ ] then - memo - else - let - id = builtins.head ids; - rest = builtins.tail ids; - kids = childMap.${id} or [ ]; - count = if kids == [ ] then 1 else lib.foldl' (acc: k: acc + (memo.${k} or 1)) 0 kids; - in - go (memo // { ${id} = count; }) rest; - in - go { } (lib.reverseList allNodeIds); - - nodeById = lib.listToAttrs ( - map (n: { - name = n.id; - value = n; - }) nodes - ); - labelOf = - id: - if id == rootId then - rootName - else if nodeById ? ${id} then - nodeById.${id}.label - else - id; - - edgeLine = - e: - let - w = leafCounts.${e.to} or 1; - in - "${csvQuote (labelOf e.from)},${csvQuote (labelOf e.to)},${toString w}"; - in - if uniqueEdges == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sankey-beta"; - } - [ - "" - "${csvQuote rootName},${csvQuote "(no aspects)"},1" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sankey-beta"; - } ([ "" ] ++ map edgeLine uniqueEdges); - - # --- Fleet sankey: user → host provisioning flow --- - # - # Each user-to-host relation carries weight = number of classes the user - # brings (`label` is a `+`-joined class list). This gives wider ribbons - # for users with multiple classes on the same host. - toFleetSankeyMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleet: - let - inherit (fleet) relations; - relLine = - rel: - let - weight = if rel.label == "uses" then 1 else builtins.length (lib.splitString "+" rel.label); - in - "${csvQuote rel.from},${csvQuote rel.to},${toString weight}"; - in - if relations == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sankey-beta"; - } - [ - "" - "${csvQuote fleet.flakeName},${csvQuote "(empty fleet)"},1" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sankey-beta"; - } ([ "" ] ++ map relLine relations); - - # --- Fan-metrics sankey: flow weight = aspect reuse (fan-in) --- - # - # Takes a list of `{ id, label, fanIn, fanOut, total, ... }` records - # (e.g. from `diag.graph.fanMetrics graph`) and emits a sankey that - # flows host → aspect with weight = fanIn (reuse count), truncated - # to the top N by total so huge graphs stay readable. Reveals which - # aspects are the "library" (high fanIn) and which are orchestrators - # (high fanOut). - toFanMetricsSankeyWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - topN ? 30, - }: - { rootName, metrics }: - let - nonZero = builtins.filter (m: m.fanIn > 0 || m.fanOut > 0) metrics; - top = lib.take topN nonZero; - fanInLines = map (m: "${csvQuote m.label},${csvQuote "reused"},${toString m.fanIn}") ( - builtins.filter (m: m.fanIn > 0) top - ); - fanOutLines = map (m: "${csvQuote "orchestrates"},${csvQuote m.label},${toString m.fanOut}") ( - builtins.filter (m: m.fanOut > 0) top - ); - in - if top == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sankey-beta"; - } - [ - "" - "${csvQuote rootName},${csvQuote "(no measurable fan)"},1" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sankey-beta"; - } ([ "" ] ++ fanInLines ++ fanOutLines); - - toSankeyMermaid = toSankeyMermaidWith { }; - toFleetSankeyMermaid = toFleetSankeyMermaidWith { }; - toFanMetricsSankey = toFanMetricsSankeyWith { }; -in -{ - inherit - toSankeyMermaid - toSankeyMermaidWith - toFleetSankeyMermaid - toFleetSankeyMermaidWith - toFanMetricsSankey - toFanMetricsSankeyWith - ; -} diff --git a/nix/lib/diag/sequence.nix b/nix/lib/diag/sequence.nix deleted file mode 100644 index 6db9c1e03..000000000 --- a/nix/lib/diag/sequence.nix +++ /dev/null @@ -1,532 +0,0 @@ -# Sequence diagram renderer. -# -# Maps the context resolution pipeline to a sequenceDiagram: -# - participants = context stages (host, default, hm-host, hm-user, user) -# - messages = entityEdges (normal or cross-provide) -# - notes = aspect labels grouped per stage, truncated -# -# The pipeline is naturally sequential, so this view reveals causal flow -# (who-resolves-before-whom) rather than structural containment. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (util) meaningful isUserAspect makeIdSanitizer; - inherit (renderUtil) renderMermaid; - - # Stable alias for mermaid sequenceDiagram participants. - aliasOf = makeIdSanitizer "p"; - - entityLabel = util.entityLabel { }; - - # Topologically order entity kinds: roots (no incoming entityEdge) first, - # then rest in insertion order. We only need approximate order for readability. - orderEntityKinds = - entityKinds: entityEdges: - let - targets = lib.listToAttrs ( - map (e: { - name = e.to; - value = true; - }) entityEdges - ); - isRoot = s: !(targets ? ${s.id}); - in - builtins.filter isRoot entityKinds ++ builtins.filter (s: !(isRoot s)) entityKinds; - - nodeLabel = - n: - let - base = n.label or n.name or ""; - args = n.fnArgNames or [ ]; - in - if args != [ ] then "${base}(${util.fmtArgs args})" else base; - - noteWrapAt = 4; - - # Word-wrap a label list into
-joined chunks of noteWrapAt. - wrapLabels = - labels: - let - len = builtins.length labels; - numChunks = if len == 0 then 0 else (len + noteWrapAt - 1) / noteWrapAt; - in - lib.concatStringsSep "
" ( - builtins.filter (c: c != "") ( - lib.genList ( - i: lib.concatStringsSep ", " (lib.take noteWrapAt (lib.drop (i * noteWrapAt) labels)) - ) numChunks - ) - ); - - mkEntityById = - entityKinds: - lib.listToAttrs ( - map (s: { - name = s.id; - value = s; - }) entityKinds - ); - - policyNodesOf = nodes: builtins.filter (n: n.isPolicyDispatch or false) nodes; - - toSequenceMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootName - nodes - entityKinds - entityEdges - ; - ordered = orderEntityKinds entityKinds entityEdges; - - participantDecl = ek: " participant ${aliasOf ek.name} as ${entityLabel ek}"; - - entityById = mkEntityById entityKinds; - messageDecl = - edge: - let - fromEntity = entityById.${edge.from} or null; - toEntity = entityById.${edge.to} or null; - fromAlias = if fromEntity != null then aliasOf fromEntity.name else edge.from; - toAlias = if toEntity != null then aliasOf toEntity.name else edge.to; - label = if edge.label != null then edge.label else "resolve"; - arrow = if (edge.style or "normal") == "provide" then "-->>" else "->>"; - in - " ${fromAlias} ${arrow} ${toAlias}: ${label}"; - - # Drop self-reference entity kind transitions (edges where source and - # target collapse to the same participant). They render as a - # self-arrow in sequenceDiagram — confusing and conveys nothing - # the entity note doesn't already say. - nonSelfEntityEdges = builtins.filter (e: e.from != e.to) entityEdges; - - # Policy dispatch messages with context annotation. - policyNodes = policyNodesOf nodes; - policyMessages = lib.concatMap ( - pn: - let - fromKind = pn.from or null; - toKind = pn.to or null; - fromAlias = if fromKind != null then aliasOf fromKind else null; - toAlias = if toKind != null then aliasOf toKind else null; - policyName = pn.policyName or pn.label; - in - lib.optional ( - fromAlias != null && toAlias != null && fromAlias != toAlias - ) " ${fromAlias} -->> ${toAlias}: ${policyName}" - ) policyNodes; - - # Per-entity-kind aspect blocks: show parametric aspects with their args - # and non-parametric aspects grouped separately. - entityBlock = - ek: - let - alias = aliasOf ek.name; - ekNodes = builtins.filter ( - n: n.entityKind == ek.name && meaningful n.label && !(n.isPolicyDispatch or false) - ) nodes; - parametric = builtins.filter (n: (n.fnArgNames or [ ]) != [ ]) ekNodes; - static = builtins.filter (n: (n.fnArgNames or [ ]) == [ ]) ekNodes; - parametricLines = map (n: " ${alias} ->> ${alias}: ${nodeLabel n}") parametric; - staticLabels = map (n: n.label) static; - staticNote = wrapLabels staticLabels; - in - lib.optionals (parametricLines != [ ]) ( - [ " activate ${alias}" ] ++ parametricLines ++ [ " deactivate ${alias}" ] - ) - ++ lib.optional (staticNote != "") " Note over ${alias}: ${staticNote}"; - in - if entityKinds == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } - [ - " participant root as ${rootName}" - " Note over root: no entity kinds captured" - ] - else - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } - ( - map participantDecl ordered - ++ [ "" ] - ++ map messageDecl nonSelfEntityEdges - ++ lib.optionals (policyMessages != [ ]) ([ "" ] ++ policyMessages) - ++ lib.concatMap (s: [ "" ] ++ entityBlock s) ordered - ); - - # Expanded variant: same entity kind participants and inter-kind - # transitions as the basic sequence view but with an UNTRUNCATED - # per-kind aspect list (rendered as a sequenceDiagram `Note over`) - # plus explicit cross-kind provide arrows for wrapper nodes. - # - # Aspects are NOT emitted as per-aspect self-arrows — those render - # as visible self-loops and bury the actual inter-kind flow. A note - # listing every aspect conveys the same detail without the loops. - # - # Cross-kind projection hints: wrapper nodes matching - # `//(self-provide|cross-provide)():` become - # src→dst arrows (same-kind self-provides filtered out). Provider - # sub-aspects named `to-hosts` / `to-` bridge from the aspect's - # own kind to the target kind. - toSequenceMermaidExpandedWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootName - nodes - entityKinds - entityEdges - ; - - ordered = orderEntityKinds entityKinds entityEdges; - entityById = mkEntityById entityKinds; - aspectsByEntityKind = - ek: - lib.sort (a: b: a.label < b.label) ( - builtins.filter (n: n.entityKind == ek.name && isUserAspect graph n) nodes - ); - - allBridges = util.detectBridges graph; - - participantDecl = ek: " participant ${aliasOf ek.name} as ${entityLabel ek}"; - - # Per-entity-kind block: - # 1. Header note marking the entity kind - # 2. Content note listing every user aspect in the kind (no - # truncation — that's the "expanded" bit) - # 3. Non-self cross-kind bridges originating in this kind - # 4. Non-self outgoing kind transitions from this kind - # - # Self-reference arrows (src == dst) are filtered at every step. - # Policy nodes grouped by source entity kind for dispatch arrows. - policyNodesByEntityKind = - ek: builtins.filter (n: (n.isPolicyDispatch or false) && (n.from or null) == ek.name) nodes; - - entityBlock = - ek: - let - alias = aliasOf ek.name; - aspects = aspectsByEntityKind ek; - outgoing = builtins.filter (e: e.from == ek.id && e.to != ek.id) entityEdges; - bridgesFromHere = builtins.filter (b: b.src == ek.name && b.dst != ek.name) allBridges; - ekPolicies = policyNodesByEntityKind ek; - - # Split aspects into parametric (with ctx args) and static. - parametric = builtins.filter (n: (n.fnArgNames or [ ]) != [ ]) aspects; - static = builtins.filter (n: (n.fnArgNames or [ ]) == [ ]) aspects; - - # Parametric aspects as self-arrows with arg annotation. - parametricLines = - if parametric == [ ] then - [ ] - else - [ " activate ${alias}" ] - ++ map (n: " ${alias} ->> ${alias}: ${nodeLabel n}") parametric - ++ [ " deactivate ${alias}" ]; - - # Static aspects as word-wrapped note. - staticLabels = map (n: n.label) static; - staticChunks = lib.genList ( - i: lib.concatStringsSep ", " (lib.take noteWrapAt (lib.drop (i * noteWrapAt) staticLabels)) - ) (if staticLabels == [ ] then 0 else (builtins.length staticLabels + noteWrapAt - 1) / noteWrapAt); - staticNote = lib.concatStringsSep "
" (builtins.filter (c: c != "") staticChunks); - - # Policy dispatch arrows from this entity kind. - policyLines = lib.concatMap ( - pn: - let - toKind = pn.to or null; - toAlias = if toKind != null then aliasOf toKind else null; - policyName = pn.policyName or pn.label; - in - lib.optional (toAlias != null && toAlias != alias) " ${alias} -->> ${toAlias}: ${policyName}" - ) ekPolicies; - - bridgeLine = - b: - let - dstAlias = aliasOf b.dst; - arrow = if b.kind == "cross-provide" || b.kind == "bridge" then "-->>" else "->>"; - label = if b.kind == "bridge" then "forward: ${b.aspect}" else "${b.kind}: ${b.aspect}"; - in - " ${alias} ${arrow} ${dstAlias}: ${label}"; - - transitionLine = - edge: - let - toEntity = entityById.${edge.to} or null; - toAlias = if toEntity != null then aliasOf toEntity.name else edge.to; - label = if edge.label != null then edge.label else "resolve"; - arrow = if (edge.style or "normal") == "provide" then "-->>" else "->>"; - in - " ${alias} ${arrow} ${toAlias}: ${label}"; - in - [ - "" - " Note over ${alias}: ── ${entityLabel ek}" - ] - ++ parametricLines - ++ lib.optional (staticNote != "") " Note over ${alias}: ${staticNote}" - ++ lib.optional (bridgesFromHere != [ ]) "" - ++ map bridgeLine bridgesFromHere - ++ lib.optional (policyLines != [ ]) "" - ++ policyLines - ++ lib.optional (outgoing != [ ]) "" - ++ map transitionLine outgoing; - in - if entityKinds == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } - [ - " participant root as ${rootName}" - " Note over root: no entity kinds captured" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } (map participantDecl ordered ++ lib.concatMap entityBlock ordered); - - toSequenceMermaid = toSequenceMermaidWith { }; - toSequenceMermaidExpanded = toSequenceMermaidExpandedWith { }; - - # Entity kind topology: focused flowchart showing only pipeline entity - # kinds and their transition edges. Answers "what is the resolution order?" - # without any aspect-level detail. - toScopeEdgesMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) entityKinds entityEdges rootName; - ordered = orderEntityKinds entityKinds entityEdges; - entityById = mkEntityById entityKinds; - nodeDecl = ek: " ${aliasOf ek.name}([${entityLabel ek}])"; - edgeDecl = - edge: - let - fromEntity = entityById.${edge.from} or null; - toEntity = entityById.${edge.to} or null; - in - if fromEntity == null || toEntity == null then - null - else - let - arrow = if (edge.style or "normal") == "provide" then "-.->" else "-->"; - lbl = if edge.label != null then "|${edge.label}|" else ""; - in - " ${aliasOf fromEntity.name} ${arrow}${lbl} ${aliasOf toEntity.name}"; - edgeLines = builtins.filter (l: l != null) (map edgeDecl entityEdges); - in - if entityKinds == [ ] then - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } [ " root([${rootName}])" ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } (map nodeDecl ordered ++ lib.optionals (edgeLines != [ ]) ([ "" ] ++ edgeLines)); - - toScopeEdgesMermaid = toScopeEdgesMermaidWith { }; - - # Policy-centric sequence: policies ARE the participants. - # Shows each policy as an actor, what context it receives, - # what aspects it triggers, and how policies chain. - toPolicySequenceMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootName - nodes - entityKinds - entityEdges - ; - - orEmpty = v: if v == null then "" else v; - policyNodes = lib.sort ( - a: b: - let - af = orEmpty (a.from or null); - bf = orEmpty (b.from or null); - at = orEmpty (a.to or null); - bt = orEmpty (b.to or null); - in - af < bf || (af == bf && at < bt) - ) (builtins.filter (n: n.isPolicyDispatch or false) nodes); - - # Aspects grouped by target entity kind. - aspectsByEntityKind = - kindName: - builtins.filter ( - n: n.entityKind == kindName && meaningful n.label && !(n.isPolicyDispatch or false) - ) nodes; - - # Root entity participant. - rootParticipant = " participant root as ${rootName}"; - - # Each policy becomes a participant. - policyParticipants = map ( - pn: " participant ${aliasOf (pn.policyName or pn.label)} as ${pn.policyName or pn.label}" - ) policyNodes; - - childrenOf = (util.adjacency (graph.edges or [ ])).outOf; - - nodeById = lib.listToAttrs ( - map (n: { - name = n.id; - value = n; - }) nodes - ); - - # For each policy: root dispatches to it, it activates and shows - # the aspects it triggers grouped by entity. - policyBlock = - pn: - let - pAlias = aliasOf (pn.policyName or pn.label); - toKind = pn.to or null; - - targetAspects = if toKind != null then aspectsByEntityKind toKind else [ ]; - - # Top-level entities in this kind (parametric aspects with the - # kind's context args — alice, bob, deploy, etc.) - topEntities = builtins.filter ( - n: - (n.fnArgNames or [ ]) != [ ] - && !(lib.hasPrefix "provides/" n.label) - && !(lib.hasPrefix "${toKind}/" n.label) - ) targetAspects; - - # Group aspects by parent entity using edge relationships. - entityBlock = - entity: - let - childIds = childrenOf.${entity.id} or [ ]; - children = builtins.filter (n: builtins.elem n.id childIds && n.id != entity.id) targetAspects; - childLabels = map (n: nodeLabel n) children; - in - [ " Note over ${pAlias}: ${nodeLabel entity}" ] - ++ map (l: " ${pAlias} ->> ${pAlias}: ${l}") childLabels; - - # Aspects not parented to any top entity (kind-level). - topEntityIds = map (n: n.id) topEntities; - allEntityChildIds = lib.concatMap (e: childrenOf.${e.id} or [ ]) topEntities; - topEntityIdSet = lib.genAttrs topEntityIds (_: true); - allEntityChildIdSet = lib.genAttrs allEntityChildIds (_: true); - orphans = builtins.filter ( - n: !(topEntityIdSet ? ${n.id}) && !(allEntityChildIdSet ? ${n.id}) - ) targetAspects; - orphanLabels = map (n: nodeLabel n) orphans; - orphanNote = wrapLabels orphanLabels; - - # Downstream policy chains. - downstream = builtins.filter (p2: (p2.from or null) == toKind && p2 != pn) policyNodes; - chainLines = map ( - p2: " ${pAlias} -->> ${aliasOf (p2.policyName or p2.label)}: chains" - ) downstream; - in - [ - "" - " root ->> ${pAlias}: dispatch" - " activate ${pAlias}" - ] - ++ lib.concatMap entityBlock topEntities - ++ lib.optional (orphanNote != "") " Note over ${pAlias}: ${orphanNote}" - ++ chainLines - ++ [ " deactivate ${pAlias}" ]; - - # Deduplicate: only show aspects for the first policy targeting - # each stage. - seenStages = - builtins.foldl' - ( - acc: pn: - let - to = orEmpty (pn.to or null); - isFirst = !(acc.seen ? ${to}); - pAlias = aliasOf (pn.policyName or pn.label); - in - { - seen = acc.seen // { - ${to} = true; - }; - blocks = - acc.blocks - ++ ( - if isFirst then - policyBlock pn - else - [ - "" - " root ->> ${pAlias}: dispatch" - ] - ); - } - ) - { - seen = { }; - blocks = [ ]; - } - policyNodes; - in - if policyNodes == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } - [ - " participant root as ${rootName}" - " Note over root: no policies captured" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "sequenceDiagram"; - } ([ rootParticipant ] ++ policyParticipants ++ seenStages.blocks); - - toPolicySequenceMermaid = toPolicySequenceMermaidWith { }; - -in -{ - inherit - toSequenceMermaid - toSequenceMermaidWith - toSequenceMermaidExpanded - toSequenceMermaidExpandedWith - toScopeEdgesMermaid - toScopeEdgesMermaidWith - toPolicySequenceMermaid - toPolicySequenceMermaidWith - ; -} diff --git a/nix/lib/diag/state.nix b/nix/lib/diag/state.nix deleted file mode 100644 index a033754e6..000000000 --- a/nix/lib/diag/state.nix +++ /dev/null @@ -1,98 +0,0 @@ -# State diagram renderer (mermaid `stateDiagram-v2`). -# -# The context resolution pipeline is semantically a state machine: -# `host` is the initial state, `default` / `hm-host` / `hm-user` / `user` -# are intermediate states, transitions are cross-stage provides. -# stateDiagram-v2 models this more accurately than a flowchart. -# -# Intended for `diag.graph.contextOnly` output — a graph whose nodes -# are stage-synthesized and edges are stage transitions. Falls back -# to rendering every node as a state if given a different shape. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (util) sanitizeChars; - inherit (renderUtil) renderMermaid; - - toStateMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) - rootName - rootId - nodes - edges - ; - - # stateDiagram state ids must be alnum/underscore; use sanitizeChars - # without any prefix since we control the names here. - stateId = node: "s_${sanitizeChars node.label}"; - - nonHostNodes = builtins.filter (n: n.id != rootId) nodes; - nodeById = builtins.listToAttrs ( - map (n: { - name = n.id; - value = n; - }) nodes - ); - - stateDecl = node: " ${stateId node} : ${node.label}"; - - edgeLine = - edge: - let - fromNode = nodeById.${edge.from} or null; - toNode = nodeById.${edge.to} or null; - fromState = - if fromNode == null then - edge.from - else if fromNode.id == rootId then - "[*]" - else - stateId fromNode; - toState = - if toNode == null then - edge.to - else if toNode.id == rootId then - "[*]" - else - stateId toNode; - label = if edge.label != null then " : ${edge.label}" else ""; - in - " ${fromState} --> ${toState}${label}"; - - # Drop self-loops (from == to after host-replacement) to match the - # filter we already apply to the sequence view. - isSelf = edge: edge.from == edge.to; - keptEdges = builtins.filter (e: !(isSelf e)) edges; - in - if nonHostNodes == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "stateDiagram-v2"; - } - [ - " [*] --> ${sanitizeChars rootName}" - " ${sanitizeChars rootName} : ${rootName}" - " ${sanitizeChars rootName} --> [*]" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "stateDiagram-v2"; - } (map stateDecl nonHostNodes ++ [ "" ] ++ map edgeLine keptEdges); - - toStateMermaid = toStateMermaidWith { }; -in -{ - inherit toStateMermaid toStateMermaidWith; -} diff --git a/nix/lib/diag/text.nix b/nix/lib/diag/text.nix deleted file mode 100644 index d169a2f7d..000000000 --- a/nix/lib/diag/text.nix +++ /dev/null @@ -1,447 +0,0 @@ -# Plain-text / markdown renderers for data visualization. -# -# Produces structured markdown summaries from graph IR and fleet -# capture data. Useful for CLI inspection, documentation embedding, -# and LLM-friendly configuration review. -# -# All functions return plain strings (not derivations). -{ lib }: -let - # entityInstance is explicitly null in some graph constructors; - # Nix `or` only catches missing attrs, not null values. - instOf = default: e: if e.entityInstance or null == null then default else e.entityInstance; - - # --- Table helpers --- - - # Render a markdown table from headers and rows. - # headers: [ "Col1" "Col2" ] - # rows: [ [ "val1" "val2" ] [ "val3" "val4" ] ] - mkTable = - headers: rows: - let - separator = map (h: lib.concatMapStrings (_: "-") (lib.stringToCharacters h) + "--") headers; - fmtRow = cols: "| ${lib.concatStringsSep " | " cols} |"; - in - lib.concatStringsSep "\n" ( - [ - (fmtRow headers) - (fmtRow separator) - ] - ++ map fmtRow rows - ); - - # --- Fleet summary --- - - fleetSummary = - fleetCapture: - let - inherit (fleetCapture) - entries - scopeParent - scopeContexts - scopeEntityKind - scopedPipeEffects - scopedClassImports - ctxTrace - ; - - allScopes = builtins.attrNames scopeEntityKind; - hostScopes = builtins.filter (s: scopeEntityKind.${s} == "host") allScopes; - envScopes = builtins.filter (s: scopeEntityKind.${s} == "environment") allScopes; - userScopes = builtins.filter (s: scopeEntityKind.${s} == "user") allScopes; - - extractName = - kind: scopeId: - let - parts = lib.splitString "," scopeId; - match = lib.findFirst (p: lib.hasPrefix "${kind}=" p) null parts; - in - if match != null then lib.removePrefix "${kind}=" match else scopeId; - - hostsInEnv = envScope: builtins.filter (h: (scopeParent.${h} or null) == envScope) hostScopes; - - usersInHost = hostScope: builtins.filter (u: (scopeParent.${u} or null) == hostScope) userScopes; - - # Policy entries. - policyEntries = builtins.filter (e: e.isPolicyDispatch or false) entries; - policyNames = lib.unique (map (e: e.name) policyEntries); - - # Pipe data. - # Keys that are NOT user-declared pipes — class keys plus framework- - # internal structural keys that appear in scopedClassImports. - nonPipeKeys = [ - "nixos" - "homeManager" - "user" - "darwin" - "excludes" - ]; - isPipeKey = k: !builtins.elem k nonPipeKeys; - - pipesByHost = map ( - hScope: - let - hName = extractName "host" hScope; - ci = scopedClassImports.${hScope} or { }; - pipes = builtins.filter isPipeKey (builtins.attrNames ci); - effects = scopedPipeEffects.${hScope} or [ ]; - collectPipes = lib.unique ( - builtins.filter (p: p != null) ( - map (e: e.value.pipeName or e.pipeName or null) ( - builtins.filter ( - e: builtins.any (s: (s.__pipeStage or null) == "collect") (e.value.stages or e.stages or [ ]) - ) effects - ) - ) - ); - in - { - name = hName; - produces = pipes; - collects = collectPipes; - } - ) hostScopes; - - allPipeNames = lib.unique (lib.concatMap (h: h.produces ++ h.collects) pipesByHost); - - # Scope chain from ctxTrace. - kindChain = lib.concatStringsSep " → " (lib.reverseList (map (e: e.key) ctxTrace)); - - # Environment table. - envRows = map ( - envScope: - let - eName = extractName "environment" envScope; - hosts = hostsInEnv envScope; - hostNames = map (extractName "host") hosts; - userCount = builtins.length (lib.concatMap usersInHost hosts); - in - [ - eName - (lib.concatStringsSep ", " hostNames) - (toString (builtins.length hosts)) - (toString userCount) - ] - ) envScopes; - - # Pipe table — grouped by collection boundary (the parent scope of - # collecting hosts). pipe.collect finds siblings = same parent. - # The boundary kind is whatever entity kind the parent scope has. - hostParentScopes = lib.unique (map (hScope: scopeParent.${hScope} or null) hostScopes); - - pipeRows = lib.concatMap ( - pipeName: - lib.concatMap ( - parentScope: - let - parentKind = if parentScope != null then scopeEntityKind.${parentScope} or null else null; - parentName = - if parentScope != null && parentKind != null then extractName parentKind parentScope else "global"; - boundary = if parentKind != null then "${parentKind}: ${parentName}" else "global"; - siblingHosts = builtins.filter (h: (scopeParent.${h} or null) == parentScope) hostScopes; - siblingNames = map (extractName "host") siblingHosts; - producers = builtins.filter ( - h: builtins.elem pipeName h.produces && builtins.elem h.name siblingNames - ) pipesByHost; - collectors = builtins.filter ( - h: builtins.elem pipeName h.collects && builtins.elem h.name siblingNames - ) pipesByHost; - pureConsumers = builtins.filter (h: !builtins.elem pipeName h.produces) collectors; - effectiveConsumers = if pureConsumers != [ ] then pureConsumers else collectors; - in - lib.optional (producers != [ ] || effectiveConsumers != [ ]) [ - pipeName - boundary - (lib.concatStringsSep ", " (map (h: h.name) producers)) - (lib.concatStringsSep ", " (map (h: h.name) effectiveConsumers)) - ] - ) hostParentScopes - ) allPipeNames; - - # Policy table. - policyRows = map ( - name: - let - entry = lib.findFirst (e: e.name == name) null policyEntries; - from = if entry != null then entry.from or "—" else "—"; - in - [ - name - from - ] - ) policyNames; - - # Aspect counts per host. - aspectEntries = builtins.filter ( - e: - !(e.isPolicyDispatch or false) - && (e.hasClass or false) - && (e.provider or [ ]) == [ ] - && e.name != "host" - && e.name != "user" - && e.name != "default" - && !(lib.hasPrefix "<" (e.name or "")) - ) entries; - - aspectsByHost = lib.foldl' ( - acc: e: - let - inst = instOf "" e; - parts = lib.splitString ":" inst; - kind = builtins.head parts; - name = if builtins.length parts > 1 then lib.concatStringsSep ":" (lib.tail parts) else ""; - in - if kind == "host" && name != "" then - acc // { ${name} = lib.unique ((acc.${name} or [ ]) ++ [ e.name ]); } - else - acc - ) { } aspectEntries; - - hostAspectRows = map ( - hScope: - let - hName = extractName "host" hScope; - aspects = aspectsByHost.${hName} or [ ]; - in - [ - hName - (toString (builtins.length aspects)) - (lib.concatStringsSep ", " (lib.take 8 (lib.sort (a: b: a < b) aspects))) - ] - ) hostScopes; - in - lib.concatStringsSep "\n" [ - "# Fleet Summary" - "" - "## Topology" - "" - "- **${toString (builtins.length envScopes)}** environments, **${toString (builtins.length hostScopes)}** hosts, **${toString (builtins.length userScopes)}** users" - "- Scope chain: ${kindChain}" - "- Trace entries: ${toString (builtins.length entries)}" - "" - "## Environments" - "" - (mkTable [ - "Environment" - "Hosts" - "Host Count" - "Users" - ] envRows) - "" - "## Aspects by Host" - "" - (mkTable [ - "Host" - "Aspect Count" - "Aspects" - ] hostAspectRows) - "" - ( - if allPipeNames != [ ] then - lib.concatStringsSep "\n" [ - "## Pipes" - "" - (mkTable [ - "Pipe" - "Scope Boundary" - "Producers" - "Collectors" - ] pipeRows) - ] - else - "" - ) - "" - "## Policies" - "" - (mkTable [ - "Policy" - "Fires at" - ] policyRows) - ]; - - # --- Per-host summary --- - - hostSummary = - { - graph, - host ? null, - fleetCapture ? null, - }: - let - inherit (graph) nodes edges; - - hostName = graph.rootName; - - # Meaningful user aspects. - userAspects = builtins.filter ( - n: (n.hasClass or false) && !(n.isPolicyDispatch or false) && !(lib.hasPrefix "<" n.label) - ) nodes; - - # Group by entity instance. - instanceGroups = lib.foldl' ( - acc: n: - let - inst = instOf "unscoped" n; - in - acc // { ${inst} = (acc.${inst} or [ ]) ++ [ n ]; } - ) { } userAspects; - - # Policies. - policyNodes = builtins.filter (n: n.isPolicyDispatch or false) nodes; - - # Providers. - providerNodes = builtins.filter (n: n.isProvider or false) nodes; - - # Classes present. - allClasses = lib.unique (lib.concatMap (n: n.classes or [ ]) userAspects); - - # Aspect table. - aspectRows = map (n: [ - n.label - (lib.concatStringsSep ", " (n.classes or [ ])) - ( - if n.isParametric or false then "yes (${lib.concatStringsSep ", " (n.fnArgNames or [ ])})" else "no" - ) - (instOf "—" n) - ]) (lib.sort (a: b: a.label < b.label) userAspects); - - # Class breakdown. - classBreakdowns = map ( - className: - let - classAspects = builtins.filter (n: builtins.elem className (n.classes or [ ])) userAspects; - names = lib.sort (a: b: a < b) (map (n: n.label) classAspects); - in - "### ${className} (${toString (builtins.length names)})\n\n${ - lib.concatMapStringsSep "\n" (n: "- ${n}") names - }" - ) allClasses; - - # Provider breakdown. - providerRows = map (n: [ - n.label - (lib.concatStringsSep ", " (n.classes or [ ])) - (lib.concatStringsSep "/" (n.providerPath or [ ])) - ]) (lib.sort (a: b: a.label < b.label) providerNodes); - - # Pipe data from fleet capture if available. - pipeSection = - if fleetCapture == null then - "" - else - let - inherit (fleetCapture) - scopedPipeEffects - scopedClassImports - scopeParent - scopeEntityKind - ; - # Find the host scope matching this host name. - hostScopes = builtins.filter ( - s: - (scopeEntityKind.${s} or null) == "host" - && lib.hasSuffix "host=${hostName}" (lib.last (lib.splitString "," s)) - ) (builtins.attrNames scopeEntityKind); - hScope = if hostScopes != [ ] then builtins.head hostScopes else null; - ci = if hScope != null then scopedClassImports.${hScope} or { } else { }; - classKeySet = [ - "nixos" - "homeManager" - "user" - "darwin" - ]; - producedPipes = builtins.filter (k: !builtins.elem k classKeySet) (builtins.attrNames ci); - effects = if hScope != null then scopedPipeEffects.${hScope} or [ ] else [ ]; - collectPipes = lib.unique ( - builtins.filter (p: p != null) ( - map (e: e.value.pipeName or e.pipeName or null) ( - builtins.filter ( - e: builtins.any (s: (s.__pipeStage or null) == "collect") (e.value.stages or e.stages or [ ]) - ) effects - ) - ) - ); - # Find siblings that produce pipes this host collects. - siblings = - if hScope != null then - let - parent = scopeParent.${hScope} or null; - in - builtins.filter ( - s: s != hScope && (scopeParent.${s} or null) == parent && (scopeEntityKind.${s} or null) == "host" - ) (builtins.attrNames scopeParent) - else - [ ]; - siblingNames = map ( - s: - let - parts = lib.splitString "," s; - hp = lib.findFirst (p: lib.hasPrefix "host=" p) null parts; - in - if hp != null then lib.removePrefix "host=" hp else s - ) siblings; - in - lib.concatStringsSep "\n" ( - [ - "" - "## Pipe Data" - "" - "**Produces:** ${if producedPipes != [ ] then lib.concatStringsSep ", " producedPipes else "none"}" - "**Collects:** ${if collectPipes != [ ] then lib.concatStringsSep ", " collectPipes else "none"}" - ] - ++ lib.optional (siblingNames != [ ]) "**Siblings:** ${lib.concatStringsSep ", " siblingNames}" - ); - in - lib.concatStringsSep "\n" ( - [ - "# Host: ${hostName}" - "" - "## Overview" - "" - "- **${toString (builtins.length userAspects)}** aspects across **${toString (builtins.length allClasses)}** classes (${lib.concatStringsSep ", " allClasses})" - "- **${toString (builtins.length providerNodes)}** provider sub-aspects" - "- **${toString (builtins.length policyNodes)}** policies fired" - "- **${toString (builtins.length (builtins.attrNames instanceGroups))}** entity instances" - "" - "## Aspects" - "" - (mkTable [ - "Aspect" - "Classes" - "Parametric" - "Instance" - ] aspectRows) - "" - "## Classes" - "" - ] - ++ lib.intersperse "\n" classBreakdowns - ++ [ - "" - "" - "## Providers" - "" - (mkTable [ - "Provider Aspect" - "Classes" - "Provider Path" - ] providerRows) - "" - "## Policies" - "" - (lib.concatMapStringsSep "\n" (p: "- **${p.policyName or p.label}** (from: ${p.from or "—"})") ( - lib.sort (a: b: (a.policyName or a.label) < (b.policyName or b.label)) policyNodes - )) - pipeSection - ] - ); - -in -{ - inherit - fleetSummary - hostSummary - mkTable - ; -} diff --git a/nix/lib/diag/themes.nix b/nix/lib/diag/themes.nix deleted file mode 100644 index 9c1cbba09..000000000 --- a/nix/lib/diag/themes.nix +++ /dev/null @@ -1,258 +0,0 @@ -# Theme records for the diag renderer library. -# -# A theme record bundles every color the renderers need: backgrounds, -# foregrounds, cluster/subgraph surfaces, edge colors, adapter styling, -# and an accent pool used by the per-node color hash. Every renderer -# (mermaid, dot, plantuml, c4) consumes the same record so swapping a -# theme updates all diagram types consistently. -# -# Themes are built from base16 palettes. `pkgs.base16-schemes` ships -# ~300 schemes as YAML; we convert a requested scheme to JSON via `yj` -# at build time and parse it with `builtins.fromJSON`. -# -# Usage from a template: -# -# theme = diag.themeFromBase16 { inherit pkgs; scheme = "catppuccin-mocha"; }; -# rendered = diag.toMermaid { inherit theme; } graph; -{ lib }: -let - # Convert a base16 YAML scheme file into a parsed palette via `yj`. - # Returns the attribute set `{ base00 = "#..."; ... base0F = "#..."; }`. - paletteFromBase16 = - { pkgs, scheme }: - let - yamlFile = "${pkgs.base16-schemes}/share/themes/${scheme}.yaml"; - jsonDrv = - pkgs.runCommand "base16-${scheme}.json" - { - nativeBuildInputs = [ pkgs.yj ]; - } - '' - yj < ${yamlFile} > $out - ''; - parsed = builtins.fromJSON (builtins.readFile jsonDrv); - # base16 YAML values usually include the leading "#" but some - # schemes historically omit it; normalize so downstream code can - # concatenate without thinking. - normalize = v: if lib.hasPrefix "#" v then v else "#${v}"; - in - lib.mapAttrs (_: normalize) parsed.palette; - - # Map a base16 palette onto our theme record. base16's semantic slots - # align reasonably with our needs: - # - # base00 = default background → background - # base01 = lighter background → clusterBg / nodeBg - # base03 = comments / faded → subtle edges - # base04 = dark foreground → nodeBorder / clusterBorder - # base05 = default foreground → foreground / nodeText - # base08 = red → excluded nodes - # base09 = orange → replaced nodes - # base0A..base0F → accent pool for per-node hashing - # - # `accentPool` is the list of 8 hues the per-node color hash selects - # from. Keeping to base16's accent slots means every diagram's nodes - # stay faithful to the chosen scheme. - # Detect whether a palette is "light" (light base00 background) by - # comparing the first hex digit of base00 vs base05. In base16: - # - Dark schemes: base00 is dark (low hex), base05 is light (high hex) - # - Light schemes: base00 is light (high hex), base05 is dark (low hex) - isLightPalette = - palette: - let - bg = builtins.substring 1 1 palette.base00; # first hex digit after # - fg = builtins.substring 1 1 palette.base05; - in - (hexToInt bg) > (hexToInt fg); - - # Hex digit lookup (reused from colors.nix pattern) - hexToInt = - c: - { - "0" = 0; - "1" = 1; - "2" = 2; - "3" = 3; - "4" = 4; - "5" = 5; - "6" = 6; - "7" = 7; - "8" = 8; - "9" = 9; - "a" = 10; - "b" = 11; - "c" = 12; - "d" = 13; - "e" = 14; - "f" = 15; - "A" = 10; - "B" = 11; - "C" = 12; - "D" = 13; - "E" = 14; - "F" = 15; - } - .${c} or 0; - - themeFromPalette = - palette: - let - light = isLightPalette palette; - # Text on accent fills: need maximum contrast against vivid mid-tones. - # Dark themes: base00 (dark background) on bright fills. - # Light themes: base07 (dark foreground end) on bright fills. - contrastText = if light then palette.base07 else palette.base00; - in - { - inherit palette; - background = palette.base00; - foreground = palette.base05; - mutedForeground = palette.base04; - nodeBg = palette.base01; - nodeBorder = palette.base04; - nodeText = palette.base05; - clusterBg = palette.base01; - clusterBorder = palette.base03; - edgeColor = palette.base04; - edgeText = palette.base05; - labelBg = palette.base00; - rootFill = palette.base0D; - rootStroke = palette.base0D; - rootText = contrastText; - excludedFill = palette.base08; - excludedStroke = palette.base08; - excludedText = contrastText; - replacedFill = palette.base09; - replacedStroke = palette.base09; - replacedText = contrastText; - accentPool = [ - palette.base08 - palette.base09 - palette.base0A - palette.base0B - palette.base0C - palette.base0D - palette.base0E - palette.base0F - ]; - }; - - # One-shot helper: scheme name → theme record. - themeFromBase16 = - { pkgs, scheme }: - themeFromPalette (paletteFromBase16 { - inherit pkgs scheme; - }); - - # Build a mermaid `%%{init: {...}}%%` preamble from a theme record - # and optional extra config. We use the init directive (rather than - # YAML frontmatter) because mermaid's frontmatter `config:` parser - # silently drops several keys we care about — most notably `themeCSS`, - # which we need to override the canvas background. The init directive - # accepts the full mermaidAPI config including themeCSS, layout, - # themeVariables, flowchart options, and so on. - # - # `extraConfig` is recursively merged *over* our theme-derived base - # config, so callers can set `layout = "elk"`, tweak `flowchart.curve`, - # override individual themeVariables, etc., without losing the theme. - mermaidFrontmatter = - theme: extraConfig: - let - t = theme; - vars = { - # Shared / flowchart - background = t.background; - mainBkg = t.nodeBg; - secondBkg = t.clusterBg; - tertiaryColor = t.clusterBg; - primaryColor = t.nodeBg; - primaryTextColor = t.nodeText; - primaryBorderColor = t.nodeBorder; - secondaryColor = t.clusterBg; - secondaryTextColor = t.foreground; - secondaryBorderColor = t.clusterBorder; - tertiaryTextColor = t.foreground; - tertiaryBorderColor = t.clusterBorder; - lineColor = t.edgeColor; - textColor = t.foreground; - nodeBkg = t.nodeBg; - nodeTextColor = t.nodeText; - nodeBorder = t.nodeBorder; - clusterBkg = t.clusterBg; - clusterBorder = t.clusterBorder; - edgeLabelBackground = t.labelBg; - titleColor = t.foreground; - # Sequence diagrams - actorBkg = t.nodeBg; - actorBorder = t.nodeBorder; - actorTextColor = t.nodeText; - actorLineColor = t.edgeColor; - signalColor = t.edgeColor; - signalTextColor = t.edgeText; - labelBoxBkgColor = t.nodeBg; - labelBoxBorderColor = t.nodeBorder; - labelTextColor = t.nodeText; - loopTextColor = t.foreground; - noteBkgColor = t.clusterBg; - noteBorderColor = t.clusterBorder; - noteTextColor = t.foreground; - activationBkgColor = t.clusterBg; - activationBorderColor = t.clusterBorder; - sequenceNumberColor = t.background; - # Class / state / ER - classText = t.foreground; - # Pie / sankey / treemap accent colors - pie1 = builtins.elemAt t.accentPool 0; - pie2 = builtins.elemAt t.accentPool 1; - pie3 = builtins.elemAt t.accentPool 2; - pie4 = builtins.elemAt t.accentPool 3; - pie5 = builtins.elemAt t.accentPool 4; - pie6 = builtins.elemAt t.accentPool 5; - pie7 = builtins.elemAt t.accentPool 6; - pie8 = builtins.elemAt t.accentPool 7; - pieTitleTextColor = t.foreground; - pieSectionTextColor = t.foreground; - pieLegendTextColor = t.foreground; - pieStrokeColor = t.clusterBorder; - pieOuterStrokeColor = t.clusterBorder; - }; - baseConfig = { - theme = "base"; - themeVariables = vars; - }; - merged = lib.recursiveUpdate baseConfig extraConfig; - in - "%%{init: ${builtins.toJSON merged}}%%"; - - # Sensible default theme that doesn't require pkgs — hard-coded github - # light palette so the library is usable without running yj. Renderers - # fall back to this when their caller doesn't thread a theme through. - defaultPalette = { - base00 = "#eaeef2"; - base01 = "#d0d7de"; - base02 = "#afb8c1"; - base03 = "#8c959f"; - base04 = "#6e7781"; - base05 = "#424a53"; - base06 = "#32383f"; - base07 = "#1f2328"; - base08 = "#fa4549"; - base09 = "#e16f24"; - base0A = "#bf8700"; - base0B = "#2da44e"; - base0C = "#339D9B"; - base0D = "#218bff"; - base0E = "#a475f9"; - base0F = "#4d2d00"; - }; - defaultTheme = themeFromPalette defaultPalette; -in -{ - inherit - paletteFromBase16 - themeFromPalette - themeFromBase16 - defaultTheme - mermaidFrontmatter - ; -} diff --git a/nix/lib/diag/treemap.nix b/nix/lib/diag/treemap.nix deleted file mode 100644 index d20301776..000000000 --- a/nix/lib/diag/treemap.nix +++ /dev/null @@ -1,232 +0,0 @@ -# Treemap diagrams (mermaid treemap-beta). -# -# Syntax: indented hierarchy of quoted labels. Sections are labels without a -# value; leaves have ": N" where N is a numeric weight. Sections nest. -# -# Mapping: -# per-host: sections = provider aspects (the first element of each -# sub-aspect's provider chain), leaves = the sub-aspects they -# expand into. This surfaces where provider expansion actually -# happens in a host — the "customization points" of the config. -# fleet: sections = providers observed anywhere in the fleet, leaves -# = {sub-aspect, count} showing how many hosts selected each -# provider option. -{ - lib, - themes, - util, - renderUtil, -}: -let - inherit (renderUtil) renderMermaid; - - quote = s: "\"${lib.replaceStrings [ "\"" ] [ "\\\"" ] s}\""; - - # --- Per-host treemap: provider groups --- - toTreemapMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - graph: - let - inherit (graph) rootName nodes; - - providerNodes = builtins.filter (n: (n.providerPath or [ ]) != [ ] && n.id != graph.rootId) nodes; - - # Group by top-level provider name (first element of providerPath). - grouped = lib.foldl' ( - acc: n: - let - key = builtins.head n.providerPath; - in - acc // { ${key} = (acc.${key} or [ ]) ++ [ n ]; } - ) { } providerNodes; - - providerSection = - providerName: - let - kids = lib.sort (a: b: a.label < b.label) grouped.${providerName}; - header = quote providerName; - leafLines = map (n: " ${quote n.label}: 1") kids; - in - [ header ] ++ leafLines; - - providerNames = lib.sort (a: b: a < b) (builtins.attrNames grouped); - in - if providerNodes == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "treemap-beta"; - } - [ - "${quote rootName}" - " ${quote "(no provider sub-aspects)"}: 1" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "treemap-beta"; - } (lib.concatMap providerSection providerNames); - - # --- Fleet treemap --- - # When enriched fleet data carries per-host provider info, group by - # provider name and show each sub-aspect with count = number of hosts - # that selected it. Otherwise fall back to user→hosts layout. - toFleetTreemapMermaidWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleet: - let - inherit (fleet) relations; - providerSubAspects = fleet.providerSubAspects or [ ]; - hasProviderData = providerSubAspects != [ ]; - - # Group a list of { provider, subAspect, hostName } records by provider. - groupByProvider = - items: - lib.foldl' ( - acc: item: acc // { ${item.provider} = (acc.${item.provider} or [ ]) ++ [ item ]; } - ) { } items; - - providerGrouped = groupByProvider providerSubAspects; - - # Count selections per sub-aspect. - countsBySubAspect = - items: - lib.foldl' (acc: item: acc // { ${item.subAspect} = (acc.${item.subAspect} or 0) + 1; }) { } items; - - providerSection = - providerName: - let - items = providerGrouped.${providerName}; - counts = countsBySubAspect items; - sortedSubs = lib.sort (a: b: a < b) (builtins.attrNames counts); - header = quote providerName; - leafLines = map (sub: " ${quote sub}: ${toString counts.${sub}}") sortedSubs; - in - [ header ] ++ leafLines; - - # Fallback: user → host sections. - usersWithHosts = - let - byUser = builtins.foldl' ( - acc: rel: acc // { ${rel.from} = (acc.${rel.from} or [ ]) ++ [ rel.to ]; } - ) { } relations; - in - lib.mapAttrsToList (userName: hosts: { - name = userName; - hosts = lib.unique hosts; - }) byUser; - - userSection = - u: - let - header = quote u.name; - leafLines = map (h: " ${quote h}: 1") u.hosts; - in - [ header ] ++ leafLines; - in - if hasProviderData then - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "treemap-beta"; - } (lib.concatMap providerSection (lib.sort (a: b: a < b) (builtins.attrNames providerGrouped))) - else if relations == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "treemap-beta"; - } - [ - "${quote fleet.flakeName}: 1" - ] - else - renderMermaid { - inherit theme mermaidConfig; - diagramKind = "treemap-beta"; - } (lib.concatMap userSection usersWithHosts); - - # --- Fleet provider matrix (mermaid flowchart) --- - # - # Bipartite graph of providers ↔ hosts. Each distinct provider-host - # pairing from `fleet.providerSubAspects` becomes an edge. Answers - # "which hosts pull which providers?" at a glance — complements - # fleet-treemap which shows counts per provider. - toFleetProviderMatrixWith = - { - theme ? themes.defaultTheme, - mermaidConfig ? { }, - }: - fleet: - let - providerSubAspects = fleet.providerSubAspects or [ ]; - pairs = lib.unique (map (item: { inherit (item) provider hostName; }) providerSubAspects); - - sanChars = util.sanitizeChars; - - providers = lib.unique (map (p: p.provider) pairs); - hosts = lib.unique (map (p: p.hostName) pairs); - - provId = p: "p_${sanChars p}"; - hostId = h: "h_${sanChars h}"; - - providerDecl = p: " ${provId p}[/\"${p}\"\\]:::provider_c"; - hostDecl = h: " ${hostId h}([\"${h}\"]):::matrixhost_c"; - edgeDecl = pair: " ${provId pair.provider} --> ${hostId pair.hostName}"; - in - if pairs == [ ] then - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } - [ - " empty[\"(no provider usage found)\"]" - ] - else - renderMermaid - { - inherit theme mermaidConfig; - diagramKind = "graph LR"; - } - ( - [ " subgraph providers[\"Providers\"]" ] - ++ map providerDecl providers - ++ [ - " end" - "" - " subgraph hostsCluster[\"Hosts\"]" - ] - ++ map hostDecl hosts - ++ [ - " end" - "" - ] - ++ map edgeDecl pairs - ++ [ - "" - " classDef provider_c fill:${theme.nodeBg},stroke:${theme.nodeBorder},color:${theme.nodeText},stroke-width:2px" - " classDef matrixhost_c fill:${theme.rootFill},stroke:${theme.rootStroke},color:${theme.rootText},font-weight:bold" - " style providers fill:${theme.clusterBg},stroke:${theme.clusterBorder},stroke-width:2px" - " style hostsCluster fill:${theme.clusterBg},stroke:${theme.clusterBorder},stroke-width:2px" - ] - ); - - toTreemapMermaid = toTreemapMermaidWith { }; - toFleetTreemapMermaid = toFleetTreemapMermaidWith { }; - toFleetProviderMatrix = toFleetProviderMatrixWith { }; -in -{ - inherit - toTreemapMermaid - toTreemapMermaidWith - toFleetTreemapMermaid - toFleetTreemapMermaidWith - toFleetProviderMatrix - toFleetProviderMatrixWith - ; -} diff --git a/nix/lib/diag/util.nix b/nix/lib/diag/util.nix deleted file mode 100644 index ade2f6875..000000000 --- a/nix/lib/diag/util.nix +++ /dev/null @@ -1,390 +0,0 @@ -# Shared graph-level primitives used by graph.nix and the renderers. -# -# Everything in here is pure data manipulation. No theme, no color, no -# render-time concerns — those live in render-util.nix. -{ lib }: -let - # Drop list entries whose key (derived by keyFn) has been seen before. - # Preserves input order. - dedupBy = - keyFn: items: - (builtins.foldl' - ( - acc: item: - let - k = keyFn item; - in - if acc.seen ? ${k} then - acc - else - { - seen = acc.seen // { - ${k} = true; - }; - result = acc.result ++ [ item ]; - } - ) - { - seen = { }; - result = [ ]; - } - items - ).result; - - # Comma-join a list of arg names. Used by renderers that format - # parametric aspect fnArgNames as a function signature hint. - fmtArgs = names: if names == [ ] then "" else lib.concatStringsSep ", " names; - - # Shared filter: drops anonymous nodes, function bodies, and module-merge - # definition artifacts. These are structurally uninteresting to every - # renderer that cares about user-visible aspects. - meaningful = - name: name != "" && name != "" && !(lib.hasPrefix "[definition " name); - - # Context-pipeline scaffolding predicate. Matches node labels produced by - # aspect resolution machinery (`foo/aspect`, `foo/self-provide`, etc.) — - # not things a user wrote. Used by foldWrappers and by renderers that want - # to hide pipeline plumbing behind user aspects. - isWrapper = label: builtins.match ".+/(aspect|self-provide|cross-provide|resolve).*" label != null; - - # A node is a "user aspect" if it's meaningful, not a wrapper, and not the - # host root. Renderers that suppress plumbing in aspect-level views share - # this predicate instead of each redefining it. - isUserAspect = graph: n: meaningful n.label && !(isWrapper n.label) && n.id != (graph.rootId or ""); - - # Mermaid flowchart + sequenceDiagram reserved words. A bare identifier - # matching one of these confuses the parser (e.g. `class` collides with - # the `classDef` keyword, `end` closes a subgraph block). - mermaidReservedIds = [ - "class" - "classDef" - "classDiagram" - "click" - "default" - "direction" - "end" - "flowchart" - "graph" - "link" - "linkStyle" - "note" - "participant" - "style" - "subgraph" - ]; - - # Plain character sanitization, no prefix. Used for the char-level - # normalization step when a caller wants to build an identifier with - # its own fixed prefix (e.g. `ctx_` for stage ids). - # - # `/` (the den provider separator) becomes `__` so `a/b` stays distinct - # from `a_b`. All other delimiters collapse to `_`. - sanitizeChars = - s: - lib.replaceStrings - [ - "/" - "-" - " " - "." - "@" - "~" - "<" - ">" - "[" - "]" - ":" - "(" - ")" - "{" - "}" - "," - "=" - "'" - "\"" - ] - [ - "__" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - "_" - ] - s; - - # Build an identifier sanitizer parameterized by prefix. - # - # The `_` is only prepended when the sanitized form collides - # with a mermaid reserved word, is empty, or starts with a digit. Most - # identifiers pass through cleanly: `boot/systemd` becomes - # `boot__systemd` rather than `n_boot__systemd`, which makes raw - # mermaid source much easier to read and debug. - makeIdSanitizer = - prefix: s: - let - sanitized = sanitizeChars s; - needsPrefix = - builtins.elem sanitized mermaidReservedIds - || builtins.match "[0-9].*" sanitized != null - || sanitized == ""; - in - if needsPrefix then "${prefix}_${sanitized}" else sanitized; - - # Canonical entity kind label. If withCtxKeys is false the `{ a, b }` suffix - # is dropped — ishikawa uses this because its parser doesn't tolerate braces. - entityLabel = - { - withCtxKeys ? true, - }: - stage: - stage.name - + ( - if withCtxKeys && stage.ctxKeys != [ ] then - " { ${lib.concatStringsSep ", " stage.ctxKeys} }" - else - "" - ); - - # Shared style-classifier predicates. Node `style` is a coarse bucket - # set in `graph.nix::nodeStyle` and consumed by every renderer and - # every style-aware filter. Defining the buckets in one place keeps - # filters consistent when the vocabulary grows. - # Rendering style string — for renderers only. - styleOf = n: n.style or "default"; - - # Structural predicates — for filters. Read the structural booleans - # set in graph.nix::mkNode rather than the rendering `style` string. - isTombstone = n: (n.isExcluded or false) || (n.isReplaced or false); - isAdapter = n: (styleOf n) == "adapter"; - - # Keep only nodes whose style is in `styles` (a list of strings). - # Thin wrapper over filterByNodes. Collapses `adaptersOnly`, - # tombstone-only, etc. into single-line calls. - filterByStyle = - styles: graph: - let - styleSet = lib.listToAttrs ( - builtins.map (s: { - name = s; - value = true; - }) styles - ); - in - filterByNodes (n: styleSet ? ${styleOf n}) graph; - - # Build {from-id -> [to-ids]} and {to-id -> [from-ids]} adjacency tables - # from a list of edges. Consumers do O(1) lookups instead of linear scans - # per traversal step. - adjacency = - edges: - let - outOf = lib.foldl' (acc: e: acc // { ${e.from} = (acc.${e.from} or [ ]) ++ [ e.to ]; }) { } edges; - inTo = lib.foldl' (acc: e: acc // { ${e.to} = (acc.${e.to} or [ ]) ++ [ e.from ]; }) { } edges; - in - { - inherit outOf inTo; - }; - - # --- Subgraph primitives --- - # - # Shared helpers for filter/reshape functions in graph.nix and - # filters.nix. Each one returns a graph record with nodes+edges - # restricted to the specified subset, preserving everything else - # (rootName, rootId, direction, entityKinds, entityEdges). - - # Build a `{ id = true; }` attrset from a list of nodes. - idSetOfNodes = - nodes: - lib.listToAttrs ( - map (n: { - name = n.id; - value = true; - }) nodes - ); - - # Restrict a graph to nodes whose id is in `keptIds` (a `{ id = true; }` - # attrset). Edges are pruned to ones where BOTH endpoints survive. - subgraphByIds = - keptIds: graph: - graph - // { - nodes = builtins.filter (n: keptIds ? ${n.id}) graph.nodes; - edges = builtins.filter (e: keptIds ? ${e.from} && keptIds ? ${e.to}) graph.edges; - }; - - # Filter a graph by a node predicate. Edges are pruned to ones where - # both endpoints pass. Used by most "keep only X" style filters. - filterByNodes = pred: graph: subgraphByIds (idSetOfNodes (builtins.filter pred graph.nodes)) graph; - - # 1-hop neighborhood around the nodes matching `pred`: the matches - # themselves plus every node connected to one of them by an incoming - # OR outgoing edge. Edges are the FULL induced subgraph on that id - # set — meaning edges between two non-seed neighbors are kept too, - # since they show structural context around the seed. Used by - # neighborhoodOf / adaptersOnly / parametricOnly. - neighborhoodByNodes = - pred: graph: - let - seedIds = idSetOfNodes (builtins.filter pred graph.nodes); - expanded = lib.foldl' ( - acc: e: - if seedIds ? ${e.from} then - acc // { ${e.to} = true; } - else if seedIds ? ${e.to} then - acc // { ${e.from} = true; } - else - acc - ) seedIds graph.edges; - in - subgraphByIds expanded graph; - - # Transitive ancestor closure of nodes matching `pred`: start with - # the matches and walk backward through `in-edge` adjacency until - # no new ancestors appear. Used by classSlice where we want to - # preserve the inclusion hierarchy above each seed. - ancestorClosureBy = - pred: graph: - let - adj = adjacency graph.edges; - parentsOf = id: adj.inTo.${id} or [ ]; - seeds = builtins.filter pred graph.nodes; - expand = - id: visited: - if visited ? ${id} then - visited - else - let - v = visited // { - ${id} = true; - }; - in - lib.foldl' (acc: p: expand p acc) v (parentsOf id); - keptIds = lib.foldl' (acc: n: expand n.id acc) { } seeds; - in - subgraphByIds keptIds graph; - - # Detect cross-entity-kind bridges in a graph. - # Returns list of { aspect, src, dst, kind, node } records. - # Two detection methods: - # 1. Provide wrappers: parse `//(self|cross)-provide():` labels - # 2. Entity bridges: `to-` provider sub-aspect naming convention - detectBridges = - graph: - let - inherit (graph) nodes entityKinds; - kindNames = map (s: s.name) entityKinds; - - # Parse wrapper labels for cross-entity provide hints. - # Patterns we care about (matched against `fullLabel`, not `label`, - # so aspect names stay unambiguous even if the short form was - # chosen for display): - # //self-provide(): - # //cross-provide(): - # Returns null for non-matching labels. - parseProvide = - label: - let - m = builtins.match "(.+)/([a-z-]+)/(self-provide|cross-provide)\\(([^)]+)\\):(.+)" label; - in - if m == null then - null - else - { - aspect = builtins.elemAt m 0; - src = builtins.elemAt m 1; - kind = builtins.elemAt m 2; - dst = builtins.elemAt m 3; - }; - - # Wrapper nodes annotated with parsed provide info. - provideWrappers = builtins.filter (p: p != null) ( - map ( - n: - let - p = parseProvide (n.fullLabel or n.label); - in - if p == null then null else p // { node = n; } - ) nodes - ); - - # `alice/to-hosts` and similar provider-sub-aspects bridge entity - # kinds implicitly — the sub-aspect lives in one kind but its content - # goes elsewhere via provides.* naming conventions. Detect the - # common `to-` / `to-hosts` sub-aspects as entity bridges. - entityBridges = lib.concatMap ( - n: - let - pp = n.providerPath or [ ]; - tail = - if pp == [ ] then - null - else - let - parts = lib.splitString "/" n.label; - in - if parts == [ ] then null else lib.last parts; - dstKind = - if tail == null then - null - else if tail == "to-hosts" then - "host" - else if lib.hasPrefix "to-" tail && builtins.elem (lib.removePrefix "to-" tail) kindNames then - lib.removePrefix "to-" tail - else - null; - in - lib.optional (dstKind != null && (n.entityKind or null) != null) { - src = n.entityKind; - dst = dstKind; - aspect = n.label; - kind = "bridge"; - node = n; - } - ) nodes; - in - provideWrappers ++ entityBridges; - - # Null-coalescing accessor: treats both missing and explicit-null as - # "use the default". Plain `attr or default` only falls through on - # missing — explicit null still returns null. - nullOr = default: value: if value == null then default else value; - -in -{ - inherit - dedupBy - fmtArgs - meaningful - nullOr - isWrapper - isUserAspect - makeIdSanitizer - sanitizeChars - entityLabel - styleOf - isTombstone - isAdapter - filterByStyle - adjacency - idSetOfNodes - subgraphByIds - filterByNodes - neighborhoodByNodes - ancestorClosureBy - detectBridges - ; -} diff --git a/nix/lib/diag/views.nix b/nix/lib/diag/views.nix deleted file mode 100644 index 0076b2570..000000000 --- a/nix/lib/diag/views.nix +++ /dev/null @@ -1,276 +0,0 @@ -# Standard view definitions for aspect-resolution diagrams. -# -# Each view is a record describing what to compute from a graph IR -# and how to present it. Views are returned as lists so templates can -# extend (`++ [ myView ]`), filter (`builtins.filter`), or replace -# individual entries. -# -# Usage from a template: -# -# rc = diag.renderContext { inherit pkgs theme; mermaidConfig = elkCfg; }; -# hostViewDefs = diag.views.host rc; -# -# # Extend with a custom view: -# hostViewDefs = (diag.views.host rc) ++ [ myCustomView ]; -# -# # Drop a view: -# hostViewDefs = builtins.filter (v: v.view != "pipeline") -# (diag.views.host rc); -# -# Fields per view entry: -# -# view — short identifier (used in file name: `-.md`) -# title — markdown heading -# altText — SVG alt text -# mdLang — fenced code block language (`mermaid`, `plantuml`, `json`) -# svgInfix — `mmd`/`puml`/`dot`/null; inserted before `.svg` in filename -# svgFn — base → source → derivation (null = no SVG render) -# compute — graph → source string -# -{ graph, toJSON }: -let - mmd = svgFn: { - mdLang = "mermaid"; - svgInfix = "mmd"; - inherit svgFn; - }; - puml = svgFn: { - mdLang = "plantuml"; - svgInfix = "puml"; - inherit svgFn; - }; - json = { - mdLang = "json"; - svgInfix = null; - svgFn = null; - }; - # Raw format: bypasses markdown wrapping, outputs file directly. - # Used for JSON IR that should be piped through jq. - raw = ext: { - mdLang = null; - svgInfix = null; - svgFn = null; - rawExt = ext; - }; - - # mkView — canonical constructor for a single view entry. - # - # view — short identifier - # title — markdown heading / SVG alt text (altText defaults to title) - # altText — override SVG alt text when it differs from title - # fmt — mmd/puml/json format attrs (from helpers above) - # compute — graph → source string - mkView = - { - view, - title, - altText ? title, - fmt, - compute, - }: - { - inherit - view - title - altText - compute - ; - } - // fmt; - - self = { - - # --- Entity-agnostic core views --- - # - # Shared foundation for every entity kind (host, user, home, …). - # Default views: the essential set for understanding resolution. - core = - { - render, - renderDense, - mmdSourceToSvg, - ... - }: - [ - (mkView { - view = "aspects"; - title = "Aspect Hierarchy"; - altText = "Aspect hierarchy"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.aspectsOnly g); - }) - - (mkView { - view = "scope-seq"; - title = "Scope Sequence"; - altText = "Scope sequence"; - fmt = mmd mmdSourceToSvg; - compute = g: render.toSequenceMermaid g; - }) - - (mkView { - view = "scope-seq-full"; - title = "Scope Sequence (expanded)"; - altText = "Scope sequence expanded"; - fmt = mmd mmdSourceToSvg; - compute = g: render.toSequenceMermaidExpanded g; - }) - - (mkView { - view = "policy-seq"; - title = "Policy Sequence"; - altText = "Policy sequence"; - fmt = mmd mmdSourceToSvg; - compute = g: render.toPolicySequenceMermaid g; - }) - - (mkView { - view = "providers"; - title = "Provider Tree"; - altText = "Providers"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.providersOnly g); - }) - - (mkView { - view = "ir"; - title = "Graph IR (JSON)"; - altText = "IR JSON"; - fmt = raw "json"; - compute = g: toJSON g; - }) - ]; - - # Extended views: available for opt-in but not in the default set. - extended = - { - render, - renderDense, - mmdSourceToSvg, - ... - }: - [ - (mkView { - view = "ctx"; - title = "Context Hierarchy"; - altText = "Context hierarchy"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.contextOnly g); - }) - - (mkView { - view = "simple"; - title = "Simplified View"; - altText = "Simplified"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.simplified g); - }) - - (mkView { - view = "scope-edges"; - title = "Scope Topology"; - altText = "Scope edges"; - fmt = mmd mmdSourceToSvg; - compute = g: render.toScopeEdgesMermaid g; - }) - - (mkView { - view = "providers-resolved"; - title = "Providers Resolved"; - altText = "Provider resolution"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.providersResolved g); - }) - - (mkView { - view = "adapters"; - title = "Adapter Impact"; - altText = "Adapters"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.adaptersOnly g); - }) - - (mkView { - view = "decisions"; - title = "Structural Decisions"; - altText = "Decisions"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.decisionsView g); - }) - - (mkView { - view = "declared"; - title = "User-Declared Aspects"; - altText = "Declared"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.userDeclaredOnly g); - }) - - (mkView { - view = "diff-classes"; - title = "Class Diff"; - altText = "Class diff"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.diffClasses g); - }) - ]; - - # --- Dynamic per-class views --- - # - # Generated from the graph's available classes. Each class gets a - # slice view showing only the aspects that contribute to that class. - classViews = - { - renderDense, - mmdSourceToSvg, - ... - }: - classes: - map ( - className: - mkView { - view = "class-${className}"; - title = "Class Slice: ${className}"; - altText = "${className} slice"; - fmt = mmd mmdSourceToSvg; - compute = g: renderDense.toMermaid (graph.classSlice className g); - } - ) classes; - - # --- Per-entity views (host): core + class views --- - host = rc: self.core rc; - - # --- Per-entity views (user): core only --- - user = rc: self.core rc; - - # --- Per-entity views (home): core only --- - home = rc: self.core rc; - - # --- Fleet-level views (flake-wide, host-independent) --- - fleet = - { - render, - renderDense, - mmdSourceToSvg, - ... - }: - [ - (mkView { - view = "namespace"; - title = "Aspect Namespace (declarations)"; - altText = "Aspect namespace"; - fmt = mmd mmdSourceToSvg; - compute = _: renderDense.toMermaid (graph.ofNamespace { }); - }) - - (mkView { - view = "provider-matrix"; - title = "Fleet Provider Matrix"; - altText = "Provider matrix"; - fmt = mmd mmdSourceToSvg; - compute = render.toFleetProviderMatrix; - }) - ]; - }; -in -self From e029cd9e7ba16dc221ac2392d6ef8cd465af5bb5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 09:45:12 -0700 Subject: [PATCH 02/33] test: update diag tests for den-gram extraction --- templates/ci/flake.nix | 1 + .../modules/internal-api/fx-diag-capture.nix | 14 +++---- .../modules/internal-api/fx-diag-context.nix | 40 ++++++++++++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/templates/ci/flake.nix b/templates/ci/flake.nix index 8df911244..3c5be1939 100644 --- a/templates/ci/flake.nix +++ b/templates/ci/flake.nix @@ -8,6 +8,7 @@ inputs = { den.url = "github:denful/den"; + den-gram.url = "github:sini/den-gram"; import-tree.url = "github:vic/import-tree"; nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; diff --git a/templates/ci/modules/internal-api/fx-diag-capture.nix b/templates/ci/modules/internal-api/fx-diag-capture.nix index 85d8b6915..d673d20a8 100644 --- a/templates/ci/modules/internal-api/fx-diag-capture.nix +++ b/templates/ci/modules/internal-api/fx-diag-capture.nix @@ -28,7 +28,7 @@ } ]; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; in { expr = { @@ -79,7 +79,7 @@ } ]; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; excludedEntries = builtins.filter (e: e.excluded or false) result.entries; in { @@ -108,7 +108,7 @@ } ]; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; childEntry = lib.findFirst (e: e.name == "child") null result.entries; in { @@ -137,7 +137,7 @@ } ]; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; gcEntry = lib.findFirst (e: e.name == "grandchild") null result.entries; in { @@ -172,7 +172,7 @@ } ]; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; pathCount = builtins.length (builtins.attrNames (result.pathsByClass.nixos or { })); in { @@ -196,7 +196,7 @@ }; includes = [ ]; }; - result = den.lib.diag.captureWithPaths [ + result = den.lib.capture.captureWithPaths [ "nixos" "homeManager" ] root; @@ -230,7 +230,7 @@ }; includes = [ ]; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; rootEntry = lib.findFirst (e: e.name == "root") null result.entries; in { diff --git a/templates/ci/modules/internal-api/fx-diag-context.nix b/templates/ci/modules/internal-api/fx-diag-context.nix index d20972d35..b7d0734f5 100644 --- a/templates/ci/modules/internal-api/fx-diag-context.nix +++ b/templates/ci/modules/internal-api/fx-diag-context.nix @@ -8,7 +8,10 @@ flake.tests.fx-diag-context = { test-host-context = denTest ( - { den, ... }: + { den, inputs, ... }: + let + gram = inputs.den-gram.lib; + in { den.hosts.x86_64-linux.testhost.users.tux = { }; den.aspects.testhost.nixos = @@ -19,7 +22,19 @@ expr = let host = lib.head (builtins.attrValues den.hosts.x86_64-linux); - graph = den.lib.diag.hostContext { inherit host; }; + captured = den.lib.capture.captureWithPathsWith { + classes = [ + "nixos" + "homeManager" + "user" + ]; + root = den.lib.resolveEntity "host" { inherit host; }; + ctx = { inherit host; }; + }; + graph = gram.context { + inherit (captured) entries ctxTrace; + name = host.name; + }; in { hasNodes = (graph.nodes or [ ]) != [ ]; @@ -55,7 +70,7 @@ let host = lib.head (builtins.attrValues den.hosts.x86_64-linux); root = den.lib.resolveEntity "host" { inherit host; }; - result = den.lib.diag.captureWithPaths [ "nixos" ] root; + result = den.lib.capture.captureWithPaths [ "nixos" ] root; in { hasEntries = (builtins.length result.entries) > 0; @@ -69,7 +84,10 @@ ); test-handleWith-exclude-in-aspect = denTest ( - { den, ... }: + { den, inputs, ... }: + let + gram = inputs.den-gram.lib; + in { den.hosts.x86_64-linux.testhost.users.tux = { }; den.aspects.testhost = { @@ -97,7 +115,19 @@ expr = let host = lib.head (builtins.attrValues den.hosts.x86_64-linux); - graph = den.lib.diag.hostContext { inherit host; }; + captured = den.lib.capture.captureWithPathsWith { + classes = [ + "nixos" + "homeManager" + "user" + ]; + root = den.lib.resolveEntity "host" { inherit host; }; + ctx = { inherit host; }; + }; + graph = gram.context { + inherit (captured) entries ctxTrace; + name = host.name; + }; in { hasNodes = (graph.nodes or [ ]) != [ ]; From 903a631c8dcb32581a40dc1dd0abb8690de749b1 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 09:48:16 -0700 Subject: [PATCH 03/33] refactor: migrate diagram-demo and fleet-demo to den-gram --- ci.bash | 4 +- templates/diagram-demo/flake.nix | 1 + templates/diagram-demo/modules/diagrams.nix | 111 ++++++++++++++++---- templates/fleet-demo/flake.nix | 1 + templates/fleet-demo/modules/diagrams.nix | 53 +++++++--- 5 files changed, 135 insertions(+), 35 deletions(-) diff --git a/ci.bash b/ci.bash index c6afc5b74..359dda151 100644 --- a/ci.bash +++ b/ci.bash @@ -33,7 +33,7 @@ args=($@) # When a specific test is requested, delegate to nix-unit for traces if test -n "$testFilter"; then - nix_unit_output=$(nix-unit --override-input den . --flake "./templates/ci#.tests.${suite}" "${args[@]}" 2>&1) || true + nix_unit_output=$(nix-unit --override-input den . --override-input den-gram path:../den-gram --flake "./templates/ci#.tests.${suite}" "${args[@]}" 2>&1) || true # Show only the matching test's output (with surrounding trace context) echo "$nix_unit_output" | grep -v '^[✅❌🎉😢]' | grep -v 'successful$' >&2 || true if echo "$nix_unit_output" | grep -q "^✅ ${testFilter}$"; then @@ -57,7 +57,7 @@ workers=$(( $(nproc) < max_workers ? $(nproc) : max_workers )) nix-eval-jobs \ --flake ./templates/ci#tests${preSuite} \ - --override-input den . \ + --override-input den . --override-input den-gram path:../den-gram \ --workers "$workers" \ --max-memory-size "$mem_per_worker" \ --force-recurse \ diff --git a/templates/diagram-demo/flake.nix b/templates/diagram-demo/flake.nix index 638a792db..929606c52 100644 --- a/templates/diagram-demo/flake.nix +++ b/templates/diagram-demo/flake.nix @@ -5,6 +5,7 @@ inputs = { den.url = "path:../.."; + den-gram.url = "github:sini/den-gram"; import-tree.url = "github:vic/import-tree"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; diff --git a/templates/diagram-demo/modules/diagrams.nix b/templates/diagram-demo/modules/diagrams.nix index 349a1f610..f2de5c84d 100644 --- a/templates/diagram-demo/modules/diagrams.nix +++ b/templates/diagram-demo/modules/diagrams.nix @@ -14,10 +14,11 @@ den, lib, self, + inputs, ... }: let - inherit (den.lib) diag; + gram = inputs.den-gram.lib; allHosts = lib.concatMap builtins.attrValues (builtins.attrValues den.hosts); @@ -29,7 +30,7 @@ in perSystem = { pkgs, ... }: let - theme = diag.themeFromBase16 { + theme = gram.themeFromBase16 { inherit pkgs; scheme = themeScheme; }; @@ -54,7 +55,7 @@ in ''; }); - rc = diag.renderContext { + rc = gram.renderContext { inherit pkgs theme; mermaidCli = mermaidCliPatched; mermaidConfig = { @@ -69,7 +70,10 @@ in }; }; - fleetData = diag.fleet.of { flakeName = "diagram-demo"; }; + fleetData = gram.fleet.of { + hosts = den.hosts; + flakeName = "diagram-demo"; + }; # --- Render control --- # @@ -86,7 +90,7 @@ in # --- Helpers --- - inherit (diag.export) + inherit (gram.export) entityEntries filterByRender mkGallery @@ -97,14 +101,73 @@ in graphClasses = entity: lib.unique (lib.concatMap (n: n.classes or [ ]) entity.nodes); + # --- Capture + context helpers --- + + mkHostEntity = + host: + let + captured = den.lib.capture.captureWithPathsWith { + classes = lib.unique ( + [ + "nixos" + "homeManager" + "user" + ] + ++ lib.concatMap (u: u.classes or [ ]) (lib.attrValues (host.users or { })) + ); + root = den.lib.resolveEntity "host" { inherit host; }; + ctx = { inherit host; }; + }; + in + gram.context { + inherit (captured) entries ctxTrace pathsByClass; + name = host.name; + }; + + mkUserEntity = + u: + let + captured = den.lib.capture.captureWithPathsWith { + classes = lib.unique ( + [ + "homeManager" + "user" + ] + ++ (u.user.classes or [ "homeManager" ]) + ); + root = den.lib.resolveEntity "user" { inherit (u) host user; }; + ctx = { inherit (u) host user; }; + }; + in + gram.context { + inherit (captured) entries ctxTrace pathsByClass; + name = u.userName; + }; + + mkHomeEntity = + h: + let + captured = den.lib.capture.captureWithPathsWith { + classes = lib.unique ([ "homeManager" ] ++ (h.home.classes or [ "homeManager" ])); + root = den.lib.resolveEntity "home" { home = h.home; }; + ctx = { + home = h.home; + }; + }; + in + gram.context { + inherit (captured) entries ctxTrace pathsByClass; + name = h.home.name; + }; + # --- Host entries --- hostEntries = lib.concatMap ( host: let - entity = diag.hostContext { inherit host; }; + entity = mkHostEntity host; in - entityEntries { inherit pkgs rc diag; } { + entityEntries { inherit pkgs rc; } { inherit entity; name = host.name; dir = "hosts/${host.name}"; @@ -131,9 +194,9 @@ in userEntries = lib.concatMap ( u: let - entity = diag.userContext { inherit (u) host user; }; + entity = mkUserEntity u; in - entityEntries { inherit pkgs rc diag; } { + entityEntries { inherit pkgs rc; } { inherit entity; name = u.userName; dir = "hosts/${u.host.name}/users/${u.userName}"; @@ -157,9 +220,9 @@ in h: let safeName = lib.replaceStrings [ "@" ] [ "-at-" ] h.key; - entity = diag.homeContext { home = h.home; }; + entity = mkHomeEntity h; in - entityEntries { inherit pkgs rc diag; } { + entityEntries { inherit pkgs rc; } { inherit entity; name = "home-${safeName}"; dir = "homes/${safeName}"; @@ -169,19 +232,19 @@ in # --- Fleet entries --- - fleetEntriesList = diag.export.fleetEntries { inherit pkgs; } { + fleetEntriesList = gram.export.fleetEntries { inherit pkgs; } { inherit fleetData; viewDefs = fleetViewDefs; }; # --- Fleet-level views from captureFleet --- - fleetCapture = diag.captureFleet { }; + fleetCapture = den.lib.capture.captureFleet { }; # Per-host graph IRs for fleet DAG composition. hostGraphs = lib.listToAttrs ( map (host: { name = host.name; - value = diag.hostContext { inherit host; }; + value = mkHostEntity host; }) allHosts ); @@ -197,15 +260,15 @@ in }; # --- Text summaries --- - fleetSummaryText = diag.text.fleetSummary fleetCapture; + fleetSummaryText = gram.text.fleetSummary fleetCapture; fleetSummaryDrv = pkgs.writeText "fleet-summary.md" fleetSummaryText; hostSummaryDrvs = lib.listToAttrs ( map ( host: let - entity = diag.hostContext { inherit host; }; - text = diag.text.hostSummary { + entity = mkHostEntity host; + text = gram.text.hostSummary { graph = entity; inherit host fleetCapture; }; @@ -225,7 +288,7 @@ in rc.render.toPolicyResolutionMapMermaid; pipeSeqView = mkFleetView "pipe-sequence" "Pipe Sequence" rc.render.toPipeSequenceMermaid; fleetDagSource = rc.render.toFleetDagMermaid { inherit fleetCapture hostGraphs; }; - fleetIrJson = diag.fleetGraph.toJSON { inherit fleetCapture hostGraphs; }; + fleetIrJson = gram.fleetGraph.toJSON { inherit fleetCapture hostGraphs; }; fleetIrDrv = pkgs.runCommand "fleet-ir.json" { nativeBuildInputs = [ pkgs.jq ]; } '' echo ${lib.escapeShellArg fleetIrJson} | jq . > $out ''; @@ -234,6 +297,14 @@ in svg = rc.mmdSourceToSvg "fleet-dag" fleetDagSource; }; + # --- Namespace view (explicit, not part of fleet views) --- + namespaceGraph = gram.graph.ofNamespace { aspects = den.aspects or { }; }; + namespaceSource = rc.renderDense.toMermaid namespaceGraph; + namespaceView = { + md = pkgs.writeText "namespace.md" "# Namespace\n\n![Namespace](./namespace.mmd.svg)\n\n```mermaid\n${namespaceSource}\n```\n"; + svg = rc.mmdSourceToSvg "namespace" namespaceSource; + }; + # --- Fleet view entries --- mkFleetEntries = viewName: view: [ @@ -276,6 +347,7 @@ in ++ mkFleetEntries "policy-resolution" policyMapView ++ mkFleetEntries "pipe-sequence" pipeSeqView ++ mkFleetEntries "fleet-dag" fleetDagView + ++ mkFleetEntries "namespace" namespaceView ++ [ { name = "fleet"; @@ -360,7 +432,7 @@ in readmeDrv = pkgs.writeText "README.md" '' # Diag Demo - Aspect-resolution visualization via `den.lib.diag`. + Aspect-resolution visualization via `den-gram`. ## Directory Structure @@ -412,6 +484,7 @@ in | `policy-resolution` | Policy resolution map | | `pipe-sequence` | Pipe sequence diagram | | `fleet-dag` | Fleet-wide DAG | + | `namespace` | Aspect namespace graph | | `fleet-ir` | Graph IR (JSON, for ir-viewer) | | `summary` | Text summary (fleet + per-host) | diff --git a/templates/fleet-demo/flake.nix b/templates/fleet-demo/flake.nix index 0cf83c5af..6a68dd08e 100644 --- a/templates/fleet-demo/flake.nix +++ b/templates/fleet-demo/flake.nix @@ -5,6 +5,7 @@ inputs = { den.url = "path:../.."; + den-gram.url = "github:sini/den-gram"; import-tree.url = "github:vic/import-tree"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; diff --git a/templates/fleet-demo/modules/diagrams.nix b/templates/fleet-demo/modules/diagrams.nix index fdbf15613..524cb6b70 100644 --- a/templates/fleet-demo/modules/diagrams.nix +++ b/templates/fleet-demo/modules/diagrams.nix @@ -7,19 +7,23 @@ { den, lib, + inputs, ... }: let - inherit (den.lib) diag; + gram = inputs.den-gram.lib; allHosts = lib.concatMap builtins.attrValues (builtins.attrValues den.hosts); in { perSystem = { pkgs, ... }: let - rc = diag.renderContext { inherit pkgs; }; - fleetCapture = diag.captureFleet { }; - fleetData = diag.fleet.of { flakeName = "fleet-demo"; }; + rc = gram.renderContext { inherit pkgs; }; + fleetCapture = den.lib.capture.captureFleet { }; + fleetData = gram.fleet.of { + hosts = den.hosts; + flakeName = "fleet-demo"; + }; # Strip %%{init: ...}%% frontmatter from mermaid source. # Produces clean diagrams that use the renderer's default theme @@ -125,7 +129,8 @@ in let # Fleet-demo doesn't use WSL — filter out battery-injected aspects # that aren't relevant to this template's topology. - namespaceGraph = diag.graph.ofNamespace { + namespaceGraph = gram.graph.ofNamespace { + aspects = den.aspects or { }; filter = v: v.name != "wsl-host-aspect"; }; source = stripFrontmatter (rc.renderDense.toMermaid namespaceGraph); @@ -162,7 +167,7 @@ in fleetSummarySection = let - summaryText = diag.text.fleetSummary fleetCapture; + summaryText = gram.text.fleetSummary fleetCapture; in '' ## Fleet Summary @@ -190,14 +195,34 @@ in # --- IR (machine-readable) --- hostGraphs = lib.listToAttrs ( - map (host: { - name = host.name; - value = diag.hostContext { inherit host; }; - }) allHosts + map ( + host: + let + captured = den.lib.capture.captureWithPathsWith { + classes = lib.unique ( + [ + "nixos" + "homeManager" + "user" + ] + ++ lib.concatMap (u: u.classes or [ ]) (lib.attrValues (host.users or { })) + ); + root = den.lib.resolveEntity "host" { inherit host; }; + ctx = { inherit host; }; + }; + in + { + name = host.name; + value = gram.context { + inherit (captured) entries ctxTrace pathsByClass; + name = host.name; + }; + } + ) allHosts ); fleetIrDrv = pkgs.runCommand "fleet-ir.json" { nativeBuildInputs = [ pkgs.jq ]; } '' echo ${ - lib.escapeShellArg (diag.fleetGraph.toJSON { inherit fleetCapture hostGraphs; }) + lib.escapeShellArg (gram.fleetGraph.toJSON { inherit fleetCapture hostGraphs; }) } | jq . > $out ''; @@ -273,7 +298,7 @@ in `users.nix` promotes users to real entities (`den.schema.user.isEntity = true`) and extends the user schema with `email`, `groups`, and `ssh-keys` options. The registry type imports `den.schema.user` so each entry is a proper user - entity with `userName`, `classes`, and `aspect` — not a plain attrset. + entity with `userName`, `classes`, `aspect` — not a plain attrset. ## Policy-Driven Scope Tree @@ -481,8 +506,8 @@ in ); in { - packages = diag.export.entriesToPackages everyEntry // { - write-diagrams = diag.export.mkWriteScript pkgs { + packages = gram.export.entriesToPackages everyEntry // { + write-diagrams = gram.export.mkWriteScript pkgs { entries = everyEntry; galleries = [ ]; inherit readmeDrv; From 72e521cbba6a1695119e316797937589fddf8e53 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 09:53:45 -0700 Subject: [PATCH 04/33] refactor: move diagram tests to den-gram, keep capture tests --- ci.bash | 4 +- nix/lib/diag/capture.nix | 6 +- templates/ci/flake.nix | 1 - .../modules/internal-api/fx-diag-context.nix | 103 +----------------- 4 files changed, 10 insertions(+), 104 deletions(-) diff --git a/ci.bash b/ci.bash index 359dda151..c6afc5b74 100644 --- a/ci.bash +++ b/ci.bash @@ -33,7 +33,7 @@ args=($@) # When a specific test is requested, delegate to nix-unit for traces if test -n "$testFilter"; then - nix_unit_output=$(nix-unit --override-input den . --override-input den-gram path:../den-gram --flake "./templates/ci#.tests.${suite}" "${args[@]}" 2>&1) || true + nix_unit_output=$(nix-unit --override-input den . --flake "./templates/ci#.tests.${suite}" "${args[@]}" 2>&1) || true # Show only the matching test's output (with surrounding trace context) echo "$nix_unit_output" | grep -v '^[✅❌🎉😢]' | grep -v 'successful$' >&2 || true if echo "$nix_unit_output" | grep -q "^✅ ${testFilter}$"; then @@ -57,7 +57,7 @@ workers=$(( $(nproc) < max_workers ? $(nproc) : max_workers )) nix-eval-jobs \ --flake ./templates/ci#tests${preSuite} \ - --override-input den . --override-input den-gram path:../den-gram \ + --override-input den . \ --workers "$workers" \ --max-memory-size "$mem_per_worker" \ --force-recurse \ diff --git a/nix/lib/diag/capture.nix b/nix/lib/diag/capture.nix index ee9ed30f6..4b4d48a8f 100644 --- a/nix/lib/diag/capture.nix +++ b/nix/lib/diag/capture.nix @@ -2,9 +2,9 @@ # via the fx pipeline's tracingHandler. # # Usage: -# entries = diag.capture "nixos" rootAspect; -# entries = diag.captureAll [ "nixos" "homeManager" ] rootAspect; -# { entries, pathsByClass, ctxTrace } = diag.captureWithPaths classes rootAspect; +# entries = den.lib.capture.capture "nixos" rootAspect; +# entries = den.lib.capture.captureAll [ "nixos" "homeManager" ] rootAspect; +# { entries, pathsByClass, ctxTrace } = den.lib.capture.captureWithPaths classes rootAspect; { den, lib, diff --git a/templates/ci/flake.nix b/templates/ci/flake.nix index 3c5be1939..8df911244 100644 --- a/templates/ci/flake.nix +++ b/templates/ci/flake.nix @@ -8,7 +8,6 @@ inputs = { den.url = "github:denful/den"; - den-gram.url = "github:sini/den-gram"; import-tree.url = "github:vic/import-tree"; nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; diff --git a/templates/ci/modules/internal-api/fx-diag-context.nix b/templates/ci/modules/internal-api/fx-diag-context.nix index b7d0734f5..5669e1e02 100644 --- a/templates/ci/modules/internal-api/fx-diag-context.nix +++ b/templates/ci/modules/internal-api/fx-diag-context.nix @@ -1,4 +1,8 @@ -# Tests for the diag library's context graph construction. +# Tests for the diag library's capture integration. +# +# Context graph construction tests (basic context, exclude handling) live +# in den-gram's own test suite. These tests exercise den-specific capture +# and constraint APIs that require the full pipeline. { denTest, lib, @@ -7,48 +11,6 @@ { flake.tests.fx-diag-context = { - test-host-context = denTest ( - { den, inputs, ... }: - let - gram = inputs.den-gram.lib; - in - { - den.hosts.x86_64-linux.testhost.users.tux = { }; - den.aspects.testhost.nixos = - { ... }: - { - networking.hostName = "fx-diag-test"; - }; - expr = - let - host = lib.head (builtins.attrValues den.hosts.x86_64-linux); - captured = den.lib.capture.captureWithPathsWith { - classes = [ - "nixos" - "homeManager" - "user" - ]; - root = den.lib.resolveEntity "host" { inherit host; }; - ctx = { inherit host; }; - }; - graph = gram.context { - inherit (captured) entries ctxTrace; - name = host.name; - }; - in - { - hasNodes = (graph.nodes or [ ]) != [ ]; - hasEdges = graph ? edges; - rootName = graph.rootName or "unknown"; - }; - expected = { - hasNodes = true; - hasEdges = true; - rootName = "testhost"; - }; - } - ); - test-capture-in-context = denTest ( { den, ... }: { @@ -83,61 +45,6 @@ } ); - test-handleWith-exclude-in-aspect = denTest ( - { den, inputs, ... }: - let - gram = inputs.den-gram.lib; - in - { - den.hosts.x86_64-linux.testhost.users.tux = { }; - den.aspects.testhost = { - includes = [ - den.aspects.networking - den.aspects.desktop - ]; - meta.handleWith = den.lib.aspects.fx.constraints.exclude den.aspects.tailscale; - }; - den.aspects.networking.nixos = - { ... }: - { - networking.firewall.enable = true; - }; - den.aspects.desktop.nixos = - { ... }: - { - services.xserver.enable = true; - }; - den.aspects.tailscale.nixos = - { ... }: - { - services.tailscale.enable = true; - }; - expr = - let - host = lib.head (builtins.attrValues den.hosts.x86_64-linux); - captured = den.lib.capture.captureWithPathsWith { - classes = [ - "nixos" - "homeManager" - "user" - ]; - root = den.lib.resolveEntity "host" { inherit host; }; - ctx = { inherit host; }; - }; - graph = gram.context { - inherit (captured) entries ctxTrace; - name = host.name; - }; - in - { - hasNodes = (graph.nodes or [ ]) != [ ]; - }; - expected = { - hasNodes = true; - }; - } - ); - test-fx-constraints-access = denTest ( { den, ... }: let From 3bbd13b35cfae835ac6fdd1dbf97d2f410b4fba5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 10:12:55 -0700 Subject: [PATCH 05/33] =?UTF-8?q?rename:=20den-gram=20=E2=86=92=20den-diag?= =?UTF-8?q?ram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/ci/modules/internal-api/fx-diag-context.nix | 2 +- templates/diagram-demo/flake.nix | 2 +- templates/diagram-demo/modules/diagrams.nix | 4 ++-- templates/fleet-demo/flake.nix | 2 +- templates/fleet-demo/modules/diagrams.nix | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/templates/ci/modules/internal-api/fx-diag-context.nix b/templates/ci/modules/internal-api/fx-diag-context.nix index 5669e1e02..8a2432812 100644 --- a/templates/ci/modules/internal-api/fx-diag-context.nix +++ b/templates/ci/modules/internal-api/fx-diag-context.nix @@ -1,7 +1,7 @@ # Tests for the diag library's capture integration. # # Context graph construction tests (basic context, exclude handling) live -# in den-gram's own test suite. These tests exercise den-specific capture +# in den-diagram's own test suite. These tests exercise den-specific capture # and constraint APIs that require the full pipeline. { denTest, diff --git a/templates/diagram-demo/flake.nix b/templates/diagram-demo/flake.nix index 929606c52..bd5bdd53e 100644 --- a/templates/diagram-demo/flake.nix +++ b/templates/diagram-demo/flake.nix @@ -5,7 +5,7 @@ inputs = { den.url = "path:../.."; - den-gram.url = "github:sini/den-gram"; + den-diagram.url = "github:sini/den-diagram"; import-tree.url = "github:vic/import-tree"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; diff --git a/templates/diagram-demo/modules/diagrams.nix b/templates/diagram-demo/modules/diagrams.nix index f2de5c84d..fe204f392 100644 --- a/templates/diagram-demo/modules/diagrams.nix +++ b/templates/diagram-demo/modules/diagrams.nix @@ -18,7 +18,7 @@ ... }: let - gram = inputs.den-gram.lib; + gram = inputs.den-diagram.lib; allHosts = lib.concatMap builtins.attrValues (builtins.attrValues den.hosts); @@ -432,7 +432,7 @@ in readmeDrv = pkgs.writeText "README.md" '' # Diag Demo - Aspect-resolution visualization via `den-gram`. + Aspect-resolution visualization via `den-diagram`. ## Directory Structure diff --git a/templates/fleet-demo/flake.nix b/templates/fleet-demo/flake.nix index 6a68dd08e..8036836ea 100644 --- a/templates/fleet-demo/flake.nix +++ b/templates/fleet-demo/flake.nix @@ -5,7 +5,7 @@ inputs = { den.url = "path:../.."; - den-gram.url = "github:sini/den-gram"; + den-diagram.url = "github:sini/den-diagram"; import-tree.url = "github:vic/import-tree"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; diff --git a/templates/fleet-demo/modules/diagrams.nix b/templates/fleet-demo/modules/diagrams.nix index 524cb6b70..b0112dd74 100644 --- a/templates/fleet-demo/modules/diagrams.nix +++ b/templates/fleet-demo/modules/diagrams.nix @@ -11,7 +11,7 @@ ... }: let - gram = inputs.den-gram.lib; + gram = inputs.den-diagram.lib; allHosts = lib.concatMap builtins.attrValues (builtins.attrValues den.hosts); in { From 5bc38893bfda788fd60061cb5b1a7e8b14546429 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 10:16:55 -0700 Subject: [PATCH 06/33] docs: update CLAUDE.md for den-diagram extraction --- CLAUDE.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index e96b96aa0..3a1b620ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ nix/ — all library and flake-module code aspect/ — children, normalize, provide policy/ — policy dispatch and effects entities/ — host.nix, home.nix entity kind definitions - diag/ — diagram generation (c4, mermaid, dot, fleet views) + diag/capture.nix — trace capture (graph/rendering moved to sini/den-diagram) nixModule/ — den.aspects, den.policies, den.lib option declarations modules/ — NixOS-module-style option declarations and batteries options.nix — den.hosts, den.homes, den.schema, den.classes, den.quirks @@ -129,6 +129,34 @@ New test files must be `git add`'d before nix can evaluate them. Use `--override - **den-debugging** (`.claude/skills/den-debugging.md`) — structured workflow for reproducing, isolating, and fixing bugs. Guides through: understand report → trace code path → write failing test → fix → validate. Includes an entry point table mapping symptoms to source files. +## Diagrams (den-diagram) + +Diagram rendering lives in a separate repo: [sini/den-diagram](https://github.com/sini/den-diagram). Den keeps only the capture layer (`nix/lib/diag/capture.nix`) which runs the fx pipeline with tracing handlers. + +**Capture** stays in den — `den.lib.capture.*`: +- `capture`, `captureAll`, `captureWithPaths`, `captureWithPathsWith`, `captureFleet` + +**Rendering** lives in den-diagram — added as `inputs.den-diagram` in templates that need it: + +```nix +gram = inputs.den-diagram.lib; + +# Two-step: capture in den, render in den-diagram +captured = den.lib.capture.captureWithPathsWith { + classes = [ "nixos" "homeManager" ]; + root = den.lib.resolveEntity "host" { inherit host; }; + ctx = { inherit host; }; +}; +g = gram.context { + entries = captured.entries; + ctxTrace = captured.ctxTrace; + name = host.name; +}; +rendered = gram.toMermaid g; +``` + +Templates using den-diagram: `diagram-demo`, `fleet-demo`. Den's core flake and CI have no den-diagram dependency. + ## Debugging and tracing For pipeline debugging, use `builtins.trace` temporarily to inspect values flowing through handlers: From 0d3077db9711ef4d229a439cdcf477a8c48aaa14 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 10:46:39 -0700 Subject: [PATCH 07/33] fix: treefmt exclude CLAUDE.md, fix markdown list spacing --- CLAUDE.md | 1 + checkmate/modules/formatter.nix | 1 + 2 files changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3a1b620ae..a35a8c2f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,7 @@ New test files must be `git add`'d before nix can evaluate them. Use `--override Diagram rendering lives in a separate repo: [sini/den-diagram](https://github.com/sini/den-diagram). Den keeps only the capture layer (`nix/lib/diag/capture.nix`) which runs the fx pipeline with tracing handlers. **Capture** stays in den — `den.lib.capture.*`: + - `capture`, `captureAll`, `captureWithPaths`, `captureWithPathsWith`, `captureFleet` **Rendering** lives in den-diagram — added as `inputs.den-diagram` in templates that need it: diff --git a/checkmate/modules/formatter.nix b/checkmate/modules/formatter.nix index ebbe71fbb..0cf87037f 100644 --- a/checkmate/modules/formatter.nix +++ b/checkmate/modules/formatter.nix @@ -6,6 +6,7 @@ "docs/*" "Justfile" "AGENT*.md" + "CLAUDE.md" "*.txt" "*.svg" "ci.bash" From 336200c13b1b9a04420bc9ff5dd01aeedd7df6ed Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 10:49:11 -0700 Subject: [PATCH 08/33] fix: update den-diagram references to denful org --- CLAUDE.md | 4 ++-- templates/diagram-demo/flake.nix | 2 +- templates/fleet-demo/flake.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a35a8c2f3..2e861f155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ nix/ — all library and flake-module code aspect/ — children, normalize, provide policy/ — policy dispatch and effects entities/ — host.nix, home.nix entity kind definitions - diag/capture.nix — trace capture (graph/rendering moved to sini/den-diagram) + diag/capture.nix — trace capture (graph/rendering moved to denful/den-diagram) nixModule/ — den.aspects, den.policies, den.lib option declarations modules/ — NixOS-module-style option declarations and batteries options.nix — den.hosts, den.homes, den.schema, den.classes, den.quirks @@ -131,7 +131,7 @@ New test files must be `git add`'d before nix can evaluate them. Use `--override ## Diagrams (den-diagram) -Diagram rendering lives in a separate repo: [sini/den-diagram](https://github.com/sini/den-diagram). Den keeps only the capture layer (`nix/lib/diag/capture.nix`) which runs the fx pipeline with tracing handlers. +Diagram rendering lives in a separate repo: [denful/den-diagram](https://github.com/denful/den-diagram). Den keeps only the capture layer (`nix/lib/diag/capture.nix`) which runs the fx pipeline with tracing handlers. **Capture** stays in den — `den.lib.capture.*`: diff --git a/templates/diagram-demo/flake.nix b/templates/diagram-demo/flake.nix index bd5bdd53e..b8a958a1a 100644 --- a/templates/diagram-demo/flake.nix +++ b/templates/diagram-demo/flake.nix @@ -5,7 +5,7 @@ inputs = { den.url = "path:../.."; - den-diagram.url = "github:sini/den-diagram"; + den-diagram.url = "github:denful/den-diagram"; import-tree.url = "github:vic/import-tree"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; diff --git a/templates/fleet-demo/flake.nix b/templates/fleet-demo/flake.nix index 8036836ea..a3aca2bf9 100644 --- a/templates/fleet-demo/flake.nix +++ b/templates/fleet-demo/flake.nix @@ -5,7 +5,7 @@ inputs = { den.url = "path:../.."; - den-diagram.url = "github:sini/den-diagram"; + den-diagram.url = "github:denful/den-diagram"; import-tree.url = "github:vic/import-tree"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; From 30c754b2e22fbd6c74dcebd39db9642da0462741 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 11:29:42 -0700 Subject: [PATCH 09/33] docs: remove accidentally commited specs --- .../2026-05-07-diagram-identity-resolution.md | 753 ------------------ ...-diagram-identity-resolution.md.tasks.json | 89 --- .../plans/2026-05-07-hero-canvas-animation.md | 283 ------- ...-05-07-hero-canvas-animation.md.tasks.json | 41 - ...5-07-diagram-identity-resolution-design.md | 218 ----- ...2026-05-07-hero-canvas-animation-design.md | 79 -- ...08-fleet-pipe-visualization-exploration.md | 200 ----- 7 files changed, 1663 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md delete mode 100644 docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md.tasks.json delete mode 100644 docs/superpowers/plans/2026-05-07-hero-canvas-animation.md delete mode 100644 docs/superpowers/plans/2026-05-07-hero-canvas-animation.md.tasks.json delete mode 100644 docs/superpowers/specs/2026-05-07-diagram-identity-resolution-design.md delete mode 100644 docs/superpowers/specs/2026-05-07-hero-canvas-animation-design.md delete mode 100644 docs/superpowers/specs/2026-05-08-fleet-pipe-visualization-exploration.md diff --git a/docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md b/docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md deleted file mode 100644 index 18c00cd49..000000000 --- a/docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md +++ /dev/null @@ -1,753 +0,0 @@ -# Diagram Identity Resolution Overhaul — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix the diagram system's broken trace handler so entity kinds, scope bounding, policy nodes, and sequence diagrams all work again. - -**Architecture:** The trace handler (`trace.nix`) needs three fixes: seed entityKind from `param.__entityKind`, populate `ctxTrace` for sequence diagrams, and add a `record-fired` handler for policy nodes. The graph builder (`graph.nix`) needs entity instance grouping. The mermaid renderer needs per-instance subgraphs. View names need updating from "stage" to "scope" vocabulary. - -**Tech Stack:** Pure Nix. Tests via nix-unit (`just ci`). Diagrams via mermaid-cli for SVG rendering. - -**Spec:** `docs/superpowers/specs/2026-05-07-diagram-identity-resolution-design.md` - ---- - -### Task 1: Fix entity kind seeding and ctxTrace population in trace handler - -**Goal:** Make `tracingHandler` produce entries with correct `entityKind` and populate `ctxTrace` for sequence diagrams. - -**Files:** -- Modify: `nix/lib/aspects/fx/trace.nix:102-148` (tracingHandler resolve-complete handler) -- Test: `templates/ci/modules/features/fx-trace.nix` - -**Acceptance Criteria:** -- [ ] Entity boundary aspects (those with `__entityKind`) produce entries with non-null `entityKind` -- [ ] Descendant aspects inherit `entityKind` via `deriveEntityKind` -- [ ] `ctxTrace` accumulates one entry per entity kind with `{ key, selfName, entityKind, ctxKeys }` -- [ ] ctxTrace entries are deduplicated by entity kind -- [ ] Existing trace tests still pass - -**Verify:** `nix develop -c just ci fx-trace` → summary line shows all pass - -**Steps:** - -- [ ] **Step 1: Fix entityKind and add ctxTrace in tracingHandler, then write test** - -Note: The test and implementation are done together because the ctxTrace assertions depend on the ctxTrace implementation. Write the implementation first, then the test. - -- [ ] **Step 2: Fix entityKind in tracingHandler** - -In `nix/lib/aspects/fx/trace.nix`, in the `tracingHandler` function's `resolve-complete` handler, change: - -```nix -# Old (line ~108): -entityKind = deriveEntityKind state; -``` - -to: - -```nix -entityKind = - let direct = param.__entityKind or null; - in if direct != null then direct else deriveEntityKind state; -``` - -- [ ] **Step 3: Add ctxTrace population** - -In the same handler, after computing `entityKind` and `name`, add ctxTrace logic. Add a `resolveEntityName` helper above the handler: - -```nix -resolveEntityName = ek: scopeCtx: - let entity = scopeCtx.${ek} or null; - in if entity != null && entity ? name then entity.name - else ek; -``` - -Then in the state update section, add: - -```nix -scope = state.currentScope; -scopeCtx = if scope == null then {} else ((state.scopeContexts or (_: {})) null).${scope} or {}; -isNewKind = !(builtins.any (e: e.key == entityKind) (state.ctxTrace or [])); -ctxEntry = { - key = entityKind; - selfName = resolveEntityName entityKind scopeCtx; - entityKind = entityKind; - ctxKeys = builtins.attrNames scopeCtx; -}; -``` - -And in the state merge: - -```nix -state = state // { - entries = (state.entries or []) ++ [ entry ]; -} // lib.optionalAttrs (entityKind != null && isNewKind) { - ctxTrace = (state.ctxTrace or []) ++ [ ctxEntry ]; -}; -``` - -- [ ] **Step 4: Write test for entityKind seeding and ctxTrace** - -Add to `templates/ci/modules/features/fx-trace.nix`: - -```nix -test-tracingHandler-entity-kind-seeded = denTest ( - { den, ... }: - let - entity = { - name = "host"; - __entityKind = "host"; - meta = { provider = []; }; - nixos = { a = 1; }; - includes = [ - { - name = "child"; - meta = { provider = []; }; - nixos = { b = 2; }; - includes = []; - } - ]; - }; - result = den.lib.aspects.fx.pipeline.mkPipeline { - class = "nixos"; - extraHandlers = den.lib.aspects.fx.trace.tracingHandler "nixos"; - extraState = { entries = []; ctxTrace = []; }; - } { - self = entity // { into = _: {}; provides = {}; }; - ctx = {}; - }; - hostEntry = lib.findFirst (e: e.name == "host") null result.state.entries; - childEntry = lib.findFirst (e: e.name == "child") null result.state.entries; - in { - expr = { - hostEntityKind = hostEntry.entityKind; - childEntityKind = childEntry.entityKind; - ctxTraceLength = builtins.length result.state.ctxTrace; - ctxTraceKey = (builtins.head result.state.ctxTrace).key; - }; - expected = { - hostEntityKind = "host"; - childEntityKind = "host"; - ctxTraceLength = 1; - ctxTraceKey = "host"; - }; - } -); -``` - -- [ ] **Step 5: Run tests, format, and commit** - -Run: `nix develop -c just fmt && nix develop -c just ci fx-trace` -Expected: All tests PASS including the new entityKind test. - -```bash -git add nix/lib/aspects/fx/trace.nix templates/ci/modules/features/fx-trace.nix -git commit -m "fix(diag): seed entityKind from param.__entityKind, populate ctxTrace" -``` - ---- - -### Task 2: Add record-fired handler for policy trace entries - -**Goal:** Capture fired policy names as trace entries so the graph builder can create policy dispatch nodes. - -**Files:** -- Modify: `nix/lib/aspects/fx/trace.nix` (add `record-fired` to tracingHandler) -- Test: `templates/ci/modules/features/fx-trace.nix` - -**Acceptance Criteria:** -- [ ] `tracingHandler` returns a handler set with both `resolve-complete` and `record-fired` -- [ ] Fired policies produce entries with `isPolicyDispatch = true`, `policyName`, `from`, `entityKind` -- [ ] `to` is `null` (inferred later in graph builder) -- [ ] Composing with `defaultHandlers` doesn't break `record-fired` resume (must be `null`) - -**Verify:** `nix develop -c just ci fx-trace` → all pass - -**Steps:** - -- [ ] **Step 1: Write test for record-fired handler** - -Add to `templates/ci/modules/features/fx-trace.nix`: - -```nix -# Test that tracingHandler's record-fired creates policy entries -test-tracingHandler-record-fired = denTest ( - { den, ... }: - let - fx = den.lib.fx; - comp = fx.send "record-fired" { - entityKind = "host"; - firedPolicies = { host-to-users = true; host-to-default = true; }; - }; - result = fx.handle { - handlers = den.lib.aspects.fx.pipeline.composeHandlers - (den.lib.aspects.fx.pipeline.defaultHandlers { class = "nixos"; ctx = {}; }) - (den.lib.aspects.fx.trace.tracingHandler "nixos"); - state = den.lib.aspects.fx.pipeline.defaultState // { - entries = []; ctxTrace = []; - }; - } comp; - policyEntries = builtins.filter (e: e.isPolicyDispatch or false) result.state.entries; - policyNames = lib.sort (a: b: a < b) (map (e: e.policyName) policyEntries); - in { - expr = { - count = builtins.length policyEntries; - names = policyNames; - fromKind = (builtins.head policyEntries).from; - toIsNull = (builtins.head policyEntries).to == null; - }; - expected = { - count = 2; - names = [ "host-to-default" "host-to-users" ]; - fromKind = "host"; - toIsNull = true; - }; - } -); -``` - -- [ ] **Step 2: Add record-fired handler to tracingHandler** - -In `nix/lib/aspects/fx/trace.nix`, change `tracingHandler` from returning a single-key handler set to a two-key handler set. Add `record-fired` alongside `resolve-complete`: - -```nix -tracingHandler = class: { - "resolve-complete" = { param, state }: /* ... existing handler ... */; - - "record-fired" = { param, state }: - let - firedNames = builtins.attrNames param.firedPolicies; - policyEntries = map (policyName: { - name = policyName; - class = ""; - parent = null; - provider = []; - excluded = false; - excludedFrom = null; - replacedBy = null; - isProvider = false; - handlers = []; - hasClass = false; - isParametric = false; - fnArgNames = []; - entityKind = param.entityKind; - isPolicyDispatch = true; - policyName = policyName; - from = param.entityKind; - to = null; - }) firedNames; - in { - resume = null; - state = state // { - entries = (state.entries or []) ++ policyEntries; - }; - }; -}; -``` - -- [ ] **Step 3: Run tests and commit** - -Run: `nix develop -c just fmt && nix develop -c just ci fx-trace` -Expected: All tests PASS. - -```bash -git add nix/lib/aspects/fx/trace.nix templates/ci/modules/features/fx-trace.nix -git commit -m "feat(diag): add record-fired handler to capture policy dispatch entries" -``` - ---- - -### Task 3: Add entity instance tracking to trace entries and graph IR - -**Goal:** Each trace entry carries an `entityInstance` field (e.g., `"host:laptop"`) so the graph builder can group nodes by specific entity instances. The graph IR gains an `entityInstances` list. - -**Files:** -- Modify: `nix/lib/aspects/fx/trace.nix:102-148` (add `entityInstance` to entry) -- Modify: `nix/lib/diag/graph.nix:23-51` (add `entityInstance` to `emptyNode` and `stubEntry`) -- Modify: `nix/lib/diag/graph.nix:100-493` (build `entityInstances` list in `buildGraph`, add `entityInstance` to `mkNode`) -- Modify: `nix/lib/diag/json.nix:41-51` (add `entityInstances` to serialized output) - -**Acceptance Criteria:** -- [ ] Trace entries include `entityInstance` field (e.g., `"host:laptop"` or `null`) -- [ ] `emptyNode` and `stubEntry` have `entityInstance = null` -- [ ] `buildGraph` output includes `entityInstances` list with `{ id, kind, name, label }` records -- [ ] `buildGraph` output retains `entityKinds` for sequence diagram compatibility -- [ ] Nodes without entity kind get `entityInstance = "flake"` when there are other entity instances in the graph - -**Verify:** `nix develop -c just ci fx-trace` → all pass; `nix build --override-input den . ./templates/diagram-demo#laptop-ir --no-link --print-out-paths` → IR JSON has `entityInstances` array with entries - -**Steps:** - -- [ ] **Step 1: Add entityInstance to trace entries** - -In `nix/lib/aspects/fx/trace.nix`, in the `tracingHandler`'s `resolve-complete` handler, compute `entityInstance` alongside `entityKind`: - -```nix -entityInstance = - if entityKind != null then - let - scope = state.currentScope; -scopeCtx = if scope == null then {} else ((state.scopeContexts or (_: {})) null).${scope} or {}; - eName = resolveEntityName entityKind scopeCtx; - in "${entityKind}:${eName}" - else null; -``` - -Add `inherit entityInstance;` to the entry record. - -Also add `entityInstance` to the `record-fired` policy entries, using the same derivation from state at that point. - -- [ ] **Step 2: Add entityInstance to graph.nix emptyNode/stubEntry** - -In `nix/lib/diag/graph.nix`, add to `emptyNode` (after `entityKind = null;`): - -```nix -entityInstance = null; -``` - -Add the same to `stubEntry`. - -- [ ] **Step 3: Add entityInstance to mkNode and build entityInstances list** - -In `buildGraph`, after computing `mkNode`, read `entityInstance` from the entry: - -```nix -mkNode = entry: - let - # ... existing code ... - in { - # ... existing fields ... - entityInstance = entry.entityInstance or null; - }; -``` - -After `finalNodes`, build `entityInstances`: - -```nix -# Assign "flake" instance to unscoped nodes when entity instances exist. -hasAnyInstances = builtins.any (n: n.entityInstance != null) finalNodes; -taggedNodes = if hasAnyInstances then - map (n: if n.entityInstance == null then n // { entityInstance = "flake"; } else n) finalNodes -else finalNodes; - -entityInstanceNames = lib.unique ( - builtins.filter (s: s != null) (map (n: n.entityInstance) taggedNodes) -); -entityInstances = map (inst: - let - parts = lib.splitString ":" inst; - kind = builtins.head parts; - name = if builtins.length parts > 1 then lib.concatStringsSep ":" (lib.tail parts) else inst; - in { - id = sanitize "ctx_${inst}"; - inherit kind name; - label = if inst == "flake" then "flake" else "${kind}: ${name}"; - } -) entityInstanceNames; -``` - -Update the return record: - -```nix -{ - inherit rootName direction; - rootId = sanitize rootName; - nodes = taggedNodes; # was: finalNodes - edges = /* ... unchanged ... */; - entityKinds = map mkEntityKind entityKindNames; # retained for sequence diagrams - inherit entityEdges entityInstances; -} -``` - -- [ ] **Step 4: Update json.nix to serialize entityInstances** - -In `nix/lib/diag/json.nix`, add `entityInstances` to the `toJSON` function's output record (line 49, after `entityEdges`): - -```nix -entityInstances = g.entityInstances or []; -``` - -- [ ] **Step 5: Also add entityInstance to record-fired entries** - -In `nix/lib/aspects/fx/trace.nix`, update the `record-fired` handler's policy entries to include `entityInstance`: - -```nix -scope = state.currentScope; -scopeCtx = if scope == null then {} else ((state.scopeContexts or (_: {})) null).${scope} or {}; -entityInstance = - if param.entityKind != null then - "${param.entityKind}:${resolveEntityName param.entityKind scopeCtx}" - else null; -``` - -Add `inherit entityInstance;` to each policy entry. - -- [ ] **Step 6: Run tests and verify IR output** - -Run: `nix develop -c just fmt && nix develop -c just ci fx-trace` -Then: `nix build --override-input den . ./templates/diagram-demo#laptop-ir --no-link --print-out-paths` and inspect the JSON for `entityInstances`. - -```bash -git add nix/lib/aspects/fx/trace.nix nix/lib/diag/graph.nix nix/lib/diag/json.nix -git commit -m "feat(diag): add entityInstance tracking to trace entries and graph IR" -``` - ---- - -### Task 4: Update mermaid renderer for entity instance subgraphs and policy bridges - -**Goal:** DAG diagrams group nodes into per-instance subgraphs (`host:laptop`, `user:alice`) with policy nodes as bridges between them. - -**Files:** -- Modify: `nix/lib/diag/mermaid.nix:70-275` (replace `entitySubgraph` with instance-based grouping, update policy edge rendering) - -**Acceptance Criteria:** -- [ ] DAG renders `subgraph` blocks per entity instance (e.g., `host: laptop`, `user: alice`) -- [ ] Unscoped nodes render in a `flake` subgraph -- [ ] Policy dispatch nodes render outside all subgraphs with bridge edges -- [ ] Cross-instance edges render at top level -- [ ] Flat views (no entity instances) still work correctly - -**Verify:** `nix build --override-input den . ./templates/diagram-demo#laptop-dag --no-link --print-out-paths` → DAG contains `subgraph` blocks with entity instance labels - -**Steps:** - -- [ ] **Step 1: Replace entitySubgraph with instanceSubgraph** - -In `nix/lib/diag/mermaid.nix`, replace the `entitySubgraph` function and related code. The key changes: - -Replace `hasEntityKinds` with: -```nix -hasEntityInstances = graph.entityInstances or [] != []; -``` - -Replace `entitySubgraph` with: -```nix -instanceSubgraph = inst: - let - instNodes = builtins.filter (n: - n.entityInstance == "${inst.kind}:${inst.name}" - && n.id != rootId - && !(n.isPolicyDispatch or false) - ) nodes; - instEdges = builtins.filter (e: - let - fromNode = nodeById.${e.from} or null; - toNode = nodeById.${e.to} or null; - fromInst = if fromNode != null then fromNode.entityInstance else null; - toInst = if toNode != null then toNode.entityInstance else null; - thisInst = "${inst.kind}:${inst.name}"; - in - fromNode != null - && fromInst == thisInst - && (toInst == null || toInst == thisInst) - && (e.style or "normal") != "policy" - ) edges; - in - lib.optional (instNodes != []) ( - " subgraph ${inst.id}[\"${inst.label}\"]\n" - + lib.concatMapStringsSep "\n" nodeDecl instNodes - + "\n" - + lib.concatMapStringsSep "\n" edgeDecl instEdges - + "\n end" - ); -``` - -- [ ] **Step 2: Update topLevelNodes and unmappedEdges for instances** - -```nix -topLevelNodes = - if hasEntityInstances then - builtins.filter (n: - n.entityInstance == null - && n.id != rootId - && !(n.isPolicyDispatch or false) - ) nodes - else - builtins.filter (n: n.id != rootId) nodes; - -policyNodes = builtins.filter (n: n.isPolicyDispatch or false) nodes; - -unmappedEdges = builtins.filter (e: - let - fromNode = nodeById.${e.from} or null; - toNode = nodeById.${e.to} or null; - fromInst = if fromNode != null then fromNode.entityInstance else null; - toInst = if toNode != null then toNode.entityInstance else null; - isCrossInst = fromInst != null && toInst != null && fromInst != toInst; - in - (fromNode != null && fromInst == null) - || (isCrossInst && (e.style or "normal") != "policy") -) edges; -``` - -- [ ] **Step 3: Update the diagram assembly** - -Replace the `hasEntityKinds` branch in the final `renderMermaid` call with `hasEntityInstances`: - -```nix -if hasEntityInstances then - lib.concatMap instanceSubgraph (graph.entityInstances or []) - ++ [ "" ] - ++ map nodeDecl policyNodes - ++ map edgeDecl (builtins.filter (e: (e.style or "normal") == "policy") edges) - ++ map edgeDecl unmappedEdges -else - map edgeDecl edges -``` - -Update `kindSuffix` to use `entityInstance` instead of `entityKind` for flat views. Update the subgraph style lines to iterate `entityInstances` instead of `entityKinds`. - -- [ ] **Step 4: Verify and commit** - -Run: `nix develop -c just fmt` -Then: `nix build --override-input den . ./templates/diagram-demo#laptop-dag --no-link --print-out-paths` and read the output. -Expected: Mermaid source contains `subgraph ctx_host_laptop["host: laptop"]` and similar blocks. - -```bash -git add nix/lib/diag/mermaid.nix -git commit -m "feat(diag): render entity instance subgraphs with policy bridges" -``` - ---- - -### Task 5: Investigate and fix anonymous nodes - -**Goal:** Determine what the `host/:3`, `user/:2`, `insecure-predicate/:1` nodes actually are, then either prune internal artifacts or enrich their labels. - -**Files:** -- Modify: `nix/lib/aspects/fx/trace.nix` (improve anon naming with entityKind now working) -- Possibly modify: `nix/lib/diag/filters/predicate.nix` or `fold.nix` (if pruning needed) - -**Acceptance Criteria:** -- [ ] Anonymous nodes that are entity resolution boundaries get `entityKind/resolve(ctxAspect)` names -- [ ] Anonymous nodes from policy-emitted includes get `policy:` prefix where possible -- [ ] Pure internal plumbing nodes (no class content, no children) are identified and documented -- [ ] `insecure-predicate/:1,2` nodes are explained and either named or pruned - -**Verify:** `nix build --override-input den . ./templates/diagram-demo#laptop-dag --no-link --print-out-paths` → no unexplained `:N` nodes - -**Steps:** - -- [ ] **Step 1: Verify entityKind disambiguation now works** - -After Task 1, rebuild the laptop DAG and check which anon nodes remain. Many should now have `entityKind/resolve(...)` names since entityKind is no longer null. - -Read the IR JSON and list remaining anonymous nodes. For each, check: -- Does it have class content (`hasClass`)? → real entity, needs a name -- Does it have children in the edge list? → structural node, needs a name -- Neither? → plumbing artifact, candidate for pruning - -- [ ] **Step 2: Investigate insecure-predicate/unfree-predicate anon children** - -Read the demo aspects that define `insecure-predicate` and `unfree-predicate` in `templates/diagram-demo/modules/aspects/den.nix`. Check if their `includes` contain anonymous functions (compile-conditional guards). The `:N` children are likely the guard branches. - -If they are conditional guard branches with no class content: these are structural artifacts of `compile-conditional`. They should be folded out in the `foldWrappers` filter or the existing `aspectsOnly` filter. - -If they have class content: they need names derived from their parent and role. - -- [ ] **Step 3: Improve naming for remaining anon nodes** - -In `nix/lib/aspects/fx/trace.nix`, after the existing entity-kind disambiguation block, add handling for policy-sourced aspects: - -```nix -else if isAnon && (param.__sourcePolicyName or null) != null then - "policy:${param.__sourcePolicyName}" -``` - -Verify `__sourcePolicyName` propagation: check `nix/lib/aspects/fx/policy/classify.nix` for where this is tagged. - -- [ ] **Step 4: Commit findings and fixes** - -Document which anon nodes were real vs artifacts. Commit the naming improvements. - -```bash -git add nix/lib/aspects/fx/trace.nix -git commit -m "fix(diag): improve anonymous node naming with entityKind disambiguation" -``` - ---- - -### Task 6: Rename views from "stage" to "scope" vocabulary - -**Goal:** Update view identifiers, titles, and internal function names from the removed "stage" concept to "scope". - -**Files:** -- Modify: `nix/lib/diag/views.nix:96-108,162-166` (view IDs and titles) -- Modify: `nix/lib/diag/sequence.nix:319-359` (rename `toStageEdgesMermaid` → `toScopeEdgesMermaid`) -- Modify: `nix/lib/diag/default.nix:208-210` (renderer spec key) -- Modify: `templates/diagram-demo/modules/diagrams.nix` (if it references stage-* views) - -**Acceptance Criteria:** -- [ ] `stage-seq` → `scope-seq`, `stage-seq-full` → `scope-seq-full`, `stage-edges` → `scope-edges` -- [ ] View titles updated: "Stage Sequence" → "Scope Sequence" etc. -- [ ] Internal function `toStageEdgesMermaid` → `toScopeEdgesMermaid` (and `With` variant) -- [ ] Renderer spec key in `default.nix` updated -- [ ] No remaining "stage" references in view/sequence code (except comments explaining the rename) - -**Verify:** `nix build --override-input den . ./templates/diagram-demo#laptop-scope-seq --no-link --print-out-paths` → builds successfully with "Scope Sequence" title - -**Steps:** - -- [ ] **Step 1: Rename in views.nix** - -```nix -# Line 96-108: rename view IDs and titles -view = "scope-seq"; -title = "Scope Sequence"; -altText = "Scope sequence"; - -view = "scope-seq-full"; -title = "Scope Sequence (expanded)"; -altText = "Scope sequence expanded"; - -# Line 162-166: extended views -view = "scope-edges"; -title = "Scope Topology"; -altText = "Scope edges"; -``` - -- [ ] **Step 2: Rename in sequence.nix** - -Rename the functions and their `With` variants: -- `toStageEdgesMermaidWith` → `toScopeEdgesMermaidWith` -- `toStageEdgesMermaid` → `toScopeEdgesMermaid` - -Update the export block at the bottom of the file. - -- [ ] **Step 3: Update default.nix renderer spec** - -In `nix/lib/diag/default.nix`, line 208-210: - -```nix -# Old: -toStageEdgesMermaid = { withFn = sequence.toStageEdgesMermaidWith; mc = true; }; -# New: -toScopeEdgesMermaid = { withFn = sequence.toScopeEdgesMermaidWith; mc = true; }; -``` - -- [ ] **Step 4: Update diagram-demo template if needed** - -Check `templates/diagram-demo/modules/diagrams.nix` for any references to `stage-seq`, `stage-seq-full`, or `stage-edges` view names and update them. - -- [ ] **Step 5: Run full CI and commit** - -Run: `nix develop -c just fmt && nix develop -c just ci` -Expected: All tests pass. The diagram packages now use `scope-seq` etc. - -Note: Also check `diagrams.nix` README text (writeText derivation) for hardcoded `stage-seq` strings in the output table and update them. - -Note: Package names change from `laptop-stage-seq` to `laptop-scope-seq`. This is acceptable since the branch hasn't shipped. - -```bash -git add nix/lib/diag/views.nix nix/lib/diag/sequence.nix nix/lib/diag/default.nix templates/diagram-demo/modules/diagrams.nix -git commit -m "refactor(diag): rename stage-* views to scope-* vocabulary" -``` - ---- - -### Task 7: Update filters for entityInstances and run full verification - -**Goal:** Ensure all graph filters handle the new `entityInstances` field, then do a full verification pass. - -**Files:** -- Modify: `nix/lib/diag/filters/fold.nix:178-185` (`flattenEntityKinds`) -- Modify: `nix/lib/diag/filters/reshape.nix:20-49` (`contextOnly`) -- Modify: `nix/lib/diag/filters/closure.nix:25-35` (`neighborhoodOf`) -- Modify: `nix/lib/diag/filters/predicate.nix:55-62` (if it zeros entityKinds) -- Modify: `nix/lib/diag/filters/diff.nix:80-85` (carry entityInstances) - -**Acceptance Criteria:** -- [ ] `flattenEntityKinds` also zeros `entityInstances` and nulls `entityInstance` on nodes -- [ ] `contextOnly` handles entity instances (or zeros them since it replaces all nodes) -- [ ] `neighborhoodOf` zeros `entityInstances` -- [ ] `diffGraphs` carries `entityInstances` from the `a` graph -- [ ] Predicate filters zero `entityInstances` -- [ ] All diagram packages build without errors -- [ ] Full CI passes - -**Verify:** `nix develop -c just ci` → all pass; `nix run --override-input den . ./templates/diagram-demo#write-diagrams` → regenerates all diagrams successfully - -**Steps:** - -- [ ] **Step 1: Update flattenEntityKinds** - -In `nix/lib/diag/filters/fold.nix`: - -```nix -flattenEntityKinds = graph: - graph // { - nodes = map (n: n // { entityKind = null; entityInstance = null; }) graph.nodes; - entityKinds = []; - entityEdges = []; - entityInstances = []; - }; -``` - -- [ ] **Step 2: Update contextOnly** - -In `nix/lib/diag/filters/reshape.nix`, `contextOnly` replaces all nodes, so add: - -```nix -entityInstances = []; -``` - -to the result attrset. - -- [ ] **Step 3: Update neighborhoodOf and closure filters** - -In `nix/lib/diag/filters/closure.nix`, `neighborhoodOf` already zeros `entityKinds` and `entityEdges`. Add: - -```nix -entityInstances = []; -``` - -- [ ] **Step 4: Update diffGraphs** - -In `nix/lib/diag/filters/diff.nix`, carry `entityInstances` from graph `a`: - -```nix -entityInstances = a.entityInstances or []; -``` - -- [ ] **Step 5: Update predicate filters** - -In `nix/lib/diag/filters/predicate.nix`, add `entityInstances = [];` where `entityKinds = [];` already appears. - -- [ ] **Step 6: Full CI and regenerate diagrams** - -Run: `nix develop -c just fmt && nix develop -c just ci` -Expected: All 753+ tests pass. - -Run: `nix run --override-input den . ./templates/diagram-demo#write-diagrams` -Expected: All diagrams regenerate without errors. - -Verify key outputs: -- `laptop-dag` has entity instance subgraphs -- `laptop-scope-seq` has entity kind participants -- `laptop-policy-seq` has policy dispatch nodes -- `home-alice-dag` has home entity instance subgraph - -```bash -git add nix/lib/diag/filters/ -git commit -m "fix(diag): update filters to handle entityInstances field" -``` - ---- - -## Task Dependencies - -``` -Task 1 (entityKind + ctxTrace) - ↓ -Task 2 (record-fired) - ↓ -Task 3 (entityInstance in trace + graph IR) - ↓ -Task 4 (mermaid renderer) Task 5 (anon nodes) Task 6 (view rename) - ↓ ↓ ↓ -Task 7 (filters + full verification) -``` - -Tasks 4, 5, and 6 can run in parallel after Task 3. Task 7 depends on all of them. diff --git a/docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md.tasks.json b/docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md.tasks.json deleted file mode 100644 index f4dcc089a..000000000 --- a/docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md.tasks.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "planPath": "docs/superpowers/plans/2026-05-07-diagram-identity-resolution.md", - "tasks": [ - { - "id": 2, - "subject": "Task 1: Fix entityKind seeding and ctxTrace population", - "status": "pending", - "description": "**Goal:** Make tracingHandler produce entries with correct entityKind and populate ctxTrace.\n\n**Files:**\n- Modify: nix/lib/aspects/fx/trace.nix\n- Test: templates/ci/modules/features/fx-trace.nix\n\n**Verify:** nix develop -c just ci fx-trace", - "metadata": { - "files": ["nix/lib/aspects/fx/trace.nix", "templates/ci/modules/features/fx-trace.nix"], - "verifyCommand": "nix develop -c just ci fx-trace", - "acceptanceCriteria": ["entityKind seeded from param.__entityKind", "ctxTrace populated with dedup", "existing tests pass"] - } - }, - { - "id": 3, - "subject": "Task 2: Add record-fired handler for policy trace entries", - "status": "pending", - "blockedBy": [2], - "description": "**Goal:** Capture fired policy names as trace entries for policy dispatch nodes.\n\n**Files:**\n- Modify: nix/lib/aspects/fx/trace.nix\n- Test: templates/ci/modules/features/fx-trace.nix\n\n**Verify:** nix develop -c just ci fx-trace", - "metadata": { - "files": ["nix/lib/aspects/fx/trace.nix", "templates/ci/modules/features/fx-trace.nix"], - "verifyCommand": "nix develop -c just ci fx-trace", - "acceptanceCriteria": ["record-fired handler added", "policy entries have isPolicyDispatch=true", "compose with defaultHandlers works"] - } - }, - { - "id": 4, - "subject": "Task 3: Add entityInstance tracking to trace and graph IR", - "status": "pending", - "blockedBy": [3], - "description": "**Goal:** Each trace entry carries entityInstance field, graph IR gains entityInstances list.\n\n**Files:**\n- Modify: nix/lib/aspects/fx/trace.nix\n- Modify: nix/lib/diag/graph.nix\n\n**Verify:** nix develop -c just ci fx-trace + build laptop-ir", - "metadata": { - "files": ["nix/lib/aspects/fx/trace.nix", "nix/lib/diag/graph.nix", "nix/lib/diag/json.nix"], - "verifyCommand": "nix develop -c just ci fx-trace", - "acceptanceCriteria": ["entityInstance on trace entries", "entityInstances in graph IR", "entityKinds retained", "json.nix serializes entityInstances"] - } - }, - { - "id": 5, - "subject": "Task 4: Update mermaid renderer for instance subgraphs", - "status": "pending", - "blockedBy": [4], - "description": "**Goal:** DAG diagrams group nodes into per-instance subgraphs with policy bridges.\n\n**Files:**\n- Modify: nix/lib/diag/mermaid.nix\n\n**Verify:** Build laptop-dag, check for subgraph blocks", - "metadata": { - "files": ["nix/lib/diag/mermaid.nix"], - "verifyCommand": "nix build --override-input den . ./templates/diagram-demo#laptop-dag --no-link --print-out-paths", - "acceptanceCriteria": ["per-instance subgraphs", "flake subgraph for unscoped", "policy bridges"] - } - }, - { - "id": 6, - "subject": "Task 5: Investigate and fix anonymous nodes", - "status": "pending", - "blockedBy": [4], - "description": "**Goal:** Determine what anon nodes are, then prune artifacts or enrich labels.\n\n**Files:**\n- Modify: nix/lib/aspects/fx/trace.nix\n\n**Verify:** Build laptop-dag, check for unexplained anon nodes", - "metadata": { - "files": ["nix/lib/aspects/fx/trace.nix"], - "verifyCommand": "nix build --override-input den . ./templates/diagram-demo#laptop-dag --no-link --print-out-paths", - "acceptanceCriteria": ["no unexplained anon nodes", "entityKind disambiguation works", "policy naming works"] - } - }, - { - "id": 7, - "subject": "Task 6: Rename views from stage to scope vocabulary", - "status": "pending", - "blockedBy": [4], - "description": "**Goal:** Update view IDs, titles, and function names from stage to scope.\n\n**Files:**\n- Modify: nix/lib/diag/views.nix, sequence.nix, default.nix, diagrams.nix\n\n**Verify:** Build laptop-scope-seq", - "metadata": { - "files": ["nix/lib/diag/views.nix", "nix/lib/diag/sequence.nix", "nix/lib/diag/default.nix", "templates/diagram-demo/modules/diagrams.nix"], - "verifyCommand": "nix build --override-input den . ./templates/diagram-demo#laptop-scope-seq --no-link --print-out-paths", - "acceptanceCriteria": ["view IDs renamed", "function names renamed", "titles updated"] - } - }, - { - "id": 8, - "subject": "Task 7: Update filters and full verification", - "status": "pending", - "blockedBy": [5, 6, 7], - "description": "**Goal:** Ensure all filters handle entityInstances, full CI + diagram regen passes.\n\n**Files:**\n- Modify: nix/lib/diag/filters/\n\n**Verify:** nix develop -c just ci + write-diagrams", - "metadata": { - "files": ["nix/lib/diag/filters/fold.nix", "nix/lib/diag/filters/reshape.nix", "nix/lib/diag/filters/closure.nix", "nix/lib/diag/filters/predicate.nix", "nix/lib/diag/filters/diff.nix"], - "verifyCommand": "nix develop -c just ci", - "acceptanceCriteria": ["filters handle entityInstances", "full CI passes", "all diagrams build"] - } - } - ], - "lastUpdated": "2026-05-07T00:00:00Z" -} diff --git a/docs/superpowers/plans/2026-05-07-hero-canvas-animation.md b/docs/superpowers/plans/2026-05-07-hero-canvas-animation.md deleted file mode 100644 index 28386d7d6..000000000 --- a/docs/superpowers/plans/2026-05-07-hero-canvas-animation.md +++ /dev/null @@ -1,283 +0,0 @@ -# Hero Canvas Animation Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the static hero image on the docs landing page with an animated canvas showing a branching topology. - -**Architecture:** Override Starlight's Hero.astro component to conditionally render a LogoAnimation.astro component on the index page. LogoAnimation is self-contained: canvas markup, scoped styles, and a client-side script with the topology animation algorithm adapted from ~/foo-wip.html. - -**Tech Stack:** Astro components, HTML Canvas 2D API, Catppuccin CSS custom properties, IntersectionObserver, MutationObserver - -**Spec:** `docs/superpowers/specs/2026-05-07-hero-canvas-animation-design.md` - ---- - -### Task 1: Create LogoAnimation.astro component - -**Goal:** Create the self-contained canvas animation component with topology algorithm, theme-aware colors, visibility pausing, and SPA cleanup. - -**Files:** -- Create: `docs/src/components/LogoAnimation.astro` -- Reference: `/home/sini/foo-wip.html` (source algorithm, outside repo) - -**Acceptance Criteria:** -- [ ] Canvas renders at 400×400 -- [ ] Animation cycles: grow topology → photon walk → rewind → regenerate (infinite loop) -- [ ] Colors read from `--sl-color-blue`, `--sl-color-purple`, `--sl-color-white` CSS custom properties -- [ ] Theme toggle (light/dark) updates colors via MutationObserver on `data-theme` -- [ ] IntersectionObserver pauses animation when canvas is off-screen -- [ ] `astro:before-swap` listener cleans up (cancelAnimationFrame, clearTimeouts, disconnect observers) -- [ ] No "den" text overlay -- [ ] Component wrapper has correct sizing styles for hero slot - -**Verify:** Visual inspection — component can be tested by temporarily importing it directly in index.mdx body. - -**Steps:** - -- [ ] **Step 1: Create LogoAnimation.astro with markup and scoped styles** - -```astro ---- -// No server-side logic needed ---- - -
- -
- - -``` - -- [ ] **Step 2: Add client-side script with color resolution and observers** - -Add a `