defmodule NPM.Dependency.Peer.Check do
@moduledoc """
Deep peer dependency compatibility scanner.
Scans all installed packages for unmet, conflicting, or optional
peer dependencies and reports compatibility issues.
"""
alias NPM.Dependency.Peer
@type issue :: %{
package: String.t(),
peer: String.t(),
required: String.t(),
status: :missing | :incompatible | :optional_missing,
installed: String.t() | nil
}
@doc """
Scans installed packages for peer dependency issues.
"""
@spec check(String.t(), map()) :: [issue()]
def check(node_modules_dir, lockfile) do
case File.ls(node_modules_dir) do
{:ok, entries} ->
entries
|> Enum.reject(&String.starts_with?(&1, "."))
|> Enum.flat_map(&check_package(node_modules_dir, &1, lockfile))
|> Enum.sort_by(&{&1.package, &1.peer})
_ ->
[]
end
end
@doc """
Checks a single package's peer dependencies against installed versions.
"""
@spec check_peers(map(), map()) :: [issue()]
def check_peers(pkg_data, lockfile) do
name = pkg_data["name"] || "unknown"
optional = Peer.optional_peers(pkg_data)
pkg_data
|> Peer.extract()
|> Enum.flat_map(fn {peer, range} ->
check_peer(name, peer, range, lockfile, MapSet.member?(optional, peer))
end)
end
@doc """
Returns only issues of a specific status.
"""
@spec filter_by_status([issue()], atom()) :: [issue()]
def filter_by_status(issues, status) do
Enum.filter(issues, &(&1.status == status))
end
@doc """
Formats issues for display.
"""
@spec format_issues([issue()]) :: String.t()
def format_issues([]), do: "All peer dependencies satisfied."
def format_issues(issues) do
Enum.map_join(issues, "\n", &format_issue/1)
end
@doc """
Summary of peer dependency status.
"""
@spec summary([issue()]) :: %{
missing: non_neg_integer(),
incompatible: non_neg_integer(),
optional_missing: non_neg_integer(),
total: non_neg_integer()
}
def summary(issues) do
%{
missing: count_status(issues, :missing),
incompatible: count_status(issues, :incompatible),
optional_missing: count_status(issues, :optional_missing),
total: length(issues)
}
end
defp check_package(nm_dir, entry, lockfile) do
if String.starts_with?(entry, "@") do
check_scoped(nm_dir, entry, lockfile)
else
check_single_package(nm_dir, entry, lockfile)
end
end
defp check_scoped(nm_dir, scope, lockfile) do
scope_dir = Path.join(nm_dir, scope)
case File.ls(scope_dir) do
{:ok, subs} ->
Enum.flat_map(subs, fn sub ->
pkg_dir = Path.join(scope_dir, sub)
read_and_check_peers(pkg_dir, "#{scope}/#{sub}", lockfile)
end)
_ ->
[]
end
end
defp check_single_package(nm_dir, name, lockfile) do
pkg_dir = Path.join(nm_dir, name)
read_and_check_peers(pkg_dir, name, lockfile)
end
defp read_and_check_peers(pkg_dir, name, lockfile) do
pkg_json = Path.join(pkg_dir, "package.json")
case NPM.JSON.read_file(pkg_json) do
{:ok, data} when is_map(data) ->
data |> Map.put("name", name) |> check_peers(lockfile)
_ ->
[]
end
end
defp check_peer(package, peer, range, lockfile, is_optional) do
case Map.get(lockfile, peer) do
nil ->
status = if is_optional, do: :optional_missing, else: :missing
[%{package: package, peer: peer, required: range, status: status, installed: nil}]
%{version: version} ->
if NPMSemver.matches?(version, range) do
[]
else
[
%{
package: package,
peer: peer,
required: range,
status: :incompatible,
installed: version
}
]
end
_ ->
[]
end
rescue
_ -> []
end
defp format_issue(%{status: :missing} = i) do
"✗ #{i.package} requires #{i.peer}@#{i.required} — not installed"
end
defp format_issue(%{status: :incompatible} = i) do
"✗ #{i.package} requires #{i.peer}@#{i.required} — installed #{i.installed}"
end
defp format_issue(%{status: :optional_missing} = i) do
"? #{i.package} optionally requires #{i.peer}@#{i.required} — not installed"
end
defp count_status(issues, status) do
Enum.count(issues, &(&1.status == status))
end
end