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

nordisk/edda

crates/nornir-testmatrix/src/discover.rs

//! # autonom self-discovery — the anti-drift core of the completeness gate
//!
//! AUT2. autonom refuses to be green if any **surface** ships untested. To do
//! that without a hand-maintained list (which drifts — the #1 recurring
//! failure), it **discovers** the testable surface *from data*:
//!
//! ```text
//! Surface = every fn / tab / cmd / tool in {viz(thin+fat), CLI, MCP, core fns}   ← discovered
//! Covered = every surface reached by an inject-assert test                       ← call_edges / tools/list / registry
//! Gap     = Surface − Covered − Allowlist
//! GATE: Gap == ∅   (else the build/release fails, and the gap is shown)
//! ```
//!
//! This module is **pure**: every enumerator takes the raw facts (rows the
//! caller pulls from the symbol graph / `tools/list` / clap introspection / the
//! facett registry) and returns [`SurfaceNode`]s as plain structs. No warehouse,
//! no iceberg, no MCP client lives here — those feeders live in `nornir`. That
//! keeps the whole thing unit-testable by feeding sample rows and asserting the
//! computed [`Surface`] / [`Gap`].
//!
//! The proven template is the MCP `tools/list` gate (`tests/support/mcp_harness.rs`
//! self-discovers 56 tools and fails on any uncovered one). [`mcp_tools`] +
//! [`compute_gap`] generalize that "no silent gaps" rule to the whole surface.

use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

/// Which *kind* of surface a node belongs to. The enumerator that produced it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SurfaceKind {
    /// A facett component — `{T : impl Facet for T} ∩ registry()`.
    FacettComponent,
    /// A viz tab in one mode — the tab enum × {thin, fat}.
    VizTab,
    /// A CLI subcommand (clap introspection).
    CliCommand,
    /// An MCP tool (`tools/list` — the proven template).
    McpTool,
    /// A gRPC service handler — `Service.verb` (the thin/server backend a marker
    /// crosses). Enumerated from the tonic handler impls (arch's grpc handler map),
    /// so every RPC is a must-be-covered surface, not an invisible backend.
    Grpc,
    /// A core function — `symbol_facts − test-reachable-closure(call_edges)`.
    Function,
    /// A **UI atom** — one interactive/labelled AccessKit node the live atom-walk
    /// discovered in a viz tab (a button, a label, a field). The L0 oracle's
    /// per-atom surface: a station the metro map would draw must correspond to a
    /// `ui_atom` the viz actually renders. `id` is `"<tab>/<atom-label>"`.
    UiAtom,
    /// A **hot-path that must carry a bencher** — one perf-critical module/crate
    /// (Task #39). Benchers self-register via `inventory`
    /// ([`crate::discover::BencherRow`] is the fed data shape the caller fills from
    /// that seam); a hot-path is *covered* iff a registered [`Bencher`] targets it,
    /// so a module that should be benched but isn't is a `Bencher` surface gap. `id`
    /// is the owning module/crate key (`<repo>.<scenario>` namespace, e.g.
    /// `"nornir.dep_graph"`).
    Bencher,
}

impl SurfaceKind {
    /// The stable tag stored in [`SurfaceNode::kind`] string form / warehouse rows.
    pub fn label(self) -> &'static str {
        match self {
            SurfaceKind::FacettComponent => "facett_component",
            SurfaceKind::VizTab => "viz_tab",
            SurfaceKind::CliCommand => "cli_command",
            SurfaceKind::McpTool => "mcp_tool",
            SurfaceKind::Grpc => "grpc",
            SurfaceKind::Function => "function",
            SurfaceKind::UiAtom => "ui_atom",
            SurfaceKind::Bencher => "bencher",
        }
    }

    /// The canonical **layer** this surface sits at: `ui` · `grpc` · `cli` · `mcp`
    /// · `core`. This is the AUT8-GAP-LAYER resolution — the layer is **DERIVED**
    /// from the kind, not a stored column: a `functional_status` emit site can't
    /// know its own layer, but the surface enumerator does, so the matrix groups
    /// by `layer()` without bloating `test_results`. Maps 1:1 to
    /// [`crate::discover`]'s peer `arch::NodeKind::layer` (ui≙Component, grpc≙Grpc,
    /// cli≙Cli, core≙CoreFn).
    pub fn layer(self) -> &'static str {
        match self {
            SurfaceKind::VizTab | SurfaceKind::FacettComponent => "ui",
            SurfaceKind::Grpc => "grpc",
            SurfaceKind::CliCommand => "cli",
            SurfaceKind::McpTool => "mcp",
            SurfaceKind::Function => "core",
            // A rendered UI atom is part of the ui layer (same as a viz tab).
            SurfaceKind::UiAtom => "ui",
            // A bencher sits at its own perf layer — not ui/grpc/cli/mcp/core.
            SurfaceKind::Bencher => "bench",
        }
    }
}

/// The thin/fat axis (autonom §4). Every data surface has **two** entrypoints —
/// `load()` (fat / embedded) and `fetch_*()` (thin / RPC) — and **both** must be
/// covered (the invariant is thin == fat parity). A non-data surface uses
/// [`Mode::NA`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
    /// Embedded / in-process path (`load()`), reads the warehouse directly.
    Fat,
    /// Thin client path (`fetch_*()`), reads over an RPC.
    Thin,
    /// Not a thin/fat data surface (e.g. a stateless CLI command).
    #[serde(rename = "na")]
    NA,
}

impl Mode {
    pub fn label(self) -> &'static str {
        match self {
            Mode::Fat => "fat",
            Mode::Thin => "thin",
            Mode::NA => "na",
        }
    }
}

/// One discovered, testable surface node — the unit of the completeness gate.
///
/// `id` is the **stable key** the gate matches coverage against (e.g. a facett
/// component named in `registry()`, an MCP tool name from `tools/list`, a fully
/// qualified fn path). Two nodes are the same surface iff their [`SurfaceNode::key`]
/// (kind + id + mode) is equal — so the same tab in thin vs fat are *distinct*
/// nodes (both must be covered).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceNode {
    pub kind: SurfaceKind,
    /// The stable identity within its kind (component name / tab name / cmd /
    /// tool name / fn path). Matched against coverage rows.
    pub id: String,
    /// The thin/fat mode this node covers ([`Mode::NA`] for non-data surfaces).
    pub mode: Mode,
    /// Human label for display (defaults to `id` when not given).
    pub label: String,
    /// Capability flags the enumerator surfaced (e.g. facett `reads_warehouse`,
    /// `has_local`, `has_remote`). Free-form, so each repo plugs its own caps;
    /// `BTreeSet` keeps them stable-ordered + deduped for deterministic output.
    pub caps: BTreeSet<String>,
}

impl SurfaceNode {
    /// The stable match key: `(kind, id, mode)`. The gate joins coverage on this.
    pub fn key(&self) -> (SurfaceKind, &str, Mode) {
        (self.kind, self.id.as_str(), self.mode)
    }

    /// A flat string key (`"kind:id@mode"`) for set membership / serde maps.
    pub fn key_str(&self) -> String {
        format!("{}:{}@{}", self.kind.label(), self.id, self.mode.label())
    }

    fn new(kind: SurfaceKind, id: impl Into<String>, mode: Mode) -> Self {
        let id = id.into();
        Self { kind, label: id.clone(), id, mode, caps: BTreeSet::new() }
    }

    /// A [`SurfaceKind::UiAtom`] node with an explicit `id` + display `label`,
    /// [`Mode::NA`]. The single public atom-node constructor — used by both the
    /// live atom-walk enumerator ([`ui_atoms`], id `"<tab>/<label>"`) and the
    /// atom-layer verifier ([`crate::atom`], id `"<tab>/<atom>/<state>"`). There is
    /// exactly ONE atom surface kind; the verifier folds the state axis into the
    /// id, not a second variant.
    pub fn ui_atom(id: impl Into<String>, label: impl Into<String>) -> Self {
        Self::new(SurfaceKind::UiAtom, id, Mode::NA).with_label(label)
    }

    fn with_label(mut self, label: impl Into<String>) -> Self {
        self.label = label.into();
        self
    }

    fn with_caps<I, S>(mut self, caps: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.caps = caps.into_iter().map(Into::into).collect();
        self
    }
}

// ─── input row shapes (what the caller feeds in) ────────────────────────────

/// One `impl Facet for T` row, as the caller reads it from the symbol graph /
/// warehouse. nornir-testmatrix can't depend on facett, so this is the DATA
/// SHAPE the caller fills from `symbol_facts` + the facett registry.
///
/// The discovery contract: a component is a surface iff it both `impl Facet`s
/// **and** is in `registry()` — so [`facett_components`] intersects them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FacetRow {
    /// The component type name (`T` in `impl Facet for T`) — the stable id.
    pub component: String,
    /// Is this `T` a member of `registry()`? Only registered components count
    /// (a `impl Facet` not in the registry is not yet a live surface).
    #[serde(default)]
    pub in_registry: bool,
    /// Does it expose a `local(...)` (fat) constructor? → a fat node.
    #[serde(default)]
    pub has_local_ctor: bool,
    /// Does it expose a `remote(...)` (thin) constructor? → a thin node.
    #[serde(default)]
    pub has_remote_ctor: bool,
    /// Free-form capability tags from `caps()`/`FacetCaps` (e.g. `reads_warehouse`,
    /// `interactive`). Copied onto each emitted node's `caps`.
    #[serde(default)]
    pub caps: Vec<String>,
    /// **The REAL headless-drive verdict** (not a name match): `true` when the
    /// caller actually built this component, drove it through a `Msg` script via
    /// `facett_core::harness`, and got a clean snapshot/render. The caller (the
    /// `nornir` binary, which alone has the facett deps) fills this from the live
    /// drive; [`covered_facetts`] turns it into the covered-key set — mirroring how
    /// [`DiscoveredAtom::ran`]/[`DiscoveredAtom::clean`] feed [`covered_atoms`].
    /// Defaults `false` (a row with no live drive is an uncovered surface).
    #[serde(default)]
    pub driven: bool,
    /// **The STRUCTURAL severity the drive observed** (RESOLVED decision (a)): the
    /// pane's `facett_core::Panel::severity()` folded to a tag — `"info"` (green),
    /// `"warning"`, or `"error"` (RED). This is the ERROR SIGNAL the gate asserts on
    /// *structurally*, replacing the `error_atoms()`/`ERROR_MARKERS` substring scan
    /// (which survives only as a MIGRATION FALLBACK, already folded into
    /// [`driven`](FacetRow::driven) by the caller). A driven pane whose severity is
    /// `"error"` is **NOT** counted covered by [`covered_facetts`] — so its surface
    /// node stays MISSING and the HARD gate goes RED, even though it "drove". Empty /
    /// absent defaults to `"info"` (the green floor), so the field is purely
    /// ADDITIVE for callers that have not yet adopted structural severity.
    #[serde(default = "default_severity")]
    pub severity: String,
}

/// The serde default for [`FacetRow::severity`] — the [`Severity::Info`] green
/// floor, so an old row with no `severity` column deserializes as green.
fn default_severity() -> String {
    "info".to_string()
}

/// Is a [`FacetRow::severity`] tag the RED, gate-failing severity? Lenient (the same
/// synonyms `facett_core::Severity::parse` accepts), so the string channel between
/// facett-core and this lean lib stays robust. Anything not recognizably "error"
/// (including `"info"`/`"warning"`/unknown) is NOT red.
pub fn severity_is_error(tag: &str) -> bool {
    matches!(tag.trim().to_ascii_lowercase().as_str(), "error" | "err" | "red")
}

/// One symbol-graph function row (`symbol_facts`). The id used for reachability
/// is [`SymbolRow::fqn`] (fully-qualified name) — the same id `call_edges` use.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolRow {
    /// Fully-qualified function name (`crate::module::func`). The reachability id.
    pub fqn: String,
    /// Is this a test function (`#[test]` / a known test target)? Test fns are
    /// the **roots** of the reachable closure, not surface to be covered.
    #[serde(default)]
    pub is_test: bool,
    /// Display label (defaults to `fqn`).
    #[serde(default)]
    pub label: Option<String>,
}

/// One `caller → callee` edge from `call_edges`. Used to compute the
/// test-reachable closure (a fn is reachable iff a test fn can reach it).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CallEdge {
    pub caller: String,
    pub callee: String,
}

/// One **discovered UI atom** from the live atom-walk — the row shape `nornir`
/// fills from `nornir_robotui::all_atoms` (the lib can't depend on egui/robotui,
/// so this is the DATA SHAPE the binary feeds in). `(tab, label)` is the stable
/// identity; `ran` + `clean` drive the verdict the [`ui_atoms`] enumerator's peer
/// producer assigns (covered ⟺ the tab ran AND no error atom leaked in that state).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredAtom {
    /// The viz tab the atom was rendered in (the `state_json` section key).
    pub tab: String,
    /// The atom's visible text/label (`Atom::text` from the walk).
    pub label: String,
    /// The atom's AccessKit role (`Atom::role`, e.g. `Button`/`Label`) — kept as a
    /// cap so the warehouse row carries what kind of atom it is.
    #[serde(default)]
    pub role: String,
    /// Did the owning tab actually RUN (render) in the walk? A tab the walk never
    /// reached yields no atoms, so in practice this is `true` for every produced
    /// atom; kept explicit so a future per-state walk can mark a non-rendered state.
    #[serde(default)]
    pub ran: bool,
    /// Was the tab's rendered state CLEAN (no error atom) when this atom was seen?
    /// `covered` ⟺ `ran && clean` — the producer's verdict rule.
    #[serde(default)]
    pub clean: bool,
}

/// One **hot-path that should carry a bencher** — the row shape `nornir` fills
/// from the `inventory` bencher registry (the lib can't depend on `nornir-bench`,
/// so this is the DATA SHAPE the binary feeds in). `module` is the owning
/// module/crate key (`<repo>.<scenario>` namespace, e.g. `"nornir.dep_graph"`) —
/// the stable identity. `has_bencher` is the REAL discovery verdict: `true` iff
/// the caller found a registered [`Bencher`] targeting this hot-path when it swept
/// `inventory::iter::<BencherRegistration>()`. `bench_ids` records which
/// bencher(s) covered it (carried as caps for the warehouse row). A row with
/// `has_bencher == false` is an uncovered `Bencher` surface — exactly the "this
/// hot-path has no bencher" gap Task #39 makes enforceable. Mirrors
/// [`FacetRow::driven`] (a real verdict, not a name match).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BencherRow {
    /// The owning module/crate key (`<repo>.<scenario>` namespace) — the stable id.
    pub module: String,
    /// Did the `inventory` sweep find a registered [`Bencher`] targeting this
    /// hot-path? `false` ⟹ an uncovered surface (the gate gap / allowlist candidate).
    #[serde(default)]
    pub has_bencher: bool,
    /// The registered bencher id(s) covering this hot-path (carried as `bench:<id>`
    /// caps so the warehouse row records WHICH bencher(s) satisfy it). Empty when
    /// `has_bencher == false`.
    #[serde(default)]
    pub bench_ids: Vec<String>,
}

// ─── enumerators (PURE — feed facts, return SurfaceNodes) ───────────────────

/// **facett components**: `{T : impl Facet for T} ∩ registry()`.
///
/// One node per registered component **per mode it supports**: a `local()` ctor
/// → a [`Mode::Fat`] node, a `remote()` ctor → a [`Mode::Thin`] node (both must
/// be covered — thin == fat parity). A component with neither ctor (a stateless
/// view) gets a single [`Mode::NA`] node. Components not in `registry()` are
/// dropped (the intersection).
pub fn facett_components(rows: &[FacetRow]) -> Vec<SurfaceNode> {
    let mut out = Vec::new();
    for r in rows.iter().filter(|r| r.in_registry) {
        let mk = |mode: Mode| {
            SurfaceNode::new(SurfaceKind::FacettComponent, &r.component, mode)
                .with_caps(r.caps.iter().cloned())
        };
        match (r.has_local_ctor, r.has_remote_ctor) {
            (false, false) => out.push(mk(Mode::NA)),
            (l, t) => {
                if l {
                    out.push(mk(Mode::Fat));
                }
                if t {
                    out.push(mk(Mode::Thin));
                }
            }
        }
    }
    out
}

/// The drift between the authoritative Facet **registry** and the declared **tab
/// roster** (RESOLVED decision (b)). korp's SURFACE is the registry (self-discovering);
/// the 17-tab list is a CROSS-CHECK. Both sets empty ⟺ the registry and the tab bar
/// agree — every tab is backed by a registered Facet, and every registered Facet is
/// reachable as a tab.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabFacetDrift {
    /// Tab keys that have **no** registered Facet backing them — a tab the user sees
    /// with no tested component behind it (the "unwired surface ships green" bug).
    pub tabs_without_facet: Vec<String>,
    /// Registered Facet ids that are **not** reachable as any tab — a tested
    /// component the user can never navigate to (dead surface / a dropped tab).
    pub facets_without_tab: Vec<String>,
}

impl TabFacetDrift {
    /// Clean ⟺ the registry and the tab roster agree (both drift sets empty). The
    /// gate treats a non-clean result as a DISCOVERY failure (fail RED).
    pub fn is_clean(&self) -> bool {
        self.tabs_without_facet.is_empty() && self.facets_without_tab.is_empty()
    }
}

/// Cross-check the korp SURFACE (the authoritative Facet registry, given as the set
/// of registered Facet ids) against the declared tab roster (the tab keys) — decision
/// (b). Flags any tab with no registered Facet and any registered Facet unreachable
/// as a tab. Pure set difference; both id spaces are the stable `Tab::key()` /
/// component-id namespace, so they join directly. Sorted for deterministic output.
pub fn cross_check_tabs<'a, I, J>(registered_facets: I, tab_keys: J) -> TabFacetDrift
where
    I: IntoIterator<Item = &'a str>,
    J: IntoIterator<Item = &'a str>,
{
    let facets: BTreeSet<&str> = registered_facets.into_iter().collect();
    let tabs: BTreeSet<&str> = tab_keys.into_iter().collect();
    TabFacetDrift {
        tabs_without_facet: tabs.difference(&facets).map(|s| s.to_string()).collect(),
        facets_without_tab: facets.difference(&tabs).map(|s| s.to_string()).collect(),
    }
}

/// **viz tabs × {thin, fat}**: one node per tab per mode. The caller supplies the
/// tab names (the tab enum, discovered by introspection, never hand-listed at
/// the gate). Each tab yields a [`Mode::Fat`] *and* a [`Mode::Thin`] node —
/// that's the axis the old matrix missed (the `Test.Results RPC TODO` bug class).
pub fn viz_tabs<I, S>(tabs: I) -> Vec<SurfaceNode>
where
    I: IntoIterator<Item = S>,
    S: Into<String>,
{
    let mut out = Vec::new();
    for tab in tabs {
        let tab = tab.into();
        for mode in [Mode::Fat, Mode::Thin] {
            out.push(SurfaceNode::new(SurfaceKind::VizTab, &tab, mode));
        }
    }
    out
}

/// **CLI subcommands**: one [`Mode::NA`] node per clap subcommand name the caller
/// introspects (`Command::get_subcommands`). Modelled from a provided list — the
/// caller does the clap introspection, the gate just records the surface.
pub fn cli_commands<I, S>(subcommands: I) -> Vec<SurfaceNode>
where
    I: IntoIterator<Item = S>,
    S: Into<String>,
{
    subcommands
        .into_iter()
        .map(|c| SurfaceNode::new(SurfaceKind::CliCommand, c, Mode::NA))
        .collect()
}

/// **MCP tools**: one [`Mode::NA`] node per tool name from a `tools/list` set —
/// the proven template (the MCP harness already self-discovers 56 tools and
/// fails on any uncovered one). The caller feeds the `tools/list` names.
pub fn mcp_tools<I, S>(tool_names: I) -> Vec<SurfaceNode>
where
    I: IntoIterator<Item = S>,
    S: Into<String>,
{
    tool_names
        .into_iter()
        .map(|t| SurfaceNode::new(SurfaceKind::McpTool, t, Mode::NA))
        .collect()
}

/// **gRPC handlers**: one [`Mode::Thin`] node per `Service.verb` label — the
/// server-side backend a thin marker calls. gRPC is inherently the thin path, so
/// each handler is a `Thin`-mode surface that must be covered (an RPC with no test
/// is the `Test.Results RPC TODO` bug class the matrix is meant to catch). The
/// caller feeds the handler labels (arch's `grpc_handlers_from_symbols` values).
pub fn grpc_handlers<I, S>(labels: I) -> Vec<SurfaceNode>
where
    I: IntoIterator<Item = S>,
    S: Into<String>,
{
    labels
        .into_iter()
        .map(|l| SurfaceNode::new(SurfaceKind::Grpc, l, Mode::Thin))
        .collect()
}

/// **functions**: `symbol_facts − test-reachable-closure(call_edges)`.
///
/// A core fn is a surface node iff **no test function can reach it** through the
/// call graph (a covered fn is reached by some test and so is *not* in the
/// surface gap). This is the pure graph op: BFS from every `is_test` root over
/// the `caller → callee` edges; whatever a test can reach is dropped; the
/// remainder are the unreached [`SurfaceKind::Function`] nodes.
///
/// Test functions themselves are never surface nodes (they're the roots, not the
/// thing to be tested).
pub fn unreached_functions(symbols: &[SymbolRow], edges: &[CallEdge]) -> Vec<SurfaceNode> {
    let reachable = test_reachable(symbols, edges);
    symbols
        .iter()
        .filter(|s| !s.is_test && !reachable.contains(s.fqn.as_str()))
        .map(|s| {
            let label = s.label.clone().unwrap_or_else(|| s.fqn.clone());
            SurfaceNode::new(SurfaceKind::Function, &s.fqn, Mode::NA).with_label(label)
        })
        .collect()
}

/// **UI atoms**: one [`Mode::NA`] node per discovered atom — `id =
/// "<tab>/<label>"`, `kind = ui_atom`. This is the per-atom surface the live
/// atom-walk feeds in (via [`DiscoveredAtom`]); it lets the completeness gate
/// count rendered atoms as surface nodes, not just whole tabs. The atom's role is
/// carried as a cap. Deduped by `(tab, label)` so a label repeated across galley
/// nodes is one surface node.
pub fn ui_atoms<'a, I>(atoms: I) -> Vec<SurfaceNode>
where
    I: IntoIterator<Item = &'a DiscoveredAtom>,
{
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for a in atoms {
        let id = format!("{}/{}", a.tab, a.label);
        if !seen.insert(id.clone()) {
            continue;
        }
        let mut node = SurfaceNode::new(SurfaceKind::UiAtom, &id, Mode::NA).with_label(&a.label);
        if !a.role.is_empty() {
            node = node.with_caps([format!("role:{}", a.role)]);
        }
        out.push(node);
    }
    out
}

/// The covered set for a batch of [`DiscoveredAtom`]s: an atom's surface key is
/// covered iff the tab RAN and its state was CLEAN (no error atom) when the atom
/// was seen — the producer's verdict rule, pure so it's unit-testable. The keys
/// match [`ui_atoms`]' node keys (`ui_atom:<tab>/<label>@na`).
pub fn covered_atoms<'a, I>(atoms: I) -> BTreeSet<String>
where
    I: IntoIterator<Item = &'a DiscoveredAtom>,
{
    atoms
        .into_iter()
        .filter(|a| a.ran && a.clean)
        .map(|a| format!("{}:{}/{}@{}", SurfaceKind::UiAtom.label(), a.tab, a.label, Mode::NA.label()))
        .collect()
}

/// **facett-component COVERAGE from the REAL headless drive** — the peer producer
/// to [`covered_atoms`], but for [`SurfaceKind::FacettComponent`] nodes. A component
/// counts as covered iff the caller actually built it, drove it through a `Msg`
/// script via `facett_core::harness`, and got a clean snapshot/render — recorded as
/// [`FacetRow::driven`]. This REPLACES the old static handler-name match for facett
/// surfaces: a component is green because it *drove*, not because a fn shares its name.
///
/// The emitted keys match [`facett_components`] node-for-node (same kind/id/mode
/// split), so `covered.extend(covered_facetts(rows))` marks exactly the driven
/// component's node(s) covered. A row with `driven == false` (never driven, or drove
/// dirty) yields no key — it stays an uncovered surface, exactly as an un-run atom does.
pub fn covered_facetts(rows: &[FacetRow]) -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    // Covered ⟺ registered AND driven AND NOT structurally RED (decision (a)): a
    // pane that drove but reported `Severity::Error` is a red surface — it emits no
    // covered key, so it stays MISSING and the HARD gate fails on it.
    for r in rows.iter().filter(|r| r.in_registry && r.driven && !severity_is_error(&r.severity)) {
        let key = |mode: Mode| {
            SurfaceNode::new(SurfaceKind::FacettComponent, &r.component, mode).key_str()
        };
        match (r.has_local_ctor, r.has_remote_ctor) {
            (false, false) => {
                out.insert(key(Mode::NA));
            }
            (l, t) => {
                if l {
                    out.insert(key(Mode::Fat));
                }
                if t {
                    out.insert(key(Mode::Thin));
                }
            }
        }
    }
    out
}

/// **benchers**: one [`SurfaceKind::Bencher`] node per hot-path that should carry
/// a bencher — `id = <module>`, [`Mode::NA`]. This is the per-hot-path surface the
/// bencher-coverage gate counts (Task #39): the caller feeds the canonical
/// hot-path list as [`BencherRow`]s (each with the `inventory`-derived
/// `has_bencher` verdict). The covering bencher id(s) ride along as `bench:<id>`
/// caps so the warehouse row records WHAT satisfies it. Deduped by `module` so a
/// hot-path listed twice is one surface node.
pub fn benchers(rows: &[BencherRow]) -> Vec<SurfaceNode> {
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for r in rows {
        if !seen.insert(r.module.clone()) {
            continue;
        }
        let node = SurfaceNode::new(SurfaceKind::Bencher, &r.module, Mode::NA)
            .with_caps(r.bench_ids.iter().map(|id| format!("bench:{id}")));
        out.push(node);
    }
    out
}

/// **bencher COVERAGE from the `inventory` seam** — the peer producer to
/// [`covered_facetts`] / [`covered_atoms`], for [`SurfaceKind::Bencher`] nodes. A
/// hot-path counts as covered iff the caller found a registered [`Bencher`]
/// targeting it when it swept `inventory::iter::<BencherRegistration>()`
/// (recorded as [`BencherRow::has_bencher`]). The emitted keys match
/// [`benchers`]' node keys, so `covered.extend(covered_benchers(rows))` marks
/// exactly the benched hot-paths covered. A row with `has_bencher == false`
/// yields no key — it stays an uncovered surface (the "no bencher" gap), exactly
/// as an un-driven facett or un-run atom does.
pub fn covered_benchers(rows: &[BencherRow]) -> BTreeSet<String> {
    rows.iter()
        .filter(|r| r.has_bencher)
        .map(|r| SurfaceNode::new(SurfaceKind::Bencher, &r.module, Mode::NA).key_str())
        .collect()
}

/// The set of fn fqns reachable from any test function over `call_edges`
/// (the test-reachable closure). Public so a caller / test can assert it
/// directly. The test roots themselves are included in the returned set.
pub fn test_reachable<'a>(symbols: &'a [SymbolRow], edges: &'a [CallEdge]) -> BTreeSet<&'a str> {
    use std::collections::BTreeMap;
    // Adjacency: caller → [callees].
    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for e in edges {
        adj.entry(e.caller.as_str()).or_default().push(e.callee.as_str());
    }
    let mut reached: BTreeSet<&str> = BTreeSet::new();
    let mut stack: Vec<&str> = symbols
        .iter()
        .filter(|s| s.is_test)
        .map(|s| s.fqn.as_str())
        .collect();
    while let Some(n) = stack.pop() {
        if !reached.insert(n) {
            continue; // already visited — handles cycles
        }
        if let Some(callees) = adj.get(n) {
            for &c in callees {
                if !reached.contains(c) {
                    stack.push(c);
                }
            }
        }
    }
    reached
}

// ─── the Surface + Gap model ────────────────────────────────────────────────

/// The full discovered surface — every [`SurfaceNode`] across all enumerators.
/// Built once per gate run, then differenced against coverage to compute the
/// [`Gap`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Surface {
    pub nodes: Vec<SurfaceNode>,
}

impl Surface {
    pub fn new() -> Self {
        Self::default()
    }

    /// Merge a batch of nodes from one enumerator into the surface.
    pub fn extend(&mut self, nodes: impl IntoIterator<Item = SurfaceNode>) -> &mut Self {
        self.nodes.extend(nodes);
        self
    }

    /// Total discovered surface nodes.
    pub fn len(&self) -> usize {
        self.nodes.len()
    }

    pub fn is_empty(&self) -> bool {
        self.nodes.is_empty()
    }

    /// Count of nodes of a given kind.
    pub fn count_kind(&self, kind: SurfaceKind) -> usize {
        self.nodes.iter().filter(|n| n.kind == kind).count()
    }
}

/// The served workspaces the completeness gate sweeps — the **workspace
/// dimension** of the surface. The full surface is `workspace × {cli_command,
/// viz_tab@{fat,thin}, mcp_tool, …}`: each served workspace must clean-slate →
/// populate → verify its own GUI/CLI surface, so a tab that works for `nornir`
/// but is blank for `knut` is a distinct gap. The caller (nornir) reads the live
/// served set from `workspaces_list`; this const is the canonical fallback /
/// reference so the dimension is discoverable from the lib.
pub const SERVED_WORKSPACES: &[&str] =
    &["facett", "holger", "knut", "korp", "nornir", "ordning", "skade", "znippy"];

/// The workspace-scoped surface key: `"<workspace>/<kind:id@mode>"`. The gate
/// joins coverage on THIS when the workspace dimension is active, so the same tab
/// in two workspaces are two distinct surfaces (both must be covered from their
/// own populated warehouse). [`workspace_keys`] maps a whole surface for one
/// workspace.
pub fn workspace_surface_key(workspace: &str, node: &SurfaceNode) -> String {
    format!("{workspace}/{}", node.key_str())
}

/// Every surface node's workspace-scoped key for `workspace` (the join keys the
/// gate uses when sweeping `workspace × surface`).
pub fn workspace_keys(workspace: &str, surface: &Surface) -> BTreeSet<String> {
    surface
        .nodes
        .iter()
        .map(|n| workspace_surface_key(workspace, n))
        .collect()
}

/// The completeness verdict: `Gap = Surface − Covered − Allowlist`.
///
/// `missing` is the set of surface nodes with **no covering test** and **not**
/// on the allowlist — the gate fails iff `missing` is non-empty. `allowlisted`
/// records which surface nodes were excused (so the excuse is visible, never
/// silent). `is_clean()` is the green/red verdict.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Gap {
    /// Surface nodes that are neither covered nor allowlisted — the gate fails
    /// if this is non-empty. Sorted by `key_str` for deterministic output.
    pub missing: Vec<SurfaceNode>,
    /// Surface nodes excused by the allowlist (recorded so the excuse is visible).
    pub allowlisted: Vec<SurfaceNode>,
    /// How many surface nodes were actually covered by a test.
    pub covered: usize,
    /// Total discovered surface nodes (`covered + allowlisted + missing`).
    pub total: usize,
}

impl Gap {
    /// The gate verdict: **green ⟺ no missing surface**. `Gap == ∅`.
    pub fn is_clean(&self) -> bool {
        self.missing.is_empty()
    }

    /// A one-line summary for the CLI / viz.
    pub fn summary(&self) -> String {
        format!(
            "{}/{} surface nodes covered · {} allowlisted · {} MISSING — {}",
            self.covered,
            self.total,
            self.allowlisted.len(),
            self.missing.len(),
            if self.is_clean() { "GREEN" } else { "RED (gap not empty)" },
        )
    }
}

/// Compute `Gap = Surface − Covered − Allowlist`.
///
/// - `covered` is the set of surface keys (`SurfaceNode::key_str`) reached by an
///   inject-assert test (the caller derives it from coverage rows / call_edges /
///   `tools/list` exercised-set / the facett registry's covered components).
/// - `allowlist` is the set of surface keys explicitly excused (recorded as
///   `allowlisted`, never silently dropped).
///
/// Pure: feed the surface + two key sets, get the verdict back.
pub fn compute_gap(
    surface: &Surface,
    covered: &BTreeSet<String>,
    allowlist: &BTreeSet<String>,
) -> Gap {
    let mut missing = Vec::new();
    let mut allowlisted = Vec::new();
    let mut covered_count = 0usize;
    for node in &surface.nodes {
        let key = node.key_str();
        if covered.contains(&key) {
            covered_count += 1;
        } else if allowlist.contains(&key) {
            allowlisted.push(node.clone());
        } else {
            missing.push(node.clone());
        }
    }
    missing.sort_by_key(|n| n.key_str());
    allowlisted.sort_by_key(|n| n.key_str());
    Gap {
        covered: covered_count,
        total: surface.nodes.len(),
        missing,
        allowlisted,
    }
}

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

    fn caps_of(n: &SurfaceNode) -> Vec<&str> {
        n.caps.iter().map(String::as_str).collect()
    }

    fn atom(tab: &str, label: &str, role: &str, ran: bool, clean: bool) -> DiscoveredAtom {
        DiscoveredAtom { tab: tab.into(), label: label.into(), role: role.into(), ran, clean }
    }

    /// The `ui_atoms` enumerator turns discovered atoms into `ui_atom:<tab>/<label>`
    /// surface nodes (deduped, role carried as a cap), and `covered_atoms` marks an
    /// atom covered iff its tab ran AND its state was clean — the producer's rule.
    #[test]
    fn ui_atoms_enumerate_and_cover_per_atom() {
        let atoms = vec![
            atom("test", "Run full matrix", "Button", true, true),  // covered
            atom("test", "Status: idle", "Label", true, true),      // covered
            atom("test", "Run full matrix", "Button", true, true),  // dup → folded
            atom("nornir", "✗ is not served", "Label", true, false), // ran but dirty → NOT covered
        ];
        let nodes = ui_atoms(&atoms);
        // 3 distinct surface nodes (the dup folded).
        assert_eq!(nodes.len(), 3, "deduped per (tab,label): {nodes:?}");
        let keys: BTreeSet<String> = nodes.iter().map(|n| n.key_str()).collect();
        assert!(keys.contains("ui_atom:test/Run full matrix@na"));
        assert!(keys.contains("ui_atom:test/Status: idle@na"));
        assert!(keys.contains("ui_atom:nornir/✗ is not served@na"));
        // The role rides along as a cap for the warehouse row.
        let btn = nodes.iter().find(|n| n.id == "test/Run full matrix").unwrap();
        assert!(btn.caps.contains("role:Button"), "role cap carried: {:?}", btn.caps);

        let covered = covered_atoms(&atoms);
        assert!(covered.contains("ui_atom:test/Run full matrix@na"), "clean tab atom covered");
        assert!(covered.contains("ui_atom:test/Status: idle@na"));
        assert!(
            !covered.contains("ui_atom:nornir/✗ is not served@na"),
            "an atom in a dirty (error) tab state is NOT covered",
        );
    }

    // ── bencher hot-path enumerator (Task #39) ──────────────────────────────

    fn bench_row(module: &str, has: bool, ids: &[&str]) -> BencherRow {
        BencherRow {
            module: module.into(),
            has_bencher: has,
            bench_ids: ids.iter().map(|s| s.to_string()).collect(),
        }
    }

    /// The `benchers` enumerator turns hot-path rows into `bencher:<module>@na`
    /// surface nodes (deduped, covering bench ids carried as caps), and
    /// `covered_benchers` marks a hot-path covered iff a registered bencher targets
    /// it (`has_bencher`). A hot-path with NO bencher stays an uncovered surface —
    /// which makes the gate row RED unless allowlisted.
    #[test]
    fn benchers_enumerate_and_cover_per_hot_path() {
        let rows = vec![
            bench_row("nornir.dep_graph", true, &["nornir.dep_graph_build"]),
            bench_row("nornir.vector_search", false, &[]), // hot-path with NO bencher
            bench_row("nornir.dep_graph", true, &["nornir.dep_graph_build"]), // dup → folded
        ];
        let nodes = benchers(&rows);
        assert_eq!(nodes.len(), 2, "deduped per module: {nodes:?}");
        let keys: BTreeSet<String> = nodes.iter().map(|n| n.key_str()).collect();
        assert!(keys.contains("bencher:nornir.dep_graph@na"));
        assert!(keys.contains("bencher:nornir.vector_search@na"));
        // The covering bench id rides along as a cap on the benched hot-path.
        let dg = nodes.iter().find(|n| n.id == "nornir.dep_graph").unwrap();
        assert!(dg.caps.contains("bench:nornir.dep_graph_build"), "bench id cap: {:?}", dg.caps);

        let covered = covered_benchers(&rows);
        assert!(covered.contains("bencher:nornir.dep_graph@na"), "benched hot-path covered");
        assert!(
            !covered.contains("bencher:nornir.vector_search@na"),
            "a hot-path with no registered bencher is NOT covered",
        );
    }

    /// A hot-path lacking a bencher is a gate gap; allowlisting it excuses the row.
    #[test]
    fn missing_bencher_is_gap_unless_allowlisted() {
        let rows = vec![
            bench_row("nornir.dep_graph", true, &["nornir.dep_graph_build"]),
            bench_row("nornir.vector_search", false, &[]),
        ];
        let mut surface = Surface::new();
        surface.extend(benchers(&rows));
        assert_eq!(surface.count_kind(SurfaceKind::Bencher), 2);

        let covered = covered_benchers(&rows);
        // No allowlist: the un-benched hot-path is the lone gap → RED.
        let gap = compute_gap(&surface, &covered, &BTreeSet::new());
        assert_eq!(gap.missing.len(), 1, "the un-benched hot-path is a gap");
        assert_eq!(gap.missing[0].key_str(), "bencher:nornir.vector_search@na");
        assert!(!gap.is_clean(), "a hot-path with no bencher makes the gate RED");

        // Allowlist the known-missing hot-path → GREEN (the honest burn-down escape).
        let allow: BTreeSet<String> =
            ["bencher:nornir.vector_search@na".to_string()].into_iter().collect();
        let gap2 = compute_gap(&surface, &covered, &allow);
        assert!(gap2.is_clean(), "an allowlisted hot-path passes the gate");
        assert_eq!(gap2.allowlisted.len(), 1);
        assert_eq!(gap2.covered, 1, "the benched hot-path is covered");
    }

    #[test]
    fn bencher_kind_labels_and_layers() {
        assert_eq!(SurfaceKind::Bencher.label(), "bencher");
        assert_eq!(SurfaceKind::Bencher.layer(), "bench");
    }

    // ── facett component enumerator ─────────────────────────────────────────

    #[test]
    fn covered_facetts_marks_only_driven_registered_rows_matching_node_keys() {
        // NA component driven → its NA node key; fat+thin component driven → both;
        // a driven-but-unregistered row → nothing; a registered-but-undriven row → nothing.
        let rows = vec![
            FacetRow {
                component: "facett-helix".into(),
                in_registry: true,
                has_local_ctor: false,
                has_remote_ctor: false,
                caps: vec![],
                driven: true,
                severity: "info".into(),
            },
            FacetRow {
                component: "WarehouseView".into(),
                in_registry: true,
                has_local_ctor: true,
                has_remote_ctor: true,
                caps: vec![],
                driven: true,
                severity: "info".into(),
            },
            FacetRow {
                component: "NotRegistered".into(),
                in_registry: false,
                has_local_ctor: false,
                has_remote_ctor: false,
                caps: vec![],
                driven: true,
                severity: "info".into(),
            },
            FacetRow {
                component: "NeverDriven".into(),
                in_registry: true,
                has_local_ctor: false,
                has_remote_ctor: false,
                caps: vec![],
                driven: false,
                severity: "info".into(),
            },
        ];
        let covered = covered_facetts(&rows);
        // The covered keys must be EXACTLY the surface-node keys the enumerator emits
        // for the driven, registered rows.
        let nodes = facett_components(&rows);
        let driven_ids = ["facett-helix", "WarehouseView"];
        let want: BTreeSet<String> = nodes
            .iter()
            .filter(|n| driven_ids.contains(&n.id.as_str()))
            .map(|n| n.key_str())
            .collect();
        assert_eq!(covered, want, "covered keys match the driven rows' node keys");
        // helix (NA) → 1 key, WarehouseView (fat+thin) → 2 keys.
        assert_eq!(covered.len(), 3);
        assert!(covered.contains("facett_component:facett-helix@na"));
        assert!(covered.contains("facett_component:WarehouseView@fat"));
        assert!(covered.contains("facett_component:WarehouseView@thin"));
        // Nothing for the unregistered or undriven rows.
        assert!(!covered.iter().any(|k| k.contains("NotRegistered")));
        assert!(!covered.iter().any(|k| k.contains("NeverDriven")));
    }

    #[test]
    fn a_red_pane_severity_error_is_not_covered_and_fails_the_gate() {
        // Two registered, driven components. One reported Severity::Error (a RED
        // pane) — the STRUCTURAL error signal (decision (a)). It must NOT count as
        // covered, so its surface node stays MISSING and the HARD gate goes RED —
        // even though it "drove".
        let rows = vec![
            FacetRow {
                component: "GreenPane".into(),
                in_registry: true,
                has_local_ctor: false,
                has_remote_ctor: false,
                caps: vec![],
                driven: true,
                severity: "info".into(),
            },
            FacetRow {
                component: "RedPane".into(),
                in_registry: true,
                has_local_ctor: false,
                has_remote_ctor: false,
                caps: vec![],
                driven: true, // it DID drive …
                severity: "error".into(), // … but it rendered a RED pane.
            },
        ];
        let covered = covered_facetts(&rows);
        assert!(covered.contains("facett_component:GreenPane@na"), "green pane covered");
        assert!(
            !covered.contains("facett_component:RedPane@na"),
            "a driven-but-RED (Severity::Error) pane is NOT covered — this is the gate signal",
        );

        // Wire it through the full gate: the red pane is the MISSING surface.
        let surface = {
            let mut s = Surface::new();
            s.extend(facett_components(&rows));
            s
        };
        let gap = compute_gap(&surface, &covered, &BTreeSet::new());
        assert!(!gap.is_clean(), "the gate is RED while a red pane is uncovered");
        assert_eq!(gap.missing.len(), 1);
        assert_eq!(gap.missing[0].id, "RedPane");

        // A `warning` severity still counts as covered (it does not fail the gate).
        let mut warn = rows.clone();
        warn[1].severity = "warning".into();
        assert!(
            covered_facetts(&warn).contains("facett_component:RedPane@na"),
            "a Warning pane is degraded-but-covered — only Error fails the gate",
        );
    }

    #[test]
    fn cross_check_flags_tabs_without_facets_and_facets_without_tabs() {
        // decision (b): the registry is authoritative, the tab roster is the cross-check.
        let facets = ["map", "graph", "timeline", "orphan_facet"];
        let tabs = ["map", "graph", "timeline", "ghost_tab"];
        let drift = cross_check_tabs(facets, tabs);
        assert!(!drift.is_clean());
        assert_eq!(drift.tabs_without_facet, vec!["ghost_tab".to_string()], "a tab with no Facet");
        assert_eq!(
            drift.facets_without_tab,
            vec!["orphan_facet".to_string()],
            "a Facet no tab reaches",
        );

        // Perfect agreement → clean.
        let ok = cross_check_tabs(["map", "graph"], ["graph", "map"]);
        assert!(ok.is_clean(), "registry == tab roster is clean drift");
    }

    #[test]
    fn severity_is_error_is_lenient() {
        assert!(severity_is_error("error"));
        assert!(severity_is_error("ERROR"));
        assert!(severity_is_error(" red "));
        assert!(!severity_is_error("info"));
        assert!(!severity_is_error("warning"));
        assert!(!severity_is_error(""));
    }

    #[test]
    fn facett_row_severity_defaults_to_info_on_deserialize() {
        // A row from an OLD schema (no `severity` column) deserializes as green —
        // the field is purely additive (serde default).
        let row: FacetRow = serde_json::from_str(
            r#"{"component":"Legacy","in_registry":true,"driven":true}"#,
        )
        .unwrap();
        assert_eq!(row.severity, "info", "absent severity defaults to the green floor");
        assert!(covered_facetts(&[row]).contains("facett_component:Legacy@na"));
    }

    #[test]
    fn facett_intersects_registry_and_splits_thin_fat() {
        let rows = vec![
            // A data component with both ctors → a Fat AND a Thin node.
            FacetRow {
                component: "WarehouseView".into(),
                in_registry: true,
                has_local_ctor: true,
                has_remote_ctor: true,
                caps: vec!["reads_warehouse".into(), "interactive".into()],
                driven: false,
                severity: "info".into(),
            },
            // Registered but stateless (no ctors) → a single NA node.
            FacetRow {
                component: "AboutPanel".into(),
                in_registry: true,
                has_local_ctor: false,
                has_remote_ctor: false,
                caps: vec![],
                driven: false,
                severity: "info".into(),
            },
            // impl Facet but NOT in registry() → dropped (the intersection).
            FacetRow {
                component: "ScratchView".into(),
                in_registry: false,
                has_local_ctor: true,
                has_remote_ctor: true,
                caps: vec![],
                driven: false,
                severity: "info".into(),
            },
        ];
        let nodes = facett_components(&rows);
        // WarehouseView → 2 nodes (fat+thin); AboutPanel → 1 (na); ScratchView → 0.
        assert_eq!(nodes.len(), 3, "registry intersection + thin/fat split");

        let wh: Vec<_> = nodes.iter().filter(|n| n.id == "WarehouseView").collect();
        assert_eq!(wh.len(), 2, "data component yields a fat AND a thin node");
        let modes: BTreeSet<_> = wh.iter().map(|n| n.mode).collect();
        assert!(modes.contains(&Mode::Fat) && modes.contains(&Mode::Thin));
        assert_eq!(caps_of(wh[0]), vec!["interactive", "reads_warehouse"]);

        assert!(
            nodes.iter().any(|n| n.id == "AboutPanel" && n.mode == Mode::NA),
            "stateless registered component is a single NA node"
        );
        assert!(
            !nodes.iter().any(|n| n.id == "ScratchView"),
            "a Facet impl absent from registry() is NOT a surface"
        );
    }

    // ── viz tabs × {thin,fat} ───────────────────────────────────────────────

    #[test]
    fn viz_tabs_yield_thin_and_fat_each() {
        let nodes = viz_tabs(["Search", "Test", "Bench"]);
        assert_eq!(nodes.len(), 6, "3 tabs × 2 modes");
        let test_thin = nodes
            .iter()
            .find(|n| n.id == "Test" && n.mode == Mode::Thin)
            .expect("Test tab has a thin node — the bug class autonom kills");
        assert_eq!(test_thin.kind, SurfaceKind::VizTab);
        assert_eq!(test_thin.key_str(), "viz_tab:Test@thin");
    }

    // ── cli + mcp from provided lists ───────────────────────────────────────

    #[test]
    fn cli_and_mcp_model_provided_lists() {
        let cli = cli_commands(["test", "bench", "viz"]);
        assert_eq!(cli.len(), 3);
        assert!(cli.iter().all(|n| n.kind == SurfaceKind::CliCommand && n.mode == Mode::NA));

        let mcp = mcp_tools(["search", "build_order", "viz_state"]);
        assert_eq!(mcp.len(), 3);
        assert_eq!(mcp[1].key_str(), "mcp_tool:build_order@na");
    }

    #[test]
    fn surface_kind_layer_groups_ui_grpc_cli_mcp_core() {
        // AUT8-GAP-LAYER: layer is DERIVED from kind (no stored column).
        assert_eq!(SurfaceKind::VizTab.layer(), "ui");
        assert_eq!(SurfaceKind::FacettComponent.layer(), "ui");
        assert_eq!(SurfaceKind::Grpc.layer(), "grpc");
        assert_eq!(SurfaceKind::CliCommand.layer(), "cli");
        assert_eq!(SurfaceKind::McpTool.layer(), "mcp");
        assert_eq!(SurfaceKind::Function.layer(), "core");
    }

    #[test]
    fn grpc_handlers_make_thin_nodes() {
        let g = grpc_handlers(["Viz.Architecture", "Bench.Submit"]);
        assert_eq!(g.len(), 2);
        // gRPC is the thin/server backend → every handler is a Thin-mode surface
        // that must be covered (no invisible RPC).
        assert!(g.iter().all(|n| n.kind == SurfaceKind::Grpc && n.mode == Mode::Thin));
        assert_eq!(SurfaceKind::Grpc.label(), "grpc");
        assert_eq!(g[0].key_str(), "grpc:Viz.Architecture@thin");
    }

    // ── function reachability (the pure graph op) ───────────────────────────

    #[test]
    fn unreached_fn_shows_in_gap_covered_one_does_not() {
        // test_a → helper_covered → deep_covered ; orphan_fn reached by nobody.
        let symbols = vec![
            SymbolRow { fqn: "test_a".into(), is_test: true, label: None },
            SymbolRow { fqn: "helper_covered".into(), is_test: false, label: None },
            SymbolRow { fqn: "deep_covered".into(), is_test: false, label: None },
            SymbolRow { fqn: "orphan_fn".into(), is_test: false, label: Some("orphan".into()) },
        ];
        let edges = vec![
            CallEdge { caller: "test_a".into(), callee: "helper_covered".into() },
            CallEdge { caller: "helper_covered".into(), callee: "deep_covered".into() },
        ];

        let reach = test_reachable(&symbols, &edges);
        assert!(reach.contains("helper_covered") && reach.contains("deep_covered"));
        assert!(!reach.contains("orphan_fn"));

        let unreached = unreached_functions(&symbols, &edges);
        assert_eq!(unreached.len(), 1, "only the orphan is unreached");
        assert_eq!(unreached[0].id, "orphan_fn");
        assert_eq!(unreached[0].label, "orphan", "label carried from symbol row");
        // The transitively-covered fn must NOT appear, and a test fn never does.
        assert!(!unreached.iter().any(|n| n.id == "deep_covered"));
        assert!(!unreached.iter().any(|n| n.id == "test_a"));
    }

    #[test]
    fn reachability_handles_cycles() {
        // a ↔ b cycle, both reached from a test; c is an unreached cycle.
        let symbols = vec![
            SymbolRow { fqn: "t".into(), is_test: true, label: None },
            SymbolRow { fqn: "a".into(), is_test: false, label: None },
            SymbolRow { fqn: "b".into(), is_test: false, label: None },
            SymbolRow { fqn: "c".into(), is_test: false, label: None },
        ];
        let edges = vec![
            CallEdge { caller: "t".into(), callee: "a".into() },
            CallEdge { caller: "a".into(), callee: "b".into() },
            CallEdge { caller: "b".into(), callee: "a".into() }, // cycle
            CallEdge { caller: "c".into(), callee: "c".into() }, // self-loop, unreached
        ];
        let unreached = unreached_functions(&symbols, &edges);
        let ids: BTreeSet<_> = unreached.iter().map(|n| n.id.clone()).collect();
        assert_eq!(ids, BTreeSet::from(["c".to_string()]), "cycle doesn't hang; c is the only gap");
    }

    // ── Gap = Surface − Covered − Allowlist ─────────────────────────────────

    #[test]
    fn compute_gap_subtracts_covered_and_allowlist() {
        let mut surface = Surface::new();
        surface
            .extend(viz_tabs(["Search", "Test"])) // 4 nodes
            .extend(mcp_tools(["search"])) // 1 node
            .extend(cli_commands(["doctor"])); // 1 node — the one we'll allowlist

        assert_eq!(surface.len(), 6);
        assert_eq!(surface.count_kind(SurfaceKind::VizTab), 4);

        // Covered: every viz node EXCEPT Test@thin, plus the mcp tool.
        let covered: BTreeSet<String> = [
            "viz_tab:Search@fat",
            "viz_tab:Search@thin",
            "viz_tab:Test@fat",
            "mcp_tool:search@na",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        // Allowlist the doctor CLI command (excused on purpose).
        let allowlist: BTreeSet<String> = ["cli_command:doctor@na".to_string()].into_iter().collect();

        let gap = compute_gap(&surface, &covered, &allowlist);
        assert_eq!(gap.covered, 4);
        assert_eq!(gap.total, 6);
        assert_eq!(gap.allowlisted.len(), 1);
        assert_eq!(gap.allowlisted[0].id, "doctor");
        // The one true gap: Test@thin — the RPC-TODO bug class, now caught.
        assert_eq!(gap.missing.len(), 1, "exactly one uncovered, un-allowlisted node");
        assert_eq!(gap.missing[0].key_str(), "viz_tab:Test@thin");
        assert!(!gap.is_clean(), "a missing surface makes the gate RED");
        assert!(gap.summary().contains("RED"));
    }

    #[test]
    fn empty_gap_is_green() {
        let mut surface = Surface::new();
        surface.extend(mcp_tools(["a", "b"]));
        let covered: BTreeSet<String> =
            ["mcp_tool:a@na", "mcp_tool:b@na"].iter().map(|s| s.to_string()).collect();
        let gap = compute_gap(&surface, &covered, &BTreeSet::new());
        assert!(gap.is_clean(), "every surface covered → Gap == ∅ → GREEN");
        assert_eq!(gap.covered, 2);
        assert_eq!(gap.missing.len(), 0);
        assert!(gap.summary().contains("GREEN"));
    }

    #[test]
    fn end_to_end_full_surface_round_trips_through_serde() {
        // Build a full surface from all five enumerators, then serialize the Gap
        // (the warehouse row shape) and read it back — proves the model is the
        // schema the gate persists.
        let facets = vec![FacetRow {
            component: "GraphView".into(),
            in_registry: true,
            has_local_ctor: true,
            has_remote_ctor: true,
            caps: vec!["reads_warehouse".into()],
            driven: false,
            severity: "info".into(),
        }];
        let symbols = vec![
            SymbolRow { fqn: "t".into(), is_test: true, label: None },
            SymbolRow { fqn: "wired".into(), is_test: false, label: None },
            SymbolRow { fqn: "dead".into(), is_test: false, label: None },
        ];
        let edges = vec![CallEdge { caller: "t".into(), callee: "wired".into() }];

        let mut surface = Surface::new();
        surface
            .extend(facett_components(&facets))
            .extend(viz_tabs(["Graph"]))
            .extend(cli_commands(["graph"]))
            .extend(mcp_tools(["dep_graph_mermaid"]))
            .extend(unreached_functions(&symbols, &edges));

        // facett: 2 (fat+thin) · viz: 2 · cli: 1 · mcp: 1 · fns: 1 (dead) = 7.
        assert_eq!(surface.len(), 7);
        assert!(surface
            .nodes
            .iter()
            .any(|n| n.kind == SurfaceKind::Function && n.id == "dead"));

        let covered = BTreeSet::new();
        let gap = compute_gap(&surface, &covered, &BTreeSet::new());
        assert_eq!(gap.missing.len(), 7, "nothing covered → all 7 are gaps");

        let json = serde_json::to_string(&gap).unwrap();
        let back: Gap = serde_json::from_str(&json).unwrap();
        assert_eq!(back, gap, "Gap round-trips through serde (the warehouse row)");
        // The dead fn's node survives the round trip with its kind tag.
        assert!(back
            .missing
            .iter()
            .any(|n| n.kind == SurfaceKind::Function && n.id == "dead"));
    }
}