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