crates/nornir-release-core/src/model.rs
//! Shared release-doctor PODs + the PURE closure the un-deferred partials need.
//!
//! Relocated from `nornir/src/release/{doctor,preflight}.rs`: the engine-agnostic,
//! warehouse-FREE data types and the pure functions over them. Everything here is
//! I/O only insofar as it walks `Cargo.toml` files on disk (no warehouse, no
//! network, no `nornir` dep) — the same closure the doctor/promote/undo/depdag/
//! root_cause legs reason against.
//!
//! What lives here (and why it's pure):
//! * skew PODs [`SkewStatus`]/[`RepoCrateStatus`]/[`CrateSkew`] — the doctor's
//! external-dependency version-skew advice, consumed by
//! [`cargo::apply_skew_bumps`](crate::cargo) +
//! [`undo::record_skew_bumps`](crate::undo);
//! * the patch-fork promote gate ([`ForkKind`]/[`PatchForkBlock`]/[`HeldFork`]/
//! [`HeldExplanation`]/[`PromoteBlockResult`] + [`compute_promote_block`]) — a
//! filesystem-only scan that classifies which crates ride a FOREIGN
//! `[patch.crates-io]` fork and so can't crates.io-publish, consumed by
//! [`promote::run`](crate::promote);
//! * [`RepoGraph`] — the per-repo cross-repo dependency facts, consumed by
//! [`depdag`](crate::depdag);
//! * the preflight [`Issue`]/[`IssueKind`] PODs, consumed by
//! [`root_cause`](crate::root_cause).
//!
//! The warehouse-COUPLED doctor ENGINE (the `run_with_warehouse_*` entry, the skew
//! gatherers that read `Cargo.lock`/the warehouse) and the preflight ENGINE (the
//! checks that shell `cargo`/probe the registry) STAY in nornir — a leaf may not
//! touch the warehouse impl or it inverts the dep graph into a cycle.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
// ─────────────────────── skew analysis PODs ───────────────────────
/// Per-repo verdict for one shared external crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum SkewStatus {
Ok,
Behind,
Forbidden,
}
/// One repo's declared version of a shared external crate + its skew verdict.
#[derive(Debug, Clone, Serialize)]
pub struct RepoCrateStatus {
pub repo: String,
pub version: String,
pub status: SkewStatus,
/// `true` when this repo is `Behind` on the crate yet the TARGET major already
/// resolves in its `Cargo.lock` (a dual-major tree). That means a transitive
/// dependency pins the old major — a direct bump of the declared version won't
/// take until that dep moves, so the plain "bump" hint would be misleading.
/// (Filled in by the deferred warehouse-side `enrich_transitive_pins`; `false`
/// until then.)
#[serde(default)]
pub held_by_transitive_pin: bool,
}
/// One shared external crate's cross-repo skew: the inferred target (highest
/// declared version) and each repo's status.
#[derive(Debug, Clone, Serialize)]
pub struct CrateSkew {
pub crate_name: String,
/// The highest declared version across the repos = the inferred target.
pub target: String,
/// `true` when repos declare more than one distinct version.
pub diverged: bool,
pub entries: Vec<RepoCrateStatus>,
}
impl CrateSkew {
/// Repos that should bump (status not Ok) — the hint set.
pub fn bump_repos(&self) -> Vec<&str> {
self.entries
.iter()
.filter(|e| e.status != SkewStatus::Ok)
.map(|e| e.repo.as_str())
.collect()
}
}
// ─────────────────── patch-fork promote gate ───────────────────
//
// A crate that builds on a local `[patch.crates-io]` override to a NON-registry
// source (a `path=` or `git=` fork — e.g. `iceberg = { path =
// "../iceberg-arrow58" }`) is NOT crates.io-publishable: `cargo publish` strips
// `[patch]`, so the published immutable crate resolves the STOCK dep the fork
// replaced → broken. The detector finds those forks; the transitive closure marks
// every workspace-internal dependent blocked too.
/// What kind of non-registry source a `[patch.crates-io]` entry points at.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ForkKind {
/// `dep = { path = "../fork" }`
Path,
/// `dep = { git = "https://…" }`
Git,
}
/// One fork-patched dependency that blocks a crate from a crates.io publish.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PatchForkBlock {
/// The workspace crate whose manifest carries the fork patch (a virtual-root
/// patch is tagged with the repo-dir name).
pub crate_name: String,
/// The patched dependency name (e.g. `iceberg`).
pub patched_dep: String,
/// Whether the override is a `path=` or `git=` fork.
pub fork_kind: ForkKind,
/// The override source string (the path or git url) for the report.
pub source: String,
/// Human reason, suitable for a `⛔` line.
pub reason: String,
/// `true` when `patched_dep` is NOT produced anywhere in the constellation — a
/// genuine FOREIGN fork (a hard promote-blocker). `false` for a path/git
/// override of one of OUR OWN crates (a safe local dev override). Set by
/// [`compute_promote_block`] (the raw [`patch_fork_blockers`] scan leaves it
/// `false` since foreign-ness needs the cross-workspace produced set).
pub is_foreign_fork: bool,
}
/// One foreign fork a held crate rides — the concrete "because" behind a hold.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeldFork {
/// The `[patch.crates-io]`-forked dependency name (e.g. `iceberg`, `arrow`).
pub patched_dep: String,
/// Whether the fork override is a `path=` or `git=` source.
pub fork_kind: ForkKind,
/// The override source (the path or git url) — surfaced verbatim in the reason.
pub source: String,
}
/// **Why a crate is held out of the crates.io publish plan.** The `--publish`
/// cascade drops every crate that (transitively) rides a foreign
/// `[patch.crates-io]` fork; this makes the hold EXPLICIT: `fork_kind`, the
/// `patched_dep` it rides, direct-vs-transitive, and concrete unblock advice.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HeldExplanation {
/// The held crate.
pub crate_name: String,
/// The foreign fork(s) this crate transitively reaches (the reason it's held),
/// sorted by patched-dep name for a stable report.
pub rides: Vec<HeldFork>,
/// `true` when the crate DIRECTLY depends on a forked dep; `false` when it is
/// held only because a workspace-internal dependency of it rides the fork.
pub direct: bool,
/// Concrete "how to unblock" advice, ready for a `⛔` line.
pub advice: String,
}
/// Result of [`compute_promote_block`].
#[derive(Debug, Clone, Default)]
pub struct PromoteBlockResult {
/// Every detected patch-fork, with `is_foreign_fork` set. Foreign ones are hard
/// blockers; the rest are safe own-crate dev overrides.
pub forks: Vec<PatchForkBlock>,
/// The foreign-fork dependency names that seed the block (e.g. `iceberg`).
pub foreign_forks: std::collections::BTreeSet<String>,
/// The per-crate transitive promote-block closure.
pub blocked: std::collections::BTreeSet<String>,
/// Per HELD crate → its stated reason(s). One entry per crate in `blocked`.
pub explained: Vec<HeldExplanation>,
}
/// PURE-ish detector (one filesystem scan, no network): walk every `Cargo.toml`
/// under `repo_root`, parse each `[patch.crates-io]` table, and return one
/// [`PatchForkBlock`] per entry that redirects to a NON-registry source (`path=`
/// or `git=`). Registry-version no-op patches do NOT count.
pub fn patch_fork_blockers(repo_root: &Path) -> Vec<PatchForkBlock> {
let mut out = Vec::new();
for toml_path in find_cargo_tomls(repo_root, 4) {
let Ok(txt) = std::fs::read_to_string(&toml_path) else { continue };
let Ok(doc) = txt.parse::<toml::Table>().map(toml::Value::Table) else { continue };
let owner = doc
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string);
let Some(patch) = doc.get("patch") else { continue };
let Some(patch_tbl) = patch.as_table() else { continue };
for key in ["crates-io"] {
let Some(entries) = patch_tbl.get(key).and_then(|t| t.as_table()) else { continue };
for (dep, spec) in entries {
let (kind, source) = match spec {
toml::Value::String(_) => continue,
toml::Value::Table(t) => {
if let Some(p) = t.get("path").and_then(|v| v.as_str()) {
(ForkKind::Path, p.to_string())
} else if let Some(g) = t.get("git").and_then(|v| v.as_str()) {
(ForkKind::Git, g.to_string())
} else {
continue;
}
}
_ => continue,
};
let owner_name = owner.clone().unwrap_or_else(|| {
repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string()
});
let reason = format!(
"publishing strips [patch.crates-io] → stock {dep} is incompatible \
(fork: {source}). Unblock: publish the fork, or wait for upstream."
);
out.push(PatchForkBlock {
crate_name: owner_name,
patched_dep: dep.clone(),
fork_kind: kind,
source,
reason,
is_foreign_fork: false,
});
}
}
}
out.sort_by(|a, b| (&a.crate_name, &a.patched_dep).cmp(&(&b.crate_name, &b.patched_dep)));
out
}
/// Per-crate (NOT per-repo) dependency edges: every publishable `[package]` under
/// `root` maps to the set of dependency crate names it declares (internal +
/// external, non-optional, incl. build-deps). Keeps crates SEPARATE so a member
/// that doesn't touch a forked dep isn't tarred with a sibling's fork.
pub fn gather_crate_deps(
root: &Path,
) -> BTreeMap<String, std::collections::BTreeSet<String>> {
use std::collections::BTreeSet;
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for toml_path in find_cargo_tomls(root, 4) {
let Ok(txt) = std::fs::read_to_string(&toml_path) else { continue };
let Ok(doc) = txt.parse::<toml::Table>().map(toml::Value::Table) else { continue };
let package = doc.get("package");
let publishable = package
.and_then(|p| p.get("publish"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
if !publishable {
continue;
}
let Some(name) = package.and_then(|p| p.get("name")).and_then(|n| n.as_str()) else {
continue;
};
let entry = out.entry(name.to_string()).or_default();
for key in ["dependencies", "build-dependencies"] {
if let Some(t) = doc.get(key).and_then(|t| t.as_table()) {
for (dep, spec) in t {
let optional = spec
.as_table()
.and_then(|d| d.get("optional"))
.and_then(|o| o.as_bool())
.unwrap_or(false);
if !optional {
entry.insert(dep.clone());
}
}
}
}
}
out
}
/// CRATE-LEVEL promote-block (PRECISE). A crate is blocked iff its transitive
/// dependency closure (over `crate_deps`) reaches one of `foreign_forks`.
/// Path-patches to OUR OWN crates are NOT foreign forks. Pure; unit-testable.
pub fn promote_blocked_crates_precise(
crate_deps: &BTreeMap<String, std::collections::BTreeSet<String>>,
foreign_forks: &std::collections::BTreeSet<String>,
) -> std::collections::BTreeSet<String> {
use std::collections::BTreeSet;
let mut blocked: BTreeSet<String> = BTreeSet::new();
if foreign_forks.is_empty() {
return blocked;
}
for crate_name in crate_deps.keys() {
let mut stack = vec![crate_name.clone()];
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut hit = false;
while let Some(c) = stack.pop() {
if foreign_forks.contains(&c) {
hit = true;
break;
}
if !seen.insert(c.clone()) {
continue;
}
if let Some(deps) = crate_deps.get(&c) {
for d in deps {
if foreign_forks.contains(d) {
hit = true;
break;
}
if crate_deps.contains_key(d) {
stack.push(d.clone());
}
}
}
if hit {
break;
}
}
if hit {
blocked.insert(crate_name.clone());
}
}
blocked
}
/// Compute the per-crate hold reasons: for every crate whose transitive dep
/// closure reaches a foreign fork, record WHICH fork(s) it reaches, whether it
/// depends on one directly, and the unblock advice. The crate set returned is
/// exactly [`promote_blocked_crates_precise`]'s `blocked` set. Pure.
pub fn held_fork_reasons(
crate_deps: &BTreeMap<String, std::collections::BTreeSet<String>>,
forks: &[PatchForkBlock],
foreign_forks: &std::collections::BTreeSet<String>,
) -> Vec<HeldExplanation> {
use std::collections::BTreeSet;
if foreign_forks.is_empty() {
return Vec::new();
}
let mut fork_meta: BTreeMap<&str, &PatchForkBlock> = BTreeMap::new();
for b in forks.iter().filter(|b| b.is_foreign_fork) {
fork_meta.entry(b.patched_dep.as_str()).or_insert(b);
}
let mut out: Vec<HeldExplanation> = Vec::new();
for crate_name in crate_deps.keys() {
let mut reached: BTreeSet<String> = BTreeSet::new();
let mut stack = vec![crate_name.clone()];
let mut seen: BTreeSet<String> = BTreeSet::new();
while let Some(c) = stack.pop() {
if !seen.insert(c.clone()) {
continue;
}
if let Some(deps) = crate_deps.get(&c) {
for d in deps {
if foreign_forks.contains(d) {
reached.insert(d.clone());
} else if crate_deps.contains_key(d) {
stack.push(d.clone());
}
}
}
}
if reached.is_empty() {
continue;
}
let direct = crate_deps
.get(crate_name)
.map(|deps| deps.iter().any(|d| foreign_forks.contains(d)))
.unwrap_or(false);
let rides: Vec<HeldFork> = reached
.iter()
.map(|dep| match fork_meta.get(dep.as_str()) {
Some(b) => HeldFork {
patched_dep: dep.clone(),
fork_kind: b.fork_kind,
source: b.source.clone(),
},
None => HeldFork {
patched_dep: dep.clone(),
fork_kind: ForkKind::Path,
source: String::new(),
},
})
.collect();
let deps_list = reached.iter().cloned().collect::<Vec<_>>().join(", ");
let advice = if direct {
format!(
"publishing strips [patch.crates-io] → stock {deps_list} is incompatible. \
Unblock: publish the real (unforked) {deps_list} to crates.io, or wait for \
upstream, then this crate promotes."
)
} else {
format!(
"held transitively — a workspace dependency rides forked {deps_list}. \
Unblock: publish the real {deps_list} (and any rider dep) first, then this \
crate promotes."
)
};
out.push(HeldExplanation { crate_name: crate_name.clone(), rides, direct, advice });
}
out.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
out
}
/// The full precise promote-block computation across a set of `(name, path)`
/// repos. Scans each repo for crate-level deps + `[patch.crates-io]` forks,
/// classifies each fork as FOREIGN vs an own-crate override, tags every
/// [`PatchForkBlock`] with `is_foreign_fork`, and returns the per-crate transitive
/// block closure against the foreign forks. Filesystem-only — no warehouse.
pub fn compute_promote_block<I, P>(repos: I) -> PromoteBlockResult
where
I: IntoIterator<Item = (String, P)>,
P: AsRef<Path>,
{
use std::collections::BTreeSet;
let mut crate_deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut forks: Vec<PatchForkBlock> = Vec::new();
for (_name, path) in repos {
let path = path.as_ref();
for (c, deps) in gather_crate_deps(path) {
crate_deps.entry(c).or_default().extend(deps);
}
forks.extend(patch_fork_blockers(path));
}
let produced: BTreeSet<String> = crate_deps.keys().cloned().collect();
let foreign_forks: BTreeSet<String> = forks
.iter()
.map(|b| b.patched_dep.clone())
.filter(|d| !produced.contains(d))
.collect();
for b in forks.iter_mut() {
b.is_foreign_fork = foreign_forks.contains(&b.patched_dep);
}
forks.sort_by(|a, b| (&a.crate_name, &a.patched_dep).cmp(&(&b.crate_name, &b.patched_dep)));
let blocked = promote_blocked_crates_precise(&crate_deps, &foreign_forks);
let explained = held_fork_reasons(&crate_deps, &forks, &foreign_forks);
PromoteBlockResult { forks, foreign_forks, blocked, explained }
}
/// Recursively collect every `Cargo.toml` under `root` up to `max_depth`, skipping
/// symlinks (avoids following `.claude/worktrees/*` → sibling repos), `target/`,
/// and dot-dirs. The shared manifest-walk the gate/graph gatherers reason over.
pub(crate) fn find_cargo_tomls(root: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![(root.to_path_buf(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
let manifest = dir.join("Cargo.toml");
if manifest.is_file() {
out.push(manifest);
}
if depth >= max_depth {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path();
if e.file_type().map(|t| t.is_symlink()).unwrap_or(false) {
continue;
}
if !p.is_dir() {
continue;
}
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "target" || name.starts_with('.') {
continue;
}
stack.push((p, depth + 1));
}
}
out
}
// ─────────────────────── cross-repo dep graph facts ───────────────────────
/// Per-repo cross-repo dependency facts, at the CRATE granularity cargo publishes
/// at. The "master of dependencies" POD: `crate_deps` carries the order-gating
/// in-workspace edges the publish-DAG topo-sorts. Relocated as a plain data type
/// (the warehouse-side gatherers that populate it STAY in nornir); consumed by
/// [`depdag`](crate::depdag), which reads `repo` + `crate_deps`.
#[derive(Debug, Clone, Default)]
pub struct RepoGraph {
pub repo: String,
pub produces: std::collections::BTreeSet<String>,
/// PUBLISH-ORDER-gating dependency crate names (repo-unioned).
pub deps: std::collections::BTreeSet<String>,
/// `[dev-dependencies]` crate names (NOT order-gating; surfaced for transparency).
pub dev_deps: std::collections::BTreeSet<String>,
/// Order-gating OPTIONAL (feature-gated) path dep crate name → enabling feature(s).
pub optional_deps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
/// Order-gating path dep crate name → the `[target.'cfg(…)']` string(s).
pub cfg_deps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
/// Cross-repo-by-NAME deps pinned to a crates.io VERSION (not gating).
pub version_pinned: std::collections::BTreeSet<String>,
/// PER-CRATE order-gating in-workspace dep crate names (the true publish-order
/// granularity — cargo publishes CRATES, not repos).
pub crate_deps: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
/// Order-gating in-workspace dep crate name → the version REQUIREMENT string(s).
pub dep_reqs: std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
/// PER-CONSUMER version requirements: consumer crate → (dep crate → requirement
/// string(s)). Unlike [`Self::dep_reqs`] (which UNIONS every consumer's requirement
/// on a dep), this preserves WHICH consumer declares each requirement — so the
/// graph precheck can gate an edge on whether its CONSUMER is actually in the
/// publish set (a detached crate we never publish must not have its outgoing
/// requirements checked).
pub crate_dep_reqs: std::collections::BTreeMap<
String,
std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
>,
}
// ─────────────────────── preflight issue PODs ───────────────────────
/// The class of a blocking preflight issue. (Relocated from `preflight.rs` — the
/// PURE POD; the preflight ENGINE that raises these STAYS in nornir.)
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueKind {
/// Planned `-p` target is not a member of its repo-root workspace.
NonMember,
/// Missing required crates.io metadata field(s).
MissingMetadata,
/// `cargo publish --dry-run` verify build failed on dep-API skew.
VerifyFailed,
/// The version to publish is already live on crates.io (immutable clash).
RegistrySkew,
/// The LOCAL version is BEHIND the registry's published max (a desync/downgrade).
RegistryDesync,
/// A `[dev-dependencies]` entry carries BOTH `path =` and `version =` on a sibling.
VersionedPathDevDep,
/// A dependency EDGE that can never be satisfied on publish.
UnpublishableDep,
/// A crates.io manifest constraint beyond the required fields.
BadManifest,
/// The crate NAME violates crates.io's rules.
InvalidCrateName,
/// The packaged `.crate` tarball exceeds crates.io's upload size limit.
OversizedTarball,
/// The crate's LOCAL source DIFFERS from the copy already PUBLISHED at the SAME
/// version — "edited without a version bump". A CORRECTNESS blocker.
StaleSourceDrift,
/// The crate REFERENCES a symbol from a workspace-sibling dependency that the
/// version the sibling RESOLVES TO (an already-published crates.io release) does
/// NOT export — the "my code calls `dep::sym`, but the published `dep` lacks
/// `sym`" trap (the release-night `skade::ingest_multi` case). Detected STATICALLY
/// before the multi-minute `cargo publish --verify` build. A CORRECTNESS blocker.
ApiSkew,
/// An ACTIVE dependency edge requires a feature on its provider that the
/// provider does NOT EXPOSE at the version it will resolve to — the 4th
/// Package-Calculus condition (Feature-Availability). The release-night
/// `online`-on-`holger-server-lib` trap: the feature exists in local source but
/// not in the frozen published version. A CORRECTNESS blocker — crates.io is
/// immutable, so the provider MUST bump to carry the feature.
FeatureUnavailable,
/// A member repo carries uncommitted NON-lock (source) changes on the eve of a
/// real publish — the errors-book §II dirty-tree trap. A SOFT blocker: forceable
/// via `--force` / `--allow-dirty` (`Cargo.lock` churn is always tolerated and is
/// NEVER flagged). Surfaced by the [`dirty_sweep`](crate::dirty_sweep)
/// Gate-A leg via the additive
/// [`gate_a_wiring`](crate::gate_a_wiring) merge.
DirtyTree,
/// The crate ships a BREAKING public-API change under a version bump that does NOT
/// signal it — the FORWARD, downstream-facing semver hazard (`cargo-semver-checks`
/// class): a removed/renamed/retyped `pub` item, a narrowed bound, etc., released as
/// a patch/minor when semver requires a minor/major. The reverse of
/// [`ApiSkew`](IssueKind::ApiSkew) (which is "WE call a symbol the published dep
/// lacks"); this is "OUR bump under-states the break WE are shipping to consumers".
/// Crates.io is immutable, so an under-bumped break silently compiles-breaks every
/// downstream on the next resolve. Detected by the
/// [`semver_checks`](crate::semver_checks) Gate-A leg; forceable
/// (the operator may knowingly under-bump inside the constellation) — the fix is to
/// raise the bump to the level semver-checks requires.
SemverHazard,
/// A TRANSITIVE dependency in the LOCKED graph (`Cargo.lock`) carries a known RUSTSEC/OSV
/// advisory — a live-but-vulnerable, unsound, or advisory-yanked crate. Our resolver-level
/// yank check reads the INDEX and misses this (a committed lockfile pinning a
/// vulnerable/yanked version still builds); the [`advisory`](crate::advisory)
/// Gate-A leg scans the lockfile against a vendored advisory-db. Publishing a crate that
/// carries a CVE in its tree is the "we shipped a CVE" fault — forceable (the operator may
/// knowingly accept an advisory), fixed by bumping/replacing the offending dep.
VulnerableDep,
/// A `cargo-deny`-class POLICY fault surfaced by
/// [`packaging_lint`](crate::packaging_lint): a member's `license` SPDX
/// expression is not on the configured allowlist (or is not valid SPDX), or a banned
/// crate appears in the dependency tree. Distinct from
/// [`MissingMetadata`](IssueKind::MissingMetadata) (a license is PRESENT — it is the
/// WRONG one, per policy). Forceable (the operator may knowingly override a policy for a
/// given release); the fix is to change the license / remove the banned dep.
PolicyViolation,
/// The NEW compiled artifact grew past its size budget versus the PRIOR released
/// artifact — the `size_regression` verdict of the binary-analysis Gate
/// (`nornir bin gate`, wired by the [`binary_gate`](crate::binary_gate)
/// leg): a bloat regression on the shipped binary a source-only gate never sees.
/// Forceable (the operator may knowingly accept the growth / raise the budget); the
/// fix is to shrink the artifact or widen the `--max-growth-*` budget. OPT-IN (needs a
/// built artifact + the prior binary), so it is never on a default green release.
SizeRegression,
/// The NEW compiled artifact DROPPED an exported symbol from its ABI surface versus
/// the PRIOR released artifact — the `abi_removed` verdict of the binary-analysis Gate
/// (`nornir bin gate`, wired by the [`binary_gate`](crate::binary_gate)
/// leg): a binary-level ABI break a dynamic consumer resolves against, invisible to a
/// source/manifest gate. Forceable (the operator may knowingly break the ABI with a
/// MAJOR bump); the fix is to restore the export or signal the break. OPT-IN.
AbiDrop,
/// The crate's DECLARED version requirement on a sibling admits a published version
/// that does NOT export the API the crate calls — an **understated floor**.
///
/// The mirror image of [`ApiSkew`](IssueKind::ApiSkew), and invisible to it.
/// `ApiSkew` asks whether the sibling's published **MAX** has the symbol; this asks
/// whether the **LOWEST version the requirement admits** has it. A caret requirement
/// (`"0.1.9"` ⇒ `>=0.1.9, <0.2.0`) resolves to the newest match, so the ceiling is
/// green while the floor is a lie — and the `path = …` sitting beside the
/// requirement means cargo never consults it inside the workspace at all. Measured
/// 2026-08-03: SEVEN crates shipped with one. Four of them declared
/// `linflate = "0.1.9"` while calling `clamp_expected_size`, which first appeared in
/// `linflate 0.1.11`.
///
/// A CORRECTNESS blocker, non-forceable, for the reason the class exists: a
/// dependent resolving at the declared floor builds against an API the published
/// dependency does not have. crates.io bytes are immutable, so the remedy for a
/// shipped one is another version — which is why it has to be caught before upload.
/// Raised by the [`min_req_gate`] leg in dwarves; the fix is a one-line manifest
/// edit (raise the requirement to the version that introduced the symbol).
///
/// [`min_req_gate`]: https://git.gunnar.rs/nordisk/dwarves
UnderstatedFloor,
}
impl IssueKind {
/// The CORRECTNESS class: a dependent would build/verify against an API that is
/// NOT in the published dependency. NEVER overridable by `--force`.
pub fn is_correctness_blocker(&self) -> bool {
matches!(
self,
IssueKind::VerifyFailed
| IssueKind::StaleSourceDrift
| IssueKind::ApiSkew
| IssueKind::FeatureUnavailable
// An understated floor is the same class by the definition above: a
// dependent resolving at the declared requirement builds against an API
// the published dependency does not have. crates.io is immutable, so a
// shipped one costs another version — it must not be forceable.
| IssueKind::UnderstatedFloor
)
}
}
/// One blocking preflight issue: a short, specific "why" plus the exact "fix".
/// (Relocated PURE POD; consumed by [`root_cause`](crate::root_cause).)
#[derive(Debug, Clone)]
pub struct Issue {
pub repo: String,
pub krate: String,
pub kind: IssueKind,
pub detail: String,
pub fix: String,
/// Crate(s) this issue's error explicitly BLAMES as the un-published upstream —
/// set for `VerifyFailed`. Feeds topological root-cause analysis so the true
/// root is named even when it was skipped and never became its own issue.
#[allow(dead_code)]
pub blames: Vec<String>,
}
// Identity is (repo, krate, kind, blames) — deliberately NOT `detail`/`fix`, which
// are human prose that gets reworded without changing which issue this is. Defined
// beside the type (an impl for a foreign type cannot live with the consumer) so the
// conductor's `VerifyOutcome` can derive its own equality over `Issue`.
impl PartialEq for Issue {
fn eq(&self, other: &Self) -> bool {
self.repo == other.repo
&& self.krate == other.krate
&& self.kind == other.kind
&& self.blames == other.blames
}
}
impl Eq for Issue {}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn member(root: &Path, name: &str, deps: &[&str]) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let mut t = format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n[dependencies]\n");
for d in deps {
t.push_str(&format!("{d} = \"1\"\n"));
}
std::fs::write(dir.join("Cargo.toml"), t).unwrap();
}
fn set(xs: &[&str]) -> std::collections::BTreeSet<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn precise_gate_blocks_only_crates_that_reach_the_foreign_fork() {
let mut cd: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
cd.insert("skade".into(), set(&["iceberg", "serde"]));
cd.insert("znippy-iceberg".into(), set(&["iceberg"]));
cd.insert("nornir".into(), set(&["skade", "clap"]));
cd.insert("znippy-common".into(), set(&["serde"]));
cd.insert("lgz".into(), set(&["znippy-common"]));
let foreign = set(&["iceberg"]);
let blocked = promote_blocked_crates_precise(&cd, &foreign);
assert!(blocked.contains("skade"));
assert!(blocked.contains("znippy-iceberg"));
assert!(blocked.contains("nornir"), "nornir → skade → iceberg (transitive)");
assert!(!blocked.contains("znippy-common"), "clean sibling is FREE");
assert!(!blocked.contains("lgz"), "lgz → znippy-common only → FREE");
}
#[test]
fn compute_promote_block_classifies_foreign_vs_own_overrides() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("nordic");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(
ws.join("Cargo.toml"),
r#"
[workspace]
members = ["skade", "znippy-common", "znippy-iceberg"]
[patch.crates-io]
iceberg = { path = "../iceberg-arrow58" }
skade = { path = "../skade" }
"#,
)
.unwrap();
member(&ws, "skade", &["iceberg"]);
member(&ws, "znippy-common", &["serde"]);
member(&ws, "znippy-iceberg", &["iceberg", "znippy-common"]);
let repos = vec![("nordic".to_string(), ws.clone())];
let block = compute_promote_block(repos.iter().map(|(n, p)| (n.clone(), p.as_path())));
// iceberg is foreign (not produced here); skade is OUR crate → safe override.
assert!(block.foreign_forks.contains("iceberg"), "iceberg is a foreign fork");
assert!(!block.foreign_forks.contains("skade"), "skade is our own crate, not foreign");
let iceberg_block = block.forks.iter().find(|b| b.patched_dep == "iceberg").unwrap();
assert!(iceberg_block.is_foreign_fork);
let skade_block = block.forks.iter().find(|b| b.patched_dep == "skade").unwrap();
assert!(!skade_block.is_foreign_fork, "skade override is safe, not a blocker");
// The block holds only iceberg-touchers; znippy-common stays publishable.
assert!(block.blocked.contains("skade"));
assert!(block.blocked.contains("znippy-iceberg"));
assert!(!block.blocked.contains("znippy-common"), "clean sibling is FREE");
// Every blocked crate carries an explanation.
let explained: std::collections::BTreeSet<String> =
block.explained.iter().map(|h| h.crate_name.clone()).collect();
assert_eq!(explained, block.blocked, "every held crate is explained");
assert!(block.explained.iter().all(|h| !h.advice.is_empty()));
}
#[test]
fn issue_kind_correctness_blockers() {
assert!(IssueKind::VerifyFailed.is_correctness_blocker());
assert!(IssueKind::StaleSourceDrift.is_correctness_blocker());
assert!(IssueKind::ApiSkew.is_correctness_blocker());
// An understated floor is non-forceable for the same reason ApiSkew is: the
// published dependency does not have the API, and crates.io bytes are immutable.
assert!(IssueKind::UnderstatedFloor.is_correctness_blocker());
assert!(!IssueKind::MissingMetadata.is_correctness_blocker());
assert!(!IssueKind::RegistryDesync.is_correctness_blocker());
}
}