Skip to main content

lib/npm/dependency/peer.ex

defmodule NPM.Dependency.Peer do
  @moduledoc """
  Analyzes and validates peer dependency declarations.

  Peer dependencies declare that a package expects the consumer to provide a
  compatible version of another package. This module extracts peer dependency
  metadata from package manifests, distinguishes required and optional peers,
  checks lockfiles for satisfaction, and formats peer warnings for reports.
  """

  @type warning :: %{
          package: String.t(),
          peer: String.t(),
          required: String.t(),
          found: String.t() | nil,
          satisfied: boolean()
        }

  @doc "Extract peer dependencies from package manifest data."
  @spec extract(map()) :: map()
  def extract(%{"peerDependencies" => peers}) when is_map(peers), do: peers
  def extract(_), do: %{}

  @doc "Extract raw `peerDependenciesMeta` data from package manifest data."
  @spec meta(map()) :: map()
  def meta(%{"peerDependenciesMeta" => meta}) when is_map(meta), do: meta
  def meta(_), do: %{}

  @doc "Return the peer dependency names marked as optional."
  @spec optional_peers(map()) :: MapSet.t()
  def optional_peers(data) do
    data
    |> meta()
    |> Enum.filter(fn {_name, opts} -> is_map(opts) and opts["optional"] == true end)
    |> Enum.map(&elem(&1, 0))
    |> MapSet.new()
  end

  @doc "Check if a peer dependency is marked as optional."
  @spec optional?(String.t(), map()) :: boolean()
  def optional?(name, data), do: MapSet.member?(optional_peers(data), name)

  @doc "Return required, non-optional peer dependencies."
  @spec required(map()) :: map()
  def required(data) do
    optional = optional_peers(data)

    data
    |> extract()
    |> Map.reject(fn {name, _range} -> MapSet.member?(optional, name) end)
  end

  @doc "Check if all required peers are satisfied by a lockfile."
  @spec satisfied?(map(), map()) :: boolean()
  def satisfied?(data, lockfile) do
    data
    |> required()
    |> Enum.all?(fn {name, range} -> peer_satisfied?(lockfile, name, range) end)
  end

  @doc "List required peer dependencies not satisfied by a lockfile."
  @spec unsatisfied(map(), map()) :: [{String.t(), String.t(), String.t() | nil}]
  def unsatisfied(data, lockfile) do
    data
    |> required()
    |> Enum.flat_map(&check_peer(&1, lockfile))
    |> Enum.sort_by(&elem(&1, 0))
  end

  @doc "Count total peer dependency declarations across packages."
  @spec count_across([map()]) :: non_neg_integer()
  def count_across(packages) do
    packages |> Enum.map(&(extract(&1) |> map_size())) |> Enum.sum()
  end

  @doc "Check peer dependency satisfaction for a list of package manifests."
  @spec check(map(), [map()]) :: [warning()]
  def check(lockfile, manifests) when is_map(lockfile) and is_list(manifests) do
    Enum.flat_map(manifests, &check_manifest(lockfile, &1))
  end

  def check(_, _), do: []

  @doc "Summarize peer dependency warnings for display."
  @spec summary([warning()]) :: %{
          satisfied: non_neg_integer(),
          missing: non_neg_integer(),
          incompatible: non_neg_integer()
        }
  def summary(warnings) do
    {missing, incompatible} =
      Enum.reduce(warnings, {0, 0}, fn warning, {missing, incompatible} ->
        if warning.found == nil,
          do: {missing + 1, incompatible},
          else: {missing, incompatible + 1}
      end)

    %{satisfied: 0, missing: missing, incompatible: incompatible}
  end

  @doc "Format peer dependency warnings as human-readable strings."
  @spec format_warnings([warning()]) :: [String.t()]
  def format_warnings(warnings) do
    Enum.map(warnings, fn warning ->
      case warning.found do
        nil ->
          "#{warning.package} requires #{warning.peer}@#{warning.required} but it is not installed"

        version ->
          "#{warning.package} requires #{warning.peer}@#{warning.required} but found #{version}"
      end
    end)
  end

  defp check_manifest(lockfile, manifest) do
    package_name = manifest["name"] || "unknown"
    optional = optional_peers(manifest)

    manifest
    |> extract()
    |> Enum.reject(fn {name, range} -> peer_ok?(lockfile, name, range, optional) end)
    |> Enum.map(fn {peer_name, required_range} ->
      %{
        package: package_name,
        peer: peer_name,
        required: required_range,
        found: installed_version(lockfile, peer_name),
        satisfied: false
      }
    end)
  end

  defp peer_ok?(lockfile, name, range, optional) do
    MapSet.member?(optional, name) or peer_satisfied?(lockfile, name, range)
  end

  defp check_peer({name, range}, lockfile) do
    case installed_version(lockfile, name) do
      nil -> [{name, range, nil}]
      version -> if version_satisfies?(version, range), do: [], else: [{name, range, version}]
    end
  end

  defp peer_satisfied?(lockfile, name, range) do
    case installed_version(lockfile, name) do
      nil -> false
      version -> version_satisfies?(version, range)
    end
  end

  defp installed_version(lockfile, name) do
    case Map.get(lockfile, name) do
      %{version: version} -> version
      _ -> nil
    end
  end

  defp version_satisfies?(version, range) do
    NPMSemver.matches?(version, range)
  rescue
    ArgumentError -> false
  end
end