feat: implement mix_unused-style 'mix unused' task using Erlang xref
This commit is contained in:
parent
bbfdea46e2
commit
3397e1ce40
4 changed files with 525 additions and 0 deletions
132
lib/mix/tasks/unused.ex
Normal file
132
lib/mix/tasks/unused.ex
Normal 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
228
lib/towerops/unused.ex
Normal 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
|
||||||
|
|
@ -62,6 +62,17 @@ defmodule Towerops.Profiles.ProfileWatcherTest do
|
||||||
end
|
end
|
||||||
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
|
describe "GenServer init/handle_info" do
|
||||||
test "init/1 returns :ok with a state map" do
|
test "init/1 returns :ok with a state map" do
|
||||||
assert {:ok, state} = ProfileWatcher.init([])
|
assert {:ok, state} = ProfileWatcher.init([])
|
||||||
|
|
|
||||||
154
test/towerops/unused_test.exs
Normal file
154
test/towerops/unused_test.exs
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue