Mutare.Schema (mutare v0.1.1)

Copy Markdown View Source

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"):

  1. Count (count_files/3) — Mutare.Transform.count_string/2 per 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's next_id - start_id by construction.
  2. Render (render_files/3) — prefix-sum the counts so each sited file knows its globally-unique :start_id up front, then Mutare.Transform.transform_string/2 each file (with that :start_id and the run's :skip_ids) in parallel. render_one/5 re-checks the count against the rendered next_id and 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

t()

@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

build(root, opts \\ [])

@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 .ex files.
  • :exclude — wildcard patterns to drop.
  • :only_files — an explicit root-relative file set, such as --since.
  • :only_linesfile:line filters, such as --line; discovery is narrowed to the named files, then from_files/4 filters the sites.
  • :mutators — forwarded to Mutare.Transform.
  • :max_mutants — caps the final schema to the first N sites; see from_files/4.

count(schema)

@spec count(t()) :: non_neg_integer()

Total number of mutants in the schema.

from_files(files, root \\ ".", opts \\ [], skip_ids \\ MapSet.new())

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

rebuild(schema, root, opts, skip_ids)

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

render_opts(options, file, start_id)

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