Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Bun with bun.lock #11209

Closed
wants to merge 10 commits into from
Closed
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
4 changes: 3 additions & 1 deletion npm_and_yarn/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ ARG PNPM_VERSION=9.15.0
# Check for updates at https://github.com/yarnpkg/berry/releases
ARG YARN_VERSION=4.5.3

# Check for updates at https://github.com/oven-sh/bun/releases
ARG BUN_VERSION=1.1.39

# See https://github.com/nodesource/distributions#installation-instructions
ARG NODEJS_VERSION=20
Expand All @@ -26,7 +28,7 @@ RUN mkdir -p /etc/apt/keyrings \
&& apt-get install -y --no-install-recommends \
nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g corepack@$COREPACK_VERSION \
&& npm install -g corepack@$COREPACK_VERSION bun@$BUN_VERSION \
&& rm -rf ~/.npm

USER dependabot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def package_required_lockfile?(lockfile)

sig { params(lockfile: DependencyFile).returns(T::Boolean) }
def workspaces_lockfile?(lockfile)
return false unless ["yarn.lock", "package-lock.json", "pnpm-lock.yaml"].include?(lockfile.name)
return false unless ["yarn.lock", "package-lock.json", "pnpm-lock.yaml", "bun.lock"].include?(lockfile.name)

return false unless parsed_root_package_json["workspaces"] || dependency_files.any? do |file|
file.name.end_with?("pnpm-workspace.yaml") && File.dirname(file.name) == File.dirname(lockfile.name)
Expand Down Expand Up @@ -148,7 +148,8 @@ def lockfile?(file)
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"npm-shrinkwrap.json"
"npm-shrinkwrap.json",
"bun.lock"
)
end
end
Expand Down
41 changes: 40 additions & 1 deletion npm_and_yarn/lib/dependabot/npm_and_yarn/file_fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def ecosystem_versions
package_managers["npm"] = npm_version if npm_version
package_managers["yarn"] = yarn_version if yarn_version
package_managers["pnpm"] = pnpm_version if pnpm_version
package_managers["bun"] = bun_version if bun_version
package_managers["unknown"] = 1 if package_managers.empty?

{
Expand All @@ -83,6 +84,7 @@ def fetch_files
fetched_files += npm_files if npm_version
fetched_files += yarn_files if yarn_version
fetched_files += pnpm_files if pnpm_version
fetched_files += bun_files if bun_version
fetched_files += lerna_files
fetched_files += workspace_package_jsons
fetched_files += path_dependencies(fetched_files)
Expand Down Expand Up @@ -120,6 +122,13 @@ def pnpm_files
fetched_pnpm_files
end

sig { returns(T::Array[DependencyFile]) }
def bun_files
fetched_bun_files = []
fetched_bun_files << bun_lock if bun_lock
fetched_bun_files
end

sig { returns(T::Array[DependencyFile]) }
def lerna_files
fetched_lerna_files = []
Expand Down Expand Up @@ -202,6 +211,14 @@ def pnpm_version
)
end

sig { returns(T.nilable(T.any(Integer, String))) }
def bun_version
@bun_version ||= T.let(
package_manager_helper.setup(Bun::NAME),
T.nilable(T.any(Integer, String))
)
end

sig { returns(PackageManagerHelper) }
def package_manager_helper
@package_manager_helper ||= T.let(
Expand All @@ -219,7 +236,8 @@ def lockfiles
{
npm: package_lock || shrinkwrap,
yarn: yarn_lock,
pnpm: pnpm_lock
pnpm: pnpm_lock,
bun: bun_lock
}
end

Expand Down Expand Up @@ -274,6 +292,27 @@ def pnpm_lock
@pnpm_lock
end

sig { returns(T.nilable(DependencyFile)) }
def bun_lock
return @bun_lock if defined?(@bun_lock)

@bun_lock ||= T.let(fetch_file_if_present(Bun::LOCKFILE_NAME), T.nilable(DependencyFile))

return @bun_lock if @bun_lock || directory == "/"

# Loop through parent directories looking for a pnpm-lock
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Loop through parent directories looking for a pnpm-lock
# Loop through parent directories looking for a bun.lock

(1..directory.split("/").count).each do |i|
@bun_lock = fetch_file_from_host(("../" * i) + Bun::LOCKFILE_NAME)
.tap { |f| f.support_file = true }
break if @bun_lock
rescue Dependabot::DependencyFileNotFound
# Ignore errors (bun.lock may not be present)
nil
end

@bun_lock
end

sig { returns(T.nilable(DependencyFile)) }
def shrinkwrap
return @shrinkwrap if defined?(@shrinkwrap)
Expand Down
10 changes: 9 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 @@ -110,7 +110,8 @@ def lockfiles
{
npm: package_lock || shrinkwrap,
yarn: yarn_lock,
pnpm: pnpm_lock
pnpm: pnpm_lock,
bun: bun_lock
}
end

Expand Down Expand Up @@ -167,6 +168,13 @@ def pnpm_lock
end, T.nilable(Dependabot::DependencyFile))
end

sig { returns(T.nilable(Dependabot::DependencyFile)) }
def bun_lock
@bun_lock ||= T.let(dependency_files.find do |f|
f.name == Bun::LOCKFILE_NAME
end, T.nilable(Dependabot::DependencyFile))
end

sig { returns(T.nilable(Dependabot::DependencyFile)) }
def npmrc
@npmrc ||= T.let(dependency_files.find do |f|
Expand Down
148 changes: 148 additions & 0 deletions npm_and_yarn/lib/dependabot/npm_and_yarn/file_parser/bun_lock.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# typed: strict
# frozen_string_literal: true

require "yaml"
require "dependabot/errors"
require "dependabot/npm_and_yarn/helpers"
require "sorbet-runtime"

module Dependabot
module NpmAndYarn
class FileParser < Dependabot::FileParsers::Base
class BunLock
extend T::Sig

sig { params(dependency_file: DependencyFile).void }
def initialize(dependency_file)
@dependency_file = dependency_file
end

sig { returns(T::Hash[String, T.untyped]) }
def parsed
# Since bun.lock is a JSONC file, which is a subset of YAML, we can use YAML to parse it
content = YAML.load(T.must(@dependency_file.content))
raise_invalid!("expected to be an object") unless content.is_a?(Hash)

version = content["lockfileVersion"]
raise_invalid!("expected 'lockfileVersion' to be an integer") unless version.is_a?(Integer)
raise_invalid!("expected 'lockfileVersion' to be >= 0") unless version >= 0
Copy link

@glensc glensc Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps stick to version 0 only, until newer versions are known to be compatible?

unless version.zero?
raise_invalid!(<<~ERROR
unsupported 'lockfileVersion' = #{version}, please open an issue with Dependabot to support this:
https://github.com/dependabot/dependabot/issues/new
ERROR
)
end

@content ||= T.let(content, T.untyped)
rescue Psych::SyntaxError => e
raise_invalid!("malformed JSONC at line #{e.line}, column #{e.column}")
end

sig { returns(Integer) }
def version
parsed["lockfileVersion"]
end

sig { returns(Dependabot::FileParsers::Base::DependencySet) }
def dependencies
dependency_set = Dependabot::FileParsers::Base::DependencySet.new

# bun.lock v0 format:
# https://github.com/oven-sh/bun/blob/c130df6c589fdf28f9f3c7f23ed9901140bc9349/src/install/bun.lock.zig#L595-L605

packages = parsed["packages"]
raise_invalid!("expected 'packages' to be an object") unless packages.is_a?(Hash)

packages.each do |key, details|
raise_invalid!("expected 'packages.#{key}' to be an array") unless details.is_a?(Array)

resolution = details.first
raise_invalid!("expected 'packages.#{key}[0]' to be a string") unless resolution.is_a?(String)

name, version = resolution.split(/(?<=\w)\@/)
next if name.empty?

semver = Version.semver_for(version)
next unless semver

dependency_set << Dependency.new(
name: name,
version: semver.to_s,
package_manager: "npm_and_yarn",
requirements: []
)
end

dependency_set
end

sig do
params(dependency_name: String, requirement: T.untyped, _manifest_name: String)
.returns(T.nilable(T::Hash[String, T.untyped]))
end
def details(dependency_name, requirement, _manifest_name)
packages = parsed["packages"]
return unless packages.is_a?(Hash)

candidates =
packages
.select { |name, _| name == dependency_name }
.values

# If there's only one entry for this dependency, use it, even if
# the requirement in the lockfile doesn't match
if candidates.one?
parse_details(candidates.first)
else
candidate = candidates.find do |label, _|
label.scan(/(?<=\w)\@(?:npm:)?([^\s,]+)/).flatten.include?(requirement)
end&.last
parse_details(candidate)
end
end

private

sig { params(message: String).void }
def raise_invalid!(message)
raise Dependabot::DependencyFileNotParseable.new(@dependency_file.path, "Invalid bun.lock file: #{message}")
end

sig do
params(entry: T.nilable(T::Array[T.untyped])).returns(T.nilable(T::Hash[String, T.untyped]))
end
def parse_details(entry)
return unless entry.is_a?(Array)

# Either:
# - "{name}@{version}", registry, details, integrity
# - "{name}@{resolution}", details
resolution = entry.first
return unless resolution.is_a?(String)

name, version = resolution.split(/(?<=\w)\@/)
semver = Version.semver_for(version)

if semver
registry, details, integrity = entry[1..3]
{
"name" => name,
"version" => semver.to_s,
"registry" => registry,
"details" => details,
"integrity" => integrity
}
else
details = entry[1]
{
"name" => name,
"resolution" => version,
"details" => details
}
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class LockfileParser
require "dependabot/npm_and_yarn/file_parser/yarn_lock"
require "dependabot/npm_and_yarn/file_parser/pnpm_lock"
require "dependabot/npm_and_yarn/file_parser/json_lock"
require "dependabot/npm_and_yarn/file_parser/bun_lock"

sig { params(dependency_files: T::Array[DependencyFile]).void }
def initialize(dependency_files:)
Expand All @@ -29,7 +30,7 @@ def parse_set
# end up unique by name. That's not a perfect representation of
# the nested nature of JS resolution, but it makes everything work
# comparably to other flat-resolution strategies
(yarn_locks + pnpm_locks + package_locks + shrinkwraps).each do |file|
(package_locks + yarn_locks + pnpm_locks + bun_locks + shrinkwraps).each do |file|
dependency_set += lockfile_for(file).dependencies
end

Expand Down Expand Up @@ -65,24 +66,26 @@ def lockfile_details(dependency_name:, requirement:, manifest_name:)
def potential_lockfiles_for_manifest(manifest_filename)
dir_name = File.dirname(manifest_filename)
possible_lockfile_names =
%w(package-lock.json npm-shrinkwrap.json pnpm-lock.yaml yarn.lock).map do |f|
%w(package-lock.json yarn.lock pnpm-lock.yaml bun.lock npm-shrinkwrap.json).map do |f|
Pathname.new(File.join(dir_name, f)).cleanpath.to_path
end +
%w(yarn.lock pnpm-lock.yaml package-lock.json npm-shrinkwrap.json)
%w(package-lock.json yarn.lock pnpm-lock.yaml bun.lock npm-shrinkwrap.json)

possible_lockfile_names.uniq
.filter_map { |nm| dependency_files.find { |f| f.name == nm } }
end

sig { params(file: DependencyFile).returns(T.any(JsonLock, YarnLock, PnpmLock)) }
sig { params(file: DependencyFile).returns(T.any(JsonLock, YarnLock, PnpmLock, BunLock)) }
def lockfile_for(file)
@lockfiles ||= T.let({}, T.nilable(T::Hash[String, T.any(JsonLock, YarnLock, PnpmLock)]))
@lockfiles ||= T.let({}, T.nilable(T::Hash[String, T.any(JsonLock, YarnLock, PnpmLock, BunLock)]))
@lockfiles[file.name] ||= if [*package_locks, *shrinkwraps].include?(file)
JsonLock.new(file)
elsif yarn_locks.include?(file)
YarnLock.new(file)
else
elsif pnpm_locks.include?(file)
PnpmLock.new(file)
else
BunLock.new(file)
end
end

Expand All @@ -102,6 +105,14 @@ def pnpm_locks
)
end

sig { returns(T::Array[DependencyFile]) }
def bun_locks
@bun_locks ||= T.let(
dependency_files
.select { |f| f.name.end_with?("bun.lock") }, T.nilable(T::Array[DependencyFile])
)
end

sig { returns(T::Array[DependencyFile]) }
def yarn_locks
@yarn_locks ||= T.let(
Expand Down
Loading
Loading