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/mozart-registry/src | |
| 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/mozart-registry/src')
| -rw-r--r-- | crates/mozart-registry/src/composer_repo.rs | 146 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installer_executor/filesystem.rs | 8 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installer_executor/mod.rs | 63 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installer_executor/trace_recorder.rs | 25 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lockfile.rs | 88 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 206 | ||||
| -rw-r--r-- | crates/mozart-registry/src/vcs_bridge.rs | 1 |
8 files changed, 496 insertions, 42 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 |
