@@ -44,39 +44,180 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
4444
4545 MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i
4646
47+ # Minimum and maximum lengths for Git SHAs
48+ MIN_GIT_SHA_LENGTH = 7
49+ MAX_GIT_SHA_LENGTH = 40
50+
51+ # Regex for a valid Git SHA
52+ # - Only hexadecimal characters (0-9, a-f)
53+ # - Case-insensitive
54+ # - At least one letter a-f to avoid purely numeric strings
55+ GIT_COMMIT = T . let (
56+ /\A (?=[0-9a-f]{#{ MIN_GIT_SHA_LENGTH } ,#{ MAX_GIT_SHA_LENGTH } }\z )(?=.*[a-f])/i ,
57+ Regexp
58+ )
59+
4760 sig { params ( comparison_version : Dependabot ::Version ) . returns ( T ::Boolean ) }
4861 def matches_dependency_version_type? ( comparison_version )
4962 return true unless dependency . version
5063
51- current_version_string = dependency . version
52- candidate_version_string = comparison_version . to_s
64+ current = dependency . version
65+ candidate = comparison_version . to_s
66+
67+ return true if pre_release_compatible? ( current , candidate )
5368
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 )
69+ return true if upgrade_to_stable? ( current , candidate )
5670
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
71+ suffix_compatible? ( current , candidate )
72+ end
6173
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
74+ private
6775
68- current_suffix = extract_version_suffix ( current_version_string )
69- candidate_suffix = extract_version_suffix ( candidate_version_string )
76+ # Determines whether two versions have compatible suffixes.
77+ #
78+ # Suffix compatibility is evaluated based on the type of suffix present:
79+ #
80+ # - Java runtime suffixes (JRE/JDK): Must have matching major versions and
81+ # compatible runtime types (JRE can upgrade to JDK, but not vice versa)
82+ #
83+ # - Git commit SHAs: When any of the versions contain Git SHAs, they are considered irrelevant
84+ # for compatibility purposes,
85+ # as SHAs indicate specific build states rather than compatibility constraints.
86+ #
87+ # - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
88+ #
89+ # - No suffix: Both versions must have no suffix
90+ #
91+ # @example Java runtime compatibility
92+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
93+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
94+ # suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
95+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
96+ #
97+ # @example Git SHA compatibility
98+ # suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
99+ # suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => true ( considered irrelevant for compatibility)
100+ #
101+ # @example Exact suffix matching
102+ # suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
103+ # suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
104+ # suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
105+ # suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
106+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
107+ def suffix_compatible? ( current , candidate )
108+ current_suffix = extract_version_suffix ( current )
109+ candidate_suffix = extract_version_suffix ( candidate )
70110
71111 if jre_or_jdk? ( current_suffix ) && jre_or_jdk? ( candidate_suffix )
72112 return compatible_java_runtime? ( T . must ( current_suffix ) , T . must ( candidate_suffix ) )
73113 end
74114
115+ return true if contains_git_sha? ( current_suffix ) || contains_git_sha? ( candidate_suffix )
116+
75117 # If both versions share the exact suffix or no suffix, they are compatible
76118 current_suffix == candidate_suffix
77119 end
78120
79- private
121+ # Determines whether a given string is a valid Git commit SHA.
122+ #
123+ # Accepts both short SHAs (7-40 characters) and full SHAs (40 characters).
124+ # Handles versions with a leading 'v' prefix (e.g., "v018aa6b0d3").
125+ #
126+ # @example Valid Git SHAs
127+ # git_sha?("a1b2c3d") # => true (7-char short SHA)
128+ # git_sha?("a1b2c3d4e5f6") # => true (12-char SHA)
129+ # git_sha?("a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4") # => true (40-char full SHA)
130+ # git_sha?("v018aa6b0d3") # => true (with 'v' prefix)
131+ #
132+ # @example Invalid inputs
133+ # git_sha?("1.2.3") # => false (version number)
134+ # git_sha?("abc") # => false (too short, < 7 chars)
135+ # git_sha?("ghijklm") # => false (invalid hex characters)
136+ # git_sha?(nil) # => false (nil input)
137+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
138+ def git_sha? ( version )
139+ return false unless version
140+
141+ normalized = version . start_with? ( "v" ) ? version [ 1 ..-1 ] : version
142+ !!T . must ( normalized ) . match? ( GIT_COMMIT )
143+ end
144+
145+ # Determines whether a version string contains a Git commit SHA.
146+ #
147+ # This method checks if any part of a version string (when split by common
148+ # delimiters like '-', '.', or '_') is a valid Git SHA. It also handles
149+ # cases where delimiters within the SHA itself have been replaced with
150+ # underscores or other characters.
151+
152+ # @example Standard delimiter-separated SHAs
153+ # contains_git_sha?("1.0.0-a1b2c3d") # => true (SHA after hyphen)
154+ # contains_git_sha?("2.3.4.a1b2c3d4e5") # => true (SHA after dot)
155+ # contains_git_sha?("v1.2_a1b2c3d") # => true (SHA after underscore)
156+ #
157+ # @example Embedded SHAs with modified delimiters
158+ # contains_git_sha?("va_b_018a_a_6b_0d3") # => true (SHA with underscores replacing chars)
159+ # contains_git_sha?("1.0.a.1.b.2.c.3.d") # => true (SHA scattered across segments)
160+ #
161+ # @example Non-SHA versions
162+ # contains_git_sha?("1.2.3") # => false (regular version)
163+ # contains_git_sha?("abc") # => false (too short)
164+ # contains_git_sha?(nil) # => false (nil input)
165+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
166+ def contains_git_sha? ( version )
167+ return false unless version
168+
169+ # Check if any delimiter-separated part is a SHA
170+ version . split ( /[-._]/ ) . any? { |part | git_sha? ( part ) } ||
171+ # Check if removing delimiters reveals a SHA (e.g., "va_b_018a_a_6b_0d3")
172+ git_sha? ( version . gsub ( /[-._]/ , "" ) )
173+ end
174+
175+ # Determines whether two versions are compatible based on pre-release status.
176+ #
177+ # Two versions are considered compatible if both are pre-release versions.
178+ # This allows upgrades between different pre-release qualifiers of the same
179+ # base version (e.g., RC1 → CR2, ALPHA → BETA)
180+ #
181+ # @example Compatible pre-release transitions
182+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-RC2") # => true (same qualifier)
183+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-CR2") # => true (different qualifier, same stage)
184+ # pre_release_compatible?("2.0.0-ALPHA", "2.0.0-BETA") # => true (progression)
185+ # pre_release_compatible?("1.5-M1", "1.5-MILESTONE2") # => true (equivalent qualifiers)
186+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
187+ def pre_release_compatible? ( current , candidate )
188+ pre_release? ( current ) && pre_release? ( candidate )
189+ end
190+
191+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
192+ def pre_release? ( version )
193+ version &.match? ( MAVEN_PRE_RELEASE_QUALIFIERS ) || false
194+ end
195+
196+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
197+ def snapshot? ( version )
198+ version &.match? ( MAVEN_SNAPSHOT_QUALIFIER ) || false
199+ end
200+
201+ # This method allows upgrades from unstable versions (pre-releases or snapshots)
202+ # to stable releases, which is a common and expected upgrade path.
203+ # However, it prevents downgrades from stable releases back to pre-releases,
204+ # as this would violate semantic versioning expectations.
205+ #
206+ # @example Valid upgrades to stable
207+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0") # => true (pre-release → stable)
208+ # upgrade_to_stable?("2.0.0-SNAPSHOT", "2.0.0") # => true (snapshot → stable)
209+ # upgrade_to_stable?("1.5-BETA", "1.5") # => true (beta → stable)
210+ # upgrade_to_stable?("3.0.0-ALPHA2", "3.0.0-FINAL") # => true (pre-release → release qualifier)
211+ #
212+ # @example Invalid transitions (returns false)
213+ # upgrade_to_stable?("1.0.0", "1.0.1-RC1") # => false (stable → pre-release not allowed)
214+ # upgrade_to_stable?("2.0.0", "2.1.0") # => false (stable → stable, use other logic)
215+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0-BETA") # => false (pre-release → pre-release)
216+ # upgrade_to_stable?(nil, "1.0.0") # => false (no current version)
217+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
218+ def upgrade_to_stable? ( current , candidate )
219+ ( pre_release? ( current ) || snapshot? ( current ) ) && !pre_release? ( candidate )
220+ end
80221
81222 # Determines whether two Java runtime suffixes are compatible.
82223 #
@@ -182,8 +323,7 @@ def extract_version_suffix(version_string)
182323 # e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183324 return nil if suffix . match? ( /^_?\d +$/ )
184325
185- # Must contain a hyphen to be considered a valid suffix
186- return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" )
326+ return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" ) || git_sha? ( suffix )
187327 end
188328
189329 nil
0 commit comments