Skip to main content

callisto_graph/
cascade.rs

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    // Pre-release versions are never covered by a caret range: ^1.2.3 must not
110    // satisfy 1.9.0-alpha.1 even though 1.9.0-alpha.1 > 1.2.3 and they share
111    // the same major. Caret ranges cover stable releases within the allowed
112    // major/minor bounds only; a pre-release of an in-range stable version is
113    // not itself a stable release and must not be treated as covered.
114    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
155/// Trait mirror of [`solve_cascade`]'s signature — no implementation exists in this crate
156/// today (the free function is called directly by every current caller); kept as a seam for
157/// a future test double the same way [`DependencyResolver`] is, rather than a live extension
158/// point.
159pub 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
167/// Propagates `input.seed`'s severities outward to dependents until no
168/// target version changes, or `convergence_bound`'s iteration cap is
169/// exceeded ([`GraphError::CascadeNotConverged`]) -- turns a would-be
170/// infinite loop into a reportable bug, bound derived from package count.
171///
172/// One pass: pop a package, compute its bumped target, walk every
173/// dependent edge, apply [`cascade_action`] to decide if that dependent's
174/// severity/target changes and gets re-queued.
175///
176/// A genuine cross-grammar coverage failure (`coverage`'s `Err`, e.g.
177/// SemVer target vs. PEP440 spec) aborts the whole cascade with
178/// `Err(GraphError::GrammarMismatch)`. Soft instead: `Catalog`/`Opaque`
179/// specs never have coverage tested (`coverage` returns `Unknown`
180/// unconditionally), so they can't block anything -- `Catalog` gets
181/// [`DiagnosticCode::CatalogSpecNotRewritten`], `Opaque` gets no
182/// diagnostic. [`DiagnosticCode::RangeNotRoundTrippable`]'s real trigger
183/// is a different step: [`rewrite_spec`], when a mechanical range rewrite
184/// (not a coverage test) fails on an otherwise-known-coverage spec.
185pub 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        // Spec §G.6.7: Linked group release severity propagation
304        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        // Track 1: Fixed group convergence -- mirrors the Linked-group block
366        // above but computes each group's shared target via
367        // fixed_group_target instead of taking the max of independently
368        // bumped per-member candidates.
369        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            // PreMode::Exit: finalize the current pre-release to a stable version.
436            // Bumping the on-disk pre-release version (e.g. "1.0.0-alpha.2") with
437            // any severity that matches the pre-release target strips the tag and
438            // produces the stable version (e.g. "1.0.0").
439            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    /// §13 invariant 9 / §7.4 row 5 vs row 4: peer-dependency escalation to
570    /// `Severity::Major` must only fire when the upstream (source) severity
571    /// is non-patch (Minor or Major). A patch-severity source that leaves a
572    /// peer spec out of range falls through to row 4 (`cfg.bump_severity`),
573    /// not a manufactured Major bump -- `cascade_action`'s `source` parameter
574    /// must actually gate the escalation, not just be accepted and ignored.
575    #[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        // Patch-severity source: must NOT escalate to Major (row 4).
585        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        // Minor-severity source: MUST escalate to Major (row 5).
602        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        // Major-severity source: MUST also escalate to Major (row 5).
612        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        // None-severity source: provably unreachable through run_cascade (every
617        // write site into the severities map gates on `sev > Severity::None`
618        // before inserting), but the match arm falls through to row 4 exactly
619        // like Patch if it ever were reached -- exhaustive over the enum
620        // rather than relying on that untested cross-function invariant.
621        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    // -------------------------------------------------------------------------
627    // T06: caret range must not cover pre-release versions
628    // -------------------------------------------------------------------------
629
630    /// Spec: caret_covers(cur, new) must return false when `new` is a pre-release
631    /// version, regardless of whether the major/minor bounds would otherwise be
632    /// satisfied. "^1.2.3" should not be satisfied by "1.9.0-alpha.1" even though
633    /// 1.9.0-alpha.1 > 1.2.3 and they share the same major version.
634    #[test]
635    fn caret_covers_excludes_prerelease_versions() {
636        let v123 = Version::semver(1, 2, 3);
637
638        // Pre-release with same major: must be false even though major matches
639        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        // Pre-release with different major: also false (different major anyway)
647        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        // Stable version within same major: must still be true
654        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    /// In-memory graph that supports arbitrary edges for cascade tests.
712    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            // Return edges where `from == id` (id depends on the target).
724            self.edges.iter().filter(move |e| &e.from == id)
725        }
726
727        fn dependents_of(&self, id: &PackageId) -> impl Iterator<Item = &DepEdge> {
728            // Return edges where `to == id` (i.e. the caller depends on id).
729            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    /// AC-001/AC-002: a dual-identity rewrite target registered under two
765    /// different native names per ecosystem must produce a RewriteKey whose
766    /// `name` is the name registered for the DEPENDENT's own ecosystem, not
767    /// PackageId::name() (the target's own native-ecosystem registration).
768    /// Exercised through the real run_cascade -> solve_cascade call path, not a
769    /// direct, isolated call to IdentityIndex::native_name.
770    #[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    /// AC-003: when IdentityIndex::native_name(&target, dependent_eco) returns
849    /// None (the common, non-dual-identity case), RewriteKey.name must equal
850    /// PackageId::name() exactly as it did before this fix, for a Prefixed
851    /// target with no registration.
852    #[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    /// AC-007: same fallback, exercised for a PackageId::Bare target with no
909    /// registration, confirming the Bare variant is handled identically to the
910    /// Prefixed None-registration case in AC-003.
911    #[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    /// Spec §G.6.7: a severity bump landing on a single member of a linked
967    /// group (e.g. via a changeset naming only that package) must propagate
968    /// to every other member of the group, and every member must converge
969    /// on a stable target version.
970    #[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        // Only pkg_b receives a severity via a changeset; pkg_a starts
984        // completely unseeded.
985        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        // pkg_a was never seeded directly; it must pick up pkg_b's severity
1022        // purely through linked-group union.
1023        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        // Both members share the same base version, so convergence must
1030        // land them on the exact same target version.
1031        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    /// Spec §G.6.7: linked group members with *different* base versions must
1043    /// converge on the SAME winning target version (the max of each member's
1044    /// individually-computed candidate at the converged severity), not just
1045    /// the same severity.
1046    #[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        // pkg_a at minor from 1.4.0 -> 1.5.0; pkg_b at minor from 2.7.3 -> 2.8.0.
1105        // The winner (max by Version::compare) is 2.8.0 and both must converge on it.
1106        assert_eq!(target_a, target_b);
1107        assert_eq!(target_a.render(), "2.8.0");
1108    }
1109
1110    /// When `pre.mode == PreMode::Exit`, `bump_target` must finalize the current
1111    /// pre-release to a stable version instead of producing another pre-release
1112    /// iteration. The test uses `base = "1.0.0-alpha.2"` (current on-disk prerelease)
1113    /// and asserts the result is the stable `"1.0.0"`, not another `-alpha.N`.
1114    ///
1115    /// Calling `versioning.bump` on a pre-release version strips the pre-release
1116    /// tag and finalizes in-place when the underlying release segment already
1117    /// matches the requested bump (patch=0, minor=0 for a minor bump).
1118    #[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        // On-disk version is at a major pre-release (1.0.0-alpha.2).
1127        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        // PreMode::Exit: the release cycle is being finalized.
1140        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    /// Diamond dependency: A -> B, A -> C, B -> D, C -> D.
1187    ///
1188    /// Bumping D (seeded Major) cascades both B and C; A depends on both,
1189    /// so the solver visits A from two paths. The worklist must process A
1190    /// only ONCE -- not duplicated in the outcome's severity/target maps
1191    /// despite two edges arriving at A.
1192    ///
1193    /// The idempotency check `sev <= cur_sev` in `raise` already prevents
1194    /// double-insertion without explicit dedup, but this test makes the
1195    /// constraint explicit and guards against regressions if dedup breaks.
1196    #[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        // Edges: B→D, C→D, A→B, A→C
1204        // B and C use "^1.0.0" for D; A uses exact "=1.0.0" for B and C.
1205        // With CascadeBumpSeverity::Patch, when D is seeded Major (→ 2.0.0):
1206        //   - B and C cascade with Patch (^1.0.0 doesn't cover 2.0.0) → both become 1.0.1
1207        //   - A's "=1.0.0" on B (now 1.0.1) is out of range → A cascades from B
1208        //   - A's "=1.0.0" on C (now 1.0.1) is out of range → A cascades from C
1209        // Both paths reach A; the dedup invariant is that A appears exactly once.
1210        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        // Only D is seeded — all others propagate via cascade.
1235        let mut seed = BTreeMap::new();
1236        seed.insert(pkg_d.clone(), Severity::Major);
1237
1238        // Patch cascade severity. When D is seeded Major (→ 2.0.0), B and C's
1239        // "^1.0.0" specs don't cover 2.0.0, so they cascade at Patch (→ 1.0.1).
1240        // A's exact "=1.0.0" specs on B and C don't cover 1.0.1, so A cascades
1241        // from both paths. The dedup invariant ensures A appears exactly once.
1242        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        // D is seeded, B and C cascade from D, A cascades from both B and C.
1268        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        // The critical deduplication check: A must appear exactly ONCE even
1277        // though two separate cascade paths (via B and via C) both reach A.
1278        // BTreeMap guarantees at most one entry per key; iterating and
1279        // filtering by key yields exactly 1.
1280        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    /// Pre-release cascade: when D bumps to a pre-release version (via
1305    /// `PreMode::Pre`) and a dependent B has a stable-only spec that does NOT
1306    /// cover the pre-release version, B is cascaded and its target is also a
1307    /// pre-release (because `bump_target` in `PreMode::Pre` calls
1308    /// `bump_prerelease`). This test documents and pins that behavior.
1309    ///
1310    /// The concrete scenario:
1311    ///   - D is at 1.0.0, seeded with Major severity in pre-release mode
1312    ///   - D bumps to 2.0.0-alpha.0
1313    ///   - B depends on D with "^1.0.0" (stable-only)
1314    ///   - "^1.0.0" does NOT cover 2.0.0-alpha.0 (different major, and cargo
1315    ///     semver excludes pre-releases from stable ranges)
1316    ///   - B IS cascaded (DoesNotCover → cascade) and also bumps to a
1317    ///     pre-release version
1318    #[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        // D is seeded with Major; in PreMode::Pre it bumps to 2.0.0-alpha.0.
1359        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        // D bumps to a pre-release version.
1388        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        // B is cascaded because ^1.0.0 does not cover 2.0.0-alpha.0.
1401        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    /// A pre-release cycle must bump from the pinned baseline in
1414    /// `PreState.initial_versions` (captured at `pre enter`), not from
1415    /// whatever the current on-disk prerelease happens to be -- otherwise
1416    /// the release segment re-derives from a moving target instead of the
1417    /// pinned one.
1418    #[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        // On-disk is already one prerelease bump into a MAJOR cycle
1427        // (2.0.0-next.0), while the pinned baseline from `pre enter` is
1428        // still 1.0.0. Requesting a Minor bump now must be computed from
1429        // the pinned baseline (-> release 1.1.0), not by re-deriving from
1430        // the already-major-bumped on-disk value (which would incorrectly
1431        // treat 2.0.0-next.0 as still-in-progress and yield 2.0.0-next.1).
1432        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    /// AC-017: solve_cascade's rewrite-construction logic must never route an
1481    /// inherited Cargo dependency edge through `DepWriteTarget::Manifest` --
1482    /// it must always route through `DepWriteTarget::CargoWorkspaceDependency`
1483    /// instead. This is the cross-module invariant that CargoToml's own
1484    /// self-delegation guard (AC-018) relies on being true of real planner
1485    /// output.
1486    #[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    /// AC-001/AC-002/AC-004/AC-013(bug1): Fixed-group members seeded
1592    /// directly (not via raise()) must converge on ONE shared,
1593    /// group-aligned target version computed by fixed_group_target, not on
1594    /// independently-bumped per-member targets. Exercised through the real
1595    /// call path run_cascade -> solve_cascade -> the new Fixed-group block
1596    /// -> fixed_group_target -- fixed_group_target is never called
1597    /// directly, and raise() is never invoked (both siblings arrive via
1598    /// input.seed).
1599    #[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        // TagIndex::build requires each package to resolve a version
1629        // grammar via a canonical manifest (Package::version_grammar);
1630        // bare_package's empty `manifests` cannot satisfy that, so this
1631        // fixture attaches a canonical Cargo.toml manifest to each package
1632        // -- the fixture is otherwise identical to bare_package.
1633        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}