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