Skip to content
Fallow home
All docs pages

Mutation testing with fallow mutation

Mutation testing for JavaScript and TypeScript. fallow mutation changes your code, runs Vitest, node:test, or bun test, and reports changes no test detects.

fallow mutation runs mutation testing on JavaScript and TypeScript code. It measures whether your tests fail when the changed code is wrong. It makes small changes to the code (mutants), runs your project tests against each mutant, and reports the mutants that no test detects.

A mutant changes one thing, for example <= to <, + to -, or true to false. When a test fails, the test detected the change and the mutant is killed. When every test passes, the mutant survived. A survivor shows a place where the code can be wrong and your tests still pass.

Fallow reports evidence. It does not write tests and it does not propose code changes. You decide what a survivor means.

Mutation testing is opt-in. Bare fallow, fallow audit, fallow health, the CI gates, SARIF, CodeClimate, LSP, the GitHub Action, and the GitLab template never run it. fallow health can read a saved report. See Mutation score in fallow health.

fallow mutation

Commands

CommandRuns project codeJSON kind
fallow mutation --planNomutation-plan
fallow mutationYes, the project testsmutation
fallow mutation --recheckYes, the project testsmutation
fallow mutation --compare <PATH>Yes, the project testsmutation
fallow mutation inspect <mutant_id>Nomutation-inspect
fallow mutation tasksNomutation-tasks

Only fallow mutation without --plan runs code. Project config cannot start a run, and the MCP server cannot start a run.

Scope

By default, fallow mutates only the changed code against the base ref. It finds the base ref in the same way as fallow audit.

FlagDescription
--changed-since <REF>Set the base ref, for example main or HEAD~5. Alias: --base.
--diff-file <PATH>Use a unified diff for the changed lines. Pass - to read stdin.
--allMutate every source file.
<PATH>...Mutate only these files or directories.

The changed-code scope, --all, and paths are exclusive. --workspace and --changed-workspaces exit with code 2, because the plan cannot narrow to a workspace yet. To mutate one package, pass its directory as a path:

fallow mutation packages/billing

Source files

The plan, the run, and inspect cover these files:

  • JavaScript and TypeScript files: .js, .jsx, .mjs, .cjs, .ts, .tsx, .mts, and .cts.
  • Vue, Svelte, and Astro components (.vue, .svelte, .astro). Only the script blocks get mutants: the Vue <script> and <script setup> blocks, the Svelte instance and module scripts, and the Astro frontmatter. The template and the styles do not change, so the framework plugin of your project compiles the mutated component.
  • Svelte module files (.svelte.js, .svelte.ts).

The mutant line, column, and span in a component are positions in the whole file, not in the script block. An edit of the template does not change the mutant ids.

The framework compiler moves some code out of the script block. These positions give no mutant:

  • A call of a Vue compiler macro (defineProps, defineEmits, defineExpose, defineModel, defineOptions, defineSlots, withDefaults) in a <script setup> block, its arguments, and the destructure defaults of a declaration such as const { size = 1 } = defineProps().
  • An expression that holds a Svelte rune call, for example $state(0) or $derived(a + b). The arguments of the rune still get mutants.
  • An exported declaration in the Astro frontmatter.

Plan without running code

--plan selects, maps, and ranks the mutants. It runs no code.

fallow mutation --plan
fallow mutation --plan --all

The plan shows each mutant with its location, function, operator, and id:

Mutation plan (no code runs)
Scope: all source files
Mutants: 8 planned, 0 pending, 0 no coverage, 0 pruned
Files: 1 instrumented, 1 test file reaches them

● Planned (8)
  #1 src/pricing.ts:2:7  applyDiscount  relational-operator  c65def5cd9bd279a
  #2 src/pricing.ts:2:15  applyDiscount  relational-operator  72487a15b6adc213
  ...

Use the plan to see the size of a run before you start it. When the changed-code scope is empty, the output suggests fallow mutation --plan --all.

When a mutant is no_coverage and the project has test files, the plan prints one line with the test reach counts, for example No coverage by test reach: 2 mocked-only, 1 none-file.

Run the tests

fallow mutation without --plan runs your project tests against each mutant.

fallow mutation

Runners

Runnermutation.runner valueVersionsDefault runtime
VitestvitestVitest 4.1 to 5, in Node modenode, then bun, from PATH
node:testnode-testNode.js 22.18 to 26node
bun testbun-testBun 1.4 and later 1.x releasesbun

Vitest 4.0 is not supported. Fallow stops with an error for Vitest 4.0, because that version ignores the test-id filter and each mutant looks killed.

Bun 1.3 is not supported. Fallow stops with an error for Bun 1.3, because that version gives no test results for a run that stops at the first failure, and it reports each runtime error as an assertion failure.

The default value auto selects the runner from the project, in this order:

  1. A vitest dependency in the root package.json selects Vitest.
  2. Otherwise, the test module that most test files import: vitest, bun:test, or node:test.
  3. Otherwise, a bunfig.toml, bun.lock, or bun.lockb file at the root selects bun test.
  4. Otherwise a node --test or borp command in a package.json script selects node:test.
  5. Otherwise, Vitest.

borp is a test runner that runs node:test. Fallow reads the test files of a borp command in this order. First, it reads the positional arguments. Fallow ignores a glob that starts with !, so it does not exclude those files. Second, it reads the glob of -p or --pattern. Third, it uses the default glob **/*.test.{js,mjs,cjs,ts,mts,cts}. These files are test entry points, so the plan finds the test files of the project. Fallow does not read a .borp.yaml file.

Fallow finds Vitest in the project node_modules. For node:test and bun test, each test file runs in its own process. The test files are the files that the default patterns of the runner find. These patterns do not go into a hidden directory, such as .github/. A test file in a hidden directory also runs when the plan maps it statically and its path matches a default pattern of the runner. A mapped test file that no default pattern matches does not run, and the run is partial with the reason test-files-not-discovered.

With Vitest, a test that loads a source file with a native require, for example through createRequire(import.meta.url), also gets the mutated file. This needs Node.js 22.15 or later. On an older Node.js version, the test runs the original file, and the report lists the file in unloaded_paths. Load the file with a static import instead.

The node:test runner reads the node --test command in your package.json scripts. It reads the test script first, then the other scripts in name order. Each test process gets the module loading flags of that command:

  • --experimental-transform-types
  • --experimental-strip-types and --no-experimental-strip-types
  • --import and --require (-r)
  • --experimental-loader (--loader)
  • --conditions (-C)

Fallow ignores the other flags of the command. A relative --import, --require or loader path resolves against the project root. Example: a project that uses a TypeScript enum runs its tests with node --experimental-transform-types --test. fallow mutation then uses the same flag, and you do not need to set it.

NODE_OPTIONS of the fallow mutation process also reaches each test process. Use it for a Node flag that is not in the list above.

With node-test, a Node process that a test starts (for example with execFileSync(process.execPath, [...]) or fork()) also loads the mutated files. A test that checks the output of your CLI can then kill a mutant. Fallow adds its preload to NODE_OPTIONS of the test process for this. The source files that only a child process loads do not count as coverage. A child process that is not Node, or that gets an environment without NODE_OPTIONS, loads the original files.

When a test file does not load, the baseline failure message starts with the error line. Example: SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode.

Node.js and Bun cannot load a Vue, Svelte, or Astro component file. With node-test or bun-test, the plan leaves each component file out. The report lists the file in skipped_files with the reason runner-cannot-load, and the human output prints one Skipped files: line. The mutants of these files do not count in the coverage. To test component files, use Vitest with the framework plugin of your project.

--runtime takes a name or a file path. A bare name such as node or bun, without a path separator, is a PATH lookup, as in a shell. On Unix, the lookup skips a file without an execute bit. A path with a separator, such as ./bin/node, is a file path relative to the current directory.

--runtime must name a runtime of the correct kind for the runner. A Bun runtime for node-test, or a Node.js runtime for bun-test, exits with code 2 and FALLOW_MUTATION_RUNTIME_NOT_FOUND. For bun-test, install Bun.

Runner config

--runner-config <path>, or the mutation.runnerConfig config key, selects the config file of the test runner. Use it when your project keeps its integration, UI, or browser tests in a separate Vitest config:

fallow mutation src/math.ts --runner-config vitest.integration.config.mjs
  • The flag wins over the config key. --config is a different 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_OPTIONS, and the context mutation.runnerConfig.
  • Only the Vitest runner reads a config file. With node-test or bun-test, a selected config exits with code 2.
  • Without a selection, Vitest finds its config the usual way.
  • Fallow starts Vitest from the project root, as vitest run does there. The root and test.root of the selected config stay in effect, and a relative root resolves against the project root. Test files, test ids and report paths stay relative to the project root, also when the Vitest root is a subdirectory.
  • The selected config sets the test files that Vitest lists. When the selected config does not include a static test file of the plan, the run is partial with the reason test-files-not-discovered. See Completion.
  • A change to the selected config file changes the result cache key. The key does not include the files that the selected config imports.

Example output

Mutation testing (ran the project tests)
Scope: all source files
Score 71.4%  killed 5  survived 2  timeout 0  complete
Coverage 87.5%  no coverage 1

● Survived (2)
  src/pricing.ts
    #2 2:15  applyDiscount  relational-operator  if (percent <= 0) return total; -> if (percent < 0) return total;
      id 72487a15b6adc213
      covered by 1 test: src/pricing.test.ts::discount
    #3 2:15  applyDiscount  relational-operator  if (percent <= 0) return total; -> if (percent == 0) return total;
      id 77ac825e81517e35
      covered by 1 test: src/pricing.test.ts::discount

● No coverage (1): no test reaches these mutants
  src/pricing.ts  1 mutant  test reach: 1 none-file

Next
  Inspect a survivor (runs no code): fallow mutation inspect 72487a15b6adc213

The human output shows the 10 best-ranked survivors, grouped by file. The JSON report lists all survivors and all covering tests.

The output can add these lines:

  • The score line adds compile error N when the survivor type check found N mutants that do not type-check.
  • Survivor type check: 2 checked, 1 does not type-check, 1 not checked (no-project 1) after a run with --typecheck. When the check fails, the line says that the survivors keep the status survived.
  • Cache: 2 results came from an earlier run. Pass --no-cache to run each mutant. when the result cache gave at least one result.

Progress

A run with human output prints phase lines on stderr, so a long run does not look stalled. Each line starts with fallow: mutation: and ends with the run time:

fallow: mutation: 8/20 mutants done (1m25s)
  • The phases are the runner setup, the baseline run, the mutant runs with done and total counts, 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, for example a CI log.
  • --quiet and --format json print no progress lines. Stdout never gets a progress line, so the JSON report does not change.

Run options

FlagDescription
--max-mutants <N>Run at most N covered mutants, in rank order. Uncovered mutants do not count. The others are pending.
--time-budget <DURATION>Stop starting mutants after this time, for example 90s, 10m, or 1h. The others are pending. The run stops new mutants early, to keep time for the retries of its survivors and timeouts. A run that started continues until its timeout, so the command can end a little after the budget.
--setup-timeout <DURATION>Wait this long for each runner process to start, finish its dry run or reload (default 2m). Use a longer value, for example 5m, on a slow or busy host.
--runner-config <PATH>The Vitest config file of the run. The file must be inside the project root. See Runner config.
--concurrency <N>The number of test processes. Default: half the logical cores.
--runtime <PATH>The JavaScript runtime: a name to look up on PATH, or a file path. Default: node for node-test, bun for bun-test, and node, then bun, for Vitest. See Runners.
--operators <NAMES>The operators to use, comma-separated. Default: every operator.
--min-score <0-100>Exit with code 1 when the mutation score is below this percentage.
--verdicts <PATH>A verdict document that marks mutants as equivalent. See Verdicts.
--kill-matrixRun all covering tests with bail disabled and report redundant test candidates from complete kill evidence.
--kill-matrix-for <TARGET>Check whether the named tests are redundant, without a full kill matrix. A target is a test file or a test ID (file::name). Repeat the flag for each target. Cannot be used with --kill-matrix. See Check named tests.
--typecheckType-check each survivor after the tests. See Type check of survivors.
--resumeContinue an interrupted or partial run. The run reuses the final results in its journal and runs only the other mutants. See Resume a run.
--recheckRerun the open tasks of a saved report against the current tests. See Recheck after new tests.
--report <PATH>The saved report of --recheck. Default: the report of the last run.
--mutant <ID>Recheck only these mutant ids, comma-separated or repeated.
--compare <PATH>Compare this run with a saved complete report. See Compare with a saved report.
--allow-dirtyRun in a git worktree with uncommitted changes.
--allow-side-effectsKeep mutants in functions whose own body calls a file system, process, network, or database API. See Safety rules.
-o, --output-file <PATH>Write the report to a file after the run.
-f, --format <FORMAT>human (default) or json.

The global flag --no-cache turns off the result cache for one run.

Operators

OperatorChange
arithmetic-operatora + b to a - b, and the other arithmetic pairs.
relational-operatora < b to a <= b, and the other relational pairs.
logical-operatora && b to a || b, and the reverse.
negate-conditionA condition to its negation.
unary-operatorA unary operator removed or changed.
update-operatori++ to i--, and the reverse.
removed-callA call statement removed.
return-valueA return value changed.
boolean-literaltrue to false, and the reverse.
string-literalA string literal emptied or filled.
optional-chaininga?.b to a.b.
regex-literalOne token of a regular expression literal changed.
removed-chain-linkOne call link of a fluent chain in a module-level const removed, for example z.number().int() to z.number().

The regex-literal operator gives one mutant for each of these tokens:

  • A ^ anchor at the start of the pattern is removed.
  • A $ anchor at the end of the pattern is removed.
  • A character class is negated, for example [a-z] to [^a-z], and the reverse. A pattern with the v flag gets no negation.
  • A class escape gets the opposite case: \d to \D, \w to \W, and \s to \S, and the reverse.

The flags of the regular expression do not change.

The removed-chain-link operator removes one link of a fluent call chain. For export const quantitySchema = z.number().int().positive(); it gives one mutant without .int() and one mutant without .positive(), so a validation schema without a literal or an operator still gets mutants. A link is a call r.m(args) with a static member .m and a receiver r that is also a call, so the root call z.number() is not a link. The call must be in the initializer of a const declaration that no function encloses. A let or var declaration, a const in a function body, and a chain with an optional link (?.) get no mutant. 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 gets no string-literal or boolean-literal mutant when its clause body is the same as the default body. The original label and the changed label then select the same code for each input, so no test can detect the mutant. Fallow skips the label only when all labels are string, number, boolean, or null literals, both bodies end in return, throw, break, or continue, and no other label has the original or the changed value. The mutants in the clause body still run.

The test of a counted for loop gets no != mutant from relational-operator. For a loop such as for (let i = 0; i < 12; i++), the counter takes each integer from the start to the bound, so i < 12 and i != 12 select the same iterations and no test can detect the mutant. Fallow skips it only when the counter is one let variable with an integer literal start, the bound is an integer literal that the start does not pass, the update is i++, ++i, or i += 1 (or the decrement for >), and the loop body has no other write to the counter and no eval call. The <= mutant and the constant mutant of the test still run.

A zero comparison on the length of a known array gets no equivalent mutant from relational-operator. An array length is never negative, so items.length > 0 and items.length != 0 give the same result, and no test can detect that mutant. For the same reason, Fallow skips the == mutant of items.length <= 0, the constant false of items.length < 0, and the constant true of items.length >= 0, and also the reversed forms such as 0 < items.length. The receiver must be an array literal, or a name that resolves by scope to an array binding: a const with an array literal, Array.from(...), or Array.of(...) value, a variable or parameter with the type T[], readonly T[], Array<T>, ReadonlyArray<T>, or a tuple type, or a rest parameter (...items) with no assignment to it. An object with a length property, items?.length, and items["length"] keep each mutant. A reference to a global with the same name, a redeclared binding, a with statement, a direct eval call, or a local Array binding also keeps each mutant.

A conditional lookup in an array before .filter(Boolean) gets no true mutant from negate-condition. In ["base", align ? alignment[align] : ""].filter(Boolean), a truthy align takes the same branch in the mutant. For a falsy align, the mutant reads alignment[align], which is undefined, and filter(Boolean) drops it as it drops "", so no test can detect the mutant. Fallow skips it only when the conditional is a direct element of an array literal with a .filter(Boolean) call that is not optional, the test is a local name, the consequent is obj[test] or obj?.[test] with the same name as key, and the alternate is "", an empty template, null, undefined, false, 0, or 0n. The object must be a local const object literal that is not exported, with plain properties only and no key that a falsy value converts to (undefined, null, false, 0, NaN, or the empty string), and each use of it must be a member read. The false mutant still runs. A template literal, a .join without .filter(Boolean), a .filter((v) => v) callback, a spread, getter, or __proto__ key, a write to the object, a local Boolean binding, a with statement, and a direct eval call keep the true mutant.

Fallow has no absolute-value operator (x to -x). Without type information, that operator applies to each operand of each arithmetic or relational expression. These expressions already get mutants, so the extra mutants add run time and little new information.

Safety rules

A run executes your project tests, so fallow applies these rules:

  • Clean worktree. A run refuses a git worktree with uncommitted changes and exits with code 2. Commit or stash your changes, or pass --allow-dirty. After the run, fallow compares the worktree with the start state and reports each path that changed.
  • What the check sees. The check covers tracked and untracked files. It also covers the ignored files that git lists at the start, such as .env, so a write to one of them or a delete is a change. Git lists an ignored directory such as node_modules/ or coverage/ 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. The check leaves out mutation/ in the fallow cache directory and the output files of the run.
  • Safe cache directory. A run refuses a cache directory that is the project root or a directory above it, or that holds a planned source or test file. It exits with code 2 and FALLOW_MUTATION_UNSAFE_CACHE_DIR. Such a directory would hide the writes of your tests from the check.
  • Side effects. Fallow skips the mutants in a function whose own body calls a file system, process, network, or database API. A mutant in top-level code is skipped when the module makes such a call at the top level. These mutants have the status pruned with the reason side-effect-sink. A mutant there can remove a guard of a real side effect. The rule does not follow calls: a mutant in a function that calls a helper, which then makes the call, still runs. A database write through an ORM or a query builder, such as db.insert(table), db.insertInto("person") or prisma.user.create(...), counts as such a call. Fallow finds it by the method name (insert, update, delete, create, upsert, save, transaction and similar). SQL text that starts with a write verb also counts, such as sql`insert into ...` or pool.query("DELETE ..."). A prepared statement run also counts, such as stmt.run(value) after const stmt = db.prepare("INSERT ..."), unless its SQL text starts with select. The statement can be in a name or in a property that the same module sets: a declaration, an assignment, a class field (also a static or a #private field) or an object literal property. Examples are this.insert.run(value), this.#insert.run(value), Repository.insert.run(value) and statements.insert.run(value) after const statements = { insert: db.prepare("INSERT ...") }. A property matches by its name only, whatever object holds it. A statement that a function gets as a parameter, or that the module imports, does not count. A route registration such as app.delete("/items/:id", handler) does not count. The module must import a database client package (drizzle-orm, @prisma/client, kysely, pg, better-sqlite3, mongodb and similar), or import a project module that imports one. An ES import, a require() call and an import() call with a literal source each count. A client two or more project imports away does not count. In a module that imports the client package itself, every run and exec call also counts, such as lite.exec("DELETE FROM t"), but exec on a regular expression does not. In a module with such an import, a call such as map.delete(key) also counts, because Fallow does not know the type of the receiver. --allow-side-effects keeps the skipped mutants. This option is a flag only. The config rejects an allowSideEffects key, so a project config cannot widen what a run executes.
  • Project files never change. Fallow does not write to your project files. The changed copies of your files live in a run directory in the fallow cache. Two runs of the same plan share one directory, and a run never reads a partly written one. Fallow keeps the 5 most recently used run directories. It removes older ones that no run used in the last hour. The report lists any path that your tests changed.
  • No test process outlives fallow. The test processes run in their own process group on Unix and in a Job Object on Windows. Fallow ends them when the run ends, on SIGINT, SIGTERM, SIGHUP, or SIGQUIT, and on a crash. When fallow starts with SIGHUP ignored, as under nohup, a hangup does not end the run.
  • No temp files stay behind. Each test runner gets its own temp directory, named fallow-mt-<id> in the system temp directory. Fallow sets TMPDIR, TMP and TEMP to it, so your tests and their child processes write their temp files there. Fallow deletes the directory when the runner ends. It also deletes it when it stops a test run after a timeout, because the teardown of your suite does not run then. On Ctrl+C or another signal, fallow deletes it before it exits. After a crash, the next run deletes it. On Unix, only your user can read the directory.

Do not redirect the report with > mutation.json. The shell creates the file before fallow starts, so the clean-worktree check refuses the run. Use -o mutation.json or the saved report instead.

Score and coverage

The report gives two separate values. In JSON, both are fractions from 0 to 1.

ValueFormulaWhat it tells you
summary.score(killed + timeout) / (killed + timeout + survived)How many of the tested mutants your tests detect.
summary.coveragecovered / (covered + no_coverage)How many of the planned mutants a test reaches.

The score covers only the mutants that ran. A mutant that no test reaches is no_coverage, and it never enters the score. The coverage reports these mutants apart. A run of 200 mutants in a large project therefore gets a score that describes the 200 tested mutants.

Before the mutants run, fallow runs your tests once with no active mutant (the dry run). The dry run decides the coverage of every planned mutant, also of the mutants after the --max-mutants limit. A run that --max-mutants limits therefore reports the coverage of the full plan. A planned mutant that no test covers is no_coverage and does not use a place in the limit. The next covered pending mutant in rank order runs in its place. A cached result uses a place in the limit, so a run with the result cache can execute fewer than N mutants. The --plan output comes before the dry run, so a run can execute a mutant that the plan shows as pending.

A run with no executed mutant has no score (null in JSON).

--min-score and mutation.minScore use percent, not a fraction.

Statuses

StatusMeaningIn scoreIn coverage
killedA covering test failed.YesYes
timeoutThe run passed its timeout, also in a second run with twice the time when the time budget allowed it. The mutant counts as detected.YesYes
survivedEach covering test passed, also in a second run when the time budget allowed it.YesYes
no_coverageNo test reaches the mutant.NoYes
runtime_errorThe runner failed for a reason outside the tests.NoYes
flakyThe second run changed whether the tests detect the mutant.NoYes
compile_errorThe mutant survived the tests, but it adds a type error to its file. Only --typecheck sets this status.NoYes
pendingThe mutant did not run, because the mutant limit or the time budget ended first.NoYes
equivalentA verdict marks the mutant as equivalent to the original code.NoNo
prunedA static rule removed the mutant, for example side-effect-sink, dead-code, or generated.NoNo

A pruned mutant has a reason. One of the reasons:

  • framework-config: the mutant changes framework or tool config, not tested logic. Framework config is an export that an active plugin reads, for example export const revalidate = 60 in a Next.js page. It is also the default export of a tool config file, such as vite.config.ts, vitest.config.ts, eslint.config.js, .eslintrc.js or docs/.vitepress/config.ts. A top-level module.exports = ... statement counts as the default export. A mutant inside a function of the config, for example an inline Vite plugin, is not pruned. No test loads a tool config file, so these mutants do not lower the coverage.
  • no-target-coverage: a --kill-matrix-for run skipped the mutant, because no target test covers it. See Check named tests.

Retries

A survived or timeout mutant runs a second time in a new runner process. Host load can make a test time out, so one timeout is not proof that your tests detect the mutant. The second run of a timeout gets twice the time. A mutant with "retried": true in JSON got both runs. A survived or timeout mutant without the field got no second run, because the time budget ended first. With --time-budget, the mutant runs keep time free for these second runs, so the result cache stores the confirmed survivors. A repeat run with the same budget then continues with the next mutants. A survivor without a second run is not stored, so a repeat run does it again.

First runSecond runFinal status
anyruntime_errorruntime_error
survivedsurvived or timeoutsurvived
survivedkilled by a test of the first runflaky
survivedkilled by a test that the first run did not executekilled
timeouttimeouttimeout
timeoutkilledkilled. killed_by names the test of the second run.
timeoutsurvivedflaky
  • A killed result gets no retry. A failed test is a fact about the mutant.
  • A survived result with a timeout second run stays survived. The first run finished all tests with the mutant active, and host load alone can make the second run time out.
  • The second run of a survivor runs each test of the files of its covering tests. The first run uses only the tests that executed the mutated code in the baseline run. A test can read a value that an earlier test of its file computed and cached, so it does not execute that code itself. A kill by such a test is killed when the same tests pass in a run with no active mutant. When they do not pass, the mutant stays survived and the failed tests are baseline failures. covering_tests stays the tests that reached the mutant in the baseline run, so killed_by can name a test that is not in that list.
  • The second run of a survivor stops at the first test that fails, and that test decides the result. The tests of each file run in file order.
  • A retried mutant has an attempts array in JSON: the first run, then the second run. Each entry has the status and the duration_ms of that run alone. A killed entry has killed_by and a failure_kind (assertion, error or timeout). A timeout entry has the failure_kind timeout. A runtime_error entry has a detail with the first line of the reason. A timeout then survived result and a survived then killed result are both flaky, and attempts shows which history each one has.
  • A mutant without a retry, and a reused result, have no attempts.
  • timings.retry_ms is the time of the retries, for the survived and the timed-out mutants.

Completion

completion.status is complete or partial. Only a complete run makes an empty survivor list conclusive. A partial run lists its reasons, for example mutant-limit, time-budget, runtime-error, baseline-failure, typecheck-failed, or test-files-not-discovered.

The reason test-files-not-discovered means that the runner did not list a test file that the plan maps statically. For example, the test file is included only by a second Vitest config. The tests of that file never run. The errors entry with the same code names each such file. A survivor or a no_coverage mutant that such a file reaches is then not proof of a test gap. To include the file, select its config with --runner-config.

A partial run does not change the exit code. The run exits with 0, with 1 when the score is lower than --min-score, or with 2 when baseline failures leave no mutant with a test result.

A test that fails in the dry run is a baseline failure. Fallow lists it in baseline_failures, and its result decides no mutant status. The message keeps the first error of the test: the error name and message, then the stack frames of your project. This also applies to the dry run of a restarted runner process, where a test can fail because it depends on state outside the test, such as the process id. Fix the failing test, then run again.

Type tests

When your Vitest config enables the type-check pass (typecheck.enabled), the run does not execute it. A mutant that only a type test detects is not counted as killed. The report adds typecheck-not-run to runner.notes, and the human output prints one line for it. The survivor type check does not run these type tests either.

A type test file (*.test-d.ts, *.spec-d.ts, or a TypeScript file below a test-d/ directory) is not a test file of the mutation plan. No runner executes it. A project whose only test files are type tests has no_test_files: true, and a file that only type tests reach has no coverage.

Redundant test candidates

fallow mutation --all --kill-matrix --format json --quiet

The --kill-matrix mode runs all covering tests with bail disabled. It can increase execution time. Read the matrix evidence status before you review candidates. Normal mode reports kill-matrix-disabled and no candidates. With --kill-matrix, incomplete evidence produces no candidates. With --kill-matrix-for, only the tests of complete targets can be candidates.

kills and unique_kills are arrays of mutant IDs. Individual subsumers cover a candidate's full kill set. Other tests can also cover that set collectively. Review each candidate separately, including equal kill sets. Removing equal candidates together can remove their shared protection. A candidate does not prove safe removal. Baseline-failing tests cannot become candidates or subsumers.

Tests with runtime coverage and no observed kills appear separately. Zero kills do not prove that a test has no assertions. Sole-file notes use observed instrumented-file coverage. Public-export notes use static test-file reach, without per-test export coverage claims.

The mutation and mutation-inspect JSON envelopes use schema version "2". The mutation-plan envelope and verdict document keep schema version "1".

The kill_matrix object has these fields:

FieldMeaning
statuscomplete, incomplete, or unavailable, separate from mutation completion.
reasonsEvidence limitations, such as kill-matrix-disabled, incomplete-mutant-results, baseline-failure, partial-mutation-run, or missing-test-file-metadata.
testsEntries with test_id, file, kills, unique_kills, subsuming_tests, and notes.
candidatesTest IDs whose observed kill sets other tests cover.
coverage_without_killsTest IDs with runtime coverage and no observed kills.

Each mutant and attempt records killing_tests and kill_matrix_complete. The existing killed_by field keeps the first observed killer. Outside matrix mode, killing_tests is empty.

Survivors and timeouts do not count as kills. Matrix mode uses separate cache keys and journal inputs from normal mode.

Matrix mode does not add the other tests of the covering test files to the retry of a survivor. A survivor that normal mode kills in that retry can stay survived in matrix mode, so the two modes can give different scores.

Check named tests

fallow mutation --all --kill-matrix-for src/cart.test.ts --format json --quiet
fallow mutation --all --kill-matrix-for "src/cart.test.ts::adds an item" --format json --quiet

--kill-matrix-for checks whether the named tests are redundant, without the cost of a full kill matrix. A target is a test file (each test in it) or a test ID (file::name). Repeat the flag for each target.

  • Only the mutants that a target test covers run. The other mutants are pruned with prune_reason: "no-target-coverage".
  • Each mutant runs its target tests with bail disabled. Then it runs its other covering tests with bail enabled, up to the first failure.
  • kill_matrix.tests has only the target tests. subsuming_tests is empty, because bail hides the other killers.
  • Each target test has the field target_only_kills. It lists the kills that only other targets share, each as { mutant_id, targets }. The test stays redundant only while one of those targets stays in the suite.
  • kill_matrix.targets lists each target as { target, test_ids, status, reasons }. test_ids are the dry-run test IDs that the target selects. The field is absent outside target mode.
  • Each target has its own evidence status: complete or incomplete. A target is incomplete with the reason incomplete-mutant-results when one of its tests covers a mutant with incomplete evidence. The run-wide reasons partial-mutation-run, baseline-failure and missing-test-file-metadata make each target incomplete.
  • Only the tests of a complete target can be candidates. One incomplete mutant removes only the candidates of the targets that cover it.
  • The top-level kill_matrix.status is incomplete when any evidence is incomplete. Thus candidates can have entries while the top-level status is incomplete. The human report shows the status and the reasons of each target.

A target that selects no test of the dry run stops the command with exit code 2 and FALLOW_MUTATION_TARGET_UNMATCHED. The same error applies when the runner cannot run single tests. --kill-matrix-for and --kill-matrix cannot be used together.

Test reach of no-coverage mutants

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. Mutants with other statuses do not have the object. The object never changes the score or the coverage.

FieldDescription
reachThe test reach class: mocked-only, none, none-file, transitive, or direct. These are the classes of the fallow audit test reach. More classes can come in a later version, so treat the set as open.
export_nameThe name of the export, when the mutant is in an exported declaration. The class is then the class of that export. Without this field, the class is the class of the file.
testsOnly for mocked-only: the test files whose references a mock replaced.
mocked_targetsOnly for mocked-only: the mocked modules.
  • none-file: no test loads the file. A project without test files gives none-file to each mutant.
  • mocked-only: tests reach the code only through a mock.
  • none: a test loads the file, but no test reaches this code.
  • direct or transitive on a no_coverage mutant is not a conflict. A test refers to the export, but no test ran the code of the mutant.

In the human output, each file line in the no-coverage section adds the counts by class, for example test reach: 2 mocked-only, 1 none-file.

Type check of survivors

--typecheck, or mutation.typecheck: true in the config, type-checks each survived mutant after the tests. The check uses the same TypeScript companion as --type-aware. Other mutants are not checked, so the cost grows only with the number of survivors.

fallow mutation --typecheck
  • The companion applies one mutant at a time to a copy of the file in memory. It never writes a project file.
  • It compares the type errors of the mutated file with the type errors of the original file. An error that the original file already has does not count.
  • A survivor with a new type error gets the status compile_error. type_error_codes lists the TypeScript error codes, sorted, at most 8. The mutant leaves the score, and it counts as covered.
  • The check reads only the mutated file. A mutant that breaks only another file stays survived.
  • A survivor that the check cannot check stays survived. The reasons are no-project (no tsconfig.json includes the file), not-type-checked (// @ts-nocheck, or a JavaScript file without checkJs), source-changed, file-missing, and unsupported-file (a Vue, Svelte or Astro component, which the checker cannot read).
  • 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. Fallow finds the companion in the same way as for --type-aware: FALLOW_TYPE_AWARE_BIN, the npm wrapper, or the companion next to the fallow executable.
  • When the companion fails after the tests, the survivors keep the status survived. The report then has an error with the code typecheck-failed, and the completion is partial with the reason typecheck-failed.
  • --plan --typecheck exits with code 2, because a plan has no survivors. The config key has no effect on a plan.

With the option on, the report has a typecheck object:

FieldDescription
completedfalse when the check failed.
survivorsThe number of survivors before the check.
checkedThe number of survivors that the check checked.
compile_errorsThe number of survivors that do not type-check.
skippedA list of { reason, count } for the survivors that the check did not check.

The report also has summary.counts.compile_error, functions[].counts.compile_error, and timings.typecheck_ms. Without the option, the typecheck object is absent and no mutant gets compile_error.

Result cache

A run stores the result of each killed mutant and of each survived mutant that a retry confirmed. A later run uses a stored result when the key is the same, and does not run that mutant again. The dry run always runs.

The key covers the mutant, the content of its source file, its covering tests, the content of the covering test files and the files that they import, the runner setup files, the lockfile, the runner config files at the root and the files that they import, the configs that the root tsconfig.json extends, the runner and runtime versions, and the fallow version. A change to one of them runs the mutant again.

Some inputs are not in the key: dynamic imports that the module graph does not resolve, and files that your tests or the runner config read. Runner configs and tsconfig.json files below the root, for example in a workspace package, are also not in the key. A change to them does not run the mutant again. Pass --no-cache when such a change can affect your tests.

  • A timeout, flaky, runtime_error, or pending result is never stored.
  • A reused survived result also has "retried": true, because only a survivor that a retry confirmed is stored.
  • A reused result has "cached": true on its entry in mutants[]. Without a reused result, the key is absent. A reused result counts in the score and the coverage in the same way as a new result.
  • The report has a cache object: hits (the reused results), stored (the stored results), and not_keyed (["dynamic-imports", "file-reads"]). The object is absent with --no-cache, and when no test process started.
  • The cache file is mutation/results.json in the fallow cache directory. The cache directory has its own .gitignore.
  • A failed write of the cache file adds a cache-write-failed entry to errors[]. The run result stays.
  • The global --no-cache flag turns off the result cache. The run then reads and writes no entry.

Resume a run

Each run writes its final results to mutation/journal.jsonl in the fallow cache directory, by default .fallow/mutation/journal.jsonl. A result is final when the mutant is killed, or when a retry confirmed that it survived. The run writes each result when it is final. An interrupted run, for example after Ctrl-C, therefore keeps its finished results. The run writes the journal also with --no-cache.

fallow mutation --time-budget 10m
fallow mutation --resume

fallow mutation --resume reuses the results in the journal and runs only the other mutants:

  • The dry run always runs, because it finds the covering tests.
  • The journal must come from a run with the same inputs. These inputs include the fallow version, the runner, the runtime, the planned files, the runner config, the scope, the operators, and --allow-side-effects. A journal of other inputs exits with code 2 and FALLOW_MUTATION_RESUME_MISMATCH. The message names each input that differs.
  • --max-mutants, --time-budget, and --concurrency are not inputs. You can continue a run with other limits.
  • When a covering test file changed, the run does not reuse the journal result of that mutant. The mutant runs again, or its result comes from the result cache.
  • When no journal exists, the run starts from the beginning and does not exit. The report says why.
  • --plan --resume exits with code 2, because a plan runs no mutant.
  • A reused result has "resumed": true on its entry in mutants[]. It counts in the score and the coverage in the same way as a new result.
  • The report has a resume object: journal (the path), reused, rejected, and note when the run did not read a journal. The object is absent without --resume. The human output prints one Resumed: line.

A timeout, flaky, runtime_error, no_coverage, or pending result is never in the journal. These mutants run again in a resumed run.

A complete run removes the journal. A partial run keeps it, so that a later --resume can continue it. An interrupted run and a run that exits with code 2 also keep the journal. A run without --resume replaces the journal.

Saved report

Each run saves its full JSON report to .fallow/mutation/last-report.json in the fallow cache directory. The cache directory has its own .gitignore, so the saved report does not make the worktree dirty. Each run replaces the saved report.

For a report file that you keep, use -o:

fallow mutation --format json -o mutation.json

The change of a mutant

Each mutant in the JSON report and in the --plan document has a change object. It shows the edit without the code of the project:

{ "operator": "logical-operator", "change": { "from": "??", "to": "&&" } }
  • A side is an operator token, a fixed literal such as true, undefined, "", "fallow" or void 0, or a regular expression token such as ^, $, [^ or \d.
  • A side that is project code is a placeholder: <condition> for a negate-condition test, <call> for a removed-call statement or a removed-chain-link link (with an empty to), <expression> for a return-value argument or a comparison that becomes true or false, and <string> for a non-empty string.
  • An empty side means that the edit removes the token, for example { "from": "!", "to": "" }.

To see the code of a mutant, use fallow mutation inspect.

Mutation score in fallow health

fallow health --mutation-report <FILE> reads a saved run report and shows the mutation score of each complexity finding under its CRAP score. It reads files only. It runs no test.

fallow mutation --all
fallow health --mutation-report .fallow/mutation/last-report.json

The mutation score never changes CRAP, the health score, or a gate. A function whose file changed since the run gets no score. See fallow health.

Inspect a mutant

fallow mutation inspect shows one mutant against the current file. It runs no code.

fallow mutation inspect 72487a15b6adc213
Mutant 72487a15b6adc213  fresh
src/pricing.ts:2:15  applyDiscount  relational-operator  (report: survived)
-    2 |   if (percent <= 0) return total;
+    2 |   if (percent < 0) return total;

● Covering tests (1)
  src/pricing.test.ts::discount

Without --report, inspect reads the report that the last run saved. Pass --report <file> to read another saved mutation report or --plan document. Pass the same --root and --config as the run, because the config can move the cache directory. From a subdirectory, inspect finds the report of a run at the project root.

The report holds no source text. Inspect reads the current file and finds the mutant again:

  • fresh: the file matches the report. The output shows the original and the mutated line, the covering tests, and the killing test.
  • stale: the file changed or is missing. The output shows no code, so a changed file never gives a wrong answer. Run fallow mutation again.

Inspect exits with code 2 when no run saved a report, or when the id is not in the report.

Open tasks

fallow mutation tasks lists the open tasks of a saved report as one batch. A task is a survived or no_coverage mutant: a small change to the code that made no test fail. Give the batch to a coding agent, or use it yourself, to write the missing tests. It runs no code.

fallow mutation tasks
Mutation tasks: 2 open (2 survived)

Each item is a small change to the code that made no test fail. Write tests that pass on the current code and fail on the changed code. One test can cover several items. Skip an item when no input can detect the change; a verdict document (`--verdicts`) can mark it as equivalent.

1. src/math/inRange.ts:48, function inRange (survived) [a4b41e7dd2a29e55]
   current: if (minimum >= maximum) {
   changed: if (minimum > maximum) {
   tests: src/math/inRange.spec.ts

2. src/math/inRange.ts:52, function inRange (survived) [3450287e93650287]
   current: return minimum <= value && value < maximum;
   changed: return minimum < value && value < maximum;
   tests: src/math/inRange.spec.ts
  • Without --report, the command reads the report of the last run, as inspect does. A --plan document has no test results, so the command refuses it.
  • Each task holds the source lines before and after the change, and no other source text. The lines come from the current file. When the file changed after the run, the task is stale and shows no code.
  • --format markdown puts the lines in a fenced block. --format json gives the mutation-tasks envelope: each task has mutant_id, file, line, column, operator, function, status, original_lines, mutated_lines and covering_test_files.
  • The command exits with code 0, also when the batch has tasks. To gate a run, use --min-score.

Recheck after new tests

fallow mutation --recheck reruns the open tasks of a saved report against the current tests. With tasks, it gives a loop: list the tasks, write tests, recheck, and the mutants that still survive are the next batch.

fallow mutation src/utils.ts       # run, and save the report
fallow mutation tasks              # the open tasks as one batch
# write or change tests
fallow mutation --recheck          # rerun the open tasks
fallow mutation tasks              # what is still open
  • --mutant <ID> rechecks only these ids, with any status. Without it, the recheck runs each survived and no_coverage mutant of the report.
  • A recheck proves a change of the tests only. When a selected file changed after the report, the recheck exits with code 2 and FALLOW_MUTATION_SOURCE_CHANGED. An uncommitted change to a file that is not a test file exits with code 2 and FALLOW_MUTATION_NON_TEST_CHANGE. A change that you committed after the report to another file, for example a module that a selected file imports or the runner config, is not detected. Run fallow mutation again after such a commit.
  • The other mutants of the selected files are pruned with the reason not-selected. They do not count in the score.
  • The report gets a recheck object. For each rechecked mutant, it gives the earlier status, the new status and an outcome:
outcomeMeaning
killedA test now fails on the mutant.
survivedEach covering test still passes on the mutant.
no_coverageNo test reaches the mutant.
tests_fail_on_originalA test of the run fails on the original code, so the run proves no survivor. Fix that test first.
unsettledThe mutant has no final result, for example runtime_error or flaky.
  • A recheck without --mutant replaces the saved report, so tasks then lists the mutants that are still open. A recheck with --mutant keeps the saved report.
  • A --mutant id that the report does not have exits with code 2 and FALLOW_MUTATION_UNKNOWN_MUTANT. A report without an open task exits with code 2 and FALLOW_MUTATION_NOTHING_TO_RECHECK.
  • --recheck cannot be combined with a scope flag, --plan, --resume, --allow-dirty, --max-mutants or --min-score. The score of a recheck covers only the rechecked mutants.

Compare with a saved report

fallow mutation --compare <PATH> compares the run with a saved report. It shows if a change of the tests weakens the detection of mutants: a mutant that a test killed before and that survives now.

fallow mutation src/utils.ts --format json -o before.json   # save a report
# change, move or remove tests, then commit
fallow mutation src/utils.ts --compare before.json
  • The saved report is a fallow mutation --format json report. Keep it in a file of its own, because each run replaces the saved report of the last run.
  • The compare matches the mutants of the two runs by mutant_id. A mutant id stays the same until its enclosing function changes, so an edit of a test file keeps the ids. Run the two runs over the same files.
  • The saved report and the run must have completion.status: complete. A saved report that is not complete exits with code 2 and FALLOW_MUTATION_COMPARE_INCOMPLETE before the run starts. A run that is not complete writes its report without a compare object, then exits with code 2 and the same code.
  • A regression does not change the exit code. To fail a CI job on a regression, read compare.regressed of the JSON output.
  • --compare cannot be combined with --plan or --recheck. A verdict document applies before the compare, so an equivalent mutant is not a regression.

The report gets a compare object with these fields:

FieldMeaning
report_sha256The SHA-256 digest of the saved report.
comparedThe number of mutant ids in both runs.
regressed, inconclusive, added, removedThe number of mutants with each change.
mutantsEach mutant with a change, sorted by file, line and mutant id. A mutant in both runs without a change is not in the list.

Each entry of mutants has mutant_id, file, line, change, previous_status (absent for added), status (absent for removed) and previous_killing_tests. previous_killing_tests holds the killing_tests of the saved report, else its killed_by.

changeMeaning
regressedThe saved report detects the mutant (killed or timeout), and this run does not (survived or no_coverage).
inconclusiveThe mutant is flaky in one of the two runs, so the compare of this mutant proves nothing.
addedOnly this run has the mutant id.
removedOnly the saved report has the mutant id.

The human output adds a compare section after the run report. Each regressed mutant names the test that killed it in the saved report. When no mutant id is in both runs, the section says so, because the compare checked no mutant.

Verdicts for equivalent mutants

Some mutants do not change the behavior of the code. No test can detect them. You can mark them in a verdict document:

{
  "schema_version": "1",
  "verdicts": [
    {
      "review_key": "77ac825e81517e35",
      "verdict": "equivalent",
      "note": "percent is never negative here"
    }
  ]
}
fallow mutation --verdicts verdicts.json

The review_key is the mutant_id. The id stays the same until the enclosing function changes, so one verdict document can serve many runs.

Mutant statusVerdict result
survived, no_coverage, flakyThe status becomes equivalent. The mutant leaves the score and the coverage.
killed, timeout, compile_errorThe verdict is contradicted. A test or the type check detects the mutant, so the status stays.
pending, runtime_error, prunedThe verdict is deferred.
No matchThe verdict is unmatched.

The JSON report lists each group under verdicts, together with a digest of the document. A malformed document, an empty or duplicate review_key, or an unknown field stops the command with exit code 2 before any test runs.

Exit codes

CodeMeaning
0The 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.
2Invalid input or an execution error. Examples: a dirty worktree without --allow-dirty, a cache directory that hides project files (FALLOW_MUTATION_UNSAFE_CACHE_DIR), a missing runtime or runner install, a runtime of the wrong kind for the runner (FALLOW_MUTATION_RUNTIME_NOT_FOUND), an invalid verdict document, a --runner-config file that does not exist inside the project root or a runner config with a runner other than Vitest (FALLOW_INVALID_MUTATION_OPTIONS), --typecheck without a type-aware companion (FALLOW_MUTATION_TYPECHECK_UNAVAILABLE), --plan --typecheck, --plan --resume, a --resume journal of other inputs (FALLOW_MUTATION_RESUME_MISMATCH), a --kill-matrix-for target that selects no test (FALLOW_MUTATION_TARGET_UNMATCHED), a --recheck that the recheck rules stop, a --compare with a saved report or a run that is not complete (FALLOW_MUTATION_COMPARE_INCOMPLETE).

The flag wins over the config key. A run with no score exits with 0, also when every mutant is no_coverage.

fallow mutation --min-score 80

Configuration

Set project defaults in the mutation config section:

{
  "mutation": {
    "runner": "auto",
    "operators": ["arithmetic-operator", "relational-operator", "regex-literal"],
    "minScore": 80,
    "concurrency": 4,
    "typecheck": true,
    "runnerConfig": "vitest.integration.config.mjs"
  }
}

A CLI flag wins over the config. No config key starts a run or keeps side-effect mutants.

MCP tools

The MCP server has three read-only tools. No tool runs tests.

  • mutation_plan returns the plan envelope: each mutant's id, location, operator, disposition, and static test files. It takes changed_since, all, paths, max_mutants, and operators.
  • mutation_inspect shows one mutant of a saved report. It takes mutant_id and an optional report. Without report, it reads the report of the last run.
  • mutation_tasks returns the open tasks of a saved report, the same JSON as fallow mutation tasks --format json. It takes an optional report.

A run needs the CLI command fallow mutation, which executes your tests. An agent should ask you before it starts a run.

Current limits

  • The output formats are human and json, plus markdown for tasks. There is no SARIF, CodeClimate, LSP, or CI gate output.
  • --workspace and --changed-workspaces are not supported. Pass a package directory as a path.
  • A run executes the mutants that a verdict marks. Fallow applies the verdicts after the run.
  • The in-process mode of node:test (isolation: "none") is not supported.
  • With node:test and bun test, fallow can match the code that a test ran to that test only when the tests of a file run in a fixed order. When tests overlap (concurrency in node:test, test.concurrent in Bun) or randomize = true is set in bunfig.toml, each test of the file shares the code that the file ran. A mutant then runs against each test of the file that can kill it.

See also