From 85b603d2d4973364f5bc7a7c9710b8955b272d53 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:11:42 +0000 Subject: [PATCH 01/12] semver: collapse a || union containing a match-all branch to * before the prerelease rule node-semver's Range constructor collapses the whole range to * when any ||-alternative is the ANY comparator (see range.js L57-67). Under the default prerelease rule * rejects prereleases, so "x || 1.2.3-alpha.1" rejects 1.2.3-alpha.1 in npm but accepted it in Bun, which evaluated each branch independently. Track during parse whether any explicit ||-delimited branch is entirely match-all (*, x, X, >=0.0.0, ^*, ~*, hyphen-to-wildcard, or empty), set Flags::MATCH_ALL_BRANCH, and have Group::satisfies reject prereleases when the flag is set. Release versions are unaffected. --- src/semver/SemverQuery.rs | 57 +++++++++++++++++++++++++-------- src/semver/SemverRange.rs | 13 ++++++++ test/cli/install/semver.test.ts | 28 ++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index 65a52e8825cd..ed6f42844900 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -350,6 +350,10 @@ pub struct Flags; impl Flags { pub const PRE: usize = 1; pub const BUILD: usize = 0; + /// Set when an explicit `||` union contains a branch that is entirely the + /// ANY comparator (`*`, `x`, `>=0.0.0`, ...). node-semver collapses such a + /// range to `*` before applying the prerelease rule. + pub const MATCH_ALL_BRANCH: usize = 2; } pub struct Group { @@ -462,6 +466,7 @@ impl Group { let range = &self.head.head.range; if self.head.next.is_none() && self.head.head.next.is_none() + && !self.flags.is_set(Flags::MATCH_ALL_BRANCH) && range.has_left() && range.left.op == RangeOp::Eql && !range.has_right() @@ -578,6 +583,11 @@ impl Group { #[inline] pub fn satisfies(&self, version: Version, group_buf: &[u8], version_buf: &[u8]) -> bool { if version.tag.has_pre() { + // node-semver collapses a `||`-union containing a match-all branch + // to `*`, which then rejects every prerelease (see range.js L57-67). + if self.flags.is_set(Flags::MATCH_ALL_BRANCH) { + return false; + } self.head.satisfies_pre(version, group_buf, version_buf) } else { self.head.satisfies(version, group_buf, version_buf) @@ -855,6 +865,8 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { let mut count: u32 = 0; let mut skip_round; let mut is_or = false; + let mut saw_or_separator = false; + let mut branch_has_non_any = false; while i < input.len() { skip_round = false; @@ -926,6 +938,11 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { while i < input.len() && input[i] == b' ' { i += 1; } + if !branch_has_non_any { + list.flags.set_value(Flags::MATCH_ALL_BRANCH, true); + } + saw_or_separator = true; + branch_has_non_any = false; is_or = true; token.tag = TokenTag::None; skip_round = true; @@ -1074,6 +1091,9 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { }, }; + if !range.is_match_all() { + branch_has_non_any = true; + } if is_or { list.or_range(&range)?; } else { @@ -1090,21 +1110,26 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { // tag, like "1 || - foo". token.wildcard = Wildcard::None; continue; - } else if count == 0 && token.tag == TokenTag::Version { - match parse_result.wildcard { - Wildcard::None => { - list.or_version(version)?; - } - _ => { - list.or_range(&token.to_range(&parse_result.version))?; - } - } - } else if count == 0 { - list.and_range(&token.to_range(&parse_result.version))?; - } else if is_or { - list.or_range(&token.to_range(&parse_result.version))?; + } else if count == 0 + && token.tag == TokenTag::Version + && parse_result.wildcard == Wildcard::None + { + branch_has_non_any = true; + list.or_version(version)?; } else { - list.and_range(&token.to_range(&parse_result.version))?; + let range = token.to_range(&parse_result.version); + if !range.is_match_all() { + branch_has_non_any = true; + } + if count == 0 && token.tag == TokenTag::Version { + list.or_range(&range)?; + } else if count == 0 { + list.and_range(&range)?; + } else if is_or { + list.or_range(&range)?; + } else { + list.and_range(&range)?; + } } is_or = false; @@ -1113,5 +1138,9 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { } } + if saw_or_separator && !branch_has_non_any { + list.flags.set_value(Flags::MATCH_ALL_BRANCH, true); + } + Ok(list) } diff --git a/src/semver/SemverRange.rs b/src/semver/SemverRange.rs index b24f716eeb5c..52e82a45e514 100644 --- a/src/semver/SemverRange.rs +++ b/src/semver/SemverRange.rs @@ -131,6 +131,19 @@ impl Range { self.right.op != Op::Unset } + /// True when this range is node-semver's ANY comparator (`*` / `x` / + /// `>=0.0.0`): it accepts every release version and, under the default + /// prerelease rule, no prerelease. + pub fn is_match_all(self) -> bool { + if !self.has_left() { + return true; + } + self.left.op == Op::Gte + && self.left.version.is_zero() + && !self.left.version.tag.has_pre() + && !self.has_right() + } + /// Is the Range equal to another Range /// This does not evaluate the range. #[inline] diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 266117ab69ca..36f4c6028a23 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -357,6 +357,34 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("^3.0.0-next.0||^3.0.0", "3.0.0-next.2", true); + // node-semver collapses a `||`-union containing a match-all branch to `*` + // before applying the prerelease rule, so the prerelease is rejected even + // though a sibling branch names it explicitly. + for (const any of ["*", "x", "X", "x.x", "x.x.x", ">=0.0.0", ">=0.0.0+build", "* *", "0.0.0 - x", "^*", "~*", ""]) { + testSatisfies(`${any} || 1.2.3-alpha.1`, "1.2.3-alpha.1", false); + testSatisfies(`1.2.3-alpha.1 || ${any}`, "1.2.3-alpha.1", false); + testSatisfies(`${any} || ^1.2.3-alpha`, "1.2.3-alpha.1", false); + testSatisfies(`${any} || 1.2.3-alpha.1 || ${any}`, "1.2.3-alpha.1", false); + } + for (const any of ["*", "x", "X", "x.x", "x.x.x", ">=0.0.0", ">=0.0.0+build", "* *", "0.0.0 - x"]) { + // A release version still satisfies the collapsed `*`. + testSatisfies(`${any} || 1.2.3-alpha.1`, "1.2.4", true); + } + testSatisfies("|| 1.2.3-alpha.1", "1.2.3-alpha.1", false); + testSatisfies("1.2.3-alpha.1 ||", "1.2.3-alpha.1", false); + // A non-match-all first branch keeps union semantics. + testSatisfies("1.x || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies(">0.0.0 || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies(">=1.0.0 || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + // `>=0.0.0-0` has a prerelease tag so it is not the ANY comparator. + testSatisfies(">=0.0.0-0 || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + // `*` alongside a narrower comparator in the same `||`-branch is not a + // match-all branch: node-semver drops the `*` and keeps the narrower one. + testSatisfies("* 2.0.0 || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("2.0.0 * || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("2.0.0 || * 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("* 1.2.3-alpha.1", "1.2.3-alpha.1", true); + const notPassing = [ "0.1.0", "0.10.0", From e9222bfadbceebc0859c0545c2f55509759c692b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:26:00 +0000 Subject: [PATCH 02/12] ci: retrigger From 0d85b615c24fcb3ecd87e3d10da6792260cfafe0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:53:45 +0000 Subject: [PATCH 03/12] semver: treat skipped dist-tag/garbage branches as non-ANY; guard is_exact() A ||-branch consisting only of a skipped dist-tag token (the "1.0.0 || boop" shape the parser already handles via the catch-all arm) emitted no comparator and left branch_has_non_any false, so it was misclassified as the ANY comparator and set MATCH_ALL_BRANCH. node-semver loose mode drops such branches via .filter(c => c.length) before the collapse step, so "1.2.3-alpha.1 || latest" must keep union semantics. Set branch_has_non_any in the catch-all arm. Also add the MATCH_ALL_BRANCH guard to is_exact() to keep it in sync with get_exact_version(). --- src/semver/SemverQuery.rs | 4 ++++ test/cli/install/semver.test.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index ed6f42844900..7c72a55aec42 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -503,6 +503,7 @@ impl Group { pub fn is_exact(&self) -> bool { self.head.next.is_none() && self.head.head.next.is_none() + && !self.flags.is_set(Flags::MATCH_ALL_BRANCH) && !self.head.head.range.has_right() && self.head.head.range.left.op == RangeOp::Eql } @@ -970,6 +971,9 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { while i < input.len() && input[i] != b' ' && input[i] != b'|' { i += 1; } + // node-semver loose mode drops these tokens via `.filter(c => c.length)` + // before the collapse-to-`*` step, so a garbage-only branch is not ANY. + branch_has_non_any = true; skip_round = true; } } diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 36f4c6028a23..32b68bb5ee77 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -384,6 +384,13 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("2.0.0 * || 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("2.0.0 || * 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("* 1.2.3-alpha.1", "1.2.3-alpha.1", true); + // A dist-tag/garbage branch is dropped by node-semver loose mode before + // the collapse-to-`*` step, so it is not a match-all branch. + testSatisfies("1.2.3-alpha.1 || boop", "1.2.3-alpha.1", true); + testSatisfies("boop || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("1.2.3-alpha.1 || latest", "1.2.3-alpha.1", true); + testSatisfies("latest || 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("boop || * || 1.2.3-alpha.1", "1.2.3-alpha.1", false); const notPassing = [ "0.1.0", From 5ae87f1f99219424bc67a69d620f4ddd5b7abe21 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:27:35 +0000 Subject: [PATCH 04/12] semver: treat a bare operator with no operand as non-ANY A ||-branch consisting solely of "^", "~" or ">=" with no operand emits a comparator synthesized from a zero-length Version::parse whose range passes is_match_all(), so it was misclassified as ANY and set MATCH_ALL_BRANCH. node-semver loose mode drops such branches via .filter(c => c.length) before the collapse step, so "1.2.3-alpha.1 || ^" must keep union semantics. Gate the match-all classification on parse_result.len > 0. --- src/semver/SemverQuery.rs | 5 ++++- test/cli/install/semver.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index 7c72a55aec42..69dfc0e92f42 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -1122,7 +1122,10 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { list.or_version(version)?; } else { let range = token.to_range(&parse_result.version); - if !range.is_match_all() { + // A bare operator with no operand (`^`, `~`, `>=` at end of + // branch) parses len==0 and node-semver loose mode drops it + // before the collapse step, so it is not the ANY comparator. + if !range.is_match_all() || parse_result.len == 0 { branch_has_non_any = true; } if count == 0 && token.tag == TokenTag::Version { diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 32b68bb5ee77..72f401aa32ac 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -391,6 +391,11 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("1.2.3-alpha.1 || latest", "1.2.3-alpha.1", true); testSatisfies("latest || 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("boop || * || 1.2.3-alpha.1", "1.2.3-alpha.1", false); + // A bare operator with no operand is likewise dropped, not treated as `*`. + for (const op of ["^", "~", ">=", ">", "<", "<=", "=", "v"]) { + testSatisfies(`1.2.3-alpha.1 || ${op}`, "1.2.3-alpha.1", true); + testSatisfies(`${op} || 1.2.3-alpha.1`, "1.2.3-alpha.1", true); + } const notPassing = [ "0.1.0", From a355a12491c01a7c3b9ab56ff7da8e862a4d77bc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:12:12 +0000 Subject: [PATCH 05/12] semver: treat a bare '-' branch as non-ANY; compare MATCH_ALL_BRANCH in Group::eql The TokenTag::None continue path (reached by a bare '-' in a ||-branch) emitted no comparator and left branch_has_non_any false, so it was misclassified as ANY. node-semver loose mode drops it via .filter(c => c.length) before the collapse step, so "1.2.3-alpha.1 || -" must keep union semantics. Mirror the catch-all arm fix from 0d85b615c2. Also compare MATCH_ALL_BRANCH in Group::eql so two Groups that differ only in the flag are not considered equal (their satisfies() results can now differ for prerelease versions). --- src/semver/SemverQuery.rs | 4 +++- test/cli/install/semver.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index 69dfc0e92f42..754e432b9bfa 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -520,7 +520,8 @@ impl Group { #[inline] pub fn eql(&self, rhs: &Group) -> bool { - self.head.eql(&rhs.head) + self.flags.is_set(Flags::MATCH_ALL_BRANCH) == rhs.flags.is_set(Flags::MATCH_ALL_BRANCH) + && self.head.eql(&rhs.head) } pub fn to_version(&self) -> Version { @@ -1112,6 +1113,7 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { // covers a leading "--foo" (treat "--foo" the same as "-foo", example: // foo/bar@1.2.3@--canary.24) as well as a dangling "-" after a skipped // tag, like "1 || - foo". + branch_has_non_any = true; token.wildcard = Wildcard::None; continue; } else if count == 0 diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 72f401aa32ac..7f0c4336203c 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -392,7 +392,7 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("latest || 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("boop || * || 1.2.3-alpha.1", "1.2.3-alpha.1", false); // A bare operator with no operand is likewise dropped, not treated as `*`. - for (const op of ["^", "~", ">=", ">", "<", "<=", "=", "v"]) { + for (const op of ["^", "~", ">=", ">", "<", "<=", "=", "v", "-", "- -", "- boop"]) { testSatisfies(`1.2.3-alpha.1 || ${op}`, "1.2.3-alpha.1", true); testSatisfies(`${op} || 1.2.3-alpha.1`, "1.2.3-alpha.1", true); } From 6663913e32056140cfc7faab228a3d244e56d944 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:59:12 +0000 Subject: [PATCH 06/12] semver: gate match-all classification on a real operand char at the parse position The previous len==0 check only covered a bare operator at end of input. Version::parse consumes leading v/=/whitespace into len and treats '|' as a clean delimiter (valid stays true), so neither len==0 nor !valid distinguishes a real wildcard from junk like 'vv', '==', '^v' in a ||-branch. Instead check whether the byte at the parse start is a digit or wildcard char; node-semver loose mode drops anything else before the collapse step. --- src/semver/SemverQuery.rs | 9 +++++---- test/cli/install/semver.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index 754e432b9bfa..c6d8f0ccf3d8 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -980,6 +980,7 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { } if !skip_round { + let has_real_operand = matches!(input.get(i), Some(b'0'..=b'9' | b'x' | b'X' | b'*')); let parse_result = Version::parse(sliced.sub(&input[i..])); let version = parse_result.version.min(); if version.tag.has_build() { @@ -1124,10 +1125,10 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { list.or_version(version)?; } else { let range = token.to_range(&parse_result.version); - // A bare operator with no operand (`^`, `~`, `>=` at end of - // branch) parses len==0 and node-semver loose mode drops it - // before the collapse step, so it is not the ANY comparator. - if !range.is_match_all() || parse_result.len == 0 { + // An operator whose operand is not a real version or wildcard + // char (`^`, `~`, `>=`, `^v`, `vv`, ...) is dropped by + // node-semver loose mode before the collapse step. + if !range.is_match_all() || !has_real_operand { branch_has_non_any = true; } if count == 0 && token.tag == TokenTag::Version { diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 7f0c4336203c..699673509780 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -392,7 +392,7 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("latest || 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("boop || * || 1.2.3-alpha.1", "1.2.3-alpha.1", false); // A bare operator with no operand is likewise dropped, not treated as `*`. - for (const op of ["^", "~", ">=", ">", "<", "<=", "=", "v", "-", "- -", "- boop"]) { + for (const op of ["^", "~", ">=", ">", "<", "<=", "=", "v", "-", "- -", "- boop", "vv", "==", "^v", "~=", ">=v", "^\t"]) { testSatisfies(`1.2.3-alpha.1 || ${op}`, "1.2.3-alpha.1", true); testSatisfies(`${op} || 1.2.3-alpha.1`, "1.2.3-alpha.1", true); } From df6eb9afaae1d33d2d5d981780864d4d19ceedab Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:01:15 +0000 Subject: [PATCH 07/12] [autofix.ci] apply automated fixes --- test/cli/install/semver.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 699673509780..22dd724a9692 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -392,7 +392,25 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("latest || 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("boop || * || 1.2.3-alpha.1", "1.2.3-alpha.1", false); // A bare operator with no operand is likewise dropped, not treated as `*`. - for (const op of ["^", "~", ">=", ">", "<", "<=", "=", "v", "-", "- -", "- boop", "vv", "==", "^v", "~=", ">=v", "^\t"]) { + for (const op of [ + "^", + "~", + ">=", + ">", + "<", + "<=", + "=", + "v", + "-", + "- -", + "- boop", + "vv", + "==", + "^v", + "~=", + ">=v", + "^\t", + ]) { testSatisfies(`1.2.3-alpha.1 || ${op}`, "1.2.3-alpha.1", true); testSatisfies(`${op} || 1.2.3-alpha.1`, "1.2.3-alpha.1", true); } From b7412e5fe413534364e76110d2d239ef40031db3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:24:49 +0000 Subject: [PATCH 08/12] semver: don't treat a lone '|' as a '||' separator for the match-all check node-semver only splits on '||'; a lone '|' is garbage that loose mode drops before the collapse step. The b'|' arm consumed one-or-more pipes and ran the MATCH_ALL_BRANCH bookkeeping unconditionally, so '| 1.2.3-alpha.1' set the flag and rejected the prerelease. Gate the flag logic on having seen at least two consecutive pipes; a lone pipe now marks the branch as non-ANY like other garbage. --- src/semver/SemverQuery.rs | 15 +++++++++++---- test/cli/install/semver.test.ts | 4 ++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index c6d8f0ccf3d8..483560cdb039 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -934,17 +934,24 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { b'|' => { i += 1; + let is_double = i < input.len() && input[i] == b'|'; while i < input.len() && input[i] == b'|' { i += 1; } while i < input.len() && input[i] == b' ' { i += 1; } - if !branch_has_non_any { - list.flags.set_value(Flags::MATCH_ALL_BRANCH, true); + // node-semver only splits on `||`; a lone `|` is loose-mode + // garbage dropped before the collapse step, not a separator. + if is_double { + if !branch_has_non_any { + list.flags.set_value(Flags::MATCH_ALL_BRANCH, true); + } + saw_or_separator = true; + branch_has_non_any = false; + } else { + branch_has_non_any = true; } - saw_or_separator = true; - branch_has_non_any = false; is_or = true; token.tag = TokenTag::None; skip_round = true; diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 22dd724a9692..4b8d58e82184 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -414,6 +414,10 @@ describe("Bun.semver.satisfies()", () => { testSatisfies(`1.2.3-alpha.1 || ${op}`, "1.2.3-alpha.1", true); testSatisfies(`${op} || 1.2.3-alpha.1`, "1.2.3-alpha.1", true); } + // A lone `|` is not the `||` separator and is dropped in loose mode. + testSatisfies("| 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("1.2.3-alpha.1 |", "1.2.3-alpha.1", true); + testSatisfies("* | 1.2.3-alpha.1", "1.2.3-alpha.1", true); const notPassing = [ "0.1.0", From 13ddb39a714ca2ede66c4cf0876d8e05125e5fac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:40:13 +0000 Subject: [PATCH 09/12] retrigger gate: release build infra failure From b5276d0d9e608dcc8bea829546d2e2798437e50e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:15:47 +0000 Subject: [PATCH 10/12] semver: tighten ANY classification in hyphenate arm and require valid parse Gate the hyphenate arm's match-all check on token.tag in {Version, Gte} so operator-prefixed hyphen ranges like '^0.0.0 - x' (which npm does not treat as hyphen ranges) are not classified as ANY. Also require parse_result.valid in the fallthrough arm so wildcard-prefixed garbage that Version::parse rejects ('xy', '*a', '^xy') is not classified as ANY. Closes most of the remaining adversarial edge cases surfaced in review; the handful that remain ('xx', 'x.', '=0.0.0 - x') depend on bun's lenient tokenizer accepting shapes npm's regex chain rejects and are left as documented gaps. --- src/semver/SemverQuery.rs | 8 ++++++-- test/cli/install/semver.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index 483560cdb039..afffe66279a3 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -1104,7 +1104,11 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { }, }; - if !range.is_match_all() { + // npm's hyphen-range grammar only accepts a `[v=\s]*` prefix; + // an operator-prefixed left side is not a hyphen range there. + if !range.is_match_all() + || !matches!(token.tag, TokenTag::Version | TokenTag::Gte) + { branch_has_non_any = true; } if is_or { @@ -1135,7 +1139,7 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { // An operator whose operand is not a real version or wildcard // char (`^`, `~`, `>=`, `^v`, `vv`, ...) is dropped by // node-semver loose mode before the collapse step. - if !range.is_match_all() || !has_real_operand { + if !range.is_match_all() || !has_real_operand || !parse_result.valid { branch_has_non_any = true; } if count == 0 && token.tag == TokenTag::Version { diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 4b8d58e82184..414b2de01ed6 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -410,6 +410,15 @@ describe("Bun.semver.satisfies()", () => { "~=", ">=v", "^\t", + "xy", + "*a", + "^xy", + "xoop", + "^0.0.0 - x", + "~0.0.0 - x", + ">0.0.0 - x", + "<0.0.0 - x", + "<=0.0.0 - x", ]) { testSatisfies(`1.2.3-alpha.1 || ${op}`, "1.2.3-alpha.1", true); testSatisfies(`${op} || 1.2.3-alpha.1`, "1.2.3-alpha.1", true); From 0193881ed64f92360374492cd2deeb88549c68fe Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:17:48 +0000 Subject: [PATCH 11/12] [autofix.ci] apply automated fixes --- src/semver/SemverQuery.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index afffe66279a3..112d65ab3c31 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -1106,8 +1106,7 @@ pub fn parse(input: &[u8], sliced: SlicedString) -> Result { // npm's hyphen-range grammar only accepts a `[v=\s]*` prefix; // an operator-prefixed left side is not a hyphen range there. - if !range.is_match_all() - || !matches!(token.tag, TokenTag::Version | TokenTag::Gte) + if !range.is_match_all() || !matches!(token.tag, TokenTag::Version | TokenTag::Gte) { branch_has_non_any = true; } From 978d5a739836384b696e9321750363b5d9db7a14 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:47:15 +0000 Subject: [PATCH 12/12] semver: add ||| pipe-run test coverage Pins the cases where a 3+ pipe run already agrees with npm-loose (leading |||, trailing ||||, ||| followed by a comparator). The one shape that diverges (exactly three trailing pipes with nothing after) cannot be fixed without trading it for a regression in '||| *', since bun consumes the pipe run as one separator; it joins the documented gap set (xx, x., =0.0.0 - x). --- test/cli/install/semver.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/cli/install/semver.test.ts b/test/cli/install/semver.test.ts index 414b2de01ed6..379679cb7379 100644 --- a/test/cli/install/semver.test.ts +++ b/test/cli/install/semver.test.ts @@ -427,6 +427,10 @@ describe("Bun.semver.satisfies()", () => { testSatisfies("| 1.2.3-alpha.1", "1.2.3-alpha.1", true); testSatisfies("1.2.3-alpha.1 |", "1.2.3-alpha.1", true); testSatisfies("* | 1.2.3-alpha.1", "1.2.3-alpha.1", true); + testSatisfies("1.2.3-alpha.1 ||| 2.0.0", "1.2.3-alpha.1", true); + testSatisfies("1.2.3-alpha.1 ||| *", "1.2.3-alpha.1", false); + testSatisfies("||| 1.2.3-alpha.1", "1.2.3-alpha.1", false); + testSatisfies("1.2.3-alpha.1 ||||", "1.2.3-alpha.1", false); const notPassing = [ "0.1.0",