diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-03 16:23:40 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-03 16:23:40 +0900 |
| commit | d84024fb179e3ebb55573971a329cb6ff72d7fa0 (patch) | |
| tree | e7742e916c66c5e7cebb136453db9412fefb530b /crates/mozart-registry | |
| parent | 87f5bcdc2d0e5fbec3848de3865d6c0d47d623ea (diff) | |
| download | php-mozart-d84024fb179e3ebb55573971a329cb6ff72d7fa0.tar.gz php-mozart-d84024fb179e3ebb55573971a329cb6ff72d7fa0.tar.zst php-mozart-d84024fb179e3ebb55573971a329cb6ff72d7fa0.zip | |
fix(resolver): seed locked packages into pool and honour root-require barrier
Mirror Composer's PoolBuilder/Request semantics for partial updates: each
non-allow-listed locked package becomes a non-fixed pool entry restricted to
its locked version, so `replace`-providing peers cannot silently displace
it. Path-repo packages are exempt — Composer always reloads them from disk.
Threading `--with-dependencies` through `expand_with_direct_dependencies`
now performs transitive expansion with a root-require barrier matching
UPDATE_LISTED_WITH_TRANSITIVE_DEPS_NO_ROOT_REQUIRE, so root requires stay
locked when reached via a transitive dep.
Newly green: remove_does_nothing_if_removal_requires_update_of_dep,
update_allow_list_removes_unused, github_issues_4795,
partial_update_with_deps_warns_root.
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/lockfile.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 100 |
2 files changed, 101 insertions, 0 deletions
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index fa6c72f..da2384d 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -1407,6 +1407,7 @@ mod tests { root_replace: IndexMap::new(), root_conflict: IndexMap::new(), locked_package_names: IndexSet::new(), + locked_packages: Vec::new(), }; let resolved = resolve(&resolve_request) diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 40acfad..6bce0f1 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -696,6 +696,31 @@ pub struct ResolveRequest { /// `require: "X as Y"` root aliases. Empty for installs and full updates, /// where every package can take aliases as usual. pub locked_package_names: IndexSet<String>, + /// Full data of packages pinned to their lock-file version (a partial + /// update). Each entry is added to the pool as a fixed entry, mirroring + /// Composer's `Request::lockPackage` + `PoolBuilder::buildPool`'s + /// `getFixedOrLockedPackages` loop: a locked-only package's pretty/normalized + /// version, requires, replaces, provides and conflicts all enter the pool + /// at exactly one version, so the SAT solver cannot pick a different + /// version (whether directly or via another package's `replace`). Empty + /// for installs and full updates. + pub locked_packages: Vec<LockedPackageInfo>, +} + +/// Full data for a lock-pinned package, used in partial updates. Carried on +/// `ResolveRequest::locked_packages` and turned into a fixed pool entry +/// inside `resolve()`. Mirrors what Composer's `PoolBuilder` reads off a +/// `BasePackage` retrieved from the locked repository. +pub struct LockedPackageInfo { + pub name: String, + /// Pretty (display) version, e.g. "1.2.3". + pub pretty_version: String, + /// Normalized version, e.g. "1.2.3.0". + pub version_normalized: String, + pub requires: Vec<(String, String)>, + pub replaces: Vec<(String, String)>, + pub provides: Vec<(String, String)>, + pub conflicts: Vec<(String, String)>, } /// A single package in the resolution output. @@ -899,6 +924,65 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R builder.add_package(root_input); } + // Add lock-pinned packages as pool entries (partial-update case). + // + // Mirrors Composer's `PoolBuilder::buildPool` flow: every locked package + // not in the `updateAllowList` is added through `Request::lockPackage`, + // then re-entered into the pool via the `getFixedOrLockedPackages` + // loop. Crucially, a *locked* package is NOT a *fixed* package + // (Request.php:89-98): the SAT solver does not force its installation, + // so a locked package whose root require has been removed will simply + // drop out of the result. The locked entry's purpose is to constrain + // the pool to *only* the locked version for that name — every other + // version is filtered out below — so other packages cannot pick a + // different version (whether directly, or via `replace`, which would + // otherwise let an upgraded replacer silently drop the dependency). + // + // Build a map first so the filter below knows which (name, version) + // pairs are the only allowed entries for locked names. + let locked_name_to_version: IndexMap<String, String> = request + .locked_packages + .iter() + .map(|p| (p.name.to_lowercase(), p.version_normalized.clone())) + .collect(); + let lock_filter_allows = |name: &str, version: &str| -> bool { + match locked_name_to_version.get(&name.to_lowercase()) { + Some(locked_version) => locked_version == version, + None => true, + } + }; + for locked in &request.locked_packages { + let locked_name_lower = locked.name.to_lowercase(); + let input = PoolPackageInput { + name: locked_name_lower.clone(), + version: locked.version_normalized.clone(), + pretty_version: locked.pretty_version.clone(), + requires: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.requires, + ), + replaces: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.replaces, + ), + provides: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.provides, + ), + conflicts: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.conflicts, + ), + is_fixed: false, + is_alias_of: None, + }; + builder.add_package(input); + } + // Scan VCS repositories and collect packages from them let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.raw_repositories).await; let mut vcs_package_names: IndexSet<String> = IndexSet::new(); @@ -911,6 +995,9 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R let inputs = vcs_bridge::vcs_to_pool_inputs(vpkg, request.minimum_stability, &stability_flags); for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } builder.add_package(input); } } @@ -929,6 +1016,9 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R &stability_flags, ); for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } builder.add_package(input); } } @@ -950,6 +1040,9 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R &stability_flags, ); for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } builder.add_package(input); } } @@ -991,6 +1084,9 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R &stability_flags, ); for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } builder.add_package(input); } } @@ -1030,6 +1126,9 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R &request.stability_flags, ); for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } builder.add_package(input); } } @@ -1581,6 +1680,7 @@ mod tests { root_replace: IndexMap::new(), root_conflict: IndexMap::new(), locked_package_names: IndexSet::new(), + locked_packages: Vec::new(), }; let result = resolve(&request).await; |
