towerops/lib/towerops/unused.ex

228 lines
7.2 KiB
Elixir

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