use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use callisto_model::{
BumpReason, ConfigKey, Coverage, DepEdge, DepKind, DepSpec, Diagnostic, DiagnosticCode, DiagnosticSeverity,
Ecosystem, GrammarMismatch, PackageId, Severity, Version,
};
use crate::config::GroupTable;
use crate::config::{CascadeConfig, CascadeMode};
use crate::error::GraphError;
use crate::identity::IdentityIndex;
use crate::resolver::DependencyResolver;
use crate::tags::TagIndex;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CascadeDecision {
pub severity: Severity,
pub rewrite: bool,
pub governed_by: Option<ConfigKey>,
pub escalated: bool,
pub unknown_coverage: bool,
}
pub fn cascade_action(kind: DepKind, coverage: Coverage, source: Severity, cfg: &CascadeConfig) -> CascadeDecision {
use Coverage::*;
use DepKind::*;
let effective = match (cfg.mode, coverage) {
(CascadeMode::Always, _) => DoesNotCover,
(CascadeMode::OutOfRange, Covers) => Covers,
(CascadeMode::OutOfRange, DoesNotCover) => DoesNotCover,
(CascadeMode::OutOfRange, Unknown) => Covers,
};
let rewrite = matches!(coverage, DoesNotCover);
let (severity, governed_by, escalated) = match (kind, effective) {
(Runtime | Optional | Build, Covers) => (Severity::None, None, false),
(Runtime | Optional | Build, DoesNotCover) => (
cfg.bump_severity.as_severity(),
Some(ConfigKey::CASCADE_BUMP_SEVERITY),
false,
),
(Peer, Covers) => (Severity::None, None, false),
(Peer, DoesNotCover)
if cfg.peer_escalation
&& matches!(coverage, DoesNotCover)
&& matches!(source, Severity::Minor | Severity::Major) =>
{
(Severity::Major, Some(ConfigKey::CASCADE_PEER_ESCALATION), true)
}
(Peer, DoesNotCover) => (
cfg.bump_severity.as_severity(),
Some(ConfigKey::CASCADE_BUMP_SEVERITY),
false,
),
(Dev, _) => (Severity::None, None, false),
_ => (Severity::None, None, false),
};
let governed_by = match (cfg.mode, coverage, severity) {
(CascadeMode::Always, Covers | Unknown, s) if s != Severity::None => Some(ConfigKey::CASCADE_MODE),
_ => governed_by,
};
CascadeDecision {
severity,
rewrite,
governed_by,
escalated,
unknown_coverage: matches!(coverage, Unknown),
}
}
pub fn coverage(spec: &DepSpec, new: &Version) -> Result<Coverage, GrammarMismatch> {
match spec {
DepSpec::Exact(v) => {
if v == new {
Ok(Coverage::Covers)
} else {
Ok(Coverage::DoesNotCover)
}
}
DepSpec::CargoBare(v) => {
if caret_covers(v, new)? {
Ok(Coverage::Covers)
} else {
Ok(Coverage::DoesNotCover)
}
}
DepSpec::Range(req, _) => {
if req.matches(new)? {
Ok(Coverage::Covers)
} else {
Ok(Coverage::DoesNotCover)
}
}
DepSpec::Workspace(_) => Ok(Coverage::Covers),
DepSpec::Catalog(_) | DepSpec::Opaque(_) => Ok(Coverage::Unknown),
}
}
pub(crate) fn caret_covers(cur: &Version, new: &Version) -> Result<bool, GrammarMismatch> {
let cmp = Version::compare(new, cur)?;
if cmp.is_lt() {
return Ok(false);
}
if new.is_prerelease() {
return Ok(false);
}
let cur_maj = cur.major().unwrap_or(0);
let cur_min = cur.minor().unwrap_or(0);
let new_maj = new.major().unwrap_or(0);
let new_min = new.minor().unwrap_or(0);
if cur_maj > 0 {
Ok(new_maj == cur_maj)
} else if cur_min > 0 {
Ok(new_maj == 0 && new_min == cur_min)
} else {
Ok(new == cur)
}
}
pub struct CascadeInput<'a, D: DependencyResolver> {
pub graph: &'a D,
pub groups: &'a GroupTable,
pub cfg: &'a CascadeConfig,
pub seed: &'a BTreeMap<PackageId, Severity>,
pub reasons: &'a BTreeMap<PackageId, BumpReason>,
pub named_by: &'a BTreeMap<PackageId, crate::aggregate::NamedBy>,
pub base: &'a BTreeMap<PackageId, Version>,
pub pre: Option<&'a callisto_format::PreState>,
pub tags: &'a TagIndex,
pub identity: &'a IdentityIndex,
}
#[derive(Clone, Debug, Default)]
pub struct CascadeOutcome {
pub severities: BTreeMap<PackageId, Severity>,
pub targets: BTreeMap<PackageId, Version>,
pub reasons: BTreeMap<PackageId, BumpReason>,
pub governed_by: BTreeMap<PackageId, ConfigKey>,
pub rewrites: BTreeMap<RewriteKey, SpecRewrite>,
pub diagnostics: Vec<Diagnostic>,
pub iterations: usize,
}
pub trait CascadeSolver<D: DependencyResolver> {
fn solve_cascade(&self, input: CascadeInput<'_, D>) -> Result<CascadeOutcome, GraphError>;
}
pub fn run_cascade<D: DependencyResolver>(input: CascadeInput<'_, D>) -> Result<CascadeOutcome, GraphError> {
solve_cascade(input)
}
pub fn solve_cascade<D: DependencyResolver>(input: CascadeInput<'_, D>) -> Result<CascadeOutcome, GraphError> {
let mut out = CascadeOutcome {
severities: input.seed.clone(),
reasons: input.reasons.clone(),
..Default::default()
};
for (id, &sev) in input.seed {
let t = bump_target(id, sev, &input)?;
out.targets.insert(id.clone(), t);
}
let mut worklist: BTreeSet<PackageId> = out.targets.keys().cloned().collect();
let mut iterations = 0;
let bound = convergence_bound(input.graph.packages().count());
let mut changed = true;
while changed {
changed = false;
while let Some(pkg) = worklist.pop_first() {
iterations += 1;
if iterations > bound {
return Err(GraphError::CascadeNotConverged { iterations });
}
let new_version = out.targets[&pkg].clone();
let src_sev = out.severities[&pkg];
let dependents: Vec<DepEdge> = input.graph.dependents_of(&pkg).cloned().collect();
for edge in dependents {
let cov = coverage(&edge.spec, &new_version).map_err(|source| GraphError::GrammarMismatch {
from: edge.from.clone(),
to: edge.to.clone(),
source,
})?;
let d = cascade_action(edge.kind, cov, src_sev, input.cfg);
if d.unknown_coverage && !matches!(edge.spec, DepSpec::Opaque(_)) {
let code = match edge.spec {
DepSpec::Catalog(_) => DiagnosticCode::CatalogSpecNotRewritten,
_ => DiagnosticCode::RangeNotRoundTrippable,
};
out.diagnostics.push(Diagnostic {
code,
severity: DiagnosticSeverity::Warning,
message: format!(
"spec `{}` for `{}` could not be tested for coverage",
edge.spec.render(),
edge.to.display_name()
),
package: Some(edge.from.clone()),
path: Some(edge.from_manifest.clone()),
governed_by: Some(ConfigKey::CASCADE_PRESERVE_NPM_RANGES),
escalated_by: None,
});
}
if d.rewrite {
let eco = edge.from.ecosystem().unwrap_or_else(|| {
if edge.from_manifest.to_string_lossy().ends_with("Cargo.toml") {
Ecosystem::Cargo
} else {
Ecosystem::Npm
}
});
match rewrite_spec(&edge.spec, &new_version, eco, input.cfg) {
RewriteOutcome::Rewritten(to_spec) => {
let key = RewriteKey {
target: if edge.inherited {
DepWriteTarget::CargoWorkspaceDependency {
root_manifest: edge.from_manifest.clone(),
}
} else {
DepWriteTarget::Manifest(edge.from_manifest.clone())
},
name: input
.identity
.native_name(&edge.to, eco)
.map(str::to_string)
.unwrap_or_else(|| edge.to.name().to_string()),
kind: if edge.inherited { None } else { Some(edge.kind) },
};
out.rewrites.insert(
key.clone(),
SpecRewrite {
key,
dependency: edge.to.clone(),
from: edge.spec.clone(),
to: to_spec,
},
);
}
RewriteOutcome::LeftAlone(dg) => {
out.diagnostics.push(dg);
}
}
}
let cur_sev = out.severities.get(&edge.from).copied().unwrap_or(Severity::None);
if d.severity > cur_sev {
raise(
&edge.from,
d.severity,
&d,
&pkg,
&edge,
&new_version,
&mut out,
input.groups,
&mut worklist,
&input,
)?;
}
}
}
for g in input.groups.linked.values() {
let member_ids: Vec<PackageId> = g
.members(crate::config::GroupMemberKind::Package)
.filter_map(|m| match m {
crate::config::GroupMember::Package(ref id) => Some(id.clone()),
_ => None,
})
.collect();
let mut max_sev = Severity::None;
for id in &member_ids {
if let Some(&sev) = out.severities.get(id) {
max_sev = max_sev.max(sev);
}
}
if max_sev > Severity::None {
for id in &member_ids {
let cur_sev = out.severities.get(id).copied().unwrap_or(Severity::None);
if max_sev > cur_sev {
out.severities.insert(id.clone(), max_sev);
}
}
let mut winner: Option<Version> = None;
for id in &member_ids {
let candidate = bump_target(id, max_sev, &input)?;
winner = Some(match winner {
None => candidate,
Some(best) => {
let cmp = Version::compare(&candidate, &best).map_err(|_grammar_mismatch| {
GraphError::GroupGrammarMismatch {
group: g.name.clone(),
members: member_ids
.iter()
.filter_map(|m| out.targets.get(m).map(|v| (m.clone(), v.clone())))
.collect(),
}
})?;
if cmp.is_gt() {
candidate
} else {
best
}
}
});
}
let winner = winner.expect("linked group has at least one member");
for id in member_ids {
if out.targets.get(&id) != Some(&winner) {
out.targets.insert(id.clone(), winner.clone());
out.reasons
.insert(id.clone(), BumpReason::LinkedGroupUnion { group: g.name.clone() });
worklist.insert(id.clone());
changed = true;
}
}
}
}
for g in input.groups.fixed.values() {
let member_ids: Vec<PackageId> = g
.members(crate::config::GroupMemberKind::Package)
.filter_map(|m| match m {
crate::config::GroupMember::Package(ref id) => Some(id.clone()),
_ => None,
})
.collect();
let mut max_sev = Severity::None;
for id in &member_ids {
if let Some(&sev) = out.severities.get(id) {
max_sev = max_sev.max(sev);
}
}
if max_sev > Severity::None {
for id in &member_ids {
let cur_sev = out.severities.get(id).copied().unwrap_or(Severity::None);
if max_sev > cur_sev {
out.severities.insert(id.clone(), max_sev);
}
}
let winner = crate::groups::fixed_group_target(g, input.base, &out.severities, input.tags, input.pre)?;
for id in member_ids {
if out.targets.get(&id) != Some(&winner) {
out.targets.insert(id.clone(), winner.clone());
out.reasons
.insert(id.clone(), BumpReason::FixedGroupUnion { group: g.name.clone() });
worklist.insert(id.clone());
changed = true;
}
}
}
}
}
out.iterations = iterations;
Ok(out)
}
fn bump_target<D: DependencyResolver>(
id: &PackageId,
sev: Severity,
input: &CascadeInput<'_, D>,
) -> Result<Version, GraphError> {
let base = input.base.get(id).cloned().ok_or_else(|| {
GraphError::Manifest(callisto_model::ManifestError::MissingField {
path: PathBuf::from(id.name()),
field: "version",
})
})?;
let versioning: &dyn callisto_format::Versioning = match base.grammar() {
callisto_model::VersionGrammar::Pep440 => &callisto_format::Pep440Versioning,
_ => &callisto_format::SemVerVersioning,
};
if let Some(pre) = input.pre {
if pre.mode == callisto_format::PreMode::Pre {
let pinned_base = pre.initial_versions.get(id.name()).unwrap_or(&base);
versioning
.bump_prerelease(pinned_base, sev, &pre.tag, &base)
.map_err(GraphError::Bump)
} else {
versioning.bump(&base, sev).map_err(GraphError::Bump)
}
} else {
versioning.bump(&base, sev).map_err(GraphError::Bump)
}
}
#[allow(clippy::too_many_arguments)]
fn raise<D: DependencyResolver>(
pkg: &PackageId,
sev: Severity,
decision: &CascadeDecision,
via: &PackageId,
edge: &DepEdge,
dependency_to: &Version,
out: &mut CascadeOutcome,
groups: &GroupTable,
worklist: &mut BTreeSet<PackageId>,
input: &CascadeInput<'_, D>,
) -> Result<bool, GraphError> {
let cur_sev = out.severities.get(pkg).copied().unwrap_or(Severity::None);
if sev <= cur_sev {
return Ok(false);
}
out.severities.insert(pkg.clone(), sev);
let new_reason = if decision.escalated {
BumpReason::PeerEscalation {
via: via.clone(),
spec: edge.spec.render(),
}
} else {
BumpReason::Cascade {
via: via.clone(),
dep_kind: edge.kind,
spec: edge.spec.render(),
dependency_to: dependency_to.clone(),
}
};
out.reasons.insert(pkg.clone(), new_reason);
if let Some(ref gov) = decision.governed_by {
out.governed_by.insert(pkg.clone(), gov.clone());
}
let new_t = bump_target(pkg, sev, input)?;
out.targets.insert(pkg.clone(), new_t);
worklist.insert(pkg.clone());
for sib in groups.fixed_siblings(pkg) {
let sib_sev = out.severities.get(sib).copied().unwrap_or(Severity::None);
if sev > sib_sev {
out.severities.insert(sib.clone(), sev);
out.reasons.insert(
sib.clone(),
BumpReason::FixedGroupUnion {
group: groups.fixed_group_of(pkg).unwrap().name.clone(),
},
);
let sib_t = bump_target(sib, sev, input)?;
out.targets.insert(sib.clone(), sib_t);
worklist.insert(sib.clone());
}
}
Ok(true)
}
pub(crate) fn convergence_bound(package_count: usize) -> usize {
4 * package_count + 1
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum DepWriteTarget {
Manifest(PathBuf),
CargoWorkspaceDependency { root_manifest: PathBuf },
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RewriteKey {
pub target: DepWriteTarget,
pub name: String,
pub kind: Option<DepKind>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SpecRewrite {
pub key: RewriteKey,
pub dependency: PackageId,
pub from: DepSpec,
pub to: DepSpec,
}
pub enum RewriteOutcome {
Rewritten(DepSpec),
LeftAlone(Diagnostic),
}
pub fn rewrite_spec(original: &DepSpec, new: &Version, eco: Ecosystem, cfg: &CascadeConfig) -> RewriteOutcome {
if !cfg.preserve_npm_ranges && eco == Ecosystem::Npm {
return RewriteOutcome::Rewritten(DepSpec::Exact(new.clone()));
}
if let Some(rewritten) = callisto_manifests::round_trip(eco, original, new) {
RewriteOutcome::Rewritten(rewritten)
} else {
RewriteOutcome::LeftAlone(Diagnostic {
code: DiagnosticCode::RangeNotRoundTrippable,
severity: DiagnosticSeverity::Warning,
message: format!(
"spec `{}` could not be round-tripped toward version `{}`",
original.render(),
new.render()
),
package: None,
path: None,
governed_by: Some(ConfigKey::CASCADE_PRESERVE_NPM_RANGES),
escalated_by: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use callisto_model::{GroupKind, GroupName, Package, ReleaseTrigger, VersionGrammar};
use crate::config::{CascadeBumpSeverity, GroupDef, GroupMember};
#[test]
fn cascade_action_peer_escalation_only_fires_for_non_patch_source() {
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let patch_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::Patch, &cfg);
assert_eq!(
patch_decision.severity,
Severity::Patch,
"a patch-severity upstream source must not escalate a peer dependent to Major"
);
assert!(
!patch_decision.escalated,
"a patch-severity source must not set `escalated`"
);
assert_eq!(
patch_decision.governed_by,
Some(ConfigKey::CASCADE_BUMP_SEVERITY),
"a patch-severity source falls through to the ordinary bump_severity row"
);
let minor_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::Minor, &cfg);
assert_eq!(
minor_decision.severity,
Severity::Major,
"a non-patch (Minor) upstream source must escalate a peer dependent to Major"
);
assert!(minor_decision.escalated, "a non-patch source must set `escalated`");
assert_eq!(minor_decision.governed_by, Some(ConfigKey::CASCADE_PEER_ESCALATION));
let major_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::Major, &cfg);
assert_eq!(major_decision.severity, Severity::Major);
assert!(major_decision.escalated);
let none_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::None, &cfg);
assert_eq!(none_decision.severity, Severity::Patch);
assert!(!none_decision.escalated);
}
#[test]
fn caret_covers_excludes_prerelease_versions() {
let v123 = Version::semver(1, 2, 3);
let pre_same_major = Version::parse("1.9.0-alpha.1", VersionGrammar::SemVer).unwrap();
assert!(
!caret_covers(&v123, &pre_same_major).unwrap(),
"caret_covers(1.2.3, 1.9.0-alpha.1) must be false: caret ranges must \
not cover pre-releases"
);
let pre_diff_major = Version::parse("2.0.0-alpha.1", VersionGrammar::SemVer).unwrap();
assert!(
!caret_covers(&v123, &pre_diff_major).unwrap(),
"caret_covers(1.2.3, 2.0.0-alpha.1) must be false: different major"
);
let stable_same_major = Version::semver(1, 9, 0);
assert!(
caret_covers(&v123, &stable_same_major).unwrap(),
"caret_covers(1.2.3, 1.9.0) must be true: stable, same major"
);
}
#[test]
fn cascade_input_accepts_identity_field() {
let graph = TwoPackageGraph { packages: vec![] };
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let seed = BTreeMap::new();
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let base = BTreeMap::new();
let identity = IdentityIndex::default();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &identity,
};
assert!(input.identity.native.is_empty());
}
struct TwoPackageGraph {
packages: Vec<Package>,
}
impl DependencyResolver for TwoPackageGraph {
fn packages(&self) -> impl Iterator<Item = &Package> {
self.packages.iter()
}
fn dependencies_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
std::iter::empty()
}
fn dependents_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
std::iter::empty()
}
}
struct TestGraph {
packages: Vec<Package>,
edges: Vec<DepEdge>,
}
impl DependencyResolver for TestGraph {
fn packages(&self) -> impl Iterator<Item = &Package> {
self.packages.iter()
}
fn dependencies_of(&self, id: &PackageId) -> impl Iterator<Item = &DepEdge> {
self.edges.iter().filter(move |e| &e.from == id)
}
fn dependents_of(&self, id: &PackageId) -> impl Iterator<Item = &DepEdge> {
self.edges.iter().filter(move |e| &e.to == id)
}
}
fn make_dep_edge(
from: &PackageId,
to: &PackageId,
spec_str: &str,
ecosystem: callisto_model::Ecosystem,
) -> DepEdge {
let spec = DepSpec::Range(
callisto_model::VersionReq::parse(spec_str, ecosystem).unwrap(),
spec_str.to_string(),
);
DepEdge {
from: from.clone(),
to: to.clone(),
kind: DepKind::Runtime,
spec,
from_manifest: std::path::PathBuf::from(format!("{}/Cargo.toml", from.name())),
inherited: false,
}
}
fn bare_package(id: &PackageId) -> Package {
Package {
id: id.clone(),
manifests: Vec::new(),
changelog: None,
release_trigger: ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
}
}
#[test]
fn test_dual_identity_rewrite_key_uses_dependents_ecosystem_native_name() {
let dep_target = PackageId::Bare("my-native-lib".to_string());
let dependent = PackageId::Prefixed {
ecosystem: Ecosystem::Npm,
name: "dep-app".to_string(),
};
let edge = DepEdge {
from: dependent.clone(),
to: dep_target.clone(),
kind: DepKind::Runtime,
spec: DepSpec::Range(
callisto_model::VersionReq::parse("^1.0.0", Ecosystem::Npm).unwrap(),
"^1.0.0".to_string(),
),
from_manifest: std::path::PathBuf::from("dep-app/package.json"),
inherited: false,
};
let graph = TestGraph {
packages: vec![bare_package(&dep_target), bare_package(&dependent)],
edges: vec![edge],
};
let mut base = BTreeMap::new();
base.insert(dep_target.clone(), Version::semver(1, 0, 0));
base.insert(dependent.clone(), Version::semver(1, 0, 0));
let mut seed = BTreeMap::new();
seed.insert(dep_target.clone(), Severity::Major);
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let mut identity = IdentityIndex::default();
identity
.native
.insert((Ecosystem::Cargo, "my-native-lib".to_string()), dep_target.clone());
identity
.native
.insert((Ecosystem::Npm, "@scope/my-native-lib".to_string()), dep_target.clone());
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &identity,
};
let outcome = run_cascade(input).unwrap();
let rewrite = outcome
.rewrites
.values()
.find(|r| r.dependency == dep_target)
.expect("a rewrite for dep_target must be produced");
assert_eq!(
rewrite.key.name, "@scope/my-native-lib",
"RewriteKey.name must use the Npm-native name registered for the \
dependent's own ecosystem, not PackageId::name() (\"my-native-lib\")"
);
}
#[test]
fn test_none_registration_fallback_uses_package_id_name_ac003() {
let dep_target = PackageId::parse("pkg-core").unwrap();
let dependent = PackageId::parse("pkg-app").unwrap();
let edge = make_dep_edge(&dependent, &dep_target, "^1.0.0", Ecosystem::Cargo);
let graph = TestGraph {
packages: vec![bare_package(&dep_target), bare_package(&dependent)],
edges: vec![edge],
};
let mut base = BTreeMap::new();
base.insert(dep_target.clone(), Version::semver(1, 0, 0));
base.insert(dependent.clone(), Version::semver(1, 0, 0));
let mut seed = BTreeMap::new();
seed.insert(dep_target.clone(), Severity::Major);
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let identity = IdentityIndex::default();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &identity,
};
let outcome = run_cascade(input).unwrap();
let rewrite = outcome
.rewrites
.values()
.find(|r| r.dependency == dep_target)
.expect("rewrite for dep_target must be produced");
assert_eq!(
rewrite.key.name,
dep_target.name(),
"with no IdentityIndex.native registration, RewriteKey.name must fall back to PackageId::name()"
);
}
#[test]
fn test_bare_target_with_no_registration_falls_back_to_name_ac007() {
let dep_target = PackageId::Bare("foo".to_string());
let dependent = PackageId::parse("pkg-app").unwrap();
let edge = make_dep_edge(&dependent, &dep_target, "^1.0.0", Ecosystem::Cargo);
let graph = TestGraph {
packages: vec![bare_package(&dep_target), bare_package(&dependent)],
edges: vec![edge],
};
let mut base = BTreeMap::new();
base.insert(dep_target.clone(), Version::semver(1, 0, 0));
base.insert(dependent.clone(), Version::semver(1, 0, 0));
let mut seed = BTreeMap::new();
seed.insert(dep_target.clone(), Severity::Major);
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let identity = IdentityIndex::default();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &identity,
};
let outcome = run_cascade(input).unwrap();
let rewrite = outcome
.rewrites
.values()
.find(|r| r.dependency == dep_target)
.expect("rewrite for dep_target must be produced");
assert_eq!(
rewrite.key.name, "foo",
"Bare target with no IdentityIndex.native registration must fall back to edge.to.name()"
);
}
#[test]
fn test_linked_group_propagates_severity_to_unseeded_member_and_converges() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let graph = TwoPackageGraph {
packages: vec![bare_package(&pkg_a), bare_package(&pkg_b)],
};
let mut base = BTreeMap::new();
base.insert(pkg_a.clone(), Version::semver(1, 0, 0));
base.insert(pkg_b.clone(), Version::semver(1, 0, 0));
let mut seed = BTreeMap::new();
seed.insert(pkg_b.clone(), Severity::Major);
let mut groups = GroupTable::default();
let group_def = GroupDef {
name: GroupName("linked-pair".to_string()),
kind: GroupKind::Linked,
members: vec![GroupMember::Package(pkg_a.clone()), GroupMember::Package(pkg_b.clone())],
};
groups.linked.insert(group_def.name.clone(), group_def);
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let outcome = run_cascade(input).unwrap();
assert_eq!(outcome.severities.get(&pkg_a), Some(&Severity::Major));
assert_eq!(outcome.severities.get(&pkg_b), Some(&Severity::Major));
let target_a = outcome.targets.get(&pkg_a).unwrap();
let target_b = outcome.targets.get(&pkg_b).unwrap();
assert_eq!(target_a, target_b);
assert_eq!(target_a.render(), "2.0.0");
assert_eq!(
outcome.reasons.get(&pkg_a),
Some(&BumpReason::LinkedGroupUnion {
group: GroupName("linked-pair".to_string()),
})
);
}
#[test]
fn test_linked_group_converges_target_version_across_divergent_bases() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let graph = TwoPackageGraph {
packages: vec![bare_package(&pkg_a), bare_package(&pkg_b)],
};
let mut base = BTreeMap::new();
base.insert(
pkg_a.clone(),
Version::parse("1.4.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
base.insert(
pkg_b.clone(),
Version::parse("2.7.3", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let mut seed = BTreeMap::new();
seed.insert(pkg_a.clone(), Severity::Minor);
let mut groups = GroupTable::default();
let group_def = GroupDef {
name: GroupName("linked-pair".to_string()),
kind: GroupKind::Linked,
members: vec![GroupMember::Package(pkg_a.clone()), GroupMember::Package(pkg_b.clone())],
};
groups.linked.insert(group_def.name.clone(), group_def);
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let outcome = run_cascade(input).unwrap();
let target_a = outcome.targets.get(&pkg_a).unwrap();
let target_b = outcome.targets.get(&pkg_b).unwrap();
assert_eq!(target_a, target_b);
assert_eq!(target_a.render(), "2.8.0");
}
#[test]
fn test_bump_target_exit_mode_finalizes_to_stable() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let graph = TwoPackageGraph {
packages: vec![bare_package(&pkg_a)],
};
let mut base = BTreeMap::new();
base.insert(
pkg_a.clone(),
Version::parse("1.0.0-alpha.2", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let mut initial_versions = indexmap::IndexMap::new();
initial_versions.insert(
"pkg-a".to_string(),
Version::parse("0.9.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let pre = callisto_format::PreState {
mode: callisto_format::PreMode::Exit,
tag: "alpha".to_string(),
initial_versions,
changesets: Vec::new(),
};
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let seed = BTreeMap::new();
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: Some(&pre),
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let target = bump_target(&pkg_a, Severity::Minor, &input).unwrap();
assert!(
!target.is_prerelease(),
"PreMode::Exit must produce a stable version, not a pre-release; got {}",
target.render()
);
assert_eq!(
target.render(),
"1.0.0",
"bump_target with Exit mode, base=1.0.0-alpha.2, sev=Minor must finalize to 1.0.0"
);
}
#[test]
fn test_diamond_dependency_a_appears_exactly_once_in_cascade_result() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
let pkg_c = PackageId::parse("pkg-c").unwrap();
let pkg_d = PackageId::parse("pkg-d").unwrap();
let eco = callisto_model::Ecosystem::Cargo;
let edges = vec![
make_dep_edge(&pkg_b, &pkg_d, "^1.0.0", eco),
make_dep_edge(&pkg_c, &pkg_d, "^1.0.0", eco),
make_dep_edge(&pkg_a, &pkg_b, "=1.0.0", eco),
make_dep_edge(&pkg_a, &pkg_c, "=1.0.0", eco),
];
let graph = TestGraph {
packages: vec![
bare_package(&pkg_a),
bare_package(&pkg_b),
bare_package(&pkg_c),
bare_package(&pkg_d),
],
edges,
};
let mut base = BTreeMap::new();
base.insert(pkg_a.clone(), Version::semver(1, 0, 0));
base.insert(pkg_b.clone(), Version::semver(1, 0, 0));
base.insert(pkg_c.clone(), Version::semver(1, 0, 0));
base.insert(pkg_d.clone(), Version::semver(1, 0, 0));
let mut seed = BTreeMap::new();
seed.insert(pkg_d.clone(), Severity::Major);
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: false,
preserve_npm_ranges: false,
};
let groups = crate::config::GroupTable::default();
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let outcome = run_cascade(input).unwrap();
assert!(outcome.severities.contains_key(&pkg_d), "D must be in outcome");
assert!(outcome.severities.contains_key(&pkg_b), "B must cascade from D");
assert!(outcome.severities.contains_key(&pkg_c), "C must cascade from D");
assert!(
outcome.severities.contains_key(&pkg_a),
"A must cascade from B and C (both bumped to 2.0.0, out of ^1.0.0)"
);
let a_severity_count = outcome.severities.keys().filter(|k| *k == &pkg_a).count();
assert_eq!(
a_severity_count, 1,
"pkg-a must appear exactly once in severities map, got {a_severity_count}"
);
let a_target_count = outcome.targets.keys().filter(|k| *k == &pkg_a).count();
assert_eq!(
a_target_count, 1,
"pkg-a must appear exactly once in targets map, got {a_target_count}"
);
assert_eq!(
outcome.severities[&pkg_d],
Severity::Major,
"D must have Major severity (seeded directly)"
);
assert_eq!(
outcome.severities[&pkg_a],
Severity::Patch,
"A's cascaded severity must be Patch (Patch cascade config)"
);
}
#[test]
fn test_prerelease_cascade_stable_spec_out_of_range_triggers_cascade() {
let pkg_b = PackageId::parse("pkg-b").unwrap();
let pkg_d = PackageId::parse("pkg-d").unwrap();
let eco = callisto_model::Ecosystem::Cargo;
let edges = vec![make_dep_edge(&pkg_b, &pkg_d, "^1.0.0", eco)];
let graph = TestGraph {
packages: vec![bare_package(&pkg_b), bare_package(&pkg_d)],
edges,
};
let mut base = BTreeMap::new();
base.insert(
pkg_b.clone(),
Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
base.insert(
pkg_d.clone(),
Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let mut initial_versions = indexmap::IndexMap::new();
initial_versions.insert(
"pkg-d".to_string(),
Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
initial_versions.insert(
"pkg-b".to_string(),
Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let pre = callisto_format::PreState {
mode: callisto_format::PreMode::Pre,
tag: "alpha".to_string(),
initial_versions,
changesets: Vec::new(),
};
let mut seed = BTreeMap::new();
seed.insert(pkg_d.clone(), Severity::Major);
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: false,
preserve_npm_ranges: false,
};
let groups = crate::config::GroupTable::default();
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: Some(&pre),
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let outcome = run_cascade(input).unwrap();
let d_target = outcome.targets.get(&pkg_d).expect("D must have a target");
assert!(
d_target.is_prerelease(),
"D must bump to a pre-release version in PreMode::Pre; got {}",
d_target.render()
);
assert!(
d_target.render().starts_with("2.0.0-"),
"D's pre-release bump from 1.0.0 with Major severity must start with 2.0.0-; got {}",
d_target.render()
);
assert!(
outcome.severities.contains_key(&pkg_b),
"B must be cascaded when D's pre-release version is out of range for ^1.0.0"
);
let b_target = outcome.targets.get(&pkg_b).expect("B must have a target");
assert!(
b_target.is_prerelease(),
"B's cascade target in PreMode::Pre must also be a pre-release; got {}",
b_target.render()
);
}
#[test]
fn test_bump_target_uses_pinned_pre_baseline_not_current_prerelease() {
let pkg_a = PackageId::parse("pkg-a").unwrap();
let graph = TwoPackageGraph {
packages: vec![bare_package(&pkg_a)],
};
let mut base = BTreeMap::new();
base.insert(
pkg_a.clone(),
Version::parse("2.0.0-next.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let mut initial_versions = indexmap::IndexMap::new();
initial_versions.insert(
"pkg-a".to_string(),
Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
);
let pre = callisto_format::PreState {
mode: callisto_format::PreMode::Pre,
tag: "next".to_string(),
initial_versions,
changesets: Vec::new(),
};
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let seed = BTreeMap::new();
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: Some(&pre),
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let target = bump_target(&pkg_a, Severity::Minor, &input).unwrap();
assert_eq!(target.render(), "1.1.0-next.0");
}
#[test]
fn inherited_cargo_dependency_edge_never_produces_manifest_rewrite_target() {
let pkg_root = PackageId::parse("cargo:root-crate").unwrap();
let pkg_member = PackageId::parse("cargo:member-crate").unwrap();
let pkg_dep = PackageId::parse("cargo:shared-dep").unwrap();
let inherited_edge = DepEdge {
from: pkg_root.clone(),
to: pkg_dep.clone(),
kind: DepKind::Runtime,
spec: DepSpec::Range(
callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
"^1.0.0".to_string(),
),
from_manifest: std::path::PathBuf::from("Cargo.toml"),
inherited: true,
};
let non_inherited_edge = DepEdge {
from: pkg_member.clone(),
to: pkg_dep.clone(),
kind: DepKind::Runtime,
spec: DepSpec::Range(
callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
"^1.0.0".to_string(),
),
from_manifest: std::path::PathBuf::from("member/Cargo.toml"),
inherited: false,
};
let graph = TestGraph {
packages: vec![
bare_package(&pkg_root),
bare_package(&pkg_member),
bare_package(&pkg_dep),
],
edges: vec![inherited_edge, non_inherited_edge],
};
let mut base = BTreeMap::new();
base.insert(pkg_root.clone(), Version::semver(1, 0, 0));
base.insert(pkg_member.clone(), Version::semver(1, 0, 0));
base.insert(pkg_dep.clone(), Version::semver(1, 0, 0));
let mut seed = BTreeMap::new();
seed.insert(pkg_dep.clone(), Severity::Major);
let groups = GroupTable::default();
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &TagIndex::empty(),
identity: &IdentityIndex::default(),
};
let outcome = run_cascade(input).unwrap();
let has_manifest_target_for_inherited = outcome.rewrites.values().any(|r| {
r.key.name == pkg_dep.name()
&& matches!(&r.key.target, DepWriteTarget::Manifest(p) if p == &PathBuf::from("Cargo.toml"))
});
assert!(
!has_manifest_target_for_inherited,
"inherited edge must never produce DepWriteTarget::Manifest; rewrites: {:?}",
outcome.rewrites
);
let has_workspace_dep_target_for_inherited = outcome.rewrites.values().any(|r| {
r.key.name == pkg_dep.name()
&& r.key.target
== DepWriteTarget::CargoWorkspaceDependency {
root_manifest: PathBuf::from("Cargo.toml"),
}
});
assert!(
has_workspace_dep_target_for_inherited,
"inherited edge must produce a CargoWorkspaceDependency rewrite target; rewrites: {:?}",
outcome.rewrites
);
let has_manifest_target_for_non_inherited = outcome
.rewrites
.values()
.any(|r| matches!(&r.key.target, DepWriteTarget::Manifest(p) if p == &PathBuf::from("member/Cargo.toml")));
assert!(
has_manifest_target_for_non_inherited,
"non-inherited edge must produce a DepWriteTarget::Manifest rewrite; rewrites: {:?}",
outcome.rewrites
);
}
#[test]
fn test_fixed_group_seeded_siblings_converge_on_shared_target() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct FakeGitTagRunner {
calls: AtomicUsize,
tags: Vec<String>,
}
impl callisto_model::CommandRunner for FakeGitTagRunner {
fn run(
&self,
program: &str,
args: &[&str],
_cwd: &std::path::Path,
) -> Result<callisto_model::CommandOutput, callisto_model::CommandError> {
assert_eq!(program, "git");
assert_eq!(args, ["tag", "--list"]);
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(callisto_model::CommandOutput {
exit_code: Some(0),
stdout: self.tags.join("\n"),
stderr: String::new(),
})
}
}
let pkg_a = PackageId::parse("pkg-a").unwrap();
let pkg_b = PackageId::parse("pkg-b").unwrap();
fn package_with_canonical_manifest(id: &PackageId) -> Package {
let manifest = callisto_model::ManifestDecl::new(
"Cargo.toml",
callisto_model::ManifestRole::Canonical,
callisto_model::ManifestFormat::CargoToml,
)
.unwrap();
Package {
id: id.clone(),
manifests: vec![manifest],
changelog: None,
release_trigger: ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
}
}
let graph = TwoPackageGraph {
packages: vec![
package_with_canonical_manifest(&pkg_a),
package_with_canonical_manifest(&pkg_b),
],
};
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert!(
callisto_vcs::GitRepository::discover(dir).is_err(),
"fixture dir must not be a discoverable git repo, forcing the CommandRunner fallback"
);
let runner = FakeGitTagRunner {
calls: AtomicUsize::new(0),
tags: vec!["pkg-a@2.0.0".to_string()],
};
let git = callisto_vcs::GitAccess::discover(dir, &runner);
let cfg_resolved = crate::config::load(dir).unwrap();
let tags = crate::tags::TagIndex::build(&git, &graph, &cfg_resolved).unwrap();
assert!(tags.last_tag(&pkg_a).is_some(), "A must have a prior release tag");
assert!(tags.last_tag(&pkg_b).is_none(), "B must never have been released");
let mut base = BTreeMap::new();
base.insert(pkg_a.clone(), Version::semver(2, 0, 0));
base.insert(pkg_b.clone(), Version::semver(0, 1, 0));
let mut seed = BTreeMap::new();
seed.insert(pkg_a.clone(), Severity::Minor);
seed.insert(pkg_b.clone(), Severity::Minor);
let group_def = GroupDef {
name: GroupName("ab-fixed".to_string()),
kind: GroupKind::Fixed,
members: vec![GroupMember::Package(pkg_a.clone()), GroupMember::Package(pkg_b.clone())],
};
let groups = GroupTable::from_groups(vec![group_def], vec![]);
let cfg = CascadeConfig {
mode: CascadeMode::OutOfRange,
bump_severity: CascadeBumpSeverity::Patch,
peer_escalation: true,
preserve_npm_ranges: false,
};
let reasons = BTreeMap::new();
let named_by = BTreeMap::new();
let input = CascadeInput {
graph: &graph,
groups: &groups,
cfg: &cfg,
seed: &seed,
reasons: &reasons,
named_by: &named_by,
base: &base,
pre: None,
tags: &tags,
identity: &IdentityIndex::default(),
};
let outcome = run_cascade(input).unwrap();
let target_a = outcome.targets.get(&pkg_a).unwrap();
let target_b = outcome.targets.get(&pkg_b).unwrap();
assert_eq!(
target_a.render(),
"2.1.0",
"A (the released member) must land on the group-aligned target"
);
assert_eq!(
target_b.render(),
"2.1.0",
"B must converge on the SAME target as A, not its own independently-bumped 0.2.0"
);
assert_eq!(target_a, target_b);
}
}