@@ -126,13 +126,83 @@ function compareMaven(a: string, b: string): number | null {
126126 return 0
127127}
128128
129- const SEMVER_ECOSYSTEMS = new Set ( [ 'npm' , 'cargo' , 'nuget' ] )
129+ // Mirrors Gem::Version::VERSION_PATTERN / ANCHORED_VERSION_PATTERN — a hyphen
130+ // only ever introduces a single trailing prerelease group.
131+ const RUBYGEMS_VERSION_PATTERN =
132+ / ^ \s * [ 0 - 9 ] + ( \. [ 0 - 9 a - z A - Z ] + ) * ( - [ 0 - 9 A - Z a - z - ] + ( \. [ 0 - 9 A - Z a - z - ] + ) * ) ? \s * $ /
133+
134+ // Splits a version into alternating digit/letter runs, per Gem::Version#segments.
135+ // A hyphen is a prerelease marker (Gem::Version#initialize rewrites "-" to
136+ // ".pre." before tokenizing), not a plain separator. Numeric runs use bigint
137+ // so segments beyond Number.MAX_SAFE_INTEGER still compare exactly.
138+ function toRubyGemsSegments ( version : string ) : ( bigint | string ) [ ] {
139+ if ( ! RUBYGEMS_VERSION_PATTERN . test ( version ) ) return [ ]
140+ const normalized = version . replace ( / - / g, '.pre.' )
141+ const runs = normalized . match ( / [ 0 - 9 ] + | [ a - z A - Z ] + / g) ?? [ ]
142+ return runs . map ( ( run ) => ( / ^ [ 0 - 9 ] + $ / . test ( run ) ? BigInt ( run ) : run ) )
143+ }
144+
145+ // Mirrors Gem::Version#canonical_segments: drop trailing zero segments, then
146+ // drop any zero segments that sit directly before the first letter segment.
147+ function canonicalRubyGemsSegments ( segments : ( bigint | string ) [ ] ) : ( bigint | string ) [ ] {
148+ const trimmed = [ ...segments ]
149+ while ( trimmed . length > 1 && trimmed [ trimmed . length - 1 ] === BigInt ( 0 ) ) trimmed . pop ( )
150+
151+ const firstLetterIndex = trimmed . findIndex ( ( segment ) => typeof segment === 'string' )
152+ if ( firstLetterIndex === - 1 ) return trimmed
153+
154+ let zeroRunStart = firstLetterIndex
155+ while ( zeroRunStart > 0 && trimmed [ zeroRunStart - 1 ] === BigInt ( 0 ) ) zeroRunStart --
156+ trimmed . splice ( zeroRunStart , firstLetterIndex - zeroRunStart )
157+
158+ return trimmed
159+ }
160+
161+ // Compares one pair of segments the way Gem::Version#<=> does: a letter
162+ // segment always sorts below a numeric segment, regardless of its value.
163+ function compareRubyGemsSegment ( lhs : bigint | string , rhs : bigint | string ) : number {
164+ if ( typeof lhs === 'string' && typeof rhs !== 'string' ) return - 1
165+ if ( typeof lhs !== 'string' && typeof rhs === 'string' ) return 1
166+ return lhs < rhs ? - 1 : lhs > rhs ? 1 : 0
167+ }
168+
169+ // Once the shorter side is exhausted, Gem::Version#<=> walks the longer
170+ // side's remaining segments: a letter segment means the longer side is a
171+ // prerelease of the shorter one (sorts lower); a nonzero number means the
172+ // longer side sorts higher; zeros (already mostly trimmed) are skipped.
173+ function rubyGemsTailSign ( segments : ( bigint | string ) [ ] , startIndex : number ) : number {
174+ for ( let i = startIndex ; i < segments . length ; i ++ ) {
175+ const segment = segments [ i ]
176+ if ( typeof segment === 'string' ) return - 1
177+ if ( segment !== BigInt ( 0 ) ) return 1
178+ }
179+ return 0
180+ }
181+
182+ function compareRubyGems ( a : string , b : string ) : number | null {
183+ const aSegments = canonicalRubyGemsSegments ( toRubyGemsSegments ( a ) )
184+ const bSegments = canonicalRubyGemsSegments ( toRubyGemsSegments ( b ) )
185+ if ( aSegments . length === 0 || bSegments . length === 0 ) return null
186+
187+ const limit = Math . min ( aSegments . length , bSegments . length )
188+ for ( let i = 0 ; i < limit ; i ++ ) {
189+ if ( aSegments [ i ] === bSegments [ i ] ) continue
190+ return compareRubyGemsSegment ( aSegments [ i ] , bSegments [ i ] )
191+ }
192+
193+ if ( aSegments . length > bSegments . length ) return rubyGemsTailSign ( aSegments , limit )
194+ if ( bSegments . length > aSegments . length ) return - rubyGemsTailSign ( bSegments , limit )
195+ return 0
196+ }
197+
198+ const SEMVER_ECOSYSTEMS = new Set ( [ 'npm' , 'cargo' , 'nuget' , 'go' ] )
130199
131200// Ecosystem names are stored lowercase in packages-db per ADR-0001 §OSV
132201// "Ecosystem normalization" — 'npm', 'maven', 'cargo'. Callers (deriveCriticalFlag)
133202// pull the value straight from the DB so the literals here must match.
134203export function compareVersion ( ecosystem : string , a : string , b : string ) : number | null {
135204 if ( SEMVER_ECOSYSTEMS . has ( ecosystem ) ) return compareSemver ( a , b )
136205 if ( ecosystem === 'maven' ) return compareMaven ( a , b )
206+ if ( ecosystem === 'rubygems' ) return compareRubyGems ( a , b )
137207 return null
138208}
0 commit comments