Skip to content

Commit 209c52b

Browse files
authored
Merge pull request #15099 from dependabot/brrygrdn/dg-11931-capture-multi-version-graphs
[Graph] Fix handling of multiple version resolution
2 parents 88d7c47 + 957fc1b commit 209c52b

24 files changed

Lines changed: 1000 additions & 13 deletions

File tree

npm_and_yarn/lib/dependabot/npm_and_yarn/dependency_grapher.rb

Lines changed: 188 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,31 @@ def relevant_dependency_file
2727
lockfile || package_json
2828
end
2929

30+
# Override to expand multi-version dependencies into separate resolved
31+
# dependency entries. When the same package exists at multiple versions
32+
# (e.g., is-number@6.0.0 direct + is-number@7.0.0 transitive), each
33+
# version gets its own entry with correct subdependency edges.
34+
sig { override.returns(T::Hash[String, Dependabot::DependencyGraphers::ResolvedDependency]) }
35+
def resolved_dependencies
36+
prepare! unless prepared
37+
38+
@dependencies.each_with_object({}) do |dep, resolved|
39+
all_versions = dep.metadata[:all_versions] || [dep]
40+
41+
all_versions.each do |version_dep|
42+
purl = build_purl(version_dep)
43+
next if resolved.key?(purl)
44+
45+
resolved[purl] = Dependabot::DependencyGraphers::ResolvedDependency.new(
46+
package_url: purl,
47+
direct: version_dep.top_level?,
48+
runtime: version_dep.production?,
49+
dependencies: subdependency_purls_for(version_dep)
50+
)
51+
end
52+
end
53+
end
54+
3055
sig { override.void }
3156
def prepare!
3257
if lockfile.nil?
@@ -156,7 +181,48 @@ def emit_missing_lockfile_warning!
156181

157182
sig { override.params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
158183
def fetch_subdependencies(dependency)
159-
package_relationships.fetch(dependency.name, [])
184+
key = "#{dependency.name}@#{dependency.version}"
185+
package_relationships.fetch(key, [])
186+
end
187+
188+
# Builds purl strings for subdependencies directly from the name@version
189+
# entries in package_relationships, without going through dependencies_by_name
190+
# which only holds one combined dep per package name.
191+
sig { params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
192+
def subdependency_purls_for(dependency)
193+
return [] if errored_fetching_subdependencies
194+
195+
key = "#{dependency.name}@#{dependency.version}"
196+
children = package_relationships.fetch(key, [])
197+
198+
children.filter_map do |child_key|
199+
child_name, child_version = split_name_version(child_key)
200+
next unless child_name && child_version
201+
202+
purl_name = child_name.sub(/^@/, "%40")
203+
format(PURL_TEMPLATE, type: "npm", name: purl_name, version: "@#{child_version}")
204+
end
205+
rescue StandardError => e
206+
errored_fetching_subdependencies!
207+
@subdependency_error = T.let(e, T.nilable(StandardError))
208+
Dependabot.logger.error("Error fetching subdependencies: #{e.message}")
209+
[]
210+
end
211+
212+
# Splits a "name@version" string, handling scoped packages like "@scope/pkg@1.0.0"
213+
sig { params(name_version: String).returns([T.nilable(String), T.nilable(String)]) }
214+
def split_name_version(name_version)
215+
# For scoped packages (@scope/name@version), find the second @
216+
at_index = if name_version.start_with?("@")
217+
name_version.index("@", 1)
218+
else
219+
name_version.index("@")
220+
end
221+
return [name_version, nil] unless at_index
222+
223+
version = name_version[(at_index + 1)..]
224+
version = nil if version.nil? || version.empty?
225+
[name_version[0...at_index], version]
160226
end
161227

162228
sig { returns(T::Hash[String, T::Array[String]]) }
@@ -225,14 +291,67 @@ def fetch_npm_lock_relationships
225291
children = details.fetch("dependencies", {}).keys
226292
next if children.empty?
227293

228-
rels[path.split("node_modules/").last] = children
294+
package_name = path.split("node_modules/").last
295+
version = details["version"]
296+
next if version.nil? || version.to_s.empty?
297+
298+
resolved = resolve_npm_v3_children(packages, path, children)
299+
rels["#{package_name}@#{version}"] = resolved unless resolved.empty?
229300
end
230301
end
231302

232303
# if packages isn't present, attempt a v1 fallback
233304
fetch_npm_v1_lock_relationships(parsed)
234305
end
235306

307+
sig do
308+
params(
309+
packages: T::Hash[String, T.untyped],
310+
parent_path: String,
311+
children: T::Array[String]
312+
).returns(T::Array[String])
313+
end
314+
def resolve_npm_v3_children(packages, parent_path, children)
315+
children.filter_map do |child_name|
316+
child_details = resolve_npm_child(packages, parent_path, child_name)
317+
next unless child_details
318+
319+
child_version = child_details["version"]
320+
next if child_version.nil? || child_version.to_s.empty?
321+
322+
"#{child_name}@#{child_version}"
323+
end
324+
end
325+
326+
# Walks up the node_modules tree to resolve a child dependency,
327+
# matching Node.js module resolution behavior.
328+
sig do
329+
params(
330+
packages: T::Hash[String, T.untyped],
331+
parent_path: String,
332+
child_name: String
333+
).returns(T.nilable(T::Hash[String, T.untyped]))
334+
end
335+
def resolve_npm_child(packages, parent_path, child_name)
336+
# First check directly nested under parent
337+
candidate = "#{parent_path}/node_modules/#{child_name}"
338+
return packages[candidate] if packages.key?(candidate)
339+
340+
# Walk up the tree: strip trailing node_modules/pkg segments
341+
segments = parent_path.split("node_modules/")
342+
segments.pop # remove the current package segment
343+
344+
while segments.any?
345+
candidate = "#{segments.join('node_modules/')}node_modules/#{child_name}"
346+
return packages[candidate] if packages.key?(candidate)
347+
348+
segments.pop
349+
end
350+
351+
# Top-level fallback
352+
packages["node_modules/#{child_name}"]
353+
end
354+
236355
sig { params(parsed: T::Hash[String, T.untyped]).returns(T::Hash[String, T::Array[String]]) }
237356
def fetch_npm_v1_lock_relationships(parsed)
238357
dependencies = parsed.fetch("dependencies", {})
@@ -244,29 +363,73 @@ def fetch_npm_v1_lock_relationships(parsed)
244363
nested = details.fetch("dependencies", nil)
245364
next unless nested.is_a?(Hash)
246365

247-
children = nested.keys
248-
rels[name] = children unless children.empty?
366+
version = details["version"]
367+
next if version.nil? || version.to_s.empty?
368+
369+
children = resolve_npm_v1_children(nested)
370+
rels["#{name}@#{version}"] = children unless children.empty?
249371
rels.merge!(fetch_npm_v1_lock_relationships(details))
250372
end
251373
end
252374

375+
sig { params(nested: T::Hash[String, T.untyped]).returns(T::Array[String]) }
376+
def resolve_npm_v1_children(nested)
377+
nested.filter_map do |child_name, child_details|
378+
next unless child_details.is_a?(Hash)
379+
380+
child_version = child_details["version"]
381+
next if child_version.nil? || child_version.to_s.empty?
382+
383+
"#{child_name}@#{child_version}"
384+
end
385+
end
386+
253387
sig { returns(T::Hash[String, T::Array[String]]) }
254388
def fetch_yarn_lock_relationships
255389
parsed = FileParser::YarnLock.new(T.must(yarn_lockfile)).parsed
256390

257391
parsed.each_with_object({}) do |(req, details), rels|
258392
next unless details.is_a?(Hash)
259393

394+
version = details["version"]
260395
parent_name = T.must(req.split(/(?<=\w)\@/).first)
261-
children = details.fetch("dependencies", {})&.keys || []
396+
children = details.fetch("dependencies", {})
397+
398+
next if children.nil? || children.empty?
399+
400+
key = "#{parent_name}@#{version}"
401+
resolved_children = resolve_yarn_children(children, parsed)
262402

263-
next if children.empty?
403+
rels[key] ||= []
404+
rels[key].concat(resolved_children).uniq!
405+
end
406+
end
264407

265-
rels[parent_name] ||= []
266-
rels[parent_name].concat(children).uniq!
408+
sig { params(children: T::Hash[String, String], parsed: T::Hash[String, T.untyped]).returns(T::Array[String]) }
409+
def resolve_yarn_children(children, parsed)
410+
children.filter_map do |child_name, child_req|
411+
version = resolve_yarn_child_version(child_name, child_req, parsed)
412+
"#{child_name}@#{version}" if version
267413
end
268414
end
269415

416+
sig { params(child_name: String, child_req: String, parsed: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
417+
def resolve_yarn_child_version(child_name, child_req, parsed)
418+
# Try exact key first
419+
child_entry = parsed["#{child_name}@#{child_req}"]
420+
return child_entry["version"] if child_entry && child_entry["version"]
421+
422+
# Yarn groups multiple requirements into single keys like "foo@^1.0.0, foo@^1.2.0"
423+
target_req = "#{child_name}@#{child_req}"
424+
grouped_match = parsed.find { |k, _| k.split(", ").include?(target_req) }
425+
return grouped_match.last["version"] if grouped_match && grouped_match.last["version"]
426+
427+
# Fallback: find by name only if there's exactly one candidate
428+
candidates = parsed.select { |k, _| k.split(/(?<=\w)\@/).first == child_name }
429+
candidate = candidates.first
430+
candidate.last["version"] if candidates.size == 1 && candidate
431+
end
432+
270433
sig { returns(T::Hash[String, T::Array[String]]) }
271434
def fetch_pnpm_lock_relationships
272435
parsed = YAML.safe_load(T.must(T.must(pnpm_lockfile).content)) || {}
@@ -278,13 +441,25 @@ def fetch_pnpm_lock_relationships
278441
next unless details.is_a?(Hash)
279442

280443
# Keys are "/name@version" (v6) or "name@version" (v9)
281-
parent_name = key.sub(%r{^/}, "").split(/(?<=\w)\@/).first
282-
children = details.fetch("dependencies", {})&.keys || []
444+
name_version = key.sub(%r{^/}, "")
445+
children = details.fetch("dependencies", {})
446+
447+
next if children.nil? || children.empty?
448+
449+
# Strip any pnpm suffix metadata (e.g., parenthesized peer dep info)
450+
name_version = name_version.sub(/\(.*\)$/, "")
283451

284-
next if children.empty?
452+
# pnpm dependencies are already resolved: {"name": "version"}
453+
# Strip any peer metadata suffixes like "7.49.0(react@18.2.0)"
454+
resolved_children = children.filter_map do |child_name, child_version|
455+
clean_version = child_version.to_s.sub(/\(.*\)$/, "")
456+
next if clean_version.empty?
457+
458+
"#{child_name}@#{clean_version}"
459+
end
285460

286-
rels[parent_name] ||= []
287-
rels[parent_name].concat(children).uniq!
461+
rels[name_version] ||= []
462+
rels[name_version].concat(resolved_children).uniq!
288463
end
289464
end
290465

0 commit comments

Comments
 (0)