diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..95cf0ed4 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "4.16.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} diff --git a/.github/workflows/justdummies-mutation.yml b/.github/workflows/justdummies-mutation.yml new file mode 100644 index 00000000..e36f7dcb --- /dev/null +++ b/.github/workflows/justdummies-mutation.yml @@ -0,0 +1,259 @@ +name: justdummies-mutation + +# Mutation testing for the JustDummies libraries, kept in a workflow of their own rather than as two +# more legs of `mutation.yml`. JustDummies is destined for a repository of its own (it is already a +# standalone, error-agnostic package — ADR-0011), and this file is written so that the move is a +# FILE MOVE, not an edit: take this workflow, `build/stryker/justdummies*.json`, +# `.config/dotnet-tools.json` and the reference page, then repoint the `solution` field in the two +# configs at the new solution. Nothing here refers to a FirstClassErrors project. +# +# The mechanism is identical to `mutation.yml`; keep the two in step when you change one. + +on: + pull_request: + branches: + - main + # Weekly full sweep. The pull-request legs only mutate what a pull request touched, so nothing ever + # re-measures the parts of the libraries no one has edited: this is the run that does. Offset from + # `mutation`'s slot so the two sweeps do not contend for runners. + schedule: + - cron: '47 3 * * 1' + workflow_dispatch: + +# Cancel superseded runs on the same branch / PR. +concurrency: + group: justdummies-mutation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: this workflow only checks out, builds and runs tests, so the token +# needs nothing beyond read access to the repository contents. +permissions: + contents: read + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + +jobs: + # The gate. One leg per shipped library, each mutating ONLY what the pull request changed + # (Stryker's `--since`), so the cost follows the diff and not the size of the library. + # + # `gate` below aggregates these legs into the single check to mark as required on `main`. + changed: + name: Mutate the diff (${{ matrix.name }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # A leg whose library the pull request did not touch finishes in ~2 min (analysis, build, initial + # test run, mutant generation). The cap covers the worst realistic case instead: `JustDummies` is + # the largest library in the repository, and Stryker selects per changed FILE, so touching one of + # its bigger generators puts that whole file's mutants on the gate. + timeout-minutes: 60 + strategy: + # Run both legs to completion: a survivor in the adapter is worth seeing even when the + # generator is red. + fail-fast: false + matrix: + # The two JustDummies packages this repository SHIPS: the generator and its xUnit v3 adapter + # (ADR-0039). The FirstClassErrors libraries live in `mutation.yml`. See ADR-0043. + include: + - name: justdummies + config: build/stryker/justdummies.json + - name: justdummies-xunit + config: build/stryker/justdummies-xunit.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Stryker's `--since` diffs the working tree against a commit, so the full history must be + # present; a shallow clone would leave that commit unreachable. + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + # Pins dotnet-stryker through .config/dotnet-tools.json, so CI and a maintainer's machine run + # the same mutation engine — a newer engine invents new mutants and would move every score on + # its own, without a line of code changing. + run: dotnet tool restore + + # `pull_request` checks out the merge commit, so what this branch actually changed is measured + # from the FORK POINT — not from the base branch tip, which may have moved on since the branch + # was cut and would drag every file changed on `main` in the meantime into the "changed" set. + - name: Resolve the fork point + id: base + # A real commit SHA, not a rev expression: `--since:HEAD` is rejected outright by Stryker + # ("No branch or tag or commit found with given target"). `set -e` makes a failed merge-base + # fail the step, rather than handing Stryker an empty target that fails it later and vaguer. + run: | + set -euo pipefail + base=$(git merge-base '${{ github.event.pull_request.base.sha }}' HEAD) + echo "fork point: $base" + echo "sha=$base" >> "$GITHUB_OUTPUT" + + - name: Mutate the changed files + # The runner mode, the thresholds and the reporters all live in the config file, so this is + # the same command a maintainer runs locally. `--break-at` is deliberately NOT passed here: + # overriding the threshold from the YAML is what would let CI and a local run disagree. + # + # Stryker exits 0 — "unable to calculate a mutation score" — when the diff touches none of + # this library's sources, which is the common case for a leg nobody is working on. + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --since:${{ steps.base.outputs.sha }} + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # The score alone does not say what to fix. This turns the JSON report into the actionable + # half — file, line and the kind of rewrite that went unnoticed — in the run summary, so a + # failing gate can be diagnosed without downloading the artifact. `if: always()` because the + # interesting case is the failing one. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: the diff selected no mutant" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "This pull request changed nothing this library is mutated from." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + # Always: the report is most useful precisely when the run failed the threshold — the HTML + # view shows each surviving mutant in its source, which the summary table cannot. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # A leg that selected no mutant writes no report; that is a pass, not a packaging failure. + if-no-files-found: ignore + + # The single check to mark as required on `main` for the JustDummies packages. Separate from + # `mutation.yml`'s gate on purpose: the two are independent quality bars for what will become two + # repositories, and marking them required is one branch-protection entry each. + gate: + name: JustDummies mutation gate + if: always() && github.event_name == 'pull_request' + needs: changed + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check the mutation legs + # `needs.changed.result` is the matrix's aggregate: 'success' only when every leg succeeded. + # 'skipped' is accepted so the job stays green when the whole matrix is skipped by design. + run: | + result='${{ needs.changed.result }}' + echo "mutation legs: $result" + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "::error::the JustDummies mutation gate failed — see the 'Mutate the diff' legs and their reports" + exit 1 + fi + + # The weekly sweep: every mutant of both JustDummies packages, not just the ones a pull request + # touched. Advisory by construction — `--break-at 0` disables the threshold — because its job is to + # publish a trend, not to turn `main` red on a Monday morning over code nobody changed. + full: + name: Full sweep (${{ matrix.name }}) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + # The sweep runs the library's whole test suite once per mutant, and `JustDummies` carries a few + # thousand of them — this is the longest job in the repository, and the reason the sweep is + # weekly and the gate is diff-scoped. Measured locally on four cores, it runs well past an hour; + # the cap therefore sits just under the six-hour ceiling GitHub imposes on a job, because this + # run is meant to take as long as it takes rather than be cut short. + timeout-minutes: 350 + strategy: + fail-fast: false + matrix: + include: + - name: justdummies + config: build/stryker/justdummies.json + - name: justdummies-xunit + config: build/stryker/justdummies-xunit.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + run: dotnet tool restore + + - name: Mutate everything + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --break-at 0 + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # Same table as the gate legs produce. On the sweep it is the deliverable, not a diagnosis + # aid: this is the list of behaviours nothing in the suite asserts, library by library. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: no report produced" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation sweep — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "No mutant was selected — check the run's Stryker output." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-full-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # The sweep always produces a report; nothing to upload means the run never got that far. + if-no-files-found: error diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 00000000..891da8c5 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,275 @@ +name: mutation + +# Mutation testing for the FirstClassErrors libraries. JustDummies has its own, identical workflow +# — `justdummies-mutation.yml` — because it is destined for a repository of its own: the split has to +# be a file move, not an edit of a shared matrix. Keep the two in step when you change one. + +on: + pull_request: + branches: + - main + # Weekly full sweep. The pull-request legs only mutate what a pull request touched, so nothing ever + # re-measures the parts of the libraries no one has edited: this is the run that does. + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +# Cancel superseded runs on the same branch / PR. +concurrency: + group: mutation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: this workflow only checks out, builds and runs tests, so the token +# needs nothing beyond read access to the repository contents. +permissions: + contents: read + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + +jobs: + # The gate. One leg per shipped library, each mutating ONLY what the pull request changed + # (Stryker's `--since`), so the cost follows the diff and not the size of the library. + # + # `gate` below aggregates these legs into the single check to mark as required on `main`. + changed: + name: Mutate the diff (${{ matrix.name }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # A leg whose library the pull request did not touch finishes in ~2 min (analysis, build, initial + # test run, mutant generation). The cap covers the worst realistic case instead: a pull request + # touching one of the large files — Stryker selects per changed FILE, so `Outcome.cs` alone puts + # ~280 mutants on the gate, each costing one run of the library's test suite. + timeout-minutes: 60 + strategy: + # Run every leg to completion: a survivor in the binder is worth seeing even when core is red. + fail-fast: false + matrix: + # Every FirstClassErrors project whose code is compiled and shipped — the three libraries AND + # the tooling: the `fce` command line, the documentation generator, the Roslyn analyzers. The + # `Usage` samples and the binder benchmarks stay out (samples and a measurement harness, not + # shipped behaviour), and so does `GenDoc.Worker`: nothing unit-tests it in process — it is a + # process entry point, exercised end to end by the `floor` job of `ci` — so mutating it would + # report survivors that no test could ever kill. + # JustDummies and JustDummies.Xunit live in `justdummies-mutation.yml`. See ADR-0043. + include: + - name: core + config: build/stryker/core.json + - name: testing + config: build/stryker/testing.json + - name: binder + config: build/stryker/binder.json + - name: analyzers + config: build/stryker/analyzers.json + - name: gendoc + config: build/stryker/gendoc.json + - name: cli + config: build/stryker/cli.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Stryker's `--since` diffs the working tree against a commit, so the full history must be + # present; a shallow clone would leave that commit unreachable. + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + # Pins dotnet-stryker through .config/dotnet-tools.json, so CI and a maintainer's machine run + # the same mutation engine — a newer engine invents new mutants and would move every score on + # its own, without a line of code changing. + run: dotnet tool restore + + # `pull_request` checks out the merge commit, so what this branch actually changed is measured + # from the FORK POINT — not from the base branch tip, which may have moved on since the branch + # was cut and would drag every file changed on `main` in the meantime into the "changed" set. + - name: Resolve the fork point + id: base + # A real commit SHA, not a rev expression: `--since:HEAD` is rejected outright by Stryker + # ("No branch or tag or commit found with given target"). `set -e` makes a failed merge-base + # fail the step, rather than handing Stryker an empty target that fails it later and vaguer. + run: | + set -euo pipefail + base=$(git merge-base '${{ github.event.pull_request.base.sha }}' HEAD) + echo "fork point: $base" + echo "sha=$base" >> "$GITHUB_OUTPUT" + + - name: Mutate the changed files + # The runner mode, the thresholds and the reporters all live in the config file, so this is + # the same command a maintainer runs locally. `--break-at` is deliberately NOT passed here: + # overriding the threshold from the YAML is what would let CI and a local run disagree. + # + # Stryker exits 0 — "unable to calculate a mutation score" — when the diff touches none of + # this library's sources, which is the common case for a leg nobody is working on. + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --since:${{ steps.base.outputs.sha }} + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # The score alone does not say what to fix. This turns the JSON report into the actionable + # half — file, line and the kind of rewrite that went unnoticed — in the run summary, so a + # failing gate can be diagnosed without downloading the artifact. `if: always()` because the + # interesting case is the failing one. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: the diff selected no mutant" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "This pull request changed nothing this library is mutated from." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + # Always: the report is most useful precisely when the run failed the threshold — the HTML + # view shows each surviving mutant in its source, which the summary table cannot. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # A leg that selected no mutant writes no report; that is a pass, not a packaging failure. + if-no-files-found: ignore + + # The single check to mark as required on `main` for the FirstClassErrors libraries. A matrix + # produces one check per leg, and the set of leg names would have to be re-declared in the branch + # protection every time the matrix changes; this job collapses them into one stable name. + # `if: always()` is what makes it run after a failed leg — without it the job would be skipped, and + # GitHub reports a skipped required check as success. + gate: + name: Mutation gate + if: always() && github.event_name == 'pull_request' + needs: changed + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check the mutation legs + # `needs.changed.result` is the matrix's aggregate: 'success' only when every leg succeeded. + # 'skipped' is accepted so the job stays green when the whole matrix is skipped by design. + run: | + result='${{ needs.changed.result }}' + echo "mutation legs: $result" + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "::error::the mutation gate failed — see the 'Mutate the diff' legs and their reports" + exit 1 + fi + + # The weekly sweep: every mutant of every FirstClassErrors library, not just the ones a pull request + # touched. Advisory by construction — `--break-at 0` disables the threshold — because its job is to + # publish a trend, not to turn `main` red on a Monday morning over code nobody changed. + full: + name: Full sweep (${{ matrix.name }}) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + # The sweep runs the project's whole test suite once per mutant, and the six projects together + # carry several thousand — the tooling's suites being the slow ones, since they drive Roslyn + # compilations and snapshot comparisons. That is the reason the sweep is weekly and the gate is + # diff-scoped. The cap sits just under the six-hour ceiling GitHub imposes on a job: this run is + # meant to take as long as it takes. + timeout-minutes: 350 + strategy: + fail-fast: false + matrix: + include: + - name: core + config: build/stryker/core.json + - name: testing + config: build/stryker/testing.json + - name: binder + config: build/stryker/binder.json + - name: analyzers + config: build/stryker/analyzers.json + - name: gendoc + config: build/stryker/gendoc.json + - name: cli + config: build/stryker/cli.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + run: dotnet tool restore + + - name: Mutate everything + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --break-at 0 + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # Same table as the gate legs produce. On the sweep it is the deliverable, not a diagnosis + # aid: this is the list of behaviours nothing in the suite asserts, library by library. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: no report produced" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation sweep — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "No mutant was selected — check the run's Stryker output." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-full-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # The sweep always produces a report; nothing to upload means the run never got that far. + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 018e3e99..7fdfff19 100644 --- a/.gitignore +++ b/.gitignore @@ -299,3 +299,7 @@ tools/floor-check/build.log tools/justdummies-check/local-feed/ tools/justdummies-check/packages/ tools/justdummies-check/*.log +# Stryker mutation runs (the `mutation` workflow, or `dotnet stryker` locally): reports and the temporary +# copies of the solution Stryker compiles the mutants in. The workflow writes under artifacts/ (already +# ignored above); a local run defaults to StrykerOutput/ next to the config file. +StrykerOutput/ diff --git a/CLAUDE.md b/CLAUDE.md index 7ba54a5a..32790fea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,15 @@ errors should stay structured, documented, and close to the code. to apply it are in [`doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md`](doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md) (decision: ADR-0040). Read it before adding a JustDummies test. +* Mutation testing gates every pull request on the files it changed, for every + project whose code ships or runs, through two independent checks — one for the + FirstClassErrors libraries and tooling, one for the JustDummies packages + (decision: ADR-0043). A test that *executes* new code without *asserting* it + will pass `dotnet test` and still fail that gate. Reproduce it on a branch with + `dotnet tool restore && dotnet stryker --config-file build/stryker/.json --since:$(git merge-base origin/main HEAD)`; + the configurations and the reasons behind them are in + [`mutation.en.md`](doc/handwritten/for-maintainers/workflows/mutation.en.md) and + [`justdummies-mutation.en.md`](doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md). * Only report tests as passing if you actually ran the corresponding command. * If you did not run a relevant command, say so explicitly. diff --git a/build/stryker/analyzers.json b/build/stryker/analyzers.json new file mode 100644 index 00000000..2fcb7cb8 --- /dev/null +++ b/build/stryker/analyzers.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.Analyzers.csproj", + "test-projects": [ + "FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/build/stryker/binder.json b/build/stryker/binder.json new file mode 100644 index 00000000..59e20cec --- /dev/null +++ b/build/stryker/binder.json @@ -0,0 +1,21 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.RequestBinder.csproj", + "test-projects": [ + "FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj", + "FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 98, + "break": 95 + } + } +} diff --git a/build/stryker/cli.json b/build/stryker/cli.json new file mode 100644 index 00000000..d24dfe70 --- /dev/null +++ b/build/stryker/cli.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.Cli.csproj", + "test-projects": [ + "FirstClassErrors.Cli.UnitTests/FirstClassErrors.Cli.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/build/stryker/core.json b/build/stryker/core.json new file mode 100644 index 00000000..3178b7d9 --- /dev/null +++ b/build/stryker/core.json @@ -0,0 +1,21 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.csproj", + "test-projects": [ + "FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj", + "FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 98, + "break": 95 + } + } +} diff --git a/build/stryker/gendoc.json b/build/stryker/gendoc.json new file mode 100644 index 00000000..5290eb1f --- /dev/null +++ b/build/stryker/gendoc.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.GenDoc.csproj", + "test-projects": [ + "FirstClassErrors.GenDoc.UnitTests/FirstClassErrors.GenDoc.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/build/stryker/justdummies-xunit.json b/build/stryker/justdummies-xunit.json new file mode 100644 index 00000000..f6f89a2b --- /dev/null +++ b/build/stryker/justdummies-xunit.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "JustDummies.Xunit.csproj", + "test-projects": [ + "JustDummies.Xunit.UnitTests/JustDummies.Xunit.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 85, + "low": 70, + "break": 50 + } + } +} diff --git a/build/stryker/justdummies.json b/build/stryker/justdummies.json new file mode 100644 index 00000000..f0b91dc4 --- /dev/null +++ b/build/stryker/justdummies.json @@ -0,0 +1,21 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "JustDummies.csproj", + "test-projects": [ + "JustDummies.UnitTests/JustDummies.UnitTests.csproj", + "JustDummies.PropertyTests/JustDummies.PropertyTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 95, + "low": 85, + "break": 0 + } + } +} diff --git a/build/stryker/testing.json b/build/stryker/testing.json new file mode 100644 index 00000000..fc74b95d --- /dev/null +++ b/build/stryker/testing.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.Testing.csproj", + "test-projects": [ + "FirstClassErrors.Testing.UnitTests/FirstClassErrors.Testing.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 80, + "low": 60, + "break": 40 + } + } +} diff --git a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md new file mode 100644 index 00000000..f07fb7ea --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md @@ -0,0 +1,323 @@ +# ADR-0043 | Conditionner les pull requests au score de mutation de ce qu'elles modifient + +🌍 🇬🇧 [English](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-27 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors livre cinq bibliothèques — `FirstClassErrors`, +`FirstClassErrors.Testing`, `FirstClassErrors.RequestBinder`, `JustDummies` et +`JustDummies.Xunit` — dont le produit *est* la sémantique : quelle erreur une +fabrique retourne, quelle branche un `Outcome` emprunte, quelle valeur une +contrainte admet. Un défaut à cet endroit n'est pas un plantage que le +consommateur voit venir ; c'est une réponse fausse délivrée avec assurance. + +Le dépôt impose déjà deux signaux de qualité automatiques sur chaque pull +request : toute la suite de tests sur deux plateformes (`ci`), et la quality gate +SonarQube Cloud, couverture de lignes et de branches comprise (`sonar`). La +couverture enregistre qu'une ligne a été **exécutée** par un test. Elle ne peut +pas enregistrer si un test aurait **remarqué** que cette ligne était fausse : une +suite qui appelle une méthode sans rien affirmer de son résultat obtient la même +couverture qu'une suite qui épingle tous les cas. + +Les tests de mutation mesurent exactement cette différence. L'outil réécrit la +bibliothèque un petit changement à la fois, rejoue la suite contre chaque +réécriture, et signale celles que la suite laisse passer. Sur .NET, cet outil est +Stryker.NET ; il n'existe pas d'alternative maintenue. + +Quatre faits sur son exécution dans ce dépôt ont été établis par la mesure avant +que cette décision soit prise : + +* **Le runner VSTest par défaut de Stryker ne fonctionne pas sur ce banc de + tests.** Tous les projets de tests sont ici en xUnit v3, et un projet de tests + xUnit v3 est un exécutable que l'adaptateur VSTest lance dans un processus fils + — hors de portée des crochets in-process dont Stryker se sert à la fois pour + capturer la couverture et pour *activer* un mutant. Le run va au bout, annonce + un nombre de tests plausible, et score **0 %** : tous les mutants sont + rapportés survivants, y compris des mutants que la suite tue de façon + démontrable quand la même modification est appliquée à la main. Un barrage bâti + sur ce runner serait rouge en permanence et ne prouverait rien. +* **Le runner Microsoft Testing Platform de Stryker fonctionne**, parce qu'il + lance lui-même l'exécutable de tests. Il est marqué *preview* par ses auteurs. +* **Sa sélection de mutants par la couverture n'est pas encore fiable.** + Sélection activée, des mutants que la suite tue effectivement sont classés non + couverts et comptés contre le score ; sélection désactivée — chaque mutant + confronté à toute la suite — la même population score nettement plus haut, et + ce résultat correspond à ce que donne l'application des mutations à la main. La + désactiver ne coûte pratiquement rien ici, car les suites sont rapides. +* **Le coût est d'environ une exécution de la suite par mutant.** Cela représente + environ une seconde par mutant pour ces bibliothèques : le balayage complet + d'une bibliothèque se compte en minutes, celui des cinq est trop long pour être + attendu à chaque push. Ne sélectionner que les mutants touchés par un + changement ramène le cas courant au coût fixe de l'analyse et du build. + +Le mode diff de Stryker sélectionne les mutants par **fichier** modifié, pas par +ligne modifiée ; il n'y a pas de granularité à la ligne. Une modification d'une +ligne dans un gros fichier met donc tous les mutants de ce fichier sur le +barrage. + +Tout survivant n'est pas un défaut : certains mutants sont *équivalents*, ils +changent le code sans changer le comportement observable, et aucun test ne peut +les tuer. Un seuil à 100 % est donc inatteignable par principe. + +Deux des cinq bibliothèques — `JustDummies` et son adaptateur xUnit v3 — sont +déjà tenues à l'écart de toute référence aux trois autres +([ADR-0011](0011-host-dummies-as-a-standalone-package.fr.md)) et sont destinées à +migrer vers un dépôt à elles. + +Les runs ont lieu sur des runners hébergés par GitHub : quatre vCPU, un plafond +de six heures par job. Un check ne peut être rendu obligatoire sur `main` que par +la protection de branche, qui nomme les checks un par un — une matrice fournit un +nom de check par branche. + +## Décision + +Toute pull request ciblant `main` doit franchir un seuil de score de mutation, +mesuré par Stryker.NET sur les mutants des fichiers qu'elle modifie, pour chaque +projet du dépôt dont le code est livré ou exécuté — imposé par deux barrages +indépendants, découpés le long de la frontière de dépôt à venir. + +## Justification + +Le barrage bouche exactement le trou que les signaux existants laissent ouvert. +`ci` prouve que la suite passe ; `sonar` prouve que le code a été exécuté. Ni +l'un ni l'autre ne distingue un test qui épingle un comportement d'un test qui ne +fait que le traverser — et cette distinction est toute la qualité d'une +bibliothèque dont le produit est sa sémantique. Les tests de mutation sont le +seul signal automatique qui la mesure. + +Le rendre **obligatoire plutôt que consultatif** est l'objet de la décision, pas +un détail aggravant. Un rapport consultatif, sur un dépôt maintenu par une seule +personne, est un rapport que personne ne lit ; la pratique qu'il vise à installer +— écrire l'assertion, pas seulement l'appel — ne survit que si le merge en +dépend. Le dépôt traite déjà ainsi ses autres invariants : le cliquet de +warnings, la convention de commit et les floors supportés sont imposés, pas +suggérés. + +Cantonner le barrage à **ce que la pull request modifie** est ce qui rend +l'obligation abordable. Le modèle de coût est linéaire en nombre de mutants, et +ce nombre est proportionnel au code mesuré ; mesurer le diff maintient une pull +request ordinaire au coût fixe de l'analyse et du build, là où tout mesurer, à +chaque fois, mettrait des dizaines de minutes sur chaque push pour du code que +l'auteur n'a pas touché. Le compromis accepté est que la granularité au fichier +du mode diff fait rapporter le score du fichier entier pour une petite +modification, si bien qu'une pull request peut avoir à répondre de manques +préexistants dans un fichier qu'elle n'a fait qu'effleurer. C'est un coût réel, +et c'est la bonne direction pour l'erreur : elle pousse à la hausse la couverture +des fichiers les plus faibles au contact, et le mainteneur peut toujours écarter +une branche. + +Le périmètre couvre **l'outillage et les analyseurs autant que les +bibliothèques**. Leurs suites sont les lentes — elles pilotent des compilations +Roslyn et des comparaisons de snapshots, donc leurs mutants sont les chers —, mais +la dépense est une raison de les mettre dans le balayage hebdomadaire, pas une +raison de les laisser sans mesure : ce balayage est précisément le run autorisé à +durer le temps qu'il faut. Sur le barrage, le cantonnement au diff borne déjà ce +qu'ils coûtent, puisqu'une pull request qui ne touche pas le générateur ne paie +rien pour lui. Ce qui reste exclu l'est pour une autre raison que le coût : les +échantillons `Usage` et les benchmarks du binder ne sont pas du comportement +livré, et le worker de documentation est un point d'entrée de processus qu'aucun +test n'exerce en processus — le muter ne fabriquerait que des survivants qu'aucun +test ne pourrait tuer. + +L'imposer par **deux barrages plutôt qu'un** ne coûte rien aujourd'hui et achète +la migration. Les packages JustDummies sont déjà isolés du reste par construction, +et ils partent ; une matrice unique devrait être réécrite, et son entrée de check +obligatoire renégociée, précisément au moment où la partie la moins intéressante +d'une séparation de dépôt devrait être sa CI. Deux barrages font de cette étape un +déplacement de fichier. Ils laissent aussi les deux barres évoluer séparément — ce +qui est nécessaire, puisque les bibliothèques se situent à des niveaux de maturité +de test visiblement différents et qu'une barre unique devrait être calée sur la +plus faible des deux. + +Épingler le moteur de mutation compte pour la même raison que le floor Roslyn de +l'analyseur ([ADR-0001](0001-lock-the-analyzer-roslyn-floor.md)) : un moteur plus +récent invente de nouveaux mutants, et le score bougerait sans qu'une ligne de +code change. Un seuil n'a de sens que face à un générateur figé. + +Le statut **preview** du runner dont dépend le barrage est la principale +faiblesse de la décision, et elle est acceptée en connaissance de cause : +l'alternative est l'absence totale de signal de mutation, puisque le runner +supporté ne sous-estime pas — il rapporte zéro. L'atténuation tient à ce que le +mode de défaillance est bruyant et non silencieux : une régression du runner qui +cesserait d'activer les mutants ramènerait tous les scores à zéro et ferait +échouer le barrage, au lieu de le laisser passer discrètement. + +Enfin, le seuil est fixé **sous 100 %** parce que les mutants équivalents rendent +100 % inatteignable, et parce que c'est un *score* qui est contrôlé, pas +l'absence de survivants : c'est le rapport, et non le code de sortie, que le +mainteneur lit pour décider si un survivant est une assertion manquante ou un +mutant équivalent. + +Chaque bibliothèque reçoit son **propre** seuil, dérivé de son score mesuré et +non d'une cible choisie dans l'abstrait. Un chiffre unique pour cinq +bibliothèques devrait être soit assez bas pour la plus faible — et donc sans +mordant pour celles déjà au sommet —, soit assez haut pour la plus forte, et donc +rouge dès le premier jour pour les autres. Le dériver par bibliothèque fait du +barrage un cliquet : il interdit la régression par rapport au niveau déjà atteint, +passe à l'introduction, et ne peut que monter. Le prix de ce choix, c'est que la +barre est basse là où le banc de tests est faible — précisément là où le barrage +serait le plus utile ; relever ces seuils à mesure que le balayage hebdomadaire +révèle de la marge est l'usage prévu du cliquet, et c'est délibérément une +décision du mainteneur plutôt qu'un automatisme. + +## Alternatives envisagées + +### Publier le score de mutation sans faire échouer le build + +Envisagée parce qu'elle ne porte aucun risque : aucune pull request n'est jamais +bloquée par un outil en preview, et le chiffre est quand même publié. + +Rejetée parce qu'elle ne change rien. Les invariants de qualité du dépôt sont +tous imposés plutôt que suggérés, précisément parce qu'un signal qu'il ne coûte +rien d'ignorer finit ignoré. La décision qui mérite d'être consignée ici, c'est +qu'une pull request doit répondre des assertions qu'elle n'a pas écrites ; un +rapport consultatif ne formule pas cette exigence. + +### Muter chaque bibliothèque en entier sur chaque pull request + +Envisagée parce que c'est la mesure honnête : un score sur toute la bibliothèque +est comparable d'un run à l'autre, et il ne peut pas être contourné en +n'effleurant que les bords d'un fichier. + +Rejetée pour deux raisons. La première est le coût : le coût par mutant est une +exécution complète de la suite du projet, si bien qu'un balayage complet de tout +le périmètre se compte en heures — payées à chaque push, essentiellement pour +re-mesurer du code que personne n'a changé. La seconde suffit à elle seule : **un +score sur tout un projet est bien trop insensible pour servir de barrage**. La +plus grosse bibliothèque porte quelques milliers de mutants : un comportement +nouvellement ajouté et non affirmé déplace donc son score d'une fraction de +pour-cent — bien en dessous de tout seuil qui ne serait pas lui-même du bruit. Le score cantonné au diff est sensible précisément parce +que son dénominateur est petit : une poignée de mutants neufs, dont un survit, +c'est une chute visible. Le balayage hebdomadaire récupère le chiffre sur toute la +bibliothèque là où il a sa place — comme tendance, pas comme barrage. + +### Relever l'exigence de couverture SonarQube Cloud à la place + +Envisagée parce que la quality gate existe déjà, est déjà obligatoire, et ne +rapporte déjà que sur le code neuf — le mécanisme même dont cette décision a +besoin. + +Rejetée parce qu'elle mesure autre chose. La couverture ne peut pas descendre +sous 100 % pour une ligne qu'un test exécute sans rien en affirmer ; relever le +pourcentage exigé achète des lignes exécutées, pas du comportement épinglé. Les +deux signaux sont complémentaires, et celui-ci n'a pas de substitut. + +### Conserver le runner VSTest par défaut de Stryker + +Envisagée parce que c'est la configuration supportée et non-preview, et que +préférer un composant en preview dans un check obligatoire n'est pas une décision +à prendre à la légère. + +Rejetée parce qu'elle ne fonctionne pas du tout sur ce banc de tests. Vérifié par +la mesure : elle rapporte tous les mutants comme survivants, y compris des +mutants qui cassent la suite de façon démontrable quand on les applique à la +main. La choisir reviendrait soit à un barrage rouge en permanence, soit à un +seuil assez bas pour ne rien vouloir dire. + +### Restreindre le périmètre aux bibliothèques livrées, en laissant l'outillage dehors + +Envisagée, et retenue dans un premier temps, pour son coût : les tests +d'analyseurs et de générateur compilent du code et lancent des processus, leurs +mutants sont donc d'un ordre de grandeur plus chers que ceux d'une bibliothèque, +et leur comportement visible de l'extérieur est déjà tenu par les jobs +`analyzers`, `gendoc-docs` et le `floor` de `ci`. + +Rejetée parce que le coût qu'elle évite est précisément celui que le balayage +hebdomadaire est là pour absorber, et parce que l'exclusion laissait un trou +plutôt qu'une frontière : une pull request ne touchant que les analyseurs n'aurait +franchi aucun barrage de mutation. Mesuré avant de revenir sur la décision, les +projets exclus portent à peu près autant de mutants que les bibliothèques +FirstClassErrors déjà dans le périmètre, et Stryker tourne sur tous — suites à +snapshots et tests lanceurs de processus compris. + +## Conséquences + +### Positives + +* Une pull request qui ajoute du comportement sans ajouter l'assertion qui + l'épingle est refusée automatiquement, sur le code qu'elle modifie, avant la + revue. +* Les fichiers les moins bien testés s'améliorent au contact : en toucher un met + tout son score de mutation sur le barrage. +* Le balayage hebdomadaire donne aux parties non touchées des bibliothèques une + tendance que rien d'autre dans la chaîne ne produit. +* Le diagnostic est concret. Un barrage en échec nomme le mutant survivant, son + fichier et sa ligne — il dit quelle assertion manque, pas seulement qu'un + chiffre est trop bas. + +### Négatives + +* Une pull request qui touche un gros fichier faiblement couvert paie pour les + manques préexistants de ce fichier, pas seulement pour son propre changement. +* Deux checks obligatoires de plus sur le chemin critique de chaque merge, et une + pièce mobile de plus à maintenir en état — l'épinglage de l'outil, le mode du + runner et les seuils doivent tous être entretenus délibérément. +* Les deux workflows sont des fichiers quasi identiques. Tant que la séparation + n'a pas eu lieu, un correctif sur l'un est un correctif sur l'autre, et rien ne + l'impose. +* Les mutants équivalents rendent une partie de la distance restante jusqu'à + 100 % inatteignable : le seuil relève donc du jugement, pas d'un calcul. +* Le balayage hebdomadaire est long — des heures, dominées par la plus grosse + bibliothèque et par les suites lentes de l'outillage. C'est assumé : c'est la + raison pour laquelle ce balayage est hebdomadaire et consultatif, pas un + barrage. + +### Risques + +* **Le runner Microsoft Testing Platform est en preview.** Une régression pourrait + déplacer les scores d'une version du moteur à l'autre. Atténué par l'épinglage + du moteur dans le manifeste d'outils, qui fait de la montée de version un acte + délibéré, et par le caractère bruyant du mode de défaillance : un runner qui + cesse d'activer les mutants ramène tous les scores à zéro. +* **Sa sélection par la couverture est désactivée** : le coût est donc d'une + exécution complète de la suite par mutant. C'est supportable aujourd'hui parce + que ces suites sont rapides ; une suite nettement plus lente rendrait le + balayage, puis le barrage, coûteux. +* **Les seuils sont calibrés sur les scores d'aujourd'hui.** Une bibliothèque + ajoutée plus tard avec un banc de tests plus faible échouerait au barrage à son + premier contact plutôt qu'à son introduction. +* **`JustDummies` part sans seuil de score.** Son balayage est trop long pour + servir de calibration interactive : cette bibliothèque est donc barrée sur tout + sauf sur un score, jusqu'à ce que le premier balayage hebdomadaire en fournisse + un. +* **Les pull requests de tests seuls sélectionnent aussi des mutants**, via les + fichiers de tests qu'elles modifient : une pull request qui n'ajoute que des + tests peut donc être barrée. + +## Actions de suivi + +* Rendre les deux checks agrégés — `Mutation gate` et `JustDummies mutation gate` + — obligatoires sur `main` dans la protection de branche ; un workflow ne peut + pas se rendre obligatoire lui-même. +* Quand JustDummies migrera dans son propre dépôt, emporter son workflow, ses deux + configurations et le manifeste d'outil tels quels, puis repointer le champ + `solution` ; la page de référence du workflow porte la check-list. +* Réexaminer le choix du runner quand le support Microsoft Testing Platform de + Stryker sortira de preview, et réactiver la sélection par la couverture quand + elle classera correctement les mutants couverts. +* Relire les seuils après chaque montée de version du moteur, et après tout ajout + d'une bibliothèque au périmètre. +* Fixer un seuil pour les projets qui n'en ont pas encore — `JustDummies`, les + analyseurs, le générateur de documentation et la ligne de commande — à partir du + premier balayage hebdomadaire. Leurs balayages sont trop longs pour être + exécutés interactivement : aucun score n'a donc été mesuré pour eux, et leurs + barrages de score sont livrés désactivés plutôt que devinés. + +## Références + +* [Référence du workflow `mutation`](../workflows/mutation.fr.md) — comment la + décision est mise en œuvre, et les réglages qu'elle expose. +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md) — le précédent en matière + d'épinglage d'une version d'outil qui, sinon, déplacerait seule un résultat + mesuré. +* [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md) + — le découpage du banc de tests dont les deux suites alimentent ce barrage. +* [stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117) + — le signalement amont du runner VSTest de Stryker face à xUnit v3. +* [stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629) + — la limitation amont de l'analyse de couverture sous le runner Microsoft + Testing Platform. diff --git a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md new file mode 100644 index 00000000..7a81584f --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md @@ -0,0 +1,297 @@ +# ADR-0043 | Gate pull requests on the mutation score of what they changed + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md) + +**Status:** Proposed +**Date:** 2026-07-27 +**Decision Makers:** Reefact + +## Context + +FirstClassErrors ships five libraries — `FirstClassErrors`, +`FirstClassErrors.Testing`, `FirstClassErrors.RequestBinder`, `JustDummies` and +`JustDummies.Xunit` — whose product *is* their semantics: which error a factory +returns, which branch an `Outcome` takes, which value a constraint admits. A +defect there is not a crash a consumer can see coming; it is a wrong answer +delivered confidently. + +The repository already enforces two automated quality signals on every pull +request: the full test suite on two platforms (`ci`), and SonarQube Cloud's +quality gate including line and branch coverage (`sonar`). Coverage records that +a line was **executed** by a test. It cannot record whether any test would have +**noticed** that line being wrong: a suite that calls a method and asserts +nothing about the result scores the same coverage as one that pins every case. + +Mutation testing measures exactly that difference. The tool rewrites the library +one small change at a time, re-runs the suite against each rewrite, and reports +the rewrites the suite still passes on. On .NET the tool is Stryker.NET; there is +no maintained alternative. + +Four facts about running it on this repository were established by measurement +before this decision was taken: + +* **Stryker's default VSTest runner does not work on this test bed.** Every test + project here is xUnit v3, and an xUnit v3 test project is an executable the + VSTest adapter launches as a child process — out of reach of the in-process + hooks Stryker uses both to capture coverage and to *activate* a mutant. The + run completes, reports a plausible-looking number of tests, and scores **0 %**: + every mutant is reported as survived, including mutants the suite demonstrably + kills when the same edit is applied by hand. A gate built on that runner would + be permanently red and would prove nothing. +* **Stryker's Microsoft Testing Platform runner does work**, because it launches + the test executable itself. It is flagged *preview* by its authors. +* **Its coverage-based mutant selection is not trustworthy yet.** With selection + enabled, mutants the suite does kill are classified as uncovered and counted + against the score; with it disabled — every mutant tested against the whole + suite — the same population scores materially higher and matches what running + the mutations by hand shows. Disabling it also costs almost nothing here, + because the suites are fast. +* **The cost is roughly one test-suite run per mutant.** That is about a second + per mutant for these libraries, which makes a full sweep of one library a + matter of minutes and a full sweep of all five too long to sit in front of on + every push. Selecting only the mutants a change touches brings the common case + down to the fixed cost of analysis and build. + +Stryker's diff mode selects mutants per changed **file**, not per changed line; +there is no line granularity. A one-line edit to a large file therefore puts that +whole file's mutants on the gate. + +Not every survivor is a defect: some mutants are *equivalent*, changing the code +without changing observable behaviour, and no test can kill them. A threshold of +100 % is therefore not reachable in principle. + +Two of the five libraries — `JustDummies` and its xUnit v3 adapter — are already +kept free of any reference to the other three +([ADR-0011](0011-host-dummies-as-a-standalone-package.md)) and are intended to +move to a repository of their own. + +Runs happen on GitHub-hosted runners: four vCPU, a six-hour cap per job. A check +can only be made mandatory on `main` through branch protection, which names +checks individually — a matrix contributes one check name per leg. + +## Decision + +Every pull request targeting `main` must clear a mutation-score threshold, +measured by Stryker.NET over the mutants of the files it changed, for every +project in the repository whose code is shipped or executed — enforced by two +independent gates, split along the prospective repository boundary. + +## Rationale + +The gate closes the specific hole the existing signals leave open. `ci` proves +the suite passes; `sonar` proves the code was executed. Neither can distinguish a +test that pins behaviour from a test that merely visits it, and that distinction +is the whole quality of a library whose product is its semantics. Mutation +testing is the only automated signal that measures it. + +Making it **mandatory rather than advisory** is the point of the decision, not an +aggravating detail. An advisory report on a repository maintained by one person +is a report nobody reads; the practice it is meant to install — write the +assertion, not just the call — only survives if merging depends on it. The +repository already treats its other invariants this way: the warning ratchet, the +commit convention, the support floors are all enforced, not suggested. + +Scoping the gate to **what the pull request changed** is what makes mandatory +affordable. The cost model is linear in the number of mutants, and the number of +mutants is proportional to the code under measurement; measuring the diff keeps a +typical pull request at the fixed cost of analysis and build, while measuring +everything, every time, would put tens of minutes on every push for code the +author did not touch. The trade-off accepted is that the file-level granularity +of the diff mode makes a small edit to a large file report that whole file's +score, so a pull request can be asked to answer for pre-existing gaps in a file +it merely touched. That is a real cost, and it is the acceptable direction of +error: it pushes coverage of the weakest files up on contact, and the maintainer +can always waive a leg. + +The scope covers the **tooling and the analyzers as well as the libraries**. Their +suites are the slow ones — they drive Roslyn compilations and snapshot +comparisons, so their mutants are the expensive ones — but expense is a reason to +put them in the weekly sweep, not a reason to leave them unmeasured: the sweep is +precisely the run allowed to take as long as it takes. On the gate, the diff scope +already bounds what they cost, since a pull request that does not touch the +generator pays nothing for it. What is excluded is excluded for a reason other +than cost: the `Usage` samples and the binder benchmarks are not shipped +behaviour, and the documentation worker is a process entry point that no test +exercises in process — mutating it would only manufacture survivors no test could +ever kill. + +Enforcing it through **two gates rather than one** costs nothing today and buys +the move. The JustDummies packages are already isolated from the rest by design, +and they are leaving; a single shared matrix would have to be edited, and its +required-check entry renegotiated, at exactly the moment when the least +interesting part of a repository split should be its CI. Two gates make that step +a file move. They also let the two bars move independently — which they must, +since the libraries sit at visibly different levels of test maturity, and one bar +would have to be set at the weaker of the two. + +Pinning the mutation engine matters for the same reason the analyzer's Roslyn +floor is pinned ([ADR-0001](0001-lock-the-analyzer-roslyn-floor.md)): a newer +engine invents new mutants, and the score would move without a line of code +changing. A threshold is only meaningful against a fixed generator. + +The **preview** status of the runner the gate depends on is the decision's main +weakness, and it is accepted knowingly: the alternative is no mutation signal at +all, because the supported runner does not merely under-report on this test bed — +it reports zero. The mitigation is that the failure mode is loud rather than +silent: a runner regression that stopped activating mutants would take every +score to zero and fail the gate, not quietly pass it. + +Finally, the threshold is set **below 100 %** because equivalent mutants make +100 % unreachable, and it gates a *score*, not the absence of survivors: the +report, not the exit code, is what a maintainer reads to decide whether a +survivor is a missing assertion or an equivalent mutant. + +Each library gets its **own** threshold, derived from its own measured score +rather than from a target chosen in the abstract. A single number across five +libraries would have to be either low enough for the weakest — and therefore +toothless for the ones already at the top — or high enough for the strongest, and +therefore red on day one for the rest. Deriving it per library makes the gate a +ratchet: it forbids regression from where each library already stands, clears on +introduction, and only ever moves up. The cost of that choice is that the bar is +low where the test bed is weak, which is exactly where the gate would be most +useful; raising those thresholds as the weekly sweep shows headroom is the way +the ratchet is meant to be used, and it is deliberate that doing so is a +maintainer's decision rather than an automatic one. + +## Alternatives Considered + +### Report the mutation score without failing the build + +Considered because it carries none of the risk: no pull request is ever blocked +by a preview-status tool, and the number is still published. + +Rejected because it changes nothing. The repository's existing quality +invariants are all enforced rather than suggested, precisely because a signal +that costs nothing to ignore is ignored. The decision worth recording here is +that a pull request must answer for the assertions it did not write; an advisory +report does not make that demand. + +### Mutate every library in full on every pull request + +Considered because it is the honest measurement: a score over the whole library +is comparable between runs, and it cannot be gamed by touching a file's edges. + +Rejected on two counts. The first is cost: the per-mutant cost is a full run of +the project's suite, so a complete sweep of everything in scope runs into hours — +paid on every push, mostly to re-measure code nobody changed. The second is +decisive on its own: **a whole-project score is far too insensitive to gate on**. +The largest library carries a few thousand mutants, so one newly added, +unasserted behaviour moves its score by a fraction of a percent — well below any +threshold that is not itself noise. The diff-scoped +score is sensitive precisely because its denominator is small: a handful of new +mutants, one of which survives, is a visible drop. The weekly sweep recovers the +whole-library number where it belongs — as a trend, not as a gate. + +### Raise the SonarQube Cloud coverage requirement instead + +Considered because the quality gate already exists, is already mandatory, and +already reports on new code only — the mechanism this decision needs. + +Rejected because it measures the wrong thing. Coverage cannot fall below 100 % +for a line that a test executes and asserts nothing about; raising the required +percentage buys more executed lines, not more pinned behaviour. The two signals +are complements, and this one has no substitute. + +### Keep Stryker's default VSTest runner + +Considered because it is the supported, non-preview configuration, and preferring +a preview component in a required check is not a decision to take lightly. + +Rejected because it does not work on this test bed at all. Verified by +measurement: it reports every mutant as survived, including mutants that +demonstrably break the suite when applied by hand. Choosing it would mean either +a permanently red gate or a threshold low enough to be meaningless. + +### Restrict the scope to the shipped libraries, leaving the tooling out + +Considered, and initially chosen, on cost: analyzer and generator tests compile +code and spawn processes, so their mutants are an order of magnitude more +expensive than a library's, and their externally visible behaviour is already +pinned by the `analyzers`, `gendoc-docs` and `ci` floor jobs. + +Rejected because the cost it avoids is cost the weekly sweep exists to absorb, +and because the exclusion left a hole rather than a boundary: a pull request +touching only the analyzers would have crossed no mutation gate at all. Measured +before reversing the decision, the excluded projects carry about as many mutants +again as the FirstClassErrors libraries already in scope, and Stryker runs on all +of them — snapshot suites and process-spawning tests included. + +## Consequences + +### Positive + +* A pull request that adds behaviour without adding the assertion that pins it is + refused automatically, on the code it changed, before review. +* The weakest-tested files improve on contact: touching one puts its whole + mutation score on the gate. +* The weekly sweep gives the untouched parts of the libraries a trend line that + nothing else in the pipeline produces. +* The diagnosis is concrete. A failing gate names the surviving mutant, its file + and its line — it says which assertion is missing, not merely that a number is + too low. + +### Negative + +* A pull request touching a large, weakly covered file pays for that file's + pre-existing gaps, not only for its own change. +* Two more required checks on the critical path of every merge, and one more + moving part to keep working — the tool pin, the runner mode and the thresholds + all have to be maintained deliberately. +* The two workflows are near-identical files. Until the split happens, a fix to + one is a fix to both, and nothing enforces that. +* Equivalent mutants make part of the remaining distance to 100 % unreachable, so + the threshold is a judgement call rather than a derived value. +* The weekly sweep is long — hours, dominated by the largest library and by the + tooling's slow suites. That is accepted deliberately; it is the reason the sweep + is weekly and advisory rather than a gate. + +### Risks + +* **The Microsoft Testing Platform runner is preview.** A regression in it could + change scores between engine versions. Mitigated by pinning the engine in the + tool manifest, so an upgrade is a deliberate act, and by the failure mode being + loud: a runner that stops activating mutants takes every score to zero. +* **Its coverage-based selection is disabled**, so the cost is one full suite run + per mutant. That is affordable today because these suites are fast; a + substantially slower suite would make the sweep, and eventually the gate, + expensive. +* **The thresholds are calibrated against today's scores.** A library added later + with a weaker test bed would fail the gate on its first contact rather than on + its introduction. +* **`JustDummies` ships without a score threshold.** Its sweep is too long to + calibrate against interactively, so that library is gated on everything except + a score until the first weekly sweep supplies one. +* **Test-only pull requests select mutants too**, through the test files they + change, so a pull request that only adds tests can still be gated. + +## Follow-up Actions + +* Mark both aggregated checks — `Mutation gate` and `JustDummies mutation gate` — + as required on `main` in the branch protection; a workflow cannot make itself + mandatory. +* When JustDummies moves to its own repository, take its workflow, its two + configurations and the tool manifest across unchanged and repoint the + `solution` field; the workflow reference page carries the checklist. +* Revisit the runner choice when Stryker's Microsoft Testing Platform support + leaves preview, and re-enable coverage-based selection when it classifies + covered mutants correctly. +* Re-read the thresholds after each engine upgrade, and after any library is + added to the scope. +* Set a threshold for the projects that have none yet — `JustDummies`, the + analyzers, the documentation generator and the command line — from the first + weekly sweep. Their sweeps are too long to run interactively, so no score was + measured for them and their score gates ship disabled rather than guessed. + +## References + +* [`mutation` workflow reference](../workflows/mutation.en.md) — how the decision + is implemented, and the knobs it exposes. +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.md) — the precedent for pinning + a tool version that would otherwise move a measured result on its own. +* [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) + — the test-bed split whose two suites both feed this gate. +* [stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117) + — the upstream report of Stryker's VSTest runner mishandling xUnit v3. +* [stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629) + — the upstream limitation of coverage analysis under the Microsoft Testing + Platform runner. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index ec581398..125aa306 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -225,3 +225,4 @@ Optional supporting material: | [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) | Split the JustDummies test bed between an example suite and a property suite | Accepted | | [ADR-0041](0041-draw-flag-enum-combinations-behind-an-opt-in.md) | Draw flag-enum combinations behind an opt-in | Accepted | | [ADR-0042](0042-serialize-draws-on-a-random-source.md) | Serialize draws on a random source, and scope reproducibility to the draw sequence | Proposed | +| [ADR-0043](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) | Gate pull requests on the mutation score of what they changed | Proposed | diff --git a/doc/handwritten/for-maintainers/workflows/README.fr.md b/doc/handwritten/for-maintainers/workflows/README.fr.md index 9812fd73..17f5c495 100644 --- a/doc/handwritten/for-maintainers/workflows/README.fr.md +++ b/doc/handwritten/for-maintainers/workflows/README.fr.md @@ -68,6 +68,8 @@ documentées une seule fois ici plutôt que répétées sur chaque page. | --- | --- | | [`ci`](ci.fr.md) | Construit et teste toute la solution sous Linux et Windows, avec couverture. Le barrage principal. | | [`sonar`](sonar.fr.md) | Analyse SonarQube Cloud — quality gate et remontée de couverture. | +| [`mutation`](mutation.fr.md) | Tests de mutation des bibliothèques et de l'outillage FirstClassErrors avec Stryker.NET — check obligatoire sur ce qu'une PR modifie, plus un balayage complet hebdomadaire. | +| [`justdummies-mutation`](justdummies-mutation.fr.md) | Idem pour les packages JustDummies, avec son propre check obligatoire — séparé pour que la future séparation de dépôt soit un déplacement de fichier. | | [`analyzers`](analyzers.fr.md) | Dogfood des analyzers Roslyn embarqués, y compris sur le plus vieux compilateur supporté (le floor Roslyn). | | [`commit-lint`](commit-lint.fr.md) | Impose la convention Conventional Commits sur chaque commit de PR, via le même script que le hook local. | | [`adr-check`](adr-check.fr.md) | Consultatif, dispatch manuel : confronte une branche à la base d'ADR (nouvelle décision / remplacement / conflit). Le repli pour les contributeurs sans Claude Code ; ne bloque jamais. | diff --git a/doc/handwritten/for-maintainers/workflows/README.md b/doc/handwritten/for-maintainers/workflows/README.md index c35927db..ae25cee9 100644 --- a/doc/handwritten/for-maintainers/workflows/README.md +++ b/doc/handwritten/for-maintainers/workflows/README.md @@ -64,6 +64,8 @@ here instead of being repeated on every page. | --- | --- | | [`ci`](ci.en.md) | Build and test the whole solution on Linux and Windows, with coverage. The primary gate. | | [`sonar`](sonar.en.md) | SonarQube Cloud analysis — quality gate and coverage reporting. | +| [`mutation`](mutation.en.md) | Mutation testing of the FirstClassErrors libraries and tooling with Stryker.NET — a required check on what a PR changed, plus a weekly full sweep. | +| [`justdummies-mutation`](justdummies-mutation.en.md) | The same, for the JustDummies packages, with its own required check — kept separate so the future repository split is a file move. | | [`analyzers`](analyzers.en.md) | Dogfood the bundled Roslyn analyzers, including on the oldest supported compiler (the Roslyn floor). | | [`commit-lint`](commit-lint.en.md) | Enforce the Conventional Commits convention on every PR commit, using the same script as the local hook. | | [`adr-check`](adr-check.en.md) | Advisory, manual dispatch: check a branch against the ADR base (new decision / supersede / conflict). The fallback for contributors without Claude Code; never blocks. | diff --git a/doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md new file mode 100644 index 00000000..85977c66 --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md @@ -0,0 +1,140 @@ +# `justdummies-mutation` workflow + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](justdummies-mutation.fr.md) + +> Maintainer documentation — part of the [workflow reference](README.md). +> Not part of the user documentation under `doc/`. + +**Workflow file:** [`.github/workflows/justdummies-mutation.yml`](../../../../.github/workflows/justdummies-mutation.yml) + +## What it is for + +Mutation testing for the **two JustDummies packages** — `JustDummies` and its +xUnit v3 adapter `JustDummies.Xunit` ([ADR-0039](../adr/0039-adapt-dummies-to-xunit-v3-through-a-companion-package.md)). +On a pull request it mutates only the files the pull request changed and fails +when the score falls under the library's threshold; a weekly sweep measures +everything else. What mutation testing *is*, and why this repository gates on +it, is explained once on the [`mutation`](mutation.en.md) page — this workflow is +the same machine with a different matrix. + +## Why it is a separate workflow + +`JustDummies` is a standalone, error-agnostic package that deliberately holds no +reference to `FirstClassErrors` ([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.md)), +and it is headed for a repository of its own. Splitting the mutation gate along +that future boundary now means the move is a **file move rather than an edit**: +nothing in this workflow names a FirstClassErrors project, and nothing in +[`mutation`](mutation.en.md) names a JustDummies one. + +It also gives JustDummies its **own required check**, +**`JustDummies mutation gate`**, independent of the FirstClassErrors one. Two +gates, two branch-protection entries, two bars that move independently — which is +what two libraries at different levels of test maturity need anyway. + +## When it runs + +- On every **pull request targeting `main`** — diff-scoped. **This is the gate.** +- **Weekly** on a schedule (Monday, 03:47 UTC) — the full sweep, advisory. The + slot is offset from `mutation`'s so the two sweeps do not contend for runners. +- On demand via **`workflow_dispatch`** — the full sweep. + +## How it runs + +Identically to [`mutation`](mutation.en.md), whose page documents the mechanism +in full: `changed` mutates the diff from the fork point, `gate` collapses the +matrix into one stable check name, `full` sweeps everything with the threshold +disabled. The per-library Stryker configurations are +[`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) +and [`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json). + +Two points from that page matter more here than anywhere else: + +- **`JustDummies` is the largest library in the repository** — a few thousand + mutants — so its full sweep is the longest job the repository runs. That is the + whole reason the gate is diff-scoped rather than a full sweep per pull request. +- **`"test-runner": "mtp"` and `"coverage-analysis": "off"` are not tuning knobs.** + With Stryker's default VSTest runner these suites score 0 % — every mutant + reported as survived, because the runner cannot activate a mutant in an xUnit v3 + test project. Read + [that section](mutation.en.md#two-settings-that-are-not-tuning-knobs) before + changing either. + +## `JustDummies` has no score threshold yet + +Every other library's bar was set from a measured full sweep of that library +([how and why](mutation.en.md#where-the-thresholds-come-from)). `JustDummies` was +not: it carries a few thousand mutants over a heavy suite, its full sweep runs +well past an hour, and **no score for it has been measured**. Rather than invent +a number, [`justdummies.json`](../../../../build/stryker/justdummies.json) sets +`break` to **0** — the score gate for this one library is off. + +That is deliberate and it is temporary. The leg still runs, still fails on a +broken build or a failing suite, and still lists its surviving mutants in the run +summary; what it does not yet do is refuse a pull request over a score. **The +first weekly sweep publishes the library-wide figure** — that is the run this +threshold is waiting on. Read it, and set `break` from it exactly as the other +libraries' bars were set. + +`JustDummies.Xunit` needs no such caveat: it is small enough that its bar came +from a full sweep like the rest, and it gates normally. + +## Permissions & security + +`contents: read` only. The workflow checks out, builds and runs tests; it stores +no secret and needs no write scope. + +## When JustDummies moves to its own repository + +Take, unchanged: + +- this workflow file, renamed to `mutation.yml` there (and its `name:` with it); +- [`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) + and [`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json); +- [`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) — the + Stryker pin; +- this page, plus the shared sections of [`mutation`](mutation.en.md) folded into + it, since the page it defers to will not exist over there. + +Then change exactly one thing: the **`solution`** field in the two +configurations, which still names `FirstClassErrors.sln`. The `project` and +`test-projects` paths are already repository-relative and unchanged by the move. + +On this side, delete this workflow, its configurations and this page, and drop +the `JustDummies mutation gate` entry from the branch protection. + +## Handle with care + +- **Keep this workflow and [`mutation`](mutation.en.md) in step.** They are + duplicated on purpose — that is what makes the split a file move — so a fix to + one is a fix to both until the split happens. +- Everything under + [*Handle with care* on the `mutation` page](mutation.en.md#handle-with-care) + applies here word for word: `fetch-depth: 0`, `--since` rejecting `HEAD`, + `if: always()` on `gate`, the pinned engine, where the thresholds live. + +## Running it locally + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/justdummies.json +``` + +That is the full sweep of the largest library and it takes a while. To reproduce +what the gate does on a branch: + +```bash +dotnet stryker --config-file build/stryker/justdummies.json --since:$(git merge-base origin/main HEAD) +``` + +Reports land in `StrykerOutput/` (git-ignored); open `reports/mutation-report.html`. + +## Related + +- [`mutation`](mutation.en.md) — the same machine for the FirstClassErrors + libraries, and where the mechanism is documented in full. +- [`justdummies`](../../../../.github/workflows/justdummies.yml) *(no reference + page yet)* — the other JustDummies-scoped workflow: it proves the packaged + `netstandard2.0` and `net8.0` assets behave on their own runtimes. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) + — the decision both workflows implement. diff --git a/doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md new file mode 100644 index 00000000..ec73d09a --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md @@ -0,0 +1,154 @@ +# Workflow `justdummies-mutation` + +🌍 🇬🇧 [English](justdummies-mutation.en.md) · 🇫🇷 Français (ce fichier) + +> Documentation mainteneur — fait partie de la [référence des workflows](README.fr.md). +> Ne fait pas partie de la documentation utilisateur sous `doc/`. + +**Fichier du workflow :** [`.github/workflows/justdummies-mutation.yml`](../../../../.github/workflows/justdummies-mutation.yml) + +## À quoi il sert + +Les tests de mutation des **deux packages JustDummies** : `JustDummies` et son +adaptateur xUnit v3 `JustDummies.Xunit` +([ADR-0039](../adr/0039-adapt-dummies-to-xunit-v3-through-a-companion-package.fr.md)). +Sur une pull request, il ne mute que les fichiers modifiés par celle-ci et échoue +si le score passe sous le seuil de la bibliothèque ; un balayage hebdomadaire +mesure tout le reste. Ce que *sont* les tests de mutation, et pourquoi ce dépôt +en fait un barrage, est expliqué une seule fois sur la page +[`mutation`](mutation.fr.md) — ce workflow est la même machine avec une matrice +différente. + +## Pourquoi un workflow séparé + +`JustDummies` est un package autonome et agnostique des erreurs, qui ne référence +volontairement pas `FirstClassErrors` +([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.fr.md)), et il est +destiné à un dépôt à lui. Découper le barrage de mutation le long de cette +frontière future dès maintenant fait de la migration **un déplacement de fichier +plutôt qu'une réécriture** : rien dans ce workflow ne nomme un projet +FirstClassErrors, et rien dans [`mutation`](mutation.fr.md) ne nomme un projet +JustDummies. + +Cela donne aussi à JustDummies **son propre check obligatoire**, +**`JustDummies mutation gate`**, indépendant de celui de FirstClassErrors. Deux +barrages, deux entrées de protection de branche, deux barres qui évoluent +séparément — ce dont deux bibliothèques de maturité de test différente ont de +toute façon besoin. + +## Quand il s'exécute + +- Sur chaque **pull request ciblant `main`** — cantonné au diff. **C'est le + barrage.** +- **Chaque semaine** sur planification (lundi, 03h47 UTC) — le balayage complet, + consultatif. Le créneau est décalé de celui de `mutation` pour que les deux + balayages ne se disputent pas les runners. +- À la demande via **`workflow_dispatch`** — le balayage complet. + +## Comment il s'exécute + +À l'identique de [`mutation`](mutation.fr.md), dont la page documente le +mécanisme en entier : `changed` mute le diff depuis le point de fourche, `gate` +regroupe la matrice sous un nom de check stable, `full` balaie tout avec le seuil +désactivé. Les configurations Stryker sont +[`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) et +[`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json). + +Deux points de cette page comptent ici plus qu'ailleurs : + +- **`JustDummies` est la plus grosse bibliothèque du dépôt** — quelques milliers + de mutants — et son balayage complet est donc le job le plus long que le dépôt + exécute. C'est toute la raison pour laquelle le barrage est cantonné au diff + plutôt qu'un balayage complet par pull request. +- **`"test-runner": "mtp"` et `"coverage-analysis": "off"` ne sont pas des + réglages de confort.** Avec le runner VSTest par défaut de Stryker, ces suites + scorent 0 % — tous les mutants rapportés survivants, parce que le runner ne sait + pas activer un mutant dans un projet de tests xUnit v3. Lisez + [cette section](mutation.fr.md#deux-réglages-qui-nen-sont-pas) avant de toucher + à l'un ou à l'autre. + +## `JustDummies` n'a pas encore de seuil de score + +La barre de chaque autre bibliothèque a été fixée à partir d'un balayage complet +mesuré sur cette bibliothèque +([comment et pourquoi](mutation.fr.md#doù-viennent-les-seuils)). Pas celle de +`JustDummies` : elle porte quelques milliers de mutants sur une suite lourde, son +balayage complet dépasse largement l'heure, et **aucun score n'a été mesuré pour +elle**. Plutôt que d'inventer un chiffre, +[`justdummies.json`](../../../../build/stryker/justdummies.json) met `break` à +**0** — le barrage sur le score est coupé pour cette seule bibliothèque. + +C'est délibéré et c'est temporaire. La branche s'exécute toujours, échoue toujours +sur un build cassé ou une suite en échec, et liste toujours ses mutants +survivants dans le résumé du run ; ce qu'elle ne fait pas encore, c'est refuser +une pull request sur un score. **Le premier balayage hebdomadaire publie le +chiffre sur toute la bibliothèque** — c'est ce run que ce seuil attend. +Lisez-le, et fixez `break` à partir de là, exactement comme les barres des autres +bibliothèques l'ont été. + +`JustDummies.Xunit` n'appelle pas cette réserve : elle est assez petite pour que +sa barre vienne d'un balayage complet comme les autres, et elle barre normalement. + +## Permissions & sécurité + +`contents: read` seulement. Le workflow fait un checkout, un build et lance des +tests ; il ne stocke aucun secret et n'a besoin d'aucun périmètre en écriture. + +## Quand JustDummies partira dans son propre dépôt + +À emporter tel quel : + +- ce fichier de workflow, renommé `mutation.yml` là-bas (et son `name:` avec) ; +- [`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) + et [`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json) ; +- [`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) — + l'épinglage de Stryker ; +- cette page, augmentée des sections partagées de [`mutation`](mutation.fr.md) + repliées dedans, puisque la page à laquelle elle renvoie n'existera pas là-bas. + +Puis changer exactement une chose : le champ **`solution`** des deux +configurations, qui nomme encore `FirstClassErrors.sln`. Les chemins `project` et +`test-projects` sont déjà relatifs au dépôt et inchangés par la migration. + +De ce côté-ci, supprimer ce workflow, ses configurations et cette page, et +retirer l'entrée `JustDummies mutation gate` de la protection de branche. + +## À manipuler avec précaution + +- **Gardez ce workflow et [`mutation`](mutation.fr.md) synchronisés.** Ils sont + dupliqués à dessein — c'est ce qui fait de la migration un déplacement de + fichier —, donc un correctif sur l'un est un correctif sur l'autre tant que la + séparation n'a pas eu lieu. +- Tout ce qui figure sous + [*À manipuler avec précaution* sur la page `mutation`](mutation.fr.md#à-manipuler-avec-précaution) + vaut ici mot pour mot : `fetch-depth: 0`, `--since` qui refuse `HEAD`, + `if: always()` sur `gate`, le moteur épinglé, l'endroit où vivent les seuils. + +## L'exécuter en local + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/justdummies.json +``` + +C'est le balayage complet de la plus grosse bibliothèque, et cela prend un +moment. Pour reproduire ce que fait le barrage sur une branche : + +```bash +dotnet stryker --config-file build/stryker/justdummies.json --since:$(git merge-base origin/main HEAD) +``` + +Les rapports atterrissent dans `StrykerOutput/` (ignoré par git) ; ouvrez +`reports/mutation-report.html`. + +## Voir aussi + +- [`mutation`](mutation.fr.md) — la même machine pour les bibliothèques + FirstClassErrors, et l'endroit où le mécanisme est documenté en entier. +- [`justdummies`](../../../../.github/workflows/justdummies.yml) *(pas encore de + page de référence)* — l'autre workflow cantonné à JustDummies : il prouve que + les assets `netstandard2.0` et `net8.0` publiés se comportent bien sur leurs + runtimes respectifs. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md) + — la décision que les deux workflows mettent en œuvre. diff --git a/doc/handwritten/for-maintainers/workflows/mutation.en.md b/doc/handwritten/for-maintainers/workflows/mutation.en.md new file mode 100644 index 00000000..7dbc63b7 --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/mutation.en.md @@ -0,0 +1,276 @@ +# `mutation` workflow + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](mutation.fr.md) + +> Maintainer documentation — part of the [workflow reference](README.md). +> Not part of the user documentation under `doc/`. + +**Workflow file:** [`.github/workflows/mutation.yml`](../../../../.github/workflows/mutation.yml) + +## What it is for + +Coverage answers *"was this line executed by a test?"*. Mutation testing answers +the question that actually matters: *"would any test have noticed if this line +had been wrong?"*. + +[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/introduction/) rewrites +the library one small change at a time — flip a comparison, drop a statement, +return the other constant, remove a block — rebuilds it, and re-runs the test +suite against each rewrite. A **mutant** the suite still passes on is a +**survivor**: a behaviour the code has and nothing asserts. A killed mutant is a +test doing its job. + +This workflow makes that check automatic. On a pull request it mutates **only the +files the pull request changed**, which is what keeps it cheap enough to be a +required check; a weekly sweep measures everything else. + +Its scope is **every FirstClassErrors project whose code is shipped or executed**: +the three libraries (`FirstClassErrors`, `FirstClassErrors.Testing`, +`FirstClassErrors.RequestBinder`) *and* the tooling — the `fce` command line, the +documentation generator, the Roslyn analyzers. What stays out, and why, is under +*Handle with care* below. + +**JustDummies is not measured here.** It has its own workflow with its own gate, +[`justdummies-mutation`](justdummies-mutation.en.md), because it is headed for a +repository of its own ([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.md)). +The two are the same machine with different matrices; everything in this page +except the scope applies to both, and the JustDummies page links back here rather +than repeating it. + +## When it runs + +- On every **pull request targeting `main`** — diff-scoped. **This is the gate.** +- **Weekly** on a schedule (Monday, 03:23 UTC) — the full sweep, advisory. +- On demand via **`workflow_dispatch`** — the full sweep. + +## How it runs + +Each mutated library has its own Stryker configuration under +[`build/stryker/`](../../../../build/stryker/): the project to mutate, the test +projects that must kill its mutants, and the thresholds. Nothing about the run +policy lives only in the YAML, so `dotnet stryker --config-file +build/stryker/core.json` on a maintainer's machine gates exactly like CI does. + +The engine itself is pinned in +[`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) and restored +with `dotnet tool restore`. That pin is load-bearing: a newer Stryker invents new +mutants, which moves every score on its own. + +### `changed` — the diff, on every pull request + +One matrix leg per project in scope. Each leg: + +1. Checks out with **`fetch-depth: 0`** — Stryker's `--since` diffs against a + commit, so the history has to be there. +2. Resolves the **fork point** (`git merge-base` of the pull request's base and + `HEAD`), not the base branch tip: the tip may have moved on since the branch + was cut, and every file changed on `main` in the meantime would otherwise be + counted as "changed by this pull request". +3. Runs Stryker with `--since:`, so only mutants **in files this pull + request touched** are tested. +4. Renders the surviving mutants — status, file, line, kind of rewrite — into the + run summary, so a failing gate can be diagnosed without leaving the run page. +5. Uploads the HTML and JSON reports as an artifact — `if: always()`, because the + HTML view shows each survivor *in its source*, which the summary table cannot. + +A leg whose project the pull request did not touch selects no mutant, reports +*"unable to calculate a mutation score"*, and exits 0. That is a pass — and it is +the common case, since most pull requests touch one project. + +### `gate` — the single required check + +A matrix produces one check per leg, so marking the gate as required on `main` +would mean re-declaring the leg names in the branch protection every time the +matrix changes. `gate` collapses them into one stable check name — **`Mutation +gate`** — and that is the one to make required. + +It runs with `if: always()`, which is load-bearing: without it, a failed leg +would leave `gate` *skipped*, and GitHub reports a skipped required check as a +success. + +### `full` — the weekly sweep + +The same six legs with the `--since` filter removed: every mutant of every +project in scope. It is **advisory by construction** — `--break-at 0` disables the +threshold — because its job is to publish a trend, not to turn `main` red on a +Monday morning over code nobody changed. Read it from the uploaded HTML report. + +## Two settings that are not tuning knobs + +`build/stryker/*.json` carries two settings that look like performance tuning and +are not. Both were established by measurement; changing either silently breaks +the gate rather than making it slower. + +### `"test-runner": "mtp"` — mandatory, not a preference + +Stryker's **default VSTest runner does not work on this test bed at all.** Every +test project here is xUnit v3, and an xUnit v3 test project *is* an executable +that the VSTest adapter launches as a child process — out of reach of the +in-process hooks Stryker uses both to capture coverage and, crucially, to +**activate** the mutant. The run completes, reports a plausible test count, and +scores **0 %**: every mutant comes back "survived", including mutants that +demonstrably break the suite when the same edit is applied by hand. Upstream: +[stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117). + +The Microsoft Testing Platform runner launches the test executable itself, so the +mutant is activated and the score is real. Stryker flags it **preview** and says +so on every run; that warning is expected here, not a misconfiguration. + +If a future Stryker upgrade makes every score collapse to zero, this is the first +thing to check. + +### `"coverage-analysis": "off"` — accuracy, not speed + +Stryker normally runs a coverage pass first so each mutant only re-runs the tests +that reach it. Under the MTP runner that selection is still incomplete +([stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629)): +mutants the suite *does* kill get classified as uncovered and counted against the +score. Measured on `Error.cs`, the same population scores 75 % with selection on +and 100 % with it off — and the 100 % is the true figure. + +Turning it off costs little on the libraries, whose suites are fast — a fraction +of a second to a couple of seconds per mutant. It costs more on the tooling, whose +suites compile Roslyn and compare snapshots. That is a reason to keep those legs +out of the critical path, not a reason to re-enable a selection that reports the +wrong number. + +## The cost model, and why the gate is diff-scoped + +**One full run of the library's test suite per mutant**, plus roughly two minutes +of fixed cost per leg (solution analysis, build, initial test run, mutant +generation). A full sweep of one library is therefore minutes; a full sweep of +every library, on every push, is not something to sit in front of. + +That is what makes the diff scope right for a required check. It also explains +two things that surprise people: + +- **Selection is per changed *file*, not per changed *line*.** Stryker's `--since` + has no line granularity. Adding one line to a large file selects **every** + mutant in that file, so the gate reports the whole file's mutation score — not + just the score of what was added. On the biggest files that is a longer job and + a score that reflects pre-existing debt. +- **A pull request that only adds tests still selects mutants**, through the test + files it changed. + +## Where the thresholds come from + +Each project carries its own `break` in `build/stryker/*.json`, and the values +differ from one to the next on purpose. They are **not** an opinion about how good +a project ought to be: each one was set from that project's measured full-sweep +score at the time the gate was introduced, rounded down, with a little room left +for the odd equivalent mutant. + +**Four projects have no bar yet** — the analyzers, the documentation generator, +the command line and `JustDummies`. Their sweeps are too long to have been run +interactively, so no score was ever measured for them, and a bar was **not** +guessed: their `break` is `0`. Their legs still run, still fail on a broken build +or a failing suite, and still list their survivors — they simply do not yet refuse +a pull request over a score. The first weekly sweep is what supplies those four +figures. + +That makes the gate a **ratchet**, not an aspiration. It says *do not go below +where this library already is* — a bar every library clears on day one, so the +gate never starts red, and one that only ever moves up. Raising a value after the +weekly sweep shows real headroom is the intended way to use it; lowering one +should feel like a decision. + +The consequence to keep in mind: a library sitting well below 100 % has a low bar +today, and a pull request touching one of its weaker files can still fall under +it. That is the gate working, not misfiring — the report says which assertion is +missing. + +One library escapes this rule for now: `JustDummies`, whose sweep is too long to +have been calibrated against, ships with its score gate off. See +[`justdummies-mutation`](justdummies-mutation.en.md#justdummies-has-no-score-threshold-yet). + +## When the survivor is an equivalent mutant + +Sometimes the honest answer is that the mutant cannot be killed: the rewrite does +not change observable behaviour, so no test could tell the difference. Writing a +test to chase it would be writing a test that asserts an implementation detail — +worse than the gap. + +Stryker takes that answer in the source, next to the code, as a comment: + +```csharp +// Stryker disable once Statement : the trace call has no observable effect +``` + +The form is `// Stryker disable [once] [: reason]`, with +`// Stryker restore all` to end a non-`once` block. Prefer `once`, prefer naming +the mutator rather than `all`, and always give the reason — an undocumented +exclusion is indistinguishable from a missing test six months later. Reach for it +only after deciding the mutant really is equivalent; lowering a threshold to +silence one survivor hides every future survivor with it. + +## Permissions & security + +`contents: read` only. The workflow checks out, builds and runs tests; it stores +no secret and needs no write scope. + +## Handle with care + +- **`fetch-depth: 0` is required**, not a habit. A shallow clone leaves the fork + point unreachable and `--since` cannot resolve it. +- **`--since` wants a branch, a tag or a real commit SHA — `HEAD` is rejected.** + `--since:HEAD` fails the whole run with *"No branch or tag or commit found with + given target"*, which is why the workflow resolves `git merge-base` to a SHA + first rather than passing a rev expression through. +- **The CI warning ratchet does not need disabling here.** It is a fair worry — + Stryker compiles *mutated* source, and a mutant routinely raises a warning the + original never had — but measured, `GITHUB_ACTIONS=true` changes nothing: + Stryker compiles the mutants through Roslyn with its own options and does not + inherit `TreatWarningsAsErrors` from + [`Directory.Build.props`](../../../../Directory.Build.props). The + compile-error count is identical with the ratchet on and off. If a future + Stryker started honouring it, mutants would silently turn into compile errors + instead of being tested — the count in the run log is where that would show. +- **`if: always()` on `gate` is required.** Remove it and a red matrix leg turns + the required check green. +- **The Stryker version is pinned in the tool manifest.** Bumping it is a + deliberate act: expect the scores to move, and re-read the thresholds. +- **The thresholds live in `build/stryker/*.json`, not in the YAML.** That is what + keeps a local run and CI in agreement. `break` is the value that fails the + build; `high`/`low` only colour the report. +- **Three things are out of scope, and none of them for cost.** The `Usage` + samples and the binder benchmarks are not shipped behaviour — a sample and a + measurement harness. `FirstClassErrors.GenDoc.Worker` is a process entry point + that no test exercises *in process*: it is proven end to end by the `floor` job + of [`ci`](ci.en.md), and mutating it would only manufacture survivors that no + test could ever kill. Everything else that compiles into a shipped artefact is + measured. +- **The tooling legs are the slow ones.** Analyzer and generator suites drive + Roslyn compilations and snapshot comparisons, so each of their mutants costs + more than a library's. That is why they belong to the weekly sweep — the run + allowed to take as long as it takes — and why the gate's diff scope matters + more for them than anywhere else: a pull request that does not touch the + generator pays nothing for it. +- **A survivor is not automatically a bug**, and the answer to an equivalent one + is a `// Stryker disable once` comment with a reason, never a lowered threshold + — see *When the survivor is an equivalent mutant* above. + +## Running it locally + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/core.json +``` + +That is the full sweep of one library and it takes a while. To reproduce what the +gate does on a branch: + +```bash +dotnet stryker --config-file build/stryker/core.json --since:$(git merge-base origin/main HEAD) +``` + +Reports land in `StrykerOutput/` (git-ignored); open `reports/mutation-report.html`. + +## Related + +- [`ci`](ci.en.md) — the primary gate, and where the warning ratchet is enforced. +- [`sonar`](sonar.en.md) — line and branch coverage. Mutation testing is the + complement, not the replacement: Sonar tells you what was *executed*, this + workflow tells you what was *asserted*. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) + — the decision this workflow implements. diff --git a/doc/handwritten/for-maintainers/workflows/mutation.fr.md b/doc/handwritten/for-maintainers/workflows/mutation.fr.md new file mode 100644 index 00000000..f6a2c430 --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/mutation.fr.md @@ -0,0 +1,301 @@ +# Workflow `mutation` + +🌍 🇬🇧 [English](mutation.en.md) · 🇫🇷 Français (ce fichier) + +> Documentation mainteneur — fait partie de la [référence des workflows](README.fr.md). +> Ne fait pas partie de la documentation utilisateur sous `doc/`. + +**Fichier du workflow :** [`.github/workflows/mutation.yml`](../../../../.github/workflows/mutation.yml) + +## À quoi il sert + +La couverture répond à *« cette ligne a-t-elle été exécutée par un test ? »*. Les +tests de mutation répondent à la question qui compte vraiment : *« un test +aurait-il remarqué quoi que ce soit si cette ligne avait été fausse ? »*. + +[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/introduction/) réécrit +la bibliothèque un petit changement à la fois — inverser une comparaison, +supprimer une instruction, retourner l'autre constante, retirer un bloc —, la +recompile, et rejoue la suite de tests contre chaque réécriture. Un **mutant** que +la suite laisse passer est un **survivant** : un comportement que le code a et que +rien n'affirme. Un mutant tué, c'est un test qui fait son travail. + +Ce workflow rend ce contrôle automatique. Sur une pull request, il ne mute **que +les fichiers modifiés par la pull request** — c'est ce qui le rend assez peu +coûteux pour être un check obligatoire ; un balayage hebdomadaire mesure tout le +reste. + +Son périmètre, c'est **tout projet FirstClassErrors dont le code est livré ou +exécuté** : les trois bibliothèques (`FirstClassErrors`, +`FirstClassErrors.Testing`, `FirstClassErrors.RequestBinder`) *et* l'outillage — +la ligne de commande `fce`, le générateur de documentation, les analyseurs +Roslyn. Ce qui en reste dehors, et pourquoi, est sous *À manipuler avec +précaution* plus bas. + +**JustDummies n'est pas mesuré ici.** Il a son propre workflow et son propre +barrage, [`justdummies-mutation`](justdummies-mutation.fr.md), parce qu'il est +destiné à un dépôt à lui +([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.fr.md)). Les deux +sont la même machine avec une matrice différente ; tout ce qui suit, hormis le +périmètre, vaut pour les deux, et la page JustDummies renvoie ici plutôt que de +le répéter. + +## Quand il s'exécute + +- Sur chaque **pull request ciblant `main`** — cantonné au diff. **C'est le + barrage.** +- **Chaque semaine** sur planification (lundi, 03h23 UTC) — le balayage complet, + consultatif. +- À la demande via **`workflow_dispatch`** — le balayage complet. + +## Comment il s'exécute + +Chaque bibliothèque mutée a sa propre configuration Stryker sous +[`build/stryker/`](../../../../build/stryker/) : le projet à muter, les projets de +tests censés tuer ses mutants, et les seuils. Rien de la politique d'exécution ne +vit uniquement dans le YAML : `dotnet stryker --config-file +build/stryker/core.json` sur la machine d'un mainteneur applique donc exactement +le même barrage que la CI. + +Le moteur lui-même est épinglé dans +[`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) et restauré +par `dotnet tool restore`. Cet épinglage est porteur : un Stryker plus récent +invente de nouveaux mutants, ce qui déplace tous les scores à lui seul. + +### `changed` — le diff, sur chaque pull request + +Une branche de matrice par projet du périmètre. Chaque branche : + +1. Fait un checkout avec **`fetch-depth: 0`** — le `--since` de Stryker compare à + un commit, l'historique doit donc être présent. +2. Résout le **point de fourche** (`git merge-base` entre la base de la pull + request et `HEAD`), et non la tête de la branche de base : celle-ci a pu + avancer depuis la création de la branche, et tout fichier modifié sur `main` + entre-temps serait alors compté comme « modifié par cette pull request ». +3. Lance Stryker avec `--since:` : seuls les mutants **des + fichiers touchés par la pull request** sont testés. +4. Rend les mutants survivants — statut, fichier, ligne, nature de la réécriture — + dans le résumé du run, pour qu'un barrage en échec se diagnostique sans quitter + la page. +5. Uploade les rapports HTML et JSON en artefact — `if: always()`, car la vue HTML + montre chaque survivant *dans sa source*, ce que le tableau du résumé ne peut + pas faire. + +Une branche dont le projet n'a pas été touché par la pull request ne sélectionne +aucun mutant, signale *« unable to calculate a mutation score »*, et sort en 0. +C'est un succès — et c'est le cas courant, la plupart des pull requests ne +touchant qu'un projet. + +### `gate` — l'unique check obligatoire + +Une matrice produit un check par branche : rendre le barrage obligatoire sur +`main` obligerait donc à redéclarer les noms des branches dans la protection de +branche à chaque évolution de la matrice. `gate` les regroupe sous un nom de check +stable — **`Mutation gate`** — et c'est celui-là qu'il faut rendre obligatoire. + +Il s'exécute avec `if: always()`, ce qui est porteur : sans cela, une branche en +échec laisserait `gate` *skipped*, et GitHub compte un check obligatoire skippé +comme un succès. + +### `full` — le balayage hebdomadaire + +Les mêmes six branches, filtre `--since` retiré : tous les mutants de tous les +projets du périmètre. Il est **consultatif par construction** — `--break-at 0` +désactive le seuil — car son rôle est de publier une tendance, pas de faire virer +`main` au rouge un lundi matin pour du code que personne n'a touché. Il se lit +dans le rapport HTML uploadé. + +## Deux réglages qui n'en sont pas + +`build/stryker/*.json` porte deux réglages qui ressemblent à de l'optimisation et +n'en sont pas. Les deux ont été établis par la mesure ; changer l'un ou l'autre +casse le barrage en silence plutôt que de le ralentir. + +### `"test-runner": "mtp"` — obligatoire, pas une préférence + +**Le runner VSTest par défaut de Stryker ne fonctionne pas du tout sur ce banc de +tests.** Tous les projets de tests sont ici en xUnit v3, et un projet de tests +xUnit v3 *est* un exécutable que l'adaptateur VSTest lance dans un processus fils +— hors de portée des crochets in-process dont Stryker se sert pour capturer la +couverture et, surtout, pour **activer** le mutant. Le run va au bout, annonce un +nombre de tests plausible, et score **0 %** : tous les mutants reviennent +« survivants », y compris des mutants qui cassent la suite de façon démontrable +quand on applique la même modification à la main. Ticket amont : +[stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117). + +Le runner Microsoft Testing Platform lance lui-même l'exécutable de tests : le +mutant est donc activé et le score est réel. Stryker le marque **preview** et le +signale à chaque run ; cet avertissement est attendu ici, ce n'est pas une erreur +de configuration. + +Si une future montée de version de Stryker fait s'effondrer tous les scores à +zéro, c'est la première chose à vérifier. + +### `"coverage-analysis": "off"` — pour l'exactitude, pas pour la vitesse + +Stryker fait normalement une passe de couverture au préalable, pour que chaque +mutant ne rejoue que les tests qui l'atteignent. Sous le runner MTP, cette +sélection est encore incomplète — voir +[stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629) — +et des mutants que la suite tue *effectivement* sont classés non couverts et +comptés contre le score. Mesuré sur `Error.cs`, la même population score 75 % +sélection activée et 100 % sélection coupée — et c'est le 100 % qui est le vrai +chiffre. + +La couper coûte peu sur les bibliothèques, dont les suites sont rapides — de la +fraction de seconde à quelques secondes par mutant. Cela coûte davantage sur +l'outillage, dont les suites compilent du Roslyn et comparent des snapshots. C'est +une raison de garder ces branches hors du chemin critique, pas une raison de +réactiver une sélection qui rapporte le mauvais chiffre. + +## Le modèle de coût, et pourquoi le barrage est cantonné au diff + +**Une exécution complète de la suite de tests de la bibliothèque par mutant**, +plus environ deux minutes de coût fixe par branche (analyse de la solution, +build, run de tests initial, génération des mutants). Le balayage complet d'une +bibliothèque se compte donc en minutes ; celui de toutes, à chaque push, n'est pas +quelque chose que l'on attend devant son écran. + +C'est ce qui rend le cantonnement au diff pertinent pour un check obligatoire. +Cela explique aussi deux choses qui surprennent : + +- **La sélection se fait par *fichier* modifié, pas par *ligne* modifiée.** Le + `--since` de Stryker n'a pas de granularité à la ligne. Ajouter une ligne dans + un gros fichier sélectionne **tous** les mutants de ce fichier : le barrage + rapporte alors le score de mutation du fichier entier — pas seulement celui de + ce qui a été ajouté. Sur les plus gros fichiers, cela fait un job long et un + score qui reflète une dette préexistante. +- **Une pull request qui n'ajoute que des tests sélectionne quand même des + mutants**, via les fichiers de tests qu'elle modifie. + +## D'où viennent les seuils + +Chaque projet porte son propre `break` dans `build/stryker/*.json`, et les valeurs +diffèrent de l'une à l'autre à dessein. Elles ne traduisent **pas** un avis sur le +niveau que tel projet devrait atteindre : chacune a été fixée à partir du score de +balayage complet mesuré sur ce projet au moment de l'introduction du barrage, +arrondi vers le bas, avec un peu de marge pour l'éventuel mutant équivalent. + +**Quatre projets n'ont pas encore de barre** : les analyseurs, le générateur de +documentation, la ligne de commande et `JustDummies`. Leurs balayages sont trop +longs pour avoir été exécutés interactivement : aucun score n'a donc été mesuré +pour eux, et une barre n'a **pas** été devinée — leur `break` vaut `0`. Leurs +branches tournent quand même, échouent toujours sur un build cassé ou une suite en +échec, et listent toujours leurs survivants ; elles ne refusent simplement pas +encore une pull request sur un score. C'est le premier balayage hebdomadaire qui +fournira ces quatre chiffres. + +Cela fait du barrage un **cliquet**, pas une aspiration. Il dit *ne descendez pas +sous le niveau où cette bibliothèque est déjà* — une barre que toutes franchissent +dès le premier jour, si bien que le barrage ne démarre jamais rouge, et qui ne +peut que monter. Relever une valeur quand le balayage hebdomadaire montre de la +marge est l'usage prévu ; en baisser une devrait ressembler à une décision. + +La conséquence à garder en tête : une bibliothèque nettement sous les 100 % a une +barre basse aujourd'hui, et une pull request qui touche l'un de ses fichiers les +plus faibles peut quand même passer dessous. C'est le barrage qui fonctionne, pas +qui se trompe — le rapport dit quelle assertion manque. + +Une bibliothèque échappe pour l'instant à cette règle : `JustDummies`, dont le +balayage est trop long pour avoir servi de calibration, part avec son barrage sur +le score coupé. Voir +[`justdummies-mutation`](justdummies-mutation.fr.md#justdummies-na-pas-encore-de-seuil-de-score). + +## Quand le survivant est un mutant équivalent + +Il arrive que la réponse honnête soit que le mutant ne peut pas être tué : la +réécriture ne change pas le comportement observable, aucun test ne saurait donc +faire la différence. Écrire un test pour le poursuivre reviendrait à écrire un +test qui affirme un détail d'implémentation — pire que le manque. + +Stryker accepte cette réponse dans la source, au plus près du code, sous forme de +commentaire : + +```csharp +// Stryker disable once Statement : the trace call has no observable effect +``` + +La forme est `// Stryker disable [once] [: raison]`, avec +`// Stryker restore all` pour refermer un bloc sans `once`. Préférez `once`, +préférez nommer le mutateur plutôt que `all`, et donnez toujours la raison — une +exclusion non documentée est indiscernable d'un test manquant six mois plus tard. +N'y recourez qu'après avoir établi que le mutant est bien équivalent ; baisser un +seuil pour faire taire un survivant masque avec lui tous les survivants à venir. + +## Permissions & sécurité + +`contents: read` seulement. Le workflow fait un checkout, un build et lance des +tests ; il ne stocke aucun secret et n'a besoin d'aucun périmètre en écriture. + +## À manipuler avec précaution + +- **`fetch-depth: 0` est nécessaire**, ce n'est pas une habitude. Un clone + superficiel rend le point de fourche inatteignable et `--since` ne peut plus le + résoudre. +- **`--since` veut une branche, un tag ou un vrai SHA de commit — `HEAD` est + refusé.** `--since:HEAD` fait échouer tout le run avec *« No branch or tag or + commit found with given target »* ; c'est pourquoi le workflow résout d'abord + `git merge-base` en SHA au lieu de passer une expression de révision. +- **Le cliquet de warnings de la CI n'a pas besoin d'être coupé ici.** + L'inquiétude est légitime — Stryker compile du code *muté*, et un mutant lève + couramment un warning que l'original n'avait pas — mais, mesure faite, + `GITHUB_ACTIONS=true` ne change rien : Stryker compile les mutants via Roslyn + avec ses propres options et n'hérite pas du `TreatWarningsAsErrors` de + [`Directory.Build.props`](../../../../Directory.Build.props). Le nombre + d'erreurs de compilation est identique cliquet actif ou coupé. Si un futur + Stryker se mettait à en tenir compte, des mutants deviendraient silencieusement + des erreurs de compilation au lieu d'être testés — c'est ce compteur, dans le + log du run, qui le révélerait. +- **`if: always()` sur `gate` est nécessaire.** Retirez-le et une branche de + matrice rouge rend le check obligatoire vert. +- **La version de Stryker est épinglée dans le manifeste d'outils.** La monter est + un acte délibéré : attendez-vous à voir les scores bouger, et relisez les + seuils. +- **Les seuils vivent dans `build/stryker/*.json`, pas dans le YAML.** C'est ce + qui garde un run local et la CI d'accord. `break` est la valeur qui fait échouer + le build ; `high`/`low` ne colorent que le rapport. +- **Trois choses restent hors périmètre, et aucune pour une raison de coût.** Les + échantillons `Usage` et les benchmarks du binder ne sont pas du comportement + livré — un échantillon et un harnais de mesure. `FirstClassErrors.GenDoc.Worker` + est un point d'entrée de processus qu'aucun test n'exerce *en processus* : il est + prouvé de bout en bout par le job `floor` de [`ci`](ci.fr.md), et le muter ne + fabriquerait que des survivants qu'aucun test ne pourrait tuer. Tout le reste de + ce qui se compile dans un artefact livré est mesuré. +- **Les branches d'outillage sont les lentes.** Les suites d'analyseurs et de + générateur pilotent des compilations Roslyn et des comparaisons de snapshots : + chacun de leurs mutants coûte plus cher que celui d'une bibliothèque. C'est + pourquoi elles relèvent du balayage hebdomadaire — le run autorisé à durer le + temps qu'il faut — et pourquoi le cantonnement au diff compte plus pour elles + que partout ailleurs : une pull request qui ne touche pas le générateur ne paie + rien pour lui. +- **Un survivant n'est pas automatiquement un bug**, et la réponse à un mutant + équivalent est un commentaire `// Stryker disable once` avec sa raison, jamais un + seuil abaissé — voir *Quand le survivant est un mutant équivalent* plus haut. + +## L'exécuter en local + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/core.json +``` + +C'est le balayage complet d'une bibliothèque, et cela prend un moment. Pour +reproduire ce que fait le barrage sur une branche : + +```bash +dotnet stryker --config-file build/stryker/core.json --since:$(git merge-base origin/main HEAD) +``` + +Les rapports atterrissent dans `StrykerOutput/` (ignoré par git) ; ouvrez +`reports/mutation-report.html`. + +## Voir aussi + +- [`ci`](ci.fr.md) — le barrage principal, et l'endroit où le cliquet de warnings + est imposé. +- [`sonar`](sonar.fr.md) — couverture de lignes et de branches. Les tests de + mutation en sont le complément, pas le remplacement : Sonar dit ce qui a été + *exécuté*, ce workflow dit ce qui a été *vérifié*. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) + — la décision que ce workflow met en œuvre *(rédigée en anglais)*.