Skip to content

Commit 45bc068

Browse files
PAMulliganclaude
andcommitted
ci(mutation): wire Stryker mutation testing into nightly CI
Stryker deps were declared but no workflow ran them. Add a working root config, a nightly/on-demand workflow, and docs (#80). - stryker.conf.json: mutation-tests packages/pipeline (TS); runs from the package so pnpm-linked node_modules resolve in the sandbox; vitest runner + typescript checker; excludes barrels/CLIs/entry points - vitest.stryker.config.ts: excludes the e2e test (reads a repo-root fixture outside the sandbox) from mutation runs - .github/workflows/mutation.yml: nightly + workflow_dispatch (never on PRs); posts the score to the run summary, uploads the HTML report - README: how to run locally + the opt-in threshold (qualityGate.mutationScore in pipeline.config.json, enabled:false, threshold 80) - Verified locally: a complete scoped run finishes and produces a mutation score Refs #80 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6ee08fb commit 45bc068

4 files changed

Lines changed: 162 additions & 0 deletions

File tree

.github/workflows/mutation.yml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Mutation Testing
2+
3+
# Mutation testing is slow (it re-runs the test suite per mutant), so it runs
4+
# nightly and on demand — never on pull requests — to avoid PR latency. It is
5+
# informational/non-blocking: the score is reported to the run summary and the
6+
# HTML report is uploaded as an artifact.
7+
8+
on:
9+
schedule:
10+
- cron: "0 4 * * *" # nightly, 04:00 UTC
11+
workflow_dispatch:
12+
inputs:
13+
mutate:
14+
description: "Optional glob to scope mutation (e.g. src/tokens/**/*.ts)"
15+
required: false
16+
type: string
17+
18+
concurrency:
19+
group: mutation-${{ github.ref }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
stryker:
24+
name: Stryker (packages/pipeline)
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 90
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: "20"
34+
35+
- name: Install pnpm
36+
uses: pnpm/action-setup@v4
37+
with:
38+
version: 9
39+
40+
- name: Install dependencies
41+
run: pnpm install --frozen-lockfile
42+
43+
- name: Run Stryker mutation testing
44+
working-directory: packages/pipeline
45+
run: |
46+
# Run from packages/pipeline so the pnpm-linked node_modules resolve in
47+
# the Stryker sandbox; the config lives at the repo root.
48+
if [ -n "${{ github.event.inputs.mutate }}" ]; then
49+
npx stryker run ../../stryker.conf.json --mutate "${{ github.event.inputs.mutate }}"
50+
else
51+
npx stryker run ../../stryker.conf.json
52+
fi
53+
54+
- name: Mutation score summary
55+
if: always()
56+
working-directory: packages/pipeline
57+
run: |
58+
node -e '
59+
const fs = require("fs");
60+
const path = "reports/mutation/mutation-report.json";
61+
if (!fs.existsSync(path)) { console.log("No mutation report produced."); process.exit(0); }
62+
const report = JSON.parse(fs.readFileSync(path, "utf-8"));
63+
const counts = { Killed: 0, Survived: 0, Timeout: 0, NoCoverage: 0, RuntimeError: 0, CompileError: 0, Ignored: 0 };
64+
for (const file of Object.values(report.files || {})) {
65+
for (const m of file.mutants || []) counts[m.status] = (counts[m.status] || 0) + 1;
66+
}
67+
const detected = counts.Killed + counts.Timeout;
68+
const valid = detected + counts.Survived + counts.NoCoverage;
69+
const score = valid ? ((detected / valid) * 100).toFixed(2) : "n/a";
70+
const out = [
71+
"## Mutation testing (packages/pipeline)",
72+
"",
73+
`**Mutation score: ${score}%**` + (score === "n/a" ? "" : ` (target 80%, opt-in)`),
74+
"",
75+
"| Status | Count |",
76+
"| --- | --- |",
77+
...Object.entries(counts).map(([k, v]) => `| ${k} | ${v} |`),
78+
].join("\n");
79+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY || "/dev/stdout", out + "\n");
80+
console.log(`Mutation score: ${score}%`);
81+
'
82+
83+
- name: Upload mutation report
84+
if: always()
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: mutation-report
88+
path: packages/pipeline/reports/mutation/
89+
if-no-files-found: ignore
90+
retention-days: 14

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,33 @@ Full catalog: `.claude/skills/README.md`
241241
./scripts/setup-playwright.sh # One-time browser engine setup
242242
```
243243

244+
### Mutation testing (Stryker)
245+
246+
Mutation testing measures how well the test suite actually catches bugs. The
247+
framework's own TypeScript code (`packages/pipeline`) is mutation-tested via
248+
[Stryker](https://stryker-mutator.io/); `stryker.conf.json` lives at the repo
249+
root but **runs from `packages/pipeline`** so the pnpm-linked dependencies resolve
250+
inside Stryker's sandbox.
251+
252+
```bash
253+
# Full run (slow — re-runs the suite per mutant)
254+
cd packages/pipeline && npx stryker run ../../stryker.conf.json
255+
256+
# Scope to one area while iterating
257+
cd packages/pipeline && npx stryker run ../../stryker.conf.json --mutate "src/tokens/**/*.ts"
258+
```
259+
260+
The HTML report is written to `packages/pipeline/reports/mutation/index.html`.
261+
262+
- **Threshold (opt-in).** The target mutation score lives in
263+
`.claude/pipeline.config.json``qualityGate.mutationScore`
264+
(`{ "enabled": false, "threshold": 80, "blocking": false }`). It is **off by
265+
default**; set `enabled: true` to treat it as a quality gate.
266+
- **CI.** `.github/workflows/mutation.yml` runs Stryker **nightly** and
267+
**on demand** (`workflow_dispatch`) — never on PRs, to avoid latency. It is
268+
informational: the score is posted to the run summary and the HTML report is
269+
uploaded as an artifact.
270+
244271
### Pipeline Verification
245272
```bash
246273
./scripts/verify-tokens.sh # Catch hardcoded design values
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { configDefaults, defineConfig } from "vitest/config";
2+
3+
/**
4+
* Vitest config used by Stryker mutation runs (see ../../stryker.conf.json).
5+
*
6+
* Identical to vitest.config.ts except it excludes the end-to-end test, which
7+
* reads a fixture at the repo root — outside the Stryker sandbox (cwd =
8+
* packages/pipeline) — and would fail the initial test run. Unit tests provide
9+
* the mutant-killing signal.
10+
*/
11+
export default defineConfig({
12+
test: {
13+
include: ["src/**/*.test.ts"],
14+
environment: "node",
15+
exclude: [...configDefaults.exclude, "**/__tests__/e2e.test.ts"],
16+
},
17+
});

stryker.conf.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker/master/packages/core/schema/stryker-schema.json",
3+
"_comment": "Mutation testing for the framework's own TypeScript code in packages/pipeline. Run from packages/pipeline (see .github/workflows/mutation.yml and the README) so the pnpm-linked node_modules resolve inside the Stryker sandbox.",
4+
"packageManager": "pnpm",
5+
"plugins": ["@stryker-mutator/vitest-runner", "@stryker-mutator/typescript-checker"],
6+
"testRunner": "vitest",
7+
"vitest": { "configFile": "vitest.stryker.config.ts" },
8+
"checkers": ["typescript"],
9+
"tsconfigFile": "tsconfig.json",
10+
"mutate": [
11+
"src/**/*.ts",
12+
"!src/**/*.test.ts",
13+
"!src/**/__tests__/**",
14+
"!src/**/*.d.ts",
15+
"!src/**/index.ts",
16+
"!src/**/cli.ts",
17+
"!src/**/pipeline-cli.ts"
18+
],
19+
"reporters": ["clear-text", "json", "html"],
20+
"htmlReporter": { "fileName": "reports/mutation/index.html" },
21+
"jsonReporter": { "fileName": "reports/mutation/mutation-report.json" },
22+
"thresholds": { "high": 80, "low": 60, "break": null },
23+
"concurrency": 2,
24+
"timeoutMS": 60000,
25+
"tempDirName": ".stryker-tmp",
26+
"cleanTempDir": true,
27+
"ignoreStatic": true
28+
}

0 commit comments

Comments
 (0)