diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/constraint_helper.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/constraint_helper.rb new file mode 100644 index 0000000000..75d214b38d --- /dev/null +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/constraint_helper.rb @@ -0,0 +1,304 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + module NpmAndYarn + module ConstraintHelper + extend T::Sig + + INVALID = "invalid" # Invalid constraint + # Regex Components for Semantic Versioning + DIGIT = "\\d+" # Matches a single number (e.g., "1") + PRERELEASE = "(?:-[a-zA-Z0-9.-]+)?" # Matches optional pre-release tag (e.g., "-alpha") + BUILD_METADATA = "(?:\\+[a-zA-Z0-9.-]+)?" # Matches optional build metadata (e.g., "+001") + DOT = "\\." # Matches a literal dot "." + + # Matches semantic versions: + VERSION = T.let("#{DIGIT}(?:\\.#{DIGIT}){0,2}#{PRERELEASE}#{BUILD_METADATA}".freeze, String) + + # SemVer regex: major.minor.patch[-prerelease][+build] + SEMVER_REGEX = /^(?\d+\.\d+\.\d+)(?:-(?[a-zA-Z0-9.-]+))?(?:\+(?[a-zA-Z0-9.-]+))?$/ + + # Constraint Types as Constants + CARET_CONSTRAINT_REGEX = T.let(/^\^(#{VERSION})$/, Regexp) + TILDE_CONSTRAINT_REGEX = T.let(/^~(#{VERSION})$/, Regexp) + EXACT_CONSTRAINT_REGEX = T.let(/^(#{VERSION})$/, Regexp) + GREATER_THAN_EQUAL_REGEX = T.let(/^>=(#{VERSION})$/, Regexp) + LESS_THAN_EQUAL_REGEX = T.let(/^<=(#{VERSION})$/, Regexp) + GREATER_THAN_REGEX = T.let(/^>(#{VERSION})$/, Regexp) + LESS_THAN_REGEX = T.let(/^<(#{VERSION})$/, Regexp) + WILDCARD_REGEX = T.let(/^\*$/, Regexp) + + # Unified Regex for Valid Constraints + VALID_CONSTRAINT_REGEX = 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 + ).freeze, Regexp) + + # Validates if the provided semver constraint expression from a `package.json` is valid. + # A valid semver constraint expression in `package.json` can consist of multiple groups + # separated by logical OR (`||`). Within each group, space-separated constraints are treated + # as logical AND. Each individual constraint must conform to the semver rules defined in + # `VALID_CONSTRAINT_REGEX`. + # + # Example (valid `package.json` semver constraints): + # ">=1.2.3 <2.0.0 || ~3.4.5" → Valid (space-separated constraints are AND, `||` is OR) + # "^1.0.0 || >=2.0.0 <3.0.0" → Valid (caret and range constraints combined) + # "1.2.3" → Valid (exact version) + # "*" → Valid (wildcard allows any version) + # + # Example (invalid `package.json` semver constraints): + # ">=1.2.3 && <2.0.0" → Invalid (`&&` is not valid in semver) + # ">=x.y.z" → Invalid (non-numeric version parts are not valid) + # "1.2.3 ||" → Invalid (trailing OR operator) + # + # @param constraint_expression [String] The semver constraint expression from `package.json` to validate. + # @return [T::Boolean] Returns true if the constraint expression is valid semver, false otherwise. + sig { params(constraint_expression: T.nilable(String)).returns(T::Boolean) } + def self.valid_constraint_expression?(constraint_expression) + normalized_constraint = constraint_expression&.strip + + # Treat nil or empty input as valid (no constraints) + return true if normalized_constraint.nil? || normalized_constraint.empty? + + # Split the expression by logical OR (`||`) into groups + normalized_constraint.split("||").reject(&:empty?).all? do |or_group| + or_group.split(/\s+/).reject(&:empty?).all? do |and_constraint| + and_constraint.match?(VALID_CONSTRAINT_REGEX) + end + end + end + + # Extract unique constraints from the given constraint expression. + # @param constraint_expression [T.nilable(String)] The semver constraint expression. + # @return [T::Array[String]] The list of unique Ruby-compatible constraints. + sig do + params( + constraint_expression: T.nilable(String), + dependabot_versions: T.nilable(T::Array[Dependabot::Version]) + ) + .returns(T.nilable(T::Array[String])) + end + def self.extract_constraints(constraint_expression, dependabot_versions = nil) + normalized_constraint = constraint_expression&.strip + return [] if normalized_constraint.nil? || normalized_constraint.empty? + + parsed_constraints = parse_constraints(normalized_constraint, dependabot_versions) + + return nil unless parsed_constraints + + parsed_constraints.filter_map { |parsed| parsed[:constraint] } + end + + # Find the highest version from the given constraint expression. + # @param constraint_expression [T.nilable(String)] The semver constraint expression. + # @return [T.nilable(String)] The highest version, or nil if no versions are available. + sig do + params( + constraint_expression: T.nilable(String), + dependabot_versions: T.nilable(T::Array[Dependabot::Version]) + ) + .returns(T.nilable(String)) + end + def self.find_highest_version_from_constraint_expression(constraint_expression, dependabot_versions = nil) + normalized_constraint = constraint_expression&.strip + return nil if normalized_constraint.nil? || normalized_constraint.empty? + + parsed_constraints = parse_constraints(normalized_constraint, dependabot_versions) + + return nil unless parsed_constraints + + parsed_constraints + .filter_map { |parsed| parsed[:version] } # Extract all versions + .max_by { |version| Version.new(version) } + end + + # Parse all constraints (split by logical OR `||`) and convert to Ruby-compatible constraints. + # Return: + # - `nil` if the constraint expression is invalid + # - `[]` if the constraint expression is valid but represents "no constraints" + # - An array of hashes for valid constraints with details about the constraint and version + sig do + params( + constraint_expression: T.nilable(String), + dependabot_versions: T.nilable(T::Array[Dependabot::Version]) + ) + .returns(T.nilable(T::Array[T::Hash[Symbol, T.nilable(String)]])) + end + def self.parse_constraints(constraint_expression, dependabot_versions = nil) + normalized_constraint = constraint_expression&.strip + + # Return an empty array for valid "no constraints" (nil or empty input) + return [] if normalized_constraint.nil? || normalized_constraint.empty? + + # Return nil for invalid constraints + return nil unless valid_constraint_expression?(normalized_constraint) + + # Parse valid constraints + constraints = normalized_constraint.split("||").flat_map do |or_group| + or_group.strip.split(/\s+/).map(&:strip) + end.then do |normalized_constraints| # rubocop:disable Style/MultilineBlockChain + to_ruby_constraints_with_versions(normalized_constraints, dependabot_versions) + end.uniq { |parsed| parsed[:constraint] } # Ensure uniqueness based on `:constraint` # rubocop:disable Style/MultilineBlockChain + constraints + end + + sig do + params( + constraints: T::Array[String], + dependabot_versions: T.nilable(T::Array[Dependabot::Version]) + ).returns(T::Array[T::Hash[Symbol, T.nilable(String)]]) + end + 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 && parsed[:constraint] # Only include valid constraints + end.uniq + end + + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity + # Converts a semver constraint to a Ruby-compatible constraint and extracts the version, if available. + # @param constraint [String] The semver constraint to parse. + # @return [T.nilable(T::Hash[Symbol, T.nilable(String)])] Returns the Ruby-compatible constraint and the version, + # if available, or nil if the constraint is invalid. + # + # @example + # to_ruby_constraint_with_version("=1.2.3") # => { constraint: "=1.2.3", version: "1.2.3" } + # to_ruby_constraint_with_version("^1.2.3") # => { constraint: ">=1.2.3 <2.0.0", version: "1.2.3" } + # to_ruby_constraint_with_version("*") # => { constraint: nil, version: nil } + # to_ruby_constraint_with_version("invalid") # => nil + sig do + params( + constraint: String, + dependabot_versions: T.nilable(T::Array[Dependabot::Version]) + ) + .returns(T.nilable(T::Hash[Symbol, T.nilable(String)])) + end + 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) + _, 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 # Wildcard + { constraint: nil, version: dependabot_versions&.max&.to_s } # Explicitly valid but no specific constraint + end + end + + sig do + params( + dependabot_versions: T.nilable(T::Array[Dependabot::Version]), + constraint_version: String, + condition: T.proc.params(version: Dependabot::Version, constraint: Dependabot::Version).returns(T::Boolean) + ) + .returns(T.nilable(Dependabot::Version)) + end + 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 + + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/CyclomaticComplexity + + # Parses a semantic version string into its components as per the SemVer spec + # Example: "1.2.3-alpha+001" → ["1.2.3", "1", "2", "3", "alpha", "001"] + sig { params(full_version: T.nilable(String)).returns(T.nilable(T::Array[String])) } + def self.version_components(full_version) + return [] if full_version.nil? + + match = full_version.match(SEMVER_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 + end + end +end diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb index 43522db45c..2820823f19 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb @@ -11,6 +11,7 @@ require "dependabot/npm_and_yarn/pnpm_package_manager" require "dependabot/npm_and_yarn/bun_package_manager" require "dependabot/npm_and_yarn/language" +require "dependabot/npm_and_yarn/constraint_helper" module Dependabot module NpmAndYarn @@ -189,7 +190,7 @@ def language_requirement end sig { params(name: String).returns(T.nilable(Requirement)) } - def find_engine_constraints_as_requirement(name) + def find_engine_constraints_as_requirement(name) # rubocop:disable Metrics/PerceivedComplexity Dependabot.logger.info("Processing engine constraints for #{name}") return nil unless @engines.is_a?(Hash) && @engines[name] @@ -197,19 +198,31 @@ def find_engine_constraints_as_requirement(name) raw_constraint = @engines[name].to_s.strip return nil if raw_constraint.empty? - raw_constraints = raw_constraint.split - constraints = raw_constraints.map do |constraint| - case constraint - when /^\d+$/ - ">=#{constraint}.0.0 <#{constraint.to_i + 1}.0.0" - when /^\d+\.\d+$/ - ">=#{constraint} <#{constraint.split('.').first.to_i + 1}.0.0" - when /^\d+\.\d+\.\d+$/ - "=#{constraint}" - else - Dependabot.logger.warn("Unrecognized constraint format for #{name}: #{constraint}") - constraint + if Dependabot::Experiments.enabled?(:enable_engine_version_detection) + constraints = ConstraintHelper.extract_constraints(raw_constraint) + + # When constraints are invalid we return constraints array nil + if constraints.nil? + Dependabot.logger.warn( + "Unrecognized constraint format for #{name}: #{raw_constraint}" + ) + end + else + raw_constraints = raw_constraint.split + constraints = raw_constraints.map do |constraint| + case constraint + when /^\d+$/ + ">=#{constraint}.0.0 <#{constraint.to_i + 1}.0.0" + when /^\d+\.\d+$/ + ">=#{constraint} <#{constraint.split('.').first.to_i + 1}.0.0" + when /^\d+\.\d+\.\d+$/ + "=#{constraint}" + else + Dependabot.logger.warn("Unrecognized constraint format for #{name}: #{constraint}") + constraint + end end + end Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}") @@ -434,7 +447,8 @@ def check_engine_version(name) return if @package_json.nil? version_selector = VersionSelector.new - engine_versions = version_selector.setup(@package_json, name) + + engine_versions = version_selector.setup(@package_json, name, dependabot_versions(name)) return if engine_versions.empty? @@ -442,6 +456,20 @@ def check_engine_version(name) Dependabot.logger.info("Returned (#{MANIFEST_ENGINES_KEY}) info \"#{name}\" : \"#{version}\"") version end + + sig { params(name: String).returns(T.nilable(T::Array[Dependabot::Version])) } + def dependabot_versions(name) + case name + when "npm" + NpmPackageManager::SUPPORTED_VERSIONS + when "yarn" + YarnPackageManager::SUPPORTED_VERSIONS + when "bun" + BunPackageManager::SUPPORTED_VERSIONS + when "pnpm" + PNPMPackageManager::SUPPORTED_VERSIONS + end + end end end end diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/version_selector.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/version_selector.rb index 78396de7ec..8207e39b6d 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/version_selector.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/version_selector.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "dependabot/shared_helpers" +require "dependabot/npm_and_yarn/constraint_helper" module Dependabot module NpmAndYarn @@ -13,18 +14,42 @@ class VersionSelector # such as "20.8.7", "8.1.2", "8.21.2", NODE_ENGINE_SUPPORTED_REGEX = /^\d+(?:\.\d+)*$/ - sig { params(manifest_json: T::Hash[String, T.untyped], name: String).returns(T::Hash[Symbol, T.untyped]) } - def setup(manifest_json, name) + # Sets up engine versions from the given manifest JSON. + # + # @param manifest_json [Hash] The manifest JSON containing version information. + # @param name [String] The engine name to match. + # @return [Hash] A hash with selected versions, if found. + sig do + params( + manifest_json: T::Hash[String, T.untyped], + name: String, + dependabot_versions: T.nilable(T::Array[Dependabot::Version]) + ) + .returns(T::Hash[Symbol, T.untyped]) + end + def setup(manifest_json, name, dependabot_versions = nil) engine_versions = manifest_json["engines"] + # Return an empty hash if no engine versions are specified return {} if engine_versions.nil? - # Only keep matching specs versions i.e. "20.21.2", "7.1.2", - # Additional specs can be added later - engine_versions.delete_if { |_key, value| !valid_extracted_version?(value) } - version = engine_versions.select { |engine, _value| engine.to_s.match(name) } + versions = {} + + if Dependabot::Experiments.enabled?(:enable_engine_version_detection) + engine_versions.each do |engine, value| + next unless engine.to_s.match(name) + + versions[name] = ConstraintHelper.find_highest_version_from_constraint_expression( + value, dependabot_versions + ) + end + else + versions = engine_versions.select do |engine, value| + engine.to_s.match(name) && valid_extracted_version?(value) + end + end - version + versions end sig { params(version: String).returns(T::Boolean) } diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/constraint_helper_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/constraint_helper_spec.rb new file mode 100644 index 0000000000..30e780201b --- /dev/null +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/constraint_helper_spec.rb @@ -0,0 +1,211 @@ +# typed: false +# frozen_string_literal: true + +require "spec_helper" +require "dependabot/npm_and_yarn/constraint_helper" + +RSpec.describe Dependabot::NpmAndYarn::ConstraintHelper do + let(:helper) { described_class } + let(:version_regex) { /^#{helper::VERSION}$/o } + + describe "Regex Constants" do + describe "VERSION" do + it "matches valid semantic versions" do + valid_versions = [ + "1.2.3", "1.2.3-alpha", "1.2.3+build", "1.2.3-alpha+build", + "0.1.0", "1", "1.0", "1.2.3-0", "1.2.3-0.3.7" + ] + valid_versions.each do |version| + expect(version_regex.match?(version)).to be(true), "Expected #{version} to match" + end + end + + it "does not match invalid semantic versions" do + invalid_versions = [ + "1.2.3-", "1..2.3", "1.2.3_alpha", + "1.2.-3", "v1.2.3" + ] + invalid_versions.each do |version| + expect(version_regex.match?(version)).to be(false), "Expected #{version} to not match" + end + end + end + + describe "CARET_CONSTRAINT_REGEX" do + it "matches valid caret constraints" do + valid_constraints = [ + "^1.2.3", "^0.1.0", "^1.2.3-alpha", "^1.0.0+build", "^1" + ] + valid_constraints.each do |constraint| + expect(helper::CARET_CONSTRAINT_REGEX.match?(constraint)).to be(true), "Expected #{constraint} to match" + end + end + + it "does not match invalid caret constraints" do + invalid_constraints = [ + "^1.2.3-", "^", "1.2.3", "^1.2.3 alpha", "^1.2..3" + ] + invalid_constraints.each do |constraint| + expect(helper::CARET_CONSTRAINT_REGEX.match?(constraint)).to be(false), "Expected #{constraint} to not match" + end + end + end + + describe "TILDE_CONSTRAINT_REGEX" do + it "matches valid tilde constraints" do + valid_constraints = [ + "~1.2.3", "~0.1.0", "~1.2.3-alpha", "~1.0.0+build" + ] + valid_constraints.each do |constraint| + expect(helper::TILDE_CONSTRAINT_REGEX.match?(constraint)).to be(true), "Expected #{constraint} to match" + end + end + + it "does not match invalid tilde constraints" do + invalid_constraints = [ + "~1.2.3-", "~", "1.2.3", "~1.2.3 alpha", "~1.2..3" + ] + invalid_constraints.each do |constraint| + expect(helper::TILDE_CONSTRAINT_REGEX.match?(constraint)).to be(false), "Expected #{constraint} to not match" + end + end + end + + describe "EXACT_CONSTRAINT_REGEX" do + it "matches valid exact constraints" do + valid_constraints = [ + "1.2.3", "0.1.0", "1.2.3-alpha", "1.0.0+build" + ] + valid_constraints.each do |constraint| + expect(helper::EXACT_CONSTRAINT_REGEX.match?(constraint)).to be(true), "Expected #{constraint} to match" + end + end + + it "does not match invalid exact constraints" do + invalid_constraints = [ + "1.2.3-", "~1.2.3", "^1.2.3", "" + ] + invalid_constraints.each do |constraint| + expect(helper::EXACT_CONSTRAINT_REGEX.match?(constraint)).to be(false), "Expected #{constraint} to not match" + end + end + end + + describe "GREATER_THAN_EQUAL_REGEX" do + it "matches valid greater-than-or-equal constraints" do + valid_constraints = [ + ">=1.2.3", ">=1.0.0+build", ">=0.1.0-alpha" + ] + valid_constraints.each do |constraint| + expect(helper::GREATER_THAN_EQUAL_REGEX.match?(constraint)).to be(true), "Expected #{constraint} to match" + end + end + + it "does not match invalid greater-than-or-equal constraints" do + invalid_constraints = [ + ">1.2.3", ">=1.2.3-", ">=1.2.3 alpha", "" + ] + invalid_constraints.each do |constraint| + expect(helper::GREATER_THAN_EQUAL_REGEX.match?(constraint)).to be(false), + "Expected #{constraint} to not match" + end + end + end + + describe "LESS_THAN_EQUAL_REGEX" do + it "matches valid less-than-or-equal constraints" do + valid_constraints = [ + "<=1.2.3", "<=1.0.0+build", "<=0.1.0-alpha" + ] + valid_constraints.each do |constraint| + expect(helper::LESS_THAN_EQUAL_REGEX.match?(constraint)).to be(true), "Expected #{constraint} to match" + end + end + + it "does not match invalid less-than-or-equal constraints" do + invalid_constraints = [ + "<1.2.3", "<=1.2.3-", "<=1.2.3 alpha", "" + ] + invalid_constraints.each do |constraint| + expect(helper::LESS_THAN_EQUAL_REGEX.match?(constraint)).to be(false), "Expected #{constraint} to not match" + end + end + end + + describe "WILDCARD_REGEX" do + it "matches valid wildcard constraints" do + expect(helper::WILDCARD_REGEX.match?("*")).to be(true) + end + + it "does not match invalid wildcard constraints" do + invalid_constraints = [ + "**", "1.*", "1.2.*" + ] + invalid_constraints.each do |constraint| + expect(helper::WILDCARD_REGEX.match?(constraint)).to be(false), "Expected #{constraint} to not match" + end + end + end + end + + describe ".valid_constraint_expression?" do + it "returns true for valid constraints" do + valid_constraints = [ + ">=1.2.3 <2.0.0 || ~3.4.5", "1.2.3", "*", ">=1.0.0-alpha+build" + ] + valid_constraints.each do |constraint| + expect(helper.valid_constraint_expression?(constraint)).to be(true), "Expected #{constraint} to be valid" + end + end + + it "returns false for invalid constraints" do + invalid_constraints = [ + ">=1.2.3 && <2.0.0", ">=x.y.z", "invalid || >=x.y.z" + ] + invalid_constraints.each do |constraint| + expect(helper.valid_constraint_expression?(constraint)).to be(false), "Expected #{constraint} to be invalid" + end + end + end + + describe ".extract_constraints" do + it "extracts unique constraints from valid expressions" do + constraints = ">=1.2.3 <2.0.0 || ~2.3.4 || ^3.0.0" + result = helper.extract_constraints(constraints) + expect(result).to eq([">=1.2.3", "<2.0.0", ">=2.3.4 <2.4.0", ">=3.0.0 <4.0.0"]) + end + + it "returns nil for invalid constraints" do + constraints = "invalid || >=x.y.z" + result = helper.extract_constraints(constraints) + expect(result).to be_nil + end + end + + describe ".find_highest_version_from_constraint_expression" do + it "finds the highest version from valid constraints" do + constraints = ">=1.2.3 <2.0.0 || ~2.3.4 || ^3.0.0" + result = helper.find_highest_version_from_constraint_expression(constraints) + expect(result).to eq("3.0.0") + end + + it "returns nil if no versions are present" do + constraints = "* || invalid" + result = helper.find_highest_version_from_constraint_expression(constraints) + expect(result).to be_nil + end + end + + describe ".parse_constraints" do + it "parses valid constraints into hashes" do + constraints = ">=1.2.3 <2.0.0 || ~2.3.4 || ^3.0.0" + result = helper.parse_constraints(constraints) + expect(result).to eq([ + { constraint: ">=1.2.3", version: nil }, + { constraint: "<2.0.0", version: nil }, + { constraint: ">=2.3.4 <2.4.0", version: "2.3.4" }, + { constraint: ">=3.0.0 <4.0.0", version: "3.0.0" } + ]) + end + end +end diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/package_manager_helper_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/package_manager_helper_spec.rb index b49257759f..60ca67be5e 100644 --- a/npm_and_yarn/spec/dependabot/npm_and_yarn/package_manager_helper_spec.rb +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/package_manager_helper_spec.rb @@ -486,6 +486,21 @@ expect(requirement).to be_a(Dependabot::NpmAndYarn::Requirement) expect(requirement.constraints).to eq(["= 7.5.0"]) end + + context "when package manager lockfile does not exist" do + let(:lockfiles) { {} } + + it "returns a requirement for npm with the correct constraints" do + # NOTE: This is a regression test for a previous bug where calling + # helper.package_manager will mutate helper's internal state and break + # subsequent calls to helper.find_engine_constraints_as_requirement. + expect(helper.package_manager).to be_a(Dependabot::NpmAndYarn::NpmPackageManager) + + requirement = helper.find_engine_constraints_as_requirement("npm") + expect(requirement).to be_a(Dependabot::NpmAndYarn::Requirement) + expect(requirement.constraints).to eq([">= 6.0.0", "< 8.0.0"]) + end + end end context "when the engines field does not contain the specified package manager" do