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").
Clean copies
A clean region (Mutare.Transform.CleanRegion) keeps a function's uninstrumented source
beside the instrumented code. That copy belongs to no mutant, and the transform copies
any source without asking whether it survives the copy, so the copy's lines are recorded
too, under the region's identity — the id interval its guard carries (:clean,
clean_span/0). blame_at_line/2 weighs both kinds by the same narrowest-range rule,
and poison recovery drops a blamed region as it drops a blamed mutant. A lifted group's
clean clauses sit outside their dispatcher, under the name CleanRegion.read/2 reports;
each such definition is a span of that region.
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
A clean region's identity within its file: the inclusive interval of file-local mutant ids its guard carries. Regions claim disjoint ids, and ids are stable across poison rebuilds.
A line range holding a clean region's uninstrumented copy, under the region's identity. :part is :branch for the clean branch of
the region case — an in-place body, or a lifted dispatcher's call to its clean clauses —
and :definition for one relocated clean clause.
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
What a compile error on line is blamed on: the mutant ids whose generated code occupies
it, and the clean regions whose uninstrumented copy does — %{ids: ids, clean: ranges}.
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 clean_range() :: {pos_integer(), pos_integer()}
A clean region's identity within its file: the inclusive interval of file-local mutant ids its guard carries. Regions claim disjoint ids, and ids are stable across poison rebuilds.
@type clean_span() :: %{ range: clean_range(), part: :branch | :definition, lo: pos_integer(), hi: pos_integer() }
A line range holding a clean region's uninstrumented copy, under the region's identity. :part is :branch for the clean branch of
the region case — an in-place body, or a lifted dispatcher's call to its clean clauses —
and :definition for one relocated clean clause.
@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.
@type t() :: %Mutare.Manifest{ ast: Macro.t() | nil, clean: [clean_span()], mentions: [mention()], regions: [region()] }
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.:clean— the line ranges of clean regions' uninstrumented copies, in source order.: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
@spec blame_at_line(t(), pos_integer()) :: %{ ids: [pos_integer()], clean: [clean_range()] }
What a compile error on line is blamed on: the mutant ids whose generated code occupies
it, and the clean regions whose uninstrumented copy does — %{ids: ids, clean: ranges}.
Both kinds compete by the narrowest containing range, as in ids_at_line/2. A clean
copy holds no mutant code, so in practice a line has one kind of owner; the shared rule
only settles nesting.
Examples
iex> manifest = %Mutare.Manifest{
...> regions: [%{ids: [1], lo: 3, hi: 4}],
...> clean: [%{range: {1, 2}, part: :branch, lo: 6, hi: 9}]
...> }
iex> Mutare.Manifest.blame_at_line(manifest, 7)
%{ids: [], clean: [{1, 2}]}
iex> Mutare.Manifest.blame_at_line(manifest, 3)
%{ids: [1], clean: []}
iex> Mutare.Manifest.blame_at_line(manifest, 5)
%{ids: [], clean: []}Nested and overlapping owners:
iex> manifest = %Mutare.Manifest{
...> regions: [
...> %{ids: [1, 2, 3], lo: 3, hi: 8},
...> %{ids: [1, 2], lo: 5, hi: 6},
...> %{ids: [2, 3], lo: 5, hi: 6}
...> ]
...> }
iex> Mutare.Manifest.blame_at_line(manifest, 5)
%{ids: [1, 2, 3], clean: []}
iex> Mutare.Manifest.blame_at_line(manifest, 4)
%{ids: [1, 2, 3], clean: []}
iex> Mutare.Manifest.blame_at_line(%{manifest | regions: Enum.take(manifest.regions, 2)}, 5)
%{ids: [1, 2], clean: []}
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.