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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "yaml"

require "dependabot/npm_and_yarn/file_updater"

# Handles yarn berry lockfile manipulation — parsing descriptors, finding
# entries, and rewriting keys from exact versions back to ranges. This is
# the berry equivalent of yarn classic's replace-lockfile-declaration.ts.
module Dependabot
module NpmAndYarn
class FileUpdater < Dependabot::FileUpdaters::Base
class BerryLockfileHandler
extend T::Sig

# Parses a yarn berry lockfile (YAML format). Returns nil if unparseable.
sig { params(lockfile_path: String).returns(T.nilable(T::Hash[String, T.untyped])) }
def self.parse(lockfile_path)
return unless File.exist?(lockfile_path)

parsed = YAML.safe_load_file(lockfile_path)
parsed.is_a?(Hash) ? parsed : nil
end

# Checks if the parsed lockfile has the target version for a dependency.
sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T::Boolean) }
def self.version_matches?(parsed, dep_name, version)
parsed.any? do |key, value|
next false unless value.is_a?(Hash)

key.to_s.split(", ").any? { |part| split_descriptor(part)[0] == dep_name } &&
value["version"] == version
end
end

# Rewrites a lockfile descriptor key from exact version to range.
# Example: "axios@npm:1.15.2" → "axios@npm:^1.15.2"
# The resolved version, checksum, and dependencies remain unchanged.
sig do
params(
lockfile_path: String,
dep_name: String,
version: String,
requirement: String
).void
end
def self.replace_declaration(lockfile_path, dep_name, version, requirement)
return unless File.exist?(lockfile_path)

content = File.read(lockfile_path)
parsed = parse(lockfile_path)
return unless parsed

exact_key = find_exact_key(parsed, dep_name, version)
return unless exact_key

protocol = extract_protocol(exact_key, dep_name)
new_key = "#{dep_name}@#{protocol}#{requirement}"

escaped = Regexp.escape(exact_key)
File.write(lockfile_path, content.gsub(/^"#{escaped}":/m, "\"#{new_key}\":"))
end

# Finds the lockfile key containing the given dep name with exact version.
# Handles composite keys (e.g., "a@npm:1.0, a@npm:^1.0").
sig { params(parsed: T::Hash[String, T.untyped], dep_name: String, version: String).returns(T.nilable(String)) }
def self.find_exact_key(parsed, dep_name, version)
parsed.keys.find do |key|
next false unless key.is_a?(String)

key.split(", ").any? do |part|
name, desc = split_descriptor(part)
name == dep_name && (desc&.end_with?(version) || false)
end
end
end

# Splits a yarn berry descriptor into [package_name, version/range].
# Handles scoped packages like @scope/pkg@npm:^1.0.0.
sig { params(descriptor: String).returns([String, T.nilable(String)]) }
def self.split_descriptor(descriptor)
if descriptor.start_with?("@")
at_index = descriptor.index("@", 1)
return [descriptor, nil] unless at_index

[T.must(descriptor[0...at_index]), descriptor[(at_index + 1)..]]
else
parts = descriptor.split("@", 2)
[T.must(parts[0]), parts[1]]
end
end

# Extracts the protocol prefix (e.g., "npm:") from a descriptor.
sig { params(key: String, dep_name: String).returns(String) }
def self.extract_protocol(key, dep_name)
part = key.split(", ").find { |p| split_descriptor(p)[0] == dep_name }
return "" unless part

_, descriptor = split_descriptor(part)
match = descriptor&.match(/^([a-z]+:)/)
match ? T.must(match[1]) : ""
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class YarnLockfileUpdater
require_relative "npmrc_builder"
require_relative "package_json_updater"
require_relative "package_json_preparer"
require_relative "berry_lockfile_handler"

extend T::Sig

Expand Down Expand Up @@ -222,6 +223,13 @@ def run_yarn_berry_top_level_updater(top_level_dependency_updates:, yarn_lock:)

if top_level_dependency_updates.all? { |dep| requirements_changed?(dep[:name]) }
Helpers.run_yarn_command("install #{yarn_berry_args}".strip)

# Yarn berry resolves ranges to the latest matching version, which
# may differ from Dependabot's target. If the lockfile resolved to a
# different version, re-install with the exact target and rewrite
# the lockfile descriptor back to the range — same approach as yarn
# classic's replaceLockfileDeclaration.
pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock)
else
updates = top_level_dependency_updates.collect do |dep|
dep[:name]
Expand All @@ -243,6 +251,78 @@ def requirements_changed?(dependency_name)
dep.requirements != dep.previous_requirements
end

# Checks if yarn resolved to a different version than Dependabot's target
# and re-pins if needed. Yarn berry resolves ranges to the latest matching
# version, which can bypass Dependabot's version selection — including
# security updates (minimum safe version), ignore conditions, and cooldown.
sig do
params(
top_level_dependency_updates: T::Array[T::Hash[Symbol, T.untyped]],
yarn_lock: Dependabot::DependencyFile
).void
end
def pin_berry_versions_if_needed(top_level_dependency_updates, yarn_lock)
parsed = BerryLockfileHandler.parse(yarn_lock.name)
return unless parsed

top_level_dependency_updates.each do |dep|
pin_berry_version_if_needed(dep, yarn_lock, parsed)
end
end

sig do
params(
dep: T::Hash[Symbol, T.untyped],
yarn_lock: Dependabot::DependencyFile,
parsed_lockfile: T::Hash[String, T.untyped]
).void
end
def pin_berry_version_if_needed(dep, yarn_lock, parsed_lockfile)
version = dep[:version]
return unless version

dep_name = T.cast(dep[:name], String)
reqs = dep[:requirements]
return if reqs.nil? || reqs.empty?
return if reqs.any? { |req| req[:source] && req[:source][:type] == "git" }
return if BerryLockfileHandler.version_matches?(parsed_lockfile, dep_name, T.cast(version, String))

saved_package_jsons = save_package_jsons

Helpers.run_yarn_command(
"up #{dep_name}@#{version} #{yarn_berry_args}".strip,
fingerprint: "up <dep>@<version> #{yarn_berry_args}".strip
)

reqs.each do |req|
requirement = req[:requirement]
next unless requirement

BerryLockfileHandler.replace_declaration(yarn_lock.name, dep_name, T.cast(version, String), requirement)
end

# Restore package.json and re-install to normalize lockfile descriptors,
# same as yarn classic's replaceLockfileDeclaration flow.
restore_package_jsons(saved_package_jsons)
Helpers.run_yarn_command("install #{yarn_berry_args}".strip)
end

sig { returns(T::Hash[String, String]) }
def save_package_jsons
result = T.let({}, T::Hash[String, String])
package_files.each do |file|
next unless File.exist?(file.name)

result[file.name] = File.read(file.name)
end
result
end

sig { params(saved: T::Hash[String, String]).void }
def restore_package_jsons(saved)
saved.each { |path, content| File.write(path, content) }
end

sig { params(yarn_lock: Dependabot::DependencyFile).returns(T::Hash[String, String]) }
def run_yarn_berry_subdependency_updater(yarn_lock:)
dep = T.must(sub_dependencies.first)
Expand Down
Loading
Loading