aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 21:23:37 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 21:23:37 +0900
commit577bd35f97fd46ad5f296980c86f5fcc51413f5c (patch)
tree715d60517780e379c5e41e5792da280cbfc75644 /crates/mozart-registry/src
parent6ec10b18cfe2e473d71f8d786ae0d6a9877864ab (diff)
downloadphp-mozart-577bd35f97fd46ad5f296980c86f5fcc51413f5c.tar.gz
php-mozart-577bd35f97fd46ad5f296980c86f5fcc51413f5c.tar.zst
php-mozart-577bd35f97fd46ad5f296980c86f5fcc51413f5c.zip
fix(update): mirror Composer's always-include-dev resolution path
Composer's `Installer::doUpdate` hardcodes `includeDevRequires=true` for the first solve, so a `--no-dev` update still considers require-dev during resolution and writes a complete lock file (the flag only gates what gets installed). Mozart was passing `include_dev: dev_mode`, dropping require-dev from both the resolver pool and the lock when `--no-dev` was set, which broke fixtures where a non-dev requirement was satisfied by a package pulled in transitively through require-dev (e.g. `provided/pkg` provided by a require-dev metapackage). Also extend `classify_dev_packages` to walk `provide`/`replace` edges so the production BFS reaches packages that satisfy a `require` virtually, matching what Composer's `extractDevPackages` second-Solver run achieves through a real solve.
Diffstat (limited to 'crates/mozart-registry/src')
-rw-r--r--crates/mozart-registry/src/lockfile.rs118
1 files changed, 88 insertions, 30 deletions
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
index 6979301..70e4a42 100644
--- a/crates/mozart-registry/src/lockfile.rs
+++ b/crates/mozart-registry/src/lockfile.rs
@@ -669,46 +669,52 @@ fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> Loc
/// A package is dev-only if it is NOT reachable from the non-dev dependency tree
/// (i.e., only reachable through require-dev paths).
///
-/// `package_metadata` must be pre-fetched full `PackagistVersion` data for each resolved package.
+/// `requires_by_name` and `providers_by_name` are keyed by lowercase package
+/// names. `providers_by_name` maps a satisfied name (own name + each `provide`
+/// or `replace` target) to the list of resolved package names that satisfy it,
+/// so a non-dev `require` like `provided/pkg` reaches `b/b` when `b/b`
+/// declares `provide: { provided/pkg: 1.0.0 }`.
fn classify_dev_packages(
resolved: &[ResolvedPackage],
require: &BTreeMap<String, String>,
_require_dev: &BTreeMap<String, String>,
requires_by_name: &IndexMap<String, Vec<String>>,
+ providers_by_name: &IndexMap<String, Vec<String>>,
) -> IndexSet<String> {
- // Build set of all resolved package names for quick lookup
- let resolved_names: IndexSet<&str> = resolved.iter().map(|p| p.name.as_str()).collect();
-
// BFS from non-dev root dependencies through each package's `require` map.
// All reachable packages are production packages.
let mut production: IndexSet<String> = IndexSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
- // Seed queue with non-dev root dependencies that are actual packages (not platform)
- for name in require.keys() {
+ let visit = |name: &str, production: &mut IndexSet<String>, queue: &mut VecDeque<String>| {
let name_lower = name.to_lowercase();
- // Skip platform packages (php, ext-*, lib-*, etc.)
if is_platform_name(&name_lower) {
- continue;
+ return;
}
- if resolved_names.contains(name_lower.as_str()) && production.insert(name_lower.clone()) {
- queue.push_back(name_lower);
+ // A required name is satisfied either by a resolved package whose own
+ // name matches (the common case, captured here as `providers_by_name`
+ // also indexes own names) or by a resolved package that provides /
+ // replaces it. Mirrors Composer's `extractDevPackages` second-solve
+ // semantics, which walks the same provide/replace edges through a
+ // real Solver call.
+ if let Some(provs) = providers_by_name.get(&name_lower) {
+ for prov in provs {
+ let prov_lower = prov.to_lowercase();
+ if production.insert(prov_lower.clone()) {
+ queue.push_back(prov_lower);
+ }
+ }
}
+ };
+
+ for name in require.keys() {
+ visit(name, &mut production, &mut queue);
}
- // BFS: walk transitive `require` deps of each production package
while let Some(pkg_name) = queue.pop_front() {
if let Some(deps) = requires_by_name.get(&pkg_name) {
- for dep_name in deps {
- let dep_lower = dep_name.to_lowercase();
- if is_platform_name(&dep_lower) {
- continue;
- }
- if resolved_names.contains(dep_lower.as_str())
- && production.insert(dep_lower.clone())
- {
- queue.push_back(dep_lower);
- }
+ for dep_name in deps.clone() {
+ visit(&dep_name, &mut production, &mut queue);
}
}
}
@@ -716,7 +722,7 @@ fn classify_dev_packages(
// Any resolved package not in `production` is dev-only
resolved
.iter()
- .filter(|p| !production.contains(&p.name))
+ .filter(|p| !production.contains(&p.name.to_lowercase()))
.map(|p| p.name.clone())
.collect()
}
@@ -867,19 +873,45 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::
// preserved-from-old-lock requires when available so a partial update
// sees the same dev-classification graph the previous lock did.
let mut requires_by_name: IndexMap<String, Vec<String>> = IndexMap::new();
+ // Inverse map: `satisfied name → list of resolved packages that satisfy it`.
+ // A resolved package satisfies its own name plus each `provide` / `replace`
+ // target (Composer's `extractDevPackages` reaches the same edges through
+ // its second Solver run; we walk them directly during the dev BFS).
+ let mut providers_by_name: IndexMap<String, Vec<String>> = IndexMap::new();
for (name, pv) in &package_metadata {
- let keys: Vec<String> = if let Some(rel) = preserved_rel.get(name) {
- rel.require.keys().cloned().collect()
- } else {
- pv.require.keys().cloned().collect()
- };
- requires_by_name.insert(name.to_lowercase(), keys);
+ let name_lower = name.to_lowercase();
+ let (require_keys, provide_keys, replace_keys): (Vec<String>, Vec<String>, Vec<String>) =
+ if let Some(rel) = preserved_rel.get(name) {
+ (
+ rel.require.keys().cloned().collect(),
+ rel.provide.keys().cloned().collect(),
+ rel.replace.keys().cloned().collect(),
+ )
+ } else {
+ (
+ pv.require.keys().cloned().collect(),
+ pv.provide.keys().cloned().collect(),
+ pv.replace.keys().cloned().collect(),
+ )
+ };
+ requires_by_name.insert(name_lower.clone(), require_keys);
+ providers_by_name
+ .entry(name_lower.clone())
+ .or_default()
+ .push(name_lower.clone());
+ for target in provide_keys.iter().chain(replace_keys.iter()) {
+ providers_by_name
+ .entry(target.to_lowercase())
+ .or_default()
+ .push(name_lower.clone());
+ }
}
let dev_only = classify_dev_packages(
&real_owned,
&request.composer_json.require,
&request.composer_json.require_dev,
&requires_by_name,
+ &providers_by_name,
);
// 3. Build LockedPackage lists.
@@ -1340,7 +1372,20 @@ mod tests {
.iter()
.map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
.collect();
- let dev_only = classify_dev_packages(&resolved, &require, &require_dev, &requires_by_name);
+ let providers_by_name: IndexMap<String, Vec<String>> = metadata
+ .keys()
+ .map(|name| {
+ let lower = name.to_lowercase();
+ (lower.clone(), vec![lower])
+ })
+ .collect();
+ let dev_only = classify_dev_packages(
+ &resolved,
+ &require,
+ &require_dev,
+ &requires_by_name,
+ &providers_by_name,
+ );
assert!(!dev_only.contains("vendor/a"), "A is a production package");
assert!(dev_only.contains("vendor/b"), "B is dev-only");
@@ -1416,7 +1461,20 @@ mod tests {
.iter()
.map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
.collect();
- let dev_only = classify_dev_packages(&resolved, &require, &require_dev, &requires_by_name);
+ let providers_by_name: IndexMap<String, Vec<String>> = metadata
+ .keys()
+ .map(|name| {
+ let lower = name.to_lowercase();
+ (lower.clone(), vec![lower])
+ })
+ .collect();
+ let dev_only = classify_dev_packages(
+ &resolved,
+ &require,
+ &require_dev,
+ &requires_by_name,
+ &providers_by_name,
+ );
assert!(!dev_only.contains("vendor/a"), "A is a production package");
assert!(dev_only.contains("vendor/b"), "B is dev-only");