Skip to main content

lib/npm/dependency/phantom.ex

defmodule NPM.Dependency.Phantom do
  @moduledoc """
  Detects phantom (undeclared) dependencies.

  A phantom dependency is one that can be required in code but is not
  declared in package.json — it only works because another package
  depends on it and it gets hoisted.
  """

  @doc """
  Finds packages in node_modules that aren't declared in package.json.
  """
  @spec detect(map(), map()) :: [String.t()]
  def detect(pkg_data, lockfile) do
    declared = declared_deps(pkg_data)

    lockfile
    |> Map.keys()
    |> Enum.reject(&MapSet.member?(declared, &1))
    |> Enum.sort()
  end

  @doc """
  Counts phantom dependencies.
  """
  @spec count(map(), map()) :: non_neg_integer()
  def count(pkg_data, lockfile), do: detect(pkg_data, lockfile) |> length()

  @doc """
  Checks if a specific package is a phantom dependency.
  """
  @spec phantom?(String.t(), map()) :: boolean()
  def phantom?(name, pkg_data) do
    not MapSet.member?(declared_deps(pkg_data), name)
  end

  @doc """
  Formats a report of phantom dependencies.
  """
  @spec format_report([String.t()]) :: String.t()
  def format_report([]), do: "No phantom dependencies detected."

  def format_report(phantoms) do
    header = "#{length(phantoms)} phantom dependencies (undeclared but available):\n"
    list = Enum.map_join(phantoms, "\n", &"  #{&1}")
    header <> list
  end

  defp declared_deps(pkg_data) do
    ~w(dependencies devDependencies optionalDependencies peerDependencies)
    |> Enum.flat_map(fn field ->
      case Map.get(pkg_data, field) do
        deps when is_map(deps) -> Map.keys(deps)
        _ -> []
      end
    end)
    |> MapSet.new()
  end
end