Skip to content

Commit cbe832b

Browse files
committed
Pull relationship resolving out into subclasses
1 parent 0655197 commit cbe832b

4 files changed

Lines changed: 261 additions & 225 deletions

File tree

npm_and_yarn/lib/dependabot/npm_and_yarn/dependency_grapher.rb

Lines changed: 6 additions & 225 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# frozen_string_literal: true
33

44
require "json"
5-
require "yaml"
65
require "sorbet-runtime"
76

87
require "dependabot/dependency_graphers"
@@ -17,6 +16,9 @@ class DependencyGrapher < Dependabot::DependencyGraphers::Base
1716
extend T::Sig
1817

1918
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"
2022

2123
sig { override.returns(Dependabot::DependencyFile) }
2224
def relevant_dependency_file
@@ -241,237 +243,16 @@ def package_relationships
241243
def fetch_package_relationships
242244
case detected_package_manager
243245
when NpmPackageManager::NAME
244-
fetch_npm_lock_relationships
246+
NpmRelationshipResolver.new(T.must(lockfiles_hash[:npm])).relationships
245247
when YarnPackageManager::NAME
246-
fetch_yarn_lock_relationships
248+
YarnRelationshipResolver.new(T.must(lockfiles_hash[:yarn])).relationships
247249
when PNPMPackageManager::NAME
248-
fetch_pnpm_lock_relationships
250+
PnpmRelationshipResolver.new(T.must(lockfiles_hash[:pnpm])).relationships
249251
else
250252
{}
251253
end
252254
end
253255

254-
sig { returns(T.nilable(Dependabot::DependencyFile)) }
255-
def npm_lockfile
256-
return @npm_lockfile if defined?(@npm_lockfile)
257-
258-
@npm_lockfile = T.let(
259-
dependency_files.find { |f| f.name.end_with?(NpmPackageManager::LOCKFILE_NAME) },
260-
T.nilable(Dependabot::DependencyFile)
261-
)
262-
end
263-
264-
sig { returns(T.nilable(Dependabot::DependencyFile)) }
265-
def yarn_lockfile
266-
return @yarn_lockfile if defined?(@yarn_lockfile)
267-
268-
@yarn_lockfile = T.let(
269-
dependency_files.find { |f| f.name.end_with?(YarnPackageManager::LOCKFILE_NAME) },
270-
T.nilable(Dependabot::DependencyFile)
271-
)
272-
end
273-
274-
sig { returns(T.nilable(Dependabot::DependencyFile)) }
275-
def pnpm_lockfile
276-
return @pnpm_lockfile if defined?(@pnpm_lockfile)
277-
278-
@pnpm_lockfile = T.let(
279-
dependency_files.find { |f| f.name.end_with?(PNPMPackageManager::LOCKFILE_NAME) },
280-
T.nilable(Dependabot::DependencyFile)
281-
)
282-
end
283-
284-
sig { returns(T::Hash[String, T::Array[String]]) }
285-
def fetch_npm_lock_relationships
286-
parsed = JSON.parse(T.must(T.must(npm_lockfile).content))
287-
packages = parsed.fetch("packages", {})
288-
289-
# v3/v2 lockfiles use a flat "packages" section
290-
return build_npm_v3_relationships(packages) if packages.is_a?(Hash) && !packages.empty?
291-
292-
# if packages isn't present, attempt a v1 fallback
293-
fetch_npm_v1_lock_relationships(parsed)
294-
end
295-
296-
sig { params(packages: T::Hash[String, T.untyped]).returns(T::Hash[String, T::Array[String]]) }
297-
def build_npm_v3_relationships(packages)
298-
packages.each_with_object({}) do |(path, details), rels|
299-
next if path.empty? # skip root package entry
300-
next unless details.is_a?(Hash)
301-
302-
children = details.fetch("dependencies", {}).keys
303-
next if children.empty?
304-
305-
package_name = details["name"] || path.split("node_modules/").last
306-
version = details["version"]
307-
next if version.nil? || version.to_s.empty?
308-
309-
resolved = resolve_npm_v3_children(packages, path, children)
310-
rels["#{package_name}@#{version}"] = resolved unless resolved.empty?
311-
end
312-
end
313-
314-
sig do
315-
params(
316-
packages: T::Hash[String, T.untyped],
317-
parent_path: String,
318-
children: T::Array[String]
319-
).returns(T::Array[String])
320-
end
321-
def resolve_npm_v3_children(packages, parent_path, children)
322-
children.filter_map do |child_name|
323-
child_details = resolve_npm_child(packages, parent_path, child_name)
324-
next unless child_details
325-
326-
child_version = child_details["version"]
327-
next if child_version.nil? || child_version.to_s.empty?
328-
329-
# Use the "name" field for aliased packages (real name vs path alias)
330-
real_name = child_details["name"] || child_name
331-
"#{real_name}@#{child_version}"
332-
end
333-
end
334-
335-
# Walks up the node_modules tree to resolve a child dependency,
336-
# matching Node.js module resolution behavior.
337-
sig do
338-
params(
339-
packages: T::Hash[String, T.untyped],
340-
parent_path: String,
341-
child_name: String
342-
).returns(T.nilable(T::Hash[String, T.untyped]))
343-
end
344-
def resolve_npm_child(packages, parent_path, child_name)
345-
# First check directly nested under parent
346-
candidate = "#{parent_path}/node_modules/#{child_name}"
347-
return packages[candidate] if packages.key?(candidate)
348-
349-
# Walk up the tree: strip trailing node_modules/pkg segments
350-
segments = parent_path.split("node_modules/")
351-
segments.pop # remove the current package segment
352-
353-
while segments.any?
354-
candidate = "#{segments.join('node_modules/')}node_modules/#{child_name}"
355-
return packages[candidate] if packages.key?(candidate)
356-
357-
segments.pop
358-
end
359-
360-
# Top-level fallback
361-
packages["node_modules/#{child_name}"]
362-
end
363-
364-
sig { params(parsed: T::Hash[String, T.untyped]).returns(T::Hash[String, T::Array[String]]) }
365-
def fetch_npm_v1_lock_relationships(parsed)
366-
dependencies = parsed.fetch("dependencies", {})
367-
return {} unless dependencies.is_a?(Hash)
368-
369-
dependencies.each_with_object({}) do |(name, details), rels|
370-
next unless details.is_a?(Hash)
371-
372-
nested = details.fetch("dependencies", nil)
373-
next unless nested.is_a?(Hash)
374-
375-
version = details["version"]
376-
next if version.nil? || version.to_s.empty?
377-
378-
children = resolve_npm_v1_children(nested)
379-
rels["#{name}@#{version}"] = children unless children.empty?
380-
rels.merge!(fetch_npm_v1_lock_relationships(details))
381-
end
382-
end
383-
384-
sig { params(nested: T::Hash[String, T.untyped]).returns(T::Array[String]) }
385-
def resolve_npm_v1_children(nested)
386-
nested.filter_map do |child_name, child_details|
387-
next unless child_details.is_a?(Hash)
388-
389-
child_version = child_details["version"]
390-
next if child_version.nil? || child_version.to_s.empty?
391-
392-
"#{child_name}@#{child_version}"
393-
end
394-
end
395-
396-
sig { returns(T::Hash[String, T::Array[String]]) }
397-
def fetch_yarn_lock_relationships
398-
parsed = FileParser::YarnLock.new(T.must(yarn_lockfile)).parsed
399-
400-
parsed.each_with_object({}) do |(req, details), rels|
401-
next unless details.is_a?(Hash)
402-
403-
version = details["version"]
404-
parent_name = T.must(req.split(/(?<=\w)\@/).first)
405-
children = details.fetch("dependencies", {})
406-
407-
next if children.nil? || children.empty?
408-
409-
key = "#{parent_name}@#{version}"
410-
resolved_children = resolve_yarn_children(children, parsed)
411-
412-
rels[key] ||= []
413-
rels[key].concat(resolved_children).uniq!
414-
end
415-
end
416-
417-
sig { params(children: T::Hash[String, String], parsed: T::Hash[String, T.untyped]).returns(T::Array[String]) }
418-
def resolve_yarn_children(children, parsed)
419-
children.filter_map do |child_name, child_req|
420-
version = resolve_yarn_child_version(child_name, child_req, parsed)
421-
"#{child_name}@#{version}" if version
422-
end
423-
end
424-
425-
sig { params(child_name: String, child_req: String, parsed: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
426-
def resolve_yarn_child_version(child_name, child_req, parsed)
427-
# Try exact key first
428-
child_entry = parsed["#{child_name}@#{child_req}"]
429-
return child_entry["version"] if child_entry && child_entry["version"]
430-
431-
# Yarn groups multiple requirements into single keys like "foo@^1.0.0, foo@^1.2.0"
432-
target_req = "#{child_name}@#{child_req}"
433-
grouped_match = parsed.find { |k, _| k.split(", ").include?(target_req) }
434-
return grouped_match.last["version"] if grouped_match && grouped_match.last["version"]
435-
436-
# Fallback: find by name only if there's exactly one candidate
437-
candidates = parsed.select { |k, _| k.split(/(?<=\w)\@/).first == child_name }
438-
candidate = candidates.first
439-
candidate.last["version"] if candidates.size == 1 && candidate
440-
end
441-
442-
sig { returns(T::Hash[String, T::Array[String]]) }
443-
def fetch_pnpm_lock_relationships
444-
parsed = YAML.safe_load(T.must(T.must(pnpm_lockfile).content)) || {}
445-
446-
# v9+ uses "snapshots" for resolved dependency details; v6 uses "packages"
447-
entries = parsed.fetch("snapshots", nil) || parsed.fetch("packages", {})
448-
449-
entries.each_with_object({}) do |(key, details), rels|
450-
next unless details.is_a?(Hash)
451-
452-
# Keys are "/name@version" (v6) or "name@version" (v9)
453-
name_version = key.sub(%r{^/}, "")
454-
children = details.fetch("dependencies", {})
455-
456-
next if children.nil? || children.empty?
457-
458-
# Strip any pnpm suffix metadata (e.g., parenthesized peer dep info)
459-
name_version = name_version.sub(/\(.*\)$/, "")
460-
461-
# pnpm dependencies are already resolved: {"name": "version"}
462-
# Strip any peer metadata suffixes like "7.49.0(react@18.2.0)"
463-
resolved_children = children.filter_map do |child_name, child_version|
464-
clean_version = child_version.to_s.sub(/\(.*\)$/, "")
465-
next if clean_version.empty?
466-
467-
"#{child_name}@#{clean_version}"
468-
end
469-
470-
rels[name_version] ||= []
471-
rels[name_version].concat(resolved_children).uniq!
472-
end
473-
end
474-
475256
sig { override.params(_dependency: Dependabot::Dependency).returns(String) }
476257
def purl_pkg_for(_dependency)
477258
"npm"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)