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 d35b6520379..87261f8b197 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 @@ -207,8 +207,9 @@ def find_engine_constraints_as_requirement(name) end if constraints && !constraints.empty? - Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}") - Requirement.new(constraints) + normalized_constraints = split_compound_constraints(constraints) + Dependabot.logger.info("Parsed constraints for #{name}: #{normalized_constraints.join(', ')}") + Requirement.new(normalized_constraints) end rescue StandardError => e Dependabot.logger.error("Error processing constraints for #{name}: #{e.message}") @@ -374,6 +375,70 @@ def installed_version(name) private + sig { params(constraints: T::Array[String]).returns(T::Array[String]) } + def split_compound_constraints(constraints) + constraints.flat_map { |constraint| split_compound_constraint(constraint) } + end + + sig { params(constraint: String).returns(T::Array[String]) } + def split_compound_constraint(constraint) + tokens = T.let([], T::Array[String]) + current = +"" + index = 0 + + while index < constraint.length + character = T.must(constraint[index]) + + if whitespace_character?(character) + index = skip_whitespace_characters(constraint, index + 1) + normalize_boundary_after_whitespace(constraint, index, current, tokens) + + next + end + + current << character + index += 1 + end + + tokens << current unless current.empty? + tokens + end + + sig { params(constraint: String, index: Integer).returns(Integer) } + def skip_whitespace_characters(constraint, index) + index += 1 while index < constraint.length && whitespace_character?(T.must(constraint[index])) + index + end + + sig do + params( + constraint: String, + index: Integer, + current: String, + tokens: T::Array[String] + ).void + end + def normalize_boundary_after_whitespace(constraint, index, current, tokens) + return if current.empty? || index >= constraint.length + + if comparator_start?(T.must(constraint[index])) + tokens << current.dup + current.clear + else + current << " " + end + end + + sig { params(character: String).returns(T::Boolean) } + def whitespace_character?(character) + character == " " || character == "\t" || character == "\n" || character == "\r" || character == "\f" + end + + sig { params(character: String).returns(T::Boolean) } + def comparator_start?(character) + character == ">" || character == "<" || character == "=" + end + sig { params(name: String, version: String).void } def raise_if_unsupported!(name, version) return unless name == PNPMPackageManager::NAME 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 51fc3a5ee66..9bf5e35d02e 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 @@ -492,6 +492,24 @@ end end + context "when node engines include a compound range and an alternative comparator" do + let(:package_json) do + { + "name" => "example", + "version" => "1.0.0", + "engines" => { + "node" => "^22.0.0 || >=24" + } + } + end + + it "returns a requirement without raising illformed requirement errors" do + requirement = helper.find_engine_constraints_as_requirement("node") + expect(requirement).to be_a(Dependabot::NpmAndYarn::Requirement) + expect(requirement.constraints).to eq([">= 22.0.0", "< 23.0.0", ">= 24"]) + end + end + context "when the engines field contains npm >=11.0.0 constraint" do let(:package_json) do {