Skip to main content

lib/npm/validate.ex

defmodule NPM.Validate do
  @moduledoc """
  Validates package.json data against npm conventions.

  Checks for required fields, correct types, valid values,
  and common mistakes.
  """

  @required_fields ~w(name version)
  @known_fields ~w(
    name version description main module browser types typings
    bin scripts dependencies devDependencies peerDependencies
    optionalDependencies bundleDependencies bundledDependencies
    engines os cpu repository bugs homepage license author
    contributors maintainers keywords files directories exports
    imports type sideEffects private publishConfig workspaces
    funding config overrides resolutions
  )

  @doc """
  Validates package.json data, returning a list of issues.
  """
  @spec validate(map()) :: [map()]
  def validate(data) when is_map(data) do
    []
    |> check_required(data)
    |> check_name(data)
    |> check_version(data)
    |> check_dependencies(data)
    |> check_types(data)
    |> Enum.reverse()
  end

  def validate(_), do: [%{level: :error, field: nil, message: "package.json must be an object"}]

  @doc """
  Returns only errors (not warnings).
  """
  @spec errors(map()) :: [map()]
  def errors(data), do: data |> validate() |> Enum.filter(&(&1.level == :error))

  @doc """
  Returns only warnings.
  """
  @spec warnings(map()) :: [map()]
  def warnings(data), do: data |> validate() |> Enum.filter(&(&1.level == :warning))

  @doc """
  Checks if the package.json is valid (no errors).
  """
  @spec valid?(map()) :: boolean()
  def valid?(data), do: errors(data) == []

  @doc """
  Returns a list of unknown fields.
  """
  @spec unknown_fields(map()) :: [String.t()]
  def unknown_fields(data) when is_map(data) do
    data
    |> Map.keys()
    |> Enum.reject(&(&1 in @known_fields or String.starts_with?(&1, "_")))
    |> Enum.sort()
  end

  def unknown_fields(_), do: []

  @doc """
  Formats validation issues for display.
  """
  @spec format_issues([map()]) :: String.t()
  def format_issues([]), do: "No issues found."

  def format_issues(issues) do
    Enum.map_join(issues, "\n", fn issue ->
      prefix = if issue.level == :error, do: "ERROR", else: "WARN"
      field = if issue.field, do: " [#{issue.field}]", else: ""
      "#{prefix}#{field}: #{issue.message}"
    end)
  end

  defp check_required(issues, data) do
    Enum.reduce(@required_fields, issues, fn field, acc ->
      if Map.has_key?(data, field) do
        acc
      else
        [%{level: :error, field: field, message: "missing required field '#{field}'"} | acc]
      end
    end)
  end

  defp check_name(issues, %{"name" => name}) when is_binary(name) do
    issues
    |> maybe_issue(String.length(name) == 0, :error, "name", "name cannot be empty")
    |> maybe_issue(String.length(name) > 214, :error, "name", "name exceeds 214 characters")
    |> maybe_issue(
      name != String.downcase(name) and not NPM.Scope.scoped?(name),
      :warning,
      "name",
      "name should be lowercase"
    )
    |> maybe_issue(String.starts_with?(name, "."), :error, "name", "name cannot start with a dot")
    |> maybe_issue(
      String.starts_with?(name, "_"),
      :error,
      "name",
      "name cannot start with an underscore"
    )
  end

  defp check_name(issues, %{"name" => _}) do
    [%{level: :error, field: "name", message: "name must be a string"} | issues]
  end

  defp check_name(issues, _), do: issues

  defp check_version(issues, %{"version" => version}) when is_binary(version) do
    case Version.parse(version) do
      {:ok, _} -> issues
      :error -> maybe_issue(issues, true, :error, "version", "invalid semver: '#{version}'")
    end
  end

  defp check_version(issues, %{"version" => _}) do
    [%{level: :error, field: "version", message: "version must be a string"} | issues]
  end

  defp check_version(issues, _), do: issues

  defp check_dependencies(issues, data) do
    dep_fields = ~w(dependencies devDependencies peerDependencies optionalDependencies)

    Enum.reduce(dep_fields, issues, fn field, acc ->
      case Map.get(data, field) do
        nil -> acc
        deps when is_map(deps) -> check_dep_values(acc, field, deps)
        _ -> [%{level: :error, field: field, message: "#{field} must be an object"} | acc]
      end
    end)
  end

  defp check_dep_values(issues, field, deps) do
    Enum.reduce(deps, issues, fn {name, range}, acc ->
      if is_binary(range) do
        acc
      else
        [
          %{level: :error, field: field, message: "'#{name}' has non-string version in #{field}"}
          | acc
        ]
      end
    end)
  end

  defp check_types(issues, data) do
    type_checks = [
      {"description", &is_binary/1},
      {"license", &is_binary/1},
      {"private", &is_boolean/1},
      {"keywords", &is_list/1},
      {"files", &is_list/1}
    ]

    Enum.reduce(type_checks, issues, fn {field, checker}, acc ->
      case Map.get(data, field) do
        nil ->
          acc

        val ->
          maybe_issue(acc, not checker.(val), :warning, field, "#{field} has unexpected type")
      end
    end)
  end

  defp maybe_issue(issues, false, _, _, _), do: issues

  defp maybe_issue(issues, true, level, field, message) do
    [%{level: level, field: field, message: message} | issues]
  end
end