Skip to main content

lib/npm/lockfile/shrinkwrap.ex

defmodule NPM.Lockfile.Shrinkwrap do
  @moduledoc """
  Implements npm shrinkwrap lockfile freezing.

  Creates a `npm-shrinkwrap.json` that locks the entire dependency tree,
  including transitive dependencies. Unlike `package-lock.json`, shrinkwrap
  files are published with the package.
  """

  @shrinkwrap_file "npm-shrinkwrap.json"
  @lockfile "package-lock.json"

  @doc """
  Creates a shrinkwrap file from the current lockfile.
  """
  @spec create(String.t()) :: :ok | {:error, term()}
  def create(project_dir \\ ".") do
    lock_path = Path.join(project_dir, @lockfile)
    shrink_path = Path.join(project_dir, @shrinkwrap_file)

    case File.read(lock_path) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)
        shrinkwrap = Map.put(data, "lockfileVersion", Map.get(data, "lockfileVersion", 3))
        File.write(shrink_path, :json.encode(shrinkwrap))

      {:error, reason} ->
        {:error, {:no_lockfile, reason}}
    end
  end

  @doc """
  Checks if a shrinkwrap file exists.
  """
  @spec exists?(String.t()) :: boolean()
  def exists?(project_dir \\ ".") do
    project_dir |> Path.join(@shrinkwrap_file) |> File.exists?()
  end

  @doc """
  Reads and parses the shrinkwrap file.
  """
  @spec read(String.t()) :: {:ok, map()} | {:error, term()}
  def read(project_dir \\ ".") do
    path = Path.join(project_dir, @shrinkwrap_file)

    case File.read(path) do
      {:ok, content} -> {:ok, NPM.JSON.decode!(content)}
      error -> error
    end
  end

  @doc """
  Verifies that installed packages match the shrinkwrap exactly.
  Returns a list of mismatches.
  """
  @spec verify(map(), map()) :: [mismatch()]
  def verify(shrinkwrap_deps, installed) do
    Enum.flat_map(shrinkwrap_deps, fn {name, expected_version} ->
      case Map.get(installed, name) do
        nil ->
          [%{name: name, expected: expected_version, actual: nil, type: :missing}]

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

        _ ->
          []
      end
    end)
    |> Enum.sort_by(& &1.name)
  end

  @typep mismatch :: %{
           name: String.t(),
           expected: String.t(),
           actual: String.t() | nil,
           type: :missing | :version_mismatch
         }

  @doc """
  Checks if the shrinkwrap is outdated compared to the lockfile.
  """
  @spec outdated?(String.t()) :: boolean()
  def outdated?(project_dir \\ ".") do
    lock_path = Path.join(project_dir, @lockfile)
    shrink_path = Path.join(project_dir, @shrinkwrap_file)

    with {:ok, lock_content} <- File.read(lock_path),
         {:ok, shrink_content} <- File.read(shrink_path) do
      lock_data = NPM.JSON.decode!(lock_content)
      shrink_data = NPM.JSON.decode!(shrink_content)

      lock_packages = lock_data["packages"] || lock_data["dependencies"] || %{}
      shrink_packages = shrink_data["packages"] || shrink_data["dependencies"] || %{}

      lock_packages != shrink_packages
    else
      _ -> true
    end
  end

  @doc """
  Removes the shrinkwrap file.
  """
  @spec remove(String.t()) :: :ok | {:error, term()}
  def remove(project_dir \\ ".") do
    path = Path.join(project_dir, @shrinkwrap_file)

    case File.rm(path) do
      :ok -> :ok
      {:error, :enoent} -> :ok
      error -> error
    end
  end
end