defmodule NPM.Resolver do
alias NPM.Security.ExoticDeps
alias NPM.Security.RegistryPolicy
@moduledoc """
`HexSolver.Registry` implementation for npm packages.
Bridges the npm registry to hex_solver's PubGrub dependency resolver.
Packuments are cached in an ETS table for the duration of a resolution.
"""
@behaviour HexSolver.Registry
defmodule PrefetchError do
@moduledoc "Raised when resolver prefetching fails before PubGrub can run."
defexception [:reason]
@impl true
def message(%__MODULE__{reason: reason}) do
"npm dependency prefetch failed: #{NPM.Resolver.format_prefetch_reason(reason)}"
end
end
@table :npm_resolver_cache
@max_nesting_depth 128
@max_prefetch_depth 10
@prefetch_concurrency 16
@fetch_timeout 30_000
@doc """
Resolve a set of root dependencies to exact versions.
Uses a two-phase approach:
1. Try flat resolution with PubGrub
2. On conflict, identify conflicting packages and retry with
those excluded, tracking them as nested dependencies
Returns `{:ok, %{name => version}}` where the map includes
a `:nested` key with nested package info when version conflicts exist.
"""
@spec resolve(%{String.t() => String.t()}, keyword()) ::
{:ok, %{String.t() => String.t()}} | {:error, String.t()}
def resolve(root_deps, opts \\ [])
def resolve(root_deps, _opts) when map_size(root_deps) == 0, do: {:ok, %{}}
def resolve(root_deps, opts) do
ensure_cache()
overrides = Keyword.get(opts, :overrides, %{})
if overrides != %{}, do: store_overrides(overrides)
resolve_with_nesting(root_deps, MapSet.new(), %{}, 0)
rescue
error in [ExoticDeps.Error, PrefetchError, RegistryPolicy.Error] ->
{:error, Exception.message(error)}
end
defp store_overrides(overrides) do
:ets.insert(@table, {:__overrides__, overrides})
end
defp get_overrides do
case :ets.lookup(@table, :__overrides__) do
[{_, overrides}] -> overrides
[] -> %{}
end
end
defp resolve_with_nesting(_root_deps, _excluded, _nested, depth)
when depth > @max_nesting_depth do
{:error, "Too many resolution retries — deeply conflicting dependencies"}
end
defp resolve_with_nesting(root_deps, excluded, nested, depth) do
case prefetch_tree(Map.keys(root_deps)) do
:ok ->
dependencies = build_dependencies(root_deps)
case run_solver(dependencies, excluded) do
{:ok, result} ->
final = if nested == %{}, do: result, else: Map.put(result, :nested, nested)
{:ok, final}
{:error, message} ->
conflict_packages =
message
|> extract_conflict_packages()
|> Enum.reject(&MapSet.member?(excluded, &1))
if conflict_packages != [] do
new_nested = collect_nested_versions(conflict_packages)
merged_nested = Map.merge(nested, new_nested)
new_excluded = Enum.reduce(conflict_packages, excluded, &MapSet.put(&2, &1))
resolve_with_nesting(root_deps, new_excluded, merged_nested, depth + 1)
else
{:error, message}
end
end
{:error, reason} ->
{:error, prefetch_error_message(reason)}
end
end
defp build_dependencies(root_deps) do
Enum.map(root_deps, fn {name, range} ->
{:ok, constraint} = normalize_range(range)
%{
repo: nil,
name: name,
constraint: constraint,
optional: false,
label: name,
dependencies: []
}
end)
end
defp run_solver(dependencies, excluded) do
ensure_cache()
put_excluded(excluded)
# Remove excluded packages from the version cache to prevent stale data
if MapSet.size(excluded) > 0, do: strip_excluded_from_cache(excluded)
case HexSolver.run(__MODULE__, dependencies, [], []) do
{:ok, solution} ->
result =
for {name, {version, _repo}} <- solution, into: %{}, do: {name, to_string(version)}
{:ok, result}
{:error, message} ->
{:error, message}
end
end
defp put_excluded(excluded) do
ensure_cache()
:ets.insert(@table, {:__excluded_packages__, excluded})
end
defp get_excluded do
case :ets.lookup(@table, :__excluded_packages__) do
[{_, excluded}] -> excluded
[] -> MapSet.new()
end
end
defp strip_excluded_from_cache(excluded) do
# Delete excluded packages entirely from the cache
Enum.each(excluded, &:ets.delete(@table, &1))
# Strip excluded deps from all cached packuments (skip non-packument entries)
:ets.foldl(
fn
{_name, %{versions: _} = packument} = entry, acc ->
stripped = strip_deps(packument, excluded)
if stripped != packument, do: :ets.insert(@table, put_elem(entry, 1, stripped))
acc
_, acc ->
acc
end,
:ok,
@table
)
end
defp strip_deps(packument, excluded) do
versions =
Map.new(packument.versions, fn {ver, info} ->
deps = Map.drop(info.dependencies, MapSet.to_list(excluded))
{ver, %{info | dependencies: deps}}
end)
%{packument | versions: versions}
end
@doc false
def extract_conflict_package(message) do
message
|> extract_conflict_packages()
|> List.first()
end
@doc false
def extract_conflict_packages(message) do
[
extract_repeated_dependency_targets(message),
extract_dependency_pair_conflicts(message),
extract_exact_version_conflicts(message)
]
|> List.flatten()
|> Enum.uniq()
end
defp extract_repeated_dependency_targets(message) do
message
|> dependency_target_names()
|> repeated_package_names()
end
defp extract_dependency_pair_conflicts(message) do
~r/depends on "([^"]+)" and "[^"]+" depends on "([^"]+)"/
|> Regex.scan(message)
|> Enum.flat_map(fn [_, left, right] ->
with {:ok, left_name} <- package_name_from_term(left),
{:ok, right_name} <- package_name_from_term(right),
true <- left_name == right_name do
[left_name]
else
_ -> []
end
end)
end
defp extract_exact_version_conflicts(message) do
# Look for patterns like: "ms 2.0.0" and "ms 2.1.3" in the error
case Regex.scan(~r/"(\S+) (\d+\.\d+\.\d+)"/, message) do
[_, _ | _] = matches ->
names = Enum.map(matches, fn [_, name, _] -> name end)
repeated_package_names(names)
_ ->
[]
end
end
defp dependency_target_names(message) do
~r/depends on "([^"]+)"/
|> Regex.scan(message)
|> Enum.map(fn [_, term] -> package_name_from_term(term) end)
|> Enum.flat_map(fn
{:ok, name} -> [name]
:error -> []
end)
end
defp package_name_from_term(term) do
case Regex.run(~r/^(@[^\/\s]+\/[^\s]+|[^\s]+)\s+.+$/, term) do
[_, name] -> {:ok, name}
_ -> :error
end
end
defp repeated_package_names(names) do
names
|> Enum.frequencies()
|> Enum.filter(fn {_, count} -> count >= 2 end)
|> Enum.map(&elem(&1, 0))
end
@doc false
def get_original_deps(package) do
ensure_cache()
case :ets.lookup(@table, {:__original_deps__, package}) do
[{_, deps}] -> deps
[] -> %{}
end
end
defp collect_nested_versions(packages) do
# Before stripping, save the original dependency data
# so the linker can look up which parents need which version
Map.new(packages, fn package ->
save_original_deps(package)
{package, :nested}
end)
end
defp save_original_deps(package) do
parent_deps =
:ets.foldl(
fn
{name, %{versions: _} = packument}, acc ->
find_dependents(name, packument, package, acc)
_, acc ->
acc
end,
%{},
@table
)
:ets.insert(@table, {{:__original_deps__, package}, parent_deps})
end
defp find_dependents(name, %{versions: versions}, package, acc) do
Enum.reduce(versions, acc, fn {ver, info}, inner_acc ->
case info |> version_dependencies() |> Map.get(package) do
nil -> inner_acc
range -> Map.put(inner_acc, "#{name}@#{ver}", range)
end
end)
end
defp find_dependents(_name, _packument, _package, acc), do: acc
defp version_dependencies(%{dependencies: deps}) when is_map(deps), do: deps
defp version_dependencies(%{"dependencies" => deps}) when is_map(deps), do: deps
defp version_dependencies(_info), do: %{}
@doc "Clear the packument cache."
@spec clear_cache() :: :ok
def clear_cache do
if :ets.info(@table) != :undefined, do: :ets.delete_all_objects(@table)
:ok
end
# --- HexSolver.Registry callbacks ---
@impl true
def versions(_repo, package) do
case get_cached_packument(package) do
{:ok, packument} -> {:ok, parse_sorted_versions(packument)}
{:error, _} -> :error
end
end
@impl true
def dependencies(_repo, package, version) do
case get_cached_packument(package) do
{:ok, packument} -> deps_for_version(package, packument, to_string(version))
{:error, _} -> :error
end
end
@impl true
def prefetch(packages) do
packages
|> Enum.map(fn {_repo, name} -> name end)
|> Enum.reject(&cached?/1)
|> prefetch_packages()
|> case do
:ok -> :ok
{:error, reason} -> raise PrefetchError, reason: reason
end
end
# --- Helpers ---
@doc false
def collect_prefetch_results(results) do
Enum.reduce_while(results, :ok, fn
{:ok, {:ok, _packument}}, :ok ->
{:cont, :ok}
{:ok, {:error, reason}}, :ok ->
{:halt, {:error, reason}}
{:exit, reason}, :ok ->
{:halt, {:error, reason}}
end)
end
@doc false
def format_prefetch_reason({%{__exception__: true} = error, _stack}) do
Exception.message(error)
end
def format_prefetch_reason(%{__exception__: true} = error), do: Exception.message(error)
def format_prefetch_reason(reason), do: inspect(reason)
defp prefetch_error_message(reason), do: Exception.message(%PrefetchError{reason: reason})
defp parse_sorted_versions(packument) do
key = {:__sorted_versions__, packument.name}
case :ets.lookup(@table, key) do
[{^key, versions}] ->
versions
[] ->
versions = do_parse_sorted_versions(packument)
:ets.insert(@table, {key, versions})
versions
end
end
defp do_parse_sorted_versions(packument) do
packument.versions
|> Enum.reject(fn {version_str, info} ->
exotic_candidate?(packument.name, version_str, info)
end)
|> Enum.flat_map(fn {v, _info} ->
case Version.parse(v) do
{:ok, version} -> [version]
:error -> []
end
end)
|> Enum.sort(Version)
end
defp exotic_candidate?(package, version_str, info) do
case validate_version(package, version_str, info) do
:ok -> false
{:error, %ExoticDeps.Error{}} -> true
end
end
defp deps_for_version(package, packument, version_str) do
excluded = get_excluded()
case Map.get(packument.versions, version_str) do
nil ->
:error
info ->
validate_version!(package, version_str, info)
overrides = get_overrides()
deps =
info
|> solver_dependencies(package, excluded, overrides)
{:ok, deps}
end
end
defp validate_version!(package, version_str, info) do
case validate_version(package, version_str, info) do
:ok -> :ok
{:error, error} -> raise error
end
end
defp validate_version(package, version_str, info) do
key = {:__exotic_validation__, package, version_str}
case :ets.lookup(@table, key) do
[{^key, result}] ->
result
[] ->
result =
try do
ExoticDeps.validate!(package, version_str, info)
:ok
rescue
error in ExoticDeps.Error -> {:error, error}
end
:ets.insert(@table, {key, result})
result
end
end
defp solver_dependencies(info, self_name, excluded, overrides) do
optional_dependency_names = Map.keys(info.optional_dependencies)
required =
info.dependencies
|> Enum.reject(fn {name, _} ->
name == self_name or name in optional_dependency_names or
MapSet.member?(excluded, name)
end)
|> Enum.map(fn {name, range} -> {name, Map.get(overrides, name, range), false} end)
optional =
info.optional_dependencies
|> NPM.PlatformOptional.select()
|> Enum.reject(fn {name, _} ->
name == self_name or MapSet.member?(excluded, name)
end)
|> Enum.map(fn {name, range} -> {name, Map.get(overrides, name, range), false} end)
(required ++ optional)
|> Enum.flat_map(&to_solver_dep/1)
end
defp to_solver_dep({name, range, optional?}) do
case normalize_range(range) do
{:ok, constraint} ->
[
%{
repo: nil,
name: name,
constraint: constraint,
optional: optional?,
label: name,
dependencies: []
}
]
:error ->
[]
end
end
defp to_solver_dep({name, range}) do
to_solver_dep({name, range, false})
end
defp normalize_range(range) when range in ["*", "", "latest"] do
NPMSemver.to_hex_constraint(">=0.0.0")
end
defp normalize_range(range), do: NPMSemver.to_hex_constraint(range)
# --- Cache ---
defp ensure_cache do
if :ets.whereis(@table) == :undefined do
try do
:ets.new(@table, [:named_table, :set, :public])
rescue
ArgumentError -> :ok
end
end
end
defp cached?(package) do
:ets.info(@table) != :undefined and :ets.member(@table, package)
end
defp get_cached_packument(package) do
ensure_cache()
case :ets.lookup(@table, package) do
[{^package, packument}] -> {:ok, packument}
[] -> fetch_and_cache(package)
end
end
defp fetch_and_cache(package) do
case NPM.Registry.get_packument(package) do
{:ok, packument} ->
:ets.insert(@table, {package, packument})
{:ok, packument}
error ->
error
end
end
defp prefetch_packages([]), do: :ok
defp prefetch_packages(packages) do
packages
|> Task.async_stream(&safe_fetch_and_cache/1,
max_concurrency: @prefetch_concurrency,
timeout: @fetch_timeout,
ordered: false,
on_timeout: :kill_task
)
|> collect_prefetch_results()
end
defp safe_fetch_and_cache(package) do
fetch_and_cache(package)
rescue
error -> {:error, {error, __STACKTRACE__}}
catch
kind, reason -> {:error, {kind, reason}}
end
defp prefetch_tree(packages, depth \\ 0)
defp prefetch_tree(_packages, depth) when depth > @max_prefetch_depth, do: :ok
defp prefetch_tree(packages, depth) do
to_fetch = Enum.reject(packages, &cached?/1)
case prefetch_packages(to_fetch) do
:ok ->
next_level =
to_fetch
|> Enum.flat_map(&dep_names_from_cache/1)
|> Enum.uniq()
|> Enum.reject(&cached?/1)
if next_level != [] do
prefetch_tree(next_level, depth + 1)
else
:ok
end
{:error, reason} ->
{:error, reason}
end
end
defp dep_names_from_cache(package) do
case :ets.lookup(@table, package) do
[{_, packument}] ->
case latest_version_info(packument) do
nil -> []
info -> Map.keys(info.dependencies)
end
[] ->
[]
end
end
defp latest_version_info(packument) do
packument.versions
|> Map.keys()
|> Enum.flat_map(fn v ->
case Version.parse(v) do
{:ok, ver} -> [{v, ver}]
:error -> []
end
end)
|> Enum.reject(fn {_, ver} -> ver.pre != [] end)
|> Enum.sort_by(&elem(&1, 1), {:desc, Version})
|> case do
[{latest_str, _} | _] -> Map.get(packument.versions, latest_str)
[] -> nil
end
end
end