Skip to content
mutastic

Plugins

mutastic’s operators cover the language and the JDK’s streams. A plugin adds more, for the libraries and patterns your code uses. There are two kinds:

KindFileAddsNeeds
A rules file.yamlOperators on calls: remove one, rename it, or call another method insteadNothing but the file
A jar.jarOperators on any instruction, and filtersJava, built against io.mutastic:mutastic-api

List each under plugins: in mutastic.yaml, relative to the project’s directory:

plugins:
- mutastic/mutiny.yaml
- build/libs/acme-mutastic.jar

A plugin’s operators then run like mutastic’s own: each in the defaults set unless it says it’s stronger, chosen by its id or namespace with --operators (--operators=defaults,-mutiny), and reported, explained and ignored the same way. A plugin’s code runs in mutastic’s own process, so only list plugins you trust.

A rules file describes call operators as data: which calls to match, by the type that declares the method and the method’s name, and how to rewrite them. mutastic’s own stream operators are a rules file of exactly this shape. This one adds two operators for Mutiny, and mutastic’s own tests run it on a Quarkus app:

mutiny.yaml
# Mutiny (Uni and Multi) operators, as a plugin: only data. List this file under `plugins:` in
# mutastic.yaml.
#
# Mutiny groups its operators (`multi.select().where(p)`), so removing `where` would leave a
# MultiSelect where a Multi belongs. Instead the call becomes one on the same group that lets
# everything through, or passes the failure on unchanged.
- id: mutiny/remove-selection
explanation: A selection on a Multi lets every item through, as if `where`, `distinct` or `first` weren't there. It goes unnoticed when no test has an item the selection must leave out.
owners: io.smallrye.mutiny.groups.MultiSelect
methods: where, distinct, first
rewrite: call first(J) with Long.MAX_VALUE
source: first(Long.MAX_VALUE)
changed: selection removed
headline: no test needs `.select().{name}(…)` in {method}
headline.distinct: no test has duplicate items for `.select().distinct()` in {method} to remove
headline.first: no test has more items than `.select().first(…)` in {method} lets through
help: add a test with an item the selection must leave out, and assert it's gone
- id: mutiny/remove-recovery
explanation: A recovery from failure is removed, so the failure goes on downstream. It goes unnoticed when no test makes the upstream fail.
owners: io.smallrye.mutiny.groups.UniOnFailure, io.smallrye.mutiny.groups.MultiOnFailure
methods: recoverWithItem, recoverWithNull, recoverWithUni, recoverWithMulti, recoverWithCompletion
rewrite: call transform(Ljava/util/function/Function;) with java.util.function.Function.identity()
source: transform(Function.identity())
changed: recovery removed, so the failure propagates
headline: no test makes {method} fail, so `.onFailure().{name}(…)` is never needed
help: add a test where the upstream fails, and assert what {method} gives instead

Each rule is a list item with these fields:

Field
idrequirednamespace/name, lower case: how reports, --operators and ignores name it
explanationrequiredWhat it changes, and when a test misses that: shown by mutastic explain
ownersrequiredThe types whose calls it matches, in full: java.lang.String
methodsrequiredThe method names it matches
rewriterequiredWhat the call becomes: see below
headlinerequiredA missed mutant’s headline, the gap in the tests
helprequiredThe test to write
changedWhat the line now does, in a code frame: stage removed
sourceHow a call rewrite reads in the source, for its label: first(Long.MAX_VALUE)
tierdefaults (unless it says) or stronger

headline and help can have a version per method, headline.sorted, used for that method instead. In all three wordings, {name} is the method called, {to} what a rename calls instead, and {method} the method the call is in.

RewriteDoesFor example
removeDrops the call and its arguments, and keeps the object it was called on as the result. Only for a method that returns its own type: a stream stage, a builder step, String.strip()stream.filter(p).count() becomes stream.count()
rename a->b, c->dCalls another method of the same type with the same arguments; each a->b is one mutantanyMatch->allMatch, anyMatch->noneMatch
call name(ARGS) with A, BDrops the arguments and calls another method on the same object, which must return the same type. ARGS are the method’s parameter types as the JVM writes them (J for long, Ljava/util/function/Function;)call first(J) with Long.MAX_VALUE

A call rewrite’s arguments are 0, 0L, true, false, null, Integer.MAX_VALUE, Long.MAX_VALUE, or a static method with no arguments, written in full: java.util.function.Function.identity().

A rewrite that doesn’t fit a call it meets, because the types don’t line up, isn’t run: the mutant is skipped as unviable:, with the reason, and stays out of the score. A label, the replacement a mutant shows, is part of its id, so once a rule is in use, change its rewrite and source only knowing that its mutants get new ids.

For an operator a rules file can’t describe, write it in Java: implement io.mutastic.api.Mutator, with the JDK’s java.lang.classfile API, which mutastic is built on. This one makes every whole-number constant one more:

package acme;
import io.mutastic.api.Mutant;
import io.mutastic.api.Mutator;
import java.lang.classfile.CodeBuilder;
import java.lang.classfile.Instruction;
import java.lang.classfile.instruction.ConstantInstruction;
import java.util.List;
public class ConstantPlusOne implements Mutator {
public String id() {
return "acme/constant-plus-one";
}
public String explanation() {
return "A whole-number constant goes up by one.";
}
public boolean onCalls() {
return false; // every instruction is a site, not only calls
}
public List<Change> changes(Site site) {
return site.instruction() instanceof ConstantInstruction constant
&& constant.constantValue() instanceof Integer
? List.of(Change.of("plus one"))
: List.of();
}
public void write(CodeBuilder builder, Instruction instruction, String label) {
var value = (Integer) ((ConstantInstruction) instruction).constantValue();
builder.loadConstant(value + 1);
}
public Words words(Mutant mutant) {
return new Words(
"constant plus one",
"no test tells the constant in " + mutant.methodName() + "() from one more",
"test at the constant itself, and assert the result");
}
}
  • changes is asked about each instruction, with the method and its code around it (site.code(), site.index()), and gives a label for each change it makes there, if any, and why one shouldn’t run (equivalent: …), if it shouldn’t.
  • write puts the changed instruction in its place. Afterwards the stack must hold what the original left; a change that doesn’t verify is skipped as unviable:, never run.
  • words is what a missed mutant’s card says: the change, the headline and the test to write.
  • onCalls says which sites the operator counts: calls, as mutastic’s call operators count them, or every instruction in the method. tier defaults to defaults; return Tier.STRONGER to leave an operator out unless asked for.

Build it against io.mutastic:mutastic-api (compileOnly, or Maven’s provided: mutastic brings it), on JDK 25, and name the class in the jar’s META-INF/services/io.mutastic.api.Mutator:

acme.ConstantPlusOne

Labels are part of mutant ids, as a rules file’s are: keep them once the operator is in use.

A jar can also hold filters, which decide that a method’s mutants shouldn’t run: io.mutastic.api.Filter, named in META-INF/services/io.mutastic.api.Filter. Unlike operators, a plugin’s filters run only when filters: in mutastic.yaml names them: see Configuration and skipping.