/** * Inspect — pure selectors that turn the deep manifest into the unified * per-kind browser: the kind switcher (what's present + counts), a node's full * detail (reusing `BcNodeDetail` so `NodeCard` renders it), and a node's * relationships (its graph edges, grouped + humanized). Native: reads * `ManifestView` / `GraphNode` / `GraphEdge` from `@nwire/scan` — no adapter. */ import type { Manifest, ManifestView, GraphNode, GraphEdge, EdgeType } from "./manifest"; import { nodeDetail, toInvariants, toConfigModules, toEnvVars, type BcNodeDetail, } from "./bc-graph"; /** Friendly labels per graph node kind. */ const KIND_LABELS: Record = { action: "Actions", query: "Queries", event: "Events", actor: "Actors", workflow: "Workflows", projection: "Projections", command: "Commands", route: "Routes", cron: "Crons", externalCall: "External calls", inboundWebhook: "Webhooks", outbox: "Outboxes", inbox: "Inboxes", sinkStage: "Sinks", sourceStage: "Sources", plugin: "Plugins", capability: "Capabilities", kind: "Ctx kinds", hook: "Hooks", app: "Apps", schema: "Schemas", resource: "Resources", error: "Errors", binding: "Bindings", }; /** Curated display order; kinds not listed fall to the end, alphabetical. */ const KIND_ORDER: readonly string[] = [ "action", "query", "event", "actor", "workflow", "projection", "command", "route", "cron", "externalCall", "inboundWebhook", "sinkStage", "sourceStage", "app", "plugin", "capability", "hook", "schema", "resource", "error", ]; /** Human label for a kind (falls back to the raw kind). */ export function kindLabel(kind: string): string { return KIND_LABELS[kind] ?? kind; } export interface InspectKindEntry { readonly kind: string; readonly label: string; readonly count: number; } /** * Which kinds are present in the manifest + their counts, in curated order * (then alphabetical for anything uncurated). Only kinds with ≥1 node appear. */ export function inspectKinds(view: ManifestView | null): InspectKindEntry[] { if (!view) return []; const counts = new Map(); for (const n of view.model.nodes) counts.set(n.kind, (counts.get(n.kind) ?? 0) + 1); const rank = (k: string): number => { const i = KIND_ORDER.indexOf(k); return i === -1 ? KIND_ORDER.length : i; }; return [...counts.entries()] .map(([kind, count]) => ({ kind, label: kindLabel(kind), count })) .sort((a, b) => rank(a.kind) - rank(b.kind) || a.label.localeCompare(b.label)); } /** Nodes of one kind, name-sorted — the master list for the active tab. */ export function nodesOfKind(view: ManifestView | null, kind: string): GraphNode[] { if (!view) return []; return view .byKind(kind) .slice() .sort((a, b) => a.name.localeCompare(b.name)); } /** Free-text match over a node's name + intent description. */ export function matchesNode(node: GraphNode, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) return true; return ( node.name.toLowerCase().includes(q) || (node.intent?.description?.toLowerCase().includes(q) ?? false) ); } /** * Resolve a graph node to a full `BcNodeDetail` (so `NodeCard` renders it). * Prefers the flat-array entry (carries the input schema) and falls back to the * graph node's own intent + `data` for kinds without a flat array. */ export function nodeToDetail( view: ManifestView | null, manifest: Manifest | null | undefined, node: GraphNode | null | undefined, ): BcNodeDetail | null { if (!node) return null; const flat = nodeDetail(manifest, node.id); if (flat) return flat; // Structural kinds (plugin, hook, route, capability, …) have no flat entry — // build the detail from the graph node itself. const data = node.data ?? {}; // env/config are surfaced as their own sections, never in the generic Details. const env = toEnvVars(data.env); const config = toConfigModules(data.config); const extra: Record = {}; for (const [k, v] of Object.entries(data)) { if (k !== "emits" && k !== "env" && k !== "config" && v !== undefined) extra[k] = v; } const intent = node.intent ?? {}; if (intent.persona) extra.persona = intent.persona; if (intent.journeyStep) extra.journeyStep = intent.journeyStep; if (intent.capability) extra.capability = intent.capability; if (intent.tags?.length) extra.tags = intent.tags; // Invariants are surfaced as their own section, never in the generic Details. delete extra.invariants; return { id: node.id, kind: node.kind, name: node.name, description: intent.description, public: intent.public === true, source: node.source as BcNodeDetail["source"], emits: Array.isArray(data.emits) ? (data.emits as string[]) : undefined, invariants: toInvariants(intent.invariants), env, config, extra, }; } export interface RelatedNode { readonly id: string; readonly kind: string; readonly name: string; } export interface RelationGroup { readonly label: string; readonly items: readonly RelatedNode[]; } /** Direction-aware human labels per edge type. */ const RELATION_LABELS: Record = { triggers: { out: "Triggers", in: "Triggered by" }, emits: { out: "Emits", in: "Emitted by" }, delivers: { out: "Delivers to", in: "Receives from" }, transitions: { out: "Transitions to", in: "Transitioned from" }, dispatches: { out: "Dispatches", in: "Dispatched by" }, reads: { out: "Reads", in: "Read by" }, calls: { out: "Calls", in: "Called by" }, provides: { out: "Provides", in: "Provided by" }, contributes: { out: "Contributes", in: "Contributed by" }, mounts: { out: "Mounts", in: "Mounted by" }, }; function nameOfId(id: string): { kind: string; name: string } { const i = id.indexOf(":"); return i === -1 ? { kind: "", name: id } : { kind: id.slice(0, i), name: id.slice(i + 1) }; } /** * A node's edges, grouped by humanized relation (direction-aware) — the * "Relationships" panel. Empty when the node has no edges. */ export function relationships(view: ManifestView | null, id: string | null): RelationGroup[] { if (!view || !id) return []; const groups = new Map(); const push = (label: string, otherId: string): void => { const { kind, name } = nameOfId(otherId); const item = { id: otherId, kind, name }; const list = groups.get(label); if (list) { if (!list.some((x) => x.id === otherId)) list.push(item); } else { groups.set(label, [item]); } }; for (const e of view.edgesOf(id, "both") as GraphEdge[]) { const labels = RELATION_LABELS[e.type]; if (!labels) continue; if (e.from === id) push(labels.out, e.to); if (e.to === id) push(labels.in, e.from); } return [...groups.entries()].map(([label, items]) => ({ label, items })); } /** Kinds that can be invoked from the Operate/Dispatch surface. */ export function isDispatchable(kind: string): boolean { return kind === "action" || kind === "command" || kind === "query"; }