From 65993be1b2ecdc590f566b2bcfea803d0d08b5e6 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 23:44:47 +0900 Subject: 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) --- crates/mozart-registry/src/inline_package.rs | 32 +++++++++++++------ crates/mozart-registry/src/resolver.rs | 48 +++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 14 deletions(-) (limited to 'crates/mozart-registry') 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 { // 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, 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, 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, R true }; + // Pre-parse root-require constraints once. Reused for every name lookup + // in the seed + transitive loops below. + let root_require_constraints: IndexMap = 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, R .collect(); let mut seed_queries: Vec> = 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, 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, -- cgit v1.3.1