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