defmodule NPM.Dependency.Conflict do
@moduledoc """
Detects version conflicts between dependency groups.
Finds packages that appear in multiple groups (dependencies,
devDependencies, etc.) with different version ranges.
"""
@dep_fields ~w(dependencies devDependencies peerDependencies optionalDependencies)
@doc """
Finds packages with conflicting ranges across dependency groups.
"""
@spec find(map()) :: [map()]
def find(pkg_data) do
all_entries = collect_entries(pkg_data)
all_entries
|> Enum.group_by(& &1.name)
|> Enum.flat_map(fn {name, entries} ->
ranges = entries |> Enum.map(& &1.range) |> Enum.uniq()
if length(ranges) > 1 do
[%{name: name, entries: entries, ranges: ranges}]
else
[]
end
end)
|> Enum.sort_by(& &1.name)
end
@doc """
Checks if there are any conflicts.
"""
@spec conflicts?(map()) :: boolean()
def conflicts?(pkg_data), do: find(pkg_data) != []
@doc """
Counts conflicts.
"""
@spec count(map()) :: non_neg_integer()
def count(pkg_data), do: find(pkg_data) |> length()
@doc """
Finds packages that appear in multiple groups (even with same range).
"""
@spec duplicated(map()) :: [%{name: String.t(), groups: [String.t()]}]
def duplicated(pkg_data) do
all_entries = collect_entries(pkg_data)
all_entries
|> Enum.group_by(& &1.name)
|> Enum.filter(fn {_name, entries} -> match?([_, _ | _], entries) end)
|> Enum.map(fn {name, entries} -> %{name: name, groups: Enum.map(entries, & &1.group)} end)
|> Enum.sort_by(& &1.name)
end
@doc """
Formats conflict report.
"""
@spec format([map()]) :: String.t()
def format([]), do: "No version conflicts."
def format(conflicts) do
Enum.map_join(conflicts, "\n", fn c ->
ranges = Enum.map_join(c.entries, ", ", fn e -> "#{e.group}: #{e.range}" end)
"#{c.name}: #{ranges}"
end)
end
defp collect_entries(pkg_data) do
Enum.flat_map(@dep_fields, &entries_for_field(pkg_data, &1))
end
defp entries_for_field(pkg_data, field) do
case Map.get(pkg_data, field) do
deps when is_map(deps) ->
Enum.map(deps, fn {name, range} -> %{name: name, range: range, group: field} end)
_ ->
[]
end
end
end