From 3c0527aa63574f17c9f372b6187d5690e0cbaff0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 13:14:00 +0900 Subject: fix(resolver): apply root "X as Y" aliases via pool second pass Mirrors Composer's `RootPackageLoader::extractAliases` + `PoolBuilder::loadPackage` flow: strip the `as` clause from each root require so the SAT side sees only the LEFT-hand constraint, and after every package is loaded run a second pass that materializes an alias entry for any input matching `(name, version_normalized)`. Locked-only packages in a partial update are excluded via a new `ResolveRequest::locked_package_names` so they don't pick up the alias (`propagateUpdate=false` in Composer). Two adjacent fixes uncovered while making `install_aliased_alias` green: - `Version::cmp` treated unnamed wildcard branches (`1.0.x-dev`, `is_dev_branch=true && name=None`) as below every numeric version. They are semantically the same as the four-segment `*-dev` form Composer's `normalizeBranch` emits, so let only *named* branches take the shortcut. - `Constraint::Exact` / `NotEqual` used the derived `==`, which compared `is_dev_branch` field-by-field and missed the wildcard/numeric equivalence. Switch to `cmp` so both forms count as equal. - `Pool::matches_package` now falls back to parsing `pretty_version` when the `version` parse doesn't match the constraint, so a `dev-master` query lines up with a pool entry stored as the internal `9999999.x.x.x-dev` expansion. Net effect on installer fixtures: `install_aliased_alias` newly green, plus `aliased_priority`, `aliased_priority_conflicting`, and `install_dev_using_dist` come along for the ride. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/mozart-registry/src/resolver.rs | 157 ++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) (limited to 'crates/mozart-registry/src/resolver.rs') diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 48db7c3..40acfad 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -237,6 +237,77 @@ pub(crate) fn normalize_branch_alias_target(alias_target: &str) -> Option Option { + let trimmed = atom.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_lowercase(); + let stripped = lower.strip_prefix("dev-").unwrap_or(&lower); + if matches!(stripped, "master" | "trunk" | "default") { + return Some("9999999.9999999.9999999.9999999-dev".to_string()); + } + if let Some(numeric) = normalize_branch_alias_target(trimmed) { + return Some(numeric); + } + if let Some(rest) = lower.strip_prefix("dev-") { + return Some(format!("dev-{rest}")); + } + parse_normalized(trimmed).map(|_| trimmed.to_string()) +} + +/// A root-level alias declared via the `require: "X as Y"` shorthand on the +/// root composer.json. Mirrors Composer's +/// `RootPackageLoader::extractAliases` entries: when the resolver loads a +/// package matching `(package, version_normalized)`, it materializes an extra +/// alias entry exposing the same install under `alias_normalized`/`alias`. +#[derive(Debug, Clone)] +struct RootAlias { + package: String, + /// Normalized form of the LEFT-hand side (the actual constraint). + version_normalized: String, + /// Pretty form of the RIGHT-hand side (the alias to expose). + alias: String, + /// Normalized form of the RIGHT-hand side. + alias_normalized: String, +} + +/// Strip a single-atom ` as ` clause from a constraint string. Returns +/// the cleaned constraint plus the `(left, right)` pieces when an alias is +/// present. Mirrors Composer's `VersionParser::parseConstraint` `as`-strip: +/// the constraint passed to the resolver is the LEFT side, and a separate +/// alias entry is recorded for the RIGHT side. +fn strip_root_alias_clause(constraint: &str) -> (String, Option<(String, String)>) { + let trimmed = constraint.trim(); + if let Some(idx) = trimmed.find(" as ") { + let before = trimmed[..idx].trim(); + let after = trimmed[idx + 4..].trim(); + if !before.is_empty() + && !after.is_empty() + && !before.contains([' ', '\t', ',', '|']) + && !after.contains([' ', '\t', ',', '|']) + { + return ( + before.to_string(), + Some((before.to_string(), after.to_string())), + ); + } + } + (trimmed.to_string(), None) +} + // ───────────────────────────────────────────────────────────────────────────── // PackageName // ───────────────────────────────────────────────────────────────────────────── @@ -618,6 +689,13 @@ pub struct ResolveRequest { /// targeted version and any alias / replace / provide that would resolve /// to it. pub root_conflict: IndexMap, + /// Lowercase names of packages that are pinned to their lock-file version + /// for this resolve (a partial update where the package is not in the + /// update list). Mirrors the `propagateUpdate=false` branch of Composer's + /// `PoolBuilder::loadPackage`: locked-only packages do not pick up + /// `require: "X as Y"` root aliases. Empty for installs and full updates, + /// where every package can take aliases as usual. + pub locked_package_names: IndexSet, } /// A single package in the resolution output. @@ -653,10 +731,33 @@ pub async fn resolve(request: &ResolveRequest) -> Result, R // `RootPackageLoader::extractStabilityFlags`. Merged on top of the // request's caller-supplied flags (which today are usually empty). let mut stability_flags: IndexMap = request.stability_flags.clone(); + // Root-level aliases extracted from `require: "X as Y"`. Mirrors + // Composer's `RootPackageLoader::extractAliases`: each entry adds a new + // alias package to the pool exposing the matched real package under the + // RIGHT-hand version label. + let mut root_aliases: Vec = Vec::new(); let minimum_stability = request.minimum_stability; let mut insert_root_require = |name: &str, constraint: &str| { - let (clean, stability) = extract_stability_suffix(constraint); + // Strip any ` as ` clause first (mirrors Composer's + // `parseConstraint` strip + `extractAliases` capture). The cleaned + // constraint feeds the resolver; the alias is recorded for a second + // pool-population pass once real packages are in. + let (constraint_no_as, alias_pieces) = strip_root_alias_clause(constraint); + if let Some((target_atom, alias_atom)) = alias_pieces + && let (Some(target_normalized), Some(alias_normalized)) = ( + normalize_root_alias_atom(&target_atom), + normalize_root_alias_atom(&alias_atom), + ) + { + root_aliases.push(RootAlias { + package: name.to_lowercase(), + version_normalized: target_normalized, + alias: alias_atom, + alias_normalized, + }); + } + let (clean, stability) = extract_stability_suffix(&constraint_no_as); let lower = name.to_lowercase(); if let Some(s) = stability { let entry = stability_flags.entry(lower.clone()).or_insert(s); @@ -934,6 +1035,59 @@ pub async fn resolve(request: &ResolveRequest) -> Result, R } } + // Second pass: materialize root aliases (`require: "X as Y"`). + // + // Mirrors Composer's `PoolBuilder::loadPackage` post-load step: when a + // package whose `(name, version)` matches a `rootAliases` entry is added, + // an extra `AliasPackage` exposing that install under + // `(alias_normalized, alias)` is appended to the pool. When the matched + // input is already an alias (e.g. an `extra.branch-alias` entry from + // `packagist_to_pool_inputs`), Composer follows `getAliasOf()` to the + // base package — we replicate by carrying the input's `is_alias_of` + // value forward, so the new alias points straight at the real package + // rather than chaining through the intermediate alias. + if !root_aliases.is_empty() { + let mut new_aliases: Vec = Vec::new(); + for input in builder.inputs() { + // Skip alias creation for packages locked to their lock-file + // version (partial update where this package wasn't requested). + // Mirrors Composer's `propagateUpdate=false` skip in + // `PoolBuilder::loadPackage`. + if request + .locked_package_names + .contains(&input.name.to_lowercase()) + { + continue; + } + for alias in &root_aliases { + if input.name.to_lowercase() != alias.package { + continue; + } + if input.version != alias.version_normalized { + continue; + } + let target_normalized = input + .is_alias_of + .clone() + .unwrap_or_else(|| input.version.clone()); + new_aliases.push(PoolPackageInput { + name: input.name.clone(), + version: alias.alias_normalized.clone(), + pretty_version: alias.alias.clone(), + requires: input.requires.clone(), + replaces: input.replaces.clone(), + provides: input.provides.clone(), + conflicts: input.conflicts.clone(), + is_fixed: false, + is_alias_of: Some(target_normalized), + }); + } + } + for alias_input in new_aliases { + builder.add_package(alias_input); + } + } + // Build the pool let mut pool = builder.build(); // Collect fixed package IDs @@ -1426,6 +1580,7 @@ mod tests { root_provide: IndexMap::new(), root_replace: IndexMap::new(), root_conflict: IndexMap::new(), + locked_package_names: IndexSet::new(), }; let result = resolve(&request).await; -- cgit v1.3.1