Skip to content

Commit

Permalink
Store Language Name, Version, and Requirements for npm, pnpm, and…
Browse files Browse the repository at this point in the history
… `yarn` (#11017)
  • Loading branch information
kbukum1 authored Nov 26, 2024
1 parent 8964345 commit f7a116d
Show file tree
Hide file tree
Showing 6 changed files with 270 additions and 14 deletions.
3 changes: 2 additions & 1 deletion npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ def ecosystem
@ecosystem ||= T.let(
Ecosystem.new(
name: ECOSYSTEM,
package_manager: package_manager_helper.package_manager
package_manager: package_manager_helper.package_manager,
language: package_manager_helper.language
),
T.nilable(Ecosystem)
)
Expand Down
56 changes: 53 additions & 3 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,37 @@ def self.run_npm_command(command, fingerprint: command)
end
end

sig { returns(T.nilable(String)) }
def self.node_version
version = run_node_command("-v", fingerprint: "-v").strip

# 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
end
rescue StandardError => e
puts "Error retrieving Node.js version: #{e.message}"
nil
end

sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
def self.run_node_command(command, fingerprint: nil)
full_command = "node #{command}"

Dependabot.logger.info("Running node command: #{full_command}")

result = Dependabot::SharedHelpers.run_shell_command(
full_command,
fingerprint: "node #{fingerprint || command}"
)

Dependabot.logger.info("Command executed successfully: #{full_command}")
result
rescue StandardError => e
Dependabot.logger.error("Error running node command: #{full_command}, Error: #{e.message}")
raise
end

# Setup yarn and run a single yarn command returning stdout/stderr
sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
def self.run_yarn_command(command, fingerprint: nil)
Expand Down Expand Up @@ -392,7 +423,15 @@ def self.package_manager_activate(name, version)
# Get the version of the package manager by using corepack
sig { params(name: String).returns(String) }
def self.package_manager_version(name)
package_manager_run_command(name, "-v")
Dependabot.logger.info("Fetching version for package manager: #{name}")

version = package_manager_run_command(name, "-v").strip

Dependabot.logger.info("Version for #{name}: #{version}")
version
rescue StandardError => e
Dependabot.logger.error("Error fetching version for package manager #{name}: #{e.message}")
raise
end

# Run single command on package manager returning stdout/stderr
Expand All @@ -404,11 +443,22 @@ def self.package_manager_version(name)
).returns(String)
end
def self.package_manager_run_command(name, command, fingerprint: nil)
Dependabot::SharedHelpers.run_shell_command(
"corepack #{name} #{command}",
full_command = "corepack #{name} #{command}"

Dependabot.logger.info("Running package manager command: #{full_command}")

result = Dependabot::SharedHelpers.run_shell_command(
full_command,
fingerprint: "corepack #{name} #{fingerprint || command}"
).strip

Dependabot.logger.info("Command executed successfully: #{full_command}")
result
rescue StandardError => e
Dependabot.logger.error("Error running package manager command: #{full_command}, Error: #{e.message}")
raise
end

private_class_method :run_single_yarn_command

sig { params(pnpm_lock: DependencyFile).returns(T.nilable(String)) }
Expand Down
98 changes: 89 additions & 9 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,20 @@ def initialize(lockfiles, package_json)
# Defaults to npm if no package manager is detected
sig { returns(String) }
def detect_package_manager
name_from_lockfiles || name_from_package_manager_attr || name_from_engines || DEFAULT_PACKAGE_MANAGER
package_manager = name_from_lockfiles ||
name_from_package_manager_attr ||
name_from_engines

if package_manager
Dependabot.logger.info("Detected package manager: #{package_manager}")
else
package_manager = DEFAULT_PACKAGE_MANAGER
Dependabot.logger.info("Default package manager used: #{package_manager}")
end
package_manager
rescue StandardError => e
Dependabot.logger.error("Error detecting package manager: #{e.message}")
DEFAULT_PACKAGE_MANAGER
end

private
Expand Down Expand Up @@ -256,6 +269,41 @@ def name_from_engines
end
end

class Language < Ecosystem::VersionManager
extend T::Sig
NAME = "node"

SUPPORTED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])

DEPRECATED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])

sig do
params(
raw_version: T.nilable(String),
requirement: T.nilable(Requirement)
).void
end
def initialize(raw_version, requirement: nil)
super(
NAME,
Version.new(raw_version),
DEPRECATED_VERSIONS,
SUPPORTED_VERSIONS,
requirement
)
end

sig { override.returns(T::Boolean) }
def deprecated?
false
end

sig { override.returns(T::Boolean) }
def unsupported?
false
end
end

class PackageManagerHelper
extend T::Sig
extend T::Helpers
Expand All @@ -274,6 +322,9 @@ def initialize(package_json, lockfiles:)
@engines = T.let(package_json&.fetch(MANIFEST_ENGINES_KEY, nil), T.nilable(T::Hash[String, T.untyped]))

@installed_versions = T.let({}, T::Hash[String, String])

@language = T.let(nil, T.nilable(Ecosystem::VersionManager))
@language_requirement = T.let(nil, T.nilable(Requirement))
end

sig { returns(Ecosystem::VersionManager) }
Expand All @@ -283,31 +334,47 @@ def package_manager
)
end

sig { returns(Ecosystem::VersionManager) }
def language
@language ||= Language.new(
Helpers.node_version,
requirement: language_requirement
)
end

sig { returns(T.nilable(Requirement)) }
def language_requirement
@language_requirement ||= find_engine_constraints_as_requirement(Language::NAME)
end

sig { params(name: String).returns(T.nilable(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

return nil if raw_constraint.empty?

raw_constraints = raw_constraint.split

constraints = raw_constraints.map do |constraint|
if constraint.match?(/^\d+$/)
case constraint
when /^\d+$/
">=#{constraint}.0.0 <#{constraint.to_i + 1}.0.0"
elsif constraint.match?(/^\d+\.\d+$/)
when /^\d+\.\d+$/
">=#{constraint} <#{constraint.split('.').first.to_i + 1}.0.0"
elsif constraint.match?(/^\d+\.\d+\.\d+$/)
when /^\d+\.\d+\.\d+$/
"=#{constraint}"
else
Dependabot.logger.warn("Unrecognized constraint format for #{name}: #{constraint}")
constraint
end
end

Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}")
Requirement.new(constraints)
rescue StandardError => e
Dependabot.logger.error("Failed to parse engines constraint for #{name}: #{e.message}")
Dependabot.logger.error("Error processing constraints for #{name}: #{e.message}")
nil
end

Expand Down Expand Up @@ -374,18 +441,31 @@ def setup(name)

sig { params(name: T.nilable(String)).returns(Ecosystem::VersionManager) }
def package_manager_by_name(name)
name = ensure_valid_package_manager(name)
Dependabot.logger.info("Resolving package manager for: #{name || 'default'}")

name = ensure_valid_package_manager(name)
package_manager_class = T.must(PACKAGE_MANAGER_CLASSES[name])

installed_version = installed_version(name)
Dependabot.logger.info("Installed version for #{name}: #{installed_version}")

package_manager_requirement = find_engine_constraints_as_requirement(name)
if package_manager_requirement
Dependabot.logger.info("Version requirement for #{name}: #{package_manager_requirement}")
else
Dependabot.logger.info("No version requirement found for #{name}")
end

package_manager_class.new(
package_manager_instance = package_manager_class.new(
installed_version,
requirement: package_manager_requirement
)

Dependabot.logger.info("Package manager resolved for #{name}: #{package_manager_instance}")
package_manager_instance
rescue StandardError => e
Dependabot.logger.error("Error resolving package manager for #{name || 'default'}: #{e.message}")
raise
end

# rubocop:enable Metrics/CyclomaticComplexity
Expand Down
75 changes: 75 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 @@ -293,4 +293,79 @@
end
end
end

describe "::run_node_command" do
it "executes the correct node command and returns the output" do
allow(Dependabot::SharedHelpers).to receive(:run_shell_command).with(
"node --version",
fingerprint: "node --version"
).and_return("v16.13.1")

expect(described_class.run_node_command("--version", fingerprint: "--version")).to eq("v16.13.1")
end

it "executes the node command with a custom fingerprint" do
allow(Dependabot::SharedHelpers).to receive(:run_shell_command).with(
"node -e 'console.log(\"Hello World\")'",
fingerprint: "node custom_fingerprint"
).and_return("Hello World")

expect(
described_class.run_node_command(
"-e 'console.log(\"Hello World\")'",
fingerprint: "custom_fingerprint"
)
).to eq("Hello World")
end

it "raises an error if the node command fails" do
error_context = {
command: "node invalid_command",
fingerprint: "node invalid_command"
}

allow(Dependabot::SharedHelpers).to receive(:run_shell_command).with(
"node invalid_command",
fingerprint: "node invalid_command"
).and_raise(
Dependabot::SharedHelpers::HelperSubprocessFailed.new(
message: "Command failed",
error_context: error_context
)
)

expect { described_class.run_node_command("invalid_command", fingerprint: "invalid_command") }
.to raise_error(Dependabot::SharedHelpers::HelperSubprocessFailed, /Command failed/)
end
end

describe "::node_version" do
it "returns the correct Node.js version" do
allow(Dependabot::SharedHelpers).to receive(:run_shell_command).with(
"node -v",
fingerprint: "node -v"
).and_return("v16.13.1")

expect(described_class.node_version).to eq("16.13.1")
end

it "raises an error if the Node.js version command fails" do
error_context = {
command: "node -v",
fingerprint: "node -v"
}

allow(Dependabot::SharedHelpers).to receive(:run_shell_command).with(
"node -v",
fingerprint: "node -v"
).and_raise(
Dependabot::SharedHelpers::HelperSubprocessFailed.new(
message: "Error running node command",
error_context: error_context
)
)

expect(described_class.node_version).to be_nil
end
end
end
50 changes: 50 additions & 0 deletions npm_and_yarn/spec/dependabot/npm_and_yarn/language_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# typed: false
# frozen_string_literal: true

require "dependabot/ecosystem"
require "dependabot/npm_and_yarn/package_manager"
require "spec_helper"

RSpec.describe Dependabot::NpmAndYarn::Language do
let(:language) { described_class.new(raw_version, requirement: requirement) }
let(:raw_version) { "16.13.1" }
let(:requirement) { nil }

describe "#initialize" do
it "sets the version correctly" do
expect(language.version).to eq(Dependabot::Version.new(raw_version))
end

it "sets the name correctly" do
expect(language.name).to eq(Dependabot::NpmAndYarn::Language::NAME)
end

it "sets the deprecated_versions correctly" do
expect(language.deprecated_versions).to eq(Dependabot::NpmAndYarn::Language::DEPRECATED_VERSIONS)
end

it "sets the supported_versions correctly" do
expect(language.supported_versions).to eq(Dependabot::NpmAndYarn::Language::SUPPORTED_VERSIONS)
end

context "when a requirement is provided" do
let(:requirement) { Dependabot::NpmAndYarn::Requirement.new([">= 16.0.0", "< 17.0.0"]) }

it "sets the requirement correctly" do
expect(language.requirement).to eq(requirement)
end
end
end

describe "#deprecated?" do
it "returns false by default" do
expect(language.deprecated?).to be false
end
end

describe "#unsupported?" do
it "returns false by default" do
expect(language.unsupported?).to be false
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@
end

it "logs an error and returns nil" do
expect(Dependabot.logger).to receive(:error).with(/Failed to parse engines constraint/)
expect(Dependabot.logger).to receive(:warn).with(/Unrecognized constraint format for npm: invalid/)
requirement = helper.find_engine_constraints_as_requirement("npm")
expect(requirement).to be_nil
end
Expand Down

0 comments on commit f7a116d

Please sign in to comment.