Skip to main content

lib/npm/diagnostics.ex

defmodule NPM.Diagnostics do
  alias NPM.Config.Npmrc

  @moduledoc """
  Runs diagnostic checks on a project for common npm issues.
  """

  @doc """
  Runs all diagnostic checks.
  """
  @spec run(String.t()) :: [map()]
  def run(project_dir) do
    []
    |> check_package_json(project_dir)
    |> check_lockfile(project_dir)
    |> check_node_modules(project_dir)
    |> check_npmrc(project_dir)
    |> Enum.reverse()
  end

  @doc """
  Formats diagnostic results.
  """
  @spec format([map()]) :: String.t()
  def format([]), do: "All checks passed."

  def format(issues) do
    issues
    |> Enum.map_join("\n", fn issue ->
      icon = if issue.level == :error, do: "✗", else: "!"
      "#{icon} [#{issue.check}] #{issue.message}"
    end)
  end

  @doc """
  Counts issues by level.
  """
  @spec counts([map()]) :: map()
  def counts(issues) do
    %{
      errors: Enum.count(issues, &(&1.level == :error)),
      warnings: Enum.count(issues, &(&1.level == :warning)),
      total: length(issues)
    }
  end

  defp check_package_json(issues, dir) do
    path = Path.join(dir, "package.json")

    if File.exists?(path) do
      issues
    else
      [%{level: :error, check: "package.json", message: "package.json not found"} | issues]
    end
  end

  defp check_lockfile(issues, dir) do
    lock_path = Path.join(dir, "npm.lock")
    pkg_lock_path = Path.join(dir, "package-lock.json")

    cond do
      File.exists?(lock_path) -> issues
      File.exists?(pkg_lock_path) -> issues
      true -> [%{level: :warning, check: "lockfile", message: "No lockfile found"} | issues]
    end
  end

  defp check_node_modules(issues, dir) do
    nm_path = Path.join(dir, "node_modules")

    if File.dir?(nm_path) do
      issues
    else
      [
        %{
          level: :warning,
          check: "node_modules",
          message: "node_modules not found — run npm install"
        }
        | issues
      ]
    end
  end

  defp check_npmrc(issues, dir) do
    npmrc_path = Path.join(dir, ".npmrc")

    case File.exists?(npmrc_path) && Npmrc.read(npmrc_path) do
      {:ok, config} -> check_npmrc_auth(issues, config)
      _ -> issues
    end
  end

  defp check_npmrc_auth(issues, config) do
    if Npmrc.has_auth?(config) do
      [
        %{
          level: :warning,
          check: "npmrc",
          message: ".npmrc contains auth tokens — ensure it's gitignored"
        }
        | issues
      ]
    else
      issues
    end
  end
end