defmodule HexpmMcp do @moduledoc """ Public API for querying hex.pm and hexdocs.pm. All functions return `{:ok, result}` or `{:error, reason}` and are designed to be used from iex, other Elixir code, or as the backend for MCP tools. ## Quick Start iex> HexpmMcp.search("json") {:ok, [%{name: "jason", version: "1.4.4", downloads_all: 197_000_000, ...}, ...]} iex> HexpmMcp.get_info("plug") {:ok, %{name: "plug", description: "Composable modules for web applications", downloads: %{all: 156_000_000, recent: 3_100_000, week: 250_000, day: 35_000}, licenses: ["Apache-2.0"], latest_stable_version: "1.19.1", ...}} iex> HexpmMcp.health_check("req") {:ok, %{name: "req", maintenance: %{status: "Active", age: "4 years ago", days_since_release: 57, total_versions: 52}, popularity: %{all: 11_500_000, recent: 1_600_000, week: 141_000}, quality: %{has_docs: true, licenses: ["Apache-2.0"], required_deps: 3, optional_deps: 4}, risk: %{owner_count: 1, retired_count: 0}, links: %{...}}} ## Function Groups ### Simple lookups `search/2`, `get_info/1`, `get_downloads/1`, `get_owners/1`, `get_versions/1` ### Version-resolving lookups `get_release/2`, `get_dependencies/2`, `get_features/2` -- pass `nil` for version to automatically resolve to the latest stable version. ### Composite analysis `compare_packages/1`, `health_check/1`, `audit_dependencies/2`, `find_alternatives/1`, `dependency_tree/3` -- these make multiple API calls in parallel and return aggregated results. ### HexDocs browsing `get_readme/2`, `get_docs/2`, `get_doc_item/3`, `search_docs/3` ## Error Values All functions return `{:error, reason}` on failure. Common reasons: - `:not_found` -- package or version does not exist on hex.pm - `:rate_limited` -- hex.pm API rate limit exceeded - `:too_few_packages` / `:too_many_packages` -- invalid input to `compare_packages/1` - `{:api_error, status, body}` -- unexpected HTTP status from hex.pm - `{:request_failed, reason}` -- network error """ alias HexpmMcp.{Client, HexDocs, OSV} @stop_words ~w(a an the and or but in on at to for of is it that this with from by as are was be) # --------------------------------------------------------------------------- # Simple lookups # --------------------------------------------------------------------------- @typedoc "Common error reasons from hex.pm API calls." @type error :: :not_found | :rate_limited | {:api_error, integer(), any()} | {:request_failed, any()} @doc """ Search for packages on hex.pm by name/keywords. ## Options - `:sort` -- sort order: `"name"`, `"recent_downloads"`, `"total_downloads"`, `"inserted_at"`, `"updated_at"` - `:page` -- page number (1-indexed, 100 results per page) ## Examples iex> HexpmMcp.search("json") {:ok, [%{name: "jason", version: "1.4.4", description: "A blazing fast JSON parser...", downloads_all: 197_000_000, downloads_recent: 4_000_000, url: "https://hex.pm/packages/jason"}, ...]} iex> HexpmMcp.search("http client", sort: "recent_downloads", page: 1) {:ok, [%{name: "req", ...}, %{name: "finch", ...}, ...]} """ @spec search(String.t(), keyword()) :: {:ok, [map()]} | {:error, error()} def search(query, opts \\ []) do case Client.search(query, opts) do {:ok, packages} -> results = Enum.map(packages, fn pkg -> %{ name: pkg.name, version: pkg.latest_stable_version || pkg.latest_version, description: get_in(pkg.meta, ["description"]) || "", downloads_all: pkg.downloads["all"] || 0, downloads_recent: pkg.downloads["recent"] || 0, url: "https://hex.pm/packages/#{pkg.name}" } end) {:ok, results} error -> error end end @doc """ Get detailed information about a hex.pm package. Returns metadata, download stats, licenses, links, and version info. ## Examples iex> HexpmMcp.get_info("plug") {:ok, %{ name: "plug", description: "Composable modules for web applications", latest_stable_version: "1.19.1", latest_version: "1.19.1", downloads: %{all: 156_000_000, recent: 3_100_000, week: 250_000, day: 35_000}, licenses: ["Apache-2.0"], build_tools: ["mix"], elixir_requirement: "~> 1.14", inserted_at: "2013-12-31T...", updated_at: "2025-12-09T...", docs_url: "https://hexdocs.pm/plug/", hex_url: "https://hex.pm/packages/plug", links: %{"GitHub" => "https://github.com/elixir-plug/plug"} }} iex> HexpmMcp.get_info("nonexistent") {:error, :not_found} """ @spec get_info(String.t()) :: {:ok, map()} | {:error, error()} def get_info(name) do with {:ok, pkg} <- Client.get_package(name) do {:ok, package_to_info(pkg)} end end defp package_to_info(pkg) do %{ name: pkg.name, description: meta(pkg, "description") || "No description", latest_stable_version: pkg.latest_stable_version, latest_version: pkg.latest_version, downloads: extract_downloads(pkg.downloads), licenses: meta(pkg, "licenses") || [], build_tools: meta(pkg, "build_tools") || [], elixir_requirement: meta(pkg, "elixir"), inserted_at: pkg.inserted_at, updated_at: pkg.updated_at, docs_url: pkg.docs_html_url, hex_url: "https://hex.pm/packages/#{pkg.name}", links: meta(pkg, "links") || %{} } end @doc """ Get download statistics for a hex.pm package. ## Examples iex> HexpmMcp.get_downloads("phoenix") {:ok, %{name: "phoenix", all: 148_000_000, recent: 2_600_000, week: 223_000, day: 13_000}} """ @spec get_downloads(String.t()) :: {:ok, map()} | {:error, error()} def get_downloads(name) do with {:ok, pkg} <- Client.get_package(name) do {:ok, Map.put(extract_downloads(pkg.downloads), :name, pkg.name)} end end @doc """ Get the owners/maintainers of a hex.pm package. ## Examples iex> HexpmMcp.get_owners("phoenix") {:ok, [%{username: "josevalim", email: "jose@example.com"}, ...]} """ @spec get_owners(String.t()) :: {:ok, [map()]} | {:error, error()} def get_owners(name) do case Client.get_owners(name) do {:ok, owners} -> {:ok, Enum.map(owners, fn o -> %{username: o.username, email: o.email} end)} error -> error end end @doc """ List all versions of a hex.pm package with retirement status. ## Examples iex> HexpmMcp.get_versions("plug") {:ok, %{name: "plug", versions: [ %{version: "1.19.1", inserted_at: "2025-12-09T...", has_docs: true, retired: nil}, %{version: "1.14.0", inserted_at: "2023-03-15T...", has_docs: true, retired: %{reason: "security", message: "CVE-2024-..."}}, ... ]}} """ @spec get_versions(String.t()) :: {:ok, map()} | {:error, error()} def get_versions(name) do case Client.get_package(name) do {:ok, pkg} -> retirements = pkg.retirements || %{} versions = Enum.map(pkg.releases || [], fn rel -> version = rel["version"] retirement = Map.get(retirements, version) %{ version: version, inserted_at: rel["inserted_at"], has_docs: rel["has_docs"] || false, retired: if(retirement, do: %{ reason: retirement["reason"] || "unknown", message: retirement["message"] }, else: nil ) } end) {:ok, %{name: pkg.name, versions: versions}} error -> error end end # --------------------------------------------------------------------------- # Version-resolving lookups # --------------------------------------------------------------------------- @doc """ Get detailed information about a specific release. If `version` is nil, resolves to the latest stable version. ## Examples iex> HexpmMcp.get_release("plug", "1.19.1") {:ok, %{ name: "plug", version: "1.19.1", publisher: "josevalim", inserted_at: "2025-12-09T...", updated_at: "2025-12-09T...", downloads: 500_000, has_docs: true, build_tools: ["mix"], elixir_requirement: "~> 1.14", dependencies: [ %{name: "mime", requirement: "~> 2.0", optional: false}, %{name: "plug_crypto", requirement: "~> 2.1", optional: false} ], retired: nil }} # Resolves to latest stable when version is nil iex> HexpmMcp.get_release("plug") {:ok, %{name: "plug", version: "1.19.1", ...}} """ @spec get_release(String.t(), String.t() | nil) :: {:ok, map()} | {:error, error()} def get_release(name, version \\ nil) do with {:ok, version} <- resolve_version(name, version), {:ok, rel} <- Client.get_release(name, version) do {:ok, release_to_map(name, rel)} end end defp release_to_map(name, rel) do %{ name: name, version: rel.version, publisher: rel.publisher["username"] || "unknown", inserted_at: rel.inserted_at, updated_at: rel.updated_at, downloads: rel.downloads || 0, has_docs: rel.has_docs || false, build_tools: get_in(rel.meta, ["build_tools"]) || [], elixir_requirement: get_in(rel.meta, ["elixir"]), dependencies: parse_requirements(rel.requirements), retired: parse_retirement(rel.retirement) } end @doc """ Get the dependencies of a package version. If `version` is nil, resolves to the latest stable version. ## Examples iex> HexpmMcp.get_dependencies("req") {:ok, %{name: "req", version: "0.5.17", dependencies: [ %{name: "finch", requirement: "~> 0.17", optional: false}, %{name: "jason", requirement: "~> 1.0", optional: true}, %{name: "mime", requirement: "~> 2.0 or ~> 1.0", optional: false}, ... ]}} iex> HexpmMcp.get_dependencies("plug", "1.19.1") {:ok, %{name: "plug", version: "1.19.1", dependencies: [...]}} """ @spec get_dependencies(String.t(), String.t() | nil) :: {:ok, map()} | {:error, error()} def get_dependencies(name, version \\ nil) do with {:ok, version} <- resolve_version(name, version), {:ok, rel} <- Client.get_release(name, version) do {:ok, %{name: name, version: version, dependencies: parse_requirements(rel.requirements)}} end end @doc """ Get optional features/extras for a package release. If `version` is nil, resolves to the latest stable version. ## Examples iex> HexpmMcp.get_features("req") {:ok, %{ name: "req", version: "0.5.17", optional_deps: [ %{name: "brotli", requirement: "~> 0.3.1"}, %{name: "jason", requirement: "~> 1.0"}, ... ], extra_metadata: %{} }} """ @spec get_features(String.t(), String.t() | nil) :: {:ok, map()} | {:error, error()} def get_features(name, version \\ nil) do with {:ok, version} <- resolve_version(name, version), {:ok, rel} <- Client.get_release(name, version) do optional_deps = parse_requirements(rel.requirements) |> Enum.filter(& &1.optional) |> Enum.map(&Map.take(&1, [:name, :requirement])) {:ok, %{ name: name, version: version, optional_deps: optional_deps, extra_metadata: get_in(rel.meta, ["extra"]) || %{} }} end end # --------------------------------------------------------------------------- # Composite / analytical # --------------------------------------------------------------------------- @doc """ Compare 2-5 hex.pm packages side by side. Fetches package info and dependency counts in parallel for each package. ## Examples iex> HexpmMcp.compare_packages(["plug", "bandit", "cowboy"]) {:ok, [ %{name: "plug", downloads_all: 156_000_000, downloads_recent: 3_100_000, latest_version: "1.19.1", updated_at: "2025-12-09T...", licenses: "Apache-2.0", dep_count: 3}, %{name: "bandit", downloads_all: 9_900_000, ...}, %{name: "cowboy", downloads_all: 78_000_000, ...} ]} iex> HexpmMcp.compare_packages(["only_one"]) {:error, :too_few_packages} """ @spec compare_packages([String.t()]) :: {:ok, [map()]} | {:error, :too_few_packages | :too_many_packages} def compare_packages(names) when is_list(names) do cond do length(names) < 2 -> {:error, :too_few_packages} length(names) > 5 -> {:error, :too_many_packages} true -> do_compare(names) end end defp do_compare(names) do packages = names |> Task.async_stream(&enrich_for_comparison/1, timeout: 30_000) |> Enum.map(fn {:ok, result} -> result end) {:ok, packages} end defp enrich_for_comparison(name) do case Client.get_package(name) do {:ok, pkg} -> version = pkg.latest_stable_version || pkg.latest_version dep_count = count_deps(name, version) %{ name: pkg.name, downloads_all: pkg.downloads["all"] || 0, downloads_recent: pkg.downloads["recent"] || 0, latest_version: pkg.latest_stable_version || pkg.latest_version || "?", updated_at: pkg.updated_at, licenses: (meta(pkg, "licenses") || []) |> Enum.join(", "), dep_count: dep_count } _ -> %{name: name, error: true} end end defp count_deps(name, version) do case Client.get_release(name, version) do {:ok, rel} -> map_size(rel.requirements || %{}) _ -> 0 end end @doc """ Comprehensive health check for a hex.pm package. Fetches package info, owners, and latest release in parallel, then computes maintenance status, popularity metrics, quality indicators, and risk factors. ## Examples iex> HexpmMcp.health_check("req") {:ok, %{ name: "req", maintenance: %{ age: "4 years ago", total_versions: 52, status: "Active", # Active (<90d), Recent (<1y), Aging (<2y), Stale (2y+) days_since_release: 57 }, popularity: %{all: 11_500_000, recent: 1_600_000, week: 141_000}, quality: %{ has_docs: true, licenses: ["Apache-2.0"], build_tools: ["mix"], elixir_requirement: "~> 1.14", required_deps: 3, optional_deps: 4 }, risk: %{ owner_count: 1, # 1 = "single maintainer" warning retired_count: 0 }, links: %{ hex_url: "https://hex.pm/packages/req", docs_url: "https://hexdocs.pm/req/", "GitHub" => "https://github.com/wojtekmach/req" } }} """ @spec health_check(String.t()) :: {:ok, map()} | {:error, error()} def health_check(name) do tasks = %{ package: Task.async(fn -> Client.get_package(name) end), owners: Task.async(fn -> Client.get_owners(name) end) } with {:ok, pkg} <- Task.await(tasks.package, 30_000) do owners = unwrap_or(Task.await(tasks.owners, 30_000), []) release = fetch_latest_release(pkg) {:ok, build_health_report(pkg, owners, release)} end end defp fetch_latest_release(pkg) do version = pkg.latest_stable_version || pkg.latest_version case if(version, do: Client.get_release(pkg.name, version), else: {:error, :no_version}) do {:ok, r} -> r _ -> nil end end defp build_health_report(pkg, owners, release) do now = DateTime.utc_now() %{ name: pkg.name, maintenance: build_maintenance(pkg, now), popularity: %{ all: pkg.downloads["all"] || 0, recent: pkg.downloads["recent"] || 0, week: pkg.downloads["week"] || 0 }, quality: build_quality(pkg, release), risk: %{ owner_count: length(owners), retired_count: map_size(pkg.retirements || %{}) }, links: build_links(pkg) } end defp unwrap_or({:ok, value}, _default), do: value defp unwrap_or(_, default), do: default @doc """ Audit a package's dependencies for risks. Checks each dependency in parallel for: - Retired versions - Stale packages (no release in 2+ years) - Single-maintainer packages (bus factor risk) - Known vulnerabilities via OSV.dev ## Examples iex> HexpmMcp.audit_dependencies("phoenix") {:ok, %{ name: "phoenix", version: "1.8.5", total_checked: 10, total_warnings: 8, deps_with_warnings: 7, results: [ %{name: "jason", issues: ["single maintainer"]}, %{name: "plug", issues: ["2 retired version(s)", "3 known vulnerability(ies)"]}, %{name: "telemetry", issues: []}, ... ] }} iex> HexpmMcp.audit_dependencies("phoenix", "1.7.0") {:ok, %{name: "phoenix", version: "1.7.0", ...}} """ @spec audit_dependencies(String.t(), String.t() | nil) :: {:ok, map()} | {:error, error()} def audit_dependencies(name, version \\ nil) do with {:ok, version} <- resolve_version(name, version), {:ok, rel} <- Client.get_release(name, version) do reqs = rel.requirements || %{} {:ok, run_audit(name, version, reqs)} end end @doc """ Find and compare alternative packages for a given hex.pm package. Extracts keywords from the package description, searches for similar packages, deduplicates, and returns the top 10 sorted by recent downloads. ## Examples iex> HexpmMcp.find_alternatives("httpoison") {:ok, %{ package: %{name: "httpoison", description: "HTTP client for Elixir", downloads_all: 42_000_000, downloads_recent: 500_000}, alternatives: [ %{name: "req", version: "0.5.17", downloads_all: 11_500_000, downloads_recent: 1_600_000, updated_at: "2026-02-07T...", status: "Active", description: "Req is a batteries-included HTTP client...", licenses: "Apache-2.0"}, %{name: "finch", ...}, ... ] }} """ @spec find_alternatives(String.t()) :: {:ok, map()} | {:error, error()} def find_alternatives(name) do with {:ok, pkg} <- Client.get_package(name) do alternatives = search_similar(name, extract_keywords(pkg)) {:ok, %{package: package_summary(pkg), alternatives: alternatives}} end end defp search_similar(exclude_name, keywords) do keywords |> Task.async_stream( fn kw -> case Client.search(kw, sort: "recent_downloads") do {:ok, packages} -> packages _ -> [] end end, timeout: 30_000 ) |> Enum.flat_map(fn {:ok, packages} -> packages end) |> Enum.uniq_by(& &1.name) |> Enum.reject(&(&1.name == exclude_name)) |> Enum.sort_by(fn p -> -(p.downloads["recent"] || 0) end) |> Enum.take(10) |> Enum.map(&package_to_alternative/1) end defp package_to_alternative(pkg) do %{ name: pkg.name, version: pkg.latest_stable_version || pkg.latest_version || "?", downloads_all: pkg.downloads["all"] || 0, downloads_recent: pkg.downloads["recent"] || 0, updated_at: pkg.updated_at, status: maintenance_status(pkg.updated_at), description: meta(pkg, "description") || "", licenses: (meta(pkg, "licenses") || []) |> Enum.join(", ") } end defp package_summary(pkg) do %{ name: pkg.name, description: meta(pkg, "description") || "", downloads_all: pkg.downloads["all"] || 0, downloads_recent: pkg.downloads["recent"] || 0 } end @doc """ Get the full transitive dependency tree for a package (BFS, max depth 5). Traverses dependencies breadth-first, resolving each to its latest version. Deduplicates by package name (each package appears once in the tree). ## Options - `:max_depth` -- maximum depth to traverse (default 5, capped at 5) ## Examples iex> HexpmMcp.dependency_tree("req", nil, max_depth: 2) {:ok, %{ name: "req", version: "0.5.17", total_unique_deps: 8, tree: [ %{name: "req", version: "0.5.17", depth: 0, deps: [ %{name: "finch", requirement: "~> 0.17", optional: false, depth: 1}, %{name: "mime", requirement: "~> 2.0 or ~> 1.0", optional: false, depth: 1}, ... ]}, %{name: "finch", version: "0.21.0", depth: 1, deps: [...]}, ... ] }} """ @spec dependency_tree(String.t(), String.t() | nil, keyword()) :: {:ok, map()} | {:error, error()} def dependency_tree(name, version \\ nil, opts \\ []) do max_depth = min(Keyword.get(opts, :max_depth, 5), 5) with {:ok, version} <- resolve_version(name, version) do tree = build_tree(name, version, max_depth) total_unique_deps = tree |> Enum.flat_map(fn entry -> Enum.map(entry.deps, & &1.name) end) |> Enum.uniq() |> length() {:ok, %{ name: name, version: version, total_unique_deps: total_unique_deps, tree: tree }} end end # --------------------------------------------------------------------------- # HexDocs passthrough # --------------------------------------------------------------------------- @doc """ Get the README content for a package as markdown. Fetches the README from hexdocs.pm and converts HTML to markdown. ## Examples iex> HexpmMcp.get_readme("req") {:ok, "# Req\\n\\nReq is a batteries-included HTTP client for Elixir.\\n\\n..."} iex> HexpmMcp.get_readme("req", "0.5.0") {:ok, "# Req\\n\\n..."} """ @spec get_readme(String.t(), String.t() | nil) :: {:ok, String.t()} | {:error, error()} def get_readme(name, version \\ nil), do: HexDocs.get_readme(name, version) @doc """ Get the module listing for a package's documentation. Parses the hexdocs.pm sidebar data to extract all modules, behaviours, and protocols with their function/type counts. ## Examples iex> HexpmMcp.get_docs("plug") {:ok, [ %{name: "Plug", type: "module", doc: "Types: 1, Callbacks: 2, Functions: 2"}, %{name: "Plug.Conn", type: "module", doc: "Types: 22, Functions: 52"}, %{name: "Plug.Router", type: "module", doc: "Functions: 12"}, ... ]} """ @spec get_docs(String.t(), String.t() | nil) :: {:ok, [map()]} | {:error, error()} def get_docs(name, version \\ nil), do: HexDocs.get_modules(name, version) @doc """ Get full documentation for a specific module or function. Fetches the module's hexdocs.pm page and converts it to markdown. ## Examples iex> HexpmMcp.get_doc_item("plug", "Plug.Conn") {:ok, "# Plug.Conn\\n\\nThe Plug connection.\\n\\n..."} iex> HexpmMcp.get_doc_item("plug", "Plug.Conn", "1.19.1") {:ok, "# Plug.Conn\\n\\n..."} """ @spec get_doc_item(String.t(), String.t(), String.t() | nil) :: {:ok, String.t()} | {:error, error()} def get_doc_item(name, module, version \\ nil), do: HexDocs.get_doc_item(name, module, version) @doc """ Search within a package's documentation by name. Searches module names and doc snippets in the hexdocs.pm sidebar data. Returns up to 20 matching items. ## Examples iex> HexpmMcp.search_docs("phoenix", "Router") {:ok, [ %{"title" => "Phoenix.Router", "type" => "module", "doc" => "Reflection: 3, Functions: 21"}, %{"title" => "Phoenix.Router.NoRouteError", "type" => "module", "doc" => ""}, ... ]} """ @spec search_docs(String.t(), String.t(), String.t() | nil) :: {:ok, [map()]} | {:error, error()} def search_docs(name, query, version \\ nil), do: HexDocs.search_docs(name, query, version) # --------------------------------------------------------------------------- # Mix.exs analysis # --------------------------------------------------------------------------- @doc """ Audit a list of mix.exs dependencies for risks. Accepts a deps string (as it appears in mix.exs) and runs a comprehensive audit on each package: staleness, retirement, bus factor, and CVEs via OSV.dev. ## Examples iex> deps = \""" ...> {:phoenix, "~> 1.7"}, ...> {:ecto, "~> 3.10"}, ...> {:jason, "~> 1.0"} ...> \""" iex> HexpmMcp.audit_mix_deps(deps) {:ok, %{ total_checked: 3, total_warnings: 2, deps_with_warnings: 2, results: [ %{name: "ecto", pinned_version: "~> 3.10", issues: []}, %{name: "jason", pinned_version: "~> 1.0", issues: ["single maintainer"]}, %{name: "phoenix", pinned_version: "~> 1.7", issues: ["3 retired version(s)"]}, ... ] }} """ @spec audit_mix_deps(String.t()) :: {:ok, map()} | {:error, :no_deps_found} def audit_mix_deps(deps_string) do deps = parse_deps_string(deps_string) if deps == [] do {:error, :no_deps_found} else results = deps |> Task.async_stream( fn {name, pinned} -> {name, pinned, audit_dep(name)} end, timeout: 30_000 ) |> Enum.map(fn {:ok, result} -> result end) |> Enum.sort_by(fn {name, _, _} -> name end) total_warnings = Enum.sum(Enum.map(results, fn {_, _, issues} -> length(issues) end)) result = %{ total_checked: length(results), total_warnings: total_warnings, deps_with_warnings: Enum.count(results, fn {_, _, issues} -> issues != [] end), results: Enum.map(results, fn {name, pinned, issues} -> %{name: name, pinned_version: pinned, issues: issues} end) } {:ok, result} end end @doc """ Check which mix.exs dependencies have newer versions available. Accepts a deps string and checks each against the latest version on hex.pm. Flags major version bumps as potentially breaking. ## Examples iex> deps = \""" ...> {:phoenix, "~> 1.7"}, ...> {:jason, "~> 1.0"} ...> \""" iex> HexpmMcp.upgrade_check(deps) {:ok, %{ total_checked: 2, upgrades_available: 1, results: [ %{name: "jason", pinned_version: "~> 1.0", latest_version: "1.4.4", status: :up_to_date}, %{name: "phoenix", pinned_version: "~> 1.7", latest_version: "1.8.5", status: :minor_upgrade, retired: false} ] }} """ @spec upgrade_check(String.t()) :: {:ok, map()} | {:error, :no_deps_found} def upgrade_check(deps_string) do deps = parse_deps_string(deps_string) if deps == [] do {:error, :no_deps_found} else results = deps |> Task.async_stream(&check_upgrade/1, timeout: 30_000) |> Enum.map(fn {:ok, result} -> result end) |> Enum.sort_by(& &1.name) upgrades = Enum.count(results, &(&1.status != :up_to_date and &1.status != :error)) {:ok, %{total_checked: length(results), upgrades_available: upgrades, results: results}} end end defp check_upgrade({name, pinned}) do case Client.get_package(name) do {:ok, pkg} -> latest = pkg.latest_stable_version || pkg.latest_version retired = Map.has_key?(pkg.retirements || %{}, latest) status = classify_upgrade(pinned, latest) %{ name: name, pinned_version: pinned, latest_version: latest, status: status, retired: retired } {:error, _} -> %{ name: name, pinned_version: pinned, latest_version: nil, status: :error, retired: false } end end defp classify_upgrade(pinned, latest) when is_binary(pinned) and is_binary(latest) do case extract_base_version(pinned) do nil -> :unknown base -> case {Version.parse(base), Version.parse(latest)} do {{:ok, pinned_v}, {:ok, latest_v}} -> compare_versions(pinned_v, latest_v) _ -> :unknown end end end defp classify_upgrade(_, _), do: :unknown defp extract_base_version(requirement) do case Regex.run(~r/(\d+\.\d+[\.\d]*)/, requirement) do [_, version] -> # Ensure it has 3 parts for Version.parse case String.split(version, ".") do [maj, min] -> "#{maj}.#{min}.0" [_, _, _] -> version _ -> nil end _ -> nil end end defp compare_versions(pinned, latest) do cond do latest.major > pinned.major -> :major_upgrade latest.minor > pinned.minor -> :minor_upgrade latest.patch > pinned.patch -> :patch_upgrade true -> :up_to_date end end @doc false def parse_deps_string(deps_string) do # Keep names as strings: they are only used as package names for lookups, # and String.to_atom on unbounded user input would exhaust the atom table. ~r/\{:(\w+),\s*"([^"]+)"/ |> Regex.scan(deps_string) |> Enum.map(fn [_, name, version] -> {name, version} end) |> Enum.uniq_by(fn {name, _} -> name end) end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp meta(pkg, key), do: get_in(pkg.meta, [key]) defp parse_requirements(nil), do: [] defp parse_requirements(reqs) do reqs |> Enum.sort_by(fn {dep_name, _} -> dep_name end) |> Enum.map(fn {dep_name, info} -> %{ name: dep_name, requirement: info["requirement"] || "any", optional: info["optional"] || false } end) end defp parse_retirement(nil), do: nil defp parse_retirement(retirement) do %{reason: retirement["reason"] || "unknown", message: retirement["message"]} end defp extract_downloads(downloads) do %{ all: downloads["all"] || 0, recent: downloads["recent"] || 0, week: downloads["week"] || 0, day: downloads["day"] || 0 } end defp resolve_version(_name, version) when is_binary(version), do: {:ok, version} defp resolve_version(name, nil) do case Client.get_package(name) do {:ok, pkg} -> {:ok, pkg.latest_stable_version || pkg.latest_version} error -> error end end defp maintenance_status(updated_at) do case days_since(updated_at) do nil -> "Unknown" days -> status_from_days(days) end end defp status_from_days(days) do cond do days < 90 -> "Active" days < 365 -> "Recent" days < 730 -> "Aging" true -> "Stale" end end defp days_since(nil), do: nil defp days_since(date_str), do: days_since(date_str, DateTime.utc_now()) defp days_since(nil, _now), do: nil defp days_since(date_str, now) do case DateTime.from_iso8601(date_str) do {:ok, dt, _} -> DateTime.diff(now, dt, :day) _ -> nil end end defp format_age(nil, _now), do: "unknown" defp format_age(date_str, now) do case days_since(date_str, now) do nil -> "unknown" days -> years = div(days, 365) if years > 0, do: "#{years} years ago", else: "#{days} days ago" end end defp build_maintenance(pkg, now) do total_versions = length(pkg.releases || []) days = days_since(pkg.updated_at, now) %{ age: format_age(pkg.inserted_at, now), total_versions: total_versions, status: if(days, do: status_from_days(days), else: "Unknown"), days_since_release: days } end defp build_quality(pkg, release) do base = %{ has_docs: pkg.docs_html_url != nil, licenses: get_in(pkg.meta, ["licenses"]) || [], build_tools: get_in(pkg.meta, ["build_tools"]) || [] } if release do reqs = release.requirements || %{} required = Enum.count(reqs, fn {_, info} -> not (info["optional"] || false) end) optional = Enum.count(reqs, fn {_, info} -> info["optional"] || false end) Map.merge(base, %{ elixir_requirement: get_in(release.meta, ["elixir"]), required_deps: required, optional_deps: optional }) else Map.merge(base, %{elixir_requirement: nil, required_deps: 0, optional_deps: 0}) end end defp build_links(pkg) do base = %{hex_url: "https://hex.pm/packages/#{pkg.name}"} base = if pkg.docs_html_url do Map.put(base, :docs_url, pkg.docs_html_url) else base end meta_links = get_in(pkg.meta, ["links"]) || %{} Map.merge(base, meta_links |> Enum.into(%{}, fn {k, v} -> {k, v} end)) end defp run_audit(name, version, reqs) when map_size(reqs) == 0 do %{ name: name, version: version, total_checked: 0, total_warnings: 0, deps_with_warnings: 0, results: [] } end defp run_audit(name, version, reqs) do results = reqs |> Map.keys() |> Task.async_stream(fn dep_name -> {dep_name, audit_dep(dep_name)} end, timeout: 30_000) |> Enum.map(fn {:ok, result} -> result end) |> Enum.sort_by(fn {dep_name, _} -> dep_name end) total_warnings = Enum.sum(Enum.map(results, fn {_, issues} -> length(issues) end)) %{ name: name, version: version, total_checked: length(results), total_warnings: total_warnings, deps_with_warnings: Enum.count(results, fn {_, issues} -> issues != [] end), results: Enum.map(results, fn {dep_name, issues} -> %{name: dep_name, issues: issues} end) } end defp audit_dep(dep_name) do pkg_result = Client.get_package(dep_name) owners_result = Client.get_owners(dep_name) vuln_result = OSV.query(dep_name) check_package(pkg_result) ++ check_owners(owners_result) ++ check_vulns(vuln_result) end defp check_package({:ok, pkg}) do retirement_issues = check_retirements(pkg.retirements || %{}) staleness_issues = check_staleness(pkg.updated_at) retirement_issues ++ staleness_issues end defp check_package(_), do: ["could not fetch package info"] defp check_retirements(retirements) do count = map_size(retirements) if count > 0, do: ["#{count} retired version(s)"], else: [] end defp check_staleness(updated_at) do case days_since(updated_at) do nil -> [] days when days > 730 -> ["stale (last release #{Float.round(days / 365, 1)} years ago)"] _ -> [] end end defp check_owners({:ok, [_single]}), do: ["single maintainer"] defp check_owners(_), do: [] defp check_vulns({:ok, vulns}) when vulns != [] do ["#{length(vulns)} known vulnerability(ies)"] end defp check_vulns(_), do: [] defp extract_keywords(pkg) do desc = get_in(pkg.meta, ["description"]) || "" desc |> String.downcase() |> String.replace(~r/[^a-z0-9\s]/, "") |> String.split() |> Enum.reject(&(&1 in @stop_words or String.length(&1) < 3)) |> Enum.take(3) end defp build_tree(name, version, max_depth) do queue = :queue.from_list([{name, version, 0}]) visited = MapSet.new([name]) do_bfs(queue, visited, max_depth, []) end defp do_bfs(queue, visited, max_depth, acc) do case :queue.out(queue) do {:empty, _} -> Enum.reverse(acc) {{:value, {name, version, depth}}, rest_queue} -> deps = fetch_deps(name, version, depth) entry = %{name: name, version: version, depth: depth, deps: deps} acc = [entry | acc] if depth < max_depth do {new_queue, new_visited} = enqueue_new_deps(deps, rest_queue, visited, depth) do_bfs(new_queue, new_visited, max_depth, acc) else do_bfs(rest_queue, visited, max_depth, acc) end end end defp fetch_deps(name, version, depth) do case Client.get_release(name, version) do {:ok, rel} -> Enum.map(rel.requirements || %{}, fn {dep_name, info} -> %{ name: dep_name, requirement: info["requirement"] || "any", optional: info["optional"] || false, depth: depth + 1 } end) _ -> [] end end defp enqueue_new_deps(deps, queue, visited, depth) do Enum.reduce(deps, {queue, visited}, fn dep, {q, v} -> if MapSet.member?(v, dep.name) do {q, v} else dep_version = resolve_dep_version(dep.name) { :queue.in({dep.name, dep_version, depth + 1}, q), MapSet.put(v, dep.name) } end end) end defp resolve_dep_version(name) do case Client.get_package(name) do {:ok, pkg} -> pkg.latest_stable_version || pkg.latest_version || "0.0.0" _ -> "0.0.0" end end end