defmodule NPM.Stats do @moduledoc """ Collects statistics about the project's npm dependencies. Provides aggregate insights: package counts, version freshness, dependency types, maintainer diversity, etc. """ @type project_stats :: %{ total_packages: non_neg_integer(), direct_deps: non_neg_integer(), transitive_deps: non_neg_integer(), with_install_scripts: non_neg_integer(), avg_dep_count: float(), max_dep_chain: non_neg_integer(), scoped_packages: non_neg_integer() } @doc """ Computes statistics from a lockfile and root dependencies. """ @spec compute(map(), map()) :: project_stats() def compute(lockfile, root_deps) do direct_names = MapSet.new(Map.keys(root_deps)) all_names = Map.keys(lockfile) dep_counts = Enum.map(lockfile, fn {_name, entry} -> map_size(entry.dependencies) end) %{ total_packages: length(all_names), direct_deps: MapSet.size(direct_names), transitive_deps: length(all_names) - MapSet.size(MapSet.intersection(direct_names, MapSet.new(all_names))), with_install_scripts: 0, avg_dep_count: safe_avg(dep_counts), max_dep_chain: max_chain_depth(lockfile, root_deps), scoped_packages: Enum.count(all_names, &String.starts_with?(&1, "@")) } end @doc """ Computes version distribution across the lockfile. Returns a map of major version buckets. """ @spec version_distribution(map()) :: %{String.t() => non_neg_integer()} def version_distribution(lockfile) do lockfile |> Enum.map(fn {_name, entry} -> major_version(entry.version) end) |> Enum.frequencies() |> Enum.sort_by(&elem(&1, 0)) |> Map.new() end @doc """ Returns the dependency-to-package ratio (how connected the graph is). """ @spec connectivity(map()) :: float() def connectivity(lockfile) when map_size(lockfile) == 0, do: 0.0 def connectivity(lockfile) do total_dep_edges = Enum.reduce(lockfile, 0, fn {_name, entry}, acc -> acc + map_size(entry.dependencies) end) total_dep_edges / map_size(lockfile) end @doc """ Finds the most depended-upon packages (highest fan-in). """ @spec most_depended(map(), non_neg_integer()) :: [{String.t(), non_neg_integer()}] def most_depended(lockfile, n \\ 10) do lockfile |> Enum.flat_map(fn {_name, entry} -> Map.keys(entry.dependencies) end) |> Enum.frequencies() |> Enum.sort_by(&elem(&1, 1), :desc) |> Enum.take(n) end defp safe_avg([]), do: 0.0 defp safe_avg(list), do: Enum.sum(list) / length(list) defp major_version(version) do case String.split(version, ".") do [major | _] -> "#{major}.x" _ -> "other" end end defp max_chain_depth(lockfile, root_deps) do root_deps |> Map.keys() |> Enum.map(&chain_depth(&1, lockfile, MapSet.new())) |> Enum.max(fn -> 0 end) end defp chain_depth(name, lockfile, visited) do cond do MapSet.member?(visited, name) -> 0 not Map.has_key?(lockfile, name) -> 1 true -> 1 + max_child_depth(lockfile[name], lockfile, MapSet.put(visited, name)) end end defp max_child_depth(entry, lockfile, visited) do entry.dependencies |> Map.keys() |> Enum.map(&chain_depth(&1, lockfile, visited)) |> Enum.max(fn -> 0 end) end end