src/code_room/reader/blast.rs
//! The semantic blast of a pending change — the release doctor's question
//! (decision D3, wiring map I3).
//!
//! ```text
//! the member's DIRTY tree
//! → dry-run dump (nothing written) → delta against the open set
//! → the symbols of the member the tree CHANGES or REMOVES
//! → who outside the member names them (one hop back over the Jikes
//! kinds + READS_FIELD), with the sites
//! → how far it goes (the Jikes walk: `Room::dependents`)
//! ```
//!
//! What a tree only ADDS breaks nobody and is not in the answer.
//!
//! A version bump keeps every IDENTITY (stable ids since mimir-schema 5:
//! the version is payload, not identity): the bump re-emits each node as
//! CHANGED, and the blast counts those as unchanged when the tree's raw id
//! carries another version than the room's. Removals are exact under a
//! bump; a change of a surviving item's type alone is then not told apart
//! (said in the notes).
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use mimir_ingest::DryDelta;
use mimir_room::{EdgeSel, JIKES_KINDS, MaskSpec, Room, Scope};
use mimir_schema::{EdgeKind, Level, Origin, kind as nk, moniker};
use super::super::{Reach, Refusal, State};
use super::wired::{LOWER_BOUND, internal};
use super::{BlastSite, BlastSymbol, PendingBlast, TreeDriver};
/// [`super::pending_blast`], linked.
pub fn pending_blast(reach: &Reach<'_>, driver: &TreeDriver, member: &str, tree: &Path, dependents: &[String]) -> Result<PendingBlast, Refusal> {
let handle = reach.code_room()?;
// every repo whose sites the answer must hold has to be in the room: a
// dependent nobody consumed would read as "nobody calls it"
let log = handle.log()?;
let offsets = log.block_on(async { log.pin().await?.offsets().await }).map_err(internal)?;
let unread: Vec<&str> = std::iter::once(member).chain(dependents.iter().map(String::as_str)).filter(|r| !offsets.contains_key(*r)).collect();
if !unread.is_empty() {
return Err(Refusal::new(
State::NotConsumed,
format!(
"semantic blast of `{member}` in `{}`: not consumed: {} — the change is diffed against `{member}`'s consumed state and its sites are read from every repo that depends on it",
handle.workspace(),
unread.join(", ")
),
));
}
let delta = match driver {
TreeDriver::Unavailable(why) => return Err(why.clone()),
TreeDriver::Ready(ready) => handle.dry_run(ready, member, tree)?,
TreeDriver::Given(dump) => handle.dry_delta_of(member, || dump(member, tree))?,
};
let room = handle.room(&Scope::current())?;
blast_of(&room, member, &delta)
}
/// What `delta` (a dry run of `member`'s tree) breaks, read from `room`.
pub fn blast_of(room: &Room, member: &str, delta: &DryDelta) -> Result<PendingBlast, Refusal> {
let own = room.mask(&MaskSpec { repos: vec![member.to_string()], origins: vec![Origin::Local], defined_only: true, ..Default::default() }).map_err(internal)?.value;
let mut rekeyed = 0usize;
// ordinal → removed (a change otherwise)
let mut touched: BTreeMap<u64, bool> = BTreeMap::new();
let mut touch = |o: u64, removed: bool| {
let was = touched.entry(o).or_insert(removed);
*was |= removed;
};
for (ids, removed) in [(&delta.nodes.changed, false), (&delta.nodes.removed, true)] {
for id in ids {
// the delta keys by STABLE ids: the room's node(s) of that form.
// A stub of another crate's item in the member's dump is not the
// member's symbol.
let Some(o) = room.resolve(id).into_iter().find(|o| own.contains(*o)) else { continue };
// a changed node whose TREE id carries another version is the
// version bump itself: the payload moved with the version, which
// breaks nobody (a real change riding the bump is not told apart)
if !removed
&& let Some(raw) = delta.raw_nodes.get(id)
&& let Some((_, tree_v, _)) = moniker::parse(raw)
&& room.node(o).version.is_some_and(|v| v != tree_v)
{
rekeyed += 1;
continue;
}
let v = room.node(o);
match (v.level, v.kind, v.parent) {
// a param / generic param is its fn's signature: the fn changed
(Some(Level::Part), Some(nk::PARAM | nk::GENERIC), Some(p)) => touch(p, false),
// a field / variant is named on its own, and its type changed with it
(Some(Level::Part), _, Some(p)) => {
touch(o, removed);
touch(p, false);
}
_ => touch(o, removed),
}
}
}
let mut out = PendingBlast { member: member.to_string(), failed_units: delta.failed_units.clone(), ..Default::default() };
out.crates = touched.keys().filter_map(|o| room.node(*o).krate).map(str::to_string).collect::<BTreeSet<_>>().into_iter().collect();
if rekeyed > 0 {
out.notes.push(format!(
"the tree moves a version: {rekeyed} node(s) keep their identity under the new one and count as unchanged — a removal is exact, a change of a surviving item's type alone is not told apart (the version is payload, not identity)"
));
}
if touched.is_empty() {
return Ok(out);
}
let seeds: Vec<u64> = touched.keys().copied().collect();
let seed_mask = room.mask_of(&seeds);
let mut kinds = JIKES_KINDS.to_vec();
kinds.push(EdgeKind::ReadsField);
let edges = room.edges(&EdgeSel { to: Some(&seed_mask), ..EdgeSel::kinds(&kinds) }).map_err(internal)?;
let mut sites: BTreeMap<u64, Vec<BlastSite>> = BTreeMap::new();
for e in &edges.value.edges {
let from = room.node(e.from);
if from.repo == Some(member) {
// the member's own callers were compiled by the dry run itself
continue;
}
let site = |file: &str, line: u32| BlastSite {
repo: from.repo.unwrap_or_default().to_string(),
crate_name: from.krate.unwrap_or_default().to_string(),
caller: room.display_path(e.from),
kind: e.kind.as_str().to_string(),
file: file.to_string(),
line,
};
let at = room.sites(e.from, e.to, e.kind);
let list = sites.entry(e.to).or_default();
if at.is_empty() {
// an edge without a positioned site: where its source stands
list.push(site(from.file.unwrap_or_default(), from.start_line.unwrap_or(0)));
}
list.extend(at.iter().map(|s| site(s.file, s.start_line)));
}
for (o, removed) in &touched {
let mut at = sites.remove(o).unwrap_or_default();
at.sort_by(|a, b| (&a.repo, &a.crate_name, &a.file, a.line, &a.caller).cmp(&(&b.repo, &b.crate_name, &b.file, b.line, &b.caller)));
out.symbols.push(BlastSymbol { symbol: room.display_path(*o), id: room.moniker(*o).to_string(), removed: *removed, sites: at });
}
out.symbols.sort_by(|a, b| b.sites.len().cmp(&a.sites.len()).then(b.removed.cmp(&a.removed)).then(a.symbol.cmp(&b.symbol)));
// how far it goes: everything that transitively calls / uses / implements it
let walk = room.dependents(&seeds[..], None).map_err(internal)?;
let outside: Vec<u64> = walk.value.reached.iter().copied().filter(|o| room.node(*o).repo != Some(member)).collect();
out.dependents = outside.len() as u64;
out.dependent_repos = outside.iter().filter_map(|o| room.node(*o).repo).map(str::to_string).collect::<BTreeSet<_>>().into_iter().collect();
out.notes.extend(walk.evidence.missing.iter().chain(&edges.evidence.missing).map(|m| format!("missing {}: {}", m.input, m.why)).collect::<BTreeSet<_>>());
out.notes.push(LOWER_BOUND.to_string());
Ok(out)
}
#[cfg(test)]
mod tests {
//! The `shop` room (shop consumed to seq 2, web calls `shop::checkout`)
//! against dumps of an uncommitted `shop` tree.
use mimir_schema::NodeRow;
use super::super::fixture::{self, shop::*};
use super::*;
fn reach() -> Reach<'static> {
Reach::room_only(&fixture::shop().handle)
}
/// The dump step of a `shop` tree at `version` whose rows went through `edit`.
fn tree(version: &'static str, edit: impl Fn(&mut NodeRow) -> bool + Send + Sync + 'static) -> TreeDriver {
TreeDriver::Given(Box::new(move |_, _| Ok(shop_tree(version, &edit))))
}
fn blast(driver: &TreeDriver) -> PendingBlast {
pending_blast(&reach(), driver, SHOP, Path::new("/nowhere"), &[WEB.to_string()]).unwrap_or_else(|r| panic!("refused [{}] {r}", r.state))
}
fn the_one_site() -> BlastSite {
BlastSite { repo: WEB.into(), crate_name: "web".into(), caller: "web::render".into(), kind: "CALLS".into(), file: "src/lib.rs".into(), line: 5 }
}
#[test]
fn a_changed_public_fn_yields_the_sites_that_name_it_and_its_dependents() {
let b = blast(&tree("0.1.0", |n| {
if n.id == m(CHECKOUT) {
n.ty = Some("fn(Cart) -> Receipt".into());
}
true
}));
assert_eq!(b.symbols, vec![BlastSymbol { symbol: "shop::checkout".into(), id: m(CHECKOUT), removed: false, sites: vec![the_one_site()] }]);
assert_eq!((b.dependents, b.dependent_repos.clone(), b.crates.clone()), (1, vec![WEB.to_string()], vec!["shop".to_string()]));
assert!(b.failed_units.is_empty());
assert!(b.notes.iter().any(|n| n.contains("lower bound")), "{:?}", b.notes);
}
#[test]
fn a_removed_fn_is_removed_with_its_sites() {
let gone = m(CHECKOUT);
let b = blast(&tree("0.1.0", move |n| n.id != gone));
assert_eq!(b.symbols.iter().map(|s| (s.symbol.as_str(), s.removed, s.sites.len())).collect::<Vec<_>>(), vec![("shop::checkout", true, 1)]);
assert_eq!(b.symbols[0].sites, vec![the_one_site()]);
}
/// The tree is dirty, but nothing a node carries moved: an EMPTY blast.
#[test]
fn an_untouched_tree_is_an_empty_blast_not_a_refusal() {
let b = blast(&tree("0.1.0", |_| true));
assert!(b.symbols.is_empty() && b.failed_units.is_empty(), "{b:?}");
assert_eq!((b.dependents, b.member.as_str()), (0, SHOP));
}
/// Nobody outside the member names a private fn: it is listed, with no site.
#[test]
fn a_change_nobody_outside_names_has_no_site() {
let b = blast(&tree("0.1.0", |n| {
if n.id == m(CART_OPEN) {
n.doc = Some("Open a cart.".into());
n.has_doc = Some(true);
}
true
}));
assert_eq!(b.symbols.iter().map(|s| (s.symbol.as_str(), s.removed, s.sites.len())).collect::<Vec<_>>(), vec![("shop::cart::open", false, 0)]);
// inside the member `Cart::total` calls it, and web reaches that through checkout
assert_eq!(b.dependent_repos, vec![WEB.to_string()]);
}
/// A param is its fn's signature; a field is named on its own, and its struct changed with it.
#[test]
fn a_param_lifts_to_its_fn_and_a_field_to_its_struct() {
let b = blast(&tree("0.1.0", |n| {
if n.id == m(ADD_ITEM) {
n.ty = Some("&Item".into());
}
true
}));
assert_eq!(b.symbols.iter().map(|s| (s.symbol.as_str(), s.removed)).collect::<Vec<_>>(), vec![("shop::cart::Cart::add", false)]);
let gone = m(ITEMS);
let b = blast(&tree("0.1.0", move |n| n.id != gone));
let mut got: Vec<(&str, bool)> = b.symbols.iter().map(|s| (s.symbol.as_str(), s.removed)).collect();
got.sort();
assert_eq!(got, vec![("shop::cart::Cart", false), ("shop::cart::Cart::items", true)]);
}
/// A version bump moves every id of the crate: what keeps its path is the
/// same item, what does not come back is removed.
#[test]
fn a_version_bump_alone_removes_nothing() {
let b = blast(&tree("0.2.0", |_| true));
assert!(b.symbols.is_empty(), "{:?}", b.symbols);
assert!(b.notes.iter().any(|n| n.contains("moves a version") && n.contains("a removal is exact")), "{:?}", b.notes);
let gone = m(CHECKOUT);
let b = blast(&tree("0.2.0", move |n| n.id != gone));
assert_eq!(b.symbols.iter().map(|s| (s.symbol.as_str(), s.removed, s.sites.clone())).collect::<Vec<_>>(), vec![("shop::checkout", true, vec![the_one_site()])]);
}
#[test]
fn every_state_without_an_answer_is_a_refusal() {
let same = || tree("0.1.0", |_| true);
let at = Path::new("/nowhere");
let state = |r: Result<PendingBlast, Refusal>| r.expect_err("must refuse").state;
// no room in reach of the door
assert_eq!(state(pending_blast(&Reach::none(), &same(), SHOP, at, &[])), State::NoRoom);
// nothing consumed at all, the member not consumed, a dependent not consumed
let fresh = Reach::room_only(&fixture::unconsumed().handle);
assert_eq!(state(pending_blast(&fresh, &same(), SHOP, at, &[])), State::NotConsumed);
assert_eq!(state(pending_blast(&reach(), &same(), "facett", at, &[])), State::NotConsumed);
let r = pending_blast(&reach(), &same(), SHOP, at, &[WEB.to_string(), "ghost".to_string()]).unwrap_err();
assert_eq!(r.state, State::NotConsumed);
assert!(r.note.contains("ghost") && !r.note.contains("web,"), "names exactly what is not consumed: {r}");
// no driver on this box
let none = TreeDriver::Unavailable(Refusal::new(State::DriverNotAvailable, "the Mímir driver is not installed"));
assert_eq!(state(pending_blast(&reach(), &none, SHOP, at, &[])), State::DriverNotAvailable);
// a tree with units that did not compile answers, and says so: its caller refuses
let broken = TreeDriver::Given(Box::new(|_, _| {
let mut d = shop_tree("0.1.0", |_| true);
d.failed_units.insert("shop:lib".into());
Ok(d)
}));
assert_eq!(blast(&broken).failed_units, vec!["shop:lib".to_string()]);
}
}