From 4453aaddb071515e4b2c263864bd00fe7fa2eee6 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 1 May 2026 21:32:01 +0900 Subject: feat(install): verify lock file satisfies composer.json requires Mirrors Composer's Installer::doInstall() check: before installing from an existing composer.lock, walk every root require (and require-dev in dev mode) and confirm the lock contains a satisfying package. If any are missing or fail the constraint, print the standard bullet-list diagnostic and exit with LOCK_FILE_INVALID (4) instead of blindly attempting to install and failing later with a misleading "no dist or source information" error. Closes the gap exercised by the outdated-lock-file-fails-install installer fixture. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/mozart-registry/src/lockfile.rs | 260 +++++++++++++++++++++++++++++++++ crates/mozart/src/commands/install.rs | 25 +++- crates/mozart/tests/installer.rs | 5 +- 3 files changed, 282 insertions(+), 8 deletions(-) (limited to 'crates') diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index a9ecf36..19e721c 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -226,6 +226,111 @@ impl LockFile { let digest = md5::compute(compact.as_bytes()); Ok(format!("{:x}", digest)) } + + /// Check that every root `require` (and `require-dev` when `include_dev`) + /// is satisfied by the locked packages. Returns the list of bullet-prefixed + /// error lines (plus the trailing merge-conflict hint) if anything is + /// missing or mismatched, otherwise an empty vec. + /// + /// Mirrors `Composer\Package\Locker::getMissingRequirementInfo()`. + pub fn get_missing_requirement_info( + &self, + root: &mozart_core::package::RawPackageData, + include_dev: bool, + ) -> Vec { + let mut messages = Vec::new(); + let mut any_missing = false; + + let base_pool: Vec<&LockedPackage> = self.packages.iter().collect(); + let mut dev_pool: Vec<&LockedPackage> = base_pool.clone(); + if let Some(dev) = &self.packages_dev { + dev_pool.extend(dev.iter()); + } + + check_requirement_set( + &root.require, + "Required", + &base_pool, + &mut messages, + &mut any_missing, + ); + if include_dev { + check_requirement_set( + &root.require_dev, + "Required (in require-dev)", + &dev_pool, + &mut messages, + &mut any_missing, + ); + } + + if any_missing { + messages.push( + "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string(), + ); + messages.push( + "Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string(), + ); + messages.push( + "and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string(), + ); + } + + messages + } +} + +fn check_requirement_set( + requires: &BTreeMap, + description: &str, + pool: &[&LockedPackage], + messages: &mut Vec, + any_missing: &mut bool, +) { + for (name, constraint_str) in requires { + if mozart_core::platform::is_platform_package(name) { + continue; + } + if constraint_str.trim() == "self.version" { + continue; + } + + let constraint = mozart_semver::VersionConstraint::parse(constraint_str).ok(); + + let mut name_only_match: Option<&LockedPackage> = None; + let mut satisfied = false; + for pkg in pool { + if pkg.name != *name { + continue; + } + if name_only_match.is_none() { + name_only_match = Some(pkg); + } + if let Some(ref c) = constraint + && let Ok(version) = mozart_semver::Version::parse(&pkg.version) + && c.matches(&version) + { + satisfied = true; + break; + } + } + + if satisfied { + continue; + } + + *any_missing = true; + if let Some(pkg) = name_only_match { + messages.push(format!( + "- {description} package \"{name}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{constraint_str}\".", + pkg.version + )); + } else { + messages.push(format!( + "- {description} package \"{name}\" is not present in the lock file." + )); + } + } } // ───────────────────────────────────────────────────────────────────────────── @@ -1092,4 +1197,159 @@ mod tests { println!(" {} {}", pkg.name, pkg.version); } } + + // ──────────── get_missing_requirement_info tests ──────────── + + fn make_locked(name: &str, version: &str) -> LockedPackage { + LockedPackage { + name: name.to_string(), + version: version.to_string(), + version_normalized: None, + source: None, + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + suggest: None, + package_type: Some("library".to_string()), + autoload: None, + autoload_dev: None, + license: None, + description: None, + homepage: None, + keywords: None, + authors: None, + support: None, + funding: None, + time: None, + extra_fields: BTreeMap::new(), + } + } + + fn lock_with(packages: Vec, dev: Vec) -> LockFile { + LockFile { + readme: LockFile::default_readme(), + content_hash: "x".to_string(), + packages, + packages_dev: Some(dev), + aliases: vec![], + minimum_stability: "stable".to_string(), + stability_flags: serde_json::json!({}), + prefer_stable: false, + prefer_lowest: false, + platform: serde_json::json!({}), + platform_dev: serde_json::json!({}), + plugin_api_version: Some("2.6.0".to_string()), + } + } + + fn root_with_require( + require: &[(&str, &str)], + require_dev: &[(&str, &str)], + ) -> mozart_core::package::RawPackageData { + let mut root = mozart_core::package::RawPackageData::new("__root__".to_string()); + for (k, v) in require { + root.require.insert((*k).to_string(), (*v).to_string()); + } + for (k, v) in require_dev { + root.require_dev.insert((*k).to_string(), (*v).to_string()); + } + root + } + + #[test] + fn missing_requirement_info_empty_when_satisfied() { + let lock = lock_with(vec![make_locked("a/a", "1.0.0")], vec![]); + let root = root_with_require(&[("a/a", "^1.0")], &[]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_reports_missing_package() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[("a/a", "^1.0")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert_eq!( + info[0], + "- Required package \"a/a\" is not present in the lock file." + ); + assert!(info.iter().any(|m| m.contains("merge conflicts"))); + } + + #[test] + fn missing_requirement_info_reports_unsatisfied_constraint() { + let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); + let root = root_with_require(&[("some/dep", "dev-main")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert_eq!( + info[0], + "- Required package \"some/dep\" is in the lock file as \"dev-foo\" but that does not satisfy your constraint \"dev-main\"." + ); + } + + #[test] + fn missing_requirement_info_skips_platform_packages() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[("php", "^8.0"), ("ext-json", "*")], &[]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_skips_self_version() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[("a/a", "self.version")], &[]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_dev_pool_includes_packages_dev() { + // require-dev "a/a" should be satisfied by an entry in packages-dev. + let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); + let root = root_with_require(&[], &[("a/a", "^1.0")]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_skips_dev_when_include_dev_false() { + // require-dev errors must NOT appear when include_dev is false (no_dev). + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[], &[("a/a", "^1.0")]); + assert!(lock.get_missing_requirement_info(&root, false).is_empty()); + } + + #[test] + fn missing_requirement_info_require_pool_excludes_packages_dev() { + // A regular require should NOT be satisfied by an entry that lives only + // in packages-dev. + let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); + let root = root_with_require(&[("a/a", "^1.0")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert_eq!( + info[0], + "- Required package \"a/a\" is not present in the lock file." + ); + } + + #[test] + fn missing_requirement_info_reports_multiple_problems() { + let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); + let root = root_with_require(&[("some/dep", "dev-main"), ("some/dep2", "dev-main")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert!( + info.iter() + .any(|m| m.contains("some/dep") && m.contains("dev-foo") && m.contains("dev-main")) + ); + assert!( + info.iter() + .any(|m| m == "- Required package \"some/dep2\" is not present in the lock file.") + ); + } + + #[test] + fn missing_requirement_info_uses_dev_description_label() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[], &[("a/a", "^1.0")]); + let info = lock.get_missing_requirement_info(&root, true); + assert!(info[0].contains("Required (in require-dev) package \"a/a\"")); + } } diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index b245182..f1cc6a9 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -662,7 +662,15 @@ pub async fn execute( } let lock = lockfile::LockFile::read_from_file(&lock_path)?; - // Step 4: Freshness check + // Step 4: Determine dev mode (needed for the lock-vs-composer.json check) + let dev_mode = !args.no_dev; + + // Step 5: Freshness check + lock-vs-composer.json requirement check + // + // Mirrors `Composer\Installer::doInstall()` lines 745-756: if the lock is + // stale, warn; then verify every root require (and require-dev when in dev + // mode) is satisfied by the lock contents. If not, exit with + // ERROR_LOCK_FILE_INVALID (4) before attempting to install. let composer_json_path = working_dir.join("composer.json"); if composer_json_path.exists() { let content = std::fs::read_to_string(&composer_json_path)?; @@ -671,9 +679,20 @@ pub async fn execute( "Warning: The lock file is not up to date with the latest changes in composer.json. You may be getting outdated dependencies. It is recommended that you run `mozart update`." )); } + + let root_pkg = mozart_core::package::read_from_file(&composer_json_path)?; + let missing = lock.get_missing_requirement_info(&root_pkg, dev_mode); + if !missing.is_empty() { + for line in &missing { + console.info(line); + } + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::LOCK_FILE_INVALID, + )); + } } - // Step 5: Determine if prefer-source is enabled + // Step 6: Determine if prefer-source is enabled let prefer_source = args.prefer_source || args .prefer_install @@ -681,8 +700,6 @@ pub async fn execute( .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); - // Step 6: Determine dev mode and vendor directory - let dev_mode = !args.no_dev; let vendor_dir = working_dir.join("vendor"); // Step 7: Delegate to shared install_from_lock() diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index 0520108..af38c9e 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -297,10 +297,7 @@ installer_fixture!( load_replaced_package_if_replacer_dropped, ignore = "mozart binary cannot yet run this fixture" ); -installer_fixture!( - outdated_lock_file_fails_install, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(outdated_lock_file_fails_install); installer_fixture!( outdated_lock_file_with_new_platform_reqs_fails, ignore = "mozart binary cannot yet run this fixture" -- cgit v1.3.1