Skip to content

Commit f9048b7

Browse files
committed
feat(closure): suffix-aware companions, unversioned siblings defer to a versioned provider, and a provided language root closes without companions
Holes carry their versionsuffix into discovery, the companion cache key and the cycle path, so -MPI and -CUDA builds of one package are two companions; a catalog's unversioned wildcard no longer vetoes a unique versioned sibling; and a PyPI or CRAN root the robot already provides completes as already-provided instead of hole-filling leftovers. Ported from develop.
1 parent 4fa668c commit f9048b7

2 files changed

Lines changed: 116 additions & 16 deletions

File tree

‎src/package_closure.rs‎

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
1313
use crate::domain::{Candidate, Toolchain};
1414
use crate::eb_parse::{resolve_easyconfig_file, resolve_easyconfig_str};
15-
use crate::hierarchy::{hierarchy_for_with_tree, ToolchainHierarchy};
15+
use crate::hierarchy::{
16+
filter_candidates_in_hierarchy, hierarchy_for_with_tree, ToolchainHierarchy,
17+
};
1618
use crate::package::{OutputRequest, PackageOrigin, PackagePlan, ProfileEnvironment, StackPolicy};
1719
use crate::package_catalog::{
1820
CatalogProviderKind, PackageCatalogError, PackageSourceCatalog, PackageSourceProvider,
@@ -33,6 +35,7 @@ use crate::package_workflow::{
3335
write_package_bundle_into, BumpPackageRequest, NewPackageRequest, PackageBundle,
3436
PackageWorkflowError, WrittenPackageBundle,
3537
};
38+
use crate::provides::existing_language_provider;
3639
use crate::version::matches_req;
3740
use serde::{Deserialize, Serialize};
3841
use serde_json::{json, Map, Value};
@@ -315,11 +318,7 @@ pub fn plan_package_closure_with_sources(
315318
)?;
316319
let target_hierarchy = hierarchy_for_with_tree(&request.toolchain, None, &tree.candidates).ok();
317320

318-
let (mut plan, sbom) = prepare_new_package_plan(request)?;
319-
// The index answers "what version is this leftover", so it has to reach the
320-
// root plan here too: the closure path builds its own requests and the CLI
321-
// goes through this one, not through plan_new_package.
322-
plan.package_index = request.package_index.clone();
321+
let (plan, sbom) = prepare_new_package_plan(request)?;
323322
let mut state = ClosureState {
324323
robot: tree.candidates,
325324
generated: HashMap::new(),
@@ -654,6 +653,13 @@ impl ClosureState<'_> {
654653
stack_policy: &StackPolicy,
655654
path: &[String],
656655
) -> Result<PackageBundle, PackageClosureError> {
656+
// A language root already on the robot must not hole-fill leftovers
657+
// as companions. complete_package_bundle short-circuits to the same
658+
// already-provided residual as plan_new_package.
659+
if language_root_already_provided(prepared.plan(), &self.universe()) {
660+
return self.complete_prepared(prepared, stack_policy);
661+
}
662+
657663
// Fill holes for every requested profile before the final multi-profile solve.
658664
let profiles: Vec<String> = prepared
659665
.plan()
@@ -664,7 +670,14 @@ impl ClosureState<'_> {
664670
for profile in &profiles {
665671
self.fill_holes_for_profile(prepared.plan(), profile, stack_policy, path)?;
666672
}
673+
self.complete_prepared(prepared, stack_policy)
674+
}
667675

676+
fn complete_prepared(
677+
&self,
678+
prepared: PreparedCompanion,
679+
stack_policy: &StackPolicy,
680+
) -> Result<PackageBundle, PackageClosureError> {
668681
let candidates = self.universe();
669682
match prepared {
670683
PreparedCompanion::Foreign { plan, sbom } => {
@@ -750,12 +763,10 @@ impl ClosureState<'_> {
750763
hole: &UnsatisfiedDirectDependency,
751764
path: &[String],
752765
) -> Result<(), PackageClosureError> {
753-
if path
754-
.iter()
755-
.any(|step| package_identity(step) == package_identity(&hole.name))
756-
{
766+
let step = hole_path_step(hole);
767+
if path.iter().any(|seen| seen == &step) {
757768
let mut cycle = path.to_vec();
758-
cycle.push(hole.name.clone());
769+
cycle.push(step);
759770
return Err(PackageClosureError::Cycle { path: cycle });
760771
}
761772

@@ -786,13 +797,14 @@ impl ClosureState<'_> {
786797
}
787798
}
788799

789-
let key = companion_key(&provider);
800+
let key = companion_key(&provider, hole);
790801
if self.generated.contains_key(&key) {
791802
// Already closed; verify the generated candidate still satisfies the hole.
792803
let entry = self.generated.get(&key).expect("just checked");
793804
let ok = entry.candidates.iter().any(|candidate| {
794805
package_identity(&candidate.name) == package_identity(&hole.name)
795806
&& matches_req(&candidate.version, &hole.version_req)
807+
&& hole.matches_versionsuffix(candidate.versionsuffix.as_deref())
796808
});
797809
if ok {
798810
return Ok(());
@@ -983,6 +995,7 @@ fn resolve_provider_candidates_for_hole(
983995
.providers()
984996
.iter()
985997
.filter(|provider| package_identity(&provider.name) == package_identity(&hole.name))
998+
.filter(|provider| catalog_provider_matches_hole_suffix(provider, hole))
986999
.collect::<Vec<_>>();
9871000
if !named.is_empty() {
9881001
let compatible = named
@@ -995,7 +1008,24 @@ fn resolve_provider_candidates_for_hole(
9951008
.is_none_or(|version| matches_req(version, &hole.version_req))
9961009
})
9971010
.collect::<Vec<_>>();
998-
return match compatible.as_slice() {
1011+
// Unversioned wildcards must not veto a unique versioned sibling.
1012+
let versioned = compatible
1013+
.iter()
1014+
.copied()
1015+
.filter(|provider| provider.version.is_some())
1016+
.collect::<Vec<_>>();
1017+
let preferred = match versioned.as_slice() {
1018+
[_] => versioned,
1019+
[] => compatible,
1020+
many => {
1021+
return Err(PackageClosureError::AmbiguousProvider {
1022+
name: hole.name.clone(),
1023+
version: format!(" ({})", hole.version_req),
1024+
count: many.len(),
1025+
});
1026+
}
1027+
};
1028+
return match preferred.as_slice() {
9991029
[provider] => Ok(vec![(*provider).clone()]),
10001030
[] => Err(PackageClosureError::IncompatibleProviderVersion {
10011031
name: hole.name.clone(),
@@ -1246,15 +1276,83 @@ fn candidates_from_bundle(bundle: &PackageBundle) -> Result<Vec<Candidate>, Pack
12461276
Ok(candidates)
12471277
}
12481278

1249-
fn companion_key(provider: &PackageSourceProvider) -> String {
1279+
fn language_root_already_provided(plan: &PackagePlan, candidates: &[Candidate]) -> bool {
1280+
if !matches!(plan.origin, PackageOrigin::Pypi | PackageOrigin::Cran) {
1281+
return false;
1282+
}
1283+
let admitted = match hierarchy_for_with_tree(&plan.build.toolchain, None, candidates) {
1284+
Ok(hierarchy) => filter_candidates_in_hierarchy(candidates, &hierarchy),
1285+
// Unknown hierarchy is not "provided on this generation".
1286+
Err(_) => return false,
1287+
};
1288+
existing_language_provider(&plan.package.name, &admitted).is_some()
1289+
}
1290+
1291+
/// The identity a hole contributes to the closure path: its package, with
1292+
/// the versionsuffix it asks for, so `-MPI` and `-CUDA` builds are two steps.
1293+
fn hole_path_step(hole: &UnsatisfiedDirectDependency) -> String {
1294+
let identity = package_identity(&hole.name);
1295+
match hole
1296+
.versionsuffix
1297+
.as_deref()
1298+
.filter(|suffix| !suffix.is_empty())
1299+
{
1300+
Some(suffix) => format!("{identity}{suffix}"),
1301+
None => identity,
1302+
}
1303+
}
1304+
1305+
fn catalog_provider_matches_hole_suffix(
1306+
provider: &PackageSourceProvider,
1307+
hole: &UnsatisfiedDirectDependency,
1308+
) -> bool {
1309+
if hole
1310+
.versionsuffix
1311+
.as_deref()
1312+
.is_none_or(|suffix| suffix.is_empty())
1313+
{
1314+
return true;
1315+
}
1316+
if provider.provider != CatalogProviderKind::EasyBuildBump {
1317+
return false;
1318+
}
1319+
let suffix = resolve_easyconfig_file(&provider.source)
1320+
.ok()
1321+
.and_then(|resolved| resolved.versionsuffix);
1322+
hole.matches_versionsuffix(suffix.as_deref())
1323+
}
1324+
1325+
fn companion_key(provider: &PackageSourceProvider, hole: &UnsatisfiedDirectDependency) -> String {
12501326
format!(
1251-
"{}@{}@{}",
1327+
"{}@{}@{}@{}",
12521328
package_identity(&provider.name),
12531329
provider.version.as_deref().unwrap_or(""),
1254-
provider.toolchain.label()
1330+
provider.toolchain.label(),
1331+
companion_versionsuffix(provider, hole)
12551332
)
12561333
}
12571334

1335+
fn companion_versionsuffix(
1336+
provider: &PackageSourceProvider,
1337+
hole: &UnsatisfiedDirectDependency,
1338+
) -> String {
1339+
if let Some(suffix) = hole
1340+
.versionsuffix
1341+
.as_deref()
1342+
.filter(|suffix| !suffix.is_empty())
1343+
{
1344+
return suffix.to_string();
1345+
}
1346+
if provider.provider != CatalogProviderKind::EasyBuildBump {
1347+
return String::new();
1348+
}
1349+
resolve_easyconfig_file(&provider.source)
1350+
.ok()
1351+
.and_then(|resolved| resolved.versionsuffix)
1352+
.filter(|suffix| !suffix.is_empty())
1353+
.unwrap_or_default()
1354+
}
1355+
12581356
fn package_identity(name: &str) -> String {
12591357
name.chars()
12601358
.filter(|character| character.is_ascii_alphanumeric())

‎src/package_sources.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,7 @@ pub fn discover_provider_candidates_for_hole(
900900
.iter()
901901
.filter(|candidate| package_identity(&candidate.name) == identity)
902902
.filter(|candidate| matches_req(&candidate.version, &hole.version_req))
903+
.filter(|candidate| hole.matches_versionsuffix(candidate.versionsuffix.as_deref()))
903904
.filter(|candidate| {
904905
source_toolchain_family_is_admitted(candidate, target_parent, hierarchy)
905906
})
@@ -915,6 +916,7 @@ pub fn discover_provider_candidates_for_hole(
915916
.iter()
916917
.filter(|candidate| package_identity(&candidate.name) == identity)
917918
.filter(|candidate| matches_req(&candidate.version, &hole.version_req))
919+
.filter(|candidate| hole.matches_versionsuffix(candidate.versionsuffix.as_deref()))
918920
.cloned()
919921
.collect::<Vec<_>>();
920922

0 commit comments

Comments
 (0)