dependabot-npm_and_yarn 0.294.0 → 0.295.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72e338772b3c3aac3cf86538fc2d70dbbc45f5f7cb854cd7fd74913b140fe056
4
- data.tar.gz: 1856c138b871ebe80e5cc6faa5984ba80c10b342aa8bf0822e325e5a31a3f815
3
+ metadata.gz: 14edb941111a95dc07d83e6449c01bd3b0f4b92c4e3168acd19d31ce1fa96183
4
+ data.tar.gz: 0ec9a05bb2ebda169ad58028bfc2c0465ef0bbf192265f317570842f5aaa81b7
5
5
  SHA512:
6
- metadata.gz: 0e3267d0aafcf35e345505c87a23b2b783cfc36377e46f5f10875a4bd8b0c4fe201b6dea0dbed75463b75dccba175014af850451cfc52b39c0a2a410a5c5ab34
7
- data.tar.gz: a1c6be5ccbebcf43a76a51d7871a37743428f052c734a985a48fc90d4b1e2944679a8b6eb17910a8ef7e14c69dbe46d9761319b28d6a57d7dcbac7e94fbb9c09
6
+ metadata.gz: 5822f02ef7a153c079c70f91724efe53250ea696af5c0a995cfb86537be568e155344eb9cbb1481c2f2be01c080d486a0b98300dd318aa72c6271b36bd0d906e
7
+ data.tar.gz: 85bf7be8bb171f92b38a67d7c0b12b6c0e68056a6cdf09a0b20d8c2054517276e614e1bc7fd01bc366a53e917ba54c032ee816c977d47ca4eb11c22afda6c2ee
@@ -0,0 +1,306 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Dependabot
7
+ module NpmAndYarn
8
+ module ConstraintHelper
9
+ extend T::Sig
10
+
11
+ INVALID = "invalid" # Invalid constraint
12
+ # Regex Components for Semantic Versioning
13
+ DIGIT = "\\d+" # Matches a single number (e.g., "1")
14
+ PRERELEASE = "(?:-[a-zA-Z0-9.-]+)?" # Matches optional pre-release tag (e.g., "-alpha")
15
+ BUILD_METADATA = "(?:\\+[a-zA-Z0-9.-]+)?" # Matches optional build metadata (e.g., "+001")
16
+ DOT = "\\." # Matches a literal dot "."
17
+
18
+ # Matches semantic versions:
19
+ VERSION = T.let("#{DIGIT}(?:\\.#{DIGIT}){0,2}#{PRERELEASE}#{BUILD_METADATA}".freeze, String)
20
+
21
+ VERSION_REGEX = T.let(/\A#{VERSION}\z/o, Regexp)
22
+
23
+ # SemVer regex: major.minor.patch[-prerelease][+build]
24
+ SEMVER_REGEX = /^(?<version>\d+\.\d+\.\d+)(?:-(?<prerelease>[a-zA-Z0-9.-]+))?(?:\+(?<build>[a-zA-Z0-9.-]+))?$/
25
+
26
+ # Constraint Types as Constants
27
+ CARET_CONSTRAINT_REGEX = T.let(/^\^(#{VERSION})$/, Regexp)
28
+ TILDE_CONSTRAINT_REGEX = T.let(/^~(#{VERSION})$/, Regexp)
29
+ EXACT_CONSTRAINT_REGEX = T.let(/^(#{VERSION})$/, Regexp)
30
+ GREATER_THAN_EQUAL_REGEX = T.let(/^>=(#{VERSION})$/, Regexp)
31
+ LESS_THAN_EQUAL_REGEX = T.let(/^<=(#{VERSION})$/, Regexp)
32
+ GREATER_THAN_REGEX = T.let(/^>(#{VERSION})$/, Regexp)
33
+ LESS_THAN_REGEX = T.let(/^<(#{VERSION})$/, Regexp)
34
+ WILDCARD_REGEX = T.let(/^\*$/, Regexp)
35
+
36
+ # Unified Regex for Valid Constraints
37
+ VALID_CONSTRAINT_REGEX = T.let(Regexp.union(
38
+ CARET_CONSTRAINT_REGEX,
39
+ TILDE_CONSTRAINT_REGEX,
40
+ EXACT_CONSTRAINT_REGEX,
41
+ GREATER_THAN_EQUAL_REGEX,
42
+ LESS_THAN_EQUAL_REGEX,
43
+ GREATER_THAN_REGEX,
44
+ LESS_THAN_REGEX,
45
+ WILDCARD_REGEX
46
+ ).freeze, Regexp)
47
+
48
+ # Validates if the provided semver constraint expression from a `package.json` is valid.
49
+ # A valid semver constraint expression in `package.json` can consist of multiple groups
50
+ # separated by logical OR (`||`). Within each group, space-separated constraints are treated
51
+ # as logical AND. Each individual constraint must conform to the semver rules defined in
52
+ # `VALID_CONSTRAINT_REGEX`.
53
+ #
54
+ # Example (valid `package.json` semver constraints):
55
+ # ">=1.2.3 <2.0.0 || ~3.4.5" → Valid (space-separated constraints are AND, `||` is OR)
56
+ # "^1.0.0 || >=2.0.0 <3.0.0" → Valid (caret and range constraints combined)
57
+ # "1.2.3" → Valid (exact version)
58
+ # "*" → Valid (wildcard allows any version)
59
+ #
60
+ # Example (invalid `package.json` semver constraints):
61
+ # ">=1.2.3 && <2.0.0" → Invalid (`&&` is not valid in semver)
62
+ # ">=x.y.z" → Invalid (non-numeric version parts are not valid)
63
+ # "1.2.3 ||" → Invalid (trailing OR operator)
64
+ #
65
+ # @param constraint_expression [String] The semver constraint expression from `package.json` to validate.
66
+ # @return [T::Boolean] Returns true if the constraint expression is valid semver, false otherwise.
67
+ sig { params(constraint_expression: T.nilable(String)).returns(T::Boolean) }
68
+ def self.valid_constraint_expression?(constraint_expression)
69
+ normalized_constraint = constraint_expression&.strip
70
+
71
+ # Treat nil or empty input as valid (no constraints)
72
+ return true if normalized_constraint.nil? || normalized_constraint.empty?
73
+
74
+ # Split the expression by logical OR (`||`) into groups
75
+ normalized_constraint.split("||").reject(&:empty?).all? do |or_group|
76
+ or_group.split(/\s+/).reject(&:empty?).all? do |and_constraint|
77
+ and_constraint.match?(VALID_CONSTRAINT_REGEX)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Extract unique constraints from the given constraint expression.
83
+ # @param constraint_expression [T.nilable(String)] The semver constraint expression.
84
+ # @return [T::Array[String]] The list of unique Ruby-compatible constraints.
85
+ sig do
86
+ params(
87
+ constraint_expression: T.nilable(String),
88
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version])
89
+ )
90
+ .returns(T.nilable(T::Array[String]))
91
+ end
92
+ def self.extract_constraints(constraint_expression, dependabot_versions = nil)
93
+ normalized_constraint = constraint_expression&.strip
94
+ return [] if normalized_constraint.nil? || normalized_constraint.empty?
95
+
96
+ parsed_constraints = parse_constraints(normalized_constraint, dependabot_versions)
97
+
98
+ return nil unless parsed_constraints
99
+
100
+ parsed_constraints.filter_map { |parsed| parsed[:constraint] }
101
+ end
102
+
103
+ # Find the highest version from the given constraint expression.
104
+ # @param constraint_expression [T.nilable(String)] The semver constraint expression.
105
+ # @return [T.nilable(String)] The highest version, or nil if no versions are available.
106
+ sig do
107
+ params(
108
+ constraint_expression: T.nilable(String),
109
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version])
110
+ )
111
+ .returns(T.nilable(String))
112
+ end
113
+ def self.find_highest_version_from_constraint_expression(constraint_expression, dependabot_versions = nil)
114
+ normalized_constraint = constraint_expression&.strip
115
+ return nil if normalized_constraint.nil? || normalized_constraint.empty?
116
+
117
+ parsed_constraints = parse_constraints(normalized_constraint, dependabot_versions)
118
+
119
+ return nil unless parsed_constraints
120
+
121
+ parsed_constraints
122
+ .filter_map { |parsed| parsed[:version] } # Extract all versions
123
+ .max_by { |version| Version.new(version) }
124
+ end
125
+
126
+ # Parse all constraints (split by logical OR `||`) and convert to Ruby-compatible constraints.
127
+ # Return:
128
+ # - `nil` if the constraint expression is invalid
129
+ # - `[]` if the constraint expression is valid but represents "no constraints"
130
+ # - An array of hashes for valid constraints with details about the constraint and version
131
+ sig do
132
+ params(
133
+ constraint_expression: T.nilable(String),
134
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version])
135
+ )
136
+ .returns(T.nilable(T::Array[T::Hash[Symbol, T.nilable(String)]]))
137
+ end
138
+ def self.parse_constraints(constraint_expression, dependabot_versions = nil)
139
+ normalized_constraint = constraint_expression&.strip
140
+
141
+ # Return an empty array for valid "no constraints" (nil or empty input)
142
+ return [] if normalized_constraint.nil? || normalized_constraint.empty?
143
+
144
+ # Return nil for invalid constraints
145
+ return nil unless valid_constraint_expression?(normalized_constraint)
146
+
147
+ # Parse valid constraints
148
+ constraints = normalized_constraint.split("||").flat_map do |or_group|
149
+ or_group.strip.split(/\s+/).map(&:strip)
150
+ end.then do |normalized_constraints| # rubocop:disable Style/MultilineBlockChain
151
+ to_ruby_constraints_with_versions(normalized_constraints, dependabot_versions)
152
+ end.uniq { |parsed| parsed[:constraint] } # Ensure uniqueness based on `:constraint` # rubocop:disable Style/MultilineBlockChain
153
+ constraints
154
+ end
155
+
156
+ sig do
157
+ params(
158
+ constraints: T::Array[String],
159
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version])
160
+ ).returns(T::Array[T::Hash[Symbol, T.nilable(String)]])
161
+ end
162
+ def self.to_ruby_constraints_with_versions(constraints, dependabot_versions = [])
163
+ constraints.filter_map do |constraint|
164
+ parsed = to_ruby_constraint_with_version(constraint, dependabot_versions)
165
+ parsed if parsed && parsed[:constraint] # Only include valid constraints
166
+ end.uniq
167
+ end
168
+
169
+ # rubocop:disable Metrics/MethodLength
170
+ # rubocop:disable Metrics/PerceivedComplexity
171
+ # rubocop:disable Metrics/AbcSize
172
+ # rubocop:disable Metrics/CyclomaticComplexity
173
+ # Converts a semver constraint to a Ruby-compatible constraint and extracts the version, if available.
174
+ # @param constraint [String] The semver constraint to parse.
175
+ # @return [T.nilable(T::Hash[Symbol, T.nilable(String)])] Returns the Ruby-compatible constraint and the version,
176
+ # if available, or nil if the constraint is invalid.
177
+ #
178
+ # @example
179
+ # to_ruby_constraint_with_version("=1.2.3") # => { constraint: "=1.2.3", version: "1.2.3" }
180
+ # to_ruby_constraint_with_version("^1.2.3") # => { constraint: ">=1.2.3 <2.0.0", version: "1.2.3" }
181
+ # to_ruby_constraint_with_version("*") # => { constraint: nil, version: nil }
182
+ # to_ruby_constraint_with_version("invalid") # => nil
183
+ sig do
184
+ params(
185
+ constraint: String,
186
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version])
187
+ )
188
+ .returns(T.nilable(T::Hash[Symbol, T.nilable(String)]))
189
+ end
190
+ def self.to_ruby_constraint_with_version(constraint, dependabot_versions = [])
191
+ return nil if constraint.empty?
192
+
193
+ case constraint
194
+ when EXACT_CONSTRAINT_REGEX # Exact version, e.g., "1.2.3-alpha"
195
+ return unless Regexp.last_match
196
+
197
+ full_version = Regexp.last_match(1)
198
+ { constraint: "=#{full_version}", version: full_version }
199
+ when CARET_CONSTRAINT_REGEX # Caret constraint, e.g., "^1.2.3"
200
+ return unless Regexp.last_match
201
+
202
+ full_version = Regexp.last_match(1)
203
+ _, major, minor = version_components(full_version)
204
+ return nil if major.nil?
205
+
206
+ ruby_constraint =
207
+ if major.to_i.zero?
208
+ minor.nil? ? ">=#{full_version} <1.0.0" : ">=#{full_version} <0.#{minor.to_i + 1}.0"
209
+ else
210
+ ">=#{full_version} <#{major.to_i + 1}.0.0"
211
+ end
212
+ { constraint: ruby_constraint, version: full_version }
213
+ when TILDE_CONSTRAINT_REGEX # Tilde constraint, e.g., "~1.2.3"
214
+ return unless Regexp.last_match
215
+
216
+ full_version = Regexp.last_match(1)
217
+ _, major, minor = version_components(full_version)
218
+ ruby_constraint =
219
+ if minor.nil?
220
+ ">=#{full_version} <#{major.to_i + 1}.0.0"
221
+ else
222
+ ">=#{full_version} <#{major}.#{minor.to_i + 1}.0"
223
+ end
224
+ { constraint: ruby_constraint, version: full_version }
225
+ when GREATER_THAN_EQUAL_REGEX # Greater than or equal, e.g., ">=1.2.3"
226
+
227
+ return unless Regexp.last_match && Regexp.last_match(1)
228
+
229
+ found_version = highest_matching_version(
230
+ dependabot_versions,
231
+ T.must(Regexp.last_match(1))
232
+ ) do |version, constraint_version|
233
+ version >= Version.new(constraint_version)
234
+ end
235
+ { constraint: ">=#{Regexp.last_match(1)}", version: found_version&.to_s }
236
+ when LESS_THAN_EQUAL_REGEX # Less than or equal, e.g., "<=1.2.3"
237
+ return unless Regexp.last_match
238
+
239
+ full_version = Regexp.last_match(1)
240
+ { constraint: "<=#{full_version}", version: full_version }
241
+ when GREATER_THAN_REGEX # Greater than, e.g., ">1.2.3"
242
+ return unless Regexp.last_match && Regexp.last_match(1)
243
+
244
+ found_version = highest_matching_version(
245
+ dependabot_versions,
246
+ T.must(Regexp.last_match(1))
247
+ ) do |version, constraint_version|
248
+ version > Version.new(constraint_version)
249
+ end
250
+ { constraint: ">#{Regexp.last_match(1)}", version: found_version&.to_s }
251
+ when LESS_THAN_REGEX # Less than, e.g., "<1.2.3"
252
+ return unless Regexp.last_match && Regexp.last_match(1)
253
+
254
+ found_version = highest_matching_version(
255
+ dependabot_versions,
256
+ T.must(Regexp.last_match(1))
257
+ ) do |version, constraint_version|
258
+ version < Version.new(constraint_version)
259
+ end
260
+ { constraint: "<#{Regexp.last_match(1)}", version: found_version&.to_s }
261
+ when WILDCARD_REGEX # Wildcard
262
+ { constraint: nil, version: dependabot_versions&.max&.to_s } # Explicitly valid but no specific constraint
263
+ end
264
+ end
265
+
266
+ sig do
267
+ params(
268
+ dependabot_versions: T.nilable(T::Array[Dependabot::Version]),
269
+ constraint_version: String,
270
+ condition: T.proc.params(version: Dependabot::Version, constraint: Dependabot::Version).returns(T::Boolean)
271
+ )
272
+ .returns(T.nilable(Dependabot::Version))
273
+ end
274
+ def self.highest_matching_version(dependabot_versions, constraint_version, &condition)
275
+ return unless dependabot_versions&.any?
276
+
277
+ # Returns the highest version that satisfies the condition, or nil if none.
278
+ dependabot_versions
279
+ .sort
280
+ .reverse
281
+ .find { |version| condition.call(version, Version.new(constraint_version)) } # rubocop:disable Performance/RedundantBlockCall
282
+ end
283
+
284
+ # rubocop:enable Metrics/MethodLength
285
+ # rubocop:enable Metrics/PerceivedComplexity
286
+ # rubocop:enable Metrics/AbcSize
287
+ # rubocop:enable Metrics/CyclomaticComplexity
288
+
289
+ # Parses a semantic version string into its components as per the SemVer spec
290
+ # Example: "1.2.3-alpha+001" → ["1.2.3", "1", "2", "3", "alpha", "001"]
291
+ sig { params(full_version: T.nilable(String)).returns(T.nilable(T::Array[String])) }
292
+ def self.version_components(full_version)
293
+ return [] if full_version.nil?
294
+
295
+ match = full_version.match(SEMVER_REGEX)
296
+ return [] unless match
297
+
298
+ version = match[:version]
299
+ return [] unless version
300
+
301
+ major, minor, patch = version.split(".")
302
+ [version, major, minor, patch, match[:prerelease], match[:build]].compact
303
+ end
304
+ end
305
+ end
306
+ end
@@ -31,7 +31,6 @@ module Dependabot
31
31
  version = content["lockfileVersion"]
32
32
  raise_invalid!("expected 'lockfileVersion' to be an integer") unless version.is_a?(Integer)
33
33
  raise_invalid!("expected 'lockfileVersion' to be >= 0") unless version >= 0
34
- raise_invalid!("unsupported 'lockfileVersion' = #{version}") unless version.zero?
35
34
 
36
35
  T.let(content, T.untyped)
37
36
  end
@@ -55,10 +55,11 @@ module Dependabot
55
55
  end
56
56
 
57
57
  sig { override.returns(T::Array[Dependency]) }
58
- def parse
58
+ def parse # rubocop:disable Metrics/PerceivedComplexity
59
59
  dependency_set = DependencySet.new
60
60
  dependency_set += manifest_dependencies
61
61
  dependency_set += lockfile_dependencies
62
+ dependency_set += workspace_catalog_dependencies if enable_pnpm_workspace_catalog?
62
63
 
63
64
  dependencies = Helpers.dependencies_with_all_versions_metadata(dependency_set)
64
65
 
@@ -93,6 +94,11 @@ module Dependabot
93
94
 
94
95
  private
95
96
 
97
+ sig { returns(T.nilable(T::Boolean)) }
98
+ def enable_pnpm_workspace_catalog?
99
+ pnpm_workspace_yml && Dependabot::Experiments.enabled?(:enable_pnpm_workspace_catalog)
100
+ end
101
+
96
102
  sig { returns(PackageManagerHelper) }
97
103
  def package_manager_helper
98
104
  @package_manager_helper ||= T.let(
@@ -168,6 +174,13 @@ module Dependabot
168
174
  end, T.nilable(Dependabot::DependencyFile))
169
175
  end
170
176
 
177
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
178
+ def pnpm_workspace_yml
179
+ @pnpm_workspace_yml ||= T.let(dependency_files.find do |f|
180
+ f.name.end_with?(PNPMPackageManager::PNPM_WS_YML_FILENAME)
181
+ end, T.nilable(Dependabot::DependencyFile))
182
+ end
183
+
171
184
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
172
185
  def bun_lock
173
186
  @bun_lock ||= T.let(dependency_files.find do |f|
@@ -225,6 +238,30 @@ module Dependabot
225
238
  dependency_set
226
239
  end
227
240
 
241
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
242
+ def workspace_catalog_dependencies
243
+ dependency_set = DependencySet.new
244
+ workspace_config = YAML.safe_load(T.must(pnpm_workspace_yml&.content), aliases: true)
245
+
246
+ workspace_config["catalog"]&.each do |name, version|
247
+ dep = build_dependency(
248
+ file: T.must(pnpm_workspace_yml), type: "dependencies", name: name, requirement: version
249
+ )
250
+ dependency_set << dep if dep
251
+ end
252
+
253
+ workspace_config["catalogs"]&.each do |_, group_depenencies|
254
+ group_depenencies.each do |name, version|
255
+ dep = build_dependency(
256
+ file: T.must(pnpm_workspace_yml), type: "dependencies", name: name, requirement: version
257
+ )
258
+ dependency_set << dep if dep
259
+ end
260
+ end
261
+
262
+ dependency_set
263
+ end
264
+
228
265
  sig { returns(LockfileParser) }
229
266
  def lockfile_parser
230
267
  @lockfile_parser ||= T.let(LockfileParser.new(
@@ -37,8 +37,13 @@ module Dependabot
37
37
  sig { returns(T::Array[Dependabot::Dependency]) }
38
38
  attr_reader :dependencies
39
39
 
40
+ # rubocop:disable Metrics/PerceivedComplexity
41
+
40
42
  sig { returns(T.nilable(String)) }
41
43
  def updated_package_json_content
44
+ # checks if we are updating single dependency in package.json
45
+ unique_deps_count = dependencies.map(&:name).to_a.uniq.compact.length
46
+
42
47
  dependencies.reduce(package_json.content.dup) do |content, dep|
43
48
  updated_requirements(dep)&.each do |new_req|
44
49
  old_req = old_requirement(dep, new_req)
@@ -50,7 +55,25 @@ module Dependabot
50
55
  new_req: new_req
51
56
  )
52
57
 
53
- raise "Expected content to change!" if content == new_content
58
+ if Dependabot::Experiments.enabled?(:avoid_duplicate_updates_package_json) &&
59
+ (content == new_content && unique_deps_count > 1)
60
+
61
+ # (we observed that) package.json does not always contains the same dependencies compared to
62
+ # "dependencies" list, for example, dependencies object can contain same name dependency "dep"=> "1.0.0"
63
+ # and "dev" => "1.0.1" while package.json can only contain "dep" => "1.0.0",the other dependency is
64
+ # not present in package.json so we don't have to update it, this is most likely (as observed)
65
+ # a transitive dependency which only needs update in lockfile, So we avoid throwing exception and let
66
+ # the update continue.
67
+
68
+ Dependabot.logger.info("experiment: avoid_duplicate_updates_package_json.
69
+ Updating package.json for #{dep.name} ")
70
+
71
+ raise "Expected content to change!"
72
+ end
73
+
74
+ if !Dependabot::Experiments.enabled?(:avoid_duplicate_updates_package_json) && (content == new_content)
75
+ raise "Expected content to change!"
76
+ end
54
77
 
55
78
  content = new_content
56
79
  end
@@ -69,7 +92,7 @@ module Dependabot
69
92
  content
70
93
  end
71
94
  end
72
-
95
+ # rubocop:enable Metrics/PerceivedComplexity
73
96
  sig do
74
97
  params(
75
98
  dependency: Dependabot::Dependency,
@@ -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,6 +44,7 @@ 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
  ]
@@ -54,6 +56,9 @@ module Dependabot
54
56
  updated_files = T.let([], T::Array[DependencyFile])
55
57
 
56
58
  updated_files += updated_manifest_files
59
+ if Dependabot::Experiments.enabled?(:enable_pnpm_workspace_catalog)
60
+ updated_files += updated_pnpm_workspace_files
61
+ end
57
62
  updated_files += updated_lockfiles
58
63
 
59
64
  if updated_files.none?
@@ -208,6 +213,15 @@ module Dependabot
208
213
  )
209
214
  end
210
215
 
216
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
217
+ def pnpm_workspace
218
+ @pnpm_workspace ||= T.let(
219
+ filtered_dependency_files
220
+ .select { |f| f.name.end_with?("pnpm-workspace.yaml") },
221
+ T.nilable(T::Array[Dependabot::DependencyFile])
222
+ )
223
+ end
224
+
211
225
  sig { returns(T::Array[Dependabot::DependencyFile]) }
212
226
  def bun_locks
213
227
  @bun_locks ||= T.let(
@@ -270,6 +284,16 @@ module Dependabot
270
284
  end
271
285
  end
272
286
 
287
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
288
+ def updated_pnpm_workspace_files
289
+ pnpm_workspace.filter_map do |file|
290
+ updated_content = updated_pnpm_workspace_content(file)
291
+ next if updated_content == file.content
292
+
293
+ updated_file(file: file, content: T.must(updated_content))
294
+ end
295
+ end
296
+
273
297
  # rubocop:disable Metrics/MethodLength
274
298
  # rubocop:disable Metrics/PerceivedComplexity
275
299
  sig { returns(T::Array[Dependabot::DependencyFile]) }
@@ -407,6 +431,19 @@ module Dependabot
407
431
  dependencies: dependencies
408
432
  ).updated_package_json.content
409
433
  end
434
+
435
+ sig do
436
+ params(file: Dependabot::DependencyFile)
437
+ .returns(T.nilable(String))
438
+ end
439
+ def updated_pnpm_workspace_content(file)
440
+ @updated_pnpm_workspace_content ||= T.let({}, T.nilable(T::Hash[String, T.nilable(String)]))
441
+ @updated_pnpm_workspace_content[file.name] ||=
442
+ PnpmWorkspaceUpdater.new(
443
+ workspace_file: file,
444
+ dependencies: dependencies
445
+ ).updated_pnpm_workspace.content
446
+ end
410
447
  end
411
448
  end
412
449
  end
@@ -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
@@ -189,7 +190,7 @@ module Dependabot
189
190
  end
190
191
 
191
192
  sig { params(name: String).returns(T.nilable(Requirement)) }
192
- def find_engine_constraints_as_requirement(name)
193
+ def find_engine_constraints_as_requirement(name) # rubocop:disable Metrics/PerceivedComplexity
193
194
  Dependabot.logger.info("Processing engine constraints for #{name}")
194
195
 
195
196
  return nil unless @engines.is_a?(Hash) && @engines[name]
@@ -197,19 +198,31 @@ module Dependabot
197
198
  raw_constraint = @engines[name].to_s.strip
198
199
  return nil if raw_constraint.empty?
199
200
 
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
201
+ if Dependabot::Experiments.enabled?(:enable_engine_version_detection)
202
+ constraints = ConstraintHelper.extract_constraints(raw_constraint)
203
+
204
+ # When constraints are invalid we return constraints array nil
205
+ if constraints.nil?
206
+ Dependabot.logger.warn(
207
+ "Unrecognized constraint format for #{name}: #{raw_constraint}"
208
+ )
212
209
  end
210
+ else
211
+ raw_constraints = raw_constraint.split
212
+ constraints = raw_constraints.map do |constraint|
213
+ case constraint
214
+ when /^\d+$/
215
+ ">=#{constraint}.0.0 <#{constraint.to_i + 1}.0.0"
216
+ when /^\d+\.\d+$/
217
+ ">=#{constraint} <#{constraint.split('.').first.to_i + 1}.0.0"
218
+ when /^\d+\.\d+\.\d+$/
219
+ "=#{constraint}"
220
+ else
221
+ Dependabot.logger.warn("Unrecognized constraint format for #{name}: #{constraint}")
222
+ constraint
223
+ end
224
+ end
225
+
213
226
  end
214
227
 
215
228
  Dependabot.logger.info("Parsed constraints for #{name}: #{constraints.join(', ')}")
@@ -289,19 +302,24 @@ module Dependabot
289
302
 
290
303
  sig { params(name: String).returns(T.nilable(String)) }
291
304
  def detect_version(name)
292
- # we prioritize version mentioned in "packageManager" instead of "engines"
305
+ # Prioritize version mentioned in "packageManager" instead of "engines"
293
306
  if @manifest_package_manager&.start_with?("#{name}@")
294
307
  detected_version = @manifest_package_manager.split("@").last.to_s
295
308
  end
296
309
 
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?
310
+ # If "packageManager" has no version specified, check if we can extract "engines" information
311
+ detected_version ||= check_engine_version(name) if detected_version.to_s.empty?
299
312
 
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?
313
+ # If neither "packageManager" nor "engines" have versions, infer version from lockfileVersion
314
+ detected_version ||= guessed_version(name) if detected_version.to_s.empty?
303
315
 
304
- detected_version&.to_s
316
+ # Strip and validate version format
317
+ detected_version_string = detected_version.to_s.strip
318
+
319
+ # Ensure detected_version is neither "0" nor invalid format
320
+ return if detected_version_string == "0" || !detected_version_string.match?(ConstraintHelper::VERSION_REGEX)
321
+
322
+ detected_version_string
305
323
  end
306
324
 
307
325
  sig { params(name: T.nilable(String)).returns(Ecosystem::VersionManager) }
@@ -332,7 +350,7 @@ module Dependabot
332
350
  end
333
351
 
334
352
  package_manager_class.new(
335
- detected_version: detected_version.to_s,
353
+ detected_version: detected_version,
336
354
  raw_version: installed_version,
337
355
  requirement: package_manager_requirement
338
356
  )
@@ -434,7 +452,8 @@ module Dependabot
434
452
  return if @package_json.nil?
435
453
 
436
454
  version_selector = VersionSelector.new
437
- engine_versions = version_selector.setup(@package_json, name)
455
+
456
+ engine_versions = version_selector.setup(@package_json, name, dependabot_versions(name))
438
457
 
439
458
  return if engine_versions.empty?
440
459
 
@@ -442,6 +461,20 @@ module Dependabot
442
461
  Dependabot.logger.info("Returned (#{MANIFEST_ENGINES_KEY}) info \"#{name}\" : \"#{version}\"")
443
462
  version
444
463
  end
464
+
465
+ sig { params(name: String).returns(T.nilable(T::Array[Dependabot::Version])) }
466
+ def dependabot_versions(name)
467
+ case name
468
+ when "npm"
469
+ NpmPackageManager::SUPPORTED_VERSIONS
470
+ when "yarn"
471
+ YarnPackageManager::SUPPORTED_VERSIONS
472
+ when "bun"
473
+ BunPackageManager::SUPPORTED_VERSIONS
474
+ when "pnpm"
475
+ PNPMPackageManager::SUPPORTED_VERSIONS
476
+ end
477
+ end
445
478
  end
446
479
  end
447
480
  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) }
@@ -146,6 +146,10 @@ module Dependabot
146
146
  # if not package found with specified version
147
147
  YARN_PACKAGE_NOT_FOUND = /MessageError: Couldn't find any versions for "(?<pkg>.*?)" that matches "(?<ver>.*?)"/
148
148
 
149
+ YN0001_DEPS_RESOLUTION_FAILED = T.let({
150
+ DEPS_INCORRECT_MET: /peer dependencies are incorrectly met/
151
+ }.freeze, T::Hash[String, Regexp])
152
+
149
153
  YN0001_FILE_NOT_RESOLVED_CODES = T.let({
150
154
  FIND_PACKAGE_LOCATION: /YN0001:(.*?)UsageError: Couldn't find the (?<pkg>.*) state file/,
151
155
  NO_CANDIDATE_FOUND: /YN0001:(.*?)Error: (?<pkg>.*): No candidates found/,
@@ -165,6 +169,8 @@ module Dependabot
165
169
  REQUIREMENT_NOT_PROVIDED: /(?<dep>.*)(.*?)doesn't provide (?<pkg>.*)(.*?), requested by (?<parent>.*)/
166
170
  }.freeze, T::Hash[String, Regexp])
167
171
 
172
+ YN0086_DEPS_RESOLUTION_FAILED = /peer dependencies are incorrectly met/
173
+
168
174
  # registry returns malformed response
169
175
  REGISTRY_NOT_REACHABLE = /Received malformed response from registry for "(?<ver>.*)". The registry may be down./
170
176
 
@@ -227,6 +233,12 @@ module Dependabot
227
233
  end
228
234
  end
229
235
 
236
+ YN0001_DEPS_RESOLUTION_FAILED.each do |(_yn0001_key, yn0001_regex)|
237
+ if (msg = message.match(yn0001_regex))
238
+ return Dependabot::DependencyFileNotResolvable.new(msg)
239
+ end
240
+ end
241
+
230
242
  Dependabot::DependabotError.new(message)
231
243
  }
232
244
  },
@@ -351,6 +363,13 @@ module Dependabot
351
363
  Dependabot::DependencyNotFound.new(message)
352
364
  end
353
365
  }
366
+ },
367
+ "YN0086" => {
368
+ message: "deps resolution failed",
369
+ handler: lambda { |message, _error, _params|
370
+ msg = message.match(YN0086_DEPS_RESOLUTION_FAILED)
371
+ Dependabot::DependencyFileNotResolvable.new(msg || message)
372
+ }
354
373
  }
355
374
  }.freeze, T::Hash[String, {
356
375
  message: T.any(String, NilClass),
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-npm_and_yarn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.294.0
4
+ version: 0.295.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-23 00:00:00.000000000 Z
11
+ date: 2025-01-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dependabot-common
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - '='
18
18
  - !ruby/object:Gem::Version
19
- version: 0.294.0
19
+ version: 0.295.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 0.294.0
26
+ version: 0.295.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: debug
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -307,6 +307,7 @@ files:
307
307
  - helpers/test/yarn/updater.test.js
308
308
  - lib/dependabot/npm_and_yarn.rb
309
309
  - lib/dependabot/npm_and_yarn/bun_package_manager.rb
310
+ - lib/dependabot/npm_and_yarn/constraint_helper.rb
310
311
  - lib/dependabot/npm_and_yarn/dependency_files_filterer.rb
311
312
  - lib/dependabot/npm_and_yarn/file_fetcher.rb
312
313
  - lib/dependabot/npm_and_yarn/file_fetcher/path_dependency_builder.rb
@@ -323,6 +324,7 @@ files:
323
324
  - lib/dependabot/npm_and_yarn/file_updater/package_json_preparer.rb
324
325
  - lib/dependabot/npm_and_yarn/file_updater/package_json_updater.rb
325
326
  - lib/dependabot/npm_and_yarn/file_updater/pnpm_lockfile_updater.rb
327
+ - lib/dependabot/npm_and_yarn/file_updater/pnpm_workspace_updater.rb
326
328
  - lib/dependabot/npm_and_yarn/file_updater/yarn_lockfile_updater.rb
327
329
  - lib/dependabot/npm_and_yarn/helpers.rb
328
330
  - lib/dependabot/npm_and_yarn/language.rb
@@ -354,7 +356,7 @@ licenses:
354
356
  - MIT
355
357
  metadata:
356
358
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
357
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.294.0
359
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.295.0
358
360
  post_install_message:
359
361
  rdoc_options: []
360
362
  require_paths: