defmodule NPM.HoistingConflict do
@moduledoc """
Detects hoisting conflicts in the dependency tree.
When multiple packages depend on different versions of the same
package, only one can be hoisted to the top level.
"""
@doc """
Finds packages with conflicting version requirements.
"""
@spec find(map()) :: [map()]
def find(lockfile) do
lockfile
|> collect_requirements()
|> Enum.filter(fn {_name, reqs} -> length(reqs) > 1 end)
|> Enum.map(fn {name, reqs} ->
versions = Enum.map(reqs, & &1.version) |> Enum.uniq()
%{
package: name,
versions: Enum.sort(versions),
required_by: Enum.map(reqs, & &1.required_by) |> Enum.sort(),
conflict: length(versions) > 1
}
end)
|> Enum.filter(& &1.conflict)
|> Enum.sort_by(& &1.package)
end
@doc """
Counts the number of hoisting conflicts.
"""
@spec count(map()) :: non_neg_integer()
def count(lockfile), do: lockfile |> find() |> length()
@doc """
Checks if there are any hoisting conflicts.
"""
@spec conflicts?(map()) :: boolean()
def conflicts?(lockfile), do: count(lockfile) > 0
@doc """
Formats conflict report.
"""
@spec format_report([map()]) :: String.t()
def format_report([]), do: "No hoisting conflicts."
def format_report(conflicts) do
header = "#{length(conflicts)} hoisting conflicts:\n"
details =
Enum.map_join(conflicts, "\n", fn c ->
versions = Enum.join(c.versions, ", ")
by = Enum.join(c.required_by, ", ")
" #{c.package}: #{versions} (required by: #{by})"
end)
header <> details
end
defp collect_requirements(lockfile) do
lockfile
|> Enum.flat_map(fn {parent, entry} ->
entry |> extract_deps() |> Enum.flat_map(&resolve_dep(&1, parent, lockfile))
end)
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
end
defp resolve_dep({dep_name, range}, parent, lockfile) do
case Map.get(lockfile, dep_name) do
%{version: version} -> [{dep_name, %{version: version, required_by: parent, range: range}}]
_ -> []
end
end
defp extract_deps(%{dependencies: deps}) when is_map(deps), do: Map.to_list(deps)
defp extract_deps(%{"dependencies" => deps}) when is_map(deps), do: Map.to_list(deps)
defp extract_deps(_), do: []
end