diff --git a/.changeset/fix-resolver-exports-pattern-specificity.md b/.changeset/fix-resolver-exports-pattern-specificity.md new file mode 100644 index 000000000000..7bb47f083dd3 --- /dev/null +++ b/.changeset/fix-resolver-exports-pattern-specificity.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9370](https://github.com/biomejs/biome/issues/9370): The resolver now correctly prioritizes more specific `exports` patterns over less specific ones. Previously, a pattern like `"./*"` could match before `"./features/*"`, causing resolution failures for packages with overlapping subpath patterns. diff --git a/crates/biome_resolver/src/lib.rs b/crates/biome_resolver/src/lib.rs index abff734def16..a7491c277077 100644 --- a/crates/biome_resolver/src/lib.rs +++ b/crates/biome_resolver/src/lib.rs @@ -4,7 +4,7 @@ mod errors; mod node_builtins; mod resolver_fs_proxy; -use std::{borrow::Cow, ops::Deref, sync::Arc}; +use std::{borrow::Cow, cmp::Ordering, ops::Deref, sync::Arc}; use biome_fs::normalize_path; use biome_json_value::{JsonObject, JsonValue}; @@ -335,9 +335,12 @@ fn resolve_export( /// `mapping` must be taken from either `imports` or `exports` key in a /// `package.json` file. /// -/// Keys in these mappings may contain globs with a single `*` character. If -/// present, the target value should also have a `*` character and the matching -/// characters are used as a literal replacement in the target value. +/// This function implements the `PACKAGE_IMPORTS_EXPORTS_RESOLVE` algorithm +/// from the Node.js spec: +/// +/// 1. Exact matches (keys without `*`) are checked first. +/// 2. Pattern keys (containing a single `*`) are sorted by +/// [`pattern_key_compare()`] (descending specificity) and tried in order. /// /// This function only implements the functionality that is common between /// `imports` or `exports`. [`resolve_export()`] contains additional logic that @@ -350,27 +353,71 @@ fn resolve_target_mapping( options: &ResolveOptions, ) -> Result { let subpath = normalize_subpath(subpath); + + // Step 1: Try exact matches first (keys without '*'). for (key, target) in mapping.iter() { let key = normalize_subpath(key.as_str()); - if let Some((start, end)) = key.split_once('*') { - if subpath.starts_with(start) && subpath.ends_with(end) { - let glob_replacement = &subpath[start.len()..subpath.len() - end.len()]; - return resolve_target_value( - target, - Some(glob_replacement), - package_path, - fs, - options, - ); - } - } else if key == subpath { + if !key.contains('*') && key == subpath { return resolve_target_value(target, None, package_path, fs, options); } } + // Step 2: Collect pattern keys (containing a single '*') and sort them + // by PATTERN_KEY_COMPARE (longer prefix first, then longer suffix). + let mut pattern_keys: Vec<_> = mapping + .iter() + .filter(|(key, _)| key.as_str().contains('*')) + .collect(); + pattern_keys.sort_by(|(a, _), (b, _)| { + pattern_key_compare(normalize_subpath(a.as_str()), normalize_subpath(b.as_str())) + }); + + // Step 3: Try pattern matches in sorted order. + for (key, target) in pattern_keys { + let key = normalize_subpath(key.as_str()); + if let Some((pattern_base, pattern_trailer)) = key.split_once('*') + && subpath.starts_with(pattern_base) + && subpath != pattern_base + && (pattern_trailer.is_empty() + || (subpath.ends_with(pattern_trailer) && subpath.len() >= key.len())) + { + let glob_replacement = + &subpath[pattern_base.len()..subpath.len() - pattern_trailer.len()]; + return resolve_target_value(target, Some(glob_replacement), package_path, fs, options); + } + } + Err(ResolveError::NotFound) } +/// Compares two pattern keys for sorting in descending order of specificity. +/// +/// Implements the `PATTERN_KEY_COMPARE` algorithm from the Node.js ESM +/// resolver specification. Keys with longer prefixes (before `*`) are +/// considered more specific. If prefixes have equal length, keys with longer +/// suffixes (after `*`) take priority. +fn pattern_key_compare(key_a: &str, key_b: &str) -> Ordering { + let base_length_a = key_a.find('*').unwrap_or(key_a.len()); + let base_length_b = key_b.find('*').unwrap_or(key_b.len()); + + if base_length_a > base_length_b { + return Ordering::Less; + } + if base_length_b > base_length_a { + return Ordering::Greater; + } + + // Equal base lengths: longer suffix (trailer) wins. + if key_a.len() > key_b.len() { + return Ordering::Less; + } + if key_b.len() > key_a.len() { + return Ordering::Greater; + } + + Ordering::Equal +} + /// Resolves the given module `specifier` inside the given `package_path` by /// looking it up in the `compilerOptions.paths` mapping inside the given /// `tsconfig_json`. @@ -500,6 +547,8 @@ fn resolve_target_value( resolve_target_value(target, glob_replacement, package_path, fs, options).ok() }) .ok_or(ResolveError::NotFound), + // A `null` target explicitly excludes the subpath from resolution. + JsonValue::Null => Err(ResolveError::NotFound), _ => Err(ResolveError::InvalidMappingTarget), } } diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/package.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/package.json new file mode 100644 index 000000000000..750ea22ebfdb --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/package.json @@ -0,0 +1,9 @@ +{ + "name": "@kcconfigs/biome", + "exports": { + ".": "./src/presets/default.json", + "./*": "./src/presets/*.json", + "./features/*": "./src/features/*.json", + "./features/private-internal/*": null + } +} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/features/private-internal/secret.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/features/private-internal/secret.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/features/private-internal/secret.json @@ -0,0 +1 @@ +{} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/features/svelte.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/features/svelte.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/features/svelte.json @@ -0,0 +1 @@ +{} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/presets/default.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/presets/default.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/node_modules/@kcconfigs/biome/src/presets/default.json @@ -0,0 +1 @@ +{} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/package.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/package.json new file mode 100644 index 000000000000..39f661cbdc8f --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/package.json @@ -0,0 +1,3 @@ +{ + "name": "test-app" +} diff --git a/crates/biome_resolver/tests/spec_tests.rs b/crates/biome_resolver/tests/spec_tests.rs index 0568b0506018..94eb49802d67 100644 --- a/crates/biome_resolver/tests/spec_tests.rs +++ b/crates/biome_resolver/tests/spec_tests.rs @@ -746,3 +746,68 @@ fn test_resolve_extension_alias_not_apply_to_extension_nor_main_files() { "file", ); } + +/// Tests that exports patterns are sorted by specificity per the Node.js +/// PATTERN_KEY_COMPARE algorithm (longer prefix wins), so `./features/*` +/// takes priority over `./*` when resolving `features/svelte`. +/// +/// Regression test for https://github.com/biomejs/biome/issues/9370 +#[test] +fn test_resolve_exports_pattern_specificity() { + let base_dir = get_fixtures_path("resolver_cases_8"); + let fs = OsFileSystem::new(base_dir.clone()); + + // Exact match: "." -> "./src/presets/default.json" + assert_eq!( + resolve( + "@kcconfigs/biome", + &base_dir, + &fs, + &ResolveOptions::default() + ), + Ok(Utf8PathBuf::from(format!( + "{base_dir}/node_modules/@kcconfigs/biome/src/presets/default.json" + ))), + "exact match on root export", + ); + // Pattern match: "./*" -> "./src/presets/*.json" + assert_eq!( + resolve( + "@kcconfigs/biome/default", + &base_dir, + &fs, + &ResolveOptions::default() + ), + Ok(Utf8PathBuf::from(format!( + "{base_dir}/node_modules/@kcconfigs/biome/src/presets/default.json" + ))), + "wildcard match on ./* pattern", + ); + + // More specific pattern: "./features/*" must take priority over "./*" + // This is the core bug from #9370. + assert_eq!( + resolve( + "@kcconfigs/biome/features/svelte", + &base_dir, + &fs, + &ResolveOptions::default() + ), + Ok(Utf8PathBuf::from(format!( + "{base_dir}/node_modules/@kcconfigs/biome/src/features/svelte.json" + ))), + "./features/* must match before ./* for features/svelte", + ); + + // Null target: "./features/private-internal/*" -> null (explicitly excluded) + assert_eq!( + resolve( + "@kcconfigs/biome/features/private-internal/secret", + &base_dir, + &fs, + &ResolveOptions::default() + ), + Err(ResolveError::NotFound), + "null target should block resolution", + ); +}