From 26af378d81da76c50593674fa86ed4911aa0e46f Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 23:02:32 +0900 Subject: fix(update): pattern-match allow-list specifiers and reuse locked metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related parity gaps surfaced by the `update-allow-list-patterns` fixture: 1. `mozart-semver`'s wildcard parser turned `*.*` into `>=0 <1` (a single-major range) because stripping the trailing `.*` left `*` in the major slot, which `parse()` quietly read as `0`. Composer reduces such patterns to a plain `*` (unconstrained) — match that and short-circuit when the stripped base is `*`. 2. `expand_wildcards` passed any non-wildcard specifier straight through, so a typo like `notexact/Test` (lock has `notexact/testpackage`) entered the resolver as a real package name and failed lookup. Mirror Composer's regex-based `isUpdateAllowed`/`warnAboutNonMatchingUpdateAllowList`: every specifier — wildcard or not — is matched against locked names *and* current root-require names, with `*` expanded to `.*`, and unmatched specs are warned and dropped instead of forwarded. 3. The lockfile generator's metadata loop hit the empty test repo set when a partial update kept a non-allow-listed package at its locked version that the inline repo no longer advertised, and bailed with "Could not find version". Add a `previous_lock` fallback that synthesizes a `PackagistVersion` straight off the `LockedPackage` so the lock entry's own metadata stays authoritative for packages that aren't moving. --- crates/mozart/src/commands/update.rs | 79 ++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 27 deletions(-) (limited to 'crates/mozart/src/commands') diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 0439cfa..f065c2a 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -471,10 +471,18 @@ fn glob_segment_matches_inner(pattern: &[u8], text: &[u8]) -> bool { pub fn expand_wildcards( specifiers: &[String], lock: &lockfile::LockFile, + root_requires: &IndexSet, console: &mozart_core::console::Console, ) -> Vec { - // Collect all locked package names (prod + dev) - let all_names: Vec = lock + // Collect all locked package names (prod + dev) plus the current root + // require names. Mirrors Composer's + // `PoolBuilder::warnAboutNonMatchingUpdateAllowList`, which accepts a + // pattern as soon as it matches *either* a locked package or a root + // require (so `update new/pkg` works even when `new/pkg` was just + // added to composer.json and isn't in the lock yet). Names appear in + // declaration order; deduplication happens implicitly via `seen` + // below. + let mut all_names: Vec = lock .packages .iter() .map(|p| p.name.to_lowercase()) @@ -485,32 +493,41 @@ pub fn expand_wildcards( .map(|p| p.name.to_lowercase()), ) .collect(); + for name in root_requires { + let lower = name.to_lowercase(); + if !all_names.contains(&lower) { + all_names.push(lower); + } + } let mut result: Vec = Vec::new(); let mut seen: IndexSet = IndexSet::new(); for spec in specifiers { - if spec.contains('*') { - // Expand the wildcard against the lock - let mut matched = false; - for name in &all_names { - if glob_matches(spec, name) && seen.insert(name.clone()) { - result.push(name.clone()); - matched = true; - } - } - if !matched { - console.info(&console::warning(&format!( - "No locked packages matched the pattern '{}'. Pattern will be ignored.", - spec - ))); - } - } else { - let lower = spec.to_lowercase(); - if seen.insert(lower.clone()) { - result.push(lower); + // Mirror Composer's `BasePackage::packageNameToRegexp` + the + // `isUpdateAllowed` walk over locked packages: the pattern is + // matched case-insensitively against each locked name, with `*` + // expanded to `.*` and every other character treated literally. + // Specs that match no locked package are warned about and dropped + // — for a non-wildcard spec like `notexact/Test` that's typo'd + // against `notexact/testpackage`, this prevents Mozart from + // forwarding the bogus name into the resolver (which would then + // fail looking it up). Genuinely new packages are still picked up + // by the resolver via `composer.json` root requires regardless of + // whether they appear in `update_packages`. + let mut matched = false; + for name in &all_names { + if glob_matches(spec, name) && seen.insert(name.clone()) { + result.push(name.clone()); + matched = true; } } + if !matched { + console.info(&console::warning(&format!( + "Package '{}' listed for update is not in the lock file. Specifier will be ignored.", + spec + ))); + } } result @@ -754,7 +771,7 @@ pub fn expand_packages( console: &mozart_core::console::Console, ) -> Vec { let mut packages: Vec = if let Some(lock) = lock { - expand_wildcards(specifiers, lock, console) + expand_wildcards(specifiers, lock, root_requires, console) } else { // No lock file: pass through as-is (no wildcards can be resolved) specifiers.iter().map(|s| s.to_lowercase()).collect() @@ -2240,8 +2257,12 @@ mod tests { #[test] fn test_expand_wildcards_no_wildcard_passthrough() { let lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]); + let root_requires: IndexSet = ["psr/log", "nonexistent/pkg"] + .into_iter() + .map(String::from) + .collect(); let specs = vec!["psr/log".to_string(), "nonexistent/pkg".to_string()]; - let result = expand_wildcards(&specs, &lock, &test_console()); + let result = expand_wildcards(&specs, &lock, &root_requires, &test_console()); assert_eq!(result, vec!["psr/log", "nonexistent/pkg"]); } @@ -2253,7 +2274,8 @@ mod tests { make_locked_package("monolog/monolog", "3.8.0"), ]); let specs = vec!["symfony/*".to_string()]; - let mut result = expand_wildcards(&specs, &lock, &test_console()); + let root_requires: IndexSet = IndexSet::new(); + let mut result = expand_wildcards(&specs, &lock, &root_requires, &test_console()); result.sort(); assert_eq!(result, vec!["symfony/console", "symfony/http-kernel"]); } @@ -2262,8 +2284,9 @@ mod tests { fn test_expand_wildcards_no_match_emits_warning() { let lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]); let specs = vec!["unknown/*".to_string()]; + let root_requires: IndexSet = IndexSet::new(); // Should return empty (no match), no panic - let result = expand_wildcards(&specs, &lock, &test_console()); + let result = expand_wildcards(&specs, &lock, &root_requires, &test_console()); assert!(result.is_empty()); } @@ -2271,7 +2294,8 @@ mod tests { fn test_expand_wildcards_deduplication() { let lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]); let specs = vec!["psr/log".to_string(), "psr/log".to_string()]; - let result = expand_wildcards(&specs, &lock, &test_console()); + let root_requires: IndexSet = IndexSet::new(); + let result = expand_wildcards(&specs, &lock, &root_requires, &test_console()); assert_eq!(result.len(), 1); assert_eq!(result[0], "psr/log"); } @@ -2281,7 +2305,8 @@ mod tests { let mut lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]); lock.packages_dev = Some(vec![make_locked_package("phpunit/phpunit", "11.0.0")]); let specs = vec!["phpunit/*".to_string()]; - let result = expand_wildcards(&specs, &lock, &test_console()); + let root_requires: IndexSet = IndexSet::new(); + let result = expand_wildcards(&specs, &lock, &root_requires, &test_console()); assert_eq!(result, vec!["phpunit/phpunit"]); } -- cgit v1.3.1