-
Notifications
You must be signed in to change notification settings - Fork 1
Add mutation-testing-rollout skill #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| --- | ||
| name: mutation-testing-rollout | ||
| description: Roll out scheduled, informational mutation testing across an estate of repositories, triage the results into issues and killing tests, and keep the callers documented and drift-free. | ||
| --- | ||
|
|
||
| # Mutation-testing estate rollout and triage | ||
|
|
||
| This skill captures the process used to roll mutation testing out across | ||
| github.com/leynos/\* — approximately forty Rust and Python repositories — | ||
| and to operate it afterwards: harvesting survivors into issues, fixing | ||
| broken baselines, writing killing tests, and documenting the machinery. | ||
| Apply it when adopting mutation testing in a new repository, when triaging | ||
| the output of scheduled runs, or when running an estate-wide sweep. | ||
|
|
||
| ## Architecture | ||
|
|
||
| One pair of reusable workflows lives in `leynos/shared-actions`: | ||
|
|
||
| - `mutation-cargo.yml` (Rust, [cargo-mutants](https://mutants.rs/)) and | ||
| - `mutation-mutmut.yml` (Python, [mutmut](https://mutmut.readthedocs.io/)). | ||
|
|
||
| Each consumer repository carries only a thin caller, | ||
| `.github/workflows/mutation-testing.yml`, pinned to a shared-actions commit | ||
| SHA that Dependabot owns. The runs are **informational only**: they never | ||
| gate pull requests. A daily cron fires a change-scoped run (mutating only | ||
| files changed within a detection window, so quiet days are cheap no-ops); | ||
| manual dispatch runs the full mutation set, fanned out across shards. | ||
| Survivors surface in the job summary and in downloadable | ||
| `mutation-report-*` artefacts. | ||
|
|
||
| The mutation run is a measurement of the test suite, not of the code. A | ||
| surviving mutant is a test gap; the goal is a falling survivor count, not | ||
| zero. | ||
|
|
||
| ## Per-repository adoption recipe | ||
|
|
||
| 1. **Verify eligibility.** The repository needs a healthy test suite that | ||
| passes in CI. No tests mean every mutant survives as noise — defer | ||
| until tests exist. Python repositories must be uv-managed (a hard | ||
| requirement of the mutmut workflow), and Rust repositories must carry | ||
| their workspace manifest at the repository root (see step 3). | ||
| 2. **Verify the baseline under the mutation runner's conditions, not | ||
| CI's.** This is the single most common failure. Hazards found in | ||
| practice: | ||
| - Tests that pass under nextest's process isolation but fail under | ||
| plain `cargo test` (shared global state, log capture, a claimed | ||
| global tracing dispatcher). Either fix the test or pass | ||
| `--test-tool=nextest`. | ||
| - CI running `--all-targets` never runs doctests, but cargo-mutants | ||
| does. Broken doctests (including in vendored code) break the | ||
| baseline. Fix them, mark them `ignore`, or set `doctest = false`. | ||
| - mutmut copies sources into a `mutants/` sandbox that omits | ||
| `.github/`; any test reading workflow files needs a | ||
| `pytest.mark.skipif(not WORKFLOW_PATH.exists(), ...)` guard (or put | ||
| `.github/` in `also_copy`, or exclude the test from selection). | ||
| - Hygiene tests that walk the file system see the workflow-source | ||
| checkout; enumerate `git ls-files` instead. | ||
| - Stateful test dependencies (embedded PostgreSQL) break on re-run; | ||
| pin credentials/versions via the workflow's `setup-commands` input. | ||
| 3. **Write the caller.** Copy a proven caller (wireframe for Rust, | ||
| cmd-mox for Python) and adapt. The Rust caller assumes the workspace | ||
| manifest sits at the repository root, and the shared workflow cannot | ||
| be told otherwise: `mutation-cargo.yml` exposes no | ||
| `manifest-path`/`working-directory` input, and a full | ||
| `workflow_dispatch` always fans out a repository-root cargo-mutants | ||
| target. A Rust workspace whose manifest lives in a subdirectory (for | ||
| example `rust/Cargo.toml`) therefore has no working caller — enrol | ||
| only the root-manifest side and leave the subdirectory workspace out | ||
| until the shared workflow can target it: | ||
| - `paths`: change-detection globs matching the repository's source | ||
| layout. | ||
| - `exclude-globs`: example code, test scaffolding, fixture crates, and | ||
| `cfg(test)`-only companion files — anything whose survivors are | ||
| noise. Note that a module-root glob (`src/foo.rs`) does not match the | ||
| directory's submodules; add `src/foo/**` too. | ||
| - `extra-args`: match the CI baseline exactly (`--all-features`, | ||
| `--test-workspace=true` for workspaces). A mismatch reports | ||
| feature-gated code as untested. For workspaces where bare feature | ||
| names fail to resolve against the shard-scoped baseline, ensure every | ||
| mutated member defines the baseline feature set (forwarding features | ||
| if necessary). | ||
| - `setup-commands`: install anything `.cargo/config.toml` assumes | ||
| (clang/lld/mold), pin environmental state. | ||
| - `shard-count`: size from the first full run; a shard that brushes the | ||
| 360-minute job ceiling needs more shards. | ||
| - For mutmut: configure `[tool.mutmut]` in `pyproject.toml` | ||
| (`source_paths`, test selection, `do_not_mutate` for | ||
| subprocess-executed or generated code, `also_copy` for fixtures the | ||
| tests need). mutmut hard-codes importable layouts (`./`, `src/`, | ||
| `source/`); a `pkg/` layout needs a committed `src -> pkg` symlink | ||
| and `module-prefix-strip`. | ||
| 4. **Pin and stagger.** Pin the `uses:` line to a full 40-character | ||
| shared-actions commit SHA. Give each repository a distinct cron slot; | ||
| mutation runs are heavy and synchronized schedules concentrate load. | ||
| 5. **Add a contract test.** A PyYAML-based test | ||
| (`tests/workflow_contracts/` or `tests/test_workflow_contract.py`) | ||
| pins the caller's shape so drift fails the pull request: the `uses:` | ||
| path and that its ref is a 40-hex commit SHA (match the *shape* with a | ||
| regex — never hard-code the SHA value, or every Dependabot bump becomes | ||
| a manual chore), least-privilege permissions (`contents: read`, | ||
| `id-token: write`; empty workflow default), concurrency that queues per | ||
| ref without cancelling, the schedule-plus-plain-dispatch triggers, and | ||
| the `with:` block contents. | ||
| 6. **Dispatch a full run** and calibrate: verify the summary populates, | ||
| record survivor counts and runtime, and adjust shards/timeouts. | ||
| 7. **Document.** Add a "Mutation-testing workflow contract tests" section | ||
| to `docs/developers-guide.md`: why the workflow exists (informational | ||
| only), the two run modes, the rationale for each `with:` input, the | ||
| SHA-pin policy, the exact aspects the contract test validates, and the | ||
| local run command (a `make test-workflow-contracts` target where the | ||
| repository has a Makefile). Create the guide (and file an issue for a | ||
| fuller one) where it is missing. | ||
|
|
||
| ## Triage discipline | ||
|
|
||
| Harvest survivors from `mutants.out/outcomes.json` (cargo; | ||
| `scenario.Mutant.file`, `span.start.line`, `name`) or the mutmut summary. | ||
| File **one issue per coherent area** (module or concern), not per mutant, | ||
| with counts, representative survivors (`file:line` plus the mutation), and | ||
| a proposed next step. Then work the issues as draft PRs: | ||
|
|
||
| - **Real test gap** — write a killing test. Prefer exact assertions; | ||
| "equivalent" verdicts frequently fall to a sharper test. | ||
| - **Equivalent mutant** — suppress with justification: | ||
| `#[cfg_attr(test, mutants::skip)]` (Rust, via the `mutants` crate) or | ||
| `# pragma: no mutate` (Python). Keep skips rare and justified; prefer a | ||
| killing test wherever the mutation is observable. Beware two silent | ||
| no-op traps: mutmut honours the pragma only on single logical lines | ||
| (trailing pragmas on continuation lines of multi-line statements are | ||
| ignored — restructure to a single line), and cargo-mutants' skip | ||
| attribute does not cover plain `const` items or binary-expression | ||
| initializers (only functions, impls, and certain expression kinds) — a | ||
| skip placed there changes nothing and the mutant resurfaces next run. | ||
| Verify a suppression actually suppressed by re-running scoped | ||
| (`cargo mutants --file <f>` / `mutmut run '<module>.*'`; mutmut's | ||
| argument is a mutant-name glob over the import path, not a file path). | ||
| - **Dead code** — delete it; this is one of mutation testing's best | ||
| yields. | ||
| - **Untestable boundary** — document and move on. | ||
|
|
||
| Link every kill site to its tracking issue (issue refs in PR titles and | ||
| doc comments at the test), so the provenance survives the merge. | ||
|
|
||
| Red-green verification traps: stale `.pyc` bytecode can fake a red-green | ||
| cycle, and `PYTHONDONTWRITEBYTECODE=1` only stops new bytecode being | ||
| written — CPython still imports caches already on disk. Before trusting a | ||
| cycle, delete existing bytecode (`find . -type d -name __pycache__ | ||
| -exec rm -rf {} +`, plus any stray `*.pyc`), set | ||
| `PYTHONDONTWRITEBYTECODE=1`, and re-run. An ambient environment variable | ||
| can also turn a gate into a no-op — check what the gate actually ran. | ||
|
|
||
| ## Operating the estate: run sweeps | ||
|
|
||
| Periodically sweep all repositories' runs (`gh api --paginate | ||
| repos/<owner>/<repo>/actions/workflows`, then the | ||
| `repos/<owner>/<repo>/actions/runs` endpoint). Page both to exhaustion — | ||
| an unpaginated read returns only the first 30 items, so a truncated sweep | ||
| silently misclassifies. Both endpoints answer with an envelope | ||
| (`.workflows`/`.workflow_runs`), so aggregate every page before | ||
| classifying: `--slurp` yields one outer array of those envelopes, while | ||
| `--paginate --jq` alone prints one document per page rather than one | ||
| array. `--slurp` rejects `--jq` and `--template`, so fold the pages in a | ||
| second pass. Do NOT classify by wall-clock duration — runner-queue wait | ||
| routinely inflates a 15-second no-op to an hour. The reliable signals are: | ||
| `mutation_detect_has_changes` in the detect step's log (the mutmut | ||
| workflow runs its gate *inside* the single mutants job, so a no-op still | ||
| reports a green job), and the `mutants` job's conclusion (the cargo | ||
| workflow skips it visibly). Remember success does not mean clean — a real | ||
| run's survivors live in the job summary — and conversely a "failure" can | ||
| mask a fully completed run (a teardown-step regression once failed | ||
| otherwise-green runs estate-wide). For failures, read | ||
| `gh run view <id> --log-failed`, identify the root cause, and file one | ||
| issue per distinct cause (not per run); recurring identical quick failures | ||
| are one issue listing occurrences. Deduplicate against existing issues | ||
| first — comment on a persisting area rather than re-filing it (and when | ||
| concurrent sweep agents race, reconcile their duplicate filings onto one | ||
| canonical issue). If the cause lies in the reusable workflow, file it on | ||
| the shared-actions repository. | ||
|
|
||
| Be aware the change-detection gate suppresses scheduled coverage on quiet | ||
| repositories: a repo can report weeks of green runs while executing zero | ||
| mutants, leaving a broken baseline undetected. After merging a | ||
| baseline fix, trigger a full `workflow_dispatch` to prove it end-to-end | ||
| and produce a survivor dataset — do not wait for the schedule. | ||
|
|
||
| ## Estate lessons (hard-won) | ||
|
|
||
| - **Unpinned linters break baselines estate-wide.** `uv tool install ty`, | ||
| unpinned ruff preview rules, and rolling-release dylint each turned CI | ||
| red across multiple repositories at once. Pin linter versions. | ||
| - **Stale local checkouts lie.** Before concluding a file is absent, | ||
| verify against the remote default branch (`gh api | ||
| repos/<owner>/<repo>/contents/...`), and branch from a freshly fetched | ||
| `origin/<default>`. | ||
| - **Companion and fixture crates produce false survivors.** Where a | ||
| crate's coverage lives in a sibling, scope the run or record that its | ||
| survivor table is advisory. | ||
| - **Keep a living rollout ledger** (per-repository status, hazards, | ||
| issue/PR numbers). The tracker outlives any one session and is the only | ||
| reliable memory across an estate this size. | ||
| - Spelling gates (typos, en-GB Oxford `-ize`) apply to generated prose | ||
| too; expect `-ise` endings to be rejected, so write `summarize` and | ||
| `serialize`. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.