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

//! The **advisory** release report (`release doctor`) — the "see it and get hints
//! first" tier: read-only, no mutation. Relocated from `nornir/src/release/doctor.rs`
//! (DOCTRINE 2026-07-14 — *release is a build op*): the READ-ANALYSIS closure that
//! composes the constellation's release picture — *can I release, what's off, in what
//! order?* — moves OUT of nornir and INTO dwarves so the forge OWNS it instead of
//! reaching it over gRPC.
//!
//! Everything here reasons over the pure POD closure ([`crate::model`]), the pure
//! graph-theory core ([`crate::graph_math`]), and — for the semantic-blast leg — the
//! engine-agnostic [`nornir_warehouse_trait::Warehouse`] seam + the relocated
//! knowledge readers ([`crate::knowledge`]) / semantic-blast ([`crate::semantic_blast`]).
//! No `nornir` dep: the dirty-tree read goes through the shared pure-Rust
//! [`nornir_git::gitio`] leaf, the manifest/lock analysis is filesystem-only, and the
//! warehouse is touched purely through the trait.
//!
//! Three signals composed here:
//! * **dirty trees** — real LOCAL working-tree freshness per repo (via
//!   [`nornir_git::gitio::worktree_freshness`]), not a server clone.
//! * **external-dependency version skew** — per shared external crate, the target
//!   (highest declared version) and each repo's status (ok / behind / forbidden),
//!   with bump hints. Surfaces *disagreement across repos*.
//! * topo publish order + blast radius reuse the pure graph tools ([`crate::graph_math`]).
//!
//! ## Relocated vs DEFERRED
//! Relocated: the advisory [`run`], the injectable [`run_with_semantic_blast`]
//! (takes the per-repo changed-symbol delta as data), the skew gatherers, the
//! `RepoGraph` manifest gatherer, and every pure cross-repo graph analysis + report
//! formatter. **DEFERRED (stay in nornir):**
//! * `run_with_warehouse_semantic_blast` — DERIVES the changed-symbol delta by
//!   diffing the warehouse-persisted surface against a **fresh syn scan** of the
//!   working tree (`knowledge::symbols::scan_repo`, the `knowledge::scan_all` syn
//!   scanner nornir OWNS). dwarves has no syn scanner leaf, so nornir keeps this
//!   entry and calls the relocated [`run_with_semantic_blast`] with the delta it
//!   scanned. (Injecting the delta is exactly what [`run_with_semantic_blast`] is.)
//! * `graph_from_binary` / `BinaryDerivedGraph` / `cross_check_source_vs_binary` —
//!   read `crate::graph::extract::binary` (the ELF extractor). DEFERRED.
//!
//! `registry_non_gating_crates` is now RELOCATED here — it consumes the relocated
//! [`preflight::RegistryProbe`](crate::registry::RegistryProbe).

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::model::{
    compute_promote_block, find_cargo_tomls, CrateSkew, ForkKind, HeldExplanation, PatchForkBlock,
    RepoCrateStatus, RepoGraph, SkewStatus,
};

// ─────────────────────────── policy ───────────────────────────

/// A forbidden external-dependency version — matched on the MAJOR component of a
/// declared version (e.g. `arrow` `56` bans `56`, `56.2`, `=56.2.1`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForbiddenDep {
    pub crate_name: String,
    pub version: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DepPolicy {
    pub forbidden: Vec<ForbiddenDep>,
}

// ─────────────────────────── inputs ───────────────────────────

/// One repo's declared EXTERNAL dependency versions (crate → declared version req).
#[derive(Debug, Clone)]
pub struct RepoExternals {
    pub repo: String,
    pub deps: BTreeMap<String, String>,
}

// ─────────────────────── skew analysis ───────────────────────

/// Parse a declared version requirement into a comparable `(major, minor, patch)`
/// key, tolerating partials (`"57"` → `(57,0,0)`) and req operators (`"^1.2"`,
/// `">=0.9.0"`, `"=56.2.1"`).
fn version_key(s: &str) -> (u64, u64, u64) {
    let cleaned = s.trim().trim_start_matches(|c: char| !c.is_ascii_digit());
    let mut it = cleaned.split('.').map(|p| {
        p.chars()
            .take_while(|c| c.is_ascii_digit())
            .collect::<String>()
            .parse::<u64>()
            .unwrap_or(0)
    });
    (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
}

fn is_forbidden(crate_name: &str, version: &str, policy: &DepPolicy) -> bool {
    policy
        .forbidden
        .iter()
        .any(|f| f.crate_name == crate_name && same_semver_line(&f.version, version))
}

/// Whether two versions share the SemVer-significant leading component: for
/// `major >= 1` that's the major alone (`1.x` ↔ `1.y`); for `0.x` it's
/// `(major, minor)` — `0.9` and `0.10` are DIFFERENT SemVer lines, so a `0.9`
/// forbid must not ban every `0.x` (bug #19).
fn same_semver_line(forbidden: &str, version: &str) -> bool {
    let (fm, fmin, _) = version_key(forbidden);
    let (vm, vmin, _) = version_key(version);
    if fm == 0 { fm == vm && fmin == vmin } else { fm == vm }
}

/// THE BRAIN (pure, no I/O). Per external crate, infer the target = the highest
/// declared version, and classify each repo: `Ok` at the target, `Behind` below it,
/// `Forbidden` if the policy bans that version. Only crates that DIVERGE across
/// repos, or that hit a forbidden version, are surfaced (a single-repo dep at one
/// version isn't a skew worth a hint).
pub fn analyze_skew(repos: &[RepoExternals], policy: &DepPolicy) -> Vec<CrateSkew> {
    let mut by_crate: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
    for r in repos {
        for (c, v) in &r.deps {
            by_crate.entry(c.as_str()).or_default().push((r.repo.as_str(), v.as_str()));
        }
    }

    let mut out = Vec::new();
    for (crate_name, mut uses) in by_crate {
        let has_forbidden = uses.iter().any(|(_, v)| is_forbidden(crate_name, v, policy));
        let distinct: std::collections::BTreeSet<(u64, u64, u64)> =
            uses.iter().map(|(_, v)| version_key(v)).collect();
        let diverged = distinct.len() >= 2;
        if !diverged && !has_forbidden {
            continue;
        }

        uses.sort_by(|a, b| a.0.cmp(b.0)); // stable by repo name
        let target_key = uses.iter().map(|(_, v)| version_key(v)).max().unwrap();
        let target = uses
            .iter()
            .find(|(_, v)| version_key(v) == target_key)
            .map(|(_, v)| v.to_string())
            .unwrap();

        let entries = uses
            .iter()
            .map(|(repo, v)| {
                let status = if is_forbidden(crate_name, v, policy) {
                    SkewStatus::Forbidden
                } else if version_key(v) < target_key {
                    SkewStatus::Behind
                } else {
                    SkewStatus::Ok
                };
                RepoCrateStatus {
                    repo: repo.to_string(),
                    version: v.to_string(),
                    status,
                    held_by_transitive_pin: false,
                }
            })
            .collect();

        out.push(CrateSkew { crate_name: crate_name.to_string(), target, diverged, entries });
    }
    out
}

/// The set of MAJOR versions a crate resolves to in a `Cargo.lock`. `arrow` with
/// both `57.3.1` and `58.3.0` present → `{57, 58}`. Pure (parses the lock text),
/// so it's unit-testable without a real checkout.
pub fn crate_majors_in_lock(lock_text: &str, crate_name: &str) -> std::collections::BTreeSet<u64> {
    let mut out = std::collections::BTreeSet::new();
    let Ok(doc) = lock_text.parse::<toml::Table>().map(toml::Value::Table) else { return out };
    let Some(pkgs) = doc.get("package").and_then(|p| p.as_array()) else { return out };
    for pkg in pkgs {
        let name = pkg.get("name").and_then(|n| n.as_str());
        let ver = pkg.get("version").and_then(|v| v.as_str());
        if name == Some(crate_name) {
            if let Some(v) = ver {
                out.insert(version_key(v).0);
            }
        }
    }
    out
}

/// Enrich a skew analysis with transitive-pin facts: for each repo that's `Behind`
/// on a crate, set `held_by_transitive_pin` when that repo's `Cargo.lock` ALREADY
/// resolves the target major (a dual-major tree). That's the tell that a transitive
/// dependency pins the old major — so the naive "bump the declared version" hint
/// would be misleading (it can't take until the pinning dep moves). `repo_locks`
/// maps repo name → its `Cargo.lock` contents (absent / unreadable locks are
/// simply skipped, leaving the flag `false`).
pub fn enrich_transitive_pins(skew: &mut [CrateSkew], repo_locks: &BTreeMap<String, String>) {
    for c in skew.iter_mut() {
        let target_major = version_key(&c.target).0;
        for e in c.entries.iter_mut() {
            if e.status != SkewStatus::Behind {
                continue;
            }
            // Only a genuine MAJOR gap can be transitively pinned. A loose
            // declaration like `clap = "4"` reads as "behind 4.5.51" but resolves
            // to it (same major) — that's not a pin, just an imprecise req.
            let declared_major = version_key(&e.version).0;
            if declared_major >= target_major {
                continue;
            }
            if let Some(lock) = repo_locks.get(&e.repo) {
                // Held iff the lock carries BOTH the declared (old) major AND the
                // target major as distinct resolves — a real dual-major tree, the
                // fingerprint of a transitive dep pinning the old line.
                let majors = crate_majors_in_lock(lock, &c.crate_name);
                if majors.contains(&declared_major) && majors.contains(&target_major) {
                    e.held_by_transitive_pin = true;
                }
            }
        }
    }
}

// ─────────────────────── dirty trees ───────────────────────

#[derive(Debug, Clone, Serialize)]
pub struct RepoDirty {
    pub repo: String,
    pub dirty: bool,
    pub error: Option<String>,
}

/// Real per-repo working-tree dirty state, computed against the LOCAL checkout
/// (not a server clone) via the shared pure-Rust [`nornir_git::gitio::worktree_freshness`]
/// leaf (gix, zero-shell) — the same reader nornir uses.
pub fn check_dirty(repos: &[(String, PathBuf)]) -> Vec<RepoDirty> {
    repos
        .iter()
        .map(|(name, path)| match nornir_git::gitio::worktree_freshness(path) {
            Ok(f) => RepoDirty { repo: name.clone(), dirty: f.dirty, error: None },
            Err(e) => RepoDirty { repo: name.clone(), dirty: false, error: Some(e.to_string()) },
        })
        .collect()
}

// ─────────────────────── gatherer ───────────────────────

/// Collect a repo's declared EXTERNAL dependency versions by scanning its
/// `Cargo.toml` files. External = declared with a literal `version` and NO `path`
/// (path deps are workspace-internal). Keeps the highest version seen per crate.
pub fn gather_repo_externals(repo: &str, root: &Path) -> Result<RepoExternals> {
    let mut deps: BTreeMap<String, 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 };
        for key in ["dependencies", "build-dependencies"] {
            collect_deps(doc.get(key), &mut deps);
        }
        if let Some(ws) = doc.get("workspace").and_then(|w| w.get("dependencies")) {
            collect_deps(Some(ws), &mut deps);
        }
    }
    Ok(RepoExternals { repo: repo.to_string(), deps })
}

/// Merge a `[dependencies]`-style table into `deps`, taking only external crates
/// (literal version, no `path`) and keeping the highest version per crate.
fn collect_deps(table: Option<&toml::Value>, deps: &mut BTreeMap<String, String>) {
    let Some(table) = table.and_then(|t| t.as_table()) else { return };
    for (name, spec) in table {
        let version = match spec {
            toml::Value::String(v) => Some(v.clone()),
            toml::Value::Table(t) => {
                if t.contains_key("path") {
                    None // workspace-internal
                } else {
                    t.get("version").and_then(|v| v.as_str()).map(str::to_string)
                }
            }
            _ => None,
        };
        if let Some(v) = version {
            deps.entry(name.clone())
                .and_modify(|cur| {
                    if version_key(&v) > version_key(cur) {
                        *cur = v.clone();
                    }
                })
                .or_insert(v);
        }
    }
}

// ──────────── publish preflight: path deps need a version ────────────

/// A `path` dependency of a PUBLISHABLE crate that lacks a `version` requirement.
/// `cargo publish` rejects exactly this ("all dependencies must have a version
/// requirement specified when publishing") — so it's the class of blocker that
/// `--rehearse` otherwise trips on MID-publish, per crate, slowly. This is the
/// STATIC pre-flight for it: found up front, before any registry work, and
/// classified so the fix is obvious.
#[derive(Debug, Clone, Serialize)]
pub struct PathDepVersionGap {
    /// Constellation member whose crate has the gap.
    pub repo: String,
    /// Repo-relative path of the offending `Cargo.toml`.
    pub manifest: String,
    /// The publishable crate carrying the versionless path dep.
    pub crate_name: String,
    /// The path dependency missing a `version =`.
    pub dep: String,
    /// The constellation repo that PRODUCES `dep`, if any. `Some` ⇒ AUTO-FIXABLE
    /// (member-owned — fill `version` from the dep crate's own manifest). `None` ⇒
    /// NEEDS A DECISION: a non-member dep (e.g. `modgunn`, `graphar`) — it must
    /// join the release cascade or be published to crates.io first.
    pub dep_owner: Option<String>,
    /// The dep crate's own declared version, when it's a member we can read — the
    /// value to write into `version = "…"`.
    pub suggested_version: Option<String>,
    /// True when the missing-version declaration lives in `[workspace.dependencies]`
    /// (inherited via `dep.workspace = true`), so the fix is at the workspace root.
    pub via_workspace: bool,
}

/// Read a repo's manifests (path + parsed doc), its `[workspace.package].version`
/// (to resolve `version.workspace = true`), and its `[workspace.dependencies]`
/// specs (to resolve `dep.workspace = true`).
fn read_repo_manifests(
    root: &Path,
) -> (Vec<(PathBuf, toml::Value)>, Option<String>, BTreeMap<String, toml::Value>) {
    let mut docs = Vec::new();
    let mut ws_pkg_ver = None;
    let mut ws_deps = BTreeMap::new();
    for p in find_cargo_tomls(root, 4) {
        let Ok(txt) = std::fs::read_to_string(&p) else { continue };
        let Ok(doc) = txt.parse::<toml::Table>().map(toml::Value::Table) else { continue };
        if let Some(w) = doc.get("workspace") {
            if let Some(v) =
                w.get("package").and_then(|pk| pk.get("version")).and_then(|v| v.as_str())
            {
                ws_pkg_ver = Some(v.to_string());
            }
            if let Some(t) = w.get("dependencies").and_then(|t| t.as_table()) {
                for (k, spec) in t {
                    ws_deps.insert(k.clone(), spec.clone());
                }
            }
        }
        docs.push((p, doc));
    }
    (docs, ws_pkg_ver, ws_deps)
}

/// A package's own version, resolving `version.workspace = true`
/// (`version = { workspace = true }`) to the workspace `[workspace.package].version`.
fn crate_version_of(pkg: &toml::Value, ws_pkg_ver: &Option<String>) -> Option<String> {
    match pkg.get("version") {
        Some(toml::Value::String(s)) => Some(s.clone()),
        Some(toml::Value::Table(t))
            if t.get("workspace").and_then(|w| w.as_bool()).unwrap_or(false) =>
        {
            ws_pkg_ver.clone()
        }
        _ => None,
    }
}

/// The local (workspace) version of every PUBLISHABLE crate across `repos`: crate
/// name → its `version` (resolving `version.workspace = true`). Feeds the release
/// dep-DAG's registry-state annotation (local-vs-published) — see
/// [`crate::depdag`]. Cheap + offline (manifest parse only).
pub fn local_crate_versions(repos: &[(String, PathBuf)]) -> BTreeMap<String, String> {
    let mut out: BTreeMap<String, String> = BTreeMap::new();
    for (_repo, root) in repos {
        let (docs, ws_pkg_ver, _) = read_repo_manifests(root);
        for (_p, doc) in &docs {
            let Some(pkg) = doc.get("package") else { continue };
            if !pkg.get("publish").and_then(|v| v.as_bool()).unwrap_or(true) {
                continue;
            }
            let Some(name) = pkg.get("name").and_then(|n| n.as_str()) else { continue };
            if let Some(ver) = crate_version_of(pkg, &ws_pkg_ver) {
                out.entry(name.to_string()).or_insert(ver);
            }
        }
    }
    out
}

/// Scan every PUBLISHABLE crate across the constellation for Normal/Build `path`
/// deps missing a `version` — the `cargo publish` manifest-verify blocker, caught
/// statically. Dev-deps are exempt (cargo drops a path dev-dep's requirement).
/// Each gap is classified member-owned (auto-fixable, with a suggested version)
/// vs non-member (needs a decision) via the produced-by map built from `repos`.
pub fn scan_path_dep_version_gaps(repos: &[(String, PathBuf)]) -> Vec<PathDepVersionGap> {
    // 1) produced map: crate name → (owning repo, its version), publishable crates.
    let mut produced: BTreeMap<String, (String, Option<String>)> = BTreeMap::new();
    for (repo, root) in repos {
        let (docs, ws_pkg_ver, _) = read_repo_manifests(root);
        for (_p, doc) in &docs {
            let Some(pkg) = doc.get("package") else { continue };
            if !pkg.get("publish").and_then(|v| v.as_bool()).unwrap_or(true) {
                continue;
            }
            let Some(name) = pkg.get("name").and_then(|n| n.as_str()) else { continue };
            let ver = crate_version_of(pkg, &ws_pkg_ver);
            produced.entry(name.to_string()).or_insert((repo.clone(), ver));
        }
    }

    // 2) per publishable crate, flag Normal/Build path deps with no version.
    let mut gaps: Vec<PathDepVersionGap> = Vec::new();
    for (repo, root) in repos {
        let (docs, _ws_pkg_ver, ws_deps) = read_repo_manifests(root);
        for (path, doc) in &docs {
            let Some(pkg) = doc.get("package") else { continue };
            if !pkg.get("publish").and_then(|v| v.as_bool()).unwrap_or(true) {
                continue;
            }
            let Some(crate_name) = pkg.get("name").and_then(|n| n.as_str()) else { continue };
            let rel = path.strip_prefix(root).unwrap_or(path).display().to_string();
            // Normal + Build dep tables, INCLUDING `[target.'cfg(…)'.…]` variants —
            // cargo publish requires a version on those path deps too.
            for tbl in publish_dep_tables(doc) {
                for (dkey, spec) in tbl {
                    // Resolve `dep.workspace = true` to the `[workspace.dependencies]` spec.
                    let via_ws = spec
                        .as_table()
                        .and_then(|t| t.get("workspace"))
                        .and_then(|w| w.as_bool())
                        .unwrap_or(false);
                    let eff = if via_ws { ws_deps.get(dkey).unwrap_or(spec) } else { spec };
                    let Some(et) = eff.as_table() else { continue }; // bare "1.2" → fine
                    if !et.contains_key("path") || et.contains_key("version") {
                        continue; // not a path dep, or already versioned → fine
                    }
                    let real = dep_real_name(dkey, eff);
                    let owner = produced.get(&real);
                    gaps.push(PathDepVersionGap {
                        repo: repo.clone(),
                        manifest: rel.clone(),
                        crate_name: crate_name.to_string(),
                        dep: real,
                        dep_owner: owner.map(|(r, _)| r.clone()),
                        suggested_version: owner.and_then(|(_, v)| v.clone()),
                        via_workspace: via_ws,
                    });
                }
            }
        }
    }
    // needs-decision first, then by repo/dep; dedupe identical (repo, manifest, dep).
    gaps.sort_by(|a, b| {
        (a.dep_owner.is_some(), &a.repo, &a.manifest, &a.dep)
            .cmp(&(b.dep_owner.is_some(), &b.repo, &b.manifest, &b.dep))
    });
    gaps.dedup_by(|a, b| a.repo == b.repo && a.manifest == b.manifest && a.dep == b.dep);
    gaps
}

/// The Normal + Build dependency tables of a manifest that `cargo publish` verifies
/// — the two top-level ones PLUS every `[target.'cfg(…)'.{dependencies,
/// build-dependencies}]` variant. Dev-deps are excluded (cargo drops a dev-dep's
/// path requirement). Returns the tables as `&toml::Table`.
fn publish_dep_tables(doc: &toml::Value) -> Vec<&toml::value::Table> {
    let mut out = Vec::new();
    for key in ["dependencies", "build-dependencies"] {
        if let Some(t) = doc.get(key).and_then(|t| t.as_table()) {
            out.push(t);
        }
    }
    if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
        for (_cfg, ct) in targets {
            for key in ["dependencies", "build-dependencies"] {
                if let Some(t) = ct.get(key).and_then(|t| t.as_table()) {
                    out.push(t);
                }
            }
        }
    }
    out
}

// ─────────────── cross-repo graph: topo order + blast radius ───────────────

/// The dependency table an edge was declared in. Dev edges are tracked (for
/// blast-radius / "who tests against whom") but are NEVER publish-order-gating: a
/// `cargo publish` requires a crate's Normal+Build deps on crates.io first, but
/// not its dev-deps (they're not part of the published crate's requirements for
/// consumers). Master-of-deps points #1/#2.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum DepKind {
    Normal,
    Build,
    Dev,
}

/// The real crate name a dependency entry resolves to, honoring a `package = "…"`
/// rename (what `cargo metadata`'s `Dependency.name` reports).
fn dep_real_name(key: &str, spec: &toml::Value) -> String {
    spec.as_table()
        .and_then(|t| t.get("package"))
        .and_then(|p| p.as_str())
        .unwrap_or(key)
        .to_string()
}

/// The version REQUIREMENT a dependency entry declares, if any: the `version = …`
/// of a table entry, or a bare `dep = "1.2"` string. `None` when the entry states
/// no version (e.g. a path-only dep, or a `workspace = true` inheritance whose
/// requirement must be looked up in `[workspace.dependencies]` by the caller).
fn dep_version_req_string(spec: &toml::Value) -> Option<String> {
    match spec {
        toml::Value::Table(t) => t.get("version").and_then(|v| v.as_str()).map(str::to_string),
        toml::Value::String(s) => Some(s.clone()),
        _ => None,
    }
}

/// True when `spec` resolves to IN-WORKSPACE SOURCE: a direct `path = …`, or a
/// `workspace = true` inheritance whose `[workspace.dependencies]` entry (looked up
/// by real name in `ws_paths`) itself carries a path. A pure version dep (no path)
/// is crates.io-resolvable and so NOT order-gating for a from-source republish.
fn dep_is_source_path(spec: &toml::Value, real: &str, ws_paths: &BTreeMap<String, bool>) -> bool {
    match spec {
        toml::Value::Table(t) => {
            if t.contains_key("path") {
                return true;
            }
            if t.get("workspace").and_then(|w| w.as_bool()).unwrap_or(false) {
                return *ws_paths.get(real).unwrap_or(&false);
            }
            false
        }
        // A bare `foo = "1.2"` is a version pin — never a path.
        _ => false,
    }
}

/// Scan a repo's Cargo.tomls into the cross-repo dependency facts. The master
/// gatherer: resolves workspace inheritance, separates dep KINDS, keeps optional
/// + target-`cfg` deps in the order graph (tagged), and excludes dev / version-pin
/// / `publish=false` edges from ordering while still recording them for the report.
/// Pure filesystem walk — no warehouse, no `nornir` dep.
pub fn gather_repo_graph(repo: &str, root: &Path) -> Result<RepoGraph> {
    use std::collections::BTreeSet;
    let manifests: Vec<toml::Value> = find_cargo_tomls(root, 4)
        .into_iter()
        .filter_map(|p| std::fs::read_to_string(&p).ok())
        .filter_map(|t| t.parse::<toml::Table>().map(toml::Value::Table).ok())
        .collect();

    // Pass 1: the repo's `[workspace.dependencies]` — real name → has-path. Used to
    // resolve `dep.workspace = true` inheritance back to a real path/version. Also
    // captures the inherited version REQUIREMENT so a `workspace = true` path dep
    // still surfaces the `version=` a consumer resolves the registry copy at.
    let mut ws_paths: BTreeMap<String, bool> = BTreeMap::new();
    let mut ws_reqs: BTreeMap<String, String> = BTreeMap::new();
    for doc in &manifests {
        if let Some(t) = doc
            .get("workspace")
            .and_then(|w| w.get("dependencies"))
            .and_then(|t| t.as_table())
        {
            for (key, spec) in t {
                let real = dep_real_name(key, spec);
                let has_path = spec.as_table().map(|s| s.contains_key("path")).unwrap_or(false);
                ws_paths.insert(real.clone(), has_path);
                if let Some(req) = dep_version_req_string(spec) {
                    ws_reqs.insert(real, req);
                }
            }
        }
    }

    let mut g = RepoGraph { repo: repo.to_string(), ..Default::default() };

    for doc in &manifests {
        let package = doc.get("package");
        // `publish = false` (xtask, bench, internal test helpers): ships nothing, so
        // its deps must NEVER gate publish order (that's what turns the
        // nornir↔{holger,znippy,facett} tooling link into a false cycle). Its DEV
        // edges are still captured below for the transparency report.
        let publishable = package
            .and_then(|p| p.get("publish"))
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
        let pkg_name = package
            .and_then(|p| p.get("name"))
            .and_then(|n| n.as_str())
            .map(str::to_string);
        if publishable {
            if let Some(n) = &pkg_name {
                g.produces.insert(n.clone());
                g.crate_deps.entry(n.clone()).or_default();
            }
        }

        // Enumerate every dep table in this manifest: the three top-level kinds plus
        // their `[target.'cfg(…)'.…]` variants (master-of-deps point #5).
        let kinds = [
            ("dependencies", DepKind::Normal),
            ("build-dependencies", DepKind::Build),
            ("dev-dependencies", DepKind::Dev),
        ];
        let mut tables: Vec<(DepKind, Option<String>, &toml::Value)> = Vec::new();
        for (k, kind) in kinds {
            if let Some(t) = doc.get(k) {
                tables.push((kind, None, t));
            }
        }
        if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
            for (cfg, ct) in targets {
                for (k, kind) in kinds {
                    if let Some(t) = ct.get(k) {
                        tables.push((kind, Some(cfg.clone()), t));
                    }
                }
            }
        }

        // First find the OPTIONAL order-table dep keys (so `[features]` can be
        // mapped to the deps it enables).
        let mut optional_keys: BTreeSet<String> = BTreeSet::new();
        for (kind, _cfg, t) in &tables {
            if *kind == DepKind::Dev {
                continue;
            }
            let Some(tbl) = t.as_table() else { continue };
            for (key, spec) in tbl {
                if spec
                    .as_table()
                    .and_then(|d| d.get("optional"))
                    .and_then(|o| o.as_bool())
                    .unwrap_or(false)
                {
                    optional_keys.insert(key.clone());
                }
            }
        }
        // `[features]` → which optional dep KEYS each feature turns on. Forms:
        // `dep:foo` (explicit), `foo/feat` (enables foo), `foo?/feat` (weak — does
        // NOT enable foo), bare `foo` (enables optional dep foo). An optional dep
        // with no explicit reference gets the implicit same-named feature.
        let mut key_features: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
        if let Some(feats) = doc.get("features").and_then(|f| f.as_table()) {
            for (feat, list) in feats {
                let Some(arr) = list.as_array() else { continue };
                for item in arr {
                    let Some(s) = item.as_str() else { continue };
                    let enabled = if let Some(rest) = s.strip_prefix("dep:") {
                        Some(rest.to_string())
                    } else if let Some((name, _)) = s.split_once('/') {
                        if name.ends_with('?') { None } else { Some(name.to_string()) }
                    } else {
                        Some(s.to_string())
                    };
                    if let Some(dep) = enabled {
                        if optional_keys.contains(&dep) {
                            key_features.entry(dep).or_default().insert(feat.clone());
                        }
                    }
                }
            }
        }
        for key in &optional_keys {
            // Implicit feature `foo` for an optional dep `foo` with no explicit
            // `dep:foo` mention — cargo synthesizes it.
            key_features.entry(key.clone()).or_default().insert(key.clone());
        }

        // Second pass: record edges.
        for (kind, cfg, t) in &tables {
            let Some(tbl) = t.as_table() else { continue };
            for (key, spec) in tbl {
                let real = dep_real_name(key, spec);
                if *kind == DepKind::Dev {
                    // Dev edge — captured for the transparency report, never gating.
                    // (A dep named exactly after its own repo is still its own crate
                    // dev-dep, harmless: cross-repo matching filters to producers.)
                    if real != repo {
                        g.dev_deps.insert(real);
                    }
                    continue;
                }
                // Normal / Build. Only PUBLISHABLE crates' deps gate order.
                if !publishable {
                    continue;
                }
                if dep_is_source_path(spec, &real, &ws_paths) {
                    // Per-CRATE order edge (incl. same-repo crate deps, e.g.
                    // skade-katalog→skade) — the true publish granularity. The topo
                    // filters to produced crate names, so a self-named dep is fine.
                    if let Some(pn) = &pkg_name {
                        if *pn != real {
                            g.crate_deps.entry(pn.clone()).or_default().insert(real.clone());
                        }
                    }
                    // Repo-level set skips a dep named exactly after this repo
                    // (avoids a self-loop in the coarse repo graph).
                    if real == repo {
                        continue;
                    }
                    g.deps.insert(real.clone());
                    // Record the version REQUIREMENT this consumer declares on the dep
                    // (direct `version=`, or the inherited `[workspace.dependencies]`
                    // pin under `workspace = true`). The empty string is a sentinel for
                    // a path dep with NO version = → it can never resolve from the
                    // registry, so it must stay order-gating.
                    let req = dep_version_req_string(spec)
                        .or_else(|| {
                            spec.as_table()
                                .and_then(|d| d.get("workspace"))
                                .and_then(|w| w.as_bool())
                                .unwrap_or(false)
                                .then(|| ws_reqs.get(&real).cloned())
                                .flatten()
                        })
                        .unwrap_or_default();
                    g.dep_reqs.entry(real.clone()).or_default().insert(req.clone());
                    // Per-consumer attribution (the precheck's publish-set edge gate).
                    if let Some(pn) = &pkg_name {
                        g.crate_dep_reqs
                            .entry(pn.clone())
                            .or_default()
                            .entry(real.clone())
                            .or_default()
                            .insert(req);
                    }
                    if optional_keys.contains(key) {
                        let feats = key_features.get(key).cloned().unwrap_or_default();
                        g.optional_deps.entry(real.clone()).or_default().extend(feats);
                    }
                    if let Some(c) = cfg {
                        g.cfg_deps.entry(real.clone()).or_default().insert(c.clone());
                    }
                } else {
                    // Cross-repo-by-name but version-pinned to crates.io → not gating.
                    g.version_pinned.insert(real);
                }
            }
        }
    }
    Ok(g)
}

/// repo → the set of OTHER repos it depends on (A→B when A declares a dependency
/// on a crate B produces).
fn repo_edges(graphs: &[RepoGraph]) -> BTreeMap<String, std::collections::BTreeSet<String>> {
    repo_edges_gated(graphs, &std::collections::BTreeSet::new())
}

/// Like [`repo_edges`] but treats any dep crate in `non_gating` as ALREADY
/// resolvable from crates.io — its current published version satisfies the
/// dependent's `version=` requirement, so on publish cargo strips the local
/// `path=` and resolves it from the registry (no republish-first needed). An
/// edge justified ONLY by such crates is therefore dropped. With an empty set it
/// is exactly the naive name-only model.
fn repo_edges_gated(
    graphs: &[RepoGraph],
    non_gating: &std::collections::BTreeSet<String>,
) -> BTreeMap<String, std::collections::BTreeSet<String>> {
    let mut out: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
    for a in graphs {
        let set = out.entry(a.repo.clone()).or_default();
        for b in graphs {
            if a.repo == b.repo {
                continue;
            }
            // Edge a→b iff a depends on SOME crate b produces that STILL gates —
            // i.e. is not already satisfiable from the registry.
            let gates =
                b.produces.iter().any(|c| a.deps.contains(c) && !non_gating.contains(c));
            if gates {
                set.insert(b.repo.clone());
            }
        }
    }
    out
}

/// Every crate that JUSTIFIES a cross-repo publish-order edge — produced by one
/// repo and (gating-) depended on by another. ONLY these need a registry probe
/// to decide whether they still gate (unpublished) or resolve from crates.io
/// (already live). Pure.
pub fn cross_repo_gating_crates(graphs: &[RepoGraph]) -> std::collections::BTreeSet<String> {
    let mut out = std::collections::BTreeSet::new();
    for a in graphs {
        for b in graphs {
            if a.repo == b.repo {
                continue;
            }
            for c in b.produces.intersection(&a.deps) {
                out.insert(c.clone());
            }
        }
    }
    out
}

/// Which cross-repo gating crates are ALREADY resolvable from the registry and so
/// NON-order-gating: crates.io serves a published version satisfying EVERY
/// consumer's declared requirement. This is the general, correct rule the naive
/// order misses — it holds **regardless of whether the crate's producing repo is
/// ALSO in the current publish set**. The consumer resolves the dep from the
/// registry (the required version already exists), so the producer need NOT publish
/// first; order is a hard blocker ONLY when a consumer's requirement can be met
/// solely by a not-yet-published version (a fresh crate, or a pin bumped past every
/// live version — the genuinely-unpublished case, which stays gating).
///
/// A crate is dropped to non-gating iff, for the crate `c`:
///   * at least one consumer in the set gates on it (it justifies a cross-repo
///     edge), AND
///   * EVERY consumer requirement on `c` is satisfied by some published version
///     (`probe.req_satisfied == Some(true)`). A consumer that declares no version
///     (empty-string sentinel: a path dep with no `version=`) can never resolve
///     from the registry, so its presence keeps `c` gating; and a `None` probe
///     (offline / undeterminable) is conservative — it keeps `c` gating.
///
/// Requiring ALL consumers satisfied is the conservative choice for a GLOBAL
/// non-gating set: if one consumer needs an unpublished version while another is
/// happy with a live one, the crate must still be produced first for the former.
/// Pure over `graphs` + the injected `probe` (the relocated
/// [`preflight::RegistryProbe`](crate::registry::RegistryProbe)).
pub fn registry_non_gating_crates(
    graphs: &[RepoGraph],
    probe: &dyn crate::registry::RegistryProbe,
) -> std::collections::BTreeSet<String> {
    use std::collections::{BTreeMap, BTreeSet};
    let gating = cross_repo_gating_crates(graphs);
    // Per gating crate → every version requirement a CONSUMER (a different repo that
    // does not itself produce the crate) declares on it. A gating dep with no
    // recorded requirement contributes the empty-string sentinel (stays gating).
    let mut reqs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for a in graphs {
        for c in &gating {
            if a.deps.contains(c) && !a.produces.contains(c) {
                let entry = reqs.entry(c.clone()).or_default();
                match a.dep_reqs.get(c) {
                    Some(rs) if !rs.is_empty() => entry.extend(rs.iter().cloned()),
                    _ => {
                        entry.insert(String::new());
                    }
                }
            }
        }
    }
    let mut out = BTreeSet::new();
    for c in &gating {
        let Some(rs) = reqs.get(c) else { continue };
        if rs.is_empty() {
            continue;
        }
        let all_satisfied = rs
            .iter()
            .all(|r| !r.is_empty() && probe.req_satisfied(c, r) == Some(true));
        if all_satisfied {
            out.insert(c.clone());
        }
    }
    out
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct TopoReport {
    /// Publish order: dependencies before dependents.
    pub order: Vec<String>,
    /// Repos left unordered because they sit on a dependency cycle (empty = DAG).
    pub cycle: Vec<String>,
}

/// Topological PUBLISH order (dependencies first), Kahn's algorithm over local
/// Cargo.toml edges. Deterministic (ties broken by name).
pub fn publish_order(graphs: &[RepoGraph]) -> TopoReport {
    let deps_on = repo_edges(graphs);
    let mut indeg: BTreeMap<String, usize> =
        deps_on.iter().map(|(r, d)| (r.clone(), d.len())).collect();
    let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (a, ds) in &deps_on {
        for b in ds {
            dependents.entry(b.clone()).or_default().push(a.clone());
        }
    }
    let mut ready: std::collections::BTreeSet<String> =
        indeg.iter().filter(|&(_, &d)| d == 0).map(|(r, _)| r.clone()).collect();
    let mut order = Vec::new();
    while let Some(n) = ready.iter().next().cloned() {
        ready.remove(&n);
        order.push(n.clone());
        if let Some(deps) = dependents.get(&n) {
            for a in deps {
                if let Some(d) = indeg.get_mut(a) {
                    *d -= 1;
                    if *d == 0 {
                        ready.insert(a.clone());
                    }
                }
            }
        }
    }
    let cycle: Vec<String> = indeg.keys().filter(|r| !order.contains(r)).cloned().collect();
    TopoReport { order, cycle }
}

// ─────────────────── cycle-break advisor ───────────────────

/// A concrete suggestion for breaking ONE dependency cycle: the cycle's members,
/// the single edge cheapest to cut (the one justified by the FEWEST crates), the
/// crates on that edge, and human advice.
#[derive(Debug, Clone, Serialize)]
pub struct CycleAdvice {
    /// The strongly-connected component = the repos mutually entangled.
    pub members: Vec<String>,
    /// Suggested edge to cut: `cut_from` depends on `cut_to`.
    pub cut_from: String,
    pub cut_to: String,
    /// The crates `cut_to` produces that `cut_from` depends on — the thing to
    /// extract or make optional to break the edge.
    pub via: Vec<String>,
    pub rationale: String,
}

/// Crates that justify the edge `from → to`: the ones `to` produces and `from`
/// depends on. The smaller this set, the cheaper the edge is to cut.
fn edge_via(graphs: &[RepoGraph], from: &str, to: &str) -> Vec<String> {
    let (Some(f), Some(t)) =
        (graphs.iter().find(|g| g.repo == from), graphs.iter().find(|g| g.repo == to))
    else {
        return vec![];
    };
    let mut v: Vec<String> = t.produces.intersection(&f.deps).cloned().collect();
    v.sort();
    v
}

/// Strongly-connected components over a dependency graph — the CANONICAL Tarjan in
/// [`crate::graph_math::tarjan_scc`] (`O(V+E)`, sorted comps, deterministic). Each
/// SCC with >1 node (or a self-loop) is a dependency cycle.
fn sccs(edges: &BTreeMap<String, std::collections::BTreeSet<String>>) -> Vec<Vec<String>> {
    crate::graph_math::tarjan_scc(edges)
}

/// For every dependency CYCLE (SCC > 1, or a self-loop), suggest the cheapest edge
/// to cut: the intra-cycle edge justified by the fewest crates (ties broken by
/// name). Pure — operates on the local Cargo.toml graph. Empty when the graph is a
/// clean DAG.
pub fn cycle_advice(graphs: &[RepoGraph]) -> Vec<CycleAdvice> {
    let edges = repo_edges(graphs);
    let mut advice = Vec::new();
    for comp in sccs(&edges) {
        let in_comp: std::collections::BTreeSet<&str> = comp.iter().map(|s| s.as_str()).collect();
        let self_loop =
            comp.len() == 1 && edges.get(&comp[0]).map(|d| d.contains(&comp[0])).unwrap_or(false);
        if comp.len() < 2 && !self_loop {
            continue;
        }
        // Candidate edges: intra-cycle (from → to, both in the SCC).
        let mut best: Option<(String, String, Vec<String>)> = None;
        for from in &comp {
            if let Some(deps) = edges.get(from) {
                for to in deps {
                    if !in_comp.contains(to.as_str()) {
                        continue;
                    }
                    let via = edge_via(graphs, from, to);
                    let better = match &best {
                        None => true,
                        Some((bf, bt, bv)) => {
                            (via.len(), from.as_str(), to.as_str())
                                < (bv.len(), bf.as_str(), bt.as_str())
                        }
                    };
                    if better {
                        best = Some((from.clone(), to.clone(), via));
                    }
                }
            }
        }
        if let Some((cut_from, cut_to, via)) = best {
            let rationale = if via.is_empty() {
                format!("cut `{cut_from} → {cut_to}` (fewest crates)")
            } else if via.len() == 1 {
                format!(
                    "`{cut_from} → {cut_to}` rides on one crate (`{}`); extract it into a leaf crate both depend on, or make the dep optional/dev-only",
                    via[0]
                )
            } else {
                format!(
                    "`{cut_from} → {cut_to}` rides on {} crates ({}); extract them into a shared leaf crate, or make the dep optional/dev-only",
                    via.len(),
                    via.join(", ")
                )
            };
            advice.push(CycleAdvice { members: comp, cut_from, cut_to, via, rationale });
        }
    }
    advice
}

// ─────────────────────── report edges ───────────────────────

/// One repo→repo dependency edge: `from` depends on `to` because `to` produces
/// one of the crates `from` declares. Carries the `via` crates so a dashboard can
/// label the edge.
#[derive(Debug, Clone, Serialize)]
pub struct RepoEdge {
    pub from: String,
    pub to: String,
    pub via: Vec<String>,
}

/// The repo→repo dependency edges (with the `via` crates), as a flat list. Pure:
/// derived from the gathered repo graphs.
pub fn repo_dep_edges(graphs: &[RepoGraph]) -> Vec<RepoEdge> {
    let mut out = Vec::new();
    for a in graphs {
        for b in graphs {
            if a.repo == b.repo {
                continue;
            }
            let mut via: Vec<String> =
                b.produces.iter().filter(|c| a.deps.contains(*c)).cloned().collect();
            if !via.is_empty() {
                via.sort();
                out.push(RepoEdge { from: a.repo.clone(), to: b.repo.clone(), via });
            }
        }
    }
    out.sort_by(|x, y| (&x.from, &x.to).cmp(&(&y.from, &y.to)));
    out
}

// ───────── master-of-deps: kind-tagged cross-repo sections ─────────

/// A cross-repo DEV-dependency edge — `from` dev-depends on a crate `to` produces.
/// Deliberately EXCLUDED from publish order (a dev-dep isn't part of the published
/// crate's requirements). Listed so the exclusion is transparent.
#[derive(Debug, Clone, Serialize)]
pub struct DevEdge {
    pub from: String,
    pub to: String,
    pub via: Vec<String>,
}

/// A feature-gated (optional) cross-repo dependency — KEPT in the order graph but
/// surfaced with the enabling feature(s).
#[derive(Debug, Clone, Serialize)]
pub struct OptionalCrossDep {
    pub from: String,
    pub to: String,
    pub krate: String,
    pub features: Vec<String>,
}

/// A `[target.'cfg(…)']`-gated cross-repo dependency — a real (order-gating) dep
/// tagged with the platform cfg it's conditional on.
#[derive(Debug, Clone, Serialize)]
pub struct CfgCrossDep {
    pub from: String,
    pub to: String,
    pub krate: String,
    pub cfgs: Vec<String>,
}

/// A real dependency CYCLE that REMAINS in the order-relevant (non-dev, path) graph
/// — these are NOT silently broken; doctor reports the entangled repos and every
/// offending intra-cycle edge so the cause is explicit.
#[derive(Debug, Clone, Serialize)]
pub struct DepCycle {
    pub members: Vec<String>,
    pub edges: Vec<RepoEdge>,
}

/// Map each produced crate name → the repo that produces it (publishable crates).
fn producer_index(graphs: &[RepoGraph]) -> BTreeMap<String, String> {
    let mut idx = BTreeMap::new();
    for g in graphs {
        for c in &g.produces {
            idx.insert(c.clone(), g.repo.clone());
        }
    }
    idx
}

/// Cross-repo DEV edges (excluded from order): for each repo's `dev_deps`, the
/// producer repo of each dev-depended crate. Master-of-deps point #7.
pub fn excluded_dev_edges(graphs: &[RepoGraph]) -> Vec<DevEdge> {
    let producer = producer_index(graphs);
    let mut grouped: BTreeMap<(String, String), std::collections::BTreeSet<String>> =
        BTreeMap::new();
    for g in graphs {
        for krate in &g.dev_deps {
            if let Some(to) = producer.get(krate) {
                if *to != g.repo {
                    grouped.entry((g.repo.clone(), to.clone())).or_default().insert(krate.clone());
                }
            }
        }
    }
    grouped
        .into_iter()
        .map(|((from, to), via)| DevEdge { from, to, via: via.into_iter().collect() })
        .collect()
}

/// Feature-gated (optional) cross-repo deps with the feature(s) that enable them.
/// Master-of-deps point #4.
pub fn optional_cross_deps(graphs: &[RepoGraph]) -> Vec<OptionalCrossDep> {
    let producer = producer_index(graphs);
    let mut out = Vec::new();
    for g in graphs {
        for (krate, feats) in &g.optional_deps {
            if let Some(to) = producer.get(krate) {
                if *to != g.repo {
                    out.push(OptionalCrossDep {
                        from: g.repo.clone(),
                        to: to.clone(),
                        krate: krate.clone(),
                        features: feats.iter().cloned().collect(),
                    });
                }
            }
        }
    }
    out.sort_by(|a, b| (&a.from, &a.krate).cmp(&(&b.from, &b.krate)));
    out
}

/// Cross-repo deps a repo declares against a crates.io VERSION (no `path=`) for a
/// crate ANOTHER repo produces. These resolve from the registry, so they are NOT
/// order-gating — surfaced so the path-vs-version split is visible (point #8).
pub fn version_pinned_cross_deps(graphs: &[RepoGraph]) -> Vec<RepoEdge> {
    let producer = producer_index(graphs);
    let mut grouped: BTreeMap<(String, String), std::collections::BTreeSet<String>> =
        BTreeMap::new();
    for g in graphs {
        for krate in &g.version_pinned {
            if let Some(to) = producer.get(krate) {
                if *to != g.repo {
                    grouped.entry((g.repo.clone(), to.clone())).or_default().insert(krate.clone());
                }
            }
        }
    }
    grouped
        .into_iter()
        .map(|((from, to), via)| RepoEdge { from, to, via: via.into_iter().collect() })
        .collect()
}

/// `[target.'cfg(…)']`-gated cross-repo deps, tagged with the cfg. Point #5.
pub fn cfg_cross_deps(graphs: &[RepoGraph]) -> Vec<CfgCrossDep> {
    let producer = producer_index(graphs);
    let mut out = Vec::new();
    for g in graphs {
        for (krate, cfgs) in &g.cfg_deps {
            if let Some(to) = producer.get(krate) {
                if *to != g.repo {
                    out.push(CfgCrossDep {
                        from: g.repo.clone(),
                        to: to.clone(),
                        krate: krate.clone(),
                        cfgs: cfgs.iter().cloned().collect(),
                    });
                }
            }
        }
    }
    out.sort_by(|a, b| (&a.from, &a.krate).cmp(&(&b.from, &b.krate)));
    out
}

/// The CRATE-level order graph: crate name → owning repo, and crate name → its
/// in-workspace order-gating dep crate names (filtered to crates actually produced
/// somewhere). cargo publishes CRATES, so this is the granularity where the true
/// order + real cycles live.
fn crate_graph(
    graphs: &[RepoGraph],
) -> (BTreeMap<String, String>, BTreeMap<String, std::collections::BTreeSet<String>>) {
    use std::collections::BTreeSet;
    let mut owner: BTreeMap<String, String> = BTreeMap::new();
    let produced: BTreeSet<String> =
        graphs.iter().flat_map(|g| g.crate_deps.keys().cloned()).collect();
    let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for g in graphs {
        for (c, deps) in &g.crate_deps {
            owner.entry(c.clone()).or_insert_with(|| g.repo.clone());
            let e = adj.entry(c.clone()).or_default();
            for d in deps {
                if d != c && produced.contains(d) {
                    e.insert(d.clone());
                }
            }
        }
    }
    (owner, adj)
}

/// Dependency CYCLES at CRATE granularity — Tarjan SCCs over [`crate_graph`]. Each
/// SCC>1 (or self-loop) is a REAL publish cycle. Master-of-deps point #3.
pub fn detect_cycles(graphs: &[RepoGraph]) -> Vec<DepCycle> {
    let (_owner, adj) = crate_graph(graphs);
    let mut out = Vec::new();
    for comp in sccs(&adj) {
        let in_comp: std::collections::BTreeSet<&str> = comp.iter().map(|s| s.as_str()).collect();
        let self_loop =
            comp.len() == 1 && adj.get(&comp[0]).map(|d| d.contains(&comp[0])).unwrap_or(false);
        if comp.len() < 2 && !self_loop {
            continue;
        }
        let mut cyc_edges = Vec::new();
        for from in &comp {
            if let Some(deps) = adj.get(from) {
                for to in deps {
                    if in_comp.contains(to.as_str()) {
                        cyc_edges.push(RepoEdge { from: from.clone(), to: to.clone(), via: vec![] });
                    }
                }
            }
        }
        out.push(DepCycle { members: comp, edges: cyc_edges });
    }
    out
}

// ─────────────── MFAS cycle solutions + publish waves (graph_math) ───────────────

/// A serialize-friendly view of a [`crate::graph_math::CutSolution`]: one concrete
/// way to break a cycle, with its cost, the edges it removes, the human remedy
/// actions, and the residual topo order that PROVES the cut yields a DAG.
#[derive(Debug, Clone, Serialize)]
pub struct CutSolutionView {
    /// Total cut cost (DEV = 0, OPTIONAL = 1, crate-split = high). Lower is better.
    pub cost: u32,
    /// The `(from, to)` edges to remove.
    pub edges: Vec<(String, String)>,
    /// One remedy sentence per removed edge, cheapest-class first.
    pub actions: Vec<String>,
    /// Proof witness: a topological order over the SCC's crates once the edges are
    /// removed. Its existence certifies the residual graph is acyclic.
    pub proof_order: Vec<String>,
}

/// The MFAS-ranked solution set for ONE dependency cycle: the entangled crates
/// (`members` = the SCC) and a list of candidate cuts, cheapest-first.
#[derive(Debug, Clone, Serialize)]
pub struct CycleSolution {
    pub members: Vec<String>,
    pub solutions: Vec<CutSolutionView>,
}

/// For every crate-level dependency cycle, ENUMERATE + RANK the concrete ways to
/// break it (Minimum Feedback Arc Set over the tiny SCC). Empty when the crate
/// graph is a clean DAG.
pub fn cycle_solutions(graphs: &[RepoGraph]) -> Vec<CycleSolution> {
    use std::collections::BTreeSet;
    let (_owner, adj) = crate_graph(graphs);
    // Coarse feature-gate signal: any crate declared as an optional dep somewhere is
    // treated as gate-off-able (cost 1) when it appears as a cut target.
    let optional_crates: BTreeSet<String> =
        graphs.iter().flat_map(|g| g.optional_deps.keys().cloned()).collect();
    let mut out = Vec::new();
    for comp in crate::graph_math::cycles(&adj) {
        let in_comp: BTreeSet<&str> = comp.iter().map(|s| s.as_str()).collect();
        let mut edges = Vec::new();
        for from in &comp {
            if let Some(deps) = adj.get(from) {
                for to in deps {
                    if !in_comp.contains(to.as_str()) {
                        continue;
                    }
                    let class = if optional_crates.contains(to) {
                        crate::graph_math::EdgeClass::Optional
                    } else {
                        crate::graph_math::EdgeClass::Normal
                    };
                    edges.push(crate::graph_math::ClassifiedEdge {
                        from: from.clone(),
                        to: to.clone(),
                        via: vec![to.clone()],
                        class,
                    });
                }
            }
        }
        let sols = crate::graph_math::min_feedback_arc_set(&comp, &edges);
        out.push(CycleSolution {
            members: comp,
            solutions: sols
                .into_iter()
                .map(|s| CutSolutionView {
                    cost: s.cost,
                    edges: s.edges,
                    actions: s.actions,
                    proof_order: s.proof_order,
                })
                .collect(),
        });
    }
    out
}

/// PARALLELIZABLE PUBLISH WAVES over the crate graph: each wave is an antichain of
/// the SCC condensation = crates publishable CONCURRENTLY (deps-first).
pub fn publish_waves(graphs: &[RepoGraph]) -> Vec<Vec<String>> {
    let (_owner, adj) = crate_graph(graphs);
    crate::graph_math::condense(&adj).waves()
}

/// Project a [`DoctorReport`] onto the outcome-space Ω as the open-symptom multiset
/// the invertive planner ([`crate::graph_math::plan`]) closes. Version gaps + skew +
/// dirty + cheap cycles are AUTO classes; non-member deps + hard (split-only) cycles
/// + patch-fork holds are DECISION classes.
pub fn symptoms(report: &DoctorReport) -> crate::graph_math::Symptoms {
    use crate::graph_math::SymptomKind::*;
    let mut s = crate::graph_math::Symptoms::new();
    s.set(
        VersionGap,
        report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_some()).count(),
    );
    s.set(
        NonMemberDep,
        report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_none()).count(),
    );
    // Skew that a plain manifest bump can actually move (not held by a transitive pin).
    s.set(
        Skew,
        report
            .skew
            .iter()
            .filter(|c| {
                c.entries
                    .iter()
                    .any(|e| e.status == SkewStatus::Behind && !e.held_by_transitive_pin)
            })
            .count(),
    );
    s.set(Dirty, report.dirty.iter().filter(|d| d.dirty).count());
    let (mut cheap, mut hard) = (0usize, 0usize);
    for cs in &report.cycle_solutions {
        match cs.solutions.first() {
            Some(best) if best.cost <= 1 => cheap += 1,
            _ => hard += 1, // no solution, or only a crate-split → a decision
        }
    }
    s.set(CheapCycle, cheap);
    s.set(HardCycle, hard);
    s.set(PromoteBlock, report.promote_blocked.len());
    s
}

/// PUBLISH order over REPOS, made cycle-proof by SCC CONDENSATION. `cycle` carries
/// repos on a REAL crate-level cycle (from [`detect_cycles`]) — empty when the
/// entanglement is a clean interleaved crate DAG. Deterministic.
pub fn crate_publish_order(graphs: &[RepoGraph]) -> TopoReport {
    crate_publish_order_gated(graphs, &std::collections::BTreeSet::new())
}

/// [`crate_publish_order`] made REGISTRY-AWARE: dep crates in `non_gating` are
/// dropped from the order graph, so an already-published straggler `path=` dep
/// can't forge a false cycle. `crate_publish_order(graphs)` is exactly this with an
/// empty set.
pub fn crate_publish_order_gated(
    graphs: &[RepoGraph],
    non_gating: &std::collections::BTreeSet<String>,
) -> TopoReport {
    use std::collections::BTreeSet;
    let edges = repo_edges_gated(graphs, non_gating); // from → repos it depends on
    let comps = sccs(&edges); // each: sorted member list
    let n = comps.len();
    let mut comp_of: BTreeMap<String, usize> = BTreeMap::new();
    for (i, c) in comps.iter().enumerate() {
        for r in c {
            comp_of.insert(r.clone(), i);
        }
    }
    // Condensation: comp → the comps it depends on.
    let mut cadj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
    for (from, tos) in &edges {
        let cf = comp_of[from];
        for to in tos {
            let ct = comp_of[to];
            if cf != ct {
                cadj[cf].insert(ct);
            }
        }
    }
    // Kahn deps-first over the condensation. indeg(comp) = #comps it depends on.
    let mut indeg: Vec<usize> = (0..n).map(|c| cadj[c].len()).collect();
    let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
    for (cf, deps) in cadj.iter().enumerate() {
        for &ct in deps {
            consumers[ct].push(cf);
        }
    }
    // Ready set ordered by the comp's smallest member name → stable, readable order.
    let key = |c: usize| comps[c].first().cloned().unwrap_or_default();
    let mut ready: BTreeSet<(String, usize)> =
        (0..n).filter(|&c| indeg[c] == 0).map(|c| (key(c), c)).collect();
    let mut order: Vec<String> = Vec::new();
    while let Some((k, c)) = ready.iter().next().cloned() {
        ready.remove(&(k, c));
        // Expand the SCC into its member repos (already sorted).
        order.extend(comps[c].iter().cloned());
        for &con in &consumers[c] {
            indeg[con] -= 1;
            if indeg[con] == 0 {
                ready.insert((key(con), con));
            }
        }
    }
    // REAL cycles are crate-level: only those block publishability. A repo-level SCC
    // that's a clean crate DAG is fully ordered above and reported as NO cycle. Map
    // the crate-cycle members back to their owning repos for the `cycle` field.
    let owner = crate_graph(graphs).0;
    let cycle: Vec<String> = detect_cycles(graphs)
        .iter()
        .flat_map(|c| c.members.iter().filter_map(|kr| owner.get(kr).cloned()))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect();
    TopoReport { order, cycle }
}

/// The GLOBAL, crate-level PUBLISH order across the whole constellation, deps-first
/// — the true `cargo publish` granularity, with each crate paired with the repo that
/// produces it. Unlike the REPO-block order ([`repo_publish_order`] / one repo's
/// crates published as a contiguous block), this INTERLEAVES crates from different
/// repos so a cross-repo crate edge inside a repo-level SCC is sequenced correctly.
///
/// This is the crux of the `dwarves ⇄ nornir` release: the REPO graph has an SCC
/// `{dwarves, nornir}` (dwarves' `dwarves` crate path-deps nornir's `nornir-build-thing`
/// while nornir's `nornir` crate path-deps `dwarves`), yet the CRATE graph is a clean
/// DAG — `nornir-build-thing → nornir-warehouse-embed → dwarves → nornir`. A repo-block
/// publisher cannot honour that: publishing all of nornir first strands `nornir` (needs
/// `dwarves`), and publishing all of dwarves first strands `nornir-warehouse-embed`
/// (needs `nornir-build-thing`). Publishing the individual crates in this global order
/// interleaves the two repos and satisfies every edge.
///
/// Built by condensing the crate graph ([`crate_graph`]) and topo-sorting the
/// condensation (deps-first). For a clean crate DAG every crate is its own singleton
/// component, so the result is a full crate topological order; a GENUINE crate cycle
/// collapses to one multi-crate component (still reported precisely by
/// [`detect_cycles`]) whose members come out name-sorted (unpublishable as-is).
/// Deterministic. Pure over the gathered graphs.
pub fn global_crate_publish_order(graphs: &[RepoGraph]) -> Vec<(String, String)> {
    let (owner, adj) = crate_graph(graphs);
    let cond = crate::graph_math::condense(&adj);
    let mut out = Vec::new();
    for c in cond.topo_order() {
        // Within a component (a singleton for a DAG; the entangled crate set for a
        // real cycle) the members are already name-sorted by `condense`.
        for krate in &cond.comps[c] {
            if let Some(repo) = owner.get(krate) {
                out.push((repo.clone(), krate.clone()));
            }
        }
    }
    out
}

/// ONE step of the REAL `--execute` publish driver: publish the crates of `repo`,
/// scoped to `only` (`None` = the repo's WHOLE publishable set, exactly as the legacy
/// repo-block driver did). A member NOT caught in a repo-level SCC yields a single
/// whole-repo step; a member IN a repo-level SCC yields one SCOPED step per contiguous
/// same-repo run of the global crate-level interleaved order — so the SCC's two repos
/// interleave at CRATE granularity and every cross-repo crate edge is honoured.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishStep {
    /// The member repo this step publishes from.
    pub repo: String,
    /// EXACTLY the crate names to publish in this step, or `None` for the whole repo.
    /// Fed verbatim into [`promote::PromoteOpts::only_crates`](crate::promote::PromoteOpts::only_crates).
    pub only: Option<std::collections::BTreeSet<String>>,
}

/// Build the REAL publish STEP sequence — the crux of FIX-the-real-driver. Given a
/// deps-first repo `member_order` (e.g. from [`repo_publish_order`]) and the gathered
/// crate `graphs`, emit the ordered list of [`PublishStep`]s the `--execute` cascade
/// runs:
///
/// * A member that is NOT in a non-trivial repo-level SCC publishes as ONE whole-repo
///   step (`only = None`), positioned at its slot in `member_order` — identical to the
///   legacy repo-block driver, and preserving a UNIFIED-version repo's single bump.
/// * A member that IS in a repo-level SCC is driven by [`global_crate_publish_order`]
///   RESTRICTED to that SCC's repos: that (repo, crate) subsequence is grouped into
///   maximal contiguous same-repo runs, each becoming a SCOPED step
///   (`only = {run's crates}`). The whole SCC is emitted ONCE, at the position its
///   first member reaches in `member_order`. This is EXACTLY the interleaving the
///   Gate-B rehearsal ([`crate::rehearse::cascade_crate_order`]) uses — both consume
///   the same [`global_crate_publish_order`] — so the real publish and the rehearsal
///   can no longer diverge (the false-pass this fixes).
///
/// For the `dwarves ⇄ nornir` SCC the emitted steps interleave as
/// `nornir{…, nornir-build-thing, …}` → `dwarves{nornir-warehouse-embed, …, dwarves}`
/// → `nornir{nornir}`: `nornir-build-thing` publishes before dwarves' embed crate,
/// which publishes before `dwarves`, which publishes before the `nornir` top crate — a
/// sequence a repo-block driver can never produce.
///
/// INVARIANT: every crate publishes AFTER all its cross-repo deps. A run is a
/// subsequence of a crate topological order, so its internal order is deps-first; a
/// non-SCC dep repo is a condensation predecessor of the SCC and so already fully
/// published before the SCC block begins. Pure and deterministic (BTree-ordered);
/// a member absent from `graphs` degrades to a safe whole-repo step.
pub fn plan_publish_steps(member_order: &[String], graphs: &[RepoGraph]) -> Vec<PublishStep> {
    use std::collections::{BTreeMap, BTreeSet};
    let members: BTreeSet<&str> = member_order.iter().map(String::as_str).collect();

    // Repo-level SCCs over the member set (edge `from → to` = "from depends on to").
    let edges = repo_dep_edges(graphs);
    let mut adj: crate::graph_math::Adj =
        member_order.iter().map(|m| (m.clone(), BTreeSet::new())).collect();
    for e in &edges {
        if e.from != e.to
            && members.contains(e.from.as_str())
            && members.contains(e.to.as_str())
        {
            adj.get_mut(&e.from).unwrap().insert(e.to.clone());
        }
    }
    // Map each member caught in a NON-TRIVIAL SCC (>1 repo) to that SCC's repo set.
    let mut scc_of: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for comp in crate::graph_math::tarjan_scc(&adj) {
        if comp.len() > 1 {
            let set: BTreeSet<String> = comp.iter().cloned().collect();
            for m in comp {
                scc_of.insert(m, set.clone());
            }
        }
    }

    // The GLOBAL crate-level interleaved order, restricted to the member set — the
    // SAME order the Gate-B rehearsal consumes.
    let global: Vec<(String, String)> = global_crate_publish_order(graphs)
        .into_iter()
        .filter(|(repo, _)| members.contains(repo.as_str()))
        .collect();

    let mut steps: Vec<PublishStep> = Vec::new();
    let mut emitted_scc: BTreeSet<String> = BTreeSet::new(); // one representative per SCC
    for m in member_order {
        match scc_of.get(m) {
            // Not in a repo-SCC → publish the whole repo as one step (legacy behaviour).
            None => steps.push(PublishStep { repo: m.clone(), only: None }),
            // In a repo-SCC → emit the WHOLE SCC once, interleaved at crate granularity.
            Some(scc) => {
                // A stable representative (the name-least member) dedups the SCC so it
                // is emitted exactly once, at whichever member `member_order` reaches first.
                let rep = scc.iter().next().cloned().unwrap_or_default();
                if !emitted_scc.insert(rep) {
                    continue;
                }
                // Group the SCC-restricted global order into contiguous same-repo runs.
                let mut run_repo: Option<String> = None;
                let mut run: BTreeSet<String> = BTreeSet::new();
                for (repo, krate) in global.iter().filter(|(r, _)| scc.contains(r)) {
                    if run_repo.as_deref() != Some(repo.as_str()) {
                        if let Some(r) = run_repo.take() {
                            if !run.is_empty() {
                                steps.push(PublishStep {
                                    repo: r,
                                    only: Some(std::mem::take(&mut run)),
                                });
                            }
                        }
                        run_repo = Some(repo.clone());
                    }
                    run.insert(krate.clone());
                }
                if let Some(r) = run_repo {
                    if !run.is_empty() {
                        steps.push(PublishStep { repo: r, only: Some(run) });
                    }
                }
            }
        }
    }
    steps
}

/// Deps-first PUBLISH order over the given repo `members`, made cycle-proof by SCC
/// CONDENSATION over the cross-repo [`RepoEdge`]s (edge `from → to` reads "`from`
/// depends on `to`", so `to` publishes first). This is the REPO analogue of
/// [`crate_publish_order_gated`], taking the already-derived edge list (what the CLI
/// holds) instead of the raw graphs.
///
/// Why condensation and not a plain Kahn: the nordisk constellation has repo-level
/// SCCs that are NOT real cycles — e.g. `draupnir ⇄ edda ⇄ tunnr`, mutually linked
/// through DISJOINT crates, so the crate graph is a clean DAG. A naive Kahn stalls on
/// such an SCC and (the bug this replaces) dumps every still-unordered repo
/// ALPHABETICALLY — which stranded `edda` behind a consumer whose name sorts earlier
/// (`Skidbladnir`, capital `S`), so `skidbladnir` was scheduled to publish BEFORE the
/// `nornir-hash`/`jera` it path-depends on. Condensing each SCC to one node and
/// topo-sorting the condensation keeps every genuine dependency edge (a consumer
/// always lands AFTER the SCC that produces what it needs).
///
/// Only members present in `members` participate; edges touching a non-member are
/// ignored. Within an SCC the members come out name-sorted; the outer order is
/// deterministic. When the graph is a DAG this is exactly a deps-first topo order.
pub fn repo_publish_order(members: &[String], edges: &[RepoEdge]) -> Vec<String> {
    use std::collections::BTreeSet;
    let mut names: Vec<String> = members.to_vec();
    names.sort();
    names.dedup();
    let set: BTreeSet<&str> = names.iter().map(String::as_str).collect();
    // adjacency: from → {to} (from depends on to), restricted to the member set.
    let mut adj: BTreeMap<String, BTreeSet<String>> =
        names.iter().map(|n| (n.clone(), BTreeSet::new())).collect();
    for e in edges {
        if e.from != e.to && set.contains(e.from.as_str()) && set.contains(e.to.as_str()) {
            adj.get_mut(&e.from).unwrap().insert(e.to.clone());
        }
    }
    let comps = sccs(&adj); // each: sorted member list; every member appears once
    let n = comps.len();
    let mut comp_of: BTreeMap<String, usize> = BTreeMap::new();
    for (i, c) in comps.iter().enumerate() {
        for r in c {
            comp_of.insert(r.clone(), i);
        }
    }
    // Condensation: comp → the comps it depends on.
    let mut cadj: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
    for (from, tos) in &adj {
        let cf = comp_of[from];
        for to in tos {
            let ct = comp_of[to];
            if cf != ct {
                cadj[cf].insert(ct);
            }
        }
    }
    // Kahn deps-first over the condensation. indeg(comp) = #comps it depends on.
    let mut indeg: Vec<usize> = (0..n).map(|c| cadj[c].len()).collect();
    let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
    for (cf, deps) in cadj.iter().enumerate() {
        for &ct in deps {
            consumers[ct].push(cf);
        }
    }
    // Ready set ordered by the comp's smallest member name → stable, readable order.
    let key = |c: usize| comps[c].first().cloned().unwrap_or_default();
    let mut ready: BTreeSet<(String, usize)> =
        (0..n).filter(|&c| indeg[c] == 0).map(|c| (key(c), c)).collect();
    let mut order: Vec<String> = Vec::new();
    while let Some((k, c)) = ready.iter().next().cloned() {
        ready.remove(&(k, c));
        order.extend(comps[c].iter().cloned()); // expand the SCC (already sorted)
        for &con in &consumers[c] {
            indeg[con] -= 1;
            if indeg[con] == 0 {
                ready.insert((key(con), con));
            }
        }
    }
    order
}

/// One publish-order defect: `repo` is scheduled to publish BEFORE a gating
/// cross-repo dependency it needs is available on crates.io.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrderViolation {
    /// The repo that would `cargo publish` too early.
    pub repo: String,
    /// The dep crate that isn't on the registry yet when `repo` publishes.
    pub dep_crate: String,
    /// The repo that PRODUCES `dep_crate` (scheduled later, or absent from the
    /// plan). Empty string when no repo in the set produces it.
    pub producer: String,
}

/// SELF-CHECK the computed publish `order` BEFORE the irreversible cascade. Every
/// gating cross-repo dep of a repo must be satisfiable the moment that repo
/// publishes — either already live on crates.io (`available`) or produced by a repo
/// EARLIER in `order`. A dep that is neither is an [`OrderViolation`]. Empty ⇒ the
/// order is sound. Pure and deterministic.
pub fn cascade_order_violations(
    graphs: &[RepoGraph],
    order: &[String],
    available: &std::collections::BTreeSet<String>,
) -> Vec<OrderViolation> {
    use std::collections::{BTreeMap, BTreeSet};
    // Which repo produces each crate.
    let mut producer: BTreeMap<String, String> = BTreeMap::new();
    for g in graphs {
        for c in &g.produces {
            producer.insert(c.clone(), g.repo.clone());
        }
    }
    let by_repo: BTreeMap<&str, &RepoGraph> = graphs.iter().map(|g| (g.repo.as_str(), g)).collect();
    // Everything already resolvable, plus crates produced by repos seen so far.
    let mut satisfied: BTreeSet<String> = available.clone();
    let mut viol = Vec::new();
    for repo in order {
        let Some(g) = by_repo.get(repo.as_str()) else { continue };
        for dep in &g.deps {
            // A dep the repo ITSELF produces is intra-repo — never a cross-repo
            // violation. This also correctly handles a same-NAME collision across
            // repos: each repo satisfies its own from within.
            if g.produces.contains(dep) {
                continue;
            }
            // Only cross-repo deps produced somewhere in the plan can be ordered;
            // an external crate (not produced here) resolves from crates.io.
            let Some(pr) = producer.get(dep) else { continue };
            if pr == repo || satisfied.contains(dep) {
                continue; // same repo, or already available / produced earlier.
            }
            viol.push(OrderViolation {
                repo: repo.clone(),
                dep_crate: dep.clone(),
                producer: pr.clone(),
            });
        }
        // This repo now publishes → its crates become available downstream.
        for c in &g.produces {
            satisfied.insert(c.clone());
        }
    }
    viol
}

// ─────────────────────── the report ───────────────────────

#[derive(Debug, Clone, Default, Serialize)]
pub struct DoctorReport {
    pub dirty: Vec<RepoDirty>,
    pub skew: Vec<CrateSkew>,
    pub topo: TopoReport,
    /// Per dirty repo → its blast radius (transitive dependents to re-validate).
    pub blast: BTreeMap<String, Vec<String>>,
    /// One suggestion per dependency cycle for how to break it. Empty = clean DAG.
    #[serde(default)]
    pub cycle_advice: Vec<CycleAdvice>,
    /// The repo→repo dependency edges (A depends on B). Empty = no inter-repo edges.
    #[serde(default)]
    pub repo_edges: Vec<RepoEdge>,
    /// Direct patch-fork blocks: each `[patch.crates-io]` redirect to a
    /// non-registry (`path`/`git`) fork. Empty = nothing patched to a fork.
    #[serde(default)]
    pub patch_forks: Vec<PatchForkBlock>,
    /// The TRANSITIVE promote-blocked crate set. These are EXCLUDED from a
    /// crates.io publish. Empty = nothing blocked.
    #[serde(default)]
    pub promote_blocked: Vec<String>,
    /// Cross-repo DEV edges deliberately EXCLUDED from publish order.
    #[serde(default)]
    pub excluded_dev_edges: Vec<DevEdge>,
    /// Feature-gated (optional) cross-repo deps — KEPT in the order graph.
    #[serde(default)]
    pub optional_cross_deps: Vec<OptionalCrossDep>,
    /// `[target.'cfg(…)']`-gated cross-repo deps, tagged with the cfg.
    #[serde(default)]
    pub cfg_cross_deps: Vec<CfgCrossDep>,
    /// Real dependency cycles remaining in the order-relevant graph. Empty = clean.
    #[serde(default)]
    pub cycles: Vec<DepCycle>,
    /// Cross-repo deps pinned to a crates.io VERSION (no path) — NOT order-gating.
    #[serde(default)]
    pub version_pinned_cross_deps: Vec<RepoEdge>,
    /// PUBLISH PRE-FLIGHT: publishable crates whose Normal/Build `path` deps lack a
    /// `version`. Empty = every path dep is publish-ready.
    #[serde(default)]
    pub path_dep_version_gaps: Vec<PathDepVersionGap>,
    /// MFAS: per real crate cycle, the RANKED list of concrete cuts to break it.
    #[serde(default)]
    pub cycle_solutions: Vec<CycleSolution>,
    /// PARALLELIZABLE PUBLISH WAVES: the crate-graph condensation layered into
    /// antichains.
    #[serde(default)]
    pub waves: Vec<Vec<String>>,
    /// SEMANTIC blast radius: per bumped repo → the EXACT call sites in its
    /// dependents that touch the changed crate's API. Empty unless a producer
    /// that holds a call graph filled it ([`crate::semantic_blast::rank`]).
    #[serde(default)]
    pub semantic_blast: BTreeMap<String, crate::semantic_blast::SemanticBlast>,
    /// EXPLAINED HELD SET (`--explain`): one entry per crate in `promote_blocked`,
    /// stating the foreign fork it rides, whether the dependency is direct, and how
    /// to unblock.
    #[serde(default)]
    pub held_explained: Vec<HeldExplanation>,
    /// SHADOW-SOLVE cross-check: the independent constructive solvers re-solved the
    /// same crate graph, DIFFED against the shipped SCC-condensation order. Additive
    /// and non-fatal — a Warning naming divergent members, never an order change.
    #[serde(default)]
    pub shadow_solve: crate::shadow_solve::ShadowSolveReport,
}

/// Gather + analyze: dirty trees, version skew, publish order, and the blast
/// radius of each dirty repo, for the given repos. Read-only, no mutation.
pub fn run(repos: &[(String, PathBuf)], policy: &DepPolicy) -> Result<DoctorReport> {
    let externals = repos
        .iter()
        .map(|(name, path)| gather_repo_externals(name, path))
        .collect::<Result<Vec<_>>>()?;
    let graphs = repos
        .iter()
        .map(|(name, path)| gather_repo_graph(name, path))
        .collect::<Result<Vec<_>>>()?;

    let dirty = check_dirty(repos);
    let blast = dirty
        .iter()
        .filter(|d| d.dirty)
        .map(|d| (d.repo.clone(), crate::semantic_blast::blast_radius(&graphs, &d.repo)))
        .collect();

    // Read each repo's lockfile so skew hints can tell a free bump from one a
    // transitive dependency pins (e.g. `iceberg 0.9` holding `arrow` at 57).
    let repo_locks: BTreeMap<String, String> = repos
        .iter()
        .filter_map(|(name, path)| {
            std::fs::read_to_string(path.join("Cargo.lock")).ok().map(|t| (name.clone(), t))
        })
        .collect();

    let mut skew = analyze_skew(&externals, policy);
    enrich_transitive_pins(&mut skew, &repo_locks);

    // Patch-fork promote gate (CRATE-precise): scan every repo for `[patch.crates-io]`
    // redirects to a path/git fork, classify each as a FOREIGN fork vs a safe
    // own-crate override, and take the PER-CRATE transitive block closure.
    let block = compute_promote_block(repos.iter().map(|(n, p)| (n.clone(), p.as_path())));
    let patch_forks = block.forks;
    let held_explained = block.explained;
    let promote_blocked: Vec<String> = block.blocked.into_iter().collect();

    // The publish order + cycles are computed at CRATE granularity (cargo publishes
    // crates, not repos), so a repo-level mutual dep that's a clean crate DAG doesn't
    // create a false cycle.
    let cycles = detect_cycles(&graphs);
    let cycle_advice = if cycles.is_empty() { Vec::new() } else { cycle_advice(&graphs) };

    Ok(DoctorReport {
        dirty,
        skew,
        topo: crate_publish_order(&graphs),
        blast,
        cycle_advice,
        repo_edges: repo_dep_edges(&graphs),
        patch_forks,
        promote_blocked,
        excluded_dev_edges: excluded_dev_edges(&graphs),
        optional_cross_deps: optional_cross_deps(&graphs),
        cfg_cross_deps: cfg_cross_deps(&graphs),
        cycles,
        version_pinned_cross_deps: version_pinned_cross_deps(&graphs),
        path_dep_version_gaps: scan_path_dep_version_gaps(repos),
        cycle_solutions: cycle_solutions(&graphs),
        waves: publish_waves(&graphs),
        semantic_blast: BTreeMap::new(),
        held_explained,
        shadow_solve: crate::shadow_solve::shadow_solve(&graphs),
    })
}

/// [`run`] + the **semantic blast radius**: for each bumped repo in
/// `changed_by_repo` (repo → the symbols its version bump changed/removed), the
/// exact impacted call sites of its dependents, attached to
/// [`DoctorReport::semantic_blast`].
///
/// It composes [`crate::semantic_blast::compute_for_dirty`], which has no call
/// graph to read since Mímir v2: a bump of a repo WITH dependents fails this whole
/// call with [`crate::semantic_blast::CallGraphNotProduced`] (fail closed — the
/// blast is a gate input), a bump of a repo nobody depends on is answered.
pub fn run_with_semantic_blast<'a>(
    repos: &[(String, PathBuf)],
    policy: &DepPolicy,
    wh: &dyn nornir_warehouse_trait::Warehouse,
    changed_by_repo: impl IntoIterator<Item = (&'a str, &'a [crate::semantic_blast::ChangedSymbol])>,
) -> Result<DoctorReport> {
    let mut report = run(repos, policy)?;
    let graphs = repos
        .iter()
        .map(|(name, path)| gather_repo_graph(name, path))
        .collect::<Result<Vec<_>>>()?;
    report.semantic_blast =
        crate::semantic_blast::compute_for_dirty(wh, &graphs, changed_by_repo)?;
    Ok(report)
}

/// Audit of how a CLI release path populated (or skipped) the semantic blast, so a
/// skipped semantic-link gate always states WHY. An empty blast can mean "clean
/// tree", "no persisted warehouse surface", or "a real bump with no cross-crate
/// call-site impact", and an operator must be able to tell which.
///
/// (Populated by nornir's `run_with_warehouse_semantic_blast`, which derives the
/// delta of a dirty tree from its code room — a dry-run dump against the open
/// set; kept here as the shared POD the relocated report path reasons over.)
#[derive(Debug, Default, Clone)]
pub struct SemanticBlastAudit {
    /// The dirty repos whose exported surface was diffed against the last
    /// consumed state.
    pub dirty_considered: Vec<String>,
    /// repo → number of changed/removed exported symbols the bump introduced.
    pub changed_symbols: BTreeMap<String, usize>,
    /// `true` when at least one repo produced a populated semantic blast.
    pub populated: bool,
    /// Human one-liner explaining the outcome — logged before the gate.
    pub note: String,
}

// ─────────────────────── human-readable report ───────────────────────

/// Human-readable advisory report (the CLI-parity table). All hints, no mutation.
pub fn format_report(report: &DoctorReport) -> String {
    let mut s = String::new();
    s.push_str("release doctor — advisory\n\n");

    s.push_str("Working trees:\n");
    let dirty: Vec<_> = report.dirty.iter().filter(|d| d.dirty).collect();
    if dirty.is_empty() {
        s.push_str("  ✅ all clean\n");
    } else {
        for d in &dirty {
            s.push_str(&format!("  🟡 {} — uncommitted changes\n", d.repo));
        }
    }
    for d in report.dirty.iter().filter(|d| d.error.is_some()) {
        s.push_str(&format!("  ⚠ {} — {}\n", d.repo, d.error.as_deref().unwrap_or("")));
    }

    // Publish pre-flight: the exact `cargo publish` manifest blocker, found up front.
    s.push_str("\nPublish preflight — path deps need a version:\n");
    if report.path_dep_version_gaps.is_empty() {
        s.push_str("  ✅ every path dep of a publishable crate carries a version\n");
    } else {
        let fixable: Vec<_> =
            report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_some()).collect();
        let decide: Vec<_> =
            report.path_dep_version_gaps.iter().filter(|g| g.dep_owner.is_none()).collect();
        if !fixable.is_empty() {
            s.push_str("  auto-fixable (member-owned — add version=):\n");
            for g in &fixable {
                let ver = g
                    .suggested_version
                    .as_deref()
                    .map(|v| format!("version = \"{v}\""))
                    .unwrap_or_else(|| "version = \"<its version>\"".to_string());
                let ws = if g.via_workspace { " [via workspace.dependencies]" } else { "" };
                s.push_str(&format!(
                    "    🔧 {}/{}: {} → {}  💡 {}{}\n",
                    g.repo, g.manifest, g.crate_name, g.dep, ver, ws
                ));
            }
        }
        if !decide.is_empty() {
            s.push_str(
                "  ⛔ needs decision (NON-member dep — join the release cascade or publish it first):\n",
            );
            for g in &decide {
                s.push_str(&format!(
                    "    ⛔ {}/{}: {} → {}  (not a release member)\n",
                    g.repo, g.manifest, g.crate_name, g.dep
                ));
            }
        }
    }

    s.push_str("\nExternal dependency skew:\n");
    if report.skew.is_empty() {
        s.push_str("  ✅ no divergence\n");
    } else {
        for c in &report.skew {
            let forbidden = c.entries.iter().any(|e| e.status == SkewStatus::Forbidden);
            s.push_str(&format!("  {} (target {})", c.crate_name, c.target));
            if forbidden {
                s.push_str("  ⚠ FORBIDDEN version present");
            }
            s.push('\n');
            for e in &c.entries {
                let mark = match e.status {
                    SkewStatus::Ok => "✓",
                    // Advisory, NOT a hard blocker: a transitive pin already resolves the
                    // target version, so `--publish` proceeds.
                    SkewStatus::Behind if e.held_by_transitive_pin => "ℹ",
                    SkewStatus::Behind => "·",
                    SkewStatus::Forbidden => "⚠",
                };
                let note = if e.held_by_transitive_pin {
                    format!(
                        "  (held: lock already resolves {}, a transitive dep pins {})",
                        c.target, e.version
                    )
                } else {
                    String::new()
                };
                s.push_str(&format!("      {} {} {}{}\n", mark, e.repo, e.version, note));
            }
            // Split the bump hint: repos that can bump freely vs ones a transitive
            // pin holds back (where a manifest bump alone won't take).
            let held: Vec<&str> = c
                .entries
                .iter()
                .filter(|e| e.status == SkewStatus::Behind && e.held_by_transitive_pin)
                .map(|e| e.repo.as_str())
                .collect();
            let free: Vec<&str> = c
                .entries
                .iter()
                .filter(|e| e.status != SkewStatus::Ok && !e.held_by_transitive_pin)
                .map(|e| e.repo.as_str())
                .collect();
            if !free.is_empty() {
                s.push_str(&format!("    💡 bump → {}: {}\n", c.target, free.join(", ")));
            }
            if !held.is_empty() {
                s.push_str(&format!(
                    "    ⛔ blocked → {}: a transitive dep pins {} (run `cargo tree -i {}` to find it)\n",
                    held.join(", "),
                    c.crate_name,
                    c.crate_name,
                ));
            }
        }
    }

    s.push_str("\nPublish order (dependencies first):\n");
    if report.topo.order.is_empty() {
        s.push_str("  (no repos)\n");
    } else {
        s.push_str(&format!("  {}\n", report.topo.order.join(" → ")));
    }
    if !report.topo.cycle.is_empty() {
        s.push_str(&format!("  ⚠ dependency cycle, unordered: {}\n", report.topo.cycle.join(", ")));
    }

    // Publish waves: each antichain = crates publishable CONCURRENTLY (deps-first).
    if !report.waves.is_empty() {
        s.push_str("\nPublish waves (each wave = crates publishable concurrently):\n");
        for (i, wave) in report.waves.iter().enumerate() {
            s.push_str(&format!("  wave {}: {}\n", i, wave.join(", ")));
        }
    }

    // SHADOW-SOLVE cross-check (additive, non-fatal): the independent constructive
    // solvers re-solved the same graph — surface a Warning when they diverge from the
    // shipped order, else stay silent (the shipped order is always authoritative).
    if report.shadow_solve.warning().is_some() {
        s.push_str(&format!("\n{}\n", report.shadow_solve.format()));
    }

    if !report.cycle_advice.is_empty() {
        s.push_str("\nBreak the cycle (suggested cuts):\n");
        for a in &report.cycle_advice {
            s.push_str(&format!("  ⟲ {} — 💡 {}\n", a.members.join(" ⇄ "), a.rationale));
        }
    }

    // MFAS: the RANKED, proven solution list per real crate cycle (cheapest-first).
    if !report.cycle_solutions.is_empty() {
        s.push_str("\nMFAS — ranked cycle solutions (proven to yield a DAG):\n");
        for cs in &report.cycle_solutions {
            s.push_str(&format!("  ⛓ {}\n", cs.members.join(" ⇄ ")));
            if cs.solutions.is_empty() {
                s.push_str("      (no single-arc cut found)\n");
                continue;
            }
            for (rank, sol) in cs.solutions.iter().take(4).enumerate() {
                let edges = sol
                    .edges
                    .iter()
                    .map(|(f, t)| format!("{f}→{t}"))
                    .collect::<Vec<_>>()
                    .join(" + ");
                s.push_str(&format!(
                    "      #{}. cost {} — cut {}  ✓ residual DAG: {}\n",
                    rank + 1,
                    sol.cost,
                    edges,
                    sol.proof_order.join(" → ")
                ));
                for act in &sol.actions {
                    s.push_str(&format!("          💡 {act}\n"));
                }
            }
        }
    }

    // Cycles: empty = good. Otherwise the offending edges, made explicit (never
    // silently broken). Detected at CRATE granularity.
    s.push_str("\nCycles (crate-level publish graph):\n");
    if report.cycles.is_empty() {
        s.push_str("  ✅ none\n");
    } else {
        for c in &report.cycles {
            s.push_str(&format!("  ⛔ {}\n", c.members.join(" ⇄ ")));
            for e in &c.edges {
                let via = if e.via.is_empty() {
                    String::new()
                } else {
                    format!(" (via {})", e.via.join(", "))
                };
                s.push_str(&format!("      {} → {}{}\n", e.from, e.to, via));
            }
        }
    }

    // Excluded dev-dep cross-repo edges — transparency for what is NOT order-gating.
    if !report.excluded_dev_edges.is_empty() {
        s.push_str("\nExcluded dev-dep cross-repo edges (not order-gating):\n");
        for e in &report.excluded_dev_edges {
            let via =
                if e.via.is_empty() { String::new() } else { format!(" [{}]", e.via.join(", ")) };
            s.push_str(&format!("  {} --dev--> {}{}\n", e.from, e.to, via));
        }
    }

    // Feature-gated / optional cross-repo deps (kept in order, surfaced).
    if !report.optional_cross_deps.is_empty() {
        s.push_str("\nFeature-gated / optional cross-repo deps:\n");
        for d in &report.optional_cross_deps {
            let feats = if d.features.is_empty() {
                "optional".to_string()
            } else {
                format!("optional, feature={}", d.features.join("|"))
            };
            s.push_str(&format!("  {} --[{}]--> {} ({})\n", d.from, feats, d.krate, d.to));
        }
    }

    // target-cfg cross-repo deps.
    if !report.cfg_cross_deps.is_empty() {
        s.push_str("\nTarget-cfg cross-repo deps:\n");
        for d in &report.cfg_cross_deps {
            s.push_str(&format!(
                "  {} --[{}]--> {} ({})\n",
                d.from,
                d.cfgs.join(" | "),
                d.krate,
                d.to
            ));
        }
    }

    // crates.io-pinned cross-repo deps — the path-vs-version split (point #8).
    if !report.version_pinned_cross_deps.is_empty() {
        s.push_str("\nCrates.io version-pinned cross-repo deps (not order-gating):\n");
        for e in &report.version_pinned_cross_deps {
            s.push_str(&format!("  {} --version--> {} [{}]\n", e.from, e.to, e.via.join(", ")));
        }
    }

    if !report.patch_forks.is_empty() {
        s.push_str("\nPatch-fork promote gate:\n");
        let kind_of = |b: &PatchForkBlock| match b.fork_kind {
            ForkKind::Path => "path",
            ForkKind::Git => "git",
        };
        // A fork whose patched dep we don't (re)publish ourselves is a HARD blocker.
        let foreign: Vec<&PatchForkBlock> =
            report.patch_forks.iter().filter(|b| b.is_foreign_fork).collect();
        for b in &foreign {
            s.push_str(&format!(
                "  ⛔ promote-blocked: {} rides a patch-fork ({} → {} [{}]); \
                 publishing strips it → stock {} would be incompatible. \
                 Unblock: publish {}'s real version, or wait for upstream.\n",
                b.crate_name, b.patched_dep, b.source, kind_of(b), b.patched_dep, b.patched_dep,
            ));
        }
        // Own-crate path/git overrides are SAFE — publish-order covers them.
        let overrides: Vec<&PatchForkBlock> =
            report.patch_forks.iter().filter(|b| !b.is_foreign_fork).collect();
        for b in &overrides {
            s.push_str(&format!(
                "  ℹ️  local dev override (safe): {} → {} [{}] — our own crate; \
                 stripped on publish, publish-order resolves it.\n",
                b.patched_dep, b.source, kind_of(b),
            ));
        }
        // The full transitive crate set the foreign forks hold from crates.io.
        if !report.promote_blocked.is_empty() {
            s.push_str(&format!(
                "  ⛔ held from crates.io ({} crate(s) that transitively need a forked dep): {}\n",
                report.promote_blocked.len(),
                report.promote_blocked.join(", "),
            ));
            s.push_str("     ↳ run `release doctor --explain` for the per-crate reason + unblock advice\n");
        }
        // Only safe overrides, nothing genuinely held → say so explicitly.
        if foreign.is_empty() && report.promote_blocked.is_empty() {
            s.push_str("  ✅ no foreign forks — all crates promotable\n");
        }
    }

    let blast: Vec<_> = report.blast.iter().filter(|(_, d)| !d.is_empty()).collect();
    if !blast.is_empty() {
        s.push_str("\nBlast radius of dirty repos (re-validate on change):\n");
        for (repo, deps) in blast {
            s.push_str(&format!("  {} → {}\n", repo, deps.join(", ")));
        }
    }

    // SEMANTIC blast: the exact call sites, not just "who depends on".
    if !report.semantic_blast.is_empty() {
        s.push_str("\nSemantic blast radius (exact call sites touching the changed API):\n");
        for sb in report.semantic_blast.values() {
            s.push_str(&crate::semantic_blast::format_semantic_blast(sb));
        }
        // LINK ERRORS: a removed symbol a dependent still calls fails the release like
        // a linker. Surface the headline; the gate enforces it.
        let link_errs: usize =
            report.semantic_blast.values().map(|sb| sb.link_errors().len()).sum();
        if link_errs > 0 {
            let sites: usize =
                report.semantic_blast.values().map(|sb| sb.link_error_sites()).sum();
            s.push_str(&format!(
                "  ⛔ {link_errs} LINK ERROR(s): {sites} call site(s) invoke a REMOVED symbol — \
                 release BLOCKED (a linker would fail the same way)\n"
            ));
        }
    }
    s
}

/// Render the EXPLAINED HELD SET (`release doctor --explain`) — one block per crate
/// the publish cascade withholds, stating the foreign fork it rides (`fork_kind` +
/// `patched_dep` + source), whether the dependency is direct, and the unblock advice.
/// Empty held set → an explicit "nothing held" line (so `--explain` always answers).
pub fn format_held_explanations(report: &DoctorReport) -> String {
    let mut s = String::new();
    s.push_str("\nHeld set — every crate withheld from the crates.io plan, with its reason:\n");
    if report.held_explained.is_empty() {
        s.push_str("  ✅ nothing held — every crate is promotable\n");
        return s;
    }
    for h in &report.held_explained {
        let rides = h
            .rides
            .iter()
            .map(|f| {
                let kind = match f.fork_kind {
                    ForkKind::Path => "path",
                    ForkKind::Git => "git",
                };
                if f.source.is_empty() {
                    format!("{} [{}]", f.patched_dep, kind)
                } else {
                    format!("{} → {} [{}]", f.patched_dep, f.source, kind)
                }
            })
            .collect::<Vec<_>>()
            .join(", ");
        let how = if h.direct { "directly rides" } else { "transitively rides" };
        s.push_str(&format!("  ⛔ held: {} — {} fork {}\n", h.crate_name, how, rides));
        s.push_str(&format!("        ↳ {}\n", h.advice));
    }
    s
}

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

    /// The skidbladnir→edda ordering fault, distilled: a consumer repo whose name
    /// sorts ALPHABETICALLY FIRST (`Aapp`, capital `A` — mirroring `Skidbladnir`)
    /// path-depends on a crate produced by a repo (`core`) that sits on a repo-level
    /// SCC which is a CLEAN crate DAG (`core ⇄ mid` through disjoint crates — the
    /// draupnir⇄edda⇄tunnr shape). The old naive Kahn stalled on the SCC and dumped
    /// the remainder alphabetically → `Aapp` landed BEFORE `core`, i.e. the consumer
    /// scheduled ahead of its own dependency. [`repo_publish_order`] condenses the
    /// SCC and MUST place `core` before `Aapp`. Red-when-broken: revert to the
    /// alphabetical-dump topo and this assertion fails.
    #[test]
    fn repo_topo_places_producer_before_alphabetically_earlier_consumer() {
        use std::path::Path;
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let mk = |dir: &Path, toml: &str| {
            std::fs::create_dir_all(dir).unwrap();
            std::fs::write(dir.join("Cargo.toml"), toml).unwrap();
        };
        // repo `core`: produces `core-a` + `core-b`; `core-a` path-deps `midlib`.
        mk(
            &root.join("core/core-a"),
            "[package]\nname = \"core-a\"\nversion = \"0.1.0\"\n\
             [dependencies]\nmidlib = { path = \"../../mid/midlib\", version = \"0.1.0\" }\n",
        );
        mk(&root.join("core/core-b"), "[package]\nname = \"core-b\"\nversion = \"0.1.0\"\n");
        // repo `mid`: produces `midlib`; path-deps `core-b`. → core⇄mid repo SCC, but
        // the crate graph core-b → midlib → core-a is a clean DAG (no real cycle).
        mk(
            &root.join("mid/midlib"),
            "[package]\nname = \"midlib\"\nversion = \"0.1.0\"\n\
             [dependencies]\ncore-b = { path = \"../../core/core-b\", version = \"0.1.0\" }\n",
        );
        // repo `Aapp`: sorts first; path-deps `core-a` (→ Aapp depends on repo core).
        mk(
            &root.join("Aapp"),
            "[package]\nname = \"aapp\"\nversion = \"0.1.0\"\n\
             [dependencies]\ncore-a = { path = \"../core/core-a\", version = \"0.1.0\" }\n",
        );

        let graphs = vec![
            gather_repo_graph("core", &root.join("core")).unwrap(),
            gather_repo_graph("mid", &root.join("mid")).unwrap(),
            gather_repo_graph("Aapp", &root.join("Aapp")).unwrap(),
        ];
        let edges = repo_dep_edges(&graphs);
        // Sanity: the Aapp→core edge (the skidbladnir→edda analogue) is present.
        assert!(
            edges.iter().any(|e| e.from == "Aapp" && e.to == "core"),
            "Aapp path-dep on core-a must yield an Aapp→core repo edge"
        );
        let members = vec!["Aapp".to_string(), "core".to_string(), "mid".to_string()];
        let order = repo_publish_order(&members, &edges);
        let idx = |r: &str| order.iter().position(|x| x == r).unwrap();
        assert!(
            idx("core") < idx("Aapp"),
            "producer `core` must publish BEFORE its consumer `Aapp` (got {order:?})"
        );
        // The SCC members stay adjacent and ahead of the consumer.
        assert!(idx("mid") < idx("Aapp"), "SCC repo `mid` also before the consumer: {order:?}");
    }

    /// FIX 1 (the crux, red-when-broken): a repo-level SCC `{A, B}` whose CRATE graph
    /// is a clean DAG must publish its crates in a GLOBAL crate-topo order that
    /// INTERLEAVES the two repos — never repo-block-at-a-time (which strands a
    /// cross-repo crate edge). Shape (the dwarves⇄nornir distillate): A produces
    /// `a1, a2`; B produces `b1, b2`; `a2 → b1` (A's crate deps a B crate) and
    /// `b2 → a1` (B's crate deps an A crate). No CRATE cycle exists, but the REPO
    /// graph is the SCC A⇄B. The only valid crate publish order is `a1, b1, a2, b2`
    /// (deps-first): `b2` needs `a1`, `a2` needs `b1`. A repo-block order (all A then
    /// all B, or vice versa) is IMPOSSIBLE — it always strands one cross edge.
    #[test]
    fn crate_scc_order_interleaves_repos_never_repo_blocks() {
        use std::path::Path;
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let mk = |dir: &Path, toml: &str| {
            std::fs::create_dir_all(dir).unwrap();
            std::fs::write(dir.join("Cargo.toml"), toml).unwrap();
        };
        // Repo A: a1 (leaf) + a2 (a2 → b1, a B crate).
        mk(&root.join("A/a1"), "[package]\nname = \"a1\"\nversion = \"0.1.0\"\n");
        mk(
            &root.join("A/a2"),
            "[package]\nname = \"a2\"\nversion = \"0.1.0\"\n\
             [dependencies]\nb1 = { path = \"../../B/b1\", version = \"0.1.0\" }\n",
        );
        // Repo B: b1 (leaf) + b2 (b2 → a1, an A crate).
        mk(&root.join("B/b1"), "[package]\nname = \"b1\"\nversion = \"0.1.0\"\n");
        mk(
            &root.join("B/b2"),
            "[package]\nname = \"b2\"\nversion = \"0.1.0\"\n\
             [dependencies]\na1 = { path = \"../../A/a1\", version = \"0.1.0\" }\n",
        );

        let graphs = vec![
            gather_repo_graph("A", &root.join("A")).unwrap(),
            gather_repo_graph("B", &root.join("B")).unwrap(),
        ];

        // The REPO graph is a genuine SCC A⇄B (A→B via b1, B→A via a1).
        let edges = repo_dep_edges(&graphs);
        assert!(edges.iter().any(|e| e.from == "A" && e.to == "B"), "A→B edge (a2→b1)");
        assert!(edges.iter().any(|e| e.from == "B" && e.to == "A"), "B→A edge (b2→a1)");
        // …but the CRATE graph is a clean DAG (no real cycle to report).
        assert!(detect_cycles(&graphs).is_empty(), "disjoint crate edges ⇒ NO crate cycle");

        let order = global_crate_publish_order(&graphs);
        let crates: Vec<&str> = order.iter().map(|(_, c)| c.as_str()).collect();
        let pos = |c: &str| crates.iter().position(|x| *x == c).unwrap();
        // Deps-first: every edge's target precedes its source.
        assert!(pos("b1") < pos("a2"), "b1 before a2 (a2 needs b1): {crates:?}");
        assert!(pos("a1") < pos("b2"), "a1 before b2 (b2 needs a1): {crates:?}");
        // The proof it is NOT a repo block: A's crates are NOT all before B's crates,
        // nor B's all before A's — the repos are interleaved.
        let a_positions: Vec<usize> = order
            .iter()
            .enumerate()
            .filter(|(_, (r, _))| r == "A")
            .map(|(i, _)| i)
            .collect();
        let b_positions: Vec<usize> = order
            .iter()
            .enumerate()
            .filter(|(_, (r, _))| r == "B")
            .map(|(i, _)| i)
            .collect();
        let a_block = a_positions.iter().max() < b_positions.iter().min();
        let b_block = b_positions.iter().max() < a_positions.iter().min();
        assert!(
            !a_block && !b_block,
            "the order must INTERLEAVE the two repos, not publish one repo as a block: {order:?}"
        );
    }

    /// FIX (the crux, red-when-broken) ON THE REAL DRIVER: the `--execute` publish
    /// driver's PLANNED step order must interleave a repo-level SCC exactly like the
    /// Gate-B rehearsal — never emit a full repo block that strands a cross-repo crate
    /// edge. Same A⇄B distillate as `crate_scc_order_interleaves_repos_never_repo_blocks`
    /// (A: `a1`, `a2→b1`; B: `b1`, `b2→a1`; repo SCC A⇄B, clean crate DAG). The only
    /// valid crate order is `a1, b1, a2, b2` — so across the emitted [`PublishStep`]s,
    /// `b1` must publish before `a2` and `a1` before `b2`, and the plan must NOT be one
    /// A-block followed by one B-block (or vice versa). Mirrors the rehearse-side test
    /// on the REAL step planner, closing the false-pass this whole fix targets.
    #[test]
    fn real_driver_steps_interleave_scc_never_repo_blocks() {
        use std::path::Path;
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let mk = |dir: &Path, toml: &str| {
            std::fs::create_dir_all(dir).unwrap();
            std::fs::write(dir.join("Cargo.toml"), toml).unwrap();
        };
        // Repo A: a1 (leaf) + a2 (a2 → b1, a B crate).
        mk(&root.join("A/a1"), "[package]\nname = \"a1\"\nversion = \"0.1.0\"\n");
        mk(
            &root.join("A/a2"),
            "[package]\nname = \"a2\"\nversion = \"0.1.0\"\n\
             [dependencies]\nb1 = { path = \"../../B/b1\", version = \"0.1.0\" }\n",
        );
        // Repo B: b1 (leaf) + b2 (b2 → a1, an A crate).
        mk(&root.join("B/b1"), "[package]\nname = \"b1\"\nversion = \"0.1.0\"\n");
        mk(
            &root.join("B/b2"),
            "[package]\nname = \"b2\"\nversion = \"0.1.0\"\n\
             [dependencies]\na1 = { path = \"../../A/a1\", version = \"0.1.0\" }\n",
        );

        let graphs = vec![
            gather_repo_graph("A", &root.join("A")).unwrap(),
            gather_repo_graph("B", &root.join("B")).unwrap(),
        ];
        // Deps-first REPO order over the SCC — the doctor's canonical (name-sorted SCC).
        let order = repo_publish_order(
            &["A".to_string(), "B".to_string()],
            &repo_dep_edges(&graphs),
        );

        let steps = plan_publish_steps(&order, &graphs);

        // Flatten the plan to the (repo, crate) publish sequence the driver will run:
        // each SCOPED step's crates publish in that repo's own deps-first order, which
        // for these single-crate steps is just the crate itself.
        let flat: Vec<(String, String)> = steps
            .iter()
            .flat_map(|s| {
                let mut cs: Vec<String> = s
                    .only
                    .clone()
                    .expect("SCC members are scoped steps")
                    .into_iter()
                    .collect();
                cs.sort();
                cs.into_iter().map(move |c| (s.repo.clone(), c))
            })
            .collect();
        let crates: Vec<&str> = flat.iter().map(|(_, c)| c.as_str()).collect();
        let pos = |c: &str| crates.iter().position(|x| *x == c).unwrap();
        // Deps-first across STEPS: every cross-repo edge's target precedes its source.
        assert!(pos("b1") < pos("a2"), "b1 before a2 (a2 needs b1): {crates:?}");
        assert!(pos("a1") < pos("b2"), "a1 before b2 (b2 needs a1): {crates:?}");
        // The proof it is NOT a repo block: A's crates are NOT all before B's, nor B's
        // all before A's — the STEPS interleave the two repos.
        let a_pos: Vec<usize> =
            flat.iter().enumerate().filter(|(_, (r, _))| r == "A").map(|(i, _)| i).collect();
        let b_pos: Vec<usize> =
            flat.iter().enumerate().filter(|(_, (r, _))| r == "B").map(|(i, _)| i).collect();
        let a_block = a_pos.iter().max() < b_pos.iter().min();
        let b_block = b_pos.iter().max() < a_pos.iter().min();
        assert!(
            !a_block && !b_block,
            "the driver's STEPS must interleave the two SCC repos, not publish one repo as a block: {steps:?}"
        );
        // And there is genuinely more than one step per repo (the SCC was split).
        assert!(steps.len() >= 3, "an A⇄B SCC interleaves into ≥3 steps: {steps:?}");

        nornir_testmatrix::functional_status(
            "release-doctor",
            "real_driver_steps_interleave_scc",
            !a_block && !b_block && pos("b1") < pos("a2") && pos("a1") < pos("b2"),
            "the --execute step planner interleaves an A⇄B repo-SCC (b1<a2, a1<b2), never a repo block",
        );
    }

    /// A plain deps-first CHAIN (no repo-SCC) must plan as WHOLE-repo steps in member
    /// order — the legacy repo-block behaviour, preserved so a unified-version repo
    /// still bumps once. `base` (leaf) then `app` (app → base): two `only = None` steps.
    #[test]
    fn real_driver_non_scc_plans_whole_repo_blocks() {
        use std::path::Path;
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let mk = |dir: &Path, toml: &str| {
            std::fs::create_dir_all(dir).unwrap();
            std::fs::write(dir.join("Cargo.toml"), toml).unwrap();
        };
        mk(&root.join("base"), "[package]\nname = \"base\"\nversion = \"0.1.0\"\n");
        mk(
            &root.join("app"),
            "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\
             [dependencies]\nbase = { path = \"../base\", version = \"0.1.0\" }\n",
        );
        let graphs = vec![
            gather_repo_graph("app", &root.join("app")).unwrap(),
            gather_repo_graph("base", &root.join("base")).unwrap(),
        ];
        assert!(detect_cycles(&graphs).is_empty(), "a clean chain has no SCC");
        let order = repo_publish_order(
            &["app".to_string(), "base".to_string()],
            &repo_dep_edges(&graphs),
        );
        let steps = plan_publish_steps(&order, &graphs);
        assert_eq!(
            steps,
            vec![
                PublishStep { repo: "base".to_string(), only: None },
                PublishStep { repo: "app".to_string(), only: None },
            ],
            "no SCC ⇒ whole-repo steps, deps-first (base before app)"
        );
    }

    /// REAL-REPO PROOF of FIX 1 on the ACTUAL `dwarves ⇄ nornir` SCC. Gathers the
    /// live crate graphs for the two checkouts and asserts the global crate-level
    /// publish order interleaves them correctly: nornir's `nornir-build-thing`
    /// publishes BEFORE dwarves' `nornir-warehouse-embed`, which publishes before the
    /// `dwarves` crate, which publishes before the `nornir` top crate — the exact
    /// sequence a repo-block publisher can NOT produce. Opt-in: set `DWARVES_ROOT` +
    /// `NORNIR_ROOT`. Run with `--ignored`.
    #[test]
    #[ignore = "real repos; run with DWARVES_ROOT + NORNIR_ROOT set"]
    fn real_dwarves_nornir_scc_interleaves_crate_order() {
        let (Ok(dw), Ok(no)) =
            (std::env::var("DWARVES_ROOT"), std::env::var("NORNIR_ROOT"))
        else {
            eprintln!("skipped: set DWARVES_ROOT and NORNIR_ROOT");
            return;
        };
        let graphs = vec![
            gather_repo_graph("dwarves", std::path::Path::new(&dw)).unwrap(),
            gather_repo_graph("nornir", std::path::Path::new(&no)).unwrap(),
        ];
        let order = global_crate_publish_order(&graphs);
        let crates: Vec<&str> = order.iter().map(|(_, c)| c.as_str()).collect();
        eprintln!("global crate order ({} crates): {crates:?}", crates.len());
        let pos = |c: &str| crates.iter().position(|x| *x == c);
        let bt = pos("nornir-build-thing").expect("nornir-build-thing in order");
        let emb = pos("nornir-warehouse-embed").expect("nornir-warehouse-embed in order");
        let dw_c = pos("dwarves").expect("dwarves crate in order");
        assert!(bt < emb, "nornir-build-thing must precede nornir-warehouse-embed: {crates:?}");
        assert!(emb < dw_c, "nornir-warehouse-embed must precede dwarves: {crates:?}");
        if let Some(nornir_top) = pos("nornir") {
            assert!(dw_c < nornir_top, "dwarves must precede the nornir top crate: {crates:?}");
        }
    }

    fn ext(repo: &str, deps: &[(&str, &str)]) -> RepoExternals {
        RepoExternals {
            repo: repo.to_string(),
            deps: deps.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
        }
    }

    #[test]
    fn skew_flags_divergence_and_forbidden_and_target_is_highest() {
        // arrow diverges (57 vs 58) and 56 is forbidden; clap agrees → not surfaced.
        let repos = vec![
            ext("nornir", &[("arrow", "58"), ("clap", "4")]),
            ext("skade", &[("arrow", "57")]),
            ext("dwarves", &[("arrow", "56"), ("clap", "4")]),
        ];
        let policy = DepPolicy {
            forbidden: vec![ForbiddenDep { crate_name: "arrow".into(), version: "56".into() }],
        };
        let skew = analyze_skew(&repos, &policy);
        let arrow = skew.iter().find(|c| c.crate_name == "arrow").expect("arrow surfaced");
        assert_eq!(arrow.target, "58", "target = highest declared major");
        assert!(arrow.diverged);
        let byrepo = |r: &str| {
            arrow.entries.iter().find(|e| e.repo == r).unwrap().status.clone()
        };
        assert_eq!(byrepo("nornir"), SkewStatus::Ok, "at target");
        assert_eq!(byrepo("skade"), SkewStatus::Behind, "57 < 58");
        assert_eq!(byrepo("dwarves"), SkewStatus::Forbidden, "56 is banned");
        // clap agrees across repos → no skew row.
        assert!(skew.iter().all(|c| c.crate_name != "clap"), "agreeing dep is not skew");
        // bump_repos = every non-Ok entry.
        let bumps = arrow.bump_repos();
        assert!(bumps.contains(&"skade") && bumps.contains(&"dwarves"));
        assert!(!bumps.contains(&"nornir"));
    }

    #[test]
    fn forbidden_respects_semver_line_for_0x() {
        // 0.9 forbid must NOT ban 0.10 (different SemVer line), but bans 0.9.x.
        let repos = vec![ext("a", &[("dep", "0.9.1")]), ext("b", &[("dep", "0.10.0")])];
        let policy = DepPolicy {
            forbidden: vec![ForbiddenDep { crate_name: "dep".into(), version: "0.9".into() }],
        };
        let skew = analyze_skew(&repos, &policy);
        let dep = skew.iter().find(|c| c.crate_name == "dep").unwrap();
        let a = dep.entries.iter().find(|e| e.repo == "a").unwrap();
        let b = dep.entries.iter().find(|e| e.repo == "b").unwrap();
        assert_eq!(a.status, SkewStatus::Forbidden, "0.9.1 is on the banned 0.9 line");
        assert_ne!(b.status, SkewStatus::Forbidden, "0.10 is a different SemVer line");
    }

    #[test]
    fn transitive_pin_marks_dual_major_lock() {
        // skade is Behind on arrow (57 declared, target 58) AND its lock resolves
        // BOTH 57 and 58 → held_by_transitive_pin. nornir is at target → untouched.
        let mut skew = analyze_skew(
            &[ext("nornir", &[("arrow", "58")]), ext("skade", &[("arrow", "57")])],
            &DepPolicy::default(),
        );
        let lock = r#"
[[package]]
name = "arrow"
version = "57.3.1"
[[package]]
name = "arrow"
version = "58.3.0"
"#;
        let locks: BTreeMap<String, String> =
            [("skade".to_string(), lock.to_string())].into_iter().collect();
        enrich_transitive_pins(&mut skew, &locks);
        let arrow = skew.iter().find(|c| c.crate_name == "arrow").unwrap();
        let skade = arrow.entries.iter().find(|e| e.repo == "skade").unwrap();
        assert!(skade.held_by_transitive_pin, "dual-major lock ⇒ held by transitive pin");
        assert_eq!(crate_majors_in_lock(lock, "arrow"), [57, 58].into_iter().collect());
    }

    #[test]
    fn run_reports_dirty_skew_and_order_over_a_real_two_repo_tree() {
        let tmp = tempfile::tempdir().unwrap();
        // producer repo: crate `leaf` at 0.1.0.
        let leaf = tmp.path().join("leaf");
        std::fs::create_dir_all(&leaf).unwrap();
        std::fs::write(
            leaf.join("Cargo.toml"),
            "[package]\nname = \"leaf\"\nversion = \"0.1.0\"\n\
             [dependencies]\nserde = \"1\"\narrow = \"58\"\n",
        )
        .unwrap();
        // consumer repo: crate `top` path-depends on leaf (order-gating) + skewed arrow.
        let top = tmp.path().join("top");
        std::fs::create_dir_all(&top).unwrap();
        std::fs::write(
            top.join("Cargo.toml"),
            "[package]\nname = \"top\"\nversion = \"0.1.0\"\n\
             [dependencies]\nleaf = { path = \"../leaf\", version = \"0.1.0\" }\narrow = \"57\"\n",
        )
        .unwrap();

        let repos = vec![
            ("leaf".to_string(), leaf.clone()),
            ("top".to_string(), top.clone()),
        ];
        let report = run(&repos, &DepPolicy::default()).unwrap();

        // Order: leaf (producer) BEFORE top (consumer of a path dep on leaf).
        let li = report.topo.order.iter().position(|r| r == "leaf");
        let ti = report.topo.order.iter().position(|r| r == "top");
        assert!(li.is_some() && ti.is_some() && li < ti, "leaf publishes before top: {:?}", report.topo.order);
        assert!(report.topo.cycle.is_empty(), "clean DAG, no cycle");

        // arrow skew surfaced: target 58, top behind.
        let arrow = report.skew.iter().find(|c| c.crate_name == "arrow").expect("arrow skew");
        assert_eq!(arrow.target, "58");
        assert_eq!(
            arrow.entries.iter().find(|e| e.repo == "top").unwrap().status,
            SkewStatus::Behind
        );

        // One repo→repo edge top→leaf via `leaf`.
        assert!(report
            .repo_edges
            .iter()
            .any(|e| e.from == "top" && e.to == "leaf" && e.via.contains(&"leaf".to_string())));

        // Dirty state was read for every repo (a non-git temp dir reads as an
        // error-annotated, non-dirty entry — check_dirty degrades, never panics).
        assert_eq!(report.dirty.len(), 2);
        let txt = format_report(&report);
        assert!(txt.contains("Publish order"));
        assert!(txt.contains("External dependency skew"));
    }

    #[test]
    fn path_dep_missing_version_is_a_preflight_gap() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // `top` path-depends on `leaf` WITHOUT a version → cargo-publish blocker.
        let leaf = root.join("leaf");
        std::fs::create_dir_all(&leaf).unwrap();
        std::fs::write(
            leaf.join("Cargo.toml"),
            "[package]\nname = \"leaf\"\nversion = \"0.2.0\"\n",
        )
        .unwrap();
        let top = root.join("top");
        std::fs::create_dir_all(&top).unwrap();
        std::fs::write(
            top.join("Cargo.toml"),
            "[package]\nname = \"top\"\nversion = \"0.1.0\"\n\
             [dependencies]\nleaf = { path = \"../leaf\" }\n",
        )
        .unwrap();

        let repos = vec![("ws".to_string(), root.to_path_buf())];
        let gaps = scan_path_dep_version_gaps(&repos);
        let gap = gaps.iter().find(|g| g.dep == "leaf").expect("versionless path dep flagged");
        assert_eq!(gap.crate_name, "top");
        assert_eq!(gap.dep_owner.as_deref(), Some("ws"), "member-owned ⇒ auto-fixable");
        assert_eq!(gap.suggested_version.as_deref(), Some("0.2.0"), "fill from leaf's own version");
        assert_eq!(local_crate_versions(&repos).get("leaf").map(String::as_str), Some("0.2.0"));
    }

    /// CLASS B (the skade ordering invariant, red-when-broken): a cross-repo dep declared
    /// as a crates.io VERSION requirement (`nornir-testmatrix = { version = "0.2" }`, no
    /// `path`) is DELIBERATELY non-gating — it resolves from the registry, so the consumer
    /// need NOT be ordered after the producer. This is why the topo correctly placed skade
    /// (version-pins nornir-testmatrix/znippy-zoomies) BEFORE edda/znippy-zoomies, and why
    /// that ordering is NOT the bug. The SAME dep declared as a PATH dep DOES gate — the
    /// contrast proves the gatherer keys on path-vs-version, not on the crate name.
    #[test]
    fn version_pinned_cross_dep_does_not_gate_but_path_dep_does() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // Producer repo `edda` ships the crate `nornir-testmatrix` at 0.2.7.
        let edda = root.join("edda");
        std::fs::create_dir_all(&edda).unwrap();
        std::fs::write(
            edda.join("Cargo.toml"),
            "[package]\nname = \"nornir-testmatrix\"\nversion = \"0.2.7\"\n",
        )
        .unwrap();
        // Consumer repo `skade` version-pins it from crates.io (NO path) — the real skade
        // manifest shape: `nornir-testmatrix = { version = "0.2", optional = true }`.
        let skade = root.join("skade");
        std::fs::create_dir_all(&skade).unwrap();
        std::fs::write(
            skade.join("Cargo.toml"),
            "[package]\nname = \"skade\"\nversion = \"0.4.0\"\n\
             [dependencies]\nnornir-testmatrix = { version = \"0.2\", optional = true }\n",
        )
        .unwrap();

        let g_edda = gather_repo_graph("edda", &edda).unwrap();
        let g_skade = gather_repo_graph("skade", &skade).unwrap();
        // The version-pin lands in `version_pinned`, NEVER in the order-gating `deps`.
        assert!(g_skade.version_pinned.contains("nornir-testmatrix"));
        assert!(
            !g_skade.deps.contains("nornir-testmatrix"),
            "a crates.io version-pin must NOT be an order-gating dep (the skade invariant)"
        );
        // No gating repo edge skade→edda ⇒ skade may publish before edda (correct).
        let edges = repo_edges(&[g_edda.clone(), g_skade.clone()]);
        assert!(
            edges.get("skade").map(|s| !s.contains("edda")).unwrap_or(true),
            "version-pinned dep must not create a gating skade→edda edge"
        );
        // It IS surfaced as a (non-gating) version-pinned cross-dep for transparency.
        let vp = version_pinned_cross_deps(&[g_edda, g_skade]);
        assert!(vp.iter().any(|e| e.from == "skade" && e.to == "edda"));

        // CONTRAST (red-when-broken): the SAME dep as a PATH dep DOES gate — proving the
        // split is path-vs-version, not the crate name. Rewrite skade's dep with a path.
        std::fs::write(
            skade.join("Cargo.toml"),
            "[package]\nname = \"skade\"\nversion = \"0.4.0\"\n\
             [dependencies]\nnornir-testmatrix = { path = \"../edda\", version = \"0.2\" }\n",
        )
        .unwrap();
        let g_skade_path = gather_repo_graph("skade", &skade).unwrap();
        let g_edda2 = gather_repo_graph("edda", &edda).unwrap();
        assert!(g_skade_path.deps.contains("nornir-testmatrix"), "a PATH dep gates order");
        let edges_path = repo_edges(&[g_edda2, g_skade_path]);
        assert!(
            edges_path.get("skade").map(|s| s.contains("edda")).unwrap_or(false),
            "a path dep MUST create the gating skade→edda edge"
        );
    }

    #[test]
    fn detect_cycles_finds_a_real_crate_cycle() {
        // a ⇄ b via path deps → a real crate-level publish cycle.
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        for (name, dep) in [("a", "b"), ("b", "a")] {
            let d = root.join(name);
            std::fs::create_dir_all(&d).unwrap();
            std::fs::write(
                d.join("Cargo.toml"),
                format!(
                    "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
                     [dependencies]\n{dep} = {{ path = \"../{dep}\", version = \"0.1.0\" }}\n"
                ),
            )
            .unwrap();
        }
        let g = gather_repo_graph("ws", root).unwrap();
        let cycles = detect_cycles(&[g]);
        assert!(!cycles.is_empty(), "a⇄b is a real crate cycle");
        let members: std::collections::BTreeSet<&str> =
            cycles[0].members.iter().map(String::as_str).collect();
        assert!(members.contains("a") && members.contains("b"));
        // And graph_math yields a ranked cut to break it.
        let g2 = gather_repo_graph("ws", root).unwrap();
        assert!(!cycle_solutions(&[g2]).is_empty(), "MFAS offers a cut");
    }

    #[test]
    fn semantic_blast_of_a_repo_with_dependents_fails_closed_when_no_call_graph_is_produced() {
        use crate::semantic_blast::{CallGraphNotProduced, ChangedSymbol};
        use crate::testkit::MockWarehouse;
        use nornir_warehouse_trait::{CallEdgeRow, SymbolRow};

        // Two repos: `lib` produces crate `lib`; `app` path-depends on it.
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let libd = root.join("lib");
        std::fs::create_dir_all(&libd).unwrap();
        std::fs::write(libd.join("Cargo.toml"), "[package]\nname = \"lib\"\nversion = \"0.1.0\"\n")
            .unwrap();
        let appd = root.join("app");
        std::fs::create_dir_all(&appd).unwrap();
        std::fs::write(
            appd.join("Cargo.toml"),
            "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\
             [dependencies]\nlib = { path = \"../lib\", version = \"0.1.0\" }\n",
        )
        .unwrap();
        let repos =
            vec![("lib".to_string(), libd.clone()), ("app".to_string(), appd.clone())];

        // A warehouse made before Mímir v2 still holds the old rows: app::main
        // calls `gone`. Nothing writes them any more, so they are not read.
        let wh = MockWarehouse::new();
        wh.seed_symbol_scan(
            "app",
            &[SymbolRow {
                crate_name: "app".into(),
                module_path: "app".into(),
                item_kind: "fn".into(),
                item_name: "main".into(),
                visibility: "pub".into(),
                file: "src/main.rs".into(),
                line: 1,
                doc_lines: 0,
                signature: None,
            }],
            &[CallEdgeRow {
                crate_name: "app".into(),
                caller_path: "app::main".into(),
                callee_ident: "gone".into(),
                call_kind: "call".into(),
                file: "src/main.rs".into(),
                line: 2,
            }],
        );

        // The bump REMOVES the `gone` symbol of `lib`, which `app` depends on.
        let changed = vec![ChangedSymbol::removed("gone")];
        let by_repo: Vec<(&str, &[ChangedSymbol])> = vec![("lib", changed.as_slice())];
        let err = run_with_semantic_blast(&repos, &DepPolicy::default(), &wh, by_repo)
            .expect_err("lib has a dependent and no call graph is produced");
        let typed = err
            .downcast_ref::<CallGraphNotProduced>()
            .expect("the refusal is typed, not an I/O error");
        assert_eq!(typed.target_repo, "lib");
        assert_eq!(typed.dependents, vec!["app".to_string()]);

        // The same bump on `app` — nobody depends on it — needs no call graph.
        let by_repo: Vec<(&str, &[ChangedSymbol])> = vec![("app", changed.as_slice())];
        let report =
            run_with_semantic_blast(&repos, &DepPolicy::default(), &wh, by_repo).unwrap();
        assert!(report.semantic_blast.is_empty(), "no dependents → nothing to report");
    }

    // ── registry_non_gating_crates (un-deferred: consumes preflight::RegistryProbe) ──

    /// Build a graph carrying per-dep version requirements (consumer `version=`).
    fn graph_reqs(repo: &str, produces: &[&str], deps: &[(&str, &str)]) -> RepoGraph {
        let mut g = RepoGraph {
            repo: repo.to_string(),
            produces: produces.iter().map(|s| s.to_string()).collect(),
            ..Default::default()
        };
        for (dep, req) in deps {
            g.deps.insert((*dep).to_string());
            g.dep_reqs.entry((*dep).to_string()).or_default().insert((*req).to_string());
        }
        g
    }

    /// A probe with a fixed table of published (non-yanked) versions per crate,
    /// mirroring `CratesIoProbe`: an unknown crate ⇒ `None` (conservative), a known
    /// crate ⇒ `Some(bool)` for whether a live version matches the requirement.
    struct MapProbe(std::collections::BTreeMap<&'static str, Vec<&'static str>>);
    impl crate::registry::RegistryProbe for MapProbe {
        fn version_live(&self, k: &str, v: &str) -> Option<bool> {
            self.0.get(k).map(|vs| vs.iter().any(|x| *x == v))
        }
        fn req_satisfied(&self, k: &str, req: &str) -> Option<bool> {
            let req = semver::VersionReq::parse(req).ok()?;
            let vs = self.0.get(k)?;
            Some(vs.iter().any(|s| {
                semver::Version::parse(s).map(|v| req.matches(&v)).unwrap_or(false)
            }))
        }
    }

    /// The exact false-positive this fix targets: a cross-repo dep whose PRODUCER is
    /// ALSO in the publish set (and about to bump) is NON-gating when crates.io
    /// already serves a version satisfying the consumer's `^` requirement.
    #[test]
    fn registry_satisfied_cross_repo_dep_is_non_gating_even_when_producer_in_set() {
        let graphs = [
            graph_reqs("draupnir", &["draupnir"], &[("tunnr-vm", "0.1")]),
            graph_reqs("tunnr", &["tunnr-vm"], &[]),
            graph_reqs("dwarves", &["dwarves"], &[("nornir-build-thing", "0.1.0")]),
            graph_reqs("holger", &["holger"], &[("nornir-build-thing", "0.1.0")]),
            graph_reqs("nornir", &["nornir-build-thing"], &[]),
        ];
        // crates.io serves versions that SATISFY the consumers' `^` requirements —
        // even though the producers (tunnr, nornir) would bump to 0.1.3 / 0.1.2.
        let probe = MapProbe(
            [("tunnr-vm", vec!["0.1.1", "0.1.2"]), ("nornir-build-thing", vec!["0.1.0", "0.1.1"])]
                .into_iter()
                .collect(),
        );
        let non_gating = registry_non_gating_crates(&graphs, &probe);
        assert!(non_gating.contains("tunnr-vm"), "tunnr-vm ^0.1 satisfied by live 0.1.2");
        assert!(
            non_gating.contains("nornir-build-thing"),
            "nornir-build-thing ^0.1.0 satisfied by live 0.1.1"
        );

        // With these non-gating, an order that publishes the CONSUMERS before their
        // producers is sound (0 order violations) — the false positive is gone.
        let order = vec![
            "draupnir".to_string(),
            "dwarves".to_string(),
            "holger".to_string(),
            "tunnr".to_string(),
            "nornir".to_string(),
        ];
        let viol = cascade_order_violations(&graphs, &order, &non_gating);
        assert!(viol.is_empty(), "no order violations once registry-satisfied deps are non-gating: {viol:?}");
    }

    /// The guardrail: a genuinely-UNPUBLISHED cross-repo dep (or one whose only live
    /// version does NOT satisfy the consumer's requirement) STAYS gating.
    #[test]
    fn unpublished_or_unsatisfied_cross_repo_dep_stays_gating() {
        let graphs = [
            // draupnir needs tunnr-vm ^0.1 — but crates.io has NO tunnr-vm at all.
            graph_reqs("draupnir", &["draupnir"], &[("tunnr-vm", "0.1")]),
            graph_reqs("tunnr", &["tunnr-vm"], &[]),
            // dwarves needs nornir-build-thing ^0.2 — only 0.1.x is live (no match).
            graph_reqs("dwarves", &["dwarves"], &[("nornir-build-thing", "0.2")]),
            graph_reqs("nornir", &["nornir-build-thing"], &[]),
        ];
        let probe = MapProbe(
            [("nornir-build-thing", vec!["0.1.0", "0.1.1"])].into_iter().collect(),
        );
        let non_gating = registry_non_gating_crates(&graphs, &probe);
        assert!(
            !non_gating.contains("tunnr-vm"),
            "unpublished tunnr-vm must stay gating"
        );
        assert!(
            !non_gating.contains("nornir-build-thing"),
            "^0.2 unsatisfied by live 0.1.x — stays gating"
        );

        // Publishing the consumers before the producers now IS a violation.
        let bad = vec![
            "draupnir".to_string(),
            "dwarves".to_string(),
            "tunnr".to_string(),
            "nornir".to_string(),
        ];
        let viol = cascade_order_violations(&graphs, &bad, &non_gating);
        assert!(
            viol.iter().any(|v| v.repo == "draupnir" && v.dep_crate == "tunnr-vm"),
            "draupnir→tunnr-vm still flagged: {viol:?}"
        );
        assert!(
            viol.iter().any(|v| v.repo == "dwarves" && v.dep_crate == "nornir-build-thing"),
            "dwarves→nornir-build-thing still flagged: {viol:?}"
        );
    }

    /// A path dep with NO `version=` (empty-string sentinel) can never resolve from
    /// the registry, so it keeps its crate gating even if some other consumer is
    /// satisfied — the conservative ALL-consumers rule.
    #[test]
    fn versionless_path_dep_keeps_crate_gating() {
        let mut consumer_no_ver = graph_reqs("holger", &["holger"], &[]);
        consumer_no_ver.deps.insert("nornir-build-thing".to_string());
        // No dep_reqs entry ⇒ treated as the empty-string sentinel (unsatisfiable).
        let graphs = [
            graph_reqs("dwarves", &["dwarves"], &[("nornir-build-thing", "0.1.0")]),
            consumer_no_ver,
            graph_reqs("nornir", &["nornir-build-thing"], &[]),
        ];
        let probe =
            MapProbe([("nornir-build-thing", vec!["0.1.1"])].into_iter().collect());
        let non_gating = registry_non_gating_crates(&graphs, &probe);
        assert!(
            !non_gating.contains("nornir-build-thing"),
            "a versionless consumer keeps the crate gating (ALL-consumers rule)"
        );
    }
}