The mutant schema for a whole project: every in-scope source transformed into its metamutant, with globally-unique mutant ids threaded across files.
This is what the runner compiles once and the report reads from.
Files that have no mutation sites, or that fail to parse, are left out of
:metamutants (their originals are used as-is) but never crash the build — a
single unparseable file should not sink the run. A failure after a clean
parse (transform or render) is a bug in this tool, not bad input, and is left
to crash: see render_one/5.
Two-phase build (from_files/4)
Mutant ids are baked into each metamutant's selector clauses, so a naive
per-file render can't run concurrently — file i's :start_id is file
i-1's next_id. The build decouples id assignment from rendering in two
passes, both run in throwaway worker processes so each file's heavy,
short-lived ASTs (the emitted tree, Sourceror's render buffers) die with their
worker instead of accumulating in the scan's long-lived heap, where they made
every GC scan the growing live set and inflated a big file's transform
several-fold (see NOTES "Scan is transform-bound"):
- Count (
count_files/3) —Mutare.Transform.count_string/2per file, in parallel. It runs the same analyze → plan → emit pipeline but skips the dominant final render, returning just each file's mutant count. The count is drift-proof: it comes from the same id-claiming path emission uses, so it equals the matching render'snext_id - start_idby construction. - Render (
render_files/3) — prefix-sum the counts so each sited file knows its globally-unique:start_idup front, thenMutare.Transform.transform_string/2each file (with that:start_idand the run's:skip_ids) in parallel.render_one/5re-checks the count against the renderednext_idand fails loudly on any drift, since id stability across files depends on the two passes agreeing.
The two passes agree only because the pipeline they share — parse, use-expansion,
resolution, and every mutator — is a deterministic function of the source and opts;
it runs once per pass, so a nondeterministic custom mutator or extension expand_use/3
surfaces as that render_one/5 drift crash rather than a silent id overlap.
from_files/4 also dedups its input by relative path, so a file passed twice is
rendered once, under one id range — never two overlapping ones.
Both passes preserve exact :start_id threading (the prefix sum reproduces the
old sequential thread) and the let-it-crash contract: a worker classifies an
unparseable source as a skipped file but re-raises any other exception, with its
original type and stacktrace, in the parent — so a tool bug still surfaces
faithfully across the process hop, not as an opaque Task exit.
Each sited file's :start_id is recorded under :start_ids — the origin of the
file's id range before :only_lines/:max_mutants narrow :sites. A
report-time re-render (render_opts/3) reads it from there, never from the
visible sites: under --line the smallest surviving id is not the file's first
mutant, and a re-render started from it would hand every id another site's code.
We store each file's rendered metamutant source (under :metamutants) but not
a precomputed Mutare.Manifest: the manifest (generated line ranges, for
mapping a compile error back to a mutant) is read only on a failed compile,
so Mutare.Poison builds it lazily from the metamutant for the offending
file(s). Building it eagerly here was the scan's dominant cost — a full
Sourceror.parse_string! of a ~40k-line metamutant takes minutes — and was
thrown away on every healthy run.
Summary
Functions
Build a schema by discovering files under root.
Total number of mutants in the schema.
Build a schema from an explicit list of files (paths recorded relative to root).
Rebuild a schema from the same file list it was built with.
Transform options for re-rendering one file's sites.
Types
@type t() :: %Mutare.Schema{ files: [String.t()], ineffective_argument_marks: [Mutare.Mutator.mark_declaration()], ineffective_call_routes: [Mutare.CallRouting.Spec.t()], ineffective_ignores: [ {String.t(), Mutare.Ignore.Directive.t(), pos_integer() | nil} ], ineffective_skip_lifting: [Mutare.Lifting.skip_entry()], metamutants: %{optional(String.t()) => String.t()}, sites: [Mutare.Site.t()], skipped: [{String.t(), term()}], sources: %{optional(String.t()) => String.t()}, start_ids: %{optional(String.t()) => pos_integer()}, unknown_directives: [{String.t(), pos_integer(), String.t()}] }
Functions
@spec build(Path.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword()) :: t()
Build a schema by discovering files under root.
opts may be a Mutare.Run.Context, a Mutare.Options struct, or a keyword
list. Discovery uses:
:paths— directories to scan recursively, or individual.exfiles.:exclude— wildcard patterns to drop.:only_files— an explicit root-relative file set, such as--since.:only_lines—file:linefilters, such as--line; discovery is narrowed to the named files, thenfrom_files/4filters the sites.:mutators— forwarded toMutare.Transform.:max_mutants— caps the final schema to the first N sites; seefrom_files/4.
@spec count(t()) :: non_neg_integer()
Total number of mutants in the schema.
@spec from_files( [Path.t()], Path.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword(), MapSet.t() ) :: t()
Build a schema from an explicit list of files (paths recorded relative to root).
Duplicate file entries are collapsed by root-relative path so each source file owns one stable id range.
skip_ids is poison-recovery state: ids to leave out of the emitted
metamutant while still advancing the id counter. It is passed separately from
Options because it is run state, not user configuration.
:only_lines filters the finished sites to the requested file:line pairs.
:max_mutants then caps those sites in source order. Both filters are applied
here so poison recovery can rebuild from the same inputs and still return the
same visible slice. The rendered metamutants still reserve every id, including
skipped ids, so a poisoned site inside the cap can be replaced by the next
eligible site after rebuild.
@spec rebuild( t(), Path.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword(), MapSet.t() ) :: t()
Rebuild a schema from the same file list it was built with.
This is the poison-recovery entry point. It does not rediscover files, because
rediscovery could lose restrictions from from_files/4, :only_files,
:exclude, or :only_lines. Reusing the recorded file list keeps the run's
scope and mutant ids stable while adding the new skip_ids.
Pass the same options used for the original schema so mutator and extension configuration stays unchanged.
@spec render_opts(Mutare.Options.t(), String.t(), pos_integer()) :: keyword()
Transform options for re-rendering one file's sites.
The returned keyword list is transform_opts/1 plus :file and :start_id.
Mutare.Runner.Hydrate uses it when a scan deferred site-code rendering and a
report later needs the original/mutated code for one displayed site.
The :start_id must be the schema's recorded :start_ids entry for file —
the pre-filter origin of its id range — so the re-rendered sites line up with
the original scan even when :only_lines/:max_mutants narrowed :sites. :skip_ids and
:render_site_code are intentionally left to Mutare.Transform.render_sites/2;
neither changes the id-to-code mapping.