Skip to main content

callisto_graph/
walk.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use serde_json::Value;
7
8use callisto_manifests::{detect_npm_workspace_kind, Manifest, OpenContext, WorkspaceCargoResolver};
9use callisto_model::{
10    CommandRunner, DepEdge, Diagnostic, DiagnosticCode, DiagnosticSeverity, Ecosystem, ManifestDecl, ManifestFormat,
11    ManifestRole, Package, PackageId, PublishTarget, ReleaseTrigger,
12};
13
14use crate::config::resolve::resolve_package_config;
15use crate::config::ResolvedConfig;
16use crate::crosscheck::crosscheck_declared_edges;
17use crate::error::GraphError;
18use crate::identity::IdentityIndex;
19use crate::locate::ProjectLocator;
20use crate::manifest_cache::open_cached;
21#[allow(unused_imports)]
22use crate::resolver::{DependencyResolver, ManifestWalkResolver};
23
24/// Returns (name-scoped claiming-ecosystem set, complete unfiltered native-key list)
25/// for one path's own `list` of (ecosystem, declared-id) pairs. The first is filtered
26/// to entries whose declared name equals `primary_id.name()`; the second is not filtered.
27fn compute_claiming_ecosystems_and_native_keys(
28    list: &[(Ecosystem, PackageId)],
29    primary_id: &PackageId,
30) -> (BTreeSet<Ecosystem>, Vec<(Ecosystem, String)>) {
31    let claiming = list
32        .iter()
33        .filter(|(_, id)| id.name() == primary_id.name())
34        .map(|(eco, _)| *eco)
35        .collect();
36    let native_keys = list.iter().map(|(eco, id)| (*eco, id.name().to_string())).collect();
37    (claiming, native_keys)
38}
39
40/// The PROMOTION PREDICATE: true only when the two paths' name-scoped
41/// claiming-ecosystem sets share no ecosystem. This is a disjointness test,
42/// not an inequality test -- {Cargo,Npm} and {Npm} are unequal but not
43/// disjoint, and must NOT promote (see AC-08).
44fn claiming_sets_disjoint(a: &BTreeSet<Ecosystem>, b: &BTreeSet<Ecosystem>) -> bool {
45    a.is_disjoint(b)
46}
47
48/// Returns true iff `paths.len() > 1` AND every distinct pair of paths in `paths`
49/// has disjoint claiming-ecosystem sets in `claiming_ecosystems`.
50///
51/// Overlapping claiming sets indicate a true duplicate package error (handled
52/// separately during graph construction), NOT a valid multi-ecosystem promotion.
53#[cfg(test)]
54fn is_promoted_bare_name(paths: &[PathBuf], claiming_ecosystems: &BTreeMap<PathBuf, BTreeSet<Ecosystem>>) -> bool {
55    if paths.len() <= 1 {
56        return false;
57    }
58    let mut seen = BTreeSet::new();
59    for path in paths {
60        if let Some(ecos) = claiming_ecosystems.get(path) {
61            for eco in ecos {
62                if !seen.insert(*eco) {
63                    return false;
64                }
65            }
66        }
67    }
68    true
69}
70
71impl ManifestWalkResolver {
72    pub fn build<L: ProjectLocator, R: CommandRunner>(
73        root: &Path,
74        locator: &L,
75        _runner: &R,
76        cfg: &ResolvedConfig,
77        manifest_cache: &RefCell<BTreeMap<PathBuf, Arc<dyn Manifest>>>,
78    ) -> Result<Self, GraphError> {
79        let projects = locator.projects()?;
80
81        let cargo_workspace = if root.join("Cargo.toml").exists() {
82            if let Ok(resolver) = WorkspaceCargoResolver::load(&root.join("Cargo.toml")) {
83                resolver.inheritance().ok().map(Arc::new)
84            } else {
85                None
86            }
87        } else {
88            None
89        };
90
91        let npm_workspace_kind = detect_npm_workspace_kind(root).ok().flatten();
92
93        let ctx = OpenContext {
94            workspace_root: root,
95            cargo_workspace,
96            npm_workspace_kind,
97        };
98
99        let mut package_manifest_decls: BTreeMap<PackageId, (PathBuf, Vec<ManifestDecl>)> = BTreeMap::new();
100        let mut index = IdentityIndex::default();
101        let mut diagnostics = Vec::new();
102        let mut claiming_ecosystems: BTreeMap<PathBuf, BTreeSet<Ecosystem>> = BTreeMap::new();
103        let mut path_native_keys: BTreeMap<PathBuf, Vec<(Ecosystem, String)>> = BTreeMap::new();
104        let mut path_platform_keys: BTreeMap<PathBuf, Vec<String>> = BTreeMap::new();
105        let mut primary_ecosystems: BTreeMap<PathBuf, Ecosystem> = BTreeMap::new();
106        let mut promoted_siblings: BTreeMap<String, Vec<(PackageId, BTreeSet<Ecosystem>)>> = BTreeMap::new();
107
108        // Use the identity already resolved by the locator (`proj.id`) rather
109        // than re-reading manifests through `IdentityResolver::resolve`.
110        // The locator (e.g. `IgnoreWalkLocator`) already parsed each manifest
111        // to discover the project, so re-resolving from scratch is redundant
112        // and fragile — in particular, `IdentityResolver` historically had no
113        // `Ecosystem::Pypi` arm and would crash for Python projects.
114        let mut by_path: BTreeMap<PathBuf, Vec<(Ecosystem, PackageId)>> = BTreeMap::new();
115        for proj in &projects {
116            by_path
117                .entry(proj.path.clone())
118                .or_default()
119                .push((proj.ecosystem, proj.id.clone()));
120        }
121
122        for (rel_path, mut list) in by_path {
123            // Explicit precedence: Cargo (0) > Npm (1) > Pypi (2) > others.
124            // Do NOT rely on enum discriminant order -- sort by this named
125            // priority function so the precedence survives future variant
126            // additions to `Ecosystem`.
127            list.sort_by_key(|a| ecosystem_primary_priority(a.0));
128            let mut primary_id = list[0].1.clone();
129            let (this_claiming, this_native_keys) = compute_claiming_ecosystems_and_native_keys(&list, &primary_id);
130            claiming_ecosystems.insert(rel_path.clone(), this_claiming.clone());
131            path_native_keys.insert(rel_path.clone(), this_native_keys);
132            primary_ecosystems.insert(rel_path.clone(), list[0].0);
133
134            let mut branch_ii_promoted = false;
135            if let Some(existing_members) = promoted_siblings.get(primary_id.name()) {
136                let this_set = claiming_ecosystems.get(&rel_path).cloned().unwrap_or_default();
137                let conflict = existing_members
138                    .iter()
139                    .find(|(_, member_set)| !claiming_sets_disjoint(&this_set, member_set));
140                if let Some((conflicting_id, _)) = conflict {
141                    let offending_path = package_manifest_decls
142                        .iter()
143                        .find(|(id, _)| *id == conflicting_id)
144                        .map(|(_, (p, _))| p.clone())
145                        .unwrap_or_default();
146                    return Err(GraphError::DuplicatePackage {
147                        id: primary_id,
148                        paths: vec![offending_path, rel_path],
149                    });
150                }
151                let promoted_id = PackageId::Prefixed {
152                    ecosystem: primary_ecosystems[&rel_path],
153                    name: primary_id.name().to_string(),
154                };
155                promoted_siblings
156                    .entry(primary_id.name().to_string())
157                    .or_default()
158                    .push((promoted_id.clone(), this_set));
159                primary_id = promoted_id;
160                branch_ii_promoted = true;
161            }
162
163            if !branch_ii_promoted {
164                index.bare.insert(primary_id.name().to_string(), primary_id.clone());
165            }
166
167            let mut decls = Vec::new();
168            for (eco, id) in &list {
169                let (fmt, filename) = match eco {
170                    Ecosystem::Cargo => (ManifestFormat::CargoToml, "Cargo.toml"),
171                    Ecosystem::Npm => (ManifestFormat::PackageJson, "package.json"),
172                    Ecosystem::Pypi => (ManifestFormat::PyprojectToml, "pyproject.toml"),
173                    _ => (ManifestFormat::PackageJson, "package.json"),
174                };
175                let manifest_rel = rel_path.join(filename);
176                if let Ok(decl) = ManifestDecl::new(manifest_rel.clone(), ManifestRole::Canonical, fmt) {
177                    decls.push(decl);
178                }
179                // For napi platform packages (os + cpu constraints in package.json),
180                // also push a Platform-role decl so plan_publish can route them
181                // into npm_platform_packages instead of npm_main_packages.
182                if *eco == Ecosystem::Npm {
183                    let role = detect_npm_role(&root.join(&manifest_rel));
184                    if let ManifestRole::Platform { .. } = role {
185                        if let Ok(platform_decl) = ManifestDecl::new(manifest_rel.clone(), role.clone(), fmt) {
186                            decls.push(platform_decl);
187                        }
188                        // `id` is this manifest's own npm identity, resolved from its
189                        // own `name` field -- distinct from `primary_id` whenever a
190                        // higher-priority ecosystem (Cargo) shares this directory
191                        // (Case D). Config authors reference the platform package by
192                        // its own name in `[[fixed-group]] members`, so that must be
193                        // the index key; `primary_id` is who it belongs to.
194                        index
195                            .platform
196                            .insert(id.name().to_string(), (primary_id.clone(), manifest_rel, role));
197                        path_platform_keys
198                            .entry(rel_path.clone())
199                            .or_default()
200                            .push(id.name().to_string());
201                    }
202                }
203                index.native.insert((*eco, id.name().to_string()), primary_id.clone());
204                if id.name() == primary_id.name() {
205                    index.prefixed.insert((*eco, id.name().to_string()), primary_id.clone());
206                }
207            }
208
209            let current_decls = decls.clone();
210            if let Some((existing_path, existing_decls)) =
211                package_manifest_decls.insert(primary_id.clone(), (rel_path.clone(), decls))
212            {
213                let name = primary_id.name().to_string();
214                let existing_set = claiming_ecosystems.get(&existing_path).cloned().unwrap_or_default();
215                let current_set = claiming_ecosystems.get(&rel_path).cloned().unwrap_or_default();
216                if !claiming_sets_disjoint(&existing_set, &current_set) {
217                    return Err(GraphError::DuplicatePackage {
218                        id: primary_id,
219                        paths: vec![existing_path, rel_path],
220                    });
221                }
222                // STALE-KEY REWRITE location (4): re-key package_manifest_decls under
223                // each path's own newly-promoted Prefixed id, sourcing each path's own
224                // decls -- captured via the `existing_decls` returned by `insert` above
225                // and the `current_decls` clone taken before `insert` overwrote the map,
226                // never via a `.get(&primary_id)` lookup after the fact (a lookup miss
227                // there would silently substitute an empty Vec -- exactly the AC-04
228                // manifests-bleed bug).
229                let existing_id = PackageId::Prefixed {
230                    ecosystem: primary_ecosystems[&existing_path],
231                    name: name.clone(),
232                };
233                let current_id = PackageId::Prefixed {
234                    ecosystem: primary_ecosystems[&rel_path],
235                    name: name.clone(),
236                };
237                package_manifest_decls.insert(existing_id.clone(), (existing_path.clone(), existing_decls));
238                package_manifest_decls.insert(current_id.clone(), (rel_path.clone(), current_decls));
239                package_manifest_decls.remove(&primary_id);
240                // STALE-KEY REWRITE location (1): once a bare name is promoted,
241                // it MUST NOT remain in index.bare.
242                index.bare.remove(&name);
243                // STALE-KEY REWRITE location (2): update index.native values
244                // for all native keys declared by either path so they map to
245                // their path's newly-promoted Prefixed id, not the stale primary_id.
246                if let Some(keys) = path_native_keys.get(&existing_path) {
247                    for key in keys {
248                        index.native.insert(key.clone(), existing_id.clone());
249                    }
250                }
251                if let Some(keys) = path_native_keys.get(&rel_path) {
252                    for key in keys {
253                        index.native.insert(key.clone(), current_id.clone());
254                    }
255                }
256                // STALE-KEY REWRITE location (3)/(5): update index.prefixed values
257                // for the primary identities so they point to the newly-promoted
258                // Prefixed id rather than the pre-promotion primary_id.
259                for eco in claiming_ecosystems.get(&existing_path).cloned().unwrap_or_default() {
260                    index.prefixed.insert((eco, name.clone()), existing_id.clone());
261                }
262                for eco in claiming_ecosystems.get(&rel_path).cloned().unwrap_or_default() {
263                    index.prefixed.insert((eco, name.clone()), current_id.clone());
264                }
265                // STALE-KEY REWRITE location (6): update index.platform's owner-id
266                // component for every platform manifest declared under either path,
267                // so a platform sibling co-located with a promoted owner keeps
268                // pointing at that owner's newly-promoted Prefixed id rather than
269                // the stale pre-promotion primary_id.
270                if let Some(keys) = path_platform_keys.get(&existing_path) {
271                    for key in keys {
272                        if let Some((_, manifest_rel, role)) = index.platform.get(key).cloned() {
273                            index
274                                .platform
275                                .insert(key.clone(), (existing_id.clone(), manifest_rel, role));
276                        }
277                    }
278                }
279                if let Some(keys) = path_platform_keys.get(&rel_path) {
280                    for key in keys {
281                        if let Some((_, manifest_rel, role)) = index.platform.get(key).cloned() {
282                            index
283                                .platform
284                                .insert(key.clone(), (current_id.clone(), manifest_rel, role));
285                        }
286                    }
287                }
288                promoted_siblings
289                    .entry(name.clone())
290                    .or_default()
291                    .extend([(existing_id, existing_set), (current_id, current_set)]);
292            }
293        }
294
295        let cfg = cfg.with_promoted_siblings(promoted_siblings);
296
297        // Tracks, per cfg.package_sets entry (by index), whether it matched at
298        // least one real discovered package during this walk. A [[package-set]]
299        // rule that matches nothing is almost always a typo or a stale
300        // ecosystem prefix (see PackageSetMatchedNothing below) rather than
301        // intentional, so it must be surfaced instead of silently ignored.
302        let mut package_set_matched = vec![false; cfg.package_sets.len()];
303
304        let mut packages = BTreeMap::new();
305        for (id, (rel_path, decls)) in package_manifest_decls {
306            let ch_path = rel_path.join("CHANGELOG.md");
307            let mut publish_to = Vec::new();
308            for decl in &decls {
309                if let Ok(editor) = open_cached(manifest_cache, decl, &ctx) {
310                    for target in editor.publish_targets() {
311                        if target != PublishTarget::None && !publish_to.contains(&target) {
312                            publish_to.push(target);
313                        }
314                    }
315                }
316            }
317            if publish_to.is_empty() {
318                publish_to.push(PublishTarget::None);
319            }
320
321            // The real ecosystem(s) this package's manifests were discovered
322            // in. `id` may be PackageId::Bare (unpromoted) or PackageId::Prefixed
323            // (promoted, see SPEC-TRACK3B1-IDENTITY-PROMOTION-CORE); this is the
324            // only place an ecosystem-prefixed [[package-set]] pattern has
325            // anything to match against.
326            let package_ecosystems: Vec<Ecosystem> = decls.iter().map(|d| d.ecosystem()).collect();
327
328            // Two-pass specificity search for [[package]] rules (SPEC-002 AC-1/2/3).
329            // Pass 1: find the first Prefixed rule (pattern.ecosystem().is_some())
330            //         that matches this package's ID. Prefixed rules always win
331            //         over Bare rules regardless of declaration order in callisto.toml.
332            // Pass 2: only if pass 1 found nothing, find the first Bare rule
333            //         (any rule, since no Prefixed rule matched, the first match
334            //          is necessarily Bare) that matches this package's ID.
335            // Within each pass, first-match-wins (TOML declaration order) applies.
336            let pkg_override = resolve_package_config(&id, &cfg)?;
337
338            // Record which [[package-set]] patterns match this package,
339            // independent of whether a [[package]] rule ends up shadowing the
340            // fallback below — a pattern that is always shadowed still
341            // "matched" for the purpose of the zero-match diagnostic.
342            for (idx, (pattern, _)) in cfg.package_sets.iter().enumerate() {
343                if pattern.matches_in_ecosystems(id.name(), &package_ecosystems) {
344                    package_set_matched[idx] = true;
345                }
346            }
347
348            // If no [[package]] rule matched, look for a [[package-set]] fallback.
349            // [[package-set]] uses glob patterns and can match many packages at once;
350            // [[package]] always takes priority over [[package-set]] for the same package.
351            let set_override = if pkg_override.is_none() {
352                cfg.package_sets
353                    .iter()
354                    .find(|(pattern, _)| pattern.matches_in_ecosystems(id.name(), &package_ecosystems))
355                    .map(|(_, cfg)| cfg)
356            } else {
357                None
358            };
359
360            let active_override = pkg_override.or(set_override);
361
362            let release_trigger = active_override
363                .and_then(|o| o.release_trigger)
364                .unwrap_or(ReleaseTrigger::Changeset);
365
366            let tag_template = active_override.and_then(|o| o.tag_template.clone());
367
368            let changelog = if let Some(override_path) = active_override.and_then(|o| o.changelog.as_ref()) {
369                Some(rel_path.join(override_path))
370            } else {
371                Some(ch_path)
372            };
373
374            // Apply the resolved override's publish-to if the operator explicitly set it.
375            //
376            // A `[[package]]`/`[[package-set]]` rule has no package context at
377            // config-parse time (it's just a pattern + string list), so the
378            // only place the package's real, detected ecosystem is known is
379            // here, once `decls` (the package's actual manifests) have been
380            // walked. Reject any configured target whose `.ecosystem()` does
381            // not match one of the package's detected ecosystems — e.g.
382            // `publish-to = ["nuget"]` on a Cargo-only crate — rather than
383            // silently accepting it and having the crate vanish from every
384            // real publish downstream with zero diagnostic.
385            if let Some(override_targets) = active_override.and_then(|o| o.publish_to.as_deref()) {
386                for target in override_targets {
387                    if let Some(target_ecosystem) = target.ecosystem() {
388                        if !package_ecosystems.contains(&target_ecosystem) {
389                            return Err(GraphError::PublishTargetEcosystemMismatch {
390                                package: id.clone(),
391                                target: target.config_str().to_string(),
392                                target_ecosystem,
393                                package_ecosystems,
394                            });
395                        }
396                    }
397                }
398                publish_to = override_targets.to_vec();
399            }
400
401            let pkg = Package {
402                id: id.clone(),
403                manifests: decls,
404                changelog,
405                release_trigger,
406                publish_to,
407                tag_template,
408            };
409            packages.insert(id, pkg);
410        }
411
412        // A [[package-set]] rule that matched zero real packages is almost
413        // always a mistake (e.g. an ecosystem prefix that doesn't correspond
414        // to any discovered package, or a typo in the glob) rather than
415        // intentional, so surface it as a visible, non-fatal diagnostic
416        // instead of letting the rule silently do nothing. This is
417        // deliberately advisory rather than a hard `GraphError`: a
418        // `[[package-set]]` rule declared for a monorepo-wide callisto.toml
419        // can legitimately match nothing when only part of the workspace is
420        // present (e.g. a partial checkout or filtered walk), and a hard
421        // error would break that case.
422        for (idx, (pattern, _)) in cfg.package_sets.iter().enumerate() {
423            if !package_set_matched[idx] {
424                diagnostics.push(Diagnostic {
425                    code: DiagnosticCode::PackageSetMatchedNothing,
426                    severity: DiagnosticSeverity::Warning,
427                    message: format!("[[package-set]] `{}` matched no packages", pattern.as_str()),
428                    package: None,
429                    path: None,
430                    escalated_by: None,
431                    governed_by: None,
432                });
433            }
434        }
435
436        // SPEC-002 AC-5: Cross-ecosystem diagnostic pass.
437        //
438        // For each bare [[package]] rule in cfg.packages (pattern.ecosystem() == None),
439        // compute the distinct-ecosystem set: the Ecosystem values found in the canonical
440        // ManifestDecls of every packages-map entry matched by this rule.
441        //
442        // Packages-map keys may be PackageId::Bare or PackageId::Prefixed (a
443        // promoted package, see SPEC-TRACK3B1-IDENTITY-PROMOTION-CORE). This loop
444        // remains correct regardless: ecosystem information is always sourced from
445        // pkg.canonical_manifests(), never from key.ecosystem(), and `pattern`
446        // here is always unprefixed (prefixed rules `continue` above), so
447        // pattern.matches(key) matches by name alone independent of whether `key`
448        // itself is Bare or Prefixed. Do NOT use key.ecosystem().
449        //
450        // The primary trigger is a single directory containing both Cargo.toml and
451        // package.json (the napi case): one packages-map entry with two canonical
452        // ManifestDecls whose ecosystems are {Cargo, Npm}.
453        //
454        // Prefixed [[package]] rules are skipped unconditionally (AC-7).
455        // [[package-set]] rules are never iterated here (AC-8).
456        for (pattern, _) in &cfg.packages {
457            if pattern.ecosystem().is_some() {
458                continue; // Prefixed rules never trigger this diagnostic (AC-7).
459            }
460            let ecosystems: BTreeSet<Ecosystem> = packages
461                .iter()
462                .filter(|(key, _)| pattern.matches(key))
463                // Use the existing Package::canonical_manifests() helper
464                // (package.rs) which filters to ManifestRole::Canonical.
465                .flat_map(|(_, pkg)| pkg.canonical_manifests().map(|d| d.ecosystem()))
466                .collect();
467            if ecosystems.len() >= 2 {
468                let eco_list: Vec<&str> = ecosystems.iter().map(|e| e.prefix()).collect();
469                diagnostics.push(Diagnostic {
470                    code: DiagnosticCode::BareRuleMatchesMultipleEcosystems,
471                    severity: DiagnosticSeverity::Warning,
472                    message: format!(
473                        "[[package]] rule `{}` matches packages in multiple ecosystems ({}); \
474                         use an ecosystem-prefixed pattern like `{}/{}` to target only one",
475                        pattern.name(),
476                        eco_list.join(", "),
477                        ecosystems.iter().next().map(|e| e.prefix()).unwrap_or("cargo"),
478                        pattern.name(),
479                    ),
480                    package: None,
481                    path: None,
482                    escalated_by: None,
483                    governed_by: None,
484                });
485            }
486        }
487
488        let mut edges = Vec::new();
489        let mut out_index: BTreeMap<PackageId, Vec<usize>> = BTreeMap::new();
490        let mut in_index: BTreeMap<PackageId, Vec<usize>> = BTreeMap::new();
491
492        for pkg in packages.values() {
493            for decl in &pkg.manifests {
494                if decl.role != ManifestRole::Canonical {
495                    continue;
496                }
497                if let Ok(m) = open_cached(manifest_cache, decl, &ctx) {
498                    for entry in m.iter_dependencies() {
499                        let (spec, declaring_path) = if entry.inherited {
500                            if let Some(ref inh) = ctx.cargo_workspace {
501                                if let Some(inherited_dep) = inh.inherited(&entry.name) {
502                                    (inherited_dep.spec.clone(), inherited_dep.declared_in.to_path_buf())
503                                } else {
504                                    (entry.spec.clone(), decl.path.clone())
505                                }
506                            } else {
507                                (entry.spec.clone(), decl.path.clone())
508                            }
509                        } else {
510                            (entry.spec.clone(), decl.path.clone())
511                        };
512
513                        if let Some(to) =
514                            index.resolve_native_with_fallback(decl.ecosystem(), &entry.name, &mut diagnostics)
515                        {
516                            let idx = edges.len();
517                            let edge = DepEdge {
518                                from: pkg.id.clone(),
519                                to: to.clone(),
520                                kind: entry.kind,
521                                spec,
522                                from_manifest: declaring_path,
523                                inherited: entry.inherited,
524                            };
525                            edges.push(edge);
526
527                            out_index.entry(pkg.id.clone()).or_default().push(idx);
528                            in_index.entry(to.clone()).or_default().push(idx);
529                        }
530                    }
531                }
532            }
533        }
534
535        if let Some(declared) = locator.declared_edges() {
536            let cross_diags = crosscheck_declared_edges(&packages, &edges, &declared);
537            diagnostics.extend(cross_diags);
538        }
539
540        Ok(ManifestWalkResolver {
541            packages,
542            edges,
543            out_index,
544            in_index,
545            index,
546            diagnostics,
547        })
548    }
549}
550
551/// Reads `package.json` at `abs_path` and returns `ManifestRole::Platform`
552/// when the manifest declares both `os` and `cpu` constraint arrays (the napi
553/// platform-package convention). Returns `ManifestRole::Canonical` for all
554/// other npm packages and on any read/parse failure.
555///
556/// Note: this performs a second `fs::read` on each npm `package.json` because
557/// the `manifest_cache` stores `Arc<dyn Manifest>` (which does not expose raw
558/// JSON fields like `os`/`cpu`) rather than a raw `serde_json::Value`. Fixing
559/// the redundancy would require a `Manifest::npm_role()` extension method in
560/// the `callisto-manifests` crate.
561fn detect_npm_role(abs_path: &Path) -> ManifestRole {
562    let Ok(bytes) = std::fs::read(abs_path) else {
563        return ManifestRole::Canonical;
564    };
565    let Ok(Value::Object(map)) = serde_json::from_slice(&bytes) else {
566        return ManifestRole::Canonical;
567    };
568
569    let has_os = map.get("os").and_then(|v| v.as_array()).is_some_and(|a| !a.is_empty());
570    let has_cpu = map.get("cpu").and_then(|v| v.as_array()).is_some_and(|a| !a.is_empty());
571
572    if !has_os || !has_cpu {
573        return ManifestRole::Canonical;
574    }
575
576    let Some(platform) = map
577        .get("os")
578        .and_then(|v| v.as_array())
579        .and_then(|a| a.first())
580        .and_then(|v| v.as_str())
581        .map(str::to_string)
582    else {
583        return ManifestRole::Canonical;
584    };
585
586    let Some(arch) = map
587        .get("cpu")
588        .and_then(|v| v.as_array())
589        .and_then(|a| a.first())
590        .and_then(|v| v.as_str())
591        .map(str::to_string)
592    else {
593        return ManifestRole::Canonical;
594    };
595
596    // npm's standard `os`/`cpu` manifest fields have no libc/ABI concept at
597    // all, so a real disk-discovered Linux napi platform package always
598    // produced `abi: None` here -- but `napi.rs::role_to_triple`'s Linux
599    // match arms all require a concrete ABI, meaning such a package could
600    // never resolve to its triple. napi-rs's own package-generation
601    // convention encodes the ABI in the package *name*'s suffix instead
602    // (e.g. "@scope/pkg-linux-x64-gnu"), so infer it from there when the
603    // platform is linux.
604    let abi = if platform == "linux" {
605        map.get("name")
606            .and_then(|v| v.as_str())
607            .and_then(napi_linux_abi_from_package_name)
608    } else {
609        None
610    };
611
612    ManifestRole::Platform { platform, arch, abi }
613}
614
615/// Infers a Linux napi-rs platform package's libc ABI from its package
616/// name's trailing suffix. Returns `None` when the name has no recognized
617/// suffix -- this infers when the signal is present, it doesn't invent an
618/// ABI that isn't actually there.
619fn napi_linux_abi_from_package_name(name: &str) -> Option<String> {
620    for abi in ["gnueabihf", "gnu", "musl"] {
621        if name.ends_with(&format!("-{abi}")) {
622            return Some(abi.to_string());
623        }
624    }
625    None
626}
627
628/// Explicit ecosystem precedence for primary-ID selection when a single
629/// project directory contains manifests from multiple ecosystems (e.g., both
630/// `Cargo.toml` and `package.json`). Lower value = higher priority.
631///
632/// Precedence: Cargo (0) > Npm (1) > Pypi (2) > others (255).
633///
634/// This function is used instead of `Ecosystem`'s derived `Ord` so that the
635/// ordering is stable even if the `Ecosystem` variant sequence changes.
636fn ecosystem_primary_priority(e: Ecosystem) -> u8 {
637    match e {
638        Ecosystem::Cargo => 0,
639        Ecosystem::Npm => 1,
640        Ecosystem::Pypi => 2,
641        _ => u8::MAX,
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    fn write_pkg(root: &std::path::Path, rel: &str, eco: Ecosystem, name: &str) {
650        std::fs::create_dir_all(root.join(rel)).unwrap();
651        match eco {
652            Ecosystem::Cargo => std::fs::write(
653                root.join(rel).join("Cargo.toml"),
654                format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
655            )
656            .unwrap(),
657            Ecosystem::Npm => std::fs::write(
658                root.join(rel).join("package.json"),
659                format!(r#"{{"name":"{name}","version":"0.1.0"}}"#),
660            )
661            .unwrap(),
662            Ecosystem::Pypi => std::fs::write(
663                root.join(rel).join("pyproject.toml"),
664                format!("[project]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
665            )
666            .unwrap(),
667            _ => unreachable!(),
668        }
669    }
670
671    #[test]
672    fn ac16a_fixed_group_member_resolves_via_prefixed_unpromoted_cargo_package() {
673        let dir = tempfile::tempdir().expect("tempdir");
674        let root = dir.path();
675        write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
676        std::fs::write(
677            root.join("callisto.toml"),
678            "[[fixed-group]]\nname = \"g\"\nmembers = [\"cargo:foo\"]\n",
679        )
680        .unwrap();
681        let locator = crate::locate::IgnoreWalkLocator::new(root);
682        let runner = NoopRunner;
683        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
684            .expect("Workspace::load must succeed for an unpromoted Cargo package referenced via cargo:foo");
685        let native = ws
686            .graph
687            .identity()
688            .native
689            .get(&(Ecosystem::Cargo, "foo".to_string()))
690            .expect("native entry must exist");
691        let prefixed = ws
692            .graph
693            .identity()
694            .prefixed
695            .get(&(Ecosystem::Cargo, "foo".to_string()))
696            .expect("prefixed entry must exist for AC-02");
697        assert_eq!(
698            prefixed, native,
699            "prefixed and native must resolve to the identical PackageId for an unpromoted single-ecosystem package"
700        );
701        let group = ws
702            .config
703            .groups
704            .fixed
705            .get(&callisto_model::GroupName("g".to_string()))
706            .expect("group must exist");
707        assert_eq!(group.members.len(), 1);
708    }
709
710    #[test]
711    fn ac03_fixed_group_member_naming_absent_ecosystem_is_missing_group_member() {
712        let dir = tempfile::tempdir().expect("tempdir");
713        let root = dir.path();
714        write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
715        std::fs::write(
716            root.join("callisto.toml"),
717            "[[fixed-group]]\nname = \"g\"\nmembers = [\"npm:foo\"]\n",
718        )
719        .unwrap();
720        let locator = crate::locate::IgnoreWalkLocator::new(root);
721        let runner = NoopRunner;
722        let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
723            Err(e) => e,
724            Ok(_) => panic!("expected MissingGroupMember error, got Ok"),
725        };
726        match err {
727            GraphError::MissingGroupMember { member, .. } => {
728                assert_eq!(member, "npm:foo");
729            }
730            other => panic!("expected MissingGroupMember, got {other:?}"),
731        }
732    }
733
734    struct NoopRunner;
735    impl CommandRunner for NoopRunner {
736        fn run(
737            &self,
738            _program: &str,
739            _args: &[&str],
740            _cwd: &Path,
741        ) -> Result<callisto_model::CommandOutput, callisto_model::CommandError> {
742            panic!("this test's workspace build never needs to shell out");
743        }
744    }
745
746    /// End-to-end: a real disk-discovered napi platform manifest (Case D --
747    /// a `Cargo.toml` and a differently-named `package.json` sharing one
748    /// directory, the platform npm package's own identity distinct from the
749    /// owning crate's) must resolve through `[[fixed-group]] members`
750    /// naming the platform package by its own npm name, via
751    /// `IdentityIndex.platform` -- not the hand-constructed
752    /// `GroupMember::PlatformManifest` fixtures other tests use, which
753    /// bypass this wiring entirely and would not have caught the gap this
754    /// test pins.
755    #[test]
756    fn real_platform_manifest_resolves_via_fixed_group_and_feeds_napi_drift() {
757        let dir = tempfile::tempdir().expect("tempdir");
758        let root = dir.path();
759
760        std::fs::write(
761            root.join("Cargo.toml"),
762            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
763        )
764        .unwrap();
765        std::fs::write(
766            root.join("package.json"),
767            r#"{"name":"@myorg/my-crate-linux-x64-gnu","version":"0.1.0","os":["linux"],"cpu":["x64"]}"#,
768        )
769        .unwrap();
770        std::fs::write(
771            root.join("callisto.toml"),
772            "[[fixed-group]]\nname = \"my-group\"\nmembers = [\"my-crate\", \"@myorg/my-crate-linux-x64-gnu\"]\n",
773        )
774        .unwrap();
775
776        let locator = crate::locate::IgnoreWalkLocator::new(root);
777        let runner = NoopRunner;
778        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
779            .expect("group resolution must succeed now that the real platform manifest resolves");
780
781        let group = ws
782            .config
783            .groups
784            .fixed
785            .get(&callisto_model::GroupName("my-group".to_string()))
786            .expect("fixed group must exist");
787
788        let platform_member = group
789            .members
790            .iter()
791            .find(|m| matches!(m, crate::config::groups::GroupMember::PlatformManifest { .. }))
792            .expect("platform member must resolve, not be silently dropped or errored");
793
794        let crate::config::groups::GroupMember::PlatformManifest { role, name, .. } = platform_member else {
795            unreachable!()
796        };
797        assert_eq!(name, "@myorg/my-crate-linux-x64-gnu");
798        assert_eq!(
799            crate::napi::role_to_triple(role).as_deref(),
800            Some("x86_64-unknown-linux-gnu"),
801            "role must be the real, disk-derived role, not the old hardcoded \
802             platform=\"unknown\" stub -- got: {role:?}"
803        );
804
805        // Feed straight into napi_drift, matching the task's own framing:
806        // "napi_drift receives real group members".
807        let declared = vec!["x86_64-unknown-linux-gnu".to_string()];
808        let diagnostics = crate::napi::napi_drift(group, &declared, root);
809        assert!(
810            diagnostics.is_empty(),
811            "declared napi.targets matches the real group member; expected no drift \
812             diagnostics, got: {diagnostics:?}"
813        );
814    }
815
816    /// A real `optionalDependencies` edge onto a Case D platform package (a
817    /// `Cargo.toml` and differently-named `package.json` sharing one
818    /// directory, per the test above) must resolve through
819    /// `IdentityIndex.native`, keyed by the platform manifest's own npm
820    /// name -- not the owning crate's `primary_id` name, which a
821    /// sibling's dependency entry never names. Before this fix,
822    /// `index.native` was keyed by `primary_id.name()` for every
823    /// ecosystem in a Case D directory, so a dependency naming the
824    /// platform package by its real npm name silently failed to resolve,
825    /// dropping the edge with no diagnostic.
826    #[test]
827    fn optional_dependency_on_case_d_platform_package_resolves_via_native_index() {
828        let dir = tempfile::tempdir().expect("tempdir");
829        let root = dir.path();
830
831        std::fs::write(
832            root.join("Cargo.toml"),
833            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
834        )
835        .unwrap();
836        std::fs::write(
837            root.join("package.json"),
838            r#"{"name":"@myorg/my-crate-linux-x64-gnu","version":"0.1.0","os":["linux"],"cpu":["x64"]}"#,
839        )
840        .unwrap();
841
842        std::fs::create_dir_all(root.join("consumer")).unwrap();
843        std::fs::write(
844            root.join("consumer/package.json"),
845            r#"{"name":"@myorg/consumer","version":"0.1.0","optionalDependencies":{"@myorg/my-crate-linux-x64-gnu":"0.1.0"}}"#,
846        )
847        .unwrap();
848
849        let locator = crate::locate::IgnoreWalkLocator::new(root);
850        let runner = NoopRunner;
851        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("workspace load must succeed");
852
853        let owning_crate = callisto_model::PackageId::Bare("my-crate".to_string());
854        let consumer = callisto_model::PackageId::Bare("@myorg/consumer".to_string());
855
856        let edge = ws.graph.edges().iter().find(|e| e.from == consumer).expect(
857            "consumer's optionalDependencies edge onto the Case D platform package must \
858                 resolve, not be silently dropped",
859        );
860        assert_eq!(
861            edge.to, owning_crate,
862            "the platform package belongs to the owning crate (Case D); the edge must resolve \
863             to the owning crate's identity, not fail to resolve at all"
864        );
865    }
866
867    /// Real disk-discovered Linux napi platform packages always have
868    /// `abi: None` from `detect_npm_role` (npm's standard `os`/`cpu`
869    /// manifest fields have no libc/ABI concept), but `napi.rs::role_to_triple`'s
870    /// Linux match arms all require `Some("gnu")`/`Some("musl")`/
871    /// `Some("gnueabihf")` -- so before this fix, `role_to_triple` could
872    /// never resolve a real, disk-discovered Linux platform package to its
873    /// triple, and `napi_drift` would spuriously report it as a
874    /// declared-but-missing group member even though it's genuinely present
875    /// on disk with the correct name.
876    #[test]
877    fn detect_npm_role_infers_linux_gnu_abi_from_package_name_suffix() {
878        let dir = tempfile::tempdir().unwrap();
879        let path = dir.path().join("package.json");
880        std::fs::write(
881            &path,
882            r#"{"name":"@scope/my-lib-linux-x64-gnu","version":"1.0.0","os":["linux"],"cpu":["x64"]}"#,
883        )
884        .unwrap();
885
886        let role = detect_npm_role(&path);
887
888        assert_eq!(
889            crate::napi::role_to_triple(&role).as_deref(),
890            Some("x86_64-unknown-linux-gnu"),
891            "expected a resolvable triple, got role: {role:?}"
892        );
893    }
894
895    #[test]
896    fn detect_npm_role_infers_linux_musl_abi_from_package_name_suffix() {
897        let dir = tempfile::tempdir().unwrap();
898        let path = dir.path().join("package.json");
899        std::fs::write(
900            &path,
901            r#"{"name":"@scope/my-lib-linux-arm64-musl","version":"1.0.0","os":["linux"],"cpu":["arm64"]}"#,
902        )
903        .unwrap();
904
905        let role = detect_npm_role(&path);
906
907        assert_eq!(
908            crate::napi::role_to_triple(&role).as_deref(),
909            Some("aarch64-unknown-linux-musl"),
910            "expected a resolvable triple, got role: {role:?}"
911        );
912    }
913
914    #[test]
915    fn detect_npm_role_infers_linux_gnueabihf_abi_from_package_name_suffix() {
916        let dir = tempfile::tempdir().unwrap();
917        let path = dir.path().join("package.json");
918        std::fs::write(
919            &path,
920            r#"{"name":"@scope/my-lib-linux-arm-gnueabihf","version":"1.0.0","os":["linux"],"cpu":["arm"]}"#,
921        )
922        .unwrap();
923
924        let role = detect_npm_role(&path);
925
926        assert_eq!(
927            crate::napi::role_to_triple(&role).as_deref(),
928            Some("armv7-unknown-linux-gnueabihf"),
929            "expected a resolvable triple, got role: {role:?}"
930        );
931    }
932
933    /// Non-Linux platforms have no ABI concept in `role_to_triple`'s table
934    /// (`abi` is always `None` there) -- must not be affected by the
935    /// name-suffix inference at all.
936    #[test]
937    fn detect_npm_role_does_not_infer_abi_for_non_linux_platforms() {
938        let dir = tempfile::tempdir().unwrap();
939        let path = dir.path().join("package.json");
940        std::fs::write(
941            &path,
942            r#"{"name":"@scope/my-lib-darwin-arm64-gnu","version":"1.0.0","os":["darwin"],"cpu":["arm64"]}"#,
943        )
944        .unwrap();
945
946        let role = detect_npm_role(&path);
947
948        assert_eq!(
949            role,
950            ManifestRole::Platform {
951                platform: "darwin".to_string(),
952                arch: "arm64".to_string(),
953                abi: None,
954            }
955        );
956    }
957
958    /// A Linux platform package whose name has no recognized ABI suffix at
959    /// all stays `abi: None` -- this function infers when it can, it
960    /// doesn't invent an ABI that isn't actually signaled anywhere.
961    #[test]
962    fn detect_npm_role_leaves_abi_none_when_linux_name_has_no_recognized_suffix() {
963        let dir = tempfile::tempdir().unwrap();
964        let path = dir.path().join("package.json");
965        std::fs::write(
966            &path,
967            r#"{"name":"@scope/my-lib-linux-x64","version":"1.0.0","os":["linux"],"cpu":["x64"]}"#,
968        )
969        .unwrap();
970
971        let role = detect_npm_role(&path);
972
973        assert_eq!(
974            role,
975            ManifestRole::Platform {
976                platform: "linux".to_string(),
977                arch: "x64".to_string(),
978                abi: None,
979            }
980        );
981    }
982
983    #[test]
984    fn claiming_ecosystems_is_name_scoped_not_full_path_ecosystem_set() {
985        let list = vec![
986            (Ecosystem::Cargo, PackageId::Bare("native-core".to_string())),
987            (
988                Ecosystem::Npm,
989                PackageId::Bare("@myorg/native-core-linux-x64-gnu".to_string()),
990            ),
991        ];
992        let primary_id = PackageId::Bare("native-core".to_string());
993        let (claiming, native_keys) = compute_claiming_ecosystems_and_native_keys(&list, &primary_id);
994        let mut expected = std::collections::BTreeSet::new();
995        expected.insert(Ecosystem::Cargo);
996        assert_eq!(
997            claiming, expected,
998            "npm Platform entry must NOT count toward the name-scoped claiming set"
999        );
1000        assert_eq!(
1001            native_keys,
1002            vec![
1003                (Ecosystem::Cargo, "native-core".to_string()),
1004                (Ecosystem::Npm, "@myorg/native-core-linux-x64-gnu".to_string()),
1005            ],
1006            "path_native_keys must retain the COMPLETE unfiltered key set, unlike claiming_ecosystems"
1007        );
1008    }
1009
1010    #[test]
1011    fn primary_ecosystems_records_the_actual_primary_not_an_arbitrary_set_member() {
1012        let dir = tempfile::tempdir().expect("tempdir");
1013        let root = dir.path();
1014        write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
1015        std::fs::write(
1016            root.join("crates/foo/package.json"),
1017            r#"{"name":"@myorg/foo","version":"0.1.0"}"#,
1018        )
1019        .unwrap();
1020        let locator = crate::locate::IgnoreWalkLocator::new(root);
1021        let runner = NoopRunner;
1022        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Case D load must succeed");
1023        assert!(ws.graph.get(&PackageId::Bare("foo".to_string())).is_some());
1024    }
1025
1026    #[test]
1027    fn is_promoted_when_multiple_paths_claim_same_bare_name_with_disjoint_ecosystems() {
1028        let p1 = PathBuf::from("crates/native-core");
1029        let p2 = PathBuf::from("packages/native-core");
1030        let mut claiming = std::collections::BTreeMap::new();
1031        let mut eco1 = std::collections::BTreeSet::new();
1032        eco1.insert(Ecosystem::Cargo);
1033        let mut eco2 = std::collections::BTreeSet::new();
1034        eco2.insert(Ecosystem::Npm);
1035        claiming.insert(p1.clone(), eco1);
1036        claiming.insert(p2.clone(), eco2);
1037        let paths = vec![p1, p2];
1038        assert!(is_promoted_bare_name(&paths, &claiming));
1039    }
1040
1041    #[test]
1042    fn is_not_promoted_when_single_path_claims_bare_name() {
1043        let p1 = PathBuf::from("crates/native-core");
1044        let mut claiming = std::collections::BTreeMap::new();
1045        let mut eco1 = std::collections::BTreeSet::new();
1046        eco1.insert(Ecosystem::Cargo);
1047        claiming.insert(p1.clone(), eco1);
1048        let paths = vec![p1];
1049        assert!(!is_promoted_bare_name(&paths, &claiming));
1050    }
1051
1052    #[test]
1053    fn is_not_promoted_when_multiple_paths_have_overlapping_ecosystems() {
1054        let p1 = PathBuf::from("crates/native-core");
1055        let p2 = PathBuf::from("crates/other-core");
1056        let mut claiming = std::collections::BTreeMap::new();
1057        let mut eco1 = std::collections::BTreeSet::new();
1058        eco1.insert(Ecosystem::Cargo);
1059        let mut eco2 = std::collections::BTreeSet::new();
1060        eco2.insert(Ecosystem::Cargo);
1061        claiming.insert(p1.clone(), eco1);
1062        claiming.insert(p2.clone(), eco2);
1063        let paths = vec![p1, p2];
1064        assert!(
1065            !is_promoted_bare_name(&paths, &claiming),
1066            "overlapping ecosystem sets must NOT trigger promotion; duplicate check handles it"
1067        );
1068    }
1069
1070    #[test]
1071    fn promotion_predicate_is_disjointness_not_inequality() {
1072        let mut cargo_npm = BTreeSet::new();
1073        cargo_npm.insert(Ecosystem::Cargo);
1074        cargo_npm.insert(Ecosystem::Npm);
1075        let mut npm_only = BTreeSet::new();
1076        npm_only.insert(Ecosystem::Npm);
1077        let mut pypi_only = BTreeSet::new();
1078        pypi_only.insert(Ecosystem::Pypi);
1079
1080        assert!(!claiming_sets_disjoint(&cargo_npm, &npm_only));
1081        assert!(claiming_sets_disjoint(&cargo_npm, &pypi_only));
1082        assert!(claiming_sets_disjoint(&npm_only, &pypi_only));
1083    }
1084
1085    #[test]
1086    fn same_ecosystem_collision_still_errors_unchanged() {
1087        let dir = tempfile::tempdir().expect("tempdir");
1088        let root = dir.path();
1089        write_pkg(root, "crates/a", Ecosystem::Cargo, "dup");
1090        write_pkg(root, "crates/b", Ecosystem::Cargo, "dup");
1091        let locator = crate::locate::IgnoreWalkLocator::new(root);
1092        let runner = NoopRunner;
1093        let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1094            Err(e) => e,
1095            Ok(_) => panic!("expected DuplicatePackage error, got Ok"),
1096        };
1097        match err {
1098            GraphError::DuplicatePackage { id, paths } => {
1099                assert_eq!(id, PackageId::Bare("dup".to_string()));
1100                assert_eq!(paths.len(), 2);
1101            }
1102            other => panic!("expected DuplicatePackage, got {other:?}"),
1103        }
1104    }
1105
1106    #[test]
1107    fn case_d_colliding_with_third_disjoint_ecosystem_still_errors() {
1108        let dir = tempfile::tempdir().expect("tempdir");
1109        let root = dir.path();
1110        write_pkg(root, "crates/case-d", Ecosystem::Cargo, "hybrid");
1111        write_pkg(root, "crates/case-d", Ecosystem::Npm, "hybrid");
1112        write_pkg(root, "packages/npm-hybrid", Ecosystem::Npm, "hybrid");
1113        let locator = crate::locate::IgnoreWalkLocator::new(root);
1114        let runner = NoopRunner;
1115        let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1116            Err(e) => e,
1117            Ok(_) => panic!("expected DuplicatePackage error, got Ok"),
1118        };
1119        match err {
1120            GraphError::DuplicatePackage { id, paths } => {
1121                assert_eq!(id, PackageId::Bare("hybrid".to_string()));
1122                assert_eq!(paths.len(), 2);
1123            }
1124            other => panic!("expected DuplicatePackage, got {other:?}"),
1125        }
1126    }
1127
1128    #[test]
1129    fn disjoint_cross_ecosystem_collision_promotes_instead_of_duplicate_package() {
1130        let dir = tempfile::tempdir().expect("tempdir");
1131        let root = dir.path();
1132        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1133        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1134
1135        let locator = crate::locate::IgnoreWalkLocator::new(root);
1136        let runner = NoopRunner;
1137        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1138            .expect("disjoint cross-ecosystem collision must promote, not DuplicatePackage");
1139
1140        let cargo_id = PackageId::Prefixed {
1141            ecosystem: Ecosystem::Cargo,
1142            name: "native-core".to_string(),
1143        };
1144        let npm_id = PackageId::Prefixed {
1145            ecosystem: Ecosystem::Npm,
1146            name: "native-core".to_string(),
1147        };
1148        assert_eq!(ws.graph.packages().count(), 2);
1149        let cargo_pkg = ws.graph.get(&cargo_id).expect("Cargo-prefixed entry must exist");
1150        let npm_pkg = ws.graph.get(&npm_id).expect("Npm-prefixed entry must exist");
1151        assert_eq!(cargo_pkg.manifests.len(), 1);
1152        assert_eq!(
1153            cargo_pkg.manifests[0].path,
1154            PathBuf::from("crates/native-core/Cargo.toml")
1155        );
1156        assert_eq!(npm_pkg.manifests.len(), 1);
1157        assert_eq!(
1158            npm_pkg.manifests[0].path,
1159            PathBuf::from("packages/native-core/package.json")
1160        );
1161    }
1162
1163    #[test]
1164    fn promoted_name_removed_from_index_bare() {
1165        let dir = tempfile::tempdir().expect("tempdir");
1166        let root = dir.path();
1167        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1168        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1169        let locator = crate::locate::IgnoreWalkLocator::new(root);
1170        let runner = NoopRunner;
1171        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1172        assert!(
1173            !ws.graph.identity().bare.contains_key("native-core"),
1174            "index.bare must not retain the stale pre-promotion key once both occurrences promote"
1175        );
1176        let unprefixed_lookup = ws.graph.identity().resolve_human("native-core", &[]);
1177        assert!(matches!(unprefixed_lookup, Err(GraphError::AmbiguousName { .. })));
1178    }
1179
1180    #[test]
1181    fn promoted_native_values_point_to_prefixed_ids() {
1182        let dir = tempfile::tempdir().expect("tempdir");
1183        let root = dir.path();
1184        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1185        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1186        let locator = crate::locate::IgnoreWalkLocator::new(root);
1187        let runner = NoopRunner;
1188        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1189
1190        let cargo_native = ws
1191            .graph
1192            .identity()
1193            .native
1194            .get(&(Ecosystem::Cargo, "native-core".to_string()))
1195            .expect("Cargo native entry must exist");
1196        let npm_native = ws
1197            .graph
1198            .identity()
1199            .native
1200            .get(&(Ecosystem::Npm, "native-core".to_string()))
1201            .expect("Npm native entry must exist");
1202
1203        assert_eq!(
1204            cargo_native,
1205            &PackageId::Prefixed {
1206                ecosystem: Ecosystem::Cargo,
1207                name: "native-core".to_string(),
1208            },
1209            "index.native value must point to the promoted Cargo-prefixed ID"
1210        );
1211        assert_eq!(
1212            npm_native,
1213            &PackageId::Prefixed {
1214                ecosystem: Ecosystem::Npm,
1215                name: "native-core".to_string(),
1216            },
1217            "index.native value must point to the promoted Npm-prefixed ID"
1218        );
1219    }
1220
1221    #[test]
1222    fn promoted_prefixed_values_point_to_prefixed_ids() {
1223        let dir = tempfile::tempdir().expect("tempdir");
1224        let root = dir.path();
1225        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1226        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1227        let locator = crate::locate::IgnoreWalkLocator::new(root);
1228        let runner = NoopRunner;
1229        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1230
1231        let cargo_prefixed = ws
1232            .graph
1233            .identity()
1234            .prefixed
1235            .get(&(Ecosystem::Cargo, "native-core".to_string()))
1236            .expect("Cargo prefixed entry must exist");
1237        let npm_prefixed = ws
1238            .graph
1239            .identity()
1240            .prefixed
1241            .get(&(Ecosystem::Npm, "native-core".to_string()))
1242            .expect("Npm prefixed entry must exist");
1243
1244        assert_eq!(
1245            cargo_prefixed,
1246            &PackageId::Prefixed {
1247                ecosystem: Ecosystem::Cargo,
1248                name: "native-core".to_string(),
1249            },
1250            "index.prefixed value must point to the promoted Cargo-prefixed ID"
1251        );
1252        assert_eq!(
1253            npm_prefixed,
1254            &PackageId::Prefixed {
1255                ecosystem: Ecosystem::Npm,
1256                name: "native-core".to_string(),
1257            },
1258            "index.prefixed value must point to the promoted Npm-prefixed ID"
1259        );
1260    }
1261
1262    /// A platform npm manifest co-located with a Cargo owner (Case D) whose
1263    /// owner later gets promoted via a disjoint cross-ecosystem bare-name
1264    /// collision elsewhere in the workspace: `index.platform`'s stored
1265    /// owner id must be rewritten to the promoted Prefixed id, matching the
1266    /// same rewrite already applied to `index.bare`/`index.native`/
1267    /// `index.prefixed` for this exact scenario.
1268    #[test]
1269    fn promoted_platform_index_value_points_to_prefixed_owner_id() {
1270        let dir = tempfile::tempdir().expect("tempdir");
1271        let root = dir.path();
1272        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1273        std::fs::write(
1274            root.join("crates/native-core/package.json"),
1275            r#"{"name":"@myorg/native-core-linux-x64-gnu","version":"0.1.0","os":["linux"],"cpu":["x64"]}"#,
1276        )
1277        .unwrap();
1278        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1279
1280        let locator = crate::locate::IgnoreWalkLocator::new(root);
1281        let runner = NoopRunner;
1282        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1283
1284        let platform_entry = ws
1285            .graph
1286            .identity()
1287            .platform
1288            .get("@myorg/native-core-linux-x64-gnu")
1289            .expect("platform entry must exist");
1290
1291        assert_eq!(
1292            platform_entry.0,
1293            PackageId::Prefixed {
1294                ecosystem: Ecosystem::Cargo,
1295                name: "native-core".to_string(),
1296            },
1297            "index.platform's owner id must point to the promoted Cargo-prefixed id, \
1298             not the stale pre-promotion bare id"
1299        );
1300    }
1301
1302    #[test]
1303    fn unpromoted_standalone_cargo_package_retains_bare_id() {
1304        let dir = tempfile::tempdir().expect("tempdir");
1305        let root = dir.path();
1306        write_pkg(root, "crates/single", Ecosystem::Cargo, "single");
1307        let locator = crate::locate::IgnoreWalkLocator::new(root);
1308        let runner = NoopRunner;
1309        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1310            .expect("standalone package load must succeed");
1311        let bare_id = PackageId::Bare("single".to_string());
1312        assert!(
1313            ws.graph.get(&bare_id).is_some(),
1314            "unpromoted single-ecosystem package must register under Bare ID"
1315        );
1316        let prefixed_id = PackageId::Prefixed {
1317            ecosystem: Ecosystem::Cargo,
1318            name: "single".to_string(),
1319        };
1320        assert!(
1321            ws.graph.get(&prefixed_id).is_none(),
1322            "unpromoted package must NOT register under Prefixed ID directly"
1323        );
1324        assert_eq!(
1325            ws.graph.identity().bare.get("single"),
1326            Some(&bare_id),
1327            "index.bare must point to the Bare ID"
1328        );
1329        let resolved = ws
1330            .graph
1331            .identity()
1332            .resolve_human("cargo:single", &[])
1333            .expect("cargo:single must resolve");
1334        assert_eq!(
1335            resolved, bare_id,
1336            "cargo:single human lookup must resolve to the Bare ID"
1337        );
1338    }
1339
1340    #[test]
1341    fn case_d_single_path_multi_ecosystem_retains_bare_id_and_preserves_platform() {
1342        let dir = tempfile::tempdir().expect("tempdir");
1343        let root = dir.path();
1344        write_pkg(root, "crates/hybrid", Ecosystem::Cargo, "hybrid");
1345        std::fs::write(
1346            root.join("crates/hybrid/package.json"),
1347            r#"{"name":"@myorg/hybrid-darwin-arm64","version":"0.1.0","os":["darwin"],"cpu":["arm64"]}"#,
1348        )
1349        .unwrap();
1350
1351        let locator = crate::locate::IgnoreWalkLocator::new(root);
1352        let runner = NoopRunner;
1353        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Case D load must succeed");
1354
1355        let bare_id = PackageId::Bare("hybrid".to_string());
1356        assert!(
1357            ws.graph.get(&bare_id).is_some(),
1358            "Case D primary package must register under Bare ID"
1359        );
1360        let platform_entry = ws
1361            .graph
1362            .identity()
1363            .platform
1364            .get("@myorg/hybrid-darwin-arm64")
1365            .expect("platform entry must exist in IdentityIndex.platform");
1366        assert_eq!(platform_entry.0, bare_id, "platform entry owner must be the Bare ID");
1367        assert_eq!(platform_entry.1, PathBuf::from("crates/hybrid/package.json"));
1368    }
1369
1370    #[test]
1371    fn promoted_index_prefixed_holds_two_distinct_ids_not_stale_bare() {
1372        let dir = tempfile::tempdir().expect("tempdir");
1373        let root = dir.path();
1374        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1375        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1376        let locator = crate::locate::IgnoreWalkLocator::new(root);
1377        let runner = NoopRunner;
1378        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1379        let cargo_id = ws
1380            .graph
1381            .identity()
1382            .prefixed
1383            .get(&(Ecosystem::Cargo, "native-core".to_string()))
1384            .expect("prefixed Cargo entry must exist");
1385        let npm_id = ws
1386            .graph
1387            .identity()
1388            .prefixed
1389            .get(&(Ecosystem::Npm, "native-core".to_string()))
1390            .expect("prefixed Npm entry must exist");
1391        assert_eq!(
1392            *cargo_id,
1393            PackageId::Prefixed {
1394                ecosystem: Ecosystem::Cargo,
1395                name: "native-core".to_string()
1396            }
1397        );
1398        assert_eq!(
1399            *npm_id,
1400            PackageId::Prefixed {
1401                ecosystem: Ecosystem::Npm,
1402                name: "native-core".to_string()
1403            }
1404        );
1405        assert_ne!(
1406            cargo_id, npm_id,
1407            "a distinct-id count of 2 is required for ResolvedConfig.promoted_siblings' derivation (AC-23) to retain this name"
1408        );
1409    }
1410
1411    #[test]
1412    fn tri_fixture_rejects_only_the_offending_member_and_path() {
1413        let dir = tempfile::tempdir().expect("tempdir");
1414        let root = dir.path();
1415        write_pkg(root, "aaa/tri", Ecosystem::Cargo, "tri");
1416        std::fs::write(root.join("aaa/tri/package.json"), r#"{"name":"tri","version":"0.1.0"}"#).unwrap();
1417        write_pkg(root, "bbb/tri", Ecosystem::Pypi, "tri");
1418        write_pkg(root, "ccc/tri", Ecosystem::Npm, "tri");
1419        let locator = crate::locate::IgnoreWalkLocator::new(root);
1420        let runner = NoopRunner;
1421        let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1422            Err(e) => e,
1423            Ok(_) => panic!("expected DuplicatePackage error, got Ok"),
1424        };
1425        match err {
1426            GraphError::DuplicatePackage { id, paths } => {
1427                assert_eq!(id, PackageId::Bare("tri".to_string()));
1428                assert!(paths.contains(&PathBuf::from("aaa/tri")));
1429                assert!(paths.contains(&PathBuf::from("ccc/tri")));
1430                assert!(
1431                    !paths.contains(&PathBuf::from("bbb/tri")),
1432                    "the non-offending group member bbb/tri must not be named"
1433                );
1434            }
1435            other => panic!("expected DuplicatePackage, got {other:?}"),
1436        }
1437    }
1438
1439    #[test]
1440    fn third_path_joins_already_promoted_group() {
1441        let dir = tempfile::tempdir().expect("tempdir");
1442        let root = dir.path();
1443        write_pkg(root, "aaa", Ecosystem::Cargo, "multi");
1444        write_pkg(root, "bbb", Ecosystem::Npm, "multi");
1445        write_pkg(root, "ccc", Ecosystem::Pypi, "multi");
1446        let locator = crate::locate::IgnoreWalkLocator::new(root);
1447        let runner = NoopRunner;
1448        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("N=3 promotion must succeed");
1449        assert_eq!(ws.graph.packages().count(), 3);
1450        for eco in [Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi] {
1451            let id = PackageId::Prefixed {
1452                ecosystem: eco,
1453                name: "multi".to_string(),
1454            };
1455            let pkg = ws.graph.get(&id).unwrap_or_else(|| panic!("{eco:?} entry must exist"));
1456            assert_eq!(
1457                pkg.manifests.len(),
1458                1,
1459                "{eco:?} entry must not bleed in another path's manifest"
1460            );
1461        }
1462    }
1463
1464    #[test]
1465    fn third_path_joins_already_promoted_group_reverse_iteration_order() {
1466        // Same three ecosystems as `third_path_joins_already_promoted_group`, but
1467        // path names are chosen so BTreeMap's lexicographic by-path iteration
1468        // visits Pypi first, then Npm, then Cargo -- the reverse ecosystem
1469        // sequence -- proving the outcome is order-independent (AC-17a).
1470        let dir = tempfile::tempdir().expect("tempdir");
1471        let root = dir.path();
1472        write_pkg(root, "path-a-pypi", Ecosystem::Pypi, "multi2");
1473        write_pkg(root, "path-b-npm", Ecosystem::Npm, "multi2");
1474        write_pkg(root, "path-c-cargo", Ecosystem::Cargo, "multi2");
1475        let locator = crate::locate::IgnoreWalkLocator::new(root);
1476        let runner = NoopRunner;
1477        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1478            .expect("N=3 promotion must succeed regardless of by-path iteration order");
1479        assert_three_promoted_singletons(&ws, "multi2");
1480    }
1481
1482    fn assert_three_promoted_singletons(ws: &crate::Workspace<NoopRunner>, name: &str) {
1483        assert_eq!(ws.graph.packages().count(), 3);
1484        for eco in [Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi] {
1485            let id = PackageId::Prefixed {
1486                ecosystem: eco,
1487                name: name.to_string(),
1488            };
1489            let pkg = ws.graph.get(&id).unwrap_or_else(|| panic!("{eco:?} entry must exist"));
1490            assert_eq!(
1491                pkg.manifests.len(),
1492                1,
1493                "{eco:?} entry must not bleed in another path's manifest"
1494            );
1495        }
1496    }
1497
1498    #[test]
1499    fn maturin_pyo3_layout_promotes_cargo_and_pypi_siblings() {
1500        let dir = tempfile::tempdir().expect("tempdir");
1501        let root = dir.path();
1502        write_pkg(root, "bindings/rust", Ecosystem::Cargo, "mylib");
1503        write_pkg(root, "bindings/python", Ecosystem::Pypi, "mylib");
1504        let locator = crate::locate::IgnoreWalkLocator::new(root);
1505        let runner = NoopRunner;
1506        let ws =
1507            crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Cargo/Pypi promotion must succeed");
1508        let cargo_pkg = ws
1509            .graph
1510            .get(&PackageId::Prefixed {
1511                ecosystem: Ecosystem::Cargo,
1512                name: "mylib".to_string(),
1513            })
1514            .expect("Cargo entry must exist");
1515        let pypi_pkg = ws
1516            .graph
1517            .get(&PackageId::Prefixed {
1518                ecosystem: Ecosystem::Pypi,
1519                name: "mylib".to_string(),
1520            })
1521            .expect("Pypi entry must exist");
1522        assert_eq!(cargo_pkg.manifests.len(), 1);
1523        assert_eq!(cargo_pkg.manifests[0].path, PathBuf::from("bindings/rust/Cargo.toml"));
1524        assert_eq!(pypi_pkg.manifests.len(), 1);
1525        assert_eq!(
1526            pypi_pkg.manifests[0].path,
1527            PathBuf::from("bindings/python/pyproject.toml")
1528        );
1529    }
1530
1531    #[test]
1532    fn case_d_package_with_no_collision_stays_bare() {
1533        let dir = tempfile::tempdir().expect("tempdir");
1534        let root = dir.path();
1535        write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
1536        std::fs::write(
1537            root.join("crates/foo/package.json"),
1538            r#"{"name":"@myorg/foo","version":"0.1.0"}"#,
1539        )
1540        .unwrap();
1541        let locator = crate::locate::IgnoreWalkLocator::new(root);
1542        let runner = NoopRunner;
1543        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Case D load must succeed");
1544        assert_eq!(ws.graph.packages().count(), 1);
1545        let pkg = ws
1546            .graph
1547            .get(&PackageId::Bare("foo".to_string()))
1548            .expect("single Bare(foo) entry must exist, unpromoted");
1549        assert_eq!(
1550            pkg.manifests.len(),
1551            2,
1552            "both the Cargo and npm manifest belong to the one Case D package"
1553        );
1554    }
1555
1556    #[test]
1557    fn ac12_ac18_npm_consumer_depending_on_cargo_only_name_gets_no_edge_and_diagnostic() {
1558        let dir = tempfile::tempdir().expect("tempdir");
1559        let root = dir.path();
1560        write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
1561        std::fs::create_dir_all(root.join("packages/consumer")).unwrap();
1562        std::fs::write(
1563            root.join("packages/consumer/package.json"),
1564            r#"{"name":"consumer","version":"0.1.0","dependencies":{"foo":"^1.0.0"}}"#,
1565        )
1566        .unwrap();
1567        let locator = crate::locate::IgnoreWalkLocator::new(root);
1568        let runner = NoopRunner;
1569        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1570            .expect("load must succeed even with an unresolved dependency name");
1571        let cargo_foo = PackageId::Bare("foo".to_string());
1572        assert!(
1573            !ws.graph.edges().iter().any(|e| e.to == cargo_foo),
1574            "no DepEdge must be created to the Cargo foo id from an npm consumer's same-name miss"
1575        );
1576        let diag = ws
1577            .graph
1578            .diagnostics()
1579            .iter()
1580            .find(|d| {
1581                d.code == callisto_model::DiagnosticCode::UnknownPackage
1582                    && d.severity == callisto_model::DiagnosticSeverity::Warning
1583                    && d.message.contains("foo")
1584            })
1585            .expect("an UnknownPackage warning diagnostic naming foo must be present");
1586        assert!(diag.message.contains("ambiguous") || diag.message.contains("cargo:foo"));
1587    }
1588
1589    #[test]
1590    fn ac14_ac18_npm_consumer_depending_on_serde_with_cargo_serde_present_gets_no_edge_and_diagnostic() {
1591        let dir = tempfile::tempdir().expect("tempdir");
1592        let root = dir.path();
1593        write_pkg(root, "crates/serde", Ecosystem::Cargo, "serde");
1594        std::fs::create_dir_all(root.join("packages/consumer")).unwrap();
1595        std::fs::write(
1596            root.join("packages/consumer/package.json"),
1597            r#"{"name":"consumer","version":"0.1.0","dependencies":{"serde":"^1"}}"#,
1598        )
1599        .unwrap();
1600        let locator = crate::locate::IgnoreWalkLocator::new(root);
1601        let runner = NoopRunner;
1602        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("load must succeed");
1603        let cargo_serde = PackageId::Bare("serde".to_string());
1604        assert!(!ws.graph.edges().iter().any(|e| e.to == cargo_serde));
1605        let diag = ws
1606            .graph
1607            .diagnostics()
1608            .iter()
1609            .find(|d| {
1610                d.code == callisto_model::DiagnosticCode::UnknownPackage
1611                    && d.severity == callisto_model::DiagnosticSeverity::Warning
1612                    && d.message.contains("serde")
1613            })
1614            .expect("an UnknownPackage warning diagnostic naming serde must be present");
1615        assert!(diag.message.contains("ambiguous") || diag.message.contains("cargo:serde"));
1616    }
1617
1618    #[test]
1619    fn ac15_ac18_npm_consumer_depending_on_ambiguous_lib_with_cargo_and_pypi_present_gets_exactly_one_diagnostic() {
1620        let dir = tempfile::tempdir().expect("tempdir");
1621        let root = dir.path();
1622        write_pkg(root, "crates/ambiguous-lib", Ecosystem::Cargo, "ambiguous-lib");
1623        write_pkg(root, "py/ambiguous-lib", Ecosystem::Pypi, "ambiguous-lib");
1624        std::fs::create_dir_all(root.join("packages/consumer")).unwrap();
1625        std::fs::write(
1626            root.join("packages/consumer/package.json"),
1627            r#"{"name":"consumer","version":"0.1.0","dependencies":{"ambiguous-lib":"^1"}}"#,
1628        )
1629        .unwrap();
1630        let locator = crate::locate::IgnoreWalkLocator::new(root);
1631        let runner = NoopRunner;
1632        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("load must succeed");
1633        let diags: Vec<_> = ws
1634            .graph
1635            .diagnostics()
1636            .iter()
1637            .filter(|d| d.code == callisto_model::DiagnosticCode::UnknownPackage && d.message.contains("ambiguous-lib"))
1638            .collect();
1639        assert_eq!(
1640            diags.len(),
1641            1,
1642            "exactly one diagnostic, not one per candidate ecosystem"
1643        );
1644    }
1645
1646    #[test]
1647    fn unprefixed_package_rule_matching_two_promoted_siblings_is_ambiguous() {
1648        let dir = tempfile::tempdir().expect("tempdir");
1649        let root = dir.path();
1650        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1651        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1652        std::fs::write(
1653            root.join("callisto.toml"),
1654            "[[package]]\nmatch = \"native-core\"\nrelease-trigger = \"auto\"\n",
1655        )
1656        .unwrap();
1657        let locator = crate::locate::IgnoreWalkLocator::new(root);
1658        let runner = NoopRunner;
1659        let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1660            Err(e) => e,
1661            Ok(_) => panic!("expected AmbiguousName error, got Ok"),
1662        };
1663        match err {
1664            GraphError::AmbiguousName { name, candidates } => {
1665                assert_eq!(name, "native-core");
1666                assert_eq!(candidates.len(), 2);
1667                assert!(candidates.contains(&PackageId::Prefixed {
1668                    ecosystem: Ecosystem::Cargo,
1669                    name: "native-core".to_string()
1670                }));
1671                assert!(candidates.contains(&PackageId::Prefixed {
1672                    ecosystem: Ecosystem::Npm,
1673                    name: "native-core".to_string()
1674                }));
1675            }
1676            other => panic!("expected AmbiguousName, got {other:?}"),
1677        }
1678    }
1679
1680    #[test]
1681    fn prefixed_package_rules_still_apply_correctly_to_promoted_siblings() {
1682        let dir = tempfile::tempdir().expect("tempdir");
1683        let root = dir.path();
1684        write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1685        write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1686        std::fs::write(
1687            root.join("callisto.toml"),
1688            "[[package]]\nmatch = \"cargo:native-core\"\nrelease-trigger = \"changeset\"\n\n[[package]]\nmatch = \"npm:native-core\"\nrelease-trigger = \"auto\"\n",
1689        )
1690        .unwrap();
1691        let locator = crate::locate::IgnoreWalkLocator::new(root);
1692        let runner = NoopRunner;
1693        let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1694            .expect("prefixed rules must not trigger the ambiguity check");
1695        let cargo_pkg = ws
1696            .graph
1697            .get(&PackageId::Prefixed {
1698                ecosystem: Ecosystem::Cargo,
1699                name: "native-core".to_string(),
1700            })
1701            .unwrap();
1702        let npm_pkg = ws
1703            .graph
1704            .get(&PackageId::Prefixed {
1705                ecosystem: Ecosystem::Npm,
1706                name: "native-core".to_string(),
1707            })
1708            .unwrap();
1709        assert_eq!(cargo_pkg.release_trigger, ReleaseTrigger::Changeset);
1710        assert_eq!(npm_pkg.release_trigger, ReleaseTrigger::Auto);
1711    }
1712}