fallow mutation is an opt-in workflow that measures how well the tests of a
project detect changes in the code. It makes small changes (mutants) in the
source, runs the project tests against each mutant, and reports which mutants
no test detects. Fallow stays an evidence engine: it reports facts and
read-only next steps. It never writes a test and never proposes a code change.
| Invocation | Runs project code | Output kind |
|---|---|---|
fallow mutation --plan |
no | mutation-plan |
fallow mutation |
yes | mutation |
fallow mutation inspect <mutant_id> [--report <file>] |
no | mutation-inspect |
fallow health --mutation-report <file> |
no | health |
- The default scope is the changed code against the detected base ref, the same
way as
fallow audit.--changed-since <ref>(alias--base) sets the base, and--diff-filegives the diff. --allmutates each source file. Positional paths mutate only those files or directories. The three scopes are exclusive.--workspaceand--changed-workspacesexit with code 2 in version 1, because the plan cannot narrow to a workspace yet. Pass the workspace directory as a path.- The run flags are
--max-mutants,--time-budget(90s,10m,1h),--setup-timeout(default2m),--runner-config,--concurrency,--runtime,--allow-dirty,--allow-side-effects,--min-score,--operators,--verdictsand--typecheck. The global--no-cacheflag also turns off the result cache (see "Result cache"). - Only
--format humanand--format jsonare valid. Version 1 has no SARIF, CodeClimate, LSP, gate, GitHub Action or GitLab surface. - Each run saves its full JSON report to
<cache dir>/mutation/last-report.json(by default.fallow/mutation/). The cache directory has a.gitignorewith*, so the saved report does not make the worktree dirty. -o <path>(--output-file) writes the report only after the run, also for a run that exits2because of baseline failures. The worktree check leaves that path out, so a report from an earlier run does not make the worktree dirty. A shell redirect such as> mutation.jsoncreates the file before Fallow starts, so the dirty-worktree check refuses the run. Use-oor the saved report instead.
- JavaScript and TypeScript files (
.js,.jsx,.mjs,.cjs,.ts,.tsx,.mts,.cts) are one program each. - In a Vue or Svelte component and in an Astro component, only the script
blocks get mutants: the Vue
<script>and<script setup>blocks, the Svelte instance and module scripts, and the Astro frontmatter. A block with asrcattribute has no inline code. The extract crate finds the blocks (fallow_extract::sfc::extract_sfc_scripts,fallow_extract::astro::extract_astro_frontmatter), so the plan uses the same blocks as the analysis. - The instrumented copy of a component is the whole file with each script
block replaced. The template and the styles stay the same, so the framework
plugin of the project compiles the copy. The companion serves a component
copy in the Vite
loadhook, so a framework plugin with its ownpretransformhook also compiles the copy. A request with a query (a style or a template block) is not served: the framework plugin makes it from the component that it compiled. - Spans, lines, columns and diff ranges use the coordinates of the whole file. A mutant id depends on the text of its function, not on the offset of the block, so an edit of the template keeps the ids.
- The prelude goes into each block with module scope (the Vue
<script>, the Svelte module script). When a component has no such block, each block gets its own prelude. - The framework compiler moves some code out of the block scope, where the
prelude is not visible or where a switch is not valid. These positions give
no mutant:
- a call of
defineProps,defineEmits,defineExpose,defineModel,defineOptions,defineSlotsorwithDefaultsin a Vue<script setup>block, its arguments, and the destructure defaults of a declaration such asconst { size = 1 } = defineProps(). Aremoved-callswitch arounddefineExpose()hides the macro from the compiler, and the component then throwsdefineExpose is not defined; - an expression that holds a Svelte rune call (
$state,$derived,$effect,$props,$bindable,$inspect,$host). The rune arguments still get mutants; - an exported declaration in the Astro frontmatter.
- a call of
- Svelte module files (
.svelte.js,.svelte.ts) can use runes. They are one program each with the Svelte rune rule. - The script blocks of a component parse as one program for the side-effect and framework-config rules. A sink in a component template does not run when the module loads, so it does not prune the top-level mutants.
| Operator | Change |
|---|---|
arithmetic-operator |
+ and - swap, * and / swap, % becomes *, += and -= swap |
relational-operator |
the non-redundant relational subset |
logical-operator |
&& and || swap, ?? becomes && |
negate-condition |
a plain condition becomes true and false |
unary-operator |
!x and -x become x |
update-operator |
++ and -- swap |
removed-call |
a call statement becomes a no-op |
return-value |
return expr becomes return undefined |
boolean-literal |
true and false swap |
string-literal |
a string becomes empty, an empty string becomes "fallow" |
optional-chaining |
a?.b becomes a.b |
regex-literal |
one pattern token changes |
removed-chain-link |
r.m(args) becomes r in a fluent chain of a module-level const |
The regex-literal operator gives one mutant for each of these tokens of a
regular expression literal:
- a
^at the start of the pattern is removed; - a
$at the end of the pattern is removed; - a character class is negated (
[a-z]to[^a-z], and the reverse). A pattern with thevflag gives no negation; - a class escape gets the opposite case (
\dto\D, and the same for\wand\s).
The flags stay the same, and an empty pattern becomes (?:). The corpus for
this operator is the JavaScript and TypeScript source files of six public
projects (date-fns, zod, vue core, SvelteKit, reka-ui, runed), without tests.
Each of its 7971 regex mutants compiled with new RegExp. Most literals give
one mutant. Locale data with many patterns gives many: date-fns has 6723 regex
mutants, 6555 of them in its locale files.
The removed-chain-link operator removes one link of a fluent call chain.
For export const quantitySchema = z.number().int().positive(); it gives
two mutants: one without .int() and one without .positive(). A schema of
this type has no literal or operator, so the other operators give no mutant
for it. A link is a call r.m(args) that meets all of these conditions:
- the call is in the initializer of a
constdeclarator, at any depth through arguments, object literals and array literals; - no function encloses the declaration. A
constin a function body or an arrow function gives no mutant; - the callee is a static member
.mwithout parentheses or a type wrapper; - the receiver
ris a call, also behind parentheses or a type wrapper. The root call of a chain, such asz.number(), is not a link; - no link of the receiver chain is optional (
?.), and the call itself is not optional. A wrapper around such a receiver ends the short circuit; - the receiver is not a logging call or a
requirecall.
The removed region starts at the dot of the link, so whitespace and comments
before the link stay. A let or var declaration, an assignment and a call
statement give no mutant. A call statement gets the removed-call mutant.
A plan on five public projects measured the volume. The operator adds 72
mutants to the 28150 of zod, 10 to the 26204 of vue core, 4 to the 36215 of
date-fns, 1 to the 13820 of vscode-languageserver-node, and none to the 672
of pathe. The side-effect rules prune 71 of these 87 mutants. Most of the
other mutants remove an array method such as .filter(), .map() or
.join(), or a schema method such as .transform(). A link that only adds
metadata, such as .describe() in zod, gives an equivalent mutant. Mark it
with an equivalent verdict.
A case label of a switch gives no string-literal or boolean-literal
mutant when the mutant is equivalent. The rule needs all of these conditions:
- the switch has a
defaultclause; - each label is a string, number, boolean or
nullliteral; - the clause body and the
defaultbody have the same source text; - both bodies end in
return,throw, or abreakorcontinuewithout a label; - no other label has the original value or the mutated value.
The original label and the mutated label then select the same body for each input. Mutants in the clause body do not change.
The test of a counted for loop gives no != mutant for relational-operator,
because that mutant is equivalent. The rule needs all of these conditions:
- the init declares one
letcounter with a non-negative integer literal start; - the test is
i < Nori > N, whereNis a non-negative integer literal that the start does not pass; - the update is
i++,++iori += 1for<, andi--,--iori -= 1for>; - the body has no write to a variable with the counter name, also in a nested
function, and no
evalcall.
The counter then takes each integer from the start to the bound, so i < N and
i != N select the same iterations. The <= or >= mutant and the constant
mutant of the test do not change. A var counter, a bound that is not a
literal, and the reversed form N > i keep the != mutant.
A zero comparison on the length of a known array gives no equivalent
relational-operator mutant. An array length is a non-negative integer n,
so these mutants are equivalent:
| Comparison | Equivalent mutant |
|---|---|
n > 0 or 0 < n |
the != swap |
n <= 0 or 0 >= n |
the == swap |
n < 0 or 0 > n |
the constant false |
n >= 0 or 0 <= n |
the constant true |
The receiver of .length must be an array literal or a name that resolves
to a known array binding. Scope resolution finds the binding of each
reference. A binding is a known array when it is one of these:
- a
constwith an array literal,Array.from(...)orArray.of(...)value; - a variable or parameter with the type
T[],readonly T[],Array<T>,ReadonlyArray<T>or a tuple type; - a rest parameter (
...items) with no assignment to it.
A reference to a global, or to a different binding with the same name, keeps
each mutant. A binding with a redeclaration is not a known array. A with
statement, a direct eval call, or a local binding of Array or
ReadonlyArray stops each proof in the file. An object with a length
property, an optional chain (items?.length) and a computed access
(items["length"]) keep each mutant. A type annotation is a type-level
proof: a cast such as as any can pass another value.
A conditional lookup in an array before .filter(Boolean) gives no true
mutant for negate-condition. An example is
["base", align ? alignment[align] : ""].filter(Boolean). For a truthy
align, the true mutant takes the same branch. For a falsy align, the
mutant reads alignment[align], which is undefined, and filter(Boolean)
drops it as it drops "". The rule needs all of these conditions:
- the conditional is a direct element of an array literal, and the array
literal is the receiver of a
.filter(Boolean)call that is not optional, with the globalBoolean; - the test is a name with a local binding, and the consequent is
obj[test]orobj?.[test]with the same binding as its key; - the alternate is
"", an empty template,null, the globalundefined,false,0or0n; objis aconstthat is not exported, has no redeclaration, and has an object literal value, optionally withasorsatisfies;- the declaration of
objends before the conditional starts, so the mutant cannot readobjbefore its declaration runs; - the object literal has only plain properties with name, string or number
keys, and no spread, getter, setter, method, computed key or
__proto__key; - no key is
undefined,null,false,0,NaNor the empty string, because a falsy key converts to one of these keys; - each use of
objis a member read, not an assignment target, adeleteoperand or a callee.
The false mutant does not change. A template literal, a .join without
.filter(Boolean), a .filter((v) => v) callback, a different key, a truthy
alternate and a local Boolean binding keep the true mutant. A with
statement or a direct eval call stops each proof in the file. A change to
Object.prototype or Array.prototype.filter at run time can break the
proof. A call from the value of obj to a hoisted function that holds the
conditional can read obj before its declaration runs. The rule accepts these
risks.
No absolute-value operator (x to -x) is planned. Without types, it applies
to each operand of an arithmetic or relational expression. The same corpus has
114119 mutants, and the operator adds 6473 mutants (5.7 %), 24 % of them on
numeric literals. Each one is on an expression that already has an arithmetic
or relational mutant, so it adds little test-strength signal for its run time.
- Only
fallow mutationwithout--planruns code. Project config cannot start a run. The MCP server exposes the plan and inspect only. - The run refuses a dirty git worktree unless
--allow-dirtyis set. After the run, it compares the worktree with the start state and reports each changed path. - The check covers tracked and untracked paths. For ignored files, it covers
the files that
git status --ignored=matchinglists at the start, up to 10000 files in path order: a write to one of them or a delete is a change. Git lists an ignored directory such asnode_modules/orcoverage/as one entry, so the check does not see the files below it. A new ignored file is not a change, because test runs make such files (coverage, logs). - The check leaves out
<cache>/mutationand the output files of the run. For ignored files it also leaves out the whole cache directory, because the analysis writes its own cache files there. The run refuses a cache directory that is the project root or a directory above it, or that holds a planned source or test file (FALLOW_MUTATION_UNSAFE_CACHE_DIR, exit code2). Such a cache directory would hide the writes of the tests from the check. - Mutants in a function whose own body calls a file system, process, network or
database sink are pruned (
side-effect-sink). A mutant in top-level code is pruned when the module calls a sink at the top level. The pruning does not follow calls: a mutant in a function that calls a helper, which then calls a sink, still runs. The sinks come from the security catalogue, which matches injection shapes only. A database write call with safe arguments, such asdb.insert(table),db.insertInto("person")orprisma.user.create(...), is a sink too. The call must be a member call or a tagged template with a write method name (insert,insertOne,update,delete,del,create,upsert,save,$transaction,$executeRawand similar). A call whose first argument is a path, such as the routeapp.delete("/items/:id", handler), does not count. A prepared statement run (run,all,get,iterate,each) counts when the receiver is a.prepare(...)call, such asdb.prepare(INSERT_SQL).run(value), or a name or a property that the same module sets to one. A declarator, an assignment, a class field (also astaticor a#privatefield) and an object literal property each set one. Examples areinsertStmt.run(value)afterconst insertStmt = db.prepare("INSERT ..."),this.insert.run(value)after the class fieldinsert = db.prepare("INSERT ..."), andstatements.insert.run(value)afterconst statements = { insert: db.prepare("INSERT ...") }. A property matches by its name only, whatever object holds it. A prepared statement whose static SQL text starts withselectis a read and does not count. The check matches names in the same module only, so a statement that a function gets as a parameter, or that the module imports, does not count. In a module that loads a database client package itself, everyrunandexeccall counts too, exceptexecon a regular expression, such as/(\d+)/.exec(text). A computed member with a string key, such as the calleedb["insert"], counts too. A tagged template, or a call whose first argument is a string or a template, is also a sink when its first SQL text starts with a write verb (insert,update,delete,merge,replace,upsert,truncate,create,drop,alter):sql`insert into ...`,pool.query("DELETE ..."),lite.prepare("INSERT ...").run(value). The module must load a database client package (drizzle-orm,@prisma/client,kysely,knex,pg,mongodband similar), or load a project module that loads one. An ES import, arequire()call and animport()call with a literal source each count. A type-only import counts too, because a function that takes the client as a parameter often imports only its type. The check follows one import only. A client that a module reaches through two or more project imports does not count, and a package that is not in the list does not count. In a module with database reach,map.delete(key)andlog.info("update done")are also sinks: the plan does not know the type of the receiver or the purpose of the text.--allow-side-effectskeeps the pruned mutants. This override is a flag only: the config rejectsallowSideEffects, so a project config cannot widen what a run executes. - Mutants in framework config are pruned (
framework-config). Framework config is an export that an active plugin marks as used, for exampleexport const revalidate = 60in a Next.js page. It is also the default export of a tool config file, which is a file that theconfig_patternsor thealways_usedglobs of an active plugin match (vite.config.ts,eslint.config.js,.eslintrc.js, and the VitePress config file in.vitepress). Some plugins list their config file only as always used. Only a glob whose file name holdsconfig,.conf.or anrcsuffix counts, because some plugins also list application files as config, for examplecodegen*.ts,src/module.tsorapp/routes.ts. The config and always-used globs of external plugins count too. In a tool config file, a top-levelmodule.exports = ...statement counts as the default export. A mutant inside a function in such an export is not pruned, so an inline Vite plugin keeps its mutants. The rule lives inframework_config_exports(crates/engine/src/mutation/mod.rs) andframework_regions(crates/engine/src/mutation/plan.rs). - Fallow does not write to project files. The instrumented files live in the Fallow cache. The report lists any path that the tests changed.
- The companions run in their own process group on Unix and in a Job Object on
Windows. Fallow ends that group when a run ends, and when it receives
SIGINT, SIGTERM, SIGHUP or SIGQUIT. When Fallow starts with SIGHUP ignored,
as under
nohup, it keeps SIGHUP ignored. The release binary aborts on a panic, and a panic hook ends the group first. A companion whose stdin closes without ashutdownrequest kills its own process group and exits, so it does not outlive a Fallow process that ended in another way.
| Code | Meaning |
|---|---|
0 |
The plan, the run or inspect finished. Survivors do not fail a run. |
1 |
--min-score or mutation.minScore is set, and the score is lower. |
2 |
Invalid input or an execution error: a dirty worktree without --allow-dirty, a cache directory that hides project files, a missing runtime or runner, a companion file with a wrong digest, an invalid verdict document, --typecheck without a type-aware companion, a run in which baseline failures leave each mutant without a test result, a run path that fails the self-check (see Run-path self-check). |
The flag wins over the config key. The minimum applies to the score, in
percent. A run in which no mutant has a test result has no score. It exits
0 when it has no baseline failure, also when each mutant is no_coverage.
A run in which baseline failures leave each mutant without a test result
exits 2 with FALLOW_MUTATION_BASELINE_FAILED, the same code as a dry run
that fails outside each test file. The failed tests cannot detect a mutant,
so the run measured nothing.
- A mutant has a test result when it is
killed,timeout,survivedorflaky. A run with onlyruntime_error,compile_errorandno_coveragemutants and a baseline failure exits2. - A
pendingmutant keeps the exit code at0. The time budget or--max-mutantsstopped that run, so the baseline failure is not the cause. The completion ispartial. - The error names the first baseline failure and the first line of its message.
- The run always saves the full report to
<cache dir>/mutation/last-report.json, and to the-ofile when-ois given. Thehelpfield of the error gives these paths. - With
--format json, stdout gets the error envelope. When the run cannot savelast-report.jsonand has no-o, stdout gets the report, so the data stays available, and the error goes to stderr. - The human output prints the report and then the error on stderr.
- This check uses the statuses before
--verdicts. Anequivalentverdict for a survivor does not change the exit code to2. The--min-scorecheck uses the statuses after the verdicts. fallow_api::mutation::baseline_blocked_run_errorgives the same error to a caller of the programmatic API. MCP does not run mutants.
--max-mutants N limits the run to the covered mutants. The plan applies
the limit in rank order before the dry run, so the plan cannot know the
coverage. The scheduler corrects this after the dry run:
- A planned mutant that a dry-run test covers keeps its place.
- A planned mutant that no dry-run test covers is
no_coverageand does not use the limit. - Each free place goes to the next covered
pendingmutant in rank order. That mutant runs and gets a normal status. min(N, covered mutants)mutants are inside the limit. Each of them gets a job or a result from the cache, so a run with the cache can execute fewer mutants. The covered mutants after the filled limit staypending.- A statically
no_coveragemutant that the dry run hits (a static map miss) runs outside the limit. - The completion has
mutant-limitonly when a covered mutant stayspending. When the limit leaves onlyno_coveragemutants, the limit cut nothing.
--plan runs no dry run, so its planned and pending dispositions are a
view before the coverage is known. A run can execute a mutant that the plan
shows as pending.
summary.scorecovers the executed mutants only:score = (killed + timeout) / (killed + timeout + survived).summary.coveragereports the unreached mutants apart:coverage = covered / (covered + no_coverage)over the whole plan.coveredcounts each executed mutant (killed,timeout,survived,runtime_error,flaky,compile_error) with a covering test, and eachpendingmutant that a passing dry-run test covers. The dry run loads each mutant of the plan, also a mutant after the--max-mutantslimit. A test covers a mutant when it hits the mutant, or when its file loads the module of a mutant that the module load hits. A static import path alone is not a cover. When a module loads at collection time in one test file and inside a test of another file (for example through a dynamicimport()), the mutant is static and runs the tests of both files. Apendingmutant that no dry-run test covers isno_coverage. A run that--max-mutantscaps therefore reports the coverage and theno_coveragemutants of the full run.no_coveragenever enters the score. A capped run of 200 mutants in a project with 20000 unreached mutants still gets a score that describes the 200 tested mutants.pending,equivalent,flaky,runtime_error,prunedandcompile_errormutants never count in the score.equivalentandprunedmutants never count in the coverage.- Both values are fractions from 0 to 1 in JSON.
--min-scoreandmutation.minScoreuse percent. - Only
completion.status: "complete"makes an empty survivor list conclusive. - The runner can leave out a static test file, for example a test that only
a second Vitest config includes. The tests of that file never run. The
errorsentrytest-files-not-discoverednames each such file, and the completion ispartial(test-files-not-discovered). A survivor or ano_coveragemutant that such a file reaches is then not proof of a test gap. - The
node-testandbun-testadapters find test files with the default patterns of the runner. These glob patterns do not go into a dot directory, such as.github/. Fallow therefore sends the static test files ininit.options.extra_test_files. The adapter adds each file that exists, is not innode_modulesor.git, and matches a default pattern when its directory names have no leading dot. A static test file that the adapter does not add stays atest-files-not-discoverederror. The Vitest adapter does not get this list, because Vitest has its ownincludesetting. - A
partialcompletion does not change the exit code. The run exits0, or1when the score is under--min-score, or2when baseline failures leave each mutant without a test result (see Exit codes).
A survived or timeout result gets one retry in a new companion. Host
load can make a test time out, so a single timeout is not proof that the
tests detect the mutant.
| First result | Retry result | Final status |
|---|---|---|
survived |
survived |
survived |
survived |
timeout |
survived |
timeout |
timeout |
timeout |
timeout |
killed |
killed |
survived |
killed by a test that the first run did not execute |
killed |
| any | runtime_error |
runtime_error |
| any | other result | flaky |
- A
survivedresult with atimeoutretry stayssurvived. The first run finished all tests with the mutant active. Host load alone can make the retry time out, so the timeout is no proof that the tests detect the mutant. Akilledretry of a survivor givesflakywhen a test of the first run kills it. - The retry of a survivor runs each passing test of the files of its
covering tests, not only the covering tests (issue #3670). The per-test
hits of the dry run name only the tests that execute the mutated code. A
test can read a value that an earlier test of its file computed and
cached, so the dry run gives that test no hit. The first run stays
narrow, because most mutants are killed. A static mutant and a runner
without
test_filteralready run whole files, so their retry runs the same tests. - A kill in the retry by a test that the first run did not execute is no
disagreement with the first run, so the status is
killed. A test can fail in a run of part of its file because it depends on the order of the tests. Thus the scheduler keeps this kill only when the retry tests pass in a control run with no active mutant (one control run for each test set, the same memo as for a kill with no executed test). When the control run fails, the first run decides: the mutant stayssurvived, and the failed tests are baseline failures. - The retry of a survivor stops at the first failing test, and that test
decides the status. The companion runs the tests of each file in file
order, so a new test can fail before a test of the first run. The result
is then
killed, also when the test of the first run would fail too. Before, such a pair of runs wasflaky. The control run still guards the kill, and the mutant fails a test in both cases. covering_testsof a retried survivor stays the dry-run hits of the first run. It names the tests that reached the mutant and need an assertion. A kill by a test of the retry can thus name akilled_bytest that is not incovering_tests.- The retry of a
timeoutwaits twice as long. Host load often lasts into the retry, and a real infinite loop still times out. - A
killedresult gets no retry. In repeated runs under host load, no kill became a survivor, and a retry of each kill doubles the run time. - A retry starts only before the
--time-budgetdeadline. A mutant that gets no retry keeps its first status, and the completion ispartial(time-budget). - With
--time-budget, the first pass keeps time free for the retries of the survivors and timeouts that it found. Before each new mutant run, it adds these parts to the current time: the estimated retry time of each suspect (the measured run time, divided by--concurrencyor by the suspect count when that count is smaller), the setup and dry-run time of the retry companions, and twice the mean run time. When the sum reaches the deadline, the next mutants staypending. Thus a budgeted run confirms its survivors, the result cache stores them, and a repeat run with the same budget continues with the next mutants. A survivor that gets no retry is not stored, so a repeat run does it again. The reserve uses measured run times, so the first run of each worker starts without a reserve. A budget that is shorter than one wave of runs can still end before any retry. For a survivor, the reserve adds to its measured run time the dry-run time that the other tests of its files add to its retry. A timeout reserves twice its run time. The control run that confirms a kill by a new test is not in the reserve. When no time is left for it, the retry gets no result and the mutant keeps its first status. - A run that started before the deadline continues until its watchdog
fires, so the command can end after the deadline. For a mutant run, the
overrun is at most
timeout_msplus the setup time plus 2 seconds. For a timeout retry, it is at most twicetimeout_msand the setup time, plus 2 seconds. retried: trueon a mutant result marks a status from a first run and a retry. Asurvivedortimeoutmutant without the field got no retry.- A retried mutant result has an
attemptsarray: the first run, then the retry. Each entry has thestatusand theduration_msof that run alone. Akilledentry haskilled_byand afailure_kind(assertion,errorortimeout). Atimeoutentry has thefailure_kindtimeout. Aruntime_errorentry has adetailwith the first line of the reason. A mutant without a retry, and acachedmutant, have noattempts. No fingerprint or baseline comparison uses the field. - A retry of a real infinite loop costs two more timeout windows.
- A
runtime_errorretry takes the mutant out of the score, also after a firsttimeout. The completion is thenpartial(runtime-error). - A
killedresult after a retry reports the duration of the retry. Each other status keeps the duration of the first run.
scripts/mutation-flaky-rate.mjs compares the saved reports of repeated runs
with the same scope:
fallow mutation --all --no-cache --root <project>
cp <project>/.fallow/mutation/last-report.json run-1.json
# Repeat for run-2.json and run-3.json, then:
node scripts/mutation-flaky-rate.mjs run-1.json run-2.json run-3.json--no-cachemakes each run execute each mutant. A result from the result cache did not run: the script leaves it out ofcompared, andcachedgives its count for each report.comparedcounts the mutants that each run executed.unstablecounts the compared mutants with a different status between runs or aflakystatus in one run.score_flipscounts the unstable mutants that one run detected and another run did not.timeout_flipscounts the score flips with atimeoutin one run.- Each count has a rate over
compared.
Each no_coverage mutant has a test_reach object in the run report and in
the plan. It gives the static reason why no test covers the mutant. The other
statuses do not have the field. It never changes the score or the coverage.
reachuses the classes of theaudittest reach:mocked-only,none,none-file,transitiveanddirect. Treat the set as open.- A mutant in the top-level statement that declares a value export gets the
class of that export, and
export_namenames the export. - A mutant outside each value export declaration gets the class of its file
and has no
export_name. The class isnone-filewhen no test loads the file,mocked-onlywhen no test loads the file and a mock explains an export of the file, andnonewhen a test loads the file. testsandmocked_targetsare present only formocked-only. They list the test files whose references a mock removed and the mocked modules.- A project with no test files gives
none-fileto each mutant. directortransitiveon ano_coveragemutant is not a conflict. The graph has a covered reference to the export, but no test ran the code of the mutant.
The plan classifies the exports of each instrumented file once, with
module_graph::module_exports (the per-file part of
changed_exports_for_changed_paths) and
module_graph::classify_export_test_reach (the per-export part of the audit
test reach). ModuleGraph::test_entry_points_reaching stays a separate query:
it names each test entry point that loads a file, with the mask of that test,
and the runner selects the tests from that list. export_test_reach reads
the shared profiles of the reachability index. It names only the test files
that reference the export, so it cannot give that list.
--setup-timeout limits the start, the dry run and each reload of a runner
process. The default is 2 minutes, and units from ms to h are valid. A
slow or busy host can need more, because the dry run executes each test file.
- For the first runner process, the run stops with exit code 2 and
FALLOW_MUTATION_RUNNER_FAILED, and the help names the flag. - The dry run of a restarted runner process gets the larger of
--setup-timeoutand a limit that scales with the first dry run (see Baseline failures). - For a restarted runner process, the mutant is
runtime_error, and its error names the flag. The run continues. - A reload that passes the limit restarts the runner process.
- The config has no key for this limit.
baseline_failures lists the tests and test files that fail with no active
mutant. Their results decide no mutant status.
- A test that fails in the first dry run never covers a mutant.
- A test that fails in a control run (see Run-path self-check) is also a baseline failure. Its message says that the test fails in a mutant run with no active mutant but passed in the dry run of its whole file. A test that depends on another test of its file gives this result.
- The message of a control-run failure keeps the first error of the test
after that reason: the error name and message on the first line, then the
stack frames of the project. The companion removes the frames of the
runner, Node and dependencies, makes paths relative to the project, and
removes color codes. The companion cuts the error at 4096 UTF-16 code
units, and fallow keeps at most 8 KiB of it. The first line of the
message, with the assertion, also goes into the
FALLOW_MUTATION_BASELINE_FAILEDerror. The human footer shows the reason on theMessage:line and the first line of the error on theError:line. A companion that sends no error gives the reason only. - Each restarted companion runs its own dry run. A covering test that does
not pass there is a baseline failure of that companion, with the ids of the
affected mutants in
mutant_ids. The mutant runs with its other covering tests. Only a mutant with no covering test left isruntime_error, and itserrors[]entry names the mutant. - A static mutant needs a new companion before and after its run when the
runner keeps modules loaded between runs (Vitest with
isolate: false). After a reload of the worker gaverestart_required, the companion that starts for such a mutant dry-runs only the test files of its covering tests. Each other companion dry-runs each test file of the run. A kept companion with a narrow dry run restarts when a later job needs a test file outside it. - The dry run of a restarted companion shares the host with the other
workers. Its timeout is the first dry-run time times 2 times the worker
count, and at least
--setup-timeout(2 minutes by default). On a slow or loaded host, a fixed timeout made each mutant of a restarted companion aruntime_error. The time budget does not stop a dry run in progress, so a companion that hangs in its dry run can extend the run past the budget. - A baseline failure makes the completion
partial(baseline-failure). - A run in which baseline failures leave each mutant without a test
result exits
2(see Exit codes).
The dry run selects whole test files. A mutant run selects the covering tests by id, so it uses another path of the runner API. When a runner release changes that path, each mutant run can fail with an error before a test starts. An error outside a test kills a mutant, so each mutant looks killed and the score is a false 100%. Vitest 4.0 had this fault.
A control run is a run request with mutant_id: null. The companion runs
the requested tests through the run path of a mutant run, with no active
mutant. A control run does not stop at the first failure, so it names each
covering test that fails. Its timeout is the timeout of the mutant job, and
at least --setup-timeout. The scheduler uses control runs in two places:
- The self-check. After the dry run and before the first mutant job, the
first companion runs the tests of the first job as a control run.
- The control run passes when each requested test runs and passes, with no error.
- When no test ran, the run path is broken. The run stops with exit
code 2 and
FALLOW_MUTATION_RUNNER_FAILED, and no mutant gets a status. The message says that the runner executes no tests in a mutant run. It names the runner and its version, and the help asks you to report it. - When tests ran and some of them failed, the run continues. Each failed
test is a baseline failure, the same as a test that fails in the dry
run: it covers no mutant. A mutant with no covering test left is
no_coverage. When this leaves each mutant without a test result, the run exits2withFALLOW_MUTATION_BASELINE_FAILED(see Exit codes). - When tests ran and none failed, but the result is still not a pass
(an error, or a requested test that did not run), the run stops with
FALLOW_MUTATION_RUNNER_FAILED. The message says that the runner gives no trustworthy result in a mutant run. - When the control run times out, the run stops with
FALLOW_MUTATION_RUNNER_FAILED. The message says that the self-check timed out, and the help names--setup-timeoutand a less loaded host. A timeout is not reported as a broken run path. - Each case of a parameterized test (
it.each,describe.each,test.each) and eachnode:testsubtest is its own test id, so the self-check counts it once. - The check needs no run when no job exists: no mutant is covered, or each covered mutant has a result from the cache. The check also needs no run when the time budget ended before it.
- The check costs one run of the tests of one mutant. Its time is part of
timings.mutants_ms. - The check runs only the tests of the first job. A test outside that set that depends on another test of its file can still kill a mutant. The check does not examine that kill again. A later control run can list such a test as a baseline failure after it killed a mutant earlier in the run.
- With
--max-mutants, a planned mutant whose covering tests the check drops becomesno_coverage. The jobs are built again after the check, so the next coveredpendingmutant takes the free place, the same as for a dry-run baseline failure. A mutant that becomesno_coverageduring the mutant runs (see item 2) leaves its place free: the jobs already exist.
- A kill with no executed test. A mutant run that executes no test and
reports an error is
killedonly when the same tests pass in a control run.- A mutant that throws when its module loads gives this result. Its
tests pass with no active mutant, so it stays
killed, with no killer test. - When the control run does not pass, the mutant is
runtime_error, and itserrors[]entry gives the reason. The mutant gets no retry. - A test that ran and failed in such a control run is a baseline
failure for the rest of the run. Each later mutant run leaves it out,
and
mutant_idsof the failure lists those mutants. A later mutant whose covering tests are all such failures isno_coverage, with no retry, the same as after a drop by the self-check. - The scheduler stores the control result of each test set for the run. Each distinct test set gets at most one control run, and the test set of the self-check gets none. A fault of the companion is not stored.
- A control run that times out is stored for its test set. The mutant
is
runtime_errorwith no retry, and each later mutant of this kind with the same tests gets the same reason at once. A test set that hangs therefore costs one control run. - After the
--time-budgetdeadline, a control run that has no stored result does not start, and the mutant ispending.
- A mutant that throws when its module loads gives this result. Its
tests pass with no active mutant, so it stays
A control result is never a mutant result, so the result cache never stores it.
The companion has three runner adapters. The mutation.runner config key
selects one. The default auto reads the project in this order:
vitestin the rootpackage.jsondependencies selects Vitest.- Otherwise the test module that the most static test files import:
vitest,bun:testornode:test. A tie goes to Vitest, thenbun test. - Otherwise a
bunfig.toml,bun.lockorbun.lockbat the root selectsbun test. - Otherwise a
node --testorborpcommand in apackage.jsonscript selectsnode:test. - Otherwise Vitest.
| Runner | Config value | Versions | Runtime of the companion |
|---|---|---|---|
| Vitest | vitest |
4.1 to 5 | node, then bun |
node:test |
node-test |
Node.js 22.18 to 26 | node |
bun test |
bun-test |
Bun 1.4 and later 1.x releases | bun |
The companion refuses a runner version outside this range. The run exits
with code 2 and FALLOW_MUTATION_RUNNER_UNSUPPORTED. The message names the
installed version and the supported range. The help gives the install step
for the range. When the minimum version is not a .0.0 release, the help
also says that a release of that major version is enough. For Vitest, the
help is:
install vitest 4.1.0 or a later version below 6.0.0; a vitest 4.x release from 4.1.0 is in the range
Vitest 4.0 is not supported. It reads the test-id filter of a test specification as a list of test lines. Each mutant run then fails with an error, and each mutant looks killed. Vitest 4.1 added the test-id filter.
Bun 1.3 is not supported. Bun 1.3.0 to 1.3.9 write no JUnit report when
--bail is set, so a run gets no test results. Bun 1.3 also gives each
failed test the JUnit type AssertionError, so a runtime error looks like
an assertion failure. Bun 1.4 fixes both.
borp is a test runner that runs node:test. The script parser reads the
test files of a borp command in the order of borp 1.0:
- The positional arguments. A glob that starts with
!excludes files. - Otherwise the glob of
-por--pattern. - Otherwise the default glob
**/*.test.{js,mjs,cjs,ts,mts,cts}.
These files are test entry points, so the plan finds the static test files
of the project. The parser does not apply a ! exclusion glob: a file that
the exclusion removes stays a test entry point when another glob matches it.
The parser does not read a .borp.yaml file. A project that sets its test
files in .borp.yaml and runs a bare borp command gets the default glob.
--runner-config <path> (or the mutation.runnerConfig config key) selects
the config file of the test runner. Use it for a project that keeps its
integration, UI or browser tests in a separate Vitest config, for example:
fallow mutation src/math.ts --runner-config vitest.integration.config.mjs- The flag wins over the config key.
--configis not this flag: it selects the Fallow config. - A relative flag path resolves against the project root, then against the current directory. A relative config key path resolves against the project root.
- The file must exist inside the project root. Otherwise the run exits with
code 2,
FALLOW_INVALID_MUTATION_OPTIONSand the contextmutation.runnerConfig. - Only the Vitest adapter reads a config file. With the
node-testorbun-testrunner, a selected config exits with code 2. - Without a selection, Vitest finds its config the usual way.
- The selected config sets the test files that Vitest lists. A static test
file of the plan that the selected config does not include stays visible as
a
test-files-not-discoverederror, and the run ispartial.
--runtime must name a runtime that the runner accepts. A Bun runtime for
node-test, or a Node runtime for bun-test, exits with code 2 and
FALLOW_MUTATION_RUNTIME_NOT_FOUND.
Each adapter gives the tests the environment variables that the native
runner command sets. Code that reads NODE_ENV then behaves as in a normal
test run.
| Runner | Variables | Source |
|---|---|---|
| Vitest | TEST=true, VITEST=true, NODE_ENV=test when it is not set |
The adapter sets them as the vitest command does |
node:test |
None | node --test sets no NODE_ENV |
bun test |
NODE_ENV=test when it is not set |
bun test sets it in each test process |
- The Vitest adapter calls
createVitestdirectly and not thevitestcommand. Without these variables, Vite setsNODE_ENV=development, and the test workers inherit it. - A
NODE_ENVvalue of the environment wins, the same as with the Vitest CLI. For example,NODE_ENV=production fallow mutationruns the tests underNODE_ENV=production. - Internal protocol detail: the companion applies the
options.envvalues of the init request after these variables, so they win. The API sends an emptyoptions.env.
- A Vite plugin of the adapter serves the instrumented files to each
importthat the Vite module runner handles. A script file gets its copy in thetransformhook, and a component file in theloadhook. - A native
require, for example throughcreateRequire(import.meta.url), does not go through Vite. The setup file of the adapter registers a synchronous module hook (module.registerHooks) in each worker, so that load also gets the instrumented file. The hook replaces only the files of the overlay, and it reports each served path in the test meta (fallowLoaded). The adapter adds these paths to the loaded set, sounloaded_pathsstays correct. - The adapter gives the overlay to each project with
project.provide. A worker that runs more than one test file (isolate: false) registers the hook once. - The hook needs Node 22.15 or later. On an older Node, and in browser mode,
a native
requireloads the original file, and the path stays inunloaded_paths. A staticimportof the file is the workaround.
- Each dry run and each mutant run starts one test process for each test
file. The
node:testadapter usesrun()ofnode:testwith process isolation. Thebun testadapter startsbun testwith a JUnit report. - A preload file serves the instrumented files.
node:testuses a synchronous module hook (module.registerHooks), soimportandrequireboth get the instrumented file. Node strips the types of a TypeScript file.bun testuses aBun.pluginload hook. - The
node:testadapter gives each test process the module loading flags of the project test script. The API reads thepackage.jsonscripts at the root: thetestscript first, then the other scripts in name order. The firstnode ... --testcommand gives the flags (crates/api/src/mutation/node_args.rs). The init request carries them inoptions.node_args, each flag with a value as two entries. - These flags pass:
--experimental-transform-types,--experimental-strip-types,--no-experimental-strip-types,--import,--require(-r),--experimental-loader(--loader) and--conditions(-C). Other flags of the command are ignored. A relative--import,--requireor loader value resolves against the project root. The preload of the adapter comes before these flags, so a loader of the project wraps the hook of the preload. - The detection reads only a command whose program is
node. A wrapper command, for examplecross-env X=1 node --testortsx --test, gives no flags.npm runindirection needs no special step, because the API reads every script. NODE_OPTIONSof thefallow mutationprocess also reaches each test process. Use it for a flag that the script detection does not read, for example when the baseline fails withERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.- A test file that does not load (for example a TypeScript
enumwithout--experimental-transform-types) is a baseline failure. Its message starts with the error line of the test process stderr, for exampleSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode. The last lines of the stderr follow. - The test files are the files that the default patterns of the runner
match, without
node_modulesand.git. A static test file that the patterns do not match is not run, and the report says so. - A test id is
<file>::<suite> > <test>, as for Vitest. node:testreports the hits of a test in a test diagnostic. Bun hooks get no test context, so thebun testadapter joins the hits to the JUnit results by order. When the order is not safe, each test that ran in the file gets the hits of the file: tests that overlap (concurrencyinnode:test,test.concurrentin Bun), a count that differs, orrandomize = trueinbunfig.toml. The scheduler then runs a mutant against each test of the file that can kill it.- A run selects the requested tests with a name pattern. Each whitespace run in the pattern matches any whitespace, because a Node test process matches a name without its trailing whitespace.
- With
bail, a run stops the test process at the first failure. Node 22 applies the run timeout to the whole test file process. A requested test with no result after that timeout counts as a timeout. - The
node:testpreload adds--importofruntime/node-subprocess-preload.mjstoNODE_OPTIONSof the test process. So a Node process that a test starts (spawn,exec,execFileorfork) also loads the instrumented files with the active mutant. The URL of that import carries the overlay path and the mutant id, so the test environment has noFALLOW_MUTATION_*variable. The hits of such a child process do not count as coverage, so a mutant that only a child process reaches staysno_coverage. A child that is not Node (for examplebun), or a child that gets an environment withoutNODE_OPTIONS, loads the original files. - The in-process mode of
node:test(isolation: "none") is not supported. - Neither runtime can load a Vue, Svelte or Astro component file. The
node:testhook calls the default loader first, and that loader refuses the file type. The Bun hook serves the copy with a TypeScript loader, and a project plugin that serves the file first serves the original. So the plan leaves each component file out, with the skip reasonrunner-cannot-loadinskipped_files. Its mutants are not in the plan and not in the coverage. - The runner decision reads the static test files of the plan. When the
first plan has a component mutant and the runner cannot load components,
the plan is built again from the same analysis without the component
files (
plan_mutations_for_session_with). The run keeps the runner of the first decision.--planuses the same rule, so the plan and the run agree.
A type test (*.test-d.ts, *.spec-d.ts, or a TypeScript file below a
test-d directory) is not a test file of the plan. A compiler checks it (tsd,
expect-type, the Vitest type check), and no runner executes it. Before this
rule, a tsd file was the only static test file of some projects. The dry run
then ran it as a runtime test, it failed as a baseline failure, and runner
detection read its imports. A project whose only test entry points are type
tests has no_test_files: true. A file that only type tests reach has no
coverage, and its mutants get the test reach none-file.
runner.notes lists facts of the runner setup that limit the result. The key
is absent when the runner states no fact.
typecheck-not-run: the project config enables the Vitest type-check pass (typecheck.enabled). The run does not execute it, so a mutant that only a type test kills is not counted as killed. The human output prints one line for this note.
runner is absent when no companion started, for example in a project with
no test files.
A run stores the result of each killed mutant and each survived mutant
that a retry confirmed. A budgeted run keeps time for these retries (see the
retry section), so a repeat run with the same --time-budget continues
after the stored results. The file is <cache dir>/mutation/results.json. A
later run uses a stored result when the key is equal, and does not run that
mutant. The dry run always runs, because it gives the covering tests.
The key hashes these parts:
- the key format and the Fallow version;
- the runner name, the runner version and the runtime version;
- the mutant id, and the content of the mutated source file;
- the covering test ids of the first run. The retry of a survivor adds only tests of the same files, so the file digests below also cover the tests of the retry;
- the content of the covering test files, and of the files that they reach
through static imports in the module graph (
MutationPlan::test_file_reach). The run computes one digest for each test file and its reach, and each mutant key combines the digests of its covering test files; - the content of the runner setup files (Vitest
setupFiles, thepreloadfiles ofbunfig.toml), of the runner config modules at the root (vitest.config.*,vite.config.*andvitest.workspace.*), and of the files that they reach through static imports (MutationPlan::support_files). A change to a config fragment that the runner config imports changes the key. A runner config that--runner-configormutation.runnerConfigselects is a support file too, so a change to it changes the key. The key does not hash the files that a selected config imports; - the lockfiles of the first directory from the root upward that has one;
- the runner config files at the root:
vitest.config.*,vite.config.*,vitest.workspace.*,bunfig.toml,package.jsonandtsconfig.json. An absent file is also a key fact, so a new config file changes the key; - the configs of the
extendschain of the roottsconfig.json, at most 32 files. A relativeextendsresolves to the path, or to the path with.json. A packageextendsresolves in eachnode_modulesdirectory from the config upward, to the path, the path with.json, ortsconfig.jsonin the path. Theexportsmap of a package is not read. Anextendsthat does not resolve adds only its text to the key.
The reach ignores vi.mock, so a replaced module is still in the key. This
gives more misses, never a stale hit.
Known limit: dynamic imports that the module graph does not resolve, and files
that a test or a runner config reads, are not in the key. Runner configs and
tsconfig files below the root, for example in a workspace package, are not in
the key. A change to them does not invalidate a stored result. Each cache entry records this limit in not_keyed, and the
report repeats it in cache.not_keyed.
- A
timeout,flaky,runtime_errororpendingresult is never stored. A control run (see Run-path self-check) is never stored. A timeout depends on the load of the machine. A survivor that the time budget left without a retry is not stored. A survivor of a run without a dropped covering test (see Baseline failures) is not stored, because the key covers that test. - A reused result has
cached: trueinmutants[]. It counts in the score and the coverage the same as a result of this run. cachein the report giveshits,storedandnot_keyed. It is absent with--no-cacheand when no companion started.- The human output prints one
Cache:line when a result came from the cache. - The global
--no-cacheflag turns the result cache off. The run then reads and writes no entry. - The file keeps one entry for each mutant id, and at most 20000 entries. It drops the entries that no recent run stored or used. A file that does not parse, or that has another format version, gives an empty cache.
- A failed write of the file adds a
cache-write-failederror and keeps the run result. - The scheduler talks to the cache through the
ResultLookuptrait incrates/api/src/mutation/scheduler. The key and the file live incrates/api/src/mutation/result_cache.
The run writes the instrumented files, paths.json and the runtime hooks to
<cache dir>/mutation/runs/<content hash>/. The hash covers each planned
path and its instrumented text, so two runs of the same plan share one
directory.
- The write goes to a
.staging-*directory belowruns/, which the run renames into place. A crash or a concurrent run never leaves a partial directory, and a run that reads the directory never sees a partial file. - A directory whose files have the expected content is used again without a write. A directory with other content is replaced in one rename.
- Each run records its use in a
.last-usedmarker file. At the start of a run, Fallow removes the run directories that are not among the 5 most recently used ones and that no run used in the last hour. The hour keeps the directory of a concurrent run. The run never removes its own directory. A.staging-*directory older than one hour, which a crash can leave, is also removed.
--typecheck (or mutation.typecheck: true) sends each survived mutant to
the TypeScript-Go companion of --type-aware after the tests. Other mutants
are not checked, so the cost grows with the number of survivors only.
- The companion applies one mutant at a time to an in-memory copy of its file. It never writes a project file.
- It compares the error diagnostics of the mutated file with the diagnostics of the original file. The comparison uses the diagnostic code and message, because the positions move. An error that the original file already has does not count.
- A survivor with a new error gets the status
compile_error, andtype_error_codeslists the codes (at most 8). The mutant does not count in the score, and it counts as covered. - The check reads only the mutated file. A mutant that breaks only another
file, for example a caller of a function with an inferred return type,
stays
survived. The check also does not run Vitest type tests, so thetypecheck-not-runnote stays. - A survivor that the checker cannot check stays
survived. The reasons areno-project(notsconfig.jsonincludes the file),not-type-checked(// @ts-nocheck, or a JavaScript file withoutcheckJs),source-changed,file-missingandunsupported-file(a Vue, Svelte or Astro component).typecheck.skippedcounts them. - The run does not send a component survivor to the companion. The
TypeScript checker cannot read a component file, so the run gives it the
reason
unsupported-filedirectly. - The run looks for the companion before the tests start. When no trusted
companion exists, the run exits with code 2 and
FALLOW_MUTATION_TYPECHECK_UNAVAILABLE. Discovery is the same as for--type-aware:FALLOW_TYPE_AWARE_BIN, the npm wrapper, or a sibling of the Fallow executable. - When the companion fails after the tests, the survivors keep the status
survived. The report hastypecheck.completed: false, an error with the codetypecheck-failed, and the completion reasontypecheck-failed.typecheck.skippedstill counts the component survivors, because they never go to the companion. typecheckis absent from the report when the option is off. A run without the option never setscompile_error.--plan --typecheckexits with code 2, because a plan has no survivors. The config key has no effect on a plan.- The protocol operation is
mutant-typecheckincrates/api/type-aware-protocol.json. It is a one-shot request; a semantic session refuses it.
A verdict document replaces an inline suppression comment:
{
"schema_version": "1",
"verdicts": [
{ "review_key": "3fc446a8dd3213b8", "verdict": "equivalent", "note": "same result for each input" }
]
}review_keyis themutant_id. The id hashes the path, the text of the enclosing function, the span in that function, the operator and the replacement. It stays the same until the function changes, so a separatereview_keyfield would carry no extra information.- A verdict for a
survived,no_coverageorflakymutant sets the statusequivalent. A verdict for a killed, timed-out orcompile_errormutant iscontradicted, and the status stays. A verdict for apending,runtime_errororprunedmutant isdeferred. A verdict that matches no mutant isunmatched; one document can serve many scoped runs. - A malformed document, an empty
review_keyor a duplicatereview_keystops the command with exit code 2 before any test runs. Unknown fields are refused. The check isfallow_api::validate_mutation_verdicts. - The join is
fallow_api::apply_mutation_verdicts. The report records the document digest and each list underverdicts. A run without--verdictshas noverdictskey. - Version 1 still runs the mutants that a verdict marks. The join happens after the run.
Each mutant of the mutation report and of the mutation-plan document has a
change object, for example {"from": "??", "to": "&&"}. The object tells
which edit the mutant makes, and it keeps the rule that a report holds no
source text.
- The collector sets the change when it makes the candidate. The type of each
side is
&'static str(fallow_mutation::MutantChange), so a side cannot hold text of the source file. - A side is an operator token, a fixed literal (
true,false,undefined,void 0,"",'',"fallow",'fallow'), or a regular expression token (^,$,[,[^, or a class escape such as\d). - A side that is project code is a placeholder. A
negate-conditiontest is<condition>. Aremoved-callstatement is<call>, and aremoved-chain-linklink is<call>with an emptyto. Areturn-valueargument and a relational expression that becomestrueorfalseare<expression>. A non-emptystring-literalis<string>. - An empty side means that the edit removes the token, for example
{"from": "!", "to": ""}forunary-operator. - A report that an older version wrote has no
change.inspectcopies the saved value and does not add one.
inspect reads a saved mutation or mutation-plan document and the current
file. Without --report, it reads the report that the last run saved in the
Fallow cache. Pass the same --root and --config as the run, because the
config can move the cache directory. Without --root and --config, a
search from a subdirectory walks up to the git worktree root, so it finds the
report of a run at the project root and reads the files of that root. When no run saved a report, inspect exits
with code 2 and FALLOW_MUTATION_NO_REPORT. Each run replaces the saved
report, so an id from an earlier run exits with code 2 and
FALLOW_MUTATION_UNKNOWN_MUTANT. Pass --report <file> to read another saved
report. The report holds no source text,
so inspect instruments the current file in memory and finds the mutant by id
and span.
fresh: the file has the SHA-256 digest of the report, and the instrumented file gives the mutant. The packet shows the original and mutated lines.stale:file-missing,no-digest,source-changedormutant-not-reproduced. The packet shows no code, so a changed file never gives a wrong answer.
The run report lists a digest for each mutated file. The plan lists digests
only for the files that it instruments, so a plan mutant in a file that no test
reaches inspects as no-digest.
fallow health --mutation-report <file> reads a saved run report and adds
the mutation score of each complexity finding next to its CRAP score. It
reads files only. It runs no test.
- The file is the saved report (
<cache dir>/mutation/last-report.json) or the-ofile of a run. A plan has no results, so health refuses it with exit code 2 andFALLOW_MUTATION_REPORT_INVALID. A missing or invalid file gets the same exit code and code. - A finding matches one entry of
functions[]when the path, the first line and the name are equal. Two entries with the same key make the match ambiguous, and the finding gets no evidence. - The finding gets
mutation.state.freshmeans the file has the SHA-256 digest of the report. Thenmutation.countshas the counts of the function andmutation.scorehas its score. The score is absent when no mutant of the function ran to a tested result. stalemeans the file changed (source-changed), is missing (file-missing) or has no digest in the report (no-digest). A stale finding hasstale_reasonand no score and no counts, so a changed function never shows an old value.- The top-level
mutationobject has the completion status of the run, the number of function results, the number of fresh, stale and unmatched findings, and the stale files.sectionsthen hasmutation. - The join runs after the health report is complete. The mutation score
never changes
crap, the health score, the vital signs or a gate. - Only complexity findings get a score. A function under each threshold has
no finding, so its score stays in the mutation report. The findings of
each
--group-bygroup get the same evidence. - The human output prints one line under the CRAP line of each matched finding, and one note line with the matched and stale counts. The other output formats (SARIF, CodeClimate, markdown, compact) do not show it.
- The score uses the final statuses after the retries (see Retries).
- A run with human output and no
--quietprints phase lines on stderr. Each line starts withfallow: mutation:and ends with the run time, for examplefallow: mutation: 8/20 mutants done (1m25s). - The phases are the runner setup, the baseline run, the mutant runs, the retries of survivors and timeouts, and the final worktree check and report. A run with no covered mutant and no test file prints only the final phase.
- The start of each phase prints at once. A mutant count prints at most once in each interval and at the end of its phase. A phase that prints nothing for one interval prints a line that says it continues. The interval is 5 seconds on a terminal and 15 seconds in captured output.
--format jsonand--quietprint no progress lines. Stdout never gets a progress line.fallow_api::run_mutation_with_progresssends the same phases asfallow_api::MutationProgressevents to a caller of the programmatic API.
- The output uses the section headers and colors of the other commands.
NO_COLOR, a pipe and-oremove the colors. - The header says
(ran the project tests)only when a companion started or a mutant has a tested result. Otherwise it says(no test ran). - The score line: the score, killed, survived, timeout, and the completion
status. The line adds
pending,equivalent,runtime error,flakyandcompile erroronly when the count is not zero. - The scope line shows a full 40-character base SHA as its first 12
characters, as
auditdoes. No other line repeats the base ref. - The coverage line: the coverage and the no-coverage count.
- One line when
runner.noteshastypecheck-not-run: type-level kills are not counted. When the survivor type check ran, the line says that only type errors in the mutated file are counted. - One line for the survivor type check: the checked count, the count that
does not type-check, and the skipped count by reason. A failed check says
that the survivors keep the status
survived. - One
Cache:line when a result came from the result cache. It names--no-cache. - The 10 best-ranked survivors grouped by file, files and mutants in rank
order, and one
and N more survivorsline. Each survivor has the location, the operator, the original and mutated code, and one line with the count of covering tests and the first test name, cut to 60 characters. A long code line shows a window around the changed token, the text that differs between the original and the replacement. Both sides use the same window start. The JSON report lists all survivors and all covering tests. - The
inspectoutput shows the first 10 covering tests under aCovering tests (N)header, or the first 10 static test files under aTest files that reach the file (N)header, and oneand N moreline. The--format jsonpacket lists all of them. - The plan lists the first 20 planned mutants under a
Planned (N)header, in run order, and oneand N more mutantsline. - The minimum-score line uses the status prefixes of the other commands:
[OK]when the score meets the minimum,[X]when it misses the minimum, and[-]when no score is measured. - One line for the baseline failures. It names the failing test when there
is one, and the count and the first test when there are more. A
Messageline under it shows the first line of the message of the first failure, at most 120 characters, for exampleInvalid environment variables. - Each
Errorline names its mutant when the error belongs to one mutant. - A separate no-coverage section with one line for each file and its count.
The line adds the counts by test reach class, for example
test reach: 2 mocked-only, 1 none-file, in the class order of theaudittest reach. The section shows the 10 files with the most mutants and oneand N more filesline. The JSON report has the full list. - The plan prints one
No coverage by test reachline with the same counts when a mutant isno_coverageand the project has test files. - One
Skipped files:line with the count of each skip reason, in the run and in the plan. The JSON report lists each path. Arunner-cannot-loadskip adds one line that namesmutation.runnerand Vitest as the next step. - The pruned counts by reason, and one line for "no tests" or "no mutation
data", never one line for each function. A project with no test files gets
the one "no tests" line with the mutant count, and no no-coverage section.
An empty changed-code scope names
fallow mutation --plan --all, which lists the mutants of each file and runs no code. - The next step is read-only:
fallow mutation inspect <id>for the first survivor. It reads the saved report of the run, so no step runs the tests again. The step repeats--rootand--configwhen the run used them.
{
"mutation": {
"runner": "auto",
"operators": ["arithmetic-operator", "relational-operator"],
"minScore": 80,
"concurrency": 4,
"typecheck": true,
"runnerConfig": "vitest.integration.config.mjs"
}
}runnerisauto,vitest,node-testorbun-test. See Runners.operators,minScoreandconcurrencyapply to each run. A CLI flag wins.runnerConfigselects the runner config file for each run.--runner-configwins. See Runner config.typecheck: trueturns on the survivor type check for each run.--typecheckturns it on for one run. No flag turns it off.- The section has no source globs and no timeout keys.
MutationRunOptionshas no field for them, and a public key must have an effect. The section rejects unknown keys, so a config withinclude,exclude,timeoutFactorortimeoutMsfails to load. Add a key only together with its run option. - The section is not part of the detection digest of dead-code findings.
| Part | Path |
|---|---|
| Mutant sites and instrumentation | crates/mutation |
| Component script blocks | crates/mutation/src/blocks.rs, crates/engine/src/mutation/layout.rs |
| Selection, test map, prune and rank | crates/engine/src/mutation |
| Runner protocol, companion, scheduler, report, verdict join, survivor type check | crates/api/src/mutation |
| Type check request and response | crates/api/src/type_aware/mutant_typecheck.rs |
| Type check in the companion | tools/type-aware-sidecar/src/mutant-typecheck.mjs |
| Envelopes | crates/output/src/mutation.rs |
| Config section | crates/config/src/config/mutation.rs |
| CLI adapter, human output, inspect | crates/cli/src/mutation_cli.rs, mutation_human.rs, mutation_inspect.rs |
| Health join | crates/cli/src/health/mutation_report.rs, crates/output/src/health_mutation.rs |
| MCP plan and inspect tools | crates/mcp/src/tools/mutation.rs |
| Node companion and runner adapters | tools/mutation-runner |
| Runner selection | crates/api/src/mutation/runner_select.rs |
cargo test -p fallow-cli mutationruns the CLI unit tests and the integration tests incrates/cli/tests/integration/mutation_tests.rs.- The integration tests use a project with no test files for the exit policy, the verdict join and inspect, so they need no JavaScript runtime.
- The Vitest end-to-end test needs
nodeonPATHandnpm ciintools/mutation-runner. Without them it prints a skip message and passes. The type check end-to-end test also needsnpm ciintools/type-aware-sidecar. node --test test/mutant-typecheck.test.mjsintools/type-aware-sidecartests the companion side of the type check.- The
node:testend-to-end test needs a supportednodeonPATH, and thebun testend-to-end test needs a supportedbun. Without it, the test prints a skip message and passes. npm testintools/mutation-runnerruns the companion suites. Thebun testsuite is skipped whenbunis not onPATH.