@@ -15,19 +15,20 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
1515 # Regex to match common Maven release qualifiers that indicate stable releases.
1616 # See https://github.com/apache/maven/blob/848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java#L315-L320
1717 MAVEN_RELEASE_QUALIFIERS = /
18- ^ .+[-._](
19- RELEASE|# Official release
20- FINAL|# Final build
21- GA # General Availability
22- ) $
18+ ^(?: .+[-._])? (
19+ RELEASE|# Official release
20+ FINAL| # Final build
21+ GA # General Availability
22+ ) \d * $
2323 /ix
2424
2525 # Common Maven pre-release qualifiers.
2626 # They often indicate versions that are not yet stable but that are released to the public for testing.
2727 # Examples: 1.0.0-RC1, 2.0.0-ALPHA2, 3.1.0-BETA, 4.0.0-DEV5, etc.
2828 # See https://maven.apache.org/guides/mini/guide-naming-conventions.html#version-identifier
2929 MAVEN_PRE_RELEASE_QUALIFIERS = /
30- [-._]?(
30+ # Must be at start OR preceded by a delimiter
31+ (?: \A | [-._])(
3132 # --- Qualifiers that usually REQUIRE a number ---
3233 # Examples: "RC1", "BETA2", "M3", "ALPHA-1", "EAP.2"
3334 # The number differentiates multiple pre-releases; a version like "1.0.0-RC"
@@ -44,39 +45,180 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
4445
4546 MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i
4647
48+ # Minimum and maximum lengths for Git SHAs
49+ MIN_GIT_SHA_LENGTH = 7
50+ MAX_GIT_SHA_LENGTH = 40
51+
52+ # Regex for a valid Git SHA
53+ # - Only hexadecimal characters (0-9, a-f)
54+ # - Case-insensitive
55+ # - At least one letter a-f to avoid purely numeric strings
56+ GIT_COMMIT = T . let (
57+ /\A (?=[0-9a-f]{#{ MIN_GIT_SHA_LENGTH } ,#{ MAX_GIT_SHA_LENGTH } }\z )(?=.*[a-f])/i ,
58+ Regexp
59+ )
60+
4761 sig { params ( comparison_version : Dependabot ::Version ) . returns ( T ::Boolean ) }
4862 def matches_dependency_version_type? ( comparison_version )
4963 return true unless dependency . version
5064
51- current_version_string = dependency . version
52- candidate_version_string = comparison_version . to_s
65+ current = dependency . version
66+ candidate = comparison_version . to_s
5367
54- current_is_pre_release = current_version_string &.match? ( MAVEN_PRE_RELEASE_QUALIFIERS )
55- candidate_is_pre_release = candidate_version_string . match? ( MAVEN_PRE_RELEASE_QUALIFIERS )
68+ return true if pre_release_compatible? ( current , candidate )
5669
57- # Pre-releases are only compatible with other pre-releases
58- # When this happens, the suffix does not need to match exactly
59- # This allows transitions between 1.0.0-RC1 and 1.0.0-CR2, for example
60- return true if current_is_pre_release && candidate_is_pre_release
70+ return true if upgrade_to_stable? ( current , candidate )
6171
62- current_is_snapshot = current_version_string &.match? ( MAVEN_SNAPSHOT_QUALIFIER )
63- # If the current version is a pre-release or a snapshot, allow upgrading to a stable release
64- # This can help move from pre-release to the stable version that supersedes it,
65- # but this should not happen vice versa as a stable release should not be downgraded to a pre-release
66- return true if ( current_is_pre_release || current_is_snapshot ) && !candidate_is_pre_release
72+ suffix_compatible? ( current , candidate )
73+ end
6774
68- current_suffix = extract_version_suffix ( current_version_string )
69- candidate_suffix = extract_version_suffix ( candidate_version_string )
75+ private
76+
77+ # Determines whether two versions have compatible suffixes.
78+ #
79+ # Suffix compatibility is evaluated based on the type of suffix present:
80+ #
81+ # - Java runtime suffixes (JRE/JDK): Must have matching major versions and
82+ # compatible runtime types (JRE can upgrade to JDK, but not vice versa)
83+ #
84+ # - Git commit SHAs: When any of the versions contain Git SHAs, they are considered irrelevant
85+ # for compatibility purposes,
86+ # as SHAs indicate specific build states rather than compatibility constraints.
87+ #
88+ # - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
89+ #
90+ # - No suffix: Both versions must have no suffix
91+ #
92+ # @example Java runtime compatibility
93+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
94+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
95+ # suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
96+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
97+ #
98+ # @example Git SHA compatibility
99+ # suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
100+ # suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => true ( considered irrelevant for compatibility)
101+ #
102+ # @example Exact suffix matching
103+ # suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
104+ # suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
105+ # suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
106+ # suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
107+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
108+ def suffix_compatible? ( current , candidate )
109+ current_suffix = extract_version_suffix ( current )
110+ candidate_suffix = extract_version_suffix ( candidate )
70111
71112 if jre_or_jdk? ( current_suffix ) && jre_or_jdk? ( candidate_suffix )
72113 return compatible_java_runtime? ( T . must ( current_suffix ) , T . must ( candidate_suffix ) )
73114 end
74115
116+ return true if contains_git_sha? ( current_suffix ) || contains_git_sha? ( candidate_suffix )
117+
75118 # If both versions share the exact suffix or no suffix, they are compatible
76119 current_suffix == candidate_suffix
77120 end
78121
79- private
122+ # Determines whether a given string is a valid Git commit SHA.
123+ #
124+ # Accepts both short SHAs (7-40 characters) and full SHAs (40 characters).
125+ # Handles versions with a leading 'v' prefix (e.g., "v018aa6b0d3").
126+ #
127+ # @example Valid Git SHAs
128+ # git_sha?("a1b2c3d") # => true (7-char short SHA)
129+ # git_sha?("a1b2c3d4e5f6") # => true (12-char SHA)
130+ # git_sha?("a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4") # => true (40-char full SHA)
131+ # git_sha?("v018aa6b0d3") # => true (with 'v' prefix)
132+ #
133+ # @example Invalid inputs
134+ # git_sha?("1.2.3") # => false (version number)
135+ # git_sha?("abc") # => false (too short, < 7 chars)
136+ # git_sha?("ghijklm") # => false (invalid hex characters)
137+ # git_sha?(nil) # => false (nil input)
138+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
139+ def git_sha? ( version )
140+ return false unless version
141+
142+ normalized = version . start_with? ( "v" ) ? version [ 1 ..-1 ] : version
143+ !!T . must ( normalized ) . match? ( GIT_COMMIT )
144+ end
145+
146+ # Determines whether a version string contains a Git commit SHA.
147+ #
148+ # This method checks if any part of a version string (when split by common
149+ # delimiters like '-', '.', or '_') is a valid Git SHA. It also handles
150+ # cases where delimiters within the SHA itself have been replaced with
151+ # underscores or other characters.
152+
153+ # @example Standard delimiter-separated SHAs
154+ # contains_git_sha?("1.0.0-a1b2c3d") # => true (SHA after hyphen)
155+ # contains_git_sha?("2.3.4.a1b2c3d4e5") # => true (SHA after dot)
156+ # contains_git_sha?("v1.2_a1b2c3d") # => true (SHA after underscore)
157+ #
158+ # @example Embedded SHAs with modified delimiters
159+ # contains_git_sha?("va_b_018a_a_6b_0d3") # => true (SHA with underscores replacing chars)
160+ # contains_git_sha?("1.0.a.1.b.2.c.3.d") # => true (SHA scattered across segments)
161+ #
162+ # @example Non-SHA versions
163+ # contains_git_sha?("1.2.3") # => false (regular version)
164+ # contains_git_sha?("abc") # => false (too short)
165+ # contains_git_sha?(nil) # => false (nil input)
166+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
167+ def contains_git_sha? ( version )
168+ return false unless version
169+
170+ # Check if any delimiter-separated part is a SHA
171+ version . split ( /[-._]/ ) . any? { |part | git_sha? ( part ) } ||
172+ # Check if removing delimiters reveals a SHA (e.g., "va_b_018a_a_6b_0d3")
173+ git_sha? ( version . gsub ( /[-._]/ , "" ) )
174+ end
175+
176+ # Determines whether two versions are compatible based on pre-release status.
177+ #
178+ # Two versions are considered compatible if both are pre-release versions.
179+ # This allows upgrades between different pre-release qualifiers of the same
180+ # base version (e.g., RC1 → CR2, ALPHA → BETA)
181+ #
182+ # @example Compatible pre-release transitions
183+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-RC2") # => true (same qualifier)
184+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-CR2") # => true (different qualifier, same stage)
185+ # pre_release_compatible?("2.0.0-ALPHA", "2.0.0-BETA") # => true (progression)
186+ # pre_release_compatible?("1.5-M1", "1.5-MILESTONE2") # => true (equivalent qualifiers)
187+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
188+ def pre_release_compatible? ( current , candidate )
189+ pre_release? ( current ) && pre_release? ( candidate )
190+ end
191+
192+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
193+ def pre_release? ( version )
194+ version &.match? ( MAVEN_PRE_RELEASE_QUALIFIERS ) || false
195+ end
196+
197+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
198+ def snapshot? ( version )
199+ version &.match? ( MAVEN_SNAPSHOT_QUALIFIER ) || false
200+ end
201+
202+ # This method allows upgrades from unstable versions (pre-releases or snapshots)
203+ # to stable releases, which is a common and expected upgrade path.
204+ # However, it prevents downgrades from stable releases back to pre-releases,
205+ # as this would violate semantic versioning expectations.
206+ #
207+ # @example Valid upgrades to stable
208+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0") # => true (pre-release → stable)
209+ # upgrade_to_stable?("2.0.0-SNAPSHOT", "2.0.0") # => true (snapshot → stable)
210+ # upgrade_to_stable?("1.5-BETA", "1.5") # => true (beta → stable)
211+ # upgrade_to_stable?("3.0.0-ALPHA2", "3.0.0-FINAL") # => true (pre-release → release qualifier)
212+ #
213+ # @example Invalid transitions (returns false)
214+ # upgrade_to_stable?("1.0.0", "1.0.1-RC1") # => false (stable → pre-release not allowed)
215+ # upgrade_to_stable?("2.0.0", "2.1.0") # => false (stable → stable, use other logic)
216+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0-BETA") # => false (pre-release → pre-release)
217+ # upgrade_to_stable?(nil, "1.0.0") # => false (no current version)
218+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
219+ def upgrade_to_stable? ( current , candidate )
220+ ( pre_release? ( current ) || snapshot? ( current ) ) && !pre_release? ( candidate )
221+ end
80222
81223 # Determines whether two Java runtime suffixes are compatible.
82224 #
@@ -151,44 +293,68 @@ def jdk?(version)
151293 # Extracts the qualifier/suffix from a Maven version string.
152294 #
153295 # Maven versions consist of numeric parts and optional string qualifiers.
154- # This method identifies the suffix by finding the first segment (separated by '.')
155- # that contains a non-digit character.
296+ # This method identifies the suffix by splitting on '.' and delegating
297+ # each non-numeric segment to extract_suffix_from_part.
298+ #
299+ # @example
300+ # extract_version_suffix("1.0.0.jre8") # => "jre8"
301+ # extract_version_suffix("1.0.0-linux") # => "_linux"
302+ # extract_version_suffix("1.0.0-RELEASE") # => nil (stable release qualifier)
303+ # extract_version_suffix("1.0.0") # => nil (no suffix)
156304 sig { params ( version_string : T . nilable ( String ) ) . returns ( T . nilable ( String ) ) }
157305 def extract_version_suffix ( version_string )
158306 return nil unless version_string
159-
160- # Exclude common Maven release qualifiers that indicate stable releases
161307 return nil if version_string . match? ( MAVEN_RELEASE_QUALIFIERS )
162308
163309 version_string . split ( "." ) . each do |part |
164- # Skip fully numeric segments
165310 next if part . match? ( /\A \d +\z / )
166311
167- # strip leading digits and capture the suffix
168- suffix = part . sub ( /\A \d +/ , "" )
169- # Normalize delimiters to ensure consistent comparison
170- # e.g., "beta-1" and "beta_1" are treated the same
171- suffix = suffix . tr ( "-" , "_" )
172-
173- # Special case for JDK/JRE suffixes
174- # e.g., "13.2.1.jre8" or "13.2.1-jdk8"
175- # In Java, these suffixes often indicate compatibility with specific Java runtimes
176- # and are meaningful in version comparisons as we should not mix versions built for different runtimes.
177- # For example, "1.0.0.jdk8" should not be considered the same as "1.0.0.jdk11"
178- # because they target different Java versions.
179- return suffix if jre_or_jdk? ( suffix )
180-
181- # Ignore purely numeric suffixes (e.g., "-1", "_2")
182- # e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183- return nil if suffix . match? ( /^_?\d +$/ )
184-
185- # Must contain a hyphen to be considered a valid suffix
186- return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" )
312+ suffix = extract_suffix_from_part ( part )
313+ return suffix unless suffix . nil?
187314 end
188315
189316 nil
190317 end
191318
319+ # Extracts a meaningful suffix from a single dot-separated version segment.
320+ #
321+ # Strips any leading digits, normalizes '-' to '_', then classifies the
322+ # remainder according to the following rules:
323+ #
324+ # - JRE/JDK suffixes are returned as-is for runtime compatibility checks.
325+ # - Purely numeric suffixes (e.g., "-1", "_2") are ignored and return nil.
326+ # - Suffixes containing delimiters or matching a Git SHA are returned as-is.
327+ # - Any other non-empty string is returned as a catch-all to prevent two
328+ # distinct suffixes from both collapsing to nil and appearing compatible.
329+ # - Empty strings return nil (no meaningful suffix present).
330+ #
331+ # @example
332+ # extract_suffix_from_part("13jre8") # => "jre8"
333+ # extract_suffix_from_part("0_linux") # => "_linux"
334+ # extract_suffix_from_part("0_1") # => nil (purely numeric)
335+ # extract_suffix_from_part("0abc123") # => "abc123"
336+ # extract_suffix_from_part("123") # => nil (skipped by caller)
337+ sig { params ( part : String ) . returns ( T . nilable ( String ) ) }
338+ def extract_suffix_from_part ( part )
339+ suffix = part . sub ( /\A \d +/ , "" ) . tr ( "-" , "_" )
340+
341+ # Special case for JDK/JRE suffixes
342+ # e.g., "13.2.1.jre8" or "13.2.1-jdk8"
343+ # In Java, these suffixes often indicate compatibility with specific Java runtimes
344+ # and are meaningful in version comparisons as we should not mix versions built for different runtimes.
345+ # For example, "1.0.0.jdk8" should not be considered the same as "1.0.0.jdk11"
346+ # because they target different Java versions.
347+ return suffix if jre_or_jdk? ( suffix )
348+
349+ # Ignore purely numeric suffixes (e.g., "-1", "_2")
350+ # e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
351+ return nil if suffix . match? ( /^_?\d +$/ )
352+
353+ return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" ) || git_sha? ( suffix )
354+
355+ suffix . empty? ? nil : suffix
356+ end
357+
192358 sig { override . returns ( T . nilable ( Dependabot ::Package ::PackageDetails ) ) }
193359 def package_details
194360 raise NotImplementedError , "Subclasses must implement `package_details`"
0 commit comments