n8n/scripts/mutation-health
n8n-cat-bot[bot] 0ef6044fbb
fix: Populate mutation-health ledger churn/fix_density from setup (#32903)
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:14:28 +00:00
..
build-matrix.mjs feat: Derive picker coverage map from ledger in build-matrix (#32721) 2026-06-21 15:03:13 +00:00
build-matrix.test.mjs feat: Derive picker coverage map from ledger in build-matrix (#32721) 2026-06-21 15:03:13 +00:00
DEMO-HANDOVER.md chore: Remove duplicated local mutation skills (#32319) 2026-06-15 12:46:31 +00:00
emit-payload.mjs fix: Populate mutation-health ledger churn/fix_density from setup (#32903) 2026-06-24 07:14:28 +00:00
emit-payload.test.mjs fix: Populate mutation-health ledger churn/fix_density from setup (#32903) 2026-06-24 07:14:28 +00:00
ledger.mjs refactor: Add read-all ledger API to mutation-health pipeline (#32705) 2026-06-20 22:03:58 +00:00
ledger.test.mjs refactor: Add read-all ledger API to mutation-health pipeline (#32705) 2026-06-20 22:03:58 +00:00
mutate.mjs feat: Write per-file mutation coverage back to the ledger row (#32716) 2026-06-21 12:35:39 +00:00
mutate.test.mjs feat: Emit churn and fix_density on mutation-health ledger rows (#32858) 2026-06-23 15:52:10 +00:00
pick-next.mjs feat: Add global mode to mutation-health picker (no-changelog) (#32713) 2026-06-21 10:59:52 +00:00
pick-next.test.mjs feat: Derive picker coverage map from ledger in build-matrix (#32721) 2026-06-21 15:03:13 +00:00
README.md feat: Emit churn and fix_density on mutation-health ledger rows (#32858) 2026-06-23 15:52:10 +00:00
signals.mjs feat: Add git-derived risk signals for mutation-health (no-changelog) (#32703) 2026-06-20 21:09:10 +00:00
signals.test.mjs feat: Add git-derived risk signals for mutation-health (no-changelog) (#32703) 2026-06-20 21:09:10 +00:00
stryker.default.mjs perf: Set ignoreStatic in mutation-health default Stryker config (#32720) 2026-06-21 14:45:40 +00:00

scripts/mutation-health/

Phase 1 substrate for the Mutation Health Observability initiative.

What is mutation testing?

Line coverage tells you which lines your tests execute. Mutation testing tells you which behavioural changes your tests catch. A file can have 100% line coverage and a 0% mutation score: every line runs during the test suite, but no test would fail if the code were silently broken.

How it works

A mutation testing tool (n8n uses Stryker) does this for each source file:

  1. Parse the source into an AST.

  2. Generate small variants ("mutants") by changing nodes in the AST. Examples:

    Mutator Original Mutated
    Conditional if (item.mode === 'everyX') if (true), if (false)
    Equality a === b a !== b
    Boundary value > 0 value >= 0
    Arithmetic return a + b return a - b
    String literal 'hello' '', "Stryker was here!"
    Block statement { x(); return; } {}
    Conditional (ternary) cond ? a : b a, b, cond ? a : a, cond ? b : b

    There are ~40 mutator categories. One source line typically produces several mutants.

  3. For each mutant, run the test suite against the mutated code. One of these outcomes:

    Outcome Meaning
    Killed At least one test failed → tests caught the change. ✓
    Survived All tests passed → tests didn't catch the change. ✗
    NoCoverage No test even ran the mutated line.
    Timeout Tests hung (counted as detected).
  4. Mutation score = (killed + timeout) / (killed + timeout + survived + no_coverage). Higher = more load-bearing assertions.

Line coverage vs mutation score — a real example

packages/workflow/src/workflow-checksum.ts:

  • Line coverage: 87.09%
  • Mutation score: 38.64%

Mutating let hexString = '' to let hexString = "Stryker was here!" survived the test suite. The tests assert that two similar workflows produce different checksums — but never pin the actual output format. Line coverage calls this fine; mutation testing flags it as assertion-light test theatre.

That divergence is exactly why this project exists.


What's in this directory

File Purpose
pick-next.mjs Walk <pkg>/src/ (per-package mode) or every vitest-eligible package (global mode, --global), merge with the live ledger, return the next source file(s) to mutate
mutate.mjs Run Stryker on one source file of any vitest package, write summary.json
stryker.default.mjs Default Stryker config for onboarded packages (points at the package's own vitest.config.*)
emit-payload.mjs Turn a Stryker summary.json into a BQ-ready writer payload
ledger.mjs Read-all ledger access: readLedger({ path, pkg? }) returns every row across every package in one pass, optionally narrowed to one package without re-reading
signals.mjs Per-file git-derived risk signals (churn + fix-density) used by the global picker's value formula
build-matrix.mjs Convert the global picker's top-N output (or an on-demand SOURCE_FILE) into the GHA mutate matrix; runs the die-loud ELIGIBLE_PACKAGES ↔ ledger guard

mutate.mjs is package-agnostic — run pnpm mutate <repo-relative-file> from the repo root and the package is inferred from the path (or pass --package-dir <pkg> for a package-relative target, as the nightly does). It uses the package's own stryker.config.mjs if one exists (e.g. packages/workflow carves out the isolated-vm engine), otherwise stryker.default.mjs.

The reader and writer webhooks are plain HTTP — the GHA hits them with curl. There is no fetch/post wrapper script; if you want to call them locally, see Local usage.

The BQ table schema lives with the writer workflow (in n8n's internal Quality project), not in this repo — the writer owns the MERGE statement and is the single source of truth.

End-to-end pipeline

[GHA nightly cron, .github/workflows/mutation-health-nightly.yml]
       │
       ├─► setup job  (fetch-depth: 0, one process per night)
       │     │
       │     ├─► curl GET reader webhook    → live-ledger.json (read-all BQ state)
       │     │       │
       │     │       └─► [n8n: QA Mutation Health Reader] ──► SELECT from BQ ledger
       │     │
       │     ├─► signals.mjs                → signals.json (churn + fix-density)
       │     │
       │     ├─► build-matrix.mjs           → matrix={ include: [top-N picks] }
       │     │     │
       │     │     ├─► die-loud guard: empty ledger throws (strict mode); each
       │     │     │   ELIGIBLE_PACKAGES.name must have ≥1 non-`new` row.
       │     │     │   Escape hatch: `bootstrap_packages` workflow_dispatch input.
       │     │     │
       │     │     └─► pick-next.mjs --global --top-n N
       │     │           walks every eligible package's src/, merges with ledger,
       │     │           ranks: w_churn·churn + w_fix·fixDensity + w_cov·(1cov)
       │     │           priority: new → red → stale → skip green
       │     │
       │     └─► outputs.matrix = { include: [{name, dir, slug, mode, source_file, file_slug}, ...] }
       │
       └─► mutate job (one per matrix include)
             │
             ├─► mutate.mjs --package-dir <pkg> <source_file>   → summary.json
             │
             ├─► emit-payload.mjs                               → bq-payload.json
             │
             └─► curl POST writer webhook  → INSERTs event + MERGEs ledger row
                                              ↓
                              [n8n writer workflow: QA: Mutation Health Writer]
                                              ↓
                              ┌───────────────────────────────────┐
                              │ qa_mutation_health_ledger (MERGE) │
                              │ qa_performance_metrics  (INSERT)  │
                              └───────────────────────────────────┘

The writer workflow lives in n8n's internal Quality project. It's created and maintained outside this repo. This README documents the contract it implements.

Passes, packages & onboarding

The nightly runs a dynamic matrix of top-N picks (built once in the setup job of mutation-health-nightly.yml). The setup job fetches the read-all live ledger, gathers git-derived signals, and calls pick-next.mjs --global --top-n N once across every eligible package. Each picked row becomes one mutate job; jobs run independently, the ledger is keyed by package + file, so they don't collide. Two passes, selectable via the mode dispatch input (both on schedule):

  • baseline — files with no result yet (the new bucket). Builds out coverage. Maps from effective_status: new on a picked row.
  • coverage — revisits the weakest scored files (red/stale, lowest first). Strengthens existing tests. Maps from effective_status: red | stale on a picked row.

To onboard a vitest package: add one { name, dir } entry to ELIGIBLE_PACKAGES in pick-next.mjs. The nightly setup job derives its matrix from that single source of truth, so the picker and the workflow can't drift. No per-package config needed — stryker.default.mjs auto-resolves the package's own vitest.config.* (verified on plain and DI-decorator packages). Add a local stryker.config.mjs only if the package needs special handling.

Then, once, trigger the nightly with workflow_dispatch and set bootstrap_packages to the new package name (e.g. @n8n/decorators). The divergence guard otherwise refuses to schedule for a package that has zero non-new ledger rows — that's how it catches an ELIGIBLE_PACKAGES.nameledger.package rename mismatch, but it can't tell that from a legitimate new onboarding. The one-off bootstrap_packages value skips the guard for that package on that run only; subsequent scheduled nightlies revert to strict mode once the new package's rows have been populated.

For a genuine cold-start (first run after the ledger is provisioned, the read-all returns []), trigger with bootstrap_packages: '*' instead — it acknowledges the empty ledger and skips the per-package divergence check for every entry.

Not yet covered: jest packages (need Stryker's jest-runner — different setup) and @n8n/expression-runtime (it is the isolated-vm engine; blocked on the patch in DEVP-257).

State transitions

Trigger Stored status
Source file in src/ but no row yet synthesised as new at pick time; not stored
Last run passed the gate (score ≥ threshold_at_run AND no unjustified survivors) green
Last run failed the gate (score < threshold_at_run OR ≥1 Survived/NoCoverage mutant) red

Stored statuses are just two: red and green. new is computed in-memory by the picker for any file in the source tree that has no ledger row yet — the row is only persisted after that file's first scored run. The picker also computes a transient stale state — any green row whose last_checked_at is older than 4 weeks is treated as stale for that pick. No last_checked_sha is needed; no git history is consulted.

Picker priority: newredstale → skip fresh green.

  • Within new: alphabetical (rows exit the bucket as they're scored)
  • Within red: lowest score first (weakest tests revisited first)
  • Within stale: oldest last_checked_at first (natural cycling of long-stable files)

If every row is green and fresh, the picker exits 0 with {"picked": null, "reason": "all-green"} — a healthy "nothing to do" state, not a failure.

Webhook contracts

Two n8n workflows back the pipeline. Both live in the internal Quality project (L8csxtEbFpFOWlf8) and are created/maintained outside this repo. Both run unauthenticated (URL-as-secret pattern, matching existing qa_* workers).

Endpoint Method Workflow Purpose
https://internal.users.n8n.cloud/webhook/mutation-health-writer POST QA: Mutation Health Writer (iYEBmBat8OscRTVq) INSERT events + MERGE ledger
https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=<name> GET QA: Mutation Health Reader (ZmRsNUwvgfCSq0JI) Read current ledger state

Writer webhook

POST https://internal.users.n8n.cloud/webhook/mutation-health-writer with Content-Type: application/json:

{
  "ledger": [
    {
      "source_file_path": "packages/workflow/src/cron.ts",
      "package": "n8n-workflow",
      "last_score": 95.12,
      "coverage": 0.93,
      "churn": 7,
      "fix_density": 2.41,
      "threshold_at_run": 80,
      "last_checked_at": "2026-05-22T10:03:55.660Z",
      "status": "green",
      "mutants_killed": 39,
      "mutants_survived": 2,
      "mutants_no_coverage": 0,
      "mutants_timeout": 0
    }
  ],
  "events": [
    {
      "benchmark_name": "mutation_health",
      "value": 95.12,
      "timestamp": "2026-05-22T10:03:55.660Z",
      "dimensions": {
        "package": "n8n-workflow",
        "source_file": "packages/workflow/src/cron.ts",
        "sha": "095239e175",
        "status_after": "green",
        "threshold": 80,
        "coverage": 0.93,
        "churn": 7,
        "fix_density": 2.41,
        "mutants_killed": 39,
        "mutants_survived": 2,
        "mutants_no_coverage": 0,
        "mutants_timeout": 0
      }
    }
  ]
}

Either array may be empty (manual smoke tests sometimes send only events).

Each ledger row also carries coverage — the scored file's line-coverage proxy in [0,1], the share of mutants a test actually exercised — written back by mutate.mjs after each run. The global picker reads it next cycle as the (1 coverage) term of its value formula, so a file no test touches (coverage 0) gets the strongest urge to be scored.

Rows also carry churn — the count of commits touching the source file within a recent window (default 90 days, override with --churn-window), derived from git in emit-payload.mjs. It feeds the picker's churn term so hot files outrank cold ones. On a shallow clone history is truncated and the count would undercount, so churn is emitted as null rather than a misleading low number.

The third value-formula term, fix_density, is emitted the same way — the file's time-decayed, delta-weighted fix-density (the same git-derived signal signals.mjs computes: lines changed by fix:-shaped commits, decayed by a half-life on commit age). emit-payload.mjs reads one per-package git log --numstat pass (bounded by --fix-density-window, default 1 year; half-life via --fix-density-half-life, default 90 days) and scores each file. A file with no fix commits scores 0 (known: no fixes); a shallow clone or git failure emits null (unknown), the same guard as churn. A richer variant joining against the bug taxonomy is a possible future enhancement on the writer side, but is not required to feed the picker.

The writer:

  1. For each events[] row → INSERT into qa_performance_metrics.
  2. For each ledger[] row → MERGE into qa_mutation_health_ledger on source_file_path. Status is always red or green — the picker synthesises new in-memory and never posts it.

The webhook URL is delivered to GHA via the MUTATION_HEALTH_WEBHOOK repo secret. The secret URL itself is the only auth (matches existing qa_* writer pattern); rotate the secret if leaked.

Reader webhook

GET https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=<pkg>:

{
  "ledger": [
    {
      "source_file_path": "packages/workflow/src/cron.ts",
      "package": "n8n-workflow",
      "last_score": 95.12,
      "coverage": 0.93,
      "churn": 7,
      "fix_density": 2.41,
      "threshold_at_run": 80,
      "last_checked_at": "2026-05-22T10:03:55.660Z",
      "status": "green",
      "mutants_killed": 39,
      "mutants_survived": 2,
      "mutants_no_coverage": 0,
      "mutants_timeout": 0
    }
  ]
}

The package query param is validated server-side against the same pnpm-workspace allowlist regex used elsewhere in the pipeline; invalid input returns 500. No SQL is constructed or accepted on the client side — the SELECT is hardcoded in the workflow.

Unauthenticated — the URL is not a secret. The data isn't sensitive (file paths + integer scores), but treat the URL as low-trust: anyone with it can read all current ledger state for the queried package.

Gate semantics

A run passes only when both:

  1. Mutation score meets STRYKER_THRESHOLD (default 80), and
  2. Zero Survived / NoCoverage mutants remain — every unkilled mutant must be explicitly justified as Ignored via a // Stryker disable next-line <Mutator>: <reason> comment in the source.

Stryker excludes Ignored mutants from both numerator and denominator of the score (see scoreFromCounts in mutate.mjs), so marking a genuine equivalent as ignored is not padding — it's the documented mechanism for "this mutant is equivalent / not behaviour-bearing, here's why". The score becomes a coarse floor; the real gate is "no unjustified survivors". This stops agents from padding the suite with trivial tests to clear 80% while leaving real behaviour gaps unasserted. See DEVP-442 for the motivation.

summary.json surfaces every Ignored mutant alongside its disable-comment reason so reviewers can spot-check the justifications — those become the high-signal review artifact rather than N padding tests.

Threshold (provisional)

Runs use STRYKER_THRESHOLD=80 as a placeholder. The threshold moves to evidence-based after ~4 weeks of accumulated data. Until then, treat red/green verdicts as preliminary.

Local usage

# Run Stryker on one file (the inner loop — also invokable via /mutant-score skill).
# Package is inferred from the repo-relative path; works for any vitest package.
pnpm mutate packages/workflow/src/cron.ts
pnpm mutate packages/@n8n/crdt/src/utils.ts

# Pull current ledger from BQ
curl --fail -sS \
  'https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=n8n-workflow' \
  -o /tmp/ledger.json

# Pick the next file to score (per-package mode)
node scripts/mutation-health/pick-next.mjs \
  --package-dir packages/workflow \
  --ledger-file /tmp/ledger.json

# Pick top-N files across every vitest-eligible package, ranked by the
# global value formula w_churn·churn + w_fix·fixDensity + w_cov·(1  coverage).
# Signals and coverage files are optional — missing terms contribute 0
# except `(1  coverage)`, which degrades to 1 (worst-case) so untracked
# files float to the top.
node scripts/mutation-health/pick-next.mjs \
  --global \
  --ledger-file /tmp/ledger.json \
  --signals-file /tmp/signals.json \
  --coverage-file /tmp/coverage.json \
  --top-n 5 \
  --block '@n8n/expression-runtime'

# Build a BQ payload from a Stryker run
node scripts/mutation-health/emit-payload.mjs \
  --summary packages/workflow/reports/mutation/summary.json \
  --package n8n-workflow

# POST the result (requires MUTATION_HEALTH_WEBHOOK to be set)
curl --fail -sS -X POST \
  -H 'Content-Type: application/json' \
  --data @packages/workflow/reports/mutation/bq-payload.json \
  "$MUTATION_HEALTH_WEBHOOK"