diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-03 23:44:47 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-03 23:44:47 +0900 |
| commit | 65993be1b2ecdc590f566b2bcfea803d0d08b5e6 (patch) | |
| tree | 14169c8e5c2636264438c7c44935265f00385bc2 | |
| parent | e37b12d6e2d95b4d3924859732513e125fc552e0 (diff) | |
| download | php-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>
| -rw-r--r-- | crates/mozart-registry/src/inline_package.rs | 32 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 48 | ||||
| -rw-r--r-- | crates/mozart/tests/installer.rs | 5 |
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); |
