Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/release/doctor.rs

//! `release doctor` — the **advisory** release report. Read-only, no mutation.
//!
//! Doctrine (2026-07-14 — *release is a build op*): nornir performs NO releases,
//! so the release-doctor CORE — the engine-agnostic PODs (`RepoGraph`, `CrateSkew`,
//! the patch-fork promote gate, `Issue`/`IssueKind`) and the warehouse-FREE
//! read-analysis (skew, publish order, cycles, blast, the `run`/`format_report`
//! report path) — lives in the shared `nornir-release-core` edda leaf, which BOTH
//! nornir and the forge consume. Exactly ONE copy, and no crate edge toward the
//! forge: nornir must reach dwarves over gRPC only, never as a dep, so that the
//! forge (which is downstream of nornir) can compile nornir IN.
//!
//! This module is now MOSTLY a thin RE-EXPORT of `nornir_release_core::{model,
//! doctor}` so every remaining `crate::release::doctor::…` call site resolves the
//! single source of truth. What stays NORNIR-OWNED here is only what genuinely
//! needs nornir-only capabilities:
//!
//!  * the working-tree **fixers** (`fix_path_dep_versions` /
//!    `fix_versioned_path_dev_deps` / `apply_cheap_cycle_cuts` / `tidy_dirty_repos`
//!    + `scan_versioned_path_dev_deps`) — the `release doctor --fix/--auto` mutation
//!    operators the CLI still drives; not (yet) relocated to dwarves;
//!  * the ELF/binary graph cross-check (`graph_from_binary` /
//!    [`BinaryDerivedGraph`] / [`cross_check_source_vs_binary`]) — it reads nornir's
//!    own ELF extractor ([`crate::graph::extract::binary`]);
//!  * [`run_with_warehouse_semantic_blast`] — the release entry that DERIVES the
//!    semantic blast of every dirty repo from the code room (a dry-run dump of the
//!    dirty tree, its delta against the consumed state, who names what it changes:
//!    [`crate::code_room::reader::pending_blast`]) and fails closed when it cannot.

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

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

// ─────────────────── the unified release-doctor core (dwarves) ───────────────────
//
// The engine-agnostic PODs + promote-gate functions, and the warehouse-free
// read-analysis + report path, both live in dwarves now. Re-exported so the twin
// types unify (a value produced by `gather_repo_graph` here IS the same
// `nornir_release_core::model::RepoGraph` the retired depdag/cargo/promote/undo/
// semantic_blast modules flow) and every call site keeps working unchanged.
pub use nornir_release_core::doctor::{
    CfgCrossDep, CutSolutionView, CycleAdvice, CycleSolution, DepCycle, DepKind, DepPolicy,
    DevEdge, DoctorReport, ForbiddenDep, OptionalCrossDep, OrderViolation, PathDepVersionGap,
    RepoDirty, RepoEdge, RepoExternals, SemanticBlastAudit, TopoReport, analyze_skew,
    cascade_order_violations, cfg_cross_deps, crate_majors_in_lock, crate_publish_order,
    crate_publish_order_gated, cross_repo_gating_crates, cycle_advice, cycle_solutions,
    detect_cycles, enrich_transitive_pins, excluded_dev_edges, format_held_explanations,
    format_report, gather_repo_externals, gather_repo_graph, local_crate_versions,
    optional_cross_deps, publish_order, publish_waves, registry_non_gating_crates, repo_dep_edges,
    run, run_with_semantic_blast, scan_path_dep_version_gaps, symptoms, version_pinned_cross_deps,
};
pub use nornir_release_core::model::{
    CrateSkew, ForkKind, HeldExplanation, HeldFork, Issue, IssueKind, PatchForkBlock,
    PromoteBlockResult, RepoCrateStatus, RepoGraph, SkewStatus, compute_promote_block,
    gather_crate_deps, held_fork_reasons, patch_fork_blockers, promote_blocked_crates_precise,
};
// `check_dirty` reads the LOCAL working tree via gix; the pure POD + gatherer live in
// dwarves, so re-export it too (used by the fixers + the warehouse-blast entry below).
pub use nornir_release_core::doctor::check_dirty;

// ─────────────────── publish preflight fixers (nornir-owned) ───────────────────
//
// The `release doctor --fix/--auto` MUTATION operators: they rewrite `Cargo.toml`
// files on disk to close the exact `cargo publish` blockers the (dwarves-side)
// read-analysis surfaces. Not relocated to dwarves — these are the ACT side and stay
// with the CLI verbs for now. They reason over the re-exported dwarves PODs
// (`PathDepVersionGap`, `RepoGraph`, `RepoDirty`).

/// The outcome of `--fix`: how many gaps got a `version=` written, and which were
/// skipped (with why).
#[derive(Debug, Default)]
pub struct FixOutcome {
    pub fixed: usize,
    pub skipped: Vec<String>,
}

/// Auto-apply `version = "…"` to every path-dep-version gap
/// ([`PathDepVersionGap`]), sourcing the version from the DEP crate's own manifest
/// (follow its `path`), falling back to the report's `suggested_version`.
/// Formatting-preserving (`toml_edit`). Idempotent: a dep that already grew a
/// version is simply not re-found.
pub fn fix_path_dep_versions(
    gaps: &[PathDepVersionGap],
    repos: &[(String, PathBuf)],
) -> Result<FixOutcome> {
    let repo_root: BTreeMap<&str, &Path> = repos
        .iter()
        .map(|(n, p)| (n.as_str(), p.as_path()))
        .collect();
    // Group by absolute manifest path so each file is opened + written once.
    let mut by_file: BTreeMap<PathBuf, Vec<&PathDepVersionGap>> = BTreeMap::new();
    for g in gaps {
        let Some(root) = repo_root.get(g.repo.as_str()) else {
            continue;
        };
        by_file.entry(root.join(&g.manifest)).or_default().push(g);
    }

    let mut out = FixOutcome::default();
    for (manifest, gs) in by_file {
        let text = std::fs::read_to_string(&manifest)
            .with_context(|| format!("read {}", manifest.display()))?;
        let mut doc: toml_edit::DocumentMut = text
            .parse()
            .with_context(|| format!("parse {}", manifest.display()))?;
        let mdir = manifest.parent().unwrap_or_else(|| Path::new("."));
        let mut changed = false;
        // Locators for every Normal/Build dep table: the two top-level ones plus each
        // `[target.'cfg'.…]` variant (collected up front — inserting a version never
        // adds/removes a target table, so the set stays valid across mutation).
        let cfgs: Vec<String> = doc
            .get("target")
            .and_then(|t| t.as_table())
            .map(|t| t.iter().map(|(k, _)| k.to_string()).collect())
            .unwrap_or_default();
        let mut locators: Vec<(Option<String>, &str)> = Vec::new();
        for tn in ["dependencies", "build-dependencies"] {
            locators.push((None, tn));
        }
        for c in &cfgs {
            for tn in ["dependencies", "build-dependencies"] {
                locators.push((Some(c.clone()), tn));
            }
        }
        for g in gs {
            let mut handled = false;
            for (cfg, table_name) in &locators {
                let Some(tbl) = dep_table_mut(&mut doc, cfg.as_deref(), table_name) else {
                    continue;
                };
                // Find the dep KEY whose real name == g.dep with a path and no version.
                let key = tbl.iter().find_map(|(k, item)| {
                    let real = item.get("package").and_then(|p| p.as_str()).unwrap_or(k);
                    let has_path = item.get("path").is_some();
                    let has_ver = item.get("version").is_some();
                    (real == g.dep && has_path && !has_ver).then(|| k.to_string())
                });
                let Some(key) = key else { continue };
                let path_val = tbl
                    .get(&key)
                    .and_then(|i| i.get("path"))
                    .and_then(|p| p.as_str())
                    .map(|p| mdir.join(p).join("Cargo.toml"));
                let ver = path_val
                    .as_deref()
                    .and_then(resolve_crate_version)
                    .or_else(|| g.suggested_version.clone());
                match ver {
                    Some(v) => {
                        if let Some(item) = tbl.get_mut(&key) {
                            if let Some(it) = item.as_inline_table_mut() {
                                it.insert("version", v.into());
                            } else if let Some(t) = item.as_table_mut() {
                                t.insert("version", toml_edit::value(v));
                            }
                            out.fixed += 1;
                            changed = true;
                        }
                    }
                    None => out.skipped.push(format!(
                        "{}/{}: {} (no resolvable version)",
                        g.repo, g.manifest, g.dep
                    )),
                }
                handled = true;
                break;
            }
            if !handled {
                out.skipped.push(format!(
                    "{}/{}: {} (dep entry not found — already fixed?)",
                    g.repo, g.manifest, g.dep
                ));
            }
        }
        if changed {
            std::fs::write(&manifest, doc.to_string())
                .with_context(|| format!("write {}", manifest.display()))?;
        }
    }
    Ok(out)
}

/// Mutable access to one dependency table of a `toml_edit` manifest: either a
/// top-level `[dependencies]`/`[build-dependencies]` (`cfg = None`) or a
/// `[target.'<cfg>'.<table_name>]` variant (`cfg = Some(...)`).
fn dep_table_mut<'a>(
    doc: &'a mut toml_edit::DocumentMut,
    cfg: Option<&str>,
    table_name: &str,
) -> Option<&'a mut toml_edit::Table> {
    match cfg {
        None => doc.get_mut(table_name).and_then(|t| t.as_table_mut()),
        Some(c) => doc
            .get_mut("target")
            .and_then(|t| t.as_table_mut())
            .and_then(|t| t.get_mut(c))
            .and_then(|c| c.as_table_mut())
            .and_then(|c| c.get_mut(table_name))
            .and_then(|t| t.as_table_mut()),
    }
}

/// Resolve a crate's own `version`, following `version.workspace = true` by walking
/// up from its manifest dir to the `[workspace.package].version`.
fn resolve_crate_version(dep_manifest: &Path) -> Option<String> {
    let text = std::fs::read_to_string(dep_manifest).ok()?;
    let doc = text.parse::<toml::Table>().map(toml::Value::Table).ok()?;
    match doc.get("package").and_then(|p| p.get("version")) {
        Some(toml::Value::String(s)) => return Some(s.clone()),
        Some(toml::Value::Table(t))
            if t.get("workspace")
                .and_then(|w| w.as_bool())
                .unwrap_or(false) => {}
        _ => return None,
    }
    // version.workspace = true → find the nearest ancestor with [workspace.package].version.
    let mut dir = dep_manifest.parent();
    while let Some(d) = dir {
        if let Ok(txt) = std::fs::read_to_string(d.join("Cargo.toml")) {
            if let Ok(doc) = txt.parse::<toml::Table>().map(toml::Value::Table) {
                if let Some(v) = doc
                    .get("workspace")
                    .and_then(|w| w.get("package"))
                    .and_then(|pk| pk.get("version"))
                    .and_then(|v| v.as_str())
                {
                    return Some(v.to_string());
                }
            }
        }
        dir = d.parent();
    }
    None
}

/// One `[dev-dependencies]` (or `[target.'cfg'.dev-dependencies]`) entry that
/// carries BOTH a `path` and a `version` on a workspace sibling — the
/// `VersionedPathDevDep` blocker (preflight #6). cargo strips dev-deps on publish
/// but still RESOLVES a versioned one during `cargo publish --verify`, fail-
/// stopping if that sibling isn't live on crates.io. The fix is to DROP the
/// `version` (a path-only dev-dep is stripped cleanly and resolves locally).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionedPathDevDep {
    pub repo: String,
    /// Repo-relative manifest path.
    pub manifest: PathBuf,
    /// The dep name carrying both path + version.
    pub dep: String,
}

/// Scan every repo for `[dev-dependencies]` entries with BOTH `path` and
/// `version` (incl. `[target.'cfg'.dev-dependencies]`). Pure read; the
/// counterpart auto-fix is [`fix_versioned_path_dev_deps`]. This is the
/// nornir-owned healer for the exact blocker class the workspace-wide dry-run
/// surfaced across facett/skade/nornir/znippy.
pub fn scan_versioned_path_dev_deps(repos: &[(String, PathBuf)]) -> Vec<VersionedPathDevDep> {
    let mut out = Vec::new();
    for (repo, root) in repos {
        for path in find_cargo_tomls(root, 4) {
            let Ok(txt) = std::fs::read_to_string(&path) else {
                continue;
            };
            let Ok(doc) = txt.parse::<toml::Table>().map(toml::Value::Table) else {
                continue;
            };
            let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
            let mut collect = |tbl: &toml::Value| {
                if let Some(deps) = tbl.get("dev-dependencies").and_then(|d| d.as_table()) {
                    for (name, v) in deps {
                        let Some(t) = v.as_table() else { continue };
                        let has_path = t.get("path").and_then(|p| p.as_str()).is_some();
                        let has_ver = t.get("version").and_then(|v| v.as_str()).is_some();
                        if has_path && has_ver {
                            // The dep's REAL crate name (respect a `package = "…"` rename).
                            let real = t.get("package").and_then(|p| p.as_str()).unwrap_or(name);
                            out.push(VersionedPathDevDep {
                                repo: repo.clone(),
                                manifest: rel.clone(),
                                dep: real.to_string(),
                            });
                        }
                    }
                }
            };
            collect(&doc);
            if let Some(targets) = doc.get("target").and_then(|t| t.as_table()) {
                for (_cfg, t) in targets {
                    collect(t);
                }
            }
        }
    }
    out
}

/// Auto-heal every [`VersionedPathDevDep`] by REMOVING the `version` key from the
/// dev-dep entry (leaving the `path`). Formatting-preserving (`toml_edit`),
/// idempotent (an entry already stripped is simply not re-found). This is the
/// inverse operator of [`fix_path_dep_versions`] and closes the
/// `VersionedPathDevDep` blocker class in `release doctor --fix`.
pub fn fix_versioned_path_dev_deps(
    gaps: &[VersionedPathDevDep],
    repos: &[(String, PathBuf)],
) -> Result<FixOutcome> {
    let repo_root: BTreeMap<&str, &Path> = repos
        .iter()
        .map(|(n, p)| (n.as_str(), p.as_path()))
        .collect();
    let mut by_file: BTreeMap<PathBuf, Vec<&VersionedPathDevDep>> = BTreeMap::new();
    for g in gaps {
        let Some(root) = repo_root.get(g.repo.as_str()) else {
            continue;
        };
        by_file.entry(root.join(&g.manifest)).or_default().push(g);
    }

    let mut out = FixOutcome::default();
    for (manifest, gs) in by_file {
        let text = std::fs::read_to_string(&manifest)
            .with_context(|| format!("read {}", manifest.display()))?;
        let mut doc: toml_edit::DocumentMut = text
            .parse()
            .with_context(|| format!("parse {}", manifest.display()))?;
        let cfgs: Vec<String> = doc
            .get("target")
            .and_then(|t| t.as_table())
            .map(|t| t.iter().map(|(k, _)| k.to_string()).collect())
            .unwrap_or_default();
        // dev-dep table locators: top-level + each target cfg.
        let mut locators: Vec<Option<String>> = vec![None];
        for c in &cfgs {
            locators.push(Some(c.clone()));
        }
        let mut changed = false;
        for g in gs {
            let mut handled = false;
            for cfg in &locators {
                let Some(tbl) = dev_dep_table_mut(&mut doc, cfg.as_deref()) else {
                    continue;
                };
                // Find the dep key whose real name == g.dep with a path AND a version.
                let key = tbl.iter().find_map(|(k, item)| {
                    let real = item.get("package").and_then(|p| p.as_str()).unwrap_or(k);
                    let has_path = item.get("path").is_some();
                    let has_ver = item.get("version").is_some();
                    (real == g.dep && has_path && has_ver).then(|| k.to_string())
                });
                let Some(key) = key else { continue };
                if let Some(item) = tbl.get_mut(&key) {
                    if let Some(it) = item.as_inline_table_mut() {
                        it.remove("version");
                    } else if let Some(t) = item.as_table_mut() {
                        t.remove("version");
                    }
                    out.fixed += 1;
                    changed = true;
                }
                handled = true;
                break;
            }
            if !handled {
                out.skipped.push(format!(
                    "{}/{}: {} (dev-dep entry not found — already fixed?)",
                    g.repo,
                    g.manifest.display(),
                    g.dep
                ));
            }
        }
        if changed {
            std::fs::write(&manifest, doc.to_string())
                .with_context(|| format!("write {}", manifest.display()))?;
        }
    }
    Ok(out)
}

/// Mutable handle to a `[dev-dependencies]` table (top-level `cfg = None`, or a
/// `[target.'<cfg>'.dev-dependencies]`), creating nothing (returns `None` if
/// absent). Mirrors [`dep_table_mut`] for the dev-dep case.
fn dev_dep_table_mut<'a>(
    doc: &'a mut toml_edit::DocumentMut,
    cfg: Option<&str>,
) -> Option<&'a mut toml_edit::Table> {
    match cfg {
        None => doc
            .get_mut("dev-dependencies")
            .and_then(|i| i.as_table_mut()),
        Some(c) => doc
            .get_mut("target")
            .and_then(|t| t.as_table_mut())
            .and_then(|t| t.get_mut(c))
            .and_then(|i| i.as_table_mut())
            .and_then(|t| t.get_mut("dev-dependencies"))
            .and_then(|i| i.as_table_mut()),
    }
}

// ─────────────── AUTO remedy executors (#18: --auto apply-fns) ───────────────
//
// `fix-versions` reuses `fix_path_dep_versions` above. These are the other AUTO
// operators the invertive planner (`graph_math::remedy_table`) emits, made
// executable so `release doctor --auto` applies them to a fixpoint instead of
// printing "apply it manually".

/// Outcome of the `tidy-dirty` AUTO remedy.
#[derive(Debug, Default)]
pub struct TidyOutcome {
    /// Noise entries (files + `.claude/` dirs) removed across all dirty repos.
    pub removed: usize,
    /// Repos that became clean after shedding noise.
    pub cleaned: Vec<String>,
    /// Repos still dirty after tidying — real (non-noise) changes remain, so the
    /// dirtiness is a DECISION (commit/stash it), not auto-tidyable.
    pub still_dirty: Vec<String>,
}

/// Working-tree NOISE a dirty repo may shed without a decision: the agent scratch
/// dir (`.claude/`) and arrow scratch files (`*.arrows`). Everything else is real
/// content — never touched.
fn is_tidy_noise_dir(name: &str) -> bool {
    name == ".claude"
}
fn is_tidy_noise_file(name: &str) -> bool {
    name.ends_with(".arrows")
}

/// Recursively delete `.claude/` dirs and `*.arrows` files under `root`, skipping
/// `.git` and `target`. Returns how many top-level noise entries were removed.
fn remove_tidy_noise(root: &Path) -> Result<usize> {
    let mut removed = 0usize;
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for e in entries.flatten() {
            let p = e.path();
            let name = p
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("")
                .to_string();
            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
            if is_dir {
                if name == ".git" || name == "target" {
                    continue;
                }
                if is_tidy_noise_dir(&name) {
                    std::fs::remove_dir_all(&p)
                        .with_context(|| format!("rm -r {}", p.display()))?;
                    removed += 1;
                } else {
                    stack.push(p);
                }
            } else if is_tidy_noise_file(&name) {
                std::fs::remove_file(&p).with_context(|| format!("rm {}", p.display()))?;
                removed += 1;
            }
        }
    }
    Ok(removed)
}

/// The `tidy-dirty` executor: for every DIRTY repo, shed working-tree noise
/// (`.claude/`, `*.arrows`) and re-assess. A repo whose dirtiness was pure noise
/// goes clean (closing the `Dirty` symptom); one with real uncommitted changes
/// stays dirty and is reported (a DECISION for the human). Pure filesystem +
/// gix status — no `git` subprocess.
pub fn tidy_dirty_repos(dirty: &[RepoDirty], repos: &[(String, PathBuf)]) -> Result<TidyOutcome> {
    let root_of: BTreeMap<&str, &Path> = repos
        .iter()
        .map(|(n, p)| (n.as_str(), p.as_path()))
        .collect();
    let mut out = TidyOutcome::default();
    for d in dirty.iter().filter(|d| d.dirty) {
        let Some(root) = root_of.get(d.repo.as_str()) else {
            continue;
        };
        out.removed += remove_tidy_noise(root)?;
        match crate::gitio::worktree_freshness(root) {
            Ok(f) if !f.dirty => out.cleaned.push(d.repo.clone()),
            _ => out.still_dirty.push(d.repo.clone()),
        }
    }
    Ok(out)
}

/// Outcome of the `cut-cycle-cheap` AUTO remedy.
#[derive(Debug, Default)]
pub struct CutOutcome {
    /// The `(from, to)` crate edges physically cut from a manifest.
    pub cut: Vec<(String, String)>,
    /// Cheap cut edges we could not locate in a manifest (reported, not fatal —
    /// e.g. the edge is a `workspace = true` inherit we don't rewrite here).
    pub unresolved: Vec<(String, String)>,
}

/// Drop the dependency on `to_crate` inside a single manifest `[dev-dependencies]`
/// table (incl. `[target.'cfg'.dev-dependencies]`). Returns `true` if removed.
fn cut_dev_edge_in_manifest(doc: &mut toml_edit::DocumentMut, to_crate: &str) -> bool {
    fn real_name<'a>(key: &'a str, item: &'a toml_edit::Item) -> &'a str {
        item.get("package").and_then(|p| p.as_str()).unwrap_or(key)
    }
    // Top-level and per-target dev-dependency tables.
    let mut cut = false;
    if let Some(tbl) = doc
        .get_mut("dev-dependencies")
        .and_then(|i| i.as_table_like_mut())
    {
        let key = tbl
            .iter()
            .find(|(k, item)| real_name(k, item) == to_crate)
            .map(|(k, _)| k.to_string());
        if let Some(k) = key {
            tbl.remove(&k);
            cut = true;
        }
    }
    if let Some(targets) = doc.get_mut("target").and_then(|i| i.as_table_like_mut()) {
        let cfgs: Vec<String> = targets.iter().map(|(k, _)| k.to_string()).collect();
        for cfg in cfgs {
            if let Some(tbl) = targets
                .get_mut(&cfg)
                .and_then(|i| i.get_mut("dev-dependencies"))
                .and_then(|i| i.as_table_like_mut())
            {
                let key = tbl
                    .iter()
                    .find(|(k, item)| real_name(k, item) == to_crate)
                    .map(|(k, _)| k.to_string());
                if let Some(k) = key {
                    tbl.remove(&k);
                    cut = true;
                }
            }
        }
    }
    cut
}

/// Cut the edge on `to_crate` inside `repo_root`'s manifests. Preference order
/// matches the MFAS cost model: a **dev** edge (cost 0) is removed outright; an
/// **optional** `[dependencies]` edge (cost 1) is removed together with the
/// `[features]` references that enabled it (gate the feature off). A NORMAL hard
/// dep is NOT cut (that is a crate-split DECISION). Returns `true` if a manifest
/// was edited.
fn cut_edge_in_repo(repo_root: &Path, to_crate: &str) -> Result<bool> {
    for manifest in find_cargo_tomls(repo_root, 3) {
        let text = std::fs::read_to_string(&manifest)
            .with_context(|| format!("read {}", manifest.display()))?;
        let mut doc: toml_edit::DocumentMut = text
            .parse()
            .with_context(|| format!("parse {}", manifest.display()))?;

        // Cost-0: drop a dev edge.
        if cut_dev_edge_in_manifest(&mut doc, to_crate) {
            std::fs::write(&manifest, doc.to_string())
                .with_context(|| format!("write {}", manifest.display()))?;
            return Ok(true);
        }

        // Cost-1: an OPTIONAL normal dep → remove it + scrub the features that
        // enabled it (gate the feature off).
        let opt_key = doc
            .get("dependencies")
            .and_then(|i| i.as_table_like())
            .and_then(|tbl| {
                tbl.iter().find_map(|(k, item)| {
                    let real = item.get("package").and_then(|p| p.as_str()).unwrap_or(k);
                    let optional = item
                        .get("optional")
                        .and_then(|o| o.as_bool())
                        .unwrap_or(false);
                    (real == to_crate && optional).then(|| k.to_string())
                })
            });
        if let Some(key) = opt_key {
            if let Some(tbl) = doc
                .get_mut("dependencies")
                .and_then(|i| i.as_table_like_mut())
            {
                tbl.remove(&key);
            }
            scrub_feature_refs(&mut doc, &key);
            std::fs::write(&manifest, doc.to_string())
                .with_context(|| format!("write {}", manifest.display()))?;
            return Ok(true);
        }
    }
    Ok(false)
}

/// Remove every `[features]` reference to the (now-dropped) optional dep `key`:
/// bare `key`, explicit `dep:key`, and weak `key?/feat` / `key/feat` activations.
/// A feature whose only member was that ref is left as an empty array (harmless)
/// rather than deleted, so an external `--features <name>` invocation still
/// resolves.
fn scrub_feature_refs(doc: &mut toml_edit::DocumentMut, key: &str) {
    let Some(features) = doc.get_mut("features").and_then(|i| i.as_table_like_mut()) else {
        return;
    };
    let feat_names: Vec<String> = features.iter().map(|(k, _)| k.to_string()).collect();
    for fname in feat_names {
        if let Some(arr) = features.get_mut(&fname).and_then(|i| i.as_array_mut()) {
            arr.retain(|v| {
                let s = v.as_str().unwrap_or("");
                let dep = s.strip_prefix("dep:").unwrap_or(s);
                let base = dep.split(['/', '?']).next().unwrap_or(dep);
                base != key
            });
        }
    }
}

/// The `cut-cycle-cheap` executor: rebuild the crate graph from the live
/// manifests, and for every CHEAP (MFAS cut cost ≤ 1) dependency cycle, apply
/// its cheapest cut by editing the manifest that declares the edge (see
/// [`cut_edge_in_repo`]). Returns the edges cut; a cheap cut whose manifest edge
/// can't be located is reported in `unresolved` (never fatal).
pub fn apply_cheap_cycle_cuts(repos: &[(String, PathBuf)]) -> Result<CutOutcome> {
    let graphs: Vec<RepoGraph> = repos
        .iter()
        .filter_map(|(n, p)| gather_repo_graph(n, p).ok())
        .collect();
    let (owner, _adj) = crate_graph(&graphs);
    let root_of: BTreeMap<&str, &Path> = repos
        .iter()
        .map(|(n, p)| (n.as_str(), p.as_path()))
        .collect();
    let mut out = CutOutcome::default();
    for cs in cycle_solutions(&graphs) {
        let Some(best) = cs.solutions.first() else {
            continue;
        };
        if best.cost > 1 {
            continue; // not cheap → a crate-split DECISION, not an AUTO cut
        }
        for (from, to) in &best.edges {
            let located = owner
                .get(from)
                .and_then(|repo| root_of.get(repo.as_str()))
                .map(|root| cut_edge_in_repo(root, to))
                .transpose()?
                .unwrap_or(false);
            if located {
                out.cut.push((from.clone(), to.clone()));
            } else {
                out.unresolved.push((from.clone(), to.clone()));
            }
        }
    }
    Ok(out)
}

// ─────────────────────────── shared nornir-owned helpers ───────────────────────────

/// Find `Cargo.toml` files under `root` up to `max_depth`, skipping `target`,
/// `.git`, and other hidden directories. (dwarves owns its own copy for the
/// relocated gatherers; this stays for the nornir-owned fixers above.)
fn find_cargo_tomls(root: &Path, max_depth: usize) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![(root.to_path_buf(), 0usize)];
    while let Some((dir, depth)) = stack.pop() {
        let manifest = dir.join("Cargo.toml");
        if manifest.is_file() {
            out.push(manifest);
        }
        if depth >= max_depth {
            continue;
        }
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for e in entries.flatten() {
            let p = e.path();
            if e.file_type().map(|t| t.is_symlink()).unwrap_or(false) {
                continue;
            }
            if !p.is_dir() {
                continue;
            }
            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if name == "target" || name.starts_with('.') {
                continue;
            }
            stack.push((p, depth + 1));
        }
    }
    out
}

/// 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). Used by [`apply_cheap_cycle_cuts`] to locate the manifest that
/// declares a cut edge.
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)
}

// ─────────── ELF/binary graph cross-check (graph_math over linked binaries) ───────────

/// The ELF/binary symbol-dependency graph projected into the SAME graph_math
/// shape the source [`RepoGraph`] uses — the release doctor's second witness:
/// what the LINKED BINARIES actually reference, alongside what the SOURCE
/// manifests declare. Built by [`graph_from_binary`] over
/// [`crate::graph::extract::binary`] (the extractor the CLI `graph binary` verb
/// drives via `to_symbol_rows` / `to_call_edge_rows`).
#[derive(Debug, Clone, Default)]
pub struct BinaryDerivedGraph {
    /// object-node → the object nodes it links against (`DT_NEEDED` + resolved
    /// symbol edges) — the adjacency graph_math's Tarjan/MFAS consume.
    pub adjacency: BTreeMap<String, std::collections::BTreeSet<String>>,
    /// Non-trivial strongly-connected components (link cycles), Tarjan-sorted.
    pub cycles: Vec<Vec<String>>,
    /// Provide/need symbol facts (mirrors the source syn symbol pass).
    pub symbol_rows: Vec<crate::knowledge::symbols::SymbolRow>,
    /// PLT/GOT call edges (mirrors the source call-edge pass).
    pub call_edge_rows: Vec<crate::knowledge::symbols::CallEdgeRow>,
}

impl BinaryDerivedGraph {
    /// Project a [`WorkspaceGraph`](crate::warehouse::dep_graph::WorkspaceGraph)
    /// — the shared model
    /// [`BinaryGraph::to_workspace_graph`](crate::graph::extract::binary::BinaryGraph::to_workspace_graph)
    /// yields — onto the adjacency + cycle structure. Every node appears even with
    /// no out-edges, so an isolated object is still a graph node.
    pub fn from_workspace(wg: &crate::warehouse::dep_graph::WorkspaceGraph) -> Self {
        let mut adjacency: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
        for name in wg.facts.keys() {
            adjacency.entry(name.clone()).or_default();
        }
        for e in &wg.edges {
            adjacency
                .entry(e.from.clone())
                .or_default()
                .insert(e.to.clone());
            adjacency.entry(e.to.clone()).or_default();
        }
        let cycles = nornir_release_core::graph_math::cycles(&adjacency);
        Self {
            adjacency,
            cycles,
            symbol_rows: Vec::new(),
            call_edge_rows: Vec::new(),
        }
    }
}

/// Extract the ELF/binary symbol graph from `paths` and project it into the
/// graph_math shape — the doctor entry over the same extractor the CLI
/// `graph binary` verb drives. Carries the `symbol_rows` / `call_edge_rows` (the
/// warehouse projection) alongside the adjacency + cycles. An unreadable /
/// non-ELF input is skipped inside the extractor, never fatal.
pub fn graph_from_binary(paths: &[PathBuf]) -> Result<BinaryDerivedGraph> {
    let bg = crate::graph::extract::binary::extract_graph(paths)?;
    let wg = bg.to_workspace_graph();
    let mut d = BinaryDerivedGraph::from_workspace(&wg);
    d.symbol_rows = bg.to_symbol_rows();
    d.call_edge_rows = bg.to_call_edge_rows();
    Ok(d)
}

/// The outcome of cross-checking the SOURCE dependency graph against the
/// ELF-derived graph: whether their cycle structure (SCC partition) and their
/// minimum-feedback-arc cut sizes agree.
#[derive(Debug, Clone, Serialize)]
pub struct GraphCrossCheck {
    /// Non-trivial SCCs (sorted node lists) present in BOTH graphs.
    pub shared_cycles: Vec<Vec<String>>,
    /// Cycles the SOURCE graph has that the binary graph does not.
    pub source_only_cycles: Vec<Vec<String>>,
    /// Cycles the BINARY graph has that the source graph does not.
    pub binary_only_cycles: Vec<Vec<String>>,
    /// Per shared cycle where the MFAS min-cut cost differs: `(members, source, binary)`.
    pub mfas_divergences: Vec<(Vec<String>, u32, u32)>,
}

impl GraphCrossCheck {
    /// The two graphs agree iff neither carries a cycle the other lacks AND every
    /// shared cycle has the same minimum-cut cost in both.
    pub fn agrees(&self) -> bool {
        self.source_only_cycles.is_empty()
            && self.binary_only_cycles.is_empty()
            && self.mfas_divergences.is_empty()
    }
}

/// Cross-check the SOURCE crate-dependency graph (`source`, e.g. from
/// [`repo_dep_edges`]) against the ELF-derived graph (`binary`, from
/// [`graph_from_binary`]'s `adjacency`): compares their cycle partition (Tarjan
/// SCCs) and, per shared cycle, the minimum feedback-arc-set cut cost. A
/// divergence means "what the manifests declare" and "what the binaries link"
/// disagree about where the release is tangled — a doctor-worthy signal that the
/// source graph and the shipped artefacts have drifted.
pub fn cross_check_source_vs_binary(
    source: &BTreeMap<String, std::collections::BTreeSet<String>>,
    binary: &BTreeMap<String, std::collections::BTreeSet<String>>,
) -> GraphCrossCheck {
    use std::collections::BTreeSet;
    let src_set: BTreeSet<Vec<String>> = nornir_release_core::graph_math::cycles(source)
        .into_iter()
        .collect();
    let bin_set: BTreeSet<Vec<String>> = nornir_release_core::graph_math::cycles(binary)
        .into_iter()
        .collect();

    let shared_cycles: Vec<Vec<String>> = src_set.intersection(&bin_set).cloned().collect();
    let source_only_cycles: Vec<Vec<String>> = src_set.difference(&bin_set).cloned().collect();
    let binary_only_cycles: Vec<Vec<String>> = bin_set.difference(&src_set).cloned().collect();

    let mut mfas_divergences = Vec::new();
    for comp in &shared_cycles {
        let s_cost = min_cut_cost(source, comp);
        let b_cost = min_cut_cost(binary, comp);
        if s_cost != b_cost {
            mfas_divergences.push((comp.clone(), s_cost, b_cost));
        }
    }
    GraphCrossCheck {
        shared_cycles,
        source_only_cycles,
        binary_only_cycles,
        mfas_divergences,
    }
}

/// Minimum feedback-arc-set cut cost for one cyclic component `comp` under `adj`
/// — every intra-component edge classed `Normal` (a structural link cut). `0`
/// when the component carries no intra-cycle edges.
fn min_cut_cost(
    adj: &BTreeMap<String, std::collections::BTreeSet<String>>,
    comp: &[String],
) -> u32 {
    let members: std::collections::BTreeSet<&String> = comp.iter().collect();
    let edges: Vec<nornir_release_core::graph_math::ClassifiedEdge> = comp
        .iter()
        .flat_map(|from| {
            adj.get(from)
                .into_iter()
                .flatten()
                .filter(|to| members.contains(to))
                .map(move |to| nornir_release_core::graph_math::ClassifiedEdge {
                    from: from.clone(),
                    to: to.clone(),
                    via: Vec::new(),
                    class: nornir_release_core::graph_math::EdgeClass::Normal,
                })
        })
        .collect();
    nornir_release_core::graph_math::min_feedback_arc_set(comp, &edges)
        .into_iter()
        .map(|s| s.cost)
        .min()
        .unwrap_or(0)
}

// ─────────────── semantic blast of a pending bump (the code room) ───────────────

/// Why the semantic blast of a dirty repo could not be derived. The doctor
/// REFUSES on every one of them (fail closed): the link gate must never pass
/// because its input was missing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlastRefusal {
    /// The code room has no answer; `state` says which: `no_room`,
    /// `lake_unavailable`, `not_consumed` (the repo, or a repo that depends
    /// on it), `driver_not_available`, `not_linked`, `internal`.
    Room(crate::code_room::Refusal),
    /// Units of the dirty tree did not compile: their rows are not in the
    /// delta, so what they change is unknown.
    FailedUnits(Vec<String>),
}

/// The typed refusal of [`run_with_warehouse_semantic_blast`] (downcast the error).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlastRefused {
    /// The dirty repo whose blast is missing.
    pub repo: String,
    pub why: BlastRefusal,
}

impl std::fmt::Display for BlastRefused {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "release doctor semantic blast: `{}` carries a pending change, but what it breaks cannot be derived — ", self.repo)?;
        match &self.why {
            BlastRefusal::Room(r) => write!(f, "[{}] {}", r.state, r.note)?,
            BlastRefusal::FailedUnits(units) => write!(f, "the dirty tree does not compile ({}): what those units change is unknown", units.join(", "))?,
        }
        f.write_str(". Refusing (fail closed) rather than skipping the semantic link gate.")
    }
}

impl std::error::Error for BlastRefused {}

/// **THE RELEASE ENTRY POINT** — [`run`] plus the semantic-blast gate input.
///
/// * no dirty repo → nothing to diff → plain [`run`] (empty blast, gate skips);
/// * a dirty repo (a pending bump) → its semantic blast from the code room
///   `reach` holds, with `driver` dumping the dirty tree
///   ([`crate::code_room::reader::pending_blast`]): the symbols the tree
///   changes or removes, and every site in ANOTHER repo that names them. A
///   removed symbol with a site is a link error ([`super::gate::semantic_link_gate`]);
/// * the blast cannot be derived (no code room, the repo or one that depends
///   on it not consumed, no driver, the tree does not compile) → **FAIL
///   CLOSED** with the typed [`BlastRefused`], never a silently skipped gate.
///
/// A dirty tree that changes nothing in the graph (an untracked note, a
/// comment) is an EMPTY blast, not a refusal.
pub fn run_with_warehouse_semantic_blast(
    repos: &[(String, PathBuf)],
    policy: &DepPolicy,
    reach: &crate::code_room::Reach<'_>,
    driver: &crate::code_room::reader::TreeDriver,
) -> Result<(DoctorReport, SemanticBlastAudit)> {
    use nornir_release_core::semantic_blast::{CallSite, ChangeKind, ChangedSymbol, SemanticBlast, SymbolImpact, blast_radius};

    let mut audit = SemanticBlastAudit::default();
    let dirty: Vec<String> = check_dirty(repos)
        .into_iter()
        .filter(|d| d.dirty)
        .map(|d| d.repo)
        .collect();
    audit.dirty_considered = dirty.clone();

    let mut report = run(repos, policy)?;
    if dirty.is_empty() {
        audit.note =
            "no dirty repos → no pending bump to diff; semantic blast empty (link gate skipped)"
                .to_string();
        return Ok((report, audit));
    }

    let graphs = repos
        .iter()
        .map(|(name, path)| gather_repo_graph(name, path))
        .collect::<Result<Vec<_>>>()?;
    let mut notes: Vec<String> = Vec::new();
    for repo in &dirty {
        let tree = &repos.iter().find(|(name, _)| name == repo).context("a dirty repo is one of the repos")?.1;
        // the repos that (transitively) depend on it by manifest: their sites
        // are the answer, so they must be in the room
        let dependents = blast_radius(&graphs, repo);
        let refused = |why: BlastRefusal| anyhow::Error::new(BlastRefused { repo: repo.clone(), why });
        let pending = crate::code_room::reader::pending_blast(reach, driver, repo, tree, &dependents).map_err(|r| refused(BlastRefusal::Room(r)))?;
        if !pending.failed_units.is_empty() {
            return Err(refused(BlastRefusal::FailedUnits(pending.failed_units)));
        }
        let kind = |removed: bool| if removed { ChangeKind::Removed } else { ChangeKind::Changed };
        let mut blast = SemanticBlast { target_repo: repo.clone(), target_crates: pending.crates, source: "code_room".to_string(), ..Default::default() };
        let mut affected = std::collections::BTreeSet::new();
        for s in &pending.symbols {
            if s.sites.is_empty() {
                blast.unused_changes.push(ChangedSymbol { name: s.symbol.clone(), change: kind(s.removed) });
                continue;
            }
            affected.extend(s.sites.iter().map(|x| x.crate_name.clone()));
            blast.impacts.push(SymbolImpact {
                symbol: s.symbol.clone(),
                change: kind(s.removed),
                call_sites: s
                    .sites
                    .iter()
                    .map(|x| CallSite { crate_name: x.crate_name.clone(), caller: x.caller.clone(), callee: s.symbol.clone(), call_kind: x.kind.clone(), file: x.file.clone(), line: x.line })
                    .collect(),
            });
        }
        blast.total_call_sites = blast.impacts.iter().map(|i| i.call_sites.len()).sum();
        blast.affected_crates = affected.into_iter().collect();
        audit.changed_symbols.insert(repo.clone(), pending.symbols.len());
        notes.push(format!(
            "{repo}: {} symbol(s) changed or removed, {} site(s) in other repos name them ({} link error site(s)); {} node(s) in [{}] depend on them transitively",
            pending.symbols.len(),
            blast.total_call_sites,
            blast.link_error_sites(),
            pending.dependents,
            pending.dependent_repos.join(", "),
        ));
        notes.extend(pending.notes);
        if !blast.is_empty() {
            audit.populated = true;
            report.semantic_blast.insert(repo.clone(), blast);
        }
    }
    notes.dedup();
    audit.note = notes.join("; ");
    Ok((report, audit))
}

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

    #[test]
    fn scan_and_fix_versioned_path_dev_deps_strips_version_keeps_path() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // A crate with TWO versioned path dev-deps (one inline, one target-cfg) plus
        // a legit path-only dev-dep (must be left alone) and a normal versioned dep.
        std::fs::write(
            root.join("Cargo.toml"),
            r#"[package]
name = "consumer"
version = "0.5.0"

[dependencies]
serde = { version = "1", path = "../serde-fork" }

[dev-dependencies]
sibling-a = { path = "../sibling-a", version = "0.1" }
sibling-ok = { path = "../sibling-ok" }

[target.'cfg(unix)'.dev-dependencies]
sibling-b = { version = "0.2.1", path = "../sibling-b" }
"#,
        )
        .unwrap();

        let repos = vec![("consumer".to_string(), root.to_path_buf())];
        let mut gaps = scan_versioned_path_dev_deps(&repos);
        gaps.sort_by(|a, b| a.dep.cmp(&b.dep));
        let deps: Vec<&str> = gaps.iter().map(|g| g.dep.as_str()).collect();
        assert_eq!(
            deps,
            vec!["sibling-a", "sibling-b"],
            "only path+version DEV-deps"
        );

        let out = fix_versioned_path_dev_deps(&gaps, &repos).unwrap();
        assert_eq!(out.fixed, 2);
        let after = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
        // Reparse (formatting-tolerant): the two dev-deps lost their version but
        // kept their path; the path-only dev-dep and the normal dep are untouched.
        let doc: toml::Value = after.parse::<toml::Table>().map(toml::Value::Table).unwrap();
        let dev = |v: &toml::Value, table: &str, name: &str| -> toml::Value {
            v.get(table).and_then(|t| t.get(name)).cloned().unwrap()
        };
        let a = dev(&doc, "dev-dependencies", "sibling-a");
        assert!(a.get("version").is_none(), "sibling-a version dropped");
        assert_eq!(a.get("path").and_then(|p| p.as_str()), Some("../sibling-a"));
        let b = doc["target"]["cfg(unix)"]["dev-dependencies"]["sibling-b"].clone();
        assert!(b.get("version").is_none(), "sibling-b version dropped");
        assert_eq!(b.get("path").and_then(|p| p.as_str()), Some("../sibling-b"));
        // Untouched: the path-only dev-dep, and the NORMAL versioned path dep.
        let ok = dev(&doc, "dev-dependencies", "sibling-ok");
        assert_eq!(
            ok.get("path").and_then(|p| p.as_str()),
            Some("../sibling-ok")
        );
        let serde = dev(&doc, "dependencies", "serde");
        assert_eq!(
            serde.get("version").and_then(|v| v.as_str()),
            Some("1"),
            "normal dep kept"
        );

        // Idempotent: a second scan finds nothing.
        assert!(
            scan_versioned_path_dev_deps(&repos).is_empty(),
            "healed → nothing left"
        );
    }
}

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

    fn write(path: &Path, body: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, body).unwrap();
    }

    /// `shop` and `web` on disk as the doctor reads them (web depends on shop by
    /// manifest). `shop` is a git repo whose files are UNTRACKED, which
    /// `worktree_freshness` reports as dirty — the "pending bump" state.
    fn shop_and_web(root: &Path) -> Vec<(String, PathBuf)> {
        let (shop, web) = (root.join("shop"), root.join("web"));
        write(&shop.join("Cargo.toml"), "[package]\nname = \"shop\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
        write(&shop.join("src/lib.rs"), "pub fn checkout() {}\n");
        write(
            &web.join("Cargo.toml"),
            "[package]\nname = \"web\"\nversion = \"0.2.0\"\nedition = \"2021\"\n\n[dependencies]\nshop = { path = \"../shop\", version = \"0.1.0\" }\n",
        );
        write(&web.join("src/lib.rs"), "pub fn render() { shop::checkout() }\n");
        crate::gitio::init(&shop).unwrap();
        vec![("shop".to_string(), shop), ("web".to_string(), web)]
    }

    fn refusal(err: &anyhow::Error) -> &BlastRefused {
        err.downcast_ref::<BlastRefused>().unwrap_or_else(|| panic!("not typed: {err:#}"))
    }

    /// No code room in reach: the pending bump FAILS CLOSED, typed — the link
    /// gate is never skipped on missing data. (Every build: no engine needed.)
    #[test]
    fn a_pending_bump_fails_closed_without_a_code_room() {
        use crate::code_room::reader::TreeDriver;
        use crate::code_room::{Reach, Refusal, State};
        let dir = tempfile::tempdir().unwrap();
        let repos = shop_and_web(dir.path());
        let driver = TreeDriver::Unavailable(Refusal::new(State::DriverNotAvailable, "no driver"));
        let err = run_with_warehouse_semantic_blast(&repos, &DepPolicy::default(), &Reach::none(), &driver).expect_err("must fail closed");
        let r = refusal(&err);
        assert_eq!(r.repo, "shop");
        let expected = if crate::code_room::CodeRoom::linked() { State::NoRoom } else { State::NotLinked };
        assert!(matches!(&r.why, BlastRefusal::Room(x) if x.state == expected), "{r}");
        let msg = format!("{err:#}");
        assert!(msg.contains("`shop`") && msg.contains("fail closed"), "{msg}");
    }

    /// A clean tree has nothing to diff: plain doctor, no dump, no refusal —
    /// whatever the door reaches.
    #[test]
    fn a_clean_tree_asks_nothing_of_the_code_room() {
        use crate::code_room::reader::TreeDriver;
        use crate::code_room::{Reach, Refusal, State};
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("shop");
        write(&root.join("Cargo.toml"), "[package]\nname = \"shop\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
        write(&root.join("src/lib.rs"), "pub fn checkout() {}\n");
        crate::gitio::init(&root).unwrap();
        crate::gitio::commit_all(&root, "seed").unwrap();
        let driver = TreeDriver::Unavailable(Refusal::new(State::DriverNotAvailable, "no driver"));
        let (report, audit) = run_with_warehouse_semantic_blast(&[("shop".to_string(), root)], &DepPolicy::default(), &Reach::none(), &driver).unwrap();
        assert!(report.semantic_blast.is_empty() && audit.dirty_considered.is_empty() && !audit.populated);
        assert!(audit.note.contains("no dirty repos"), "{}", audit.note);
    }

    /// The wired road, on the `shop` room (web calls `shop::checkout`): the
    /// dirty tree's dry-run delta → who names what it changes → the gate's input.
    #[cfg(feature = "code-room")]
    mod over_the_room {
        use super::*;
        use crate::code_room::reader::TreeDriver;
        use crate::code_room::reader::fixture::{self, shop::*};
        use crate::code_room::{Reach, Refusal, State};
        use crate::release::gate::semantic_link_gate;
        use mimir_schema::NodeRow;
        use nornir_release_core::semantic_blast::ChangeKind;

        fn tree(edit: impl Fn(&mut NodeRow) -> bool + Send + Sync + 'static) -> TreeDriver {
            TreeDriver::Given(Box::new(move |_, _| Ok(shop_tree("0.1.0", &edit))))
        }

        fn doctor(driver: &TreeDriver) -> Result<(DoctorReport, SemanticBlastAudit)> {
            let dir = tempfile::tempdir().unwrap();
            let repos = shop_and_web(dir.path());
            run_with_warehouse_semantic_blast(&repos, &DepPolicy::default(), &Reach::room_only(&fixture::shop().handle), driver)
        }

        #[test]
        fn a_changed_public_fn_reaches_the_gate_with_its_dependents() {
            let (report, audit) = doctor(&tree(|n| {
                if n.id == m(CHECKOUT) {
                    n.ty = Some("fn(Cart) -> Receipt".into());
                }
                true
            }))
            .unwrap();
            let blast = report.semantic_blast.get("shop").expect("shop's blast is in the report");
            assert_eq!((blast.source.as_str(), blast.target_crates.clone(), blast.affected_crates.clone()), ("code_room", vec!["shop".to_string()], vec!["web".to_string()]));
            assert_eq!(blast.impacts.len(), 1);
            let impact = &blast.impacts[0];
            assert_eq!((impact.symbol.as_str(), impact.change), ("shop::checkout", ChangeKind::Changed));
            let site = &impact.call_sites[0];
            assert_eq!(
                (site.crate_name.as_str(), site.caller.as_str(), site.callee.as_str(), site.call_kind.as_str(), site.file.as_str(), site.line),
                ("web", "web::render", "shop::checkout", "CALLS", "src/lib.rs", 5)
            );
            assert_eq!((blast.total_call_sites, audit.populated, audit.changed_symbols.get("shop")), (1, true, Some(&1)));
            assert_eq!(audit.dirty_considered, vec!["shop".to_string()]);
            assert!(audit.note.contains("1 site(s) in other repos") && audit.note.contains("[web]") && audit.note.contains("lower bound"), "{}", audit.note);
            // still resolves: a warning, the gate passes
            semantic_link_gate(&report.semantic_blast).unwrap();
        }

        #[test]
        fn a_removed_fn_another_repo_calls_is_a_link_error() {
            let gone = m(CHECKOUT);
            let (report, _) = doctor(&tree(move |n| n.id != gone)).unwrap();
            assert!(report.semantic_blast["shop"].has_link_error());
            let err = semantic_link_gate(&report.semantic_blast).expect_err("the gate must fail");
            let msg = format!("{err:#}");
            assert!(msg.contains("shop::shop::checkout was removed") && msg.contains("web::web::render — src/lib.rs:5"), "{msg}");
        }

        #[test]
        fn a_dirty_tree_that_changes_no_node_is_an_empty_blast_not_a_refusal() {
            let (report, audit) = doctor(&tree(|_| true)).unwrap();
            assert!(report.semantic_blast.is_empty() && !audit.populated, "{:?}", report.semantic_blast);
            assert_eq!((audit.dirty_considered.clone(), audit.changed_symbols.get("shop")), (vec!["shop".to_string()], Some(&0)));
        }

        #[test]
        fn every_state_without_a_blast_is_refused_typed() {
            let same = || tree(|_| true);
            let state = |r: Result<(DoctorReport, SemanticBlastAudit)>| match &refusal(&r.expect_err("must fail closed")).why {
                BlastRefusal::Room(x) => x.state,
                BlastRefusal::FailedUnits(u) => panic!("failed units {u:?}"),
            };
            // the driver is not available
            let none = TreeDriver::Unavailable(Refusal::new(State::DriverNotAvailable, "the Mímir driver is not installed"));
            assert_eq!(state(doctor(&none)), State::DriverNotAvailable);
            // nothing of the dirty repo was consumed
            let dir = tempfile::tempdir().unwrap();
            let repos = shop_and_web(dir.path());
            let fresh = Reach::room_only(&fixture::unconsumed().handle);
            assert_eq!(state(run_with_warehouse_semantic_blast(&repos, &DepPolicy::default(), &fresh, &same())), State::NotConsumed);
            // a repo that depends on it is not in the room: its sites would be missing
            let api = dir.path().join("api");
            write(&api.join("Cargo.toml"), "[package]\nname = \"api\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nshop = { path = \"../shop\", version = \"0.1.0\" }\n");
            write(&api.join("src/lib.rs"), "pub fn serve() { shop::checkout() }\n");
            let mut three = repos.clone();
            three.push(("api".to_string(), api));
            let err = run_with_warehouse_semantic_blast(&three, &DepPolicy::default(), &Reach::room_only(&fixture::shop().handle), &same()).unwrap_err();
            assert!(matches!(&refusal(&err).why, BlastRefusal::Room(x) if x.state == State::NotConsumed && x.note.contains("api")), "{err:#}");
            // units of the dirty tree did not compile
            let broken = TreeDriver::Given(Box::new(|_, _| {
                let mut d = shop_tree("0.1.0", |_| true);
                d.failed_units.insert("shop:lib".into());
                Ok(d)
            }));
            let err = doctor(&broken).unwrap_err();
            assert_eq!(refusal(&err).why, BlastRefusal::FailedUnits(vec!["shop:lib".to_string()]));
            assert!(format!("{err:#}").contains("does not compile") && format!("{err:#}").contains("fail closed"));
        }
    }

    // ─── #18 AUTO remedy executors ───

    #[test]
    fn tidy_dirty_sheds_noise_but_keeps_real_changes() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("myrepo");
        write(
            &root.join("Cargo.toml"),
            "[package]\nname=\"m\"\nversion=\"0.1.0\"\n",
        );
        write(&root.join("src/lib.rs"), "pub fn a() {}\n");
        crate::gitio::init(&root).unwrap();
        crate::gitio::commit_all(&root, "seed").unwrap();
        assert!(
            !crate::gitio::worktree_freshness(&root).unwrap().dirty,
            "clean baseline"
        );

        // Case 1: dirtiness is PURE noise → tidy removes it → repo goes clean.
        write(&root.join(".claude/scratch.txt"), "junk\n");
        write(&root.join("data.arrows"), "\x00arrow\n");
        write(&root.join("src/deep/nested.arrows"), "\x00\n");
        assert!(crate::gitio::worktree_freshness(&root).unwrap().dirty);
        let repos = vec![("myrepo".to_string(), root.clone())];
        let dirty = check_dirty(&repos);
        let out = tidy_dirty_repos(&dirty, &repos).unwrap();
        assert_eq!(out.removed, 3, "two .arrows files + one .claude dir");
        assert_eq!(out.cleaned, vec!["myrepo".to_string()]);
        assert!(out.still_dirty.is_empty());
        assert!(!root.join(".claude").exists());
        assert!(!root.join("data.arrows").exists());
        assert!(
            !crate::gitio::worktree_freshness(&root).unwrap().dirty,
            "clean after tidy"
        );

        // Case 2: a REAL untracked file → tidy can't fix it → still dirty.
        write(&root.join("src/new_real.rs"), "pub fn b() {}\n");
        write(&root.join("more.arrows"), "\x00\n");
        let dirty2 = check_dirty(&repos);
        let out2 = tidy_dirty_repos(&dirty2, &repos).unwrap();
        assert_eq!(out2.removed, 1, "only the .arrows noise is shed");
        assert!(out2.cleaned.is_empty());
        assert_eq!(
            out2.still_dirty,
            vec!["myrepo".to_string()],
            "real change remains"
        );
        assert!(root.join("src/new_real.rs").exists(), "real file untouched");
    }

    #[test]
    fn cut_cycle_cheap_drops_the_optional_edge_and_scrubs_features() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // acrate → bcrate (normal path); bcrate → acrate (OPTIONAL path, gated).
        // The MFAS cheapest cut (cost 1) is the optional edge bcrate→acrate.
        write(
            &root.join("repoA/Cargo.toml"),
            r#"[package]
name = "acrate"
version = "0.1.0"
edition = "2021"

[dependencies]
bcrate = { path = "../repoB" }
"#,
        );
        write(
            &root.join("repoB/Cargo.toml"),
            r#"[package]
name = "bcrate"
version = "0.1.0"
edition = "2021"

[dependencies]
acrate = { path = "../repoA", optional = true }

[features]
withA = ["dep:acrate"]
"#,
        );
        let repos = vec![
            ("repoA".to_string(), root.join("repoA")),
            ("repoB".to_string(), root.join("repoB")),
        ];
        // Precondition: the crate graph really has the cheap cycle.
        let graphs: Vec<RepoGraph> = repos
            .iter()
            .map(|(n, p)| gather_repo_graph(n, p).unwrap())
            .collect();
        let sols = cycle_solutions(&graphs);
        assert_eq!(sols.len(), 1, "one crate cycle: {sols:#?}");
        assert!(
            sols[0].solutions.first().unwrap().cost <= 1,
            "cheap (optional) cut"
        );

        let out = apply_cheap_cycle_cuts(&repos).unwrap();
        assert_eq!(out.cut, vec![("bcrate".to_string(), "acrate".to_string())]);
        assert!(out.unresolved.is_empty(), "{:#?}", out.unresolved);

        // repoB no longer declares acrate, and the feature no longer references it.
        let b = std::fs::read_to_string(root.join("repoB/Cargo.toml")).unwrap();
        assert!(!b.contains("acrate = {"), "optional dep removed:\n{b}");
        assert!(!b.contains("dep:acrate"), "feature ref scrubbed:\n{b}");
        // And the cycle is gone.
        let graphs2: Vec<RepoGraph> = repos
            .iter()
            .map(|(n, p)| gather_repo_graph(n, p).unwrap())
            .collect();
        assert!(cycle_solutions(&graphs2).is_empty(), "cycle broken");
    }
}

#[cfg(test)]
mod binary_graph_cross_check_tests {
    use super::*;
    use crate::warehouse::dep_graph::{CrossRepoEdge, WorkspaceGraph};
    use std::collections::BTreeSet;

    fn adj(pairs: &[(&str, &str)]) -> BTreeMap<String, BTreeSet<String>> {
        let mut m: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
        for (a, b) in pairs {
            m.entry(a.to_string()).or_default().insert(b.to_string());
            m.entry(b.to_string()).or_default();
        }
        m
    }

    /// The ELF-derived graph, built through the SAME projection production uses
    /// (`BinaryGraph::to_workspace_graph` → `BinaryDerivedGraph::from_workspace`),
    /// matches the source graph's cycle structure AND its MFAS min-cut cost when
    /// the two constellations carry the same link cycle.
    #[test]
    fn binary_derived_scc_and_mfas_match_source() {
        // Source crate graph: a ⇄ b cycle, plus a→c tail (c is a clean leaf).
        let source = adj(&[("a", "b"), ("b", "a"), ("a", "c")]);

        // ELF constellation with the identical topology, projected via the shared
        // WorkspaceGraph model (what to_workspace_graph yields for real objects).
        let bin_edges = vec![
            CrossRepoEdge::normal("a", "b", ["s_ab".to_string()].into_iter().collect()),
            CrossRepoEdge::normal("b", "a", ["s_ba".to_string()].into_iter().collect()),
            CrossRepoEdge::normal("a", "c", ["s_ac".to_string()].into_iter().collect()),
        ];
        let wg = WorkspaceGraph::from_query_parts(Default::default(), bin_edges);
        let derived = BinaryDerivedGraph::from_workspace(&wg);

        // The binary projection found exactly the one a⇄b cycle.
        assert_eq!(derived.cycles, vec![vec!["a".to_string(), "b".to_string()]]);

        // Cross-check: SCC partition + MFAS cut cost agree.
        let cc = cross_check_source_vs_binary(&source, &derived.adjacency);
        assert!(cc.agrees(), "source and ELF graphs agree: {cc:?}");
        assert_eq!(
            cc.shared_cycles,
            vec![vec!["a".to_string(), "b".to_string()]]
        );
        assert!(cc.source_only_cycles.is_empty());
        assert!(cc.binary_only_cycles.is_empty());
        assert!(cc.mfas_divergences.is_empty());
    }

    /// A DIVERGENCE — the binaries link a cycle the source manifests do not
    /// declare (or vice-versa) — is flagged, not silently passed. This is the
    /// doctor signal that source and shipped artefacts have drifted.
    #[test]
    fn binary_derived_divergence_from_source_is_flagged() {
        // Source is a clean DAG a→b→c (no cycle).
        let source = adj(&[("a", "b"), ("b", "c")]);
        // The linked binaries, though, carry a b⇄c back-edge (a real link cycle).
        let bin_edges = vec![
            CrossRepoEdge::normal("a", "b", ["s".to_string()].into_iter().collect()),
            CrossRepoEdge::normal("b", "c", ["s".to_string()].into_iter().collect()),
            CrossRepoEdge::normal("c", "b", ["s".to_string()].into_iter().collect()),
        ];
        let wg = WorkspaceGraph::from_query_parts(Default::default(), bin_edges);
        let derived = BinaryDerivedGraph::from_workspace(&wg);

        let cc = cross_check_source_vs_binary(&source, &derived.adjacency);
        assert!(
            !cc.agrees(),
            "a binary-only cycle must NOT pass the cross-check"
        );
        assert_eq!(
            cc.binary_only_cycles,
            vec![vec!["b".to_string(), "c".to_string()]]
        );
        assert!(cc.source_only_cycles.is_empty());
        assert!(cc.shared_cycles.is_empty());
    }

    /// `graph_from_binary` drives the real ELF extractor end-to-end: point it at
    /// the test binary's own executable (a genuine ELF on this Linux target) and
    /// assert it yields a non-empty symbol graph with the object as a node. Guards
    /// the extraction → projection wiring, not just the pure cross-check math.
    #[test]
    fn graph_from_binary_reads_a_real_elf() {
        let exe = std::env::current_exe().expect("test exe path");
        let derived = graph_from_binary(&[exe]).expect("extract ELF graph from the test binary");
        assert!(
            !derived.adjacency.is_empty(),
            "the ELF object is a graph node"
        );
        assert!(
            !derived.symbol_rows.is_empty(),
            "a dynamically-linked ELF exposes .dynsym symbol rows"
        );
    }
}