A per-mutant map of where each mutant lives in its rendered metamutant.
Mutare.Transform writes the metamutant; this is what Mutare.Poison reads
back. It is built lazily (from_source/1), by Mutare.Poison on a failed
compile, only for the file(s) the error names — not eagerly during the scan,
where re-parsing every rendered metamutant was the scan's dominant cost yet is
read only when a compile actually fails (rare — built-in mutators are
compile-safe). Poison memoizes it within one recovery so a file faulting on
several lines is walked once.
Mutare.Poison wants each mutant's generated ranges — the metamutant line
spans of the code that exists only because of that mutant, so a compile error's
line maps back to the mutant id that owns it.
(Coverage no longer lives here: the metamutant self-records coverage at runtime
— see Mutare.Coverage.Recorder — keyed by mutant id directly, so there is no
metamutant {module, line} location to precompute.)
Why ranges, not a single line
Poison recovery used to match a compile error's line against the start line of a selector clause body only. That missed every poison whose bad code isn't on that exact line:
- lifted mutations — a guard/head-pattern mutant's code lives in a generated
defp <base>(mutare_active, …) when mutare_active === <id> …clause, not in the dispatcher (which only forwards). The error points into that gated clause, away from the dispatcher; - multiline bodies — an in-place mutant whose body spans several lines can fault on any of them, not just the first;
- structural errors — the compiler sometimes points at the surrounding
caserather than a clause body.
So we record, per mutant, the full line ranges of its generated code:
- each selector clause body (
<id> -> <mutated>) — catches in-place mutants, including multiline bodies; - each lifted mutant clause (gated
when mutare_active === <id>) — catches a guard/head-pattern poison, whose code lives away from the dispatcher; - the whole selector
case, attributed to all the mutant ids it hosts — the coarse fallback for a structural error that points at thecaseitself.
ids_at_line/2 resolves an error line by narrowest containing range: a line
inside a specific clause/def range maps to that one mutant; only when nothing
narrower contains it does the whole-case fallback fire (dropping every mutant
the case hosts — a bounded over-drop that still recovers the build, never the
old "couldn't map it → abort").
Ranges are in metamutant line space, which only exists after rendering, so
the manifest is built by re-parsing the rendered metamutant and ranging its
generated nodes with Sourceror.get_range/1, recognising selectors via
Mutare.Metamutant.subject?/2.
The re-parse uses Elixir's own Code.string_to_quoted! (with :token_metadata
:columns), notSourceror.parse_string!.get_range/1only needs that token metadata (:line/:column/:closing/:end/:end_of_expression), which the stdlib parser already produces; whatSourceror.parse_string!adds is a comment-merging pass that is quadratic on a large metamutant (a lifted 1.8 MB file re-parses in ~0.6 s here, versus minutes for Sourceror) and that the manifest never reads. A:literal_encoderreproduces Sourceror's{:__block__, meta, [literal]}wrapping so the recognisers — already tolerant of both shapes — see exactly what they did before; the two parses yield identical ranges. (Sourceror is still the renderer; only this readback parse changed.)
Summary
Functions
Build a manifest from one file's rendered metamutant source.
Mutant ids whose generated code occupies line.
Mutant ids that live inside a call to one of names, grouped by that call's function name.
Types
@type region() :: %{ids: [pos_integer()], lo: pos_integer(), hi: pos_integer()}
A generated line range and the mutant ids whose code occupies it.
@type t() :: %Mutare.Manifest{regions: [region()]}
One file's manifest:
:regions— generated line ranges, for mapping a compile error back to a mutant.
Functions
Build a manifest from one file's rendered metamutant source.
Re-parses (for Sourceror.get_range/1) and walks the tree once, attributing
every selector clause, lifted private definition, and selector case to the
mutant id(s) it belongs to.
Examples
iex> source = "defmodule Demo do\n def add(a, b), do: a + b\nend\n"
iex> result = Mutare.transform_string(source, mutators: [:arithmetic])
iex> %Mutare.Manifest{regions: regions} = Mutare.Manifest.from_source(result.metamutant)
iex> regions == []
false
@spec ids_at_line(t(), pos_integer()) :: [pos_integer()]
Mutant ids whose generated code occupies line.
Resolved by narrowest containing range: a specific clause/def range beats the
coarse whole-case fallback, so a precise error drops exactly the offending
mutant; a structural error that only the case range contains drops every mutant
it hosts. Returns [] when no generated code spans line.
Examples
iex> manifest = %Mutare.Manifest{
...> regions: [
...> %{ids: [1, 2], lo: 3, hi: 8},
...> %{ids: [1], lo: 5, hi: 5}
...> ]
...> }
iex> Mutare.Manifest.ids_at_line(manifest, 5)
[1]
iex> Mutare.Manifest.ids_at_line(manifest, 4)
[1, 2]
iex> Mutare.Manifest.ids_at_line(manifest, 99)
[]
Mutant ids that live inside a call to one of names, grouped by that call's function name.
The macro-expansion fallback's attribution (Mutare.Poison.macro_poison/2): when a
mutation splices a selector case into an argument a macro rewrites at compile time, the
macro raises during expansion and the compiler blames the macro call line — which no
region covers — so ids_at_line/2 finds nothing. Given the macro name from the compiler's
expanding macro: frame, this instead finds every call of that name in the rendered
metamutant, takes its full line range (Sourceror.get_range/1, to the closing
delimiter — so a literal argument on its own line is still spanned), and collects the ids of
every region contained in it.
Works in metamutant space over every reserved mutant — so it is correct under
--line/--max-mutants, where Mutare.Schema's :sites are filtered but the metamutant
still embeds (and can be poisoned by) every id. Returns %{fun_atom => MapSet.t()}, empty
when nothing matched.