1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use callisto_model::{
5 BumpReason, ConfigKey, Coverage, DepEdge, DepKind, DepSpec, Diagnostic, DiagnosticCode, DiagnosticSeverity,
6 Ecosystem, GrammarMismatch, PackageId, Severity, Version,
7};
8
9use crate::config::GroupTable;
10use crate::config::{CascadeConfig, CascadeMode};
11use crate::error::GraphError;
12use crate::identity::IdentityIndex;
13use crate::resolver::DependencyResolver;
14use crate::tags::TagIndex;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct CascadeDecision {
18 pub severity: Severity,
19 pub rewrite: bool,
20 pub governed_by: Option<ConfigKey>,
21 pub escalated: bool,
22 pub unknown_coverage: bool,
23}
24
25pub fn cascade_action(kind: DepKind, coverage: Coverage, source: Severity, cfg: &CascadeConfig) -> CascadeDecision {
26 use Coverage::*;
27 use DepKind::*;
28
29 let effective = match (cfg.mode, coverage) {
30 (CascadeMode::Always, _) => DoesNotCover,
31 (CascadeMode::OutOfRange, Covers) => Covers,
32 (CascadeMode::OutOfRange, DoesNotCover) => DoesNotCover,
33 (CascadeMode::OutOfRange, Unknown) => Covers,
34 };
35
36 let rewrite = matches!(coverage, DoesNotCover);
37
38 let (severity, governed_by, escalated) = match (kind, effective) {
39 (Runtime | Optional | Build, Covers) => (Severity::None, None, false),
40 (Runtime | Optional | Build, DoesNotCover) => (
41 cfg.bump_severity.as_severity(),
42 Some(ConfigKey::CASCADE_BUMP_SEVERITY),
43 false,
44 ),
45 (Peer, Covers) => (Severity::None, None, false),
46 (Peer, DoesNotCover)
47 if cfg.peer_escalation
48 && matches!(coverage, DoesNotCover)
49 && matches!(source, Severity::Minor | Severity::Major) =>
50 {
51 (Severity::Major, Some(ConfigKey::CASCADE_PEER_ESCALATION), true)
52 }
53 (Peer, DoesNotCover) => (
54 cfg.bump_severity.as_severity(),
55 Some(ConfigKey::CASCADE_BUMP_SEVERITY),
56 false,
57 ),
58 (Dev, _) => (Severity::None, None, false),
59 _ => (Severity::None, None, false),
60 };
61
62 let governed_by = match (cfg.mode, coverage, severity) {
63 (CascadeMode::Always, Covers | Unknown, s) if s != Severity::None => Some(ConfigKey::CASCADE_MODE),
64 _ => governed_by,
65 };
66
67 CascadeDecision {
68 severity,
69 rewrite,
70 governed_by,
71 escalated,
72 unknown_coverage: matches!(coverage, Unknown),
73 }
74}
75
76pub fn coverage(spec: &DepSpec, new: &Version) -> Result<Coverage, GrammarMismatch> {
77 match spec {
78 DepSpec::Exact(v) => {
79 if v == new {
80 Ok(Coverage::Covers)
81 } else {
82 Ok(Coverage::DoesNotCover)
83 }
84 }
85 DepSpec::CargoBare(v) => {
86 if caret_covers(v, new)? {
87 Ok(Coverage::Covers)
88 } else {
89 Ok(Coverage::DoesNotCover)
90 }
91 }
92 DepSpec::Range(req, _) => {
93 if req.matches(new)? {
94 Ok(Coverage::Covers)
95 } else {
96 Ok(Coverage::DoesNotCover)
97 }
98 }
99 DepSpec::Workspace(_) => Ok(Coverage::Covers),
100 DepSpec::Catalog(_) | DepSpec::Opaque(_) => Ok(Coverage::Unknown),
101 }
102}
103
104pub(crate) fn caret_covers(cur: &Version, new: &Version) -> Result<bool, GrammarMismatch> {
105 let cmp = Version::compare(new, cur)?;
106 if cmp.is_lt() {
107 return Ok(false);
108 }
109 if new.is_prerelease() {
115 return Ok(false);
116 }
117 let cur_maj = cur.major().unwrap_or(0);
118 let cur_min = cur.minor().unwrap_or(0);
119 let new_maj = new.major().unwrap_or(0);
120 let new_min = new.minor().unwrap_or(0);
121
122 if cur_maj > 0 {
123 Ok(new_maj == cur_maj)
124 } else if cur_min > 0 {
125 Ok(new_maj == 0 && new_min == cur_min)
126 } else {
127 Ok(new == cur)
128 }
129}
130
131pub struct CascadeInput<'a, D: DependencyResolver> {
132 pub graph: &'a D,
133 pub groups: &'a GroupTable,
134 pub cfg: &'a CascadeConfig,
135 pub seed: &'a BTreeMap<PackageId, Severity>,
136 pub reasons: &'a BTreeMap<PackageId, BumpReason>,
137 pub named_by: &'a BTreeMap<PackageId, crate::aggregate::NamedBy>,
138 pub base: &'a BTreeMap<PackageId, Version>,
139 pub pre: Option<&'a callisto_format::PreState>,
140 pub tags: &'a TagIndex,
141 pub identity: &'a IdentityIndex,
142}
143
144#[derive(Clone, Debug, Default)]
145pub struct CascadeOutcome {
146 pub severities: BTreeMap<PackageId, Severity>,
147 pub targets: BTreeMap<PackageId, Version>,
148 pub reasons: BTreeMap<PackageId, BumpReason>,
149 pub governed_by: BTreeMap<PackageId, ConfigKey>,
150 pub rewrites: BTreeMap<RewriteKey, SpecRewrite>,
151 pub diagnostics: Vec<Diagnostic>,
152 pub iterations: usize,
153}
154
155pub trait CascadeSolver<D: DependencyResolver> {
160 fn solve_cascade(&self, input: CascadeInput<'_, D>) -> Result<CascadeOutcome, GraphError>;
161}
162
163pub fn run_cascade<D: DependencyResolver>(input: CascadeInput<'_, D>) -> Result<CascadeOutcome, GraphError> {
164 solve_cascade(input)
165}
166
167pub fn solve_cascade<D: DependencyResolver>(input: CascadeInput<'_, D>) -> Result<CascadeOutcome, GraphError> {
186 let mut out = CascadeOutcome {
187 severities: input.seed.clone(),
188 reasons: input.reasons.clone(),
189 ..Default::default()
190 };
191
192 for (id, &sev) in input.seed {
193 let t = bump_target(id, sev, &input)?;
194 out.targets.insert(id.clone(), t);
195 }
196
197 let mut worklist: BTreeSet<PackageId> = out.targets.keys().cloned().collect();
198 let mut iterations = 0;
199 let bound = convergence_bound(input.graph.packages().count());
200
201 let mut changed = true;
202 while changed {
203 changed = false;
204
205 while let Some(pkg) = worklist.pop_first() {
206 iterations += 1;
207 if iterations > bound {
208 return Err(GraphError::CascadeNotConverged { iterations });
209 }
210
211 let new_version = out.targets[&pkg].clone();
212 let src_sev = out.severities[&pkg];
213
214 let dependents: Vec<DepEdge> = input.graph.dependents_of(&pkg).cloned().collect();
215 for edge in dependents {
216 let cov = coverage(&edge.spec, &new_version).map_err(|source| GraphError::GrammarMismatch {
217 from: edge.from.clone(),
218 to: edge.to.clone(),
219 source,
220 })?;
221
222 let d = cascade_action(edge.kind, cov, src_sev, input.cfg);
223
224 if d.unknown_coverage && !matches!(edge.spec, DepSpec::Opaque(_)) {
225 let code = match edge.spec {
226 DepSpec::Catalog(_) => DiagnosticCode::CatalogSpecNotRewritten,
227 _ => DiagnosticCode::RangeNotRoundTrippable,
228 };
229 out.diagnostics.push(Diagnostic {
230 code,
231 severity: DiagnosticSeverity::Warning,
232 message: format!(
233 "spec `{}` for `{}` could not be tested for coverage",
234 edge.spec.render(),
235 edge.to.display_name()
236 ),
237 package: Some(edge.from.clone()),
238 path: Some(edge.from_manifest.clone()),
239 governed_by: Some(ConfigKey::CASCADE_PRESERVE_NPM_RANGES),
240 escalated_by: None,
241 });
242 }
243
244 if d.rewrite {
245 let eco = edge.from.ecosystem().unwrap_or_else(|| {
246 if edge.from_manifest.to_string_lossy().ends_with("Cargo.toml") {
247 Ecosystem::Cargo
248 } else {
249 Ecosystem::Npm
250 }
251 });
252 match rewrite_spec(&edge.spec, &new_version, eco, input.cfg) {
253 RewriteOutcome::Rewritten(to_spec) => {
254 let key = RewriteKey {
255 target: if edge.inherited {
256 DepWriteTarget::CargoWorkspaceDependency {
257 root_manifest: edge.from_manifest.clone(),
258 }
259 } else {
260 DepWriteTarget::Manifest(edge.from_manifest.clone())
261 },
262 name: input
263 .identity
264 .native_name(&edge.to, eco)
265 .map(str::to_string)
266 .unwrap_or_else(|| edge.to.name().to_string()),
267 kind: if edge.inherited { None } else { Some(edge.kind) },
268 };
269 out.rewrites.insert(
270 key.clone(),
271 SpecRewrite {
272 key,
273 dependency: edge.to.clone(),
274 from: edge.spec.clone(),
275 to: to_spec,
276 },
277 );
278 }
279 RewriteOutcome::LeftAlone(dg) => {
280 out.diagnostics.push(dg);
281 }
282 }
283 }
284
285 let cur_sev = out.severities.get(&edge.from).copied().unwrap_or(Severity::None);
286 if d.severity > cur_sev {
287 raise(
288 &edge.from,
289 d.severity,
290 &d,
291 &pkg,
292 &edge,
293 &new_version,
294 &mut out,
295 input.groups,
296 &mut worklist,
297 &input,
298 )?;
299 }
300 }
301 }
302
303 for g in input.groups.linked.values() {
305 let member_ids: Vec<PackageId> = g
306 .members(crate::config::GroupMemberKind::Package)
307 .filter_map(|m| match m {
308 crate::config::GroupMember::Package(ref id) => Some(id.clone()),
309 _ => None,
310 })
311 .collect();
312
313 let mut max_sev = Severity::None;
314 for id in &member_ids {
315 if let Some(&sev) = out.severities.get(id) {
316 max_sev = max_sev.max(sev);
317 }
318 }
319
320 if max_sev > Severity::None {
321 for id in &member_ids {
322 let cur_sev = out.severities.get(id).copied().unwrap_or(Severity::None);
323 if max_sev > cur_sev {
324 out.severities.insert(id.clone(), max_sev);
325 }
326 }
327
328 let mut winner: Option<Version> = None;
329 for id in &member_ids {
330 let candidate = bump_target(id, max_sev, &input)?;
331 winner = Some(match winner {
332 None => candidate,
333 Some(best) => {
334 let cmp = Version::compare(&candidate, &best).map_err(|_grammar_mismatch| {
335 GraphError::GroupGrammarMismatch {
336 group: g.name.clone(),
337 members: member_ids
338 .iter()
339 .filter_map(|m| out.targets.get(m).map(|v| (m.clone(), v.clone())))
340 .collect(),
341 }
342 })?;
343 if cmp.is_gt() {
344 candidate
345 } else {
346 best
347 }
348 }
349 });
350 }
351 let winner = winner.expect("linked group has at least one member");
352
353 for id in member_ids {
354 if out.targets.get(&id) != Some(&winner) {
355 out.targets.insert(id.clone(), winner.clone());
356 out.reasons
357 .insert(id.clone(), BumpReason::LinkedGroupUnion { group: g.name.clone() });
358 worklist.insert(id.clone());
359 changed = true;
360 }
361 }
362 }
363 }
364
365 for g in input.groups.fixed.values() {
370 let member_ids: Vec<PackageId> = g
371 .members(crate::config::GroupMemberKind::Package)
372 .filter_map(|m| match m {
373 crate::config::GroupMember::Package(ref id) => Some(id.clone()),
374 _ => None,
375 })
376 .collect();
377
378 let mut max_sev = Severity::None;
379 for id in &member_ids {
380 if let Some(&sev) = out.severities.get(id) {
381 max_sev = max_sev.max(sev);
382 }
383 }
384
385 if max_sev > Severity::None {
386 for id in &member_ids {
387 let cur_sev = out.severities.get(id).copied().unwrap_or(Severity::None);
388 if max_sev > cur_sev {
389 out.severities.insert(id.clone(), max_sev);
390 }
391 }
392
393 let winner = crate::groups::fixed_group_target(g, input.base, &out.severities, input.tags, input.pre)?;
394
395 for id in member_ids {
396 if out.targets.get(&id) != Some(&winner) {
397 out.targets.insert(id.clone(), winner.clone());
398 out.reasons
399 .insert(id.clone(), BumpReason::FixedGroupUnion { group: g.name.clone() });
400 worklist.insert(id.clone());
401 changed = true;
402 }
403 }
404 }
405 }
406 }
407
408 out.iterations = iterations;
409 Ok(out)
410}
411
412fn bump_target<D: DependencyResolver>(
413 id: &PackageId,
414 sev: Severity,
415 input: &CascadeInput<'_, D>,
416) -> Result<Version, GraphError> {
417 let base = input.base.get(id).cloned().ok_or_else(|| {
418 GraphError::Manifest(callisto_model::ManifestError::MissingField {
419 path: PathBuf::from(id.name()),
420 field: "version",
421 })
422 })?;
423 let versioning: &dyn callisto_format::Versioning = match base.grammar() {
424 callisto_model::VersionGrammar::Pep440 => &callisto_format::Pep440Versioning,
425 _ => &callisto_format::SemVerVersioning,
426 };
427
428 if let Some(pre) = input.pre {
429 if pre.mode == callisto_format::PreMode::Pre {
430 let pinned_base = pre.initial_versions.get(id.name()).unwrap_or(&base);
431 versioning
432 .bump_prerelease(pinned_base, sev, &pre.tag, &base)
433 .map_err(GraphError::Bump)
434 } else {
435 versioning.bump(&base, sev).map_err(GraphError::Bump)
440 }
441 } else {
442 versioning.bump(&base, sev).map_err(GraphError::Bump)
443 }
444}
445
446#[allow(clippy::too_many_arguments)]
447fn raise<D: DependencyResolver>(
448 pkg: &PackageId,
449 sev: Severity,
450 decision: &CascadeDecision,
451 via: &PackageId,
452 edge: &DepEdge,
453 dependency_to: &Version,
454 out: &mut CascadeOutcome,
455 groups: &GroupTable,
456 worklist: &mut BTreeSet<PackageId>,
457 input: &CascadeInput<'_, D>,
458) -> Result<bool, GraphError> {
459 let cur_sev = out.severities.get(pkg).copied().unwrap_or(Severity::None);
460 if sev <= cur_sev {
461 return Ok(false);
462 }
463
464 out.severities.insert(pkg.clone(), sev);
465
466 let new_reason = if decision.escalated {
467 BumpReason::PeerEscalation {
468 via: via.clone(),
469 spec: edge.spec.render(),
470 }
471 } else {
472 BumpReason::Cascade {
473 via: via.clone(),
474 dep_kind: edge.kind,
475 spec: edge.spec.render(),
476 dependency_to: dependency_to.clone(),
477 }
478 };
479 out.reasons.insert(pkg.clone(), new_reason);
480
481 if let Some(ref gov) = decision.governed_by {
482 out.governed_by.insert(pkg.clone(), gov.clone());
483 }
484
485 let new_t = bump_target(pkg, sev, input)?;
486 out.targets.insert(pkg.clone(), new_t);
487 worklist.insert(pkg.clone());
488
489 for sib in groups.fixed_siblings(pkg) {
490 let sib_sev = out.severities.get(sib).copied().unwrap_or(Severity::None);
491 if sev > sib_sev {
492 out.severities.insert(sib.clone(), sev);
493 out.reasons.insert(
494 sib.clone(),
495 BumpReason::FixedGroupUnion {
496 group: groups.fixed_group_of(pkg).unwrap().name.clone(),
497 },
498 );
499 let sib_t = bump_target(sib, sev, input)?;
500 out.targets.insert(sib.clone(), sib_t);
501 worklist.insert(sib.clone());
502 }
503 }
504
505 Ok(true)
506}
507
508pub(crate) fn convergence_bound(package_count: usize) -> usize {
509 4 * package_count + 1
510}
511
512#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
513pub enum DepWriteTarget {
514 Manifest(PathBuf),
515 CargoWorkspaceDependency { root_manifest: PathBuf },
516}
517
518#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
519pub struct RewriteKey {
520 pub target: DepWriteTarget,
521 pub name: String,
522 pub kind: Option<DepKind>,
523}
524
525#[derive(Clone, Debug, PartialEq, Eq)]
526pub struct SpecRewrite {
527 pub key: RewriteKey,
528 pub dependency: PackageId,
529 pub from: DepSpec,
530 pub to: DepSpec,
531}
532
533pub enum RewriteOutcome {
534 Rewritten(DepSpec),
535 LeftAlone(Diagnostic),
536}
537
538pub fn rewrite_spec(original: &DepSpec, new: &Version, eco: Ecosystem, cfg: &CascadeConfig) -> RewriteOutcome {
539 if !cfg.preserve_npm_ranges && eco == Ecosystem::Npm {
540 return RewriteOutcome::Rewritten(DepSpec::Exact(new.clone()));
541 }
542
543 if let Some(rewritten) = callisto_manifests::round_trip(eco, original, new) {
544 RewriteOutcome::Rewritten(rewritten)
545 } else {
546 RewriteOutcome::LeftAlone(Diagnostic {
547 code: DiagnosticCode::RangeNotRoundTrippable,
548 severity: DiagnosticSeverity::Warning,
549 message: format!(
550 "spec `{}` could not be round-tripped toward version `{}`",
551 original.render(),
552 new.render()
553 ),
554 package: None,
555 path: None,
556 governed_by: Some(ConfigKey::CASCADE_PRESERVE_NPM_RANGES),
557 escalated_by: None,
558 })
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use callisto_model::{GroupKind, GroupName, Package, ReleaseTrigger, VersionGrammar};
566
567 use crate::config::{CascadeBumpSeverity, GroupDef, GroupMember};
568
569 #[test]
576 fn cascade_action_peer_escalation_only_fires_for_non_patch_source() {
577 let cfg = CascadeConfig {
578 mode: CascadeMode::OutOfRange,
579 bump_severity: CascadeBumpSeverity::Patch,
580 peer_escalation: true,
581 preserve_npm_ranges: false,
582 };
583
584 let patch_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::Patch, &cfg);
586 assert_eq!(
587 patch_decision.severity,
588 Severity::Patch,
589 "a patch-severity upstream source must not escalate a peer dependent to Major"
590 );
591 assert!(
592 !patch_decision.escalated,
593 "a patch-severity source must not set `escalated`"
594 );
595 assert_eq!(
596 patch_decision.governed_by,
597 Some(ConfigKey::CASCADE_BUMP_SEVERITY),
598 "a patch-severity source falls through to the ordinary bump_severity row"
599 );
600
601 let minor_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::Minor, &cfg);
603 assert_eq!(
604 minor_decision.severity,
605 Severity::Major,
606 "a non-patch (Minor) upstream source must escalate a peer dependent to Major"
607 );
608 assert!(minor_decision.escalated, "a non-patch source must set `escalated`");
609 assert_eq!(minor_decision.governed_by, Some(ConfigKey::CASCADE_PEER_ESCALATION));
610
611 let major_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::Major, &cfg);
613 assert_eq!(major_decision.severity, Severity::Major);
614 assert!(major_decision.escalated);
615
616 let none_decision = cascade_action(DepKind::Peer, Coverage::DoesNotCover, Severity::None, &cfg);
622 assert_eq!(none_decision.severity, Severity::Patch);
623 assert!(!none_decision.escalated);
624 }
625
626 #[test]
635 fn caret_covers_excludes_prerelease_versions() {
636 let v123 = Version::semver(1, 2, 3);
637
638 let pre_same_major = Version::parse("1.9.0-alpha.1", VersionGrammar::SemVer).unwrap();
640 assert!(
641 !caret_covers(&v123, &pre_same_major).unwrap(),
642 "caret_covers(1.2.3, 1.9.0-alpha.1) must be false: caret ranges must \
643 not cover pre-releases"
644 );
645
646 let pre_diff_major = Version::parse("2.0.0-alpha.1", VersionGrammar::SemVer).unwrap();
648 assert!(
649 !caret_covers(&v123, &pre_diff_major).unwrap(),
650 "caret_covers(1.2.3, 2.0.0-alpha.1) must be false: different major"
651 );
652
653 let stable_same_major = Version::semver(1, 9, 0);
655 assert!(
656 caret_covers(&v123, &stable_same_major).unwrap(),
657 "caret_covers(1.2.3, 1.9.0) must be true: stable, same major"
658 );
659 }
660
661 #[test]
662 fn cascade_input_accepts_identity_field() {
663 let graph = TwoPackageGraph { packages: vec![] };
664 let groups = GroupTable::default();
665 let cfg = CascadeConfig {
666 mode: CascadeMode::OutOfRange,
667 bump_severity: CascadeBumpSeverity::Patch,
668 peer_escalation: true,
669 preserve_npm_ranges: false,
670 };
671 let seed = BTreeMap::new();
672 let reasons = BTreeMap::new();
673 let named_by = BTreeMap::new();
674 let base = BTreeMap::new();
675 let identity = IdentityIndex::default();
676
677 let input = CascadeInput {
678 graph: &graph,
679 groups: &groups,
680 cfg: &cfg,
681 seed: &seed,
682 reasons: &reasons,
683 named_by: &named_by,
684 base: &base,
685 pre: None,
686 tags: &TagIndex::empty(),
687 identity: &identity,
688 };
689
690 assert!(input.identity.native.is_empty());
691 }
692
693 struct TwoPackageGraph {
694 packages: Vec<Package>,
695 }
696
697 impl DependencyResolver for TwoPackageGraph {
698 fn packages(&self) -> impl Iterator<Item = &Package> {
699 self.packages.iter()
700 }
701
702 fn dependencies_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
703 std::iter::empty()
704 }
705
706 fn dependents_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
707 std::iter::empty()
708 }
709 }
710
711 struct TestGraph {
713 packages: Vec<Package>,
714 edges: Vec<DepEdge>,
715 }
716
717 impl DependencyResolver for TestGraph {
718 fn packages(&self) -> impl Iterator<Item = &Package> {
719 self.packages.iter()
720 }
721
722 fn dependencies_of(&self, id: &PackageId) -> impl Iterator<Item = &DepEdge> {
723 self.edges.iter().filter(move |e| &e.from == id)
725 }
726
727 fn dependents_of(&self, id: &PackageId) -> impl Iterator<Item = &DepEdge> {
728 self.edges.iter().filter(move |e| &e.to == id)
730 }
731 }
732
733 fn make_dep_edge(
734 from: &PackageId,
735 to: &PackageId,
736 spec_str: &str,
737 ecosystem: callisto_model::Ecosystem,
738 ) -> DepEdge {
739 let spec = DepSpec::Range(
740 callisto_model::VersionReq::parse(spec_str, ecosystem).unwrap(),
741 spec_str.to_string(),
742 );
743 DepEdge {
744 from: from.clone(),
745 to: to.clone(),
746 kind: DepKind::Runtime,
747 spec,
748 from_manifest: std::path::PathBuf::from(format!("{}/Cargo.toml", from.name())),
749 inherited: false,
750 }
751 }
752
753 fn bare_package(id: &PackageId) -> Package {
754 Package {
755 id: id.clone(),
756 manifests: Vec::new(),
757 changelog: None,
758 release_trigger: ReleaseTrigger::Changeset,
759 publish_to: Vec::new(),
760 tag_template: None,
761 }
762 }
763
764 #[test]
771 fn test_dual_identity_rewrite_key_uses_dependents_ecosystem_native_name() {
772 let dep_target = PackageId::Bare("my-native-lib".to_string());
773 let dependent = PackageId::Prefixed {
774 ecosystem: Ecosystem::Npm,
775 name: "dep-app".to_string(),
776 };
777
778 let edge = DepEdge {
779 from: dependent.clone(),
780 to: dep_target.clone(),
781 kind: DepKind::Runtime,
782 spec: DepSpec::Range(
783 callisto_model::VersionReq::parse("^1.0.0", Ecosystem::Npm).unwrap(),
784 "^1.0.0".to_string(),
785 ),
786 from_manifest: std::path::PathBuf::from("dep-app/package.json"),
787 inherited: false,
788 };
789
790 let graph = TestGraph {
791 packages: vec![bare_package(&dep_target), bare_package(&dependent)],
792 edges: vec![edge],
793 };
794
795 let mut base = BTreeMap::new();
796 base.insert(dep_target.clone(), Version::semver(1, 0, 0));
797 base.insert(dependent.clone(), Version::semver(1, 0, 0));
798
799 let mut seed = BTreeMap::new();
800 seed.insert(dep_target.clone(), Severity::Major);
801
802 let groups = GroupTable::default();
803 let cfg = CascadeConfig {
804 mode: CascadeMode::OutOfRange,
805 bump_severity: CascadeBumpSeverity::Patch,
806 peer_escalation: true,
807 preserve_npm_ranges: false,
808 };
809 let reasons = BTreeMap::new();
810 let named_by = BTreeMap::new();
811
812 let mut identity = IdentityIndex::default();
813 identity
814 .native
815 .insert((Ecosystem::Cargo, "my-native-lib".to_string()), dep_target.clone());
816 identity
817 .native
818 .insert((Ecosystem::Npm, "@scope/my-native-lib".to_string()), dep_target.clone());
819
820 let input = CascadeInput {
821 graph: &graph,
822 groups: &groups,
823 cfg: &cfg,
824 seed: &seed,
825 reasons: &reasons,
826 named_by: &named_by,
827 base: &base,
828 pre: None,
829 tags: &TagIndex::empty(),
830 identity: &identity,
831 };
832
833 let outcome = run_cascade(input).unwrap();
834
835 let rewrite = outcome
836 .rewrites
837 .values()
838 .find(|r| r.dependency == dep_target)
839 .expect("a rewrite for dep_target must be produced");
840
841 assert_eq!(
842 rewrite.key.name, "@scope/my-native-lib",
843 "RewriteKey.name must use the Npm-native name registered for the \
844 dependent's own ecosystem, not PackageId::name() (\"my-native-lib\")"
845 );
846 }
847
848 #[test]
853 fn test_none_registration_fallback_uses_package_id_name_ac003() {
854 let dep_target = PackageId::parse("pkg-core").unwrap();
855 let dependent = PackageId::parse("pkg-app").unwrap();
856 let edge = make_dep_edge(&dependent, &dep_target, "^1.0.0", Ecosystem::Cargo);
857
858 let graph = TestGraph {
859 packages: vec![bare_package(&dep_target), bare_package(&dependent)],
860 edges: vec![edge],
861 };
862
863 let mut base = BTreeMap::new();
864 base.insert(dep_target.clone(), Version::semver(1, 0, 0));
865 base.insert(dependent.clone(), Version::semver(1, 0, 0));
866
867 let mut seed = BTreeMap::new();
868 seed.insert(dep_target.clone(), Severity::Major);
869
870 let groups = GroupTable::default();
871 let cfg = CascadeConfig {
872 mode: CascadeMode::OutOfRange,
873 bump_severity: CascadeBumpSeverity::Patch,
874 peer_escalation: true,
875 preserve_npm_ranges: false,
876 };
877 let reasons = BTreeMap::new();
878 let named_by = BTreeMap::new();
879 let identity = IdentityIndex::default();
880
881 let input = CascadeInput {
882 graph: &graph,
883 groups: &groups,
884 cfg: &cfg,
885 seed: &seed,
886 reasons: &reasons,
887 named_by: &named_by,
888 base: &base,
889 pre: None,
890 tags: &TagIndex::empty(),
891 identity: &identity,
892 };
893
894 let outcome = run_cascade(input).unwrap();
895 let rewrite = outcome
896 .rewrites
897 .values()
898 .find(|r| r.dependency == dep_target)
899 .expect("rewrite for dep_target must be produced");
900
901 assert_eq!(
902 rewrite.key.name,
903 dep_target.name(),
904 "with no IdentityIndex.native registration, RewriteKey.name must fall back to PackageId::name()"
905 );
906 }
907
908 #[test]
912 fn test_bare_target_with_no_registration_falls_back_to_name_ac007() {
913 let dep_target = PackageId::Bare("foo".to_string());
914 let dependent = PackageId::parse("pkg-app").unwrap();
915 let edge = make_dep_edge(&dependent, &dep_target, "^1.0.0", Ecosystem::Cargo);
916
917 let graph = TestGraph {
918 packages: vec![bare_package(&dep_target), bare_package(&dependent)],
919 edges: vec![edge],
920 };
921
922 let mut base = BTreeMap::new();
923 base.insert(dep_target.clone(), Version::semver(1, 0, 0));
924 base.insert(dependent.clone(), Version::semver(1, 0, 0));
925
926 let mut seed = BTreeMap::new();
927 seed.insert(dep_target.clone(), Severity::Major);
928
929 let groups = GroupTable::default();
930 let cfg = CascadeConfig {
931 mode: CascadeMode::OutOfRange,
932 bump_severity: CascadeBumpSeverity::Patch,
933 peer_escalation: true,
934 preserve_npm_ranges: false,
935 };
936 let reasons = BTreeMap::new();
937 let named_by = BTreeMap::new();
938 let identity = IdentityIndex::default();
939
940 let input = CascadeInput {
941 graph: &graph,
942 groups: &groups,
943 cfg: &cfg,
944 seed: &seed,
945 reasons: &reasons,
946 named_by: &named_by,
947 base: &base,
948 pre: None,
949 tags: &TagIndex::empty(),
950 identity: &identity,
951 };
952
953 let outcome = run_cascade(input).unwrap();
954 let rewrite = outcome
955 .rewrites
956 .values()
957 .find(|r| r.dependency == dep_target)
958 .expect("rewrite for dep_target must be produced");
959
960 assert_eq!(
961 rewrite.key.name, "foo",
962 "Bare target with no IdentityIndex.native registration must fall back to edge.to.name()"
963 );
964 }
965
966 #[test]
971 fn test_linked_group_propagates_severity_to_unseeded_member_and_converges() {
972 let pkg_a = PackageId::parse("pkg-a").unwrap();
973 let pkg_b = PackageId::parse("pkg-b").unwrap();
974
975 let graph = TwoPackageGraph {
976 packages: vec![bare_package(&pkg_a), bare_package(&pkg_b)],
977 };
978
979 let mut base = BTreeMap::new();
980 base.insert(pkg_a.clone(), Version::semver(1, 0, 0));
981 base.insert(pkg_b.clone(), Version::semver(1, 0, 0));
982
983 let mut seed = BTreeMap::new();
986 seed.insert(pkg_b.clone(), Severity::Major);
987
988 let mut groups = GroupTable::default();
989 let group_def = GroupDef {
990 name: GroupName("linked-pair".to_string()),
991 kind: GroupKind::Linked,
992 members: vec![GroupMember::Package(pkg_a.clone()), GroupMember::Package(pkg_b.clone())],
993 };
994 groups.linked.insert(group_def.name.clone(), group_def);
995
996 let cfg = CascadeConfig {
997 mode: CascadeMode::OutOfRange,
998 bump_severity: CascadeBumpSeverity::Patch,
999 peer_escalation: true,
1000 preserve_npm_ranges: false,
1001 };
1002
1003 let reasons = BTreeMap::new();
1004 let named_by = BTreeMap::new();
1005
1006 let input = CascadeInput {
1007 graph: &graph,
1008 groups: &groups,
1009 cfg: &cfg,
1010 seed: &seed,
1011 reasons: &reasons,
1012 named_by: &named_by,
1013 base: &base,
1014 pre: None,
1015 tags: &TagIndex::empty(),
1016 identity: &IdentityIndex::default(),
1017 };
1018
1019 let outcome = run_cascade(input).unwrap();
1020
1021 assert_eq!(outcome.severities.get(&pkg_a), Some(&Severity::Major));
1024 assert_eq!(outcome.severities.get(&pkg_b), Some(&Severity::Major));
1025
1026 let target_a = outcome.targets.get(&pkg_a).unwrap();
1027 let target_b = outcome.targets.get(&pkg_b).unwrap();
1028
1029 assert_eq!(target_a, target_b);
1032 assert_eq!(target_a.render(), "2.0.0");
1033
1034 assert_eq!(
1035 outcome.reasons.get(&pkg_a),
1036 Some(&BumpReason::LinkedGroupUnion {
1037 group: GroupName("linked-pair".to_string()),
1038 })
1039 );
1040 }
1041
1042 #[test]
1047 fn test_linked_group_converges_target_version_across_divergent_bases() {
1048 let pkg_a = PackageId::parse("pkg-a").unwrap();
1049 let pkg_b = PackageId::parse("pkg-b").unwrap();
1050
1051 let graph = TwoPackageGraph {
1052 packages: vec![bare_package(&pkg_a), bare_package(&pkg_b)],
1053 };
1054
1055 let mut base = BTreeMap::new();
1056 base.insert(
1057 pkg_a.clone(),
1058 Version::parse("1.4.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1059 );
1060 base.insert(
1061 pkg_b.clone(),
1062 Version::parse("2.7.3", callisto_model::VersionGrammar::SemVer).unwrap(),
1063 );
1064
1065 let mut seed = BTreeMap::new();
1066 seed.insert(pkg_a.clone(), Severity::Minor);
1067
1068 let mut groups = GroupTable::default();
1069 let group_def = GroupDef {
1070 name: GroupName("linked-pair".to_string()),
1071 kind: GroupKind::Linked,
1072 members: vec![GroupMember::Package(pkg_a.clone()), GroupMember::Package(pkg_b.clone())],
1073 };
1074 groups.linked.insert(group_def.name.clone(), group_def);
1075
1076 let cfg = CascadeConfig {
1077 mode: CascadeMode::OutOfRange,
1078 bump_severity: CascadeBumpSeverity::Patch,
1079 peer_escalation: true,
1080 preserve_npm_ranges: false,
1081 };
1082
1083 let reasons = BTreeMap::new();
1084 let named_by = BTreeMap::new();
1085
1086 let input = CascadeInput {
1087 graph: &graph,
1088 groups: &groups,
1089 cfg: &cfg,
1090 seed: &seed,
1091 reasons: &reasons,
1092 named_by: &named_by,
1093 base: &base,
1094 pre: None,
1095 tags: &TagIndex::empty(),
1096 identity: &IdentityIndex::default(),
1097 };
1098
1099 let outcome = run_cascade(input).unwrap();
1100
1101 let target_a = outcome.targets.get(&pkg_a).unwrap();
1102 let target_b = outcome.targets.get(&pkg_b).unwrap();
1103
1104 assert_eq!(target_a, target_b);
1107 assert_eq!(target_a.render(), "2.8.0");
1108 }
1109
1110 #[test]
1119 fn test_bump_target_exit_mode_finalizes_to_stable() {
1120 let pkg_a = PackageId::parse("pkg-a").unwrap();
1121
1122 let graph = TwoPackageGraph {
1123 packages: vec![bare_package(&pkg_a)],
1124 };
1125
1126 let mut base = BTreeMap::new();
1128 base.insert(
1129 pkg_a.clone(),
1130 Version::parse("1.0.0-alpha.2", callisto_model::VersionGrammar::SemVer).unwrap(),
1131 );
1132
1133 let mut initial_versions = indexmap::IndexMap::new();
1134 initial_versions.insert(
1135 "pkg-a".to_string(),
1136 Version::parse("0.9.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1137 );
1138
1139 let pre = callisto_format::PreState {
1141 mode: callisto_format::PreMode::Exit,
1142 tag: "alpha".to_string(),
1143 initial_versions,
1144 changesets: Vec::new(),
1145 };
1146
1147 let groups = GroupTable::default();
1148 let cfg = CascadeConfig {
1149 mode: CascadeMode::OutOfRange,
1150 bump_severity: CascadeBumpSeverity::Patch,
1151 peer_escalation: true,
1152 preserve_npm_ranges: false,
1153 };
1154
1155 let seed = BTreeMap::new();
1156 let reasons = BTreeMap::new();
1157 let named_by = BTreeMap::new();
1158
1159 let input = CascadeInput {
1160 graph: &graph,
1161 groups: &groups,
1162 cfg: &cfg,
1163 seed: &seed,
1164 reasons: &reasons,
1165 named_by: &named_by,
1166 base: &base,
1167 pre: Some(&pre),
1168 tags: &TagIndex::empty(),
1169 identity: &IdentityIndex::default(),
1170 };
1171
1172 let target = bump_target(&pkg_a, Severity::Minor, &input).unwrap();
1173
1174 assert!(
1175 !target.is_prerelease(),
1176 "PreMode::Exit must produce a stable version, not a pre-release; got {}",
1177 target.render()
1178 );
1179 assert_eq!(
1180 target.render(),
1181 "1.0.0",
1182 "bump_target with Exit mode, base=1.0.0-alpha.2, sev=Minor must finalize to 1.0.0"
1183 );
1184 }
1185
1186 #[test]
1197 fn test_diamond_dependency_a_appears_exactly_once_in_cascade_result() {
1198 let pkg_a = PackageId::parse("pkg-a").unwrap();
1199 let pkg_b = PackageId::parse("pkg-b").unwrap();
1200 let pkg_c = PackageId::parse("pkg-c").unwrap();
1201 let pkg_d = PackageId::parse("pkg-d").unwrap();
1202
1203 let eco = callisto_model::Ecosystem::Cargo;
1211 let edges = vec![
1212 make_dep_edge(&pkg_b, &pkg_d, "^1.0.0", eco),
1213 make_dep_edge(&pkg_c, &pkg_d, "^1.0.0", eco),
1214 make_dep_edge(&pkg_a, &pkg_b, "=1.0.0", eco),
1215 make_dep_edge(&pkg_a, &pkg_c, "=1.0.0", eco),
1216 ];
1217
1218 let graph = TestGraph {
1219 packages: vec![
1220 bare_package(&pkg_a),
1221 bare_package(&pkg_b),
1222 bare_package(&pkg_c),
1223 bare_package(&pkg_d),
1224 ],
1225 edges,
1226 };
1227
1228 let mut base = BTreeMap::new();
1229 base.insert(pkg_a.clone(), Version::semver(1, 0, 0));
1230 base.insert(pkg_b.clone(), Version::semver(1, 0, 0));
1231 base.insert(pkg_c.clone(), Version::semver(1, 0, 0));
1232 base.insert(pkg_d.clone(), Version::semver(1, 0, 0));
1233
1234 let mut seed = BTreeMap::new();
1236 seed.insert(pkg_d.clone(), Severity::Major);
1237
1238 let cfg = CascadeConfig {
1243 mode: CascadeMode::OutOfRange,
1244 bump_severity: CascadeBumpSeverity::Patch,
1245 peer_escalation: false,
1246 preserve_npm_ranges: false,
1247 };
1248 let groups = crate::config::GroupTable::default();
1249 let reasons = BTreeMap::new();
1250 let named_by = BTreeMap::new();
1251
1252 let input = CascadeInput {
1253 graph: &graph,
1254 groups: &groups,
1255 cfg: &cfg,
1256 seed: &seed,
1257 reasons: &reasons,
1258 named_by: &named_by,
1259 base: &base,
1260 pre: None,
1261 tags: &TagIndex::empty(),
1262 identity: &IdentityIndex::default(),
1263 };
1264
1265 let outcome = run_cascade(input).unwrap();
1266
1267 assert!(outcome.severities.contains_key(&pkg_d), "D must be in outcome");
1269 assert!(outcome.severities.contains_key(&pkg_b), "B must cascade from D");
1270 assert!(outcome.severities.contains_key(&pkg_c), "C must cascade from D");
1271 assert!(
1272 outcome.severities.contains_key(&pkg_a),
1273 "A must cascade from B and C (both bumped to 2.0.0, out of ^1.0.0)"
1274 );
1275
1276 let a_severity_count = outcome.severities.keys().filter(|k| *k == &pkg_a).count();
1281 assert_eq!(
1282 a_severity_count, 1,
1283 "pkg-a must appear exactly once in severities map, got {a_severity_count}"
1284 );
1285
1286 let a_target_count = outcome.targets.keys().filter(|k| *k == &pkg_a).count();
1287 assert_eq!(
1288 a_target_count, 1,
1289 "pkg-a must appear exactly once in targets map, got {a_target_count}"
1290 );
1291
1292 assert_eq!(
1293 outcome.severities[&pkg_d],
1294 Severity::Major,
1295 "D must have Major severity (seeded directly)"
1296 );
1297 assert_eq!(
1298 outcome.severities[&pkg_a],
1299 Severity::Patch,
1300 "A's cascaded severity must be Patch (Patch cascade config)"
1301 );
1302 }
1303
1304 #[test]
1319 fn test_prerelease_cascade_stable_spec_out_of_range_triggers_cascade() {
1320 let pkg_b = PackageId::parse("pkg-b").unwrap();
1321 let pkg_d = PackageId::parse("pkg-d").unwrap();
1322
1323 let eco = callisto_model::Ecosystem::Cargo;
1324 let edges = vec![make_dep_edge(&pkg_b, &pkg_d, "^1.0.0", eco)];
1325
1326 let graph = TestGraph {
1327 packages: vec![bare_package(&pkg_b), bare_package(&pkg_d)],
1328 edges,
1329 };
1330
1331 let mut base = BTreeMap::new();
1332 base.insert(
1333 pkg_b.clone(),
1334 Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1335 );
1336 base.insert(
1337 pkg_d.clone(),
1338 Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1339 );
1340
1341 let mut initial_versions = indexmap::IndexMap::new();
1342 initial_versions.insert(
1343 "pkg-d".to_string(),
1344 Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1345 );
1346 initial_versions.insert(
1347 "pkg-b".to_string(),
1348 Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1349 );
1350
1351 let pre = callisto_format::PreState {
1352 mode: callisto_format::PreMode::Pre,
1353 tag: "alpha".to_string(),
1354 initial_versions,
1355 changesets: Vec::new(),
1356 };
1357
1358 let mut seed = BTreeMap::new();
1360 seed.insert(pkg_d.clone(), Severity::Major);
1361
1362 let cfg = CascadeConfig {
1363 mode: CascadeMode::OutOfRange,
1364 bump_severity: CascadeBumpSeverity::Patch,
1365 peer_escalation: false,
1366 preserve_npm_ranges: false,
1367 };
1368 let groups = crate::config::GroupTable::default();
1369 let reasons = BTreeMap::new();
1370 let named_by = BTreeMap::new();
1371
1372 let input = CascadeInput {
1373 graph: &graph,
1374 groups: &groups,
1375 cfg: &cfg,
1376 seed: &seed,
1377 reasons: &reasons,
1378 named_by: &named_by,
1379 base: &base,
1380 pre: Some(&pre),
1381 tags: &TagIndex::empty(),
1382 identity: &IdentityIndex::default(),
1383 };
1384
1385 let outcome = run_cascade(input).unwrap();
1386
1387 let d_target = outcome.targets.get(&pkg_d).expect("D must have a target");
1389 assert!(
1390 d_target.is_prerelease(),
1391 "D must bump to a pre-release version in PreMode::Pre; got {}",
1392 d_target.render()
1393 );
1394 assert!(
1395 d_target.render().starts_with("2.0.0-"),
1396 "D's pre-release bump from 1.0.0 with Major severity must start with 2.0.0-; got {}",
1397 d_target.render()
1398 );
1399
1400 assert!(
1402 outcome.severities.contains_key(&pkg_b),
1403 "B must be cascaded when D's pre-release version is out of range for ^1.0.0"
1404 );
1405 let b_target = outcome.targets.get(&pkg_b).expect("B must have a target");
1406 assert!(
1407 b_target.is_prerelease(),
1408 "B's cascade target in PreMode::Pre must also be a pre-release; got {}",
1409 b_target.render()
1410 );
1411 }
1412
1413 #[test]
1419 fn test_bump_target_uses_pinned_pre_baseline_not_current_prerelease() {
1420 let pkg_a = PackageId::parse("pkg-a").unwrap();
1421
1422 let graph = TwoPackageGraph {
1423 packages: vec![bare_package(&pkg_a)],
1424 };
1425
1426 let mut base = BTreeMap::new();
1433 base.insert(
1434 pkg_a.clone(),
1435 Version::parse("2.0.0-next.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1436 );
1437
1438 let mut initial_versions = indexmap::IndexMap::new();
1439 initial_versions.insert(
1440 "pkg-a".to_string(),
1441 Version::parse("1.0.0", callisto_model::VersionGrammar::SemVer).unwrap(),
1442 );
1443 let pre = callisto_format::PreState {
1444 mode: callisto_format::PreMode::Pre,
1445 tag: "next".to_string(),
1446 initial_versions,
1447 changesets: Vec::new(),
1448 };
1449
1450 let groups = GroupTable::default();
1451 let cfg = CascadeConfig {
1452 mode: CascadeMode::OutOfRange,
1453 bump_severity: CascadeBumpSeverity::Patch,
1454 peer_escalation: true,
1455 preserve_npm_ranges: false,
1456 };
1457
1458 let seed = BTreeMap::new();
1459 let reasons = BTreeMap::new();
1460 let named_by = BTreeMap::new();
1461
1462 let input = CascadeInput {
1463 graph: &graph,
1464 groups: &groups,
1465 cfg: &cfg,
1466 seed: &seed,
1467 reasons: &reasons,
1468 named_by: &named_by,
1469 base: &base,
1470 pre: Some(&pre),
1471 tags: &TagIndex::empty(),
1472 identity: &IdentityIndex::default(),
1473 };
1474
1475 let target = bump_target(&pkg_a, Severity::Minor, &input).unwrap();
1476
1477 assert_eq!(target.render(), "1.1.0-next.0");
1478 }
1479
1480 #[test]
1487 fn inherited_cargo_dependency_edge_never_produces_manifest_rewrite_target() {
1488 let pkg_root = PackageId::parse("cargo:root-crate").unwrap();
1489 let pkg_member = PackageId::parse("cargo:member-crate").unwrap();
1490 let pkg_dep = PackageId::parse("cargo:shared-dep").unwrap();
1491
1492 let inherited_edge = DepEdge {
1493 from: pkg_root.clone(),
1494 to: pkg_dep.clone(),
1495 kind: DepKind::Runtime,
1496 spec: DepSpec::Range(
1497 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1498 "^1.0.0".to_string(),
1499 ),
1500 from_manifest: std::path::PathBuf::from("Cargo.toml"),
1501 inherited: true,
1502 };
1503 let non_inherited_edge = DepEdge {
1504 from: pkg_member.clone(),
1505 to: pkg_dep.clone(),
1506 kind: DepKind::Runtime,
1507 spec: DepSpec::Range(
1508 callisto_model::VersionReq::parse("^1.0.0", callisto_model::Ecosystem::Cargo).unwrap(),
1509 "^1.0.0".to_string(),
1510 ),
1511 from_manifest: std::path::PathBuf::from("member/Cargo.toml"),
1512 inherited: false,
1513 };
1514
1515 let graph = TestGraph {
1516 packages: vec![
1517 bare_package(&pkg_root),
1518 bare_package(&pkg_member),
1519 bare_package(&pkg_dep),
1520 ],
1521 edges: vec![inherited_edge, non_inherited_edge],
1522 };
1523
1524 let mut base = BTreeMap::new();
1525 base.insert(pkg_root.clone(), Version::semver(1, 0, 0));
1526 base.insert(pkg_member.clone(), Version::semver(1, 0, 0));
1527 base.insert(pkg_dep.clone(), Version::semver(1, 0, 0));
1528
1529 let mut seed = BTreeMap::new();
1530 seed.insert(pkg_dep.clone(), Severity::Major);
1531
1532 let groups = GroupTable::default();
1533 let cfg = CascadeConfig {
1534 mode: CascadeMode::OutOfRange,
1535 bump_severity: CascadeBumpSeverity::Patch,
1536 peer_escalation: true,
1537 preserve_npm_ranges: false,
1538 };
1539 let reasons = BTreeMap::new();
1540 let named_by = BTreeMap::new();
1541
1542 let input = CascadeInput {
1543 graph: &graph,
1544 groups: &groups,
1545 cfg: &cfg,
1546 seed: &seed,
1547 reasons: &reasons,
1548 named_by: &named_by,
1549 base: &base,
1550 pre: None,
1551 tags: &TagIndex::empty(),
1552 identity: &IdentityIndex::default(),
1553 };
1554
1555 let outcome = run_cascade(input).unwrap();
1556
1557 let has_manifest_target_for_inherited = outcome.rewrites.values().any(|r| {
1558 r.key.name == pkg_dep.name()
1559 && matches!(&r.key.target, DepWriteTarget::Manifest(p) if p == &PathBuf::from("Cargo.toml"))
1560 });
1561 assert!(
1562 !has_manifest_target_for_inherited,
1563 "inherited edge must never produce DepWriteTarget::Manifest; rewrites: {:?}",
1564 outcome.rewrites
1565 );
1566
1567 let has_workspace_dep_target_for_inherited = outcome.rewrites.values().any(|r| {
1568 r.key.name == pkg_dep.name()
1569 && r.key.target
1570 == DepWriteTarget::CargoWorkspaceDependency {
1571 root_manifest: PathBuf::from("Cargo.toml"),
1572 }
1573 });
1574 assert!(
1575 has_workspace_dep_target_for_inherited,
1576 "inherited edge must produce a CargoWorkspaceDependency rewrite target; rewrites: {:?}",
1577 outcome.rewrites
1578 );
1579
1580 let has_manifest_target_for_non_inherited = outcome
1581 .rewrites
1582 .values()
1583 .any(|r| matches!(&r.key.target, DepWriteTarget::Manifest(p) if p == &PathBuf::from("member/Cargo.toml")));
1584 assert!(
1585 has_manifest_target_for_non_inherited,
1586 "non-inherited edge must produce a DepWriteTarget::Manifest rewrite; rewrites: {:?}",
1587 outcome.rewrites
1588 );
1589 }
1590
1591 #[test]
1600 fn test_fixed_group_seeded_siblings_converge_on_shared_target() {
1601 use std::sync::atomic::{AtomicUsize, Ordering};
1602
1603 struct FakeGitTagRunner {
1604 calls: AtomicUsize,
1605 tags: Vec<String>,
1606 }
1607 impl callisto_model::CommandRunner for FakeGitTagRunner {
1608 fn run(
1609 &self,
1610 program: &str,
1611 args: &[&str],
1612 _cwd: &std::path::Path,
1613 ) -> Result<callisto_model::CommandOutput, callisto_model::CommandError> {
1614 assert_eq!(program, "git");
1615 assert_eq!(args, ["tag", "--list"]);
1616 self.calls.fetch_add(1, Ordering::SeqCst);
1617 Ok(callisto_model::CommandOutput {
1618 exit_code: Some(0),
1619 stdout: self.tags.join("\n"),
1620 stderr: String::new(),
1621 })
1622 }
1623 }
1624
1625 let pkg_a = PackageId::parse("pkg-a").unwrap();
1626 let pkg_b = PackageId::parse("pkg-b").unwrap();
1627
1628 fn package_with_canonical_manifest(id: &PackageId) -> Package {
1634 let manifest = callisto_model::ManifestDecl::new(
1635 "Cargo.toml",
1636 callisto_model::ManifestRole::Canonical,
1637 callisto_model::ManifestFormat::CargoToml,
1638 )
1639 .unwrap();
1640 Package {
1641 id: id.clone(),
1642 manifests: vec![manifest],
1643 changelog: None,
1644 release_trigger: ReleaseTrigger::Changeset,
1645 publish_to: Vec::new(),
1646 tag_template: None,
1647 }
1648 }
1649
1650 let graph = TwoPackageGraph {
1651 packages: vec![
1652 package_with_canonical_manifest(&pkg_a),
1653 package_with_canonical_manifest(&pkg_b),
1654 ],
1655 };
1656
1657 let tmp = tempfile::tempdir().unwrap();
1658 let dir = tmp.path();
1659 assert!(
1660 callisto_vcs::GitRepository::discover(dir).is_err(),
1661 "fixture dir must not be a discoverable git repo, forcing the CommandRunner fallback"
1662 );
1663 let runner = FakeGitTagRunner {
1664 calls: AtomicUsize::new(0),
1665 tags: vec!["pkg-a@2.0.0".to_string()],
1666 };
1667 let git = callisto_vcs::GitAccess::discover(dir, &runner);
1668 let cfg_resolved = crate::config::load(dir).unwrap();
1669 let tags = crate::tags::TagIndex::build(&git, &graph, &cfg_resolved).unwrap();
1670
1671 assert!(tags.last_tag(&pkg_a).is_some(), "A must have a prior release tag");
1672 assert!(tags.last_tag(&pkg_b).is_none(), "B must never have been released");
1673
1674 let mut base = BTreeMap::new();
1675 base.insert(pkg_a.clone(), Version::semver(2, 0, 0));
1676 base.insert(pkg_b.clone(), Version::semver(0, 1, 0));
1677
1678 let mut seed = BTreeMap::new();
1679 seed.insert(pkg_a.clone(), Severity::Minor);
1680 seed.insert(pkg_b.clone(), Severity::Minor);
1681
1682 let group_def = GroupDef {
1683 name: GroupName("ab-fixed".to_string()),
1684 kind: GroupKind::Fixed,
1685 members: vec![GroupMember::Package(pkg_a.clone()), GroupMember::Package(pkg_b.clone())],
1686 };
1687 let groups = GroupTable::from_groups(vec![group_def], vec![]);
1688
1689 let cfg = CascadeConfig {
1690 mode: CascadeMode::OutOfRange,
1691 bump_severity: CascadeBumpSeverity::Patch,
1692 peer_escalation: true,
1693 preserve_npm_ranges: false,
1694 };
1695 let reasons = BTreeMap::new();
1696 let named_by = BTreeMap::new();
1697
1698 let input = CascadeInput {
1699 graph: &graph,
1700 groups: &groups,
1701 cfg: &cfg,
1702 seed: &seed,
1703 reasons: &reasons,
1704 named_by: &named_by,
1705 base: &base,
1706 pre: None,
1707 tags: &tags,
1708 identity: &IdentityIndex::default(),
1709 };
1710
1711 let outcome = run_cascade(input).unwrap();
1712
1713 let target_a = outcome.targets.get(&pkg_a).unwrap();
1714 let target_b = outcome.targets.get(&pkg_b).unwrap();
1715
1716 assert_eq!(
1717 target_a.render(),
1718 "2.1.0",
1719 "A (the released member) must land on the group-aligned target"
1720 );
1721 assert_eq!(
1722 target_b.render(),
1723 "2.1.0",
1724 "B must converge on the SAME target as A, not its own independently-bumped 0.2.0"
1725 );
1726 assert_eq!(target_a, target_b);
1727 }
1728}