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