Mix Xref Explorer
Mix.install([
{:choreo, github: "code-shoily/choreo", branch: "main"},
{:kino_vizjs, "~> 0.9.0"}
])
Setup Styles
Kino.HTML.new("""
<style>
/* Prevent Mermaid SVGs from scaling down to tiny, unreadable sizes */
svg[id^="mermaid-"], .mermaid svg, [data-elixir-kino="Kino.Mermaid"] svg {
max-width: none !important;
height: auto !important;
}
div:has(> svg[id^="mermaid-"]), .mermaid, [data-elixir-kino="Kino.Mermaid"] {
overflow-x: auto !important;
width: 100% !important;
}
</style>
""")
Introduction
This notebook analyzes the internal source file dependencies of an Elixir project using mix xref and visualizes them using Choreo.Dependency diagrams. It highlights circular dependencies (which slow down compilation), calculates coupling metrics, and performs impact analysis to see what files would need recompilation if a given file changes.
How It Works
- You provide the absolute path to a local Elixir project.
-
The notebook executes
mix xref graph --format dot --output -to retrieve the compile/export/runtime dependencies in Graphviz DOT format. -
The parser extracts the dependency graph and maps:
-
Internal files to
:moduleor:testnodes. -
Compile-time dependencies to
:importsedges. -
Exports dependencies to
:usesedges. -
Runtime dependencies to
:callsedges. -
Files are grouped into clusters based on their directories (e.g.
lib/choreo/c4).
-
Internal files to
- Visualizations are rendered as Mermaid Flowcharts, Mermaid Class Diagrams, and Graphviz graphs.
- Structural analysis evaluates cycles, instability, centrality, and impact.
Configuration
project_input = Kino.Input.text("Elixir Project Path", default: Path.expand("../..", __DIR__))
label_input = Kino.Input.select("Filter dependency type", [
{"all", "All (Compile, Export & Runtime)"},
{"compile", "Compile-time only"},
{"export", "Export only"},
{"runtime", "Runtime only"}
], default: "all")
Kino.Layout.grid([project_input, label_input], columns: 2)
The Xref Parser
defmodule XrefParser do
@moduledoc """
Runs `mix xref` in the specified directory and parses its DOT output.
"""
@doc """
Runs `mix xref` and returns the parsed nodes and edges.
"""
def analyze(project_path, type_filter) do
mix_exs_path = Path.join(project_path, "mix.exs")
if not File.exists?(mix_exs_path) do
{:error, "No mix.exs found at #{project_path}. Please check the path."}
else
args = ["xref", "graph", "--format", "dot", "--output", "-"]
args = if type_filter != "all", do: args ++ ["--label", type_filter], else: args
case System.cmd("mix", args, cd: project_path, env: [{"MIX_ENV", "dev"}]) do
{dot_string, 0} ->
{:ok, parse_dot(dot_string)}
{output, status} ->
{:error, "mix xref failed with exit status #{status}:\n\n#{output}"}
end
end
end
defp parse_dot(dot_string) do
# Regex to match edges: "node1" -> "node2" [label="(type)"] or "node1" -> "node2"
edge_regex = ~r/^\s*"([^"]+)"\s*->\s*"([^"]+)"(?:\s*\[label="([^"]+)"\])?/m
# Regex to match nodes: "node1"
node_regex = ~r/^\s*"([^"]+)"\s*$/m
edges =
Regex.scan(edge_regex, dot_string)
|> Enum.map(fn
[_, from, to, label] -> {from, to, parse_edge_label(label)}
[_, from, to] -> {from, to, :calls}
end)
nodes =
Regex.scan(node_regex, dot_string)
|> Enum.map(fn [_, node] -> node end)
|> Enum.uniq()
# Capture any nodes that might only be mentioned in edges
all_nodes =
(nodes ++ Enum.map(edges, &elem(&1, 0)) ++ Enum.map(edges, &elem(&1, 1)))
|> Enum.uniq()
|> Enum.map(&parse_node/1)
%{nodes: all_nodes, edges: edges}
end
defp parse_edge_label("(compile)"), do: :imports
defp parse_edge_label("(export)"), do: :uses
defp parse_edge_label(_), do: :calls
defp parse_node(path) do
type =
cond do
String.starts_with?(path, "test/") or String.ends_with?(path, "_test.exs") ->
:test
String.contains?(path, "behaviour") or String.contains?(path, "protocol") ->
:interface
true ->
:module
end
cluster_label =
case Path.split(Path.dirname(path)) do
["lib", app, sub | _] -> "#{app}/#{sub}"
["lib", app | _] -> app
["test" | _] -> "test"
_ -> nil
end
cluster = if cluster_label, do: String.replace(cluster_label, ~r/[^a-zA-Z0-9_]/, "_")
%{
id: String.to_atom(path),
label: Path.basename(path),
path: path,
type: type,
cluster: cluster,
cluster_label: cluster_label
}
end
end
Running the Analysis
project_path = Kino.Input.read(project_input) |> String.trim()
type_filter = Kino.Input.read(label_input)
IO.puts("π Analyzing xref dependencies in #{project_path}...")
data =
case XrefParser.analyze(project_path, type_filter) do
{:ok, result} ->
IO.puts("β
Analyzed #{length(result.nodes)} files and #{length(result.edges)} dependencies")
result
{:error, reason} ->
IO.puts("β Error: #{reason}")
%{nodes: [], edges: []}
end
Building the Choreo Dependency Graph
alias Choreo.Dependency
alias Choreo.Dependency.Analysis
graph = Dependency.new()
# Add clusters
clusters =
data.nodes
|> Enum.filter(& &1.cluster)
|> Enum.map(fn node -> {node.cluster, node.cluster_label} end)
|> Enum.uniq()
graph =
clusters
|> Enum.reduce(graph, fn {cluster_id, cluster_label}, g ->
Dependency.add_cluster(g, cluster_id, label: cluster_label)
end)
# Add nodes
graph =
data.nodes
|> Enum.reduce(graph, fn node, g ->
opts = [label: node.label]
opts = if node.cluster, do: [cluster: node.cluster] ++ opts, else: opts
case node.type do
:test ->
Dependency.add_test(g, node.id, opts)
:interface ->
Dependency.add_interface(g, node.id, opts)
_ ->
Dependency.add_module(g, node.id, opts)
end
end)
# Add edges
graph =
data.edges
|> Enum.reduce(graph, fn {from, to, type}, g ->
Dependency.depends_on(g, String.to_atom(from), String.to_atom(to), type: type)
end)
IO.puts("π Choreo.Dependency graph constructed with #{length(Dependency.nodes(graph))} nodes")
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 Flowchart", Kino.Mermaid.new(Dependency.to_mermaid(graph))},
{"Mermaid Class Diagram", 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()
Structural Analysis
Validation & Diagnostics
case Analysis.validate(graph) do
[] ->
IO.puts("β
No structural validation issues found!")
issues ->
Enum.each(issues, fn {sev, msg} ->
icon = if sev == :error, do: "β", else: "β οΈ"
IO.puts("#{icon} #{msg}")
end)
end
Circular Dependencies (Compile-time Cycle Finder)
Circular dependencies (especially compile-time ones) force Elixir to recompile large groups of files whenever a single file in the cycle changes, slow down builds, and increase testing overhead.
cycles = Analysis.cyclic_dependencies(graph)
if cycles == [] do
IO.puts("β
No circular dependencies detected!")
else
IO.puts("π Found #{length(cycles)} circular dependency path(s):\n")
Enum.each(cycles, fn cycle ->
path =
cycle
|> Enum.map(&to_string/1)
|> Enum.join(" β ")
IO.puts(" π΄ #{path}")
end)
end
Instability Metrics
Robert C. Martinβs instability metric ($I = C_e / (C_a + C_e)$):
- 0.0 (Highly Stable): Nothing depends on it, but it has many dependents. It is risky to modify because of downstream impact.
- 1.0 (Highly Unstable): Depends on many other components, but nothing depends on it. Safe to modify.
instability_scores = Analysis.instability(graph)
if Enum.empty?(instability_scores) do
IO.puts("No instability metrics available.")
else
instability_scores
|> Enum.sort_by(fn {_id, score} -> score end, :asc)
|> Enum.take(20) # Top 20 most stable files
|> IO.inspect(label: "Top 20 Stable Files (Risky to change, low instability)")
IO.puts("\nDetailed Instability Ratings:")
instability_scores
|> Enum.sort_by(fn {_id, score} -> score end)
|> Enum.each(fn {id, score} ->
bar = String.duplicate("β", ceil(score * 20))
label = if score >= 0.7, do: "π’ (unstable/safe)", else: if(score >= 0.3, do: "π‘ (medium)", else: "π΄ (stable/risky)")
IO.puts(" #{id}: #{:erlang.float_to_binary(score, decimals: 2)} #{bar} #{label}")
end)
end
Most Connected Files (Centrality)
Files that act as hubs or central coupling points in your project structure:
Analysis.centrality(graph, limit: 15)
|> Enum.with_index(1)
|> Enum.each(fn {id, idx} ->
IO.puts(" #{idx}. #{id}")
end)
Impact Analysis
Select a file to see exactly what downstream files are transitively affected and would require recompilation (or re-testing) if it is changed.
# Create a selection list of files
file_options =
data.nodes
|> Enum.map(fn node -> {to_string(node.path), node.id} end)
|> Enum.sort()
target_file_input = Kino.Input.select("Target File", file_options)
target_file_id = Kino.Input.read(target_file_input)
affected = Analysis.affected_by(graph, target_file_id)
if Enum.empty?(affected) do
IO.puts("β
Changing #{target_file_id} has no downstream impact.")
else
IO.puts("β οΈ Changing #{target_file_id} will affect #{length(affected)} downstream file(s):")
Enum.each(affected, fn aff ->
IO.puts(" β #{aff}")
end)
end