Module: Dependabot::NpmAndYarn::ConstraintHelper

Extended by:
T::Sig
Defined in:
lib/dependabot/npm_and_yarn/constraint_helper.rb

Constant Summary collapse

DIGIT =

Regex Components for Semantic Versioning

"\\d+"
PRERELEASE =

Matches a single number (e.g., “1”)

"(?:-[a-zA-Z0-9.-]+)?"
BUILD_METADATA =

Matches optional pre-release tag (e.g., “-alpha”)

"(?:\\+[a-zA-Z0-9.-]+)?"
VERSION =

Matches semantic versions:

T.let("#{DIGIT}(?:\\.#{DIGIT}){0,2}#{PRERELEASE}#{BUILD_METADATA}".freeze, String)
VERSION_REGEX =
T.let(/^#{VERSION}$/, Regexp)
SEMVER_REGEX =

Base regex for SemVer (major.minor.patch[+build]) This pattern extracts valid semantic versioning strings based on the SemVer 2.0 specification.

T.let(
  /
    (?<version>\d+\.\d+\.\d+)               # Match major.minor.patch (e.g., 1.2.3)
    (?:-(?<prerelease>[a-zA-Z0-9.-]+))?     # Optional prerelease (e.g., -alpha.1, -rc.1, -beta.5)
    (?:\+(?<build>[a-zA-Z0-9.-]+))?         # Optional build metadata (e.g., +build.20231101, +exp.sha.5114f85)
  /x,
  Regexp
)
SEMVER_VALIDATION_REGEX =

Full SemVer validation regex (ensures the entire string is a valid SemVer) This ensures the entire input strictly follows SemVer, without extra characters before/after.

T.let(/^#{SEMVER_REGEX}$/, Regexp)
SEMVER_CONSTRAINT_REGEX =

SemVer constraint regex (supports package.json version constraints) This pattern ensures proper parsing of SemVer versions with optional operators.

T.let(
  /
          (?: (>=|<=|>|<|=|~|\^)\s*)?  # Make operators optional (e.g., >=, ^, ~)
          (\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)  # Match full SemVer versions
          | (\*|latest) # Match wildcard (*) or 'latest'
        /x,
  Regexp
)
SEMVER_OPERATOR_REGEX =

/(>=|<=|>|<|=|~|^)s*(d+.d+.d+(?:-)?(?:[a-zA-Z0-9.-]+)?)|(*|latest)/

/^(>=|<=|>|<|~|\^|=)$/
CARET_CONSTRAINT_REGEX =

Constraint Types as Constants

T.let(/^\^\s*(#{VERSION})$/, Regexp)
TILDE_CONSTRAINT_REGEX =
T.let(/^~\s*(#{VERSION})$/, Regexp)
EXACT_CONSTRAINT_REGEX =
T.let(/^\s*(#{VERSION})$/, Regexp)
GREATER_THAN_EQUAL_REGEX =
T.let(/^>=\s*(#{VERSION})$/, Regexp)
LESS_THAN_EQUAL_REGEX =
T.let(/^<=\s*(#{VERSION})$/, Regexp)
GREATER_THAN_REGEX =
T.let(/^>\s*(#{VERSION})$/, Regexp)
LESS_THAN_REGEX =
T.let(/^<\s*(#{VERSION})$/, Regexp)
WILDCARD_REGEX =
T.let(/^\*$/, Regexp)
LATEST_REGEX =
T.let(/^latest$/, Regexp)
SEMVER_CONSTANTS =
["*", "latest"].freeze
VALID_CONSTRAINT_REGEX =

Unified Regex for Valid Constraints

T.let(
  Regexp.union(
    CARET_CONSTRAINT_REGEX,
    TILDE_CONSTRAINT_REGEX,
    EXACT_CONSTRAINT_REGEX,
    GREATER_THAN_EQUAL_REGEX,
    LESS_THAN_EQUAL_REGEX,
    GREATER_THAN_REGEX,
    LESS_THAN_REGEX,
    WILDCARD_REGEX,
    LATEST_REGEX
  ).freeze,
  Regexp
)

Class Method Summary collapse

Class Method Details

.extract_ruby_constraints(constraint_expression, dependabot_versions = nil) ⇒ Object



89
90
91
92
93
94
95
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 89

def self.extract_ruby_constraints(constraint_expression, dependabot_versions = nil)
  parsed_constraints = parse_constraints(constraint_expression, dependabot_versions)

  return nil unless parsed_constraints

  parsed_constraints.filter_map { |parsed| parsed[:constraint] }
end

.find_highest_version_from_constraint_expression(constraint_expression, dependabot_versions = nil) ⇒ Object



185
186
187
188
189
190
191
192
193
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 185

def self.find_highest_version_from_constraint_expression(constraint_expression, dependabot_versions = nil)
  parsed_constraints = parse_constraints(constraint_expression, dependabot_versions)

  return nil unless parsed_constraints

  parsed_constraints
    .filter_map { |parsed| parsed[:version] } # Extract all versions
    .max_by { |version| Version.new(version) }
end

.highest_matching_version(dependabot_versions, constraint_version, &condition) ⇒ Object



341
342
343
344
345
346
347
348
349
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 341

def self.highest_matching_version(dependabot_versions, constraint_version, &condition)
  return unless dependabot_versions&.any?

  # Returns the highest version that satisfies the condition, or nil if none.
  dependabot_versions
    .sort
    .reverse
    .find { |version| condition.call(version, Version.new(constraint_version)) } # rubocop:disable Performance/RedundantBlockCall
end

.parse_constraints(constraint_expression, dependabot_versions = nil) ⇒ Object



207
208
209
210
211
212
213
214
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 207

def self.parse_constraints(constraint_expression, dependabot_versions = nil)
  splitted_constraints = split_constraints(constraint_expression)

  return unless splitted_constraints

  constraints = to_ruby_constraints_with_versions(splitted_constraints, dependabot_versions)
  constraints
end

.split_constraints(constraint_expression) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 105

def self.split_constraints(constraint_expression)
  normalized_constraint = constraint_expression&.strip
  return [] if normalized_constraint.nil? || normalized_constraint.empty?

  # Split constraints by logical OR (`||`)
  constraint_groups = normalized_constraint.split("||")

  # Split constraints by logical AND (`,`)
  constraint_groups = constraint_groups.map do |or_constraint|
    or_constraint.split(",").map(&:strip)
  end.flatten

  constraint_groups = constraint_groups.map do |constraint|
    tokens = constraint.split(/\s+/).map(&:strip)

    and_constraints = []

    previous = T.let(nil, T.nilable(String))
    operator = T.let(false, T.nilable(T::Boolean))
    wildcard = T.let(false, T::Boolean)

    tokens.each do |token|
      token = token.strip
      next if token.empty?

      # Invalid constraint if wildcard and anything else
      return nil if wildcard

      # If token is one of the operators (>=, <=, >, <, ~, ^, =)
      if token.match?(SEMVER_OPERATOR_REGEX)
        wildcard = false
        operator = true
      # If token is wildcard or latest
      elsif token.match?(/(\*|latest)/)
        and_constraints << token
        wildcard = true
        operator = false
      # If token is exact version (e.g., "1.2.3")
      elsif token.match(VERSION_REGEX)
        and_constraints << if operator
                             "#{previous}#{token}"
                           else
                             token
                           end
        wildcard = false
        operator = false
      # If token is a valid constraint (e.g., ">=1.2.3", "<=2.0.0")
      elsif token.match(VALID_CONSTRAINT_REGEX)
        return nil if operator

        and_constraints << token

        wildcard = false
        operator = false
      else
        # invalid constraint
        return nil
      end
      previous = token
    end
    and_constraints.uniq
  end.flatten
  constraint_groups if constraint_groups.any?
end

.to_ruby_constraint_with_version(constraint, dependabot_versions = []) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 250

def self.to_ruby_constraint_with_version(constraint, dependabot_versions = [])
  return nil if constraint.empty?

  case constraint
  when EXACT_CONSTRAINT_REGEX # Exact version, e.g., "1.2.3-alpha"
    return unless Regexp.last_match

    full_version = Regexp.last_match(1)
    { constraint: "=#{full_version}", version: full_version }
  when CARET_CONSTRAINT_REGEX # Caret constraint, e.g., "^1.2.3"
    return unless Regexp.last_match

    full_version = Regexp.last_match(1)

    # Normalize version: if full_version does not have patch version, add ".0"
    version_parts = T.must(full_version).split(".")
    full_version = "#{full_version}.0" if version_parts.length == 2

    _, major, minor = version_components(full_version)
    return nil if major.nil?

    ruby_constraint =
      if major.to_i.zero?
        minor.nil? ? ">=#{full_version} <1.0.0" : ">=#{full_version} <0.#{minor.to_i + 1}.0"
      else
        ">=#{full_version} <#{major.to_i + 1}.0.0"
      end
    { constraint: ruby_constraint, version: full_version }
  when TILDE_CONSTRAINT_REGEX # Tilde constraint, e.g., "~1.2.3"
    return unless Regexp.last_match

    full_version = Regexp.last_match(1)
    _, major, minor = version_components(full_version)
    ruby_constraint =
      if minor.nil?
        ">=#{full_version} <#{major.to_i + 1}.0.0"
      else
        ">=#{full_version} <#{major}.#{minor.to_i + 1}.0"
      end
    { constraint: ruby_constraint, version: full_version }
  when GREATER_THAN_EQUAL_REGEX # Greater than or equal, e.g., ">=1.2.3"

    return unless Regexp.last_match && Regexp.last_match(1)

    found_version = highest_matching_version(
      dependabot_versions,
      T.must(Regexp.last_match(1))
    ) do |version, constraint_version|
      version >= Version.new(constraint_version)
    end
    { constraint: ">=#{Regexp.last_match(1)}", version: found_version&.to_s }
  when LESS_THAN_EQUAL_REGEX # Less than or equal, e.g., "<=1.2.3"
    return unless Regexp.last_match

    full_version = Regexp.last_match(1)
    { constraint: "<=#{full_version}", version: full_version }
  when GREATER_THAN_REGEX # Greater than, e.g., ">1.2.3"
    return unless Regexp.last_match && Regexp.last_match(1)

    found_version = highest_matching_version(
      dependabot_versions,
      T.must(Regexp.last_match(1))
    ) do |version, constraint_version|
      version > Version.new(constraint_version)
    end
    { constraint: ">#{Regexp.last_match(1)}", version: found_version&.to_s }
  when LESS_THAN_REGEX # Less than, e.g., "<1.2.3"
    return unless Regexp.last_match && Regexp.last_match(1)

    found_version = highest_matching_version(
      dependabot_versions,
      T.must(Regexp.last_match(1))
    ) do |version, constraint_version|
      version < Version.new(constraint_version)
    end
    { constraint: "<#{Regexp.last_match(1)}", version: found_version&.to_s }
  when WILDCARD_REGEX # No specific constraint, resolves to the highest available version
    { constraint: nil, version: dependabot_versions&.max&.to_s }
  when LATEST_REGEX
    { constraint: nil, version: dependabot_versions&.max&.to_s } # Resolves to the latest available version
  end
end

.to_ruby_constraints_with_versions(constraints, dependabot_versions = []) ⇒ Object



222
223
224
225
226
227
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 222

def self.to_ruby_constraints_with_versions(constraints, dependabot_versions = [])
  constraints.filter_map do |constraint|
    parsed = to_ruby_constraint_with_version(constraint, dependabot_versions)
    parsed if parsed
  end.uniq
end

.version_components(full_version) ⇒ Object



359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/dependabot/npm_and_yarn/constraint_helper.rb', line 359

def self.version_components(full_version)
  return [] if full_version.nil?

  match = full_version.match(SEMVER_VALIDATION_REGEX)
  return [] unless match

  version = match[:version]
  return [] unless version

  major, minor, patch = version.split(".")
  [version, major, minor, patch, match[:prerelease], match[:build]].compact
end