diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-03 13:14:00 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-03 13:14:00 +0900 |
| commit | 3c0527aa63574f17c9f372b6187d5690e0cbaff0 (patch) | |
| tree | 8159e09b685f7faf2870970d465b9698093b8973 /crates/mozart-registry/src/resolver.rs | |
| parent | c53dfc52f6449c8d5ca0b160a2a25f99790711f2 (diff) | |
| download | php-mozart-3c0527aa63574f17c9f372b6187d5690e0cbaff0.tar.gz php-mozart-3c0527aa63574f17c9f372b6187d5690e0cbaff0.tar.zst php-mozart-3c0527aa63574f17c9f372b6187d5690e0cbaff0.zip | |
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) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/resolver.rs')
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 157 |
1 files changed, 156 insertions, 1 deletions
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<String Some(format!("{}-dev", expanded.join("."))) } +/// Mirror Composer's `VersionParser::normalize` for the values that appear on +/// either side of an `as` clause (`require: "1.0.x-dev as dev-master"`). +/// +/// Composer sends both sides through `normalize`, which: +/// - Maps `master` / `trunk` / `default` (with optional `dev-` prefix) to +/// `9999999-dev`. Mozart's pool uses the four-segment expansion +/// `9999999.9999999.9999999.9999999-dev`, which is what +/// `make_default_branch_alias` emits — keep the same form here so a root +/// `as dev-master` lines up with synthetic default-branch aliases. +/// - Strips a leading `v` and treats numeric `*.x-dev` branches via +/// `normalizeBranch` (= `normalize_branch_alias_target`). +/// - Leaves other `dev-NAME` strings as `dev-NAME`. +fn normalize_root_alias_atom(atom: &str) -> Option<String> { + 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 `<X> as <Y>` 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<String, String>, + /// 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<String>, } /// A single package in the resolution output. @@ -653,10 +731,33 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R // `RootPackageLoader::extractStabilityFlags`. Merged on top of the // request's caller-supplied flags (which today are usually empty). let mut stability_flags: IndexMap<String, Stability> = 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<RootAlias> = 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 `<X> as <Y>` 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<Vec<ResolvedPackage>, 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<PoolPackageInput> = 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; |
