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

nordisk/edda

crates/nornir-release-core/src/semantic_blast.rs

//! **Semantic blast radius** over a call graph — relocated from
//! `nornir/src/release/semantic_blast.rs` into the dwarves release CORE.
//!
//! The crate-level [`blast_radius`] answers *who DEPENDS on* a changed crate (the
//! pessimistic set). This module answers the sharper *what ACTUALLY breaks* by
//! cross-referencing a version bump against a call graph:
//!
//! ```text
//!   target crate C  (a version bump)
//!     → its changed / removed exported symbols
//!       → REVERSE call_edges (`KnowledgeView::callers_of`) into every dependent
//!         → the exact call sites that invoke those symbols
//!           → ranked by API-surface delta
//! ```
//!
//! [`rank`] is that analysis over a [`KnowledgeView`] the caller holds. The
//! warehouse orchestrators ([`compute`], [`compute_for_dirty`]) used to LOAD the
//! view — the resolved SCIP call graph, or the syn fallback — from
//! `scip_occurrences` / `symbol_facts` / `call_edges`. Those tables have no writer
//! since Mímir v2 (2026-10-13), so the orchestrators answer what needs no call
//! graph (a repo nobody depends on, a bump that changed nothing) and REFUSE the
//! rest with [`CallGraphNotProduced`] — a missing producer, never "0 call sites".
//!
//! Relocation deltas: `crate::knowledge::query::*` → [`crate::knowledge`];
//! `crate::warehouse::Warehouse` → `nornir_warehouse_trait::Warehouse`; knowledge
//! PODs → `nornir_warehouse_trait::{SymbolRow, CallEdgeRow}`;
//! `crate::release::doctor::{blast_radius, RepoGraph}` → `RepoGraph` from
//! [`crate::model`] + [`blast_radius`] relocated here as a small pure helper (the
//! warehouse-COUPLED doctor engine stays in nornir).

use std::collections::BTreeSet;

use anyhow::Result;
use serde::{Deserialize, Serialize};

use nornir_warehouse_trait::{SymbolRow, Warehouse};

use crate::knowledge::KnowledgeView;
use crate::model::RepoGraph;

/// **Cross-repo reverse reachability** — the repos that (transitively) depend on
/// `repo` via an order-gating cross-repo edge (repo A → B iff A depends on some
/// crate B produces). Relocated pure helper over [`RepoGraph`] facts (the
/// warehouse-side gatherers that POPULATE the graphs stay in nornir).
pub fn blast_radius(graphs: &[RepoGraph], repo: &str) -> Vec<String> {
    let deps_on = repo_edges(graphs);
    let mut result = std::collections::BTreeSet::new();
    let mut stack = vec![repo.to_string()];
    while let Some(cur) = stack.pop() {
        for (a, ds) in &deps_on {
            if ds.contains(&cur) && result.insert(a.clone()) {
                stack.push(a.clone());
            }
        }
    }
    result.into_iter().collect()
}

/// repo → the set of repos it depends on (edge a→b iff a depends on SOME crate b
/// produces). The reverse-reachability adjacency [`blast_radius`] walks.
fn repo_edges(
    graphs: &[RepoGraph],
) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
    let mut out: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
        std::collections::BTreeMap::new();
    for a in graphs {
        let set = out.entry(a.repo.clone()).or_default();
        for b in graphs {
            if a.repo == b.repo {
                continue;
            }
            if b.produces.iter().any(|c| a.deps.contains(c)) {
                set.insert(b.repo.clone());
            }
        }
    }
    out
}

/// How a symbol of the target crate changed across the version bump. Higher
/// discriminant = a harder break (the rank tie-breaker).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeKind {
    /// The signature/behaviour changed but the name still resolves.
    Changed,
    /// The symbol was removed / renamed — every call site is a hard break.
    Removed,
}

impl ChangeKind {
    fn glyph(self) -> &'static str {
        match self {
            ChangeKind::Changed => "~",
            ChangeKind::Removed => "✗",
        }
    }
}

/// One exported symbol of the target crate that the bump touched. `name` is the
/// bare identifier — the key `callers_of` matches (both `open` and `Warehouse::open`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangedSymbol {
    pub name: String,
    pub change: ChangeKind,
}

impl ChangedSymbol {
    pub fn removed(name: impl Into<String>) -> Self {
        ChangedSymbol { name: name.into(), change: ChangeKind::Removed }
    }
    pub fn changed(name: impl Into<String>) -> Self {
        ChangedSymbol { name: name.into(), change: ChangeKind::Changed }
    }
}

/// One concrete call site in a dependent that invokes a changed symbol.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CallSite {
    /// The crate the call is made FROM (best-effort: the edge's `crate_name`, else
    /// the first segment of the caller path).
    pub crate_name: String,
    pub caller: String,
    pub callee: String,
    pub call_kind: String,
    pub file: String,
    pub line: u32,
}

/// The impact of ONE changed symbol: the call sites that hit it, most-called first.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolImpact {
    pub symbol: String,
    pub change: ChangeKind,
    pub call_sites: Vec<CallSite>,
}

/// The semantic blast radius of a version bump to `target_repo`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticBlast {
    pub target_repo: String,
    pub target_crates: Vec<String>,
    /// Which call graph answered: the label the producer of the ranked view
    /// gives it; `""` when no call graph was needed (no dependents, no change).
    pub source: String,
    pub impacts: Vec<SymbolImpact>,
    /// Changed symbols NO dependent call site invokes (internal-only → safe).
    pub unused_changes: Vec<ChangedSymbol>,
    pub total_call_sites: usize,
    pub affected_crates: Vec<String>,
}

impl SemanticBlast {
    /// True when the bump touches nothing any dependent actually calls.
    pub fn is_empty(&self) -> bool {
        self.impacts.is_empty()
    }

    /// The LINK ERRORS: impacts of a REMOVED symbol a dependent still calls (a
    /// pre-release link error). A `Changed` impact still resolves → a warning.
    pub fn link_errors(&self) -> Vec<&SymbolImpact> {
        self.impacts
            .iter()
            .filter(|i| i.change == ChangeKind::Removed && !i.call_sites.is_empty())
            .collect()
    }

    /// True when at least one removed symbol has a live cross-crate call site.
    pub fn has_link_error(&self) -> bool {
        self.impacts
            .iter()
            .any(|i| i.change == ChangeKind::Removed && !i.call_sites.is_empty())
    }

    /// Total live call sites across all link errors.
    pub fn link_error_sites(&self) -> usize {
        self.link_errors().iter().map(|i| i.call_sites.len()).sum()
    }
}

/// Compute the **API-surface delta** between a crate's OLD and NEW exported-symbol
/// sets. A `pub` symbol gone from `new` → [`ChangeKind::Removed`]; a `pub` symbol
/// in both whose `signature` changed → [`ChangeKind::Changed`]; a pure addition is
/// not part of the blast; non-`pub` symbols are ignored. Keyed by bare `item_name`.
pub fn diff_exported_symbols(old: &[SymbolRow], new: &[SymbolRow]) -> Vec<ChangedSymbol> {
    use std::collections::BTreeMap;
    let is_pub = |v: &str| v == "pub";
    let index = |rows: &[SymbolRow]| -> BTreeMap<String, Option<String>> {
        rows.iter()
            .filter(|r| is_pub(&r.visibility))
            .map(|r| (r.item_name.clone(), r.signature.clone()))
            .collect()
    };
    let old_idx = index(old);
    let new_idx = index(new);
    let mut out: Vec<ChangedSymbol> = Vec::new();
    for (name, old_sig) in &old_idx {
        match new_idx.get(name) {
            None => out.push(ChangedSymbol::removed(name.clone())),
            Some(new_sig) => {
                if old_sig.is_some() && new_sig.is_some() && old_sig != new_sig {
                    out.push(ChangedSymbol::changed(name.clone()));
                }
            }
        }
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    out
}

/// Best-effort crate name for a reverse `call_edges` hit.
fn caller_crate(crate_name: &str, caller_path: &str) -> String {
    if !crate_name.is_empty() {
        return crate_name.to_string();
    }
    match caller_path.split_once("::") {
        Some((head, _)) if !head.is_empty() => head.to_string(),
        _ => caller_path.to_string(),
    }
}

/// **The pure core** — rank the changed symbols by their real call-site impact
/// over an already-loaded `view`, excluding call sites from the target repo's OWN
/// crates (`own_crates`) so only genuine cross-crate breakage counts.
pub fn rank(
    view: &KnowledgeView,
    target_repo: &str,
    target_crates: &BTreeSet<String>,
    own_crates: &BTreeSet<String>,
    changed: &[ChangedSymbol],
) -> SemanticBlast {
    let mut impacts: Vec<SymbolImpact> = Vec::new();
    let mut unused_changes: Vec<ChangedSymbol> = Vec::new();
    let mut affected: BTreeSet<String> = BTreeSet::new();

    for cs in changed {
        let mut sites: Vec<CallSite> = view
            .callers_of(&cs.name)
            .into_iter()
            .filter(|e| !own_crates.contains(&caller_crate(&e.crate_name, &e.caller_path)))
            .map(|e| CallSite {
                crate_name: caller_crate(&e.crate_name, &e.caller_path),
                caller: e.caller_path.clone(),
                callee: e.callee_ident.clone(),
                call_kind: e.call_kind.clone(),
                file: e.file.clone(),
                line: e.line,
            })
            .collect();

        if sites.is_empty() {
            unused_changes.push(cs.clone());
            continue;
        }

        sites.sort_by(|a, b| (&a.crate_name, &a.file, a.line).cmp(&(&b.crate_name, &b.file, b.line)));
        for s in &sites {
            affected.insert(s.crate_name.clone());
        }
        impacts.push(SymbolImpact { symbol: cs.name.clone(), change: cs.change, call_sites: sites });
    }

    impacts.sort_by(|a, b| {
        b.call_sites
            .len()
            .cmp(&a.call_sites.len())
            .then(b.change.cmp(&a.change))
            .then(a.symbol.cmp(&b.symbol))
    });

    let total_call_sites = impacts.iter().map(|i| i.call_sites.len()).sum();
    SemanticBlast {
        target_repo: target_repo.to_string(),
        target_crates: target_crates.iter().cloned().collect(),
        source: String::new(),
        impacts,
        unused_changes,
        total_call_sites,
        affected_crates: affected.into_iter().collect(),
    }
}

/// The call graph of a bumped repo's dependents is not produced: the tables it
/// was read from (`symbol_facts`, `call_edges`, `scip_occurrences`) have no
/// writer since Mímir v2. Carried as the error of [`compute`] so a caller can
/// tell it from an I/O failure (`err.downcast_ref::<CallGraphNotProduced>()`)
/// and show it as a state, not as "no call sites".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallGraphNotProduced {
    /// The repo whose bump was asked about.
    pub target_repo: String,
    /// Its dependents — the repos whose call sites could not be read.
    pub dependents: Vec<String>,
}

impl std::fmt::Display for CallGraphNotProduced {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "semantic blast of `{}`: the call graph of its {} dependent(s) [{}] is NOT PRODUCED — \
             `symbol_facts`, `call_edges` and `scip_occurrences` have no writer since Mímir v2 \
             (the code graph is nornir's `mimir_v2` lake, which the `Warehouse` seam does not \
             carry). A missing producer, not an absence of call sites: refusing rather than \
             reporting none.",
            self.target_repo,
            self.dependents.len(),
            self.dependents.join(", "),
        )
    }
}

impl std::error::Error for CallGraphNotProduced {}

/// **The warehouse orchestrator** — the semantic blast of `changed` in
/// `target_repo` over the call graph of its dependents.
///
/// Answers without a call graph where none is needed: a repo with no dependents
/// (every change is unused), a bump that changed nothing. Otherwise it needs the
/// dependents' call graph, which the warehouse no longer holds → it fails with
/// [`CallGraphNotProduced`], naming them. It does NOT read the three tables: a
/// warehouse made before Mímir v2 still has them, and a call graph that stopped
/// being written is not the call graph of the tree being released.
pub fn compute(
    _wh: &dyn Warehouse,
    graphs: &[RepoGraph],
    target_repo: &str,
    changed: &[ChangedSymbol],
) -> Result<SemanticBlast> {
    let own_crates: BTreeSet<String> = graphs
        .iter()
        .find(|g| g.repo == target_repo)
        .map(|g| g.produces.clone())
        .unwrap_or_default();

    let dependents = blast_radius(graphs, target_repo);
    if dependents.is_empty() || changed.is_empty() {
        let mut sb = SemanticBlast {
            target_repo: target_repo.to_string(),
            target_crates: own_crates.iter().cloned().collect(),
            ..Default::default()
        };
        sb.unused_changes = changed.to_vec();
        return Ok(sb);
    }

    Err(CallGraphNotProduced { target_repo: target_repo.to_string(), dependents }.into())
}

/// [`compute`] for every dirty repo whose changed symbols are supplied; returns
/// only the non-empty results. `changed_by_repo` maps repo → its changed set.
/// One repo that needs a call graph fails the whole call ([`CallGraphNotProduced`]):
/// this feeds a release GATE, and a map with that repo left out would read as
/// "its bump breaks nobody".
pub fn compute_for_dirty<'a>(
    wh: &dyn Warehouse,
    graphs: &[RepoGraph],
    changed_by_repo: impl IntoIterator<Item = (&'a str, &'a [ChangedSymbol])>,
) -> Result<std::collections::BTreeMap<String, SemanticBlast>> {
    let mut out = std::collections::BTreeMap::new();
    for (repo, changed) in changed_by_repo {
        let sb = compute(wh, graphs, repo, changed)?;
        if !sb.is_empty() {
            out.insert(repo.to_string(), sb);
        }
    }
    Ok(out)
}

/// Render a semantic blast as a text block for the doctor report.
pub fn format_semantic_blast(sb: &SemanticBlast) -> String {
    let mut s = String::new();
    if sb.is_empty() {
        return s;
    }
    s.push_str(&format!(
        "  {} bump touches {} call site(s) in {} crate(s) [{}]:\n",
        sb.target_repo,
        sb.total_call_sites,
        sb.affected_crates.len(),
        if sb.source.is_empty() { "no-index" } else { &sb.source },
    ));
    for imp in &sb.impacts {
        s.push_str(&format!(
            "    {} {} ({} call site(s)):\n",
            imp.change.glyph(),
            imp.symbol,
            imp.call_sites.len(),
        ));
        for site in &imp.call_sites {
            s.push_str(&format!(
                "        {}::{} — {}:{}\n",
                site.crate_name, site.caller, site.file, site.line,
            ));
        }
    }
    if !sb.unused_changes.is_empty() {
        let names: Vec<&str> = sb.unused_changes.iter().map(|c| c.name.as_str()).collect();
        s.push_str(&format!("    (no dependent call sites: {})\n", names.join(", ")));
    }
    s
}

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

    /// A synthetic call-graph: `facett` exports `render`/`open`/`secret`; three
    /// dependents call in, plus one intra-`facett` self-call (must NOT count).
    fn fixture() -> KnowledgeView {
        let sym = |cr: &str, name: &str| SymbolRow {
            crate_name: cr.into(),
            module_path: format!("{cr}::api"),
            item_kind: "fn".into(),
            item_name: name.into(),
            visibility: "pub".into(),
            file: format!("{cr}/src/api.rs"),
            line: 1,
            doc_lines: 0,
            signature: None,
        };
        let call = |cr: &str, caller: &str, callee: &str, file: &str, line: u32| CallEdgeRow {
            crate_name: cr.into(),
            caller_path: caller.into(),
            callee_ident: callee.into(),
            call_kind: "call".into(),
            file: file.into(),
            line,
        };
        let symbols = vec![sym("facett", "render"), sym("facett", "open"), sym("facett", "secret")];
        let calls = vec![
            call("korp", "korp::view::draw", "facett::render", "korp/src/view.rs", 40),
            call("korp", "korp::view::redraw", "render", "korp/src/view.rs", 88),
            call("knut", "knut::ui::paint", "facett::render", "knut/src/ui.rs", 12),
            call("nornir", "nornir::boot::run", "facett::open", "nornir/src/boot.rs", 7),
            call("facett", "facett::api::render", "open", "facett/src/api.rs", 3),
            call("korp", "korp::view::draw", "std::mem::swap", "korp/src/view.rs", 41),
        ];
        KnowledgeView::new(symbols, calls)
    }

    fn own() -> BTreeSet<String> {
        BTreeSet::from(["facett".to_string()])
    }

    #[test]
    fn ranks_changed_symbols_by_real_call_site_count() {
        let view = fixture();
        let changed = [
            ChangedSymbol::removed("render"),
            ChangedSymbol::changed("open"),
            ChangedSymbol::removed("secret"),
        ];
        let sb = rank(&view, "facett", &own(), &own(), &changed);
        let ranked: Vec<&str> = sb.impacts.iter().map(|i| i.symbol.as_str()).collect();
        let correct_rank = ranked == ["render", "open"];
        let render = &sb.impacts[0];
        let self_call_excluded =
            render.call_sites.len() == 3 && render.call_sites.iter().all(|s| s.crate_name != "facett");
        let secret_unused = sb.unused_changes.iter().any(|c| c.name == "secret")
            && sb.impacts.iter().all(|i| i.symbol != "secret");
        let totals = sb.total_call_sites == 4
            && sb.affected_crates
                == vec!["knut".to_string(), "korp".to_string(), "nornir".to_string()];

        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "ranks_by_real_call_site_count",
            correct_rank && self_call_excluded && secret_unused && totals,
            &format!("rank={ranked:?} render_sites={} total={}", render.call_sites.len(), sb.total_call_sites),
        );
        assert!(correct_rank, "render (3) must outrank open (1): {ranked:?}");
        assert!(self_call_excluded, "intra-facett self-call must be excluded");
        assert!(secret_unused, "exported-but-uncalled changed symbol is `unused`");
        assert!(totals, "4 cross-crate sites across knut/korp/nornir");
    }

    #[test]
    fn link_errors_flags_removed_with_live_call_sites_only() {
        let view = fixture();
        let sb = rank(
            &view,
            "facett",
            &own(),
            &own(),
            &[ChangedSymbol::removed("render"), ChangedSymbol::changed("open")],
        );
        let errs = sb.link_errors();
        let ok = errs.len() == 1
            && errs[0].symbol == "render"
            && sb.has_link_error()
            && sb.link_error_sites() == 3
            && sb.impacts.iter().any(|i| i.symbol == "open")
            && errs.iter().all(|i| i.symbol != "open");

        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "link_errors_only_removed",
            ok,
            &format!("errors={:?} sites={}", errs.iter().map(|i| &i.symbol).collect::<Vec<_>>(), sb.link_error_sites()),
        );
        assert!(ok, "only the removed `render` (3 live sites) is a link error");
    }

    #[test]
    fn format_renders_headline_symbols_and_sites() {
        let view = fixture();
        let mut sb = rank(&view, "facett", &own(), &own(), &[ChangedSymbol::removed("render")]);
        sb.source = "resolved/scip".to_string();
        let text = format_semantic_blast(&sb);
        let ok = text.contains("facett bump touches 3 call site(s) in 2 crate(s)")
            && text.contains("[resolved/scip]")
            && text.contains("✗ render (3 call site(s))")
            && text.contains("korp/src/view.rs:40")
            && format_semantic_blast(&rank(&view, "facett", &own(), &own(), &[ChangedSymbol::changed("nope")]))
                .is_empty();

        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "format_headline_symbols_sites",
            ok,
            &format!("text_len={}", text.len()),
        );
        assert!(ok, "rendered report wrong:\n{text}");
    }

    #[test]
    fn diff_exported_symbols_detects_removed_changed_ignores_additions_and_private() {
        let s = |name: &str, vis: &str, sig: Option<&str>| SymbolRow {
            crate_name: "facett".into(),
            module_path: "facett::api".into(),
            item_kind: "fn".into(),
            item_name: name.into(),
            visibility: vis.into(),
            file: "facett/src/api.rs".into(),
            line: 1,
            doc_lines: 0,
            signature: sig.map(|x| x.into()),
        };
        let old = vec![
            s("render", "pub", Some("fn render(&self)")),
            s("open", "pub", Some("fn open() -> Self")),
            s("secret", "pub", Some("fn secret()")),
            s("internal", "pub(crate)", Some("fn internal()")),
        ];
        let new = vec![
            s("open", "pub", Some("fn open(path: &str) -> Self")),
            s("secret", "pub", Some("fn secret()")),
            s("added", "pub", Some("fn added()")),
            s("internal", "pub(crate)", Some("fn internal(x: u32)")),
        ];
        let delta = diff_exported_symbols(&old, &new);
        let by = |n: &str| delta.iter().find(|c| c.name == n).map(|c| c.change);
        let ok = by("render") == Some(ChangeKind::Removed)
            && by("open") == Some(ChangeKind::Changed)
            && by("secret").is_none()
            && by("added").is_none()
            && by("internal").is_none();

        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "diff_exported_symbols",
            ok,
            &format!("delta={:?}", delta.iter().map(|c| (&c.name, c.change)).collect::<Vec<_>>()),
        );
        assert!(ok, "delta wrong: {delta:?}");
    }
}

/// The warehouse orchestrators after Mímir v2: they answer what needs no call
/// graph and refuse the rest BY NAME — also over a warehouse that still holds the
/// old `symbol_facts` / `call_edges` rows (a warehouse made before v2).
#[cfg(test)]
mod warehouse_tests {
    use super::*;
    use crate::testkit::MockWarehouse;
    use nornir_warehouse_trait::CallEdgeRow;

    fn sym(cr: &str, name: &str) -> SymbolRow {
        SymbolRow {
            crate_name: cr.into(),
            module_path: format!("{cr}::api"),
            item_kind: "fn".into(),
            item_name: name.into(),
            visibility: "pub".into(),
            file: format!("{cr}/src/api.rs"),
            line: 1,
            doc_lines: 0,
            signature: None,
        }
    }
    fn call(cr: &str, caller: &str, callee: &str, file: &str, line: u32) -> CallEdgeRow {
        CallEdgeRow {
            crate_name: cr.into(),
            caller_path: caller.into(),
            callee_ident: callee.into(),
            call_kind: "call".into(),
            file: file.into(),
            line,
        }
    }

    fn graphs() -> Vec<RepoGraph> {
        let g = |repo: &str, produces: &str, deps: &[&str]| RepoGraph {
            repo: repo.into(),
            produces: BTreeSet::from([produces.to_string()]),
            deps: deps.iter().map(|s| s.to_string()).collect(),
            ..Default::default()
        };
        vec![
            g("facett", "facett", &[]),
            g("korp", "korp", &["facett"]),
            g("knut", "knut", &["facett"]),
            g("nornir", "nornir", &["facett"]),
        ]
    }

    fn seeded_wh() -> MockWarehouse {
        let wh = MockWarehouse::new();
        wh.seed_symbol_scan("korp", &[sym("korp", "marker")], &[
            call("korp", "korp::view::draw", "facett::render", "korp/src/view.rs", 40),
            call("korp", "korp::view::redraw", "render", "korp/src/view.rs", 88),
            call("korp", "korp::view::draw", "std::mem::swap", "korp/src/view.rs", 41),
        ]);
        wh.seed_symbol_scan("knut", &[sym("knut", "marker")], &[
            call("knut", "knut::ui::paint", "facett::render", "knut/src/ui.rs", 12),
        ]);
        wh.seed_symbol_scan("nornir", &[sym("nornir", "marker")], &[
            call("nornir", "nornir::boot::run", "facett::open", "nornir/src/boot.rs", 7),
        ]);
        wh.seed_symbol_scan("facett", &[sym("facett", "marker")], &[
            call("facett", "facett::api::render", "open", "facett/src/api.rs", 3),
        ]);
        wh
    }

    /// A bump with dependents needs their call graph. The old rows are sitting
    /// in the warehouse (`seeded_wh`) and would answer "3 call sites of
    /// `render`" — they are not read: nothing writes them any more, so they are
    /// not the call graph of the tree being released.
    #[test]
    fn compute_refuses_by_name_instead_of_reading_tables_nothing_writes() {
        let wh = seeded_wh();
        let changed = [ChangedSymbol::removed("render"), ChangedSymbol::changed("open")];
        let err = compute(&wh, &graphs(), "facett", &changed).expect_err("no call graph is produced");
        let typed = err.downcast_ref::<CallGraphNotProduced>().cloned();
        let text = format!("{err:#}");
        let ok = typed
            == Some(CallGraphNotProduced {
                target_repo: "facett".into(),
                dependents: vec!["knut".into(), "korp".into(), "nornir".into()],
            })
            && text.contains("NOT PRODUCED")
            && text.contains("call_edges")
            && text.contains("knut, korp, nornir");

        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "compute_refuses_not_produced",
            ok,
            &format!("typed={} text={text}", typed.is_some()),
        );
        assert!(ok, "the refusal must be typed and name the dependents + the tables: {text}");
    }

    /// The same refusal over an EMPTY warehouse (one made after v2): a missing
    /// producer and "no dependent calls it" are different answers.
    #[test]
    fn compute_refuses_over_an_empty_warehouse_too() {
        let wh = MockWarehouse::new();
        let err = compute(&wh, &graphs(), "facett", &[ChangedSymbol::changed("secret")])
            .expect_err("an empty warehouse is not an empty blast");
        let ok = err.downcast_ref::<CallGraphNotProduced>().is_some();
        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "compute_empty_warehouse_is_not_an_empty_blast",
            ok,
            &format!("{err:#}"),
        );
        assert!(ok, "{err:#}");
    }

    /// What needs no call graph is still answered: a repo nobody depends on (all
    /// its changes are unused by construction), and a bump that changed nothing.
    #[test]
    fn compute_answers_without_a_call_graph_where_none_is_needed() {
        let wh = MockWarehouse::new();
        let graphs = graphs();
        let leaf = compute(&wh, &graphs, "korp", &[ChangedSymbol::removed("marker")]).unwrap();
        let unchanged = compute(&wh, &graphs, "facett", &[]).unwrap();
        let ok = leaf.is_empty()
            && leaf.unused_changes.len() == 1
            && leaf.target_crates == vec!["korp".to_string()]
            && leaf.source.is_empty()
            && unchanged.is_empty()
            && unchanged.unused_changes.is_empty();
        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "compute_no_dependents_or_no_change",
            ok,
            &format!("leaf_unused={} unchanged_empty={}", leaf.unused_changes.len(), unchanged.is_empty()),
        );
        assert!(ok, "no dependents / no change → an empty blast that needed no call graph");
    }

    /// The gate input fails CLOSED: one repo that needs a call graph fails the
    /// whole map — never a map that silently leaves it out.
    #[test]
    fn compute_for_dirty_fails_closed_on_the_first_repo_that_needs_a_call_graph() {
        let wh = seeded_wh();
        let graphs = graphs();
        let render_change = [ChangedSymbol::removed("render")];
        let secret_change = [ChangedSymbol::changed("secret")];

        // korp alone has no dependents → answered, and (empty) left out.
        let only_leaf: Vec<(&str, &[ChangedSymbol])> = vec![("korp", &secret_change)];
        let map = compute_for_dirty(&wh, &graphs, only_leaf).unwrap();

        let with_facett: Vec<(&str, &[ChangedSymbol])> =
            vec![("korp", &secret_change), ("facett", &render_change)];
        let err = compute_for_dirty(&wh, &graphs, with_facett).expect_err("facett has dependents");
        let ok = map.is_empty()
            && err.downcast_ref::<CallGraphNotProduced>().map(|e| e.target_repo.as_str()) == Some("facett");
        nornir_testmatrix::functional_status(
            "release-semantic-blast",
            "compute_for_dirty_fails_closed",
            ok,
            &format!("leaf_map_len={} err={err:#}", map.len()),
        );
        assert!(ok, "{err:#}");
    }
}