nornir-testmatrix 0.2.1

Reusable, multi-aspect Rust test-matrix engine: wrap a repo's native cargo test/nextest plus build/clippy/fmt/audit/coverage/doctest aspects, parse the results into rows, and ship them to any TestSink. Pure std + serde — no iceberg, arrow, eframe. The portable core of nornir's `nornir test` matrix.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
//! # 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,
}

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",
        }
    }
}

/// 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() }
    }

    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>,
}

/// 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,
}

// ─── 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
}

/// **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()
}

/// 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 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()
    }

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

    #[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()],
            },
            // 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![],
            },
            // 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![],
            },
        ];
        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 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()],
        }];
        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"));
    }
}