aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-04 00:13:04 +0900
committernsfisis <nsfisis@gmail.com>2026-05-04 00:13:04 +0900
commit16f8cf26db22c4fb1073b55d57d31110ea9773cc (patch)
tree6e6b5d02c3099160ca0c5c0616f7e86f79f2d1d4 /crates/mozart/src
parent837327901f28b229695c7cfd435a2c4f5fe2763d (diff)
downloadphp-mozart-16f8cf26db22c4fb1073b55d57d31110ea9773cc.tar.gz
php-mozart-16f8cf26db22c4fb1073b55d57d31110ea9773cc.tar.zst
php-mozart-16f8cf26db22c4fb1073b55d57d31110ea9773cc.zip
fix(update): run full resolve under --lock to surface alias changes
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 `<lock-version> as <alias>` from `lock.aliases` when building the mirrors-mode require list, so the resolver's alias extractor materializes the alias entry. The bare `<version>` form is required because `==<version>` 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) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src')
-rw-r--r--crates/mozart/src/commands/install.rs21
-rw-r--r--crates/mozart/src/commands/update.rs209
2 files changed, 58 insertions, 172 deletions
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<PackageOperation<'_>> = 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 <info>{}</info> (<comment>{}</comment>)",
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 <alias>` 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<String> {
+ lock.aliases
+ .iter()
+ .find(|a| a.package.eq_ignore_ascii_case(name))
+ .map(|a| a.alias.clone())
+ };
+ // The alias-bearing form uses the bare `<version>` instead of
+ // `==<version>` 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)
@@ -1815,48 +1843,6 @@ pub async fn run(
}
// ─────────────────────────────────────────────────────────────────────────────
-// --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());
- }
}