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
111fn resolve_since(git: &impl GitDataSource, tag_name: &str) -> Option<CommitSha> {
123 git.resolve_commit(tag_name).ok().flatten()
124}
125
126pub(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 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 let is_pre_mode = pre.map(|s| s.mode == callisto_format::PreMode::Pre).unwrap_or(false);
236
237 for cs in loaded {
238 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 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 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 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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 #[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 run_git(root, &["-c", "tag.gpgSign=false", "tag", "-m", "release", &tag_name]);
754
755 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[test]
1133 fn test_union_fixed_stale_member_emits_diagnostic_and_is_skipped() {
1134 let pkg_foo = PackageId::parse("pkg-foo").unwrap(); let pkg_bar = PackageId::parse("pkg-bar").unwrap(); let pkg_baz = PackageId::parse("pkg-baz").unwrap(); let mut agg = Aggregation::default();
1139 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 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 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 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 #[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 #[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 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 #[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 #[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 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 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 #[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 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 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 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}