aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 23:44:47 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 23:44:47 +0900
commit65993be1b2ecdc590f566b2bcfea803d0d08b5e6 (patch)
tree14169c8e5c2636264438c7c44935265f00385bc2 /crates
parente37b12d6e2d95b4d3924859732513e125fc552e0 (diff)
downloadphp-mozart-65993be1b2ecdc590f566b2bcfea803d0d08b5e6.tar.gz
php-mozart-65993be1b2ecdc590f566b2bcfea803d0d08b5e6.tar.zst
php-mozart-65993be1b2ecdc590f566b2bcfea803d0d08b5e6.zip
fix(resolver): cap inline package loads by root require constraint
Mirror Composer's PoolBuilder::markPackageNameForLoading: when the root requires a name with a version constraint, loads of that name (seed and transitive) are filtered down to candidates whose own version (or any emitted branch-alias version) satisfies the constraint. Without this, the actual package at a non-matching version slips into the pool alongside a provider satisfying the root require, masking what should be a conflict (provider-gets-picked-together-with-other-version-of- provided-conflict.test). Also restore the Composer v1 compat path in inline_package: when the JSON sets version_normalized to the legacy 9999999-dev sentinel, re-normalize from the human-readable version field so a root require for `dev-master` matches the loaded package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-registry/src/inline_package.rs32
-rw-r--r--crates/mozart-registry/src/resolver.rs48
-rw-r--r--crates/mozart/tests/installer.rs5
3 files changed, 67 insertions, 18 deletions
diff --git a/crates/mozart-registry/src/inline_package.rs b/crates/mozart-registry/src/inline_package.rs
index a7df987..95f842f 100644
--- a/crates/mozart-registry/src/inline_package.rs
+++ b/crates/mozart-registry/src/inline_package.rs
@@ -143,17 +143,29 @@ fn parse_inline_package(value: &serde_json::Value) -> Option<InlinePackage> {
// PackagistVersion requires `version_normalized`. If the inline definition
// omits it (the common case), compute it the same way Packagist does:
// run the version through Mozart's normalizer.
+ //
+ // Mirrors Composer's `ArrayLoader::parsePackage` Composer v1 compat path:
+ // when `version_normalized` is exactly `9999999-dev` (the legacy default
+ // branch sentinel), re-normalize from the human-readable `version` field
+ // instead. Without this, the package's version stays as `9999999-dev`
+ // even though its pretty form is e.g. `dev-master`, and a root require
+ // for `dev-master` then can't match the loaded package.
let mut value_for_parse = value.clone();
- if let serde_json::Value::Object(ref mut map) = value_for_parse
- && !map.contains_key("version_normalized")
- {
- let normalized = mozart_semver::Version::parse(&version_str)
- .map(|v| v.to_string())
- .unwrap_or_else(|_| version_str.clone());
- map.insert(
- "version_normalized".to_string(),
- serde_json::Value::String(normalized),
- );
+ if let serde_json::Value::Object(ref mut map) = value_for_parse {
+ let needs_normalize = match map.get("version_normalized") {
+ None => true,
+ Some(serde_json::Value::String(s)) => s == "9999999-dev",
+ _ => false,
+ };
+ if needs_normalize {
+ let normalized = mozart_semver::Version::parse(&version_str)
+ .map(|v| v.to_string())
+ .unwrap_or_else(|_| version_str.clone());
+ map.insert(
+ "version_normalized".to_string(),
+ serde_json::Value::String(normalized),
+ );
+ }
}
let version: PackagistVersion = serde_json::from_value(value_for_parse).ok()?;
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
index d9fe900..ac8b89f 100644
--- a/crates/mozart-registry/src/resolver.rs
+++ b/crates/mozart-registry/src/resolver.rs
@@ -1181,7 +1181,24 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
.unwrap_or(false)
})
};
- let add_inline_for = |name: &str, builder: &mut PoolBuilder| -> bool {
+ // Mirrors Composer's `PoolBuilder::markPackageNameForLoading`: a root
+ // require's constraint caps every load of that name. Transitive deps that
+ // would otherwise pull in an out-of-range version (e.g. `foo/requirer`
+ // requires `foo/original 1.0.0` while the root pinned it at `3.0.0`) are
+ // silently filtered down to the root-required range, so the pool never
+ // sees a candidate the root forbids. Without this, providers that satisfy
+ // the root require can coexist with the actual package at the wrong
+ // version, masking what should be a conflict.
+ //
+ // The match check considers both the base version and any branch-alias
+ // entries it expands to — mirrors `ArrayRepository::loadPackages`, which
+ // pulls in the base whenever any of its aliases satisfies the constraint
+ // (and vice-versa). Skipping the base when only an alias matches would
+ // leave the alias dangling.
+ let add_inline_for = |name: &str,
+ load_constraint: Option<&VersionConstraint>,
+ builder: &mut PoolBuilder|
+ -> bool {
let Some(packages) = inline_packages_by_name.get(name) else {
return false;
};
@@ -1198,6 +1215,16 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
request.minimum_stability,
&stability_flags,
);
+ if let Some(c) = load_constraint {
+ let any_matches = inputs.iter().any(|input| {
+ Version::parse(&input.version)
+ .map(|v| c.matches(&v))
+ .unwrap_or(false)
+ });
+ if !any_matches {
+ continue;
+ }
+ }
for input in inputs {
if !lock_filter_allows(&input.name, &input.version) {
continue;
@@ -1208,6 +1235,17 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
true
};
+ // Pre-parse root-require constraints once. Reused for every name lookup
+ // in the seed + transitive loops below.
+ let root_require_constraints: IndexMap<String, VersionConstraint> = root_requires
+ .iter()
+ .filter_map(|(name, c)| {
+ c.as_deref()
+ .and_then(|s| VersionConstraint::parse(s).ok())
+ .map(|vc| (name.clone(), vc))
+ })
+ .collect();
+
// Collect packages from `type: composer` repositories with file:// URLs.
// The harness rewrites `file://foobar` to `file:///abs/path` before this
// call so the read can be a plain `std::fs::read_to_string`. Same idea
@@ -1254,7 +1292,8 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
.collect();
let mut seed_queries: Vec<PackageQuery<'_>> = Vec::new();
for name in &seed_names {
- if add_inline_for(name.as_str(), &mut builder) {
+ let load_constraint = root_require_constraints.get(name);
+ if add_inline_for(name.as_str(), load_constraint, &mut builder) {
continue;
}
seed_queries.push(PackageQuery {
@@ -1297,13 +1336,14 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
if vcs_package_names.contains(&name) || composer_repo_names.contains(&name) {
continue;
}
- if add_inline_for(name.as_str(), &mut builder) {
+ let load_constraint = root_require_constraints.get(&name);
+ if add_inline_for(name.as_str(), load_constraint, &mut builder) {
continue;
}
let queries = [PackageQuery {
name: name.as_str(),
- constraint: None,
+ constraint: root_requires.get(&name).and_then(|c| c.as_deref()),
}];
let results = match repo_set.load_packages(&queries).await {
Ok(v) => v,
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index d22f2a0..00964d7 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -338,10 +338,7 @@ installer_fixture!(provider_conflicts2);
installer_fixture!(provider_conflicts3);
installer_fixture!(provider_dev_require_can_satisfy_require);
installer_fixture!(provider_gets_picked_together_with_other_version_of_provided);
-installer_fixture!(
- provider_gets_picked_together_with_other_version_of_provided_conflict,
- ignore
-);
+installer_fixture!(provider_gets_picked_together_with_other_version_of_provided_conflict);
installer_fixture!(provider_gets_picked_together_with_other_version_of_provided_indirect);
installer_fixture!(provider_packages_can_be_installed_if_selected);
installer_fixture!(provider_packages_can_be_installed_together_with_provided_if_both_installable);