prop/lib/mix/tasks/unused.ex
Graham McIntire 316fb2fbc7
Fix low-severity bugs and re-enable Credo checks
- Bug #12: Lower rate limit to 15/min
- Bug #13: Wrap model loading in Task.start
- Bug #14: Fix classify_time_period guard gap at -3.0
- Bug #15: Not applicable (Elixir has no ?? operator)
- Bug #16: Remove fallback Repo.get for station preload
- Bug #17: Extract cache_key() in ContactMapController
- Bug #18: Add has_many :contacts and :beacons to User schema
- A&D #4: Move serve_markdown_if_requested after secure headers
- Config #1: Move signing_salt to runtime.exs env var
- Config #2: Use --check-unused instead of --unused
- Config #3: Re-enable UnsafeToAtom Credo check
- Config #5: Re-enable LeakyEnvironment Credo check
- Add @spec annotations to fix re-enabled Specs violations
- Replace String.to_atom with to_existing_atom where guarded
2026-05-29 17:29:22 -05:00

479 lines
17 KiB
Elixir

defmodule Mix.Tasks.Unused do
@shortdoc "Find unused functions in the project"
@moduledoc """
Finds unused functions in the project by analyzing BEAM bytecode.
Compares the set of defined functions against the set of called
functions across all compiled `.beam` files in `_build/`.
## Usage
mix unused
mix unused --verbose
mix unused --include-test
mix unused --skip-external
## Options
* `--verbose` — show every function checked, not just unused ones
* `--include-test` — also scan `_build/test/` (scans `_build/dev/` by default)
* `--skip-external` — only check intra-project calls, skip external deps
## How it works
For each BEAM file in the target build directory, the task reads the
`abstract_code` chunk to extract:
1. Every function definition (public and private).
2. Every function call site — local, remote, and qualified Erlang calls.
Functions that appear in the definition set but never in the call set
are reported as unused. Ignored functions include:
* `__info__`, `__struct__`, `__struct__!` — compiler-generated
* `init`, `handle_call`, `handle_cast`, `handle_info`, `terminate`,
`handle_continue`, `code_change` — OTP callbacks
* `child_spec`, `start_link`, `mount`, `handle_params`, `handle_event`,
`handle_async`, `render` — Phoenix/LiveView callbacks
* Module names ending in `.Test` or `.Fixtures` — test-only modules
"""
use Mix.Task
# Callbacks that are invoked by the runtime, not by direct function calls.
# They look unused to static analysis but are actually called by OTP /
# Phoenix / LiveView machinery.
@ignored_functions MapSet.new([
# Compiler-generated
{:__info__, 1},
{:__struct__, 0},
{:__struct__, 1},
{:__struct__!, 0},
{:__struct__!, 1},
{:__impl__, 1},
{:__behaviour_info__, 1},
{:__schema__, 1},
{:__schema__, 2},
{:__changeset__, 0},
{:__changeset__, 1},
{:__meta__, 1},
{:__repo__, 0},
{:__repo__, 1},
{:__validates__, 0},
{:__validates__, 1},
{:module_info, 0},
{:module_info, 1},
# OTP / GenServer
{:init, 0},
{:init, 1},
{:handle_call, 3},
{:handle_cast, 2},
{:handle_info, 2},
{:terminate, 2},
{:handle_continue, 2},
{:code_change, 3},
{:child_spec, 1},
{:start_link, 0},
{:start_link, 1},
{:config_change, 3},
{:format_status, 2},
{:system_continue, 3},
{:system_terminate, 4},
{:system_get_state, 1},
{:system_replace_state, 2},
# Phoenix / LiveView
{:mount, 3},
{:handle_params, 3},
{:handle_event, 3},
{:handle_async, 3},
{:render, 1},
{:render, 2},
{:__live__, 0},
{:__live__, 1},
# Plug
{:init, 1},
{:call, 2},
# Ecto (changesets, validations — called by Ecto machinery)
{:changeset, 2},
{:changeset, 3},
{:validate, 2},
{:validate, 3},
{:prepare_changes, 2},
# Ecto.Repo callbacks (called by Ecto via macros, not by direct calls)
{:__adapter__, 0},
{:__adapter__, 1},
{:aggregate, 2},
{:aggregate, 3},
{:aggregate, 4},
{:all, 1},
{:all, 2},
{:all_by, 2},
{:all_by, 3},
{:checked_out?, 0},
{:config, 0},
{:delete, 1},
{:delete, 2},
{:delete!, 1},
{:delete!, 2},
{:delete_all, 1},
{:delete_all, 2},
{:exists?, 1},
{:exists?, 2},
{:get, 1},
{:get, 2},
{:get!, 1},
{:get!, 2},
{:get_by, 1},
{:get_by, 2},
{:get_by!, 1},
{:get_by!, 2},
{:in_transaction?, 0},
{:insert, 1},
{:insert, 2},
{:insert!, 1},
{:insert!, 2},
{:insert_all, 2},
{:insert_all, 3},
{:insert_or_update, 1},
{:insert_or_update, 2},
{:insert_or_update!, 1},
{:insert_or_update!, 2},
{:one, 1},
{:one, 2},
{:one!, 1},
{:one!, 2},
{:preload, 2},
{:preload, 3},
{:put_defaults, 1},
{:query, 1},
{:query!, 1},
{:query_many, 1},
{:query_many!, 1},
{:reload, 1},
{:reload, 2},
{:replica, 0},
{:rollback, 1},
{:stream, 1},
{:stream, 2},
{:transaction, 1},
{:transaction, 2},
{:update, 1},
{:update, 2},
{:update!, 1},
{:update!, 2},
{:update_all, 1},
{:update_all, 2},
{:upsert, 1},
{:upsert, 2},
{:upsert!, 1},
{:upsert!, 2},
{:checkout, 1},
{:checkout, 2},
{:disconnect_all, 1},
{:explain, 2},
{:load, 2},
{:prepare_query, 3},
{:put_dynamic_repo, 1},
{:query, 2},
{:query!, 2},
{:query_many, 2},
{:query_many!, 2},
{:reload!, 1},
{:stop, 0},
{:stop, 1},
{:to_sql, 2},
{:transact, 1},
{:transact, 2},
# Ecto schema timestamps
{:timestamps, 0},
{:timestamps, 1},
# Oban Worker pattern — `perform/1`, `new/1`, `timeout/1`
{:perform, 1},
{:new, 1},
{:new, 2},
{:timeout, 1},
{:backoff, 1},
# Protocol implementations are called via dispatch
{:impl_for, 1},
{:impl_for!, 1},
{:behaviour_info, 1},
# PromEx
{:event_metrics, 1},
{:polling_metrics, 1},
{:manual_metrics, 1},
{:dashboards, 1},
# PromEx internal callbacks
{:__dashboard_uploader_name__, 0},
{:__grafana_agent_name__, 0},
{:__grafana_client_name__, 0},
{:__grafana_dashboard_uid__, 3},
{:__grafana_folder_uid__, 0},
{:__lifecycle_annotator_name__, 0},
{:__manual_metrics_name__, 0},
{:__metrics_collector_name__, 0},
{:__metrics_server_name__, 0},
{:__otp_app__, 0},
{:__store__, 0},
{:dashboard_assigns, 0},
# Phoenix / LiveView compiler-generated
{:__components__, 0},
{:__phoenix_component_verify__, 1},
{:__phoenix_verify_routes__, 1},
{:__mix_recompile__?, 0},
# Phoenix LiveTable
{:"MACRO-__using__", 2},
# QRZ XML parser macros
{:"MACRO-xmlElement", 1},
{:"MACRO-xmlElement", 3},
{:"MACRO-xmlText", 1},
{:"MACRO-xmlText", 3},
# Phoenix controller actions (called by router dispatch)
{:index, 2},
{:show, 2},
{:edit, 2},
{:new, 2},
{:create, 2},
{:update, 2},
{:delete, 2},
{:confirm, 2},
{:home, 2},
{:check, 2},
{:live, 2},
{:skill, 2},
{:catalog, 2},
{:openapi, 2},
{:cells, 2},
{:redirect_contact, 2},
{:redirect_contacts, 2},
{:confirm_email, 2},
{:on_mount, 4}
])
# Modules whose functions should never be reported as unused because
# they are entry points (Mix tasks, application callbacks, etc.)
@entrypoint_modules MapSet.new([
"Elixir.Microwaveprop.Application",
"Elixir.MicrowavepropWeb.Endpoint",
"Elixir.MicrowavepropWeb.Router",
"Elixir.MicrowavepropWeb.Telemetry",
"Elixir.Microwaveprop.Release"
])
# Function names that indicate an entry point (never unused)
@entrypoint_functions MapSet.new([
{:start, 2},
{:run, 1},
{:run, 2},
{:evaluate, 2},
{:evaluate, 3},
{:all_features, 0},
{:parse_spot, 1},
{:path_key, 1},
{:merge_spot, 2}
])
@impl true
def run(args) do
{opts, _} =
OptionParser.parse!(args,
strict: [
verbose: :boolean,
skip_external: :boolean
]
)
verbose = Keyword.get(opts, :verbose, false)
skip_external = Keyword.get(opts, :skip_external, false)
beam_dir = Path.join(Mix.Project.build_path(), "lib/microwaveprop/ebin")
if !File.dir?(beam_dir) do
Mix.shell().error("BEAM directory not found: #{beam_dir}")
Mix.shell().info("Run `mix compile` first.")
exit({:shutdown, 1})
end
Mix.shell().info("Scanning #{beam_dir}...")
beam_files =
Path.wildcard(Path.join(beam_dir, "Elixir.Microwaveprop*.beam")) ++
Path.wildcard(Path.join(beam_dir, "Elixir.Mix.Tasks*.beam"))
if beam_files == [] do
Mix.shell().error("No BEAM files found in #{beam_dir}")
exit({:shutdown, 1})
end
# Collect definitions and call sites from each module
{all_defs, all_calls} =
Enum.reduce(beam_files, {MapSet.new(), MapSet.new()}, fn beam_file, {defs_acc, calls_acc} ->
{mod_defs, mod_calls} = analyze_beam(beam_file)
{MapSet.union(defs_acc, mod_defs), MapSet.union(calls_acc, mod_calls)}
end)
# Filter out external calls (stdlib, deps) when --skip-external is set
all_calls =
if skip_external do
all_calls
|> Enum.filter(fn {mod, _name, _arity} ->
String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop")
end)
|> MapSet.new()
else
all_calls
end
# Find unused: defined but never called
unused =
all_defs
|> Enum.reject(fn {mod, name, arity} ->
# Skip ignored functions (OTP callbacks, compiler-generated, etc.)
# Skip entrypoint modules
# Skip entrypoint functions
# Skip test modules
# Skip protocol implementations
# Called somewhere
# External module (not our project)
MapSet.member?(@ignored_functions, {name, arity}) or
MapSet.member?(@entrypoint_modules, Atom.to_string(mod)) or
MapSet.member?(@entrypoint_functions, {name, arity}) or
String.ends_with?(Atom.to_string(mod), "Test") or
String.ends_with?(Atom.to_string(mod), "Fixtures") or
String.contains?(Atom.to_string(mod), ".Impl.") or
MapSet.member?(all_calls, {mod, name, arity}) or
(skip_external and not String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop"))
end)
|> Enum.sort()
if unused == [] do
Mix.shell().info("No unused functions found.")
else
Mix.shell().info("\nUnused functions (#{length(unused)}):\n")
# Group by module
unused
|> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)})
|> Enum.sort()
|> Enum.each(fn {mod, functions} ->
Mix.shell().info(" #{mod}")
Enum.each(functions, fn {name, arity} ->
Mix.shell().info(" #{name}/#{arity}")
end)
end)
end
# Verbose: show all functions checked
if verbose do
Mix.shell().info("\n--- All defined functions ---")
all_defs
|> Enum.sort()
|> Enum.each(fn {mod, name, arity} ->
status = if MapSet.member?(all_calls, {mod, name, arity}), do: "CALLED", else: "UNUSED"
Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]")
end)
end
end
defp analyze_beam(beam_file) do
module = beam_module(beam_file)
case :beam_lib.chunks(String.to_charlist(beam_file), [:abstract_code]) do
{:ok, {_mod, chunks}} ->
abstract_code = extract_abstract_code(chunks)
defs = find_definitions(module, abstract_code)
calls = find_calls(module, abstract_code)
{defs, calls}
{:error, _reason} ->
# Binary module or unreadable — skip
{MapSet.new(), MapSet.new()}
end
end
defp beam_module(path) do
path
|> Path.basename()
|> Path.rootname()
|> String.to_existing_atom()
end
defp extract_abstract_code(chunks) do
case Keyword.get(chunks, :abstract_code) do
{:raw_abstract_v1, code} -> code
_ -> []
end
end
# Walk the abstract syntax tree to find function definitions.
# Returns MapSet of {module, name, arity} tuples.
defp find_definitions(module, abstract_code) do
Enum.reduce(abstract_code, MapSet.new(), fn
{:function, _line, name, arity, _clauses}, acc ->
MapSet.put(acc, {module, name, arity})
_other, acc ->
acc
end)
end
# Walk the abstract syntax tree to find function call sites.
# Returns MapSet of {target_module, name, arity} tuples.
defp find_calls(module, abstract_code) do
abstract_code
|> Enum.flat_map(&extract_calls/1)
|> MapSet.new(fn
{:local, name, arity} -> {module, name, arity}
{:remote, mod, name, arity} -> {mod, name, arity}
end)
end
# Extract function calls from a single AST form (recursive).
defp extract_calls(form) when is_tuple(form) do
case form do
# Local call: func(args) where func is an atom
{:call, _line, {:atom, _line2, name}, args} when is_list(args) ->
[{:local, name, length(args)} | extract_calls_in_args(args)]
# Remote call: Mod.func(args) where both Mod and func are atoms
{:call, _line, {:remote, _line2, {:atom, _line3, mod}, {:atom, _line4, name}}, args}
when is_list(args) ->
[{:remote, mod, name, length(args)} | extract_calls_in_args(args)]
# Block / do-end — recurse into expressions
{:block, _line, exprs} when is_list(exprs) ->
Enum.flat_map(exprs, &extract_calls/1)
# Case / try / receive clause: pattern, guards, body
{:clause, _line, _args, _guards, body} when is_list(body) ->
Enum.flat_map(body, &extract_calls/1)
# Match: lhs = rhs
{:match, _line, _lhs, rhs} ->
extract_calls(rhs)
# Other compound forms — recurse into list and tuple children
_ ->
form
|> Tuple.to_list()
|> Enum.flat_map(fn
v when is_list(v) -> Enum.flat_map(v, &extract_calls/1)
v when is_tuple(v) -> extract_calls(v)
_ -> []
end)
end
end
defp extract_calls(_not_a_tuple), do: []
defp extract_calls_in_args(args) do
Enum.flat_map(args, fn
arg when is_tuple(arg) -> extract_calls(arg)
arg when is_list(arg) -> Enum.flat_map(arg, &extract_calls/1)
_ -> []
end)
end
end