dependabot-npm_and_yarn 0.378.0 → 0.379.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: fc8f0275a798e00313419810c19e4111046b82ff368f0b80e4b6565ce78a4242
4
- data.tar.gz: 7a9410b39a02e0c8a2a63ed961828c5df1cf7cc478d0ceb1214c5da523242e1b
3
+ metadata.gz: bd08cdfb4fa9a2d7702785f8f1613bc691569c1cbde17dc45be2c540a7bb6de3
4
+ data.tar.gz: ed5b6525cb039e5dadbd06709f0a95a47c6c16236ec79a2c8ecbc555bcf1ddd8
5
5
  SHA512:
6
- metadata.gz: 6795bb05b732633241bef5d4d5d7a31f105eb49297de001a7f754d8b99295f414c902387ee8d22c3cb54d53916a74982786d7a6aed4cebfeb4951c41a81ad56d
7
- data.tar.gz: 79b1035a89c6cb3787776325b326f206a660d9da1e4882dc530f1eef3a0b3394e5d6c80a389e88626195fa498b94b0b439a2de55fc928549a637d8f5b727c0db
6
+ metadata.gz: af2f7366ea66b328b4ab910b54bff68773df0b76156e988b8bc1590e8fa97097b4b736f890699718df31b6e001191e0c86e7362e306fd91030df9625e1625923
7
+ data.tar.gz: 49955bd7ee30d88d916ff83ec7ded75784f5d53efc4aeab927e756fde987b4eddaa1788360f8b5f7f13314075908b4a14e7937394ff9b8b16edcbd7b8037707a
@@ -4,6 +4,7 @@
4
4
  require "sorbet-runtime"
5
5
 
6
6
  require "dependabot/dependency_file"
7
+ require "dependabot/errors"
7
8
  require "dependabot/shared_helpers"
8
9
  require "dependabot/npm_and_yarn/helpers"
9
10
  require "dependabot/npm_and_yarn/package_manager"
@@ -15,6 +16,12 @@ module Dependabot
15
16
  class LockfileGenerator
16
17
  extend T::Sig
17
18
 
19
+ # URL extraction patterns
20
+ GENERIC_URL_REGEX = T.let(%r{(https?://[^\s"']+)}, Regexp)
21
+ NETWORK_ERROR_HOST_REGEX = T.let(/E(?:NOTFOUND|TIMEDOUT)\s+(\S+)/i, Regexp)
22
+
23
+ FALLBACK_SOURCE = "a private registry"
24
+
18
25
  sig do
19
26
  params(
20
27
  dependency_files: T::Array[Dependabot::DependencyFile],
@@ -202,21 +209,49 @@ module Dependabot
202
209
  "Failed to generate lockfile with #{package_manager}: #{error.message}"
203
210
  )
204
211
 
205
- # Log more details for debugging
206
212
  if error.message.include?("ERESOLVE")
207
213
  Dependabot.logger.error(
208
214
  "Dependency resolution failed. This may be due to conflicting peer dependencies."
209
215
  )
216
+ raise Dependabot::DependencyFileNotResolvable,
217
+ "Could not resolve dependencies. This may be due to conflicting peer dependencies."
210
218
  elsif error.message.include?("ENOTFOUND") || error.message.include?("ETIMEDOUT")
211
219
  Dependabot.logger.error(
212
220
  "Network error while generating lockfile. Registry may be unreachable."
213
221
  )
214
- elsif error.message.include?("401") || error.message.include?("403")
222
+ raise Dependabot::PrivateSourceTimedOut, extract_network_error_host(error.message)
223
+ elsif authentication_error?(error.message)
215
224
  Dependabot.logger.error(
216
225
  "Authentication error. Check that credentials are configured correctly."
217
226
  )
227
+ raise Dependabot::PrivateSourceAuthenticationFailure, extract_url(error.message)
218
228
  end
219
229
  end
230
+
231
+ sig { params(message: String).returns(T::Boolean) }
232
+ def authentication_error?(message)
233
+ message.include?("401") ||
234
+ message.include?("403") ||
235
+ message.include?(NpmAndYarn::AUTHENTICATION_TOKEN_NOT_PROVIDED) ||
236
+ message.include?(NpmAndYarn::AUTHENTICATION_IS_NOT_CONFIGURED) ||
237
+ message.include?(NpmAndYarn::AUTHENTICATION_HEADER_NOT_PROVIDED) ||
238
+ message.match?(NpmAndYarn::AUTH_REQUIRED_ERROR)
239
+ end
240
+
241
+ sig { params(message: String).returns(String) }
242
+ def extract_url(message)
243
+ match = message.match(GENERIC_URL_REGEX)
244
+ match ? T.must(match[1]).chomp(":") : FALLBACK_SOURCE
245
+ end
246
+
247
+ sig { params(message: String).returns(String) }
248
+ def extract_network_error_host(message)
249
+ match = message.match(NETWORK_ERROR_HOST_REGEX)
250
+ return T.must(match[1]) if match
251
+
252
+ match = message.match(GENERIC_URL_REGEX)
253
+ match ? T.must(match[1]) : FALLBACK_SOURCE
254
+ end
220
255
  end
221
256
  end
222
257
  end
@@ -0,0 +1,134 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "sorbet-runtime"
6
+
7
+ module Dependabot
8
+ module NpmAndYarn
9
+ class DependencyGrapher < Dependabot::DependencyGraphers::Base
10
+ class NpmRelationshipResolver
11
+ extend T::Sig
12
+
13
+ sig { params(lockfile: Dependabot::DependencyFile).void }
14
+ def initialize(lockfile)
15
+ @lockfile = lockfile
16
+ end
17
+
18
+ sig { returns(T::Hash[String, T::Array[String]]) }
19
+ def relationships
20
+ parsed = JSON.parse(T.must(@lockfile.content))
21
+ packages = parsed.fetch("packages", {})
22
+
23
+ # v3/v2 lockfiles use a flat "packages" section
24
+ return build_v3_relationships(packages) if packages.is_a?(Hash) && !packages.empty?
25
+
26
+ # if packages isn't present, attempt a v1 fallback
27
+ build_v1_relationships(parsed)
28
+ end
29
+
30
+ private
31
+
32
+ sig { params(packages: T::Hash[String, T.untyped]).returns(T::Hash[String, T::Array[String]]) }
33
+ def build_v3_relationships(packages)
34
+ packages.each_with_object({}) do |(path, details), rels|
35
+ next if path.empty? # skip root package entry
36
+ next unless details.is_a?(Hash)
37
+
38
+ children = details.fetch("dependencies", {}).keys
39
+ next if children.empty?
40
+
41
+ package_name = details["name"] || path.split("node_modules/").last
42
+ version = details["version"]
43
+ next if version.nil? || version.to_s.empty?
44
+
45
+ resolved = resolve_v3_children(packages, path, children)
46
+ rels["#{package_name}@#{version}"] = resolved unless resolved.empty?
47
+ end
48
+ end
49
+
50
+ sig do
51
+ params(
52
+ packages: T::Hash[String, T.untyped],
53
+ parent_path: String,
54
+ children: T::Array[String]
55
+ ).returns(T::Array[String])
56
+ end
57
+ def resolve_v3_children(packages, parent_path, children)
58
+ children.filter_map do |child_name|
59
+ child_details = resolve_child(packages, parent_path, child_name)
60
+ next unless child_details
61
+
62
+ child_version = child_details["version"]
63
+ next if child_version.nil? || child_version.to_s.empty?
64
+
65
+ # Use the "name" field for aliased packages (real name vs path alias)
66
+ real_name = child_details["name"] || child_name
67
+ "#{real_name}@#{child_version}"
68
+ end
69
+ end
70
+
71
+ # Walks up the node_modules tree to resolve a child dependency,
72
+ # matching Node.js module resolution behavior.
73
+ sig do
74
+ params(
75
+ packages: T::Hash[String, T.untyped],
76
+ parent_path: String,
77
+ child_name: String
78
+ ).returns(T.nilable(T::Hash[String, T.untyped]))
79
+ end
80
+ def resolve_child(packages, parent_path, child_name)
81
+ # First check directly nested under parent
82
+ candidate = "#{parent_path}/node_modules/#{child_name}"
83
+ return packages[candidate] if packages.key?(candidate)
84
+
85
+ # Walk up the tree: strip trailing node_modules/pkg segments
86
+ segments = parent_path.split("node_modules/")
87
+ segments.pop # remove the current package segment
88
+
89
+ while segments.any?
90
+ candidate = "#{segments.join('node_modules/')}node_modules/#{child_name}"
91
+ return packages[candidate] if packages.key?(candidate)
92
+
93
+ segments.pop
94
+ end
95
+
96
+ # Top-level fallback
97
+ packages["node_modules/#{child_name}"]
98
+ end
99
+
100
+ sig { params(parsed: T::Hash[String, T.untyped]).returns(T::Hash[String, T::Array[String]]) }
101
+ def build_v1_relationships(parsed)
102
+ dependencies = parsed.fetch("dependencies", {})
103
+ return {} unless dependencies.is_a?(Hash)
104
+
105
+ dependencies.each_with_object({}) do |(name, details), rels|
106
+ next unless details.is_a?(Hash)
107
+
108
+ nested = details.fetch("dependencies", nil)
109
+ next unless nested.is_a?(Hash)
110
+
111
+ version = details["version"]
112
+ next if version.nil? || version.to_s.empty?
113
+
114
+ children = resolve_v1_children(nested)
115
+ rels["#{name}@#{version}"] = children unless children.empty?
116
+ rels.merge!(build_v1_relationships(details))
117
+ end
118
+ end
119
+
120
+ sig { params(nested: T::Hash[String, T.untyped]).returns(T::Array[String]) }
121
+ def resolve_v1_children(nested)
122
+ nested.filter_map do |child_name, child_details|
123
+ next unless child_details.is_a?(Hash)
124
+
125
+ child_version = child_details["version"]
126
+ next if child_version.nil? || child_version.to_s.empty?
127
+
128
+ "#{child_name}@#{child_version}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,53 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "sorbet-runtime"
6
+
7
+ module Dependabot
8
+ module NpmAndYarn
9
+ class DependencyGrapher < Dependabot::DependencyGraphers::Base
10
+ class PnpmRelationshipResolver
11
+ extend T::Sig
12
+
13
+ sig { params(lockfile: Dependabot::DependencyFile).void }
14
+ def initialize(lockfile)
15
+ @lockfile = lockfile
16
+ end
17
+
18
+ sig { returns(T::Hash[String, T::Array[String]]) }
19
+ def relationships
20
+ parsed = YAML.safe_load(T.must(@lockfile.content)) || {}
21
+
22
+ # v9+ uses "snapshots" for resolved dependency details; v6 uses "packages"
23
+ entries = parsed.fetch("snapshots", nil) || parsed.fetch("packages", {})
24
+
25
+ entries.each_with_object({}) do |(key, details), rels|
26
+ next unless details.is_a?(Hash)
27
+
28
+ # Keys are "/name@version" (v6) or "name@version" (v9)
29
+ name_version = key.sub(%r{^/}, "")
30
+ children = details.fetch("dependencies", {})
31
+
32
+ next if children.nil? || children.empty?
33
+
34
+ # Strip any pnpm suffix metadata (e.g., parenthesized peer dep info)
35
+ name_version = name_version.sub(/\(.*\)$/, "")
36
+
37
+ # pnpm dependencies are already resolved: {"name": "version"}
38
+ # Strip any peer metadata suffixes like "7.49.0(react@18.2.0)"
39
+ resolved_children = children.filter_map do |child_name, child_version|
40
+ clean_version = child_version.to_s.sub(/\(.*\)$/, "")
41
+ next if clean_version.empty?
42
+
43
+ "#{child_name}@#{clean_version}"
44
+ end
45
+
46
+ rels[name_version] ||= []
47
+ rels[name_version].concat(resolved_children).uniq!
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,68 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/npm_and_yarn/file_parser/yarn_lock"
6
+
7
+ module Dependabot
8
+ module NpmAndYarn
9
+ class DependencyGrapher < Dependabot::DependencyGraphers::Base
10
+ class YarnRelationshipResolver
11
+ extend T::Sig
12
+
13
+ sig { params(lockfile: Dependabot::DependencyFile).void }
14
+ def initialize(lockfile)
15
+ @lockfile = lockfile
16
+ end
17
+
18
+ sig { returns(T::Hash[String, T::Array[String]]) }
19
+ def relationships
20
+ parsed = FileParser::YarnLock.new(@lockfile).parsed
21
+
22
+ parsed.each_with_object({}) do |(req, details), rels|
23
+ next unless details.is_a?(Hash)
24
+
25
+ version = details["version"]
26
+ parent_name = T.must(req.split(/(?<=\w)\@/).first)
27
+ children = details.fetch("dependencies", {})
28
+
29
+ next if children.nil? || children.empty?
30
+
31
+ key = "#{parent_name}@#{version}"
32
+ resolved_children = resolve_children(children, parsed)
33
+
34
+ rels[key] ||= []
35
+ rels[key].concat(resolved_children).uniq!
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ sig { params(children: T::Hash[String, String], parsed: T::Hash[String, T.untyped]).returns(T::Array[String]) }
42
+ def resolve_children(children, parsed)
43
+ children.filter_map do |child_name, child_req|
44
+ version = resolve_child_version(child_name, child_req, parsed)
45
+ "#{child_name}@#{version}" if version
46
+ end
47
+ end
48
+
49
+ sig { params(child_name: String, child_req: String, parsed: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
50
+ def resolve_child_version(child_name, child_req, parsed)
51
+ # Try exact key first
52
+ child_entry = parsed["#{child_name}@#{child_req}"]
53
+ return child_entry["version"] if child_entry && child_entry["version"]
54
+
55
+ # Yarn groups multiple requirements into single keys like "foo@^1.0.0, foo@^1.2.0"
56
+ target_req = "#{child_name}@#{child_req}"
57
+ grouped_match = parsed.find { |k, _| k.split(", ").include?(target_req) }
58
+ return grouped_match.last["version"] if grouped_match && grouped_match.last["version"]
59
+
60
+ # Fallback: find by name only if there's exactly one candidate
61
+ candidates = parsed.select { |k, _| k.split(/(?<=\w)\@/).first == child_name }
62
+ candidate = candidates.first
63
+ candidate.last["version"] if candidates.size == 1 && candidate
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -2,7 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
- require "yaml"
6
5
  require "sorbet-runtime"
7
6
 
8
7
  require "dependabot/dependency_graphers"
@@ -17,6 +16,9 @@ module Dependabot
17
16
  extend T::Sig
18
17
 
19
18
  require_relative "dependency_grapher/lockfile_generator"
19
+ require_relative "dependency_grapher/npm_relationship_resolver"
20
+ require_relative "dependency_grapher/yarn_relationship_resolver"
21
+ require_relative "dependency_grapher/pnpm_relationship_resolver"
20
22
 
21
23
  sig { override.returns(Dependabot::DependencyFile) }
22
24
  def relevant_dependency_file
@@ -27,8 +29,37 @@ module Dependabot
27
29
  lockfile || package_json
28
30
  end
29
31
 
32
+ # Override to expand multi-version dependencies into separate resolved
33
+ # dependency entries. When the same package exists at multiple versions
34
+ # (e.g., is-number@6.0.0 direct + is-number@7.0.0 transitive), each
35
+ # version gets its own entry with correct subdependency edges.
36
+ sig { override.returns(T::Hash[String, Dependabot::DependencyGraphers::ResolvedDependency]) }
37
+ def resolved_dependencies
38
+ prepare! unless prepared
39
+
40
+ @dependencies.each_with_object({}) do |dep, resolved|
41
+ all_versions = dep.metadata[:all_versions] || [dep]
42
+
43
+ all_versions.each do |version_dep|
44
+ purl = build_purl(version_dep)
45
+ next if resolved.key?(purl)
46
+
47
+ resolved[purl] = Dependabot::DependencyGraphers::ResolvedDependency.new(
48
+ package_url: purl,
49
+ direct: version_dep.top_level? || !version_dep.metadata[:alias].nil?,
50
+ runtime: version_dep.production?,
51
+ dependencies: subdependency_purls_for(version_dep)
52
+ )
53
+ end
54
+ end
55
+ end
56
+
30
57
  sig { override.void }
31
58
  def prepare!
59
+ # Enable alias extraction for graph jobs so aliased packages appear
60
+ # in the dependency graph for security scanning.
61
+ file_parser.dealias_packages!
62
+
32
63
  if lockfile.nil?
33
64
  Dependabot.logger.info("No lockfile found, generating ephemeral lockfile for dependency graphing")
34
65
  generate_ephemeral_lockfile!
@@ -79,11 +110,14 @@ module Dependabot
79
110
 
80
111
  sig { returns(T::Hash[Symbol, T.nilable(Dependabot::DependencyFile)]) }
81
112
  def lockfiles_hash
82
- {
83
- npm: dependency_files.find { |f| f.name.end_with?(NpmPackageManager::LOCKFILE_NAME) },
84
- yarn: dependency_files.find { |f| f.name.end_with?(YarnPackageManager::LOCKFILE_NAME) },
85
- pnpm: dependency_files.find { |f| f.name.end_with?(PNPMPackageManager::LOCKFILE_NAME) }
86
- }
113
+ @lockfiles_hash ||= T.let(
114
+ {
115
+ npm: dependency_files.find { |f| f.name.end_with?(NpmPackageManager::LOCKFILE_NAME) },
116
+ yarn: dependency_files.find { |f| f.name.end_with?(YarnPackageManager::LOCKFILE_NAME) },
117
+ pnpm: dependency_files.find { |f| f.name.end_with?(PNPMPackageManager::LOCKFILE_NAME) }
118
+ },
119
+ T.nilable(T::Hash[Symbol, T.nilable(Dependabot::DependencyFile)])
120
+ )
87
121
  end
88
122
 
89
123
  sig { returns(String) }
@@ -130,6 +164,7 @@ module Dependabot
130
164
 
131
165
  # Clear our cached lockfile reference so it picks up the new one
132
166
  remove_instance_variable(:@lockfile) if instance_variable_defined?(:@lockfile)
167
+ remove_instance_variable(:@lockfiles_hash) if instance_variable_defined?(:@lockfiles_hash)
133
168
 
134
169
  # Clear the FileParser's memoized lockfile references so it will
135
170
  # find the newly injected lockfile when parse is called
@@ -156,7 +191,48 @@ module Dependabot
156
191
 
157
192
  sig { override.params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
158
193
  def fetch_subdependencies(dependency)
159
- package_relationships.fetch(dependency.name, [])
194
+ key = "#{dependency.name}@#{dependency.version}"
195
+ package_relationships.fetch(key, [])
196
+ end
197
+
198
+ # Builds purl strings for subdependencies directly from the name@version
199
+ # entries in package_relationships, without going through dependencies_by_name
200
+ # which only holds one combined dep per package name.
201
+ sig { params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
202
+ def subdependency_purls_for(dependency)
203
+ return [] if errored_fetching_subdependencies
204
+
205
+ key = "#{dependency.name}@#{dependency.version}"
206
+ children = package_relationships.fetch(key, [])
207
+
208
+ children.filter_map do |child_key|
209
+ child_name, child_version = split_name_version(child_key)
210
+ next unless child_name && child_version
211
+
212
+ purl_name = child_name.sub(/^@/, "%40")
213
+ format(PURL_TEMPLATE, type: "npm", name: purl_name, version: "@#{child_version}")
214
+ end
215
+ rescue StandardError => e
216
+ errored_fetching_subdependencies!
217
+ @subdependency_error = T.let(e, T.nilable(StandardError))
218
+ Dependabot.logger.error("Error fetching subdependencies: #{e.message}")
219
+ []
220
+ end
221
+
222
+ # Splits a "name@version" string, handling scoped packages like "@scope/pkg@1.0.0"
223
+ sig { params(name_version: String).returns([T.nilable(String), T.nilable(String)]) }
224
+ def split_name_version(name_version)
225
+ # For scoped packages (@scope/name@version), find the second @
226
+ at_index = if name_version.start_with?("@")
227
+ name_version.index("@", 1)
228
+ else
229
+ name_version.index("@")
230
+ end
231
+ return [name_version, nil] unless at_index
232
+
233
+ version = name_version[(at_index + 1)..]
234
+ version = nil if version.nil? || version.empty?
235
+ [name_version[0...at_index], version]
160
236
  end
161
237
 
162
238
  sig { returns(T::Hash[String, T::Array[String]]) }
@@ -165,129 +241,27 @@ module Dependabot
165
241
  fetch_package_relationships,
166
242
  T.nilable(T::Hash[String, T::Array[String]])
167
243
  )
244
+ rescue StandardError => e
245
+ errored_fetching_subdependencies!
246
+ @subdependency_error = T.let(e, T.nilable(StandardError))
247
+ Dependabot.logger.error("Error fetching subdependencies: #{e.message}")
248
+ @package_relationships = {}
168
249
  end
169
250
 
170
251
  sig { returns(T::Hash[String, T::Array[String]]) }
171
252
  def fetch_package_relationships
172
253
  case detected_package_manager
173
254
  when NpmPackageManager::NAME
174
- fetch_npm_lock_relationships
255
+ NpmRelationshipResolver.new(T.must(lockfiles_hash[:npm])).relationships
175
256
  when YarnPackageManager::NAME
176
- fetch_yarn_lock_relationships
257
+ YarnRelationshipResolver.new(T.must(lockfiles_hash[:yarn])).relationships
177
258
  when PNPMPackageManager::NAME
178
- fetch_pnpm_lock_relationships
259
+ PnpmRelationshipResolver.new(T.must(lockfiles_hash[:pnpm])).relationships
179
260
  else
180
261
  {}
181
262
  end
182
263
  end
183
264
 
184
- sig { returns(T.nilable(Dependabot::DependencyFile)) }
185
- def npm_lockfile
186
- return @npm_lockfile if defined?(@npm_lockfile)
187
-
188
- @npm_lockfile = T.let(
189
- dependency_files.find { |f| f.name.end_with?(NpmPackageManager::LOCKFILE_NAME) },
190
- T.nilable(Dependabot::DependencyFile)
191
- )
192
- end
193
-
194
- sig { returns(T.nilable(Dependabot::DependencyFile)) }
195
- def yarn_lockfile
196
- return @yarn_lockfile if defined?(@yarn_lockfile)
197
-
198
- @yarn_lockfile = T.let(
199
- dependency_files.find { |f| f.name.end_with?(YarnPackageManager::LOCKFILE_NAME) },
200
- T.nilable(Dependabot::DependencyFile)
201
- )
202
- end
203
-
204
- sig { returns(T.nilable(Dependabot::DependencyFile)) }
205
- def pnpm_lockfile
206
- return @pnpm_lockfile if defined?(@pnpm_lockfile)
207
-
208
- @pnpm_lockfile = T.let(
209
- dependency_files.find { |f| f.name.end_with?(PNPMPackageManager::LOCKFILE_NAME) },
210
- T.nilable(Dependabot::DependencyFile)
211
- )
212
- end
213
-
214
- sig { returns(T::Hash[String, T::Array[String]]) }
215
- def fetch_npm_lock_relationships
216
- parsed = JSON.parse(T.must(T.must(npm_lockfile).content))
217
- packages = parsed.fetch("packages", {})
218
-
219
- # v3/v2 lockfiles use a flat "packages" section
220
- if packages.is_a?(Hash) && !packages.empty?
221
- return packages.each_with_object({}) do |(path, details), rels|
222
- next if path.empty? # skip root package entry
223
- next unless details.is_a?(Hash)
224
-
225
- children = details.fetch("dependencies", {}).keys
226
- next if children.empty?
227
-
228
- rels[path.split("node_modules/").last] = children
229
- end
230
- end
231
-
232
- # if packages isn't present, attempt a v1 fallback
233
- fetch_npm_v1_lock_relationships(parsed)
234
- end
235
-
236
- sig { params(parsed: T::Hash[String, T.untyped]).returns(T::Hash[String, T::Array[String]]) }
237
- def fetch_npm_v1_lock_relationships(parsed)
238
- dependencies = parsed.fetch("dependencies", {})
239
- return {} unless dependencies.is_a?(Hash)
240
-
241
- dependencies.each_with_object({}) do |(name, details), rels|
242
- next unless details.is_a?(Hash)
243
-
244
- nested = details.fetch("dependencies", nil)
245
- next unless nested.is_a?(Hash)
246
-
247
- children = nested.keys
248
- rels[name] = children unless children.empty?
249
- rels.merge!(fetch_npm_v1_lock_relationships(details))
250
- end
251
- end
252
-
253
- sig { returns(T::Hash[String, T::Array[String]]) }
254
- def fetch_yarn_lock_relationships
255
- parsed = FileParser::YarnLock.new(T.must(yarn_lockfile)).parsed
256
-
257
- parsed.each_with_object({}) do |(req, details), rels|
258
- next unless details.is_a?(Hash)
259
-
260
- parent_name = T.must(req.split(/(?<=\w)\@/).first)
261
- children = details.fetch("dependencies", {})&.keys || []
262
-
263
- next if children.empty?
264
-
265
- rels[parent_name] ||= []
266
- rels[parent_name].concat(children).uniq!
267
- end
268
- end
269
-
270
- sig { returns(T::Hash[String, T::Array[String]]) }
271
- def fetch_pnpm_lock_relationships
272
- parsed = YAML.safe_load(T.must(T.must(pnpm_lockfile).content)) || {}
273
-
274
- # v9+ uses "snapshots" for resolved dependency details; v6 uses "packages"
275
- entries = parsed.fetch("snapshots", nil) || parsed.fetch("packages", {})
276
-
277
- entries.each_with_object({}) do |(key, details), rels|
278
- next unless details.is_a?(Hash)
279
-
280
- # Keys are "/name@version" (v6) or "name@version" (v9)
281
- parent_name = key.sub(%r{^/}, "").split(/(?<=\w)\@/).first
282
- children = details.fetch("dependencies", {})&.keys || []
283
-
284
- next if children.empty?
285
-
286
- rels[parent_name] ||= []
287
- rels[parent_name].concat(children).uniq!
288
- end
289
- end
290
-
291
265
  sig { override.params(_dependency: Dependabot::Dependency).returns(String) }
292
266
  def purl_pkg_for(_dependency)
293
267
  "npm"
@@ -12,9 +12,10 @@ module Dependabot
12
12
  class JsonLock
13
13
  extend T::Sig
14
14
 
15
- sig { params(dependency_file: DependencyFile).void }
16
- def initialize(dependency_file)
15
+ sig { params(dependency_file: DependencyFile, dealias_packages: T::Boolean).void }
16
+ def initialize(dependency_file, dealias_packages: false)
17
17
  @dependency_file = dependency_file
18
+ @dealias_packages = dealias_packages
18
19
  end
19
20
 
20
21
  sig { returns(T::Hash[String, T.untyped]) }
@@ -64,7 +65,7 @@ module Dependabot
64
65
  version = Version.semver_for(details["version"])
65
66
  next unless version
66
67
 
67
- package_name = name.split("node_modules/").last
68
+ package_name = package_name_for(name, details)
68
69
  version = version.to_s
69
70
 
70
71
  dependency_args = {
@@ -74,6 +75,11 @@ module Dependabot
74
75
  requirements: []
75
76
  }
76
77
 
78
+ if aliased_package?(name, details)
79
+ alias_name = name.split("node_modules/").last
80
+ dependency_args[:metadata] = { alias: alias_name }
81
+ end
82
+
77
83
  if details["bundled"]
78
84
  dependency_args[:subdependency_metadata] =
79
85
  [{ npm_bundled: details["bundled"] }]
@@ -91,6 +97,29 @@ module Dependabot
91
97
  dependency_set
92
98
  end
93
99
 
100
+ sig { params(package_path: String, details: T::Hash[String, T.untyped]).returns(String) }
101
+ def package_name_for(package_path, details)
102
+ package_name = T.must(package_path.split("node_modules/").last)
103
+ return package_name unless dealias_packages?
104
+
105
+ real_package_name = details["name"]
106
+ return package_name unless real_package_name.is_a?(String)
107
+ return package_name if real_package_name == package_name
108
+
109
+ real_package_name
110
+ end
111
+
112
+ sig { params(package_path: String, details: T::Hash[String, T.untyped]).returns(T::Boolean) }
113
+ def aliased_package?(package_path, details)
114
+ return false unless dealias_packages?
115
+
116
+ real_package_name = details["name"]
117
+ return false unless real_package_name.is_a?(String)
118
+
119
+ package_name = T.must(package_path.split("node_modules/").last)
120
+ real_package_name != package_name
121
+ end
122
+
94
123
  sig { params(manifest_name: String, dependency_name: String).returns(String) }
95
124
  def node_modules_path(manifest_name, dependency_name)
96
125
  return "node_modules/#{dependency_name}" if manifest_name == "package.json"
@@ -98,6 +127,11 @@ module Dependabot
98
127
  workspace_path = manifest_name.gsub("/package.json", "")
99
128
  File.join(workspace_path, "node_modules", dependency_name)
100
129
  end
130
+
131
+ sig { returns(T::Boolean) }
132
+ def dealias_packages?
133
+ @dealias_packages
134
+ end
101
135
  end
102
136
  end
103
137
  end