Skip to main content

lib/npm/diagnostics/health.ex

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