dependabot-npm_and_yarn 0.294.0 → 0.296.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.
@@ -0,0 +1,140 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/npm_and_yarn/helpers"
5
+ require "dependabot/npm_and_yarn/update_checker/registry_finder"
6
+ require "dependabot/npm_and_yarn/registry_parser"
7
+ require "dependabot/shared_helpers"
8
+
9
+ class DependencyRequirement < T::Struct
10
+ const :file, String
11
+ const :requirement, String
12
+ const :groups, T::Array[String]
13
+ const :source, T.nilable(String)
14
+ end
15
+
16
+ module Dependabot
17
+ module NpmAndYarn
18
+ class FileUpdater
19
+ class PnpmWorkspaceUpdater
20
+ extend T::Sig
21
+
22
+ sig do
23
+ params(
24
+ workspace_file: Dependabot::DependencyFile,
25
+ dependencies: T::Array[Dependabot::Dependency]
26
+ ) .void
27
+ end
28
+ def initialize(workspace_file:, dependencies:)
29
+ @dependencies = dependencies
30
+ @workspace_file = workspace_file
31
+ end
32
+
33
+ sig { returns(Dependabot::DependencyFile) }
34
+ def updated_pnpm_workspace
35
+ updated_file = workspace_file.dup
36
+ updated_file.content = updated_pnpm_workspace_content
37
+ updated_file
38
+ end
39
+
40
+ private
41
+
42
+ sig { returns(Dependabot::DependencyFile) }
43
+ attr_reader :workspace_file
44
+
45
+ sig { returns(T::Array[Dependabot::Dependency]) }
46
+ attr_reader :dependencies
47
+
48
+ sig { returns(T.nilable(String)) }
49
+ def updated_pnpm_workspace_content
50
+ content = workspace_file.content.dup
51
+ dependencies.each do |dependency|
52
+ content = update_dependency_versions(T.must(content), dependency)
53
+ end
54
+
55
+ content
56
+ end
57
+
58
+ sig { params(content: String, dependency: Dependabot::Dependency).returns(String) }
59
+ def update_dependency_versions(content, dependency)
60
+ new_requirements(dependency).each do |requirement|
61
+ content = replace_version_in_content(
62
+ content: content,
63
+ dependency: dependency,
64
+ old_requirement: T.must(old_requirement(dependency, requirement)),
65
+ new_requirement: requirement
66
+ )
67
+ end
68
+
69
+ content
70
+ end
71
+
72
+ sig do
73
+ params(
74
+ content: String,
75
+ dependency: Dependabot::Dependency,
76
+ old_requirement: DependencyRequirement,
77
+ new_requirement: DependencyRequirement
78
+ ).returns(String)
79
+ end
80
+ def replace_version_in_content(content:, dependency:, old_requirement:, new_requirement:)
81
+ old_version = old_requirement.requirement
82
+ new_version = new_requirement.requirement
83
+
84
+ pattern = build_replacement_pattern(
85
+ dependency_name: dependency.name,
86
+ version: old_version
87
+ )
88
+
89
+ replacement = build_replacement_string(
90
+ dependency_name: dependency.name,
91
+ version: new_version
92
+ )
93
+
94
+ content.gsub(pattern, replacement)
95
+ end
96
+
97
+ sig { params(dependency_name: String, version: String).returns(Regexp) }
98
+ def build_replacement_pattern(dependency_name:, version:)
99
+ /(["']?)#{dependency_name}\1:\s*(["']?)#{Regexp.escape(version)}\2/
100
+ end
101
+
102
+ sig { params(dependency_name: String, version: String).returns(String) }
103
+ def build_replacement_string(dependency_name:, version:)
104
+ "\\1#{dependency_name}\\1: \\2#{version}\\2"
105
+ end
106
+
107
+ sig { params(dependency: Dependabot::Dependency).returns(T::Array[DependencyRequirement]) }
108
+ def new_requirements(dependency)
109
+ dependency.requirements
110
+ .select { |r| r[:file] == workspace_file.name }
111
+ .map do |r|
112
+ DependencyRequirement.new(
113
+ file: r[:file],
114
+ requirement: r[:requirement],
115
+ groups: r[:groups],
116
+ source: r[:source]
117
+ )
118
+ end
119
+ end
120
+
121
+ sig do
122
+ params(dependency: Dependabot::Dependency,
123
+ new_requirement: DependencyRequirement).returns(T.nilable(DependencyRequirement))
124
+ end
125
+ def old_requirement(dependency, new_requirement)
126
+ matching_req = T.must(dependency.previous_requirements).find { |r| r[:groups] == new_requirement.groups }
127
+
128
+ return nil if matching_req.nil?
129
+
130
+ DependencyRequirement.new(
131
+ file: matching_req[:file],
132
+ requirement: matching_req[:requirement],
133
+ groups: matching_req[:groups],
134
+ source: matching_req[:source]
135
+ )
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -11,7 +11,7 @@ require "sorbet-runtime"
11
11
 
12
12
  module Dependabot
13
13
  module NpmAndYarn
14
- class FileUpdater < Dependabot::FileUpdaters::Base
14
+ class FileUpdater < Dependabot::FileUpdaters::Base # rubocop:disable Metrics/ClassLength
15
15
  extend T::Sig
16
16
 
17
17
  require_relative "file_updater/package_json_updater"
@@ -19,6 +19,7 @@ module Dependabot
19
19
  require_relative "file_updater/yarn_lockfile_updater"
20
20
  require_relative "file_updater/pnpm_lockfile_updater"
21
21
  require_relative "file_updater/bun_lockfile_updater"
22
+ require_relative "file_updater/pnpm_workspace_updater"
22
23
 
23
24
  class NoChangeError < StandardError
24
25
  extend T::Sig
@@ -43,34 +44,27 @@ module Dependabot
43
44
  %r{^(?:.*/)?npm-shrinkwrap\.json$},
44
45
  %r{^(?:.*/)?yarn\.lock$},
45
46
  %r{^(?:.*/)?pnpm-lock\.yaml$},
47
+ %r{^(?:.*/)?pnpm-workspace\.yaml$},
46
48
  %r{^(?:.*/)?\.yarn/.*}, # Matches any file within the .yarn/ directory
47
49
  %r{^(?:.*/)?\.pnp\.(?:js|cjs)$} # Matches .pnp.js or .pnp.cjs files
48
50
  ]
49
51
  end
50
52
 
51
- # rubocop:disable Metrics/PerceivedComplexity
52
53
  sig { override.returns(T::Array[DependencyFile]) }
53
54
  def updated_dependency_files
54
55
  updated_files = T.let([], T::Array[DependencyFile])
55
56
 
56
57
  updated_files += updated_manifest_files
57
- updated_files += updated_lockfiles
58
+ updated_files += if pnpm_workspace.any?
59
+ update_pnpm_workspace_and_locks
60
+ else
61
+ updated_lockfiles
62
+ end
58
63
 
59
64
  if updated_files.none?
60
-
61
- if Dependabot::Experiments.enabled?(:enable_fix_for_pnpm_no_change_error)
62
- # when all dependencies are transitive
63
- all_transitive = dependencies.none?(&:top_level?)
64
- # when there is no update in package.json
65
- no_package_json_update = package_files.empty?
66
- # handle the no change error for transitive dependency updates
67
- if pnpm_locks.any? && dependencies.length.positive? && all_transitive && no_package_json_update
68
- raise ToolFeatureNotSupported.new(
69
- tool_name: "pnpm",
70
- tool_type: "package_manager",
71
- feature: "updating transitive dependencies"
72
- )
73
- end
65
+ if Dependabot::Experiments.enabled?(:enable_fix_for_pnpm_no_change_error) && original_pnpm_locks.any?
66
+ raise_tool_not_supported_for_pnpm_if_transitive
67
+ raise_miss_configured_tooling_if_pnpm_subdirectory
74
68
  end
75
69
 
76
70
  raise NoChangeError.new(
@@ -89,10 +83,69 @@ module Dependabot
89
83
 
90
84
  vendor_updated_files(updated_files)
91
85
  end
92
- # rubocop:enable Metrics/PerceivedComplexity
93
86
 
94
87
  private
95
88
 
89
+ sig { void }
90
+ def raise_tool_not_supported_for_pnpm_if_transitive
91
+ # ✅ Ensure there are dependencies and check if all are transitive
92
+ return if dependencies.empty? || dependencies.any?(&:top_level?)
93
+
94
+ raise ToolFeatureNotSupported.new(
95
+ tool_name: "pnpm",
96
+ tool_type: "package_manager",
97
+ feature: "updating transitive dependencies"
98
+ )
99
+ end
100
+
101
+ # rubocop:disable Metrics/PerceivedComplexity
102
+ sig { void }
103
+ def raise_miss_configured_tooling_if_pnpm_subdirectory
104
+ workspace_files = original_pnpm_workspace
105
+ lockfiles = original_pnpm_locks
106
+
107
+ # ✅ Ensure `pnpm-workspace.yaml` is in a parent directory
108
+ return if workspace_files.empty?
109
+ return if workspace_files.any? { |f| f.directory == "/" }
110
+ return unless workspace_files.all? { |f| f.name.end_with?("../pnpm-workspace.yaml") }
111
+
112
+ # ✅ Ensure `pnpm-lock.yaml` is also in a parent directory
113
+ return if lockfiles.empty?
114
+ return if lockfiles.any? { |f| f.directory == "/" }
115
+ return unless lockfiles.all? { |f| f.name.end_with?("../pnpm-lock.yaml") }
116
+
117
+ # ❌ Raise error → Updating inside a subdirectory is misconfigured
118
+ raise MisconfiguredTooling.new(
119
+ "pnpm",
120
+ "Updating workspaces from inside a workspace subdirectory is not supported. " \
121
+ "Both `pnpm-lock.yaml` and `pnpm-workspace.yaml` exist in a parent directory. " \
122
+ "Dependabot should only update from the root workspace."
123
+ )
124
+ end
125
+ # rubocop:enable Metrics/PerceivedComplexity
126
+
127
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
128
+ def update_pnpm_workspace_and_locks
129
+ workspace_updates = updated_pnpm_workspace_files
130
+ lock_updates = update_pnpm_locks
131
+
132
+ workspace_updates + lock_updates
133
+ end
134
+
135
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
136
+ def update_pnpm_locks
137
+ updated_files = []
138
+ pnpm_locks.each do |pnpm_lock|
139
+ next unless pnpm_lock_changed?(pnpm_lock)
140
+
141
+ updated_files << updated_file(
142
+ file: pnpm_lock,
143
+ content: updated_pnpm_lock_content(pnpm_lock)
144
+ )
145
+ end
146
+ updated_files
147
+ end
148
+
96
149
  sig { params(updated_files: T::Array[Dependabot::DependencyFile]).returns(T::Array[Dependabot::DependencyFile]) }
97
150
  def vendor_updated_files(updated_files)
98
151
  base_dir = T.must(updated_files.first).directory
@@ -208,6 +261,33 @@ module Dependabot
208
261
  )
209
262
  end
210
263
 
264
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
265
+ def pnpm_workspace
266
+ @pnpm_workspace ||= T.let(
267
+ filtered_dependency_files
268
+ .select { |f| f.name.end_with?("pnpm-workspace.yaml") },
269
+ T.nilable(T::Array[Dependabot::DependencyFile])
270
+ )
271
+ end
272
+
273
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
274
+ def original_pnpm_locks
275
+ @original_pnpm_locks ||= T.let(
276
+ dependency_files
277
+ .select { |f| f.name.end_with?("pnpm-lock.yaml") },
278
+ T.nilable(T::Array[Dependabot::DependencyFile])
279
+ )
280
+ end
281
+
282
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
283
+ def original_pnpm_workspace
284
+ @original_pnpm_workspace ||= T.let(
285
+ dependency_files
286
+ .select { |f| f.name.end_with?("pnpm-workspace.yaml") },
287
+ T.nilable(T::Array[Dependabot::DependencyFile])
288
+ )
289
+ end
290
+
211
291
  sig { returns(T::Array[Dependabot::DependencyFile]) }
212
292
  def bun_locks
213
293
  @bun_locks ||= T.let(
@@ -270,8 +350,16 @@ module Dependabot
270
350
  end
271
351
  end
272
352
 
273
- # rubocop:disable Metrics/MethodLength
274
- # rubocop:disable Metrics/PerceivedComplexity
353
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
354
+ def updated_pnpm_workspace_files
355
+ pnpm_workspace.filter_map do |file|
356
+ updated_content = updated_pnpm_workspace_content(file)
357
+ next if updated_content == file.content
358
+
359
+ updated_file(file: file, content: T.must(updated_content))
360
+ end
361
+ end
362
+
275
363
  sig { returns(T::Array[Dependabot::DependencyFile]) }
276
364
  def updated_lockfiles
277
365
  updated_files = []
@@ -285,14 +373,7 @@ module Dependabot
285
373
  )
286
374
  end
287
375
 
288
- pnpm_locks.each do |pnpm_lock|
289
- next unless pnpm_lock_changed?(pnpm_lock)
290
-
291
- updated_files << updated_file(
292
- file: pnpm_lock,
293
- content: updated_pnpm_lock_content(pnpm_lock)
294
- )
295
- end
376
+ updated_files.concat(update_pnpm_locks)
296
377
 
297
378
  bun_locks.each do |bun_lock|
298
379
  next unless bun_lock_changed?(bun_lock)
@@ -323,9 +404,6 @@ module Dependabot
323
404
 
324
405
  updated_files
325
406
  end
326
- # rubocop:enable Metrics/MethodLength
327
- # rubocop:enable Metrics/PerceivedComplexity
328
-
329
407
  sig { params(yarn_lock: Dependabot::DependencyFile).returns(String) }
330
408
  def updated_yarn_lock_content(yarn_lock)
331
409
  @updated_yarn_lock_content ||= T.let({}, T.nilable(T::Hash[String, T.nilable(String)]))
@@ -337,7 +415,10 @@ module Dependabot
337
415
  def updated_pnpm_lock_content(pnpm_lock)
338
416
  @updated_pnpm_lock_content ||= T.let({}, T.nilable(T::Hash[String, T.nilable(String)]))
339
417
  @updated_pnpm_lock_content[pnpm_lock.name] ||=
340
- pnpm_lockfile_updater.updated_pnpm_lock_content(pnpm_lock)
418
+ pnpm_lockfile_updater.updated_pnpm_lock_content(
419
+ pnpm_lock,
420
+ updated_pnpm_workspace_content: @updated_pnpm_workspace_content
421
+ )
341
422
  end
342
423
 
343
424
  sig { params(bun_lock: Dependabot::DependencyFile).returns(String) }
@@ -407,6 +488,19 @@ module Dependabot
407
488
  dependencies: dependencies
408
489
  ).updated_package_json.content
409
490
  end
491
+
492
+ sig do
493
+ params(file: Dependabot::DependencyFile)
494
+ .returns(T.nilable(String))
495
+ end
496
+ def updated_pnpm_workspace_content(file)
497
+ @updated_pnpm_workspace_content ||= T.let({}, T.nilable(T::Hash[String, T.nilable(String)]))
498
+ @updated_pnpm_workspace_content[file.name] ||=
499
+ PnpmWorkspaceUpdater.new(
500
+ workspace_file: file,
501
+ dependencies: dependencies
502
+ ).updated_pnpm_workspace.content
503
+ end
410
504
  end
411
505
  end
412
506
  end
@@ -467,6 +467,8 @@ module Dependabot
467
467
  # Attempt to activate the local version of the package manager
468
468
  sig { params(name: String).void }
469
469
  def self.fallback_to_local_version(name)
470
+ return "Corepack does not support #{name}" unless corepack_supported_package_manager?(name)
471
+
470
472
  Dependabot.logger.info("Falling back to activate the currently installed version of #{name}.")
471
473
 
472
474
  # Fetch the currently installed version directly from the environment
@@ -553,6 +555,11 @@ module Dependabot
553
555
  result
554
556
  rescue StandardError => e
555
557
  Dependabot.logger.error("Error running package manager command: #{full_command}, Error: #{e.message}")
558
+ if e.message.match?(/Response Code.*:.*404.*\(Not Found\)/) &&
559
+ e.message.include?("The remote server failed to provide the requested resource")
560
+ raise RegistryError.new(404, "The remote server failed to provide the requested resource")
561
+ end
562
+
556
563
  raise
557
564
  end
558
565
 
@@ -38,8 +38,8 @@ module Dependabot
38
38
  def initialize(detected_version: nil, raw_version: nil, requirement: nil)
39
39
  super(
40
40
  name: NAME,
41
- detected_version: detected_version ? Version.new(detected_version) : nil,
42
- version: raw_version ? Version.new(raw_version) : nil,
41
+ detected_version: detected_version && !detected_version.empty? ? Version.new(detected_version) : nil,
42
+ version: raw_version && !raw_version.empty? ? Version.new(raw_version) : nil,
43
43
  deprecated_versions: DEPRECATED_VERSIONS,
44
44
  supported_versions: SUPPORTED_VERSIONS,
45
45
  requirement: requirement
@@ -48,22 +48,16 @@ module Dependabot
48
48
 
49
49
  sig { override.returns(T::Boolean) }
50
50
  def deprecated?
51
- return false unless detected_version
52
-
53
- return false if unsupported?
54
-
55
51
  return false unless Dependabot::Experiments.enabled?(:npm_v6_deprecation_warning)
56
52
 
57
- deprecated_versions.include?(detected_version)
53
+ super
58
54
  end
59
55
 
60
56
  sig { override.returns(T::Boolean) }
61
57
  def unsupported?
62
- return false unless detected_version
63
-
64
58
  return false unless Dependabot::Experiments.enabled?(:npm_v6_unsupported_error)
65
59
 
66
- supported_versions.all? { |supported| supported > detected_version }
60
+ super
67
61
  end
68
62
  end
69
63
  end
@@ -11,6 +11,7 @@ require "dependabot/npm_and_yarn/yarn_package_manager"
11
11
  require "dependabot/npm_and_yarn/pnpm_package_manager"
12
12
  require "dependabot/npm_and_yarn/bun_package_manager"
13
13
  require "dependabot/npm_and_yarn/language"
14
+ require "dependabot/npm_and_yarn/constraint_helper"
14
15
 
15
16
  module Dependabot
16
17
  module NpmAndYarn
@@ -188,6 +189,8 @@ module Dependabot
188
189
  @language_requirement ||= find_engine_constraints_as_requirement(Language::NAME)
189
190
  end
190
191
 
192
+ # rubocop:disable Metrics/PerceivedComplexity
193
+ # rubocop:disable Metrics/AbcSize
191
194
  sig { params(name: String).returns(T.nilable(Requirement)) }
192
195
  def find_engine_constraints_as_requirement(name)
193
196
  Dependabot.logger.info("Processing engine constraints for #{name}")
@@ -197,27 +200,42 @@ module Dependabot
197
200
  raw_constraint = @engines[name].to_s.strip
198
201
  return nil if raw_constraint.empty?
199
202
 
200
- raw_constraints = raw_constraint.split
201
- constraints = raw_constraints.map do |constraint|
202
- case constraint
203
- when /^\d+$/
204
- ">=#{constraint}.0.0 <#{constraint.to_i + 1}.0.0"
205
- when /^\d+\.\d+$/
206
- ">=#{constraint} <#{constraint.split('.').first.to_i + 1}.0.0"
207
- when /^\d+\.\d+\.\d+$/
208
- "=#{constraint}"
209
- else
210
- Dependabot.logger.warn("Unrecognized constraint format for #{name}: #{constraint}")
211
- constraint
203
+ if Dependabot::Experiments.enabled?(:enable_engine_version_detection)
204
+ constraints = ConstraintHelper.extract_ruby_constraints(raw_constraint)
205
+ # When constraints are invalid we return constraints array nil
206
+ if constraints.nil?
207
+ Dependabot.logger.warn(
208
+ "Unrecognized constraint format for #{name}: #{raw_constraint}"
209
+ )
210
+ end
211
+ else
212
+ raw_constraints = raw_constraint.split
213
+ constraints = raw_constraints.map do |constraint|
214
+ case constraint
215
+ when /^\d+$/
216
+ ">=#{constraint}.0.0 <#{constraint.to_i + 1}.0.0"
217
+ when /^\d+\.\d+$/
218
+ ">=#{constraint} <#{constraint.split('.').first.to_i + 1}.0.0"
219
+ when /^\d+\.\d+\.\d+$/
220
+ "=#{constraint}"
221
+ else
222
+ Dependabot.logger.warn("Unrecognized constraint format for #{name}: #{constraint}")
223
+ constraint
224
+ end
212
225
  end
226
+
213
227
  end
214
228
 
215
- Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}")
216
- Requirement.new(constraints)
229
+ if constraints && !constraints.empty?
230
+ Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}")
231
+ Requirement.new(constraints)
232
+ end
217
233
  rescue StandardError => e
218
234
  Dependabot.logger.error("Error processing constraints for #{name}: #{e.message}")
219
235
  nil
220
236
  end
237
+ # rubocop:enable Metrics/AbcSize
238
+ # rubocop:enable Metrics/PerceivedComplexity
221
239
 
222
240
  # rubocop:disable Metrics/CyclomaticComplexity
223
241
  # rubocop:disable Metrics/AbcSize
@@ -289,19 +307,24 @@ module Dependabot
289
307
 
290
308
  sig { params(name: String).returns(T.nilable(String)) }
291
309
  def detect_version(name)
292
- # we prioritize version mentioned in "packageManager" instead of "engines"
310
+ # Prioritize version mentioned in "packageManager" instead of "engines"
293
311
  if @manifest_package_manager&.start_with?("#{name}@")
294
312
  detected_version = @manifest_package_manager.split("@").last.to_s
295
313
  end
296
314
 
297
- # if "packageManager" have no version specified, we check if we can extract "engines" information
298
- detected_version = check_engine_version(name) if !detected_version || detected_version.empty?
315
+ # If "packageManager" has no version specified, check if we can extract "engines" information
316
+ detected_version ||= check_engine_version(name) if detected_version.to_s.empty?
317
+
318
+ # If neither "packageManager" nor "engines" have versions, infer version from lockfileVersion
319
+ detected_version ||= guessed_version(name) if detected_version.to_s.empty?
320
+
321
+ # Strip and validate version format
322
+ detected_version_string = detected_version.to_s.strip
299
323
 
300
- # if "packageManager" and "engines" both are not present, we check if we can infer the version
301
- # from the manifest file lockfileVersion
302
- detected_version = guessed_version(name) if !detected_version || detected_version.empty?
324
+ # Ensure detected_version is neither "0" nor invalid format
325
+ return if detected_version_string == "0" || !detected_version_string.match?(ConstraintHelper::VERSION_REGEX)
303
326
 
304
- detected_version&.to_s
327
+ detected_version_string
305
328
  end
306
329
 
307
330
  sig { params(name: T.nilable(String)).returns(Ecosystem::VersionManager) }
@@ -332,7 +355,7 @@ module Dependabot
332
355
  end
333
356
 
334
357
  package_manager_class.new(
335
- detected_version: detected_version.to_s,
358
+ detected_version: detected_version,
336
359
  raw_version: installed_version,
337
360
  requirement: package_manager_requirement
338
361
  )
@@ -394,10 +417,15 @@ module Dependabot
394
417
 
395
418
  Dependabot.logger.info("Installing \"#{name}@#{version}\"")
396
419
 
397
- SharedHelpers.run_shell_command(
398
- "corepack install #{name}@#{version} --global --cache-only",
399
- fingerprint: "corepack install <name>@<version> --global --cache-only"
400
- )
420
+ begin
421
+ SharedHelpers.run_shell_command(
422
+ "corepack install #{name}@#{version} --global --cache-only",
423
+ fingerprint: "corepack install <name>@<version> --global --cache-only"
424
+ )
425
+ rescue SharedHelpers::HelperSubprocessFailed => e
426
+ Dependabot.logger.error("Error installing #{name}@#{version}: #{e.message}")
427
+ Helpers.fallback_to_local_version(name)
428
+ end
401
429
  end
402
430
 
403
431
  sig { params(name: T.nilable(String)).returns(String) }
@@ -434,7 +462,8 @@ module Dependabot
434
462
  return if @package_json.nil?
435
463
 
436
464
  version_selector = VersionSelector.new
437
- engine_versions = version_selector.setup(@package_json, name)
465
+
466
+ engine_versions = version_selector.setup(@package_json, name, dependabot_versions(name))
438
467
 
439
468
  return if engine_versions.empty?
440
469
 
@@ -442,6 +471,20 @@ module Dependabot
442
471
  Dependabot.logger.info("Returned (#{MANIFEST_ENGINES_KEY}) info \"#{name}\" : \"#{version}\"")
443
472
  version
444
473
  end
474
+
475
+ sig { params(name: String).returns(T.nilable(T::Array[Dependabot::Version])) }
476
+ def dependabot_versions(name)
477
+ case name
478
+ when "npm"
479
+ NpmPackageManager::SUPPORTED_VERSIONS
480
+ when "yarn"
481
+ YarnPackageManager::SUPPORTED_VERSIONS
482
+ when "bun"
483
+ BunPackageManager::SUPPORTED_VERSIONS
484
+ when "pnpm"
485
+ PNPMPackageManager::SUPPORTED_VERSIONS
486
+ end
487
+ end
445
488
  end
446
489
  end
447
490
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "dependabot/shared_helpers"
5
+ require "dependabot/npm_and_yarn/constraint_helper"
5
6
 
6
7
  module Dependabot
7
8
  module NpmAndYarn
@@ -13,18 +14,42 @@ module Dependabot
13
14
  # such as "20.8.7", "8.1.2", "8.21.2",
14
15
  NODE_ENGINE_SUPPORTED_REGEX = /^\d+(?:\.\d+)*$/
15
16
 
16
- sig { params(manifest_json: T::Hash[String, T.untyped], name: String).returns(T::Hash[Symbol, T.untyped]) }
17
- def setup(manifest_json, name)
17
+ # Sets up engine versions from the given manifest JSON.
18
+ #
19
+ # @param manifest_json [Hash] The manifest JSON containing version information.
20
+ # @param name [String] The engine name to match.
21
+ # @return [Hash] A hash with selected versions, if found.
22
+ sig do
23
+ params(
24
+ manifest_json: T::Hash[String, T.untyped],
25
+ name: String,
26
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version])
27
+ )
28
+ .returns(T::Hash[Symbol, T.untyped])
29
+ end
30
+ def setup(manifest_json, name, dependabot_versions = nil)
18
31
  engine_versions = manifest_json["engines"]
19
32
 
33
+ # Return an empty hash if no engine versions are specified
20
34
  return {} if engine_versions.nil?
21
35
 
22
- # Only keep matching specs versions i.e. "20.21.2", "7.1.2",
23
- # Additional specs can be added later
24
- engine_versions.delete_if { |_key, value| !valid_extracted_version?(value) }
25
- version = engine_versions.select { |engine, _value| engine.to_s.match(name) }
36
+ versions = {}
37
+
38
+ if Dependabot::Experiments.enabled?(:enable_engine_version_detection)
39
+ engine_versions.each do |engine, value|
40
+ next unless engine.to_s.match(name)
41
+
42
+ versions[name] = ConstraintHelper.find_highest_version_from_constraint_expression(
43
+ value, dependabot_versions
44
+ )
45
+ end
46
+ else
47
+ versions = engine_versions.select do |engine, value|
48
+ engine.to_s.match(name) && valid_extracted_version?(value)
49
+ end
50
+ end
26
51
 
27
- version
52
+ versions
28
53
  end
29
54
 
30
55
  sig { params(version: String).returns(T::Boolean) }