use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
pub use dwarves::release_doctor::model::{
compute_promote_block, gather_crate_deps, held_fork_reasons, patch_fork_blockers,
promote_blocked_crates_precise, CrateSkew, ForkKind, HeldExplanation, HeldFork, Issue,
IssueKind, PatchForkBlock, PromoteBlockResult, RepoCrateStatus, RepoGraph, SkewStatus,
};
pub use dwarves::release_doctor::doctor::{
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,
CfgCrossDep, CutSolutionView, CycleAdvice, CycleSolution, DepCycle, DepKind, DepPolicy,
DevEdge, DoctorReport, ForbiddenDep, OptionalCrossDep, OrderViolation, PathDepVersionGap,
RepoDirty, RepoEdge, RepoExternals, SemanticBlastAudit, TopoReport,
};
pub use dwarves::release_doctor::doctor::check_dirty;
#[derive(Debug, Default)]
pub struct FixOutcome {
pub fixed: usize,
pub skipped: Vec<String>,
}
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();
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;
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;
};
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)
}
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()),
}
}
fn resolve_crate_version(dep_manifest: &Path) -> Option<String> {
let text = std::fs::read_to_string(dep_manifest).ok()?;
let doc = text.parse::<toml::Value>().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,
}
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::Value>() {
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
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionedPathDevDep {
pub repo: String,
pub manifest: PathBuf,
pub dep: String,
}
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::Value>() 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 {
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
}
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();
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 };
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)
}
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()),
}
}
#[derive(Debug, Default)]
pub struct TidyOutcome {
pub removed: usize,
pub cleaned: Vec<String>,
pub still_dirty: Vec<String>,
}
fn is_tidy_noise_dir(name: &str) -> bool {
name == ".claude"
}
fn is_tidy_noise_file(name: &str) -> bool {
name.ends_with(".arrows")
}
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)
}
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)
}
#[derive(Debug, Default)]
pub struct CutOutcome {
pub cut: Vec<(String, String)>,
pub unresolved: Vec<(String, String)>,
}
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)
}
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
}
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()))?;
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);
}
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)
}
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
});
}
}
}
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; }
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)
}
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
}
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)
}
#[derive(Debug, Clone, Default)]
pub struct BinaryDerivedGraph {
pub adjacency: BTreeMap<String, std::collections::BTreeSet<String>>,
pub cycles: Vec<Vec<String>>,
pub symbol_rows: Vec<crate::knowledge::symbols::SymbolRow>,
pub call_edge_rows: Vec<crate::knowledge::symbols::CallEdgeRow>,
}
impl BinaryDerivedGraph {
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 = super::graph_math::cycles(&adjacency);
Self { adjacency, cycles, symbol_rows: Vec::new(), call_edge_rows: Vec::new() }
}
}
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)
}
#[derive(Debug, Clone, Serialize)]
pub struct GraphCrossCheck {
pub shared_cycles: Vec<Vec<String>>,
pub source_only_cycles: Vec<Vec<String>>,
pub binary_only_cycles: Vec<Vec<String>>,
pub mfas_divergences: Vec<(Vec<String>, u32, u32)>,
}
impl GraphCrossCheck {
pub fn agrees(&self) -> bool {
self.source_only_cycles.is_empty()
&& self.binary_only_cycles.is_empty()
&& self.mfas_divergences.is_empty()
}
}
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>> = super::graph_math::cycles(source).into_iter().collect();
let bin_set: BTreeSet<Vec<String>> = super::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 }
}
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<super::graph_math::ClassifiedEdge> = comp
.iter()
.flat_map(|from| {
adj.get(from)
.into_iter()
.flatten()
.filter(|to| members.contains(to))
.map(move |to| super::graph_math::ClassifiedEdge {
from: from.clone(),
to: to.clone(),
via: Vec::new(),
class: super::graph_math::EdgeClass::Normal,
})
})
.collect();
super::graph_math::min_feedback_arc_set(comp, &edges)
.into_iter()
.map(|s| s.cost)
.min()
.unwrap_or(0)
}
pub fn run_with_warehouse_semantic_blast(
repos: &[(String, PathBuf)],
policy: &DepPolicy,
wh: &dyn crate::warehouse::Warehouse,
) -> Result<(DoctorReport, SemanticBlastAudit)> {
use crate::release::semantic_blast::{diff_exported_symbols, ChangedSymbol};
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();
if dirty.is_empty() {
audit.note =
"no dirty repos → no pending bump to diff; semantic blast empty (link gate skipped)"
.to_string();
return Ok((run(repos, policy)?, audit));
}
let mut changed_by_repo: Vec<(String, Vec<ChangedSymbol>)> = Vec::new();
for (name, path) in repos {
if !dirty.iter().any(|d| d == name) {
continue;
}
let old = match crate::knowledge::query::load_latest(wh, name) {
Ok(view) => view.symbols,
Err(_) => Vec::new(),
};
if old.is_empty() {
continue;
}
let new = match crate::knowledge::symbols::scan_repo(
path,
name,
uuid::Uuid::new_v4(),
chrono::Utc::now(),
) {
Ok(scan) => scan.symbols,
Err(_) => continue,
};
let delta = diff_exported_symbols(&old, &new);
if !delta.is_empty() {
audit.changed_symbols.insert(name.clone(), delta.len());
changed_by_repo.push((name.clone(), delta));
}
}
if changed_by_repo.is_empty() {
audit.note = format!(
"{} dirty repo(s), but no exported-surface delta \
(unchanged API or no persisted warehouse surface) → semantic blast empty (link gate skipped)",
dirty.len()
);
return Ok((run(repos, policy)?, audit));
}
let refs: Vec<(&str, &[ChangedSymbol])> = changed_by_repo
.iter()
.map(|(r, cs)| (r.as_str(), cs.as_slice()))
.collect();
let report = run_with_semantic_blast(repos, policy, wh, refs)?;
audit.populated = !report.semantic_blast.is_empty();
audit.note = format!(
"semantic blast: diffed {} dirty repo(s), {} with a surface delta; \
{} carry a populated cross-crate blast (link gate LIVE)",
dirty.len(),
changed_by_repo.len(),
report.semantic_blast.len(),
);
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();
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();
let doc: toml::Value = after.parse().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"));
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");
assert!(scan_versioned_path_dev_deps(&repos).is_empty(), "healed → nothing left");
}
}
#[cfg(test)]
mod semantic_blast_run_tests {
use super::*;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow, SymbolScan};
use crate::warehouse::iceberg::IcebergWarehouse;
fn write(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
fn seed_korp_calls_facett(wh: &IcebergWarehouse) {
let scan = SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: "korp".into(),
symbols: vec![SymbolRow {
crate_name: "korp".into(),
module_path: "korp::view".into(),
item_kind: "fn".into(),
item_name: "draw".into(),
visibility: "pub".into(),
file: "korp/src/view.rs".into(),
line: 1,
doc_lines: 0,
signature: None,
}],
calls: vec![CallEdgeRow {
crate_name: "korp".into(),
caller_path: "korp::view::draw".into(),
callee_ident: "facett::render".into(),
call_kind: "call".into(),
file: "korp/src/view.rs".into(),
line: 40,
}],
features: vec![],
tests: vec![],
};
wh.append_symbol_scan(&scan).unwrap();
}
fn git_init(root: &Path) {
std::fs::create_dir_all(root).unwrap();
let ok = std::process::Command::new("git")
.arg("-C").arg(root).arg("init")
.status().map(|s| s.success()).unwrap_or(false);
assert!(ok, "git init failed (is git installed?)");
}
fn seed_symbols(wh: &IcebergWarehouse, repo: &str, syms: &[(&str, &str)]) {
let symbols = syms
.iter()
.map(|(name, vis)| SymbolRow {
crate_name: repo.into(),
module_path: format!("{repo}::api"),
item_kind: "fn".into(),
item_name: (*name).into(),
visibility: (*vis).into(),
file: format!("{repo}/src/lib.rs"),
line: 1,
doc_lines: 0,
signature: None,
})
.collect();
let scan = SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: repo.into(),
symbols,
calls: vec![],
features: vec![],
tests: vec![],
};
wh.append_symbol_scan(&scan).unwrap();
}
#[test]
fn wired_path_populates_blast_and_link_gate_fires() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let facett = root.join("facett");
let korp = root.join("korp");
write(&facett.join("Cargo.toml"),
"[package]\nname = \"facett\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write(&facett.join("src/lib.rs"), "pub fn open() {}\n");
git_init(&facett);
write(&korp.join("Cargo.toml"),
"[package]\nname = \"korp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\nfacett = { path = \"../facett\", version = \"0.1.0\" }\n");
write(&korp.join("src/lib.rs"), "pub fn draw() {}\n");
let wh = IcebergWarehouse::open(&root.join("wh")).unwrap();
seed_symbols(&wh, "facett", &[("render", "pub"), ("open", "pub")]);
seed_korp_calls_facett(&wh);
let repos = vec![
("facett".to_string(), facett.clone()),
("korp".to_string(), korp.clone()),
];
let policy = DepPolicy::default();
let (report, audit) =
run_with_warehouse_semantic_blast(&repos, &policy, &wh).unwrap();
assert!(
audit.dirty_considered.contains(&"facett".to_string()),
"facett must be considered dirty: {:?}", audit.dirty_considered,
);
assert_eq!(
audit.changed_symbols.get("facett"), Some(&1),
"the removed `render` is the sole surface delta: {:?}", audit.changed_symbols,
);
assert!(audit.populated, "semantic blast must be populated: {}", audit.note);
let sb = report.semantic_blast.get("facett")
.expect("facett carries a populated semantic blast");
assert!(sb.has_link_error(), "removed `render` + a live korp call site = link error");
let gate = crate::release::gate::semantic_link_gate(&report.semantic_blast);
let fired = gate.is_err();
let msg = gate.err().map(|e| e.to_string()).unwrap_or_default();
nornir_testmatrix::functional_status(
"release-doctor",
"wired_semantic_link_gate_fires",
fired && audit.populated && msg.contains("render"),
&format!("note={:?} gate_msg={msg:?}", audit.note),
);
assert!(fired, "semantic_link_gate must FIRE on the wired blast (was inert): {}", audit.note);
assert!(msg.contains("LINK ERROR") && msg.contains("render"),
"gate names the removed symbol: {msg}");
}
#[test]
fn wired_path_degrades_to_empty_when_no_warehouse_surface() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let facett = root.join("facett");
write(&facett.join("Cargo.toml"),
"[package]\nname = \"facett\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write(&facett.join("src/lib.rs"), "pub fn open() {}\n");
git_init(&facett);
let wh = IcebergWarehouse::open(&root.join("wh")).unwrap();
let repos = vec![("facett".to_string(), facett.clone())];
let policy = DepPolicy::default();
let (report, audit) =
run_with_warehouse_semantic_blast(&repos, &policy, &wh).unwrap();
assert!(report.semantic_blast.is_empty(), "no OLD surface ⇒ empty blast");
assert!(!audit.populated, "degraded, not populated: {}", audit.note);
assert!(crate::release::gate::semantic_link_gate(&report.semantic_blast).is_ok());
}
#[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");
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");
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();
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")),
];
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);
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}");
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
}
#[test]
fn binary_derived_scc_and_mfas_match_source() {
let source = adj(&[("a", "b"), ("b", "a"), ("a", "c")]);
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);
assert_eq!(derived.cycles, vec![vec!["a".to_string(), "b".to_string()]]);
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());
}
#[test]
fn binary_derived_divergence_from_source_is_flagged() {
let source = adj(&[("a", "b"), ("b", "c")]);
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());
}
#[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"
);
}
}