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