diff --git a/AGENTS.md b/AGENTS.md
index 77bf5bf..99e6917 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,7 @@ In `docs/decisions.md`:
- No runtime dependencies
- Standards ship as data
- fast-check
+- Stryker in its own package
- Any ES2022 engine
- ESM only
- One built entrypoint
diff --git a/README.md b/README.md
index d9a3796..827d869 100644
--- a/README.md
+++ b/README.md
@@ -733,7 +733,8 @@ Both commands need only Docker, git and bash 5.
- `./ci.sh` runs the gate: every check CI runs.
- `./mutation-tests/run.sh []` mutation-tests the source lines changed since ``,
- `origin/main` by default, running each mutant against only the test files that reach it.
+ `origin/main` by default, running each mutant, in a 6 GB container, against only the test files
+ that reach it.
## Links
diff --git a/docker-runner.sh b/docker-runner.sh
index 2a0f236..ed74022 100644
--- a/docker-runner.sh
+++ b/docker-runner.sh
@@ -9,7 +9,7 @@ node_image=node:26.12.1-alpine3.24
in_image() {
local image=$1 entrypoint=$2
shift 2
- docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
+ docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_memory:+--memory "$in_image_memory" --memory-swap "$in_image_memory"} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
}
# Markers on stderr so a captured leg's value stays clean; leg_* because bash scopes local into the leg's own call.
diff --git a/docs/decisions.md b/docs/decisions.md
index 38538fa..85b1ef3 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -487,6 +487,14 @@ hand otherwise.
`fast-check` earns its place as a devDependency shrinking a failing generated document to the
nodes that break it.
+## Stryker in its own package
+
+2026-10-28, item 152. Code health: the code is easy to change safely. Valid while the gate runs no
+mutation tests.
+
+Mutation testing with Stryker lives in `mutation-tests/`'s own package, so the gate's `npm ci` never
+installs it. CI does not run it, so Renovate does not automerge its bumps.
+
## Any ES2022 engine
2026-09-01, the maintainer. Goal 8. Valid while ES2022 is the floor browsers and servers share.
diff --git a/mutation-tests/check-runtime-errors.mjs b/mutation-tests/check-runtime-errors.mjs
new file mode 100644
index 0000000..d99aefe
--- /dev/null
+++ b/mutation-tests/check-runtime-errors.mjs
@@ -0,0 +1,9 @@
+import { readFileSync } from 'node:fs'
+import config from './stryker.config.mjs'
+
+const mutants = Object.values(JSON.parse(readFileSync(config.jsonReporter.fileName, 'utf8')).files).flatMap((file) => file.mutants)
+const errors = mutants.filter((mutant) => mutant.status === 'RuntimeError').length
+if (errors > 0) {
+ console.error(`${errors} of ${mutants.length} mutants ended as RuntimeError, so each may hide a survivor`)
+ process.exit(1)
+}
diff --git a/mutation-tests/run.sh b/mutation-tests/run.sh
index 204c043..aa14dfd 100755
--- a/mutation-tests/run.sh
+++ b/mutation-tests/run.sh
@@ -19,4 +19,4 @@ if [ -z "$mutate" ]; then
fi
leg "install ($node_image)" in_image "$node_image" sh -c 'npm ci --no-audit --no-fund && npm ci --no-audit --no-fund --prefix mutation-tests'
-leg "mutation tests ($node_image)" in_image "$node_image" mutation-tests/node_modules/.bin/stryker run mutation-tests/stryker.config.mjs --mutate "$mutate"
+in_image_memory=6g leg "mutation tests ($node_image)" in_image "$node_image" sh -c 'mutation-tests/node_modules/.bin/stryker run mutation-tests/stryker.config.mjs --mutate "$1" && node mutation-tests/check-runtime-errors.mjs' sh "$mutate"
diff --git a/mutation-tests/stryker.config.mjs b/mutation-tests/stryker.config.mjs
index 6372fa3..a00dab2 100644
--- a/mutation-tests/stryker.config.mjs
+++ b/mutation-tests/stryker.config.mjs
@@ -1,9 +1,10 @@
export default {
- // A test file peaks near 2.6 GB: past four workers a 6 GB container kills some, and a killed worker's mutant reads as an error, hiding a survivor.
+ // A test file peaks near 2.6 GB: past four workers run.sh's 6 GB container kills some, and a killed worker's mutant reads as a RuntimeError, which fails the run.
concurrency: 4,
coverageAnalysis: 'perTest',
ignorePatterns: ['/browser-tests', '/converter-comparison', '/dist', '/mutation-tests', '/package-tests'],
- reporters: ['clear-text', 'progress-append-only'],
+ jsonReporter: { fileName: '/tmp/mutation-report.json' },
+ reporters: ['clear-text', 'json', 'progress-append-only'],
tap: { testFiles: ['src/**/*.test.ts'] },
tempDirName: '/tmp/stryker',
testRunner: 'tap',
diff --git a/renovate.json b/renovate.json
index 1d36104..0a3ce2d 100644
--- a/renovate.json
+++ b/renovate.json
@@ -51,6 +51,11 @@
],
"extends": ["config:recommended"],
"packageRules": [
+ {
+ "automerge": false,
+ "description": "CI does not run Stryker, so its bumps wait for a mutation run by hand",
+ "matchFileNames": ["mutation-tests/package.json"]
+ },
{
"allowedVersions": "<19",
"description": "engines.node states >=18, so the image proving it stays on 18",
diff --git a/todo.md b/todo.md
index 2016215..90e1f8f 100644
--- a/todo.md
+++ b/todo.md
@@ -436,6 +436,10 @@ assumes they agree. A helper added to one list lints test code under the shipped
shipped code from coverage, silently. Derive two from the third, or assert in a test that the three
agree. Found by the technical-principles audit, 2026-10-04.
+`mutation-tests/run.sh`'s pathspec and `mutation-tests/stryker.config.mjs`'s `testFiles` restate
+which files are tests and test helpers, so a helper missing from them reads as survivors. Found by
+the architecture review of item 152, 2026-10-28.
+
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.
Technical principle "One owner per value": the `Walk` record passes through functions that mutate it