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