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