22# frozen_string_literal: true
33
44require "json"
5- require "yaml"
65require "sorbet-runtime"
76
87require "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"
0 commit comments