Skip to content

Add mutation testing on the files each pull request changes - #82

Merged
tnunamak merged 4 commits into
mainfrom
test/reactive-mutation
Sep 10, 2026
Merged

Add mutation testing on the files each pull request changes#82
tnunamak merged 4 commits into
mainfrom
test/reactive-mutation

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

A suite that always passes proves nothing about whether its assertions still bite. Mutation testing introduces small faults into production code and checks that a test fails.

This adds Stryker on every pull request, scoped to the production files the PR changed between merge base and head. A PR that changes only tests or docs mutates nothing and reports not_applicable. The client cohort uses Stryker's Vitest runner. The reference implementation uses the command runner with node --test, coverage analysis off, and test selection inside the command. Stryker runs from each cohort's own root, because every relative path in its configuration resolves against the working directory.

Stryker's own Killed and Survived are recorded verbatim but not used as the verdict. A separate projection in scripts/mutation-falsification/stryker-adapter.ts counts a kill only when a named test failed, and keeps crashes, timeouts, and no-coverage as inconclusive. The denominator is killed plus survived; inconclusive mutants never enter it.

There is no result cache: every run is cold, so no run can reuse a stale verdict. Each run uploads three records as PR artifacts, including on failure: the frozen intent, the raw Stryker output, and the projection.

Verify: npx vitest run scripts/mutation-falsification, then run the workflow's script locally against one changed file as .github/workflows/mutation.yml does.

Not established: a run on the applicable path in GitHub Actions itself, since this PR changes no production source.

Assisted-by: AI

A passing test suite does not show which faults it can detect. This adds a
per-revision mutation run that produces that evidence for the files a pull
request actually touched.

Scope comes from git, not from a budget: the workflow diffs merge-base..head,
selects production sources in each cohort, and passes them to Stryker, which has
no changed-files selection of its own. A revision touching no production source
is reported as not_applicable, which is neither a pass nor a failure.

Stryker is treated as a black box that produces observations, never verdicts.
Its raw statuses are retained and re-projected through a conservative table:
a Killed without retained assertion output is inconclusive rather than a kill,
a timeout is not a kill, and uncovered code is not a survivor. Inconclusive
trials stay outside the killed+survived denominator.

The client cohort runs on the Vitest runner; the reference implementation runs
on the command runner with coverageAnalysis off and test selection inside the
command, since that runner reports no individual test identities. Incremental
reuse is keyed on recorded execution inputs -- config, tool version, runtime,
and lockfile digests -- so a change Stryker does not track cannot reuse stale
verdicts. There is no age or cost threshold.

The job publishes evidence as artifacts and does not block merge.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…ills

Three defects, each found by running the code rather than reading it.

The reference-implementation cohort could never run. Its configuration reads
the tests to run from a file that nothing wrote, so it always took a fallback
of `node --test test/` -- and Node resolves a bare directory as a module, not
as a test glob, so the baseline failed with MODULE_NOT_FOUND on every
applicable revision. Because the run step tolerates failure and a rejected
baseline makes every mutant inconclusive, that cohort reported success while
producing killed=0, survived=0 and no evidence at all, forever. The intent
stage now derives the test selection from the same diff that produces the
mutate list and writes it, and the fallback is a glob Node expands.

An applicable run that produced no evidence now fails. The receipt was already
honest -- it recorded "no evidence" accurately -- but a green check on top of
an empty receipt is not, and the check is what a reader sees first. A rejected
baseline, a run that wrote no report, and a batch in which every trial was
inconclusive each exit non-zero, with the receipt and raw report still
published. Survivors do not fail the job; only the absence of evidence does.

The incremental cache was keyed so it could never hit. The key hashed the whole
intent packet, which contains the head commit and the changed-file list, so it
was unique per revision and every run was cold. It is now keyed on the digest
of the execution inputs alone. `mayReuseCache` was correct and tested but
unreachable from the workflow, which trusted the cache action's own hit output
instead; a new step calls it, deletes a cache whose recorded inputs do not
match, and records that verdict in the receipt.

Kills are decided from structure, not from failure prose. The predicate
regex-matched failure text for assertion vocabulary, and on the first client
file it ran against it refused five of six real kills: those mutants made a
validator reject a valid input, so a ZodError was thrown inside the subject
expression of a genuine assertion and the retained text was a Zod issue array
with no assertion words in it. A dialect list cannot fix that, because the text
of a real assertion kill is whatever the thrown value happened to print. A kill
now requires a killing test identity from the runner's own per-test results and
the absence of crash evidence. Re-projecting the same report gives killed=6 of
6 rather than 1 of 6, and uncovered code is still never a survivor.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak
tnunamak force-pushed the test/reactive-mutation branch from d30f956 to c0e22ba Compare September 9, 2026 17:41
…he attempt

The reference-implementation cohort mutated zero files. Every relative path it
uses is cohort-relative -- the mutate globs, the configuration's reporter path,
its test-selection lookup and its sandbox exclusions -- and Stryker resolves all
of them against the working directory rather than against the configuration's
own location. The workflow invoked it from the
repository root, so the globs matched nothing, the sandbox tried to copy the
whole repository, and the report was written where the next step does not read.
Running it from the cohort root settles those at once: the same revision now
instruments one file into two mutants and writes its report exactly where the
projection step looks for it.

A failed attempt and a clean attempt that produced nothing wrote byte-identical
receipts. Both have zero trials, so the projection alone cannot separate them,
and the check exiting non-zero does not help a reader holding only the
artifact. The receipt now carries the engine's exit status, whether a report was
written at all, whether a baseline was established, and whether the attempt
produced evidence -- so the record that outlives the run says what happened
rather than leaving it in process output.

Deletions never reached the classifier. The diff was filtered to added, copied,
modified, renamed and type-changed paths, which dropped deletions before the
selector saw them, while the selector's own comment promised the exclusion list
was a full account of the diff. Deletions are now included and recorded as
excluded, which is what that list is for.

A cohort's own test files were labelled as being outside the cohort. Cohort
membership was decided before asking whether a path is a test, and the
reference implementation keeps its tests outside its production prefixes, so
every one of them got the wrong reason. Both reasons exclude, so no mutate list
ever changed; only the recorded reason was wrong.

Also removes the job's duration cutoff, which contradicted this workflow's own
statement that it holds no duration constants, and restores the plain
explanation of why three local tooling directories are excluded from the
sandbox copy.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The incremental cache had two independently reproduced defects, and removing it
fixes both without adding a repair layer.

The reference implementation read and wrote its incremental report beneath its
own root, because Stryker resolves that path against the working directory and
this cohort runs from its own directory. Every mechanism that managed the file
-- the cache save and restore, and the step that refused a cache whose inputs
had changed -- used the repository root instead. So the cohort's cache could
never be stored, and a refusal deleted a copy the engine does not read while
leaving the one it does. A run could reuse a cache the record said was refused.

The identity that authorised reuse was also incomplete. It covered the
configuration file's bytes, the tool and runtime versions and the lockfile, but
not the resolved test command. Two runs executing different test files hash
identically, so the identity would have approved reuse across runs that did not
run the same tests.

Neither defect is needed to run mutation testing on the files a pull request
changed and retain its evidence, and a cache is an optimisation rather than a
result. So the cache is gone: the two workflow steps, the decision entry point,
the reuse predicate, the recorded cache decision in the receipt, and the
incremental settings in both configurations. Every run is now cold. That costs
time and removes any possibility of reusing a stale verdict.

Also guards the change of directory before the engine runs, so a failure there
cannot silently mutate nothing from the wrong place, and corrects a comment that
claimed that change of directory settled the incremental-file path. It did not.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak
tnunamak force-pushed the test/reactive-mutation branch from 4e5f47c to 21ec75e Compare September 10, 2026 01:39
@tnunamak
tnunamak merged commit d71b2c5 into main Sep 10, 2026
16 checks passed
@tnunamak
tnunamak deleted the test/reactive-mutation branch September 10, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant