Skip to main content

lib/npm/overrides.ex

defmodule NPM.Overrides do
  @moduledoc """
  Handles npm overrides for forcing specific package versions.

  npm overrides allow replacing versions of transitive dependencies,
  useful for security patches and compatibility fixes.
  """

  @type override :: %{
          package: String.t(),
          version: String.t(),
          parent: String.t() | nil
        }

  @doc """
  Parses overrides from package.json data.

  Supports both flat overrides `{"pkg": "version"}` and
  nested overrides `{"parent": {"pkg": "version"}}`.
  """
  @spec parse(map()) :: [override()]
  def parse(%{"overrides" => overrides}) when is_map(overrides) do
    Enum.flat_map(overrides, &parse_entry/1)
  end

  def parse(_), do: []

  @doc """
  Applies overrides to a lockfile, replacing matched versions.

  Returns the modified lockfile and a list of applied overrides.
  """
  @spec apply_overrides(map(), [override()]) :: {map(), [map()]}
  def apply_overrides(lockfile, overrides) do
    Enum.reduce(overrides, {lockfile, []}, fn override, {lf, applied} ->
      case apply_single(lf, override) do
        {:applied, new_lf, info} -> {new_lf, [info | applied]}
        :noop -> {lf, applied}
      end
    end)
  end

  @doc """
  Finds which overrides would affect the current lockfile.
  """
  @spec matching(map(), [override()]) :: [override()]
  def matching(lockfile, overrides) do
    Enum.filter(overrides, fn override ->
      Map.has_key?(lockfile, override.package)
    end)
  end

  @doc """
  Validates overrides — checks if specified versions are valid semver.
  """
  @spec validate([override()]) :: {:ok, [override()]} | {:error, [String.t()]}
  def validate(overrides) do
    errors =
      overrides
      |> Enum.reject(fn o -> valid_version_spec?(o.version) end)
      |> Enum.map(fn o -> "Invalid version for #{o.package}: #{o.version}" end)

    if errors == [], do: {:ok, overrides}, else: {:error, errors}
  end

  @doc """
  Formats an override for display.
  """
  @spec format_override(override()) :: String.t()
  def format_override(%{parent: nil} = o), do: "#{o.package}#{o.version}"
  def format_override(o), do: "#{o.parent} > #{o.package}#{o.version}"

  defp parse_entry({package, version}) when is_binary(version) do
    [%{package: package, version: version, parent: nil}]
  end

  defp parse_entry({parent, nested}) when is_map(nested) do
    Enum.map(nested, fn {package, version} ->
      %{package: package, version: version, parent: parent}
    end)
  end

  defp parse_entry(_), do: []

  defp apply_single(lockfile, %{package: name, version: version, parent: nil}) do
    case Map.get(lockfile, name) do
      nil ->
        :noop

      entry ->
        new_entry = %{entry | version: version}
        info = %{package: name, from: entry.version, to: version}
        {:applied, Map.put(lockfile, name, new_entry), info}
    end
  end

  defp apply_single(_lockfile, _scoped_override), do: :noop

  defp valid_version_spec?(spec) do
    String.match?(spec, ~r/^\d+\.\d+\.\d+/) or
      String.starts_with?(spec, "^") or
      String.starts_with?(spec, "~") or
      String.starts_with?(spec, ">=") or
      spec == "*"
  end
end