- 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
479 lines
17 KiB
Elixir
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
|