defmodule NPM.Diagnostics.Health do
@moduledoc """
Comprehensive project health scoring.
Evaluates multiple dimensions of npm project health and
produces an overall score with actionable recommendations.
"""
@doc """
Computes a health score (0-100) for a project.
"""
@spec score(map()) :: %{score: non_neg_integer(), details: map()}
def score(checks) do
points = [
has_lockfile: check_val(checks, :has_lockfile, 15),
has_package_json: check_val(checks, :has_package_json, 15),
integrity_coverage: integrity_points(checks),
no_vulnerabilities: vuln_points(checks),
up_to_date: freshness_points(checks),
has_license: check_val(checks, :has_license, 10),
no_deprecated: check_val(checks, :no_deprecated, 10)
]
total = points |> Keyword.values() |> Enum.sum()
capped = min(total, 100)
%{score: capped, details: Map.new(points)}
end
@doc """
Returns a grade based on the score.
"""
@spec grade(non_neg_integer()) :: String.t()
def grade(score) when score >= 90, do: "A"
def grade(score) when score >= 80, do: "B"
def grade(score) when score >= 70, do: "C"
def grade(score) when score >= 60, do: "D"
def grade(_), do: "F"
@doc """
Returns recommendations based on checks.
"""
@spec recommendations(map()) :: [String.t()]
def recommendations(checks) do
recs = [
{!checks[:has_lockfile], "Run `mix npm.install` to generate a lockfile"},
{!checks[:has_package_json], "Create a package.json file"},
{checks[:vulnerability_count] && checks[:vulnerability_count] > 0,
"Run `mix npm.audit` to review vulnerabilities"},
{checks[:outdated_count] && checks[:outdated_count] > 0,
"Run `mix npm.outdated` to check for updates"},
{!checks[:has_license], "Add a license field to package.json"},
{!checks[:no_deprecated], "Replace deprecated dependencies"}
]
recs
|> Enum.filter(&elem(&1, 0))
|> Enum.map(&elem(&1, 1))
end
@doc """
Formats the health report.
"""
@spec format_report(map()) :: String.t()
def format_report(%{score: score, details: details}) do
grade_str = grade(score)
detail_lines =
Enum.map_join(details, "\n", fn {key, points} -> " #{key}: #{points} pts" end)
"Health Score: #{score}/100 (#{grade_str})\n#{detail_lines}"
end
defp check_val(checks, key, points) do
if Map.get(checks, key, false), do: points, else: 0
end
defp integrity_points(checks) do
pct = Map.get(checks, :integrity_pct, 0)
cond do
pct >= 95 -> 15
pct >= 80 -> 10
pct >= 50 -> 5
true -> 0
end
end
defp vuln_points(checks) do
count = Map.get(checks, :vulnerability_count, 0)
if count == 0, do: 15, else: 0
end
defp freshness_points(checks) do
outdated = Map.get(checks, :outdated_count, 0)
cond do
outdated == 0 -> 10
outdated < 5 -> 5
true -> 0
end
end
end