# `Mutare.Schema`
[🔗](https://github.com/foxbenjaminfox/mutare/blob/v0.2.1/lib/mutare/schema.ex#L1)

The mutant schema for a whole project: every in-scope source transformed into
its metamutant, with globally-unique report ids and stable per-file runtime ids.

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/3`.

## Two-phase build (`from_files/4`)

Report ids reserve contiguous ranges in file order — file *i*'s `:start_id`
is file *i-1*'s `next_id`. Emitted selectors instead use local integers under
the root-relative file namespace (`Mutare.RuntimeId`). An unrelated file's
candidate-count change therefore leaves this file's metamutant byte-identical,
provided its source, transform inputs, and effective emission selection stay
the same. A global cap can change that selection even with unchanged options.

The build determines report ranges and global selection before 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/4`) — `Mutare.Transform.count_report/2` per file,
     in parallel. It runs the same analyze → plan → emit pipeline but skips the
     dominant final render, returning each file's mutant count and, for a line
     selection, the matching local ids (without rendering site diffs) — plus the
     *facts* the end-of-build diagnostics read (matched configuration entries, the
     comment-directive container, degraded `use`s), collected here because this
     is the one pass that parses every file: nothing later re-parses. 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
     is given its globally-unique `:start_id` up front, then
     `Mutare.Transform.transform_string/2` each file (with that `:start_id` and
     the file's `:runtime_namespace`, and the run's report-space `:skip_ids` and
     statically selected `:emit_ids`) in parallel. Every
     candidate reserves its id and records its diagnostic site, but only selected
     candidates emit branches. `render_one/3` 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/3` 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.

The *slice* is checked the same way. A `--line` / `--max-mutants` run narrows twice —
emission in `render_jobs/2`, the reported sites at the end of `from_files/4` — so
`from_files/4` compares the finished site ids against the ids it selected for emission
and raises on any difference: a reported site whose mutant never reached the metamutant
would run the suite unmutated and be scored a survivor.

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`) and the
dispatch variable its generated code reads (under `:dispatch_vars`, beside
`:start_ids`; `Mutare.Transform.Result.dispatch_var`) 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.

# `degraded_use`

```elixir
@type degraded_use() :: %{
  file: String.t(),
  module: module(),
  line: pos_integer() | nil,
  reason: atom()
}
```

A module-level `use` the scan could not expand in-process, with the root-relative file it
sits in (`Mutare.Transform.Uses.degraded_use/0` plus `:file`). `mix mutare --check` prints
these: a `:call_routes` `:raw` keyed on what such a `use` injects would silently never fire.

# `t`

```elixir
@type t() :: %Mutare.Schema{
  degraded_uses: [degraded_use()],
  dispatch_vars: %{optional(String.t()) =&gt; atom()},
  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()) =&gt; String.t()},
  sites: [Mutare.Site.t()],
  skipped: [{String.t(), term()}],
  sources: %{optional(String.t()) =&gt; String.t()},
  start_ids: %{optional(String.t()) =&gt; pos_integer()},
  unknown_directives: [{String.t(), pos_integer(), String.t()}]
}
```

# `build`

```elixir
@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_lines` — `file: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`

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

Total number of mutants in the schema.

# `from_files`

```elixir
@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
has 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. Every candidate reserves its id, including skipped ids,
but only selected candidates that are neither ignored nor poisoned emit code. Poisoned and ignored
sites still occupy their places inside the cap, as they do in the report.
Files with no emitted mutants retain their exact original source. Changing selection
can therefore change the metamutant and invalidate a retained sandbox's build.

# `rebuild`

```elixir
@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`

```elixir
@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`, `:start_id`, and
the file's `:runtime_namespace`.
`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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
