Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/arch/mod.rs

//! EPIC ARCH — the **architecture-wiring auto-generator** (n-006).
//!
//! Coarsen the fn-level knowledge facts (symbol + call rows of the code room) and
//! the `fn → warehouse-table` access fact (the room's `TOUCHES_TABLE` edges, as
//! [`crate::warehouse::access_scan::AccessEdge`] rows) into ONE component/gRPC/core/table wiring
//! graph — the picture `.nornir/architecture-wiring.md` §2 hand-derives, but
//! produced *by code*.
//!
//! The model is a **quotient** of the fn-level call graph by an *owner-fn*
//! classifier ([`classify`]):
//!
//! | owner-fn shape                                   | node kind            |
//! |--------------------------------------------------|----------------------|
//! | `impl Facet for T` (a viz pane / UI component)   | [`NodeKind::Component`] (`T`) |
//! | `impl Backend for T` (a warehouse-gen backend)   | [`NodeKind::Backend`] (`T`) |
//! | `impl Codec for T` (a compression engine)        | [`NodeKind::Codec`] (`T`) |
//! | `impl *Sink for T` (`TestSink`/`SagaSink`/…)      | [`NodeKind::Sink`] (`T`) |
//! | a gRPC service handler (`Svc::verb`)             | [`NodeKind::Grpc`] (`Svc.verb`) |
//! | the table of an access row (`fn → table`)        | [`NodeKind::Table`] (the table) |
//! | anything else                                    | [`NodeKind::CoreFn`] (`crate::module`) |
//!
//! The marker traits + kinds are the [`DEFAULT_MARKER_TRAITS`] registry, so
//! adding an architectural layer is one line there — not a classifier rewrite.
//!
//! Collapsing every fn-level call edge `caller → callee` to
//! `class(caller) → class(callee)` turns the dense fn graph into the
//! component↔gRPC↔core↔table board. The `fn → table` access fact supplies the
//! `* → Table` edges directly (a call graph never yields "this fn writes that
//! table"). Self-edges and duplicate edges are dropped, so e.g. the Test tab's
//! `fetch_test_results → test_results` collapses to `TestTab → test_results`.
//!
//! The graph renders [`to_svg`] in the **skade circuit-board** house style — a
//! direct-emit SVG (no mermaid, no typst layout engine), so it is available in
//! every feature set. It is generated/exported by `nornir arch [svg]`.

pub mod metro;

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
use crate::warehouse::access_scan::{Access, AccessEdge};

/// A node kind in the coarsened architecture graph.
///
/// **Deserialization is tolerant** (custom `Deserialize`, not derived): any
/// variant string this binary doesn't recognise maps to [`NodeKind::CoreFn`]
/// (the catch-all bucket). This is load-bearing for the 🏛 Architecture viz:
/// the historized `architecture_wiring` graph JSON is read by THREE binaries
/// that deploy independently (the `nornir-server` thin RPC, the `nornir-ui`
/// viz client, the `nornir-mcp` tool). A board generated by a NEWER binary that
/// has a variant an OLDER reader lacks (e.g. `Cli`, added after the deployed
/// reader was built) must NOT make the whole member's board fail to decode and
/// silently blank the tab — it degrades that one node to CoreFn and renders the
/// rest. (Found 2026-06-18: deployed readers without `Cli` errored
/// `unknown variant 'Cli'` → `members_with_data: []`, `node_count: 0`.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum NodeKind {
    /// An entrypoint / UI component — a `T` with `impl Facet for T` (a viz pane).
    Component,
    /// A gRPC service handler — `Service.verb`.
    Grpc,
    /// A CLI command — a clap subcommand (`nornir <verb>`). Seeded from clap
    /// introspection (`Cli::command().get_subcommands()`), the authoritative,
    /// anti-drift source — so CLI is a first-class marker layer alongside UI
    /// (Component) and gRPC, not buried in the CoreFn bucket.
    Cli,
    /// A core function / module (the catch-all bucket, labelled `crate::module`).
    CoreFn,
    /// A warehouse table.
    Table,
    /// A pluggable backend — a `T` with `impl Backend for T` (e.g. a
    /// warehouse-generator backend). Seeded the same syntactic way as Component,
    /// but for the `Backend` architectural marker trait rather than `Facet`, so
    /// the backend layer reads apart from UI instead of collapsing to CoreFn.
    Backend,
    /// A codec / compression engine — a `T` with `impl Codec for T`
    /// (the znippy-zoomies `Codec` trait). The engine layer.
    Codec,
    /// A data sink — a `T` with `impl *Sink for T` (`TestSink`, `SagaSink`,
    /// `ArchiveMetaSink`, …): the warehouse / emit egress seam. Matched by the
    /// `*Sink` trait-name suffix so future sinks light up without a config edit.
    Sink,
}

impl<'de> Deserialize<'de> for NodeKind {
    /// Tolerant decode: map the externally-tagged variant name to a `NodeKind`,
    /// falling back to [`NodeKind::CoreFn`] for ANY unrecognised name (forward-
    /// compatible cross-binary reads — see the type doc). The on-wire form is the
    /// derived `Serialize` variant identifier (`"Component"`, `"Cli"`, …); we also
    /// accept the lowercase [`NodeKind::as_str`] spelling for robustness.
    fn deserialize<D>(d: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(d)?;
        Ok(match s.as_str() {
            "Component" | "component" => NodeKind::Component,
            "Grpc" | "grpc" => NodeKind::Grpc,
            "Cli" | "cli" => NodeKind::Cli,
            "Table" | "table" => NodeKind::Table,
            "Backend" | "backend" => NodeKind::Backend,
            "Codec" | "codec" => NodeKind::Codec,
            "Sink" | "sink" => NodeKind::Sink,
            // "CoreFn"/"corefn" AND any unknown future variant → the catch-all.
            _ => NodeKind::CoreFn,
        })
    }
}

impl NodeKind {
    pub fn as_str(self) -> &'static str {
        match self {
            NodeKind::Component => "component",
            NodeKind::Grpc => "grpc",
            NodeKind::Cli => "cli",
            NodeKind::CoreFn => "corefn",
            NodeKind::Table => "table",
            NodeKind::Backend => "backend",
            NodeKind::Codec => "codec",
            NodeKind::Sink => "sink",
        }
    }
    pub fn parse(s: &str) -> Self {
        match s {
            "component" => NodeKind::Component,
            "grpc" => NodeKind::Grpc,
            "cli" => NodeKind::Cli,
            "table" => NodeKind::Table,
            "backend" => NodeKind::Backend,
            "codec" => NodeKind::Codec,
            "sink" => NodeKind::Sink,
            _ => NodeKind::CoreFn,
        }
    }

    /// The canonical **layer** (AUT8-GAP-LAYER): `ui` · `grpc` · `cli` · `core` ·
    /// `table`. Derived from the kind — the single grouping axis shared with
    /// [`nornir_testmatrix::discover::SurfaceKind::layer`] (ui≙Component,
    /// grpc≙Grpc, cli≙Cli, core≙CoreFn) so the matrix and the 🏛 board group by
    /// the same UI/gRPC/CLI strata without a stored `layer` column.
    pub fn layer(self) -> &'static str {
        match self {
            NodeKind::Component => "ui",
            NodeKind::Grpc => "grpc",
            NodeKind::Cli => "cli",
            NodeKind::CoreFn => "core",
            NodeKind::Table => "table",
            NodeKind::Backend => "backend",
            NodeKind::Codec => "codec",
            NodeKind::Sink => "sink",
        }
    }
}

/// One coarsened node. `id` is unique + stable; `label` is the displayed text.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArchNode {
    pub id: String,
    pub label: String,
    pub kind: NodeKind,
}

/// A relation between coarsened nodes. `reads`/`writes` are the only kinds the
/// access fact yields (`* → Table`); plain calls are `Calls`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArchEdgeKind {
    Calls,
    Reads,
    Writes,
}

impl ArchEdgeKind {
    pub fn as_str(self) -> &'static str {
        match self {
            ArchEdgeKind::Calls => "calls",
            ArchEdgeKind::Reads => "reads",
            ArchEdgeKind::Writes => "writes",
        }
    }
    pub fn parse(s: &str) -> Self {
        match s {
            "reads" => ArchEdgeKind::Reads,
            "writes" => ArchEdgeKind::Writes,
            _ => ArchEdgeKind::Calls,
        }
    }
}

/// A coarsened edge `from → to`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArchEdge {
    pub from: String,
    pub to: String,
    pub kind: ArchEdgeKind,
}

/// The coarsened architecture graph: nodes + edges, deterministically ordered.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchGraph {
    pub nodes: Vec<ArchNode>,
    pub edges: Vec<ArchEdge>,
}

/// The owner-fn classifier — the heart of the quotient. Maps a fn's
/// fully-qualified caller/callee path (as recorded in `symbol_facts` /
/// `call_edges`, e.g. `nornir::TestTab::fetch_test_results`) to the node it
/// collapses into.
///
/// `facet_impls` is the set of `T` types that have an `impl Facet for T`
/// (derived from `symbol_facts`); a fn whose owning type is one of them is a
/// **Component**. `grpc_handlers` maps a fully-qualified handler fn path to its
/// `Service.verb` label. Anything unmatched is a **CoreFn** bucketed by
/// `crate::module`.
pub struct Classifier<'a> {
    /// Map: type name (last segment) with an `impl <MarkerTrait> for T` → the
    /// [`NodeKind`] that marker confers (`Facet`→Component, `Backend`→Backend,
    /// `Codec`→Codec, `*Sink`→Sink). Generalises the old single-trait `facet_impls`
    /// set so every architectural marker layer — not just UI — reads apart.
    pub marker_impls: &'a BTreeMap<String, NodeKind>,
    /// Map: fully-qualified handler fn path → `Service.verb` label.
    pub grpc_handlers: &'a BTreeMap<String, String>,
}

/// The default architectural marker traits and the [`NodeKind`] each confers.
///
/// A `(trait_pattern, kind)` pair: a plain name matches an `impl <name> for T`
/// exactly; a `*Suffix` pattern matches any trait whose bare name ends in
/// `Suffix` (so every `…Sink` lights up without a config edit). Order is
/// precedence — the first matching marker wins for a type that impls several.
///
/// Grounded in the workspace: `Facet` (facett/nornir UI panes, 100+ impls),
/// `Backend` (nornir warehouse generators, 16 impls), the `*BackendTrait` family
/// (holger's per-ecosystem repository backends — `RepositoryBackendTrait` on
/// `MavenRepoZnippy`, npm, pypi, … — otherwise all collapsed to CoreFn), `Codec`
/// (znippy-zoomies engine), and the `*Sink` family (`TestSink` / `SagaSink` /
/// `ArchiveMetaSink`). Suffix globs so new backends/sinks light up with no edit.
pub const DEFAULT_MARKER_TRAITS: &[(&str, NodeKind)] = &[
    ("Facet", NodeKind::Component),
    ("Backend", NodeKind::Backend),
    ("*BackendTrait", NodeKind::Backend),
    ("Codec", NodeKind::Codec),
    ("*Sink", NodeKind::Sink),
];

impl<'a> Classifier<'a> {
    /// Classify one fully-qualified fn path into its coarsened node.
    pub fn classify(&self, fq: &str) -> ArchNode {
        // gRPC handler — exact match on the recorded handler path.
        if let Some(label) = self.grpc_handlers.get(fq) {
            return ArchNode {
                id: format!("grpc:{label}"),
                label: label.clone(),
                kind: NodeKind::Grpc,
            };
        }
        // Marker layer — the owning type (`crate::Mod::Type::method` → `Type`)
        // carries an architectural marker impl (`impl Facet/Backend/Codec/*Sink
        // for Type`). We look at the *penultimate* path segment (the type the
        // method is defined on) and take the kind that marker confers, so
        // backends/codecs/sinks read apart instead of collapsing to CoreFn.
        let segs: Vec<&str> = fq.split("::").collect();
        if segs.len() >= 2 {
            let owner_ty = segs[segs.len() - 2];
            if let Some(&kind) = self.marker_impls.get(owner_ty) {
                return ArchNode {
                    id: format!("{}:{owner_ty}", kind.as_str()),
                    label: owner_ty.to_string(),
                    kind,
                };
            }
        }
        // CoreFn — bucket by `crate::module` (drop the fn name + any type/impl
        // tail so a whole module collapses to one chip).
        let bucket = core_bucket(fq);
        ArchNode {
            id: format!("corefn:{bucket}"),
            label: bucket,
            kind: NodeKind::CoreFn,
        }
    }
}

/// Bucket a fq fn path to `crate::module` — keep the crate + the first module
/// segment, drop deeper module/type/fn tails so a module collapses to one node.
/// `nornir::viz::live::Loader::hydrate` → `nornir::viz`. A bare `crate::fn`
/// (`nornir::main`) → `nornir`.
fn core_bucket(fq: &str) -> String {
    let segs: Vec<&str> = fq.split("::").collect();
    match segs.len() {
        0 => "unknown".to_string(),
        1 => segs[0].to_string(),
        2 => segs[0].to_string(), // crate::fn → crate
        _ => format!("{}::{}", segs[0], segs[1]),
    }
}

/// Derive the marker map `type → NodeKind` from `symbol_facts` rows, for a set
/// of `(trait_pattern, kind)` markers (see [`DEFAULT_MARKER_TRAITS`]). A marker
/// impl is recorded as an `impl`-kind symbol with item_name `impl <Trait> for T`
/// (the [`crate::knowledge::symbols`] convention). Precedence follows `markers`
/// order — the first pattern a type matches wins (`entry().or_insert`), so a
/// type that is both a `Facet` and a `*Sink` stays a Component.
pub fn marker_impls_from_symbols(
    symbols: &[SymbolRow],
    markers: &[(&str, NodeKind)],
) -> BTreeMap<String, NodeKind> {
    let mut out: BTreeMap<String, NodeKind> = BTreeMap::new();
    for s in symbols {
        if s.item_kind != "impl" {
            continue;
        }
        for &(pat, kind) in markers {
            if let Some(t) = parse_marker_impl(&s.item_name, pat) {
                out.entry(t).or_insert(kind);
                break; // first matching marker wins for this impl symbol
            }
        }
    }
    out
}

/// Derive the set of `T` with `impl Facet for T` — the UI-component subset of
/// [`marker_impls_from_symbols`]. Kept for callers that only want the Component
/// layer (and for API stability).
pub fn facet_impls_from_symbols(symbols: &[SymbolRow]) -> BTreeSet<String> {
    marker_impls_from_symbols(symbols, &[("Facet", NodeKind::Component)])
        .into_keys()
        .collect()
}

/// Derive the gRPC handler map from `symbol_facts`: every method defined on a
/// `XxxSvc`-style service struct whose impl block is `impl XxxSvcTrait for
/// XxxSvc` (the tonic-generated handler trait convention used by
/// `src/bin/nornir-server.rs`) maps `fq_fn_path → "Service.Verb"`.
///
/// The service name is the `Svc` struct minus its `Svc` suffix (`VizSvc` →
/// `Viz`), and the verb is the method name in CamelCase (`timeline` →
/// `Timeline`), so `VizSvc::timeline` → `Viz.Timeline` — matching the
/// `proto/nornir.proto` service.verb naming the acceptance fixture uses.
pub fn grpc_handlers_from_symbols(symbols: &[SymbolRow]) -> BTreeMap<String, String> {
    // Service struct types that have a tonic handler impl (`impl *Trait for *Svc`).
    let mut svc_types: BTreeSet<String> = BTreeSet::new();
    for s in symbols {
        if s.item_kind == "impl" {
            if let Some(ty) = parse_grpc_impl(&s.item_name) {
                svc_types.insert(ty);
            }
        }
    }
    let mut out = BTreeMap::new();
    for s in symbols {
        if s.item_kind != "fn" {
            continue;
        }
        // The method's owning type is the last segment of its module_path.
        let owner_ty = s.module_path.rsplit("::").next().unwrap_or("");
        if !svc_types.contains(owner_ty) {
            continue;
        }
        let service = owner_ty.strip_suffix("Svc").unwrap_or(owner_ty);
        let verb = to_camel(&s.item_name);
        let label = format!("{service}.{verb}");
        let fq = format!("{}::{}", s.module_path, s.item_name);
        out.insert(fq, label);
    }
    out
}

/// `impl VizSvcTrait for VizSvc` → `Some("VizSvc")`. Matches a type whose name
/// ends in `Svc` and a trait that is its tonic handler trait — by either
/// spelling of the same impl:
///
/// * `impl VizSvcTrait for VizSvc`: the trait under the alias the source
///   imports it by (`use pb::viz_server::{Viz as VizSvcTrait, …}`), which is
///   what a reader of the source text sees;
/// * `impl Viz for VizSvc`: the trait under its OWN name — what the code room
///   holds, because rustc resolves the alias away. Without this spelling no
///   handler of a resolved room is a gRPC node.
fn parse_grpc_impl(label: &str) -> Option<String> {
    let rest = label.strip_prefix("impl ")?;
    let (trait_part, ty_part) = rest.split_once(" for ")?;
    let trait_last = trait_part.rsplit("::").next().unwrap_or(trait_part).trim();
    let ty = ty_part.trim();
    let bare = ty.split(['<', ' ']).next().unwrap_or(ty);
    let last = bare.rsplit("::").next().unwrap_or(bare).trim();
    let own_trait = last.strip_suffix("Svc").is_some_and(|service| !service.is_empty() && service == trait_last);
    if (trait_last.ends_with("Trait") || own_trait) && last.ends_with("Svc") && !last.is_empty() {
        Some(last.to_string())
    } else {
        None
    }
}

/// snake_case / single ident → CamelCase verb (`bakeoff_results` → `BakeoffResults`).
fn to_camel(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut upper = true;
    for c in s.chars() {
        if c == '_' {
            upper = true;
        } else if upper {
            out.extend(c.to_uppercase());
            upper = false;
        } else {
            out.push(c);
        }
    }
    out
}

/// Build the full architecture graph from raw knowledge + access facts,
/// deriving the Facet-component set and the gRPC-handler map from `symbols`.
/// This is the one-call entrypoint `nornir arch` uses.
pub fn generate(symbols: &[SymbolRow], calls: &[CallEdgeRow], access: &[AccessEdge]) -> ArchGraph {
    generate_with_cli(symbols, calls, access, &[])
}

/// Like [`generate`], plus seed one [`NodeKind::Cli`] node per clap subcommand in
/// `cli_verbs` — the binary passes `Cli::command().get_subcommands()` (the
/// authoritative, anti-drift verb list; the lib stays binary-free, so callers
/// without clap pass `&[]`). CLI verbs are entrypoints with no incoming edge, so
/// the layered layout naturally pins them to the left rail beside UI components.
/// A `cli:<verb>` node whose verb collides with an existing node id is skipped.
pub fn generate_with_cli(
    symbols: &[SymbolRow],
    calls: &[CallEdgeRow],
    access: &[AccessEdge],
    cli_verbs: &[String],
) -> ArchGraph {
    let markers = marker_impls_from_symbols(symbols, DEFAULT_MARKER_TRAITS);
    let handlers = grpc_handlers_from_symbols(symbols);
    let cls = Classifier {
        marker_impls: &markers,
        grpc_handlers: &handlers,
    };
    let mut graph = build_graph(symbols, calls, access, &cls);
    let existing: std::collections::BTreeSet<String> =
        graph.nodes.iter().map(|n| n.id.clone()).collect();
    for verb in cli_verbs {
        let id = format!("cli:{verb}");
        if existing.contains(&id) {
            continue;
        }
        graph.nodes.push(ArchNode {
            id,
            label: verb.clone(),
            kind: NodeKind::Cli,
        });
    }
    graph.nodes.sort();
    graph
}

/// `parse_marker_impl("impl Facet for TestTab", "Facet")` → `Some("TestTab")`;
/// a non-matching trait → `None`. The trait match is on the *bare* trait name
/// (last path segment), so `impl crate::facett::Facet for T` also matches.
///
/// `pat` is either an exact bare trait name (`"Facet"`) or a `*Suffix` glob
/// (`"*Sink"` matches `TestSink`, `SagaSink`, `ArchiveMetaSink`, …). The self
/// type is reduced to its bare ident (generics/paths stripped: `Grid<T>` → `Grid`).
fn parse_marker_impl(label: &str, pat: &str) -> Option<String> {
    let rest = label.strip_prefix("impl ")?;
    let (trait_part, ty_part) = rest.split_once(" for ")?;
    let trait_last = trait_part.rsplit("::").next().unwrap_or(trait_part).trim();
    let matches = match pat.strip_prefix('*') {
        Some(suffix) => trait_last.ends_with(suffix) && !trait_last.is_empty(),
        None => trait_last == pat,
    };
    if !matches {
        return None;
    }
    // The self-type may carry generics (`Grid<T>`) — take the bare type ident.
    let ty = ty_part.trim();
    let bare = ty.split(['<', ' ']).next().unwrap_or(ty);
    let last = bare.rsplit("::").next().unwrap_or(bare).trim();
    if last.is_empty() {
        None
    } else {
        Some(last.to_string())
    }
}

/// Build the coarsened [`ArchGraph`] — the quotient of `calls` by `classify`,
/// plus the `* → Table` edges from `access`.
///
/// - Each `caller → callee` call edge becomes `class(caller) → class(callee)`
///   with kind [`ArchEdgeKind::Calls`]. Self-edges (a node calling itself) and
///   duplicates collapse away.
/// - Each access edge `caller_fn → (table, read|write)` becomes
///   `class(caller_fn) → Table(table)` with kind `Reads`/`Writes`. The
///   `<dynamic>` sentinel table is skipped (no concrete node).
/// - A callee that is not itself a defined symbol (an external/library call) is
///   still classified by its identifier shape; bare idents with no `::` are
///   dropped (they can't be meaningfully bucketed and would be noise).
pub fn build_graph(
    symbols: &[SymbolRow],
    calls: &[CallEdgeRow],
    access: &[AccessEdge],
    cls: &Classifier<'_>,
) -> ArchGraph {
    let mut nodes: BTreeMap<String, ArchNode> = BTreeMap::new();
    let mut edges: BTreeSet<ArchEdge> = BTreeSet::new();

    let add_node = |nodes: &mut BTreeMap<String, ArchNode>, n: ArchNode| {
        nodes.entry(n.id.clone()).or_insert(n);
    };

    // Seed: every defined symbol that is a fn → its classified node (so a pane
    // with no outgoing call still appears as a chip). Cheap + makes the board
    // reflect the surface, not just the edges.
    for s in symbols {
        if s.item_kind != "fn" {
            continue;
        }
        let fq = format!("{}::{}", s.module_path, s.item_name);
        add_node(&mut nodes, cls.classify(&fq));
    }

    // Call edges → coarsened Calls edges.
    for c in calls {
        let from = cls.classify(&c.caller_path);
        // The callee is recorded as an identifier (`Foo::bar` or bare `bar`).
        // Classify it the same way; skip bare idents (no `::`) — unbucketable
        // noise (`new`, `unwrap`, `push`, …).
        if !c.callee_ident.contains("::") {
            // Still record the caller node so it is on the board.
            add_node(&mut nodes, from);
            continue;
        }
        let to = cls.classify(&c.callee_ident);
        let (from_id, to_id) = (from.id.clone(), to.id.clone());
        add_node(&mut nodes, from);
        add_node(&mut nodes, to);
        if from_id != to_id {
            edges.insert(ArchEdge {
                from: from_id,
                to: to_id,
                kind: ArchEdgeKind::Calls,
            });
        }
    }

    // Access edges → `* → Table` Reads/Writes edges (the edge no call graph has).
    for a in access {
        if a.table == crate::warehouse::access_scan::DYNAMIC_TABLE {
            continue;
        }
        let from = cls.classify(&a.caller_fn);
        let from_id = from.id.clone();
        let table_id = format!("table:{}", a.table);
        add_node(&mut nodes, from);
        add_node(
            &mut nodes,
            ArchNode {
                id: table_id.clone(),
                label: a.table.clone(),
                kind: NodeKind::Table,
            },
        );
        let kind = match a.access {
            Access::Read => ArchEdgeKind::Reads,
            Access::Write => ArchEdgeKind::Writes,
        };
        if from_id != table_id {
            edges.insert(ArchEdge {
                from: from_id,
                to: table_id,
                kind,
            });
        }
    }

    let mut nodes: Vec<ArchNode> = nodes.into_values().collect();
    nodes.sort();
    let edges: Vec<ArchEdge> = edges.into_iter().collect();
    ArchGraph { nodes, edges }
}

/// Trace mode (ARCH5 `nornir arch trace <entrypoint>` / AUT7 `flow <entrypoint>`):
/// the focused sub-board of everything **reachable downstream** from the
/// coarsened node an `entrypoint` ident collapses into. The entrypoint is
/// matched by node label or id substring (case-insensitive) against the full
/// [`ArchGraph`] `graph` — so `TestTab`, `Viz.Timeline`, or a `crate::module`
/// bucket all resolve. From each matched seed we BFS the `Calls`/`Reads`/`Writes`
/// edges forward, keeping only the reached nodes + the edges among them. The
/// result is itself an [`ArchGraph`], so it renders with the same circuit-board
/// [`to_svg`]. Returns an empty graph when no node matches the entrypoint.
///
/// This is the visual twin of `knowledge_call_path` lifted to the coarsened
/// board: instead of an fn-level chain it answers "open this surface — which
/// gRPC verbs / core modules / warehouse tables does it reach?".
pub fn trace_from(graph: &ArchGraph, entrypoint: &str) -> ArchGraph {
    use std::collections::{HashMap, VecDeque};
    let needle = entrypoint.to_lowercase();
    // Seeds: any node whose label or id contains the entrypoint substring.
    let seeds: BTreeSet<&str> = graph
        .nodes
        .iter()
        .filter(|n| {
            n.label.to_lowercase().contains(&needle) || n.id.to_lowercase().contains(&needle)
        })
        .map(|n| n.id.as_str())
        .collect();
    if seeds.is_empty() {
        return ArchGraph::default();
    }
    // Forward adjacency over all edge kinds.
    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
    for e in &graph.edges {
        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
    }
    // BFS reachability from every seed.
    let mut reached: BTreeSet<&str> = BTreeSet::new();
    let mut queue: VecDeque<&str> = VecDeque::new();
    for &s in &seeds {
        if reached.insert(s) {
            queue.push_back(s);
        }
    }
    while let Some(cur) = queue.pop_front() {
        if let Some(outs) = adj.get(cur) {
            for &nxt in outs {
                if reached.insert(nxt) {
                    queue.push_back(nxt);
                }
            }
        }
    }
    let by_id: BTreeMap<&str, &ArchNode> = graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
    let mut nodes: Vec<ArchNode> = reached
        .iter()
        .filter_map(|id| by_id.get(id).map(|n| (*n).clone()))
        .collect();
    nodes.sort();
    let edges: Vec<ArchEdge> = graph
        .edges
        .iter()
        .filter(|e| reached.contains(e.from.as_str()) && reached.contains(e.to.as_str()))
        .cloned()
        .collect();
    ArchGraph { nodes, edges }
}

// ───────────────────────────────────────────────────────────────────────────
// Click-to-code — resolve an arch node / symbol to its source `file:line`.
//
// The 🏛 Arch tab's nodes are coarsened (a Component is a `T`, a CLI verb is a
// clap subcommand, a core-fn is a `crate::module` bucket), so a node's label is
// a *symbol-ish* string, not a path. This is the one bridge a call graph can't
// give (fn → file): we match the node label / a free-form query against the
// freshly-scanned `symbol_facts` by item name, and return the `file:line` of the
// best hit. Used by BOTH the `nornir arch open` CLI verb and the viz tab's node
// click, so the click-to-code resolution is the same code on both sides.
// ───────────────────────────────────────────────────────────────────────────

/// A resolved source location for a symbol (the click-to-code target).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolLoc {
    /// The matched fully-qualified-ish symbol name (`item_name` from the scan).
    pub symbol: String,
    /// The item kind (`fn`/`struct`/`impl`/…), for disambiguation in the UI.
    pub kind: String,
    /// Source file, repo-relative (as recorded in `symbol_facts`).
    pub file: String,
    /// 1-based line where the symbol is defined.
    pub line: u32,
}

/// Resolve a free-form `query` (an arch node label, or a user-typed symbol) to
/// candidate source locations, best match first, against the scanned symbols.
///
/// Matching, in priority order (so the first hit is the most specific):
///   1. **exact** `item_name == query`,
///   2. exact match on the query's **last `::` segment** (`Type::method` → `method`,
///      or `crate::module` → `module`) — arch labels are often coarsened buckets,
///   3. **substring**, case-insensitive (the loose fallback).
/// Ties break toward the shorter file path then lower line (deterministic). A
/// label like `Viz.Architecture` (a gRPC verb) is normalised to its `verb`
/// segment too, so a clicked gRPC node resolves to the handler fn.
pub fn resolve_symbol(symbols: &[SymbolRow], query: &str) -> Vec<SymbolLoc> {
    let q = query.trim();
    if q.is_empty() {
        return Vec::new();
    }
    // Coarsened labels carry separators that aren't in the symbol name: a gRPC
    // label is `Service.verb`, a core-fn bucket is `crate::module`. Pull the
    // most specific leaf to match on, but keep the whole string for exact tries.
    let leaf = q
        .rsplit(['.', ':'])
        .find(|s| !s.is_empty())
        .unwrap_or(q)
        .to_string();
    let ql = q.to_lowercase();
    let leafl = leaf.to_lowercase();

    let mut scored: Vec<(u8, &SymbolRow)> = symbols
        .iter()
        .filter_map(|s| {
            let name = &s.item_name;
            let nl = name.to_lowercase();
            let last = name.rsplit("::").next().unwrap_or(name);
            let rank = if name == q {
                0 // exact full-name match
            } else if last == leaf {
                1 // exact leaf-segment match
            } else if nl == ql {
                2 // case-insensitive full match
            } else if last.to_lowercase() == leafl {
                3 // case-insensitive leaf match
            } else if nl.contains(&leafl) {
                4 // substring fallback
            } else {
                return None;
            };
            Some((rank, s))
        })
        .collect();
    scored.sort_by(|a, b| {
        a.0.cmp(&b.0)
            .then(a.1.file.len().cmp(&b.1.file.len()))
            .then(a.1.file.cmp(&b.1.file))
            .then(a.1.line.cmp(&b.1.line))
    });
    scored
        .into_iter()
        .map(|(_, s)| SymbolLoc {
            symbol: s.item_name.clone(),
            kind: s.item_kind.clone(),
            file: s.file.clone(),
            line: s.line,
        })
        .collect()
}

/// Best-effort open of `file:line` in the user's editor via `code --goto`. The
/// VS Code CLI is the de-facto cross-editor launcher; if `code` isn't on PATH we
/// return `Ok(false)` (logged by the caller) rather than failing — the inline
/// source preview + the printed `file:line` are the always-available fallbacks.
/// `repo_root` (when given) is prefixed so the absolute path is opened.
pub fn open_in_editor(
    repo_root: Option<&std::path::Path>,
    file: &str,
    line: u32,
) -> std::io::Result<bool> {
    let abs = match repo_root {
        Some(root) => root.join(file).to_string_lossy().to_string(),
        None => file.to_string(),
    };
    let target = format!("{abs}:{line}");
    match std::process::Command::new("code")
        .arg("--goto")
        .arg(&target)
        .status()
    {
        Ok(st) => Ok(st.success()),
        // `code` not installed / not on PATH → not an error, just unavailable.
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e),
    }
}

/// Read the source lines around `line` (1-based) for the inline preview panel:
/// `context` lines on each side. Returns `(start_line, lines)` where `start_line`
/// is the 1-based number of the first returned line. Empty when the file can't be
/// read. Pure file IO so the viz preview + a test read the same window.
pub fn source_window(
    repo_root: Option<&std::path::Path>,
    file: &str,
    line: u32,
    context: u32,
) -> (u32, Vec<String>) {
    let path = match repo_root {
        Some(root) => root.join(file),
        None => std::path::PathBuf::from(file),
    };
    let Ok(text) = std::fs::read_to_string(&path) else {
        return (0, Vec::new());
    };
    let all: Vec<&str> = text.lines().collect();
    if all.is_empty() {
        return (0, Vec::new());
    }
    let idx = (line.max(1) - 1) as usize; // 0-based target
    let start = idx.saturating_sub(context as usize);
    let end = (idx + context as usize + 1).min(all.len());
    let lines = all[start..end].iter().map(|s| s.to_string()).collect();
    (start as u32 + 1, lines)
}

// ───────────────────────────────────────────────────────────────────────────
// SVG render — skade circuit-board style, direct-emit (no mermaid, no typst).
// ───────────────────────────────────────────────────────────────────────────

impl ArchGraph {
    /// Render the graph as a **self-contained, static SVG** in the skade
    /// circuit-board house style: a dark PCB substrate, layered chips coloured
    /// by [`NodeKind`], cyan call traces and amber read/write traces. No
    /// mermaid, no JavaScript, no diagram engine, no typst — the markup is
    /// emitted directly so it renders verbatim in any viewer and is available
    /// in every feature set.
    pub fn to_svg(&self) -> String {
        render_pcb_svg(self)
    }

    /// A plain-text adjacency dump — the no-pixels fallback (terminal output).
    pub fn to_text(&self) -> String {
        let mut s = String::new();
        s.push_str("nodes:\n");
        for n in &self.nodes {
            s.push_str(&format!("  [{}] {}\n", n.kind.as_str(), n.label));
        }
        if self.edges.is_empty() {
            s.push_str("edges: (none)\n");
        } else {
            s.push_str("edges:\n");
            for e in &self.edges {
                s.push_str(&format!("  {} → {} ({})\n", e.from, e.to, e.kind.as_str()));
            }
        }
        s
    }
}

fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Layered BFS columns. Layer 0 = sources (no incoming edge). Cycles broken
/// deterministically (idempotent output for a given input). Tables are pinned
/// to the **last** layer so they ground on the right (the warehouse rail).
fn layers_of(graph: &ArchGraph) -> Vec<Vec<usize>> {
    use std::collections::HashMap;
    let n = graph.nodes.len();
    let idx: HashMap<&str, usize> = graph
        .nodes
        .iter()
        .enumerate()
        .map(|(i, nd)| (nd.id.as_str(), i))
        .collect();
    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
    let mut indeg: Vec<usize> = vec![0; n];
    for e in &graph.edges {
        if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
            if f != t {
                adj[f].push(t);
                indeg[t] += 1;
            }
        }
    }
    let mut layer_of = vec![0usize; n];
    let mut remaining: BTreeSet<usize> = (0..n).collect();
    let mut level = 0usize;
    while !remaining.is_empty() {
        let ready: Vec<usize> = remaining
            .iter()
            .copied()
            .filter(|&i| indeg[i] == 0)
            .collect();
        if ready.is_empty() {
            for &i in &remaining {
                layer_of[i] = level;
            }
            break;
        }
        for &i in &ready {
            layer_of[i] = level;
            remaining.remove(&i);
        }
        for &i in &ready {
            for &j in &adj[i] {
                if indeg[j] > 0 {
                    indeg[j] -= 1;
                }
            }
        }
        level += 1;
    }
    // Pin tables to the rightmost column (the warehouse ground rail).
    let mut max_level = *layer_of.iter().max().unwrap_or(&0);
    let has_table = graph.nodes.iter().any(|n| n.kind == NodeKind::Table);
    if has_table {
        max_level = max_level.max(1);
        for (i, nd) in graph.nodes.iter().enumerate() {
            if nd.kind == NodeKind::Table {
                layer_of[i] = max_level;
            }
        }
    }
    let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
    let mut order: Vec<usize> = (0..n).collect();
    order.sort_by(|&a, &b| graph.nodes[a].label.cmp(&graph.nodes[b].label));
    for i in order {
        layers[layer_of[i]].push(i);
    }
    layers
}

/// Chip gradient/stroke per node kind (the skade PCB palette).
fn kind_style(kind: NodeKind) -> (&'static str, &'static str) {
    // (fill, stroke)
    match kind {
        NodeKind::Component => ("#14384f", "#3aa0d0"), // UI chip — glacier blue
        NodeKind::Grpc => ("#3a2a14", "#d0962a"),      // RPC chip — amber
        NodeKind::Cli => ("#173f2c", "#46c07a"),       // CLI verb — terminal green
        NodeKind::CoreFn => ("#173d57", "#5a8fb0"),    // core chip — steel
        NodeKind::Table => ("#0c2438", "#2db6e0"),     // warehouse rail — cyan
        NodeKind::Backend => ("#2e1f4f", "#9a6cd0"),   // backend chip — violet
        NodeKind::Codec => ("#0f3f33", "#34c48c"),     // codec engine — teal
        NodeKind::Sink => ("#3f1430", "#d04a9a"),      // sink egress — magenta
    }
}

fn render_pcb_svg(graph: &ArchGraph) -> String {
    use std::collections::HashMap;
    if graph.nodes.is_empty() {
        return String::from(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 80\" \
             font-family=\"'DejaVu Sans', sans-serif\">\
             <rect x=\"0\" y=\"0\" width=\"320\" height=\"80\" fill=\"#06111d\"/>\
             <text x=\"16\" y=\"44\" fill=\"#5a8fb0\" font-size=\"14\">(empty architecture graph)</text>\
             </svg>\n",
        );
    }
    let layers = layers_of(graph);
    let col_w = 240.0f64;
    let row_h = 56.0f64;
    let box_w = 188.0f64;
    let box_h = 34.0f64;
    let margin = 28.0f64;

    let cols = layers.len().max(1);
    let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
    let width = margin * 2.0 + col_w * cols as f64;
    let height = margin * 2.0 + row_h * rows as f64;

    let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
    for (ci, layer) in layers.iter().enumerate() {
        let total_h = layer.len() as f64 * row_h;
        let start_y = margin + (height - margin * 2.0 - total_h) / 2.0;
        for (ri, &node_i) in layer.iter().enumerate() {
            let x = margin + ci as f64 * col_w;
            let y = start_y + ri as f64 * row_h;
            pos.insert(node_i, (x, y));
        }
    }
    let id_to_idx: HashMap<&str, usize> = graph
        .nodes
        .iter()
        .enumerate()
        .map(|(i, nd)| (nd.id.as_str(), i))
        .collect();

    let mut s = String::new();
    s.push_str(&format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width:.0} {height:.0}\" \
         font-family=\"'DejaVu Sans', 'Segoe UI', sans-serif\" font-size=\"12\">\n"
    ));
    s.push_str(
        "<defs>\
         <linearGradient id=\"arch-board\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\
         <stop offset=\"0\" stop-color=\"#0b1c2e\"/><stop offset=\"1\" stop-color=\"#06111d\"/>\
         </linearGradient>\
         <marker id=\"arch-arrow\" markerWidth=\"9\" markerHeight=\"9\" refX=\"7\" refY=\"3\" \
         orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#3aa0d0\"/></marker>\
         <marker id=\"arch-arrow-rw\" markerWidth=\"9\" markerHeight=\"9\" refX=\"7\" refY=\"3\" \
         orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#d0962a\"/></marker>\
         </defs>\n",
    );
    // PCB substrate.
    s.push_str(&format!(
        "<rect x=\"0\" y=\"0\" width=\"{width:.0}\" height=\"{height:.0}\" fill=\"url(#arch-board)\"/>\n"
    ));

    // Traces (edges) first, so chips sit on top.
    for e in &graph.edges {
        let (Some(&fi), Some(&ti)) = (id_to_idx.get(e.from.as_str()), id_to_idx.get(e.to.as_str()))
        else {
            continue;
        };
        let (Some(&(fx, fy)), Some(&(tx, ty))) = (pos.get(&fi), pos.get(&ti)) else {
            continue;
        };
        let x1 = fx + box_w;
        let y1 = fy + box_h / 2.0;
        let x2 = tx;
        let y2 = ty + box_h / 2.0;
        // Right-angled-ish PCB trace via a cubic with a mid breakpoint.
        let mid = (x1 + x2) / 2.0;
        let (color, marker, dash) = match e.kind {
            ArchEdgeKind::Calls => ("#3aa0d0", "url(#arch-arrow)", ""),
            ArchEdgeKind::Reads => (
                "#d0962a",
                "url(#arch-arrow-rw)",
                " stroke-dasharray=\"6 4\"",
            ),
            ArchEdgeKind::Writes => ("#e0b050", "url(#arch-arrow-rw)", ""),
        };
        s.push_str(&format!(
            "<path d=\"M{x1:.0},{y1:.0} C{mid:.0},{y1:.0} {mid:.0},{y2:.0} {x2:.0},{y2:.0}\" \
             fill=\"none\" stroke=\"{color}\" stroke-width=\"1.6\"{dash} \
             marker-end=\"{marker}\" opacity=\"0.85\"/>\n"
        ));
    }

    // Chips (nodes).
    for (i, nd) in graph.nodes.iter().enumerate() {
        let Some(&(x, y)) = pos.get(&i) else { continue };
        let (fill, stroke) = kind_style(nd.kind);
        let label = xml_escape(&nd.label);
        s.push_str(&format!(
            "<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"5\" \
             fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"1.4\"/>\n"
        ));
        // Solder-pad dot on the chip's left edge (PCB flavour).
        s.push_str(&format!(
            "<circle cx=\"{:.0}\" cy=\"{:.0}\" r=\"2.4\" fill=\"{stroke}\"/>\n",
            x + 6.0,
            y + box_h / 2.0,
        ));
        s.push_str(&format!(
            "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#dbeaf4\">{label}</text>\n",
            x + box_w / 2.0 + 4.0,
            y + box_h / 2.0 + 4.0,
        ));
    }
    s.push_str("</svg>\n");
    s
}

// ───────────────────────────────────────────────────────────────────────────
// AUT9 — CommandOutcome builders for the arch READ/data commands.
//
// These SHAPE already-fetched arch data into the uniform `CommandOutcome` (the
// CLI twin of a Facet's `state_json`) for BOTH the fat and thin handlers, so the
// two faces emit one shape. Each follows the contract law: `ok ⟺ non-empty`; an
// empty result (no node matched / no symbol matched / no historized snapshot)
// is RED (`CommandOutcome::fail` — RAGNARÖK), never silently green.
//
// The pure SVG/document generators (`arch generate`, `arch svg`, `arch trace`'s
// rendered SVG artifact) stay RAW — an SVG document is not "data" to wrap; only
// the structural payload (the reachable node/edge set, the resolved file:line,
// the snapshot listing) is migrated here.
// ───────────────────────────────────────────────────────────────────────────

/// `arch trace <entrypoint>` → the reachable sub-board as data: the focused
/// node + edge set BFS-reachable from `entrypoint`. `ok ⟺ at least one node
/// matched` (an empty trace — no node label/id contained the entrypoint — is
/// RED). The rendered SVG stays a separate raw artifact; this is the structural
/// twin the matrix asserts.
pub fn trace_outcome(
    repo: &str,
    entrypoint: &str,
    sub: &ArchGraph,
) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    let cmd = "arch trace";
    if sub.nodes.is_empty() {
        return CommandOutcome::fail(
            cmd,
            format!("arch trace: no node matched entrypoint `{entrypoint}` in `{repo}`"),
        );
    }
    let nodes: Vec<_> = sub
        .nodes
        .iter()
        .map(|n| serde_json::json!({ "id": n.id, "label": n.label, "kind": n.kind.as_str(), "layer": n.kind.layer() }))
        .collect();
    let edges: Vec<_> = sub
        .edges
        .iter()
        .map(|e| serde_json::json!({ "from": e.from, "to": e.to, "kind": e.kind.as_str() }))
        .collect();
    let human = format!(
        "arch trace `{entrypoint}` in `{repo}`: {} nodes, {} edges reachable",
        sub.nodes.len(),
        sub.edges.len()
    );
    let data = serde_json::json!({
        "repo": repo,
        "entrypoint": entrypoint,
        "node_count": sub.nodes.len(),
        "edge_count": sub.edges.len(),
        "nodes": nodes,
        "edges": edges,
    });
    CommandOutcome::ok(cmd, data, human)
}

/// `arch open <symbol>` → the click-to-code resolution as data: the resolved
/// candidate source `file:line` locations (best match first). `ok ⟺ at least
/// one hit` (no symbol matched is RED). `all` selects whether every candidate or
/// just the best is surfaced (the same slice the human print uses).
pub fn open_outcome(
    repo: &str,
    query: &str,
    hits: &[SymbolLoc],
    all: bool,
) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    let cmd = "arch open";
    if hits.is_empty() {
        return CommandOutcome::fail(
            cmd,
            format!(
                "arch open: no symbol matched `{query}` in `{repo}` (try `nornir symbol-lookup`)"
            ),
        );
    }
    let show = if all { hits } else { &hits[..1] };
    let locs: Vec<_> = show
        .iter()
        .map(|h| serde_json::json!({ "symbol": h.symbol, "kind": h.kind, "file": h.file, "line": h.line }))
        .collect();
    let human = show
        .iter()
        .map(|h| format!("{}:{}  {} {}", h.file, h.line, h.kind, h.symbol))
        .collect::<Vec<_>>()
        .join("\n");
    let best = &hits[0];
    let data = serde_json::json!({
        "repo": repo,
        "query": query,
        "match_count": hits.len(),
        "best": { "symbol": best.symbol, "kind": best.kind, "file": best.file, "line": best.line },
        "locations": locs,
    });
    CommandOutcome::ok(cmd, data, human)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sym_fn(module_path: &str, name: &str) -> SymbolRow {
        SymbolRow {
            crate_name: "nornir".into(),
            module_path: module_path.into(),
            item_kind: "fn".into(),
            item_name: name.into(),
            visibility: "pub".into(),
            file: "src/x.rs".into(),
            line: 1,
            doc_lines: 0,
            signature: None,
        }
    }
    fn sym_impl(module_path: &str, label: &str) -> SymbolRow {
        SymbolRow {
            crate_name: "nornir".into(),
            module_path: module_path.into(),
            item_kind: "impl".into(),
            item_name: label.into(),
            visibility: "pub".into(),
            file: "src/x.rs".into(),
            line: 1,
            doc_lines: 0,
            signature: None,
        }
    }
    fn call(caller: &str, callee: &str) -> CallEdgeRow {
        CallEdgeRow {
            crate_name: "nornir".into(),
            caller_path: caller.into(),
            callee_ident: callee.into(),
            call_kind: "call".into(),
            file: "src/x.rs".into(),
            line: 1,
        }
    }
    fn access(caller: &str, table: &str, a: Access) -> AccessEdge {
        AccessEdge {
            caller_fn: caller.into(),
            crate_name: "nornir".into(),
            table: table.into(),
            access: a,
            file: "src/x.rs".into(),
            line: 1,
        }
    }

    #[test]
    fn parse_marker_impl_matches_exact_and_suffix() {
        // Exact trait-name marker (`Facet`).
        assert_eq!(
            parse_marker_impl("impl Facet for TestTab", "Facet").as_deref(),
            Some("TestTab")
        );
        assert_eq!(
            parse_marker_impl("impl crate::facett::Facet for Grid<T>", "Facet").as_deref(),
            Some("Grid")
        );
        assert_eq!(parse_marker_impl("impl Clone for Foo", "Facet"), None);
        assert_eq!(parse_marker_impl("impl Foo", "Facet"), None);
        // `*Sink` suffix pattern lights up the whole sink family.
        assert_eq!(
            parse_marker_impl("impl SagaSink for JsonFileSink", "*Sink").as_deref(),
            Some("JsonFileSink")
        );
        assert_eq!(
            parse_marker_impl("impl TestSink for W", "*Sink").as_deref(),
            Some("W")
        );
        assert_eq!(parse_marker_impl("impl Facet for W", "*Sink"), None);
        // Backend exact marker.
        assert_eq!(
            parse_marker_impl("impl Backend for DuckDb", "Backend").as_deref(),
            Some("DuckDb")
        );
        // `*BackendTrait` suffix lights up holger's per-ecosystem repository backends.
        assert_eq!(
            parse_marker_impl(
                "impl RepositoryBackendTrait for MavenRepoZnippy",
                "*BackendTrait"
            )
            .as_deref(),
            Some("MavenRepoZnippy")
        );
    }

    #[test]
    fn backend_codec_sink_markers_light_up_via_generate() {
        // Three impl symbols carrying the new marker traits, each with a method
        // fn on the marked type so the node surfaces even without a call edge.
        let symbols = vec![
            sym_impl("nornir::wh", "impl Backend for DuckDb"),
            sym_fn("nornir::wh::DuckDb", "id"),
            sym_impl("znippy::gatling", "impl Codec for Gzip"),
            sym_fn("znippy::gatling::Gzip", "decode"),
            sym_impl("nornir::saga", "impl SagaSink for JsonFileSink"),
            sym_fn("nornir::saga::JsonFileSink", "write"),
        ];
        let g = generate(&symbols, &[], &[]);
        let kind_of = |ty: &str| g.nodes.iter().find(|n| n.label == ty).map(|n| n.kind);
        assert_eq!(
            kind_of("DuckDb"),
            Some(NodeKind::Backend),
            "Backend marker → Backend node"
        );
        assert_eq!(
            kind_of("Gzip"),
            Some(NodeKind::Codec),
            "Codec marker → Codec node"
        );
        // `SagaSink` matched by the `*Sink` suffix pattern.
        assert_eq!(
            kind_of("JsonFileSink"),
            Some(NodeKind::Sink),
            "*Sink marker → Sink node"
        );
    }

    #[test]
    fn core_bucket_collapses_to_crate_module() {
        assert_eq!(
            core_bucket("nornir::viz::live::Loader::hydrate"),
            "nornir::viz"
        );
        assert_eq!(core_bucket("nornir::viz::build_timeline"), "nornir::viz");
        assert_eq!(core_bucket("nornir::main"), "nornir");
        assert_eq!(core_bucket("nornir"), "nornir");
    }

    /// REGRESSION (2026-06-18, false-empty 🏛 Architecture tab): a historized
    /// `architecture_wiring` graph JSON containing a `NodeKind` variant the
    /// reader binary doesn't recognise must STILL decode (the unknown node maps
    /// to `CoreFn`), so a cross-binary read never blanks the whole board. Before
    /// the tolerant `Deserialize`, an older reader hit `unknown variant 'Cli'`
    /// and the member's board silently dropped → `node_count: 0`,
    /// `members_with_data: []`. Inject a real graph carrying BOTH a known-future
    /// variant (`Cli`) and a fabricated unknown one; assert it round-trips and a
    /// genuinely-unknown tag degrades to `CoreFn` rather than failing the parse.
    #[test]
    fn node_kind_decode_is_forward_tolerant() {
        // 1. Known variants (incl. `Cli`, the one deployed readers lacked) all
        //    round-trip through the canonical serde form.
        for (k, tag) in [
            (NodeKind::Component, "Component"),
            (NodeKind::Grpc, "Grpc"),
            (NodeKind::Cli, "Cli"),
            (NodeKind::CoreFn, "CoreFn"),
            (NodeKind::Table, "Table"),
        ] {
            let json = serde_json::to_string(&k).unwrap();
            assert_eq!(json, format!("\"{tag}\""), "serialize is the variant name");
            let back: NodeKind = serde_json::from_str(&json).unwrap();
            assert_eq!(back, k, "{tag} round-trips");
        }

        // 2. A whole graph whose node carries a FABRICATED unknown variant the
        //    reader's enum does not contain — the exact failure shape (a future
        //    binary added a kind). It must decode (no Err) with that node folded
        //    into the catch-all CoreFn, and every other node preserved.
        let wire = r#"{
            "nodes": [
                {"id":"cli:gen","label":"generate","kind":"Cli"},
                {"id":"x:weird","label":"weird","kind":"SomeFutureKind"},
                {"id":"t:tbl","label":"tbl","kind":"Table"}
            ],
            "edges": [
                {"from":"cli:gen","to":"t:tbl","kind":"Writes"}
            ]
        }"#;
        let g: ArchGraph =
            serde_json::from_str(wire).expect("unknown NodeKind must NOT fail the whole decode");
        assert_eq!(g.nodes.len(), 3, "all nodes decode, none dropped");
        assert_eq!(g.nodes[0].kind, NodeKind::Cli, "Cli decodes (not unknown)");
        assert_eq!(
            g.nodes[1].kind,
            NodeKind::CoreFn,
            "an unrecognised variant degrades to CoreFn, never an error"
        );
        assert_eq!(g.nodes[2].kind, NodeKind::Table);
        assert_eq!(g.edges.len(), 1);
    }

    /// THE acceptance fixture from the task: the Test tab's
    /// `fetch_test_results → test_results` collapses to `TestTab → test_results`.
    #[test]
    fn test_tab_fetch_collapses_to_component_and_table() {
        // TestTab is a Facet component; its fetch fn reads test_results.
        let symbols = vec![
            sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
            sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
        ];
        // fetch calls a per-table accessor (recorded only as a call edge here)…
        let calls = vec![call(
            "nornir::viz::test_tab::TestTab::fetch_test_results",
            "query_test_results",
        )];
        // …and the access fact says that fn reads test_results.
        let acc = vec![access(
            "nornir::viz::test_tab::TestTab::fetch_test_results",
            "test_results",
            Access::Read,
        )];

        let markers = marker_impls_from_symbols(&symbols, DEFAULT_MARKER_TRAITS);
        assert!(markers.contains_key("TestTab"));
        let cls = Classifier {
            marker_impls: &markers,
            grpc_handlers: &BTreeMap::new(),
        };
        let g = build_graph(&symbols, &calls, &acc, &cls);

        // The component node TestTab exists.
        let comp = g
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Component && n.label == "TestTab")
            .expect("TestTab component node");
        // The table node test_results exists.
        let tbl = g
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Table && n.label == "test_results")
            .expect("test_results table node");

        // The collapsed edge is exactly TestTab --reads--> test_results.
        assert!(
            g.edges
                .iter()
                .any(|e| e.from == comp.id && e.to == tbl.id && e.kind == ArchEdgeKind::Reads),
            "expected TestTab -reads-> test_results; got {:?}",
            g.edges
        );
        // The bare `query_test_results` callee (no `::`) produced no spurious
        // node beyond the component + table (plus the impl-derived seed).
        assert!(
            g.nodes.iter().all(|n| n.label != "query_test_results"),
            "accessor ident should not be a standalone node: {:?}",
            g.nodes
        );
    }

    #[test]
    fn grpc_handler_classifies_and_collapses() {
        let symbols = vec![sym_fn("nornir::server::Viz", "timeline")];
        let mut handlers = BTreeMap::new();
        handlers.insert(
            "nornir::server::Viz::timeline".to_string(),
            "Viz.Timeline".to_string(),
        );
        // The handler calls build_timeline (a core fn) which writes release_lineage.
        let calls = vec![call("nornir::server::Viz::timeline", "build_timeline::foo")];
        let acc = vec![access(
            "nornir::server::Viz::timeline",
            "release_lineage",
            Access::Write,
        )];

        let markers: BTreeMap<String, NodeKind> = BTreeMap::new();
        let cls = Classifier {
            marker_impls: &markers,
            grpc_handlers: &handlers,
        };
        let g = build_graph(&symbols, &calls, &acc, &cls);

        let grpc = g
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Grpc && n.label == "Viz.Timeline")
            .expect("Viz.Timeline gRPC node");
        let tbl = g
            .nodes
            .iter()
            .find(|n| n.kind == NodeKind::Table && n.label == "release_lineage")
            .unwrap();
        assert!(
            g.edges
                .iter()
                .any(|e| e.from == grpc.id && e.to == tbl.id && e.kind == ArchEdgeKind::Writes)
        );
    }

    #[test]
    fn self_edges_and_dupes_collapse_away() {
        // Two fns in the SAME module calling each other → a self-edge on the
        // module's CoreFn node → must NOT appear.
        let symbols = vec![sym_fn("nornir::mimir", "a"), sym_fn("nornir::mimir", "b")];
        let calls = vec![
            call("nornir::mimir::a", "nornir::mimir::b"),
            call("nornir::mimir::b", "nornir::mimir::a"),
        ];
        let markers: BTreeMap<String, NodeKind> = BTreeMap::new();
        let cls = Classifier {
            marker_impls: &markers,
            grpc_handlers: &BTreeMap::new(),
        };
        let g = build_graph(&symbols, &calls, &[], &cls);
        assert!(
            g.edges.is_empty(),
            "intra-module self-edge must collapse: {:?}",
            g.edges
        );
        // One CoreFn node nornir::mimir.
        assert_eq!(g.nodes.len(), 1);
        assert_eq!(g.nodes[0].kind, NodeKind::CoreFn);
        assert_eq!(g.nodes[0].label, "nornir::mimir");
    }

    #[test]
    fn dynamic_table_access_is_skipped() {
        let acc = vec![access(
            "nornir::viz::warehouse_tab::run",
            "<dynamic>",
            Access::Read,
        )];
        let markers: BTreeMap<String, NodeKind> = BTreeMap::new();
        let cls = Classifier {
            marker_impls: &markers,
            grpc_handlers: &BTreeMap::new(),
        };
        let g = build_graph(&[], &[], &acc, &cls);
        assert!(
            g.nodes.iter().all(|n| n.kind != NodeKind::Table),
            "no <dynamic> table node"
        );
    }

    #[test]
    fn svg_is_circuit_board_with_expected_labels() {
        let symbols = vec![
            sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
            sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
        ];
        let acc = vec![access(
            "nornir::viz::test_tab::TestTab::fetch_test_results",
            "test_results",
            Access::Read,
        )];
        let markers = marker_impls_from_symbols(&symbols, DEFAULT_MARKER_TRAITS);
        let cls = Classifier {
            marker_impls: &markers,
            grpc_handlers: &BTreeMap::new(),
        };
        let g = build_graph(&symbols, &[], &acc, &cls);
        let svg = g.to_svg();
        assert!(svg.starts_with("<svg"), "{svg}");
        assert!(svg.contains("</svg>"));
        assert!(!svg.contains("mermaid"));
        // Circuit-board flavour: dark substrate + the traces.
        assert!(svg.contains("arch-board"), "PCB substrate gradient: {svg}");
        assert!(svg.contains("<path "), "edge traces: {svg}");
        // Expected node labels are present.
        assert!(svg.contains(">TestTab<"), "TestTab chip label: {svg}");
        assert!(svg.contains(">test_results<"), "table chip label: {svg}");
        assert!(svg.len() > 400);
    }

    /// The code room holds the handler trait under its own name (rustc
    /// resolved `Bench as BenchSvcTrait` away): `impl Bench for BenchSvc` is
    /// the same impl, and its methods are handlers. Red without the second
    /// spelling: the map is empty and a resolved board has no gRPC node.
    #[test]
    fn grpc_handlers_are_found_under_the_traits_resolved_name_too() {
        let symbols = vec![
            sym_impl("nornir::server_grpc", "impl Bench for BenchSvc"),
            sym_fn("nornir::server_grpc::BenchSvc", "history"),
            // not the service's own trait: no handler impl
            sym_impl("nornir::server_grpc", "impl Clone for OtherSvc"),
            sym_fn("nornir::server_grpc::OtherSvc", "clone"),
            // a trait that merely shares the type's whole name is not it either
            sym_impl("nornir::server_grpc", "impl Svc for Svc"),
            sym_fn("nornir::server_grpc::Svc", "call"),
        ];
        let handlers = grpc_handlers_from_symbols(&symbols);
        assert_eq!(handlers.get("nornir::server_grpc::BenchSvc::history").map(String::as_str), Some("Bench.History"));
        assert_eq!(handlers.len(), 1, "{handlers:?}");
    }

    #[test]
    fn grpc_handlers_derived_from_symbols() {
        let symbols = vec![
            sym_impl("nornir::server", "impl VizSvcTrait for VizSvc"),
            sym_fn("nornir::server::VizSvc", "timeline"),
            sym_fn("nornir::server::VizSvc", "bakeoff_results"),
            // a non-svc impl method must not be picked up
            sym_impl("nornir::server", "impl Clone for VizSvc"),
        ];
        let handlers = grpc_handlers_from_symbols(&symbols);
        assert_eq!(
            handlers
                .get("nornir::server::VizSvc::timeline")
                .map(String::as_str),
            Some("Viz.Timeline")
        );
        assert_eq!(
            handlers
                .get("nornir::server::VizSvc::bakeoff_results")
                .map(String::as_str),
            Some("Viz.BakeoffResults")
        );
    }

    #[test]
    fn generate_with_cli_seeds_a_cli_marker_per_verb() {
        // The clap verb list becomes first-class CLI markers (the third
        // entrypoint layer), deduped, and absent from plain `generate`.
        let verbs = vec!["arch".to_string(), "release".to_string()];
        let g = generate_with_cli(&[], &[], &[], &verbs);
        let cli: Vec<&ArchNode> = g.nodes.iter().filter(|n| n.kind == NodeKind::Cli).collect();
        assert_eq!(cli.len(), 2, "one Cli node per verb");
        assert!(cli.iter().any(|n| n.id == "cli:arch" && n.label == "arch"));
        assert!(cli.iter().any(|n| n.id == "cli:release"));
        // plain generate seeds no CLI layer (lib stays binary/clap-free)
        assert!(
            generate(&[], &[], &[])
                .nodes
                .iter()
                .all(|n| n.kind != NodeKind::Cli)
        );
        assert_eq!(NodeKind::Cli.as_str(), "cli");
        assert_eq!(NodeKind::parse("cli"), NodeKind::Cli);
    }

    #[test]
    fn node_kind_layer_matches_surface_kind_layer() {
        // AUT8-GAP-LAYER: the arch layer axis is 1:1 with the matrix surface layer.
        assert_eq!(NodeKind::Component.layer(), "ui");
        assert_eq!(NodeKind::Grpc.layer(), "grpc");
        assert_eq!(NodeKind::Cli.layer(), "cli");
        assert_eq!(NodeKind::CoreFn.layer(), "core");
        assert_eq!(NodeKind::Table.layer(), "table");
        // Peer correspondence with nornir_testmatrix::discover::SurfaceKind::layer.
        use nornir_testmatrix::discover::SurfaceKind;
        assert_eq!(NodeKind::Component.layer(), SurfaceKind::VizTab.layer());
        assert_eq!(NodeKind::Grpc.layer(), SurfaceKind::Grpc.layer());
        assert_eq!(NodeKind::Cli.layer(), SurfaceKind::CliCommand.layer());
        assert_eq!(NodeKind::CoreFn.layer(), SurfaceKind::Function.layer());
    }

    #[test]
    fn generate_end_to_end_derives_both_maps() {
        // A pane + a server handler + a table, end-to-end through `generate`.
        let symbols = vec![
            sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
            sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
            sym_impl("nornir::server", "impl VizSvcTrait for VizSvc"),
            sym_fn("nornir::server::VizSvc", "test_results"),
        ];
        let calls = vec![];
        let acc = vec![
            access(
                "nornir::viz::test_tab::TestTab::fetch_test_results",
                "test_results",
                Access::Read,
            ),
            access(
                "nornir::server::VizSvc::test_results",
                "test_results",
                Access::Read,
            ),
        ];
        let g = generate(&symbols, &calls, &acc);
        assert!(
            g.nodes
                .iter()
                .any(|n| n.kind == NodeKind::Component && n.label == "TestTab")
        );
        assert!(
            g.nodes
                .iter()
                .any(|n| n.kind == NodeKind::Grpc && n.label == "Viz.TestResults")
        );
        assert!(
            g.nodes
                .iter()
                .any(|n| n.kind == NodeKind::Table && n.label == "test_results")
        );
        // Both the component AND the gRPC verb read test_results.
        let tbl = g.nodes.iter().find(|n| n.kind == NodeKind::Table).unwrap();
        let reads: Vec<&str> = g
            .edges
            .iter()
            .filter(|e| e.to == tbl.id && e.kind == ArchEdgeKind::Reads)
            .map(|e| e.from.as_str())
            .collect();
        assert!(reads.contains(&"component:TestTab"), "{reads:?}");
        assert!(reads.contains(&"grpc:Viz.TestResults"), "{reads:?}");
    }

    /// ARCH5/AUT7 trace mode: from the `TestTab` entrypoint we reach exactly the
    /// downstream table it reads, and an unrelated component is pruned.
    #[test]
    fn trace_from_entrypoint_keeps_only_reachable() {
        // TestTab -reads-> test_results; an unrelated MapTab -reads-> map_tiles.
        let g = ArchGraph {
            nodes: vec![
                ArchNode {
                    id: "component:TestTab".into(),
                    label: "TestTab".into(),
                    kind: NodeKind::Component,
                },
                ArchNode {
                    id: "component:MapTab".into(),
                    label: "MapTab".into(),
                    kind: NodeKind::Component,
                },
                ArchNode {
                    id: "table:test_results".into(),
                    label: "test_results".into(),
                    kind: NodeKind::Table,
                },
                ArchNode {
                    id: "table:map_tiles".into(),
                    label: "map_tiles".into(),
                    kind: NodeKind::Table,
                },
            ],
            edges: vec![
                ArchEdge {
                    from: "component:TestTab".into(),
                    to: "table:test_results".into(),
                    kind: ArchEdgeKind::Reads,
                },
                ArchEdge {
                    from: "component:MapTab".into(),
                    to: "table:map_tiles".into(),
                    kind: ArchEdgeKind::Reads,
                },
            ],
        };
        let sub = trace_from(&g, "TestTab");
        // Exactly the seed + its reachable table — MapTab/map_tiles pruned.
        let labels: BTreeSet<&str> = sub.nodes.iter().map(|n| n.label.as_str()).collect();
        assert!(labels.contains("TestTab"), "{labels:?}");
        assert!(labels.contains("test_results"), "{labels:?}");
        assert!(!labels.contains("MapTab"), "unreachable pruned: {labels:?}");
        assert!(
            !labels.contains("map_tiles"),
            "unreachable pruned: {labels:?}"
        );
        // The one kept edge is TestTab -reads-> test_results.
        assert_eq!(sub.edges.len(), 1);
        assert_eq!(sub.edges[0].from, "component:TestTab");
        assert_eq!(sub.edges[0].to, "table:test_results");
        assert_eq!(sub.edges[0].kind, ArchEdgeKind::Reads);
        // It renders the same circuit-board SVG.
        let svg = sub.to_svg();
        assert!(svg.contains(">TestTab<") && svg.contains(">test_results<"));
        // An entrypoint that matches nothing yields the empty graph.
        assert!(trace_from(&g, "no_such_surface").nodes.is_empty());
    }

    #[test]
    fn empty_graph_renders_placeholder_svg() {
        let svg = ArchGraph::default().to_svg();
        assert!(svg.starts_with("<svg"));
        assert!(svg.contains("empty architecture graph"));
    }

    /// Click-to-code: resolve an arch node label / a typed symbol to its
    /// `file:line` against scanned symbols. INJECT known rows → ASSERT the exact
    /// resolved path + line + match priority (LAW 1).
    #[test]
    fn resolve_symbol_finds_file_line_by_name_and_leaf() {
        let mut a = sym_fn("nornir::viz::arch_tab", "ArchTabState");
        a.item_kind = "struct".into();
        a.file = "src/viz/arch_tab.rs".into();
        a.line = 99;
        let mut b = sym_fn("nornir::arch", "trace_from");
        b.file = "src/arch/mod.rs".into();
        b.line = 460;
        // A gRPC handler whose leaf segment is the verb a `Service.verb` label maps to.
        let mut c = sym_fn("nornir_server::viz", "architecture");
        c.file = "src/bin/nornir-server.rs".into();
        c.line = 1200;
        let syms = vec![a, b, c];

        // Exact full-name match → the struct, at its file:line.
        let hit = resolve_symbol(&syms, "ArchTabState");
        assert_eq!(hit[0].file, "src/viz/arch_tab.rs");
        assert_eq!(hit[0].line, 99);
        assert_eq!(hit[0].kind, "struct");

        // A coarsened core-fn bucket label resolves via its leaf segment.
        let hit = resolve_symbol(&syms, "nornir::arch::trace_from");
        assert_eq!(hit[0].symbol, "trace_from");
        assert_eq!(hit[0].line, 460);

        // A `Service.verb` gRPC label resolves to the handler fn via the verb leaf.
        let hit = resolve_symbol(&syms, "Viz.architecture");
        assert_eq!(hit[0].symbol, "architecture");
        assert_eq!(hit[0].file, "src/bin/nornir-server.rs");

        // No match → empty (never a false location).
        assert!(resolve_symbol(&syms, "definitely_not_here").is_empty());
        assert!(resolve_symbol(&syms, "   ").is_empty());
    }

    /// The inline source-preview window reads the real lines around a target.
    #[test]
    fn source_window_reads_context_lines() {
        let dir = std::env::temp_dir().join(format!("nornir-arch-srcwin-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let file = "demo.rs";
        std::fs::write(dir.join(file), "l1\nl2\nl3\nl4\nl5\n").unwrap();
        // Target line 3, ±1 context → lines 2..=4.
        let (start, lines) = source_window(Some(&dir), file, 3, 1);
        assert_eq!(start, 2);
        assert_eq!(
            lines,
            vec!["l2".to_string(), "l3".to_string(), "l4".to_string()]
        );
        // Unreadable file → empty, never a panic.
        let (s2, l2) = source_window(Some(&dir), "nope.rs", 1, 2);
        assert_eq!(s2, 0);
        assert!(l2.is_empty());
    }

    // ── AUT9 CommandOutcome builders — inject real data, assert sannr fields ──

    fn node(id: &str, label: &str, kind: NodeKind) -> ArchNode {
        ArchNode {
            id: id.into(),
            label: label.into(),
            kind,
        }
    }
    fn edge(from: &str, to: &str, kind: ArchEdgeKind) -> ArchEdge {
        ArchEdge {
            from: from.into(),
            to: to.into(),
            kind,
        }
    }

    #[test]
    fn trace_outcome_is_sannr_for_reachable_red_for_empty() {
        // INJECT a real reachable sub-board (TestTab → core → table).
        let sub = ArchGraph {
            nodes: vec![
                node("component:TestTab", "TestTab", NodeKind::Component),
                node("corefn:nornir::test", "nornir::test", NodeKind::CoreFn),
                node("table:test_results", "test_results", NodeKind::Table),
            ],
            edges: vec![
                edge(
                    "component:TestTab",
                    "corefn:nornir::test",
                    ArchEdgeKind::Calls,
                ),
                edge(
                    "corefn:nornir::test",
                    "table:test_results",
                    ArchEdgeKind::Reads,
                ),
            ],
        };
        let o = trace_outcome("nornir", "TestTab", &sub);
        assert!(o.is_sannr(), "populated trace is truth");
        assert_eq!(o.command, "arch trace");
        assert_eq!(o.data["node_count"], serde_json::json!(3));
        assert_eq!(o.data["edge_count"], serde_json::json!(2));
        assert_eq!(o.data["entrypoint"], serde_json::json!("TestTab"));
        // real node fields surfaced (layer derived from kind).
        let nodes = o.data["nodes"].as_array().unwrap();
        assert!(
            nodes
                .iter()
                .any(|n| n["label"] == "TestTab" && n["layer"] == "ui")
        );
        assert!(
            o.data["edges"]
                .as_array()
                .unwrap()
                .iter()
                .any(|e| e["kind"] == "reads")
        );

        // RAGNARÖK: an empty trace (no node matched) is RED.
        let empty = trace_outcome("nornir", "no_such_entry", &ArchGraph::default());
        assert!(!empty.is_sannr());
        assert!(empty.human.contains("no node matched"));
    }

    #[test]
    fn open_outcome_is_sannr_for_hits_red_for_none() {
        // INJECT resolved click-to-code locations (best first).
        let hits = vec![
            SymbolLoc {
                symbol: "ArchTabState".into(),
                kind: "struct".into(),
                file: "src/viz/arch_tab.rs".into(),
                line: 42,
            },
            SymbolLoc {
                symbol: "ArchTabState::new".into(),
                kind: "fn".into(),
                file: "src/viz/arch_tab.rs".into(),
                line: 60,
            },
        ];
        // all=false → only the best match is surfaced.
        let best_only = open_outcome("nornir", "ArchTabState", &hits, false);
        assert!(best_only.is_sannr());
        assert_eq!(best_only.command, "arch open");
        assert_eq!(best_only.data["match_count"], serde_json::json!(2));
        assert_eq!(
            best_only.data["best"]["file"],
            serde_json::json!("src/viz/arch_tab.rs")
        );
        assert_eq!(best_only.data["best"]["line"], serde_json::json!(42));
        assert_eq!(
            best_only.data["locations"].as_array().unwrap().len(),
            1,
            "best-only slice"
        );

        // all=true → every candidate is surfaced.
        let all = open_outcome("nornir", "ArchTabState", &hits, true);
        assert!(all.is_sannr());
        assert_eq!(all.data["locations"].as_array().unwrap().len(), 2);

        // RAGNARÖK: no symbol matched is RED.
        let none = open_outcome("nornir", "zzz_nope", &[], false);
        assert!(!none.is_sannr());
        assert!(none.human.contains("no symbol matched"));
    }

}