Identify compile-poisoning mutants from a failed metamutant compile.
Every mutated branch is included in one build, so a single mutation that won't compile would prevent the whole run. Built-in mutators are compile-safe by construction, but a custom mutator can emit something that doesn't (an unbound variable, an undefined local call, …). The runner uses this module to identify the mutant ids at the error location, then drops them and rebuilds.
We map each error's file:line to the mutant id(s) whose generated code
spans that line, via a Mutare.Manifest built on demand from the file's
rendered metamutant and the dispatch variable its generated code reads
(Mutare.Schema's :metamutants and :dispatch_vars; see
Manifest.ids_at_line/2). Only error diagnostics are
scanned, never warnings: compiler output also includes warnings caused by mutations
(each with a footer in the same file:line format), and mistaking those for
the error's location dropped valid mutants as false poison (see error_locations/1).
The manifest records the
full line range of every mutant's generated code — its selector clause body,
and for a lifted mutant the gated clause (when mutare_active === <id>) where its
guard/head-pattern code appears — so a poison is found whether the error
points at the clause, a later line of a multiline body, a lifted mutant clause, or
(as a coarse fallback) the surrounding case. Matching only the selector clause's
start line, as we used to, missed all but the first of those.
The manifest is built here, lazily, only for the file(s) a compile error
names — not eagerly for every mutated file during the scan. That eager build
was pure waste: a manifest is read only on a failed compile (rare — built-in
mutators are compile-safe), yet a full Sourceror.parse_string! of a large
metamutant is by far the most expensive step of the scan (a 1500-line source
whose metamutant is ~40k lines took minutes to re-parse). Deferring it to the
poison path removes that cost from every healthy run.
When line attribution maps nothing — the signature of an inline DSL macro that rejects
the spliced selector, where the compiler reports an error on the macro-call line no manifest region
covers — the runner falls back to macro_poison/4, which attributes by the macro name
in the compiler output (via Mutare.Manifest.ids_in_named_calls/2) instead of by line. Only
when both fail does the run abort. The runner obtains both results through attribution/4,
which builds each file's manifest once for the round; ids/4 and macro_poison/4 are
the two halves on their own.
An error can also land in a clean region's uninstrumented copy
(Mutare.Transform.CleanRegion), which belongs to no mutant: the transform copies any
source without asking whether it survives the copy (a macro may refuse to expand twice, or
under a relocated name). attribution/4 reports those regions under :clean, as
{file, range} — the identity the region's own guard carries — and the runner drops them
through Mutare.Schema's skip_regions. A macro that raises inside a clean copy is
excluded from the macro fallback: there is no selector in a copy for it to have rejected,
so its mutants elsewhere in the file are not the cause.
A schema metamutant contains local integer ids. The runner supplies
Mutare.RuntimeId.file_index(schema.sites) so attribution translates {file, local_id}
to report ids before merging files. Without an index these APIs return the integers read
from the metamutant, as used by standalone transforms. The macro fallback retains the
call-site file through that conversion; two files' local id 1 must never collapse together.
An id absent from the index corresponds to no mutant in this run — a file the selection left with
nothing to emit renders pristine, and pristine source can imitate a selector — so it is
dropped, degrading to "mapped nothing" rather than crashing a run mid-recovery.
Summary
Types
A clean region, by its file and the id interval its guard carries.
Each rendered file's dispatch variable (Mutare.Transform.Result.dispatch_var), by
root-relative file. Every file in metamutants/0 must have one.
The macro-expansion fallback's matches: {{module_string, fun_atom}, ids} per implicated macro
that matched at least one mutant, in first-seen order.
The rendered metamutants of a build, by root-relative file.
Functions
Every attribution of one failed compile — %{line: ids, macro: matches, clean: regions}:
the results of ids/4 and macro_poison/4, and the clean regions whose uninstrumented
copy an error line falls in — from one manifest per file.
Mutant ids implicated by compile_output, given %{file => metamutant_source} and
%{file => dispatch_var} (the variable each metamutant's generated code reads,
Mutare.Transform.Result.dispatch_var; every file in metamutants must have one).
The macro-expansion fallback attribution: mutant ids inside calls to macros listed in the compiler's expansion stack, grouped by macro.
Types
@type clean_region() :: {String.t(), Mutare.Manifest.clean_range()}
A clean region, by its file and the id interval its guard carries.
Each rendered file's dispatch variable (Mutare.Transform.Result.dispatch_var), by
root-relative file. Every file in metamutants/0 must have one.
The macro-expansion fallback's matches: {{module_string, fun_atom}, ids} per implicated macro
that matched at least one mutant, in first-seen order.
The rendered metamutants of a build, by root-relative file.
Functions
@spec attribution(String.t(), metamutants(), dispatch_vars(), map() | nil) :: %{ line: MapSet.t(), macro: macro_matches(), clean: MapSet.t(clean_region()) }
Every attribution of one failed compile — %{line: ids, macro: matches, clean: regions}:
the results of ids/4 and macro_poison/4, and the clean regions whose uninstrumented
copy an error line falls in — from one manifest per file.
The runner's poison-recovery loop uses both every round (macro attribution takes priority, with line attribution as a fallback). The macro's call-site file is normally also listed in the error's source locations. With a shared cache, each such metamutant is parsed and ranged once per round, not once per attribution.
@spec ids(String.t(), metamutants(), dispatch_vars(), map() | nil) :: MapSet.t()
Mutant ids implicated by compile_output, given %{file => metamutant_source} and
%{file => dispatch_var} (the variable each metamutant's generated code reads,
Mutare.Transform.Result.dispatch_var; every file in metamutants must have one).
Builds the per-file Mutare.Manifest lazily — only for the file(s) an error
names — and memoizes it across error locations, so a file faulting on several
lines is parsed once. Returns an empty set when nothing could be mapped (the
caller then aborts).
@spec macro_poison(String.t(), metamutants(), dispatch_vars(), map() | nil) :: macro_matches()
The macro-expansion fallback attribution: mutant ids inside calls to macros listed in the compiler's expansion stack, grouped by macro.
When a mutation splices a runtime selector case into an argument that a macro rewrites
at compile time (an Ecto.Query.from/2-style inline DSL, a macro needing a literal), the
macro raises while expanding and the compiler reports the macro-call line — one line
above the selector case recorded in Mutare.Manifest — so ids/4 finds nothing and
the run would abort. The failure output identifies the macro in an expanding macro: Mod.fun/arity frame, followed by the location that invoked it (Hint.culprits/1). This
maps that name back to mutant ids through the metamutant of that call-site file
(Manifest.ids_in_named_calls/2): find every call of the name in the rendered source and
collect the ids inside its span. Bare-name match (the call is usually an imported
from(...), not Ecto.Query.from), so two same-named macros in the file are skipped
together — conservative, and one of them did raise.
Only the call-site file is searched — deliberately not every file the error located: a macro defined in the target project also puts frames from its implementation file (and Elixir internals) on the stack, and scanning those would drop valid mutants in an unrelated same-named call there as poison. A call site we didn't render (a dependency), or a frame with no location, attributes nothing.
Attributing through the metamutant + manifest (not the schema's :sites) is deliberate, and
for the same reason as the line-based ids/4 it backs up: this is positional work in
metamutant space — spans of the rendered source the compiler actually read — which
:sites, recorded in original-source coordinates, cannot answer.
Returns one {{module_string, fun_atom}, ids} entry per implicated macro that matched at least
one mutant, so the caller can drop the union and name each macro in the diagnostic and the
{Module, :fun, :raw} suggestion.