defmodule NPM.Package.License do
@moduledoc """
Scans and reports licenses across the dependency tree.
Reads `license` fields from package manifests to produce
compliance reports and detect potential issues.
"""
@type license_entry :: %{
package: String.t(),
version: String.t(),
license: String.t() | nil
}
@known_permissive ~w(MIT ISC BSD-2-Clause BSD-3-Clause Apache-2.0 Unlicense 0BSD CC0-1.0 BlueOak-1.0.0)
@doc """
Extracts the license from a package.json data map.
Handles both string `license` and legacy `licenses` array.
"""
@spec extract(map()) :: String.t() | nil
def extract(%{"license" => license}) when is_binary(license), do: license
def extract(%{"licenses" => [%{"type" => type} | _]}) when is_binary(type), do: type
def extract(%{"license" => %{"type" => type}}) when is_binary(type), do: type
def extract(_), do: nil
@doc """
Scans a node_modules directory for license information.
"""
@spec scan(String.t()) :: [license_entry()]
def scan(node_modules_dir) do
case File.ls(node_modules_dir) do
{:ok, entries} ->
entries
|> Enum.flat_map(&read_package_license(node_modules_dir, &1))
|> Enum.sort_by(& &1.package)
_ ->
[]
end
end
@doc """
Groups license entries by license type.
"""
@spec group_by_license([license_entry()]) :: %{String.t() => [license_entry()]}
def group_by_license(entries) do
Enum.group_by(entries, fn e -> e.license || "UNKNOWN" end)
end
@doc """
Checks if a license is considered permissive.
"""
@spec permissive?(String.t() | nil) :: boolean()
def permissive?(nil), do: false
def permissive?(license), do: license in @known_permissive
@doc """
Finds packages with non-permissive or unknown licenses.
"""
@spec non_permissive([license_entry()]) :: [license_entry()]
def non_permissive(entries) do
Enum.reject(entries, &permissive?(&1.license))
end
@doc """
Returns a compliance summary.
"""
@spec summary([license_entry()]) :: %{
total: non_neg_integer(),
permissive: non_neg_integer(),
non_permissive: non_neg_integer(),
unknown: non_neg_integer(),
unique_licenses: [String.t()]
}
def summary(entries) do
licenses =
entries |> Enum.map(& &1.license) |> Enum.reject(&is_nil/1) |> Enum.uniq() |> Enum.sort()
%{
total: length(entries),
permissive: Enum.count(entries, &permissive?(&1.license)),
non_permissive:
Enum.count(entries, &(not is_nil(&1.license) and not permissive?(&1.license))),
unknown: Enum.count(entries, &is_nil(&1.license)),
unique_licenses: licenses
}
end
@doc """
Checks entries against a list of allowed licenses.
Returns packages that violate the policy.
"""
@spec check_policy([license_entry()], [String.t()]) :: [license_entry()]
def check_policy(entries, allowed) do
allowed_set = MapSet.new(allowed)
Enum.reject(entries, &MapSet.member?(allowed_set, &1.license))
end
defp read_package_license(nm_dir, entry) do
pkg_json = Path.join([nm_dir, entry, "package.json"])
case File.read(pkg_json) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
[
%{
package: data["name"] || entry,
version: data["version"] || "0.0.0",
license: extract(data)
}
]
_ ->
[]
end
rescue
_ -> []
end
end