diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 22:46:44 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 22:46:44 +0900 |
| commit | 3c61a7e1e557e3b90128d2ec29227f166b17c05b (patch) | |
| tree | e68f5a03ac3ca5ba3a1ab29de755b18e0f3228e5 /crates | |
| parent | 8da98493daf5013585e07ec98ca6960a42924edf (diff) | |
| download | php-mozart-3c61a7e1e557e3b90128d2ec29227f166b17c05b.tar.gz php-mozart-3c61a7e1e557e3b90128d2ec29227f166b17c05b.tar.zst php-mozart-3c61a7e1e557e3b90128d2ec29227f166b17c05b.zip | |
feat(resolver): support inline #ref pin and default-branch alias
Adds the missing pieces for installer fixtures that pin a dev package
via `dev-foo#hex` or rely on Composer's `default-branch: true` synthetic
`9999999-dev` alias.
Mirrors Composer at four layers:
1. `mozart_semver::parse_single` strips `dev-...#hex` / `....x-dev#hex`
suffixes from constraints (Composer's `parseConstraint` regex).
2. `PackagistVersion` carries `default_branch`. When set on a `dev-`
package with no numeric prefix, `packagist_to_pool_inputs` emits
the synthetic `9999999-dev` alias — but skips it when an explicit
`extra.branch-alias` already covers the version (matches
`ArrayLoader::getBranchAlias`).
3. `RuleSetGenerator::generate` picks up `addRulesForRootAliases`:
any pool alias whose target was added gets its own alias↔target
rules so the SAT solver pulls them in together.
4. `lockfile::generate_lock_file` extracts root `#hex` overrides from
`require`/`require-dev` and rewrites source/dist references (and
github/gitlab/bitbucket archive URLs) on the matched package, the
`setSourceDistReferences` ladder Composer runs in `PoolBuilder`.
Resolver also infers `Stability::Dev` from a `dev-foo` style
single-atom constraint when no explicit `@flag` is given, mirroring
the second loop of `RootPackageLoader::extractStabilityFlags` so the
package isn't filtered out under default `stable` minimum-stability.
Newly green: install_branch_alias_composer_repo, install_reference,
conflict_with_alias_prevents_update_if_not_required,
unbounded_conflict_matches_default_branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart-registry/src/lockfile.rs | 128 | ||||
| -rw-r--r-- | crates/mozart-registry/src/packagist.rs | 8 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 120 | ||||
| -rw-r--r-- | crates/mozart-registry/src/vcs_bridge.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/version.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-sat-resolver/src/rule_set_generator.rs | 18 | ||||
| -rw-r--r-- | crates/mozart-semver/src/lib.rs | 38 | ||||
| -rw-r--r-- | crates/mozart/tests/installer.rs | 6 |
8 files changed, 315 insertions, 5 deletions
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index e422a9a..045b189 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -407,6 +407,114 @@ fn packagist_dist_to_locked(pd: &PackagistDist) -> LockedDist { } } +/// Mirror Composer's `RootPackageLoader::extractReferences`: scan +/// `require`/`require-dev` for `dev-foo#hex` style constraints, returning a +/// lowercase package name → reference map. Constraints whose stability isn't +/// `dev` after stripping the reference are left out (matching the +/// `'dev' === VersionParser::parseStability(...)` guard in PHP). +fn extract_root_references( + require: &BTreeMap<String, String>, + require_dev: &BTreeMap<String, String>, +) -> BTreeMap<String, String> { + let mut out = BTreeMap::new(); + for (name, raw_constraint) in require.iter().chain(require_dev.iter()) { + if let Some(reference) = parse_inline_reference(raw_constraint) { + out.insert(name.to_lowercase(), reference); + } + } + out +} + +/// Pull the `#hex` suffix out of a single-atom dev constraint. Returns +/// `None` for non-`dev-*` / non-`*-dev` constraints, matching Composer's +/// `'{^[^,\s@]+?#([a-f0-9]+)$}'` + `parseStability == 'dev'` guard. +fn parse_inline_reference(constraint: &str) -> Option<String> { + // Strip `... as alias` first, mirroring extractReferences's + // `'{^([^,\s@]+) as .+$}'` replacement. + let core = match constraint.split(" as ").next() { + Some(c) => c.trim(), + None => constraint.trim(), + }; + let (head, hash) = core.rsplit_once('#')?; + if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + if head.contains([' ', '\t', ',', '@']) { + return None; + } + let lower = head.to_lowercase(); + if !(lower.starts_with("dev-") || lower.ends_with("-dev")) { + return None; + } + Some(hash.to_string()) +} + +/// Mirror `Composer\Package\Package::setSourceDistReferences`: rewrite both +/// source and dist references to the supplied value, and rewrite the +/// reference inside any auto-generated GitHub/GitLab/Bitbucket dist URL when +/// present. The dist reference is only written if there was already one +/// (Composer leaves `dist.reference == null` packages alone). +fn apply_reference_override(pkg: &mut LockedPackage, reference: &str) { + if let Some(source) = pkg.source.as_mut() { + source.reference = Some(reference.to_string()); + } + if let Some(dist) = pkg.dist.as_mut() { + let url_carries_known_host = matches_dist_url_with_known_host(Some(&dist.url)); + if dist.reference.is_some() || url_carries_known_host { + dist.reference = Some(reference.to_string()); + } + if url_carries_known_host { + dist.url = rewrite_known_dist_url_reference(&dist.url, reference); + } + } +} + +/// Match the bitbucket / github / gitlab dist-URL prefixes Composer +/// rewrites. Mirrors the regex +/// `{^https?://(?:(?:www\.)?bitbucket\.org|(api\.)?github\.com|(?:www\.)?gitlab\.com)/}i`. +fn matches_dist_url_with_known_host(url: Option<&str>) -> bool { + let Some(url) = url else { return false }; + let lower = url.to_lowercase(); + let stripped = lower + .strip_prefix("http://") + .or_else(|| lower.strip_prefix("https://")) + .unwrap_or(&lower); + let stripped = stripped.strip_prefix("www.").unwrap_or(stripped); + let stripped = stripped.strip_prefix("api.").unwrap_or(stripped); + stripped.starts_with("bitbucket.org/") + || stripped.starts_with("github.com/") + || stripped.starts_with("gitlab.com/") +} + +/// Substitute any 40-char hex segment surrounded by `/` or `sha=` (the +/// archive shape produced by GitHub/GitLab/Bitbucket) with the new +/// reference. Matches Composer's +/// `'{(?<=/|sha=)[a-f0-9]{40}(?=/|$)}i'` rewrite. +fn rewrite_known_dist_url_reference(url: &str, reference: &str) -> String { + let bytes = url.as_bytes(); + let mut out = String::with_capacity(url.len()); + let mut i = 0; + while i < bytes.len() { + let start = i; + let preceded_by_slash = i > 0 && bytes[i - 1] == b'/'; + let preceded_by_sha = i >= 4 && &bytes[i - 4..i] == b"sha="; + if (preceded_by_slash || preceded_by_sha) && i + 40 <= bytes.len() { + let candidate = &url[i..i + 40]; + if candidate.chars().all(|c| c.is_ascii_hexdigit()) { + let after = bytes.get(i + 40).copied(); + if after == Some(b'/') || after.is_none() { + out.push_str(reference); + i += 40; + continue; + } + } + } + out.push(url[start..].chars().next().unwrap()); + i += url[start..].chars().next().unwrap().len_utf8(); + } + out +} + /// Convert a `PackagistVersion` to a `LockedPackage`. fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage { let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new(); @@ -600,12 +708,26 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow:: &package_metadata, ); - // 3. Build LockedPackage lists + // 3. Build LockedPackage lists. + // + // Apply root-level `#hex` reference overrides extracted from + // `require`/`require-dev`. Mirrors Composer's + // `RootPackageLoader::extractReferences` + `PoolBuilder::loadPackage`'s + // `setSourceDistReferences` call: when the user pinned a dev package via + // `dev-main#abcd123`, the resolved package's source/dist must show that + // reference in the lock + trace, not whatever the inline metadata said. + let root_references = extract_root_references( + &request.composer_json.require, + &request.composer_json.require_dev, + ); let mut packages: Vec<LockedPackage> = Vec::new(); let mut packages_dev: Vec<LockedPackage> = Vec::new(); for pkg in &real_resolved { let pv = &package_metadata[&pkg.name]; - let locked = packagist_version_to_locked_package(&pkg.name, pv); + let mut locked = packagist_version_to_locked_package(&pkg.name, pv); + if let Some(reference) = root_references.get(&pkg.name.to_lowercase()) { + apply_reference_override(&mut locked, reference); + } if dev_only.contains(&pkg.name) { packages_dev.push(locked); } else { @@ -869,6 +991,7 @@ mod tests { time: Some("2024-01-15T10:00:00+00:00".to_string()), extra: Some(serde_json::json!({"branch-alias": {"dev-main": "1.0.x-dev"}})), notification_url: Some("https://packagist.org/downloads/".to_string()), + default_branch: false, } } @@ -943,6 +1066,7 @@ mod tests { time: None, extra: None, notification_url: None, + default_branch: false, }; let locked = packagist_version_to_locked_package("vendor/pkg", &pv); diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs index 64ff11a..1d9356d 100644 --- a/crates/mozart-registry/src/packagist.rs +++ b/crates/mozart-registry/src/packagist.rs @@ -127,6 +127,14 @@ pub struct PackagistVersion { deserialize_with = "deserialize_unset_as_none" )] pub notification_url: Option<String>, + + /// `default-branch: true` marks the repository's default branch (e.g. the + /// branch returned by `git symbolic-ref HEAD`). For packages without a + /// numeric version prefix this triggers the synthetic `9999999-dev` alias + /// generation in `ArrayLoader::getBranchAlias` — see the alias loop in + /// `crate::resolver::packagist_to_pool_inputs`. + #[serde(rename = "default-branch", default)] + pub default_branch: bool, } impl PackagistVersion { diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 4b8266d..a83304f 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -52,6 +52,61 @@ pub(crate) fn extract_stability_suffix(constraint: &str) -> (String, Option<Stab (trimmed.to_string(), None) } +/// Mirror Composer's `VersionParser::parseStability` for a single-atom +/// constraint string (no `@flag` suffix). Returns `Some(stability)` for +/// recognised non-stable constraints (`dev-foo`, `1.0.x-dev`, `1.0.0-beta1`, +/// …), `None` for stable or unrecognised forms (in which case +/// `minimum_stability` already applies). +/// +/// Composer first strips a trailing `#hash` (handled here), then checks +/// `dev-` prefix / `-dev` suffix / a `(stab)?\d*` modifier. We follow the +/// same shape — the regex variant is overkill for inferring a flag. +pub(crate) fn infer_constraint_stability(constraint: &str) -> Option<Stability> { + let s = constraint.trim(); + // Strip `#ref` (matches Composer's `parseStability` line 54). + let s = match s.find('#') { + Some(p) => &s[..p], + None => s, + }; + // Reject multi-atom constraints — extractStabilityFlags inspects each + // sub-constraint individually but the most common single-atom case is + // all we need for `dev-foo` / `1.0.x-dev` style root requires. + if s.contains([' ', ',']) || s.contains("||") { + return None; + } + // Strip a leading comparison operator (`>=1.0-beta` → `1.0-beta`). + let s = s + .strip_prefix(">=") + .or_else(|| s.strip_prefix("<=")) + .or_else(|| s.strip_prefix("!=")) + .or_else(|| s.strip_prefix("==")) + .or_else(|| s.strip_prefix('>')) + .or_else(|| s.strip_prefix('<')) + .or_else(|| s.strip_prefix('=')) + .or_else(|| s.strip_prefix('^')) + .or_else(|| s.strip_prefix('~')) + .unwrap_or(s); + let lower = s.to_lowercase(); + if lower.starts_with("dev-") || lower.ends_with("-dev") { + return Some(Stability::Dev); + } + // Match `<modifier><digits?>` at the end after the last `-`/`@`. + // Composer uses `{(stable|RC|beta|alpha|dev)([.-]?\d+)?(?:\+.*)?$}`. + let tail = lower + .rsplit_once('-') + .or_else(|| lower.rsplit_once('@')) + .map(|(_, t)| t) + .unwrap_or(&lower); + let tail_word: String = tail.chars().take_while(|c| c.is_alphabetic()).collect(); + match tail_word.as_str() { + "alpha" | "a" => Some(Stability::Alpha), + "beta" | "b" => Some(Stability::Beta), + "rc" => Some(Stability::RC), + "patch" | "pl" | "p" | "stable" => Some(Stability::Stable), + _ => None, + } +} + /// Determine the `Stability` of a `Version` from its pre_release string. pub(crate) fn version_stability(v: &Version) -> Stability { match &v.pre_release { @@ -119,6 +174,25 @@ fn parse_branch_alias_target(alias_target: &str) -> Option<Version> { }) } +/// Mirror Composer's `VersionParser::parseNumericAliasPrefix`: returns true +/// when the input is a numeric branch like `1.2-dev` / `1.2.3-dev` / +/// `1.2.x-dev` (i.e. the prefix is suitable for version comparison). +/// Non-numeric branches like `dev-main` / `dev-feature/x` return false. +fn has_numeric_alias_prefix(branch: &str) -> bool { + let lower = branch.trim().to_lowercase(); + let lower = lower.strip_prefix('v').unwrap_or(&lower); + let Some(base) = lower.strip_suffix("-dev") else { + return false; + }; + let base = base.strip_suffix(".x").unwrap_or(base); + if base.is_empty() { + return false; + } + // Allow only digit segments separated by `.`. + base.split('.') + .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_digit())) +} + /// Mirror Composer's `VersionParser::normalizeBranch` for branch-alias /// targets: turn a string like `"3.2.x-dev"` into the canonical numeric form /// `"3.2.9999999.9999999-dev"`. Returns `None` if the input is not a numeric @@ -398,6 +472,7 @@ fn packagist_to_pool_inputs( results.push(make_input(&pv.version, &pv.version_normalized, None)); let aliases = pv.branch_aliases(); + let mut emitted_explicit_alias = false; for (branch, alias_target) in &aliases { if branch.to_lowercase() != pv.version.to_lowercase() { continue; @@ -413,6 +488,37 @@ fn packagist_to_pool_inputs( &alias_normalized, Some(pv.version_normalized.clone()), )); + emitted_explicit_alias = true; + } + + // Mirror Composer's `ArrayLoader::getBranchAlias`: when a + // `dev-` package carries `default-branch: true` and the version + // has no numeric prefix (i.e. it isn't already a `1.0.x-dev` form + // that would be its own alias), synthesize the `9999999-dev` + // alias so root constraints like `dev-main` pick up a default + // branch surfaced as `9999999-dev` in the lock + trace output. + // + // `getBranchAlias` returns the *first* matching branch-alias when + // one exists — i.e. an explicit `branch-alias` entry takes + // precedence over the `default-branch` synthetic one. Skip the + // synthetic alias when an explicit one has already been emitted + // for this version. + if pv.default_branch + && !emitted_explicit_alias + && !has_numeric_alias_prefix(&pv.version) + { + let default_alias = "9999999-dev"; + let default_normalized = "9999999.9999999.9999999.9999999-dev"; + let already_present = results + .iter() + .any(|r| r.version == default_normalized && r.name == package_name); + if !already_present { + results.push(make_input( + default_alias, + default_normalized, + Some(pv.version_normalized.clone()), + )); + } } } } @@ -499,6 +605,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R // request's caller-supplied flags (which today are usually empty). let mut stability_flags: HashMap<String, Stability> = request.stability_flags.clone(); + let minimum_stability = request.minimum_stability; let mut insert_root_require = |name: &str, constraint: &str| { let (clean, stability) = extract_stability_suffix(constraint); let lower = name.to_lowercase(); @@ -507,6 +614,19 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R if (*entry as u8) > (s as u8) { *entry = s; } + } else if let Some(inferred) = infer_constraint_stability(&clean) { + // Mirrors `RootPackageLoader::extractStabilityFlags` second loop: + // when a single-atom constraint like `dev-main` or `1.0.x-dev` + // implies a non-stable stability and no explicit `@flag` was + // given, raise that package's stability ceiling so the pool + // accepts it. Only applied when the inferred level is *more* + // permissive than `minimum_stability` and any existing flag. + if (inferred as u8) > (minimum_stability as u8) { + let entry = stability_flags.entry(lower.clone()).or_insert(inferred); + if (*entry as u8) < (inferred as u8) { + *entry = inferred; + } + } } root_requires.insert(lower, Some(clean)); }; diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs index cb8aad5..7714630 100644 --- a/crates/mozart-registry/src/vcs_bridge.rs +++ b/crates/mozart-registry/src/vcs_bridge.rs @@ -182,6 +182,7 @@ pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion { time: vpkg.time.clone(), extra: vpkg.composer_json.get("extra").cloned(), notification_url: None, + default_branch: vpkg.is_default_branch, } } diff --git a/crates/mozart-registry/src/version.rs b/crates/mozart-registry/src/version.rs index 8a26e92..ba120fa 100644 --- a/crates/mozart-registry/src/version.rs +++ b/crates/mozart-registry/src/version.rs @@ -198,6 +198,7 @@ mod tests { time: None, extra: None, notification_url: None, + default_branch: false, } } diff --git a/crates/mozart-sat-resolver/src/rule_set_generator.rs b/crates/mozart-sat-resolver/src/rule_set_generator.rs index b5dfcdb..92b9f77 100644 --- a/crates/mozart-sat-resolver/src/rule_set_generator.rs +++ b/crates/mozart-sat-resolver/src/rule_set_generator.rs @@ -105,6 +105,24 @@ impl<'a> RuleSetGenerator<'a> { } } + // Mirror Composer's `RuleSetGenerator::addRulesForRootAliases`: + // ensure every alias whose target was already added gets its own + // alias↔target rules, even when the alias itself didn't appear in + // any root require's `whatProvides` (e.g. the synthetic + // `9999999-dev` alias from a `default-branch: true` package, which + // only matches a literal `9999999-dev` constraint). + let alias_pairs: Vec<(PackageId, PackageId)> = self + .pool + .packages() + .iter() + .filter_map(|p| p.is_alias_of.map(|t| (p.id, t))) + .collect(); + for (alias_id, target_id) in alias_pairs { + if self.added_map.contains(&target_id) && !self.added_map.contains(&alias_id) { + self.add_rules_for_package(alias_id); + } + } + // Add conflict rules self.add_conflict_rules(); diff --git a/crates/mozart-semver/src/lib.rs b/crates/mozart-semver/src/lib.rs index dfb0db2..a15db13 100644 --- a/crates/mozart-semver/src/lib.rs +++ b/crates/mozart-semver/src/lib.rs @@ -669,12 +669,50 @@ fn split_and(s: &str) -> Vec<String> { parts } +/// Strip `#ref` suffix from `dev-...#hex` / `....x-dev#hex` constraint +/// strings. Mirrors Composer's +/// `'{^(dev-[^,\s@]+?|[^,\s@]+?\.x-dev)#.+$}i'` regex strip in +/// `VersionParser::parseConstraint`. Returns a `Cow` so callers that pass +/// constraints without `#` see no allocation. +fn strip_constraint_ref(s: &str) -> std::borrow::Cow<'_, str> { + let lower = s.to_lowercase(); + let Some(hash_pos) = s.find('#') else { + return std::borrow::Cow::Borrowed(s); + }; + let head = &lower[..hash_pos]; + let rest = &s[hash_pos + 1..]; + if rest.is_empty() { + return std::borrow::Cow::Borrowed(s); + } + // Accept `dev-foo` or `1.2.x-dev` style prefixes only, mirroring the + // Composer regex. Anything else (e.g. URLs, comments) is left alone. + let head_no_space = !head + .chars() + .any(|c: char| c.is_whitespace() || c == ',' || c == '@'); + if !head_no_space { + return std::borrow::Cow::Borrowed(s); + } + let matches = head.starts_with("dev-") || head.ends_with(".x-dev"); + if matches { + std::borrow::Cow::Owned(s[..hash_pos].to_string()) + } else { + std::borrow::Cow::Borrowed(s) + } +} + /// Parse a single constraint part. fn parse_single(s: &str) -> Result<VersionConstraint, String> { if s == "*" || s.is_empty() { return Ok(VersionConstraint::Single(Constraint::Any)); } + // Strip `#ref` suffixes from `dev-...#hex` / `....x-dev#hex` constraints — + // they pin a source reference at the root level (handled by the + // installer) and are not part of the version match. Mirrors Composer's + // `VersionParser::parseConstraint` `'{^(dev-[^,\s@]+?|[^,\s@]+?\.x-dev)#.+$}i'` strip. + let s = strip_constraint_ref(s); + let s = s.as_ref(); + // Caret: ^1.2.3 if let Some(rest) = s.strip_prefix('^') { return parse_caret(rest); diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index d674485..fcb29c1 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -244,7 +244,7 @@ installer_fixture!( ); installer_fixture!(conflict_with_alias_in_lock_does_prevents_install, ignore); installer_fixture!(conflict_with_alias_prevents_update, ignore); -installer_fixture!(conflict_with_alias_prevents_update_if_not_required, ignore); +installer_fixture!(conflict_with_alias_prevents_update_if_not_required); installer_fixture!(conflict_with_all_dependencies_option_dont_recommend_to_use_it); installer_fixture!(deduplicate_solver_problems); installer_fixture!(disjunctive_multi_constraints); @@ -276,7 +276,7 @@ installer_fixture!(install_missing_alias_from_lock, ignore); installer_fixture!(install_overridden_platform_packages, ignore); installer_fixture!(install_package_and_its_provider_skips_original); installer_fixture!(install_prefers_repos_over_package_versions, ignore); -installer_fixture!(install_reference, ignore); +installer_fixture!(install_reference); installer_fixture!(install_security_advisory_matching_dependency); installer_fixture!(install_self_from_root); installer_fixture!(install_simple); @@ -361,7 +361,7 @@ installer_fixture!(suggest_replaced); installer_fixture!(suggest_uninstalled); installer_fixture!(unbounded_conflict_does_not_match_default_branch_with_branch_alias); installer_fixture!(unbounded_conflict_does_not_match_default_branch_with_numeric_branch); -installer_fixture!(unbounded_conflict_matches_default_branch, ignore); +installer_fixture!(unbounded_conflict_matches_default_branch); installer_fixture!( update_abandoned_package_required_but_blocked_via_audit_config, ignore |
