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