aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 11:17:02 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 11:17:02 +0900
commit489d00ca3f096f69f3b05f9564b23bb70a2475c7 (patch)
treec8caf78fa618eb9971ea2f9680be5875d7b9a996
parentd175ff0aca312eafc1e125ba12a9b4e8cf81960a (diff)
downloadphp-mozart-489d00ca3f096f69f3b05f9564b23bb70a2475c7.tar.gz
php-mozart-489d00ca3f096f69f3b05f9564b23bb70a2475c7.tar.zst
php-mozart-489d00ca3f096f69f3b05f9564b23bb70a2475c7.zip
fix(resolver): fail when a root require has no matching providers
Mirror Composer's `Solver::checkForRootRequireProblems`: a root require that resolves to zero pool providers produces no SAT rule, so the solver previously succeeded with an empty plan instead of reporting the unresolvable requirement. `RuleSetGenerator::generate` now returns those misses alongside the rule set, and `resolve()` short-circuits into `ResolveError::NoSolution` so install/update exit with code 2 to match Composer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/mozart-registry/src/resolver.rs29
-rw-r--r--crates/mozart-sat-resolver/src/rule_set_generator.rs21
-rw-r--r--crates/mozart/tests/installer.rs12
3 files changed, 49 insertions, 13 deletions
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
index 9cc0751..da2f444 100644
--- a/crates/mozart-registry/src/resolver.rs
+++ b/crates/mozart-registry/src/resolver.rs
@@ -861,13 +861,38 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R
let mut generator = RuleSetGenerator::new(&mut pool);
generator.set_ignore_platform_reqs(ignore_set);
generator.set_ignore_all_platform_reqs(request.ignore_platform_reqs);
- let rules = generator.generate(
+ let (rules, missing_root_requires) = generator.generate(
&root_requires,
&fixed_ids,
&request.root_provide,
&request.root_replace,
);
+ // Mirror Composer's `Solver::checkForRootRequireProblems`: a root require
+ // with no providers in the pool yields no SAT rule, so the solver would
+ // succeed with an empty plan. Surface it as an unresolvable problem
+ // instead, matching Composer's exit code 2 behaviour.
+ if !missing_root_requires.is_empty() {
+ let problems: Vec<String> = missing_root_requires
+ .iter()
+ .map(|(name, constraint)| match constraint.as_deref() {
+ Some(c) if !c.is_empty() => format!(
+ " - Root composer.json requires {name} {c}, no matching package found."
+ ),
+ _ => {
+ format!(" - Root composer.json requires {name}, no matching package found.")
+ }
+ })
+ .collect();
+ let report = problems
+ .into_iter()
+ .enumerate()
+ .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg))
+ .collect::<Vec<_>>()
+ .join("\n");
+ return Err(ResolveError::NoSolution(report));
+ }
+
// Create policy and solve
let policy = DefaultPolicy::new(request.prefer_stable, request.prefer_lowest);
let fixed_set: HashSet<u32> = fixed_ids.into_iter().collect();
@@ -1265,7 +1290,7 @@ mod tests {
requires.insert("foo/foo".to_string(), Some("^1.0".to_string()));
let generator = RuleSetGenerator::new(&mut pool);
- let rules = generator.generate(&requires, &[], &HashMap::new(), &HashMap::new());
+ let (rules, _) = generator.generate(&requires, &[], &HashMap::new(), &HashMap::new());
let policy = DefaultPolicy::default();
let solver = Solver::new(rules, &pool, policy, HashSet::new());
diff --git a/crates/mozart-sat-resolver/src/rule_set_generator.rs b/crates/mozart-sat-resolver/src/rule_set_generator.rs
index 8f754e5..11c04cc 100644
--- a/crates/mozart-sat-resolver/src/rule_set_generator.rs
+++ b/crates/mozart-sat-resolver/src/rule_set_generator.rs
@@ -67,13 +67,21 @@ impl<'a> RuleSetGenerator<'a> {
/// so the requirement is trivially satisfied without forcing a real
/// provider. Without this, Mozart picks up an inline `provided/pkg` from
/// the repository even though the root claims to fulfill it itself.
+ ///
+ /// Returns the generated rule set together with the list of root requires
+ /// that have no matching providers in the pool. Mirrors Composer's
+ /// `Solver::checkForRootRequireProblems`: a root require with zero
+ /// providers does not produce a SAT rule (so the solver would otherwise
+ /// succeed with an empty plan), but it must still be reported as an
+ /// unresolvable problem.
pub fn generate(
mut self,
requires: &HashMap<String, Option<String>>,
fixed_packages: &[PackageId],
root_provides: &HashMap<String, String>,
root_replaces: &HashMap<String, String>,
- ) -> RuleSet {
+ ) -> (RuleSet, Vec<(String, Option<String>)>) {
+ let mut missing_root_requires: Vec<(String, Option<String>)> = Vec::new();
// Process fixed packages
for &pkg_id in fixed_packages {
if self.pool.is_unacceptable_fixed_package(pkg_id) {
@@ -130,6 +138,8 @@ impl<'a> RuleSetGenerator<'a> {
},
);
self.rules.add(rule, RuleType::Request);
+ } else {
+ missing_root_requires.push((name.clone(), constraint.clone()));
}
}
@@ -154,7 +164,7 @@ impl<'a> RuleSetGenerator<'a> {
// Add conflict rules
self.add_conflict_rules();
- self.rules
+ (self.rules, missing_root_requires)
}
/// Add rules for a package and its transitive dependencies.
@@ -388,7 +398,7 @@ mod tests {
requires.insert("a/a".to_string(), None);
let generator = RuleSetGenerator::new(&mut pool);
- let rules = generator.generate(&requires, &[], &HashMap::new(), &HashMap::new());
+ let (rules, _) = generator.generate(&requires, &[], &HashMap::new(), &HashMap::new());
// Should have a request rule: (1 | 2)
let request_count = rules.iter_type(RuleType::Request).count();
@@ -428,7 +438,7 @@ mod tests {
requires.insert("a/a".to_string(), None);
let generator = RuleSetGenerator::new(&mut pool);
- let rules = generator.generate(&requires, &[], &HashMap::new(), &HashMap::new());
+ let (rules, _) = generator.generate(&requires, &[], &HashMap::new(), &HashMap::new());
// Should have:
// 1. Request rule: (1) — root requires a/a
@@ -441,7 +451,8 @@ mod tests {
let mut pool = Pool::new(vec![make_input("php", "8.2.0.0")], vec![]);
let generator = RuleSetGenerator::new(&mut pool);
- let rules = generator.generate(&HashMap::new(), &[1], &HashMap::new(), &HashMap::new());
+ let (rules, _) =
+ generator.generate(&HashMap::new(), &[1], &HashMap::new(), &HashMap::new());
// Should have an assertion rule: (1)
let request_rules: Vec<_> = rules.iter_type(RuleType::Request).collect();
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index 7ba53a8..a13e8e6 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -219,7 +219,7 @@ installer_fixture!(abandoned_listed);
installer_fixture!(alias_in_complex_constraints, ignore);
installer_fixture!(alias_in_lock, ignore);
installer_fixture!(alias_in_lock2, ignore);
-installer_fixture!(alias_on_unloadable_package, ignore);
+installer_fixture!(alias_on_unloadable_package);
installer_fixture!(alias_solver_problems);
installer_fixture!(alias_solver_problems2);
installer_fixture!(alias_with_reference, ignore);
@@ -257,7 +257,7 @@ installer_fixture!(github_issues_8902);
installer_fixture!(github_issues_8903, ignore);
installer_fixture!(github_issues_9012, ignore);
installer_fixture!(github_issues_9290, ignore);
-installer_fixture!(hint_main_rename, ignore);
+installer_fixture!(hint_main_rename);
installer_fixture!(install_aliased_alias, ignore);
installer_fixture!(install_branch_alias_composer_repo);
installer_fixture!(install_dev);
@@ -304,7 +304,7 @@ installer_fixture!(
installer_fixture!(partial_update_with_dependencies_provide, ignore);
installer_fixture!(partial_update_with_dependencies_replace, ignore);
installer_fixture!(partial_update_with_deps_warns_root, ignore);
-installer_fixture!(partial_update_with_symlinked_path_repos, ignore);
+installer_fixture!(partial_update_with_symlinked_path_repos);
installer_fixture!(partial_update_without_lock);
installer_fixture!(platform_ext_solver_problems);
installer_fixture!(plugins_are_installed_first);
@@ -343,15 +343,15 @@ installer_fixture!(
ignore
);
installer_fixture!(replacer_satisfies_its_own_requirement);
-installer_fixture!(repositories_priorities, ignore);
+installer_fixture!(repositories_priorities);
installer_fixture!(repositories_priorities2);
installer_fixture!(repositories_priorities3, ignore);
installer_fixture!(repositories_priorities4);
-installer_fixture!(repositories_priorities5, ignore);
+installer_fixture!(repositories_priorities5);
installer_fixture!(root_alias_change_with_circular_dep, ignore);
installer_fixture!(root_alias_gets_loaded_for_locked_pkgs);
installer_fixture!(root_requirements_do_not_affect_locked_versions);
-installer_fixture!(solver_problem_with_hash_in_branch, ignore);
+installer_fixture!(solver_problem_with_hash_in_branch);
installer_fixture!(solver_problems);
installer_fixture!(solver_problems_with_disabled_platform);
installer_fixture!(suggest_installed);