dependabot-bun 0.400.0 → 0.402.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 695147981009a25e1dfdc5b0bb7f6a312a81e7505b91ae1fcf4fe04565e05116
4
- data.tar.gz: 60327771f9e963e76b9f77f32ef8be93e945b52d3b94a0ee51eeaa260d30269a
3
+ metadata.gz: e4c9217ef3c6884ac153b6bb6e516709f0440e6d70a2c25e4f795aaf443adb47
4
+ data.tar.gz: 25430e72661535db6c374dee825b3dcac942f37e98853e47750061f326253602
5
5
  SHA512:
6
- metadata.gz: c21419897e7d901b04ebcfa4f4a09600b69ce29bef225d4884dde567b45c1d09162ef6d8622385d6ba43e358b26782cf182669fe827f5ea5298f3f2480c88a84
7
- data.tar.gz: 1fbfcb079150eef24eb36562200565e38cfd65654843258f54eb3d2fd21f4fb8b1cc0f3e64101fa4bf284dec22bb9f3ac456ba9cb946f250d7f12b55ca0981a2
6
+ metadata.gz: 97eda92117fac8f2ebeb5883f865cff280b089d8430127b0dbb4da826f2ff24ddf11949181b45ca9f320a23ed3b8f8b4fbf5d2660946a5255f646b115dad5b76
7
+ data.tar.gz: 9b729c7238e1f784a09500789c26c4eec54c8afe891619600312345a7902cb15feb3ae0230e56744435876b963e4dc054f06eec94974353ef4d696f7642887c8
@@ -30,6 +30,7 @@ module Dependabot
30
30
  require_relative "file_parser/lockfile_parser"
31
31
 
32
32
  DEPENDENCY_TYPES = %w(dependencies devDependencies optionalDependencies).freeze
33
+ DEFAULT_CATALOG = "default"
33
34
  ALIAS_PROTOCOL = "npm:"
34
35
  GIT_URL_REGEX = %r{
35
36
  (?<git_prefix>^|^git.*?|^github:|^bitbucket:|^gitlab:|github\.com/)
@@ -63,6 +64,7 @@ module Dependabot
63
64
  dependency_set = DependencySet.new
64
65
  dependency_set += manifest_dependencies
65
66
  dependency_set += lockfile_dependencies
67
+ dependency_set += catalog_dependencies
66
68
 
67
69
  dependencies = Helpers.dependencies_with_all_versions_metadata(dependency_set)
68
70
 
@@ -202,11 +204,34 @@ module Dependabot
202
204
  lockfile_parser.parse_set
203
205
  end
204
206
 
207
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
208
+ def catalog_dependencies
209
+ dependency_set = DependencySet.new
210
+ document = package_json_document
211
+ catalogs = [[DEFAULT_CATALOG, document.catalog], *document.catalogs.to_a]
212
+
213
+ catalogs.each do |catalog_name, entries|
214
+ entries.each do |name, requirement|
215
+ # Catalog entries are production dependencies, as pnpm catalogs are in npm_and_yarn.
216
+ dep = build_dependency(
217
+ file: package_json,
218
+ type: "dependencies",
219
+ name: name,
220
+ requirement: requirement,
221
+ catalog: catalog_name
222
+ )
223
+ dependency_set << dep if dep
224
+ end
225
+ end
226
+
227
+ dependency_set
228
+ end
229
+
205
230
  sig do
206
- params(file: DependencyFile, type: String, name: String, requirement: String)
231
+ params(file: DependencyFile, type: String, name: String, requirement: String, catalog: T.nilable(String))
207
232
  .returns(T.nilable(Dependency))
208
233
  end
209
- def build_dependency(file:, type:, name:, requirement:)
234
+ def build_dependency(file:, type:, name:, requirement:, catalog: nil)
210
235
  lockfile_details = lockfile_parser.lockfile_details(
211
236
  dependency_name: name,
212
237
  requirement: requirement,
@@ -243,11 +268,17 @@ module Dependabot
243
268
  requirement: requirement_for(requirement),
244
269
  file: file.name,
245
270
  groups: [type],
246
- source: source_for(name, requirement, lockfile_details)
271
+ source: source_for(name, requirement, lockfile_details),
272
+ **catalog_metadata(catalog)
247
273
  }]
248
274
  )
249
275
  end
250
276
 
277
+ sig { params(catalog: T.nilable(String)).returns(T::Hash[Symbol, T::Hash[Symbol, String]]) }
278
+ def catalog_metadata(catalog)
279
+ catalog ? { metadata: { catalog: catalog } } : {}
280
+ end
281
+
251
282
  sig { override.void }
252
283
  def check_required_files
253
284
  return if get_original_file(MANIFEST_FILENAME)
@@ -6,6 +6,7 @@ require "sorbet-runtime"
6
6
  require "dependabot/bun/helpers"
7
7
  require "dependabot/bun/package/registry_finder"
8
8
  require "dependabot/bun/registry_parser"
9
+ require "dependabot/package/npm_package_json"
9
10
  require "dependabot/shared_helpers"
10
11
 
11
12
  module Dependabot
@@ -69,6 +70,24 @@ module Dependabot
69
70
  T::Hash[Regexp, Dependabot::DependabotError]
70
71
  )
71
72
 
73
+ # A registry rejected the request for a package or its tarball. bun prints
74
+ # `error: failed to download <name>@<version>: <status> <reason>` with the tarball URL on the next line, or
75
+ # `error: GET <url> - <status>` when the request for the package metadata fails.
76
+ DOWNLOAD_FAILED =
77
+ %r{error: failed to download (?<spec>\S+): (?<status>\d{3})[^\n]*(?:\n[[:space:]]*(?<url>https?://\S+))?}
78
+ REQUEST_FAILED = %r{error: GET (?<url>https?://\S+) - (?<status>\d{3})\b}
79
+ ACCESS_DENIED_STATUSES = %w(401 403).freeze
80
+ NOT_FOUND_STATUS = "404"
81
+
82
+ # A workspace that bun.lock or package.json refers to is not part of the checkout
83
+ WORKSPACE_NOT_ON_DISK =
84
+ /
85
+ error:\s(?:the\sroot\spackage|workspace\s"[^"]+")\s
86
+ depends\son\sworkspace\s"(?<name>[^"]+)"\s\((?<path>[^)]*)\),\s
87
+ which\sis\slisted\sin\sbun\.lock\sbut\snot\son\sdisk
88
+ /x
89
+ WORKSPACE_NOT_FOUND = /error: Workspace not found "(?<path>[^"]+)"/
90
+
72
91
  sig { params(bun_lock: Dependabot::DependencyFile).returns(String) }
73
92
  def run_bun_update(bun_lock:)
74
93
  SharedHelpers.in_a_temporary_repo_directory(base_dir, repo_contents_path) do
@@ -88,14 +107,83 @@ module Dependabot
88
107
 
89
108
  sig { void }
90
109
  def run_bun_updater
91
- dependency_updates = dependencies.map do |d|
92
- "#{d.name}@#{d.version}"
93
- end.join(" ")
110
+ dependency_updates_by_target.each do |workspace_name, deps|
111
+ dependency_updates = deps.map { |d| "#{d.name}@#{d.version}" }
94
112
 
95
- Helpers.run_bun_command(
96
- "install #{dependency_updates} --save-text-lockfile --ignore-scripts",
97
- fingerprint: "install <dependency_updates> --save-text-lockfile --ignore-scripts"
98
- )
113
+ command = T.let(["install", *dependency_updates], T::Array[String])
114
+ command += ["--filter", workspace_name] if workspace_name
115
+ command += ["--save-text-lockfile", "--ignore-scripts"]
116
+
117
+ Helpers.run_bun_command(
118
+ command,
119
+ fingerprint: "install <dependency_updates> --save-text-lockfile --ignore-scripts"
120
+ )
121
+ end
122
+ end
123
+
124
+ # Maps each install target (nil for the repo root, or a workspace
125
+ # path) to the dependencies that need installing there. A dependency
126
+ # declared in multiple manifests must be installed in each one, since
127
+ # Bun merges same-name declarations into a single Dependency.
128
+ sig { returns(T::Hash[T.nilable(String), T::Array[Dependabot::Dependency]]) }
129
+ def dependency_updates_by_target
130
+ dependencies
131
+ .flat_map { |dependency| install_targets_for(dependency).map { |target| [target, dependency] } }
132
+ .group_by { |target, _dependency| target }
133
+ .transform_values { |pairs| pairs.map { |_target, dependency| dependency } }
134
+ end
135
+
136
+ # Returns every install target (nil for the repo root, or a workspace
137
+ # path) that declares +dependency+. Falls back to an unfiltered root
138
+ # install if it isn't declared anywhere (e.g. a sub-dependency).
139
+ sig { params(dependency: Dependabot::Dependency).returns(T::Array[T.nilable(String)]) }
140
+ def install_targets_for(dependency)
141
+ root = root_package_json
142
+
143
+ workspace_targets = package_files.filter_map do |file|
144
+ next if file == root
145
+ next unless package_json_declares?(file, dependency)
146
+
147
+ workspace_filter(file)
148
+ end.uniq
149
+
150
+ declared_at_root = !root || package_json_declares?(root, dependency)
151
+ return workspace_targets if !declared_at_root && workspace_targets.any?
152
+
153
+ [nil, *workspace_targets]
154
+ end
155
+
156
+ # Path-based --filter target for the workspace at +file+ (e.g.
157
+ # "./packages/app"). Bun's --filter also accepts a package's declared
158
+ # "name", but names can contain glob metacharacters that would match
159
+ # unrelated workspaces, so a path is used instead.
160
+ sig { params(file: Dependabot::DependencyFile).returns(String) }
161
+ def workspace_filter(file)
162
+ unless Dependabot::Package::NpmPackageJson.from_file(file).name
163
+ raise Dependabot::DependencyFileNotParseable.new(
164
+ file.path,
165
+ "#{file.name} must declare a \"name\" to be targeted for a workspace-scoped update"
166
+ )
167
+ end
168
+
169
+ "./#{File.dirname(file.name)}"
170
+ end
171
+
172
+ sig do
173
+ params(package_json_file: Dependabot::DependencyFile, dependency: Dependabot::Dependency)
174
+ .returns(T::Boolean)
175
+ end
176
+ def package_json_declares?(package_json_file, dependency)
177
+ declared = T.let(false, T::Boolean)
178
+ Dependabot::Package::NpmPackageJson.from_file(package_json_file).each_dependency do |name, _req, _type|
179
+ declared ||= name == dependency.name
180
+ end
181
+ declared
182
+ end
183
+
184
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
185
+ def root_package_json
186
+ package_files.find { |f| f.name == "package.json" }
99
187
  end
100
188
 
101
189
  sig { void }
@@ -116,10 +204,13 @@ module Dependabot
116
204
  ).parse
117
205
  end
118
206
 
119
- sig { params(error: Dependabot::DependabotError, _bun_lock: Dependabot::DependencyFile).returns(T.noreturn) }
120
- def handle_bun_lock_updater_error(error, _bun_lock)
207
+ sig { params(error: Dependabot::DependabotError, bun_lock: Dependabot::DependencyFile).returns(T.noreturn) }
208
+ def handle_bun_lock_updater_error(error, bun_lock)
121
209
  error_message = error.message
122
210
 
211
+ raise_registry_error(error_message)
212
+ raise_workspace_error(error_message, bun_lock)
213
+
123
214
  ERR_PATTERNS.each do |pattern, error_class|
124
215
  raise error_class, error_message if error_message.match?(pattern)
125
216
  end
@@ -127,6 +218,65 @@ module Dependabot
127
218
  raise error
128
219
  end
129
220
 
221
+ # Reports the first registry failure bun printed that the repository has to resolve: access that was denied,
222
+ # or a tarball the registry does not have. A failure with any other status is skipped, so it cannot hide a
223
+ # later one that can be classified, and is left to the generic handling when there is none.
224
+ sig { params(error_message: String).void }
225
+ def raise_registry_error(error_message)
226
+ registry_failures(error_message).each do |failure|
227
+ status = failure[:status]
228
+ url = failure[:url]
229
+
230
+ if ACCESS_DENIED_STATUSES.include?(status) && url
231
+ raise Dependabot::PrivateSourceAuthenticationFailure, registry_origin(url)
232
+ end
233
+
234
+ next unless status == NOT_FOUND_STATUS && failure.names.include?("spec")
235
+
236
+ raise Dependabot::DependencyNotFound, package_name_from(T.must(failure[:spec]))
237
+ end
238
+ end
239
+
240
+ # Every registry failure in the output, in the order bun printed them
241
+ sig { params(error_message: String).returns(T::Array[MatchData]) }
242
+ def registry_failures(error_message)
243
+ [DOWNLOAD_FAILED, REQUEST_FAILED]
244
+ .flat_map { |pattern| error_message.to_enum(:scan, pattern).map { T.must(Regexp.last_match) } }
245
+ .sort_by { |match| match.begin(0) }
246
+ end
247
+
248
+ sig { params(error_message: String, bun_lock: Dependabot::DependencyFile).void }
249
+ def raise_workspace_error(error_message, bun_lock)
250
+ if (match = error_message.match(WORKSPACE_NOT_ON_DISK))
251
+ raise Dependabot::DependencyFileNotResolvable,
252
+ "#{bun_lock.path} lists the workspace \"#{match[:name]}\" (#{match[:path]}), " \
253
+ "but it is not part of the repository. Every workspace in the lockfile must be present."
254
+ end
255
+
256
+ return unless (match = error_message.match(WORKSPACE_NOT_FOUND))
257
+
258
+ raise Dependabot::DependencyFileNotResolvable,
259
+ "package.json lists the workspace \"#{match[:path]}\", but it does not exist in the repository."
260
+ end
261
+
262
+ # The scheme, host and any non-default port of the URL, so the error names the registry and not a single
263
+ # tarball. A registry on another port is a different endpoint.
264
+ sig { params(url: String).returns(String) }
265
+ def registry_origin(url)
266
+ uri = URI.parse(url)
267
+ default_port = T.cast(uri.default_port, T.nilable(Integer))
268
+ port = ":#{uri.port}" if uri.port && uri.port != default_port
269
+ "#{uri.scheme}://#{uri.host}#{port}"
270
+ rescue URI::InvalidURIError
271
+ url
272
+ end
273
+
274
+ # "@scope/name@1.2.3" and "name@1.2.3" both end in the version
275
+ sig { params(spec: String).returns(String) }
276
+ def package_name_from(spec)
277
+ spec.sub(/@[^@]*\z/, "")
278
+ end
279
+
130
280
  sig { void }
131
281
  def write_final_package_json_files
132
282
  package_files.each do |file|
@@ -0,0 +1,127 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "sorbet-runtime"
6
+
7
+ require "dependabot/bun/file_updater"
8
+
9
+ module Dependabot
10
+ module Bun
11
+ class FileUpdater < Dependabot::FileUpdaters::Base
12
+ # Locates object members in JSON source text, so they can be edited in
13
+ # place without reformatting the document.
14
+ class JsonObjectMembers
15
+ extend T::Sig
16
+
17
+ # Maps each member of the JSON object opening at `start` whose value is
18
+ # an object to the range of that value in `content`.
19
+ sig { params(content: String, start: Integer).returns(T::Hash[String, T::Range[Integer]]) }
20
+ def self.objects(content, start)
21
+ new(content, start).objects
22
+ end
23
+
24
+ # Maps each member of the JSON object opening at `start` whose value is
25
+ # a string to the range of that value, quotes included, in `content`.
26
+ sig { params(content: String, start: Integer).returns(T::Hash[String, T::Range[Integer]]) }
27
+ def self.strings(content, start)
28
+ new(content, start).strings
29
+ end
30
+
31
+ sig { returns(T::Hash[String, T::Range[Integer]]) }
32
+ attr_reader :objects
33
+
34
+ sig { returns(T::Hash[String, T::Range[Integer]]) }
35
+ attr_reader :strings
36
+
37
+ sig { params(content: String, start: Integer).void }
38
+ def initialize(content, start)
39
+ @objects = T.let({}, T::Hash[String, T::Range[Integer]])
40
+ @strings = T.let({}, T::Hash[String, T::Range[Integer]])
41
+ @depth = T.let(0, Integer)
42
+ @key = T.let(nil, T.nilable(String))
43
+ @value_start = T.let(nil, T.nilable(Integer))
44
+ @string = T.let(nil, T.nilable(String))
45
+ @last_string = T.let(nil, T.nilable(String))
46
+ @escaped = T.let(false, T::Boolean)
47
+ scan(content, start)
48
+ end
49
+
50
+ private
51
+
52
+ sig { params(content: String, start: Integer).void }
53
+ def scan(content, start)
54
+ T.must(content[start..]).each_char.with_index(start) do |char, index|
55
+ if @string
56
+ read_string(@string, char, index)
57
+ else
58
+ read_structure(char, index)
59
+ break if @depth.zero?
60
+ end
61
+ end
62
+ end
63
+
64
+ sig { params(string: String, char: String, index: Integer).void }
65
+ def read_string(string, char, index)
66
+ if @escaped
67
+ @escaped = false
68
+ elsif char == "\\"
69
+ @escaped = true
70
+ elsif char == "\""
71
+ close_string(string, index)
72
+ return
73
+ end
74
+
75
+ string << char
76
+ end
77
+
78
+ sig { params(char: String, index: Integer).void }
79
+ def read_structure(char, index)
80
+ case char
81
+ when "\"" then open_string(index)
82
+ when ":" then @key = @last_string if @depth == 1
83
+ when "," then @key = nil if @depth == 1
84
+ when "{", "[" then enter(char, index)
85
+ when "}", "]" then leave(index)
86
+ end
87
+ end
88
+
89
+ sig { params(index: Integer).void }
90
+ def open_string(index)
91
+ @string = +""
92
+ @value_start = index if @depth == 1 && @key
93
+ end
94
+
95
+ sig { params(string: String, index: Integer).void }
96
+ def close_string(string, index)
97
+ value = T.cast(JSON.parse("\"#{string}\""), String)
98
+ @last_string = value
99
+ @string = nil
100
+ record(@strings, index) if @depth == 1
101
+ end
102
+
103
+ sig { params(char: String, index: Integer).void }
104
+ def enter(char, index)
105
+ @value_start = index if @depth == 1 && char == "{" && @key
106
+ @depth += 1
107
+ end
108
+
109
+ sig { params(index: Integer).void }
110
+ def leave(index)
111
+ @depth -= 1
112
+ record(@objects, index) if @depth == 1
113
+ end
114
+
115
+ sig { params(members: T::Hash[String, T::Range[Integer]], index: Integer).void }
116
+ def record(members, index)
117
+ key = @key
118
+ value_start = @value_start
119
+ return unless key && value_start
120
+
121
+ members[key] = value_start..index
122
+ @value_start = nil
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -3,7 +3,10 @@
3
3
 
4
4
  require "sorbet-runtime"
5
5
 
6
+ require "dependabot/bun/file_parser"
6
7
  require "dependabot/bun/file_updater"
8
+ require "dependabot/bun/file_updater/json_object_members"
9
+ require "dependabot/package/npm_package_json"
7
10
 
8
11
  module Dependabot
9
12
  module Bun
@@ -93,7 +96,10 @@ module Dependabot
93
96
  def old_requirement(dependency, new_requirement)
94
97
  T.must(dependency.previous_requirements)
95
98
  .select { |r| r.file == package_json.name }
96
- .find { |r| r.groups == new_requirement.groups }
99
+ .find do |r|
100
+ r.groups == new_requirement.groups &&
101
+ r.metadata_string("catalog") == new_requirement.metadata_string("catalog")
102
+ end
97
103
  end
98
104
 
99
105
  sig { params(dependency: Dependabot::Dependency).returns(T::Array[Dependabot::DependencyRequirement]) }
@@ -131,6 +137,17 @@ module Dependabot
131
137
  .returns(String)
132
138
  end
133
139
  def update_package_json_declaration(package_json_content:, new_req:, dependency_name:, old_req:)
140
+ catalog = new_req.metadata_string("catalog")
141
+ if catalog
142
+ return update_catalog_declaration(
143
+ catalog: catalog,
144
+ content: package_json_content,
145
+ dependency_name: dependency_name,
146
+ old_req: old_req,
147
+ new_req: new_req
148
+ )
149
+ end
150
+
134
151
  original_line = declaration_line(
135
152
  dependency_name: dependency_name,
136
153
  dependency_req: old_req,
@@ -323,6 +340,72 @@ module Dependabot
323
340
  end
324
341
  end
325
342
 
343
+ # Bun reads catalogs only from `NpmPackageJson#catalog_container`, so
344
+ # the entry is looked up and replaced among those catalogs' members.
345
+ sig do
346
+ params(
347
+ catalog: String,
348
+ content: String,
349
+ dependency_name: String,
350
+ old_req: T.nilable(Dependabot::DependencyRequirement),
351
+ new_req: Dependabot::DependencyRequirement
352
+ )
353
+ .returns(String)
354
+ end
355
+ def update_catalog_declaration(catalog:, content:, dependency_name:, old_req:, new_req:)
356
+ value_ranges = catalog_ranges(catalog, content).filter_map do |range|
357
+ JsonObjectMembers.strings(content, range.begin)[dependency_name]
358
+ end
359
+
360
+ # Replacing from the end keeps the earlier ranges valid.
361
+ value_ranges.sort_by(&:begin).reverse.each_with_object(content.dup) do |range, updated|
362
+ value = updated_catalog_value(dependency_name, T.must(updated[range]), old_req, new_req)
363
+ updated[range] = value if value
364
+ end
365
+ end
366
+
367
+ # The default catalog is `catalog` together with `catalogs.default`.
368
+ sig { params(catalog: String, content: String).returns(T::Array[T::Range[Integer]]) }
369
+ def catalog_ranges(catalog, content)
370
+ root_start = content.index("{")
371
+ return [] unless root_start
372
+
373
+ fields = JsonObjectMembers.objects(content, root_start)
374
+ document = Dependabot::Package::NpmPackageJson.new(JSON.parse(content), package_json.path)
375
+ container = document.catalog_container
376
+ if container
377
+ container_range = fields[container]
378
+ return [] unless container_range
379
+
380
+ fields = JsonObjectMembers.objects(content, container_range.begin)
381
+ end
382
+
383
+ catalogs_range = fields["catalogs"]
384
+ [
385
+ (fields["catalog"] if catalog == FileParser::DEFAULT_CATALOG),
386
+ (JsonObjectMembers.objects(content, catalogs_range.begin)[catalog] if catalogs_range)
387
+ ].compact
388
+ end
389
+
390
+ # The value is matched and rewritten as a normalized declaration, so
391
+ # its source formatting never affects the match.
392
+ sig do
393
+ params(
394
+ dependency_name: String,
395
+ value: String,
396
+ old_req: T.nilable(Dependabot::DependencyRequirement),
397
+ new_req: Dependabot::DependencyRequirement
398
+ )
399
+ .returns(T.nilable(String))
400
+ end
401
+ def updated_catalog_value(dependency_name, value, old_req, new_req)
402
+ prefix = %("#{dependency_name}": )
403
+ line = prefix + JSON.generate(JSON.parse(value))
404
+ return if declaration_line(dependency_name: dependency_name, dependency_req: old_req, content: line).empty?
405
+
406
+ replacement_declaration_line(original_line: line, old_req: old_req, new_req: new_req).delete_prefix(prefix)
407
+ end
408
+
326
409
  sig { params(string: String).returns(Integer) }
327
410
  def closing_bracket_index(string)
328
411
  closes_required = 1
@@ -63,21 +63,23 @@ module Dependabot
63
63
  nil
64
64
  end
65
65
 
66
- sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
66
+ sig { params(command: T.any(String, T::Array[String]), fingerprint: T.nilable(String)).returns(String) }
67
67
  def self.run_bun_command(command, fingerprint: nil)
68
- full_command = "bun #{command}"
68
+ full_command = command.is_a?(Array) ? ["bun", *command] : "bun #{command}"
69
+ display_command = full_command.is_a?(Array) ? full_command.join(" ") : full_command
70
+ fingerprint_command = command.is_a?(Array) ? command.join(" ") : command
69
71
 
70
- Dependabot.logger.info("Running bun command: #{full_command}")
72
+ Dependabot.logger.info("Running bun command: #{display_command}")
71
73
 
72
74
  result = Dependabot::SharedHelpers.run_shell_command(
73
75
  full_command,
74
- fingerprint: "bun #{fingerprint || command}"
76
+ fingerprint: "bun #{fingerprint || fingerprint_command}"
75
77
  )
76
78
 
77
- Dependabot.logger.info("Command executed successfully: #{full_command}")
79
+ Dependabot.logger.info("Command executed successfully: #{display_command}")
78
80
  result
79
81
  rescue StandardError => e
80
- Dependabot.logger.error("Error running bun command: #{full_command}, Error: #{e.message}")
82
+ Dependabot.logger.error("Error running bun command: #{display_command}, Error: #{e.message}")
81
83
  raise
82
84
  end
83
85
 
@@ -178,21 +178,25 @@ module Dependabot
178
178
  def latest_version_listing
179
179
  return @latest_version_listing unless @latest_version_listing.nil?
180
180
 
181
+ @latest_version_listing = T.let({}, T.nilable(T::Hash[String, T.untyped]))
182
+
181
183
  response = Dependabot::RegistryClient.get(
182
184
  url: "#{dependency_url}/latest",
183
185
  headers: registry_auth_headers
184
186
  )
185
- return @latest_version_listing = {} if response.status >= 500
187
+ return T.must(@latest_version_listing) if response.status >= 500
186
188
 
187
189
  begin
188
- @latest_version_listing = JSON.parse(response.body)
190
+ # A registry can answer a missing package with a bare JSON string such as "Not Found"
191
+ parsed = JSON.parse(response.body)
192
+ @latest_version_listing = parsed if parsed.is_a?(Hash)
189
193
  rescue JSON::ParserError
190
194
  raise unless non_standard_registry?
191
-
192
- @latest_version_listing = {}
193
195
  end
196
+
197
+ T.must(@latest_version_listing)
194
198
  rescue JSON::ParserError, Excon::Error::Timeout
195
- @latest_version_listing = T.let({}, T.nilable(T::Hash[String, T.untyped]))
199
+ T.must(@latest_version_listing)
196
200
  end
197
201
 
198
202
  sig { returns(T::Array[[String, T::Hash[String, T.untyped]]]) }
@@ -209,18 +213,21 @@ module Dependabot
209
213
  def npm_listing
210
214
  return @npm_listing unless @npm_listing.nil?
211
215
 
216
+ @npm_listing = T.let({}, T.nilable(T::Hash[String, T.untyped]))
217
+
212
218
  response = Dependabot::RegistryClient.get(url: dependency_url, headers: registry_auth_headers)
213
- return @npm_listing = {} if response.status >= 500
219
+ return T.must(@npm_listing) if response.status >= 500
214
220
 
215
221
  begin
216
- @npm_listing = JSON.parse(response.body)
222
+ parsed = JSON.parse(response.body)
223
+ @npm_listing = parsed if parsed.is_a?(Hash)
217
224
  rescue JSON::ParserError
218
225
  raise unless non_standard_registry?
219
-
220
- @npm_listing = {}
221
226
  end
227
+
228
+ T.must(@npm_listing)
222
229
  rescue Excon::Error::Timeout
223
- @npm_listing = T.let({}, T.nilable(T::Hash[String, T.untyped]))
230
+ T.must(@npm_listing)
224
231
  end
225
232
 
226
233
  sig { returns(String) }
@@ -20,7 +20,7 @@ module Dependabot
20
20
  https://registry.yarnpkg.com
21
21
  http://registry.yarnpkg.com
22
22
  ).freeze
23
- NPM_AUTH_TOKEN_REGEX = %r{//(?<registry>.*)/:_authToken=(?<token>.*)$}
23
+ NPM_AUTH_TOKEN_REGEX = %r{^[ \t]*//(?<registry>.*)/:_authToken=(?<token>.*)$}
24
24
  NPM_GLOBAL_REGISTRY_REGEX = /^registry\s*=\s*['"]?(?<registry>.*?)['"]?$/
25
25
  YARN_GLOBAL_REGISTRY_REGEX = /^(?:--)?registry\s+((['"](?<registry>.*)['"])|(?<registry>.*))/
26
26
  NPM_SCOPED_REGISTRY_REGEX = /^(?<scope>@[^:]+)\s*:registry\s*=\s*['"]?(?<registry>.*?)['"]?$/
@@ -6,6 +6,7 @@ require "sorbet-runtime"
6
6
  require "dependabot/errors"
7
7
  require "dependabot/git_commit_checker"
8
8
  require "dependabot/logger"
9
+ require "dependabot/package/npm_package_json"
9
10
  require "dependabot/package/npm_registry_package"
10
11
  require "dependabot/bun/dependency_files_filterer"
11
12
  require "dependabot/bun/file_parser"
@@ -615,13 +616,119 @@ module Dependabot
615
616
  def run_bun_checker(path:, version:)
616
617
  SharedHelpers.with_git_configured(credentials: credentials) do
617
618
  Dir.chdir(path) do
618
- Helpers.run_bun_command(
619
- "update #{dependency.name}@#{version} --save-text-lockfile --ignore-scripts",
620
- fingerprint: "update <dependency_name>@<version> --save-text-lockfile --ignore-scripts"
621
- )
619
+ workspace_filters(path).flat_map { |filter| run_bun_update(filter: filter, version: version) }
622
620
  end
623
621
  end
622
+ end
623
+
624
+ sig do
625
+ params(
626
+ filter: T.nilable(String),
627
+ version: T.nilable(T.any(String, Gem::Version))
628
+ ).returns(T::Array[PeerDependencyConflict])
629
+ end
630
+ def run_bun_update(filter:, version:)
631
+ command = T.let(["update", "#{dependency.name}@#{version}"], T::Array[String])
632
+ command += ["--filter", filter] if filter
633
+ command += ["--save-text-lockfile", "--ignore-scripts"]
634
+
635
+ Helpers.run_bun_command(
636
+ command,
637
+ fingerprint: "update <dependency_name>@<version> --save-text-lockfile --ignore-scripts"
638
+ )
624
639
  []
640
+ rescue SharedHelpers::HelperSubprocessFailed => e
641
+ handle_peer_dependency_errors(e.message)
642
+ end
643
+
644
+ # Returns every workspace filter (nil for the +path+ we've already
645
+ # chdir'd into, or a workspace path relative to it) for manifests in
646
+ # the project rooted at +path+ that declare +dependency+. Manifests
647
+ # are matched by scanning their actual dependency fields (as
648
+ # BunLockfileUpdater does), rather than dependency.requirements,
649
+ # since Bun always records catalog entries against the root
650
+ # package.json even when a nested workspace is the only consumer
651
+ # (via "catalog:"). Falls back to an unfiltered check at +path+ if
652
+ # it isn't declared anywhere in this project (e.g. a
653
+ # sub-dependency).
654
+ sig { params(path: String).returns(T::Array[T.nilable(String)]) }
655
+ def workspace_filters(path)
656
+ manifests = package_jsons_in_project(path)
657
+ root_file = manifests.find { |file| File.dirname(file.name) == path }
658
+
659
+ workspace_filters = manifests.filter_map do |file|
660
+ next if file == root_file
661
+ next unless package_json_declares?(file)
662
+
663
+ workspace_filter(file, path: path)
664
+ end.uniq
665
+
666
+ declared_at_root = root_file && package_json_declares?(root_file)
667
+ return workspace_filters if !declared_at_root && workspace_filters.any?
668
+
669
+ [nil, *workspace_filters]
670
+ end
671
+
672
+ # Every package.json belonging to the same Bun project as +path+:
673
+ # its own manifest, plus any workspace members governed by the same
674
+ # lockfile. Manifests under a *nested* project with its own
675
+ # lockfile are excluded, so that sibling nested projects (each
676
+ # checked separately, chdir'd into their own path) don't leak their
677
+ # declarations into each other's filters.
678
+ sig { params(path: String).returns(T::Array[Dependabot::DependencyFile]) }
679
+ def package_jsons_in_project(path)
680
+ project_lockfile_dir = lockfile_dir_for(path)
681
+
682
+ dependency_files.select do |file|
683
+ next false unless file.name.end_with?("package.json")
684
+
685
+ dir = File.dirname(file.name)
686
+ next false unless path == "." || dir == path || dir.start_with?("#{path}/")
687
+
688
+ lockfile_dir_for(dir) == project_lockfile_dir
689
+ end
690
+ end
691
+
692
+ # The directory of the bun.lock that governs +dir+: the lockfile
693
+ # whose own directory is the longest ancestor match, since a nested
694
+ # lockfile takes precedence over the root one for its own subtree.
695
+ sig { params(dir: String).returns(T.nilable(String)) }
696
+ def lockfile_dir_for(dir)
697
+ lockfile_dirs = dependency_files_builder.bun_locks.map { |lockfile| File.dirname(lockfile.name) }
698
+ lockfile_dirs.select { |lockfile_dir| lockfile_governs_dir?(lockfile_dir, dir) }.max_by(&:length)
699
+ end
700
+
701
+ sig { params(lockfile_dir: String, dir: String).returns(T::Boolean) }
702
+ def lockfile_governs_dir?(lockfile_dir, dir)
703
+ lockfile_dir == "." || dir == lockfile_dir || dir.start_with?("#{lockfile_dir}/")
704
+ end
705
+
706
+ sig { params(package_json_file: Dependabot::DependencyFile).returns(T::Boolean) }
707
+ def package_json_declares?(package_json_file)
708
+ declared = T.let(false, T::Boolean)
709
+ Dependabot::Package::NpmPackageJson.from_file(package_json_file).each_dependency do |name, _req, _type|
710
+ declared ||= name == dependency.name
711
+ end
712
+ declared
713
+ end
714
+
715
+ # Path-based --filter target, relative to +path+, for the workspace
716
+ # at +file+ (e.g. "./packages/app"). Bun's --filter also accepts a
717
+ # package's declared "name", but names can contain glob
718
+ # metacharacters that would match unrelated workspaces, so a path is
719
+ # used instead.
720
+ sig { params(file: Dependabot::DependencyFile, path: String).returns(String) }
721
+ def workspace_filter(file, path:)
722
+ unless Dependabot::Package::NpmPackageJson.from_file(file).name
723
+ raise Dependabot::DependencyFileNotParseable.new(
724
+ file.path,
725
+ "#{file.name} must declare a \"name\" to be targeted for a workspace-scoped check"
726
+ )
727
+ end
728
+
729
+ workspace_dir = File.dirname(file.name)
730
+ workspace_dir = workspace_dir.delete_prefix("#{path}/") unless path == "."
731
+ "./#{workspace_dir}"
625
732
  end
626
733
 
627
734
  sig do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-bun
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.400.0
4
+ version: 0.402.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.400.0
18
+ version: 0.402.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.400.0
25
+ version: 0.402.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -323,6 +323,7 @@ files:
323
323
  - lib/dependabot/bun/file_parser/lockfile_parser.rb
324
324
  - lib/dependabot/bun/file_updater.rb
325
325
  - lib/dependabot/bun/file_updater/bun_lockfile_updater.rb
326
+ - lib/dependabot/bun/file_updater/json_object_members.rb
326
327
  - lib/dependabot/bun/file_updater/npmrc_builder.rb
327
328
  - lib/dependabot/bun/file_updater/package_json_preparer.rb
328
329
  - lib/dependabot/bun/file_updater/package_json_updater.rb
@@ -355,7 +356,7 @@ licenses:
355
356
  - MIT
356
357
  metadata:
357
358
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
358
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.400.0
359
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.402.0
359
360
  rdoc_options: []
360
361
  require_paths:
361
362
  - lib