From f7a116dc2657eea03fa8a7140fd0e0654f9e7fc3 Mon Sep 17 00:00:00 2001 From: kbukum1 <kbukum1@github.com> Date: Tue, 26 Nov 2024 14:04:13 -0800 Subject: [PATCH] Store Language Name, Version, and Requirements for `npm`, `pnpm`, and `yarn` (#11017) --- .../dependabot/npm_and_yarn/file_parser.rb | 3 +- .../lib/dependabot/npm_and_yarn/helpers.rb | 56 ++++++++++- .../npm_and_yarn/package_manager.rb | 98 +++++++++++++++++-- .../dependabot/npm_and_yarn/helpers_spec.rb | 75 ++++++++++++++ .../dependabot/npm_and_yarn/language_spec.rb | 50 ++++++++++ .../package_manager_helper_spec.rb | 2 +- 6 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 npm_and_yarn/spec/dependabot/npm_and_yarn/language_spec.rb diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser.rb index 9f3771c81b..6acf9978d2 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser.rb @@ -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) ) diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/helpers.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/helpers.rb index 7b4f8f370f..47ca6628bf 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/helpers.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/helpers.rb @@ -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) @@ -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 @@ -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)) } 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 203cf1808d..54b9b22ebd 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 @@ -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 @@ -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 @@ -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) } @@ -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 @@ -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 diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/helpers_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/helpers_spec.rb index 6786c77495..2cf2832b78 100644 --- a/npm_and_yarn/spec/dependabot/npm_and_yarn/helpers_spec.rb +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/helpers_spec.rb @@ -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 diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/language_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/language_spec.rb new file mode 100644 index 0000000000..380e2ddb84 --- /dev/null +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/language_spec.rb @@ -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 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 4c7c3ca2b4..5f1febe98f 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 @@ -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