From 3397e1ce40809d685fdf8cb82853ed82c83cabef Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 8 May 2026 12:30:49 -0500 Subject: [PATCH] feat: implement mix_unused-style 'mix unused' task using Erlang xref --- lib/mix/tasks/unused.ex | 132 ++++++++++ lib/towerops/unused.ex | 228 ++++++++++++++++++ .../profiles/profile_watcher_test.exs | 11 + test/towerops/unused_test.exs | 154 ++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 lib/mix/tasks/unused.ex create mode 100644 lib/towerops/unused.ex create mode 100644 test/towerops/unused_test.exs diff --git a/lib/mix/tasks/unused.ex b/lib/mix/tasks/unused.ex new file mode 100644 index 00000000..5534b97f --- /dev/null +++ b/lib/mix/tasks/unused.ex @@ -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//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 diff --git a/lib/towerops/unused.ex b/lib/towerops/unused.ex new file mode 100644 index 00000000..6f97a95c --- /dev/null +++ b/lib/towerops/unused.ex @@ -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//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 ` invokes `Mix.Tasks..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 diff --git a/test/towerops/profiles/profile_watcher_test.exs b/test/towerops/profiles/profile_watcher_test.exs index 293c0d99..8b04b6d8 100644 --- a/test/towerops/profiles/profile_watcher_test.exs +++ b/test/towerops/profiles/profile_watcher_test.exs @@ -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([]) diff --git a/test/towerops/unused_test.exs b/test/towerops/unused_test.exs new file mode 100644 index 00000000..06323ade --- /dev/null +++ b/test/towerops/unused_test.exs @@ -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