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