Hex Dependency Explorer
Mix.install([
{:choreo, github: "code-shoily/choreo", branch: "main"},
{:kino_vizjs, "~> 0.9.0"},
{:req, "~> 0.5"}
])
Introduction
This notebook takes any Hex.pm package name, crawls its dependency tree using the Hex public API, and builds a Choreo.Dependency graph you can visualise, zoom, and analyse β cycles, instability, impact, the works.
How It Works
-
You enter a package name (e.g.
phoenix,ecto,choreo) -
The crawler fetches the latest releaseβs
requirementsviaGET /api/packages/{name}/releases/{version} - It recursively crawls each dependency (with a configurable depth limit)
-
The results are assembled into a
Choreo.Dependencygraph with:- Applications for the root package
- Libraries for each dependency
-
:usesedges for required deps,:devedges for optional deps - Clusters grouping direct vs transitive dependencies
- Full Choreo analysis is run: cycles, instability, centrality, and more
Configuration
package_input = Kino.Input.text("Hex package name", default: "phoenix")
depth_input = Kino.Input.number("Max crawl depth", default: 2)
Kino.Layout.grid([package_input, depth_input], columns: 2)
The Hex Crawler
defmodule HexCrawler do
@moduledoc """
Crawls the Hex.pm API to build a dependency tree.
For each package, fetches the latest stable release and extracts
its `requirements` map. Recurses into each requirement up to a
configurable depth.
"""
@hex_api "https://hex.pm/api"
@doc """
Crawls the dependency tree for `package_name` up to `max_depth` levels.
Returns a map of:
%{
nodes: [%{id: atom, label: String.t(), depth: non_neg_integer}],
edges: [%{from: atom, to: atom, version: String.t(), optional: boolean}]
}
"""
def crawl(package_name, max_depth \\ 2) do
state = %{nodes: [], edges: [], visited: MapSet.new()}
result = do_crawl(package_name, 0, max_depth, state)
%{
nodes: Enum.reverse(result.nodes),
edges: Enum.reverse(result.edges)
}
end
defp do_crawl(package_name, depth, max_depth, state) when depth > max_depth, do: state
defp do_crawl(package_name, depth, max_depth, state) do
pkg = String.downcase(to_string(package_name))
pkg_atom = String.to_atom(pkg)
if MapSet.member?(state.visited, pkg_atom) do
state
else
state = %{state | visited: MapSet.put(state.visited, pkg_atom)}
case fetch_package(pkg) do
{:ok, %{version: version, requirements: requirements, description: description}} ->
label = "#{pkg} #{version}"
state = %{state | nodes: [%{id: pkg_atom, label: label, depth: depth, description: description} | state.nodes]}
requirements
|> Enum.reduce(state, fn {dep_name, dep_info}, acc ->
dep_atom = String.to_atom(dep_name)
acc = %{acc | edges: [
%{
from: pkg_atom,
to: dep_atom,
version: dep_info.requirement,
optional: dep_info.optional
} | acc.edges
]}
do_crawl(dep_name, depth + 1, max_depth, acc)
end)
{:error, reason} ->
# If we can't fetch the package, still add it as a node with no edges out
state = %{state | nodes: [%{id: pkg_atom, label: pkg, depth: depth, description: nil} | state.nodes]}
state
end
end
end
defp fetch_package(package_name) do
url = "#{@hex_api}/packages/#{package_name}"
case Req.get(url, headers: [{"accept", "application/json"}]) do
{:ok, %{status: 200, body: body}} ->
latest_version = body["latest_stable_version"] || body["latest_version"]
if latest_version do
fetch_release(package_name, latest_version, body["meta"]["description"])
else
{:error, :no_versions}
end
{:ok, %{status: 404}} ->
{:error, :not_found}
{:ok, %{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
end
defp fetch_release(package_name, version, description) do
url = "#{@hex_api}/packages/#{package_name}/releases/#{version}"
case Req.get(url, headers: [{"accept", "application/json"}]) do
{:ok, %{status: 200, body: body}} ->
requirements =
(body["requirements"] || %{})
|> Enum.map(fn {name, info} ->
{name, %{
requirement: info["requirement"],
optional: info["optional"] || false,
app: info["app"]
}}
end)
|> Map.new()
{:ok, %{version: version, requirements: requirements, description: description}}
{:ok, %{status: status}} ->
{:error, {:http_error, status}}
{:error, reason} ->
{:error, reason}
end
end
end
Crawling the Dependencies
package_name = Kino.Input.read(package_input)
max_depth = Kino.Input.read(depth_input)
IO.puts("π Crawling #{package_name} (depth: #{max_depth})...")
tree = HexCrawler.crawl(package_name, max_depth)
IO.puts("β
Found #{length(tree.nodes)} packages and #{length(tree.edges)} dependency edges")
Kino.DataTable.new(
tree.nodes
|> Enum.map(fn node ->
%{
"Package" => node.label,
"Depth" => node.depth,
"Description" => node.description || "β"
}
end)
|> Enum.sort_by(& &1["Depth"]),
name: "Discovered Packages"
)
Building the Choreo Dependency Graph
alias Choreo.Dependency
alias Choreo.Dependency.Analysis
root_id = String.to_atom(String.downcase(package_name))
# Group nodes by depth for clustering
depth_groups =
tree.nodes
|> Enum.group_by(& &1.depth)
|> Enum.sort_by(fn {depth, _} -> depth end)
# Build the Choreo graph
graph =
Dependency.new()
# Add clusters for each depth level
graph =
depth_groups
|> Enum.reduce(graph, fn {depth, _nodes}, g ->
cluster_label =
case depth do
0 -> "Root Package"
1 -> "Direct Dependencies"
n -> "Transitive (depth #{n})"
end
cluster_color =
case depth do
0 -> "#dbeafe"
1 -> "#dcfce7"
_ -> "#fef3c7"
end
Dependency.add_cluster(g, "depth_#{depth}", label: cluster_label, fillcolor: cluster_color)
end)
# Add nodes β root as application, everything else as library
graph =
tree.nodes
|> Enum.reduce(graph, fn node, g ->
if node.id == root_id do
Dependency.add_application(g, node.id,
label: node.label,
cluster: "depth_#{node.depth}"
)
else
Dependency.add_library(g, node.id,
label: node.label,
cluster: "depth_#{node.depth}"
)
end
end)
# Add edges
graph =
tree.edges
|> Enum.reduce(graph, fn edge, g ->
type = if edge.optional, do: :dev, else: :uses
Dependency.depends_on(g, edge.from, edge.to, type: type)
end)
IO.puts("π Graph built: #{length(Dependency.nodes(graph))} nodes, #{length(Dependency.edges(graph))} edges")
Visualisation
brand_theme =
Choreo.Theme.custom(
colors: %{
application: "#6366f1",
library: "#f59e0b",
module: "#10b981",
interface: "#8b5cf6",
test: "#ec4899"
},
node_fontcolor: "white",
edge_color: "#64748b",
graph_bgcolor: "#0f172a"
)
tabs = [
{"Mermaid", Kino.Mermaid.new(Dependency.to_mermaid(graph))},
{"Mermaid (class)", Kino.Mermaid.new(Dependency.to_mermaid(graph, syntax: :class_diagram))},
{"Graphviz", Kino.VizJS.render(Dependency.to_dot(graph), height: "700px")},
{"Graphviz (dark)", Kino.VizJS.render(Dependency.to_dot(graph, theme: brand_theme), height: "700px")},
]
Kino.Layout.tabs(tabs)
Zoomed Views
Choreo.View.zoom/2 lets you control the level of detail:
- Level 0: Applications only (the root package)
- Level 1: Applications + Libraries (direct deps)
- Level 2+: Everything including transitive deps
alias Choreo.View
label = fn level ->
case level do
0 -> "0. Root Only"
1 -> "1. Direct Deps"
2 -> "2. All Packages"
_ -> "#{level}. Full Detail"
end
end
max_level = Enum.min([max_depth + 1, 4])
zoomed_diagrams =
for level <- 0..max_level do
view = View.zoom(graph, level: level)
{label.(level), Kino.VizJS.render(Dependency.to_dot(view), height: "500px")}
end
Kino.Layout.tabs(zoomed_diagrams)
Analysis
Dependency Validation
Check for cycles, orphans, and structural issues:
case Analysis.validate(graph) do
[] ->
IO.puts("β
No issues found β clean dependency graph!")
issues ->
Enum.each(issues, fn {sev, msg} ->
icon = if sev == :error, do: "β", else: "β οΈ"
IO.puts("#{icon} #{msg}")
end)
end
Circular Dependencies
cycles = Analysis.cyclic_dependencies(graph)
if cycles == [] do
IO.puts("β
No circular dependencies detected")
else
IO.puts("π Found #{length(cycles)} cycle(s):\n")
Enum.each(cycles, fn cycle ->
path = Enum.join(cycle, " β ")
IO.puts(" π #{path}")
end)
end
Instability Scores
Robert C. Martinβs instability metric β 0.0 means maximally stable (everything depends on it, risky to change), 1.0 means maximally unstable (depends on everything, safe to change).
Analysis.instability(graph)
|> Enum.sort_by(fn {_id, score} -> score end, :desc)
|> Enum.each(fn {id, score} ->
bar = String.duplicate("β", ceil(score * 20))
label = if score >= 0.7, do: "π΄", else: if(score >= 0.4, do: "π‘", else: "π’")
IO.puts("#{label} #{id}: #{:erlang.float_to_binary(score, decimals: 2)} #{bar}")
end)
Most Central Packages
Which packages are the most connected (highest coupling)?
Analysis.centrality(graph, limit: 10)
|> Enum.with_index(1)
|> Enum.each(fn {id, rank} ->
IO.puts(" #{rank}. #{id}")
end)
Impact Analysis
Pick a core dependency and see what breaks if it changes:
# Find the most depended-upon package (the one with the most incoming edges)
most_depended =
tree.edges
|> Enum.frequencies_by(& &1.to)
|> Enum.sort_by(fn {_, count} -> count end, :desc)
|> List.first()
case most_depended do
{pkg, count} ->
IO.puts("π Most depended-upon package: #{pkg} (#{count} direct dependents)\n")
IO.puts("If #{pkg} changes, these packages are affected:\n")
Analysis.affected_by(graph, pkg)
|> Enum.each(fn id ->
IO.puts(" β‘ #{id}")
end)
nil ->
IO.puts("No dependencies found")
end
Leaves and Roots
leaves = Analysis.leaves(graph)
roots = Analysis.roots(graph)
IO.puts("πΏ Leaf packages (no dependents β safe to change):")
Enum.each(leaves, &IO.puts(" #{&1}"))
IO.puts("\nπ³ Root packages (no dependencies β foundations):")
Enum.each(roots, &IO.puts(" #{&1}"))
Longest Dependency Chain
case Analysis.longest_dependency_chain(graph) do
{:ok, chain, length} ->
IO.puts("π Longest chain (#{length} hops):")
IO.puts(" " <> Enum.join(chain, " β "))
:error ->
IO.puts("β οΈ Graph contains cycles β longest chain is undefined")
end
Isolated Subsystems
subsystems = Analysis.isolated_subsystems(graph)
IO.puts("ποΈ Found #{length(subsystems)} connected component(s):\n")
subsystems
|> Enum.with_index(1)
|> Enum.each(fn {component, idx} ->
IO.puts(" Group #{idx} (#{length(component)} packages): #{Enum.join(Enum.sort(component), ", ")}")
end)
Edge Listing
A table of every dependency edge for reference:
Kino.DataTable.new(
tree.edges
|> Enum.map(fn edge ->
%{
"From" => edge.from,
"To" => edge.to,
"Version Requirement" => edge.version,
"Optional?" => if(edge.optional, do: "β", else: "β")
}
end)
|> Enum.sort_by(& &1["From"]),
name: "All Dependency Edges"
)
Summary
This notebook demonstrates how to combine the Hex.pm API with Choreo.Dependency to get a live, analysable dependency graph for any Hex package.
| What you get | How |
|---|---|
| Interactive dependency diagram |
Dependency.to_mermaid/2, to_dot/2 |
| Cycle detection |
Analysis.cyclic_dependencies/1 |
| Instability metrics |
Analysis.instability/1 |
| Impact analysis |
Analysis.affected_by/2 |
| Centrality ranking |
Analysis.centrality/2 |
| Longest chain |
Analysis.longest_dependency_chain/1 |
| Zoom levels |
View.zoom/2 |
| Leaf / root identification |
Analysis.leaves/1, roots/1 |
| Connected component detection |
Analysis.isolated_subsystems/1 |
Try it with phoenix, ecto, nerves, nx, or your own packages!