228 lines
7.2 KiB
Elixir
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
|