From 16f8cf26db22c4fb1073b55d57d31110ea9773cc Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 4 May 2026 00:13:04 +0900 Subject: fix(update): run full resolve under --lock to surface alias changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the content-hash-only short-circuit for `--lock` and route the flag through the same updateMirrors flow Composer uses (`UpdateCommand::execute` line 219). Locked packages are pinned at their lock versions, but the resolver still runs and the installer still emits the operation trace — including MarkAliasInstalled lines for aliases the lock declares but installed.json hasn't recorded yet. Three follow-on fixes the new flow needs: - Re-attach ` as ` from `lock.aliases` when building the mirrors-mode require list, so the resolver's alias extractor materializes the alias entry. The bare `` form is required because `==` fails Composer's normalize. - Don't `continue` past Action::Skip in the install loop. Composer's Transaction::calculateOperations emits MarkAliasInstalled even when the target package is already at the right version, as long as the alias is missing from installed.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/mozart/src/commands/install.rs | 21 +++- crates/mozart/src/commands/update.rs | 209 +++++++--------------------------- 2 files changed, 58 insertions(+), 172 deletions(-) (limited to 'crates/mozart/src/commands') diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index a5698ff..a759e2b 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -1150,15 +1150,22 @@ pub async fn install_from_lock( // Declared at loop scope so the borrows outlive the await call. let from_full_pretty_buf; let to_full_pretty_buf; - let op = match action { - Action::Skip => continue, + let op: Option> = match action { + // Skip still falls through to the alias-mark block below: + // Composer's `Transaction::calculateOperations` emits a + // MarkAliasInstalled even when the target package itself is + // already present, as long as the alias hasn't been recorded + // in `installed.json` yet (`presentAliasMap` miss). This + // matters for `update --lock` from a lock that introduced a + // new root alias on a previously-installed package. + Action::Skip => None, Action::Install => { console.info(&console_format!( " - Installing {} ({})", pkg.name, pkg.version )); - PackageOperation::Install { package: pkg } + Some(PackageOperation::Install { package: pkg }) } Action::Update => { console.info(&console_format!( @@ -1188,15 +1195,17 @@ pub async fn install_from_lock( from_full_pretty_buf = String::new(); to_full_pretty_buf = format_full_pretty_version(pkg); } - PackageOperation::Update { + Some(PackageOperation::Update { from_version, from_full_pretty: &from_full_pretty_buf, to_full_pretty: &to_full_pretty_buf, package: pkg, - } + }) } }; - executor.install_package(op, &exec_ctx).await?; + if let Some(op) = op { + executor.install_package(op, &exec_ctx).await?; + } // After the target install/update, emit MarkAliasInstalled for any // aliases whose `package`+`version` (the target's pretty version) diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 5690081..5210a34 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -1071,8 +1071,10 @@ pub async fn run( let lock_path = working_dir.join("composer.lock"); let vendor_dir = working_dir.join("vendor"); - // Step 4: Handle --lock mode (early return) - // Fix 4: Reject combining --lock with specific package names + // Step 4: Reject combining --lock with specific package names. Mirrors + // Composer's `UpdateCommand::execute` line 222: the lock flag and a + // package selection are mutually exclusive because `--lock` rebuilds + // the entire lock from its current pins, not a subset of it. if args.lock { let non_magic: Vec<_> = args .packages @@ -1084,21 +1086,21 @@ pub async fn run( "You cannot simultaneously update only a selection of packages and regenerate the lock file metadata." ); } - return handle_lock_mode(&lock_path, &composer_json_content, args.dry_run, console); } - // The bare-keyword forms `update lock`, `update nothing`, and - // `update mirrors` (when used alone) trigger Composer's + // Both `--lock` and the bare-keyword forms (`update lock`, `update + // nothing`, `update mirrors`) trigger Composer's // `setUpdateMirrors(true)` flow: every locked package is re-required // pinned at its exact version, so the resolver picks the same - // versions but freshly loads source/dist metadata from the repository. - // Tracked separately from the require/require-dev pipeline below so - // root composer.json requires are intentionally skipped. - let update_mirrors = !args.packages.is_empty() - && args - .packages - .iter() - .all(|p| matches!(p.to_lowercase().as_str(), "lock" | "nothing" | "mirrors")); + // versions but freshly loads source/dist metadata from the + // repository. Mirrors `UpdateCommand::execute` line 219: + // `$updateMirrors = $input->getOption('lock') || ...`. + let update_mirrors = args.lock + || (!args.packages.is_empty() + && args + .packages + .iter() + .all(|p| matches!(p.to_lowercase().as_str(), "lock" | "nothing" | "mirrors"))); let dev_mode = !args.no_dev; @@ -1274,11 +1276,37 @@ pub async fn run( let mut req: Vec<(String, String)> = Vec::new(); let mut req_dev: Vec<(String, String)> = Vec::new(); if let Ok(lock) = lockfile::LockFile::read_from_file(&lock_path) { + // Re-attach any `as ` clause the lock recorded for this + // package so the resolver materializes the same alias entry it + // would on a fresh install. Without this, mirrors mode would + // pin `c/aliased ==1.0.0` while a transitive dep requires + // `c/aliased 2.0.0`, with no alias bridging the two — and the + // solver fails despite the lock being internally consistent. + // Mirrors Composer's `Locker::getLockedRepository` pulling lock + // aliases into the solver's pool. + let alias_for = |name: &str| -> Option { + lock.aliases + .iter() + .find(|a| a.package.eq_ignore_ascii_case(name)) + .map(|a| a.alias.clone()) + }; + // The alias-bearing form uses the bare `` instead of + // `==` because the resolver's alias extractor only + // accepts a parsable LEFT atom; `==1.0.0` would fail + // `VersionParser::normalize` and the alias pair would be + // dropped silently. A bare `1.0.0` constraint matches the same + // exact version as `==1.0.0`, so the lock pin is preserved. + let pin_with_alias = |name: &str, version: &str| -> String { + match alias_for(name) { + Some(alias) => format!("{version} as {alias}"), + None => format!("=={version}"), + } + }; for pkg in &lock.packages { - req.push((pkg.name.clone(), format!("=={}", pkg.version))); + req.push((pkg.name.clone(), pin_with_alias(&pkg.name, &pkg.version))); } for pkg in lock.packages_dev.iter().flatten() { - req_dev.push((pkg.name.clone(), format!("=={}", pkg.version))); + req_dev.push((pkg.name.clone(), pin_with_alias(&pkg.name, &pkg.version))); } } (req, req_dev) @@ -1814,48 +1842,6 @@ pub async fn run( Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── -// --lock mode handler -// ───────────────────────────────────────────────────────────────────────────── - -/// Handle the `--lock` mode: refresh the content-hash of the existing lock file. -/// -/// Reads the existing composer.lock, computes the new content-hash from the current -/// composer.json, and writes the updated lock file back to disk if the hash differs. -fn handle_lock_mode( - lock_path: &std::path::Path, - composer_json_content: &str, - dry_run: bool, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - if !lock_path.exists() { - return Err(mozart_core::exit_code::bail( - mozart_core::exit_code::LOCK_FILE_INVALID, - "No lock file found. Run `mozart update` to generate one.", - )); - } - - let mut lock = lockfile::LockFile::read_from_file(lock_path)?; - - let new_hash = lockfile::LockFile::compute_content_hash(composer_json_content)?; - - if new_hash == lock.content_hash { - console.info("Lock file is already up to date"); - return Ok(()); - } - - lock.content_hash = new_hash; - - if !dry_run { - lock.write_to_file(lock_path)?; - console.info("Lock file hash updated successfully."); - } else { - console.info("Would update lock file hash."); - } - - Ok(()) -} - // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── @@ -2196,86 +2182,6 @@ mod tests { assert_eq!(psr.version, "3.0.0"); } - // ──────────── lock mode helpers ──────────── - - #[test] - fn test_handle_lock_mode_updates_hash() { - let dir = tempfile::tempdir().unwrap(); - let lock_path = dir.path().join("composer.lock"); - - // Write an existing lock with a known hash - let mut lock = minimal_lock(vec![]); - lock.content_hash = "old_hash_value".to_string(); - lock.write_to_file(&lock_path).unwrap(); - - // Composer.json content that will produce a different hash - let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; - let result = handle_lock_mode(&lock_path, composer_json_content, false, &console); - assert!(result.is_ok()); - - // Read back and verify hash changed - let updated_lock = lockfile::LockFile::read_from_file(&lock_path).unwrap(); - assert_ne!(updated_lock.content_hash, "old_hash_value"); - let expected_hash = - lockfile::LockFile::compute_content_hash(composer_json_content).unwrap(); - assert_eq!(updated_lock.content_hash, expected_hash); - } - - #[test] - fn test_handle_lock_mode_no_change_when_hash_matches() { - let dir = tempfile::tempdir().unwrap(); - let lock_path = dir.path().join("composer.lock"); - - let composer_json_content = r#"{"name": "test/project", "require": {}}"#; - let correct_hash = lockfile::LockFile::compute_content_hash(composer_json_content).unwrap(); - - let mut lock = minimal_lock(vec![]); - lock.content_hash = correct_hash.clone(); - lock.write_to_file(&lock_path).unwrap(); - - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; - let result = handle_lock_mode(&lock_path, composer_json_content, false, &console); - assert!(result.is_ok()); - - // Hash should not have changed - let reloaded = lockfile::LockFile::read_from_file(&lock_path).unwrap(); - assert_eq!(reloaded.content_hash, correct_hash); - } - - #[test] - fn test_handle_lock_mode_dry_run_does_not_write() { - let dir = tempfile::tempdir().unwrap(); - let lock_path = dir.path().join("composer.lock"); - - let mut lock = minimal_lock(vec![]); - lock.content_hash = "original_hash".to_string(); - lock.write_to_file(&lock_path).unwrap(); - - let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; - let result = handle_lock_mode(&lock_path, composer_json_content, true, &console); - assert!(result.is_ok()); - - // Hash should NOT have changed (dry_run=true) - let reloaded = lockfile::LockFile::read_from_file(&lock_path).unwrap(); - assert_eq!(reloaded.content_hash, "original_hash"); - } - // ──────────── glob_matches ──────────── #[test] @@ -2606,33 +2512,4 @@ mod tests { assert!(!lock.packages.is_empty()); assert!(lock.packages.iter().any(|p| p.name == "monolog/monolog")); } - - #[test] - fn test_update_lock_only_e2e() { - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let lock_path = dir.path().join("composer.lock"); - - // Write a lock with an outdated hash - let mut lock = minimal_lock(vec![]); - lock.content_hash = "outdated_hash".to_string(); - lock.write_to_file(&lock_path).unwrap(); - - let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - let expected_hash = - lockfile::LockFile::compute_content_hash(composer_json_content).unwrap(); - - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; - handle_lock_mode(&lock_path, composer_json_content, false, &console).unwrap(); - - let updated = lockfile::LockFile::read_from_file(&lock_path).unwrap(); - assert_eq!(updated.content_hash, expected_hash); - // The packages should be unchanged (lock mode doesn't resolve) - assert!(updated.packages.is_empty()); - } } -- cgit v1.3.1