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

nordisk/nornir

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()]);
    }
}