Mutare.Manifest (mutare v0.4.1)

Copy Markdown View Source

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 case rather 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 (one when alternative 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 the case itself.

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), not Sourceror.parse_string!. get_range/1 only needs that token metadata (:line/:column/:closing/:end/:end_of_expression), which the stdlib parser already produces; what Sourceror.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_encoder reproduces 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.

t()

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

clean_range()

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

clean_span()

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

mention()

@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 while id is active: a selector clause body, a gated clause (lifted, tupled, fn, receive), or a gated guard alternative;
  • :exclusion — an original clause stepping aside while id is active (<var> =/= <id>, or the range form for a run of ids). A dropped clause leaves only this trace;
  • :record — a coverage record listing id.

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.

region()

@type region() :: %{ids: [pos_integer()], lo: pos_integer(), hi: pos_integer()}

A generated line range and the mutant ids whose code occupies it.

t()

@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, for Mutare.Transform.Invariants to 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. nil on a hand-built manifest.

Functions

blame_at_line(manifest, line)

@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: []}

from_source(metamutant_source, dispatch_var)

@spec from_source(String.t(), atom()) :: t()

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

ids_at_line(manifest, line)

@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)
[]

ids_in_named_calls(manifest, names)

@spec ids_in_named_calls(t(), MapSet.t(atom())) :: %{optional(atom()) => MapSet.t()}

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.