Skip to main content

lib/npm/package/license.ex

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