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