defmodule NPM.Package.Quality do
@moduledoc """
Scores individual package quality based on metadata completeness.
"""
@doc """
Scores a package's quality (0-100).
"""
@spec score(map()) :: non_neg_integer()
def score(data) do
checks = [
{Map.has_key?(data, "description"), 10},
{Map.has_key?(data, "license"), 10},
{Map.has_key?(data, "repository"), 10},
{Map.has_key?(data, "homepage"), 5},
{Map.has_key?(data, "bugs"), 5},
{Map.has_key?(data, "keywords") and is_list(data["keywords"]), 10},
{Map.has_key?(data, "engines"), 10},
{Map.has_key?(data, "types") or Map.has_key?(data, "typings"), 10},
{Map.has_key?(data, "exports"), 10},
{Map.has_key?(data, "files"), 10},
{Map.has_key?(data, "author"), 5},
{data["private"] != true, 5}
]
Enum.reduce(checks, 0, fn {passed, points}, acc ->
if passed, do: acc + points, else: acc
end)
end
@doc """
Returns a quality grade.
"""
@spec grade(non_neg_integer()) :: String.t()
def grade(score) when score >= 90, do: "A"
def grade(score) when score >= 75, do: "B"
def grade(score) when score >= 60, do: "C"
def grade(score) when score >= 40, do: "D"
def grade(_), do: "F"
@doc """
Returns missing fields that would improve quality.
"""
@spec missing_fields(map()) :: [String.t()]
def missing_fields(data) do
expected = ~w(description license repository keywords engines types exports files)
Enum.reject(expected, fn field ->
Map.has_key?(data, field) and data[field] != nil
end)
end
@doc """
Ranks packages by quality score.
"""
@spec rank([{String.t(), map()}]) :: [{String.t(), non_neg_integer()}]
def rank(packages) do
packages
|> Enum.map(fn {name, data} -> {name, score(data)} end)
|> Enum.sort_by(&elem(&1, 1), :desc)
end
@doc """
Average quality across packages.
"""
@spec average([{String.t(), map()}]) :: float()
def average([]), do: 0.0
def average(packages) do
total = packages |> Enum.map(fn {_, data} -> score(data) end) |> Enum.sum()
Float.round(total / length(packages), 1)
end
end