defmodule NPM.NodeModules do
@moduledoc """
Inspect and query the `node_modules` directory.
Provides functions to enumerate installed packages, compare
against the lockfile, and detect anomalies.
"""
@doc """
List all installed packages in `node_modules`.
Returns a sorted list of package names (including scoped).
"""
@spec installed(String.t()) :: [String.t()]
def installed(dir \\ "node_modules") do
dir
|> list_entries()
|> Enum.reject(&String.starts_with?(&1, "."))
|> Enum.flat_map(&expand_scope(dir, &1))
|> Enum.sort()
end
@doc """
Get the installed version of a package by reading its `package.json`.
"""
@spec version(String.t(), String.t()) :: String.t() | nil
def version(name, dir \\ "node_modules") do
path = Path.join([dir, name, "package.json"])
case File.read(path) do
{:ok, content} -> content |> NPM.JSON.decode!() |> Map.get("version")
{:error, _} -> nil
end
end
@doc """
Compare installed packages against the lockfile.
Returns `{missing, extra}` where:
- `missing` — packages in lockfile but not in `node_modules`
- `extra` — packages in `node_modules` but not in lockfile
"""
@spec diff(%{String.t() => term()}, String.t()) :: {[String.t()], [String.t()]}
def diff(lockfile, dir \\ "node_modules") do
locked = MapSet.new(Map.keys(lockfile))
on_disk = MapSet.new(installed(dir))
missing = MapSet.difference(locked, on_disk) |> MapSet.to_list() |> Enum.sort()
extra = MapSet.difference(on_disk, locked) |> MapSet.to_list() |> Enum.sort()
{missing, extra}
end
@doc """
Get the total disk size of `node_modules` in bytes.
"""
@spec disk_size(String.t()) :: non_neg_integer()
def disk_size(dir \\ "node_modules") do
if File.dir?(dir) do
dir |> Path.join("**/*") |> Path.wildcard(match_dot: true) |> sum_file_sizes()
else
0
end
end
defp sum_file_sizes(paths) do
Enum.reduce(paths, 0, fn path, acc ->
case File.stat(path) do
{:ok, %{size: size, type: :regular}} -> acc + size
_ -> acc
end
end)
end
@doc """
Count the total number of files in `node_modules`.
"""
@spec file_count(String.t()) :: non_neg_integer()
def file_count(dir \\ "node_modules") do
if File.dir?(dir) do
dir
|> Path.join("**/*")
|> Path.wildcard(match_dot: true)
|> Enum.count(&File.regular?/1)
else
0
end
end
defp list_entries(dir) do
case File.ls(dir) do
{:ok, entries} -> entries
{:error, _} -> []
end
end
defp expand_scope(dir, "@" <> _ = scope) do
dir |> Path.join(scope) |> list_entries() |> Enum.map(&"#{scope}/#{&1}")
end
defp expand_scope(_dir, name), do: [name]
end