Skip to main content

lib/npm/hoisting_conflict.ex

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