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

nordisk/nornir

src/autonom/mod.rs

//! # autonom — the FEEDERS for the completeness gate (AUT2 / n-005)
//!
//! The pure enumerators + the gap/verdict model live in
//! [`nornir_testmatrix::discover`] + [`nornir_testmatrix::coverage`]. This module
//! is nornir's **iceberg-backed feeder layer**: it gathers the REAL inputs —
//! `symbol_facts` / `call_edges` from the warehouse, the viz tab enum, the clap
//! subcommands, the MCP `tools/list`, the facett-style registry — turns them
//! into the pure enumerators' row shapes, and assembles a [`Surface`] +
//! `covered` set so [`GateReport`] can be computed and persisted.
//!
//! ```text
//! gather_inputs (iceberg + clap + tab enum + tools/list)
//!   → discover::{viz_tabs, cli_commands, mcp_tools, facett_components, unreached_functions}
//!   → Surface  +  covered set (test-reachable closure over call_edges)
//!   → GateReport::compute(Surface, covered, allowlist)            (the verdict)
//!   → rows_for(...) → surface_coverage iceberg table              (persisted)
//! ```
//!
//! The allowlist (`autonom-allow.toml`, checked in) is SEEDED (`--seed-allowlist`)
//! with every currently-uncovered surface so the gate is GREEN now and burns
//! down by deletion. The gate FAILS if any surface is uncovered + un-allowlisted
//! OR an allowlist entry is stale (HARD zero, not a ratchet).

pub mod megagate;
pub mod ui_values;

use std::collections::BTreeSet;
use std::path::Path;

use anyhow::Result;

use crate::knowledge::query::KnowledgeView;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow as KSymbolRow};
use crate::warehouse::iceberg::IcebergWarehouse;
use std::collections::BTreeMap;

use crate::warehouse::surface_coverage::{
    Allowlist, CoverageRow, CoverageSelector, CoverageSummary, GateReport, Verdict,
    append_surface_coverage, latest_surface_coverage,
    query_surface_coverage, rows_for_with_utfallsrum, seed_allowlist,
};
use nornir_testmatrix::discover::{
    BencherRow, CallEdge, DiscoveredAtom, FacetRow, Surface, SymbolRow, benchers, cli_commands,
    covered_atoms, covered_benchers, covered_facetts, facett_components, grpc_handlers, mcp_tools,
    ui_atoms, unreached_functions, viz_tabs,
};
use nornir_testmatrix::utfallsrum::UtfallsrumSummary;

/// **The Elm-harness DRIVE seam** (`viz`-only): construct + drive each embedded
/// facett Elm component headless and produce driven [`FacetRow`]s. Lives in the
/// binary crate (behind `viz`, with the facett deps) so `nornir-testmatrix` stays
/// facett-free. See [`facett_probe::probe_facett_components`].
#[cfg(feature = "viz")]
pub mod facett_probe;

/// The driven facett [`FacetRow`]s for nornir's embedded components — the REAL
/// headless-harness drive when `viz` is on, an empty vec (skip) otherwise. This is
/// what fills the formerly-empty `facett` slot in [`nornir_surface_inputs`].
fn embedded_facett_rows() -> Vec<FacetRow> {
    #[cfg(feature = "viz")]
    {
        facett_probe::probe_facett_components()
    }
    #[cfg(not(feature = "viz"))]
    {
        Vec::new()
    }
}

/// The discrete-surface inputs the binary feeds in (it does the clap / tab-enum
/// / tools-list / registry introspection — the lib stays binary-free). Functions
/// are NOT in here: they're read from the warehouse knowledge map.
#[derive(Debug, Clone, Default)]
pub struct SurfaceInputs {
    /// Viz tab names (the `Tab` enum's debug names) — each yields a fat + thin node.
    pub viz_tabs: Vec<String>,
    /// CLI subcommand names (clap `Command::get_subcommands`).
    pub cli_commands: Vec<String>,
    /// MCP tool names (the registered `tools/list`).
    pub mcp_tools: Vec<String>,
    /// facett-style components (registry ∩ impl Facet). For nornir the viz tabs
    /// ARE the components; a leaf repo plugs its own. Often empty for nornir
    /// (the viz_tabs axis already covers the renderable surface).
    pub facett: Vec<FacetRow>,
    /// gRPC handler labels (`Service.verb`), derived from the knowledge map via
    /// [`crate::arch::grpc_handlers_from_symbols`] — each becomes a `Thin`-mode
    /// [`SurfaceKind::Grpc`](nornir_testmatrix::discover::SurfaceKind::Grpc) node so
    /// every RPC backend is a must-be-covered surface (not an invisible handler).
    pub grpc: Vec<String>,
    /// UI atoms discovered by the live atom-walk (`nornir_robotui::all_atoms`),
    /// one [`DiscoveredAtom`] per `(tab, label)`. The binary/test feeds these from
    /// the running viz; each becomes a `ui_atom` surface node (covered ⟺ its tab
    /// ran AND its state was clean). Empty for a gate run without a live walk.
    pub atoms: Vec<DiscoveredAtom>,
    /// Hot-paths that should carry a bencher (Task #39), one [`BencherRow`] per
    /// canonical hot-path (see [`BENCH_HOT_PATHS`]). `has_bencher` is filled from
    /// the `inventory` seam ([`nornir_bench::api::registered_bench_ids`]) — a
    /// hot-path with no registered bencher becomes an uncovered
    /// [`SurfaceKind::Bencher`](nornir_testmatrix::discover::SurfaceKind::Bencher)
    /// surface node, so a module that should be benched but isn't outs itself in
    /// the completeness gate (and burns down via the checked-in allowlist).
    pub benchers: Vec<BencherRow>,
}

/// nornir's canonical facett palette names (`facett_core::theme::Theme::names()`).
///
/// Lives HERE, not next to the themes, for the same reason [`VIZ_TABS`] does: the
/// palette type comes from `facett-core`, which depends on `egui` unconditionally,
/// so a headless build (`--no-default-features --features "server mcp"`) cannot
/// reach `Theme::names()` just to name-check a string. `nornir-mcp` used to carry
/// its own literal copy for exactly that reason — a defensible twin with an
/// indefensible ending, since nothing compared the two. The
/// `viz_palettes_const_matches_theme_names` test in the viz crate (which already
/// has egui) is the gate that was missing.
pub const VIZ_PALETTES: &[&str] = &[
    "default",
    "sci-fi",
    "nordic-aurora",
    "cyberpunk-neon",
    "amber-crt",
    "deep-space",
    "hugin-noir",
];

/// nornir's canonical viz tab names (the `viz::app::Tab` enum debug names).
/// The viz crate is feature-gated, so the gate references this stable list; the
/// `tabs_match_enum` test in the viz crate keeps it honest against `Tab::ALL`.
pub const VIZ_TABS: &[&str] = &[
    "Nornir",
    "Timeline",
    "DepGraph",
    "CallGraph",
    "Architecture",
    "Metro",
    "Helix",
    "Bloodstream",
    "Funnel",
    "TimeTravel",
    "LiveRun",
    "Release",
    "Knowledge",
    "Warehouse",
    "WarehouseDeck",
    "Mcp",
    "Search",
    "Gates",
    "Chat",
    "Security",
    "Dwarves",
    "Holger",
    "Manual",
    "Claude",
    "Models",
    // 🎛 Settings — the preferences pane and the ONE home of the viz's look & feel
    // (the app-wide facett palette picker, moved off the root top bar; matches
    // dwarves-demo's 🎛 `settings` tab).
    "Settings",
];

/// nornir's canonical MCP tool names (the registered `tools/list`). The MCP
/// matrix harness (`tests/mcp_matrix`) enforces tools/list completeness against
/// the LIVE server, so this list is the gate's join key, kept in step there.
pub const MCP_TOOLS: &[&str] = &[
    "affected_by_change",
    "bench_history",
    "build_order",
    "callees_of",
    "callers_of",
    "changed_since_last_release",
    "crate_published",
    "defined_in",
    "dep_graph_svg",
    "dep_path",
    "dependents_of",
    "deps_of",
    "docs_book",
    "docs_check",
    "docs_export",
    "docs_history",
    "docs_init",
    "docs_render",
    "dwarf_call_path",
    "dwarf_callees",
    "dwarf_callers",
    "dwarf_defined_in",
    "dwarf_symbol_lookup",
    "external_dep_users",
    "funnel_add_node",
    "funnel_create_plan",
    "funnel_link",
    "funnel_next",
    "funnel_show",
    "funnel_status",
    "funnel_submit_idea",
    "guard_apply",
    "guard_status",
    "guard_verify",
    "index_status",
    "knowledge_call_path",
    "knowledge_callees",
    "knowledge_callers",
    "knowledge_defined_in",
    "knowledge_symbol_lookup",
    "path_between",
    "regression_trace",
    "release_gate_all",
    "release_gate_coverage",
    "release_gate_docs_fresh",
    "release_gate_nexus_floor",
    "release_gate_no_regression",
    "release_gate_path_patches",
    "repo_overview",
    "repos_list",
    "search",
    "symbol_lookup",
    "sync_now",
    "test_coverage",
    "vector_search",
    "viz.click",
    "viz.state",
    "workspace_register",
    "workspace_use",
    "workspaces_list",
];

/// nornir's canonical CLI subcommand names (clap kebab-case of the `Cmd` enum).
/// The `nornir` binary OVERRIDES this with live `Cli::command()` introspection
/// (the true anti-drift source); this const is the fallback for callers without
/// the clap tree (e.g. the MCP server), and is asserted against the binary by a
/// functest.
pub const CLI_COMMANDS: &[&str] = &[
    "guard",
    "bench",
    "release",
    "test",
    "docs",
    "introspect",
    "warehouse",
    "index",
    "robot",
    "vector",
    "knowledge",
    "map",
    "funnel",
    "repos",
    "root",
    "serve",
    "viz",
    "install",
    "key",
    "workspace",
    "security",
    "mimir",
    "diagram",
    "bakeoff",
];

/// nornir's canonical **bencher hot-paths** (Task #39) — the perf-critical
/// module/crate keys that SHOULD carry a bencher, in the `<repo>.<scenario>`
/// namespace the bencher `id()`s use. A hot-path is *covered* iff a registered
/// bencher's id equals it or is prefixed by `"<key>_"` / `"<key>."` (so
/// `nornir.dep_graph` is satisfied by `nornir.dep_graph_build`). Every entry that
/// no linked bencher targets becomes an uncovered `Bencher` surface node — a
/// VISIBLE gap that burns down via the checked-in allowlist, never a silent pass.
///
/// Kept deliberately honest + small: these are nornir's real hot paths, not every
/// module. `dep_graph` already has a bencher (in `examples/nornir-bench.rs`); the
/// rest are the known-missing benchers this gate now surfaces.
pub const BENCH_HOT_PATHS: &[&str] = &[
    "nornir.dep_graph",       // dep-graph build (benched in examples/nornir-bench.rs)
    "nornir.vector_search",   // embedding vector search
    "nornir.warehouse_query", // iceberg warehouse read path
];

/// The bencher hot-path rows for the given canonical hot-paths, with `has_bencher`
/// resolved against `registered` (the `inventory`-swept bencher ids from
/// [`nornir_bench::api::registered_bench_ids`]). A hot-path `key` is covered by any
/// registered id that equals `key` or starts with `"<key>_"` / `"<key>."` — the
/// `<repo>.<scenario>` naming convention — and the matching ids ride along as
/// `bench_ids`. This is the pure mapping from the discovery seam to the fed rows.
pub fn bench_rows(hot_paths: &[&str], registered: &[String]) -> Vec<BencherRow> {
    hot_paths
        .iter()
        .map(|key| {
            let matches: Vec<String> = registered
                .iter()
                .filter(|id| {
                    id.as_str() == *key
                        || id.starts_with(&format!("{key}_"))
                        || id.starts_with(&format!("{key}."))
                })
                .cloned()
                .collect();
            BencherRow {
                module: (*key).to_string(),
                has_bencher: !matches.is_empty(),
                bench_ids: matches,
            }
        })
        .collect()
}

/// The bencher hot-path rows for nornir's canonical [`BENCH_HOT_PATHS`], with
/// coverage resolved against the LIVE `inventory` registry (the discovery seam).
/// Benchers registered only in `examples/nornir-bench.rs` are not linked into the
/// library/server, so from a gate run they read as not-yet-benched — a visible,
/// allowlist-able gap (never hidden).
fn nornir_bench_rows() -> Vec<BencherRow> {
    let registered = crate::bench::api::registered_bench_ids();
    bench_rows(BENCH_HOT_PATHS, &registered)
}

/// nornir's discrete-surface inputs from the canonical lists. The binary may
/// override `cli_commands` with live clap introspection (the lib stays
/// binary-free); an EMPTY `cli_commands` falls back to [`CLI_COMMANDS`]. The tab
/// + tool lists are the canonical nornir surface.
pub fn nornir_surface_inputs(cli_commands: Vec<String>) -> SurfaceInputs {
    let cli_commands = if cli_commands.is_empty() {
        CLI_COMMANDS.iter().map(|s| s.to_string()).collect()
    } else {
        cli_commands
    };
    SurfaceInputs {
        viz_tabs: VIZ_TABS.iter().map(|s| s.to_string()).collect(),
        cli_commands,
        mcp_tools: MCP_TOOLS.iter().map(|s| s.to_string()).collect(),
        // The six embedded facett Elm components, DRIVEN headless through the
        // `facett_core::harness` (behind `viz`); each becomes a surface node whose
        // coverage is the REAL drive verdict, not a handler-name match. Empty (skip)
        // in a non-`viz` build — the classic missing-tool skip.
        facett: embedded_facett_rows(),
        // gRPC handlers are repo-specific (read from the knowledge map) — filled
        // by `enumerate_surface`/`gather` from `grpc_labels_from_view`, not here.
        grpc: Vec::new(),
        // UI atoms come from a LIVE atom-walk (the viz crate / a test), not the
        // static lists — a gate run without a walk has none.
        atoms: Vec::new(),
        // Bencher hot-paths: the canonical BENCH_HOT_PATHS, each with its coverage
        // resolved against the LIVE inventory registry (the discovery seam).
        benchers: nornir_bench_rows(),
    }
}

/// The gathered, fully-differenced gate state for one workspace.
pub struct GateState {
    pub surface: Surface,
    pub covered: BTreeSet<String>,
    pub allowlist: Allowlist,
    pub report: GateReport,
    /// Per-surface **outcome-space coverage** (`surface_key → utfallsrum
    /// summary`) measured this run. Empty when no outcome-space rows were
    /// recorded (the threshold-off / legacy path). Drives the utfallsrum gate:
    /// a surface only counts as covered when it ran end-to-end AND its
    /// function(s) swept ≥`utfallsrum_threshold` outcome classes.
    pub utfallsrum: BTreeMap<String, UtfallsrumSummary>,
    /// The minimum outcome classes a surface must sweep to count as covered
    /// ([`DEFAULT_UTFALLSRUM_THRESHOLD`]); `0` disables the rule (legacy: ran ⟺
    /// covered).
    pub utfallsrum_threshold: usize,
}

impl GateState {
    pub fn summary(&self) -> String {
        self.report.summary()
    }
}

// ─── building the surface ────────────────────────────────────────────────

/// Map the warehouse knowledge view into the pure discover row shapes.
///
/// Reachability over the persisted `call_edges` is at **identifier
/// granularity** (the syn scan records callees as the last path segment, e.g.
/// `Arc::new` → `new`), so both the caller path and the symbol fqn are reduced
/// to their last segment for matching. A function is a **test root** iff it
/// lives in a test module (`module_path` contains `tests`) or its name is a
/// conventional test name (`test_*` / `it_*` / `prop_*`). This is intentionally
/// approximate — the pure reachability is exhaustively unit-tested; here we feed
/// it the best available facts.
pub fn knowledge_to_rows(view: &KnowledgeView) -> (Vec<SymbolRow>, Vec<CallEdge>) {
    let symbols: Vec<SymbolRow> = view
        .symbols
        .iter()
        .filter(|s| s.item_kind == "fn")
        .map(|s| {
            let fqn = last_seg(&s.item_name).to_string();
            SymbolRow {
                fqn,
                is_test: is_test_symbol(s),
                label: Some(format!("{}::{}", s.module_path, s.item_name)),
            }
        })
        .collect();
    let edges: Vec<CallEdge> = view
        .calls
        .iter()
        .map(|c: &CallEdgeRow| CallEdge {
            caller: last_seg(&c.caller_path).to_string(),
            callee: last_seg(&c.callee_ident).to_string(),
        })
        .collect();
    (symbols, edges)
}

fn last_seg(s: &str) -> &str {
    s.rsplit("::").next().unwrap_or(s)
}

/// A symbol is a test root iff it sits in a test module or has a test-y name.
fn is_test_symbol(s: &KSymbolRow) -> bool {
    let m = s.module_path.to_lowercase();
    let n = s.item_name.as_str();
    m.split("::").any(|seg| seg == "tests" || seg == "test")
        || n.starts_with("test_")
        || n.starts_with("it_")
        || n.starts_with("prop_")
}

/// Assemble the full [`Surface`] from the discrete inputs + the warehouse
/// knowledge map, and compute the `covered` set.
///
/// COVERED:
/// - **functions**: a fn is covered iff it's in the test-reachable closure over
///   `call_edges` (so the surface gap is exactly `unreached_functions`).
/// - **viz tabs / CLI / MCP / facett**: covered iff a test-reachable function's
///   identifier matches a wiring marker for that surface — pragmatically, we
///   treat these discrete surfaces as covered when a same-named handler fn is
///   test-reachable, else they fall to the allowlist (seeded). This keeps the
///   gate honest (a never-tested tab outs itself) while letting the allowlist
///   burn down as real interaction tests land.
pub fn build_surface_and_covered(
    inputs: &SurfaceInputs,
    symbols: &[SymbolRow],
    edges: &[CallEdge],
) -> (Surface, BTreeSet<String>) {
    let reachable = nornir_testmatrix::discover::test_reachable(symbols, edges);

    let mut surface = Surface::new();
    surface
        .extend(viz_tabs(inputs.viz_tabs.iter().cloned()))
        .extend(cli_commands(inputs.cli_commands.iter().cloned()))
        .extend(mcp_tools(inputs.mcp_tools.iter().cloned()))
        .extend(grpc_handlers(inputs.grpc.iter().cloned()))
        .extend(facett_components(&inputs.facett))
        .extend(ui_atoms(&inputs.atoms))
        .extend(benchers(&inputs.benchers))
        .extend(unreached_functions(symbols, edges));

    // Covered set:
    //  - every function NOT in `unreached_functions` is covered → its key.
    //  - a discrete surface (tab/cli/mcp/facett) is covered iff a test-reachable
    //    fn's identifier matches its id (case-insensitive last-segment match).
    let reachable_idents: BTreeSet<String> = reachable.iter().map(|s| s.to_lowercase()).collect();

    let mut covered: BTreeSet<String> = BTreeSet::new();
    for node in &surface.nodes {
        use nornir_testmatrix::discover::SurfaceKind::*;
        let is_covered = match node.kind {
            // A Function node only EXISTS when unreached → never covered here.
            Function => false,
            // gRPC: covered iff its VERB (the handler fn name, the `Service.verb`
            // last segment) is test-reachable — the RPC handler has a test.
            Grpc => {
                let verb = node
                    .id
                    .rsplit('.')
                    .next()
                    .unwrap_or(&node.id)
                    .to_lowercase();
                reachable_idents.contains(&verb)
            }
            // Discrete surfaces: covered iff a same-named test-reachable handler.
            VizTab | CliCommand | McpTool => reachable_idents.contains(&node.id.to_lowercase()),
            // facett components have their OWN verdict — the REAL headless-harness
            // drive (`covered_facetts` over the fed `FacetRow::driven`), added below,
            // NOT the handler-name rule. A never-driven component stays uncovered.
            FacettComponent => false,
            // UI atoms have their OWN verdict (the live atom-walk's ran-and-clean
            // rule), added below via `covered_atoms` — not the handler-name rule.
            UiAtom => false,
            // Benchers have their OWN verdict — the `inventory`-seam discovery
            // (`covered_benchers` over the fed `BencherRow::has_bencher`), added
            // below, NOT the handler-name rule. A hot-path with no registered
            // bencher stays uncovered.
            Bencher => false,
        };
        if is_covered {
            covered.insert(node.key_str());
        }
    }
    // UI atoms: covered ⟺ the atom\'s tab RAN and its state was CLEAN (no error
    // atom). This is the live atom-walk\'s verdict, computed by the pure
    // `covered_atoms` over the fed `DiscoveredAtom`s — so a `ui_atom` surface node
    // the gate counts is only green when the viz actually rendered it error-free.
    covered.extend(covered_atoms(&inputs.atoms));
    // facett components: covered ⟺ the component was actually DRIVEN clean through
    // the `facett_core::harness` (its `driven` flag), NOT a static handler-name
    // match. This REPLACES the name-match verdict for `FacettComponent` nodes — the
    // peer of the atom-walk's ran-and-clean rule above.
    covered.extend(covered_facetts(&inputs.facett));
    // Benchers: covered ⟺ a registered bencher (found via the `inventory` seam)
    // targets the hot-path (its `has_bencher` flag), computed by the pure
    // `covered_benchers` over the fed `BencherRow`s. A hot-path with no bencher
    // stays an uncovered `Bencher` surface node — the "no bencher" gap Task #39
    // makes enforceable, burned down via the checked-in allowlist.
    covered.extend(covered_benchers(&inputs.benchers));
    // Functions: the gap is exactly `unreached_functions`, so covered fns are the
    // reachable ones — but they aren't surface nodes (only unreached fns are), so
    // they don't need a covered key. The `total` is over surface nodes only.
    (surface, covered)
}

/// Enumerate the FULL nornir surface in one call (plan node n-000's unified
/// entry point). Assembles the discrete-surface [`SurfaceInputs`] from the
/// canonical [`VIZ_TABS`] / [`MCP_TOOLS`] / [`CLI_COMMANDS`] lists (via
/// [`nornir_surface_inputs`]), maps the warehouse `view` into discover rows
/// (via [`knowledge_to_rows`]), and runs [`build_surface_and_covered`] —
/// returning the discovered [`Surface`]. The caller no longer hand-assembles
/// `SurfaceInputs`.
///
/// `cli_commands` lets the binary inject live clap introspection; pass an EMPTY
/// vec to fall back to the canonical [`CLI_COMMANDS`]. `view` is the warehouse
/// knowledge map — function nodes are read from it (the lib stays binary-free,
/// so the warehouse read itself stays at the caller, e.g. [`gather`]'s
/// `load_latest`). For just the surface without coverage, this is the one call.
pub fn enumerate_surface(view: &KnowledgeView, cli_commands: Vec<String>) -> Surface {
    let mut inputs = nornir_surface_inputs(cli_commands);
    inputs.grpc = grpc_labels_from_view(view);
    let (symbols, edges) = knowledge_to_rows(view);
    let (surface, _covered) = build_surface_and_covered(&inputs, &symbols, &edges);
    surface
}

/// Like [`enumerate_surface`] but ALSO folds in the live atom-walk's discovered
/// [`DiscoveredAtom`]s, so the returned [`Surface`] carries one `ui_atom` node per
/// rendered atom alongside the tabs/CLI/MCP/gRPC/function surface. This is the
/// entry the S3 atom-coverage path uses: the binary/test runs the walk, hands the
/// atoms here, and the completeness gate then counts atoms as surface nodes.
pub fn enumerate_surface_with_atoms(
    view: &KnowledgeView,
    cli_commands: Vec<String>,
    atoms: Vec<DiscoveredAtom>,
) -> Surface {
    let mut inputs = nornir_surface_inputs(cli_commands);
    inputs.grpc = grpc_labels_from_view(view);
    inputs.atoms = atoms;
    let (symbols, edges) = knowledge_to_rows(view);
    let (surface, _covered) = build_surface_and_covered(&inputs, &symbols, &edges);
    surface
}

/// **S3 atom-coverage PRODUCER** — turn a batch of live atom-walk results into
/// `surface_coverage` [`CoverageRow`]s the gate reads, WITHOUT needing the
/// warehouse knowledge map. One row per discovered atom: `kind = "ui_atom"`,
/// `id = "<tab>/<label>"`, `surface_key = "ui_atom:<tab>/<label>@na"` (the
/// [`SurfaceNode::key_str`](nornir_testmatrix::discover::SurfaceNode) convention),
/// `verdict = covered` iff the atom\'s tab ran AND its state was clean (no error
/// atom), else `missing`. Append these via
/// [`append_surface_coverage`](crate::warehouse::surface_coverage::append_surface_coverage)
/// and `autonom`/the gate counts them like any other surface node (same table,
/// same `CoverageSummary::from_rows` roll-up).
///
/// Deduped by `(tab, label)` so a label that maps to several galley nodes is one
/// row. The role is recorded in the row\'s `reason` (`"role:Button"`) since a
/// `ui_atom` carries no allowlist reason — keeping the warehouse row self-describing.
pub fn atom_coverage_rows(
    run_id: &str,
    workspace: &str,
    atoms: &[DiscoveredAtom],
    ts_micros: i64,
) -> Vec<CoverageRow> {
    let mut surface = Surface::new();
    surface.extend(ui_atoms(atoms));
    let covered = covered_atoms(atoms);
    // Map role onto each node so we can stamp it into the row reason.
    let role_for = |id: &str| -> String {
        atoms
            .iter()
            .find(|a| format!("{}/{}", a.tab, a.label) == id)
            .map(|a| {
                if a.role.is_empty() {
                    String::new()
                } else {
                    format!("role:{}", a.role)
                }
            })
            .unwrap_or_default()
    };
    let mut rows: Vec<CoverageRow> = surface
        .nodes
        .iter()
        .map(|node| {
            let key = node.key_str();
            let (verdict, reason) = if covered.contains(&key) {
                (Verdict::Covered, role_for(&node.id))
            } else {
                (Verdict::Missing, role_for(&node.id))
            };
            CoverageRow::from_node(run_id, workspace, node, verdict, &reason, ts_micros)
        })
        .collect();
    rows.sort_by(|a, b| a.surface_key.cmp(&b.surface_key));
    rows
}

/// gRPC handler labels (`Service.verb`) discovered in the knowledge map — the
/// values of [`crate::arch::grpc_handlers_from_symbols`], sorted + deduped. This
/// is autonom FINDING the already-marked gRPC handlers (a tonic handler impl IS
/// the mark); it never assigns the mark.
fn grpc_labels_from_view(view: &KnowledgeView) -> Vec<String> {
    let mut labels: Vec<String> = crate::arch::grpc_handlers_from_symbols(&view.symbols)
        .into_values()
        .collect();
    labels.sort();
    labels.dedup();
    labels
}

// ─── the gate (gather → compute → persist) ─────────────────────────────────

/// Gather the gate state for `workspace` against the checked-in `allowlist`,
/// from a door that reaches ONLY the warehouse. The surface is the code
/// room's, so this fails CLOSED (`no_room`); the doors that hold a room call
/// [`gather_in`].
pub fn gather(
    wh: &IcebergWarehouse,
    workspace: &str,
    repo: &str,
    inputs: &SurfaceInputs,
    allowlist: Allowlist,
    run_id: &str,
) -> Result<GateState> {
    gather_in(&crate::code_room::Reach { room: None, warehouse: Some(wh) }, workspace, repo, inputs, allowlist, run_id)
}

/// [`gather`] over the code room the door reaches.
pub fn gather_in(
    reach: &crate::code_room::Reach<'_>,
    workspace: &str,
    repo: &str,
    inputs: &SurfaceInputs,
    allowlist: Allowlist,
    run_id: &str,
) -> Result<GateState> {
    gather_with_utfallsrum_in(reach, workspace, repo, inputs, allowlist, run_id, BTreeMap::new(), 0)
}

/// Like [`gather`] but applies the **utfallsrum threshold** — see
/// [`gather_with_utfallsrum_in`]. A door with only the warehouse: fails closed.
#[allow(clippy::too_many_arguments)]
pub fn gather_with_utfallsrum(
    wh: &IcebergWarehouse,
    workspace: &str,
    repo: &str,
    inputs: &SurfaceInputs,
    allowlist: Allowlist,
    run_id: &str,
    utfallsrum: BTreeMap<String, UtfallsrumSummary>,
    utfallsrum_threshold: usize,
) -> Result<GateState> {
    gather_with_utfallsrum_in(
        &crate::code_room::Reach { room: None, warehouse: Some(wh) },
        workspace,
        repo,
        inputs,
        allowlist,
        run_id,
        utfallsrum,
        utfallsrum_threshold,
    )
}

/// The gate over the code room. The fn surface is the repo's NAMEABLE API
/// (effective visibility `direct` / `reexported`, from the rustc driver); the
/// roots are its `#[test]` fns; a fn is covered iff a test reaches it over
/// CALLS / TESTS — walked by the kept [`build_surface_and_covered`] at the
/// name granularity its discrete surfaces (tabs / CLI / MCP / gRPC handlers)
/// match on. gRPC handlers come from the repo's impl rows as before.
///
/// **Utfallsrum threshold**: a surface that ran end-to-end is only counted as
/// covered when its function(s) swept at least `utfallsrum_threshold` outcome
/// classes (`utfallsrum` maps `surface_key → the measured outcome-space
/// summary`). `0` is the legacy behaviour (ran ⟺ covered).
///
/// **Fails CLOSED** whenever the room cannot give the surface — the repo is
/// not consumed, the door has no room, the log carries no visibility: a
/// completeness gate over a surface it cannot read would report it
/// covered-by-absence. The error names the room's state.
#[allow(clippy::too_many_arguments)]
pub fn gather_with_utfallsrum_in(
    reach: &crate::code_room::Reach<'_>,
    workspace: &str,
    repo: &str,
    inputs: &SurfaceInputs,
    allowlist: Allowlist,
    run_id: &str,
    utfallsrum: BTreeMap<String, UtfallsrumSummary>,
    utfallsrum_threshold: usize,
) -> Result<GateState> {
    use crate::code_room::reader;
    let refuse = |what: &str, r: crate::code_room::Refusal| {
        anyhow::anyhow!(
            "autonom completeness gate (run {run_id}, workspace `{workspace}`, repo `{repo}`): the code room cannot give {what} — {r} [{}]. \
             Refusing (fail closed) rather than gating over a surface it cannot enumerate.",
            r.state
        )
    };
    let api = reader::api_surface(reach, repo).map_err(|r| refuse("the public API surface", r))?;
    let view = reader::knowledge_view(reach, repo).map_err(|r| refuse("the repo's gRPC handlers", r))?;
    // Enrich the discrete inputs with the gRPC handlers FOUND in this repo, so
    // every RPC is a counted surface (autonom finds the marks).
    let mut inputs = inputs.clone();
    inputs.grpc = grpc_labels_from_view(&view);
    let symbols: Vec<SymbolRow> = api.fns.iter().map(|f| SymbolRow { fqn: f.name.clone(), is_test: f.is_test, label: Some(f.path.clone()) }).collect();
    let edges: Vec<CallEdge> = api.calls.iter().map(|(caller, callee)| CallEdge { caller: caller.clone(), callee: callee.clone() }).collect();
    let (surface, ran) = build_surface_and_covered(&inputs, &symbols, &edges);
    // The covered set requires BOTH ran AND the utfallsrum threshold (≥K classes
    // swept). With threshold 0 / empty map this is exactly `ran` (legacy).
    let covered = nornir_testmatrix::coverage::covered_with_utfallsrum(&ran, &utfallsrum, utfallsrum_threshold);
    let report = GateReport::compute(run_id, workspace, &surface, &covered, &allowlist);
    Ok(GateState { surface, covered, allowlist, report, utfallsrum, utfallsrum_threshold })
}

/// Persist a gate run's full per-node coverage rows to `surface_coverage`.
pub fn persist(
    wh: &IcebergWarehouse,
    state: &GateState,
    run_id: &str,
    workspace: &str,
) -> Result<()> {
    let ts = chrono::Utc::now().timestamp_micros();
    let rows = rows_for_with_utfallsrum(
        run_id,
        workspace,
        &state.surface,
        &state.covered,
        &state.allowlist,
        &state.utfallsrum,
        ts,
    );
    wh.block_on(append_surface_coverage(wh, &rows))
}

/// Re-seed the allowlist with every currently-uncovered surface node (the
/// `--seed-allowlist` mode). Preserves existing reasons. Returns the new
/// allowlist for the caller to serialize to `autonom-allow.toml`.
pub fn reseed(state: &GateState) -> Allowlist {
    seed_allowlist(&state.surface, &state.covered, &state.allowlist)
}

/// Read the latest persisted gate verdict for `workspace` and roll it into a
/// [`CoverageSummary`] — what the viz Test pane + `test_coverage` tool show.
pub fn read_latest_summary(wh: &IcebergWarehouse, workspace: &str) -> Result<CoverageSummary> {
    let rows = wh.block_on(latest_surface_coverage(wh, workspace))?;
    Ok(CoverageSummary::from_rows(&rows))
}

/// Read all persisted rows for a run (for the CLI `--json` dump).
pub fn read_run(wh: &IcebergWarehouse, run_id: &str) -> Result<Vec<CoverageRow>> {
    wh.block_on(query_surface_coverage(
        wh,
        &CoverageSelector::Run(run_id.to_string()),
    ))
}

/// AUT5 release-blocking gate helper: gather the surface vs covered verdict for
/// `repo` (canonical nornir surface inputs + the repo's checked-in allowlist),
/// PERSIST the run to `surface_coverage`, and return the [`GateState`]. The
/// release pipeline (`release gate-all` / the server `gate_all` RPC) calls this
/// and then applies [`crate::release::gate::coverage_gate`] to the returned
/// `report` — RED iff `Surface − Covered ≠ ∅` (any uncovered+un-allowlisted
/// surface) OR a stale allowlist entry. One call so the CLI, the server RPC, and
/// the `release_gate_coverage` MCP tool share identical behaviour (CLI parity).
///
/// `cli_commands` lets the binary inject live clap introspection; pass EMPTY to
/// fall back to the canonical [`CLI_COMMANDS`]. `run_id` stamps the persisted run.
pub fn gate_coverage_for_repo(
    wh: &IcebergWarehouse,
    workspace: &str,
    repo: &str,
    repo_root: &Path,
    cli_commands: Vec<String>,
    run_id: &str,
) -> Result<GateState> {
    gate_coverage_for_repo_in(&crate::code_room::Reach { room: None, warehouse: Some(wh) }, workspace, repo, repo_root, cli_commands, run_id)
}

/// [`gate_coverage_for_repo`] over the code room the door reaches (the
/// verdict is persisted to the warehouse the same door reaches).
pub fn gate_coverage_for_repo_in(
    reach: &crate::code_room::Reach<'_>,
    workspace: &str,
    repo: &str,
    repo_root: &Path,
    cli_commands: Vec<String>,
    run_id: &str,
) -> Result<GateState> {
    let inputs = nornir_surface_inputs(cli_commands);
    let allowlist = load_allowlist(&allowlist_path(repo_root))?;
    let state = gather_in(reach, workspace, repo, &inputs, allowlist, run_id)?;
    let wh = reach.warehouse.ok_or_else(|| anyhow::anyhow!("autonom completeness gate: this door reaches no warehouse to persist the verdict to"))?;
    persist(wh, &state, run_id, workspace)?;
    Ok(state)
}

// ─── allowlist file I/O ─────────────────────────────────────────────────────
//
// The file model now lives in the shared `autonom` crate (edda) so robotui and
// dwarves can read/write an allowlist without nornir. These are thin re-exports
// that keep every existing `crate::autonom::{allowlist_path, load_allowlist,
// save_allowlist}` call site working verbatim.

pub use edda_autonom::allowlist::{allowlist_path, load_allowlist, save_allowlist};

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

    fn sym(name: &str, module: &str, is_test_mod: bool) -> SymbolRow {
        // Build via the knowledge mapping so is_test detection is exercised.
        let krow = KSymbolRow {
            crate_name: "nornir".into(),
            module_path: if is_test_mod {
                format!("{module}::tests")
            } else {
                module.into()
            },
            item_kind: "fn".into(),
            item_name: name.into(),
            visibility: "pub".into(),
            file: "src/x.rs".into(),
            line: 1,
            doc_lines: 0,
            signature: None,
        };
        let view = KnowledgeView::new(vec![krow], vec![]);
        knowledge_to_rows(&view).0.pop().unwrap()
    }

    #[test]
    fn knowledge_mapping_detects_test_roots_and_idents() {
        let view = KnowledgeView::new(
            vec![
                KSymbolRow {
                    crate_name: "nornir".into(),
                    module_path: "nornir::foo::tests".into(),
                    item_kind: "fn".into(),
                    item_name: "test_it".into(),
                    visibility: "".into(),
                    file: "f.rs".into(),
                    line: 1,
                    doc_lines: 0,
                    signature: None,
                },
                KSymbolRow {
                    crate_name: "nornir".into(),
                    module_path: "nornir::foo".into(),
                    item_kind: "fn".into(),
                    item_name: "render".into(),
                    visibility: "pub".into(),
                    file: "f.rs".into(),
                    line: 2,
                    doc_lines: 0,
                    signature: None,
                },
                // a non-fn item is dropped.
                KSymbolRow {
                    crate_name: "nornir".into(),
                    module_path: "nornir::foo".into(),
                    item_kind: "struct".into(),
                    item_name: "Thing".into(),
                    visibility: "pub".into(),
                    file: "f.rs".into(),
                    line: 3,
                    doc_lines: 0,
                    signature: None,
                },
            ],
            vec![CallEdgeRow {
                crate_name: "nornir".into(),
                caller_path: "nornir::foo::tests::test_it".into(),
                callee_ident: "render".into(),
                call_kind: "call".into(),
                file: "f.rs".into(),
                line: 1,
            }],
        );
        let (symbols, edges) = knowledge_to_rows(&view);
        assert_eq!(symbols.len(), 2, "only fn items, struct dropped");
        let test_it = symbols.iter().find(|s| s.fqn == "test_it").unwrap();
        assert!(test_it.is_test, "test_ name in tests module is a root");
        let render = symbols.iter().find(|s| s.fqn == "render").unwrap();
        assert!(!render.is_test);
        assert_eq!(edges.len(), 1);
        assert_eq!(edges[0].caller, "test_it");
        assert_eq!(edges[0].callee, "render", "callee reduced to last segment");
    }

    #[test]
    fn unreached_fn_is_a_gap_reached_fn_is_not() {
        // test_it → render (reached) ; orphan (unreached).
        let symbols = vec![
            sym("test_it", "nornir::foo", true),
            sym("render", "nornir::foo", false),
            sym("orphan", "nornir::foo", false),
        ];
        let edges = vec![CallEdge {
            caller: "test_it".into(),
            callee: "render".into(),
        }];
        let inputs = SurfaceInputs::default();
        let (surface, covered) = build_surface_and_covered(&inputs, &symbols, &edges);

        // Only the orphan fn is a surface node (unreached); render is reached.
        let fn_nodes: Vec<_> = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::Function)
            .collect();
        assert_eq!(fn_nodes.len(), 1, "only the unreached fn is surface");
        assert_eq!(fn_nodes[0].id, "orphan");
        // The orphan is NOT in covered → it's the gap.
        assert!(!covered.contains(&fn_nodes[0].key_str()));

        let report = GateReport::compute("r", "ws", &surface, &covered, &Allowlist::new());
        assert!(!report.is_green(), "an unreached fn makes the gate RED");
        assert_eq!(report.gap.missing.len(), 1);
        assert_eq!(report.gap.missing[0].id, "orphan");
    }

    /// Task #39: `bench_rows` maps the canonical hot-paths against the LIVE
    /// inventory registry — a hot-path with a matching registered bencher id
    /// (equal, or `<key>_`/`<key>.` prefixed) is covered; one with none is not.
    #[test]
    fn bench_rows_resolve_coverage_from_registered_ids() {
        let registered = vec![
            "nornir.dep_graph_build".to_string(), // covers "nornir.dep_graph"
            "nornir.dep_graph_build_ext".to_string(), // also covers it
        ];
        let rows = bench_rows(BENCH_HOT_PATHS, &registered);
        assert_eq!(rows.len(), BENCH_HOT_PATHS.len());
        let dg = rows
            .iter()
            .find(|r| r.module == "nornir.dep_graph")
            .unwrap();
        assert!(
            dg.has_bencher,
            "dep_graph_build satisfies the dep_graph hot-path"
        );
        assert_eq!(dg.bench_ids.len(), 2, "both dep_graph benchers matched");
        let vs = rows
            .iter()
            .find(|r| r.module == "nornir.vector_search")
            .unwrap();
        assert!(
            !vs.has_bencher,
            "no registered bencher targets vector_search"
        );
    }

    /// Task #39: a hot-path lacking a bencher makes the bencher-coverage gate row
    /// RED (an uncovered `Bencher` surface node); allowlisting it flips green —
    /// the SAME honest burn-down mechanism the other surfaces use.
    #[test]
    fn missing_bencher_reds_gate_allowlist_excuses_it() {
        let inputs = SurfaceInputs {
            benchers: vec![
                BencherRow {
                    module: "nornir.dep_graph".into(),
                    has_bencher: true,
                    bench_ids: vec!["nornir.dep_graph_build".into()],
                },
                BencherRow {
                    module: "nornir.vector_search".into(),
                    has_bencher: false,
                    bench_ids: vec![],
                },
            ],
            ..Default::default()
        };
        let (surface, covered) = build_surface_and_covered(&inputs, &[], &[]);
        // Two Bencher surface nodes; the benched one is covered, the other is not.
        assert_eq!(surface.count_kind(SurfaceKind::Bencher), 2);
        assert!(covered.contains("bencher:nornir.dep_graph@na"));
        assert!(!covered.contains("bencher:nornir.vector_search@na"));

        // No allowlist → the un-benched hot-path is the lone gap → gate RED.
        let report = GateReport::compute("r", "ws", &surface, &covered, &Allowlist::new());
        assert!(
            !report.is_green(),
            "a hot-path with no bencher reds the gate"
        );
        assert_eq!(report.gap.missing.len(), 1);
        assert_eq!(
            report.gap.missing[0].key_str(),
            "bencher:nornir.vector_search@na"
        );
        assert_eq!(report.gap.missing[0].kind, SurfaceKind::Bencher);

        // Allowlist the known-missing hot-path → GREEN (visible burn-down excuse).
        let allow = Allowlist {
            entries: vec![crate::warehouse::surface_coverage::AllowEntry {
                key: "bencher:nornir.vector_search@na".into(),
                reason: "TODO(#39): wire a bencher for the vector-search hot-path".into(),
            }],
        };
        let report2 = GateReport::compute("r", "ws", &surface, &covered, &allow);
        assert!(
            report2.is_green(),
            "an allowlisted hot-path passes the gate row"
        );
        assert_eq!(report2.gap.allowlisted.len(), 1);
    }

    /// S3: the atom-walk's discovered atoms become `ui_atom` surface nodes that
    /// the completeness gate COUNTS, and the producer's verdict (covered <=> a
    /// clean tab, missing <=> a dirty tab) round-trips into the rows. Proves
    /// `build_surface_and_covered` + `atom_coverage_rows` agree.
    #[test]
    fn ui_atoms_become_counted_surface_nodes_with_clean_dirty_verdicts() {
        let atoms = vec![
            DiscoveredAtom {
                tab: "test".into(),
                label: "Run full matrix".into(),
                role: "Button".into(),
                ran: true,
                clean: true, // clean tab -> covered
            },
            DiscoveredAtom {
                tab: "nornir".into(),
                label: "is not served".into(),
                role: "Label".into(),
                ran: true,
                clean: false, // dirty tab -> missing
            },
        ];
        let inputs = SurfaceInputs {
            viz_tabs: vec![],
            cli_commands: vec![],
            mcp_tools: vec![],
            facett: vec![],
            grpc: vec![],
            atoms: atoms.clone(),
            benchers: vec![],
        };
        let (surface, covered) = build_surface_and_covered(&inputs, &[], &[]);
        // Two ui_atom nodes are in the discovered surface (the gate counts them).
        let ui_nodes: Vec<_> = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::UiAtom)
            .collect();
        assert_eq!(ui_nodes.len(), 2, "both atoms are counted surface nodes");
        // The clean-tab atom is covered; the dirty-tab atom is NOT.
        assert!(
            covered.contains("ui_atom:test/Run full matrix@na"),
            "clean atom covered"
        );
        assert!(
            !covered.contains("ui_atom:nornir/is not served@na"),
            "dirty (error) tab atom is not covered",
        );

        // The producer agrees: a covered + a missing row, with the role in reason.
        let rows = atom_coverage_rows("r-atom", "nornir", &atoms, 7);
        assert_eq!(rows.len(), 2);
        let run = rows
            .iter()
            .find(|r| r.id == "test/Run full matrix")
            .unwrap();
        assert_eq!(run.verdict(), Verdict::Covered);
        assert_eq!(run.reason, "role:Button", "role recorded in the row reason");
        let dirty = rows
            .iter()
            .find(|r| r.id == "nornir/is not served")
            .unwrap();
        assert_eq!(dirty.verdict(), Verdict::Missing);
    }

    #[test]
    fn discrete_surface_covered_when_handler_reachable_else_seed_to_green() {
        // A test reaches `test_tab` (the Test viz tab handler) but nothing reaches
        // the `bench_tab` handler. The Test tab is covered; Bench is a gap until
        // seeded.
        let symbols = vec![
            sym("test_root", "nornir::viz", true),
            sym("Test", "nornir::viz", false),
        ];
        let edges = vec![CallEdge {
            caller: "test_root".into(),
            callee: "Test".into(),
        }];
        let inputs = SurfaceInputs {
            viz_tabs: vec!["Test".into(), "Bench".into()],
            cli_commands: vec!["coverage".into()],
            mcp_tools: vec!["test_coverage".into()],
            facett: vec![],
            grpc: vec![],
            atoms: vec![],
            benchers: vec![],
        };
        let (surface, covered) = build_surface_and_covered(&inputs, &symbols, &edges);

        // Test@fat + Test@thin are covered (handler `Test` is reachable).
        assert!(covered.contains("viz_tab:Test@fat"));
        assert!(covered.contains("viz_tab:Test@thin"));
        // Bench is NOT covered (no reachable handler).
        assert!(!covered.contains("viz_tab:Bench@fat"));

        let report = GateReport::compute("r", "ws", &surface, &covered, &Allowlist::new());
        assert!(
            !report.is_green(),
            "Bench/cli/mcp are uncovered → RED before seeding"
        );

        // SEED the allowlist → every uncovered surface excused → GREEN now.
        let seeded = seed_allowlist(&surface, &covered, &Allowlist::new());
        let report2 = GateReport::compute("r", "ws", &surface, &covered, &seeded);
        assert!(
            report2.is_green(),
            "seeded allowlist makes the gate green now"
        );
        // The covered Test tab is NOT seeded (no excuse needed).
        assert!(!seeded.entries.iter().any(|e| e.key == "viz_tab:Test@fat"));
        // Bench/cli/mcp ARE seeded.
        assert!(seeded.entries.iter().any(|e| e.key == "viz_tab:Bench@fat"));
        assert!(
            seeded
                .entries
                .iter()
                .any(|e| e.key == "cli_command:coverage@na")
        );
        assert!(
            seeded
                .entries
                .iter()
                .any(|e| e.key == "mcp_tool:test_coverage@na")
        );
    }

    /// A door that reaches no code room FAILS CLOSED: the surface is the
    /// room's, and an unreadable surface must never read as "everything covered".
    #[test]
    fn the_gate_fails_closed_when_the_door_has_no_code_room() {
        let dir = tempfile::tempdir().unwrap();
        let wh = IcebergWarehouse::open(dir.path()).unwrap();
        let err = gather(&wh, "ws", "myrepo", &SurfaceInputs::default(), Allowlist::new(), "run-1")
            .err()
            .expect("the gate must refuse, not pass");
        let msg = format!("{err:#}");
        assert!(msg.contains("[no_room]") && msg.contains("fail closed") && msg.contains("myrepo"), "{msg}");
    }

    /// The gate over a consumed repo: the surface is its nameable API, the
    /// roots its tests. `checkout` is public and no test reaches it; `charge`
    /// is `pub` in a private module (not API) and never counted.
    #[cfg(feature = "code-room")]
    #[test]
    fn the_gate_reads_the_public_api_and_the_tests_from_the_code_room() {
        use crate::code_room::{Reach, reader::fixture};
        let reach = Reach::room_only(&fixture::shop().handle);
        let state = gather_in(&reach, "ws", "shop", &SurfaceInputs::default(), Allowlist::new(), "run-1").unwrap();
        let fns: Vec<(&str, &str)> = state.surface.nodes.iter().filter(|n| n.kind == SurfaceKind::Function).map(|n| (n.id.as_str(), n.label.as_str())).collect();
        assert_eq!(fns, vec![("checkout", "shop::checkout")], "add / total / cart::open are public and test-reached; charge / audit are not API");
        assert!(state.covered.is_empty());
        assert!(crate::release::gate::coverage_gate(&state.report).is_err(), "an uncovered public fn is RED: {}", state.summary());
        // allow-listed with a reason, the same surface is green
        let mut allow = Allowlist::new();
        allow.entries.push(nornir_testmatrix::coverage::AllowEntry {
            key: state.surface.nodes[0].key_str(),
            reason: "entry point, exercised by the appliance test".into(),
        });
        let state = gather_in(&reach, "ws", "shop", &SurfaceInputs::default(), allow, "run-2").unwrap();
        assert!(crate::release::gate::coverage_gate(&state.report).is_ok(), "{}", state.summary());

        // a repo nothing was consumed of, and a workspace nothing was consumed into: closed, by state
        for (reach, repo) in [(reach, "nobody"), (Reach::room_only(&fixture::unconsumed().handle), "shop")] {
            let err = gather_in(&reach, "ws", repo, &SurfaceInputs::default(), Allowlist::new(), "run-3").err().expect("must refuse");
            let msg = format!("{err:#}");
            assert!(msg.contains("[not_consumed]") && msg.contains("fail closed"), "{msg}");
        }
    }

    #[test]
    fn grpc_handler_is_a_thin_surface_covered_when_verb_reachable() {
        use nornir_testmatrix::discover::{Mode, SurfaceKind};
        // A test reaches `submit` (the Bench.Submit handler verb); nothing reaches
        // `architecture`. So Bench.Submit is covered, Viz.Architecture is a gap —
        // an RPC with no test now OUTS itself as a red surface.
        let symbols = vec![
            sym("test_root", "nornir::server", true),
            sym("submit", "nornir::server", false),
        ];
        let edges = vec![CallEdge {
            caller: "test_root".into(),
            callee: "submit".into(),
        }];
        let inputs = SurfaceInputs {
            viz_tabs: vec![],
            cli_commands: vec![],
            mcp_tools: vec![],
            facett: vec![],
            grpc: vec!["Bench.Submit".into(), "Viz.Architecture".into()],
            atoms: vec![],
            benchers: vec![],
        };
        let (surface, covered) = build_surface_and_covered(&inputs, &symbols, &edges);
        // Both handlers are Thin-mode gRPC surface nodes (the UI/gRPC layer mark).
        let grpc: Vec<_> = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::Grpc)
            .collect();
        assert_eq!(grpc.len(), 2);
        assert!(grpc.iter().all(|n| n.mode == Mode::Thin));
        // Covered iff the verb (handler fn) is test-reachable.
        assert!(
            covered.contains("grpc:Bench.Submit@thin"),
            "verb `submit` is reached"
        );
        assert!(
            !covered.contains("grpc:Viz.Architecture@thin"),
            "verb `architecture` is not"
        );
    }

    #[test]
    fn enumerate_surface_yields_canonical_nornir_surface_in_one_call() {
        // Feed a real warehouse-shaped knowledge view (one test root reaching the
        // `Funnel` viz handler, plus an unreached `orphan` fn) and assert the
        // single-call entry point returns the FULL canonical surface.
        let view = KnowledgeView::new(
            vec![
                KSymbolRow {
                    crate_name: "nornir".into(),
                    module_path: "nornir::viz::tests".into(),
                    item_kind: "fn".into(),
                    item_name: "test_funnel".into(),
                    visibility: "".into(),
                    file: "f.rs".into(),
                    line: 1,
                    doc_lines: 0,
                    signature: None,
                },
                KSymbolRow {
                    crate_name: "nornir".into(),
                    module_path: "nornir::viz".into(),
                    item_kind: "fn".into(),
                    item_name: "Funnel".into(),
                    visibility: "pub".into(),
                    file: "f.rs".into(),
                    line: 2,
                    doc_lines: 0,
                    signature: None,
                },
                KSymbolRow {
                    crate_name: "nornir".into(),
                    module_path: "nornir::misc".into(),
                    item_kind: "fn".into(),
                    item_name: "orphan".into(),
                    visibility: "pub".into(),
                    file: "f.rs".into(),
                    line: 3,
                    doc_lines: 0,
                    signature: None,
                },
            ],
            vec![CallEdgeRow {
                crate_name: "nornir".into(),
                caller_path: "nornir::viz::tests::test_funnel".into(),
                callee_ident: "Funnel".into(),
                call_kind: "call".into(),
                file: "f.rs".into(),
                line: 1,
            }],
        );

        // ONE call — no hand-assembled SurfaceInputs; empty cli → canonical fallback.
        let surface = enumerate_surface(&view, Vec::new());

        // The canonical viz tabs are all present (assert known names explicitly).
        let viz_ids: BTreeSet<&str> = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::VizTab)
            .map(|n| n.id.as_str())
            .collect();
        assert!(viz_ids.contains("Funnel"), "Funnel tab must be enumerated");
        assert!(
            viz_ids.contains("Warehouse"),
            "Warehouse tab must be enumerated"
        );
        // Every canonical tab made it into the surface.
        for tab in VIZ_TABS {
            assert!(
                viz_ids.contains(tab),
                "canonical viz tab `{tab}` missing from surface"
            );
        }

        // MCP tools: count > 0 and equal to the canonical list size.
        let mcp_count = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::McpTool)
            .count();
        assert!(mcp_count > 0, "MCP tools must be enumerated");
        assert_eq!(
            mcp_count,
            MCP_TOOLS.len(),
            "all canonical MCP tools enumerated"
        );
        assert!(
            surface
                .nodes
                .iter()
                .any(|n| n.kind == SurfaceKind::McpTool && n.id == "funnel_show"),
            "a known MCP tool (funnel_show) must be present",
        );

        // CLI commands: non-empty, fell back to the canonical list.
        let cli_count = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::CliCommand)
            .count();
        assert!(cli_count > 0, "CLI commands must be enumerated");
        assert_eq!(cli_count, CLI_COMMANDS.len(), "canonical CLI fallback used");

        // The unreached `orphan` fn surfaced as a Function node; the reached
        // `Funnel` handler did NOT (only gaps are function nodes).
        let fn_ids: BTreeSet<&str> = surface
            .nodes
            .iter()
            .filter(|n| n.kind == SurfaceKind::Function)
            .map(|n| n.id.as_str())
            .collect();
        assert!(fn_ids.contains("orphan"), "unreached fn must surface");
        assert!(
            !fn_ids.contains("Funnel"),
            "test-reached handler is not a gap"
        );
    }

    #[test]
    fn allowlist_toml_round_trips_through_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".nornir").join("autonom-allow.toml");
        let al = Allowlist {
            entries: vec![super::super::warehouse::surface_coverage::AllowEntry {
                key: "viz_tab:Bench@thin".into(),
                reason: "Bench thin variant renders client-side only — no RPC surface to assert"
                    .into(),
            }],
        };
        save_allowlist(&path, &al).unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        assert!(
            text.contains("BURN IT DOWN"),
            "header carries the burn-down rule"
        );
        assert!(text.contains("viz_tab:Bench@thin"));

        let back = load_allowlist(&path).unwrap();
        assert_eq!(back, al, "allowlist round-trips through TOML");

        // A missing file loads as empty (not an error).
        let empty = load_allowlist(&dir.path().join("nope.toml")).unwrap();
        assert!(empty.entries.is_empty());
    }
}