defmodule NPM.Resolutions do @moduledoc """ Handles yarn-style resolutions in package.json. Resolutions allow pinning specific versions of nested dependencies, similar to npm overrides but following the Yarn convention. """ @type resolution :: %{ pattern: String.t(), version: String.t() } @doc """ Parses resolutions from package.json data. """ @spec parse(map()) :: [resolution()] def parse(%{"resolutions" => resolutions}) when is_map(resolutions) do Enum.map(resolutions, fn {pattern, version} -> %{pattern: pattern, version: version} end) end def parse(_), do: [] @doc """ Checks if a package name matches a resolution pattern. Supports exact match and glob-style `**\/package` patterns. """ @spec matches?(String.t(), String.t()) :: boolean() def matches?(name, pattern) do cond do pattern == name -> true String.starts_with?(pattern, "**/") -> suffix = String.trim_leading(pattern, "**/") name == suffix or String.ends_with?(name, "/#{suffix}") String.contains?(pattern, "/") -> name == extract_package_name(pattern) true -> name == pattern end end @doc """ Finds the resolution version for a package, if any. """ @spec resolve(String.t(), [resolution()]) :: String.t() | nil def resolve(name, resolutions) do case Enum.find(resolutions, &matches?(name, &1.pattern)) do nil -> nil res -> res.version end end @doc """ Applies resolutions to a lockfile. """ @spec apply_resolutions(map(), [resolution()]) :: {map(), non_neg_integer()} def apply_resolutions(lockfile, resolutions) do {new_lockfile, count} = Enum.reduce(lockfile, {%{}, 0}, fn {name, entry}, {acc, c} -> case resolve(name, resolutions) do nil -> {Map.put(acc, name, entry), c} version -> {Map.put(acc, name, %{entry | version: version}), c + 1} end end) {new_lockfile, count} end defp extract_package_name(pattern) do pattern |> String.split("/") |> List.last() end end