Mutare.Poison (mutare v0.1.2)

Copy Markdown View Source

Identify compile-poisoning mutants from a failed metamutant compile.

Every mutated branch lives in the one build, so a single mutation that won't compile would sink 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, …). Rather than abort, the runner asks this module which mutant ids the compile error points at, 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 (see Manifest.ids_at_line/2). Only error diagnostics are scanned, never warnings: a failed compile prints every warning the mutations provoke (each footered with the same file:line shape), 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 actually lives — 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 blames the macro-call line no manifest region covers — the runner falls back to macro_poison/2, which attributes by the macro name the compiler blamed (via Mutare.Manifest.ids_in_named_calls/2) instead of by line. Only when both fail does the run abort.

Summary

Functions

Mutant ids implicated by compile_output, given %{file => metamutant_source}.

The macro-expansion fallback attribution: mutant ids that live inside a call to a macro the compiler blamed, grouped by that macro.

Functions

ids(compile_output, metamutants)

@spec ids(String.t(), %{optional(String.t()) => String.t()}) :: MapSet.t()

Mutant ids implicated by compile_output, given %{file => metamutant_source}.

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)

@spec macro_poison(String.t(), %{optional(String.t()) => String.t()}) :: [
  {{String.t(), atom()}, MapSet.t()}
]

The macro-expansion fallback attribution: mutant ids that live inside a call to a macro the compiler blamed, grouped by that 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 blames the macro-call line — one line above the selector case the Mutare.Manifest knows about — so ids/2 finds nothing and the run would abort. But the failure output names the culprit in an expanding macro: Mod.fun/arity frame (Hint.expanding_macros/1). This maps that name back to mutant ids through the metamutant (Manifest.ids_in_named_calls/2): find every call of that name in the rendered metamutant of the file(s) the error touches, 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 are skipped together — conservative, and one of them did raise.

Attributing through the metamutant + manifest (not the schema's :sites) is deliberate: it sees every reserved mutant, so it is correct under --line/--max-mutants — where :sites is filtered but the metamutant still embeds (and can be poisoned by) an unselected mutant — exactly like the line-based ids/2 it backs up.

Returns [{{module_string, fun_atom}, MapSet.t()}] — one entry per blamed macro that matched at least one mutant — so the caller can drop the union and name each macro for the narration and the {Module, :fun, :raw} suggestion.