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/api_skew.rs

//! PROACTIVE dependency-API-skew detection — a Gate-A leg that catches "my code
//! calls `dep::sym`, but the PUBLISHED version of `dep` doesn't have `sym`" in
//! milliseconds, BEFORE the multi-minute `cargo publish --verify` build discovers
//! it as a rustc `E0425 cannot find function … in crate <dep>`.
//!
//! The canonical case (release night, 2026-07-17, errors-book ch. XI-adjacent):
//! `nornir` called `skade::ingest_multi`, an API that existed locally in
//! `skade 0.5.1` but NOT in the published `skade 0.5.0` (`ingest_with` only). The
//! publish resolved against 0.5.0 and only failed after packaging + a full verify
//! compile. This module turns that into a static check: extract the symbols a
//! member references from a dep, extract the PUBLISHED dep's public API, and diff.
//!
//! The extractors are deliberately COARSE (a source scan, no full parse) — a first
//! cut that catches the real cases with zero heavy deps. A symbol reported missing
//! is a hard Gate-A blocker with an actionable fix ("publish `<dep>` first, or pin
//! the older API"); a symbol NOT reported missing is never a false *block* because
//! the reactive verify build remains the backstop. Precision over recall here: we
//! only flag a call `alias::sym(` (an actual use), and only when `sym` is absent
//! from the published API's exported names.

use std::collections::BTreeSet;

/// The set of symbols `referenced` from a dep that are ABSENT from the dep's
/// `published_api` — i.e. the skew. Empty ⇒ no skew (safe to publish against the
/// published dep). Pure set difference; the heart of the check.
pub fn missing_symbols(
    referenced: &BTreeSet<String>,
    published_api: &BTreeSet<String>,
) -> BTreeSet<String> {
    referenced.difference(published_api).cloned().collect()
}

/// Scan a member's Rust source for the symbols it references from crate `dep_alias`
/// — every `alias::Ident` occurrence, capturing `Ident`. Coarse (no macro/parse):
/// matches `dep_alias` as a path segment followed by `::` and a leading identifier,
/// requiring a non-identifier char (or start) before `dep_alias` so `foo_skade::x`
/// does not match `skade::x`. Trailing `::` chains (`skade::write::foo`) capture the
/// FIRST segment (`write`) — the item directly under the crate root, which is what a
/// missing-item check compares against a coarse public-API scan.
pub fn referenced_symbols(source: &str, dep_alias: &str) -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    let needle = format!("{dep_alias}::");
    let bytes = source.as_bytes();
    let mut from = 0usize;
    while let Some(rel) = source[from..].find(&needle) {
        let at = from + rel;
        from = at + needle.len();
        // boundary before `dep_alias`: must not be part of a longer identifier.
        if at > 0 {
            let prev = bytes[at - 1];
            if prev == b'_' || prev.is_ascii_alphanumeric() {
                continue;
            }
        }
        // capture the identifier right after `::`.
        let rest = &source[from..];
        let ident: String = rest
            .chars()
            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
            .collect();
        if !ident.is_empty() && ident.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') {
            out.insert(ident);
        }
    }
    out
}

/// A crate's exported surface as the coarse scan can see it: the names, AND the one
/// fact that decides whether that list is COMPLETE. The two travel together because
/// they are read together — every consumer that trusts an absence in `names` must
/// first ask `has_glob_reexport`, and a struct makes forgetting impossible where two
/// separate calls made it easy (dwarves' `preflight::api_skew_issues` forgot).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PublicApi {
    /// Exported item names extracted from the source text.
    pub names: BTreeSet<String>,
    /// The source re-exports through a glob (`pub use x::*`, including inside a
    /// use-group). The surface is then NOT enumerable from a coarse scan, so an
    /// absence from `names` is not evidence of absence from the API.
    pub has_glob_reexport: bool,
}

impl PublicApi {
    /// Can an absence from [`Self::names`] be trusted as "the crate does not export
    /// this"? Only when nothing is glob-re-exported. The single question every
    /// api-skew consumer must ask before turning an absence into a block.
    pub fn is_enumerable(&self) -> bool {
        !self.has_glob_reexport
    }
}

/// Coarse scan of a crate's Rust source (typically the concatenated `src/**/*.rs`
/// of a published tarball) for its EXPORTED public API names: `pub fn NAME`,
/// `pub struct NAME`, `pub enum NAME`, `pub trait NAME`, `pub const NAME`,
/// `pub static NAME`, `pub type NAME`, `pub mod NAME`, and every name a `pub use`
/// exports — including braced groups, nested groups, `as` aliases, `self`, and
/// groups rustfmt wrapped over several lines. Over-collects rather than under —
/// a name present in the API is never a false skew. `pub(crate)`/`pub(super)` are
/// NOT exported and are excluded.
///
/// THE ONE WRITER for "what does this crate export, and is that list complete?".
/// [`public_api_names`] and [`public_api_has_glob_reexport`] are thin views of it;
/// a consumer needing both should call this once.
pub fn public_api(rust_src: &str) -> PublicApi {
    let mut api = PublicApi::default();
    // A `pub use` may be WRAPPED over several lines by rustfmt, and a statement split
    // across lines is exactly as invisible to a line-by-line scanner as a braced group
    // was. Join a `pub use` to its terminating `;` before parsing it. `MAX_JOIN` caps
    // the damage if a `;` never arrives (a macro body, a truncated file) so an
    // unbalanced brace can never swallow the rest of the crate.
    const MAX_JOIN: usize = 128;
    let mut pending: Option<String> = None;
    let mut joined = 0usize;
    for raw in rust_src.lines() {
        // `Some` once a wrapped `pub use` has just been completed by this line.
        let mut completed: Option<String> = None;
        if pending.is_some() {
            let piece = strip_line_comment(raw).trim();
            let done = piece.contains(';');
            let buf = pending.as_mut().expect("pending is Some");
            buf.push(' ');
            buf.push_str(piece);
            joined += 1;
            if done || joined >= MAX_JOIN {
                completed = pending.take();
            } else {
                continue;
            }
        } else {
            let t = raw.trim_start();
            if t.starts_with("pub use ") {
                let piece = strip_line_comment(t).trim_end();
                if !piece.contains(';') {
                    pending = Some(piece.to_string());
                    joined = 0;
                    continue;
                }
            }
        }
        let line = completed.as_deref().unwrap_or(raw).trim_start();
        scan_public_line(line, &mut api);
    }
    // A file that ends mid-statement still gets its names read.
    if let Some(buf) = pending.take() {
        scan_public_line(buf.trim_start(), &mut api);
    }
    api
}

/// [`public_api`]'s names only — the historical entry point.
pub fn public_api_names(rust_src: &str) -> BTreeSet<String> {
    public_api(rust_src).names
}

/// Everything one already-joined logical line contributes to the API.
fn scan_public_line(line: &str, api: &mut PublicApi) {
    // Exported macros: `macro_rules! NAME` (public via `#[macro_export]`, callable as
    // `dep::NAME!`). Not `pub`-prefixed, so match before the `pub ` gate below —
    // otherwise a member's `dep::some_macro!` would be a FALSE api-skew.
    if let Some(rest) = line.strip_prefix("macro_rules!") {
        if let Some(name) = leading_ident(rest.trim_start()) {
            api.names.insert(name);
        }
        return;
    }
    // Only crate-visible `pub` (exclude `pub(crate)`, `pub(super)`, `pub(in …)`).
    let Some(after_pub) = line.strip_prefix("pub ") else { return };
    let mut after_pub = after_pub.trim_start();
    // Strip modifiers that sit between `pub` and the item keyword: `pub async fn`,
    // `pub unsafe fn`, `pub default fn`, and `pub const fn` (const as a MODIFIER,
    // i.e. only when followed by `fn`). A bare `pub const NAME` is left intact so
    // the `const ` arm below still captures the const's name.
    loop {
        let t = after_pub.trim_start();
        if let Some(r) = ["async ", "unsafe ", "default "].iter().find_map(|m| t.strip_prefix(m)) {
            after_pub = r;
        } else if let Some(r) = t.strip_prefix("const ").filter(|r| r.trim_start().starts_with("fn ")) {
            after_pub = r;
        } else {
            break;
        }
    }
    let after_pub = after_pub.trim_start();
    for kw in ["fn ", "struct ", "enum ", "trait ", "const ", "static ", "type ", "mod "] {
        if let Some(rest) = after_pub.strip_prefix(kw) {
            if let Some(name) = leading_ident(rest.trim_start()) {
                api.names.insert(name);
            }
        }
    }
    // A `pub use` exports the WHOLE use-tree, not just its last segment. Everything
    // up to the terminating `;` is one tree; parse it properly.
    if let Some(rest) = after_pub.strip_prefix("use ") {
        let tree = rest.split(';').next().unwrap_or(rest);
        collect_use_tree(tree, None, api);
    }
}

/// Every name a use-tree exports. `inherited_last` is the last segment of the
/// enclosing group's prefix — what a `self` inside that group names.
///
/// `a::b::{self, c, d::E, nested::{f, g as h}}` ⇒ `b`, `c`, `E`, `f`, `h`.
fn collect_use_tree<'a>(tree: &'a str, inherited_last: Option<&'a str>, api: &mut PublicApi) {
    let tree = tree.trim();
    if tree.is_empty() {
        return;
    }
    let Some(open) = tree.find('{') else {
        return collect_use_leaf(tree, inherited_last, api);
    };
    let Some(close) = matching_brace(tree, open) else {
        // Unbalanced (a truncated join, a macro fragment): read nothing rather than
        // guess. Under-collecting a name only ever risks a skew the verify build
        // catches; MIS-collecting one would hide a real skew.
        return;
    };
    // `self` inside this group names the group's own prefix, e.g. `a::b::{self}` ⇒ `b`.
    let last = last_segment(&tree[..open]).or(inherited_last);
    for item in split_top_level(&tree[open + 1..close]) {
        collect_use_tree(item, last, api);
    }
}

/// One leaf of a use-tree (no braces): a path, an `as` rename, `self`, or a glob.
fn collect_use_leaf<'a>(leaf: &'a str, inherited_last: Option<&'a str>, api: &mut PublicApi) {
    let leaf = leaf.trim();
    if leaf.is_empty() {
        return;
    }
    // `x as Y` exports `Y`, never `x`. (`x as _` exports no nameable item at all.)
    if let Some((_, alias)) = leaf.rsplit_once(" as ") {
        if let Some(name) = leading_ident(alias.trim()) {
            api.names.insert(name);
        }
        return;
    }
    // A GLOB makes the surface non-enumerable. This is the single place that fact is
    // decided — it holds for `pub use x::*;` and for a `*` buried in a group alike.
    if leaf.ends_with('*') {
        api.has_glob_reexport = true;
        return;
    }
    let last = leaf.trim_end_matches(':').rsplit("::").next().unwrap_or(leaf).trim();
    if last == "self" {
        if let Some(name) = inherited_last.and_then(leading_ident) {
            api.names.insert(name);
        }
        return;
    }
    if let Some(name) = leading_ident(last) {
        api.names.insert(name);
    }
}

/// The last `::`-segment of a use-tree prefix (`"::gatling::"` ⇒ `"gatling"`), or
/// `None` when the prefix is empty (a bare `{…}` group).
fn last_segment(prefix: &str) -> Option<&str> {
    let p = prefix.trim().trim_end_matches(':');
    p.rsplit("::").next().map(str::trim).filter(|s| !s.is_empty())
}

/// Byte index of the `}` closing the `{` at `open`, honouring nesting.
fn matching_brace(s: &str, open: usize) -> Option<usize> {
    let mut depth = 0usize;
    for (i, c) in s.char_indices().filter(|(i, _)| *i >= open) {
        match c {
            '{' => depth += 1,
            '}' => {
                depth = depth.checked_sub(1)?;
                if depth == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }
    }
    None
}

/// Split on commas at brace-depth 0, so a nested group stays one item.
fn split_top_level(s: &str) -> Vec<&str> {
    let mut out = Vec::new();
    let (mut depth, mut start) = (0usize, 0usize);
    for (i, c) in s.char_indices() {
        match c {
            '{' => depth += 1,
            '}' => depth = depth.saturating_sub(1),
            ',' if depth == 0 => {
                out.push(&s[start..i]);
                start = i + 1;
            }
            _ => {}
        }
    }
    out.push(&s[start..]);
    out
}

/// `s` up to a `//` line comment. Applied only to `pub use` lines and their wrapped
/// continuations, where a `//` can never be anything but a comment.
fn strip_line_comment(s: &str) -> &str {
    match s.find("//") {
        Some(i) => &s[..i],
        None => s,
    }
}

/// The leading `[A-Za-z_][A-Za-z0-9_]*` of `s`, if any (stops at `<`, `(`, `{`,
/// `:`, whitespace, etc.). Rust identifiers start with a letter or `_`.
fn leading_ident(s: &str) -> Option<String> {
    let first = s.chars().next()?;
    if !(first.is_ascii_alphabetic() || first == '_') {
        return None;
    }
    let id: String = s.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
    (!id.is_empty()).then_some(id)
}

/// One member↔dep skew finding for the Gate-A report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiSkew {
    pub member: String,
    pub dep: String,
    pub published_version: String,
    /// Symbols the member calls that the published dep does not export.
    pub missing: BTreeSet<String>,
}

impl ApiSkew {
    /// Actionable Gate-A message: what breaks and how to fix it.
    pub fn explain(&self) -> String {
        let mut miss: Vec<&String> = self.missing.iter().collect();
        miss.sort();
        let list = miss.iter().map(|s| format!("`{s}`")).collect::<Vec<_>>().join(", ");
        format!(
            "API-skew: `{}` calls {} from `{}`, but published `{} {}` does not export {}. \
             Publish `{}` first (with the new API) or pin the older API.",
            self.member, list, self.dep, self.dep, self.published_version, list, self.dep
        )
    }
}

/// Compute the skew for one (member, dep) pair from already-extracted inputs — the
/// pure, wire-free core the Gate-A leg calls after fetching + extracting. Returns
/// `None` when there is no skew.
pub fn assess_pair(
    member: &str,
    dep: &str,
    published_version: &str,
    member_source: &str,
    dep_alias: &str,
    published_dep_source: &str,
) -> Option<ApiSkew> {
    // NEVER A FALSE BLOCK: if the published dep re-exports through a glob (`pub use x::*`),
    // its public surface is NOT fully enumerable from a coarse source scan — a symbol we'd
    // call "missing" might be glob-re-exported. In that case the check is INCONCLUSIVE, so
    // emit no skew and let the verify build be the backstop. (A precise warehouse-native
    // check over the published crate's rustdoc-json/SCIP API removes this limitation.)
    let published = public_api(published_dep_source);
    if !published.is_enumerable() {
        return None;
    }
    let referenced = referenced_symbols(member_source, dep_alias);
    let missing = missing_symbols(&referenced, &published.names);
    (!missing.is_empty()).then(|| ApiSkew {
        member: member.to_string(),
        dep: dep.to_string(),
        published_version: published_version.to_string(),
        missing,
    })
}

/// True if the source contains a glob re-export (`pub use …::*;`, including one nested
/// inside a use-group or wrapped onto its own line), which makes the crate's public API
/// NOT fully enumerable by [`public_api_names`] — the signal to treat an api-skew
/// verdict as INCONCLUSIVE rather than risk a false block.
///
/// A view of [`public_api`], which is where the glob is actually seen: the same parse
/// that finds a name is the one that finds the glob, so the two can never disagree
/// about a use-group. Prefer calling [`public_api`] once if you also need the names.
pub fn public_api_has_glob_reexport(rust_src: &str) -> bool {
    public_api(rust_src).has_glob_reexport
}

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

    fn set(items: &[&str]) -> BTreeSet<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    /// THE skade case: nornir calls `skade::ingest_multi`, published skade has only
    /// `ingest_with`. Must flag `ingest_multi` (and nothing else).
    #[test]
    fn skade_ingest_multi_is_flagged() {
        let member_src = r#"
            let (_a, stats) = skade::ingest_multi(catalog, tables, batches, 8)?;
            let x = skade::ingest_with(catalog, table, batches, 8)?;
        "#;
        let published_skade = r#"
            pub async fn ingest_with(catalog: &dyn Catalog) -> Result<()> { Ok(()) }
            pub struct IceTable;
            pub(crate) fn hidden_helper() {}
        "#;
        let skew = assess_pair("nornir", "skade", "0.5.0", member_src, "skade", published_skade)
            .expect("skew must be detected");
        assert_eq!(skew.missing, set(&["ingest_multi"]), "only the truly-missing symbol");
        assert!(skew.explain().contains("ingest_multi"));
        assert!(skew.explain().contains("Publish `skade` first"));
    }

    /// No skew when the published dep exports everything the member calls.
    #[test]
    fn no_skew_when_published_has_the_api() {
        let member_src = "let _ = skade::ingest_with(c, t, b, 8)?;";
        let published = "pub async fn ingest_with() {}\npub fn ingest_multi() {}";
        assert!(assess_pair("nornir", "skade", "0.5.1", member_src, "skade", published).is_none());
    }

    /// A longer identifier must not falsely match the alias (`foo_skade::x` ≠ `skade::x`),
    /// and only the first path segment is captured from a chain.
    #[test]
    fn boundary_and_chain_capture() {
        let refs = referenced_symbols("foo_skade::nope; skade::write; a.skade::hit()", "skade");
        assert!(refs.contains("write"), "captures the first chain segment");
        assert!(refs.contains("hit"));
        assert!(!refs.contains("nope"), "must not match inside foo_skade");
    }

    /// `pub(crate)` / `pub(super)` are NOT part of the exported API.
    #[test]
    fn crate_private_is_not_exported() {
        let api = public_api_names("pub fn shown() {}\npub(crate) fn hidden() {}\npub(super) struct H;");
        assert_eq!(api, set(&["shown"]));
    }

    /// `pub use` re-exports (with and without `as`) count as exported names.
    #[test]
    fn pub_use_reexports_counted() {
        let api = public_api_names("pub use inner::Thing;\npub use inner::other as Alias;");
        assert!(api.contains("Thing"));
        assert!(api.contains("Alias"));
    }

    /// FALSE-BLOCK FIX: `#[macro_export] macro_rules! NAME` is part of the callable API
    /// (`dep::NAME!`). A member calling an exported macro must NOT raise a skew.
    #[test]
    fn exported_macros_are_in_the_api() {
        let api = public_api_names("#[macro_export]\nmacro_rules! ensure_thing { () => {} }");
        assert!(api.contains("ensure_thing"), "exported macro must be in the API: {api:?}");
        // and end-to-end: a member using it is not flagged.
        let src = "let _ = skade::ensure_thing!();";
        let pub_src = "#[macro_export]\nmacro_rules! ensure_thing { () => {} }";
        assert!(assess_pair("m", "skade", "0.5.0", src, "skade", pub_src).is_none());
    }

    /// FALSE-BLOCK FIX (znippy-zoomies 0.1.22, 2026-08-02): a BRACED use-group exports
    /// every name in the group. The old scanner `rsplit("::")`'d the statement, saw
    /// `{background, …}`, and `leading_ident` returned `None` on the `{` — so all six
    /// re-exports vanished and the gate raised a NON-forceable `block_correctness`
    /// saying published `znippy-zoomies 0.1.22` "does not export `gatling`". It does.
    /// This is the exact published `src/lib.rs:55`, verbatim.
    #[test]
    fn braced_use_group_exports_every_name() {
        let api = public_api_names(
            "pub use ::gatling::{background, chunk_revolver, gatling, gatling_bfs, gatling_forkjoin, gatling_sort};",
        );
        for want in [
            "background",
            "chunk_revolver",
            "gatling",
            "gatling_bfs",
            "gatling_forkjoin",
            "gatling_sort",
        ] {
            assert!(api.contains(want), "braced group must export `{want}`: {api:?}");
        }
        // and end-to-end: the member calling `gatling` must NOT be flagged.
        let member = "let _ = znippy_zoomies::gatling(x);";
        let published =
            "pub use ::gatling::{background, chunk_revolver, gatling, gatling_bfs, gatling_forkjoin, gatling_sort};";
        assert!(
            assess_pair("m", "znippy-zoomies", "0.1.22", member, "znippy_zoomies", published)
                .is_none(),
            "published 0.1.22 DOES export `gatling` — no skew"
        );
    }

    /// A use-group WRAPPED over several lines by rustfmt is exactly as invisible to a
    /// line-by-line scanner as a braced one. The statement is joined to its `;` first.
    #[test]
    fn multiline_use_group_is_not_invisible() {
        let api = public_api_names(
            "pub use crate::inner::{\n    Alpha,\n    beta,\n    Gamma as Delta,\n};\npub fn after() {}",
        );
        assert!(api.contains("Alpha"), "{api:?}");
        assert!(api.contains("beta"), "{api:?}");
        assert!(api.contains("Delta"), "alias is the exported name: {api:?}");
        assert!(!api.contains("Gamma"), "the renamed-from name is NOT exported: {api:?}");
        assert!(api.contains("after"), "the scanner must resume after the joined stmt: {api:?}");
    }

    /// Nested groups, `self`, and inner aliases all name real exports.
    #[test]
    fn nested_groups_self_and_aliases() {
        let api = public_api_names("pub use a::b::{self, c, d::E, nested::{f, g as h}};");
        assert!(api.contains("b"), "`self` exports the prefix's last segment: {api:?}");
        assert!(api.contains("c"), "{api:?}");
        assert!(api.contains("E"), "{api:?}");
        assert!(api.contains("f"), "nested group: {api:?}");
        assert!(api.contains("h"), "nested alias: {api:?}");
        assert!(!api.contains("g"), "renamed-from is not exported: {api:?}");
    }

    /// A `*` INSIDE a group (or on a wrapped line) is still a glob: the surface is not
    /// enumerable, so the verdict must be INCONCLUSIVE, never a block. The old
    /// line-`ends_with("::*")` check saw neither shape.
    #[test]
    fn glob_inside_a_group_is_inconclusive() {
        assert!(
            public_api_has_glob_reexport("pub use a::{b, c::*};"),
            "a glob nested in a group is still a glob"
        );
        assert!(
            public_api_has_glob_reexport("pub use a::{\n    b,\n    c::*,\n};"),
            "a glob on a wrapped line is still a glob"
        );
        assert!(
            assess_pair("m", "dep", "1.0.0", "let _ = dep::mystery();", "dep", "pub use a::{b, c::*};")
                .is_none(),
            "grouped glob => inconclusive, must not block"
        );
    }

    /// FALSE-BLOCK FIX: a glob re-export (`pub use inner::*`) makes the published API
    /// non-enumerable, so a would-be skew is INCONCLUSIVE — assess_pair must return None
    /// (never a false block), even though `mystery` is not explicitly listed.
    #[test]
    fn glob_reexport_is_inconclusive_never_a_false_block() {
        assert!(public_api_has_glob_reexport("pub use inner::*;"));
        let src = "let _ = dep::mystery();";
        let pub_src = "pub use inner::*;\npub fn known() {}";
        assert!(
            assess_pair("m", "dep", "1.0.0", src, "dep", pub_src).is_none(),
            "glob re-export => inconclusive, must not block"
        );
    }
}