A map from mutant ids to their generated line ranges in a rendered metamutant, and a list of every place the generated code names a mutant id.
Mutare.Transform writes the metamutant; this is what Mutare.Poison reads
back. It is built lazily (from_source/2), 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.
The one exception is verify_invariants: true (mix mutare --verify-invariants):
the transform then builds a manifest for every file it renders and compares its
:mentions with the mutants it recorded, raising Mutare.InvariantError on a
mismatch.
Mutare.Poison uses 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 corresponding mutant id.
(Coverage is recorded separately: 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 appears 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 is outside the dispatcher. A clause shared by several guard mutants (onewhenalternative each) gives every alternative to its own mutant, and the whole clause — head patterns and the one shared body — to all of them; - 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. A selector inside a function body reads the
file's dispatch variable rather than :persistent_term directly, and a
lifted or tupled mutant clause is gated on it (<var> === <id>) — so that name
must be supplied to the reader. It is per file (:mutare_active, or a salted variant
when the source already uses that identifier), chosen by the transform and
handed out with the metamutant (Mutare.Transform.Result.dispatch_var,
Mutare.Schema's :dispatch_vars); from_source/2 takes it alongside the source.
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 — accept the resulting nodes; the two parses yield identical ranges. (Sourceror is still the renderer; only this readback parse changed.)
Summary
Types
One place where the generated code names a mutant id
A generated line range and the mutant ids whose code occupies it.
One file's manifest
Functions
Build a manifest from one file's rendered metamutant source and its dispatch variable.
Mutant ids whose generated code occupies line.
Mutant ids inside a call to one of names, grouped by that call's function name.
Types
@type mention() :: %{ kind: :branch | :exclusion | :record, id: pos_integer(), within: [pos_integer(), ...] | nil }
One place where the generated code names a mutant id:
:branch— code that runs only whileidis active: a selector clause body, a gated clause (lifted, tupled,fn,receive), or a gated guard alternative;:exclusion— an original clause stepping aside whileidis active (<var> =/= <id>, or the range form for a run of ids). A dropped clause leaves only this trace;:record— a coverage record listingid.
within names the innermost mutant branch enclosing the mention — the ids under which
that branch runs — or is nil outside every branch. It is a list because a clause shared
by several guard mutants runs its one body under any of them; every other branch has one
id. Code inside a branch runs only while one of its ids is active, so a run that activates
id alone reaches the mention only when within is nil or holds id.
@type region() :: %{ids: [pos_integer()], lo: pos_integer(), hi: pos_integer()}
A generated line range and the mutant ids whose code occupies it.
One file's manifest:
:regions— generated line ranges, for mapping a compile error back to a mutant.:mentions— every generated mention of a mutant id, in source order, forMutare.Transform.Invariantsto check against the mutants the transform recorded.:ast— the parsed metamutant the regions were ranged over, retained so the macro-expansion fallback (ids_in_named_calls/2) can range a blamed macro's calls in the same tree rather than parse the file a second time.nilon a hand-built manifest.
Functions
Build a manifest from one file's rendered metamutant source and its dispatch variable.
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, and collecting every mention of an id (mention/0). dispatch_var is the name the transform chose for this
file (Mutare.Transform.Result.dispatch_var): the hoisted selectors read it as their
subject and the gated mutant clauses compare it, so it is what makes them recognisable.
The parse is kept on the manifest (:ast): one failed compile can need both
attributions of the same file, and Mutare.Poison holds one manifest per file for the
round.
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, result.dispatch_var)
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 inside a call to one of names, grouped by that call's function name.
The macro-expansion fallback's attribution (Mutare.Poison.macro_poison/4): when a
mutation splices a selector case into an argument a macro rewrites at compile time, the
macro raises during expansion and the compiler reports 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: the rendered source is what the compiler read, so the ids
found inside a blamed macro's span are exactly the ones that could have poisoned it — no
mapping back to original-source coordinates is needed, or possible. Reads the manifest's
retained :ast (a from_source/2 manifest, whose regions were ranged with the file's
dispatch variable), so the file is parsed once for both attributions. Returns
%{fun_atom => MapSet.t()}, empty when nothing matched.