Skip to main content

lib/npm/lockfile/check.ex

defmodule NPM.Lockfile.Check do
  @moduledoc """
  Validates lockfile consistency against package.json dependencies.
  """

  @doc """
  Checks if all package.json dependencies are in the lockfile.
  """
  @spec missing(map(), map()) :: [String.t()]
  def missing(pkg_data, lockfile) do
    all_deps(pkg_data)
    |> Enum.reject(fn {name, _} -> Map.has_key?(lockfile, name) end)
    |> Enum.map(&elem(&1, 0))
    |> Enum.sort()
  end

  @doc """
  Checks if the lockfile has packages not in package.json dependencies.
  """
  @spec extraneous(map(), map()) :: [String.t()]
  def extraneous(pkg_data, lockfile) do
    dep_names = all_deps(pkg_data) |> Enum.map(&elem(&1, 0)) |> MapSet.new()

    lockfile
    |> Map.keys()
    |> Enum.reject(&MapSet.member?(dep_names, &1))
    |> Enum.sort()
  end

  @doc """
  Checks if locked versions satisfy the declared ranges.
  """
  @spec mismatched(map(), map()) :: [{String.t(), String.t(), String.t()}]
  def mismatched(pkg_data, lockfile) do
    all_deps(pkg_data)
    |> Enum.flat_map(&check_version_match(&1, lockfile))
    |> Enum.sort_by(&elem(&1, 0))
  end

  defp check_version_match({name, range}, lockfile) do
    case Map.get(lockfile, name) do
      %{version: version} ->
        if NPMSemver.matches?(version, range), do: [], else: [{name, range, version}]

      _ ->
        []
    end
  end

  @doc """
  Runs all consistency checks.
  """
  @spec check(map(), map()) :: map()
  def check(pkg_data, lockfile) do
    missing_deps = missing(pkg_data, lockfile)
    extra = extraneous(pkg_data, lockfile)
    mismatches = mismatched(pkg_data, lockfile)

    %{
      valid: missing_deps == [] and mismatches == [],
      missing: missing_deps,
      extraneous: extra,
      mismatched: mismatches
    }
  end

  @doc """
  Formats check results for display.
  """
  @spec format_results(map()) :: String.t()
  def format_results(%{valid: true, extraneous: []}), do: "Lockfile is consistent."

  def format_results(%{valid: true, extraneous: extra}),
    do: "#{length(extra)} extraneous packages in lockfile."

  def format_results(result) do
    parts = []

    parts =
      if result.missing != [],
        do: ["Missing: #{Enum.join(result.missing, ", ")}" | parts],
        else: parts

    parts =
      if result.extraneous != [],
        do: ["Extraneous: #{Enum.join(result.extraneous, ", ")}" | parts],
        else: parts

    parts =
      if result.mismatched != [] do
        mismatches =
          Enum.map_join(result.mismatched, ", ", fn {name, range, ver} ->
            "#{name} (want #{range}, have #{ver})"
          end)

        ["Mismatched: #{mismatches}" | parts]
      else
        parts
      end

    Enum.reverse(parts) |> Enum.join("\n")
  end

  defp all_deps(pkg_data) do
    dep_fields = ~w(dependencies devDependencies optionalDependencies)

    Enum.flat_map(dep_fields, fn field ->
      case Map.get(pkg_data, field) do
        deps when is_map(deps) -> Map.to_list(deps)
        _ -> []
      end
    end)
    |> Enum.uniq_by(&elem(&1, 0))
  end
end