Skip to content

Commit

Permalink
Add the bun ecosystem file fetcher
Browse files Browse the repository at this point in the history
  • Loading branch information
markhallen committed Feb 4, 2025
1 parent fc8025f commit 3266104
Show file tree
Hide file tree
Showing 31 changed files with 2,593 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ gem "dependabot-terraform", path: "terraform"
gem "sorbet", "0.5.11630", group: :development
gem "tapioca", "0.16.6", require: false, group: :development

gem "zeitwerk", "~> 2.7"

common_gemspec = File.expand_path("common/dependabot-common.gemspec", __dir__)

deps_shared_with_common = %w(
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ GEM
yard-sorbet (0.9.0)
sorbet-runtime
yard
zeitwerk (2.7.1)

PLATFORMS
aarch64-linux
Expand Down Expand Up @@ -413,6 +414,7 @@ DEPENDENCIES
vcr (~> 6.1)
webmock (~> 3.18)
webrick (>= 1.7)
zeitwerk (~> 2.7)

BUNDLED WITH
2.6.3
39 changes: 39 additions & 0 deletions javascript/lib/dependabot/bun.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# typed: strong
# frozen_string_literal: true

require "zeitwerk"

loader = Zeitwerk::Loader.new

# Set autoload paths for common/lib, excluding files whose content does not match the filename
loader.push_dir(File.join(__dir__, "../../../common/lib"))
loader.ignore(File.join(__dir__, "../../../common/lib/dependabot/errors.rb"))
loader.ignore(File.join(__dir__, "../../../common/lib/dependabot/logger.rb"))
loader.ignore(File.join(__dir__, "../../../common/lib/dependabot/notices.rb"))
loader.ignore(File.join(__dir__, "../../../common/lib/dependabot/clients/codecommit.rb"))

loader.push_dir(File.join(__dir__, ".."))
loader.ignore("#{__dir__}/../script", "#{__dir__}/../spec", "#{__dir__}/../dependabot-bun.gemspec")

loader.on_load do |_file|
require "json"
require "sorbet-runtime"
require "dependabot/errors"
require "dependabot/logger"
require "dependabot/notices"
require "dependabot/clients/codecommit"
end

loader.log! if ENV["DEBUG"]
loader.setup

Dependabot::PullRequestCreator::Labeler
.register_label_details("bun", name: "javascript", colour: "168700")

Dependabot::Dependency.register_production_check("bun", ->(_) { true })

module Dependabot
module Bun
ECOSYSTEM = "bun"
end
end
97 changes: 97 additions & 0 deletions javascript/lib/dependabot/bun/file_fetcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# typed: strong
# frozen_string_literal: true

module Dependabot
module Bun
class FileFetcher < Dependabot::FileFetchers::Base
include Javascript::FileFetcherHelper
extend T::Sig
extend T::Helpers

sig { override.params(filenames: T::Array[String]).returns(T::Boolean) }
def self.required_files_in?(filenames)
filenames.include?("package.json")
end

sig { override.returns(String) }
def self.required_files_message
"Repo must contain a package.json."
end

sig { override.returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def ecosystem_versions
return unknown_ecosystem_versions unless ecosystem_enabled?

{
package_managers: {
"bun" => 1
}
}
end

sig { override.returns(T::Array[DependencyFile]) }
def fetch_files
fetched_files = T.let([], T::Array[DependencyFile])
fetched_files << package_json(self)
fetched_files += bun_files if ecosystem_enabled?
fetched_files += workspace_package_jsons(self)
fetched_files += path_dependencies(self, fetched_files)

fetched_files.uniq
end

sig { params(filename: String, fetch_submodules: T::Boolean).returns(DependencyFile) }
def fetch_file(filename, fetch_submodules: false)
fetch_file_from_host(filename, fetch_submodules: fetch_submodules)
end

sig do
params(
dir: T.any(Pathname, String),
ignore_base_directory: T::Boolean,
raise_errors: T::Boolean,
fetch_submodules: T::Boolean
)
.returns(T::Array[T.untyped])
end
def fetch_repo_contents(dir: ".", ignore_base_directory: false, raise_errors: true, fetch_submodules: false)
repo_contents(dir: dir, ignore_base_directory:, raise_errors:, fetch_submodules:)
end

private

sig { returns(T::Array[DependencyFile]) }
def bun_files
[bun_lock].compact
end

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

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

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

@bun_lock = fetch_file_from_parent_directories(self, PackageManager::LOCKFILE_NAME)
end

sig { returns(T::Boolean) }
def ecosystem_enabled?
allow_beta_ecosystems? && Experiments.enabled?(:enable_bun_ecosystem)
end

sig { returns(T::Hash[Symbol, String]) }
def unknown_ecosystem_versions
{
package_managers: {
"unknown" => 0
}
}
end
end
end
end

Dependabot::FileFetchers
.register(Dependabot::Bun::ECOSYSTEM, Dependabot::Bun::FileFetcher)
148 changes: 148 additions & 0 deletions javascript/lib/dependabot/bun/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 "sorbet-runtime"

module Dependabot
module Bun
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
@parsed ||= begin
content = begin
# Since bun.lock is a JSONC file, which is a subset of YAML, we can use YAML to parse it
YAML.load(T.must(@dependency_file.content))
rescue Psych::SyntaxError => e
raise_invalid!("malformed JSONC at line #{e.line}, column #{e.column}")
end
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

T.let(content, T.untyped)
end
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

sig { override.returns(T::Array[Dependabot::Dependency]) }
def parse
[]
end

private

sig { override.void }
def check_required_files; end
end
end
end
79 changes: 79 additions & 0 deletions javascript/lib/dependabot/bun/helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# typed: strong
# frozen_string_literal: true

module Dependabot
module Bun
module Helpers
extend T::Sig

# BUN Version Constants
BUN_V1 = 1
BUN_DEFAULT_VERSION = BUN_V1

sig { params(_bun_lock: T.nilable(DependencyFile)).returns(Integer) }
def self.bun_version_numeric(_bun_lock)
BUN_DEFAULT_VERSION
end

sig { returns(T.nilable(String)) }
def self.bun_version
run_bun_command("--version", fingerprint: "--version").strip
rescue StandardError => e
Dependabot.logger.error("Error retrieving Bun version: #{e.message}")
nil
end

sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
def self.run_bun_command(command, fingerprint: nil)
full_command = "bun #{command}"

Dependabot.logger.info("Running bun command: #{full_command}")

result = Dependabot::SharedHelpers.run_shell_command(
full_command,
fingerprint: "bun #{fingerprint || command}"
)

Dependabot.logger.info("Command executed successfully: #{full_command}")
result
rescue StandardError => e
Dependabot.logger.error("Error running bun command: #{full_command}, Error: #{e.message}")
raise
end

# Fetch the currently installed version of the package manager directly
# from the system
sig { params(name: String).returns(String) }
def self.local_package_manager_version(name)
Dependabot::SharedHelpers.run_shell_command(
"#{name} -v",
fingerprint: "#{name} -v"
).strip
end

# Run single command on package manager returning stdout/stderr
sig do
params(
name: String,
command: String,
fingerprint: T.nilable(String)
).returns(String)
end
def self.package_manager_run_command(name, command, fingerprint: nil)
return run_bun_command(command, fingerprint: fingerprint) if name == PackageManager::NAME

# TODO: remove this method and just use the one in the PackageManager class
"noop"
end

sig { params(dependency_set: Dependabot::FileParsers::Base::DependencySet).returns(T::Array[Dependency]) }
def self.dependencies_with_all_versions_metadata(dependency_set)
# TODO: Check if we still need this method
dependency_set.dependencies.map do |dependency|
dependency.metadata[:all_versions] = dependency_set.all_versions_for_name(dependency.name)
dependency
end
end
end
end
end
Loading

0 comments on commit 3266104

Please sign in to comment.