defmodule NPM.Why do @moduledoc """ Explains why a package is installed by tracing through the dependency tree. Implements the `npm why` / `npm explain` functionality. """ @type reason :: %{ path: [String.t()], range: String.t() | nil, direct: boolean() } @doc """ Explains why a package is in the lockfile. Returns a list of dependency paths that lead to the given package. Each path is a list of package names from root to the target. """ @spec explain(String.t(), map(), map()) :: [reason()] def explain(target, lockfile, root_deps) do direct_reasons = direct_reasons(target, root_deps) transitive_reasons = transitive_reasons(target, lockfile, root_deps) direct_reasons ++ transitive_reasons end @doc """ Returns a human-readable explanation string. """ @spec format_reasons([reason()]) :: String.t() def format_reasons([]), do: "Package is not installed." def format_reasons(reasons) do Enum.map_join(reasons, "\n", &format_reason/1) end @doc """ Checks if a package is a direct dependency. """ @spec direct?(String.t(), map()) :: boolean() def direct?(name, root_deps) do Map.has_key?(root_deps, name) end @doc """ Finds all packages that directly depend on the target. """ @spec dependents(String.t(), map()) :: [String.t()] def dependents(target, lockfile) do lockfile |> Enum.filter(fn {_name, entry} -> Map.has_key?(entry.dependencies, target) end) |> Enum.map(&elem(&1, 0)) |> Enum.sort() end defp direct_reasons(target, root_deps) do case Map.get(root_deps, target) do nil -> [] range -> [%{path: [target], range: range, direct: true}] end end defp transitive_reasons(target, lockfile, root_deps) do lockfile |> Enum.filter(fn {_name, entry} -> Map.has_key?(entry.dependencies, target) end) |> Enum.flat_map(fn {parent, entry} -> range = entry.dependencies[target] parent_paths = if Map.has_key?(root_deps, parent) do [[parent, target]] else find_paths_to(parent, lockfile, root_deps, MapSet.new([target])) |> Enum.map(&(&1 ++ [target])) end Enum.map(parent_paths, fn path -> %{path: path, range: range, direct: false} end) end) end defp find_paths_to(target, lockfile, root_deps, visited) do if Map.has_key?(root_deps, target) do [[target]] else lockfile |> Enum.filter(fn {name, entry} -> Map.has_key?(entry.dependencies, target) and not MapSet.member?(visited, name) end) |> Enum.flat_map(fn {parent, _entry} -> find_paths_to(parent, lockfile, root_deps, MapSet.put(visited, parent)) |> Enum.map(&(&1 ++ [target])) end) end end defp format_reason(%{path: path, range: range, direct: true}) do "#{hd(path)}@#{range} (direct dependency)" end defp format_reason(%{path: path, range: range}) do chain = Enum.join(path, " → ") last = path |> Enum.reverse() |> hd() "#{chain} (requires #{last}@#{range})" end end