defmodule NPM.Deprecation do
@moduledoc """
Detects and reports deprecated packages in the dependency tree.
Checks the `deprecated` field from package metadata to warn
users about packages that should be replaced.
"""
@type deprecation_entry :: %{
package: String.t(),
version: String.t(),
message: String.t()
}
@doc """
Checks a lockfile against registry metadata for deprecation notices.
Takes a map of `%{package_name => %{deprecated: message | nil, ...}}`.
"""
@spec check(map(), map()) :: [deprecation_entry()]
def check(lockfile, metadata) do
lockfile
|> Enum.flat_map(fn {name, entry} ->
case get_deprecation(name, metadata) do
nil -> []
msg -> [%{package: name, version: entry.version, message: msg}]
end
end)
|> Enum.sort_by(& &1.package)
end
@doc """
Extracts the deprecation message from a package.json data map.
"""
@spec extract(map()) :: String.t() | nil
def extract(%{"deprecated" => msg}) when is_binary(msg) and msg != "", do: msg
def extract(_), do: nil
@doc """
Checks if a package is deprecated.
"""
@spec deprecated?(map()) :: boolean()
def deprecated?(%{"deprecated" => msg}) when is_binary(msg) and msg != "", do: true
def deprecated?(_), do: false
@doc """
Formats a deprecation entry as a warning string.
"""
@spec format_warning(deprecation_entry()) :: String.t()
def format_warning(entry) do
"DEPRECATED #{entry.package}@#{entry.version}: #{entry.message}"
end
@doc """
Scans node_modules for deprecated packages.
"""
@spec scan(String.t()) :: [deprecation_entry()]
def scan(node_modules_dir) do
case File.ls(node_modules_dir) do
{:ok, entries} ->
entries
|> Enum.flat_map(&scan_package(node_modules_dir, &1))
|> Enum.sort_by(& &1.package)
_ ->
[]
end
end
defp get_deprecation(name, metadata) do
case Map.get(metadata, name) do
%{deprecated: msg} when is_binary(msg) and msg != "" -> msg
_ -> nil
end
end
defp scan_package(nm_dir, entry) do
if String.starts_with?(entry, "@") do
scan_scope(nm_dir, entry)
else
read_deprecation(nm_dir, entry)
end
end
defp scan_scope(nm_dir, scope) do
scope_dir = Path.join(nm_dir, scope)
case File.ls(scope_dir) do
{:ok, subs} ->
Enum.flat_map(subs, fn sub ->
read_deprecation(scope_dir, sub, "#{scope}/#{sub}")
end)
_ ->
[]
end
end
defp read_deprecation(parent, name, full_name \\ nil) do
pkg_json = Path.join([parent, name, "package.json"])
full_name = full_name || name
case File.read(pkg_json) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
case extract(data) do
nil -> []
msg -> [%{package: full_name, version: data["version"] || "0.0.0", message: msg}]
end
_ ->
[]
end
rescue
_ -> []
end
end