Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion npm_and_yarn/lib/dependabot/npm_and_yarn/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,9 @@ def self.node_version

# Validate the output format (e.g., "v20.18.1" or "20.18.1")
if version.match?(/^v?\d+(\.\d+){2}$/)
version.strip.delete_prefix("v") # Remove the "v" prefix if present
parsed_version = version.strip.delete_prefix("v") # Remove the "v" prefix if present
Dependabot.logger.info("Using node version: #{parsed_version}")
parsed_version
end
rescue StandardError => e
Dependabot.logger.error("Error retrieving Node.js version: #{e.message}")
Expand Down
93 changes: 83 additions & 10 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -193,28 +193,101 @@ def language_requirement
def find_engine_constraints_as_requirement(name)
Dependabot.logger.info("Processing engine constraints for #{name}")

return nil unless @engines.is_a?(Hash) && @engines[name]

raw_constraint = @engines[name].to_s.strip
raw_constraint = raw_engine_constraint(name)
return nil if raw_constraint.empty?

constraints = ConstraintHelper.extract_ruby_constraints(raw_constraint)
# When constraints are invalid we return constraints array nil
if constraints.nil?
constraint_groups = parse_constraint_groups(raw_constraint)
if constraint_groups.nil?
Dependabot.logger.warn(
"Unrecognized constraint format for #{name}: #{raw_constraint}"
)
return nil
Comment thread
thavaahariharangit marked this conversation as resolved.
end

if constraints && !constraints.empty?
Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}")
Requirement.new(constraints)
end
# A wildcard/latest branch translates to no constraints, which means
# there is effectively no engine requirement.
return nil if constraint_groups.any?(&:empty?)

constraint_groups = constraint_groups.reject(&:empty?)

return nil if constraint_groups.empty?

parsed_constraints = constraint_groups.map { |group| group.join(" ") }.join(" || ")
Dependabot.logger.info("Parsed constraints for #{name}: #{parsed_constraints}")

requirement_for_group(constraint_groups, name)
rescue StandardError => e
Dependabot.logger.error("Error processing constraints for #{name}: #{e.message}")
nil
end

sig { params(name: String).returns(String) }
def raw_engine_constraint(name)
return "" unless @engines.is_a?(Hash) && @engines[name]

@engines[name].to_s.strip
end

Comment thread
thavaahariharangit marked this conversation as resolved.
sig { params(raw_constraint: String).returns(T.nilable(T::Array[T::Array[String]])) }
def parse_constraint_groups(raw_constraint)
raw_constraint.split("||").map(&:strip).reject(&:empty?).map do |constraint_group|
constraints = ConstraintHelper.extract_ruby_constraints(constraint_group)
return nil if constraints.nil?

expanded_constraints(constraints)
end
end

sig { params(constraints: T::Array[String]).returns(T::Array[String]) }
def expanded_constraints(constraints)
constraints.flat_map do |constraint|
parts = constraint.strip.split(/\s+/)
if parts.length > 1 && parts.all? { |part| part.match?(ConstraintHelper::VALID_CONSTRAINT_REGEX) }
parts
else
[constraint]
end
end
end

sig do
params(
constraint_groups: T::Array[T::Array[String]],
name: String
).returns(Requirement)
end
def requirement_for_group(constraint_groups, name)
requirements = constraint_groups.map { |constraints| Requirement.new(constraints) }
fallback_requirement = T.must(requirements.first)

current_version = current_engine_version(name)
return fallback_requirement unless current_version

matching_requirement = requirements.find { |requirement| requirement.satisfied_by?(current_version) }
matching_requirement || fallback_requirement
end

sig { params(name: String).returns(T.nilable(Dependabot::Version)) }
def current_engine_version(name)
raw_version = if name == Language::NAME
Helpers.node_version
else
@installed_versions[name]
end

return nil if raw_version.to_s.strip.empty?

Version.new(raw_version)
rescue StandardError
nil
end

private :raw_engine_constraint,
:parse_constraint_groups,
:expanded_constraints,
:requirement_for_group,
:current_engine_version

# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/PerceivedComplexity
Expand Down
2 changes: 2 additions & 0 deletions npm_and_yarn/spec/dependabot/npm_and_yarn/helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -610,8 +610,10 @@
"node -v",
fingerprint: "node -v"
).and_return("v16.13.1")
allow(Dependabot.logger).to receive(:info)

expect(described_class.node_version).to eq("16.13.1")
expect(Dependabot.logger).to have_received(:info).with("Using node version: 16.13.1")
end

it "raises an error if the Node.js version command fails" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@
end
end

context "with OR constraints where the lower branch is on the right" do
let(:lockfiles) { {} }
let(:package_json) { { "engines" => { "pnpm" => ">=10 || >=7 <9" } } }

it "selects the highest matching supported pnpm version" do
expect(helper.package_manager).to be_a(Dependabot::NpmAndYarn::PNPMPackageManager)
expect(helper.package_manager.detected_version).to eq("10")
end
end

context "when neither lockfile, packageManager, nor engines field exists" do
let(:lockfiles) { {} }
let(:package_json) { {} }
Expand Down Expand Up @@ -522,6 +532,129 @@
end
end

context "when the engines field contains a caret OR constraint" do
let(:package_json) do
{
"name" => "example",
"version" => "1.0.0",
"engines" => {
"node" => "^22 || >=24"
Comment thread
thavaahariharangit marked this conversation as resolved.
}
}
end

it "expands caret constraints into separate comparators" do
allow(Dependabot::NpmAndYarn::Helpers).to receive(:node_version).and_return("22.6.0")

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"])
end

it "selects the matching OR branch for current node version" do
allow(Dependabot::NpmAndYarn::Helpers).to receive(:node_version).and_return("24.2.0")

requirement = helper.find_engine_constraints_as_requirement("node")

expect(requirement).to be_a(Dependabot::NpmAndYarn::Requirement)
expect(requirement.constraints).to eq([">= 24"])
end

it "falls back to the first OR branch when current node version is unavailable" do
allow(Dependabot::NpmAndYarn::Helpers).to receive(:node_version).and_return(nil)

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"])
end

context "when one OR branch is invalid" do
let(:package_json) do
{
"name" => "example",
"version" => "1.0.0",
"engines" => {
"node" => "^22 || invalid"
}
}
end

it "logs a warning and returns nil" do
expect(Dependabot.logger).to receive(:warn).with(/Unrecognized constraint format for node: \^22 \|\| invalid/)

requirement = helper.find_engine_constraints_as_requirement("node")

expect(requirement).to be_nil
end
end

context "when one OR branch is a wildcard" do
let(:package_json) do
{
"name" => "example",
"version" => "1.0.0",
"engines" => {
"node" => "* || >=24"
}
}
end

it "returns nil without logging an unrecognized warning" do
allow(Dependabot.logger).to receive(:warn)

requirement = helper.find_engine_constraints_as_requirement("node")

expect(requirement).to be_nil
expect(Dependabot.logger).not_to have_received(:warn)
.with(/Unrecognized constraint format for node/)
end
end
end

context "when the engines field contains an explicit comparator OR constraint" do
let(:package_json) do
{
"name" => "example",
"version" => "1.0.0",
"engines" => {
"node" => ">=22.0.0 <23.0.0 || >=24"
}
}
end

it "splits the first OR branch into separate comparators" do
allow(Dependabot::NpmAndYarn::Helpers).to receive(:node_version).and_return("22.6.0")

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"])
end

context "when the lower branch appears on the right" do
let(:package_json) do
{
"name" => "example",
"version" => "1.0.0",
"engines" => {
"node" => ">=24 || >=22.0.0 <23.0.0"
}
}
end

it "selects the higher matching branch" do
allow(Dependabot::NpmAndYarn::Helpers).to receive(:node_version).and_return("24.2.0")

requirement = helper.find_engine_constraints_as_requirement("node")

expect(requirement).to be_a(Dependabot::NpmAndYarn::Requirement)
expect(requirement.constraints).to eq([">= 24"])
end
end
end

context "when the engines field does not contain the specified package manager" do
it "returns nil" do
requirement = helper.find_engine_constraints_as_requirement("nonexistent")
Expand Down
Loading