aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 21:50:54 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 21:50:54 +0900
commit177b894d7d77a5297bee3b2487ef18a0cae7a596 (patch)
treecb526ed3c35414dd6aa8a9bd05c722aa1b7239b6 /crates
parentb8b81bb9beab64ad073af3b32969566f9ba5a038 (diff)
downloadphp-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.
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-registry/src/lockfile.rs1
-rw-r--r--crates/mozart-registry/src/resolver.rs104
-rw-r--r--crates/mozart-sat-resolver/src/pool.rs50
-rw-r--r--crates/mozart/src/commands/create_project.rs1
-rw-r--r--crates/mozart/src/commands/remove.rs4
-rw-r--r--crates/mozart/src/commands/require.rs3
-rw-r--r--crates/mozart/src/commands/update.rs24
-rw-r--r--crates/mozart/tests/installer.rs2
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);