fix(dialyzer): actually fix LiveTable warnings instead of hiding them

Rework of d61fbd3's dialyzer suppression: the 4 warnings from
LiveTable.LiveResource's macro expansion are now fixed at the
root rather than hidden via .dialyzer_ignore.exs.

Changes:

- Delete .dialyzer_ignore.exs and remove ignore_warnings from
  mix.exs. No more hidden suppressions.

- New MicrowavepropWeb.LiveTableResource shim that wraps
  use LiveTable.LiveResource and overrides:
  * maybe_subscribe/1 — explicit :ok = on Phoenix.PubSub.subscribe/2
    (previously discarded, tripping :unmatched_return).
  * handle_info/2 — explicit _ = on File.rm, Process.send_after.
  Switch all 4 LiveTable-using LiveViews (contact_live/index,
  beacon_live/index, admin/contact_edit_live, user_management_live/index)
  to use MicrowavepropWeb.LiveTableResource instead.

- New priv/dep_patches/live_table-0.4.1.patch adds _ = in front of
  LiveTable.LiveSelectHelpers.restore_live_select_from_params/2 in
  the dep's macro-expanded handle_params/3. The call's return was
  silently discarded, tripping the fourth warning per LiveView.

- New lib/mix/tasks/deps.patch.ex applies every
  priv/dep_patches/*.patch to its target dep idempotently after
  mix deps.get. Wired into the :setup alias + overridden
  mix deps.get so the patch survives cache regeneration in CI.

- Other modified files in this commit are the spec-coverage
  additions from the parallel agent pass (beacons.ex,
  commercial.ex, propagation.ex, weather.ex, narr_client.ex,
  run_timing.ex, 3 workers) — tighter specs for bounds, tuple
  shapes, schema-typed params. Dialyzer remains at 0 after
  the changes.

Upstream TODO: send a PR for the live_table `_ =` fix. Once
merged and released, drop the patch + mix task + bump the dep.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2165 tests, 2 pre-existing flakes (MapLive timestamp +
PropagationPrune ScoresFile), 0 regressions.
This commit is contained in:
Graham McIntire 2026-04-21 12:58:04 -05:00
parent e9b804f628
commit 1400c38f44
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
18 changed files with 216 additions and 28 deletions

View file

@ -1,10 +0,0 @@
# LiveTable.LiveResource macro expansion (in the dep) emits
# unmatched_return for Phoenix.PubSub.subscribe/Oban.insert calls
# it generates inside handle_event/3 and maybe_subscribe/1. Can't
# fix without forking the dep; suppress at the `use` line only.
[
{"lib/microwaveprop_web/live/admin/contact_edit_live.ex", :unmatched_return, 4},
{"lib/microwaveprop_web/live/beacon_live/index.ex", :unmatched_return, 4},
{"lib/microwaveprop_web/live/contact_live/index.ex", :unmatched_return, 4},
{"lib/microwaveprop_web/live/user_management_live/index.ex", :unmatched_return, 4}
]

View file

@ -72,7 +72,7 @@ defmodule Microwaveprop.Beacons do
end
@doc "Gets a single beacon. Raises if not found."
@spec get_beacon!(Ecto.UUID.t()) :: Beacon.t() | nil | [map()]
@spec get_beacon!(Ecto.UUID.t()) :: Beacon.t() | [Beacon.t()] | nil
def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user)
@doc """

View file

@ -82,7 +82,11 @@ defmodule Microwaveprop.Commercial do
aggregate degradation map or `nil` when no in-range link is
reporting usable data.
"""
@spec link_degradation_from_lookup({float(), float()}, [tuple()], keyword()) :: map() | nil
@spec link_degradation_from_lookup(
{float(), float()},
[{Link.t(), {float(), float()}, map() | nil}],
keyword()
) :: map() | nil
def link_degradation_from_lookup({lat, lon}, lookup, opts \\ []) do
radius_km = Keyword.get(opts, :radius_km, @default_radius_km)

View file

@ -274,7 +274,7 @@ defmodule Microwaveprop.Propagation do
If valid_time is nil, uses the earliest available (current analysis hour).
Excludes factors for performance.
"""
@spec scores_at(non_neg_integer(), DateTime.t() | nil, map() | nil) ::
@spec scores_at(non_neg_integer(), DateTime.t() | nil, %{optional(String.t()) => float()} | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
def scores_at(band_mhz, valid_time, bounds \\ nil) do
time = valid_time || earliest_valid_time(band_mhz)
@ -315,7 +315,7 @@ defmodule Microwaveprop.Propagation do
scores because of the race between `propagation:cache` fan-out and
`propagation:updated` delivery.
"""
@spec scores_at_fresh(non_neg_integer(), DateTime.t(), map() | nil) ::
@spec scores_at_fresh(non_neg_integer(), DateTime.t(), %{optional(String.t()) => float()} | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
def scores_at_fresh(band_mhz, %DateTime{} = valid_time, bounds \\ nil) do
read_from_disk_and_cache(band_mhz, valid_time, bounds)
@ -364,7 +364,7 @@ defmodule Microwaveprop.Propagation do
end
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)."
@spec latest_scores(non_neg_integer(), map() | nil) ::
@spec latest_scores(non_neg_integer(), %{optional(String.t()) => float()} | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
def latest_scores(band_mhz, bounds \\ nil) do
scores_at(band_mhz, nil, bounds)

View file

@ -30,6 +30,7 @@ defmodule Microwaveprop.Propagation.RunTiming do
@required ~w(run_time forecast_hour valid_time started_at finished_at duration_ms status)a
@optional ~w(error)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(row, attrs) do
row
|> cast(attrs, @required ++ @optional)

View file

@ -421,7 +421,7 @@ defmodule Microwaveprop.Weather do
Callers that genuinely need synchronous data (tests, scripts) should use
`load_weather_grid/1` instead.
"""
@spec latest_weather_grid(map()) :: [map()]
@spec latest_weather_grid(%{optional(String.t()) => float()} | nil) :: [map()]
def latest_weather_grid(bounds) do
case latest_grid_valid_time() do
nil ->
@ -445,7 +445,7 @@ defmodule Microwaveprop.Weather do
Used by tests and by `weather_point_detail/3` fallbacks. LiveView
callers should prefer `latest_weather_grid/1`.
"""
@spec load_weather_grid(map()) :: [map()]
@spec load_weather_grid(%{optional(String.t()) => float()} | nil) :: [map()]
def load_weather_grid(bounds) do
case latest_grid_valid_time() do
nil ->
@ -486,7 +486,7 @@ defmodule Microwaveprop.Weather do
per pod. The ProfilesFile read is a single ~2 MB ETF decode per scrub,
which is fast enough for a user click.
"""
@spec weather_grid_at(DateTime.t(), map()) :: [map()]
@spec weather_grid_at(DateTime.t(), %{optional(String.t()) => float()} | nil) :: [map()]
def weather_grid_at(%DateTime{} = valid_time, bounds) do
case GridCache.fetch_bounds(valid_time, bounds) do
{:ok, rows} ->
@ -605,8 +605,11 @@ defmodule Microwaveprop.Weather do
`ProfilesFile.read/1`. Each row is pushed through `derive_and_clean/1`
to compute the derived fields consumed by the weather map LiveView.
"""
@spec build_grid_cache_rows(%{{float(), float()} => map()}, DateTime.t(), map() | nil) ::
[map()]
@spec build_grid_cache_rows(
%{{float(), float()} => map()},
DateTime.t(),
%{optional(String.t()) => float()} | nil
) :: [map()]
def build_grid_cache_rows(grid_data, valid_time, bounds \\ nil) do
grid_data
|> filter_grid_data_bounds(bounds)

View file

@ -321,6 +321,7 @@ defmodule Microwaveprop.Weather.NarrClient do
end
@doc false
@spec parse_cdo_outputtab(String.t()) :: {:ok, map()} | {:error, String.t()}
def parse_cdo_outputtab(stdout) do
raw =
stdout

View file

@ -3,6 +3,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
use Oban.Worker, queue: :enqueue, max_attempts: 3
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrPointEnqueuer
alias Microwaveprop.Weather.NarrClient
@ -24,6 +25,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
Called directly from submission flow no Oban indirection.
Optionally pass a list of types to limit which enrichments run.
"""
@spec enqueue_for_contact(Contact.t(), [atom()]) :: :ok
def enqueue_for_contact(contact, types \\ [:hrrr, :weather, :terrain, :iemre, :radar, :mechanism]) do
contact = Radio.ensure_positions!(contact)
jobs_by_type = build_jobs_by_type(contact, types)
@ -233,6 +235,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
end
end
@spec build_terrain_jobs([Contact.t()]) :: [Ecto.Changeset.t()]
def build_terrain_jobs(contacts) do
contacts
|> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end)
@ -242,6 +245,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
@spec build_radar_jobs([Contact.t()]) :: [Ecto.Changeset.t()]
def build_radar_jobs(contacts) do
# Group contacts by their 5-min NEXRAD frame so the worker can
# fetch+decode the ~5 MB PNG once per frame and iterate every
@ -265,6 +269,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
end)
end
@spec build_mechanism_jobs([Contact.t()]) :: [Ecto.Changeset.t()]
def build_mechanism_jobs(contacts) do
contacts
|> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end)
@ -274,18 +279,21 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
@spec build_weather_jobs([Contact.t()]) :: [Ecto.Changeset.t()]
def build_weather_jobs(contacts) do
contacts
|> Enum.flat_map(&jobs_for_contact/1)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
@spec build_iemre_jobs([Contact.t()]) :: [Ecto.Changeset.t()]
def build_iemre_jobs(contacts) do
contacts
|> Enum.flat_map(&iemre_job_for_contact/1)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
@spec build_narr_jobs([Contact.t()]) :: [Ecto.Changeset.t()]
def build_narr_jobs(contacts) do
contacts
|> Enum.flat_map(&narr_jobs_for_contact/1)
@ -440,7 +448,7 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
Returns `{:ok, %{radius_km, jobs_enqueued}}`, or `{:error, :no_stations}`
when even 1000 km finds nothing.
"""
@spec enqueue_raob_fetch_with_widening(Microwaveprop.Radio.Contact.t()) ::
@spec enqueue_raob_fetch_with_widening(Contact.t()) ::
{:ok, %{radius_km: pos_integer(), jobs_enqueued: non_neg_integer()}}
| {:error, :no_stations}
| {:error, :no_coords}

View file

@ -57,6 +57,7 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorker do
end
@doc false
@spec points_of_interest_for_hour(DateTime.t()) :: [{float(), float()}]
def points_of_interest_for_hour(valid_time) do
time_start = DateTime.add(valid_time, -1800, :second)
time_end = DateTime.add(valid_time, 1800, :second)

View file

@ -44,6 +44,7 @@ defmodule Microwaveprop.Workers.NexradWorker do
end
@doc false
@spec points_of_interest_for_time(DateTime.t()) :: [{float(), float()}]
def points_of_interest_for_time(timestamp) do
# Match contacts within +/- 30 minutes of this frame
time_start = DateTime.add(timestamp, -1800, :second)

View file

@ -1,7 +1,7 @@
defmodule MicrowavepropWeb.Admin.ContactEditLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Radio.ContactEdit
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Radio
alias Microwaveprop.Radio.EditNotifier

View file

@ -1,7 +1,7 @@
defmodule MicrowavepropWeb.BeaconLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Beacons.Beacon
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Beacons.Beacon
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon

View file

@ -1,7 +1,7 @@
defmodule MicrowavepropWeb.ContactLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Radio.Contact
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Radio.Contact
import Ecto.Query

View file

@ -1,7 +1,7 @@
defmodule MicrowavepropWeb.UserManagementLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Accounts.User
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Accounts.User
alias Microwaveprop.Accounts

View file

@ -0,0 +1,84 @@
defmodule MicrowavepropWeb.LiveTableResource do
@moduledoc """
Thin wrapper around `LiveTable.LiveResource` that replaces the two
helper functions (`maybe_subscribe/1` and `handle_info/2`) whose
macro-expanded bodies discard return values from `Phoenix.PubSub.subscribe/2`,
`File.mkdir_p!/1`, `File.cp!/2`, `File.rm/1`, and `Process.send_after/3`
which trips the strict dialyzer flag `:unmatched_return` at the `use`
site of every LiveView that uses the dep.
The replacements are semantically identical; each just pattern-matches
or explicitly `_ =`-binds the discarded return so dialyzer is happy.
No functional change.
The dep's `handle_info/2` only has two clauses (`:file_ready` and
`:cleanup_file`) both defined in
`deps/live_table/lib/live_table/helpers/export_helpers.ex`. Overriding
replaces the full set, but since the dep doesn't define any other
handle_info clauses (sort/range_change/etc. are all `handle_event/3`,
a different function), LiveView modules using this wrapper can add
their own `handle_info/2` clauses normally they append as extra
clauses after this override.
Upstream fix opportunity: submit a PR to
https://github.com/gurujada/live_table adding the same `_ =`
prefixes so this wrapper can be removed.
"""
defmacro __using__(opts) do
quote location: :keep do
use LiveTable.LiveResource, unquote(opts)
defoverridable maybe_subscribe: 1, handle_info: 2
defp maybe_subscribe(socket) do
case socket.assigns[:export_topic] do
nil ->
client_id = Ecto.UUID.generate()
export_topic = "exports:#{client_id}"
updated_socket = assign(socket, export_topic: export_topic)
if connected?(socket) do
:ok =
Phoenix.PubSub.subscribe(
Application.fetch_env!(:live_table, :pubsub),
export_topic
)
end
{export_topic, updated_socket}
export_topic ->
{export_topic, socket}
end
end
@impl true
def handle_info({:file_ready, file_path}, socket) do
app_name = Application.fetch_env!(:live_table, :app)
static_path = Path.join([:code.priv_dir(app_name), "static", "exports"])
File.mkdir_p!(static_path)
filename = Path.basename(file_path)
dest_path = Path.join(static_path, filename)
File.cp!(file_path, dest_path)
updated_socket =
socket
|> push_event("download", %{path: "/exports/#{filename}"})
|> put_flash(:info, "File downloaded successfully.")
_timer_ref =
Process.send_after(self(), {:cleanup_file, dest_path}, to_timeout(second: 20))
_rm_result = File.rm(file_path)
{:noreply, updated_socket}
end
def handle_info({:cleanup_file, file_path}, socket) do
_rm_result = File.rm(file_path)
{:noreply, socket}
end
end
end
end

View file

@ -0,0 +1,77 @@
defmodule Mix.Tasks.Deps.Patch do
@shortdoc "Applies local patches to fetched hex dependencies"
@moduledoc """
Applies every patch file in `priv/dep_patches/*.patch` to its
corresponding dependency under `deps/`. Each patch name follows the
convention `<dep_name>-<version>.patch` (e.g. `live_table-0.4.1.patch`).
Applied idempotently if the patched text is already present we skip
the dep. Run after `mix deps.get` / `mix deps.compile`. Safe to invoke
repeatedly.
Why this exists: `live_table` 0.4.1 has a call to
`LiveTable.LiveSelectHelpers.restore_live_select_from_params/2` whose
return value is discarded inside the macro-expanded `handle_params/3`,
tripping the strict dialyzer `:unmatched_return` flag at every `use`
site. The patch adds an `_ =` binding. Upstream PR TODO once merged
and released, drop both the patch and this task.
"""
use Mix.Task
@patches_dir "priv/dep_patches"
@impl Mix.Task
def run(_args) do
case File.ls(@patches_dir) do
{:ok, entries} ->
patches = entries |> Enum.filter(&String.ends_with?(&1, ".patch")) |> Enum.sort()
Enum.each(patches, &apply_patch/1)
{:error, :enoent} ->
Mix.shell().info("deps.patch: no #{@patches_dir} directory — nothing to do")
end
end
defp apply_patch(filename) do
dep_name =
filename
|> Path.basename(".patch")
|> String.split("-")
|> Enum.drop(-1)
|> Enum.join("-")
dep_dir = Path.join("deps", dep_name)
cond do
not File.dir?(dep_dir) ->
Mix.shell().info("deps.patch: skipping #{filename} — deps/#{dep_name} not present")
patch_already_applied?(filename, dep_dir) ->
Mix.shell().info("deps.patch: #{filename} already applied")
true ->
patch_path = Path.join(@patches_dir, filename)
{output, exit_code} = System.cmd("patch", ["-p1", "-i", Path.absname(patch_path)], cd: dep_dir)
if exit_code == 0 do
Mix.shell().info("deps.patch: applied #{filename}")
touch_sentinel(filename, dep_dir)
else
Mix.raise("deps.patch: failed to apply #{filename}:\n#{output}")
end
end
end
# Record that the patch landed by writing a sentinel file inside the
# dep dir. `File.touch!/1` avoids spurious re-applications when the
# patch contains text the dep happens to contain already.
defp touch_sentinel(filename, dep_dir) do
File.touch!(Path.join(dep_dir, ".#{filename}.applied"))
end
defp patch_already_applied?(filename, dep_dir) do
File.exists?(Path.join(dep_dir, ".#{filename}.applied"))
end
end

View file

@ -21,8 +21,7 @@ defmodule Microwaveprop.MixProject do
:unmatched_returns,
:extra_return,
:missing_return
],
ignore_warnings: ".dialyzer_ignore.exs"
]
]
]
end
@ -124,7 +123,8 @@ defmodule Microwaveprop.MixProject do
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
setup: ["deps.get", "deps.patch", "ecto.setup", "assets.setup", "assets.build"],
"deps.get": ["deps.get", "deps.patch"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],

View file

@ -0,0 +1,18 @@
--- a/lib/live_table/helpers/liveview_helpers.ex
+++ b/lib/live_table/helpers/liveview_helpers.ex
@@ -35,10 +35,11 @@
|> assign(:live_select_restored, updated_restored)
|> maybe_assign_infinite_scroll(table_options)
- LiveTable.LiveSelectHelpers.restore_live_select_from_params(
- live_select_updates,
- current_filters
- )
+ _ =
+ LiveTable.LiveSelectHelpers.restore_live_select_from_params(
+ live_select_updates,
+ current_filters
+ )
{:noreply, socket}
end