diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 22:21:25 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 22:21:25 +0900 |
| commit | 8da98493daf5013585e07ec98ca6960a42924edf (patch) | |
| tree | be57603fec29a4bf1e5f546b1ba2e14778595cb3 /crates | |
| parent | 804b5b9a2a7759af24e41408c82dfc60c6092cf3 (diff) | |
| download | php-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')
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); |
