Skip to main content

callisto_graph/
aggregate.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use callisto_changelog::{ChangeSource, ChangelogEntry, ChangelogInput};
6use callisto_format::{parse_changeset, Changeset};
7use callisto_model::{BumpReason, CommitSha, Diagnostic, Package, PackageId, Severity, Version};
8use callisto_vcs::{GitAccess, GitDataSource};
9
10use crate::config::resolve::resolve_package_config;
11use crate::config::GroupTable;
12use crate::config::{PreMajorInferencePolicy, ResolvedConfig};
13use crate::error::GraphError;
14use crate::infer::SeverityInference;
15use crate::resolver::DependencyResolver;
16use crate::tags::TagIndex;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct LoadedChangeset {
20    pub path: PathBuf,
21    pub id: String,
22    pub changeset: Changeset,
23}
24
25#[derive(Clone, Debug, Default)]
26pub struct Aggregation {
27    pub severities: BTreeMap<PackageId, Severity>,
28    pub reasons: BTreeMap<PackageId, BumpReason>,
29    pub named_by: BTreeMap<PackageId, NamedBy>,
30    pub consumed: Vec<PathBuf>,
31    pub changelog_inputs: BTreeMap<PackageId, ChangelogInput>,
32    pub inference_commits: BTreeMap<PackageId, Vec<(CommitSha, String)>>,
33    pub diagnostics: Vec<Diagnostic>,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum NamedBy {
38    Changeset,
39    Inference,
40}
41
42pub fn load_changesets(root: &Path, cfg: &ResolvedConfig) -> Result<Vec<LoadedChangeset>, GraphError> {
43    let dir = root.join(&cfg.changesets_dir);
44    if !dir.exists() {
45        return Ok(Vec::new());
46    }
47
48    let entries = fs::read_dir(&dir).map_err(|e| callisto_model::ManifestError::Read {
49        path: dir.clone(),
50        message: e.to_string(),
51    })?;
52
53    let mut files = Vec::new();
54    for entry in entries.flatten() {
55        let path = entry.path();
56        if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
57            if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
58                if file_name != "README.md" && file_name != "config.json" && file_name != "pre.json" {
59                    files.push(path);
60                }
61            }
62        }
63    }
64
65    files.sort();
66
67    let mut loaded = Vec::new();
68    for path in files {
69        let content = fs::read_to_string(&path).map_err(|e| callisto_model::ManifestError::Read {
70            path: path.clone(),
71            message: e.to_string(),
72        })?;
73        let changeset = parse_changeset(&content).map_err(|e| GraphError::ParseChangeset {
74            path: path.clone(),
75            source: e,
76        })?;
77        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
78        let rel_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
79        loaded.push(LoadedChangeset {
80            path: rel_path,
81            id: stem,
82            changeset,
83        });
84    }
85
86    Ok(loaded)
87}
88
89pub fn apply_pre_major(
90    inferred: Severity,
91    policy: PreMajorInferencePolicy,
92    current: &Version,
93    has_prior_release: bool,
94) -> (Severity, bool) {
95    if policy == PreMajorInferencePolicy::Off {
96        return (inferred, false);
97    }
98    if current.major() != Some(0) || current.minor() == Some(0) || !has_prior_release {
99        return (inferred, false);
100    }
101
102    match (policy, inferred) {
103        (PreMajorInferencePolicy::Conservative | PreMajorInferencePolicy::ConservativeFeat, Severity::Major) => {
104            (Severity::Minor, true)
105        }
106        (PreMajorInferencePolicy::ConservativeFeat, Severity::Minor) => (Severity::Patch, true),
107        (_, s) => (s, false),
108    }
109}
110
111/// Resolves a release tag name to the commit SHA it points at, so that
112/// severity inference can be scoped to `since..HEAD` instead of walking the
113/// entire history on every `aggregate()`-driven command.
114///
115/// Thin wrapper around [`GitDataSource::resolve_commit`] (native gix,
116/// falling back to a `CommandRunner`-shelled `git rev-parse` when gix is
117/// unavailable -- most notably on `wasm32`): any failure to resolve the tag
118/// (missing, unborn repo, etc.) degrades gracefully to `None`, which
119/// callers treat as "infer over full history" -- the same behavior this
120/// function has always had, now delegated to [`GitAccess`] instead of
121/// hand-rolling the gix-then-runner-fallback shape itself.
122fn resolve_since(git: &impl GitDataSource, tag_name: &str) -> Option<CommitSha> {
123    git.resolve_commit(tag_name).ok().flatten()
124}
125
126/// Resolves a changeset entry's parsed `PackageId` against the packages in
127/// the graph.
128///
129/// `PackageId::matches` is pairwise: a bare id and a prefixed id with the
130/// same name are compatible, since bare doesn't specify an ecosystem. But
131/// a polyglot workspace can legitimately have the same name in two-plus
132/// ecosystems (`cargo/foo`, `npm/foo`), and a bare `foo` can't resolve to
133/// either without more context. `.find()` over such a graph would silently
134/// pick whichever candidate comes first -- the ambiguity bug this function
135/// fixes by collecting *all* matches and only succeeding when there's
136/// exactly one.
137///
138/// `Ok(None)`: no match (unknown package, reported separately by
139/// `validate`). `Ok(Some(pkg))`: unambiguous. `Err(AmbiguousName)`: two or
140/// more matches.
141pub(crate) fn resolve_target_package<'a>(
142    packages: impl Iterator<Item = &'a Package>,
143    id: &PackageId,
144) -> Result<Option<&'a Package>, GraphError> {
145    id.resolve_unique(packages, |p| &p.id)
146        .map_err(|candidates| GraphError::AmbiguousName {
147            name: id.display_name(),
148            candidates: candidates.iter().map(|p| p.id.clone()).collect(),
149        })
150}
151
152pub fn aggregate<D, I>(
153    graph: &D,
154    config: &ResolvedConfig,
155    git: &GitAccess<'_>,
156    tags: &TagIndex,
157    base_versions: &BTreeMap<PackageId, Version>,
158    pre: Option<&callisto_format::PreState>,
159    inference: &I,
160) -> Result<Aggregation, GraphError>
161where
162    D: DependencyResolver,
163    I: SeverityInference,
164{
165    let loaded = load_changesets(&config.root, config)?;
166    let mut agg = Aggregation::default();
167
168    // `git` is shared with the caller (a single `Workspace`-scoped
169    // `GitAccess`, via `Workspace::git_access`) rather than discovered
170    // fresh here, so a caller resolving both this and e.g. a head SHA in
171    // the same command invocation only pays for one discovery. A
172    // resolution failure degrades gracefully to `None`, same as
173    // `resolve_since`'s own per-tag failure handling.
174
175    for pkg in graph.packages() {
176        let cur_sev = agg.severities.get(&pkg.id).copied().unwrap_or(Severity::None);
177        let pathspecs: Vec<PathBuf> = pkg.manifests.iter().map(|m| m.path.clone()).collect();
178        let last_tag = tags.last_tag(&pkg.id);
179        let cur_ver = last_tag
180            .map(|t| t.version.clone())
181            .or_else(|| base_versions.get(&pkg.id).cloned())
182            .ok_or_else(|| {
183                GraphError::Manifest(callisto_model::ManifestError::MissingField {
184                    path: pkg.manifests.first().map(|m| m.path.clone()).unwrap_or_default(),
185                    field: "version",
186                })
187            })?;
188
189        let since = last_tag.and_then(|t| resolve_since(git, t.name.as_str()));
190
191        let policy = resolve_package_config(&pkg.id, config)?
192            .and_then(|pcfg| pcfg.pre_major_inference)
193            .unwrap_or(PreMajorInferencePolicy::Off);
194
195        let window = crate::infer::InferenceWindowSpec {
196            pathspecs: &pathspecs,
197            since,
198            current_version: &cur_ver,
199            has_prior_release: last_tag.is_some(),
200            policy,
201        };
202
203        match inference.infer(pkg, git, window) {
204            Ok(Some(outcome)) => {
205                if outcome.severity > cur_sev {
206                    agg.severities.insert(pkg.id.clone(), outcome.severity);
207                    agg.reasons.insert(
208                        pkg.id.clone(),
209                        BumpReason::Inference {
210                            commits: outcome.commit_count,
211                            remapped: outcome.remapped,
212                        },
213                    );
214                    agg.named_by.insert(pkg.id.clone(), NamedBy::Inference);
215                    agg.inference_commits.insert(pkg.id.clone(), outcome.commits.clone());
216                }
217            }
218            Ok(None) => {}
219            Err(e) => {
220                agg.diagnostics.push(Diagnostic {
221                    code: callisto_model::DiagnosticCode::PreMajorInferenceInert,
222                    severity: callisto_model::DiagnosticSeverity::Warning,
223                    message: format!("Commit inference failed for package `{}`: {e}", pkg.id.display_name()),
224                    package: Some(pkg.id.clone()),
225                    path: None,
226                    governed_by: None,
227                    escalated_by: None,
228                });
229            }
230        }
231    }
232
233    // During a pre-release cycle (PreMode::Pre) changesets must NOT be consumed:
234    // they remain on disk so they can be re-applied when the cycle exits.
235    let is_pre_mode = pre.map(|s| s.mode == callisto_format::PreMode::Pre).unwrap_or(false);
236
237    for cs in loaded {
238        // Defer adding to `consumed` until after we confirm at least one entry
239        // resolved to a real workspace package.  A changeset where every entry
240        // names a removed package must NOT be consumed (which would delete it
241        // on disk); instead, an UnknownPackage diagnostic is emitted and the
242        // file is left for the user to clean up manually.
243        let mut matched_any = false;
244        for entry in cs.changeset.entries {
245            let id = match PackageId::parse(&entry.name) {
246                Ok(id) => id,
247                Err(_) => {
248                    agg.diagnostics.push(Diagnostic {
249                        code: callisto_model::DiagnosticCode::UnknownPackage,
250                        severity: callisto_model::DiagnosticSeverity::Warning,
251                        message: format!(
252                            "Changeset `{}` contains invalid package name `{}`",
253                            cs.path.display(),
254                            entry.name
255                        ),
256                        package: None,
257                        path: Some(cs.path.clone()),
258                        governed_by: None,
259                        escalated_by: None,
260                    });
261                    continue;
262                }
263            };
264            match resolve_target_package(graph.packages(), &id)? {
265                Some(target_pkg) => {
266                    matched_any = true;
267                    let canonical_id = target_pkg.id.clone();
268                    let cur_sev = agg.severities.get(&canonical_id).copied().unwrap_or(Severity::None);
269                    if entry.severity > cur_sev {
270                        agg.severities.insert(canonical_id.clone(), entry.severity);
271                        agg.reasons.insert(
272                            canonical_id.clone(),
273                            BumpReason::Changeset {
274                                changesets: vec![cs.id.clone()],
275                            },
276                        );
277                        agg.named_by.insert(canonical_id.clone(), NamedBy::Changeset);
278                    }
279
280                    if entry.severity != Severity::None {
281                        // In pre-release mode use the pre-cycle entry version as the
282                        // changelog "from" baseline so the log covers the full pre
283                        // range rather than reflecting live (pre-tagged) versions.
284                        let pkg_ver = if is_pre_mode {
285                            pre.and_then(|s| s.initial_versions.get(&canonical_id.display_name()))
286                                .cloned()
287                                .or_else(|| base_versions.get(&canonical_id).cloned())
288                                .unwrap_or_else(|| Version::semver(0, 0, 0))
289                        } else {
290                            tags.last_tag(&canonical_id)
291                                .map(|t| t.version.clone())
292                                .or_else(|| base_versions.get(&canonical_id).cloned())
293                                .unwrap_or_else(|| Version::semver(0, 0, 0))
294                        };
295                        let cl_input =
296                            agg.changelog_inputs
297                                .entry(canonical_id.clone())
298                                .or_insert_with(|| ChangelogInput {
299                                    package: canonical_id.clone(),
300                                    from: pkg_ver,
301                                    to: None,
302                                    entries: Vec::new(),
303                                });
304                        cl_input.entries.push(ChangelogEntry {
305                            severity: entry.severity,
306                            source: ChangeSource::Changeset {
307                                filename: cs.id.clone(),
308                                summary: cs.changeset.summary.clone(),
309                            },
310                        });
311                    }
312                }
313                None => {
314                    // Entry references a package not in the workspace (e.g. a
315                    // package that was removed since the changeset was written).
316                    // Emit a diagnostic so the user knows, but do NOT count
317                    // this as a match -- a fully-orphaned changeset stays on
318                    // disk rather than being silently deleted.
319                    agg.diagnostics.push(Diagnostic {
320                        code: callisto_model::DiagnosticCode::UnknownPackage,
321                        severity: callisto_model::DiagnosticSeverity::Warning,
322                        message: format!(
323                            "Changeset `{}` references package `{}` which is not in the \
324                             workspace; the changeset will not be consumed until this entry \
325                             is resolved",
326                            cs.path.display(),
327                            entry.name
328                        ),
329                        package: None,
330                        path: Some(cs.path.clone()),
331                        governed_by: None,
332                        escalated_by: None,
333                    });
334                }
335            }
336        }
337        // Only mark as consumed when at least one entry resolved to a real
338        // package AND we are not in a pre-release cycle.  During pre mode the
339        // changeset files must stay on disk so they can be re-applied on exit.
340        // A fully-orphaned changeset is also left on disk regardless of mode.
341        if matched_any && !is_pre_mode {
342            agg.consumed.push(cs.path.clone());
343        }
344    }
345
346    loop {
347        let mut changed = false;
348        if union_fixed(&mut agg, &config.groups, base_versions) {
349            changed = true;
350        }
351        if union_linked(&mut agg, &config.groups, base_versions) {
352            changed = true;
353        }
354        if !changed {
355            break;
356        }
357    }
358
359    Ok(agg)
360}
361
362pub(crate) fn union_fixed(
363    agg: &mut Aggregation,
364    groups: &GroupTable,
365    base_versions: &BTreeMap<PackageId, Version>,
366) -> bool {
367    let mut changed = false;
368    for g in groups.fixed.values() {
369        let pkg_members: Vec<PackageId> = g
370            .members(crate::config::GroupMemberKind::Package)
371            .filter_map(|m| match m {
372                crate::config::GroupMember::Package(ref id) => Some(id.clone()),
373                _ => None,
374            })
375            .collect();
376
377        let mut target = Severity::None;
378        for m in &pkg_members {
379            if let Some(&s) = agg.severities.get(m) {
380                if s > target {
381                    target = s;
382                }
383            }
384        }
385
386        if target == Severity::None {
387            continue;
388        }
389
390        for m in pkg_members {
391            let cur = agg.severities.get(&m).copied().unwrap_or(Severity::None);
392            if target > cur {
393                // Guard against stale group members: a package listed in the
394                // config group that was subsequently removed from the workspace
395                // must not be inserted into severities.  Doing so causes
396                // `bump_target` in `solve_cascade` to call
397                // `input.base.get(stale_id)` -> `None` ->
398                // `Err(GraphError::Manifest(MissingField))`, which surfaces as
399                // a misleading crash.  Emit a warning instead and skip.
400                if !base_versions.contains_key(&m) {
401                    agg.diagnostics.push(Diagnostic {
402                        code: callisto_model::DiagnosticCode::UnknownPackage,
403                        severity: callisto_model::DiagnosticSeverity::Warning,
404                        message: format!(
405                            "Fixed group `{}` references package `{}` which is not in the \
406                             workspace; the stale group member is skipped. Remove it from \
407                             callisto.toml to silence this warning.",
408                            g.name,
409                            m.display_name()
410                        ),
411                        package: Some(m.clone()),
412                        path: None,
413                        governed_by: Some(callisto_model::ConfigKey::FIXED_GROUP),
414                        escalated_by: None,
415                    });
416                    continue;
417                }
418                agg.severities.insert(m.clone(), target);
419                agg.reasons
420                    .insert(m.clone(), BumpReason::FixedGroupUnion { group: g.name.clone() });
421                changed = true;
422            }
423        }
424    }
425    changed
426}
427
428pub(crate) fn union_linked(
429    agg: &mut Aggregation,
430    groups: &GroupTable,
431    base_versions: &BTreeMap<PackageId, Version>,
432) -> bool {
433    let mut changed = false;
434    for g in groups.linked.values() {
435        let named: Vec<PackageId> = g
436            .members(crate::config::GroupMemberKind::Package)
437            .filter_map(|m| match m {
438                crate::config::GroupMember::Package(ref id) => {
439                    if agg.named_by.contains_key(id) {
440                        Some(id.clone())
441                    } else {
442                        None
443                    }
444                }
445                _ => None,
446            })
447            .collect();
448
449        if named.is_empty() {
450            continue;
451        }
452
453        let mut target_sev = Severity::None;
454        for m in &named {
455            if let Some(&s) = agg.severities.get(m) {
456                if s > target_sev {
457                    target_sev = s;
458                }
459            }
460        }
461
462        let all_members: Vec<PackageId> = g
463            .members(crate::config::GroupMemberKind::Package)
464            .filter_map(|m| match m {
465                crate::config::GroupMember::Package(ref id) => Some(id.clone()),
466                _ => None,
467            })
468            .collect();
469
470        for m in all_members {
471            let cur = agg.severities.get(&m).copied().unwrap_or(Severity::None);
472            if target_sev > cur {
473                // Guard against stale linked-group members, same rationale as
474                // in `union_fixed`: a removed package must not enter
475                // `agg.severities`, which would cause `bump_target` to crash.
476                if !base_versions.contains_key(&m) {
477                    agg.diagnostics.push(Diagnostic {
478                        code: callisto_model::DiagnosticCode::UnknownPackage,
479                        severity: callisto_model::DiagnosticSeverity::Warning,
480                        message: format!(
481                            "Linked group `{}` references package `{}` which is not in the \
482                             workspace; the stale group member is skipped. Remove it from \
483                             callisto.toml to silence this warning.",
484                            g.name,
485                            m.display_name()
486                        ),
487                        package: Some(m.clone()),
488                        path: None,
489                        governed_by: Some(callisto_model::ConfigKey::LINKED_GROUP),
490                        escalated_by: None,
491                    });
492                    continue;
493                }
494                agg.severities.insert(m.clone(), target_sev);
495                agg.reasons
496                    .insert(m.clone(), BumpReason::LinkedGroupUnion { group: g.name.clone() });
497                changed = true;
498            }
499        }
500    }
501    changed
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use std::sync::atomic::{AtomicUsize, Ordering};
508    use std::sync::Mutex;
509
510    use callisto_model::{
511        CommandError, CommandOutput, CommandRunner, DepEdge, GroupKind, GroupName, ManifestDecl, ManifestFormat,
512        ManifestRole, Package,
513    };
514
515    use crate::config::{GroupDef, GroupMember};
516    use crate::infer::{InferenceOutcome, InferenceWindowSpec, SeverityInference};
517    use callisto_fixtures::git::{init_repo, run_git, PoisonedRunner};
518
519    /// Direct unit coverage for `apply_pre_major` across all three policy
520    /// states, previously only exercised indirectly through full-config
521    /// integration tests. `Off` never downgrades; `Conservative` downgrades
522    /// Major->Minor only; `ConservativeFeat` downgrades both Major->Minor
523    /// and Minor->Patch.
524    #[test]
525    fn apply_pre_major_off_never_downgrades() {
526        let v = Version::semver(0, 1, 0);
527        assert_eq!(
528            apply_pre_major(Severity::Major, PreMajorInferencePolicy::Off, &v, true),
529            (Severity::Major, false)
530        );
531        assert_eq!(
532            apply_pre_major(Severity::Minor, PreMajorInferencePolicy::Off, &v, true),
533            (Severity::Minor, false)
534        );
535    }
536
537    #[test]
538    fn apply_pre_major_conservative_downgrades_major_to_minor_only() {
539        let v = Version::semver(0, 1, 0);
540        assert_eq!(
541            apply_pre_major(Severity::Major, PreMajorInferencePolicy::Conservative, &v, true),
542            (Severity::Minor, true)
543        );
544        assert_eq!(
545            apply_pre_major(Severity::Minor, PreMajorInferencePolicy::Conservative, &v, true),
546            (Severity::Minor, false),
547            "Conservative must not also downgrade Minor->Patch"
548        );
549    }
550
551    #[test]
552    fn apply_pre_major_conservative_feat_downgrades_both_levels() {
553        let v = Version::semver(0, 1, 0);
554        assert_eq!(
555            apply_pre_major(Severity::Major, PreMajorInferencePolicy::ConservativeFeat, &v, true),
556            (Severity::Minor, true)
557        );
558        assert_eq!(
559            apply_pre_major(Severity::Minor, PreMajorInferencePolicy::ConservativeFeat, &v, true),
560            (Severity::Patch, true)
561        );
562    }
563
564    /// Shells out to the real `git` binary. Retained as the `CommandRunner`
565    /// implementation passed to `aggregate()`/`TagIndex::build` in most
566    /// tests below, even though neither actually uses it for git access
567    /// anymore: both resolve against the real repo on disk via
568    /// `callisto_vcs::GitRepository` (gix). See
569    /// `test_aggregate_resolves_since_without_shelling_through_runner` for
570    /// the test proving `aggregate()`'s since-resolution no longer needs a
571    /// working runner at all.
572    struct RealGitRunner;
573
574    impl CommandRunner for RealGitRunner {
575        fn run(&self, program: &str, args: &[&str], cwd: &Path) -> Result<CommandOutput, CommandError> {
576            let output = std::process::Command::new(program)
577                .args(args)
578                .current_dir(cwd)
579                .output()
580                .map_err(|e| CommandError::Io {
581                    program: program.to_string(),
582                    message: e.to_string(),
583                })?;
584            Ok(CommandOutput {
585                exit_code: output.status.code(),
586                stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
587                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
588            })
589        }
590    }
591
592    /// A directory that is guaranteed not to sit inside any Git repository,
593    /// so `callisto_vcs::GitRepository::discover` fails exactly the way it
594    /// unconditionally does on `wasm32` -- the native-testable stand-in for
595    /// "gix is unavailable" used to force `resolve_since` through its
596    /// `CommandRunner` fallback. Mirrors `tags.rs`'s helper of the same
597    /// name.
598    fn non_repo_dir() -> tempfile::TempDir {
599        let dir = tempfile::tempdir().unwrap();
600        assert!(
601            callisto_vcs::GitRepository::discover(dir.path()).is_err(),
602            "test fixture must not be discoverable as a Git repo"
603        );
604        dir
605    }
606
607    /// A `CommandRunner` double that answers `git rev-parse --verify --quiet
608    /// <tag>^{commit}` with a canned SHA and counts invocations. Stands in
609    /// for the real `git` binary on the `resolve_since` fallback path,
610    /// exercised when gix is unavailable (`repo: None`, as is permanently
611    /// the case on `wasm32`).
612    struct FakeRevParseRunner {
613        calls: AtomicUsize,
614        tag: String,
615        sha: CommitSha,
616    }
617
618    impl CommandRunner for FakeRevParseRunner {
619        fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
620            assert_eq!(program, "git");
621            assert_eq!(
622                args,
623                [
624                    "rev-parse",
625                    "--verify",
626                    "--quiet",
627                    format!("{}^{{commit}}", self.tag).as_str()
628                ]
629            );
630            self.calls.fetch_add(1, Ordering::SeqCst);
631            Ok(CommandOutput {
632                exit_code: Some(0),
633                stdout: format!("{}\n", self.sha.as_str()),
634                stderr: String::new(),
635            })
636        }
637    }
638
639    /// Spec: `load_changesets` must include the filename in its error when a changeset file
640    /// fails `parse_changeset`. The bare `?` propagation previously produced a
641    /// `GraphError::Format(ParseError)` with no path context, making it impossible for a
642    /// developer to triage which file caused the failure in a workspace with many changesets.
643    #[test]
644    fn test_load_changesets_error_includes_filename() {
645        let ws_dir = tempfile::tempdir().unwrap();
646        let root = ws_dir.path();
647        let cs_dir = root.join(".changeset");
648        std::fs::create_dir_all(&cs_dir).unwrap();
649
650        // Missing `---` frontmatter delimiter — parse_changeset returns
651        // ParseError::MissingFrontmatterStart. The error must carry the filename so the
652        // developer can find the broken file.
653        std::fs::write(cs_dir.join("malformed-changeset.md"), "cargo/foo: patch\n\nSummary.\n").unwrap();
654
655        let cfg = crate::config::load(root).unwrap();
656        let result = load_changesets(root, &cfg);
657
658        let err = result.expect_err("load_changesets must return Err for a malformed changeset file");
659        let err_display = format!("{err}");
660        assert!(
661            err_display.contains("malformed-changeset"),
662            "error message must contain the offending filename so the developer can triage; \
663             got: {err_display:?}"
664        );
665    }
666
667    /// Spec: `resolve_since` must not silently degrade to `None` (forcing
668    /// an unbounded full-history commit walk, see
669    /// `test_aggregate_scopes_inference_window_to_last_tag`) just because
670    /// gix is unavailable -- it must fall back (via `GitAccess`) to a
671    /// `CommandRunner`-shelled `git rev-parse --verify --quiet
672    /// <tag>^{commit}` call.
673    #[test]
674    fn test_resolve_since_falls_back_to_command_runner_without_gix() {
675        let dir = non_repo_dir();
676        let sha = CommitSha::parse("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap();
677        let runner = FakeRevParseRunner {
678            calls: AtomicUsize::new(0),
679            tag: "pkg-a@1.0.0".to_string(),
680            sha: sha.clone(),
681        };
682        let git = GitAccess::discover(dir.path(), &runner);
683
684        let resolved = resolve_since(&git, "pkg-a@1.0.0");
685
686        assert_eq!(
687            resolved,
688            Some(sha),
689            "resolve_since must resolve the tag via the CommandRunner fallback when gix is \
690             unavailable, not silently return None"
691        );
692        assert_eq!(runner.calls.load(Ordering::SeqCst), 1);
693    }
694
695    struct SinglePackageGraph {
696        pkg: Package,
697    }
698
699    impl DependencyResolver for SinglePackageGraph {
700        fn packages(&self) -> impl Iterator<Item = &Package> {
701            std::iter::once(&self.pkg)
702        }
703
704        fn dependencies_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
705            std::iter::empty()
706        }
707
708        fn dependents_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
709            std::iter::empty()
710        }
711    }
712
713    /// Records the `since` value passed into `InferenceWindowSpec` without
714    /// doing any real inference work.
715    #[derive(Default)]
716    struct RecordingInference {
717        captured_since: Mutex<Option<CommitSha>>,
718    }
719
720    impl SeverityInference for RecordingInference {
721        fn infer(
722            &self,
723            _pkg: &Package,
724            _git: &GitAccess<'_>,
725            window: InferenceWindowSpec<'_>,
726        ) -> Result<Option<InferenceOutcome>, GraphError> {
727            *self.captured_since.lock().unwrap() = window.since.clone();
728            Ok(None)
729        }
730    }
731
732    /// Spec: `aggregate()` must scope commit inference to `last_tag..HEAD`
733    /// instead of walking full history on every run. Reproduces the bug by
734    /// building a real one-package repo with a real release tag, then
735    /// asserting the `since` field handed to `SeverityInference::infer`
736    /// carries the commit SHA the tag points at (not `None`, which forces a
737    /// full-history walk in `callisto_conventional::window::fetch_commits`).
738    #[test]
739    fn test_aggregate_scopes_inference_window_to_last_tag() {
740        let ws_dir = tempfile::tempdir().unwrap();
741        let root = ws_dir.path();
742
743        init_repo(root);
744        std::fs::write(root.join("README.md"), "hello\n").unwrap();
745        run_git(root, &["add", "."]);
746        run_git(root, &["commit", "-q", "-m", "initial commit"]);
747
748        let pkg_id = PackageId::parse("pkg-a").unwrap();
749        let tag_name = format!("{}@1.0.0", pkg_id.display_name());
750        // Explicit message + disabled gpg signing so this is robust
751        // regardless of the developer machine's global git config (e.g.
752        // `tag.forceSignAnnotated` / `tag.gpgSign`).
753        run_git(root, &["-c", "tag.gpgSign=false", "tag", "-m", "release", &tag_name]);
754
755        // A commit landing after the tag; a correctly-scoped inference
756        // window must never need to look past `tag_name` to find it, but a
757        // `since: None` (full history) window would happily walk right over
758        // it and beyond, all the way back to the repo root.
759        std::fs::write(root.join("CHANGES.md"), "more\n").unwrap();
760        run_git(root, &["add", "."]);
761        run_git(root, &["commit", "-q", "-m", "feat: add changes file"]);
762
763        let expected_sha_output = std::process::Command::new("git")
764            .args(["rev-parse", "--verify", "--quiet", &format!("{tag_name}^{{commit}}")])
765            .current_dir(root)
766            .output()
767            .unwrap();
768        assert!(expected_sha_output.status.success());
769        let expected_sha = CommitSha::parse(String::from_utf8_lossy(&expected_sha_output.stdout).trim()).unwrap();
770
771        let runner = RealGitRunner;
772        let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
773        let graph = SinglePackageGraph {
774            pkg: Package {
775                id: pkg_id.clone(),
776                manifests: vec![manifest],
777                changelog: None,
778                release_trigger: callisto_model::ReleaseTrigger::Changeset,
779                publish_to: Vec::new(),
780                tag_template: None,
781            },
782        };
783        let git = GitAccess::discover(root, &runner);
784        let cfg = crate::config::load(root).unwrap();
785        let tags = TagIndex::build(&git, &graph, &cfg).unwrap();
786
787        // Sanity: the tag we just created was actually picked up.
788        assert_eq!(
789            tags.last_tag(&pkg_id).map(|t| t.version.render().to_string()),
790            Some("1.0.0".to_string())
791        );
792
793        let inference = RecordingInference::default();
794        let base_versions = BTreeMap::new();
795
796        aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
797
798        let captured = inference.captured_since.lock().unwrap().clone();
799        assert_eq!(
800            captured,
801            Some(expected_sha),
802            "aggregate() must scope inference to last_tag..HEAD instead of hardcoding `since: None` \
803             (full history)"
804        );
805    }
806
807    struct FixedCommitsInference {
808        commits: Vec<(CommitSha, String)>,
809    }
810
811    impl SeverityInference for FixedCommitsInference {
812        fn infer(
813            &self,
814            _pkg: &Package,
815            _git: &GitAccess<'_>,
816            _window: InferenceWindowSpec<'_>,
817        ) -> Result<Option<InferenceOutcome>, GraphError> {
818            Ok(Some(InferenceOutcome {
819                severity: Severity::Minor,
820                commit_count: self.commits.len(),
821                remapped: false,
822                commits: self.commits.clone(),
823            }))
824        }
825    }
826
827    /// AC-003 scaffold: aggregate() must retain InferenceOutcome.commits
828    /// on Aggregation.inference_commits keyed by package, not discard it
829    /// after constructing BumpReason::Inference (which only carries a count).
830    #[test]
831    fn test_aggregate_retains_inference_commits_on_aggregation() {
832        let ws_dir = tempfile::tempdir().unwrap();
833        let root = ws_dir.path();
834        init_repo(root);
835        std::fs::write(root.join("README.md"), "hello\n").unwrap();
836        run_git(root, &["add", "."]);
837        run_git(root, &["commit", "-q", "-m", "initial commit"]);
838
839        let pkg_id = PackageId::parse("pkg-a").unwrap();
840        let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
841        let graph = SinglePackageGraph {
842            pkg: Package {
843                id: pkg_id.clone(),
844                manifests: vec![manifest],
845                changelog: None,
846                release_trigger: callisto_model::ReleaseTrigger::Changeset,
847                publish_to: Vec::new(),
848                tag_template: None,
849            },
850        };
851        let runner = RealGitRunner;
852        let git = GitAccess::discover(root, &runner);
853        let cfg = crate::config::load(root).unwrap();
854        let tags = TagIndex::build(&git, &graph, &cfg).unwrap();
855
856        let mut base_versions = BTreeMap::new();
857        base_versions.insert(pkg_id.clone(), callisto_model::Version::semver(1, 0, 0));
858
859        let sha_recent = CommitSha::parse(&"a".repeat(40)).unwrap();
860        let inference = FixedCommitsInference {
861            commits: vec![(sha_recent.clone(), "feat: recent".to_string())],
862        };
863
864        let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
865
866        assert_eq!(
867            agg.inference_commits.get(&pkg_id),
868            Some(&vec![(sha_recent, "feat: recent".to_string())]),
869            "Aggregation.inference_commits must retain InferenceOutcome.commits for the package"
870        );
871    }
872
873    /// Spec: since-resolution must go through `callisto_vcs::GitRepository`
874    /// (gix), not the `CommandRunner` shell-out -- a `CommandRunner` that
875    /// fails on every call must not prevent `since` from being resolved.
876    #[test]
877    fn test_aggregate_resolves_since_without_shelling_through_runner() {
878        let ws_dir = tempfile::tempdir().unwrap();
879        let root = ws_dir.path();
880
881        init_repo(root);
882        std::fs::write(root.join("README.md"), "hello\n").unwrap();
883        run_git(root, &["add", "."]);
884        run_git(root, &["commit", "-q", "-m", "initial commit"]);
885
886        let pkg_id = PackageId::parse("pkg-a").unwrap();
887        let tag_name = format!("{}@1.0.0", pkg_id.display_name());
888        run_git(root, &["-c", "tag.gpgSign=false", "tag", "-m", "release", &tag_name]);
889
890        std::fs::write(root.join("CHANGES.md"), "more\n").unwrap();
891        run_git(root, &["add", "."]);
892        run_git(root, &["commit", "-q", "-m", "feat: add changes file"]);
893
894        let expected_sha_output = std::process::Command::new("git")
895            .args(["rev-parse", "--verify", "--quiet", &format!("{tag_name}^{{commit}}")])
896            .current_dir(root)
897            .output()
898            .unwrap();
899        assert!(expected_sha_output.status.success());
900        let expected_sha = CommitSha::parse(String::from_utf8_lossy(&expected_sha_output.stdout).trim()).unwrap();
901
902        let poisoned = PoisonedRunner;
903        let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
904        let graph = SinglePackageGraph {
905            pkg: Package {
906                id: pkg_id.clone(),
907                manifests: vec![manifest],
908                changelog: None,
909                release_trigger: callisto_model::ReleaseTrigger::Changeset,
910                publish_to: Vec::new(),
911                tag_template: None,
912            },
913        };
914        let git = GitAccess::discover(root, &poisoned);
915        let cfg = crate::config::load(root).unwrap();
916        let tags = TagIndex::build(&git, &graph, &cfg).unwrap();
917
918        assert_eq!(
919            tags.last_tag(&pkg_id).map(|t| t.version.render().to_string()),
920            Some("1.0.0".to_string())
921        );
922
923        let inference = RecordingInference::default();
924        let base_versions = BTreeMap::new();
925
926        aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
927
928        let captured = inference.captured_since.lock().unwrap().clone();
929        assert_eq!(
930            captured,
931            Some(expected_sha),
932            "aggregate() must resolve `since` via callisto_vcs::GitRepository (gix), not by \
933             shelling out through the CommandRunner"
934        );
935    }
936
937    fn make_pkg(id: PackageId) -> Package {
938        let manifest = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml).unwrap();
939        Package {
940            id,
941            manifests: vec![manifest],
942            changelog: None,
943            release_trigger: callisto_model::ReleaseTrigger::Changeset,
944            publish_to: Vec::new(),
945            tag_template: None,
946        }
947    }
948
949    /// Spec: a changeset entry naming a package by its bare name (no
950    /// ecosystem prefix) must NOT silently resolve against an arbitrary
951    /// candidate when the graph contains packages in two or more ecosystems
952    /// sharing that name. Resolving `foo` against both `cargo/foo` and
953    /// `npm/foo` is genuinely ambiguous and must be a caller-visible error,
954    /// not a first-match-wins pick based on iteration order.
955    #[test]
956    fn test_resolve_target_package_ambiguous_bare_name_errors() {
957        let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
958        let pkg_npm = make_pkg(PackageId::parse("npm/foo").unwrap());
959        let packages = [pkg_cargo, pkg_npm];
960        let bare = PackageId::parse("foo").unwrap();
961
962        let result = resolve_target_package(packages.iter(), &bare);
963
964        match result {
965            Err(GraphError::AmbiguousName { name, candidates }) => {
966                assert_eq!(name, "foo");
967                assert_eq!(candidates.len(), 2);
968                assert!(candidates.contains(&PackageId::parse("cargo/foo").unwrap()));
969                assert!(candidates.contains(&PackageId::parse("npm/foo").unwrap()));
970            }
971            other => panic!("expected GraphError::AmbiguousName, got {other:?}"),
972        }
973    }
974
975    /// Spec: a bare-name lookup must still resolve fine when the name is
976    /// unambiguous (only one package with that name across all ecosystems
977    /// in the graph).
978    #[test]
979    fn test_resolve_target_package_unambiguous_bare_name_resolves() {
980        let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
981        let pkg_other = make_pkg(PackageId::parse("cargo/bar").unwrap());
982        let packages = [pkg_cargo, pkg_other];
983        let bare = PackageId::parse("foo").unwrap();
984
985        let result = resolve_target_package(packages.iter(), &bare).unwrap();
986
987        assert_eq!(
988            result.map(|p| p.id.clone()),
989            Some(PackageId::parse("cargo/foo").unwrap())
990        );
991    }
992
993    /// Spec: a bare-name lookup for a name that doesn't exist anywhere in
994    /// the graph resolves to `None` (not an error) -- unknown-package
995    /// reporting is the caller's responsibility (see validate.rs).
996    #[test]
997    fn test_resolve_target_package_unknown_name_returns_none() {
998        let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
999        let packages = [pkg_cargo];
1000        let bare = PackageId::parse("does-not-exist").unwrap();
1001
1002        let result = resolve_target_package(packages.iter(), &bare).unwrap();
1003
1004        assert!(result.is_none());
1005    }
1006
1007    /// Spec: the ambiguity check must not assume exactly two colliding
1008    /// candidates. A workspace with the same bare name registered in three
1009    /// or more ecosystems (cargo/foo, npm/foo, pypi/foo) must still report
1010    /// every candidate in `AmbiguousName`, not just the first two (an
1011    /// off-by-one truncation or a hardcoded pairwise assumption would not
1012    /// be caught by the two-ecosystem test above).
1013    #[test]
1014    fn test_resolve_target_package_ambiguous_bare_name_three_ecosystems_errors() {
1015        let pkg_cargo = make_pkg(PackageId::parse("cargo/foo").unwrap());
1016        let pkg_npm = make_pkg(PackageId::parse("npm/foo").unwrap());
1017        let pkg_pypi = make_pkg(PackageId::parse("pypi/foo").unwrap());
1018        let packages = [pkg_cargo, pkg_npm, pkg_pypi];
1019        let bare = PackageId::parse("foo").unwrap();
1020
1021        let result = resolve_target_package(packages.iter(), &bare);
1022
1023        match result {
1024            Err(GraphError::AmbiguousName { name, candidates }) => {
1025                assert_eq!(name, "foo");
1026                assert_eq!(candidates.len(), 3);
1027                assert!(candidates.contains(&PackageId::parse("cargo/foo").unwrap()));
1028                assert!(candidates.contains(&PackageId::parse("npm/foo").unwrap()));
1029                assert!(candidates.contains(&PackageId::parse("pypi/foo").unwrap()));
1030            }
1031            other => panic!("expected GraphError::AmbiguousName with 3 candidates, got {other:?}"),
1032        }
1033    }
1034
1035    /// Spec: bare-name matching against `PackageId::name()` is a plain
1036    /// string comparison, which is case-sensitive. A package registered as
1037    /// `cargo/Foo` must NOT be resolved by a bare lookup for `foo` -- they
1038    /// are treated as distinct names, so the lookup resolves to `None`
1039    /// (unknown-package) rather than matching or erroring as ambiguous.
1040    /// This test pins down that actual behavior explicitly so a future
1041    /// change to case handling is a deliberate, visible decision.
1042    #[test]
1043    fn test_resolve_target_package_bare_name_matching_is_case_sensitive() {
1044        let pkg_cargo = make_pkg(PackageId::parse("cargo/Foo").unwrap());
1045        let packages = [pkg_cargo];
1046        let bare = PackageId::parse("foo").unwrap();
1047
1048        let result = resolve_target_package(packages.iter(), &bare).unwrap();
1049
1050        assert!(
1051            result.is_none(),
1052            "case-sensitive name comparison must not match 'foo' against 'Foo'"
1053        );
1054    }
1055
1056    /// Spec: a changeset where EVERY entry references a package not in the
1057    /// workspace must NOT be added to `consumed` (which would silently delete
1058    /// it on disk) and must emit a `DiagnosticCode::UnknownPackage` warning
1059    /// for each orphaned entry.  On the current (unfixed) code, the changeset
1060    /// IS added to `consumed` before the entry loop, so it ends up deleted
1061    /// despite no version bump ever being recorded.
1062    #[test]
1063    fn test_orphaned_changeset_not_consumed_emits_unknown_package_diagnostic() {
1064        let ws_dir = tempfile::tempdir().unwrap();
1065        let root = ws_dir.path();
1066
1067        // Minimal git repo so TagIndex::build can enumerate tags.
1068        init_repo(root);
1069        std::fs::write(root.join("README.md"), "hello\n").unwrap();
1070        run_git(root, &["add", "."]);
1071        run_git(root, &["commit", "-q", "-m", "initial commit"]);
1072
1073        // Changeset referencing only pkg-foo which is NOT in the workspace.
1074        let cs_dir = root.join(".changeset");
1075        std::fs::create_dir_all(&cs_dir).unwrap();
1076        std::fs::write(
1077            cs_dir.join("orphan-cs.md"),
1078            "---\n\"pkg-foo\": minor\n---\n\nOrphaned changeset.\n",
1079        )
1080        .unwrap();
1081
1082        // Workspace has only pkg-bar.
1083        let pkg_bar_id = PackageId::parse("pkg-bar").unwrap();
1084        let graph = SinglePackageGraph {
1085            pkg: make_pkg(pkg_bar_id.clone()),
1086        };
1087        let cfg = crate::config::load(root).unwrap();
1088        let runner = RealGitRunner;
1089        let git = GitAccess::discover(root, &runner);
1090        let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
1091
1092        let mut base_versions = BTreeMap::new();
1093        base_versions.insert(pkg_bar_id.clone(), Version::semver(1, 0, 0));
1094
1095        let inference = RecordingInference::default();
1096        let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &inference).unwrap();
1097
1098        assert!(
1099            agg.consumed.is_empty(),
1100            "a fully-orphaned changeset (all entries reference non-existent packages) must NOT \
1101             be added to consumed (which would cause it to be deleted on disk): got {:?}",
1102            agg.consumed
1103        );
1104
1105        let unknown_pkg_diags: Vec<_> = agg
1106            .diagnostics
1107            .iter()
1108            .filter(|d| d.code == callisto_model::DiagnosticCode::UnknownPackage)
1109            .collect();
1110        assert!(
1111            !unknown_pkg_diags.is_empty(),
1112            "must emit at least one UnknownPackage diagnostic for orphaned changeset entries; \
1113             got diagnostics: {:?}",
1114            agg.diagnostics
1115        );
1116    }
1117
1118    /// Spec: when a fixed group in callisto.toml references a package that
1119    /// no longer exists in the workspace, `union_fixed` must NOT insert
1120    /// the stale member into `agg.severities`. Doing so causes
1121    /// `bump_target` in `solve_cascade` to call
1122    /// `input.base.get(stale_id)` -> `None` ->
1123    /// `Err(GraphError::Manifest(MissingField))`, crashing `callisto
1124    /// version` with a misleading error. The stale member must be skipped
1125    /// and an `UnknownPackage` warning emitted.
1126    ///
1127    /// Setup: `pkg_bar` has `Severity::Minor` (from a changeset), `pkg_baz`
1128    /// has none yet. Fixed group has all three: `pkg_foo` (stale),
1129    /// `pkg_bar`, `pkg_baz`. `union_fixed` should propagate `Minor` to
1130    /// `pkg_baz`, skip `pkg_foo` with a diagnostic, and
1131    /// return `true` because `pkg_baz` changed.
1132    #[test]
1133    fn test_union_fixed_stale_member_emits_diagnostic_and_is_skipped() {
1134        let pkg_foo = PackageId::parse("pkg-foo").unwrap(); // stale: removed from workspace
1135        let pkg_bar = PackageId::parse("pkg-bar").unwrap(); // real workspace package (has severity)
1136        let pkg_baz = PackageId::parse("pkg-baz").unwrap(); // real workspace package (no severity yet)
1137
1138        let mut agg = Aggregation::default();
1139        // pkg-bar has a changeset-driven Minor bump; pkg-baz has nothing yet.
1140        agg.severities.insert(pkg_bar.clone(), Severity::Minor);
1141        agg.named_by.insert(pkg_bar.clone(), NamedBy::Changeset);
1142
1143        let mut groups = GroupTable::default();
1144        let group_def = GroupDef {
1145            name: GroupName("fixed-grp".to_string()),
1146            kind: GroupKind::Fixed,
1147            members: vec![
1148                GroupMember::Package(pkg_foo.clone()),
1149                GroupMember::Package(pkg_bar.clone()),
1150                GroupMember::Package(pkg_baz.clone()),
1151            ],
1152        };
1153        groups.fixed.insert(group_def.name.clone(), group_def);
1154
1155        // Only pkg-bar and pkg-baz are in the workspace; pkg-foo is stale.
1156        let mut base_versions = BTreeMap::new();
1157        base_versions.insert(pkg_bar.clone(), Version::semver(1, 0, 0));
1158        base_versions.insert(pkg_baz.clone(), Version::semver(1, 0, 0));
1159
1160        let changed = union_fixed(&mut agg, &groups, &base_versions);
1161
1162        // pkg_baz had no severity but should now have Minor propagated from pkg_bar.
1163        assert!(
1164            changed,
1165            "union_fixed must return true because pkg-baz received a propagated severity"
1166        );
1167        assert_eq!(
1168            agg.severities.get(&pkg_baz),
1169            Some(&Severity::Minor),
1170            "real member pkg-baz must receive the propagated Minor severity"
1171        );
1172        // The stale member must never enter severities.
1173        assert!(
1174            !agg.severities.contains_key(&pkg_foo),
1175            "stale group member pkg-foo must NOT be inserted into severities (would crash cascade \
1176             with a misleading MissingField error)"
1177        );
1178
1179        let unknown_diags: Vec<_> = agg
1180            .diagnostics
1181            .iter()
1182            .filter(|d| d.code == callisto_model::DiagnosticCode::UnknownPackage)
1183            .collect();
1184        assert!(
1185            !unknown_diags.is_empty(),
1186            "must emit an UnknownPackage diagnostic for stale fixed group member; \
1187             got diagnostics: {:?}",
1188            agg.diagnostics
1189        );
1190    }
1191
1192    fn linked_group(name: &str, members: &[PackageId]) -> GroupTable {
1193        let mut groups = GroupTable::default();
1194        let group_def = GroupDef {
1195            name: GroupName(name.to_string()),
1196            kind: GroupKind::Linked,
1197            members: members.iter().cloned().map(GroupMember::Package).collect(),
1198        };
1199        groups.linked.insert(group_def.name.clone(), group_def);
1200        groups
1201    }
1202
1203    #[test]
1204    fn test_union_linked_propagates_severity_from_named_member() {
1205        let pkg_a = PackageId::parse("pkg-a").unwrap();
1206        let pkg_b = PackageId::parse("pkg-b").unwrap();
1207
1208        let mut agg = Aggregation::default();
1209        agg.severities.insert(pkg_b.clone(), Severity::Minor);
1210        agg.named_by.insert(pkg_b.clone(), NamedBy::Changeset);
1211
1212        let groups = linked_group("linked-pair", &[pkg_a.clone(), pkg_b.clone()]);
1213
1214        let mut base_versions = BTreeMap::new();
1215        base_versions.insert(pkg_a.clone(), Version::semver(1, 0, 0));
1216        base_versions.insert(pkg_b.clone(), Version::semver(1, 0, 0));
1217
1218        let changed = union_linked(&mut agg, &groups, &base_versions);
1219
1220        assert!(changed);
1221        assert_eq!(agg.severities.get(&pkg_a), Some(&Severity::Minor));
1222        assert_eq!(agg.severities.get(&pkg_b), Some(&Severity::Minor));
1223        assert_eq!(
1224            agg.reasons.get(&pkg_a),
1225            Some(&BumpReason::LinkedGroupUnion {
1226                group: GroupName("linked-pair".to_string()),
1227            })
1228        );
1229    }
1230
1231    #[test]
1232    fn test_union_linked_does_not_downgrade_higher_existing_severity() {
1233        let pkg_a = PackageId::parse("pkg-a").unwrap();
1234        let pkg_b = PackageId::parse("pkg-b").unwrap();
1235
1236        let mut agg = Aggregation::default();
1237        agg.severities.insert(pkg_a.clone(), Severity::Major);
1238        agg.severities.insert(pkg_b.clone(), Severity::Minor);
1239        agg.named_by.insert(pkg_a.clone(), NamedBy::Inference);
1240        agg.named_by.insert(pkg_b.clone(), NamedBy::Changeset);
1241
1242        let groups = linked_group("linked-pair", &[pkg_a.clone(), pkg_b.clone()]);
1243
1244        let mut base_versions = BTreeMap::new();
1245        base_versions.insert(pkg_a.clone(), Version::semver(1, 0, 0));
1246        base_versions.insert(pkg_b.clone(), Version::semver(1, 0, 0));
1247
1248        let changed = union_linked(&mut agg, &groups, &base_versions);
1249
1250        assert!(changed);
1251        assert_eq!(agg.severities.get(&pkg_a), Some(&Severity::Major));
1252        assert_eq!(agg.severities.get(&pkg_b), Some(&Severity::Major));
1253    }
1254
1255    #[test]
1256    fn test_union_linked_noop_when_no_member_named() {
1257        let pkg_a = PackageId::parse("pkg-a").unwrap();
1258        let pkg_b = PackageId::parse("pkg-b").unwrap();
1259
1260        let mut agg = Aggregation::default();
1261        let groups = linked_group("linked-pair", &[pkg_a.clone(), pkg_b.clone()]);
1262
1263        let mut base_versions = BTreeMap::new();
1264        base_versions.insert(pkg_a.clone(), Version::semver(1, 0, 0));
1265        base_versions.insert(pkg_b.clone(), Version::semver(1, 0, 0));
1266
1267        let changed = union_linked(&mut agg, &groups, &base_versions);
1268
1269        assert!(!changed);
1270        assert!(agg.severities.is_empty());
1271    }
1272
1273    /// Spec: when `SeverityInference::infer` returns `Err`, `aggregate()` must emit a
1274    /// diagnostic (warning level) describing the failure rather than silently discarding
1275    /// the error and leaving the package with no inferred severity bump.
1276    #[test]
1277    fn test_aggregate_inference_error_emits_diagnostic() {
1278        let ws_dir = tempfile::tempdir().unwrap();
1279        let root = ws_dir.path();
1280
1281        init_repo(root);
1282        std::fs::write(root.join("README.md"), "hello\n").unwrap();
1283        run_git(root, &["add", "."]);
1284        run_git(root, &["commit", "-q", "-m", "initial commit"]);
1285
1286        let pkg_id = PackageId::parse("pkg-a").unwrap();
1287        let graph = SinglePackageGraph {
1288            pkg: make_pkg(pkg_id.clone()),
1289        };
1290        let cfg = crate::config::load(root).unwrap();
1291        let runner = RealGitRunner;
1292        let git = GitAccess::discover(root, &runner);
1293        let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
1294        let mut base_versions = BTreeMap::new();
1295        base_versions.insert(pkg_id.clone(), Version::semver(1, 0, 0));
1296
1297        struct AlwaysErrorInference;
1298        impl SeverityInference for AlwaysErrorInference {
1299            fn infer(
1300                &self,
1301                _pkg: &Package,
1302                _git: &GitAccess<'_>,
1303                _window: InferenceWindowSpec<'_>,
1304            ) -> Result<Option<InferenceOutcome>, GraphError> {
1305                Err(GraphError::Vcs(callisto_vcs::VcsError::Git(
1306                    "simulated inference failure".into(),
1307                )))
1308            }
1309        }
1310
1311        let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &AlwaysErrorInference).unwrap();
1312
1313        assert!(
1314            !agg.diagnostics.is_empty(),
1315            "aggregate() must emit a diagnostic when SeverityInference::infer returns Err; got none"
1316        );
1317    }
1318
1319    /// Spec: a bare-name `[[package]]` rule in callisto.toml must match a workspace
1320    /// package with a prefixed ID (e.g. `cargo/pkg-a`) via `PackageId::matches()`.
1321    /// The previous `id == &pkg.id` structural equality check was silently inert for
1322    /// prefixed package IDs when the config rule used a bare name.
1323    #[test]
1324    fn test_aggregate_bare_name_config_policy_matches_prefixed_package() {
1325        use crate::config::resolve::PreMajorInferencePolicy;
1326        use std::sync::atomic::AtomicBool;
1327
1328        let ws_dir = tempfile::tempdir().unwrap();
1329        let root = ws_dir.path();
1330
1331        init_repo(root);
1332        std::fs::write(root.join("README.md"), "hello\n").unwrap();
1333        run_git(root, &["add", "."]);
1334        run_git(root, &["commit", "-q", "-m", "initial commit"]);
1335
1336        // Config uses a BARE name, but the workspace package has a PREFIXED ID.
1337        // PackageId::matches() must bridge the gap; == does not.
1338        std::fs::write(
1339            root.join("callisto.toml"),
1340            "[[package]]\nmatch = \"pkg-a\"\npre-major-inference = \"conservative\"\n",
1341        )
1342        .unwrap();
1343
1344        let pkg_id = PackageId::parse("cargo/pkg-a").unwrap();
1345        let graph = SinglePackageGraph {
1346            pkg: make_pkg(pkg_id.clone()),
1347        };
1348        let cfg = crate::config::load(root).unwrap();
1349        let runner = RealGitRunner;
1350        let git = GitAccess::discover(root, &runner);
1351        let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
1352        let mut base_versions = BTreeMap::new();
1353        base_versions.insert(pkg_id.clone(), Version::semver(0, 1, 0));
1354
1355        struct PolicyCapturingInference2 {
1356            saw_non_off: AtomicBool,
1357        }
1358        impl SeverityInference for PolicyCapturingInference2 {
1359            fn infer(
1360                &self,
1361                _pkg: &Package,
1362                _git: &GitAccess<'_>,
1363                window: InferenceWindowSpec<'_>,
1364            ) -> Result<Option<InferenceOutcome>, GraphError> {
1365                if window.policy != PreMajorInferencePolicy::Off {
1366                    self.saw_non_off.store(true, Ordering::SeqCst);
1367                }
1368                Ok(None)
1369            }
1370        }
1371
1372        let capturing = PolicyCapturingInference2 {
1373            saw_non_off: AtomicBool::new(false),
1374        };
1375        aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &capturing).unwrap();
1376
1377        assert!(
1378            capturing.saw_non_off.load(Ordering::SeqCst),
1379            "a bare-name [[package]] rule must match a prefixed package ID via \
1380             PackageId::matches(); the old == comparison was silently inert for \
1381             cargo/pkg-a when callisto.toml uses match = \"pkg-a\""
1382        );
1383    }
1384
1385    /// Spec: during a pre-release cycle (PreMode::Pre), aggregate() must NOT populate
1386    /// agg.consumed. Changesets must remain on disk so they can be re-applied when the
1387    /// cycle exits. The previous code unconditionally pushed to consumed regardless of
1388    /// the PreState passed in.
1389    #[test]
1390    fn test_aggregate_does_not_consume_changesets_during_pre_mode() {
1391        let ws_dir = tempfile::tempdir().unwrap();
1392        let root = ws_dir.path();
1393
1394        init_repo(root);
1395        std::fs::write(root.join("README.md"), "hello\n").unwrap();
1396        run_git(root, &["add", "."]);
1397        run_git(root, &["commit", "-q", "-m", "initial commit"]);
1398
1399        let cs_dir = root.join(".changeset");
1400        std::fs::create_dir_all(&cs_dir).unwrap();
1401        std::fs::write(
1402            cs_dir.join("some-feature.md"),
1403            "---\n\"pkg-a\": minor\n---\n\nA feature in pre mode.\n",
1404        )
1405        .unwrap();
1406
1407        let pkg_id = PackageId::parse("pkg-a").unwrap();
1408        let graph = SinglePackageGraph {
1409            pkg: make_pkg(pkg_id.clone()),
1410        };
1411        let cfg = crate::config::load(root).unwrap();
1412        let runner = RealGitRunner;
1413        let git = GitAccess::discover(root, &runner);
1414        let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
1415        let mut base_versions = BTreeMap::new();
1416        base_versions.insert(pkg_id.clone(), Version::semver(1, 0, 0));
1417
1418        let pre_state = callisto_format::PreState::entering("next", [("pkg-a".to_string(), Version::semver(1, 0, 0))]);
1419
1420        let inference = RecordingInference::default();
1421        let agg = aggregate(&graph, &cfg, &git, &tags, &base_versions, Some(&pre_state), &inference).unwrap();
1422
1423        assert!(
1424            agg.consumed.is_empty(),
1425            "changesets must NOT be consumed during a pre-release cycle (PreMode::Pre); \
1426             agg.consumed must be empty but got: {:?}",
1427            agg.consumed
1428        );
1429    }
1430
1431    /// Spec: `aggregate()` must pass the per-package `pre_major_inference` policy from
1432    /// `config.packages` into `InferenceWindowSpec`, not always hardcode `OFF`.
1433    #[test]
1434    fn test_aggregate_pre_major_inference_policy_applied() {
1435        use crate::config::resolve::PreMajorInferencePolicy;
1436        use std::sync::atomic::AtomicBool;
1437
1438        let ws_dir = tempfile::tempdir().unwrap();
1439        let root = ws_dir.path();
1440
1441        init_repo(root);
1442        std::fs::write(root.join("README.md"), "hello\n").unwrap();
1443        run_git(root, &["add", "."]);
1444        run_git(root, &["commit", "-q", "-m", "initial commit"]);
1445
1446        // Write a callisto.toml with pre_major_inference = "conservative" for pkg-a.
1447        // The [[package]] section requires a `match` field (pattern to match package names).
1448        std::fs::write(
1449            root.join("callisto.toml"),
1450            "[[package]]\nmatch = \"pkg-a\"\npre-major-inference = \"conservative\"\n",
1451        )
1452        .unwrap();
1453
1454        let pkg_id = PackageId::parse("pkg-a").unwrap();
1455        let graph = SinglePackageGraph {
1456            pkg: make_pkg(pkg_id.clone()),
1457        };
1458        let cfg = crate::config::load(root).unwrap();
1459        let runner = RealGitRunner;
1460        let git = GitAccess::discover(root, &runner);
1461        let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
1462        let mut base_versions = BTreeMap::new();
1463        base_versions.insert(pkg_id.clone(), Version::semver(0, 1, 0));
1464
1465        // An inference impl that records whether it received a non-OFF policy.
1466        struct PolicyCapturingInference {
1467            saw_non_off: AtomicBool,
1468        }
1469        impl SeverityInference for PolicyCapturingInference {
1470            fn infer(
1471                &self,
1472                _pkg: &Package,
1473                _git: &GitAccess<'_>,
1474                window: InferenceWindowSpec<'_>,
1475            ) -> Result<Option<InferenceOutcome>, GraphError> {
1476                if window.policy != PreMajorInferencePolicy::Off {
1477                    self.saw_non_off.store(true, Ordering::SeqCst);
1478                }
1479                Ok(None)
1480            }
1481        }
1482
1483        let capturing = PolicyCapturingInference {
1484            saw_non_off: AtomicBool::new(false),
1485        };
1486        aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &capturing).unwrap();
1487
1488        assert!(
1489            capturing.saw_non_off.load(Ordering::SeqCst),
1490            "aggregate() must pass the per-package pre_major_inference policy from config.packages \
1491             into InferenceWindowSpec; received OFF even though callisto.toml sets conservative"
1492        );
1493    }
1494
1495    /// AC-F7a: Prefixed rule (npm:pkg, OFF policy) must win over Bare rule (pkg, conservative)
1496    /// even when the Bare rule is declared first in callisto.toml.
1497    /// With single-pass lookup: the Bare rule (declared first) wins -> conservative applied.
1498    /// With resolve_package_config (two-pass): the Prefixed rule wins -> OFF applied.
1499    ///
1500    /// Two AtomicBool flags:
1501    /// - invoked: true if infer() was called at all (proves the package is pre-1.0 and
1502    ///   pre-major-inference was consulted; distinguishes OFF-applied from never-called).
1503    /// - saw_non_off: true if infer() received a non-OFF policy (single-pass failure mode).
1504    #[test]
1505    fn test_pre_major_inference_prefixed_beats_bare_when_bare_declared_first() {
1506        use crate::config::resolve::PreMajorInferencePolicy;
1507        use std::sync::atomic::AtomicBool;
1508
1509        let ws_dir = tempfile::tempdir().unwrap();
1510        let root = ws_dir.path();
1511
1512        init_repo(root);
1513        std::fs::write(root.join("README.md"), "hello\n").unwrap();
1514        run_git(root, &["add", "."]);
1515        run_git(root, &["commit", "-q", "-m", "initial commit"]);
1516
1517        // Bare rule declared FIRST in TOML: match = "pkg", pre-major-inference = "conservative"
1518        // Prefixed rule declared SECOND: match = "npm:pkg", pre-major-inference = "off"
1519        // Single-pass find() returns pkg (Bare, declared first) -> conservative applied.
1520        // Two-pass resolve_package_config: pass 1 finds npm:pkg (Prefixed) -> OFF applied.
1521        std::fs::write(
1522            root.join("callisto.toml"),
1523            "[[package]]\nmatch = \"pkg\"\npre-major-inference = \"conservative\"\n\n[[package]]\nmatch = \"npm:pkg\"\npre-major-inference = \"off\"\n",
1524        )
1525        .unwrap();
1526
1527        let pkg_id = PackageId::parse("pkg").unwrap();
1528        let graph = SinglePackageGraph {
1529            pkg: make_pkg(pkg_id.clone()),
1530        };
1531        let cfg = crate::config::load(root).unwrap();
1532        let runner = RealGitRunner;
1533        let git = GitAccess::discover(root, &runner);
1534        let tags = crate::tags::TagIndex::build(&git, &graph, &cfg).unwrap();
1535        let mut base_versions = BTreeMap::new();
1536        // Version 0.1.0 (pre-1.0) ensures pre-major-inference is consulted.
1537        base_versions.insert(pkg_id.clone(), Version::semver(0, 1, 0));
1538
1539        struct PolicyCapturingInference {
1540            invoked: AtomicBool,
1541            saw_non_off: AtomicBool,
1542        }
1543        impl SeverityInference for PolicyCapturingInference {
1544            fn infer(
1545                &self,
1546                _pkg: &Package,
1547                _git: &GitAccess<'_>,
1548                window: InferenceWindowSpec<'_>,
1549            ) -> Result<Option<InferenceOutcome>, GraphError> {
1550                // Set invoked before any policy check so we can distinguish
1551                // OFF-policy-applied from never-called.
1552                self.invoked.store(true, Ordering::SeqCst);
1553                if window.policy != PreMajorInferencePolicy::Off {
1554                    self.saw_non_off.store(true, Ordering::SeqCst);
1555                }
1556                Ok(None)
1557            }
1558        }
1559
1560        let capturing = PolicyCapturingInference {
1561            invoked: AtomicBool::new(false),
1562            saw_non_off: AtomicBool::new(false),
1563        };
1564        aggregate(&graph, &cfg, &git, &tags, &base_versions, None, &capturing).unwrap();
1565
1566        assert!(
1567            capturing.invoked.load(Ordering::SeqCst),
1568            "inference was never invoked; fixture is wrong and cannot distinguish OFF policy \
1569             from no invocation. Ensure Version::semver(0, 1, 0) is in base_versions so the \
1570             package is pre-1.0 and pre-major-inference is consulted."
1571        );
1572        assert!(
1573            !capturing.saw_non_off.load(Ordering::SeqCst),
1574            "expected OFF policy from Prefixed rule (npm:pkg); Bare rule (conservative) was \
1575             applied instead. Two-pass specificity is required: Prefixed rules must win over \
1576             Bare rules regardless of declaration order."
1577        );
1578    }
1579}