defmodule NPM.Dependency.UsageCheck do
@moduledoc """
Detects unused and missing dependencies in a project.
Scans source files for `require()` and `import` statements,
then compares against declared dependencies.
"""
@type check_result :: %{
unused: [String.t()],
missing: [String.t()]
}
@doc """
Analyzes a project for unused and missing dependencies.
"""
@spec check(String.t()) :: {:ok, check_result()} | {:error, term()}
def check(project_dir) do
pkg_path = Path.join(project_dir, "package.json")
case NPM.JSON.read_file(pkg_path) do
{:ok, data} when is_map(data) ->
declared = extract_declared(data)
used = scan_imports(project_dir)
{:ok,
%{
unused: find_unused(declared, used),
missing: find_missing(declared, used)
}}
{:ok, _} ->
{:error, :invalid_package_json}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Extracts package names from require/import statements in source code.
"""
@spec extract_imports(String.t()) :: [String.t()]
def extract_imports(source) do
require_pattern = ~r/require\s*\(\s*['"]([^'"\.][^'"]*)['"]\s*\)/
import_pattern = ~r/(?:import|export)\s+.*?from\s+['"]([^'"\.][^'"]*)['"]/
dynamic_pattern = ~r/import\s*\(\s*['"]([^'"\.][^'"]*)['"]\s*\)/
[require_pattern, import_pattern, dynamic_pattern]
|> Enum.flat_map(&Regex.scan(&1, source))
|> Enum.map(&List.last/1)
|> Enum.map(&normalize_package_name/1)
|> Enum.uniq()
|> Enum.sort()
end
@doc """
Normalizes a module specifier to a package name.
Handles scoped packages and deep imports.
"""
@spec normalize_package_name(String.t()) :: String.t()
def normalize_package_name("@" <> rest) do
case String.split(rest, "/", parts: 3) do
[scope, name | _] -> "@#{scope}/#{name}"
[scope] -> "@#{scope}"
end
end
def normalize_package_name(name) do
name |> String.split("/", parts: 2) |> hd()
end
@doc """
Scans JS/TS source files in a directory for imports.
"""
@spec scan_imports(String.t()) :: MapSet.t()
def scan_imports(project_dir) do
source_dirs = ["src", "lib", "app", "pages", "components"]
source_dirs
|> Enum.flat_map(fn dir ->
full = Path.join(project_dir, dir)
if File.dir?(full), do: scan_directory(full), else: []
end)
|> Enum.into(MapSet.new())
end
defp extract_declared(data) do
deps = data["dependencies"] || %{}
dev_deps = data["devDependencies"] || %{}
Map.keys(deps) ++ Map.keys(dev_deps)
end
defp find_unused(declared, used) do
declared
|> Enum.reject(&MapSet.member?(used, &1))
|> Enum.sort()
end
defp find_missing(declared, used) do
declared_set = MapSet.new(declared)
used
|> MapSet.to_list()
|> Enum.reject(&(MapSet.member?(declared_set, &1) or builtin?(&1)))
|> Enum.sort()
end
@node_builtins ~w(fs path http https os url util crypto stream events buffer net child_process cluster dgram dns readline tls vm zlib assert)
defp builtin?(name), do: name in @node_builtins or String.starts_with?(name, "node:")
defp scan_directory(dir) do
case File.ls(dir) do
{:ok, entries} -> Enum.flat_map(entries, &scan_entry(dir, &1))
_ -> []
end
end
@js_extensions ~w(.js .jsx .ts .tsx .mjs .cjs)
defp scan_entry(dir, entry) do
path = Path.join(dir, entry)
cond do
File.dir?(path) -> scan_directory(path)
Path.extname(entry) in @js_extensions -> scan_file(path)
true -> []
end
end
defp scan_file(path) do
case File.read(path) do
{:ok, content} -> extract_imports(content)
_ -> []
end
end
end