Mutare.Poison (mutare v0.4.1)

Copy Markdown View Source

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

clean_region()

@type clean_region() :: {String.t(), Mutare.Manifest.clean_range()}

A clean region, by its file and the id interval its guard carries.

dispatch_vars()

@type dispatch_vars() :: %{optional(String.t()) => atom()}

Each rendered file's dispatch variable (Mutare.Transform.Result.dispatch_var), by root-relative file. Every file in metamutants/0 must have one.

macro_matches()

@type macro_matches() :: [{{String.t(), atom()}, MapSet.t()}]

The macro-expansion fallback's matches: {{module_string, fun_atom}, ids} per implicated macro that matched at least one mutant, in first-seen order.

metamutants()

@type metamutants() :: %{optional(String.t()) => String.t()}

The rendered metamutants of a build, by root-relative file.

Functions

attribution(compile_output, metamutants, dispatch_vars, report_ids \\ nil)

@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.

ids(compile_output, metamutants, dispatch_vars, report_ids \\ nil)

@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).

macro_poison(compile_output, metamutants, dispatch_vars, report_ids \\ nil)

@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.