aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/resolver.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 22:46:44 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 22:46:44 +0900
commit3c61a7e1e557e3b90128d2ec29227f166b17c05b (patch)
treee68f5a03ac3ca5ba3a1ab29de755b18e0f3228e5 /crates/mozart-registry/src/resolver.rs
parent8da98493daf5013585e07ec98ca6960a42924edf (diff)
downloadphp-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/mozart-registry/src/resolver.rs')
-rw-r--r--crates/mozart-registry/src/resolver.rs120
1 files changed, 120 insertions, 0 deletions
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));
};