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