diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-03 21:50:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-03 21:50:54 +0900 |
| commit | 177b894d7d77a5297bee3b2487ef18a0cae7a596 (patch) | |
| tree | cb526ed3c35414dd6aa8a9bd05c722aa1b7239b6 | |
| parent | b8b81bb9beab64ad073af3b32969566f9ba5a038 (diff) | |
| download | php-mozart-177b894d7d77a5297bee3b2487ef18a0cae7a596.tar.gz php-mozart-177b894d7d77a5297bee3b2487ef18a0cae7a596.tar.zst php-mozart-177b894d7d77a5297bee3b2487ef18a0cae7a596.zip | |
fix(resolver): expand root branch-alias and self.version replace links
Two related parity gaps surfaced by the `circular-dependency` fixture:
1. The root's `extra.branch-alias` entry was never materialized in the
pool, and root-level `replace`/`provide`/`conflict` constraints
written as `self.version` were forwarded verbatim. Mirror Composer's
`RootAliasPackage`: resolve `self.version` against the root's
declared version for the base entry, then add an extra alias entry
(carrying the base links plus a duplicate link per `self.version`
original retagged at the alias's version) when the root's version
matches an `extra.branch-alias` key.
2. `Pool::matches_package` returned on the first link to a target name
even when its constraint did not match the query, hiding any later
link to the same target. With the alias above, that masked the
second `replace` link tagged at the alias version. Keep iterating
when target matches but constraint does not, so a later link can
still satisfy.
| -rw-r--r-- | crates/mozart-registry/src/lockfile.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 104 | ||||
| -rw-r--r-- | crates/mozart-sat-resolver/src/pool.rs | 50 | ||||
| -rw-r--r-- | crates/mozart/src/commands/create_project.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 4 | ||||
| -rw-r--r-- | crates/mozart/src/commands/require.rs | 3 | ||||
| -rw-r--r-- | crates/mozart/src/commands/update.rs | 24 | ||||
| -rw-r--r-- | crates/mozart/tests/installer.rs | 2 |
8 files changed, 137 insertions, 52 deletions
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index 5114fb7..701a6f7 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -1679,6 +1679,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let resolved = resolve(&resolve_request) diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index f7feddd..66e923d 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -725,6 +725,14 @@ pub struct ResolveRequest { /// versions, so a root requirement that only matches abandoned candidates /// fails with the standard "could not be resolved" error. pub block_abandoned: bool, + /// Pretty form of the root's `extra.branch-alias` target when the root's + /// version matches a key in that map (e.g. `dev-master` → `2.0-dev`). + /// Mirrors Composer's `RootAliasPackage`: an extra alias entry is added + /// to the pool exposing the root under the numeric branch-alias version, + /// with `replace`/`provide`/`conflict` links extended to advertise the + /// alias's version for any link originally written as `self.version`. + /// `None` when the root carries no matching `branch-alias` entry. + pub root_branch_alias: Option<String>, } /// Full data for a lock-pinned package, used in partial updates. Carried on @@ -906,42 +914,81 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R Some(v) if !v.is_empty() => (v.to_string(), v.to_string()), _ => ("1.0.0+no-version-set".to_string(), "1.0.0.0".to_string()), }; - let root_input = PoolPackageInput { - name: root_name_lower.clone(), - version: root_normalized, - pretty_version: root_pretty, - requires: vec![], - replaces: request - .root_replace - .iter() - .map(|(target, constraint)| PoolLink { - target: target.to_lowercase(), - constraint: constraint.clone(), - source: root_name_lower.clone(), - }) - .collect(), - provides: request - .root_provide - .iter() - .map(|(target, constraint)| PoolLink { - target: target.to_lowercase(), - constraint: constraint.clone(), - source: root_name_lower.clone(), - }) - .collect(), - conflicts: request - .root_conflict - .iter() + // Resolve `self.version` against the root's normalized version when + // building base links. Mirrors Composer's `ArrayLoader::createLink`: + // a `self.version` constraint is parsed against the declaring package's + // pretty version (here, the root's). The base entry only carries this + // resolved form; any branch-alias entry below extends each base link + // with an extra link tagged at the alias's version, matching + // `AliasPackage::replaceSelfVersionDependencies`. + let make_base_links = |raw: &IndexMap<String, String>| -> Vec<PoolLink> { + raw.iter() .map(|(target, constraint)| PoolLink { target: target.to_lowercase(), - constraint: constraint.clone(), + constraint: if constraint.trim() == "self.version" { + root_normalized.clone() + } else { + constraint.clone() + }, source: root_name_lower.clone(), }) - .collect(), + .collect() + }; + let base_replaces = make_base_links(&request.root_replace); + let base_provides = make_base_links(&request.root_provide); + let base_conflicts = make_base_links(&request.root_conflict); + let root_input = PoolPackageInput { + name: root_name_lower.clone(), + version: root_normalized.clone(), + pretty_version: root_pretty.clone(), + requires: vec![], + replaces: base_replaces.clone(), + provides: base_provides.clone(), + conflicts: base_conflicts.clone(), is_fixed: true, is_alias_of: None, }; builder.add_package(root_input); + + // Materialize a branch-alias entry for the root when `extra.branch-alias` + // mapped this version to a numeric alias (e.g. dev-master → 2.0-dev). + // Mirrors Composer's `RootAliasPackage`: the alias copies the base's + // resolved replace/provide/conflict links and then ADDS one more link + // per `self.version` original, this time pinned at the alias's own + // version. So a transitive `provided/dependency 2.*` lookup can be + // satisfied through the alias even though the base resolved + // `self.version` to a non-matching dev version. + if let Some(alias_pretty) = &request.root_branch_alias + && let Some(alias_normalized) = normalize_branch_alias_target(alias_pretty) + { + let extra_self_version_links = |raw: &IndexMap<String, String>| -> Vec<PoolLink> { + raw.iter() + .filter(|(_, constraint)| constraint.trim() == "self.version") + .map(|(target, _)| PoolLink { + target: target.to_lowercase(), + constraint: alias_normalized.clone(), + source: root_name_lower.clone(), + }) + .collect() + }; + let mut alias_replaces = base_replaces.clone(); + alias_replaces.extend(extra_self_version_links(&request.root_replace)); + let mut alias_provides = base_provides.clone(); + alias_provides.extend(extra_self_version_links(&request.root_provide)); + let mut alias_conflicts = base_conflicts.clone(); + alias_conflicts.extend(extra_self_version_links(&request.root_conflict)); + builder.add_package(PoolPackageInput { + name: root_name_lower.clone(), + version: alias_normalized, + pretty_version: alias_pretty.clone(), + requires: vec![], + replaces: alias_replaces, + provides: alias_provides, + conflicts: alias_conflicts, + is_fixed: false, + is_alias_of: Some(root_normalized), + }); + } } // Add lock-pinned packages as pool entries (partial-update case). @@ -1738,6 +1785,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let result = resolve(&request).await; diff --git a/crates/mozart-sat-resolver/src/pool.rs b/crates/mozart-sat-resolver/src/pool.rs index 2c52791..8a63c05 100644 --- a/crates/mozart-sat-resolver/src/pool.rs +++ b/crates/mozart-sat-resolver/src/pool.rs @@ -299,36 +299,40 @@ impl Pool { }; } - // Check provides + // Check provides. A package may declare more than one provide link + // for the same target (e.g. an `AliasPackage` carries the base's link + // and an extra link tagged at the alias's own version), so keep + // iterating once a target name matches but the constraint doesn't — + // a later link may still satisfy. for link in &candidate.provides { - if link.target == name { - return match constraint { - None => true, - Some(vc) => { - // The provide link has its own constraint; check if they intersect - if let Ok(provide_vc) = VersionConstraint::parse(&link.constraint) { - constraints_intersect(vc, &provide_vc) - } else { - false - } + if link.target != name { + continue; + } + match constraint { + None => return true, + Some(vc) => { + if let Ok(provide_vc) = VersionConstraint::parse(&link.constraint) + && constraints_intersect(vc, &provide_vc) + { + return true; } - }; + } } } - // Check replaces for link in &candidate.replaces { - if link.target == name { - return match constraint { - None => true, - Some(vc) => { - if let Ok(replace_vc) = VersionConstraint::parse(&link.constraint) { - constraints_intersect(vc, &replace_vc) - } else { - false - } + if link.target != name { + continue; + } + match constraint { + None => return true, + Some(vc) => { + if let Ok(replace_vc) = VersionConstraint::parse(&link.constraint) + && constraints_intersect(vc, &replace_vc) + { + return true; } - }; + } } } diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index fc0efee..89b3e4f 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -443,6 +443,7 @@ pub async fn execute( locked_package_names: indexmap::IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; console.info("Resolving dependencies..."); diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index eb6ee4a..f41d8b5 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -277,6 +277,7 @@ pub async fn execute( locked_package_names: indexmap::IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; // Print header messages @@ -563,6 +564,7 @@ async fn remove_unused( locked_package_names: indexmap::IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; console.info("Resolving dependencies to detect unused packages..."); @@ -919,6 +921,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let resolved = resolve(&request) .await @@ -978,6 +981,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let resolved2 = resolve(&request2) .await diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 110bd1a..0816d13 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -665,6 +665,7 @@ pub async fn execute( locked_package_names: indexmap::IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; // Print header messages @@ -1075,6 +1076,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let resolved = resolver::resolve(&request) @@ -1152,6 +1154,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let resolved = resolver::resolve(&request) diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 2853fc8..c4ebdf7 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -169,6 +169,28 @@ pub struct UpdateChange { /// /// Recognizes "stable", "RC", "beta", "alpha", "dev" (case-insensitive). /// Defaults to `Stability::Stable` for unrecognized values. +/// Resolve the root composer.json's `extra.branch-alias` against the root's +/// `version` field. Returns the alias target (e.g. `"2.0-dev"`) when both +/// `version` and a matching `branch-alias` entry are present, mirroring +/// Composer's `RootPackageLoader` branch-alias detection on the root package. +/// `None` for projects without a `version` or without a matching alias entry. +fn extract_root_branch_alias( + composer_json: &mozart_core::package::RawPackageData, +) -> Option<String> { + let version = composer_json.version.as_deref()?; + if version.is_empty() { + return None; + } + composer_json + .extra_fields + .get("extra") + .and_then(|extra| extra.get("branch-alias")) + .and_then(|aliases| aliases.as_object()) + .and_then(|map| map.get(version)) + .and_then(|v| v.as_str()) + .map(String::from) +} + fn parse_minimum_stability(s: &str) -> Stability { package::Stability::parse(s) } @@ -1136,6 +1158,7 @@ pub async fn run( locked_package_names, locked_packages, block_abandoned, + root_branch_alias: extract_root_branch_alias(&composer_json), }; // Step 6: Print header and run resolver @@ -2274,6 +2297,7 @@ mod tests { locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, + root_branch_alias: None, }; let resolved = resolve(&request).await.expect("Resolution should succeed"); diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index e0dcb42..6e1862c 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -227,7 +227,7 @@ installer_fixture!(aliased_priority); installer_fixture!(aliased_priority_conflicting); installer_fixture!(aliases_with_require_dev, ignore); installer_fixture!(broken_deps_do_not_replace); -installer_fixture!(circular_dependency, ignore); +installer_fixture!(circular_dependency); installer_fixture!(circular_dependency2); installer_fixture!(circular_dependency_errors); installer_fixture!(conflict_against_provided_by_dep_package_works); |
