Skip to content

Commit

Permalink
Dynamic version selector for Npm and Yarn (#10510)
Browse files Browse the repository at this point in the history
* Changes for pnpm and yarn dynamic version selector
  • Loading branch information
sachin-sandhu committed Sep 6, 2024
1 parent efdfe8e commit ae11a0e
Show file tree
Hide file tree
Showing 14 changed files with 488 additions and 3 deletions.
62 changes: 59 additions & 3 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/package_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,55 @@
# frozen_string_literal: true

require "dependabot/shared_helpers"
require "dependabot/npm_and_yarn/version_selector"

module Dependabot
module NpmAndYarn
class PackageManager
extend T::Sig
extend T::Helpers
def initialize(package_json, lockfiles:)
@package_json = package_json
@lockfiles = lockfiles
@package_manager = package_json.fetch("packageManager", nil)
@engines = package_json.fetch("engines", nil)
end

# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
def setup(name)
return unless @package_manager.nil? || @package_manager.start_with?("#{name}@")
# we prioritize version mentioned in "packageManager" instead of "engines"
# i.e. if { engines : "pnpm" : "6" } and { packageManager: "pnpm@6.0.2" },
# we go for the specificity mentioned in packageManager (6.0.2)

version = requested_version(name)
if Dependabot::Experiments.enabled?(:enable_pnpm_yarn_dynamic_engine)

unless @package_manager&.start_with?("#{name}@") || (@package_manager&.==name.to_s) || @package_manager.nil?
return
end

if @engines && @package_manager.nil?
# if "packageManager" doesn't exists in manifest file,
# we check if we can extract "engines" information
Dependabot.logger.info("No \"packageManager\" info found for \"#{name}\"")
version = check_engine_version(name)

elsif @package_manager&.==name.to_s
# if "packageManager" is found but no version is specified (i.e. pnpm@1.2.3),
# we check if we can get "engines" info to override default version
Dependabot.logger.info("Found \"packageManager\" : \"#{@package_manager}\"")
version = check_engine_version(name) if @engines

elsif @package_manager&.start_with?("#{name}@")
# if "packageManager" info has version specification i.e. yarn@3.3.1
# we go with the version in "packageManager"
Dependabot.logger.info("Found \"packageManager\" : \"#{@package_manager}\". Skipped checking \"engines\".")
end
else
return unless @package_manager.nil? || @package_manager&.start_with?("#{name}@")
end

version ||= requested_version(name)

if version
raise_if_unsupported!(name, version)
Expand All @@ -33,6 +68,8 @@ def setup(name)

version
end
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/PerceivedComplexity

private

Expand All @@ -44,6 +81,8 @@ def raise_if_unsupported!(name, version)
end

def install(name, version)
Dependabot.logger.info("Installing \"#{name}@#{version}\"")

SharedHelpers.run_shell_command(
"corepack install #{name}@#{version} --global --cache-only",
fingerprint: "corepack install <name>@<version> --global --cache-only"
Expand All @@ -53,18 +92,35 @@ def install(name, version)
def requested_version(name)
return unless @package_manager

match = @package_manager.match(/#{name}@(?<version>\d+.\d+.\d+)/)
match = @package_manager.match(/^#{name}@(?<version>\d+.\d+.\d+)/)
return unless match

Dependabot.logger.info("Requested version #{match['version']}")
match["version"]
end

def guessed_version(name)
lockfile = @lockfiles[name.to_sym]
return unless lockfile

Dependabot.logger.info("Estimating version")
Helpers.send(:"#{name}_version_numeric", lockfile)
end

sig { params(name: T.untyped).returns(T.nilable(String)) }
def check_engine_version(name)
version_selector = VersionSelector.new
engine_versions = version_selector.setup(@package_json, name)

if engine_versions.empty?
Dependabot.logger.info("No relevant (engines) info for \"#{name}\"")
return
end

version = engine_versions[name]
Dependabot.logger.info("Returned (engines) info \"#{name}\" : \"#{version}\"")
version
end
end
end
end
45 changes: 45 additions & 0 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/version_selector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# typed: strict
# frozen_string_literal: true

require "dependabot/shared_helpers"

module Dependabot
module NpmAndYarn
class VersionSelector
extend T::Sig
extend T::Helpers

# For limited testing, allowing only specific versions defined in engines in package.json
# such as "20.8.7", "8.1.2", "8.21.2",
NODE_ENGINE_SUPPORTED_REGEX = /^\d+(?:\.\d+)*$/

sig { params(manifest_json: T::Hash[String, T.untyped], name: String).returns(T::Hash[Symbol, T.untyped]) }
def setup(manifest_json, name)
engine_versions = manifest_json["engines"]

if engine_versions.nil?
Dependabot.logger.info("No info (engines) found")
return {}
end

# logs entries for analysis purposes
log = engine_versions.select do |engine, _value|
engine.to_s.match(name)
end
Dependabot.logger.info("Found engine info #{log}") unless log.empty?

# Only keep matching specs versions i.e. "20.21.2", "7.1.2",
# Additional specs can be added later
engine_versions.delete_if { |_key, value| !valid_extracted_version?(value) }
version = engine_versions.select { |engine, _value| engine.to_s.match(name) }

version
end

sig { params(version: String).returns(T::Boolean) }
def valid_extracted_version?(version)
version.match?(NODE_ENGINE_SUPPORTED_REGEX)
end
end
end
end
Loading

0 comments on commit ae11a0e

Please sign in to comment.