@@ -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