Mutare.CallRouting behaviour (mutare v0.3.1)

Copy Markdown View Source

Behaviour for describing how Mutare should treat particular calls.

A call route names a resolved {module, function, arity} — a macro or a plain function, with the same lookup rules for both — and specifies one of two treatments:

  • skip the whole call (:skip): the call is an inert leaf. Nothing inside its parentheses is descended and the call node itself is never offered to a mutator. A piped receiver is the |>'s other operand, so it is analyzed as usual — except where the skip displaced a route that governs position 0, whose treatment the receiver keeps, since skipping a call must never free a position the displaced route held (skip Kernel.match?/2 and 1 |> match?(x)'s receiver stays the pattern it is). A skipped call in tail position still gets the enclosing function's return-value mutants. The word for "this call is not worth testing" (Mixpanel.track/3, a logger, a metrics emitter). It applies to whatever the head resolves to — a function, a macro such as Kernel.if/2, or a special form ({Kernel.SpecialForms, :case, :skip}; special-form arities follow the AST, so use the any-arity form).
  • treat each argument by a position: :expression (ordinary runtime code, the default), :raw (leave the argument exactly as written — a DSL body, a pattern interpreted by the macro, an identifier list), :interior (descend into the argument but offer nothing on its own node — an assigns map whose emptying is a crash-kill while its values are the signal), :pattern / :binding_pattern (descend as a match pattern), and — for a literal keyword-list argument — a keyed refinement [leading, key: position, …] that routes one named option's value by its own position ([:expression, timeout: :raw]; the leading treatment defaults to :expression, pairs may nest).

Users write routes under call_routes: in .mutare.exs; a module implementing this behaviour registers them through call_routes/0. A route may be static, or use the :routing sentinel to defer a concrete call's argument treatments to route_arguments/2.

Both extensions and mutators may implement this behaviour. Enabling the module under :extensions or :mutators also enables its routes.

A piped first argument keeps the same treatment as a written one. When a whole-call mutant changes the stage, only :expression and :interior arguments are evaluated into a temporary value; all other treatments preserve the left operand as syntax in each branch. For example, a :raw declaration (p in Post) |> from(…) still reaches from as p in Post.

defmodule MyApp.EctoRouting do
  @behaviour Mutare.CallRouting

  @impl Mutare.CallRouting
  def call_routes do
    [
      {Ecto.Query, :where, :any, :routing},
      {Ecto.Query, :from, 2, [:expression, :raw]}
    ]
  end

  @impl Mutare.CallRouting
  def route_arguments(call, _context) do
    routes = Enum.map(call.arguments, &classify/1)
    Mutare.CallRouting.ArgumentRoutes.from_visible(call, routes)
  end
end

Shape-dependent routes use :routing. A :hosted treatment marks a position as raw for core and available to every enabled Mutare.Mutator.MacroHost subscribing to that macro. Routing and mutation ownership are independent: one library adapter can describe the DSL while several mutators contribute mutations inside it.

The vocabulary, tiered

:skip, :raw, :interior, :expression, :pattern, :binding_pattern, and keyed refinements built from them can only remove or re-route mutants, and preserve those declared argument contexts; they are the whole vocabulary the declarative call_routes: configuration key accepts. :interpolated, {:keyword, ...}, and :hosted are adapter-grade: each asserts a fact about a DSL that Mutare cannot verify, and the module routing it takes responsibility for that fact. An :interpolated position must genuinely accept ^ interpolation — where it doesn't, the spliced selector fails the single metamutant compile and is recovered as poison, discarding those mutants after a rebuild. A {:keyword, ...} list routes keyword values positionally and must name exactly one treatment per pair — a length mismatch raises at transform time, and a non-keyword argument under it is left raw (warned when a :routing classifier routed it; silent for a static route, whose other call shapes may be legal forms). A :hosted route without an enabled subscribing host aborts the run at scan time. These treatments must come from a module implementing this behaviour — an adapter written and tested against the library it describes; a declarative call_routes: entry that uses one (a keyed refinement included) is rejected with an ArgumentError.

:skip is a statement about the call, so it is valid only as a route's bare treatment ({Mixpanel, :track, 3, :skip}); inside a per-position list it is rejected with a message naming :raw. Every other word is a statement about a position.

Structural heads. The forms Mutare analyzes structurally rather than as calls — if/unless, |>, the boolean connectives (and/or/&&/||) and negations (!/not), in, and the construct special forms (case, cond, with, for, try, receive, fn, quote, &) — have no argument positions in the routing sense: a route on one accepts only :skip. An explicit positional route ({Kernel, :if, 2, [:raw, :expression]}) is rejected with an ArgumentError; a wildcard route's positions ({Kernel, :*, :raw}) simply do not apply to them. Definitions and directives (def/defp, defmacro/defmacrop, defmodule, defimpl/defprotocol/defdelegate, use, @) are not calls a route can act on at all — nor are the directives (alias/import/require), the compiler-internal forms (__block__, __aliases__, .), or the literal and pattern forms ({}, %{}, %, <<>>, =, ^, ::), whose mutations are handled by the literal families (--mutators, # mutare:ignore[<family>]). # mutare:ignore is the tool for leaving a definition alone, and # mutare:ignore[conditional] (or --mutators) for holding back particular mutants inside an if.

Routes are positional and transform-enforced: no mutator is consulted. The other facility for leaving something alone — argument marks (argument_marks: / Mutare.Mutator.argument_marks/1) — labels a position for value-dependent handling by each mutator. The built-in timeout exclusions use these marks to skip duration literals while allowing mutations in computed durations. Reach for a route when the position should simply not mutate; reach for a mark when the reaction should depend on the value. See Mutare.Mutator.

Which behaviours do I implement?

Routing and selector delivery are separate capabilities, so you declare only the ones you use:

GoalBehavioursCallbacks
Library-vocabulary routing, no mutations (a :extensions entry)Mutare.CallRoutingcall_routes/0 (+ route_arguments/2 if any route is :routing)
A mutator whose mutation depends on routingMutare.Mutator + Mutare.CallRoutingname/0, a producer, call_routes/0 (+ route_arguments/2)
A mutator that mutates inside a DSL fragment (:hosted)Mutare.Mutator + Mutare.Mutator.MacroHostname/0, hosted_macros/0, host/2; it may also implement CallRouting when it also defines the DSL's routes

Always declare the @behaviours you implement. The registry discovers capabilities by exported callbacks, so a typo'd or missing callback would otherwise compile to a silently inert module. Declaring @behaviour lets the compiler check the required callbacks, and the registry additionally rejects, at scan time, a module whose route_arguments/2 or host/2 no route ever reaches (a forgotten :routing/:hosted registration).

Route forms and precedence

A route is {module, name, arity, treatments} or {module, name, treatments} for any arity, where treatments is :skip, one position for every argument, or a per-position list (padded with :expression). :* in the name position covers a whole module; :* in the module position is the last-resort name-only match for calls whose module cannot be resolved. Exact routes beat any-arity routes, which beat whole-module routes, which beat name-only routes — so {Mixpanel, :*, :skip} plus {Mixpanel, :track, 3, [:expression, :raw, :raw]} skips every Mixpanel call except track/3, which mutates its first argument.

Identical declarations from multiple code providers coalesce. Conflicting code-provided routes raise Mutare.CallRouting.ContractError instead of depending on configuration order. A declarative call_routes: entry is an explicit final override for its key, restricted to the user-tier vocabulary above; a configured :skip that displaces an adapter's hosted route is honoured (the user turned the call off), not reported as the adapter's contract violation. The --skip-call Module.fun/arity flag is the CLI spelling of a :skip entry.

Mutare matches module-specific routes against remote calls (including aliases) and imports it can resolve, with or without a pipe, through the same resolution the call-matching mutator families use.

Local-call limitation. Mutare does not resolve an unqualified call to a function defined in the calling module to that module. Thus {SomeModule, :foobar, 0, :skip} does not skip foobar() inside SomeModule. It does skip SomeModule.foobar(), whether called inside or outside SomeModule, and foobar() in another module that imports SomeModule when Mutare can resolve the import. This limitation applies to module-specific routes regardless of their treatment.

A module wildcard bypasses that limitation: {:*, :foobar, 0, :skip} matches by name and arity, including local foobar() calls inside modules that define foobar/0. It applies across modules, subject to the precedence of more specific routes above.

A configured route (or mark) that matched no call anywhere in a full scan is reported as a warning, so a typo'd module or a wrong arity never sits silently inert.

The committed surface

An adapter should build against exactly these, and can expect compatibility from them:

Everything else in the routing path — the registry and its entries, the normalized route spec, transform metadata keys, candidate structs, and how selectors are assembled and nested — is internal and free to change between releases.

Summary

Types

A call route entry returned by call_routes/0: a {module, name, arity, treatment} 4-tuple or a {module, name, treatment} 3-tuple (arity :any). module/name/arity may be the wildcard :*; treatment is the call-level :skip, a static treatment/0, a list of treatments, or the :routing classifier sentinel.

The opt-independent context route_arguments/2 receives for a concrete call. Currently carries the call's :pipe_mode; it may gain fields, so match it as a map rather than destructuring exhaustively. It deliberately carries no mutator options — routing is a global library fact.

A treatment for one call argument or nested keyword value: an atom treatment, a {:keyword, …} per-pair routing, or a keyed refinement [leading, key: treatment, …] (a list whose optional first element is the leading treatment and whose remaining elements are {key, treatment} pairs). The call-level :skip is not a treatment — see route_args/0.

Callbacks

Returns call-route declarations.

Classify a concrete call registered with the :routing sentinel.

Types

keyword_value_treatment()

@type keyword_value_treatment() :: treatment()

route()

@type route() ::
  {route_module(), route_name(), route_args()}
  | {route_module(), route_name(), route_arity(), route_args()}

route_args()

@type route_args() :: :skip | treatment() | [treatment()] | :routing

route_arity()

@type route_arity() :: non_neg_integer() | :any | :*

route_module()

@type route_module() :: atom()

A call route entry returned by call_routes/0: a {module, name, arity, treatment} 4-tuple or a {module, name, treatment} 3-tuple (arity :any). module/name/arity may be the wildcard :*; treatment is the call-level :skip, a static treatment/0, a list of treatments, or the :routing classifier sentinel.

route_name()

@type route_name() :: atom()

routing_context()

@type routing_context() :: %{pipe_mode: Mutare.Mutator.pipe_mode()}

The opt-independent context route_arguments/2 receives for a concrete call. Currently carries the call's :pipe_mode; it may gain fields, so match it as a map rather than destructuring exhaustively. It deliberately carries no mutator options — routing is a global library fact.

routing_treatment()

@type routing_treatment() :: treatment()

treatment()

@type treatment() ::
  :expression
  | :interior
  | :raw
  | :pattern
  | :binding_pattern
  | :hosted
  | :interpolated
  | {:keyword, [treatment()]}
  | [treatment() | {atom(), treatment()}]

A treatment for one call argument or nested keyword value: an atom treatment, a {:keyword, …} per-pair routing, or a keyed refinement [leading, key: treatment, …] (a list whose optional first element is the leading treatment and whose remaining elements are {key, treatment} pairs). The call-level :skip is not a treatment — see route_args/0.

Callbacks

call_routes()

@callback call_routes() :: [route()]

Returns call-route declarations.

A treatment may be static or the :routing sentinel. :routing requires route_arguments/2. A :hosted treatment requires at least one enabled Mutare.Mutator.MacroHost whose Mutare.Mutator.MacroHost.hosted_macros/0 matches it.

Route declarations do not receive per-instance options.

route_arguments(call, context)

(optional)
@callback route_arguments(
  call :: Mutare.CallRouting.Call.t(),
  context :: routing_context()
) ::
  Mutare.CallRouting.ArgumentRoutes.t()

Classify a concrete call registered with the :routing sentinel.

A static per-position treatment list cannot express routing that depends on the call's shape — where(q, category: "Foo") is plain data while where(q, [u], u.x == u.y) contains a DSL fragment. Return a Mutare.CallRouting.ArgumentRoutes value.

context is an opt-independent routing_context/0 describing the call (currently its :pipe_mode); it carries no mutator options, because routing is a global library fact. Match it as a map (or _context) so a later field can't break your clause.

The call is a stable Mutare.CallRouting.Call, already normalized across bare, qualified, aliased, imported, and piped forms. A piped call's pipe_left holds the pipe's left side as written, so the piped position can be routed by its shape like any visible argument (Post |> from(…) and build(x) |> from(…) need not share a treatment). ArgumentRoutes.from_effective/2 uses the same effective argument order as static declarations. ArgumentRoutes.from_visible/3 is convenient when only written arguments matter and makes the pipe-left treatment explicit. Returned treatments and lengths are validated by the transform.

  • {:keyword, treatments} — routes keyword values positionally while leaving keys unchanged; the list must name exactly one treatment per pair, and nested keyword routing is supported
  • :interpolated — the value is interpolable data: core reuses its own mutators on it and delivers every mutation through ^ interpolation, introducing the pin for a bare scalar and descending inside an existing ^; use only where the macro accepts interpolation
  • :hosted — delegates the position to Mutare.Mutator.MacroHost.host/2; only an enabled hosting mutator may return it

Static and dynamic routes share one recursive treatment vocabulary (a classifier may return a keyed refinement [leading, key: treatment, …] exactly as a static route writes it):

  • {:keyword, value_treatments} for a keyword-list argument. Core routes each pair's value by the corresponding positional treatment and leaves every key raw, because a DSL keyword key is a field or option name, not a value. The list is strict — exactly one treatment per pair (:raw a value to leave it as written); a length mismatch at a concrete call raises, so a static keyword route fits only call sites with a fixed pair count (variable shapes belong to :routing). A value treatment may itself be {:keyword, ...}, so nested keyword lists route recursively. A non-keyword argument under this treatment is left raw — silently for a static route (a non-keyword call site is a legitimate alternate macro form, set(q, opts)), with a printed warning when a :routing classifier did it (the classifier saw the concrete argument, so the mismatch is a classifier bug). A nested :hosted value is delivered through Mutare.Mutator.MacroHost.host/2, which receives the resolved whole macro call.

  • :interpolated for a value in a compile-time DSL position that accepts interpolation but not a bare selector case. The name is the contract: the value is interpolated data, and core reuses its own mutators on it, delivering every mutation through the ^. For a bare scalar (category: "Foo") the configured literal families attach their ordinary mutations — each Site records the owning family's name (:string, :integer, …), never the adapter's — and the selector is emitted wrapped in ^. Its limits, precisely:

    • Bare values must be scalar. A bare compound value (a list, map, or tuple) attaches mutations to descendant nodes that the ^ wrap cannot reach, so it is rejected at transform time with an ArgumentError rather than left to poison the build. Route a compound value :raw, or route a keyword list per-pair via {:keyword, ...}.
    • An already-^-pinned value is descended, not re-pinned. Past a ^ the source wrote itself, the code is plain Elixir evaluated at build time, where ordinary selectors are legal — core mutates it like any runtime expression, arbitrarily deep, and leaves the existing ^ alone. The scalar-only rule applies to bare values — the ones core must pin itself — so a static route stays sound on call sites that mix category: "Foo" with total: ^(a + b).
    • The DSL must genuinely accept ^ interpolation at that position. Mutare cannot check this assertion; where it is wrong, the spliced ^(case …) fails the single metamutant compile and is recovered as poison — those mutants are discarded after a rebuild.
    • Only in-place mutations on a bare value's own node apply. For a scalar literal that is core's literal families; a variable yields no mutants at all. (The guard is positional, not kind-based: a mutation whose node is the whole value — an operator swap on a + b — rides the same pin, sound in any DSL that accepted the bare expression to begin with.)

Returning :hosted leaves that position raw for core and offers the call to every subscribed host mutator.

The motivating keyword case is where(q, category: "Foo", deleted_at: nil), classified as {:keyword, [:interpolated, :raw]}: mutate "Foo" through a ^-pinned selector, keep the column-name keys raw, and skip the nil pair whose DSL meaning may be IS NULL rather than an Elixir value.