Mutare.Poison.Hint (mutare v0.4.1)

Copy Markdown View Source

Turns one unrecoverable compile failure into advice the user can act on.

Almost every compile-poisoning mutation is recovered automatically — Mutare finds the offending mutant, drops it, and rebuilds. One case can't be: a macro that requires a compile-time literal argument (Size.megabytes(5) and the like). Mutating the literal turns it into runtime code, the macro rejects it and raises while the compiler is expanding it, and because the compiler reports the error at the macro call rather than at the mutation inside it, Mutare cannot identify which single mutant to drop. The whole run aborts.

The way out is to leave that macro's arguments as written by routing it :raw in .mutare.exs (the :call_routes option). This module recognises the situation from the failed compile's output, identifies the macro(s) at fault, and produces that advice — including a copy-pasteable snippet — so the error the user sees explains how to get unblocked instead of just echoing the raw compiler error.

Returns nil for any other compile failure, where the raw error stands on its own.

Summary

Functions

The culprit macro of each stacktrace in output, paired with where it was invoked: {{module_string, function_atom}, {file, line} | nil}, in source order — one entry per stacktrace whose innermost frame names a macro we can advise on.

A copy-pasteable :call_routes suggestion for the block macros a successful run had to escalate (skip wholesale) during compile-poison recovery, or nil when there were none.

The macro(s) to advise skipping, as distinct {module_string, function_atom} pairs read from the expanding macro: frames in output, in first-seen order. [] when there are none. The names of culprits/1, deduplicated.

A copy-pasteable remediation hint for the failed-compile output, or nil.

A copy-pasteable :call_routes suggestion for the inline DSL macros a successful run had to skip via the macro-expansion fallback (Mutare.Poison.macro_poison/4), or nil when there were none.

The copy-pasteable call_routes: entry that leaves module.fun's arguments as written, as source text: {Module, :fun, :raw} for a call, {Module, :fun, :skip} for a head Mutare analyzes structurally (Kernel.in/2 — the only route it accepts); nil for a head no route can name (a definition or compiler syntax such as Kernel.def/2), which only a # mutare:ignore around the offending code can silence. The one place the tuple is spelt, shared by the abort hint, the recovery notes, and the live reporter's ⚠ line.

Functions

culprits(output)

@spec culprits(String.t()) :: [
  {{String.t(), atom()}, {String.t(), pos_integer()} | nil}
]

The culprit macro of each stacktrace in output, paired with where it was invoked: {{module_string, function_atom}, {file, line} | nil}, in source order — one entry per stacktrace whose innermost frame names a macro we can advise on.

Each compile error reports only its innermost macro — the one whose literal argument was actually mutated. A literal-only macro nested inside another (if Size.megabytes(5)) also lists the enclosing macros, but skipping those would needlessly hide valid mutants, so only the culprit is kept (the frames come from Mutare.Sandbox.Command.Output.macro_expansion_stacks/1, innermost first). The call site is the frame's own — the file whose metamutant holds the poisoning mutant — which is why Mutare.Poison.macro_poison/4 reads this rather than expanding_macros/1.

iex> Mutare.Poison.Hint.culprits("expanding macro: Size.megabytes/1\n    lib/a.ex:8: A.f/0\n")
[{{"Size", :megabytes}, {"lib/a.ex", 8}}]

escalation_note(escalations)

@spec escalation_note([Mutare.Run.escalation()]) :: String.t() | nil

A copy-pasteable :call_routes suggestion for the block macros a successful run had to escalate (skip wholesale) during compile-poison recovery, or nil when there were none.

Unlike for_compile_failure/1 — which fires on an unrecoverable abort — this is the advice printed after a run recovered: the metamutant compiled, but only because Mutare guessed a DSL body could be mutated, hit poison, and skipped the block at runtime. That recovery is rediscovered from scratch on every run (the dropped ids are in-memory only), so we hand the user the durable, name-based fix. Each escalated macro becomes a module-wildcard {:*, :name, :raw} route (the invocation's module is an unknown DSL we don't resolve), skipping that macro name wherever it appears.

escalations is Mutare.Run's :recovery.escalated (a list of Mutare.Run.escalation/0).

iex> Mutare.Poison.Hint.escalation_note([%{macro: :guarded, file: "lib/x.ex", line: 3, count: 2}])
...> |> String.contains?("{:*, :guarded, :raw}")
true

expanding_macros(output)

@spec expanding_macros(String.t()) :: [{String.t(), atom()}]

The macro(s) to advise skipping, as distinct {module_string, function_atom} pairs read from the expanding macro: frames in output, in first-seen order. [] when there are none. The names of culprits/1, deduplicated.

iex> Mutare.Poison.Hint.expanding_macros("expanding macro: Size.megabytes/1\n")
[{"Size", :megabytes}]

for_compile_failure(output)

@spec for_compile_failure(String.t()) :: String.t() | nil

A copy-pasteable remediation hint for the failed-compile output, or nil.

Recognises the one poison case Mutare can't recover on its own — a macro that needs a compile-time literal argument — and returns advice on how to skip it. Any other failure returns nil, leaving the raw compiler error to stand alone.

macro_skip_note(macro_skipped)

@spec macro_skip_note([%{module: String.t(), macro: atom()}]) :: String.t() | nil

A copy-pasteable :call_routes suggestion for the inline DSL macros a successful run had to skip via the macro-expansion fallback (Mutare.Poison.macro_poison/4), or nil when there were none.

The sibling of escalation_note/1 for inline macros rather than block macros: a mutation wouldn't compile inside a macro that rewrites its argument at compile time, so Mutare dropped that macro's mutants and rebuilt. Because the compiler named the macro (an expanding macro: frame), the module is known — so unlike the block case's {:*, …} wildcard this suggests the precise {Module, :fun, :raw}. That recovery is rediscovered (and its rebuilds repaid) on every run, so pinning it is the durable fix.

macro_skipped is Mutare.Run's :recovery.macro_skipped (a list of %{module: module_string, macro: fun_atom}).

iex> Mutare.Poison.Hint.macro_skip_note([%{module: "Ecto.Query", macro: :from}])
...> |> String.contains?("{Ecto.Query, :from, :raw}")
true

route_tuple(module, fun)

@spec route_tuple(String.t(), atom()) :: String.t() | nil

The copy-pasteable call_routes: entry that leaves module.fun's arguments as written, as source text: {Module, :fun, :raw} for a call, {Module, :fun, :skip} for a head Mutare analyzes structurally (Kernel.in/2 — the only route it accepts); nil for a head no route can name (a definition or compiler syntax such as Kernel.def/2), which only a # mutare:ignore around the offending code can silence. The one place the tuple is spelt, shared by the abort hint, the recovery notes, and the live reporter's ⚠ line.

iex> Mutare.Poison.Hint.route_tuple("Ecto.Query", :from)
"{Ecto.Query, :from, :raw}"
iex> Mutare.Poison.Hint.route_tuple("Kernel", :in)
"{Kernel, :in, :skip}"
iex> Mutare.Poison.Hint.route_tuple("Kernel", :def)
nil