aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-01 22:26:16 +0900
committernsfisis <nsfisis@gmail.com>2026-05-01 22:26:16 +0900
commit4d587407cc9471dc8bfc0544eac0f8c7041fba0d (patch)
tree7b45b8e6e3dc5b1b3325b7cc6783375273a56784 /crates/mozart-registry
parent8a87adf120d5057b06d0474b293fab079e1ce967 (diff)
downloadphp-mozart-4d587407cc9471dc8bfc0544eac0f8c7041fba0d.tar.gz
php-mozart-4d587407cc9471dc8bfc0544eac0f8c7041fba0d.tar.zst
php-mozart-4d587407cc9471dc8bfc0544eac0f8c7041fba0d.zip
feat(registry): support inline 'type: package' repositories
Composer's PackageRepository lets composer.json embed full package metadata under repositories[].package, mirroring the on-disk Packagist response shape. The vast majority of installer fixtures under composer/tests/Composer/Test/Fixtures/installer (179 of 189) rely on this — they declare every package they need inline rather than hitting the network. Three pieces wire this into Mozart: 1. mozart-core::package::RawRepository: relax `url` to Option<String> (Composer enforces presence per repo type, not at JSON parse) and add `package: Option<Value>` to receive the inline definition, which can be a single object or an array. 2. mozart-registry::inline_package: a new module that walks `&[RawRepository]`, picks out type=package entries, and reshapes each `package` payload into a PackagistVersion (auto-computing version_normalized when omitted, matching Packagist's output). 3. resolver::resolve and lockfile::generate_lock_file: feed inline packages into the SAT pool builder and short-circuit the Packagist fetch when generating the lock entry for a resolved inline package. The package-name set is shared with the existing VCS-skip logic so the seed and transitive loops don't double-fetch. One additional install-time change: in install_from_lock, packages that have neither dist nor source are now skipped silently instead of bailing with "no dist or source information". This mirrors Composer's MetapackageInstaller (no installer for type=metapackage) and is also what Composer's own AllFunctionalTest exercises via InstallationManagerMock — most inline-package fixtures define synthetic packages with no download metadata, expecting the install operation to be recorded but not actually run. Net effect: installer fixture scoreboard jumps from 7/187 to 103/187. The 84 fixtures still ignored hit issues unrelated to inline-package plumbing — aliases, replace/provide chains, dev-reference handling, allow-list updates, etc. — and are tracked separately. 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/inline_package.rs171
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/lockfile.rs20
-rw-r--r--crates/mozart-registry/src/resolver.rs26
-rw-r--r--crates/mozart-registry/src/vcs_bridge.rs10
5 files changed, 222 insertions, 6 deletions
diff --git a/crates/mozart-registry/src/inline_package.rs b/crates/mozart-registry/src/inline_package.rs
new file mode 100644
index 0000000..e10cd2b
--- /dev/null
+++ b/crates/mozart-registry/src/inline_package.rs
@@ -0,0 +1,171 @@
+//! Support for inline `type: package` repositories.
+//!
+//! `composer.json` may embed full package metadata under
+//! `repositories[].package`, mirroring `Composer\Repository\PackageRepository`.
+//! These packages need no network fetch — they go straight into the resolver
+//! pool and into the generated lockfile entry verbatim.
+
+use crate::packagist::PackagistVersion;
+use mozart_core::package::RawRepository;
+
+/// One package extracted from a `type: package` repository.
+pub struct InlinePackage {
+ pub name: String,
+ pub version: PackagistVersion,
+}
+
+/// Collect every package definition from `type: package` repositories.
+///
+/// Each repository's `package` field may be a single object or an array of
+/// objects. Entries that fail to parse (missing `name`/`version`, etc.) are
+/// silently skipped so the rest of the repositories list still applies —
+/// matching Composer's lenient PackageRepository constructor.
+pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec<InlinePackage> {
+ let mut packages = Vec::new();
+ for repo in repositories {
+ if repo.repo_type != "package" {
+ continue;
+ }
+ let Some(value) = &repo.package else {
+ continue;
+ };
+
+ match value {
+ serde_json::Value::Array(arr) => {
+ for entry in arr {
+ if let Some(pkg) = parse_inline_package(entry) {
+ packages.push(pkg);
+ }
+ }
+ }
+ serde_json::Value::Object(_) => {
+ if let Some(pkg) = parse_inline_package(value) {
+ packages.push(pkg);
+ }
+ }
+ _ => {}
+ }
+ }
+ packages
+}
+
+fn parse_inline_package(value: &serde_json::Value) -> Option<InlinePackage> {
+ let obj = value.as_object()?;
+ let name = obj.get("name")?.as_str()?.to_string();
+ let version_str = obj.get("version")?.as_str()?.to_string();
+
+ // PackagistVersion requires `version_normalized`. If the inline definition
+ // omits it (the common case), compute it the same way Packagist does:
+ // run the version through Mozart's normalizer.
+ let mut value_for_parse = value.clone();
+ if let serde_json::Value::Object(ref mut map) = value_for_parse
+ && !map.contains_key("version_normalized")
+ {
+ let normalized = mozart_semver::Version::parse(&version_str)
+ .map(|v| v.to_string())
+ .unwrap_or_else(|_| version_str.clone());
+ map.insert(
+ "version_normalized".to_string(),
+ serde_json::Value::String(normalized),
+ );
+ }
+
+ let version: PackagistVersion = serde_json::from_value(value_for_parse).ok()?;
+ Some(InlinePackage { name, version })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn pkg_repo(value: serde_json::Value) -> RawRepository {
+ RawRepository {
+ repo_type: "package".to_string(),
+ url: None,
+ package: Some(value),
+ }
+ }
+
+ #[test]
+ fn collects_single_inline_package_object() {
+ let repos = vec![pkg_repo(serde_json::json!({
+ "name": "a/a",
+ "version": "1.0.0"
+ }))];
+ let pkgs = collect_inline_packages(&repos);
+ assert_eq!(pkgs.len(), 1);
+ assert_eq!(pkgs[0].name, "a/a");
+ assert_eq!(pkgs[0].version.version, "1.0.0");
+ assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0");
+ }
+
+ #[test]
+ fn collects_inline_package_array() {
+ let repos = vec![pkg_repo(serde_json::json!([
+ {"name": "a/a", "version": "1.0.0"},
+ {"name": "b/b", "version": "2.0.0"}
+ ]))];
+ let pkgs = collect_inline_packages(&repos);
+ assert_eq!(pkgs.len(), 2);
+ assert_eq!(pkgs[0].name, "a/a");
+ assert_eq!(pkgs[1].name, "b/b");
+ }
+
+ #[test]
+ fn ignores_non_package_repos() {
+ let repos = vec![RawRepository {
+ repo_type: "vcs".to_string(),
+ url: Some("https://example.com/foo.git".to_string()),
+ package: None,
+ }];
+ assert!(collect_inline_packages(&repos).is_empty());
+ }
+
+ #[test]
+ fn skips_entries_missing_name_or_version() {
+ let repos = vec![pkg_repo(serde_json::json!([
+ {"name": "a/a", "version": "1.0.0"},
+ {"name": "missing/version"},
+ {"version": "2.0.0"},
+ {"name": "b/b", "version": "2.0.0"}
+ ]))];
+ let pkgs = collect_inline_packages(&repos);
+ assert_eq!(pkgs.len(), 2);
+ assert_eq!(pkgs[0].name, "a/a");
+ assert_eq!(pkgs[1].name, "b/b");
+ }
+
+ #[test]
+ fn preserves_explicit_version_normalized() {
+ let repos = vec![pkg_repo(serde_json::json!({
+ "name": "a/a",
+ "version": "1.0",
+ "version_normalized": "1.0.0.0-explicit"
+ }))];
+ let pkgs = collect_inline_packages(&repos);
+ assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0-explicit");
+ }
+
+ #[test]
+ fn parses_full_metadata_fields() {
+ let repos = vec![pkg_repo(serde_json::json!({
+ "name": "a/a",
+ "version": "1.0.0",
+ "type": "library",
+ "require": {"b/b": "^2.0"},
+ "replace": {"old/x": "1.0"},
+ "provide": {"some/iface": "1.0"},
+ "conflict": {"bad/pkg": "*"},
+ "dist": {"type": "zip", "url": "https://e.com/a.zip"}
+ }))];
+ let pkgs = collect_inline_packages(&repos);
+ assert_eq!(pkgs.len(), 1);
+ let v = &pkgs[0].version;
+ assert_eq!(v.package_type.as_deref(), Some("library"));
+ assert_eq!(v.require.get("b/b").map(String::as_str), Some("^2.0"));
+ assert_eq!(v.replace.get("old/x").map(String::as_str), Some("1.0"));
+ assert_eq!(v.provide.get("some/iface").map(String::as_str), Some("1.0"));
+ assert_eq!(v.conflict.get("bad/pkg").map(String::as_str), Some("*"));
+ assert!(v.dist.is_some());
+ }
+}
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
index 4c26c1e..a4afacd 100644
--- a/crates/mozart-registry/src/lib.rs
+++ b/crates/mozart-registry/src/lib.rs
@@ -1,5 +1,6 @@
pub mod cache;
pub mod downloader;
+pub mod inline_package;
pub mod installed;
pub mod lockfile;
pub mod packagist;
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
index 331f58e..a99c921 100644
--- a/crates/mozart-registry/src/lockfile.rs
+++ b/crates/mozart-registry/src/lockfile.rs
@@ -354,6 +354,19 @@ pub struct LockFileGenerationRequest {
pub repo_cache: Cache,
}
+impl LockFileGenerationRequest {
+ /// Look up an inline `type: package` definition for `name` (if any).
+ /// Returns the matching `PackagistVersion` so callers can short-circuit
+ /// the Packagist fetch for resolved packages that came from a `type:
+ /// package` repository.
+ fn inline_lookup(&self, name: &str, version_normalized: &str) -> Option<PackagistVersion> {
+ crate::inline_package::collect_inline_packages(&self.composer_json.repositories)
+ .into_iter()
+ .find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized)
+ .map(|ipkg| ipkg.version)
+ }
+}
+
/// Convert a `PackagistSource` to a `LockedSource`.
fn packagist_source_to_locked(ps: &PackagistSource) -> LockedSource {
LockedSource {
@@ -504,6 +517,13 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
// 1. Fetch full metadata for all resolved packages
let mut package_metadata: HashMap<String, PackagistVersion> = HashMap::new();
for pkg in &request.resolved_packages {
+ // Inline `type: package` repositories carry full metadata in
+ // composer.json — use it directly instead of hitting Packagist.
+ if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) {
+ package_metadata.insert(pkg.name.clone(), inline);
+ continue;
+ }
+
let versions = packagist::fetch_package_versions(&pkg.name, &request.repo_cache).await?;
// Find the exact version matching pkg.version_normalized
let matching = versions
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
index e3cc1f6..7aab6b2 100644
--- a/crates/mozart-registry/src/resolver.rs
+++ b/crates/mozart-registry/src/resolver.rs
@@ -463,14 +463,32 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
}
}
+ // Collect inline `type: package` repositories. These don't require any
+ // network fetch; they go straight into the pool and are also tracked by
+ // name so the Packagist seed/transitive loops below skip them.
+ let inline_packages = crate::inline_package::collect_inline_packages(&request.repositories);
+ let mut inline_package_names: HashSet<String> = HashSet::new();
+ for ipkg in &inline_packages {
+ inline_package_names.insert(ipkg.name.clone());
+ let inputs = packagist_to_pool_inputs(
+ &ipkg.name,
+ &ipkg.version,
+ request.minimum_stability,
+ &request.stability_flags,
+ );
+ for input in inputs {
+ builder.add_package(input);
+ }
+ }
+
// Seed the builder with packages for root requirements
for name in root_requires.keys() {
if PackageName(name.clone()).is_platform() {
continue; // platform packages already added
}
- // Skip packages already provided by VCS repositories
- if vcs_package_names.contains(name) {
+ // Skip packages already provided by VCS or inline-package repositories
+ if vcs_package_names.contains(name) || inline_package_names.contains(name) {
continue;
}
@@ -500,8 +518,8 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
continue;
}
- // Skip packages already provided by VCS repositories
- if vcs_package_names.contains(&name) {
+ // Skip packages already provided by VCS or inline-package repositories
+ if vcs_package_names.contains(&name) || inline_package_names.contains(&name) {
continue;
}
diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs
index 78aaaa5..be61845 100644
--- a/crates/mozart-registry/src/vcs_bridge.rs
+++ b/crates/mozart-registry/src/vcs_bridge.rs
@@ -32,14 +32,20 @@ pub async fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec<VcsPac
other => Some(other),
};
- let vcs_repo = VcsRepository::new(repo.url.clone(), forced_type, config.clone());
+ // VCS repositories require `url`; skip silently if missing (Composer
+ // would reject this earlier in RepositoryFactory).
+ let Some(url) = repo.url.clone() else {
+ continue;
+ };
+
+ let vcs_repo = VcsRepository::new(url.clone(), forced_type, config.clone());
match vcs_repo.scan().await {
Ok(versions) => {
all_versions.extend(versions);
}
Err(e) => {
- eprintln!("Warning: Failed to scan VCS repository {}: {}", repo.url, e,);
+ eprintln!("Warning: Failed to scan VCS repository {url}: {e}");
}
}
}