diff --git a/.changeset/minimum-release-age-exclude-multi-entry.md b/.changeset/minimum-release-age-exclude-multi-entry.md new file mode 100644 index 0000000000..d93e7f6b35 --- /dev/null +++ b/.changeset/minimum-release-age-exclude-multi-entry.md @@ -0,0 +1,6 @@ +--- +"@pnpm/config.version-policy": patch +"pnpm": patch +--- + +Fixed `minimumReleaseAgeExclude` and `trustPolicyExclude` so multiple exact-version entries for the same package behave the same as a single `||` disjunction entry. Previously only the first matching rule's versions were honored, so a config like `[form-data@4.0.6, form-data@2.5.6]` could still flag `form-data@2.5.6` as violating `minimumReleaseAge`, while `[form-data@4.0.6 || 2.5.6]` worked as expected [#12463](https://github.com/pnpm/pnpm/issues/12463). diff --git a/config/version-policy/src/index.ts b/config/version-policy/src/index.ts index d49716130b..98079ff75c 100644 --- a/config/version-policy/src/index.ts +++ b/config/version-policy/src/index.ts @@ -72,16 +72,27 @@ export function expandPackageVersionSpecs (specs: string[]): Set { } function evaluateVersionPolicy (rules: VersionPolicyRule[], pkgName: string): boolean | string[] { + let matchedVersions: string[] | undefined + let seen: Set | undefined for (const { nameMatcher, exactVersions } of rules) { if (!nameMatcher(pkgName)) { continue } if (exactVersions.length === 0) { - return true + return matchedVersions ?? true + } + if (matchedVersions == null) { + matchedVersions = [] + seen = new Set() + } + for (const version of exactVersions) { + if (!seen!.has(version)) { + seen!.add(version) + matchedVersions.push(version) + } } - return exactVersions } - return false + return matchedVersions ?? false } interface VersionPolicyRule { diff --git a/config/version-policy/test/index.ts b/config/version-policy/test/index.ts index 14df0b0341..7dde678b58 100644 --- a/config/version-policy/test/index.ts +++ b/config/version-policy/test/index.ts @@ -52,6 +52,34 @@ test('createPackageVersionPolicy()', () => { const match = createPackageVersionPolicy(['pkg@1.0.0||1.0.1 || 1.0.2']) expect(match('pkg')).toStrictEqual(['1.0.0', '1.0.1', '1.0.2']) } + { + const match = createPackageVersionPolicy(['form-data@4.0.6', 'form-data@2.5.6']) + expect(match('form-data')).toStrictEqual(['4.0.6', '2.5.6']) + } + { + const match = createPackageVersionPolicy(['form-data@4.0.6', 'form-data@2.5.6 || 2.5.7']) + expect(match('form-data')).toStrictEqual(['4.0.6', '2.5.6', '2.5.7']) + } + { + const match = createPackageVersionPolicy(['form-data@4.0.6', 'form-data@4.0.6']) + expect(match('form-data')).toStrictEqual(['4.0.6']) + } + { + const match = createPackageVersionPolicy(['axios@1.12.2', 'axios']) + expect(match('axios')).toStrictEqual(['1.12.2']) + } + { + const match = createPackageVersionPolicy(['axios', 'axios@1.12.2']) + expect(match('axios')).toBe(true) + } + { + const match = createPackageVersionPolicy(['axios@1.12.2', 'ax*']) + expect(match('axios')).toStrictEqual(['1.12.2']) + } + { + const match = createPackageVersionPolicy(['ax*', 'axios@1.12.2']) + expect(match('axios')).toBe(true) + } }) test('createPackageVersionPolicyOrThrow() rewraps parser errors with INVALID_', () => { diff --git a/pacquet/crates/config/src/version_policy.rs b/pacquet/crates/config/src/version_policy.rs index 309f8f76e9..4b67df9895 100644 --- a/pacquet/crates/config/src/version_policy.rs +++ b/pacquet/crates/config/src/version_policy.rs @@ -111,9 +111,10 @@ pub enum PolicyMatch { } /// Matcher-based version policy built from a list of -/// `[@||...]` rules. Rules are walked -/// in order; the first whose name matcher accepts the input package -/// name wins. Mirrors upstream's [`createPackageVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L6-L13). +/// `[@||...]` rules. See +/// [`PackageVersionPolicy::matches`] for the evaluation semantics. +/// Mirrors upstream's +/// [`createPackageVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L6-L13). /// /// Used by `minimumReleaseAgeExclude` and `trustPolicyExclude`, both /// of which need wildcard name patterns (`is-*`, `@scope/*`) AND @@ -143,23 +144,37 @@ struct VersionPolicyRule { } impl PackageVersionPolicy { - /// Evaluate the policy against a package name. Returns the - /// matching rule's payload (`AnyVersion` for a bare-name rule, - /// `ExactVersions` for `name@versions...`), or `PolicyMatch::No` - /// when no rule matched. + /// Evaluate the policy against a package name, merging the exact + /// versions of all matching `name@version[...]` rules. + /// + /// A bare-name or wildcard rule matches every version, but never + /// widens exact versions already accumulated from earlier rules: a + /// wildcard listed after an exact-version rule does not silently + /// turn the exclusion into every version of the package. #[must_use] pub fn matches(&self, pkg_name: &str) -> PolicyMatch { + let mut merged: Option<(Vec, HashSet)> = None; for rule in &self.rules { if !rule.name_matcher.matches(pkg_name) { continue; } - return if rule.exact_versions.is_empty() { - PolicyMatch::AnyVersion - } else { - PolicyMatch::ExactVersions(rule.exact_versions.clone()) - }; + if rule.exact_versions.is_empty() { + return match merged { + Some((versions, _)) => PolicyMatch::ExactVersions(versions), + None => PolicyMatch::AnyVersion, + }; + } + let (acc, seen) = merged.get_or_insert_with(|| (Vec::new(), HashSet::new())); + for version in &rule.exact_versions { + if seen.insert(version.clone()) { + acc.push(version.clone()); + } + } + } + match merged { + Some((versions, _)) => PolicyMatch::ExactVersions(versions), + None => PolicyMatch::No, } - PolicyMatch::No } } diff --git a/pacquet/crates/config/src/version_policy/tests.rs b/pacquet/crates/config/src/version_policy/tests.rs index 1ffc077eb9..d2624d9ff4 100644 --- a/pacquet/crates/config/src/version_policy/tests.rs +++ b/pacquet/crates/config/src/version_policy/tests.rs @@ -116,13 +116,66 @@ fn create_policy_scoped_bare_name_returns_any_version() { } #[test] -fn create_policy_first_matching_rule_wins() { +fn create_policy_distinct_name_rules() { let policy = create_package_version_policy(["axios@1.12.2", "lodash@4.17.21", "is-*"]).unwrap(); assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()])); assert_eq!(policy.matches("lodash"), PolicyMatch::ExactVersions(vec!["4.17.21".to_string()])); assert_eq!(policy.matches("is-odd"), PolicyMatch::AnyVersion); } +#[test] +fn create_policy_multiple_exact_version_rules_for_same_name_merge() { + let policy = create_package_version_policy(["form-data@4.0.6", "form-data@2.5.6"]).unwrap(); + assert_eq!( + policy.matches("form-data"), + PolicyMatch::ExactVersions(vec!["4.0.6".to_string(), "2.5.6".to_string()]), + ); +} + +#[test] +fn create_policy_merges_exact_versions_and_unions_for_same_name() { + let policy = + create_package_version_policy(["form-data@4.0.6", "form-data@2.5.6 || 2.5.7"]).unwrap(); + assert_eq!( + policy.matches("form-data"), + PolicyMatch::ExactVersions(vec![ + "4.0.6".to_string(), + "2.5.6".to_string(), + "2.5.7".to_string(), + ]), + ); +} + +#[test] +fn create_policy_deduplicates_repeated_versions_across_rules() { + let policy = create_package_version_policy(["form-data@4.0.6", "form-data@4.0.6"]).unwrap(); + assert_eq!(policy.matches("form-data"), PolicyMatch::ExactVersions(vec!["4.0.6".to_string()])); +} + +#[test] +fn create_policy_bare_rule_after_exact_keeps_exact_versions() { + let policy = create_package_version_policy(["axios@1.12.2", "axios"]).unwrap(); + assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()])); +} + +#[test] +fn create_policy_bare_rule_listed_first_wins_over_later_exact() { + let policy = create_package_version_policy(["axios", "axios@1.12.2"]).unwrap(); + assert_eq!(policy.matches("axios"), PolicyMatch::AnyVersion); +} + +#[test] +fn create_policy_wildcard_after_exact_keeps_exact_versions() { + let policy = create_package_version_policy(["axios@1.12.2", "ax*"]).unwrap(); + assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()])); +} + +#[test] +fn create_policy_wildcard_listed_first_wins_over_later_exact() { + let policy = create_package_version_policy(["ax*", "axios@1.12.2"]).unwrap(); + assert_eq!(policy.matches("axios"), PolicyMatch::AnyVersion); +} + #[test] fn create_policy_range_specifier_in_version_errors() { let err = create_package_version_policy(["lodash@^4.17.0"]).expect_err("must reject");