Skip to main content

nornir_testmatrix/
coverage.rs

1//! # autonom coverage gate — the PURE verdict model (AUT2 / n-005)
2//!
3//! [`discover`](crate::discover) builds the testable [`Surface`] and
4//! [`compute_gap`] differences it against the covered + allowlisted sets. This
5//! module adds the **persistence + gate-verdict** shapes that nornir's iceberg
6//! sink and the `nornir test coverage` CLI / `test_coverage` MCP tool / viz Test
7//! pane all share — kept here, PURE (std + serde), so the whole gate is unit
8//! testable by feeding sample rows and asserting the verdict.
9//!
10//! ```text
11//! Surface  = discover::* enumerators                         (the testable surface)
12//! Covered  = a surface node reached by an inject-assert test (call_edges / tools/list / registry)
13//! Allowed  = a checked-in autonom-allow.toml entry           (excused, with a reason)
14//! Gap      = Surface − Covered − Allowed
15//! GATE: Gap == ∅  AND  no STALE allowlist entry              (HARD zero, not a ratchet)
16//! ```
17//!
18//! The [`CoverageRow`] is the warehouse row (one per `surface × mode × workspace
19//! × verdict`) — the same shape as `tests/mcp_tool_coverage.json` generalized to
20//! the whole surface. nornir's `surface_coverage` iceberg table writes/reads it.
21
22use std::collections::{BTreeMap, BTreeSet};
23
24use serde::{Deserialize, Serialize};
25
26use crate::discover::{Gap, Surface, SurfaceNode};
27
28/// The verdict a single surface node earned this gate run.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Verdict {
32    /// Reached by an inject-assert test — the gate is happy.
33    Covered,
34    /// Not covered, but excused by a checked-in `autonom-allow.toml` entry.
35    Allowlisted,
36    /// Not covered and not excused — this is what makes the gate RED.
37    Missing,
38}
39
40impl Verdict {
41    pub fn label(self) -> &'static str {
42        match self {
43            Verdict::Covered => "covered",
44            Verdict::Allowlisted => "allowlisted",
45            Verdict::Missing => "missing",
46        }
47    }
48
49    pub fn parse(s: &str) -> Option<Verdict> {
50        match s {
51            "covered" => Some(Verdict::Covered),
52            "allowlisted" => Some(Verdict::Allowlisted),
53            "missing" => Some(Verdict::Missing),
54            _ => None,
55        }
56    }
57}
58
59/// One persisted coverage row — the `surface_coverage` warehouse fact, mirroring
60/// `tests/mcp_tool_coverage.json` but for the WHOLE discovered surface. One row
61/// per `(surface_key, mode, workspace)` with its [`Verdict`].
62///
63/// `surface_key` is [`SurfaceNode::key_str`] (`"kind:id@mode"`) — the stable join
64/// key the gate matched coverage on. `kind`/`id`/`mode` are split out as columns
65/// so the warehouse / viz can filter without re-parsing the key.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CoverageRow {
68    /// The run that produced this row (groups one gate run's rows).
69    pub run_id: String,
70    /// The workspace this surface belongs to (so a multi-workspace warehouse
71    /// keeps each workspace's surface distinct).
72    pub workspace: String,
73    /// `SurfaceNode::key_str()` — `"kind:id@mode"`. The stable identity.
74    pub surface_key: String,
75    /// The enumerator kind tag (`facett_component` / `viz_tab` / `cli_command` /
76    /// `mcp_tool` / `function`).
77    pub kind: String,
78    /// The surface id within its kind (component / tab / cmd / tool / fn path).
79    pub id: String,
80    /// The thin/fat mode (`fat` / `thin` / `na`).
81    pub mode: String,
82    /// `covered` / `allowlisted` / `missing`.
83    pub verdict: String,
84    /// The allowlist reason (only set when `verdict == allowlisted`).
85    #[serde(default)]
86    pub reason: String,
87    /// Row timestamp (micros). Shared across one run's rows.
88    #[serde(default)]
89    pub ts_micros: i64,
90}
91
92impl CoverageRow {
93    /// Build a row from a node + verdict (the writer's per-node mapping).
94    pub fn from_node(
95        run_id: &str,
96        workspace: &str,
97        node: &SurfaceNode,
98        verdict: Verdict,
99        reason: &str,
100        ts_micros: i64,
101    ) -> Self {
102        CoverageRow {
103            run_id: run_id.to_string(),
104            workspace: workspace.to_string(),
105            surface_key: node.key_str(),
106            kind: node.kind.label().to_string(),
107            id: node.id.clone(),
108            mode: node.mode.label().to_string(),
109            verdict: verdict.label().to_string(),
110            reason: reason.to_string(),
111            ts_micros,
112        }
113    }
114
115    /// The parsed verdict (defaults to [`Verdict::Missing`] for an unknown tag —
116    /// fail-safe: an unrecognized verdict counts against the gate, never for it).
117    pub fn verdict(&self) -> Verdict {
118        Verdict::parse(&self.verdict).unwrap_or(Verdict::Missing)
119    }
120}
121
122/// One allowlist entry from the checked-in `autonom-allow.toml`. An entry
123/// **excuses** a surface node from the gate — but it must carry a `reason`
124/// (often a `TODO`/issue ref) so the excuse is visible and burns down by
125/// deletion, never silently.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AllowEntry {
128    /// `SurfaceNode::key_str()` — `"kind:id@mode"`. The node this excuses.
129    pub key: String,
130    /// Why it's excused (a TODO / issue link). REQUIRED — a blank reason is a
131    /// stale-ish smell the seeder fills with a placeholder.
132    #[serde(default)]
133    pub reason: String,
134}
135
136/// The parsed `autonom-allow.toml` — a flat list of [`AllowEntry`]. Serializes
137/// to/from `[[allow]]` tables (the caller does the toml (de)serialize; this
138/// stays serde-pure).
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Allowlist {
141    #[serde(default, rename = "allow")]
142    pub entries: Vec<AllowEntry>,
143}
144
145impl Allowlist {
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// The set of excused keys — what [`compute_gap`](crate::discover::compute_gap)
151    /// consumes as its `allowlist` argument.
152    pub fn key_set(&self) -> BTreeSet<String> {
153        self.entries.iter().map(|e| e.key.clone()).collect()
154    }
155
156    /// Map key → reason for annotating allowlisted rows.
157    pub fn reasons(&self) -> BTreeMap<String, String> {
158        self.entries.iter().map(|e| (e.key.clone(), e.reason.clone())).collect()
159    }
160}
161
162/// SEED an allowlist with EVERY currently-uncovered surface node (`--seed-allowlist`).
163///
164/// Every node **not** in `covered` gets an entry with a TODO reason, so the gate
165/// goes GREEN *now* and the allowlist burns down by deleting entries as tests are
166/// wired. Already-covered nodes are NOT seeded (they don't need an excuse).
167/// Existing entries' reasons are preserved (re-seeding doesn't clobber a hand
168/// reason); new uncovered nodes are appended. Sorted by key for a stable file.
169pub fn seed_allowlist(
170    surface: &Surface,
171    covered: &BTreeSet<String>,
172    existing: &Allowlist,
173) -> Allowlist {
174    let prior: BTreeMap<String, String> = existing.reasons();
175    let mut entries: Vec<AllowEntry> = Vec::new();
176    for node in &surface.nodes {
177        let key = node.key_str();
178        if covered.contains(&key) {
179            continue; // covered → no excuse needed
180        }
181        let reason = prior
182            .get(&key)
183            .filter(|r| !r.is_empty())
184            .cloned()
185            .unwrap_or_else(|| format!("TODO(autonom): wire an inject-assert test for {key}"));
186        entries.push(AllowEntry { key, reason });
187    }
188    entries.sort_by(|a, b| a.key.cmp(&b.key));
189    Allowlist { entries }
190}
191
192/// STALE allowlist entries: keys on the allowlist that are **no longer needed** —
193/// either the node is now covered, or the node no longer exists in the surface.
194/// A stale entry FAILS the gate (the allowlist must burn down, not rot): an
195/// excuse outliving its surface is exactly the drift autonom kills.
196///
197/// Returns the offending [`AllowEntry`]s sorted by key.
198pub fn stale_allowlist_entries(
199    surface: &Surface,
200    covered: &BTreeSet<String>,
201    allowlist: &Allowlist,
202) -> Vec<AllowEntry> {
203    let surface_keys: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
204    let mut stale: Vec<AllowEntry> = allowlist
205        .entries
206        .iter()
207        .filter(|e| covered.contains(&e.key) || !surface_keys.contains(&e.key))
208        .cloned()
209        .collect();
210    stale.sort_by(|a, b| a.key.cmp(&b.key));
211    stale
212}
213
214/// The full gate verdict for one run — the thing the CLI prints, the viz Test
215/// pane shows, and the release gate fails on. Combines the [`Gap`] (missing /
216/// allowlisted / covered counts) with the STALE-allowlist check.
217///
218/// `is_green()` is the HARD-zero verdict: **no missing surface AND no stale
219/// allowlist entry**.
220#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
221pub struct GateReport {
222    pub run_id: String,
223    pub workspace: String,
224    /// The differenced gap (missing + allowlisted + covered/total counts).
225    pub gap: Gap,
226    /// Allowlist entries that are no longer needed (covered or surface-gone).
227    pub stale: Vec<AllowEntry>,
228}
229
230impl GateReport {
231    /// Build the report by differencing the surface against covered + allowlist,
232    /// then checking for stale allowlist entries.
233    pub fn compute(
234        run_id: &str,
235        workspace: &str,
236        surface: &Surface,
237        covered: &BTreeSet<String>,
238        allowlist: &Allowlist,
239    ) -> GateReport {
240        let gap = crate::discover::compute_gap(surface, covered, &allowlist.key_set());
241        let stale = stale_allowlist_entries(surface, covered, allowlist);
242        GateReport {
243            run_id: run_id.to_string(),
244            workspace: workspace.to_string(),
245            gap,
246            stale,
247        }
248    }
249
250    /// GREEN ⟺ no missing surface AND no stale allowlist entry. The HARD-zero
251    /// gate: `Gap == ∅` and the allowlist is fully justified.
252    pub fn is_green(&self) -> bool {
253        self.gap.is_clean() && self.stale.is_empty()
254    }
255
256    /// One-line human summary for the CLI.
257    pub fn summary(&self) -> String {
258        format!(
259            "{} · {} stale allowlist entr{} — {}",
260            self.gap.summary(),
261            self.stale.len(),
262            if self.stale.len() == 1 { "y" } else { "ies" },
263            if self.is_green() { "GREEN" } else { "RED" },
264        )
265    }
266
267    /// The persisted rows for this report (covered are NOT in the gap, so they're
268    /// reconstructed from `surface − missing − allowlisted` by the writer; here we
269    /// emit the missing + allowlisted rows which carry the actionable verdicts).
270    /// The caller passes the full surface + the covered set to also emit covered
271    /// rows; see [`rows_for`].
272    pub fn actionable_rows(&self, ts_micros: i64) -> Vec<CoverageRow> {
273        let reasons: BTreeMap<String, String> = BTreeMap::new(); // gap has no reasons
274        let mut rows = Vec::new();
275        for node in &self.gap.missing {
276            rows.push(CoverageRow::from_node(
277                &self.run_id,
278                &self.workspace,
279                node,
280                Verdict::Missing,
281                "",
282                ts_micros,
283            ));
284        }
285        for node in &self.gap.allowlisted {
286            let reason = reasons.get(&node.key_str()).cloned().unwrap_or_default();
287            rows.push(CoverageRow::from_node(
288                &self.run_id,
289                &self.workspace,
290                node,
291                Verdict::Allowlisted,
292                &reason,
293                ts_micros,
294            ));
295        }
296        rows
297    }
298}
299
300/// Build the FULL per-node coverage rows (covered + allowlisted + missing) for
301/// persistence — one row per surface node. This is the writer's source: a row
302/// for every discovered surface node, tagged with its verdict (and an allowlist
303/// reason where applicable). The gate joins these back on `surface_key`.
304pub fn rows_for(
305    run_id: &str,
306    workspace: &str,
307    surface: &Surface,
308    covered: &BTreeSet<String>,
309    allowlist: &Allowlist,
310    ts_micros: i64,
311) -> Vec<CoverageRow> {
312    let allow_keys = allowlist.key_set();
313    let reasons = allowlist.reasons();
314    let mut rows: Vec<CoverageRow> = surface
315        .nodes
316        .iter()
317        .map(|node| {
318            let key = node.key_str();
319            let (verdict, reason) = if covered.contains(&key) {
320                (Verdict::Covered, String::new())
321            } else if allow_keys.contains(&key) {
322                (Verdict::Allowlisted, reasons.get(&key).cloned().unwrap_or_default())
323            } else {
324                (Verdict::Missing, String::new())
325            };
326            CoverageRow::from_node(run_id, workspace, node, verdict, &reason, ts_micros)
327        })
328        .collect();
329    rows.sort_by(|a, b| a.surface_key.cmp(&b.surface_key));
330    rows
331}
332
333/// Summarize persisted [`CoverageRow`]s back into a compact verdict for the viz
334/// Test pane (`state_json["test"]["coverage"]`) and the `test_coverage` tool.
335/// Counts by verdict + lists the missing keys (the actionable gap).
336#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337pub struct CoverageSummary {
338    pub run_id: String,
339    pub workspace: String,
340    pub total: usize,
341    pub covered: usize,
342    pub allowlisted: usize,
343    pub gap: usize,
344    /// The missing surface keys (`"kind:id@mode"`), sorted — the burn-down list.
345    pub missing: Vec<String>,
346    /// GREEN ⟺ gap == 0.
347    pub green: bool,
348}
349
350impl CoverageSummary {
351    /// Roll persisted rows (one run) into the viz/CLI summary.
352    pub fn from_rows(rows: &[CoverageRow]) -> CoverageSummary {
353        let run_id = rows.first().map(|r| r.run_id.clone()).unwrap_or_default();
354        let workspace = rows.first().map(|r| r.workspace.clone()).unwrap_or_default();
355        let mut covered = 0;
356        let mut allowlisted = 0;
357        let mut missing: Vec<String> = Vec::new();
358        for r in rows {
359            match r.verdict() {
360                Verdict::Covered => covered += 1,
361                Verdict::Allowlisted => allowlisted += 1,
362                Verdict::Missing => missing.push(r.surface_key.clone()),
363            }
364        }
365        missing.sort();
366        let gap = missing.len();
367        CoverageSummary {
368            run_id,
369            workspace,
370            total: rows.len(),
371            covered,
372            allowlisted,
373            gap,
374            green: gap == 0,
375            missing,
376        }
377    }
378
379    /// The JSON the viz Test pane nests under `state_json["test"]["coverage"]`.
380    pub fn to_json(&self) -> serde_json::Value {
381        serde_json::json!({
382            "run_id": self.run_id,
383            "workspace": self.workspace,
384            "total": self.total,
385            "covered": self.covered,
386            "allowlisted": self.allowlisted,
387            "gap": self.gap,
388            "green": self.green,
389            "missing": self.missing,
390        })
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::discover::{cli_commands, mcp_tools, viz_tabs};
398
399    fn sample_surface() -> Surface {
400        let mut s = Surface::new();
401        s.extend(viz_tabs(["Test"])) // viz_tab:Test@fat, viz_tab:Test@thin
402            .extend(mcp_tools(["search"])) // mcp_tool:search@na
403            .extend(cli_commands(["doctor"])); // cli_command:doctor@na
404        s
405    }
406
407    #[test]
408    fn seed_excuses_only_uncovered_with_reasons() {
409        let surface = sample_surface(); // 4 nodes
410        // Only Test@fat is covered.
411        let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
412        let seeded = seed_allowlist(&surface, &covered, &Allowlist::new());
413        // 3 uncovered nodes seeded; the covered one is NOT.
414        assert_eq!(seeded.entries.len(), 3);
415        assert!(seeded.entries.iter().all(|e| e.reason.contains("TODO(autonom)")));
416        assert!(!seeded.entries.iter().any(|e| e.key == "viz_tab:Test@fat"));
417        assert!(seeded.entries.iter().any(|e| e.key == "viz_tab:Test@thin"));
418
419        // With the seeded allowlist, the gate is GREEN now (everything excused).
420        let report = GateReport::compute("r1", "ws", &surface, &covered, &seeded);
421        assert!(report.is_green(), "seeded allowlist makes the gate green now");
422        assert_eq!(report.gap.covered, 1);
423        assert_eq!(report.gap.allowlisted.len(), 3);
424        assert_eq!(report.gap.missing.len(), 0);
425    }
426
427    #[test]
428    fn reseed_preserves_existing_reasons() {
429        let surface = sample_surface();
430        let covered = BTreeSet::new();
431        let existing = Allowlist {
432            entries: vec![AllowEntry {
433                key: "viz_tab:Test@thin".into(),
434                reason: "hand-written reason #42".into(),
435            }],
436        };
437        let seeded = seed_allowlist(&surface, &covered, &existing);
438        let thin = seeded.entries.iter().find(|e| e.key == "viz_tab:Test@thin").unwrap();
439        assert_eq!(thin.reason, "hand-written reason #42", "existing reason preserved");
440        // New nodes still get the TODO placeholder.
441        let other = seeded.entries.iter().find(|e| e.key == "mcp_tool:search@na").unwrap();
442        assert!(other.reason.contains("TODO(autonom)"));
443    }
444
445    #[test]
446    fn unreached_makes_gap_reachable_does_not_allowlisted_excused() {
447        let surface = sample_surface();
448        // Cover everything EXCEPT Test@thin; allowlist Test@thin.
449        let covered: BTreeSet<String> = [
450            "viz_tab:Test@fat",
451            "mcp_tool:search@na",
452            "cli_command:doctor@na",
453        ]
454        .iter()
455        .map(|s| s.to_string())
456        .collect();
457        let allowlist = Allowlist {
458            entries: vec![AllowEntry {
459                key: "viz_tab:Test@thin".into(),
460                reason: "RPC wiring tracked in n-006".into(),
461            }],
462        };
463        let report = GateReport::compute("r1", "ws", &surface, &covered, &allowlist);
464        // Reachable (covered) → NOT in gap. Allowlisted → excused, not missing.
465        assert!(report.is_green(), "all covered or excused → green");
466        assert_eq!(report.gap.covered, 3);
467        assert_eq!(report.gap.allowlisted.len(), 1);
468        assert!(report.stale.is_empty());
469
470        // Now REMOVE the cover for the cli command and DON'T allowlist it → RED.
471        let covered2: BTreeSet<String> =
472            ["viz_tab:Test@fat", "mcp_tool:search@na"].iter().map(|s| s.to_string()).collect();
473        let report2 = GateReport::compute("r1", "ws", &surface, &covered2, &allowlist);
474        assert!(!report2.is_green(), "an uncovered, un-allowlisted node makes it RED");
475        assert_eq!(report2.gap.missing.len(), 1);
476        assert_eq!(report2.gap.missing[0].key_str(), "cli_command:doctor@na");
477    }
478
479    #[test]
480    fn stale_allowlist_entry_fails_the_gate() {
481        let surface = sample_surface();
482        // Everything covered.
483        let covered: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
484        // But the allowlist still excuses a now-COVERED node (stale) AND a
485        // node that no longer exists in the surface (also stale).
486        let allowlist = Allowlist {
487            entries: vec![
488                AllowEntry { key: "viz_tab:Test@thin".into(), reason: "old".into() },
489                AllowEntry { key: "viz_tab:Ghost@fat".into(), reason: "deleted tab".into() },
490            ],
491        };
492        let stale = stale_allowlist_entries(&surface, &covered, &allowlist);
493        assert_eq!(stale.len(), 2, "both a now-covered and a surface-gone entry are stale");
494        let report = GateReport::compute("r1", "ws", &surface, &covered, &allowlist);
495        // Gap itself is empty (all covered) BUT the stale entries make it RED.
496        assert!(report.gap.is_clean(), "no missing surface");
497        assert!(!report.is_green(), "stale allowlist entries fail the HARD-zero gate");
498        assert!(report.summary().contains("RED"));
499    }
500
501    #[test]
502    fn rows_and_summary_round_trip_through_serde() {
503        let surface = sample_surface();
504        let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
505        let allowlist = Allowlist {
506            entries: vec![AllowEntry {
507                key: "viz_tab:Test@thin".into(),
508                reason: "excused".into(),
509            }],
510        };
511        let rows = rows_for("r1", "ws", &surface, &covered, &allowlist, 123);
512        assert_eq!(rows.len(), 4, "one row per surface node");
513        // Verdicts: 1 covered, 1 allowlisted, 2 missing.
514        let by_verdict = |v: Verdict| rows.iter().filter(|r| r.verdict() == v).count();
515        assert_eq!(by_verdict(Verdict::Covered), 1);
516        assert_eq!(by_verdict(Verdict::Allowlisted), 1);
517        assert_eq!(by_verdict(Verdict::Missing), 2);
518        // The allowlisted row carries the reason.
519        let allow_row = rows.iter().find(|r| r.verdict() == Verdict::Allowlisted).unwrap();
520        assert_eq!(allow_row.reason, "excused");
521        assert_eq!(allow_row.surface_key, "viz_tab:Test@thin");
522
523        // Each row round-trips through serde (the warehouse row shape).
524        let json = serde_json::to_string(&rows).unwrap();
525        let back: Vec<CoverageRow> = serde_json::from_str(&json).unwrap();
526        assert_eq!(back, rows);
527
528        // The summary rolls the rows up for the viz/CLI.
529        let summary = CoverageSummary::from_rows(&rows);
530        assert_eq!(summary.total, 4);
531        assert_eq!(summary.covered, 1);
532        assert_eq!(summary.allowlisted, 1);
533        assert_eq!(summary.gap, 2);
534        assert!(!summary.green);
535        assert_eq!(summary.missing.len(), 2);
536        // The viz JSON shape carries the burn-down list.
537        let vj = summary.to_json();
538        assert_eq!(vj["gap"], 2);
539        assert_eq!(vj["green"], false);
540        assert!(vj["missing"].as_array().unwrap().contains(&serde_json::json!("mcp_tool:search@na")));
541    }
542}