Skip to main content

lib/npm/dependency/graph.ex

defmodule NPM.Dependency.Graph do
  @moduledoc """
  Dependency graph operations on the lockfile.

  Provides adjacency-list based graph algorithms for analyzing
  the dependency structure: detecting cycles, computing fan-in/out,
  and finding orphans.
  """

  @doc """
  Build an adjacency list from the lockfile.

  Returns `%{name => [dep_name, ...]}`.
  """
  @spec adjacency_list(%{String.t() => NPM.Lockfile.entry()}) :: %{String.t() => [String.t()]}
  def adjacency_list(lockfile) do
    Map.new(lockfile, fn {name, entry} ->
      {name, Map.keys(entry.dependencies) |> Enum.sort()}
    end)
  end

  @doc """
  Compute fan-out (number of dependencies) for each package.
  """
  @spec fan_out(%{String.t() => [String.t()]}) :: %{String.t() => non_neg_integer()}
  def fan_out(adj) do
    Map.new(adj, fn {name, deps} -> {name, length(deps)} end)
  end

  @doc """
  Compute fan-in (number of dependents) for each package.
  """
  @spec fan_in(%{String.t() => [String.t()]}) :: %{String.t() => non_neg_integer()}
  def fan_in(adj) do
    all_names = Map.keys(adj)
    base = Map.new(all_names, &{&1, 0})

    Enum.reduce(adj, base, fn {_name, deps}, acc ->
      Enum.reduce(deps, acc, fn dep, inner_acc ->
        Map.update(inner_acc, dep, 1, &(&1 + 1))
      end)
    end)
  end

  @doc """
  Find leaf packages (no dependencies).
  """
  @spec leaves(%{String.t() => [String.t()]}) :: [String.t()]
  def leaves(adj) do
    adj
    |> Enum.filter(fn {_, deps} -> deps == [] end)
    |> Enum.map(&elem(&1, 0))
    |> Enum.sort()
  end

  @doc """
  Find root packages (not depended on by any other package).
  """
  @spec roots(%{String.t() => [String.t()]}) :: [String.t()]
  def roots(adj) do
    all_deps = adj |> Map.values() |> List.flatten() |> MapSet.new()
    adj |> Map.keys() |> Enum.reject(&MapSet.member?(all_deps, &1)) |> Enum.sort()
  end

  @doc """
  Detect circular dependencies. Returns list of cycle paths.

  Uses Erlang's `:digraph_utils` for reliable cycle detection.
  """
  @spec cycles(%{String.t() => [String.t()]}) :: [[String.t()]]
  def cycles(adj) do
    g = :digraph.new()

    try do
      Enum.each(adj, fn {name, _} -> :digraph.add_vertex(g, name) end)

      Enum.each(adj, fn {name, deps} ->
        Enum.each(deps, fn dep ->
          :digraph.add_vertex(g, dep)
          :digraph.add_edge(g, name, dep)
        end)
      end)

      components = :digraph_utils.cyclic_strong_components(g)

      components
      |> Enum.filter(fn component ->
        length(component) > 1 or self_loop?(g, component)
      end)
      |> Enum.map(&Enum.sort/1)
      |> Enum.uniq()
    after
      :digraph.delete(g)
    end
  end

  @doc """
  Compute the transitive closure — all reachable packages from a root.
  """
  @spec transitive_deps(%{String.t() => [String.t()]}, String.t()) :: MapSet.t(String.t())
  def transitive_deps(adj, root) do
    walk(adj, [root], MapSet.new())
  end

  @doc """
  Find the shortest path between two packages.
  """
  @spec shortest_path(%{String.t() => [String.t()]}, String.t(), String.t()) :: [String.t()] | nil
  def shortest_path(adj, from, to) do
    bfs(adj, [{[from]}], MapSet.new([from]), to)
  end

  @doc """
  Compute the maximum dependency depth from a package.
  """
  @spec max_depth(%{String.t() => [String.t()]}, String.t()) :: non_neg_integer()
  def max_depth(adj, root) do
    depth(adj, root, MapSet.new())
  end

  @doc """
  Compute impact score — how many packages transitively depend on a package.
  """
  @spec impact(%{String.t() => [String.t()]}, String.t()) :: non_neg_integer()
  def impact(adj, package) do
    reverse = reverse(adj)

    if Map.has_key?(reverse, package) do
      reverse |> transitive_deps(package) |> MapSet.size()
    else
      0
    end
  end

  @doc """
  Reverse the graph so all edges point from dependencies to dependents.
  """
  @spec reverse(%{String.t() => [String.t()]}) :: %{String.t() => [String.t()]}
  def reverse(adj) do
    base = Map.new(Map.keys(adj), &{&1, []})

    Enum.reduce(adj, base, fn {name, deps}, acc ->
      Enum.reduce(deps, acc, fn dep, inner ->
        Map.update(inner, dep, [name], &[name | &1])
      end)
    end)
  end

  defp walk(_adj, [], visited), do: visited

  defp walk(adj, [node | rest], visited) do
    deps = Map.get(adj, node, [])
    new_deps = Enum.reject(deps, &MapSet.member?(visited, &1))
    walk(adj, new_deps ++ rest, MapSet.union(visited, MapSet.new(new_deps)))
  end

  defp bfs(_adj, [], _visited, _target), do: nil

  defp bfs(adj, [{path} | rest], visited, target) do
    current = hd(path)

    if current == target do
      Enum.reverse(path)
    else
      neighbors = Map.get(adj, current, [])

      new_paths =
        for neighbor <- neighbors, not MapSet.member?(visited, neighbor), do: {[neighbor | path]}

      new_visited = MapSet.union(visited, MapSet.new(Enum.map(neighbors, & &1)))
      bfs(adj, rest ++ new_paths, new_visited, target)
    end
  end

  defp depth(adj, node, visited) do
    if MapSet.member?(visited, node) do
      0
    else
      deps = Map.get(adj, node, [])
      new_visited = MapSet.put(visited, node)

      case deps do
        [] -> 0
        _ -> 1 + (deps |> Enum.map(&depth(adj, &1, new_visited)) |> Enum.max())
      end
    end
  end

  defp self_loop?(g, [v]) do
    Enum.any?(:digraph.out_edges(g, v), fn edge ->
      {_, ^v, ^v, _} = :digraph.edge(g, edge)
      true
    end)
  rescue
    _ -> false
  end

  defp self_loop?(_, _), do: false
end