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