aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry
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/mozart-registry
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/mozart-registry')
-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
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