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