Skip to main content

lib/npm/verify.ex

defmodule NPM.Verify do
  @moduledoc """
  Verifies that installed packages match the lockfile.

  Checks version consistency, integrity hashes, and completeness
  of the node_modules directory against the lockfile.
  """

  @type issue :: %{
          package: String.t(),
          type: :missing | :version_mismatch | :integrity_mismatch | :extraneous,
          expected: String.t() | nil,
          actual: String.t() | nil
        }

  @doc """
  Verifies installed packages against a lockfile.

  Returns a list of issues found.
  """
  @spec check(String.t(), map()) :: [issue()]
  def check(node_modules_dir, lockfile) do
    missing = find_missing(node_modules_dir, lockfile)
    mismatched = find_mismatched(node_modules_dir, lockfile)
    extraneous = find_extraneous(node_modules_dir, lockfile)
    Enum.sort_by(missing ++ mismatched ++ extraneous, & &1.package)
  end

  @doc """
  Checks if the installation is clean (no issues).
  """
  @spec clean?(String.t(), map()) :: boolean()
  def clean?(node_modules_dir, lockfile) do
    check(node_modules_dir, lockfile) == []
  end

  @doc """
  Returns a summary of verification results.
  """
  @spec summary([issue()]) :: %{
          total: non_neg_integer(),
          missing: non_neg_integer(),
          mismatched: non_neg_integer(),
          extraneous: non_neg_integer()
        }
  def summary(issues) do
    %{
      total: length(issues),
      missing: Enum.count(issues, &(&1.type == :missing)),
      mismatched: Enum.count(issues, &(&1.type == :version_mismatch)),
      extraneous: Enum.count(issues, &(&1.type == :extraneous))
    }
  end

  @doc """
  Formats an issue for display.
  """
  @spec format_issue(issue()) :: String.t()
  def format_issue(%{type: :missing} = i), do: "MISSING #{i.package} (expected #{i.expected})"
  def format_issue(%{type: :extraneous} = i), do: "EXTRANEOUS #{i.package}@#{i.actual}"

  def format_issue(%{type: :version_mismatch} = i),
    do: "MISMATCH #{i.package}: expected #{i.expected}, got #{i.actual}"

  defp find_missing(nm_dir, lockfile) do
    Enum.flat_map(lockfile, fn {name, entry} ->
      pkg_dir = resolve_package_dir(nm_dir, name)

      if File.exists?(pkg_dir) do
        []
      else
        [%{package: name, type: :missing, expected: entry.version, actual: nil}]
      end
    end)
  end

  defp find_mismatched(nm_dir, lockfile) do
    Enum.flat_map(lockfile, fn {name, entry} ->
      pkg_dir = resolve_package_dir(nm_dir, name)

      case read_installed_version(pkg_dir) do
        nil ->
          []

        version when version != entry.version ->
          [%{package: name, type: :version_mismatch, expected: entry.version, actual: version}]

        _ ->
          []
      end
    end)
  end

  defp find_extraneous(nm_dir, lockfile) do
    case File.ls(nm_dir) do
      {:ok, entries} ->
        locked_names = MapSet.new(Map.keys(lockfile))

        entries
        |> Enum.reject(&String.starts_with?(&1, "."))
        |> Enum.flat_map(&check_extraneous(nm_dir, &1, locked_names))

      _ ->
        []
    end
  end

  defp check_extraneous(nm_dir, entry, locked_names) do
    if String.starts_with?(entry, "@") do
      check_scoped_extraneous(nm_dir, entry, locked_names)
    else
      check_single_extraneous(nm_dir, entry, locked_names)
    end
  end

  defp check_single_extraneous(nm_dir, name, locked_names) do
    if MapSet.member?(locked_names, name) do
      []
    else
      version = read_installed_version(Path.join(nm_dir, name))
      [%{package: name, type: :extraneous, expected: nil, actual: version}]
    end
  end

  defp check_scoped_extraneous(nm_dir, scope, locked_names) do
    scope_dir = Path.join(nm_dir, scope)

    case File.ls(scope_dir) do
      {:ok, subs} -> Enum.flat_map(subs, &check_scoped_sub(scope_dir, scope, &1, locked_names))
      _ -> []
    end
  end

  defp check_scoped_sub(scope_dir, scope, sub, locked_names) do
    name = "#{scope}/#{sub}"

    if MapSet.member?(locked_names, name) do
      []
    else
      version = read_installed_version(Path.join(scope_dir, sub))
      [%{package: name, type: :extraneous, expected: nil, actual: version}]
    end
  end

  defp resolve_package_dir(nm_dir, name) do
    Path.join(nm_dir, name)
  end

  defp read_installed_version(pkg_dir) do
    case File.read(Path.join(pkg_dir, "package.json")) do
      {:ok, content} -> NPM.JSON.decode!(content)["version"]
      _ -> nil
    end
  rescue
    _ -> nil
  end
end