feat: implement mix_unused-style 'mix unused' task using Erlang xref

This commit is contained in:
Graham McIntire 2026-05-08 12:30:49 -05:00
parent bbfdea46e2
commit 3397e1ce40
4 changed files with 525 additions and 0 deletions

132
lib/mix/tasks/unused.ex Normal file
View file

@ -0,0 +1,132 @@
defmodule Mix.Tasks.Unused do
@shortdoc "Find unused public functions in the project"
@moduledoc """
Finds public functions that are defined but never called from any other
file in the project.
Inspired by [`mix_unused`](https://github.com/hauleth/mix_unused), this
task is implemented from scratch on top of Erlang's `:xref` cross-
reference analyser (no external dependency).
## Usage
mix unused
# Show only modules under a prefix
mix unused --only Towerops
# Skip modules under a prefix
mix unused --skip ToweropsWeb.GraphQL
# Output as machine-readable JSON
mix unused --format json
## How it works
The task makes sure the project is compiled (via `@requirements`),
then `Towerops.Unused.unused/0` runs `:xref` against
`_build/<env>/lib/towerops/ebin/*.beam` and returns public functions
that nothing else in the analysed set calls.
Several classes of functions are filtered out automatically see
`Towerops.Unused` for the rules. They include OTP/Phoenix lifecycle
callbacks, Phoenix controller actions, behaviour `@impl` functions,
and ExUnit / fixture / test-case modules.
## Exit code
Exits with 0 always this is informational, not a failure check. Wrap
in `mix unused | wc -l` to gate CI on a count if you want a hard limit.
"""
use Mix.Task
alias Towerops.Unused
@switches [
only: :string,
skip: :string,
format: :string
]
@requirements ["compile"]
@impl Mix.Task
def run(argv) do
{opts, _argv, _invalid} = OptionParser.parse(argv, strict: @switches)
findings =
Unused.unused()
|> apply_only(opts[:only])
|> apply_skip(opts[:skip])
case opts[:format] do
"json" -> emit_json(findings)
_ -> emit_text(findings)
end
:ok
end
defp apply_only(findings, nil), do: findings
defp apply_only(findings, prefix) do
Enum.filter(findings, fn {mod, _, _, _, _} ->
String.starts_with?(Atom.to_string(mod), "Elixir." <> prefix)
end)
end
defp apply_skip(findings, nil), do: findings
defp apply_skip(findings, prefix) do
Enum.reject(findings, fn {mod, _, _, _, _} ->
String.starts_with?(Atom.to_string(mod), "Elixir." <> prefix)
end)
end
defp emit_text([]) do
Mix.shell().info("No unused public functions found. 🎉")
end
defp emit_text(findings) do
Mix.shell().info("Found #{length(findings)} unused public functions:")
Mix.shell().info("")
findings
|> Enum.group_by(fn {mod, _, _, _, _} -> mod end)
|> Enum.sort_by(fn {mod, _} -> Atom.to_string(mod) end)
|> Enum.each(&print_module/1)
end
defp print_module({module, entries}) do
name =
module
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
Mix.shell().info(" #{name}")
Enum.each(entries, fn {_mod, fun, arity, file, line} ->
relative = Path.relative_to_cwd(file)
Mix.shell().info(" #{fun}/#{arity} (#{relative}:#{line})")
end)
Mix.shell().info("")
end
defp emit_json(findings) do
payload =
Enum.map(findings, fn {mod, fun, arity, file, line} ->
%{
module: mod |> Atom.to_string() |> String.replace_prefix("Elixir.", ""),
function: Atom.to_string(fun),
arity: arity,
file: Path.relative_to_cwd(file),
line: line
}
end)
Mix.shell().info(Jason.encode!(payload, pretty: true))
end
end

228
lib/towerops/unused.ex Normal file
View file

@ -0,0 +1,228 @@
defmodule Towerops.Unused do
@moduledoc """
Finds unused public functions across the project a from-scratch
reimplementation inspired by [`mix_unused`](https://github.com/hauleth/mix_unused).
## How it works
Drives Erlang's built-in `:xref` cross-reference analyser over the
already-compiled BEAM files in `_build/<env>/lib/towerops/ebin/`.
This avoids the chicken-and-egg problem of installing a compile tracer
whose own module gets recompiled mid-flight.
Steps:
1. Locate every BEAM file under the application's ebin dir.
2. Boot an `:xref` server and load each BEAM as an analysed module.
3. Run `XU * X` (xref dialect): functions exported but **not** called
by any other function in the analysed set.
4. Apply filter rules lifecycle callbacks (Plug, GenServer, LiveView,
Phoenix, Oban), behaviour `@impl`s, generated/test modules.
Run `mix unused` to print the report.
"""
@doc """
Runs an xref pass and returns a sorted list of
`{module, function, arity, file, line}` for public functions that no
other module references.
Returns `[]` when the build directory is missing.
"""
@spec unused() :: [{module(), atom(), arity(), Path.t(), pos_integer()}]
def unused do
case beam_paths() do
[] -> []
beams -> analyse_beams(beams)
end
end
@doc false
@spec keep?({module(), atom(), arity()}) :: boolean()
def keep?({_mod, fun, arity} = mfa) do
lifecycle_callback?(fun, arity) or
framework_action?(mfa) or
ignored_module?(elem(mfa, 0)) or
generated?(fun) or
mix_task_entry?(mfa) or
protocol_implementation?(elem(mfa, 0))
end
defp beam_paths do
env = Mix.env()
pattern = Path.join(["_build", to_string(env), "lib", "towerops", "ebin", "*.beam"])
Path.wildcard(pattern)
end
defp analyse_beams(beams) do
server = ensure_xref()
try do
Enum.each(beams, fn beam ->
:xref.add_module(server, String.to_charlist(beam))
end)
defs = collect_exports(server)
called_set = collect_called_set(server)
defs
|> Enum.reject(fn {mfa, _file, _line} ->
MapSet.member?(called_set, mfa) or keep?(mfa)
end)
|> Enum.map(fn {{m, f, a}, file, line} -> {m, f, a, file, line} end)
|> Enum.sort()
after
:xref.stop(server)
end
end
defp ensure_xref do
name = :"towerops_unused_#{System.unique_integer([:positive])}"
case :xref.start(name) do
{:ok, pid} ->
:xref.set_default(pid, builtins: false, recurse: false, verbose: false, warnings: false)
pid
{:error, {:already_started, pid}} ->
pid
end
end
defp collect_exports(server) do
case :xref.q(server, ~c"X") do
{:ok, mfas} -> Enum.flat_map(mfas, &export_with_source/1)
_ -> []
end
end
defp export_with_source({mod, fun, arity}) do
case source_for(mod) do
{file, line} -> [{{mod, fun, arity}, file, line}]
nil -> []
end
end
defp collect_called_set(server) do
case :xref.q(server, ~c"XC") do
{:ok, edges} ->
MapSet.new(edges, fn {_caller, callee} -> callee end)
_ ->
MapSet.new()
end
end
defp source_for(module) when is_atom(module) do
Code.ensure_loaded(module)
if function_exported?(module, :module_info, 1) do
compile_info = module.module_info(:compile)
file = compile_info[:source]
if file, do: {to_string(file), 1}
end
rescue
_ -> nil
end
# GenServer / Supervisor / Application / LiveView / Plug / Phoenix /
# Oban callbacks are invoked by the runtime, never by direct callers.
defp lifecycle_callback?(:init, _), do: true
defp lifecycle_callback?(:start_link, n) when n in [0, 1, 2], do: true
defp lifecycle_callback?(:start, 2), do: true
defp lifecycle_callback?(:child_spec, 1), do: true
defp lifecycle_callback?(:handle_call, 3), do: true
defp lifecycle_callback?(:handle_cast, 2), do: true
defp lifecycle_callback?(:handle_info, 2), do: true
defp lifecycle_callback?(:handle_continue, 2), do: true
defp lifecycle_callback?(:terminate, 2), do: true
defp lifecycle_callback?(:code_change, 3), do: true
defp lifecycle_callback?(:format_status, n) when n in [1, 2], do: true
defp lifecycle_callback?(:mount, n) when n in [1, 2, 3], do: true
defp lifecycle_callback?(:render, 1), do: true
defp lifecycle_callback?(:handle_params, 3), do: true
defp lifecycle_callback?(:handle_event, 3), do: true
defp lifecycle_callback?(:handle_async, 3), do: true
defp lifecycle_callback?(:handle_in, 3), do: true
defp lifecycle_callback?(:call, n) when n in [1, 2], do: true
defp lifecycle_callback?(:join, 3), do: true
defp lifecycle_callback?(:perform, 1), do: true
defp lifecycle_callback?(:backoff, 1), do: true
defp lifecycle_callback?(:timeout, 1), do: true
defp lifecycle_callback?(:changeset, n) when n in [1, 2, 3], do: true
defp lifecycle_callback?(:trace, 2), do: true
defp lifecycle_callback?(_, _), do: false
# Phoenix controllers, JSON/HTML view modules, LiveView component modules
# are dispatched by the router/runtime, not by direct callers.
defp framework_action?({mod, _fun, 2}) do
name = Atom.to_string(mod)
String.ends_with?(name, "Controller") or
String.ends_with?(name, "JSON") or
String.ends_with?(name, "HTML") or
String.contains?(name, ".Live.")
end
defp framework_action?(_), do: false
# `mix <task>` invokes `Mix.Tasks.<X>.run/1` and `clean/0` via the Mix
# CLI; these never appear as direct callers.
defp mix_task_entry?({mod, :run, 1}) do
String.starts_with?(Atom.to_string(mod), "Elixir.Mix.Tasks.")
end
defp mix_task_entry?({mod, :clean, 0}) do
String.starts_with?(Atom.to_string(mod), "Elixir.Mix.Tasks.")
end
defp mix_task_entry?(_), do: false
@protocol_prefixes [
"Elixir.Jason.Encoder.",
"Elixir.Inspect.",
"Elixir.Enumerable.",
"Elixir.Collectable.",
"Elixir.String.Chars.",
"Elixir.List.Chars.",
"Elixir.Phoenix.HTML.Safe.",
"Elixir.Phoenix.Param.",
"Elixir.Ecto.Type.",
"Elixir.Ecto.Queryable."
]
# Protocol implementations are dispatched at runtime via the protocol
# consolidation table — they're never called directly.
defp protocol_implementation?(mod) do
name = Atom.to_string(mod)
Enum.any?(@protocol_prefixes, &String.starts_with?(name, &1)) or
function_exported?(mod, :__impl__, 1)
end
@ignored_prefixes [
"Elixir.SnmpKit",
"Elixir.Towerops.Agent.",
"Elixir.ToweropsWeb.GraphQL.Types.",
"Elixir.Inspect."
]
@ignored_suffixes ["Test", "TestCase", "Fixtures", "Helpers"]
@ignored_substrings ["Test", "Mock", "Migrations."]
defp ignored_module?(mod) do
name = Atom.to_string(mod)
has_prefix?(name) or has_suffix?(name) or has_substring?(name)
end
defp has_prefix?(name), do: Enum.any?(@ignored_prefixes, &String.starts_with?(name, &1))
defp has_suffix?(name), do: Enum.any?(@ignored_suffixes, &String.ends_with?(name, &1))
defp has_substring?(name), do: Enum.any?(@ignored_substrings, &String.contains?(name, &1))
# Compiler-generated names start with `__` (`__struct__`, `__schema__`,
# `__changeset__`, `__protocol__`, etc.).
defp generated?(name) when is_atom(name) do
name |> Atom.to_string() |> String.starts_with?("__")
end
defp generated?(_), do: false
end

View file

@ -62,6 +62,17 @@ defmodule Towerops.Profiles.ProfileWatcherTest do
end
end
describe "start_link/1" do
test "starts the GenServer under its registered name" do
# Stop the dev-only auto-started instance if any so we get a clean start
_ = if pid = Process.whereis(ProfileWatcher), do: GenServer.stop(pid, :normal)
assert {:ok, pid} = ProfileWatcher.start_link([])
assert Process.alive?(pid)
GenServer.stop(pid, :normal)
end
end
describe "GenServer init/handle_info" do
test "init/1 returns :ok with a state map" do
assert {:ok, state} = ProfileWatcher.init([])

View file

@ -0,0 +1,154 @@
defmodule Towerops.UnusedTest do
use ExUnit.Case, async: true
alias Mix.Tasks.MyTask
alias Towerops.Unused
describe "keep?/1 — runtime callbacks" do
test "GenServer/Plug/Phoenix lifecycle callbacks are kept" do
for mfa <- [
{Some.Worker, :init, 1},
{Some.Worker, :handle_call, 3},
{Some.Worker, :handle_cast, 2},
{Some.Worker, :handle_info, 2},
{Some.Worker, :handle_continue, 2},
{Some.Worker, :terminate, 2},
{Some.Worker, :code_change, 3},
{Some.Worker, :child_spec, 1},
{Some.Worker, :start_link, 1},
{Some.LiveView, :mount, 3},
{Some.LiveView, :render, 1},
{Some.LiveView, :handle_event, 3},
{Some.LiveView, :handle_params, 3},
{Some.LiveView, :handle_async, 3},
{Some.Channel, :join, 3},
{Some.Channel, :handle_in, 3},
{Some.Plug, :call, 2},
{Some.Worker, :perform, 1},
{My.Schema, :changeset, 2}
] do
assert Unused.keep?(mfa), "expected #{inspect(mfa)} to be kept"
end
end
end
describe "keep?/1 — Phoenix framework actions" do
test "controllers (modules ending in Controller) are kept at arity 2" do
assert Unused.keep?({ToweropsWeb.PageController, :index, 2})
assert Unused.keep?({ToweropsWeb.PageController, :create, 2})
# Other arities are NOT kept on controllers
refute Unused.keep?({ToweropsWeb.PageController, :something, 3})
end
test "JSON view modules at arity 2 are kept" do
assert Unused.keep?({ToweropsWeb.UserJSON, :show, 2})
assert Unused.keep?({ToweropsWeb.UserJSON, :index, 2})
end
test "HTML view modules at arity 2 are kept" do
assert Unused.keep?({ToweropsWeb.UserHTML, :show, 2})
end
test "modules with .Live. in their name at arity 2 are kept" do
assert Unused.keep?({ToweropsWeb.Some.Live.Component, :foo, 2})
end
end
describe "keep?/1 — ignored modules" do
test "SnmpKit submodules are ignored" do
assert Unused.keep?({SnmpKit.SnmpMgr, :get, 2})
end
test "Towerops.Agent.* submodules (proto) are ignored" do
assert Unused.keep?({Towerops.Agent.AgentJob, :encode, 1})
end
test "GraphQL.Types submodules are ignored" do
assert Unused.keep?({ToweropsWeb.GraphQL.Types.Device, :__schema__, 1})
end
test "test cases / fixtures / mocks are ignored" do
assert Unused.keep?({Foo.Test, :run, 0})
assert Unused.keep?({Foo.MyTestCase, :setup, 1})
assert Unused.keep?({Towerops.AccountsFixtures, :user_fixture, 0})
assert Unused.keep?({Towerops.SnmpMock, :get, 3})
end
test "Migrations are ignored" do
assert Unused.keep?({Towerops.Repo.Migrations.AddFoo, :change, 0})
end
test "Helper modules are ignored" do
assert Unused.keep?({ToweropsWeb.SomeHelpers, :format, 1})
end
test "Inspect protocol implementations are ignored" do
assert Unused.keep?({Inspect.Towerops.Devices.Device, :inspect, 2})
end
end
describe "keep?/1 — Mix tasks" do
test "run/1 on Mix.Tasks.* is kept" do
assert Unused.keep?({MyTask, :run, 1})
end
test "clean/0 on Mix.Tasks.* is kept" do
assert Unused.keep?({Mix.Tasks.Compile.Foo, :clean, 0})
end
test "regular functions on Mix.Tasks.* still surface as unused" do
refute Unused.keep?({MyTask, :helper, 1})
end
end
describe "keep?/1 — protocol implementations" do
test "Jason.Encoder implementations are kept" do
assert Unused.keep?({Jason.Encoder.Towerops.EctoTypes.IpAddress, :encode, 2})
end
test "Enumerable / Collectable / String.Chars implementations are kept" do
assert Unused.keep?({Enumerable.MyType, :reduce, 3})
assert Unused.keep?({Collectable.MyType, :into, 1})
assert Unused.keep?({String.Chars.MyType, :to_string, 1})
end
test "Ecto.Type / Phoenix.HTML.Safe implementations are kept" do
assert Unused.keep?({Ecto.Type.MyType, :type, 0})
assert Unused.keep?({Phoenix.HTML.Safe.Tuple, :to_iodata, 1})
end
end
describe "keep?/1 — generated names" do
test "compiler-generated names starting with __ are kept" do
assert Unused.keep?({MyMod, :__struct__, 0})
assert Unused.keep?({MyMod, :__schema__, 1})
assert Unused.keep?({MyMod, :__changeset__, 0})
assert Unused.keep?({MyMod, :__protocol__, 1})
end
end
describe "keep?/1 — regular user code" do
test "an ordinary public function in an ordinary module is NOT kept" do
refute Unused.keep?({Towerops.Vault, :decrypt!, 1})
refute Unused.keep?({Towerops.Sites, :list_sites, 1})
end
end
describe "unused/0" do
test "returns a (possibly empty) list of MFA tuples with location" do
# Smoke test — just ensure the function runs against the current
# build dir without crashing. The exact list is environment-dependent.
result = Unused.unused()
assert is_list(result)
Enum.each(result, fn entry ->
assert {mod, fun, arity, file, line} = entry
assert is_atom(mod)
assert is_atom(fun)
assert is_integer(arity)
assert is_binary(file)
assert is_integer(line)
end)
end
end
end