aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 22:21:25 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 22:21:25 +0900
commit8da98493daf5013585e07ec98ca6960a42924edf (patch)
treebe57603fec29a4bf1e5f546b1ba2e14778595cb3 /crates
parent804b5b9a2a7759af24e41408c82dfc60c6092cf3 (diff)
downloadphp-mozart-8da98493daf5013585e07ec98ca6960a42924edf.tar.gz
php-mozart-8da98493daf5013585e07ec98ca6960a42924edf.tar.zst
php-mozart-8da98493daf5013585e07ec98ca6960a42924edf.zip
feat(resolver): add branch-alias support across the resolution pipeline
Plumb Composer's `extra.branch-alias` mechanism end-to-end so a dev branch (e.g. `dev-foobar`) can be installed alongside its numeric alias (e.g. `3.2.x-dev`) and resolve constraints written against the alias target. Concretely: - `mozart-semver`: stop treating pure-numeric `-dev` as a wildcard branch — `3.2.9999999.9999999-dev` (the form `normalizeBranch` emits) now parses as a classical version with `is_dev_branch=false`, so constraints like `3.2.*` match it. - `mozart-registry/composer_repo`: load `type: composer` repositories from `file://` URLs (legacy embedded `packages.json`). - `mozart-registry/resolver`: emit pool entries in pairs for dev branches with `extra.branch-alias`, link them via `is_alias_of`, and apply `@dev`/`@beta` etc. stability suffix flags from root requires. - `mozart-sat-resolver`: alias rules (`PackageAlias` / `PackageInverseAlias`) so alias and target install together; alias packages skipped from same-name conflict indexing. - `mozart-sat-resolver/policy`: `DefaultPolicy` now honors `prefer_stable` via Composer's stability-tier comparison. - `mozart-registry/lockfile`: split resolved set into real packages vs. alias entries; populate the `aliases[]` block. - `mozart-registry/installer_executor`: new `MarkAliasInstalled` operation; `format_full_pretty_version` mirroring `BasePackage::getFullPrettyVersion` (appends source ref[0..7] for dev/git packages). - Test harness rewrites fixture-relative `file://` URLs to absolute paths. Newly green fixtures: `install_branch_alias_composer_repo`, `alias_solver_problems`, `alias_solver_problems2`, `conflict_with_all_dependencies_option_dont_recommend_to_use_it`, `unbounded_conflict_does_not_match_default_branch_with_branch_alias`, `unbounded_conflict_does_not_match_default_branch_with_numeric_branch`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-registry/src/composer_repo.rs146
-rw-r--r--crates/mozart-registry/src/installer_executor/filesystem.rs8
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs63
-rw-r--r--crates/mozart-registry/src/installer_executor/trace_recorder.rs25
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/lockfile.rs88
-rw-r--r--crates/mozart-registry/src/resolver.rs206
-rw-r--r--crates/mozart-registry/src/vcs_bridge.rs1
-rw-r--r--crates/mozart-sat-resolver/src/policy.rs46
-rw-r--r--crates/mozart-sat-resolver/src/pool.rs42
-rw-r--r--crates/mozart-sat-resolver/src/pool_builder.rs3
-rw-r--r--crates/mozart-sat-resolver/src/problem.rs1
-rw-r--r--crates/mozart-sat-resolver/src/rule_set_generator.rs36
-rw-r--r--crates/mozart-sat-resolver/src/solver.rs1
-rw-r--r--crates/mozart-sat-resolver/src/transaction.rs2
-rw-r--r--crates/mozart-semver/src/lib.rs54
-rw-r--r--crates/mozart/src/commands/install.rs16
-rw-r--r--crates/mozart/src/commands/update.rs1
-rw-r--r--crates/mozart/tests/installer.rs67
19 files changed, 715 insertions, 92 deletions
diff --git a/crates/mozart-registry/src/composer_repo.rs b/crates/mozart-registry/src/composer_repo.rs
new file mode 100644
index 0000000..28063cc
--- /dev/null
+++ b/crates/mozart-registry/src/composer_repo.rs
@@ -0,0 +1,146 @@
+//! Support for `type: composer` repositories.
+//!
+//! A Composer repository is a directory (or HTTP endpoint) hosting a
+//! `packages.json` file. The legacy format embeds full package metadata
+//! directly:
+//!
+//! ```json
+//! {
+//! "packages": {
+//! "a/a": {
+//! "dev-foobar": { "name": "a/a", "version": "dev-foobar", ... }
+//! }
+//! }
+//! }
+//! ```
+//!
+//! Mirrors `Composer\Repository\ComposerRepository` for the file:// case
+//! used by the test fixtures. Lazy / v2 / provider-includes / metadata-url
+//! variants are out of scope here — the in-process installer fixtures only
+//! exercise the legacy embedded-packages form.
+
+use crate::packagist::PackagistVersion;
+use mozart_core::package::RawRepository;
+use std::path::PathBuf;
+
+/// One package version drawn from a `type: composer` repository.
+pub struct ComposerRepoPackage {
+ pub name: String,
+ pub version: PackagistVersion,
+}
+
+/// Read every package version from `type: composer` repositories declared in
+/// `composer.json`. Only `file://` URLs are supported here — they're what
+/// the installer fixtures use after the harness rewrites
+/// `file://foobar` → `file:///abs/path/to/fixtures/foobar`.
+pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec<ComposerRepoPackage> {
+ let mut out = Vec::new();
+ for repo in repositories {
+ if repo.repo_type != "composer" {
+ continue;
+ }
+ let Some(url) = repo.url.as_deref() else {
+ continue;
+ };
+ let Some(dir) = file_url_to_path(url) else {
+ continue;
+ };
+ let packages_json = dir.join("packages.json");
+ let Ok(content) = std::fs::read_to_string(&packages_json) else {
+ continue;
+ };
+ let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else {
+ continue;
+ };
+ let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) else {
+ continue;
+ };
+ for (name, versions) in packages {
+ let Some(versions_obj) = versions.as_object() else {
+ continue;
+ };
+ for (_, version_value) in versions_obj {
+ if let Ok(pv) = serde_json::from_value::<PackagistVersion>(version_value.clone()) {
+ out.push(ComposerRepoPackage {
+ name: name.clone(),
+ version: pv,
+ });
+ }
+ }
+ }
+ }
+ out
+}
+
+/// Turn a `file://` URL into a filesystem path. Accepts both
+/// `file:///abs/path` (RFC 8089 form) and `file://abs/path` (Composer's
+/// loose form). Returns `None` for non-`file://` URLs.
+fn file_url_to_path(url: &str) -> Option<PathBuf> {
+ let rest = url.strip_prefix("file://")?;
+ // RFC 8089: file:///abs/path → empty authority, rest starts with `/`.
+ // Composer's harness writes `file:///abs/...` after rewriting, so the
+ // typical input here is one leading `/`.
+ Some(PathBuf::from(rest))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use tempfile::TempDir;
+
+ fn write_packages_json(dir: &std::path::Path, body: &str) {
+ fs::write(dir.join("packages.json"), body).unwrap();
+ }
+
+ fn composer_repo(url: String) -> RawRepository {
+ RawRepository {
+ repo_type: "composer".to_string(),
+ url: Some(url),
+ package: None,
+ }
+ }
+
+ #[test]
+ fn reads_legacy_packages_json() {
+ let tmp = TempDir::new().unwrap();
+ write_packages_json(
+ tmp.path(),
+ r#"{
+ "packages": {
+ "a/a": {
+ "dev-foobar": {
+ "name": "a/a",
+ "version": "dev-foobar",
+ "version_normalized": "dev-foobar"
+ }
+ }
+ }
+ }"#,
+ );
+ let url = format!("file://{}", tmp.path().display());
+ let repos = vec![composer_repo(url)];
+ let pkgs = collect_composer_packages(&repos);
+ assert_eq!(pkgs.len(), 1);
+ assert_eq!(pkgs[0].name, "a/a");
+ assert_eq!(pkgs[0].version.version, "dev-foobar");
+ }
+
+ #[test]
+ fn ignores_non_composer_types() {
+ let repos = vec![RawRepository {
+ repo_type: "vcs".to_string(),
+ url: Some("https://example.com/foo.git".to_string()),
+ package: None,
+ }];
+ assert!(collect_composer_packages(&repos).is_empty());
+ }
+
+ #[test]
+ fn skips_missing_packages_json() {
+ let tmp = TempDir::new().unwrap();
+ let url = format!("file://{}", tmp.path().display());
+ let repos = vec![composer_repo(url)];
+ assert!(collect_composer_packages(&repos).is_empty());
+ }
+}
diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs
index 185e5b9..cceb5da 100644
--- a/crates/mozart-registry/src/installer_executor/filesystem.rs
+++ b/crates/mozart-registry/src/installer_executor/filesystem.rs
@@ -29,7 +29,13 @@ impl InstallerExecutor for FilesystemExecutor {
op: PackageOperation<'_>,
ctx: &ExecuteContext,
) -> anyhow::Result<()> {
- let pkg = op.package();
+ // Marking an alias as installed has no filesystem side effects —
+ // the target package's files are already in vendor/. Mirrors
+ // Composer's `MarkAliasInstalledOperation` which the installation
+ // manager only uses to update the in-memory installed repository.
+ let Some(pkg) = op.package() else {
+ return Ok(());
+ };
// Try source install if --prefer-source and source info is available.
if ctx.prefer_source
diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs
index c70fe12..ff3d7a8 100644
--- a/crates/mozart-registry/src/installer_executor/mod.rs
+++ b/crates/mozart-registry/src/installer_executor/mod.rs
@@ -15,7 +15,7 @@
use std::path::PathBuf;
-use crate::lockfile::LockedPackage;
+use crate::lockfile::{LockAlias, LockedPackage};
pub mod filesystem;
pub mod trace_recorder;
@@ -35,18 +35,75 @@ pub enum PackageOperation<'a> {
from_version: &'a str,
package: &'a LockedPackage,
},
+ /// Mark an alias of a real package as installed. No filesystem effects —
+ /// only the trace recorder needs this. Mirrors Composer's
+ /// `MarkAliasInstalledOperation`.
+ MarkAliasInstalled {
+ /// The alias entry from `composer.lock`'s `aliases[]` block. Carries
+ /// pretty + normalized alias version and the target's pretty version.
+ alias: &'a LockAlias,
+ /// The target package the alias points at — used to source the
+ /// reference suffix for the trace line.
+ target: &'a LockedPackage,
+ },
}
impl<'a> PackageOperation<'a> {
- pub fn package(&self) -> &'a LockedPackage {
+ pub fn package(&self) -> Option<&'a LockedPackage> {
match self {
PackageOperation::Install { package } | PackageOperation::Update { package, .. } => {
- package
+ Some(package)
}
+ PackageOperation::MarkAliasInstalled { .. } => None,
}
}
}
+/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`.
+///
+/// For dev-stability versions backed by a git/hg source, append the reference
+/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return
+/// the pretty version unchanged.
+pub fn format_full_pretty_version(pkg: &LockedPackage) -> String {
+ format_full_pretty_with_pretty(&pkg.version, pkg)
+}
+
+/// Same as [`format_full_pretty_version`] but lets the caller supply an
+/// alternate pretty version (used by `MarkAliasInstalled` so the alias's
+/// `3.2.x-dev` text is rendered with the *target's* reference).
+pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String {
+ let is_dev = mozart_semver::Version::parse(&pkg.version)
+ .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch)
+ .unwrap_or(false);
+ if !is_dev {
+ return pretty_version.to_string();
+ }
+ let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref());
+ let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref());
+ let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str());
+ // Composer falls back to dist reference only when no source type is set
+ // (or the package isn't git/hg — in which case the dev display is skipped
+ // entirely above).
+ let reference = source_ref.or(match source_type {
+ Some("git") | Some("hg") => None,
+ _ => dist_ref,
+ });
+ let Some(reference) = reference else {
+ return pretty_version.to_string();
+ };
+ if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 {
+ format!("{} {}", pretty_version, &reference[..7])
+ } else if matches!(source_type, Some("svn")) {
+ // svn references are revision numbers, never truncated
+ format!("{} {}", pretty_version, reference)
+ } else if reference.len() == 40 {
+ // dist-ref fallback (no git/hg source) — Composer truncates here too
+ format!("{} {}", pretty_version, &reference[..7])
+ } else {
+ format!("{} {}", pretty_version, reference)
+ }
+}
+
/// Per-call configuration shared across executor methods. Owned by the
/// caller (typically `install_from_lock`) so the executor sees a consistent
/// view across an entire install/update run.
diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
index c924d73..44fceea 100644
--- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs
+++ b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
@@ -16,7 +16,10 @@
use mozart_semver::Version;
-use super::{ExecuteContext, InstallerExecutor, PackageOperation};
+use super::{
+ ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_version,
+ format_full_pretty_with_pretty,
+};
/// Recording-only executor. Construct with [`TraceRecorderExecutor::new`],
/// then read [`TraceRecorderExecutor::trace`] after the run completes.
@@ -58,8 +61,11 @@ impl InstallerExecutor for TraceRecorderExecutor {
) -> anyhow::Result<()> {
match op {
PackageOperation::Install { package } => {
- self.trace
- .push(format!("Installing {} ({})", package.name, package.version));
+ self.trace.push(format!(
+ "Installing {} ({})",
+ package.name,
+ format_full_pretty_version(package)
+ ));
}
PackageOperation::Update {
from_version,
@@ -72,7 +78,18 @@ impl InstallerExecutor for TraceRecorderExecutor {
};
self.trace.push(format!(
"{} {} ({} => {})",
- action, package.name, from_version, package.version
+ action,
+ package.name,
+ from_version,
+ format_full_pretty_version(package)
+ ));
+ }
+ PackageOperation::MarkAliasInstalled { alias, target } => {
+ let alias_full = format_full_pretty_with_pretty(&alias.alias, target);
+ let target_full = format_full_pretty_version(target);
+ self.trace.push(format!(
+ "Marking {} ({}) as installed, alias of {} ({})",
+ alias.package, alias_full, alias.package, target_full
));
}
}
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
index e60d7b0..17837cd 100644
--- a/crates/mozart-registry/src/lib.rs
+++ b/crates/mozart-registry/src/lib.rs
@@ -1,4 +1,5 @@
pub mod cache;
+pub mod composer_repo;
pub mod downloader;
pub mod inline_package;
pub mod installed;
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
index 075848f..e422a9a 100644
--- a/crates/mozart-registry/src/lockfile.rs
+++ b/crates/mozart-registry/src/lockfile.rs
@@ -372,6 +372,20 @@ impl LockFileGenerationRequest {
.find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized)
.map(|ipkg| ipkg.version)
}
+
+ /// Look up a `type: composer` repository entry for `name@version_normalized`.
+ /// Used to short-circuit the Packagist fetch when the resolved package came
+ /// from a local Composer repo (the test fixtures' file:// case).
+ fn composer_repo_lookup(
+ &self,
+ name: &str,
+ version_normalized: &str,
+ ) -> Option<PackagistVersion> {
+ crate::composer_repo::collect_composer_packages(&self.composer_json.repositories)
+ .into_iter()
+ .find(|cpkg| cpkg.name == name && cpkg.version.version_normalized == version_normalized)
+ .map(|cpkg| cpkg.version)
+ }
}
/// Convert a `PackagistSource` to a `LockedSource`.
@@ -523,7 +537,15 @@ fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> ser
/// 3. Computes the content-hash
/// 4. Assembles the complete `LockFile` struct
pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
- // 1. Fetch full metadata for all resolved packages.
+ // Split the resolved set into real packages and alias entries up front.
+ // Aliases get emitted as a separate `aliases[]` block and never enter the
+ // metadata fetch loop — their target package carries the real metadata.
+ let (real_resolved, alias_resolved): (Vec<&ResolvedPackage>, Vec<&ResolvedPackage>) = request
+ .resolved_packages
+ .iter()
+ .partition(|p| p.alias_of_normalized.is_none());
+
+ // 1. Fetch full metadata for real (non-alias) packages.
//
// Inline `type: package` repositories carry full metadata in composer.json
// — short-circuit those before hitting the network. Everything else goes
@@ -531,12 +553,17 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
// steps will move VCS / inline through the same set.
let mut package_metadata: HashMap<String, PackagistVersion> = HashMap::new();
let repo_set = &request.repositories;
- for pkg in &request.resolved_packages {
+ for pkg in &real_resolved {
if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) {
package_metadata.insert(pkg.name.clone(), inline);
continue;
}
+ if let Some(cv) = request.composer_repo_lookup(&pkg.name, &pkg.version_normalized) {
+ package_metadata.insert(pkg.name.clone(), cv);
+ continue;
+ }
+
let queries = [crate::repository::PackageQuery {
name: pkg.name.as_str(),
constraint: None,
@@ -555,9 +582,19 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
package_metadata.insert(pkg.name.clone(), matching.version);
}
- // 2. Classify dev vs non-dev packages
+ // 2. Classify dev vs non-dev packages (real packages only).
+ let real_owned: Vec<ResolvedPackage> = real_resolved
+ .iter()
+ .map(|p| ResolvedPackage {
+ name: p.name.clone(),
+ version: p.version.clone(),
+ version_normalized: p.version_normalized.clone(),
+ is_dev: p.is_dev,
+ alias_of_normalized: None,
+ })
+ .collect();
let dev_only = classify_dev_packages(
- &request.resolved_packages,
+ &real_owned,
&request.composer_json.require,
&request.composer_json.require_dev,
&package_metadata,
@@ -566,7 +603,7 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
// 3. Build LockedPackage lists
let mut packages: Vec<LockedPackage> = Vec::new();
let mut packages_dev: Vec<LockedPackage> = Vec::new();
- for pkg in &request.resolved_packages {
+ for pkg in &real_resolved {
let pv = &package_metadata[&pkg.name];
let locked = packagist_version_to_locked_package(&pkg.name, pv);
if dev_only.contains(&pkg.name) {
@@ -580,14 +617,38 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
packages.sort_by(|a, b| a.name.cmp(&b.name));
packages_dev.sort_by(|a, b| a.name.cmp(&b.name));
- // 5. Compute content-hash
+ // 5. Build the aliases[] block. Each alias entry references the target
+ // package (`package` + `version`) and carries the alias's pretty/normalized
+ // form (`alias` + `alias_normalized`). Mirrors Composer's
+ // `Locker::lockPackages` alias dump.
+ let mut alias_blocks: Vec<LockAlias> = Vec::new();
+ for alias in &alias_resolved {
+ let target_normalized = match &alias.alias_of_normalized {
+ Some(t) => t.clone(),
+ None => continue,
+ };
+ let target_pretty = real_resolved
+ .iter()
+ .find(|p| p.name == alias.name && p.version_normalized == target_normalized)
+ .map(|p| p.version.clone())
+ .unwrap_or_else(|| target_normalized.clone());
+ alias_blocks.push(LockAlias {
+ package: alias.name.clone(),
+ version: target_pretty,
+ alias: alias.version.clone(),
+ alias_normalized: alias.version_normalized.clone(),
+ });
+ }
+ alias_blocks.sort_by(|a, b| a.package.cmp(&b.package).then(a.alias.cmp(&b.alias)));
+
+ // 6. Compute content-hash
let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?;
- // 6. Extract platform requirements
+ // 7. Extract platform requirements
let platform = extract_platform_requirements(&request.composer_json.require);
let platform_dev = extract_platform_requirements(&request.composer_json.require_dev);
- // 7. Determine minimum-stability and prefer-stable
+ // 8. Determine minimum-stability and prefer-stable
let minimum_stability = request
.composer_json
.minimum_stability
@@ -601,7 +662,7 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
.and_then(|v| v.as_bool())
.unwrap_or(false);
- // 8. Assemble LockFile
+ // 9. Assemble LockFile
Ok(LockFile {
readme: LockFile::default_readme(),
content_hash,
@@ -611,7 +672,7 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
} else {
Some(vec![])
},
- aliases: vec![],
+ aliases: alias_blocks,
minimum_stability,
stability_flags: serde_json::json!({}),
prefer_stable,
@@ -904,24 +965,28 @@ mod tests {
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
ResolvedPackage {
name: "vendor/b".to_string(),
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
ResolvedPackage {
name: "vendor/c".to_string(),
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
ResolvedPackage {
name: "vendor/d".to_string(),
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
];
@@ -983,18 +1048,21 @@ mod tests {
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
ResolvedPackage {
name: "vendor/b".to_string(),
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
ResolvedPackage {
name: "vendor/c".to_string(),
version: "1.0.0".to_string(),
version_normalized: "1.0.0.0".to_string(),
is_dev: false,
+ alias_of_normalized: None,
},
];
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
index 336d6d7..4b8266d 100644
--- a/crates/mozart-registry/src/resolver.rs
+++ b/crates/mozart-registry/src/resolver.rs
@@ -21,6 +21,37 @@ use mozart_semver::Version;
// Version helpers
// ─────────────────────────────────────────────────────────────────────────────
+/// Strip a `@stability` suffix from a constraint string and return the
+/// cleaned constraint plus the parsed stability. Mirrors Composer's
+/// `RootPackageLoader::extractStabilityFlags` (single-constraint case):
+/// `"3.2.*@dev"` → (`"3.2.*"`, `Some(Stability::Dev)`).
+pub(crate) fn extract_stability_suffix(constraint: &str) -> (String, Option<Stability>) {
+ let trimmed = constraint.trim();
+ if let Some(at_pos) = trimmed.rfind('@') {
+ let suffix = &trimmed[at_pos + 1..];
+ let stability = match suffix.to_lowercase().as_str() {
+ "dev" => Some(Stability::Dev),
+ "alpha" => Some(Stability::Alpha),
+ "beta" => Some(Stability::Beta),
+ "rc" => Some(Stability::RC),
+ "stable" => Some(Stability::Stable),
+ _ => None,
+ };
+ if let Some(s) = stability {
+ let cleaned = trimmed[..at_pos].trim().to_string();
+ // An empty constraint left after the strip means "any version" —
+ // mirrors Composer's `@dev` shorthand (no version constraint).
+ let cleaned = if cleaned.is_empty() {
+ "*".to_string()
+ } else {
+ cleaned
+ };
+ return (cleaned, Some(s));
+ }
+ }
+ (trimmed.to_string(), None)
+}
+
/// Determine the `Stability` of a `Version` from its pre_release string.
pub(crate) fn version_stability(v: &Version) -> Stability {
match &v.pre_release {
@@ -88,6 +119,49 @@ fn parse_branch_alias_target(alias_target: &str) -> Option<Version> {
})
}
+/// 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
+/// branch (i.e. cannot be expanded to a four-segment numeric version).
+///
+/// Composer's flow for an `extra.branch-alias` value:
+/// 1. Strip the trailing `-dev`.
+/// 2. Pad missing segments with `.x`.
+/// 3. Replace each `x` with `9999999`.
+/// 4. Re-append `-dev`.
+///
+/// This is the form Composer's `Locker::lockPackages` writes into the
+/// `aliases` block of `composer.lock` and the form `Pool` indexes for
+/// constraint matching, so Mozart needs to use it too.
+pub(crate) fn normalize_branch_alias_target(alias_target: &str) -> Option<String> {
+ let trimmed = alias_target.trim();
+ let lower = trimmed.to_lowercase();
+ let base = lower.strip_suffix("-dev")?;
+ // Strip leading v/V before normalizing, mirroring Composer's regex
+ let base = base.strip_prefix('v').unwrap_or(base);
+ let mut segments: Vec<String> = Vec::with_capacity(4);
+ for seg in base.split('.') {
+ if seg == "x" || seg == "X" || seg == "*" {
+ segments.push("x".to_string());
+ } else if seg.chars().all(|c| c.is_ascii_digit()) && !seg.is_empty() {
+ segments.push(seg.to_string());
+ } else {
+ return None;
+ }
+ }
+ if segments.is_empty() {
+ return None;
+ }
+ while segments.len() < 4 {
+ segments.push("x".to_string());
+ }
+ let expanded: Vec<String> = segments
+ .into_iter()
+ .map(|s| if s == "x" { "9999999".to_string() } else { s })
+ .collect();
+ Some(format!("{}-dev", expanded.join(".")))
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// PackageName
// ─────────────────────────────────────────────────────────────────────────────
@@ -251,7 +325,10 @@ fn packagist_to_pool_inputs(
) -> Vec<PoolPackageInput> {
let mut results = Vec::new();
- let make_input = |version_str: &str, version_normalized: &str| -> PoolPackageInput {
+ let make_input = |version_str: &str,
+ version_normalized: &str,
+ is_alias_of: Option<String>|
+ -> PoolPackageInput {
PoolPackageInput {
name: package_name.to_string(),
version: version_normalized.to_string(),
@@ -285,32 +362,57 @@ fn packagist_to_pool_inputs(
.collect::<Vec<_>>(),
),
is_fixed: false,
+ is_alias_of,
}
};
match parse_normalized(&pv.version_normalized) {
Some(v) => {
if passes_stability_filter(package_name, &v, minimum_stability, stability_flags) {
- results.push(make_input(&pv.version, &pv.version_normalized));
+ results.push(make_input(&pv.version, &pv.version_normalized, None));
}
}
None => {
- // Dev branch — check for branch aliases
+ // Dev branch — emit the original entry (so the alias has a target
+ // to point at) and one alias entry per matching `extra.branch-alias`.
+ // Mirrors Composer's `ArrayRepository::addPackage` which adds the
+ // base package and then calls `createAliasPackage` for each
+ // branch-alias declaration on it.
+ let original_passes = passes_stability_filter(
+ package_name,
+ &Version {
+ major: 0,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ pre_release: Some("dev".to_string()),
+ is_dev_branch: true,
+ dev_branch_name: None,
+ },
+ minimum_stability,
+ stability_flags,
+ );
+ if !original_passes {
+ return results;
+ }
+ results.push(make_input(&pv.version, &pv.version_normalized, None));
+
let aliases = pv.branch_aliases();
for (branch, alias_target) in &aliases {
if branch.to_lowercase() != pv.version.to_lowercase() {
continue;
}
- if let Some(alias_v) = parse_branch_alias_target(alias_target)
- && passes_stability_filter(
- package_name,
- &alias_v,
- minimum_stability,
- stability_flags,
- )
- {
- results.push(make_input(&pv.version, alias_target));
+ if parse_branch_alias_target(alias_target).is_none() {
+ continue;
}
+ let Some(alias_normalized) = normalize_branch_alias_target(alias_target) else {
+ continue;
+ };
+ results.push(make_input(
+ alias_target,
+ &alias_normalized,
+ Some(pv.version_normalized.clone()),
+ ));
}
}
}
@@ -372,6 +474,12 @@ pub struct ResolvedPackage {
pub version_normalized: String,
/// True if the resolved version is a dev/pre-release version.
pub is_dev: bool,
+ /// When `Some`, this entry is an `AliasPackage` rather than a real
+ /// install target. The value is the target's normalized version, used
+ /// by lock-file generation to populate the `aliases[]` block (and by
+ /// the installer to emit `Marking ... as installed, alias of ...`
+ /// trace lines). Real packages have `alias_of: None`.
+ pub alias_of_normalized: Option<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -385,6 +493,23 @@ pub struct ResolvedPackage {
pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, ResolveError> {
// 1. Build root requirements
let mut root_requires: HashMap<String, Option<String>> = HashMap::new();
+ // Per-package stability overrides extracted from `@dev`/`@beta`/etc.
+ // suffixes on root constraints. Mirrors Composer's
+ // `RootPackageLoader::extractStabilityFlags`. Merged on top of the
+ // request's caller-supplied flags (which today are usually empty).
+ let mut stability_flags: HashMap<String, Stability> = request.stability_flags.clone();
+
+ let mut insert_root_require = |name: &str, constraint: &str| {
+ let (clean, stability) = extract_stability_suffix(constraint);
+ let lower = name.to_lowercase();
+ if let Some(s) = stability {
+ let entry = stability_flags.entry(lower.clone()).or_insert(s);
+ if (*entry as u8) > (s as u8) {
+ *entry = s;
+ }
+ }
+ root_requires.insert(lower, Some(clean));
+ };
for (name, constraint) in &request.require {
if should_skip_platform_dep(
@@ -394,7 +519,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
) {
continue;
}
- root_requires.insert(name.to_lowercase(), Some(constraint.clone()));
+ insert_root_require(name, constraint);
}
if request.include_dev {
@@ -406,14 +531,14 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
) {
continue;
}
- root_requires.insert(name.to_lowercase(), Some(constraint.clone()));
+ insert_root_require(name, constraint);
}
}
// Apply temporary constraints (from --with flag or inline shorthand).
// These override existing root constraints or add new ones for transitive deps.
for (name, constraint) in &request.temporary_constraints {
- root_requires.insert(name.clone(), Some(constraint.clone()));
+ insert_root_require(name, constraint);
}
// 2. Build pool, generate rules, and solve
@@ -447,6 +572,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
provides: vec![],
conflicts: vec![],
is_fixed: true,
+ is_alias_of: None,
};
builder.add_package(input);
}
@@ -460,11 +586,8 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
// Add VCS packages to the pool
for vpkg in &vcs_packages {
- let inputs = vcs_bridge::vcs_to_pool_inputs(
- vpkg,
- request.minimum_stability,
- &request.stability_flags,
- );
+ let inputs =
+ vcs_bridge::vcs_to_pool_inputs(vpkg, request.minimum_stability, &stability_flags);
for input in inputs {
builder.add_package(input);
}
@@ -481,7 +604,28 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
&ipkg.name,
&ipkg.version,
request.minimum_stability,
- &request.stability_flags,
+ &stability_flags,
+ );
+ for input in inputs {
+ builder.add_package(input);
+ }
+ }
+
+ // Collect packages from `type: composer` repositories with file:// URLs.
+ // The harness rewrites `file://foobar` to `file:///abs/path` before this
+ // call so the read can be a plain `std::fs::read_to_string`. Same idea
+ // as inline packages — they bypass the RepositorySet and go straight
+ // into the pool, with names recorded so Packagist loops skip them.
+ let composer_repo_packages =
+ crate::composer_repo::collect_composer_packages(&request.raw_repositories);
+ let mut composer_repo_names: HashSet<String> = HashSet::new();
+ for cpkg in &composer_repo_packages {
+ composer_repo_names.insert(cpkg.name.clone());
+ let inputs = packagist_to_pool_inputs(
+ &cpkg.name,
+ &cpkg.version,
+ request.minimum_stability,
+ &stability_flags,
);
for input in inputs {
builder.add_package(input);
@@ -499,7 +643,11 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
let seed_names: Vec<String> = root_requires
.keys()
.filter(|name| !PackageName((*name).clone()).is_platform())
- .filter(|name| !vcs_package_names.contains(*name) && !inline_package_names.contains(*name))
+ .filter(|name| {
+ !vcs_package_names.contains(*name)
+ && !inline_package_names.contains(*name)
+ && !composer_repo_names.contains(*name)
+ })
.cloned()
.collect();
let seed_queries: Vec<PackageQuery<'_>> = seed_names
@@ -518,7 +666,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
&r.name,
&r.version,
request.minimum_stability,
- &request.stability_flags,
+ &stability_flags,
);
for input in inputs {
builder.add_package(input);
@@ -532,7 +680,10 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
}
// Skip packages already provided by VCS or inline-package repositories
- if vcs_package_names.contains(&name) || inline_package_names.contains(&name) {
+ if vcs_package_names.contains(&name)
+ || inline_package_names.contains(&name)
+ || composer_repo_names.contains(&name)
+ {
continue;
}
@@ -601,11 +752,16 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
false
};
+ let alias_of_normalized = pkg
+ .is_alias_of
+ .map(|tid| pool.package_by_id(tid).version.clone());
+
resolved.push(ResolvedPackage {
name: pkg.name.clone(),
version: pkg.pretty_version.clone(),
version_normalized: pkg.version.clone(),
is_dev,
+ alias_of_normalized,
});
}
Ok(resolved)
@@ -950,6 +1106,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
},
PoolPackageInput {
name: "bar/bar".to_string(),
@@ -960,6 +1117,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
},
],
vec![],
diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs
index be61845..cb8aad5 100644
--- a/crates/mozart-registry/src/vcs_bridge.rs
+++ b/crates/mozart-registry/src/vcs_bridge.rs
@@ -100,6 +100,7 @@ pub fn vcs_to_pool_inputs(
.collect::<Vec<_>>(),
),
is_fixed: false,
+ is_alias_of: None,
};
// Apply stability filtering
diff --git a/crates/mozart-sat-resolver/src/policy.rs b/crates/mozart-sat-resolver/src/policy.rs
index aa63be7..a66719f 100644
--- a/crates/mozart-sat-resolver/src/policy.rs
+++ b/crates/mozart-sat-resolver/src/policy.rs
@@ -64,8 +64,20 @@ impl DefaultPolicy {
let pkg_a = pool.literal_to_package(a);
let pkg_b = pool.literal_to_package(b);
- // If same name, prefer higher version (or lower if prefer_lowest)
+ // If same name, apply Composer's policy ordering. Mirrors
+ // `DefaultPolicy::versionCompare`: when `prefer_stable` is on and
+ // the two candidates have different stabilities, the more-stable
+ // one wins outright — `prefer_lowest` only kicks in within the same
+ // stability tier. Otherwise sort by version (asc for prefer_lowest,
+ // desc otherwise).
if pkg_a.name == pkg_b.name {
+ if self.prefer_stable {
+ let stab_a = stability_priority(&pkg_a.version);
+ let stab_b = stability_priority(&pkg_b.version);
+ if stab_a != stab_b {
+ return stab_a.cmp(&stab_b);
+ }
+ }
let cmp = self.compare_versions(&pkg_a.version, &pkg_b.version);
return if self.prefer_lowest {
cmp
@@ -111,6 +123,37 @@ impl Default for DefaultPolicy {
}
}
+/// Map a normalized version string to Composer's stability priority
+/// (`BasePackage::STABILITIES`). Lower = more stable. Stable=0, RC=5, beta=10,
+/// alpha=15, dev=20. Mirrors `DefaultPolicy::versionCompare`'s comparison
+/// when `prefer_stable` is set.
+fn stability_priority(version: &str) -> u8 {
+ let Ok(v) = mozart_semver::Version::parse(version) else {
+ return 0;
+ };
+ if v.is_dev_branch {
+ return 20;
+ }
+ match v.pre_release.as_deref() {
+ None => 0,
+ Some(pre) => {
+ let lower = pre.to_lowercase();
+ if lower.starts_with("dev") {
+ 20
+ } else if lower.starts_with("alpha") || lower == "a" {
+ 15
+ } else if lower.starts_with("beta") || lower == "b" {
+ 10
+ } else if lower.starts_with("rc") {
+ 5
+ } else {
+ // patch/pl/p / unknown → stable
+ 0
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -126,6 +169,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
diff --git a/crates/mozart-sat-resolver/src/pool.rs b/crates/mozart-sat-resolver/src/pool.rs
index 652fc60..0312c24 100644
--- a/crates/mozart-sat-resolver/src/pool.rs
+++ b/crates/mozart-sat-resolver/src/pool.rs
@@ -53,6 +53,13 @@ pub struct PoolPackage {
pub conflicts: Vec<PoolLink>,
/// Whether this is a fixed/locked package.
pub is_fixed: bool,
+ /// If `Some`, this package is an `AliasPackage` whose target is the
+ /// other pool entry with the given ID. Composer creates these for
+ /// `extra.branch-alias` entries (dev branch → numeric alias). When set,
+ /// the rule generator emits `PackageAlias`/`PackageInverseAlias` rules
+ /// instead of regular requires; same-name conflict rules also skip
+ /// alias packages.
+ pub is_alias_of: Option<PackageId>,
}
impl PoolPackage {
@@ -99,6 +106,12 @@ pub struct PoolPackageInput {
pub provides: Vec<PoolLink>,
pub conflicts: Vec<PoolLink>,
pub is_fixed: bool,
+ /// When `Some`, the value is the **normalized** version of another input
+ /// in this build batch with the same `name`; the pool will resolve it to
+ /// that input's [`PackageId`] in [`PoolPackage::is_alias_of`]. Used by
+ /// the registry layer to materialize Composer's `AliasPackage` for
+ /// `extra.branch-alias` entries.
+ pub is_alias_of: Option<String>,
}
/// The package pool: contains all candidate packages for dependency resolution.
@@ -119,11 +132,17 @@ pub struct Pool {
impl Pool {
/// Create a new pool from a list of package inputs.
pub fn new(inputs: Vec<PoolPackageInput>, unacceptable_fixed_ids: Vec<PackageId>) -> Self {
- let mut packages = Vec::with_capacity(inputs.len());
+ let mut packages: Vec<PoolPackage> = Vec::with_capacity(inputs.len());
let mut package_by_name: HashMap<String, Vec<PackageId>> = HashMap::new();
+ // Collect alias links (alias_idx, target_name, target_normalized) for
+ // a second pass once every input has a stable ID.
+ let mut pending_aliases: Vec<(usize, String, String)> = Vec::new();
for (idx, input) in inputs.into_iter().enumerate() {
let id = (idx as PackageId) + 1;
+ if let Some(target) = input.is_alias_of.clone() {
+ pending_aliases.push((idx, input.name.clone(), target));
+ }
let pkg = PoolPackage {
id,
name: input.name,
@@ -134,6 +153,7 @@ impl Pool {
provides: input.provides,
conflicts: input.conflicts,
is_fixed: input.is_fixed,
+ is_alias_of: None,
};
// Index by all names this package provides
@@ -147,6 +167,25 @@ impl Pool {
packages.push(pkg);
}
+ // Resolve alias targets: for each alias input, find the matching
+ // (name, normalized version) entry and store its ID. Mirrors the
+ // post-construction wiring Composer does in
+ // `RepositorySet::createAliasPackage` / `addPackage`.
+ for (alias_idx, name, target_normalized) in pending_aliases {
+ if let Some(ids) = package_by_name.get(&name) {
+ let target_id = ids.iter().copied().find(|&id| {
+ let candidate = &packages[(id - 1) as usize];
+ !candidate.name.is_empty()
+ && candidate.name == name
+ && candidate.version == target_normalized
+ && candidate.is_alias_of.is_none()
+ });
+ if let Some(tid) = target_id {
+ packages[alias_idx].is_alias_of = Some(tid);
+ }
+ }
+ }
+
Pool {
packages,
package_by_name,
@@ -317,6 +356,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
diff --git a/crates/mozart-sat-resolver/src/pool_builder.rs b/crates/mozart-sat-resolver/src/pool_builder.rs
index 544cac3..94bbf4c 100644
--- a/crates/mozart-sat-resolver/src/pool_builder.rs
+++ b/crates/mozart-sat-resolver/src/pool_builder.rs
@@ -151,6 +151,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
});
// Should have b/b pending
@@ -166,6 +167,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
});
// No more pending
@@ -188,6 +190,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
};
assert!(builder.add_package(input.clone()));
diff --git a/crates/mozart-sat-resolver/src/problem.rs b/crates/mozart-sat-resolver/src/problem.rs
index 7ba60bc..c453fa9 100644
--- a/crates/mozart-sat-resolver/src/problem.rs
+++ b/crates/mozart-sat-resolver/src/problem.rs
@@ -415,6 +415,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
diff --git a/crates/mozart-sat-resolver/src/rule_set_generator.rs b/crates/mozart-sat-resolver/src/rule_set_generator.rs
index 83570d5..b5dfcdb 100644
--- a/crates/mozart-sat-resolver/src/rule_set_generator.rs
+++ b/crates/mozart-sat-resolver/src/rule_set_generator.rs
@@ -128,6 +128,37 @@ impl<'a> RuleSetGenerator<'a> {
let conflict_names: Vec<String> =
pkg.conflict_names().into_iter().map(String::from).collect();
let requires = pkg.requires.clone();
+ let alias_target = pkg.is_alias_of;
+
+ if let Some(target_id) = alias_target {
+ // Mirror Composer's RuleSetGenerator::addRulesForPackage alias
+ // branch: enqueue the target, emit `(-alias | target)` so the
+ // alias forces the target, and `(-target | alias)` so the
+ // target forces the alias (they install together). The alias
+ // is NOT indexed under its name for same-name conflicts —
+ // Composer skips that for aliases too.
+ work_queue.push_back(target_id);
+
+ let alias_rule = Rule::two_literals(
+ -(current_id as Literal),
+ target_id as Literal,
+ RuleReason::PackageAlias,
+ ReasonData::AliasPackage(current_id),
+ );
+ self.rules.add(alias_rule, RuleType::Package);
+
+ let inverse_rule = Rule::two_literals(
+ -(target_id as Literal),
+ current_id as Literal,
+ RuleReason::PackageInverseAlias,
+ ReasonData::AliasPackage(current_id),
+ );
+ self.rules.add(inverse_rule, RuleType::Package);
+
+ // The aliased target carries the actual requires; skip
+ // alias's own (link-rewritten copy) to avoid duplicates.
+ continue;
+ }
// Index by every name this package fully claims (own name +
// `replace` targets). Same-name conflict rules (below) then
@@ -135,7 +166,8 @@ impl<'a> RuleSetGenerator<'a> {
// identity. Mirrors `BasePackage::getNames(false)` indexing in
// Composer's RuleSetGenerator::addRulesForPackage — `provide`
// targets are intentionally omitted so that providers can
- // coexist with the package they provide.
+ // coexist with the package they provide. Alias packages are
+ // skipped because the target package's name already covers them.
for name in conflict_names {
self.added_packages_by_name
.entry(name)
@@ -270,6 +302,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
@@ -313,6 +346,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
},
make_input("b/b", "1.0.0.0"),
],
diff --git a/crates/mozart-sat-resolver/src/solver.rs b/crates/mozart-sat-resolver/src/solver.rs
index 7ade361..49a4ce4 100644
--- a/crates/mozart-sat-resolver/src/solver.rs
+++ b/crates/mozart-sat-resolver/src/solver.rs
@@ -827,6 +827,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
diff --git a/crates/mozart-sat-resolver/src/transaction.rs b/crates/mozart-sat-resolver/src/transaction.rs
index a325601..176b862 100644
--- a/crates/mozart-sat-resolver/src/transaction.rs
+++ b/crates/mozart-sat-resolver/src/transaction.rs
@@ -364,6 +364,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
@@ -391,6 +392,7 @@ mod tests {
provides: vec![],
conflicts: vec![],
is_fixed: false,
+ is_alias_of: None,
}
}
diff --git a/crates/mozart-semver/src/lib.rs b/crates/mozart-semver/src/lib.rs
index 8b77300..dfb0db2 100644
--- a/crates/mozart-semver/src/lib.rs
+++ b/crates/mozart-semver/src/lib.rs
@@ -175,16 +175,14 @@ impl Version {
});
}
- // Handle *-dev suffix (e.g., "2.1.x-dev" or "2.x-dev")
+ // Handle wildcard branch versions like "2.x-dev" / "2.1.x-dev". A
+ // pure-numeric `-dev` (e.g. `1.0.0-dev` or `3.2.9999999.9999999-dev`,
+ // the form Composer's `normalizeBranch` emits for `3.2.x`) is NOT a
+ // branch — it falls through to classical parsing where `-dev` is just
+ // a regular pre-release stability and `is_dev_branch` stays false.
let s_lower = s.to_lowercase();
- if s_lower.ends_with("-dev") || s_lower.ends_with(".x-dev") {
- let base = if s_lower.ends_with("-dev") {
- &s[..s.len() - 4]
- } else {
- s
- };
- // Replace any trailing .x with nothing, parse numeric parts
- let base = base.trim_end_matches(".x").trim_end_matches("-dev");
+ if s_lower.ends_with(".x-dev") {
+ let base = &s[..s.len() - ".x-dev".len()];
let parts: Vec<&str> = base.split('.').collect();
let major = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
@@ -1340,11 +1338,14 @@ mod tests {
#[test]
fn test_parse_numeric_dev_suffix() {
- // "2.1-dev" — ends with -dev, treated as *-dev suffix branch
+ // Pure-numeric `-dev` (no `.x`) — a regular dev-stability version,
+ // not a wildcard branch. Mirrors the form Composer's `normalizeBranch`
+ // emits for branch aliases like `3.2.x` → `3.2.9999999.9999999-dev`.
let v = Version::parse("2.1-dev").unwrap();
- assert!(v.is_dev_branch);
+ assert!(!v.is_dev_branch);
assert_eq!(v.major, 2);
assert_eq!(v.minor, 1);
+ assert_eq!(v.pre_release.as_deref(), Some("dev"));
}
#[test]
@@ -1447,15 +1448,13 @@ mod tests {
#[test]
fn test_ordering_dev_branch_lt_dev_prerelease() {
- // "1.0.0-dev" ends with "-dev", so the parser treats it as a *-dev suffix branch
- // (is_dev_branch=true, dev_branch_name=None, major=1, minor=0, patch=9999999).
- // "dev-master" is also is_dev_branch=true with dev_branch_name=Some("master").
- // When both are dev branches, they compare by dev_branch_name:
- // Some("master") vs None → Some > None, so dev-master > 1.0.0-dev (x-dev form).
+ // "dev-master" is a named dev branch (is_dev_branch=true), which sorts
+ // below every non-branch version. "1.0.0-dev" is a regular numeric
+ // version with `dev` stability — same form Composer's `normalizeBranch`
+ // produces for branch aliases like `3.2.x` → `3.2.9999999.9999999-dev`.
let dev_branch = Version::parse("dev-master").unwrap();
let dev_prerelease = Version::parse("1.0.0-dev").unwrap();
- // Both are dev branches; "master" branch name > None → dev-master is Greater
- assert!(dev_branch > dev_prerelease);
+ assert!(dev_branch < dev_prerelease);
}
#[test]
@@ -1535,23 +1534,18 @@ mod tests {
#[test]
fn test_ordering_comprehensive_chain() {
- // Note: "1.0.0-dev" is parsed as a *-dev suffix branch (is_dev_branch=true,
- // dev_branch_name=None) due to the "-dev" suffix rule in Version::parse.
- // "dev-foo" is also a dev branch (is_dev_branch=true, dev_branch_name=Some("foo")).
- // Comparing two dev branches uses dev_branch_name: None < Some("foo"), so
- // the *-dev form (None) < "dev-foo" (Some("foo")).
- // For "1.0.0-alpha1", "1.0.0-beta1", "1.0.0-RC1", "1.0.0": normal numeric ordering.
- let dev_x_dev = Version::parse("1.0.0-dev").unwrap(); // *-dev branch, name=None
- let dev_branch = Version::parse("dev-foo").unwrap(); // named branch, name=Some("foo")
+ // "dev-foo" is a named branch (is_dev_branch=true) — sorts below every
+ // non-branch. "1.0.0-dev" is a regular numeric `dev`-stability version,
+ // which sorts below alpha/beta/rc/stable but above named branches.
+ let dev_branch = Version::parse("dev-foo").unwrap();
+ let dev_prerelease = Version::parse("1.0.0-dev").unwrap();
let alpha = Version::parse("1.0.0-alpha1").unwrap();
let beta = Version::parse("1.0.0-beta1").unwrap();
let rc = Version::parse("1.0.0-RC1").unwrap();
let stable = Version::parse("1.0.0").unwrap();
- // Both dev branches; dev_branch_name None < Some("foo")
- assert!(dev_x_dev < dev_branch);
- // dev_branch (is_dev_branch=true) < alpha (is_dev_branch=false)
- assert!(dev_branch < alpha);
+ assert!(dev_branch < dev_prerelease);
+ assert!(dev_prerelease < alpha);
assert!(alpha < beta);
assert!(beta < rc);
assert!(rc < stable);
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index 356d622..9555ba7 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -670,6 +670,22 @@ pub async fn install_from_lock(
}
};
executor.install_package(op, &exec_ctx).await?;
+
+ // After the target install/update, emit MarkAliasInstalled for any
+ // aliases whose `package`+`version` (the target's pretty version)
+ // match. Mirrors Composer's `Transaction::calculateOperations` DFS
+ // which pushes alias targets first and emits MarkAliasInstalled
+ // when the alias itself is processed.
+ for alias in &lock.aliases {
+ if alias.package.eq_ignore_ascii_case(&pkg.name) && alias.version == pkg.version {
+ executor
+ .install_package(
+ PackageOperation::MarkAliasInstalled { alias, target: pkg },
+ &exec_ctx,
+ )
+ .await?;
+ }
+ }
}
// Step 8: Write updated vendor/composer/installed.json (unless download_only)
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index 9ac2664..847ccf7 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -1370,6 +1370,7 @@ mod tests {
version: version.to_string(),
version_normalized: format!("{}.0", version),
is_dev: false,
+ alias_of_normalized: None,
}
}
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index 173418a..d674485 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -25,6 +25,47 @@ fn fixtures_dir() -> PathBuf {
.join("../../composer/tests/Composer/Test/Fixtures/installer")
}
+/// Rewrite `file://foobar` URLs in COMPOSER content to absolute fixture
+/// paths. Mirrors `composer/tests/Composer/Test/InstallerTest.php:540-542`:
+/// when a fixture's repository entry uses a relative `file://` URL, anchor
+/// it to the fixtures directory so the on-disk `packages.json` is reachable.
+fn rewrite_fixture_file_urls(input: &str) -> String {
+ let fixtures = fixtures_dir();
+ let canonical = fixtures
+ .canonicalize()
+ .unwrap_or(fixtures)
+ .display()
+ .to_string()
+ .replace('\\', "/");
+ // Match `"file://X"` where X does not start with `/` — those are the
+ // fixture-relative form. Absolute URLs (`file:///abs/...`) are passed
+ // through.
+ let mut out = String::with_capacity(input.len());
+ let mut rest = input;
+ while let Some(idx) = rest.find("file://") {
+ out.push_str(&rest[..idx]);
+ let after = &rest[idx + "file://".len()..];
+ let first_byte = after.as_bytes().first().copied();
+ if first_byte == Some(b'/') {
+ out.push_str("file://");
+ rest = after;
+ continue;
+ }
+ // Read the rest of the URL until a `"` or whitespace.
+ let end = after
+ .find(|c: char| c == '"' || c.is_whitespace())
+ .unwrap_or(after.len());
+ let target = &after[..end];
+ out.push_str("file://");
+ out.push_str(&canonical);
+ out.push('/');
+ out.push_str(target);
+ rest = &after[end..];
+ }
+ out.push_str(rest);
+ out
+}
+
struct InProcessRunResult {
_working_dir: TempDir,
trace: Vec<String>,
@@ -37,7 +78,8 @@ async fn run_fixture_in_process(test: &ParsedTest) -> anyhow::Result<InProcessRu
let working_dir = TempDir::new()?;
let root = working_dir.path();
- std::fs::write(root.join("composer.json"), &test.composer)?;
+ let composer_json = rewrite_fixture_file_urls(&test.composer);
+ std::fs::write(root.join("composer.json"), &composer_json)?;
if let Some(lock) = &test.lock {
std::fs::write(root.join("composer.lock"), lock)?;
}
@@ -178,8 +220,8 @@ installer_fixture!(alias_in_complex_constraints, ignore);
installer_fixture!(alias_in_lock, ignore);
installer_fixture!(alias_in_lock2, ignore);
installer_fixture!(alias_on_unloadable_package, ignore);
-installer_fixture!(alias_solver_problems, ignore);
-installer_fixture!(alias_solver_problems2, ignore);
+installer_fixture!(alias_solver_problems);
+installer_fixture!(alias_solver_problems2);
installer_fixture!(alias_with_reference, ignore);
installer_fixture!(aliased_priority, ignore);
installer_fixture!(aliased_priority_conflicting, ignore);
@@ -203,10 +245,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_all_dependencies_option_dont_recommend_to_use_it,
- ignore
-);
+installer_fixture!(conflict_with_all_dependencies_option_dont_recommend_to_use_it);
installer_fixture!(deduplicate_solver_problems);
installer_fixture!(disjunctive_multi_constraints);
installer_fixture!(full_update_minimal_changes, ignore);
@@ -220,7 +259,7 @@ installer_fixture!(github_issues_9012, ignore);
installer_fixture!(github_issues_9290, ignore);
installer_fixture!(hint_main_rename, ignore);
installer_fixture!(install_aliased_alias, ignore);
-installer_fixture!(install_branch_alias_composer_repo, ignore);
+installer_fixture!(install_branch_alias_composer_repo);
installer_fixture!(install_dev);
installer_fixture!(install_dev_using_dist, ignore);
installer_fixture!(install_forces_reinstall_if_abandon_changes, ignore);
@@ -320,14 +359,8 @@ installer_fixture!(suggest_prod);
installer_fixture!(suggest_prod_nolock);
installer_fixture!(suggest_replaced);
installer_fixture!(suggest_uninstalled);
-installer_fixture!(
- unbounded_conflict_does_not_match_default_branch_with_branch_alias,
- ignore
-);
-installer_fixture!(
- unbounded_conflict_does_not_match_default_branch_with_numeric_branch,
- ignore
-);
+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!(
update_abandoned_package_required_but_blocked_via_audit_config,
@@ -369,7 +402,7 @@ installer_fixture!(update_ignore_platform_package_requirement_list);
installer_fixture!(update_ignore_platform_package_requirement_list_upper_bounds);
installer_fixture!(update_ignore_platform_package_requirement_wildcard);
installer_fixture!(update_ignore_platform_package_requirements);
-installer_fixture!(update_installed_alias, ignore);
+installer_fixture!(update_installed_alias);
installer_fixture!(update_installed_alias_dry_run);
installer_fixture!(update_installed_reference, ignore);
installer_fixture!(update_installed_reference_dry_run);