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:#}");
}
}