defmodule NPM.Dependency.Freshness do @moduledoc """ Analyzes how up-to-date dependencies are by comparing locked vs latest versions. """ @doc """ Classifies how far behind a locked version is from latest. """ @spec classify(String.t(), String.t()) :: atom() def classify(locked, latest) do case {parse_major(locked), parse_major(latest)} do {l, la} when l == la -> classify_minor(locked, latest) {l, la} when la - l >= 2 -> :very_outdated _ -> :major_behind end end @doc """ Groups packages by freshness level. """ @spec group([{String.t(), String.t(), String.t()}]) :: map() def group(packages) do packages |> Enum.map(fn {name, locked, latest} -> {name, classify(locked, latest)} end) |> Enum.group_by(&elem(&1, 1), &elem(&1, 0)) end @doc """ Computes a freshness score (0-100, higher is fresher). """ @spec score([{String.t(), String.t(), String.t()}]) :: non_neg_integer() def score([]), do: 100 def score(packages) do points = packages |> Enum.map(fn {_, locked, latest} -> classify_score(classify(locked, latest)) end) |> Enum.sum() max_points = length(packages) * 100 round(points / max_points * 100) end @doc """ Formats freshness report. """ @spec format(map()) :: String.t() def format(groups) do groups |> Enum.sort_by(fn {level, _} -> level_order(level) end) |> Enum.map_join("\n", fn {level, names} -> "#{level}: #{length(names)} packages" end) end defp classify_minor(locked, latest) do case {parse_minor(locked), parse_minor(latest)} do {l, la} when l == la -> :current {l, la} when la - l <= 2 -> :slightly_behind _ -> :minor_behind end end defp parse_major(v), do: v |> String.split(".", parts: 2) |> hd() |> String.to_integer() defp parse_minor(v), do: v |> String.split(".") |> Enum.at(1, "0") |> String.to_integer() defp classify_score(:current), do: 100 defp classify_score(:slightly_behind), do: 80 defp classify_score(:minor_behind), do: 60 defp classify_score(:major_behind), do: 30 defp classify_score(:very_outdated), do: 10 defp level_order(:current), do: 0 defp level_order(:slightly_behind), do: 1 defp level_order(:minor_behind), do: 2 defp level_order(:major_behind), do: 3 defp level_order(:very_outdated), do: 4 end